Update kjol website with C documentation

This commit is contained in:
2026-07-14 13:05:12 -04:00
parent 02a6dc6c48
commit 7d7b7354df
66 changed files with 23884 additions and 2551 deletions

67
.vscode/launch.json vendored
View File

@@ -1,45 +1,74 @@
{
// Debug configs for the gowasm example. cwd is the example dir because the
// dev server resolves ./wwwroot, ./app, ./wasm and the watched engine dirs
// relative to it.
// Debug configs for kjol-web (the website). cwd is the site dir because the dev server
// resolves ./wwwroot, ./app, ./wasm, ./frontend and the watched engine dirs relative
// to it.
//
// The dev-server configs preLaunchTask the prebuild (codegen + Tailwind). Codegen must
// run before the server is compiled — it writes app/*.gen.go, which the server imports —
// and under the debugger the binary is built by Delve, not by the server's own Build
// hook, so nothing else would generate it.
// Every dev-server config preLaunchTasks "kjol-web: 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.
"version": "0.2.0",
"configurations": [
{
"name": "gowasm: dev server",
"name": "kjol-web: dev server",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
"cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
"program": "${workspaceFolder}/go/cmd/kjol-web/server",
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
"args": ["-addr", ":8085"],
"preLaunchTask": "gowasm: prebuild"
"preLaunchTask": "kjol-web: prebuild"
},
{
"name": "gowasm: dev server (no watch)",
"name": "kjol-web: dev server (no watch)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
"cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
"program": "${workspaceFolder}/go/cmd/kjol-web/server",
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
// -watch=false skips the server's own Build entirely, so wwwroot is served exactly
// as it sits on disk. The prebuild is what puts current CSS and generated code
// there; without it you would be debugging against stale artefacts.
// 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": "gowasm: prebuild"
"preLaunchTask": "kjol-web: prebuild"
},
{
"name": "gowasm: codegen (wasmgen)",
"name": "kjol-web: 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",
"args": ["-build"]
},
{
"name": "kjol-web: codegen (wasmgen)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/go/cmd/wasmgen",
"cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
"args": ["./app"]
},
{
"name": "kjol-web: 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",
"args": [
"-app", "frontend",
"-web", "../../jsruntime",
"-out", "wwwroot",
"-gen-ts", "frontend/src/ui/generated"
]
}
]
}

112
.vscode/tasks.json vendored
View File

@@ -1,63 +1,95 @@
{
// Tasks for the kjol repo. The gowasm example is a nested module at
// go/cmd/examples/go-wasm-web, so its tasks set cwd there; the module-wide
// Go tasks run in go/.
// Tasks for the kjol repo.
//
// The example's build pipeline is Go, not a shell script — see
// go/cmd/examples/go-wasm-web/buildsteps. The dev server calls the same steps on every
// save, and a bash script would not run for anyone on Windows. "gowasm: prebuild" is
// codegen + Tailwind, and everything that runs the app depends on it; the two steps are
// also exposed on their own so you can rerun just the one you need.
// 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
// 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.
//
// The build pipeline is Go, not a shell script — see go/cmd/kjol-web/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
// 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.
"version": "2.0.0",
"tasks": [
{
"label": "gowasm: prebuild",
"detail": "Codegen (pages/layouts/server components) then Tailwind. Dependency of the run + debug configs.",
"label": "kjol-web: prebuild",
"detail": "Codegen -> Tailwind -> Solid bundle. Dependency of the run + debug configs.",
"dependsOrder": "sequence",
"dependsOn": ["gowasm: codegen", "gowasm: tailwind"],
"dependsOn": ["kjol-web: codegen", "kjol-web: tailwind", "kjol-web: bundle (Solid)"],
"problemMatcher": [],
"group": "build"
},
{
"label": "gowasm: codegen",
"label": "kjol-web: 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/examples/go-wasm-web" },
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" },
"problemMatcher": ["$go"],
"presentation": { "reveal": "silent", "panel": "shared" },
"group": "build"
},
{
"label": "gowasm: tailwind",
"detail": "Compile css/app.css -> wwwroot/app.css, scanning the webui kit + the example's Go markup",
"label": "kjol-web: 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/examples/go-wasm-web/css/app.css",
"-out", "cmd/examples/go-wasm-web/wwwroot/app.css",
"-entry", "cmd/kjol-web/css/app.css",
"-out", "cmd/kjol-web/wwwroot/app.css",
"-base", ".",
"webui/**/*.go",
"cmd/examples/go-wasm-web/app/**/*.go",
"cmd/examples/go-wasm-web/server/**/*.go"
"cmd/kjol-web/app/**/*.go",
"cmd/kjol-web/server/**/*.go"
],
// Run from the kjol module root so the Tailwind engine's deps resolve in kjol's
// go.mod, not the example's.
// go.mod, not the site's.
"options": { "cwd": "${workspaceFolder}/go" },
"problemMatcher": ["$go"],
"presentation": { "reveal": "silent", "panel": "shared" },
"group": "build"
},
{
"label": "gowasm: dev server (hot reload)",
"detail": "SSR + hot reload on :8085. Rebuilds Go on save; a .css save recompiles Tailwind only and swaps the stylesheet in place.",
"label": "kjol-web: bundle (Solid)",
"detail": "TSX -> Solid -> esbuild, plus the SSR bake: wwwroot/bundle.min.{js,css} + public.bundle.min.*",
"type": "shell",
"command": "go",
"args": [
"run", "kjol/cmd/bundle",
"-app", "frontend",
// The shared JS tree: the Solid kit, the vendored runtime, the icon SVGs and the
// @theme scaffold all live there.
"-web", "../../jsruntime",
"-out", "wwwroot",
"-gen-ts", "frontend/src/ui/generated"
],
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" },
"problemMatcher": ["$go"],
"presentation": { "reveal": "silent", "panel": "shared" },
"group": "build"
},
{
"label": "kjol-web: 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/examples/go-wasm-web" },
"dependsOn": ["gowasm: prebuild"],
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" },
"dependsOn": ["kjol-web: prebuild"],
"isBackground": true,
"problemMatcher": {
"owner": "go",
@@ -75,18 +107,18 @@
"group": { "kind": "build", "isDefault": true }
},
{
"label": "gowasm: build",
"detail": "One-off cold build of the example: codegen + Tailwind + wasm + stage wasm_exec.js",
"label": "kjol-web: build (cold)",
"detail": "Cold build, then exit: codegen + Tailwind + wasm + Solid bundle + wasm_exec.js.",
"type": "shell",
"command": "go",
"args": ["run", "./build"],
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
"args": ["run", "./server", "-build"],
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" },
"problemMatcher": ["$go"],
"group": "build"
},
{
"label": "kjol: build ./...",
"detail": "Build the whole kjol module (excludes the nested example)",
"detail": "Build the whole kjol module (does not descend into the nested kjol-web module)",
"type": "shell",
"command": "go build ./...",
"options": { "cwd": "${workspaceFolder}/go" },
@@ -108,6 +140,28 @@
"options": { "cwd": "${workspaceFolder}/go" },
"problemMatcher": ["$go"],
"group": { "kind": "test", "isDefault": true }
},
{
"label": "kjol: test the reconciler (wasm)",
"detail": "The reconciler needs a DOM, so `go test ./...` cannot reach it. This runs it under node, against a minimal DOM shim.",
"type": "shell",
"command": "go",
"args": ["test", "-exec=node testdata/domexec.js", "./wasmruntime"],
"options": {
"cwd": "${workspaceFolder}/go",
"env": { "GOOS": "js", "GOARCH": "wasm" }
},
"problemMatcher": ["$go"],
"group": "test"
},
{
"label": "kjol-web: 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" },
"problemMatcher": ["$go"],
"group": "test"
}
]
}

View File

@@ -46,7 +46,7 @@ drag a JavaScript bundler along for a CSS file.
### go/ — module `kjol`
Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil
l4g security snailmail validation jsbundler tw`, plus the **gowasm** web-UI engine (`vdom`
l4g lexer security snailmail validation jsbundler tw`, plus the **gowasm** web-UI engine (`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}`.
@@ -54,6 +54,14 @@ stdlib-only), and `cmd/{bundle,twcss,migrate,loc,passgen,typecheck,wasmgen}`.
`jsbundler` is the **JS** build (TSX → Solid → esbuild + the goja SSR bake). It was called
`webbundler`.
`lexer` is syntax highlighting: source in, HTML with coloured spans out, `Highlight(lang, src)`
plus `HighlightGo` / `HighlightC`. It is a lexer and not a parser on purpose — it degrades to
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
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
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
@@ -61,6 +69,12 @@ front-ends — `/wasm/*` is the Go→WebAssembly SPA, `/js/*` is the Solid SPA,
static+wasm landing page whose **Layers menu** is the site's primary navigation. Its
README is the map. Anything user-visible you add to kjol should show up there, running.
The kit is documented on ONE page per layer (`/wasm/components`, `/js/components`), not one page
per source file — a reader hunting for a date picker should not have to guess whether it was filed
under forms or overlays. `componentGroups()` / `COMPONENT_GROUPS` is the single list that drives
the sections, the sidebar that jumps to them, and the index. Build it with `go run ./server -build`;
the steps live in the `build` package (a library, because the server imports it).
Build / test (run from repo root):
```
go -C go build ./...
@@ -93,7 +107,7 @@ signals, never inside a render closure. Floating panels share one positioning en
`bg-surface` / `border-line` / `text-ink` / `text-accent`, and a `.dark` class on `<html>`
re-points what those mean. A theme is a dozen CSS variables rather than four hundred class
strings, and `dark:` on every component is exactly the thing to avoid. The Go and Solid kits use
the **same token names on purpose** — change `surface` once and both halves of a site move.
the **same token names on purpose** — change `surface` once and both layers of a site move.
Only two things still need a `dark:` variant, because no re-pointed token can fix them: a
coloured tint (a `red-50` wash is invisible on a near-black surface) and a fill that inverts (the

View File

@@ -13,8 +13,8 @@ build the site out of both.
```sh
cd go/cmd/kjol-web
go run ./build # cold build: both halves
go run ./server # SSR + /rsc + hot reload at http://localhost:8085
go run ./server -build # cold build, then exit
go run ./server # build, then SSR + /rsc + hot reload at http://localhost:8085
```
`go run ./server` performs that same build on every save and hot-swaps the result into
@@ -28,8 +28,8 @@ terminal you were not looking at.
| Path | Rendered by | What it is |
|---|---|---|
| `/`, `/about` | Go → WebAssembly, SSR'd | The landing page. The **Layers** menu is the site's primary navigation. |
| `/wasm/*` | Go → WebAssembly | **Kjol Wasm Web** — the gowasm engine: SSR + hydration, server components, the `webui` kit, overlays, AutoTable, charts, client fetching. |
| `/js/*` | Solid → esbuild, client-rendered | **Kjol JS Web** — the Solid kit: components, forms, AutoTable, theming. |
| `/wasm/*` | Go → WebAssembly | **Kjol Wasm Web** — the gowasm engine: SSR + hydration, server components, data fetching, and the whole `webui` kit on one page (`/wasm/components`). |
| `/js/*` | Solid → esbuild, client-rendered | **Kjol JS Web** — the Solid kit, all of it on one page (`/js/components`), with a sidebar that jumps to each group. |
| `/js/ssr` | Solid → **goja, at request time** | A public page server-rendered with live data injected — the ISR path. |
Crossing between `/wasm` and `/js` is a real page load. They are different binaries, and
@@ -38,16 +38,19 @@ pretending otherwise would mean shipping both to every visitor.
## Layout
```
app/ the Go/WASM half — pages as plain Go functions returning a *VNode
pages.go Deps + Shell + the public/app layouts + the landing page
layers.go the Layers, as data. MIRRORED in frontend/src/layers.ts.
docs.go the docs chrome; docsNav() is the sidebar AND the index
kit.go table.go overlays.go chart.go data.go server_counter.go
*.gen.go GENERATED by kjol/cmd/wasmgen (routes, layout dispatch, RSC stubs)
app/ Kjol Wasm Web — pages as plain Go functions returning a *VNode
pages.go Deps + Shell + the public/app layouts + the landing page
layers.go the Layers, as data. MIRRORED in frontend/src/layers.ts.
docs.go the docs chrome; docsNav() is the sidebar AND the index
components.go THE WHOLE KIT, on one page. componentGroups() is the single source
for both the sections and the sidebar that jumps to them.
table.go the AutoTable's data + controller (shared with its tests)
chart.go data.go server_counter.go
*.gen.go GENERATED by kjol/cmd/wasmgen (routes, layout dispatch, RSC stubs)
wasm/ the js/wasm client entry (main_native.go is a host stub)
css/app.css its Tailwind entry — kjol/tw scans the .go files for class names
frontend/ the Solid half — .tsx pages written against @ui/*
frontend/ Kjol JS Web — .tsx pages written against @ui/*
css/style.css brand ONLY. kjol's theme.css is prepended by the bundler, and it
is the one that does `@import "tailwindcss"`.
vendor/ pdf-lib + pdfjs-dist. @ui/AutoTable imports them at the TOP LEVEL,
@@ -57,18 +60,21 @@ frontend/ the Solid half — .tsx pages written against @ui/*
src/layers.ts the Layers again. Keep in step with app/layers.go.
server/ ONE Go server: serves wwwroot, SSRs the wasm routes, hosts /rsc,
mounts the Solid SPA at /js/*, serves the SSR'd public pages.
mounts the Solid SPA at /js/*, serves the SSR'd public pages, and
carries the -build flag.
build/ the build pipeline, in Go rather than a shell script, so the cold build
and the watch loop call the SAME functions and cannot drift. It is a
library, not a command: the server imports it, and Go will not let you
import a main — hence `go run ./server -build` rather than `./build`.
internal/handlers/ the app side of the public-page inversion: kjol generates the
registry; this owns the type and the document shell.
buildsteps/ the build, in Go rather than a shell script, so the one-shot build and
the dev server's watch loop call the SAME functions and cannot drift.
wwwroot/ both halves write here. They never collide: app.css / app.wasm for one,
wwwroot/ both layers write here. They never collide: app.css / app.wasm for one,
bundle.min.* for the other. One static dir, one server.
```
## Theming
One theme, both halves. The kits are themed by **semantic tokens** — components say
One theme, both layers. The kits are themed by **semantic tokens** — components say
`bg-surface`, `text-ink`, `border-line` and never name a colour — so dark mode
re-points about a dozen CSS variables and not one component knows it happened.
@@ -84,11 +90,12 @@ to invert (the neutral button, whose label must go dark when the fill goes pale)
## Adding things
**A Go/WASM page:** write the function, mark it `//gowasm:page /wasm/thing layout=app`,
**A Kjol Wasm Web page:** write the function, mark it `//gowasm:page /wasm/thing layout=app`,
build. `wasmgen` regenerates the routing. Add `static` to have it server-rendered.
**A Solid page:** write the `.tsx`, add it to `routes` in `frontend/src/app.ts` and to
`NAV` in `frontend/src/layout/Shell.tsx`.
**A component:** add a section to `app/components.go` (Go) or `frontend/src/pages/Components.tsx`
(Solid), and one entry to `componentGroups()` / `COMPONENT_GROUPS`. That single list drives the
sections, the sidebar that jumps to them, and the index — so none of the three can drift.
**A layer:** one entry in `app/layers.go` *and* one in `frontend/src/layers.ts`. They are
two files because nothing is upstream of both a WebAssembly binary and an esbuild bundle;
@@ -103,7 +110,7 @@ keeping each to a flat list of plain data is what makes that duplication surviva
| `kjol/rsc` | stateless server components over HTTP | `//gowasm:server` + its generated client stub |
| `kjol/wasmdevserver` | reusable dev server: SSR, `/rsc`, hot reload, error overlay | `server/main.go` fills a `wasmdevserver.Config` |
| `kjol/webui` | the Go component kit | every `/wasm/*` page |
| `kjol/jsbundler` | TSX → Solid → esbuild, the Tailwind driver, the SSR bake | `buildsteps.JS`, and the ISR render at request time |
| `kjol/jsbundler` | TSX → Solid → esbuild, the Tailwind driver, the SSR bake | `build.JS`, and the ISR render at request time |
| `kjol/jsruntime` | the Solid kit, the vendored runtime, the icons, `theme.css` | everything under `/js/*` |
| `kjol/tw` | the Tailwind v4 engine, in Go | both stylesheets — it scans `.go` for one and `.tsx` for the other |

View File

@@ -8,6 +8,7 @@ import (
chart "github.com/wcharczuk/go-chart/v2"
. "kjol/vdom"
"kjol/wasmruntime"
ui "kjol/webui"
)
@@ -60,9 +61,56 @@ func pieSVG(values []int) string {
})
}
// 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()
@@ -85,19 +133,27 @@ func ChartPage(d Deps) func() *VNode {
),
docSection("charts", "A worked example: charts",
prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing about "+
"the browser. The server draws them and ships the markup inline; there is no chart "+
"JavaScript, and no canvas that has to wait for the client to boot before it shows anything."),
prose("Shuffle re-runs the same drawing code in the browser. The first render came from the "+
"server and the next one comes from WebAssembly, and the page cannot tell the difference."),
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"),
Div(Attr("class", "lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto"), Raw(barSVG(values))),
Div(Attr("class", "lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto"), Raw(pieSVG(values))),
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",
@@ -111,7 +167,7 @@ func ChartPage(d Deps) func() *VNode {
apiRow{"//gowasm:page /path static", "Pre-render this route on the server, then hydrate it."},
apiRow{"vdom.RenderHTML", "Render a tree to an HTML string. This is what the server calls."},
apiRow{"wasmruntime.Hydrate", "Adopt server-rendered DOM instead of building it. The client's entry point for a static route."},
apiRow{"vdom.Raw", "Insert markup verbatim — how the server-drawn SVG gets in. The reconciler clears it correctly when the element is reused."},
apiRow{"vdom.Raw", "Insert markup verbatim — how the SVG the WebAssembly drew gets in. The reconciler clears it correctly when the element is reused."},
),
),
)
@@ -120,17 +176,42 @@ func ChartPage(d Deps) func() *VNode {
const chartSnippet = `//gowasm:page /wasm/chart static layout=app
func ChartPage(d Deps) func() *VNode {
data := NewSignal(fixedChartData())
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 {
// go-chart draws an SVG string — on the server for the first paint,
// and in the browser for every render after that.
return Div(
ui.Button(ui.ButtonProps{
Text: "Shuffle data",
OnClick: func() { data.Set(randomValues()) },
}),
Div(Raw(barSVG(data.Get()))),
// The server never enters barSVG: chartBox takes a CLOSURE, and calls it only
// once drawn is true. It renders the placeholder instead, and the WebAssembly
// swaps in the real chart on its first commit.
//
// drawn must be FALSE on the client's first render too. Hydration walks the
// server's DOM alongside the client's first tree, so the two have to BE the
// same tree; draw on that first pass and the reconciler rebuilds what it
// should have adopted.
chartBox("", "h-[260px]", drawn.Get(),
func() string { return barSVG(data.Get()) }),
)
}
}
// chartBox is the whole trick, and it is four lines.
func chartBox(class, height string, drawn bool, draw func() string) *VNode {
if !drawn {
return Div(Attr("class", class), chartSkeleton(height))
}
return Div(Attr("class", class), Raw(draw()))
}`

View File

@@ -0,0 +1,600 @@
package app
import (
. "kjol/vdom"
)
// The C layer's documentation.
//
// One page, like the component pages: a reader looking for the arena API should not have
// to guess which of six pages it was filed under. The sidebar calls the sections
// SUBSYSTEMS, which is what they are — each is a directory of C, and each has a different
// idea of what it depends on.
//
// Everything here is read off the source. Where a subsystem needs something the reader has
// to supply — a unity translation unit, a vendored ImGui, a resource script pointed at his
// own product — the page says what it needs and what to write, rather than filing it as a
// defect. This is a base layer: it is COPIED INTO a project and compiled with it, so "what
// you have to provide" is not a caveat, it is the interface.
type subsystem struct {
ID string
Label string
Icon string
Blurb string
}
// cSubsystems is the single source for the page's sections AND the sidebar that jumps to
// them — so the sidebar cannot offer a jump to a section that does not exist.
func cSubsystems() []subsystem {
return []subsystem{
{ID: "base", Label: "base", Icon: "cube",
Blurb: "The vocabulary: fixed-width types, a bump allocator, counted strings, and 2D rectangle math."},
{ID: "build", Label: "build", Icon: "bolt",
Blurb: "A single-header build system. Your build script is a C program that recompiles itself."},
{ID: "platform", Label: "platform", Icon: "server",
Blurb: "A window, its input, the clipboard. Win32, and it hands every message to Dear ImGui first."},
{ID: "lexer", Label: "lexer", Icon: "code",
Blurb: "Syntax highlighting. Five languages, thirteen themes, and a paint array rather than a token list."},
{ID: "config", Label: "config", Icon: "file",
Blurb: "An INI file beside the executable. Program-managed state, not user-authored settings."},
{ID: "installer", Label: "installer", Icon: "download",
Blurb: "A Win32 wizard that carries the product inside itself as a resource."},
}
}
// cNav is the sidebar while you are reading /c. The group is called Subsystems.
func cNav() []docsGroup {
items := make([]docsItem, 0, len(cSubsystems()))
for _, s := range cSubsystems() {
items = append(items, docsItem{
Path: "/c#" + s.ID,
Label: s.Label,
Icon: s.Icon,
Blurb: s.Blurb,
})
}
return []docsGroup{
{
Title: "Introduction",
Items: []docsItem{
{Path: "/c", Label: "Overview", Icon: "book-open",
Blurb: "What this layer is, and where it came from."},
{Path: "/c#compiling", Label: "Compiling it", Icon: "bolt",
Blurb: "The unity translation unit: what you write, and what each subsystem needs from you."},
},
},
{Title: "Subsystems", Items: items},
}
}
//gowasm:page /c static layout=app
func CPage(d Deps) func() *VNode {
return func() *VNode {
return docPage("Layers", "Kjøl C",
"A base layer in C — a bump allocator, counted strings, a syntax highlighter, a Win32 window, "+
"and a build system that is itself a C program. It was lifted out of codeMAX, a code editor, "+
"which is why the pieces are the pieces an editor needs.",
docSection("what-this-is", "What this is",
prose("Not a library you link against. A set of headers and translation units you COPY INTO "+
"a project and compile with it — the base-layer style you will recognise if you have read "+
"Ryan Fleury's RAD Debugger or watched Handmade Hero. The headers say as much: base_core, "+
"base_arena and base_strings all credit raddebugger, and the naming (U32, Str8, Rng2F32) "+
"comes straight from it."),
prose("There is no package manager, no version, and no ABI to keep stable — because there is "+
"nothing to keep stable BETWEEN. The code and its consumer are compiled together. Which is "+
"also why the next section is about the file YOU write: the layer does not build on its "+
"own, by design. It builds as part of your program."),
note("It arrived from codeMAX, and still carries its names",
"The whole directory came into Kjøl in one commit, out of codeMAX, a code editor. Its "+
"fingerprints are still on it and they are worth knowing before you go looking: the "+
"Win32 window class is codemax_wc, the installer installs codeMAX, and installer.rc "+
"names codeMAX's icon, manifest and payload. Those are the strings to change when you "+
"adopt it — they are the product's name, not the layer's."),
),
cCompiling(),
cBase(),
cBuild(),
cPlatform(),
cLexer(),
cConfig(),
cInstaller(),
)
}
}
// ---- compiling -----------------------------------------------------------
// How you actually compile the thing. It is second on the page, before any API, because a
// base layer's first question is "how does this get into my program" and the answer here is
// not the one a reader arriving from a package-manager language expects.
func cCompiling() *VNode {
return docSection("compiling", "Compiling it",
prose("One translation unit. You write a single .c that #includes the layer's .c files in order, "+
"and hand THAT to the compiler — the whole layer is one TU, compiled with your program. "+
"base/base_inc.c is the pattern in miniature: three lines, pulling in the arena and the "+
"strings. Your own unity file is the same idea, one level up."),
codeLang("app_inc.c — the file you write", "c", unityTUSnippet),
prose("This is not a workaround for the absence of a build system; it IS the build system. There is "+
"no per-file compilation, so there are no object files, no link order and no header guard "+
"archaeology — and the compiler sees the whole layer at once, which is what makes every helper "+
"in it `internal` and every call to one a candidate for inlining."),
docSubheading("What each subsystem needs from you"),
prose("They are not equally self-contained, and the differences are worth knowing before you pick "+
"the ones you want:"),
apiTable(
apiRow{"base", "Nothing. It stands on its own — base_inc.c is a complete TU, and everything above depends on it."},
apiRow{"lexer", "Include lexer.c AND the five backends in the same TU. Their helpers are `internal`, so separate compilation would leave lexer.c's dispatch with nothing to call."},
apiRow{"config", "Include config.h above config.c. config.c uses Config without including its own header — which is exactly what a unity build lets it do."},
apiRow{"platform", "Dear ImGui, vendored, and a C++ compiler for it. See the subsystem below — the window procedure hands it every message before it looks at any of them."},
apiRow{"installer", "Its own program and its own .rc, pointed at your icon, your manifest and your payload."},
apiRow{"build", "Nothing — it is the thing that runs the compiler. Write a build.c, compile it once, and never compile it again."},
),
note("There is no build.c in Kjøl yet, and that is the next job",
"build.h is here, complete and self-contained, and nothing in the repository includes it — so "+
"the layer currently has a build system and no build. Writing that build.c is what turns "+
"this directory into something you can type one command at, and it is the single most "+
"useful thing anyone could add to the C layer. The build subsystem below documents the "+
"header it would be written against, and the snippet there is a working sketch of it."),
)
}
// ---- base ----------------------------------------------------------------
func cBase() *VNode {
return docSection("base", "base",
prose("The vocabulary. Fixed-width integers with short names, a bump allocator, a counted string, "+
"and the rectangle math a user interface actually spends its time on. Everything else in the "+
"layer is written in these — which is why there is no malloc below this line, and no char* "+
"pretending to be a string."),
docSubheading("The types (base_core.h)"),
prose("U8 through U64, S8 through S64, B32 for a boolean, F32 and F64 — upper case, not the u8/i32 "+
"of the Rust-adjacent style. Sizes are written KB(4) and MB(64) rather than as a number of "+
"zeroes you have to count. And the three meanings of `static` in C get three different names: "+
"`internal` for a file-local function, `global` for a translation-unit variable, `local_persist` "+
"for a local that survives the call. They all expand to `static`; they do not all mean the same "+
"thing, and the code says which one it meant."),
codeLang("c/base/base_core.h", "c", baseCoreSnippet),
prose("There is more in the header than the types: Min/Max/Clamp, the nil-aware doubly and singly "+
"linked-list macros from raddebugger (DLLPushBack, SLLQueuePush and friends), DeferLoop — which "+
"is `defer` written as a for-loop, because C has no defer — and an Assert that traps on MSVC "+
"with __debugbreak."),
docSubheading("The arena (base_arena.h)"),
prose("One allocator, and it is a bump pointer: arena_push moves a cursor and hands you the memory. "+
"There is no free. You release the whole arena, or you roll it back to a saved position — which "+
"is what Temp is. A function that wants scratch space opens a Temp, allocates as freely as it "+
"likes, and closes it. Nothing has to be individually released, so nothing can be individually "+
"forgotten."),
codeLang("c/base/base_arena.h", "c", baseArenaSnippet),
note("It is malloc-backed and fixed-capacity — and that has a sharp edge",
"The header says so: this is raddebugger's arena with the virtual-memory reserve/commit taken "+
"out. arena_alloc is one malloc of the capacity you asked for, and that capacity is final — "+
"the arena does not grow and does not chain. Overflow hits Assert(!\"Arena overflow\") and "+
"returns NULL. But Assert compiles to nothing unless _DEBUG is defined, so in a release build "+
"an over-full arena hands you a silent NULL. Alignment is a hard-coded 8 bytes, so there is "+
"no SIMD guarantee. And there is no thread-local scratch pool — if you came here expecting "+
"raddebugger's scratch_begin, it is not in this snapshot."),
docSubheading("Strings (base_strings.h)"),
prose("Str8 is a pointer and a length. Not NUL-terminated, not owned, does not allocate — so a "+
"substring is free, and a Str8 can point into the middle of a file you mapped. The two "+
"operations that MUST allocate take the arena, and so they say so in their signature. That is "+
"the whole API: eight functions. It is early, and there is no split, join, trim or "+
"case-insensitive compare yet."),
codeLang("c/base/base_strings.h", "c", baseStringsSnippet),
docSubheading("Math (base_math.h)"),
prose("Look at the shape of this file and it tells you what it is for. Vec3F32 has a constructor "+
"and no operations. Rng2F32 — a rectangle — has nine: width, height, dim, center, contains, "+
"pad, shift, intersect. There are no matrices, no quaternions, no Mat4. This is 2D interface "+
"math, and a layout written in it is a sequence of rectangle operations rather than eight lines "+
"of x + w arithmetic with an off-by-one hiding in them."),
prose("The Axis2 / Side / Corner enums plus v2f32_axis are the raddebugger trick for axis-generic "+
"code: one code path handles both X and Y by indexing into the vector instead of naming .x "+
"and .y, so a horizontal and a vertical layout are the same function with a different argument."),
apiTable(
apiRow{"arena_alloc / arena_release", "One malloc, fixed capacity. It does not grow."},
apiRow{"arena_push / arena_push_no_zero", "Bump the cursor. Zeroed by default; the fast one has to be asked for by name."},
apiRow{"temp_begin / temp_end", "A scratch scope: save the position, allocate freely, roll it all back."},
apiRow{"push_array(arena, T, n)", "Typed sugar over arena_push. The macro you will actually use."},
apiRow{"str8 / str8_cstr / str8_lit", "Make a counted string. None of them allocate."},
apiRow{"str8_pushf / str8_push_copy", "The two that DO allocate — and so they take the arena."},
apiRow{"Rng2F32 + rng2f32_intersect / _pad / _shift", "Rectangles, and the three things a layout does to them."},
),
)
}
// ---- build ---------------------------------------------------------------
func cBuild() *VNode {
return docSection("build", "build",
prose("The build system is a C program, and the build system's build system is a C compiler. "+
"build.h is a single header in the stb style: define BUILD_IMPLEMENTATION in one file, include "+
"it, and write your build as a main() that shells out to a compiler. The lineage is nob.h — the "+
"self-rebuild macro here is called GO_REBUILD_URSELF, which is a straight nod to it."),
docSubheading("Bootstrapping"),
prose("You compile the build script once, by hand. After that you never compile it again, because "+
"the first thing it does when it runs is compare its own source against its own executable — "+
"and if the source is newer, it rebuilds itself, swaps the binary, and re-executes. Edit the "+
"build script and just run it. It will notice."),
codeLang("terminal", "sh", buildBootstrapSnippet),
note("On Windows you cannot overwrite a running .exe — so it doesn't",
"go_rebuild_urself RENAMES the current binary to build.exe.old, compiles the new one in its "+
"place, and re-execs. If the compile FAILS, it renames the old one back. That is the whole "+
"reason the dance exists, and it is the difference between a build script you can edit and "+
"one that bricks itself the first time you make a typo."),
docSubheading("Writing one"),
prose("GO_REBUILD_URSELF is the first line of main. Cmd is a growable argv you append to and run. "+
"needs_rebuild compares timestamps, so a target whose inputs have not changed is skipped. That "+
"is the whole of it — no rule syntax, no DSL, no dependency graph, because a C program already "+
"has if-statements and for-loops and you already know how to write them."),
codeLang("build.c — the file you write", "c", buildUsageSnippet),
note("What it deliberately does NOT do",
"There is no compiler detection: the compiler is cl.exe on Windows and cc everywhere else, "+
"hard-coded, and only for the self-rebuild — for your own targets you assemble the command "+
"yourself. There is no parallelism: cmd_run is synchronous, start to finish. There is no "+
"globbing and no header-dependency scanning: needs_rebuild compares mtimes against the input "+
"list YOU pass it, so if you edit a header that is not in that list, nothing rebuilds. Know "+
"that going in and it is a fine tool; expect make and you will be bitten."),
docSubheading("What else is in the header"),
prose("More than a build system strictly needs, and all of it is there because a real build wanted "+
"it. A temp allocator — an 8 MB ring buffer that silently wraps, so temp_sprintf can hand you a "+
"formatted path that you never free. A string builder. File I/O that reports its own errors. "+
"And two functions that are squarely about shipping a graphical program: embed_file, which "+
"turns any binary into a C array in a header, and compile_shader / embed_spirv, which run glslc "+
"over a GLSL source and embed the SPIR-V the same way. That last pair is a fossil of a Vulkan "+
"renderer, and it is the clearest evidence in the layer of what codeMAX was becoming."),
apiTable(
apiRow{"GO_REBUILD_URSELF(argc, argv)", "First line of main. Rebuilds and re-executes the script if its source changed."},
apiRow{"Cmd + cmd_append + cmd_run", "A growable argv, appended variadically, run synchronously. cmd_run resets it for reuse."},
apiRow{"needs_rebuild(out, inputs, n)", "1 if any input is newer than the output, 0 if up to date, -1 on error. Mtimes only."},
apiRow{"temp_sprintf / temp_reset", "Formatted strings from a ring buffer. You never free them; you reset the ring."},
apiRow{"sb_read_file / write_entire_file", "Whole-file I/O into a String_Builder, and back out."},
apiRow{"embed_file(in, out, var)", "Turn a binary into a C header: an array, and its size."},
apiRow{"compile_shader / embed_spirv", "glslc a .glsl to .spv, then embed the .spv as a C array."},
),
)
}
// ---- platform ------------------------------------------------------------
func cPlatform() *VNode {
return docSection("platform", "platform",
prose("The operating system, behind one header — and it is a narrower header than the name "+
"suggests. There is no file I/O here, no clock, no threads, and no virtual memory (which is why "+
"the arena is malloc-backed). What there is: a window, the input that arrives at it, the "+
"clipboard, and a way to spawn a terminal."),
prose("Input comes in two shapes on purpose. PlatformRawInput is what the OS actually said — UTF-16 "+
"characters, virtual key codes, mouse coordinates. PlatformInput is what the application wants "+
"to hear. platform_adapt_input converts one into the other, and that function is the seam a "+
"second backend would be written behind."),
note("It expects Dear ImGui in the build, and Windows underneath",
"platform_win32.c is the only backend, and its window procedure forward-declares "+
"ImGui_ImplWin32_WndProcHandler and calls it on every message BEFORE it looks at any of "+
"them — so ImGui gets first refusal on the input, which is what makes an ImGui text field "+
"inside the window behave like a text field rather than a hole the editor's keybindings "+
"fall through. To use this subsystem you vendor ImGui and compile it (it is C++) alongside; "+
"to use it WITHOUT ImGui you cut that one call, and everything else in the file is C and "+
"stands. A second backend would replace the PKEY_ enum — whose values ARE Windows virtual "+
"key codes, passed straight through — and find a home for platform_spawn_terminal, which "+
"hard-codes cmd.exe /k."),
docSubheading("Two decisions worth stealing"),
prose("WM_SIZE calls the frame callback synchronously, from inside the window procedure. That looks "+
"wrong and is the standard Win32 fix for a real problem: while you drag a window's edge, "+
"Windows runs a modal resize loop that never returns to your main loop, so the window goes "+
"blank or smears. Rendering from inside the message handler is the only way to keep drawing. "+
"platform_set_frame_callback exists for exactly this."),
prose("And platform_get_input drains its accumulator on read — it memsets the buffered keys and "+
"characters to zero on the way out, and returns was_mouse_down beside mouse_down so the caller "+
"can see an edge without keeping its own copy of last frame."),
apiTable(
apiRow{"platform_create_window / _destroy_window", "A window from a PlatformWindowDesc; and its teardown."},
apiRow{"platform_poll_events", "Pump the message queue. False means the user wants to close."},
apiRow{"platform_get_input / platform_adapt_input", "The raw OS input for this frame; and the conversion that is the portability seam."},
apiRow{"platform_set_frame_callback", "Called per frame — including from inside a live resize, which is the point."},
apiRow{"platform_get_dpi_scale", "GetDpiForWindow / 96. The layer is per-monitor DPI aware throughout."},
apiRow{"platform_clipboard_get / _set", "Get returns a pointer into a static 64 KB buffer. Do not free it, do not keep it."},
apiRow{"platform_get_native_handle", "The HWND, for the one place that genuinely needs it."},
),
)
}
// ---- lexer ---------------------------------------------------------------
func cLexer() *VNode {
return docSection("lexer", "lexer",
prose("Syntax highlighting, and the most complete thing in this layer — about 2,800 lines of it. "+
"The interesting decision is the output shape, and the header credits it to the Focus editor: a "+
"tokenizer here does not return a list of tokens. It takes the buffer and an out_tokens array "+
"of the SAME LENGTH, and paints one token-type byte per source byte."),
codeLang("c/lexer/lexer.h", "c", lexerSnippet),
prose("That sounds wasteful and is the opposite. An editor never wants to know what the tokens are; "+
"it wants to know what colour to draw the character at offset N — and with a parallel array "+
"that is one indexed read, not a search through a token list. The enum value IS the index into "+
"the theme's colour table, which is why TOK_DEFAULT has to be zero. Painting is a memset. The "+
"cost is one byte per byte of source, for a file you already have in memory."),
note("It lets you rewrite the past, which is why it can find function names",
"All five backends share one trick. When the tokenizer emits a `(` and the PREVIOUS token was "+
"an identifier, it goes back and re-paints that identifier as TOK_FUNCTION. With a token "+
"list you would have to look ahead, or fix up afterwards. With a paint array the past is "+
"just an array range, and repainting it costs a memset."),
docSubheading("Languages"),
prose("Five backends — C, Go, JavaScript, Lua, SQL — plus plain text, each a single .c file behind "+
"the same LexerTokenizeFn signature, selected by a hard-coded switch. There is no registry and "+
"no plugin: adding a language means editing four things in lexer.c, deliberately."),
prose("They are not toys. The JavaScript one carries an explicit depth stack for nested tagged "+
"template literals, so an html`…${css`…${x}`}…` lexes correctly at arbitrary nesting, and it "+
"has a regex-versus-division heuristic — after a keyword or an operator a slash starts a regex, "+
"after an identifier it is a divide. The Lua one counts the equals signs in a long bracket so "+
"[==[ is closed by ]==] and not by ]]. The SQL one is case-insensitive and treats \"quoted\" as "+
"an identifier rather than a string."),
docSubheading("Themes"),
prose("A theme is a colour per token type, and there are thirteen: Default, Default Light, Focus, "+
"Handmade Hero, Witness Classic, Witness, VS Classic, RAD Debugger, 4coder, Ryan Fleury, Gruber "+
"Darker, VS Dark, Freshcut Contrast. The file is pure data — not one function in five hundred "+
"lines."),
note("The Theme struct is a fossil, and you can date the rock",
"Every theme carries a 24-bit colour table AND a 256-colour ANSI fallback, with a use_truecolor "+
"flag — because this started life in a TERMINAL editor, where you cannot assume truecolor. "+
"But the platform layer beside it is a DPI-aware Win32 GUI window driven by ImGui, and the "+
"struct has since grown status_bg, minibuffer and file-browser colours that mean nothing to "+
"a terminal. It is a terminal-era struct with GUI-era fields bolted on. That is a seam, not "+
"a design."),
apiTable(
apiRow{"LexerTokenizeFn(data, len, out_tokens)", "The one signature every backend implements. One byte of token type per byte of source."},
apiRow{"lexer_get_tokenize_fn(lang)", "The backend for a language. A switch, not a registry."},
apiRow{"lexer_detect_lang(filename)", "Language from the file extension."},
apiRow{"Tokenizer + tokenizer_init / _eat_whitespace", "The shared cursor the backends are written against."},
apiRow{"g_themes / theme_active()", "Thirteen built-in themes, and the active one."},
),
)
}
// ---- config --------------------------------------------------------------
func cConfig() *VNode {
return docSection("config", "config",
prose("An INI file — [sections], key = value — that lives NEXT TO THE EXECUTABLE rather than in a "+
"home directory, which makes the program portable: copy the folder, keep your settings. It "+
"parses into one global Config struct and saves back out of it."),
codeLang("c/config/config.h", "c", configSnippet),
prose("The struct is the schema, and it is a fixed-size one: ten recent directories, every path a "+
"flat 1024 bytes. Nothing in this subsystem allocates — which is what lets it be loaded before "+
"an arena exists. If the file is absent, config_load writes a default one."),
note("It stores what the PROGRAM knows, not what the user wants",
"The header is explicit about the split, and it is a good one: this file is managed by the "+
"program — your window size, your recent folders, your active project — and per-project "+
"settings belong in .editorconfig, where other tools can read them. A config file that tries "+
"to be both ends up being neither."),
note("It needs a writable directory beside the executable",
"That is the one requirement the design carries, and it is easy to miss: the file is written "+
"NEXT TO the .exe, and config_save does not check whether the write succeeded. Run out of a "+
"folder you can write to — which is what \"copy the folder, keep your settings\" means — and "+
"it does exactly what it says. Install the same binary into C:\\Program Files and a "+
"non-elevated process cannot write there, so the save quietly does nothing. If you ship it "+
"through the installer, either keep the config beside the exe and expect an elevated write, "+
"or point config_get_path at %APPDATA% and give up the portability."),
apiTable(
apiRow{"config_load / config_save", "Read the INI into g_config; write g_config back out. Writes defaults if the file is absent."},
apiRow{"config_get_path", "Beside the executable — GetModuleFileNameA, then strip the filename."},
apiRow{"config_push_recent_dir", "Dedupes case-insensitively, moves the entry to the front, caps at ten, and saves itself."},
apiRow{"g_config", "The one global. There is no second one."},
),
)
}
// ---- installer -----------------------------------------------------------
func cInstaller() *VNode {
return docSection("installer", "installer",
prose("A Windows installer AND uninstaller, in one executable, with the thing it installs embedded "+
"inside it as a resource. No NSIS, no WiX, no MSI: it is a Win32 property-sheet wizard — "+
"welcome, directory, progress, finish — that extracts the payload, copies itself to "+
"uninstall.exe, optionally appends itself to the system PATH, writes a Start Menu shortcut via "+
"COM, and registers under HKLM so it shows up in Add/Remove Programs."),
prose("installer.manifest is what makes Windows ask for elevation UP FRONT rather than failing on "+
"the first write to Program Files. installer.h exists solely so the resource script and the C "+
"agree on the resource ids."),
note("The wizard has no dialog resources — it writes the DLGTEMPLATEs by hand, at runtime",
"There is a small serializer in installer.c that assembles the Win32 DLGTEMPLATE and "+
"DLGITEMTEMPLATE binary layout byte by byte, alignment padding and all, and hands the result "+
"to the property sheet with PSP_DLGINDIRECT. Which means the installer's entire user "+
"interface needs no resource compiler: only the icon, the manifest and the payload go "+
"through the .rc. That is either magnificent or deranged, and it is certainly deliberate."),
note("A running .exe cannot delete itself, so the uninstaller doesn't",
"It spawns a detached cmd.exe that waits two seconds, deletes uninstall.exe, and removes the "+
"directory. The process outlives the program that started it, which is the only way this can "+
"be done on Windows."),
docSubheading("Pointing it at your own product"),
prose("The .rc is the part you edit. It names codeMAX's icon, codeMAX's manifest and codeMAX's "+
"payload, at the paths the original repository had them at — so adopting the installer means "+
"pointing those three lines at your icon, your manifest and your executable, and changing the "+
"product name and the HKLM key the uninstall entry is written under. Nothing else in installer.c "+
"knows what it is installing."),
prose("There is also visible residue of a two-binary product that was collapsed into one, and it is "+
"worth recognising rather than copying: IDR_EXE_TERMINAL is still declared though nothing embeds "+
"or extracts it, do_install's numbered steps skip step 3, and the uninstaller still removes a "+
"second executable and a shortcut that the installer no longer creates. Those are the lines to "+
"delete on the way in."),
)
}
// ---- helpers -------------------------------------------------------------
// docSubheading is a heading INSIDE a section. The subsystems each have several parts, and
// six sections with no internal structure is a wall.
func docSubheading(text string) *VNode {
return H3(Attr("class", "mt-8 text-base font-semibold text-text-heading"), Text(text))
}
// ---- snippets ------------------------------------------------------------
//
// Every one of these is copied from the source, except two — unityTUSnippet and
// buildUsageSnippet, which are the two files the layer expects its CONSUMER to write and
// so cannot be copied from a layer that has no consumer yet. Both are written against the
// real API and both are captioned as the file you write, not as a file that is here.
//
// If you change the source, change these. A snippet that has drifted from the code it
// claims to show is the most expensive documentation there is, because it is believed.
const unityTUSnippet = `// The whole layer, as one translation unit — the only .c you hand to the
// 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
// belong in the SAME TU as the dispatch that calls them.
#include "lexer/lexer.c"
#include "lexer/lexer_c.c"
#include "lexer/lexer_go.c"
#include "lexer/lexer_js.c"
#include "lexer/lexer_lua.c"
#include "lexer/lexer_sql.c"
// config.c uses Config without including its own header — which is exactly the
// thing a unity build is for. Put the header above it.
#include "config/config.h"
#include "config/config.c"
// ...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.
#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
typedef uint8_t U8; typedef int8_t S8;
typedef uint32_t U32; typedef int32_t S32;
typedef uint64_t U64; typedef int64_t S64;
typedef S32 B32; // a boolean, sized so it packs predictably
typedef float F32; typedef double F64;
#define KB(n) (((U64)(n)) << 10)
#define MB(n) (((U64)(n)) << 20)`
const baseArenaSnippet = `typedef struct Arena { U8 *base; U64 pos; U64 cap; } Arena;
typedef struct Temp { Arena *arena; U64 pos; } Temp;
// One malloc, of exactly cap. It does not grow and it does not chain.
Arena *arena_alloc(U64 cap);
void *arena_push(Arena *arena, U64 size); // zeroed
void *arena_push_no_zero(Arena *arena, U64 size); // not
void arena_pop_to(Arena *arena, U64 pos);
#define push_array(arena, T, count) ((T *)arena_push((arena), sizeof(T) * (count)))
// A scratch scope. Allocate as freely as you like inside it; none of it needs
// releasing, because temp_end rolls the cursor back over all of it at once.
Temp scratch = temp_begin(arena);
Node *nodes = push_array(arena, Node, 1024);
temp_end(scratch);`
const baseStringsSnippet = `// A pointer and a length. Not NUL-terminated, not owned, does not allocate —
// so a substring is free, and a Str8 can point into a file you mapped.
typedef struct Str8 { const char *str; U64 size; } Str8;
static inline Str8 str8_lit(const char *s);
static inline B32 str8_match(Str8 a, Str8 b);
static inline B32 str8_is_empty(Str8 s);
// The two that must allocate take the arena, and so they say so:
Str8 str8_pushf(Arena *arena, const char *fmt, ...);
Str8 str8_push_copy(Arena *arena, Str8 s);`
const buildBootstrapSnippet = `# Once, by hand. (On Windows, from a Visual Studio developer prompt —
# it shells out to cl.exe.)
cl /nologo build.c # Windows
cc build.c -o build # macOS / Linux
# Ever after, just run it. If build.c is newer than the binary, the binary
# rebuilds itself, swaps in the new one, and re-executes:
./build`
const buildUsageSnippet = `#define BUILD_IMPLEMENTATION
#include "build.h"
int main(int argc, char **argv) {
// Renames the running binary to .old, recompiles, and re-execs. If the
// compile fails it puts the old one back — so a typo in your build
// script cannot brick your build script.
GO_REBUILD_URSELF(argc, argv);
mkdir_if_not_exists("out");
// needs_rebuild compares MTIMES against the list you pass it. There is
// no header scanning: if a .h you depend on is not in this list, editing
// it will not trigger a rebuild.
const char *srcs[] = {"base/base_inc.c", "lexer/lexer.c"};
if (needs_rebuild("out/app.exe", srcs, ARRAY_LEN(srcs)) > 0) {
Cmd cmd = {0};
cmd_append(&cmd, "cl", "/nologo", "/I.", "/Fe:out/app.exe");
cmd_append(&cmd, srcs[0], srcs[1]);
if (!cmd_run(&cmd)) return 1; // synchronous; resets cmd for reuse
cmd_free(&cmd);
}
build_log(LOG_INFO, "done");
return 0;
}`
const lexerSnippet = `// A tokenizer does not RETURN tokens. It paints one token-type byte per
// source byte, into an array the same length as the buffer.
//
// The editor never asks "what are the tokens". It asks "what colour is the
// character at offset N" — and this answers that with one indexed read.
// The enum value IS the index into the theme's colour table, which is why
// TOK_DEFAULT has to be 0.
typedef void (*LexerTokenizeFn)(const char *data, S32 length, U8 *out_tokens);
typedef enum Lang { LANG_PLAIN_TEXT, LANG_C, LANG_GO, LANG_JS, LANG_LUA, LANG_SQL } Lang;
LexerTokenizeFn lexer_get_tokenize_fn(Lang lang); // a switch, not a registry
Lang lexer_detect_lang(const char *filename);`
const configSnippet = `// The struct IS the schema, and it is fixed-size throughout: this subsystem
// allocates nothing, so it can be loaded before an arena exists.
typedef struct Config {
char theme[64];
char recent_dirs[CONFIG_MAX_RECENT_DIRS][CONFIG_PATH_MAX]; // most recent first
S32 recent_dir_count;
B32 show_line_numbers;
B32 syntax_enabled;
F32 ui_scale;
char editor_font[64];
char active_project[CONFIG_PATH_MAX];
} Config;
extern Config g_config; // the one global
static void config_load(void); // <exe dir>/config.ini — not $HOME
static void config_save(void);`

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,14 @@
package app
import (
"strings"
"kjol/lexer" // syntax highlighting for the code blocks — a string in, HTML out
. "kjol/vdom"
// The host API is dual-build — real under js/wasm, no-ops natively — so neutral page
// code can measure the browser and still server-render. This page uses it for exactly
// one thing: reading the clock when hydration commits.
"kjol/wasmruntime"
ui "kjol/webui"
)
@@ -31,18 +38,38 @@ type docsItem struct {
Icon string
}
// The Components group is not a list of PAGES — it is a list of anchors into the one
// components page. There used to be three pages there ("UI kit", "Overlays",
// "AutoTable"), which split the kit along the lines of its source files rather than
// along anything a reader wants: a person hunting for a date picker does not know, and
// should not have to guess, whether it was filed under forms or under overlays.
//
// So the whole kit is one page, and the sidebar jumps you down it. The groups come from
// componentGroups(), which is also what BUILDS the sections — so the sidebar cannot
// offer a jump to a section that does not exist, and a section cannot go missing from
// the sidebar.
func docsNav() []docsGroup {
items := make([]docsItem, 0, len(componentGroups()))
for _, g := range componentGroups() {
items = append(items, docsItem{
Path: "/wasm/components#" + g.ID,
Label: g.Label,
Icon: g.Icon,
Blurb: g.Blurb,
})
}
return []docsGroup{{
Title: "Introduction",
Items: []docsItem{
{Path: "/wasm", Label: "Overview", Icon: "book-open",
Blurb: "What Kjol Web is, how a page becomes a WebAssembly binary, and what runs where."},
Blurb: "What Kjøl Wasm Web is, how a page becomes a WebAssembly binary, and what runs where."},
},
}, {
Title: "Rendering",
Items: []docsItem{
{Path: "/wasm/chart", Label: "SSR & hydration", Icon: "chart-column",
Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, server-drawn as SVG."},
Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, drawn by the WebAssembly."},
{Path: "/wasm/server", Label: "Server components", Icon: "server",
Blurb: "Components whose state and code stay on the server. Calling one looks like calling any other."},
{Path: "/wasm/data", Label: "Data fetching", Icon: "cloud-arrow-down",
@@ -50,14 +77,7 @@ func docsNav() []docsGroup {
},
}, {
Title: "Components",
Items: []docsItem{
{Path: "/wasm/kit", Label: "UI kit", Icon: "squares",
Blurb: "Buttons, forms, tabs, alerts, cards — the kjol/webui components, written in Go."},
{Path: "/wasm/overlays", Label: "Overlays", Icon: "layers",
Blurb: "Tooltips, popovers, menus, modals: measured against the real viewport, flipped and shifted to fit."},
{Path: "/wasm/table", Label: "AutoTable", Icon: "table",
Blurb: "Filtering, sorting, column management, calculated columns, CSV and PDF export."},
},
Items: items,
}}
}
@@ -71,7 +91,7 @@ func docPage(eyebrow, title, lede string, sections ...*VNode) *VNode {
Div(Attr("class", "border-b border-line pb-6"),
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text(eyebrow)),
H1(Attr("class", "mt-2 text-3xl font-semibold tracking-tight text-text-heading"), Text(title)),
P(Attr("class", "mt-3 max-w-3xl text-ink-muted leading-relaxed"), Text(lede)),
P(Attr("class", "mt-3 text-ink-muted leading-relaxed"), Text(lede)),
),
)
for _, s := range sections {
@@ -93,11 +113,15 @@ func docSection(id, title string, body ...*VNode) *VNode {
return El("section", mods...)
}
// prose is a paragraph of explanation. Constrained to a reading measure: a line of body
// text that runs the full width of a wide screen is genuinely harder to read, and the
// demos beside it are allowed to be as wide as they like.
// prose is a paragraph of explanation.
//
// It used to be pinned to a reading measure (max-w-3xl). It is not any more: on a
// documentation page the paragraphs sit directly above demos, tables and code blocks
// that are as wide as the column, and a narrow ribbon of text over a full-width panel
// reads as a mistake rather than as typographic care. The column itself (max-w-6xl, set
// by AppLayout) is the measure now.
func prose(text string) *VNode {
return P(Attr("class", "mt-3 max-w-3xl text-ink-soft leading-relaxed"), Text(text))
return P(Attr("class", "mt-3 text-ink-soft leading-relaxed"), Text(text))
}
// ---- code ---------------------------------------------------------------
@@ -109,23 +133,20 @@ func prose(text string) *VNode {
// lets you go and check.
func code(caption, src string) *VNode { return codeLang(caption, "Go", src) }
// codeLang is code() for a block that is not Go — a shell session, a formula. The label
// in the corner says what you are looking at, and a shell command labelled "Go" is worse
// than no label at all.
// codeLang is code() for a block that is not Go — a C header, a shell session, a formula.
// The label in the corner says what you are looking at, and a shell command labelled "Go"
// is worse than no label at all.
//
// Go blocks are syntax-highlighted (webui.HighlightGo); the others are shown verbatim.
// A shell transcript put through a Go lexer comes out with `serving` painted as an
// identifier and quotes as string literals — highlighting the wrong language is more
// distracting than not highlighting at all.
// The label is ALSO what picks the lexer, so the two cannot disagree: a block cannot be
// labelled C and painted as Go. A language kjol/lexer does not know comes back escaped and
// unpainted, which is what should happen — a shell transcript put through a Go lexer comes
// out with `serving` painted as an identifier and quotes as string literals, and
// highlighting the WRONG language is more distracting than not highlighting at all.
func codeLang(caption, lang, src string) *VNode {
var body *VNode
if lang == "Go" {
// Raw, not Text: HighlightGo returns HTML. It escapes every run of source on the
// way out, so the snippets that contain markup stay inert.
body = El("code", Raw(ui.HighlightGo(src)))
} else {
body = El("code", Text(src))
}
// Raw, not Text: the lexer returns HTML. It escapes every run of source on the way out
// — including for a language it does not know — so the snippets that contain markup,
// and every C snippet, which is all pointers and shifts, stay inert.
body := El("code", Raw(lexer.Highlight(lang, src)))
return Div(Attr("class", "mt-4 overflow-hidden rounded-default border border-neutral-800 bg-neutral-900"),
Div(Attr("class", "flex items-center gap-2 border-b border-neutral-800 px-4 py-2"),
@@ -156,7 +177,7 @@ func demo(title string, body ...*VNode) *VNode {
// note is an aside — a caveat, a gotcha, the reason something is the way it is.
func note(title, body string) *VNode {
return Div(Attr("class", "mt-4 max-w-3xl rounded-default border border-primary-border bg-primary-subtle px-4 py-3"),
return Div(Attr("class", "mt-4 rounded-default border border-primary-border bg-primary-subtle px-4 py-3"),
P(Attr("class", "text-sm font-semibold text-text-heading"), Text(title)),
P(Attr("class", "mt-1 text-sm text-ink-soft leading-relaxed"), Text(body)),
)
@@ -180,7 +201,10 @@ func apiTable(rows ...apiRow) *VNode {
for _, b := range body {
rowMods = append(rowMods, b)
}
return Div(Attr("class", "mt-4 max-w-5xl overflow-x-auto"),
// Full width, like everything else on the page. A reference table pinned to max-w-5xl
// inside a max-w-6xl column is not narrower for a reason — it is narrower by an inch,
// which reads as a misalignment rather than as a decision.
return Div(Attr("class", "mt-4 overflow-x-auto"),
El("table", Attr("class", "w-full border-collapse text-left"),
Tbody(rowMods...),
),
@@ -191,7 +215,38 @@ func apiTable(rows ...apiRow) *VNode {
//gowasm:page /wasm static layout=app
func DocsPage(d Deps) func() *VNode {
clicks := NewSignal(0)
// The one measurement on the page: performance.now() when the client's first render
// commits. Zero until then — which is what the SERVER renders, and what the client
// renders on its first pass, so the two agree and hydration stays clean.
hydratedAt := NewSignal(0.0)
wasmruntime.AfterRender(func() {
if hydratedAt.Get() == 0 {
hydratedAt.Set(wasmruntime.Now())
}
})
// demoTree is called TWICE per render below — once for the DOM, once for the HTML.
// That is the point: the two panes cannot drift, because there is only one of them.
//
// This demo used to be on the front page. It does not belong there — it is the Wasm
// Web engine's single best argument, and the front page is kjøl's, not this engine's.
// Here it is the first thing the section shows, which is where an argument like this
// one earns its place.
demoTree := func() *VNode {
return Div(Attr("class", "flex items-center gap-3"),
ui.Button(ui.ButtonProps{
Color: ui.ButtonPrimary, Text: "Click me",
OnClick: func() { clicks.Set(clicks.Get() + 1) },
}),
Span(Attr("class", "text-ink-soft"), Text("clicked "+itoa(clicks.Get())+" times")),
)
}
return func() *VNode {
markup := RenderHTML(demoTree())
var groups []*VNode
for _, g := range docsNav() {
grid := []Mod{Attr("class", "mt-3 grid gap-3 sm:grid-cols-2")}
@@ -213,10 +268,39 @@ func DocsPage(d Deps) func() *VNode {
}
return docPage("Introduction", "Overview",
"Kjol Web is kjol's Go→WebAssembly UI engine. You write components as ordinary Go functions "+
"returning a virtual DOM; the server renders them to HTML and the same code hydrates them "+
"in the browser. There is no JavaScript build step, and the engine depends on nothing "+
"outside the standard library.",
"Kjøl Wasm Web is Kjøl's Go→WebAssembly UI engine. You write components as ordinary Go "+
"functions returning a virtual DOM; the server renders them to HTML and the same code "+
"hydrates them in the browser. There is no JavaScript build step, and the engine depends "+
"on nothing outside the standard library.",
// ---- the demonstration ----
//
// The one thing on this site that cannot be faked: the same Go function, rendered
// twice at once, as live DOM and as the HTML string the server sent.
docSection("two-runtimes", "One function, two runtimes",
prose("Below is a single Go function, shown twice. On the left it has been reconciled into "+
"the DOM and you can use it. On the right is the HTML the same function produces when the "+
"server renders it — the markup that reached your browser before any WebAssembly had "+
"loaded. Click the button; both move."),
Div(Attr("class", "mt-5 border border-line sm:grid sm:grid-cols-2"),
Div(Attr("class", "border-b border-line sm:border-b-0 sm:border-r"),
paneLabel("in your browser"),
Div(Attr("class", "px-4 py-8"), demoTree()),
),
Div(
paneLabel(itoa(len(markup))+" bytes of HTML"),
Pre(Attr("class", "whitespace-pre-wrap px-4 py-4 font-mono text-[12px] leading-relaxed text-ink-muted"),
El("code", Text(prettyHTML(markup))),
),
),
),
P(Attr("class", "mt-3 text-sm leading-relaxed text-ink-muted"),
Text("The right pane is not a picture of the source. It is vdom.RenderHTML, called on the "+
"very tree the left pane is showing, recomputed on every click.")),
P(Attr("class", "mt-2 text-sm text-ink-muted"),
Text(hydrationNote(hydratedAt.Get()))),
),
docSection("what-runs-where", "What runs where",
prose("A page is Go, compiled twice. On the server it renders to an HTML string, so the first "+
@@ -230,15 +314,35 @@ func DocsPage(d Deps) func() *VNode {
"position itself, without a branch in the component."),
),
docSection("what-is-in-it", "What is in it",
Ul(Attr("class", "mt-3 space-y-1.5 leading-relaxed text-ink-soft"),
item("Server-side rendering and client hydration, from one codebase."),
item("Server components: mark a function and its code and state stay on the server."),
item("A component kit — forms, tabs, modals, tooltips, toasts — with dark mode."),
item("A table with filtering, sorting, column management, formulas, and CSV and PDF export."),
item("Tailwind, compiled by a Go program that reads your Go."),
),
prose("Two commands build it. The first produced the page you are reading; the second serves "+
"it and rebuilds on save."),
codeLang("terminal", "sh", buildTranscript),
),
appendNodes(Div(Attr("class", "mt-14 border-t border-line pt-2")), groups...),
)
}
}
func docsCard(d Deps, it docsItem) *VNode {
// A component card is a jump into the components page, not a page of its own — so it
// routes there and scrolls, exactly as the sidebar does.
click := navigate(d, it.Path)
if base, frag, ok := strings.Cut(it.Path, "#"); ok {
click = navigateAnchor(d, base, frag)
}
return A(
Attr("class", "group block rounded-default border border-line bg-surface p-4 no-underline shadow-xs transition hover:border-primary-border hover:shadow-sm"),
Attr("href", it.Path), navigate(d, it.Path),
Attr("href", it.Path), click,
Div(Attr("class", "flex items-center gap-2"),
Span(Attr("class", "inline-flex h-7 w-7 items-center justify-center rounded-default bg-primary-subtle text-accent"),
ui.IconInline(it.Icon, 14, "")),

View File

@@ -1,355 +0,0 @@
package app
import (
"strings"
. "kjol/vdom"
ui "kjol/webui"
)
// orElse is a fallback for an empty string.
func orElse(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
// row is a flex/grid container helper (appends *VNode children as Mods).
func row(class string, children ...*VNode) *VNode {
mods := []Mod{Attr("class", class)}
for _, c := range children {
mods = append(mods, c)
}
return Div(mods...)
}
// kitSection is one labelled block of the gallery — a live demo panel, so that what you
// are looking at is unmistakably the component running rather than a picture of it.
func kitSection(title string, body ...*VNode) *VNode {
return demo(title, row("flex flex-col gap-4", body...))
}
func ptRow(name, plan string, status *VNode) *VNode {
td := func(cls string, c *VNode) *VNode { return El("td", Attr("class", "px-3 py-2 text-sm "+cls), c) }
return El("tr",
td("text-ink", Text(name)),
td("text-ink-soft", Text(plan)),
El("td", Attr("class", "px-3 py-2 text-sm text-right"), status),
)
}
// languageOptions is deliberately longer than the pill limit, so the multi-select
// demonstrates both ways it collapses: past 3 selections it says "N items selected"
// outright, and below that it still collapses if the pills are too wide for the field.
func languageOptions() []ui.FormSelectOption {
return []ui.FormSelectOption{
{Value: "go", Label: "Go"},
{Value: "rust", Label: "Rust"},
{Value: "ts", Label: "TypeScript"},
{Value: "python", Label: "Python"},
{Value: "kotlin", Label: "Kotlin"},
{Value: "swift", Label: "Swift"},
}
}
//gowasm:page /wasm/kit layout=app
func KitPage(d Deps) func() *VNode {
// Interactive demos own their state via signals (a write re-renders).
tab := NewSignal(0)
acc := NewSignal(0)
notify := NewSignal(true)
span := NewSignal("week")
name := NewSignal("")
email := NewSignal("")
plan := NewSignal("pro")
langs := NewSignal([]string{"go"})
// Floating components are CONTROLLERS: they own refs, timers and open state, so
// they are built once here — never inside the render closure below, which would
// rebuild them (and lose their state) on every frame.
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
tip := ui.NewHoverTooltip(ui.PlacementTop, "")
skills := ui.NewMultiSelect(ui.DropdownOptions{})
// The controls the first port left out, now that the host API can carry them.
taxID := NewSignal("")
rate := NewSignal("")
signed := NewSignal("")
picked := NewSignal("")
tags := NewSignal([]string{"go"})
pad := ui.NewSignaturePad(ui.SignaturePadOptions{
OnChange: func(svg string) { signed.Set(svg) },
})
// The search is the caller's: the component knows how to debounce, order and render,
// and nothing at all about where options come from. Here it is a local slice; in an
// app it would be a fetch.
people := ui.NewAsyncCombobox(ui.AsyncComboboxOptions{
MinChars: 2,
Search: func(q string, done func([]ui.FormSelectOption)) {
var out []ui.FormSelectOption
for _, row := range employees() {
p, ok := row.(Employee)
if ok && strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) {
out = append(out, ui.FormSelectOption{Value: p.Email, Label: p.Name})
}
}
done(out)
},
})
tagPicker := ui.NewMultiSelectTrigger(ui.DropdownOptions{})
return func() *VNode {
return docPage("Components", "UI kit",
"kjol/webui is the component library: buttons, badges, forms, tabs, alerts, cards, tables. "+
"It is a Go port of the Solid.js kit the applications used before, styled with the same "+
"Tailwind utilities — so the two can be swapped for one another a screen at a time.",
docSection("using", "Using a component",
prose("Components are functions taking a props struct. There is no class hierarchy and nothing "+
"to register: a component is a value, so you can build one, store it, pass it around, and "+
"the compiler will tell you when you get it wrong."),
code("app/kit.go", kitSnippet),
note("Styling is Tailwind, compiled from your Go",
"The Tailwind engine scans .go files for class names, because that is where the markup is. "+
"There is no JavaScript build in this example at all — the CSS is compiled by a Go "+
"program from Go source."),
),
docSection("gallery", "The gallery",
prose("Everything below is running. Click it."),
),
kitSection("Buttons",
row("flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Text: "Red"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Text: "Blue"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Neutral"}),
),
row("flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Outline: true, Text: "Outline"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Icon: "check", Text: "Small + icon"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Icon: "plus"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Disabled", Disabled: true}),
),
),
kitSection("Badges",
row("flex flex-wrap items-center gap-2",
ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeRed}, Text("failed")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("info")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("default")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeMuted}, Text("muted")),
),
),
kitSection("Alerts",
ui.Alert(ui.AlertBlue, "Heads up", Text("An informational message with a header.")),
ui.Alert(ui.AlertGreen, "", Text("A success alert without a header.")),
ui.Alert(ui.AlertYellow, "Warning", Text("Something needs your attention.")),
ui.Alert(ui.AlertRed, "Error", Text("Something went wrong.")),
),
kitSection("Toggles & segmented control",
ui.ToggleSwitch(notify.Get(), func(v bool) { notify.Set(v) }, "Email notifications", "Send me product updates", false, ""),
ui.SegmentedButtons([]ui.SegmentedButtonOption{
{Value: "day", Label: "Day"},
{Value: "week", Label: "Week"},
{Value: "month", Label: "Month"},
}, span.Get(), func(v string) { span.Set(v) }, false, "max-w-xs"),
),
kitSection("Tabs",
ui.TabGroup(ui.TabGroupProps{
Items: []ui.TabItem{
{Title: "Overview", Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The overview panel."))},
{Title: "Details", Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The details panel."))},
{Title: "Activity", Badge: 3, Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The activity panel (3 new)."))},
},
ActiveIndex: tab.Get(),
OnTabChange: func(i int) { tab.Set(i) },
}),
),
kitSection("Accordion",
ui.SingleAccordion([]ui.AccordionItemData{
{Title: "What is Kjol Web?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("kjol's Go→WebAssembly UI engine."))},
{Title: "Is it isomorphic?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("Yes — the same Go renders on the server (SSR) and hydrates on the client."))},
{Title: "How is it styled?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("Tailwind utility classes, compiled by kjol's native Tailwind engine."))},
}, acc.Get(), func(i int) { acc.Set(i) }),
),
kitSection("Forms",
row("grid gap-4 sm:grid-cols-3",
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Name")),
ui.FormInput(ui.FormInputProps{Value: name.Get(), Placeholder: "Ada Lovelace", OnInput: func(v string) { name.Set(v) }})),
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Email")),
ui.FormEmailInput(ui.FormInputProps{Value: email.Get(), Placeholder: "ada@example.com", OnInput: func(v string) { email.Set(v) }}, true)),
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Plan")),
ui.FormSelect(ui.FormSelectProps{Value: plan.Get(), OnChange: func(v string) { plan.Set(v) }},
ui.FormOption("free", "Free", false),
ui.FormOption("pro", "Pro", false),
ui.FormOption("enterprise", "Enterprise", false))),
// A multi-select. Its rows carry checkboxes, and the field shows the
// selection as removable pills — until they stop fitting, at which point
// it collapses to "N items selected". Tick a few and watch it flip.
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Languages")),
skills.Render(ui.FormMultiSelectProps{
Options: languageOptions(),
Value: langs.Get(),
Placeholder: "Pick a few",
Searchable: true,
ShowSelectAll: true,
OnChange: func(v []string) { langs.Set(v) },
})),
),
P(Attr("class", "text-xs text-ink-muted"),
Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+
"\" languages="+strings.Join(langs.Get(), ","))),
),
kitSection("Masked inputs",
row("grid gap-4 sm:grid-cols-2",
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Tax ID")),
// The mask is a pure function of the string, applied on every keystroke.
// It must be idempotent — it is fed its own output — or the field
// corrupts itself as you type.
ui.FormInput(ui.FormInputProps{
Value: taxID.Get(),
Placeholder: "12-3456789",
OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) },
})),
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Rate")),
ui.FormInput(ui.FormInputProps{
Value: rate.Get(),
Placeholder: "5.25",
OnInput: func(v string) { rate.Set(ui.MaskRate(v)) },
})),
),
P(Attr("class", "text-xs text-ink-muted"),
Text("Type letters, extra dots, leading zeros — the mask takes what it can use.")),
),
kitSection("Async combobox",
row("max-w-sm",
people.Render(ui.FormAsyncComboboxProps{
Placeholder: "Search people…",
OnSelect: func(o ui.FormSelectOption) { picked.Set(o.Label + " <" + o.Value + ">") },
}),
),
P(Attr("class", "text-xs text-ink-muted"),
Text("Two characters before it asks; 200 ms after you stop typing. A response for a "+
"query you have already typed past is discarded rather than shown. Picked: "+
orElse(picked.Get(), "nothing yet"))),
),
kitSection("Multi-select behind your own trigger",
tagPicker.Render(ui.FormMultiSelectTriggerProps{
Trigger: ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Icon: "filter", Text: "Tags (" + itoa(len(tags.Get())) + ")"}),
Options: languageOptions(),
Value: tags.Get(),
Searchable: true,
ShowSelectAll: true,
OnChange: func(v []string) { tags.Set(v) },
}),
P(Attr("class", "text-xs text-ink-muted"),
Text("Same selection model as the field above; only the thing you click on differs.")),
),
kitSection("Signature pad",
pad.Render(ui.SignaturePadProps{}),
P(Attr("class", "text-xs text-ink-muted"),
Text("Draw in it. It is an SVG, not a canvas — so the markup you are looking at IS the "+
"value the caller gets ("+itoa(len(signed.Get()))+" bytes), and a stored signature "+
"renders on the server.")),
),
kitSection("Table",
ui.PrettyTable(
[]ui.PrettyTableColumn{
{DisplayName: "Name"},
{DisplayName: "Plan"},
{DisplayName: "Status", DisplayPosition: ui.PrettyTableColRight},
},
ui.PrettyTableOptions{Hover: true, Alternate: true, SurroundingBorder: true, HeaderBorderY: true},
ptRow("Ada Lovelace", "Pro", ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active"))),
ptRow("Alan Turing", "Free", ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("trial"))),
ptRow("Grace Hopper", "Enterprise", ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("invited"))),
),
),
kitSection("Overlays (measured, portaled)",
row("flex flex-wrap items-center gap-4",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
// The menu measures itself against the viewport: drag the window
// narrow, or scroll it to the bottom, and it flips/shifts to stay on
// screen. Items close the menu themselves — no callback plumbing.
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
caret := " ▾"
if open {
caret = " ▴"
}
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Menu" + caret})
}),
menu.Content("",
menu.Item(ui.MenuItemProps{Icon: "check"}, Text("Profile")),
menu.Item(ui.MenuItemProps{}, Text("Settings")),
ui.MenuDivider(""),
menu.Item(ui.MenuItemProps{}, Text("Sign out")),
),
// The tooltip's arrow tracks the trigger even when the panel gets
// shifted away from it near a viewport edge.
tip.Render(Span(Text("A measured tooltip — try it near the window edge")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Hover me"})),
),
modal.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
},
P(Attr("class", "text-ink-soft"),
Text("Portaled to document.body, so it is not clipped by any ancestor. Escape closes "+
"the topmost modal; the backdrop click closes too.")),
),
),
docSection("more", "Where to go next",
prose("The floating components on this page — the menu, the tooltip, the modal, the "+
"multi-select — are the shallow end. Overlays covers how they are positioned, and what "+
"happens when one would open off the edge of the screen."),
apiTable(
apiRow{"ui.Button / ui.Badge / ui.Alert", "The presentational set. Props structs, no state."},
apiRow{"ui.FormInput / FormSelect / FormCombobox", "Inputs. Value in, OnChange out — the caller owns the state."},
apiRow{"ui.NewMultiSelect", "A controller: checkboxed rows, pills that collapse to \"N items selected\" when they stop fitting."},
apiRow{"ui.Tabs / ui.Accordion / ui.Card", "Layout and disclosure."},
apiRow{"ui.RegisterIcon", "Add your own icons. The kit ships a small set; the app brings the rest."},
),
),
)
}
}
const kitSnippet = `// A component is a function taking a props struct.
ui.Button(ui.ButtonProps{
Color: ui.ButtonPrimary,
Icon: "check",
Text: "Save",
OnClick: func() { toaster.Success("Saved.") },
})
// Inputs are controlled: the caller owns the state.
name := NewSignal("")
ui.FormInput(ui.FormInputProps{
Value: name.Get(),
OnInput: func(v string) { name.Set(v) }, // a write re-renders
})`

View File

@@ -7,22 +7,42 @@ import (
ui "kjol/webui"
)
// The layers of kjol, as data.
// What kjøl is made of, as data.
//
// This is the Go mirror of frontend/src/layers.ts. The site has two front-ends built
// by two completely different pipelines, and the Layers menu has to be identical in
// both — so it is a LIST in each, not markup, and the two lists are the only thing
// that has to be kept in step.
// There are two kinds of thing here, and conflating them was the mistake this file used
// to make — one flat list called "the layers", holding both.
//
// (A shared source would be better than a mirrored one. There isn't one: this half
// compiles to WebAssembly and the other is bundled by esbuild, and nothing is upstream
// of both. Keeping it to a flat slice of plain data is what makes the duplication
// survivable — you can diff the two by eye.)
// LAYERS are LANGUAGES. What kjøl is written in, and what it gives you in each:
// the Go base, the TypeScript kit, the C base, the Jai modules. A layer is
// a directory of code you can use on its own.
//
// COMPOSITIONS are FRAMEWORKS. What you get when the layers are assembled into
// something that does a job — the two web engines. A composition is not
// another language; it is a use of them.
//
// Kjøl Wasm Web is Go, all the way down. Kjøl JS Web is TypeScript compiled by a Go
// toolchain — two layers, one framework. Listing that beside "C" as though they were the
// same kind of noun told the reader nothing about either.
//
// This is the Go mirror of frontend/src/layers.ts. The site has two front-ends built by
// two completely different pipelines, and these menus have to be identical in both — so
// each is a LIST, not markup, and the two lists are the only thing that has to be kept in
// step.
//
// (A shared source would be better than a mirrored one. There isn't one: this side
// compiles to WebAssembly and the other is bundled by esbuild, and nothing is upstream of
// both. Keeping each to a flat slice of plain data is what makes the duplication
// survivable — you can diff them by eye.)
type Layer struct {
Name string
Href string
Tagline string
// Sub is the half-line beside the wordmark while you are inside this layer. It says
// what you are standing in — "Go + WebAssembly", "arenas, strings, a lexer" — and a
// wordmark that says the same thing everywhere is one more thing the reader has to
// keep track of himself.
Sub string
// Live means you can click into worked examples. The others are documented but
// have no demo — they still appear, because a menu that silently omits half the
// library teaches the reader that the library is half the size it is.
@@ -30,66 +50,107 @@ type Layer struct {
Icon string
}
func Layers() []Layer {
// Wordmark is what the CHROME calls this layer — the top bar, and the page's own title.
// The menu calls it Name.
//
// They differ, and only for the languages. In a menu headed "Layers" the row says "C",
// because the row is answering "which language"; up in the top bar, alone, "C" is the name
// of a language rather than the name of the thing you are reading, and it has to say whose
// C this is. The compositions are already named "Kjøl Wasm Web" — the product's name is
// part of what they ARE, not a prefix bolted on — so they are returned unchanged.
func (l Layer) Wordmark() string {
if strings.HasPrefix(l.Name, "Kjøl") {
return l.Name
}
return "Kjøl " + l.Name
}
// Languages: what kjøl is written in.
func Languages() []Layer {
return []Layer{
{
Name: "Kjol Go",
Name: "Go",
Href: "/go",
Tagline: "The server base: config, database, logging, HTTP, mail, validation.",
Tagline: "The base: config, database, logging, HTTP, mail, validation — and both web engines.",
Icon: "server",
},
{
Name: "Kjol Wasm Web",
Href: "/wasm",
Tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, no JS build.",
Live: true,
Icon: "code",
},
{
Name: "Kjol JS Web",
Href: "/js",
Tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
Live: true,
Name: "TypeScript",
Href: "/ts",
Tagline: "The Solid component kit, the vendored runtime, and the generic scaffolding apps import as @kjol/*.",
Icon: "squares",
},
{
Name: "Kjol C",
Name: "C",
Href: "/c",
Tagline: "Arena allocator, strings, math, lexer, platform layer.",
Tagline: "Arena allocator, counted strings, math, a lexer, a platform layer — and a build system that is a C file.",
Sub: "a base layer in C",
Live: true,
Icon: "bolt",
},
{
Name: "Kjol Jai",
Name: "Jai",
Href: "/jai",
Tagline: "Console rendering module. Early.",
Tagline: "Console rendering. Early.",
Icon: "cube",
},
}
}
// CurrentLayer is the layer the given path belongs to, or nil on the front page.
// Compositions: what the languages are assembled into.
func Compositions() []Layer {
return []Layer{
{
Name: "Kjøl Wasm Web",
Href: "/wasm",
Tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, and no JavaScript build at all.",
Sub: "Go + WebAssembly",
Live: true,
Icon: "code",
},
{
Name: "Kjøl JS Web",
Href: "/js",
Tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
Sub: "Solid + Go toolchain",
Live: true,
Icon: "table",
},
}
}
// CurrentLayer is the layer or composition the given path belongs to, or nil on the front
// page. The wordmark uses it to name where you are standing.
func CurrentLayer(path string) *Layer {
for i, l := range Layers() {
all := append(Compositions(), Languages()...)
for i, l := range all {
if path == l.Href || strings.HasPrefix(path, l.Href+"/") {
return &Layers()[i]
return &all[i]
}
}
return nil
}
// LayersMenuCtl is the Layers menu's controller.
// The two menus' controllers.
//
// It is created ONCE, here, at package level — not inside layersMenu, which is called
// from a layout on every single render. A floating component is a controller: it owns
// an open signal, a positioning engine and document listeners, and building a fresh one
// per render would leak all three and give you a menu that never opens. Same rule as
// Theme, a few lines up in pages.go.
var LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
// They are created ONCE, here, at package level — not inside the functions below, which a
// layout calls on every single render. A floating component is a controller: it owns an
// open signal, a positioning engine and document listeners, and building a fresh one per
// render would leak all three and give you a menu that never opens. Same rule as Theme, a
// few lines up in pages.go.
//
// TWO menus, not one with two headings inside it. They are different questions — "what is
// this written in" and "what can I read" — and a reader who wants the second should not
// have to scroll past the first to find it. The kit's single-open manager means opening
// one closes the other, so they behave like one control with two halves.
var (
LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
CompositionsMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
)
// layersMenu is the site's primary navigation: kjol is a stack of layers, and this is
// how you get from any one of them to any other.
// layersMenu lists the LANGUAGES. compositionsMenu, below, lists the frameworks.
//
// A layer that is Live is a link. One that is not is inert and dimmed, with the word
// An entry that is Live is a link. One that is not is inert and dimmed, with the word
// "reference" on it — it exists, it is documented in the repository, there is simply
// nothing here to click.
//
@@ -98,35 +159,41 @@ var LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
// interception — an intercepted click would ask this WebAssembly to render a page it
// does not have.
func layersMenu(d Deps) *VNode {
content := []*VNode{
P(Attr("class", "px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint"),
Text("The layers of kjol")),
}
for _, l := range Layers() {
return dropdown(d, LayersMenuCtl, "Layers", Languages())
}
// compositionsMenu lists the FRAMEWORKS — the two things assembled out of the layers, and
// the two a reader can actually click into.
func compositionsMenu(d Deps) *VNode {
return dropdown(d, CompositionsMenuCtl, "Compositions", Compositions())
}
func dropdown(d Deps, ctl *ui.Menu, label string, rows []Layer) *VNode {
content := make([]*VNode, 0, len(rows))
for _, l := range rows {
content = append(content, layerItem(d, l))
}
return Div(Attr("class", "relative"),
LayersMenuCtl.Trigger(ui.MenuTriggerProps{
ctl.Trigger(ui.MenuTriggerProps{
Class: "inline-flex items-center gap-1.5 rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink",
},
Text("Layers"),
Text(label),
ui.IconInline("chevron-down", 11, "text-ink-faint"),
),
LayersMenuCtl.Content("w-96", content...),
ctl.Content("w-96", content...),
)
}
// layersGrid is the front page's list of layers — the same data as the menu, laid out
// to be read rather than navigated. A layer with no examples still gets a row: the
// point of the page is what kjol IS, and half of it having no demo yet does not make
// that half not exist.
func layersGrid(d Deps) *VNode {
rows := []Mod{Attr("class", "mt-5 divide-y divide-line rounded-default border border-line")}
for _, l := range Layers() {
rows = append(rows, layerRow(l))
// layerGrid is the front page's list — the same data as the menu, laid out to be read
// rather than navigated. A layer with no examples still gets a row: the point of the page
// is what kjøl IS, and half of it having no demo yet does not make that half not exist.
func layerGrid(rows []Layer) *VNode {
mods := []Mod{Attr("class", "mt-5 divide-y divide-line rounded-default border border-line")}
for _, l := range rows {
mods = append(mods, layerRow(l))
}
return Div(rows...)
return Div(mods...)
}
func layerRow(l Layer) *VNode {

View File

@@ -1,406 +0,0 @@
package app
import (
"strconv"
. "kjol/vdom"
ui "kjol/webui"
)
// Every floating component in the kit, on one page: tooltips, popovers, menus and
// submenus, the date picker, modals (plain, confirm, wizard, imperative), toasts,
// and the tutorial's spotlight coachmarks.
//
// All of them are CONTROLLERS. They own refs, timers and open state, so they are
// created once here — never inside the render closure, which runs on every signal
// write and would rebuild them (and their refs) from scratch every frame. That is
// the single rule to remember about the floating layer.
//
// The page is NOT `static`: nothing is open during SSR anyway, so pre-rendering it
// buys nothing, and it keeps the example honest about which routes need it.
//
//gowasm:page /wasm/overlays layout=app
func OverlaysPage(d Deps) func() *VNode {
// --- tooltips -----------------------------------------------------------
tipTop := ui.NewHoverTooltip(ui.PlacementTop, "")
tipRight := ui.NewHoverTooltip(ui.PlacementRight, "")
tipFocus := ui.NewFocusTooltip(ui.PlacementBottom, "")
tipFast := ui.NewTooltip(ui.TooltipProps{Placement: ui.PlacementTop, Delay: -1})
// --- popovers -----------------------------------------------------------
pop := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomStart})
popEnd := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomEnd})
hoverPop := ui.NewHoverPopover(ui.HoverPopoverProps{
Placement: ui.PlacementTop,
// The bridge: the cursor gets 300ms of grace to cross the gap from the
// trigger onto the panel. Without it, the panel closes in the dead space
// between them — which is exactly what happens once a panel is portaled and
// CSS :hover no longer reaches it.
HoverCloseDelay: 300,
})
// --- menus --------------------------------------------------------------
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
sub := ui.NewSubmenu(menu)
hoverMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart, OpenOnHover: true})
// --- date pickers -------------------------------------------------------
picked := NewSignal("")
dp := ui.NewDatePicker(ui.DatePickerProps{
Placeholder: "Pick a date",
Clearable: true,
OnChange: func(v string) { picked.Set(v) },
})
dob := ui.NewDateOfBirthPicker(ui.DatePickerProps{Placeholder: "Date of birth"})
// --- modals -------------------------------------------------------------
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
nested := ui.NewModal(ui.ModalOptions{Size: ui.ModalSmall})
deleted := NewSignal(false)
confirm := ui.NewModal(ui.ModalOptions{})
// --- wizard -------------------------------------------------------------
wizardName := NewSignal("")
wizardDone := NewSignal(false)
wizard := ui.NewWizard(ui.ModalOptions{})
// --- toasts -------------------------------------------------------------
// The Toaster owns the queue AND the clocks: it generates IDs, runs the
// auto-dismiss timer, and animates the countdown bar down to zero. (ToastProvider,
// the dumb half, renders a list you hand it and removes nothing — a toast pushed
// through it stays until you take it away yourself.)
toaster := ui.NewToaster(ui.ToasterOptions{Position: ui.ToastBottomRight})
pushToast := func(kind ui.ToastType, msg string) {
toaster.Push(ui.Toast{Message: msg, Type: kind})
}
// --- tutorial -----------------------------------------------------------
// Steps target elements by CSS SELECTOR. The tour resolves each one with
// document.querySelector, measures it, scrolls it into view, and cuts a hole in
// the dimmed overlay around it — the spotlight animates from target to target.
tour := ui.NewTutorial(ui.TutorialOptions{
Steps: []ui.TutorialStep{
{
Title: "Tooltips",
Target: "#demo-tooltips",
Content: func() *VNode { return Text("Measured, portaled, and they flip near a viewport edge.") },
},
{
Title: "Popovers",
Target: "#demo-popovers",
Placement: ui.PlacementBottom,
Content: func() *VNode { return Text("Click or hover. The hover bridge lets you reach the panel.") },
},
{
Title: "Menus",
Target: "#demo-menus",
Content: func() *VNode { return Text("Items close the menu themselves; submenus are portaled.") },
},
{
// No Target: the page dims flat and the card centres in the viewport.
Title: "That's the tour",
Content: func() *VNode { return Text("Escape ends it. Arrow keys and Enter move between steps.") },
},
},
})
return func() *VNode {
return docPage("Components", "Overlays",
"Tooltips, popovers, menus, modals and toasts — every one of them measured against the real "+
"viewport. A floating panel is portaled to document.body, positioned from its trigger's "+
"bounding box, and flipped or shifted when it would otherwise run off the screen.",
docSection("engine", "How a panel is placed",
prose("Positioning is a pure function: given the trigger's rectangle, the panel's size and the "+
"viewport, it returns coordinates. It is unit-tested natively, with no browser in sight, "+
"because none of it is about the browser — the browser only supplies the three rectangles."),
prose("The result is written to the element with SetStyle, NOT through a signal. A signal write "+
"re-renders the whole tree, and this runs on every scroll and resize frame; going through "+
"the vdom would rebuild the page sixty times a second to move one panel four pixels."),
code("webui/floating.go", floatingSnippet),
note("Controllers are built once",
"A floating component owns refs, timers and its open state. Build it alongside your signals, "+
"never inside the render closure — one built per frame can never stay open, because the "+
"thing holding \"open\" is thrown away and replaced before you can see it."),
row("mt-4 flex gap-2", tour.StartButton(0, "", Text("Take the tour"))),
),
// ---- tooltips ----
docSection("demo-tooltips", "Tooltips",
prose("Hover, or focus — a tooltip that only answers to a mouse is a tooltip a keyboard user "+
"cannot read. Narrow the window and hover the Right one: it flips to the left, and its "+
"arrow follows it. Near an edge the panel shifts back on screen and the arrow slides to "+
"keep pointing at the trigger; in the original kit the arrow detached and pointed at "+
"nothing."),
demo("Placement, delay, and focus triggers",
row("flex flex-wrap items-center gap-3",
tipTop.Render(Span(Text("Above — the default")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Top"})),
tipRight.Render(Span(Text("To the right, unless it would run off the edge")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Right"})),
tipFast.Render(Span(Text("No open delay")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Instant"})),
tipFocus.Render(Span(Text("Shown on focus, not hover — tab to the field")),
ui.FormInput(ui.FormInputProps{Placeholder: "Focus me"})),
),
),
),
// ---- popovers ----
docSection("demo-popovers", "Popovers",
prose("A popover closes on an outside click or on Escape — and only the TOPMOST one closes per "+
"press, so a dropdown inside a popover does not take the popover down with it. The hover "+
"variant keeps a bridge across the gap between trigger and panel, so the cursor can "+
"actually reach the thing it opened."),
demo("Click, alignment, and hover-with-a-bridge",
row("flex flex-wrap items-center gap-3",
pop.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Click me"})),
pop.Content(ui.PopoverContentProps{Class: "w-64"},
P(Attr("class", "text-sm text-ink-soft"),
Text("Click outside, or press Escape, to close. Only the topmost floating closes per press.")),
),
popEnd.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Aligned to my right edge"})),
popEnd.Content(ui.PopoverContentProps{Class: "w-56"},
P(Attr("class", "text-sm text-ink-soft"), Text("Placement bottom-end.")),
),
hoverPop.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Hover me, then reach the panel"})),
hoverPop.Content(ui.PopoverContentProps{Class: "w-64"},
P(Attr("class", "text-sm text-ink-soft"),
Text("Move the cursor across the gap and onto this panel — it stays open. "+
"Select this text to prove it.")),
),
),
),
),
// ---- menus ----
docSection("demo-menus", "Menus & submenus",
prose("Opening one menu closes the other: a single-open manager keeps the page from filling up "+
"with panels nobody asked for. Submenus are exempt from it — they are Standalone — or a "+
"submenu would close the very menu it belongs to as it opened."),
prose("A submenu is portaled too, which is not a detail: the parent menu scrolls its own "+
"contents, and a submenu rendered inside it was clipped by that overflow the moment it "+
"was taller than its parent."),
demo("Items, icons, a submenu, and KeepOpen",
row("flex flex-wrap items-center gap-3",
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
caret := " ▾"
if open {
caret = " ▴"
}
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Actions" + caret})
}),
menu.Content("",
menu.Item(ui.MenuItemProps{Icon: "check", OnClick: func() { pushToast(ui.ToastSuccess, "Profile opened") }},
Text("Profile")),
menu.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastInfo, "Settings opened") }},
Text("Settings")),
// The submenu is portaled — it used to be clipped by the parent
// menu's own overflow-y-auto.
sub.Submenu(ui.SubmenuProps{Trigger: "More", Icon: "ellipsis"},
sub.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastInfo, "Archived") }}, Text("Archive")),
sub.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastWarning, "Duplicated") }}, Text("Duplicate")),
),
ui.MenuDivider(""),
// KeepOpen is the TSX's closeOnClick inverted: by default an item
// closes the menu, which the first Go port dropped entirely.
menu.Item(ui.MenuItemProps{KeepOpen: true, OnClick: func() { pushToast(ui.ToastGeneric, "Menu stayed open") }},
Text("Stay open (KeepOpen)")),
menu.Item(ui.MenuItemProps{Icon: "arrow-right-from-bracket",
OnClick: func() { pushToast(ui.ToastError, "Signed out") }}, Text("Sign out")),
),
hoverMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(bool) *VNode {
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Opens on hover"})
}),
hoverMenu.Content("",
hoverMenu.Item(ui.MenuItemProps{}, Text("One")),
hoverMenu.Item(ui.MenuItemProps{}, Text("Two")),
),
),
),
),
// ---- date pickers ----
docSection("demo-dates", "Date picker",
prose("The field is typeable, not merely clickable. It parses loosely — 7/4/26, Jul 4 2026 and "+
"2026-07-04 all work — and commits what it understood on blur, so the calendar is an "+
"affordance rather than the only way in."),
demo("Picked: \""+picked.Get()+"\"",
row("grid gap-4 sm:grid-cols-2",
row("flex flex-col gap-1",
ui.FormLabel(ui.FormLabelProps{}, Text("Date (portaled, flips near the bottom)")),
dp.Render(),
),
row("flex flex-col gap-1",
ui.FormLabel(ui.FormLabelProps{}, Text("Date of birth (inline, three selects)")),
dob.Render(),
),
),
),
),
// ---- modals ----
docSection("demo-modals", "Modals",
prose("Portaled to document.body, so no ancestor's overflow:hidden or transform can clip them. "+
"Open the modal, then the nested one inside it, and press Escape twice: modals unwind one "+
"layer per press rather than all at once."),
prose("The last button opens a modal that no component in the tree owns — webui.OpenModal hands "+
"content to a shared host rendered once in the layout. That is what code far from the view "+
"needs: a confirmation raised from inside a save handler, say."),
demo("Deleted: "+strconv.FormatBool(deleted.Get()),
row("flex flex-wrap items-center gap-3",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Delete something…", OnClick: confirm.Open}),
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open wizard", OnClick: wizard.Open}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Open imperatively",
OnClick: func() {
// No component in the tree owns this one: OpenModal hands content
// to the shared host rendered in the layout.
ui.OpenModal(func() *VNode {
return ui.ModalContent(ui.ModalContentProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Opened from anywhere")),
},
P(Attr("class", "text-ink-soft"),
Text("This content was not rendered by any component — it was handed to "+
"ModalHost (see AppLayout) by webui.OpenModal.")),
)
}, ui.ModalOptions{Size: ui.ModalSmall})
}}),
),
),
// The modals themselves. They portal to document.body, so where they sit in
// the tree makes no difference to where they appear.
modal.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("A modal")),
Footer: ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Close", OnClick: modal.Close}),
},
P(Attr("class", "text-ink-soft"),
Text("Portaled to document.body, so no ancestor's overflow:hidden can clip it. It fades "+
"and scales in — a double requestAnimationFrame, because a single frame does not "+
"give the browser time to commit the initial style.")),
row("mt-4",
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open a nested modal", OnClick: nested.Open}),
),
),
nested.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Nested")),
},
P(Attr("class", "text-ink-soft"), Text("Escape closes THIS one first, not the one behind it.")),
),
confirm.Confirm(ui.ConfirmModalProps{
Title: "Delete row",
Message: "This cannot be undone.",
OnConfirm: func() {
deleted.Set(true)
pushToast(ui.ToastError, "Row deleted")
},
}),
wizard.Render(ui.WizardProps{
Title: "Set up your account",
FinishText: "Finish",
OnComplete: func() {
wizardDone.Set(true)
pushToast(ui.ToastSuccess, "Wizard complete: "+wizardName.Get())
},
Steps: []ui.WizardStep{
{
Title: "Your name",
// Each step gets its own context: SetCanContinue gates THIS step's
// Next button, which a single shared bool could not express.
Content: func(ctx ui.WizardStepContext) *VNode {
ctx.SetCanContinue(wizardName.Get() != "")
return row("flex flex-col gap-1",
ui.FormLabel(ui.FormLabelProps{}, Text("Name (required to continue)")),
ui.FormInput(ui.FormInputProps{
Value: wizardName.Get(),
Placeholder: "Ada Lovelace",
OnInput: func(v string) { wizardName.Set(v) },
}),
)
},
},
{
Title: "Confirm",
Content: func(ctx ui.WizardStepContext) *VNode {
ctx.SetCanContinue(true)
return P(Attr("class", "text-ink-soft"),
Text("All set for "+wizardName.Get()+". Finish to close."))
},
},
},
}),
),
// ---- toasts ----
docSection("demo-toasts", "Toasts",
prose("They dismiss themselves after five seconds. Watch the bar count down: it is one CSS "+
"transition, written straight at the element — not a re-render per frame, which is what a "+
"progress bar driven through a signal would cost you."),
prose("A sticky toast (Duration: ToastSticky) waits for the user instead. The menu items above "+
"raise toasts too, which is how you can see that an item really does close its own menu."),
demo("Push, dismiss, and a sticky one",
row("flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "Success",
OnClick: func() { toaster.Success("Saved.") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Small: true, Text: "Error",
OnClick: func() { toaster.Error("Something went wrong.") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Small: true, Text: "Info",
OnClick: func() { toaster.Info("Just so you know.") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Sticky (no timer)",
OnClick: func() {
toaster.Push(ui.Toast{
Message: "This one waits for you to dismiss it.",
Type: ui.ToastWarning,
Duration: ui.ToastSticky,
})
}}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear all",
OnClick: toaster.Clear}),
),
),
),
docSection("overlay-api", "Reference",
apiTable(
apiRow{"NewFloating", "The positioning engine behind every panel: placement, offset, flip, shift, arrow."},
apiRow{"NewTooltip / NewPopover / NewMenu", "Controllers. Build once, outside the render."},
apiRow{"Standalone", "Exempts a panel from the single-open manager. A submenu needs it, or it closes its own parent."},
apiRow{"vdom.Portal", "Mounts children at document.body — the escape hatch from an ancestor's overflow:hidden."},
apiRow{"webui.OpenModal / ModalHost", "Open a modal from code that owns no component. Render the host once, in your layout."},
),
),
// The toast container and the tutorial's overlay both render here; both are
// fixed-position, so where they sit in the tree does not matter.
toaster.Render(),
tour.Render(),
)
}
}
const floatingSnippet = `// Built ONCE — it owns refs, timers, and whether it is open.
pop := ui.NewPopover(ui.PopoverOptions{
Placement: ui.PlacementBottomStart,
Offset: 8,
})
// ...and in the render:
pop.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Text: "Click me"}),
)
pop.Content(ui.PopoverContentProps{Class: "w-64"},
P(Text("Outside click and Escape close me.")),
)
// The panel is portaled to document.body and positioned imperatively:
// render invisible -> AfterRender -> measure -> ComputePosition -> SetStyle -> reveal
// Never through a signal: this runs on every scroll frame.`

View File

@@ -17,10 +17,6 @@ import (
. "kjol/vdom"
// The host API is dual-build — real under js/wasm, no-ops natively — so neutral page
// code can measure the browser and still server-render. The landing page uses it for
// exactly one thing: reading the clock when hydration commits.
"kjol/wasmruntime"
ui "kjol/webui"
)
@@ -67,16 +63,18 @@ func notFound(path string) *VNode {
// wordmark is the brand lockup, shared by both layouts so they cannot drift.
//
// The boat is the point of the name: kjol is Norwegian for KEEL — the spine of a hull,
// The boat is the point of the name: kjøl is Norwegian for KEEL — the spine of a hull,
// the thing every other part is built onto. Which is what this library is meant to be
// for the applications that share it.
func wordmark(d Deps, href string) *VNode {
// The lockup names the LAYER you are standing in, not the site. On the front page
// that is kjol itself; inside /wasm it is Kjol Wasm Web. A wordmark that says the
// same thing everywhere is one more thing the reader has to keep track of himself.
name, sub := "kjol", "a shared base layer"
// that is Kjøl itself; inside /wasm it is Kjøl Wasm Web; inside /c it is Kjøl C —
// Wordmark, not Name, because up here "C" alone names a language rather than the thing
// you are reading. A wordmark that says the same thing everywhere is one more thing the
// reader has to keep track of himself.
name, sub := "Kjøl", "a shared base layer"
if l := CurrentLayer(d.Path()); l != nil {
name, sub = l.Name, "Go + WebAssembly"
name, sub = l.Wordmark(), l.Sub
}
return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
@@ -103,22 +101,39 @@ func PublicLayout(d Deps, content *VNode) *VNode {
// read aloud.
Div(Attr("class", "pointer-events-none fixed inset-0 -z-10 bg-grid grid-fade"), Attr("aria-hidden", "true")),
// The nav, the content and the footer are ONE column, and the way to get that is for
// all three to be built the same way: gutter on the outside, measure on the inside.
//
// <div class="px-4"> <div class="mx-auto max-w-3xl"> …
//
// This used to be `mx-auto max-w-3xl px-4` on the nav's inner div — measure and gutter
// on the SAME element. On a wide screen the gutter has nothing to do (the centring has
// already pushed the box in much further), so all it did was inset the nav's contents
// by another 16px: the wordmark sat a finger's width to the right of the headline
// underneath it. Close enough to look like a mistake, far enough to see.
//
// The footer was worse — it was max-w-2xl, a different measure entirely.
Nav(Attr("class", "site-nav border-b border-line"),
Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 px-4 py-4"),
wordmark(d, "/"),
Div(Attr("class", "ml-auto flex items-center gap-1"),
layersMenu(d),
Ul(Attr("class", "flex items-center gap-1"),
navItem(d, "/about", "About", false),
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
),
))),
Div(Attr("class", "px-4"),
Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 py-4"),
wordmark(d, "/"),
Div(Attr("class", "ml-auto flex items-center gap-1"),
layersMenu(d),
compositionsMenu(d),
Ul(Attr("class", "flex items-center gap-1"),
navItem(d, "/about", "About", false),
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
),
)))),
Main(Attr("class", "px-4 py-14"), content),
Footer(Attr("class", "mx-auto max-w-2xl px-4 pb-14"),
P(Attr("class", "text-sm text-ink-faint"),
Text("kjol is a shared base layer, factored out of several applications so they stay in sync. It is Norwegian for keel.")),
Footer(Attr("class", "px-4 pb-14"),
Div(Attr("class", "mx-auto max-w-3xl"),
P(Attr("class", "text-sm text-ink-faint"),
Text("Kjøl is a shared base layer, factored out of several applications so they stay in "+
"sync. It is Norwegian for keel.")),
),
),
ui.ModalHost(),
)
@@ -127,7 +142,7 @@ func PublicLayout(d Deps, content *VNode) *VNode {
// wideRoutes get a roomier container. A table with a dozen columns, a drag handle
// and three calculated columns has no business being squeezed into a reading-width
// column; prose pages still are.
var wideRoutes = map[string]bool{"/wasm/table": true}
var wideRoutes = map[string]bool{"/wasm/components": true}
// AppLayout is the DOCUMENTATION shell: a sidebar of sections on the left, the page on
// the right. The app routes are the framework's docs — each one explains a capability,
@@ -155,6 +170,7 @@ func AppLayout(d Deps, content *VNode) *VNode {
Span(Attr("class", "rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint"), Text("Docs")),
Div(Attr("class", "ml-auto flex items-center gap-2"),
layersMenu(d),
compositionsMenu(d),
Ul(Attr("class", "flex items-center gap-2"),
navItem(d, "/", "Home", false),
Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
@@ -179,9 +195,22 @@ func AppLayout(d Deps, content *VNode) *VNode {
// docsSidebar is the section list. Sticky, so it stays put while a long page scrolls —
// on a documentation site the nav is how you know where you are, and a nav that scrolls
// away leaves you nowhere.
// sidebarNav is the sidebar's contents, which depend on WHICH LAYER you are reading.
//
// AppLayout is shared by every documentation page in this binary, and those pages are no
// longer all about the same thing: /wasm/* documents the Go→WebAssembly engine, /c
// documents the C base layer. A sidebar listing the engine's chapters while you are
// reading about arenas would be worse than no sidebar at all.
func sidebarNav(path string) []docsGroup {
if path == "/c" || strings.HasPrefix(path, "/c/") {
return cNav()
}
return docsNav()
}
func docsSidebar(d Deps) *VNode {
mods := []Mod{Attr("class", "sticky top-[3.75rem] hidden h-[calc(100vh-3.75rem)] w-56 shrink-0 overflow-y-auto py-10 lg:block")}
for _, g := range docsNav() {
for _, g := range sidebarNav(d.Path()) {
items := []Mod{Attr("class", "mt-2 space-y-0.5")}
for _, it := range g.Items {
items = append(items, Li(sidebarLink(d, it)))
@@ -197,13 +226,30 @@ func docsSidebar(d Deps) *VNode {
}
func sidebarLink(d Deps, it docsItem) *VNode {
base, frag, isAnchor := strings.Cut(it.Path, "#")
cls := "flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline text-ink-soft hover:bg-surface-raised hover:text-ink"
iconCls := "text-ink-faint"
if d.Path() == it.Path {
// A section link is NEVER "active", and that is deliberate. It cannot be: it would
// have to know which section you had scrolled to, which means measuring all fifteen of
// them on every scroll frame, and the only way to act on the answer is a signal write
// — which re-renders this entire page. Sixty times a second, to move a highlight.
//
// (Marking them active by PAGE instead lights up all fifteen at once, which is worse
// than no highlight: it tells you nothing and looks broken.)
active := !isAnchor && d.Path() == it.Path
if active {
cls = "active flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline bg-primary-subtle font-medium text-accent"
iconCls = "text-accent"
}
return A(Attr("class", cls), Attr("href", it.Path), navigate(d, it.Path),
click := navigate(d, it.Path)
if isAnchor {
click = navigateAnchor(d, base, frag)
}
return A(Attr("class", cls), Attr("href", it.Path), click,
ui.IconInline(it.Icon, 14, iconCls),
Text(it.Label),
)
@@ -251,122 +297,56 @@ func Counter(label string, count *Signal[int]) *VNode {
// ---- landing ------------------------------------------------------------
// The landing page is one narrow column of plain text, a demo, and a list.
// The landing page is a column of plain text and two lists.
//
// It used to be a framework marketing page: an oversized headline, a hero glow, feature
// cards in a grid, numbered chapters, a call to action repeated at both ends. All of it
// was arguing. None of it was showing. A library this small does not need to argue — it
// needs to say what it is, show that it works, and get out of the way, and a reader who
// wants to be convinced can click into the docs and find every page running the code it
// documents.
// It used to carry the Wasm Web engine's own highlights: the two-runtime demo, a list of
// SSR/hydration/server-component features, the build transcript. All of it was true, and
// none of it belonged HERE — the front page is kjøl's, and kjøl is not the Go/WebAssembly
// engine any more than it is the C arena allocator. A reader landing on it should learn
// what the thing IS, not be pitched one of its five parts.
//
// What survives is the part that could not be faked: the same Go function rendered twice
// at once, as live DOM and as the HTML string the server sends.
// So the demo moved to /wasm, where it is the first thing that section shows, and the
// front page says what is actually true of the whole: here are the languages, here are the
// frameworks assembled out of them, go and read one.
//
//gowasm:page / static layout=public
func HomePage(d Deps) func() *VNode {
clicks := NewSignal(0)
// The one measurement on the page: performance.now() when the client's first render
// commits. Zero until then — which is what the SERVER renders, and what the client
// renders on its first pass, so the two agree and hydration stays clean.
hydratedAt := NewSignal(0.0)
wasmruntime.AfterRender(func() {
if hydratedAt.Get() == 0 {
hydratedAt.Set(wasmruntime.Now())
}
})
// demoTree is called TWICE per render below — once for the DOM, once for the HTML.
// That is the point: the two panes cannot drift, because there is only one of them.
demoTree := func() *VNode {
return Div(Attr("class", "flex items-center gap-3"),
ui.Button(ui.ButtonProps{
Color: ui.ButtonPrimary, Text: "Click me",
OnClick: func() { clicks.Set(clicks.Get() + 1) },
}),
Span(Attr("class", "text-ink-soft"), Text("clicked "+itoa(clicks.Get())+" times")),
)
}
return func() *VNode {
markup := RenderHTML(demoTree())
return Div(Attr("class", "mx-auto max-w-3xl"),
H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
Text("kjol")),
Text("Kjøl")),
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
Text("A shared base layer, factored out of several applications so they stay in sync. "+
"Kjol is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
"Kjøl is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
Text("It is not one library. It is a stack of them, in several languages, and each one is "+
"documented here.")),
Text("It is not one library. It is a set of them, in several languages, and a couple of "+
"frameworks assembled out of those. Each one is documented here, and every page of that "+
"documentation runs the code it documents.")),
// ---- the layers ----
//
// The layers are the site. Everything else on this page is evidence that they
// work; this is the part you are meant to click.
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("The layers")),
layersGrid(d),
// ---- the demonstration ----
//
// This survives from the old landing page because it is the one thing on the site
// that cannot be faked: the same Go function, rendered twice at once, as live DOM
// and as the HTML string the server sent.
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("One function, two runtimes")),
// ---- layers: the languages ----
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Layers")),
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
Text("Below is a single Go function, shown twice. On the left it has been reconciled into "+
"the DOM and you can use it. On the right is the HTML the same function produces when "+
"the server renders it — the markup that reached your browser before any WebAssembly "+
"had loaded. Click the button; both move.")),
Text("What Kjøl is written in, and what it gives you in each. A layer is a directory of "+
"code you can use on its own — the Go base does not know the C one exists.")),
layerGrid(Languages()),
Div(Attr("class", "mt-5 border border-line sm:grid sm:grid-cols-2"),
Div(Attr("class", "border-b border-line sm:border-b-0 sm:border-r"),
paneLabel("in your browser"),
Div(Attr("class", "px-4 py-8"), demoTree()),
),
Div(
paneLabel(itoa(len(markup))+" bytes of HTML"),
Pre(Attr("class", "whitespace-pre-wrap px-4 py-4 font-mono text-[12px] leading-relaxed text-ink-muted"),
El("code", Text(prettyHTML(markup))),
),
),
),
P(Attr("class", "mt-3 text-sm leading-relaxed text-ink-muted"),
Text("The right pane is not a picture of the source. It is vdom.RenderHTML, called on the "+
"very tree the left pane is showing, recomputed on every click.")),
// ---- what is in it ----
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("What is in it")),
Ul(Attr("class", "mt-3 space-y-1.5 leading-relaxed text-ink-soft"),
item("Server-side rendering and client hydration, from one codebase."),
item("Server components: mark a function and its code and state stay on the server."),
item("A component kit — forms, tabs, modals, tooltips, toasts — with dark mode."),
item("A table with filtering, sorting, column management, formulas, and CSV and PDF export."),
item("Tailwind, compiled by a Go program that reads your Go."),
),
// ---- building ----
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Building it")),
// ---- compositions: the frameworks ----
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Compositions")),
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
Text("Two commands. The first produced the page you are reading; the second serves it and "+
"rebuilds on save.")),
codeLang("terminal", "sh", buildTranscript),
Text("What the layers become when they are assembled into something that does a job. A "+
"composition is not another language: Kjøl Wasm Web is Go all the way down, and Kjøl JS "+
"Web is TypeScript compiled by a Go toolchain. These are the two you can click into.")),
layerGrid(Compositions()),
// ---- close ----
P(Attr("class", "mt-12 border-t border-line pt-6 leading-relaxed text-ink-soft"),
Text("Every page of the documentation runs the code it documents — there are no screenshots "+
"of components anywhere on this site. "),
Text("There is not a screenshot of a component anywhere on this site. Every example is the "+
"real thing, running — which is the only way a documentation page can tell you when it "+
"has gone stale. "),
A(Attr("class", "text-accent underline underline-offset-4"),
Attr("href", "/wasm"), navigate(d, "/wasm"), Text("Read the docs")),
Text(", or "),
A(Attr("class", "text-accent underline underline-offset-4"),
Attr("href", "/wasm/kit"), navigate(d, "/wasm/kit"), Text("look at the components")),
Attr("href", "/about"), navigate(d, "/about"), Text("Why this exists")),
Text("."),
),
P(Attr("class", "mt-4 text-sm text-ink-muted"),
Text(hydrationNote(hydratedAt.Get()))),
)
}
}
@@ -404,10 +384,14 @@ func prettyHTML(s string) string {
return strings.ReplaceAll(s, "><", ">\n<")
}
const buildTranscript = `$ go run ./build
// The real transcript. It is on the front page, so it is the first thing anybody copies —
// which makes it the first thing to notice when it goes stale.
const buildTranscript = `$ go run ./server -build
==> generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)
==> compiling Tailwind CSS -> wwwroot/app.css
==> compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)
==> bundling the Solid app -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)
==> copying Go's wasm_exec.js shim into wwwroot/
$ go run ./server
serving "./wwwroot" on http://localhost:8085`
@@ -422,16 +406,22 @@ func AboutPage(d Deps) func() *VNode {
H1(Attr("class", "mt-2 text-4xl font-semibold tracking-tight text-text-heading"), Text("Why this exists")),
P(Attr("class", "mt-6 text-lg leading-relaxed text-ink-soft"),
Text("Kjol Web is one part of kjol — a shared base layer factored out of several applications "+
"so they stay in sync. (Kjol is Norwegian for KEEL: the spine of a hull, the thing every "+
"other part is built onto.) The applications had drifted: the same table, the same forms, "+
"the same charts, each subtly different in each app, each fixed twice.")),
Text("Kjøl is a shared base layer, factored out of several applications so they stay in sync. "+
"(Kjøl is Norwegian for KEEL: the spine of a hull, the thing every other part is built "+
"onto.) The applications had drifted: the same table, the same forms, the same charts, "+
"each subtly different in each app, each fixed twice.")),
P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
Text("The UI kit began as Solid.js components. Kjol Web is the same kit, written in Go and "+
"compiled to WebAssembly — the same components, the same Tailwind, no JavaScript build. "+
"That means one language across the server and the browser, and a table you can share "+
"between a web app and a native one because it is a Go function, not a JSX file.")),
Text("The UI kit began as Solid.js components, and it still is — that is Kjøl JS Web, and it "+
"is what those applications run today. Kjøl Wasm Web is the same kit written a second time "+
"in Go and compiled to WebAssembly: the same components, the same Tailwind, no JavaScript "+
"build at all. One language across the server and the browser, and a table you could share "+
"with a native app, because it is a Go function rather than a JSX file.")),
P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
Text("Neither of them is Kjøl. They are two compositions of it — two uses of the layers "+
"underneath, which are just directories of Go, TypeScript, C and Jai. The front page lists "+
"both, and does not argue for either.")),
H2(Attr("class", "mt-12 text-2xl font-semibold tracking-tight text-text-heading"), Text("The rules it keeps")),
Div(Attr("class", "mt-6 space-y-4"),

View File

@@ -6,15 +6,14 @@ import "kjol/vdom"
// Routes maps each //gowasm:page path to its instantiated render function.
func Routes(d Deps) map[string]func() *vdom.VNode {
return map[string]func() *vdom.VNode{
"/": HomePage(d),
"/about": AboutPage(d),
"/wasm": DocsPage(d),
"/wasm/chart": ChartPage(d),
"/wasm/data": DataPage(d),
"/wasm/kit": KitPage(d),
"/wasm/overlays": OverlaysPage(d),
"/wasm/server": ServerPage(d),
"/wasm/table": TablePage(d),
"/": HomePage(d),
"/about": AboutPage(d),
"/c": CPage(d),
"/wasm": DocsPage(d),
"/wasm/chart": ChartPage(d),
"/wasm/components": ComponentsPage(d),
"/wasm/data": DataPage(d),
"/wasm/server": ServerPage(d),
}
}
@@ -22,23 +21,22 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
var StaticPaths = map[string]bool{
"/": true,
"/about": true,
"/c": true,
"/wasm": true,
"/wasm/chart": true,
"/wasm/data": true,
"/wasm/table": true,
}
// RouteLayout maps each route to the name of the layout that wraps it.
var RouteLayout = map[string]string{
"/": "public",
"/about": "public",
"/wasm": "app",
"/wasm/chart": "app",
"/wasm/data": "app",
"/wasm/kit": "app",
"/wasm/overlays": "app",
"/wasm/server": "app",
"/wasm/table": "app",
"/": "public",
"/about": "public",
"/c": "app",
"/wasm": "app",
"/wasm/chart": "app",
"/wasm/components": "app",
"/wasm/data": "app",
"/wasm/server": "app",
}
// LayoutFor wraps a page's content in the layout declared for its route.

View File

@@ -11,7 +11,7 @@ import (
)
func TestSSRPages(t *testing.T) {
for _, path := range []string{"/", "/about", "/wasm/chart", "/wasm/data", "/wasm/table", "/wasm/overlays", "/wasm/kit"} {
for _, path := range []string{"/", "/about", "/wasm/chart", "/wasm/data", "/wasm/components"} {
deps := Deps{Path: func() string { return path }}
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
t.Logf("%-8s %6d bytes portals=%d", path, len(html), strings.Count(html, "data-portal"))
@@ -22,7 +22,7 @@ func TestSSRPages(t *testing.T) {
}
func TestSSRTablePage(t *testing.T) {
deps := Deps{Path: func() string { return "/wasm/table" }}
deps := Deps{Path: func() string { return "/wasm/components" }}
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
// The table persists a personal layout in localStorage, which the SERVER CANNOT
@@ -33,13 +33,21 @@ func TestSSRTablePage(t *testing.T) {
// This is a real cost — the page ships no table content — and it is the price of
// never showing the wrong table. See webui.RestoreLayout.
if !strings.Contains(html, `aria-busy="true"`) {
t.Error("SSR /table should render the loading skeleton, not a table")
t.Error("SSR should render the AutoTable's loading skeleton, not a table")
}
if !strings.Contains(html, "animate-pulse") {
t.Error("the skeleton bars are missing")
}
if strings.Contains(html, "Ada Lovelace") {
t.Error("SSR rendered table CONTENT — a user with a saved layout would watch it rearrange")
// A salary, which ONLY the AutoTable renders.
//
// This used to look for "Ada Lovelace", which was a fine proxy back when the table
// had a page to itself. It is not one any more: the components page also demos
// PrettyTable, and PrettyTable's rows are Ada, Alan and Grace — so the old assertion
// failed on a page that was behaving perfectly. A test that names a value only the
// component under test can produce cannot be fooled by its neighbours.
if strings.Contains(html, "$1,610.25") {
t.Error("SSR rendered AutoTable CONTENT — a user with a saved layout would watch it rearrange")
}
}

View File

@@ -174,159 +174,6 @@ func newEmployeeTable(highlight *Signal[string]) *ui.AutoTableState {
})
}
//gowasm:page /wasm/table layout=app static
func TablePage(d Deps) func() *VNode {
// Which row to spotlight, if any.
highlight := NewSignal("")
table := newEmployeeTable(highlight)
table.SetRows(employees())
// The export menu, with a submenu for the PDF's page orientation. Both are
// controllers, both built once. A submenu is Standalone — opening it must not
// close the menu it lives in.
exportMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
pdfSub := ui.NewSubmenu(exportMenu)
// What the PDF prints above the table.
//
// Note what is NOT here: the footer lines. The export takes the table's OWN
// summary rows — including any the user builds at runtime in the Calculated
// editor — and evaluates them against the same filtered rows it is printing. Only
// pass Summaries explicitly to print something that is not one of the table's own
// rows.
pdfHeader := func(landscape bool) ui.AutoTablePDFHeader {
orientation := ui.PDF_ORIENTATION_PORTRAIT
if landscape {
orientation = ui.PDF_ORIENTATION_LANDSCAPE
}
return ui.AutoTablePDFHeader{
Title: "Employees",
Subtitle: "Exported from the Kjol Web example",
ShowDate: true,
Orientation: orientation,
}
}
return func() *VNode {
return docPage("Components", "AutoTable",
"A table that filters, sorts, pages, reorders, resizes, computes and exports — configured with "+
"a column list and a slice of rows. Everything a user changes about it is theirs and persists; "+
"everything it exports is what they filtered, not what happened to be on screen.",
docSection("defining", "Defining one",
prose("A column says how to read a field, how to sort it, and how to render it. The state object "+
"is a CONTROLLER: build it once, alongside your signals — never inside the render, which "+
"would hand it fresh refs and a fresh idea of which page it was on every frame."),
code("app/table.go", tableSnippet),
note("The server renders a skeleton, on purpose",
"The layout — column order, widths, what is hidden, the calculated columns — lives in the "+
"browser's localStorage, which the server cannot read. So the server ships a skeleton "+
"rather than the DEFAULT table: a user who had reordered their columns would otherwise "+
"watch them rearrange themselves the moment the WebAssembly booted."),
),
docSection("try-it", "Try it",
prose("Search matches name or email. Sort by Salary and it parses the currency, so $980 sorts "+
"below $1,200.50. Unhide Rank and sort that: \"Item 2\" comes before \"Item 10\", because "+
"numbers inside text are compared as numbers. Drag a header to reorder it, drag its right "+
"edge to resize — reload the page and both are still where you left them."),
prose("Filter it, then export. You get every matching row across every page, in the column order "+
"you dragged them into, with the calculated columns computed per row."),
),
table.Render(
ui.AutoTableWithHover(),
ui.AutoTableWithAlternate(),
ui.AutoTableWithSurroundingBorder(),
ui.AutoTableWithPaginationShowAll(),
ui.AutoTableWithSearchFields(
// One box, several fields: a global search.
table.GlobalSearch("Search name or email…", "Name", "Email"),
// Exact-match dropdown.
table.SelectSearch("Status", []string{"active", "inactive"}, "Any status"),
// IN-set: matches any of the selected teams.
table.MultiSelectSearch("Team", "Any team", []string{"Engineering", "Research", "Networking"}),
),
ui.AutoTableWithToolbarActions(
table.ColumnPicker(),
// Build calculated columns and footer rows at runtime. Basic picks a
// function and the columns it combines across each row; Advanced writes
// a formula, with insert menus for columns, functions and constants.
// The formula is compiled and previewed against the real first row as
// you type, so a typo shows up immediately rather than as a column of
// dashes. What you build is persisted with the rest of the layout.
table.CalculatedColumnEditor(),
// Export writes what the FILTER selected — every matching row across
// every page — not the five rows on screen. And it writes the columns
// you can actually see, in the order you dragged them into.
exportMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Icon: "download", Text: "Export"})
}),
exportMenu.Content("",
exportMenu.Item(ui.MenuItemProps{Icon: "file-csv",
OnClick: func() { table.DownloadCSV("employees") }}, Text("Download CSV")),
// A submenu — portaled, so it is not clipped by the menu's own
// overflow-y-auto, which is what broke it before.
pdfSub.Submenu(ui.SubmenuProps{Trigger: "Download PDF", Icon: "file-pdf"},
pdfSub.Item(ui.MenuItemProps{
OnClick: func() { table.DownloadPDF("employees", pdfHeader(false)) }}, Text("Portrait")),
pdfSub.Item(ui.MenuItemProps{
OnClick: func() { table.DownloadPDF("employees", pdfHeader(true)) }}, Text("Landscape")),
),
ui.MenuDivider(""),
exportMenu.Item(ui.MenuItemProps{Icon: "print",
OnClick: func() { table.PrintPDF(pdfHeader(true)) }}, Text("Print")),
),
),
),
// Highlight + auto-page-jump: Radia is on page 3 by default, and the table
// pages itself to wherever she actually is once filters and sorting move her.
row("mt-4 flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Text: "Find Radia Perlman",
OnClick: func() { highlight.Set("radia@example.com") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Text: "Clear highlight",
OnClick: func() { highlight.Set("") }}),
),
docSection("calculated", "Calculated columns",
prose("The toolbar's calculator builds new columns at runtime, in two modes. Basic picks a "+
"function and the columns it combines ACROSS each row — sum of Salary and Bonus, per "+
"person. Advanced writes a formula, with insert menus for columns, functions and constants: "+
"([Salary] + [Bonus]) * 12."),
prose("A summary row is the other axis: it aggregates ONE column DOWN the filtered rows and "+
"prints the result in the footer. Confusing the two is the classic bug here — a column that "+
"aggregates down shows every row the same number, and it looks plausible enough to ship."),
codeLang("formulas", "syntax", formulaSnippet),
note("Compiled as you type",
"The formula is parsed and evaluated against the real first row while you write it, so a "+
"typo shows up as an error under the box — not as a column of dashes discovered later."),
),
docSection("export", "Export",
prose("CSV and PDF are written in Go, standard library only — the PDF writer builds its own "+
"xref table and embeds Helvetica metrics. Export takes the FILTERED rows, the VISIBLE "+
"columns, in the user's order, including whatever they calculated."),
apiTable(
apiRow{"NewAutoTableState", "Build the controller: the columns, and where to persist the layout."},
apiRow{".SetRows", "Hand it the data. It filters, sorts and pages from there."},
apiRow{".RestoreLayout", "Read the saved layout and reveal the table over its skeleton. Call it once, on the client."},
apiRow{".FilteredRows / .ExportColumns", "What the user selected, and what they can see — the inputs to any export."},
apiRow{"ExportCSV / ExportPDF", "Write the bytes. DownloadCSV / DownloadPDF / PrintPDF do it and hand them to the browser."},
),
),
)
}
}
const tableSnippet = `// A controller: built ONCE, next to your signals — never inside the render.
table := ui.NewAutoTableState([]ui.AutoTableColumn{
{DisplayName: "Name", SortIdentifier: "Name", Sortable: true,

View File

@@ -7,15 +7,18 @@ import (
"kjol/vdom"
)
// The landing page's whole claim is that its two panes are ONE function: the live
// component on the left, and the HTML string the server sends on the right. If they
// could drift, the page would be a lie told in the most embarrassing possible place.
// The two-runtime demo.s whole claim is that its two panes are ONE function: the live
// component on the left, and the HTML string the server sends on the right. If they could
// drift, the page would be a lie told in the most embarrassing possible place.
//
// It lives on /wasm now, not on the front page — it is the Wasm Web engine.s argument,
// and the front page is kjøl.s. The test followed it.
//
// So: render it, click the button the way the browser would, render again, and check
// that BOTH panes moved. A pane rendered from a stale copy of the tree — or from a
// second, hand-written one — fails here.
func TestLandingPanesShareOneTree(t *testing.T) {
page := HomePage(Deps{Path: func() string { return "/" }})
func TestTwoRuntimePanesShareOneTree(t *testing.T) {
page := DocsPage(Deps{Path: func() string { return "/wasm" }})
html := vdom.RenderHTML(page())
if !strings.Contains(html, "clicked 0 times") {
@@ -38,8 +41,8 @@ func TestLandingPanesShareOneTree(t *testing.T) {
// The byte count under the right-hand pane is the length of the string actually shown,
// not a number typed in by hand — so it has to move when the markup does.
func TestLandingByteCountIsReal(t *testing.T) {
page := HomePage(Deps{Path: func() string { return "/" }})
func TestTwoRuntimeByteCountIsReal(t *testing.T) {
page := DocsPage(Deps{Path: func() string { return "/wasm" }})
before := byteCountLabel(t, vdom.RenderHTML(page()))
clickButton(t, page(), "Click me")
@@ -60,7 +63,7 @@ func byteCountLabel(t *testing.T, html string) string {
t.Helper()
i := strings.Index(html, " bytes of HTML")
if i < 0 {
t.Fatal("no byte-count caption on the landing page")
t.Fatal("no byte-count caption on the /wasm overview")
}
start := strings.LastIndexByte(html[:i], '>') + 1
return html[start : i+len(" bytes of HTML")]

View File

@@ -0,0 +1,177 @@
// Package build is the build pipeline: directive codegen, Tailwind, the wasm binary,
// the Solid bundle, and Go's JS shim.
//
// It used to be two packages — a `buildsteps` library and a `build` command that did
// nothing but loop over it and print. There was nothing for that split to be: the
// library had exactly two importers, both in this directory tree, and a package with no
// outside consumers is an import statement pretending to be a boundary. It is one
// package now.
//
// It cannot be `package main`, because the dev server imports it and Go will not let you
// import a main. So the cold build is a flag on the server rather than a second binary:
//
// go run ./server -build # build once and exit
// go run ./server # build, then watch and serve
//
// It is Go rather than a shell script for three reasons. The dev server has to call
// these steps on every save and cannot shell out to bash on Windows. Editors need to run
// them as tasks, and a task that only works on one platform is a task half the team
// cannot use. And the cold build and the watch build must be the SAME steps — the moment
// they are two scripts they drift, and the bug only shows up in whichever one you use
// less.
package build
import (
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"kjol/jsbundler"
)
// kjolRoot is the kjol Go module root, relative to the app. The Tailwind and codegen
// commands are run FROM there so the engine's dependencies resolve in kjol's own go.mod,
// and this app's stays lean.
const kjolRoot = "../.."
// Wwwroot is where every build artefact lands, and what the server serves.
const Wwwroot = "wwwroot"
// Step is one named stage. Naming them lets the cold build narrate itself without the
// watch loop having to care what they are called.
type Step struct {
Name string
Run func() ([]byte, error)
}
func Steps() []Step {
return []Step{
{"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", Codegen},
{"compiling Tailwind CSS -> wwwroot/app.css", Tailwind},
{"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", Wasm},
{"bundling the Solid app -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)", JS},
{"copying Go's wasm_exec.js shim into wwwroot/", Shim},
}
}
// All is the full build, in order. It is what the dev server runs on a code change.
//
// The returned bytes are the failing command's combined stdout+stderr, which the dev
// server puts straight into the browser's error overlay — so a compile error lands in
// front of you rather than in a terminal you were not looking at.
func All() ([]byte, error) {
for _, s := range Steps() {
if out, err := s.Run(); err != nil {
return out, err
}
}
return nil, nil
}
// Cold runs every step once, narrating as it goes, and exits non-zero on the first
// failure. This is `go run ./server -build`: CI, a cold start, or an editor's pre-launch
// task — anywhere there is nobody watching a browser overlay.
func Cold() {
log.SetFlags(0)
for _, s := range Steps() {
log.Println("==>", s.Name)
if out, err := s.Run(); err != nil {
os.Stderr.Write(out)
log.Fatalln("build failed:", err)
}
}
log.Println("==> Done. Serve it with: go run ./server")
}
// Codegen regenerates app/*.gen.go from the //gowasm: directives — the routes, the
// layouts, and the client stubs for server components. It runs FIRST: everything after
// it compiles the code it writes.
func Codegen() ([]byte, error) {
return exec.Command("go", "run", "kjol/cmd/wasmgen", "./app").CombinedOutput()
}
// Tailwind compiles css/app.css to wwwroot/app.css, scanning the webui kit, the lexer's
// palette and this app's Go markup for utility candidates.
//
// It scans .go files, which is the whole point of kjol's native engine: the markup is
// written in Go, so that is where the class names are. Nothing in this stage involves
// JavaScript.
//
// EVERY package whose class names have to exist has to be listed here. That is not a
// warning about carelessness — it is the failure mode: a package left off this list still
// compiles, still renders, and just comes out unstyled, because the class it asked for was
// never generated. kjol/lexer is here for exactly that reason; its whole output is class
// 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",
"-base", ".",
"webui/**/*.go",
"lexer/**/*.go",
"cmd/kjol-web/app/**/*.go",
"cmd/kjol-web/server/**/*.go",
)
cmd.Dir = kjolRoot
return cmd.CombinedOutput()
}
// Wasm compiles ./wasm to wwwroot/app.wasm.
func Wasm() ([]byte, error) {
cmd := exec.Command("go", "build", "-o", filepath.Join(Wwwroot, "app.wasm"), "./wasm")
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
return cmd.CombinedOutput()
}
// JS builds the Solid app: the SPA under /js, the server-rendered public pages, and
// their stylesheet. It is kjol/jsbundler — TSX compiled to Solid by a Go program, bundled
// by esbuild's Go API, styled by kjol/tw — run in-process rather than shelled out to, so
// a compile error comes back as a Go error and lands in the browser's error overlay like
// every other failure.
//
// It writes bundle.min.{js,css} and public.bundle.min.{js,css} into the SAME wwwroot as
// the wasm build. They never collide: different filenames, one static dir, one server.
//
// WebDir points at the shared tree — the kit, the vendored Solid runtime, the icon SVGs
// and the @theme scaffold all live there.
func JS() ([]byte, error) {
err := jsbundler.Build(jsbundler.Config{
AppFrontend: "frontend",
WebDir: filepath.Join(kjolRoot, "jsruntime"),
Output: Wwwroot,
GenTSDir: filepath.Join("frontend", "src", "ui", "generated"),
})
if err != nil {
return []byte(err.Error()), err
}
return nil, nil
}
// Shim copies Go's wasm_exec.js into wwwroot. It is the loader the browser needs to start
// a Go wasm binary, it ships with the toolchain, and it must match the compiler that
// produced the binary — so it is copied from GOROOT rather than vendored.
func Shim() ([]byte, error) {
out, err := exec.Command("go", "env", "GOROOT").Output()
if err != nil {
return out, err
}
goroot := strings.TrimSpace(string(out))
for _, src := range []string{
filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go >= 1.24
filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // Go <= 1.23
} {
b, err := os.ReadFile(src)
if err != nil {
continue
}
dst := filepath.Join(Wwwroot, "wasm_exec.js")
// The GOROOT copy is read-only, and so is the copy we made last time. Remove it
// first, or the write fails with a permission error that says nothing useful.
os.Remove(dst)
return nil, os.WriteFile(dst, b, 0o644)
}
return nil, os.ErrNotExist
}

View File

@@ -1,42 +0,0 @@
// Command build runs the example's full pre-compile step once: directive codegen,
// Tailwind, the wasm binary, and Go's JS shim.
//
// go run ./build # from go/cmd/kjol-web
//
// For day-to-day work run the dev server instead (`go run ./server`) — it performs
// these same steps on every save and hot-swaps the result into the browser. This
// command is for a cold build, CI, or an editor's pre-launch task.
package main
import (
"log"
"os"
"kjolweb/buildsteps"
)
func main() {
log.SetFlags(0)
steps := []struct {
name string
run func() ([]byte, error)
}{
{"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", buildsteps.Codegen},
{"compiling Tailwind CSS -> wwwroot/app.css", buildsteps.Tailwind},
{"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", buildsteps.Wasm},
{"bundling the Solid half -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)", buildsteps.JS},
{"copying Go's wasm_exec.js shim into wwwroot/", buildsteps.Shim},
}
for _, s := range steps {
log.Println("==>", s.name)
if out, err := s.run(); err != nil {
os.Stderr.Write(out)
log.Fatalln("build failed:", err)
}
}
log.Println("==> Done. Run the server with: go run ./server")
log.Println(" then open http://localhost:8085")
}

View File

@@ -1,133 +0,0 @@
// Package buildsteps is the example's build pipeline: directive codegen, Tailwind,
// the wasm binary, and Go's JS shim.
//
// It is Go, not a shell script, for three reasons. The dev server needs to call these
// steps on every save and cannot shell out to bash on Windows. Editors need to run
// them as tasks, and a task that only works on one platform is a task half the team
// cannot use. And the one-off build and the watch build must be the SAME steps — the
// moment they are two scripts, they drift, and the bug only shows up in whichever one
// you use less.
//
// Run the whole thing with `go run ./build`.
package buildsteps
import (
"os"
"os/exec"
"path/filepath"
"strings"
"kjol/jsbundler"
)
// kjolRoot is the kjol Go module root, relative to the example directory. The Tailwind
// and codegen commands are run FROM there so the engine's dependencies resolve in
// kjol's own go.mod, and this example's stays lean.
const kjolRoot = "../.."
// Wwwroot is where every build artefact lands, and what the dev server serves.
const Wwwroot = "wwwroot"
// Codegen regenerates app/*.gen.go from the //gowasm: directives — the routes, the
// layouts, and the client stubs for server components. It runs FIRST: everything after
// it compiles the code it writes.
func Codegen() ([]byte, error) {
return run("go", "run", "kjol/cmd/wasmgen", "./app")
}
// Tailwind compiles css/app.css to wwwroot/app.css, scanning the webui kit and this
// example's Go markup for utility candidates.
//
// It scans .go files, which is the whole point of kjol's native engine: the markup is
// written in Go, so that is where the class names are. There is no JS build here at
// all.
func Tailwind() ([]byte, error) {
cmd := exec.Command("go", "run", "./cmd/twcss",
"-entry", "cmd/kjol-web/css/app.css",
"-out", "cmd/kjol-web/wwwroot/app.css",
"-base", ".",
"webui/**/*.go",
"cmd/kjol-web/app/**/*.go",
"cmd/kjol-web/server/**/*.go",
)
cmd.Dir = kjolRoot
return cmd.CombinedOutput()
}
// Wasm compiles ./wasm to wwwroot/app.wasm.
func Wasm() ([]byte, error) {
cmd := exec.Command("go", "build", "-o", filepath.Join(Wwwroot, "app.wasm"), "./wasm")
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
return cmd.CombinedOutput()
}
// JS builds the OTHER half of the site: the Solid SPA under /js, the SSR'd public
// pages, and their stylesheet. It is kjol/jsbundler — TSX compiled to Solid by a Go
// program, bundled by esbuild's Go API, styled by kjol/tw — run in-process rather than
// shelled out to, so a compile error comes back as a Go error and lands in the dev
// server's browser overlay like every other failure.
//
// It writes bundle.min.{js,css} and public.bundle.min.{js,css} into the SAME wwwroot as
// the wasm build. The two halves never collide: different filenames, one static dir, one
// server.
//
// -web points at the shared tree, which is where the kit, the vendored Solid runtime,
// the icon SVGs and the @theme scaffold all live.
func JS() ([]byte, error) {
err := jsbundler.Build(jsbundler.Config{
AppFrontend: "frontend",
WebDir: filepath.Join(kjolRoot, "jsruntime"),
Output: Wwwroot,
GenTSDir: filepath.Join("frontend", "src", "ui", "generated"),
})
if err != nil {
return []byte(err.Error()), err
}
return nil, nil
}
// Shim copies Go's wasm_exec.js into wwwroot. It is the loader the browser needs to
// start a Go wasm binary, it ships with the toolchain, and it must match the compiler
// that produced the binary — so it is copied from GOROOT rather than vendored.
func Shim() ([]byte, error) {
out, err := exec.Command("go", "env", "GOROOT").Output()
if err != nil {
return out, err
}
goroot := strings.TrimSpace(string(out))
for _, src := range []string{
filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go >= 1.24
filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // Go <= 1.23
} {
b, err := os.ReadFile(src)
if err != nil {
continue
}
dst := filepath.Join(Wwwroot, "wasm_exec.js")
// The GOROOT copy is read-only, and so is the copy we made last time. Remove it
// first, or the write fails with a permission error that says nothing useful.
os.Remove(dst)
return nil, os.WriteFile(dst, b, 0o644)
}
return nil, os.ErrNotExist
}
// All is the full build, in order. It is what the dev server runs on a code change and
// what `go run ./build` runs once.
//
// Returned output is the failing command's combined stdout+stderr, which the dev server
// puts straight into the browser's error overlay — so a compile error lands in front of
// you rather than in a terminal you were not looking at.
func All() ([]byte, error) {
for _, step := range []func() ([]byte, error){Codegen, Tailwind, Wasm, JS, Shim} {
if out, err := step(); err != nil {
return out, err
}
}
return nil, nil
}
func run(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).CombinedOutput()
}

View File

@@ -77,19 +77,20 @@
@theme {
--radius-default: 0.375rem;
/* Sky: a cool, neutral blue. The page is mostly prose, code and tables, and the
accent's job is to mark the few things you can act on — a saturated indigo or a
primary blue competes with the content for attention instead of directing it. */
--color-primary: #0284c7; /* sky-600 — accent FILLS; they carry white text */
--color-primary-hover: #0369a1; /* sky-700 */
--color-primary-subtle: #f0f9ff; /* sky-50 — tinted panels, badges, callouts */
--color-primary-border: #bae6fd; /* sky-200 */
/* 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.) */
--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 */
--color-primary-border: #c5d1e2;
/* accent is for TEXT and icons. It is a separate token from primary because the two
have opposite constraints: a fill must be dark enough for white text on top of it,
and accent text must be readable ON the surface. One value cannot be both, and in
dark mode they diverge completely. */
--color-accent: #0369a1; /* sky-700 */
/* accent is for TEXT and icons, and it is the RED — which is why it is a separate token
from primary rather than a lighter shade of it. 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 not even the same hue: navy fills, red links. */
--color-accent: #9e1b32; /* dark red */
/* Surfaces, lines, ink — the kit's theme contract. */
--color-surface: #ffffff;
@@ -129,7 +130,7 @@
as graph paper you have to read the page through.
--------------------------------------------------------------------------- */
:root {
--grid-line: rgba(15, 23, 42, 0.055);
--grid-line: rgba(30, 58, 99, 0.06); /* the navy, at the edge of visible */
}
/* ---------------------------------------------------------------------------
@@ -156,12 +157,21 @@
--color-ink-muted: #9a9aa3;
--color-ink-faint: #71717a;
/* The fill stays put — white text has to remain readable on it — but accent TEXT has
to climb to stay readable on a near-black surface. This is exactly why they are two
tokens. */
--color-accent: #7dd3fc; /* sky-300 */
--color-primary-subtle: #0b2c3f;
--color-primary-border: #0e4966;
/* Both brand tokens move in the dark, and for different reasons.
The accent has to CLIMB: a dark red on a near-black surface is unreadable, so it goes
up to a light rose. It is still the flag's red, just the only version of it you can
read here.
The fill has to climb too — which the sky blue it replaced did not. Navy is dark
enough that on a #101013 page a navy button loses its edges and reads as a hole in the
surface. So it lifts to a steel blue that still carries white text (~7:1) and still
looks like a button. */
--color-accent: #f0a3ad;
--color-primary: #2b4f80;
--color-primary-hover: #37619b;
--color-primary-subtle: #182234;
--color-primary-border: #2c3e5c;
--color-text-heading: #f5f5f5;

View File

@@ -10,20 +10,25 @@
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
halves of ONE site rather than two demos that happen to share a domain. The
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.
--------------------------------------------------------------------------- */
@theme {
/* The accent. `primary` is the ONE semantic token the Solid kit actually
honours (bg-primary / text-primary / border-primary / bg-primary-hover);
everything else in the kit names a raw Tailwind neutral directly. That is a
real difference from the Go/WASM kit — which is themed end to end by tokens
and can therefore switch to dark by changing ten values — and the /js/theming
page says so out loud rather than pretending otherwise. */
--color-primary: #0284c7; /* sky-600 — fills; they carry white text */
--color-primary-hover: #0369a1; /* sky-700 */
/* The brand: navy and red, the Norwegian flag muted down. 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 than two navies that happen to be close.
primary FILLS (white text sits on it) and is the NAVY; accent is TEXT and icons and
is the RED (it has to be readable on the surface); primary-subtle/-border are the
tinted panel. */
--color-primary: #1e3a63; /* muted navy — fills; they carry white text */
--color-primary-hover: #16294a;
--color-primary-subtle: #eef2f8; /* a navy wash — tinted panels, callouts */
--color-primary-border: #c5d1e2;
--color-accent: #9e1b32; /* dark red — accent TEXT */
/* Lora, the same body face the Go/WASM section vendors. The woff2 files are
served out of wwwroot/fonts by the same server, so this section pays no
@@ -77,3 +82,21 @@
html {
font-family: var(--font-sans);
}
/* The dark values for the brand.
---------------------------------------------------------------------------
Both tokens move, for different reasons. The accent CLIMBS: a dark red on a near-black
surface is unreadable, so it goes up to a light rose — still the flag's red, just the
only version of it you can read here. The FILL climbs too, which the sky blue it
replaced did not have to: navy is dark enough that a navy button on a #101013 page loses
its edges and reads as a hole. And the tinted panel inverts outright, because a pale
wash on #101013 is not a tint, it is a white box.
Same values, same names, as the Go/WASM section's css/app.css. */
.dark {
--color-accent: #f0a3ad;
--color-primary: #2b4f80;
--color-primary-hover: #37619b;
--color-primary-subtle: #182234;
--color-primary-border: #2c3e5c;
}

View File

@@ -1,4 +1,4 @@
// SPA entry for the Kjol JS Web section (/js/*).
// SPA entry for the Kjøl JS Web section (/js/*).
//
// This file is .ts and NOT .tsx on purpose — it is not a style choice. The bundler
// resolves the SPA entry as src/app.ts (falling back to src/app.js) and nothing
@@ -6,13 +6,13 @@
// the pages it points at.
//
// The section is mounted under a base path rather than at the root: the front page
// and the whole /wasm section are served by the Go/WASM half of this site, which
// and the whole /wasm section are served by Kjøl Wasm Web, a different binary, which
// this bundle knows nothing about. `base: "/js"` keeps every route in here relative
// to that, so a link to "/kit" resolves to /js/kit and the two SPAs never fight
// over a URL.
// to that, so a link to "/components" resolves to /js/components and the two SPAs
// never fight over a URL.
//
// Crossing OUT of /js (to the front page, or into /wasm) is a plain <a href> and a
// real page load — the other half of the site is a different binary. That is the
// real page load — the rest of the site is a different binary. That is the
// honest cost of running two front-ends behind one server, and it is one navigation.
import { render, createComponent } from "solid-js/web";
@@ -21,19 +21,26 @@ import type { RouteDefinition } from "@solidjs/router";
import { Shell } from "./layout/Shell.tsx";
import { Overview } from "./pages/Overview.tsx";
import { Kit } from "./pages/Kit.tsx";
import { Forms } from "./pages/Forms.tsx";
import { Table } from "./pages/Table.tsx";
import { Theming } from "./pages/Theming.tsx";
import { Components } from "./pages/Components.tsx";
import { NotFound } from "./pages/NotFound.tsx";
// Routes as plain data: solid-router accepts RouteDefinition[] as `children`, which
// is what lets a JSX-free entry declare a full route tree.
//
// There are only two. The kit used to be spread across /kit, /forms, /table and
// /theming — a split along the lines of the SOURCE FILES rather than along anything a
// reader wants: a person looking for a date picker does not know, and should not have
// to guess, whether it was filed under forms or under overlays. It is one page now,
// and the sidebar jumps you down it.
const routes: RouteDefinition[] = [
{ path: "/", component: Overview },
{ path: "/kit", component: Kit },
{ path: "/forms", component: Forms },
{ path: "/table", component: Table },
{ path: "/theming", component: Theming },
{ path: "/components", component: Components },
// The catch-all, and it is not optional. The SERVER answers every /js/* URL with this
// shell — it has no idea which paths the router knows about — so without a fallback an
// unknown one renders the chrome around an empty <main>: a blank page, with a 200, and
// nothing to tell you why. Kjøl Wasm Web has the same catch-all for the same reason.
{ path: "*", component: NotFound },
];
const root = document.getElementById("app");

View File

@@ -0,0 +1,36 @@
// The component groups: one entry per section of /js/components, AND one line in the
// sidebar that jumps to it.
//
// Declaring them once, as data, is what keeps those two in step — the sidebar cannot
// offer a jump to a section that does not exist, and a section cannot go missing from
// the sidebar.
//
// This is the JS mirror of app/components.go's componentGroups(). The ids match, so the
// two sections of the site have the same shape and a reader crossing between them lands
// in the same place. The ICONS differ, and have to: this side names FontAwesome, the Go
// side names webui's own hand-drawn registry, and where the two have no glyph in common
// the names diverge.
export interface ComponentGroup {
id: string;
label: string;
icon: string;
}
export const COMPONENT_GROUPS: ComponentGroup[] = [
{ id: "buttons", label: "Buttons", icon: "check" },
{ id: "badges", label: "Badges & alerts", icon: "circle-info" },
{ id: "cards", label: "Cards & layout", icon: "table-columns" },
{ id: "icons", label: "Icons", icon: "star" },
{ id: "forms", label: "Forms & inputs", icon: "pen-to-square" },
{ id: "selects", label: "Selects & comboboxes", icon: "sliders" },
{ id: "toggles", label: "Toggles & signature", icon: "check" },
{ id: "dates", label: "Dates", icon: "calendar" },
{ id: "tables", label: "Tables", icon: "table" },
{ id: "overlays", label: "Overlays", icon: "copy" },
{ id: "feedback", label: "Toasts & tours", icon: "bell" },
{ id: "navigation", label: "Tabs & navigation", icon: "bars" },
{ id: "search", label: "Fuzzy search", icon: "magnifying-glass" },
{ id: "charts", label: "Charts", icon: "chart-column" },
{ id: "theming", label: "Theming", icon: "palette" },
];

View File

@@ -1,14 +1,22 @@
// The layers of kjol, as data.
// What kjøl is made of, as data.
//
// This is the JS mirror of app/layers.go on the Go/WASM side. The site has two
// front-ends built by two completely different pipelines, and the Layers menu has
// to be identical in both — so it is a LIST in each, not markup, and the two lists
// are the only thing that has to be kept in step.
// There are two kinds of thing here, and conflating them was the mistake this file used
// to make — one flat list called "the layers", holding both.
//
// (A shared source would be better than a mirrored one. There isn't one: the Go
// side compiles to WebAssembly and the JS side is bundled by esbuild, and nothing
// is upstream of both. Keeping it to a flat array of plain data is what makes the
// duplication survivable — you can diff the two by eye.)
// LAYERS are LANGUAGES. What kjøl is written in, and what it gives you in each:
// the Go base, the TypeScript kit, the C base, the Jai modules. A layer is
// a directory of code you can use on its own.
//
// COMPOSITIONS are FRAMEWORKS. What you get when the layers are assembled into
// something that does a job — the two web engines. A composition is not
// another language; it is a use of them.
//
// Kjøl Wasm Web is Go, all the way down. Kjøl JS Web is TypeScript compiled by a Go
// toolchain — two layers, one framework. Listing that beside "C" as though they were the
// same kind of noun told the reader nothing about either.
//
// This is the JS mirror of app/layers.go. The ids, the order and the taglines match; only
// the ICONS diverge, and they have to — see below.
export interface Layer {
name: string;
@@ -20,7 +28,7 @@ export interface Layer {
* The ONE field that does not match app/layers.go, and cannot: the two kits have
* different icon sets. This side names FontAwesome; the Go side names webui's own
* hand-drawn registry, which has no FontAwesome in it at all. Where the two have no
* glyph in common the names diverge (here `table-columns`, there `squares`).
* glyph in common the names diverge.
*
* Both sides fail loudly rather than quietly — a name neither registry knows renders
* an empty box, and app/icons_test.go fails the build over it.
@@ -28,45 +36,59 @@ export interface Layer {
icon: string;
}
export const LAYERS: Layer[] = [
/** Languages: what kjøl is written in. */
export const LANGUAGES: Layer[] = [
{
name: "Kjol Go",
name: "Go",
href: "/go",
tagline: "The server base: config, database, logging, HTTP, mail, validation.",
tagline: "The base: config, database, logging, HTTP, mail, validation — and both web engines.",
live: false,
icon: "server",
},
{
name: "Kjol Wasm Web",
href: "/wasm",
tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, no JS build.",
live: true,
name: "TypeScript",
href: "/ts",
tagline: "The Solid component kit, the vendored runtime, and the generic scaffolding apps import as @kjol/*.",
live: false,
icon: "code",
},
{
name: "Kjol JS Web",
href: "/js",
tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
live: true,
icon: "table-columns",
},
{
name: "Kjol C",
name: "C",
href: "/c",
tagline: "Arena allocator, strings, math, lexer, platform layer.",
live: false,
tagline: "Arena allocator, counted strings, math, a lexer, a platform layer — and a build system that is a C file.",
live: true,
icon: "bolt",
},
{
name: "Kjol Jai",
name: "Jai",
href: "/jai",
tagline: "Console rendering module. Early.",
tagline: "Console rendering. Early.",
live: false,
icon: "cube",
},
];
/** The layer the current path belongs to, or undefined on the front page. */
/** Compositions: what the languages are assembled into. */
export const COMPOSITIONS: Layer[] = [
{
name: "Kjøl Wasm Web",
href: "/wasm",
tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, and no JavaScript build at all.",
live: true,
icon: "code",
},
{
name: "Kjøl JS Web",
href: "/js",
tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
live: true,
icon: "table-columns",
},
];
/** The layer or composition the current path belongs to, or undefined on the front page. */
export function currentLayer(path: string): Layer | undefined {
return LAYERS.find((l) => path === l.href || path.startsWith(l.href + "/"));
return [...COMPOSITIONS, ...LANGUAGES].find(
(l) => path === l.href || path.startsWith(l.href + "/"),
);
}

View File

@@ -1,4 +1,4 @@
// The Kjol JS Web shell: top bar (wordmark + Layers menu), sidebar, content.
// The Kjøl JS Web shell: top bar (wordmark + Layers menu), sidebar, content.
//
// It is deliberately a near-copy of the Go/WASM section's AppLayout. Two front-ends,
// one site: if the chrome drifted, crossing from /wasm to /js would feel like leaving
@@ -7,11 +7,12 @@
// and the page should not betray that.
import { For, Show } from "solid-js";
import { A, useLocation } from "@solidjs/router";
import { A, useLocation, useNavigate } from "@solidjs/router";
import { Icon } from "@ui/Icons";
import { Menu, MenuTrigger, MenuContent, MenuLink, MenuSection } from "@ui/Menu";
import { Menu, MenuTrigger, MenuContent } from "@ui/Menu";
import { ThemeToggle, initTheme } from "@ui/Theme";
import { LAYERS } from "../layers.ts";
import { LANGUAGES, COMPOSITIONS, currentLayer, Layer } from "../layers.ts";
import { COMPONENT_GROUPS } from "../componentGroups.ts";
interface NavItem {
path: string;
@@ -22,68 +23,109 @@ interface NavItem {
// The section's own pages. Paths are relative to the router base (/js).
const NAV: NavItem[] = [
{ path: "/", label: "Overview", icon: "circle-info" },
{ path: "/kit", label: "Components", icon: "table-columns" },
{ path: "/forms", label: "Forms", icon: "pen-to-square" },
{ path: "/table", label: "AutoTable", icon: "table" },
{ path: "/theming", label: "Theming", icon: "palette" },
{ path: "/components", label: "Components", icon: "table-columns" },
];
// The Layers menu — the site's primary navigation. kjol is a stack of layers, and
// this is how you get from any one of them to any other. It is rendered from the
// LAYERS array so adding a layer is one object, not a nav edit in two front-ends.
// jumpTo scrolls a section into view, routing there first if we are somewhere else.
//
// Layers that are not `live` still appear. A menu that silently omits half the
// library teaches the reader that the library is half the size it is; showing them
// greyed, with the reason, is the more honest shape.
function LayersMenu() {
// A plain <a href="#forms"> would work if the reader were already on the components
// page, and would do nothing useful from anywhere else. The router's <A> is no good
// either — it would try to navigate to a route called "#forms".
//
// The queueMicrotask is not superstition: after navigate() the target section does not
// exist yet, because the page it lives on has not rendered. Scrolling on the next tick
// is the earliest moment the element is actually there to scroll to.
function jumpTo(navigate: (to: string) => void, onComponentsPage: boolean, id: string) {
const scroll = () => document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
if (onComponentsPage) {
scroll();
return;
}
navigate("/components");
queueMicrotask(scroll);
}
// The site's primary navigation, as TWO dropdowns: Layers (the languages) and
// Compositions (the frameworks assembled out of them — see layers.ts).
//
// Two menus, not one with two headings inside it. They answer different questions —
// "what is this written in" and "what can I read" — and a reader who wants the second
// should not have to scroll past the first to find it. The kit's single-open manager
// means opening one closes the other, so they behave like one control with two halves.
//
// Anything not `live` still appears, greyed, with the reason. A menu that silently omits
// half the library teaches the reader that the library is half the size it is.
//
// Same shape as the Go side (app/layers.go: layersMenu / compositionsMenu).
function Dropdown(props: { label: string; rows: Layer[] }) {
const location = useLocation();
const current = () => currentLayer(location.pathname);
return (
<Menu>
// bottom-end, because the triggers sit at the right-hand end of the bar and a 24rem
// panel hanging off the left edge of one would run past the window.
<Menu placement="bottom-end">
<MenuTrigger>
<span class="inline-flex items-center gap-1.5 rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink">
Layers
{props.label}
<Icon icon="chevron-down" size={11} class="text-ink-faint" />
</span>
</MenuTrigger>
<MenuContent class="w-96">
<MenuSection>
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">
The layers of kjol
</p>
<For each={LAYERS}>
{(layer) => (
<Show
when={layer.live}
fallback={
<div class="flex cursor-default flex-col gap-0.5 px-3 py-2 opacity-55">
<span class="flex items-center gap-2 text-sm font-medium text-ink-muted">
<Icon icon={layer.icon} size={14} class="shrink-0 text-ink-faint" />
{layer.name}
<span class="rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted">
reference
</span>
</span>
<span class="pl-6 text-xs text-ink-muted">{layer.tagline}</span>
</div>
}
>
{/* MenuLink is a real <a href> (not a router link), which is what a
cross-layer jump has to be: the other layers are served by a
different binary. */}
<MenuLink href={layer.href} icon={layer.icon}>
<span class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-ink">{layer.name}</span>
<span class="text-xs text-ink-muted">{layer.tagline}</span>
</span>
</MenuLink>
</Show>
)}
</For>
</MenuSection>
<For each={props.rows}>{(layer) => <LayerItem layer={layer} current={current()} />}</For>
</MenuContent>
</Menu>
);
}
// LayerItem is one row of the menu.
//
// The markup is deliberately the same shape and the same classes as app/layers.go's
// layerItem. Two front-ends, one menu: if they drifted, this is where it would show,
// because it is the one component a reader sees on both sides within seconds of each
// other.
function LayerItem(props: { layer: Layer; current?: Layer }) {
return (
<Show
when={props.layer.live}
fallback={
<div class="flex cursor-default flex-col gap-0.5 px-3 py-2 opacity-55">
<span class="flex items-center gap-2 text-sm font-medium text-ink-muted">
<Icon icon={props.layer.icon} size={14} class="shrink-0 text-ink-faint" />
{props.layer.name}
<span class="rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted">
reference
</span>
</span>
<span class="pl-6 text-xs text-ink-muted">{props.layer.tagline}</span>
</div>
}
>
{/* A plain <a href>, not MenuLink and not the router's <A>. Both of the
alternatives are wrong here: MenuLink lays its icon out BESIDE the whole
two-line block (so the tagline never lines up under the name, which is what
the Go menu does), and the router would try to handle the jump itself — but
the other side is served by a different binary, so it has to be a real
navigation. */}
<a
href={props.layer.href}
class={
"flex flex-col gap-0.5 px-3 py-2 no-underline hover:bg-surface-raised " +
(props.current?.href === props.layer.href ? "bg-primary-subtle" : "")
}
>
<span class="flex items-center gap-2 text-sm font-medium text-ink">
<Icon icon={props.layer.icon} size={14} class="shrink-0 text-accent" />
{props.layer.name}
</span>
<span class="pl-6 text-xs text-ink-muted">{props.layer.tagline}</span>
</a>
</Show>
);
}
function Wordmark() {
// A plain <a href>, not a router <A>: "/" is the front page, which belongs to the
// Go/WASM binary. Routing to it inside this SPA would resolve to /js and land you
@@ -94,7 +136,7 @@ function Wordmark() {
<Icon icon="sailboat" size={17} />
</span>
<span class="flex items-baseline gap-1.5">
<span class="text-lg font-semibold tracking-tight text-ink">Kjol JS Web</span>
<span class="text-lg font-semibold tracking-tight text-ink">Kjøl JS Web</span>
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
</span>
</a>
@@ -103,29 +145,32 @@ function Wordmark() {
function Sidebar() {
const location = useLocation();
// The router's pathname is absolute (/js/kit); NAV paths are base-relative (/kit).
const navigate = useNavigate();
// The router's pathname is absolute (/js/components); NAV paths are base-relative.
const active = (path: string) => location.pathname === "/js" + (path === "/" ? "" : path);
const onComponents = () => location.pathname === "/js/components";
// The same active treatment the Go sidebar uses (app/pages.go: sidebarLink) — a
// tinted panel and accent text, not a grey fill. Now that the Solid theme carries the
// primary-subtle / accent tokens, the two sidebars are the same sidebar.
const linkCls = (on: boolean) =>
on
? "flex items-center gap-2 rounded-default bg-primary-subtle px-2 py-1.5 text-sm font-medium text-accent no-underline"
: "flex items-center gap-2 rounded-default px-2 py-1.5 text-sm text-ink-soft no-underline hover:bg-surface-raised hover:text-ink";
return (
<aside class="sticky top-[3.75rem] hidden h-[calc(100vh-3.75rem)] w-56 shrink-0 overflow-y-auto py-10 lg:block">
<p class="px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">Kjol JS Web</p>
<p class="px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">Kjøl JS Web</p>
<ul class="mt-2 space-y-0.5">
<For each={NAV}>
{(item) => (
<li>
<A
href={item.path}
end={item.path === "/"}
class={
active(item.path)
? "flex items-center gap-2 rounded-default bg-surface-raised px-2 py-1.5 text-sm font-medium text-primary no-underline"
: "flex items-center gap-2 rounded-default px-2 py-1.5 text-sm text-ink-soft no-underline hover:bg-surface-muted hover:text-ink"
}
>
<A href={item.path} end={item.path === "/"} class={linkCls(active(item.path))}>
<Icon
icon={item.icon}
size={14}
class={active(item.path) ? "text-primary" : "text-ink-faint"}
class={active(item.path) ? "text-accent" : "text-ink-faint"}
/>
{item.label}
</A>
@@ -133,6 +178,36 @@ function Sidebar() {
)}
</For>
</ul>
{/* The component groups are not pages — they are anchors into the one components
page, and clicking one scrolls you there.
They are not highlighted by which section you have scrolled to. Finding that
out means measuring all fifteen of them on every scroll frame, and the only
way to act on the answer is a state write, which re-renders. Sixty times a
second, to move a highlight. The highlight is not worth the page. */}
<p class="mt-8 px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">
Components
</p>
<ul class="mt-2 space-y-0.5">
<For each={COMPONENT_GROUPS}>
{(g) => (
<li>
<a
href={"/js/components#" + g.id}
class={linkCls(false)}
onclick={(e) => {
e.preventDefault();
jumpTo(navigate, onComponents(), g.id);
}}
>
<Icon icon={g.icon} size={14} class="text-ink-faint" />
{g.label}
</a>
</li>
)}
</For>
</ul>
</aside>
);
}
@@ -155,7 +230,8 @@ export function Shell(props: { children?: any }) {
Docs
</span>
<div class="ml-auto flex items-center gap-2">
<LayersMenu />
<Dropdown label="Layers" rows={LANGUAGES} />
<Dropdown label="Compositions" rows={COMPOSITIONS} />
<a
href="/"
class="rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:bg-surface-raised hover:text-ink"

File diff suppressed because it is too large Load Diff

View File

@@ -1,220 +0,0 @@
// /js/forms — the form fields, and the masks that make them worth having.
import { createSignal } from "solid-js";
import {
FormInput,
FormLabel,
FormSelect,
FormTextarea,
FormCurrencyInput,
FormPercentInput,
FormPhoneInput,
FormEmailInput,
FormNumberInput,
FormCombobox,
FormMultiSelect,
FormFieldset,
US_STATES,
} from "@ui/Forms";
import { ToggleSwitch } from "@ui/ToggleSwitch";
import { ButtonUI, BUTTON_COLOR_PRIMARY } from "@ui/Buttons";
import { AlertBlue } from "@ui/Alerts";
import { isEmailValid } from "@ui/Validation";
import { Demo } from "../layout/Demo.tsx";
export function Forms() {
const [name, setName] = createSignal("");
const [email, setEmail] = createSignal("");
const [amount, setAmount] = createSignal("");
const [rate, setRate] = createSignal("");
const [phone, setPhone] = createSignal("");
const [term, setTerm] = createSignal("90");
const [state, setState] = createSignal("");
const [tags, setTags] = createSignal<string[]>(["cd"]);
const [notify, setNotify] = createSignal(true);
const [notes, setNotes] = createSignal("");
// The error is a derived value, not a second piece of state — so it cannot get
// out of step with the field it describes. Blank is not "invalid", it is unfilled.
const emailError = () => (email() && !isEmailValid(email()) ? "That is not an email address." : "");
return (
<div class="max-w-4xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Forms</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
The fields carry their own input masks. A currency field will not let you type a letter into
it; a percent field keeps one trailing symbol; a phone field formats as you go. That behaviour
is in the component, not in the page which is the only reason it is the same in every app.
</p>
<AlertBlue header="Handlers are lowercase" class="mt-6">
These are Solid components, so DOM handlers keep their DOM names:{" "}
<code class="font-mono">oninput</code>, <code class="font-mono">onchange</code>,{" "}
<code class="font-mono">onclick</code> not <code class="font-mono">onInput</code>. It is the
single most common thing to get wrong when writing against this kit.
</AlertBlue>
<Demo
title="Text, email, and validation"
code={`const emailError = () =>
email() && !isEmailValid(email()) ? "That is not an email address." : "";
<FormEmailInput
value={email}
oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()}
showIcon
/>`}
>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<FormLabel for="f-name">Name</FormLabel>
<FormInput
id="f-name"
placeholder="Ada Lovelace"
value={name}
oninput={(e) => setName(e.currentTarget.value)}
/>
</div>
<div>
<FormLabel for="f-email">Email</FormLabel>
<FormEmailInput
id="f-email"
placeholder="ada@example.com"
value={email}
oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()}
showIcon
/>
</div>
</div>
</Demo>
<Demo
title="Masked inputs"
code={`<FormCurrencyInput value={amount} oninput={…} />
<FormPercentInput value={rate} oninput={…} />
<FormPhoneInput value={phone} oninput={…} />
<FormNumberInput int unsigned />`}
>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<FormLabel for="f-amt">Amount</FormLabel>
<FormCurrencyInput
id="f-amt"
value={amount}
oninput={(e) => setAmount(e.currentTarget.value)}
/>
</div>
<div>
<FormLabel for="f-rate">Rate</FormLabel>
<FormPercentInput id="f-rate" value={rate} oninput={(e) => setRate(e.currentTarget.value)} />
</div>
<div>
<FormLabel for="f-phone">Phone</FormLabel>
<FormPhoneInput id="f-phone" value={phone} oninput={(e) => setPhone(e.currentTarget.value)} />
</div>
<div>
<FormLabel for="f-int">Whole number</FormLabel>
<FormNumberInput id="f-int" int unsigned placeholder="0" />
</div>
</div>
<p class="mt-4 text-sm text-ink-muted">
Try typing letters into any of them.
</p>
</Demo>
<Demo
title="Select, combobox, multi-select"
code={`<FormCombobox
options={US_STATES}
value={state}
onchange={setState}
searchable
placeholder="Pick a state"
/>
<FormMultiSelect options={…} value={tags} onchange={setTags} showSelectAll />`}
>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<FormLabel for="f-term">Term (plain select)</FormLabel>
<FormSelect id="f-term" value={term} onchange={(e) => setTerm(e.currentTarget.value)}>
<option value="90">90 day</option>
<option value="180">180 day</option>
<option value="365">1 year</option>
</FormSelect>
</div>
<div>
<FormLabel>State (searchable)</FormLabel>
<FormCombobox
options={US_STATES}
value={state}
onchange={setState}
searchable
placeholder="Pick a state"
/>
</div>
<div>
<FormLabel>Products (multi)</FormLabel>
<FormMultiSelect
options={[
{ value: "cd", label: "Certificates of deposit" },
{ value: "mm", label: "Money market" },
{ value: "sv", label: "Savings" },
{ value: "tr", label: "Treasuries" },
]}
value={tags}
onchange={setTags}
showSelectAll
searchable
/>
</div>
</div>
<p class="mt-4 text-sm text-ink-muted">
selected: <span class="font-mono text-ink">{tags().join(", ") || "—"}</span>
</p>
</Demo>
<Demo
title="Toggles and textareas"
code={`// there is no FormCheckbox — booleans are a ToggleSwitch
<ToggleSwitch
checked={notify}
onchange={setNotify}
label="Email me when a rate changes"
description="At most one message a day."
/>`}
>
<FormFieldset legend="Notifications">
<ToggleSwitch
checked={notify}
onchange={setNotify}
label="Email me when a rate changes"
description="At most one message a day."
/>
<div class="mt-4">
<FormLabel for="f-notes">Notes</FormLabel>
<FormTextarea
id="f-notes"
rows={3}
placeholder="Anything worth remembering about this account…"
value={notes}
oninput={(e) => setNotes(e.currentTarget.value)}
/>
</div>
</FormFieldset>
<div class="mt-5 flex items-center gap-3">
<ButtonUI color={BUTTON_COLOR_PRIMARY} disabled={!!emailError()}>
Save
</ButtonUI>
<span class="text-sm text-ink-muted">
{emailError() ? "Fix the email address first." : "The button disables itself off derived state."}
</span>
</div>
</Demo>
</div>
);
}

View File

@@ -1,199 +0,0 @@
// /js/kit — the components, running.
import { createSignal, For } from "solid-js";
import {
ButtonUI,
SegmentedButtons,
BUTTON_COLOR_PRIMARY,
BUTTON_COLOR_NEUTRAL,
BUTTON_COLOR_GREEN,
BUTTON_COLOR_RED,
BUTTON_COLOR_BLUE,
} from "@ui/Buttons";
import { Badge, BADGE_GREEN, BADGE_RED, BADGE_BLUE, BADGE_AMBER, BADGE_NEUTRAL } from "@ui/Badges";
import { AlertBlue, AlertGreen, AlertRed, AlertYellow } from "@ui/Alerts";
import { Card, CardHeader } from "@ui/Cards";
import { TabGroup } from "@ui/Tabs";
import { Modal, ConfirmModal } from "@ui/Modal";
import { Tooltip } from "@ui/Tooltips";
import { Icon } from "@ui/Icons";
import { Demo } from "../layout/Demo.tsx";
export function Kit() {
const [count, setCount] = createSignal(0);
const [seg, setSeg] = createSignal("day");
const [modalOpen, setModalOpen] = createSignal(false);
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [confirmed, setConfirmed] = createSignal(0);
return (
<div class="max-w-4xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Components</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
Every component below is the real one from{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">@ui/*</code>, imported
and rendered on this page. Nothing here is a picture of a component.
</p>
<Demo
title="Buttons"
code={`import { ButtonUI, BUTTON_COLOR_PRIMARY } from "@ui/Buttons";
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => setCount(count() + 1)}>
Clicked {count()} times
</ButtonUI>`}
>
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => setCount(count() + 1)}>
Clicked {count()} times
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL}>Neutral</ButtonUI>
<ButtonUI color={BUTTON_COLOR_GREEN}>Green</ButtonUI>
<ButtonUI color={BUTTON_COLOR_RED}>Red</ButtonUI>
<ButtonUI color={BUTTON_COLOR_BLUE} outline>
Outline
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL} small>
Small
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL} disabled>
Disabled
</ButtonUI>
</div>
</Demo>
<Demo
title="Segmented buttons"
code={`<SegmentedButtons
options={[{ value: "day", label: "Day" }, ...]}
value={seg}
onchange={setSeg}
/>`}
>
<div class="flex flex-col gap-3">
<SegmentedButtons
options={[
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
]}
value={seg}
onchange={setSeg}
/>
<p class="text-sm text-ink-muted">
selected: <span class="font-mono text-ink">{seg()}</span>
</p>
</div>
</Demo>
<Demo
title="Badges"
code={`<Badge color={BADGE_GREEN} pill>Active</Badge>`}
>
<div class="flex flex-wrap items-center gap-2">
<Badge color={BADGE_GREEN} pill>
Active
</Badge>
<Badge color={BADGE_RED} pill>
Overdue
</Badge>
<Badge color={BADGE_BLUE}>Info</Badge>
<Badge color={BADGE_AMBER}>Pending</Badge>
<Badge color={BADGE_NEUTRAL}>Draft</Badge>
</div>
</Demo>
<Demo
title="Alerts"
code={`<AlertGreen header="Saved">Your changes have been written.</AlertGreen>`}
>
<div class="space-y-3">
<AlertGreen header="Saved">Your changes have been written.</AlertGreen>
<AlertBlue header="Heads up">The rate table refreshes every fifteen minutes.</AlertBlue>
<AlertYellow header="Check this">Two rows are missing a maturity date.</AlertYellow>
<AlertRed header="Failed">The upload was rejected by the server.</AlertRed>
</div>
</Demo>
<Demo
title="Tabs"
code={`<TabGroup items={[{ title: "Summary", content: <p>…</p> }, …]} />`}
>
<TabGroup
items={[
{ title: "Summary", content: <p class="text-sm text-ink-soft">Three accounts, two of them funded.</p> },
{ title: "Activity", badge: 3, content: <p class="text-sm text-ink-soft">Three events since Tuesday.</p> },
{ title: "Settings", content: <p class="text-sm text-ink-soft">Nothing configurable yet.</p> },
]}
/>
</Demo>
<Demo
title="Modals"
code={`<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} header="A modal">
</Modal>
// no provider needed — it portals itself to document.body`}
>
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_NEUTRAL} onclick={() => setModalOpen(true)}>
Open modal
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_RED} outline onclick={() => setConfirmOpen(true)}>
Delete something
</ButtonUI>
<span class="text-sm text-ink-muted">confirmed {confirmed()} times</span>
</div>
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} header={<h3 class="text-lg font-semibold">A modal</h3>}>
<p class="text-sm leading-relaxed text-ink-soft">
It portals itself to <code class="font-mono">document.body</code>, so it escapes any
ancestor with <code class="font-mono">overflow: hidden</code> or a transform the two
things that silently clip a floating panel.
</p>
</Modal>
<ConfirmModal
isOpen={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={() => setConfirmed(confirmed() + 1)}
title="Delete this?"
message="This cannot be undone. (Nothing is actually deleted — this is a docs page.)"
confirmText="Delete"
/>
</Demo>
<Demo
title="Tooltips and icons"
code={`<Tooltip content="…"><Icon icon="circle-info" /></Tooltip>`}
>
<div class="flex flex-wrap items-center gap-5">
<For each={["circle-info", "calendar", "download", "print", "trash-can", "pen-to-square", "globe"]}>
{(name) => (
<Tooltip content={name}>
<span class="inline-flex cursor-help items-center gap-2 text-ink-soft">
<Icon icon={name} size={18} />
</span>
</Tooltip>
)}
</For>
</div>
<p class="mt-3 text-sm text-ink-muted">
Only the icons actually referenced in the source are bundled. The registry for this whole
site is a few dozen paths, not FontAwesome's 41.5 MB kit.
</p>
</Demo>
<Card class="mt-8">
<CardHeader>Not shown here</CardHeader>
<p class="text-sm leading-relaxed text-ink-soft">
The kit also carries a calendar, a date picker, popovers, an accordion, a signature pad, a
chart wrapper, a toast system, a guided-tour overlay and a fuzzy matcher. They are in{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">go/jsruntime/uikit</code>.
</p>
</Card>
</div>
);
}

View File

@@ -0,0 +1,41 @@
// The fallback for any /js/* URL the router does not know.
//
// The server cannot 404 these: it answers every /js/* path with the same SPA shell,
// because it has no idea which routes the bundle contains. So the router has to be the
// one to say so — and if it does not, an unknown URL renders the chrome around an empty
// <main>, which is a blank page with a 200 and no explanation.
//
// It names the paths that MOVED, because that is what a stale bookmark most likely wants:
// /js/kit, /js/forms, /js/table and /js/theming were four pages, and are now four
// sections of one.
import { useLocation, useNavigate } from "@solidjs/router";
import { ButtonUI, BUTTON_COLOR_PRIMARY, BUTTON_COLOR_LIGHT_NEUTRAL } from "@ui/Buttons";
export function NotFound() {
const location = useLocation();
const navigate = useNavigate();
return (
<div class="max-w-2xl py-10">
<h1 class="text-2xl font-semibold tracking-tight text-ink">Page not found</h1>
<p class="mt-2 leading-relaxed text-ink-soft">
No route matches <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">{location.pathname}</code>.
</p>
<p class="mt-3 leading-relaxed text-ink-soft">
The kit used to be spread across several pages <code class="font-mono">/js/kit</code>,{" "}
<code class="font-mono">/js/forms</code>, <code class="font-mono">/js/table</code>,{" "}
<code class="font-mono">/js/theming</code>. It is one page now, and they are sections of it.
</p>
<div class="mt-6 flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => navigate("/components")}>
Go to Components
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL} onclick={() => navigate("/")}>
Overview
</ButtonUI>
</div>
</div>
);
}

View File

@@ -1,13 +1,19 @@
// /js — what the JS layer is, and how it is built.
import { For } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { Card, CardHeader } from "@ui/Cards";
import { AlertBlue } from "@ui/Alerts";
import { CodeBox } from "@ui/General";
import { Icon } from "@ui/Icons";
import { COMPONENT_GROUPS } from "../componentGroups.ts";
export function Overview() {
const navigate = useNavigate();
return (
<div class="max-w-3xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<div>
<p class="text-xs 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>
@@ -48,7 +54,7 @@ export function Overview() {
/>
</div>
<CodeBox class="mt-5" code={"$ go run ./build\nGenerating FA icon subset...\nGenerating public routes...\nBundling JS + CSS...\n\nBundle Files Size Time\n-------------------------------------------------------\nbundle.min.js 84 241.3 KB 412ms\nbundle.min.css 1418 68.1 KB 31ms"} />
<CodeBox class="mt-5" code={"$ go run ./server -build\nGenerating FA icon subset...\n FA icons: 94 defs for 47 names\nGenerating public routes...\n /js/ssr Ssr (rendered, 2498 bytes)\nBundling JS + CSS...\n\nBundle Files Size Time\n-------------------------------------------------------\nbundle.min.js 1 1.4 MB 198ms\nbundle.min.css 2500 74.6 KB 30ms"} />
<AlertBlue header="One reactive instance, always" class="mt-8">
The single hardest invariant in this build is that there is exactly one copy of solid-js. Two
@@ -57,26 +63,38 @@ export function Overview() {
precisely this reason.
</AlertBlue>
<h2 class="mt-10 text-lg font-semibold text-ink">What is on the other pages</h2>
<div class="mt-4 grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader>Components</CardHeader>
<p class="text-sm text-ink-soft">
Buttons, badges, alerts, cards, tabs and menus rendered live, not screenshotted.
</p>
</Card>
<Card>
<CardHeader>Forms</CardHeader>
<p class="text-sm text-ink-soft">
Masked inputs, comboboxes, multi-select, toggles, and the validation helpers.
</p>
</Card>
<Card>
<CardHeader>AutoTable</CardHeader>
<p class="text-sm text-ink-soft">
Sorting, search, column management, CSV export from one array of column defs.
</p>
</Card>
<h2 class="mt-10 text-lg font-semibold text-ink">The kit</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
All of it is on one page. It used to be three Components, Forms, AutoTable which is a
split along the lines of the source files rather than along anything a reader wants: a person
looking for a date picker does not know, and should not have to guess, whether it was filed
under forms or under overlays.
</p>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<For each={COMPONENT_GROUPS}>
{(g) => (
<a
href={"/js/components#" + g.id}
class="group flex items-center gap-2 rounded-default border border-line bg-surface px-3 py-2.5 no-underline shadow-xs transition hover:border-primary hover:shadow-sm"
onclick={(e) => {
e.preventDefault();
navigate("/components");
queueMicrotask(() =>
document.getElementById(g.id)?.scrollIntoView({ behavior: "smooth", block: "start" }),
);
}}
>
<Icon icon={g.icon} size={14} class="shrink-0 text-primary" />
<span class="text-sm font-medium text-ink">{g.label}</span>
<Icon
icon="arrow-right"
size={11}
class="ml-auto text-ink-faint transition group-hover:text-primary"
/>
</a>
)}
</For>
</div>
</div>
);

View File

@@ -1,167 +0,0 @@
// /js/table — AutoTable, driven by an array of column definitions.
import AutoTable, {
AutoTableColumn,
AutoTableSearch,
AutoTableFilterFields,
TdLeft,
TdRight,
TdCenter,
COL_POS_LEFT,
COL_POS_RIGHT,
COL_POS_CENTER,
AUTOTABLE_SIZE_COMPACT,
} from "@ui/AutoTable";
import { Badge, BADGE_GREEN, BADGE_RED, BADGE_NEUTRAL } from "@ui/Badges";
import { AlertBlue } from "@ui/Alerts";
interface Institution {
name: string;
state: string;
term: string;
rate: number;
minimum: number;
status: "open" | "closed" | "waitlist";
}
// Static rows: the point of the page is the table, not where the rows came from.
// Swapping `data` for `url` is the only change needed to make it fetch, sort and
// paginate against a server instead.
const ROWS: Institution[] = [
{ name: "First Meridian Bank", state: "CA", term: "90 day", rate: 4.85, minimum: 1000, status: "open" },
{ name: "Harborline Credit Union", state: "WA", term: "180 day", rate: 5.1, minimum: 2500, status: "open" },
{ name: "Cascade Federal", state: "OR", term: "1 year", rate: 5.35, minimum: 500, status: "waitlist" },
{ name: "Ironwood Savings", state: "IL", term: "90 day", rate: 4.6, minimum: 10000, status: "closed" },
{ name: "Great Lakes Trust", state: "MI", term: "2 year", rate: 5.55, minimum: 1000, status: "open" },
{ name: "Sunbelt National", state: "TX", term: "180 day", rate: 4.95, minimum: 5000, status: "open" },
{ name: "Granite State Bank", state: "NH", term: "1 year", rate: 5.2, minimum: 2000, status: "waitlist" },
{ name: "Pacific Crest", state: "CA", term: "5 year", rate: 5.75, minimum: 25000, status: "open" },
{ name: "Copper Ridge Bank", state: "AZ", term: "90 day", rate: 4.4, minimum: 1000, status: "closed" },
{ name: "Bayou Community", state: "LA", term: "1 year", rate: 5.05, minimum: 1500, status: "open" },
{ name: "Northern Pine FCU", state: "MN", term: "2 year", rate: 5.45, minimum: 500, status: "open" },
{ name: "Chesapeake First", state: "MD", term: "180 day", rate: 4.75, minimum: 3000, status: "waitlist" },
];
// The whole table is this list. Sorting, column ordering, hiding, resizing and CSV
// export are all driven from it — there is no per-column wiring anywhere else.
const COLUMNS: AutoTableColumn[] = [
{ displayName: "Institution", sortable: true, sortIdentifier: "name", displayPosition: COL_POS_LEFT },
{ displayName: "State", sortable: true, sortIdentifier: "state", displayPosition: COL_POS_CENTER, toggleable: true },
{ displayName: "Term", sortable: true, sortIdentifier: "term", displayPosition: COL_POS_LEFT },
{
displayName: "Rate",
sortable: true,
sortIdentifier: "rate",
sortType: "numeric",
displayPosition: COL_POS_RIGHT,
csvValue: (i: Institution) => i.rate,
},
{
displayName: "Minimum",
sortable: true,
sortIdentifier: "minimum",
sortType: "money",
displayPosition: COL_POS_RIGHT,
toggleable: true,
csvValue: (i: Institution) => i.minimum,
},
{ displayName: "Status", displayPosition: COL_POS_CENTER, sortable: true, sortIdentifier: "status" },
];
const money = (n: number) => "$" + n.toLocaleString("en-US");
function StatusBadge(props: { status: Institution["status"] }) {
if (props.status === "open") return <Badge color={BADGE_GREEN} pill>open</Badge>;
if (props.status === "closed") return <Badge color={BADGE_RED} pill>closed</Badge>;
return <Badge color={BADGE_NEUTRAL} pill>waitlist</Badge>;
}
export function Table() {
return (
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">AutoTable</h1>
<p class="mt-4 max-w-3xl leading-relaxed text-ink-soft">
One array of column definitions produces sorting, per-column search, column reordering by
drag, column show/hide, column resizing, pagination and CSV export. The page below writes no
table markup only a <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">rowRenderer</code>{" "}
to say what a cell looks like.
</p>
<AlertBlue header="Try it" class="mt-6 max-w-3xl">
Sort by clicking a header. Drag a header to reorder. Use the toolbar to hide a column or
export what you are looking at. The column layout persists it is keyed to localStorage, so
it survives a reload.
</AlertBlue>
<div class="mt-8">
<AutoTable
data={ROWS}
columns={COLUMNS}
emptyMessage="No institutions match those filters."
options={{
size: AUTOTABLE_SIZE_COMPACT,
hover: true,
alternate: true,
surroundingBorder: true,
headerBorderY: true,
draggableColumns: true,
toggleColumns: true,
resizableColumns: true,
resetButton: true,
exportCSV: true,
exportFilename: "kjol-rates",
inlineToolbar: true,
columnOrderStorageKey: "kjolweb.table.order",
columnVisibilityStorageKey: "kjolweb.table.visible",
columnWidthStorageKey: "kjolweb.table.widths",
}}
searchFields={(ctx) => (
<AutoTableFilterFields>
<AutoTableSearch
label="Institution"
placeholder="Search by name…"
value={ctx.getSearchValue("name")}
onchange={(v) => ctx.setSearchValue("name", v)}
/>
<AutoTableSearch
label="State"
placeholder="CA"
value={ctx.getSearchValue("state")}
onchange={(v) => ctx.setSearchValue("state", v)}
/>
</AutoTableFilterFields>
)}
rowRenderer={(item: Institution) => (
<>
<TdLeft class="font-medium text-ink">{item.name}</TdLeft>
<TdCenter>{item.state}</TdCenter>
<TdLeft>{item.term}</TdLeft>
<TdRight class="font-mono">{item.rate.toFixed(2)}%</TdRight>
<TdRight class="font-mono">{money(item.minimum)}</TdRight>
<TdCenter>
<StatusBadge status={item.status} />
</TdCenter>
</>
)}
/>
</div>
<div class="mt-10 max-w-3xl">
<h2 class="text-lg font-semibold text-ink">Local rows, or a server</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
This table is passed <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">data</code>.
Give it <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">url</code> instead and
the same column list drives a server-side query the sort identifier becomes the sort key,
the search fields become query parameters, and pagination is handled for you. Nothing else
on the page changes.
</p>
<p class="mt-3 leading-relaxed text-ink-soft">
The Go/WASM layer has this same table, rewritten as Go returning a virtual DOM. Same
behaviour, no JavaScript which is the whole argument the other half of this site is
making.
</p>
</div>
</div>
);
}

View File

@@ -1,168 +0,0 @@
// /js/theming — how the kit is themed, and the switch that proves it.
import { AlertBlue, AlertGreen } from "@ui/Alerts";
import { Card, CardHeader } from "@ui/Cards";
import { ButtonUI, BUTTON_COLOR_PRIMARY, BUTTON_COLOR_NEUTRAL, BUTTON_COLOR_WHITE } from "@ui/Buttons";
import { Badge, BADGE_GREEN, BADGE_NEUTRAL } from "@ui/Badges";
import { CodeBox } from "@ui/General";
import { ThemeToggle, useTheme } from "@ui/Theme";
import { Demo } from "../layout/Demo.tsx";
// The swatch class is written out in full, not built as "bg-" + name. Tailwind finds
// the classes it must compile by SCANNING THE SOURCE for literal strings — a
// concatenation is invisible to it, and every swatch here would come out colourless.
// It is the one thing about a utility CSS engine you cannot forget.
const TOKENS: { swatch: string; name: string; role: string }[] = [
{ swatch: "bg-surface", name: "surface", role: "the page" },
{ swatch: "bg-surface-muted", name: "surface-muted", role: "a recessed strip" },
{ swatch: "bg-surface-raised", name: "surface-raised", role: "a panel, a hover" },
{ swatch: "bg-surface-strong", name: "surface-strong", role: "a track, a divider fill" },
{ swatch: "bg-line", name: "line", role: "an ordinary border" },
{ swatch: "bg-line-strong", name: "line-strong", role: "a border that has to be seen" },
{ swatch: "bg-ink", name: "ink", role: "body text, headings" },
{ swatch: "bg-ink-soft", name: "ink-soft", role: "secondary text" },
{ swatch: "bg-ink-muted", name: "ink-muted", role: "captions, labels" },
{ swatch: "bg-ink-faint", name: "ink-faint", role: "placeholders, disabled" },
];
export function Theming() {
const { isDark, mode } = useTheme();
return (
<div class="max-w-3xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Theming</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
No component in this kit names a colour. They say{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">bg-surface</code>,{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">text-ink</code>,{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">border-line</code> and
what those mean is decided in one place. That is the whole of the theme system, and it is why
dark mode is a rule that re-points ten variables rather than a{" "}
<code class="font-mono">dark:</code> variant on four hundred class strings.
</p>
<Demo
title="The switch"
code={`// styles/theme.css
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-surface: #ffffff;
--color-ink: #171717;
--color-line: #e5e5e5;
}
.dark {
--color-surface: #101013; /* not black: black makes every border vanish */
--color-ink: #f2f2f3;
--color-line: #2a2a30;
}`}
>
<div class="flex flex-wrap items-center gap-4">
<ThemeToggle />
<div class="text-sm text-ink-soft">
currently <span class="font-mono text-ink">{isDark() ? "dark" : "light"}</span>, because
you asked for <span class="font-mono text-ink">{mode()}</span>
</div>
</div>
<p class="mt-4 text-sm leading-relaxed text-ink-muted">
Press it. Every component on every page of this section moves none of them were told.
Your choice is remembered, and it is the <em>same</em> choice the Go/WASM section reads:
both halves of this site share one localStorage key, so the theme survives crossing between
two entirely different front-ends.
</p>
</Demo>
<h2 class="mt-12 text-lg font-semibold text-ink">The contract</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
These are the tokens a component is allowed to name. Each swatch below is drawn with the token
itself, so this table is not a picture of the theme it <em>is</em> the theme, and it repaints
when you press the switch.
</p>
<div class="mt-5 overflow-hidden rounded-default border border-line">
{TOKENS.map((t, i) => (
<div
class={
"flex items-center gap-4 px-4 py-2.5 " +
(i > 0 ? "border-t border-line" : "")
}
>
<span class={"h-7 w-7 shrink-0 rounded border border-line-strong " + t.swatch} />
<code class="w-40 shrink-0 font-mono text-[13px] text-ink">{t.name}</code>
<span class="text-sm text-ink-muted">{t.role}</span>
</div>
))}
</div>
<AlertBlue header="Two kits, one vocabulary" class="mt-8">
The Go/WASM kit uses these exact token names. A designer changes{" "}
<code class="font-mono">surface</code> once and both halves of the site move together even
though one is Solid compiled by esbuild and the other is Go compiled to WebAssembly.
</AlertBlue>
<h2 class="mt-12 text-lg font-semibold text-ink">Where a variant is still needed</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
Two things a re-pointed token cannot fix, so they are the only places the kit still carries a{" "}
<code class="font-mono">dark:</code> variant.
</p>
<div class="mt-5 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>Coloured tints</CardHeader>
<p class="text-sm leading-relaxed text-ink-soft">
A <code class="font-mono">red-50</code> wash is invisible on a near-black surface. An
alert's tint has to become a deep, transparent one a different colour, not a
different value of the same one.
</p>
</Card>
<Card>
<CardHeader>Fills that invert</CardHeader>
<p class="text-sm leading-relaxed text-ink-soft">
The neutral button is dark on a light page and light on a dark one so its label must
invert with it. <code class="font-mono">text-white</code> would disappear the moment
the fill went pale. Hence three tokens, not one.
</p>
</Card>
</div>
<Demo
title="The buttons that had to think about it"
code={`// the fill and its text move together, or the label vanishes
"neutral": "bg-fill-neutral text-on-fill-neutral hover:bg-fill-neutral-hover",
// a chromatic fill is dark enough for white text in BOTH themes — leave it
"red": "bg-red-700 text-white hover:bg-red-800",`}
>
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_NEUTRAL}>Neutral (inverts)</ButtonUI>
<ButtonUI color={BUTTON_COLOR_WHITE}>White (a surface)</ButtonUI>
<ButtonUI color={BUTTON_COLOR_PRIMARY}>Primary (a fill)</ButtonUI>
<Badge color={BADGE_GREEN} pill>solid</Badge>
<Badge color={BADGE_NEUTRAL} pill>fills stay put</Badge>
</div>
</Demo>
<AlertGreen header="No flash" class="mt-8">
The theme class is applied by a ten-line script in the document head, before the stylesheet and
before any markup. The server cannot read localStorage, so it cannot know which theme to send;
if the class waited for the bundle, every dark-mode reader would get a white page and then have
it snatched away. It is the only hand-written JavaScript on the Go/WASM side of this site.
</AlertGreen>
<CodeBox
class="mt-5"
code={`<head>
<script>(function(){try{
var m = localStorage.getItem("kjol-theme");
var dark = m === "dark" || (!m && matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
}catch(e){}})();</script>
<link rel="stylesheet" href="/bundle.min.css" />
</head>`}
/>
</div>
);
}

View File

@@ -31,7 +31,7 @@ export function PublicLayout(props: { currentPath: string; children?: JSXElement
</svg>
</span>
<span class="flex items-baseline gap-1.5">
<span class="text-lg font-semibold tracking-tight text-ink">Kjol JS Web</span>
<span class="text-lg font-semibold tracking-tight text-ink">Kjøl JS Web</span>
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
</span>
</a>
@@ -65,7 +65,7 @@ export function PublicLayout(props: { currentPath: string; children?: JSXElement
<footer class="mx-auto max-w-2xl px-4 pb-14">
<p class="text-sm text-ink-faint">
Kjol JS Web is one layer of kjol a shared base layer. kjol is Norwegian for keel.
Kjøl JS Web is one layer of kjol a shared base layer. kjol is Norwegian for keel.
</p>
</footer>
</div>

View File

@@ -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">Kjol JS Web</p>
<p class="text-xs 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>
@@ -69,7 +69,7 @@ export function Ssr() {
<p class="mt-8 text-sm text-ink-muted">
<a href="/js" class="text-primary underline underline-offset-4">
Back to Kjol JS Web
Back to Kjøl JS Web
</a>
</p>
</div>

View File

@@ -22,7 +22,7 @@ export const publicPages: PublicPageDef[] = [
path: "/js/ssr",
module: "pages/public/Ssr.tsx",
component: "Ssr",
title: "Server-rendered — Kjol JS Web",
title: "Server-rendered — Kjøl JS Web",
// dynamic: the server may inject data for this route at request time, so bake
// the render bundle too, not just the static skeleton.
dynamic: true,

View File

@@ -13,5 +13,5 @@ export const publicRoutes: Record<string, () => JSXElement> = {
// <title> for each public route, applied by the client router on navigation
// (the first load gets its title from the server-rendered shell).
export const publicTitles: Record<string, string> = {
"/js/ssr": "Server-rendered — Kjol JS Web",
"/js/ssr": "Server-rendered — Kjøl JS Web",
};

View File

@@ -0,0 +1,589 @@
/*!
* @kurkle/color v0.3.4
* https://github.com/kurkle/color#readme
* (c) 2024 Jukka Kurkela
* Released under the MIT License
*/
function round(v) {
return v + 0.5 | 0;
}
const lim = (v, l, h) => Math.max(Math.min(v, h), l);
function p2b(v) {
return lim(round(v * 2.55), 0, 255);
}
function b2p(v) {
return lim(round(v / 2.55), 0, 100);
}
function n2b(v) {
return lim(round(v * 255), 0, 255);
}
function b2n(v) {
return lim(round(v / 2.55) / 100, 0, 1);
}
function n2p(v) {
return lim(round(v * 100), 0, 100);
}
const map$1 = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, A: 10, B: 11, C: 12, D: 13, E: 14, F: 15, a: 10, b: 11, c: 12, d: 13, e: 14, f: 15};
const hex = [...'0123456789ABCDEF'];
const h1 = b => hex[b & 0xF];
const h2 = b => hex[(b & 0xF0) >> 4] + hex[b & 0xF];
const eq = b => ((b & 0xF0) >> 4) === (b & 0xF);
const isShort = v => eq(v.r) && eq(v.g) && eq(v.b) && eq(v.a);
function hexParse(str) {
var len = str.length;
var ret;
if (str[0] === '#') {
if (len === 4 || len === 5) {
ret = {
r: 255 & map$1[str[1]] * 17,
g: 255 & map$1[str[2]] * 17,
b: 255 & map$1[str[3]] * 17,
a: len === 5 ? map$1[str[4]] * 17 : 255
};
} else if (len === 7 || len === 9) {
ret = {
r: map$1[str[1]] << 4 | map$1[str[2]],
g: map$1[str[3]] << 4 | map$1[str[4]],
b: map$1[str[5]] << 4 | map$1[str[6]],
a: len === 9 ? (map$1[str[7]] << 4 | map$1[str[8]]) : 255
};
}
}
return ret;
}
const alpha = (a, f) => a < 255 ? f(a) : '';
function hexString(v) {
var f = isShort(v) ? h1 : h2;
return v
? '#' + f(v.r) + f(v.g) + f(v.b) + alpha(v.a, f)
: undefined;
}
const HUE_RE = /^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;
function hsl2rgbn(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = (n, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return [f(0), f(8), f(4)];
}
function hsv2rgbn(h, s, v) {
const f = (n, k = (n + h / 60) % 6) => v - v * s * Math.max(Math.min(k, 4 - k, 1), 0);
return [f(5), f(3), f(1)];
}
function hwb2rgbn(h, w, b) {
const rgb = hsl2rgbn(h, 1, 0.5);
let i;
if (w + b > 1) {
i = 1 / (w + b);
w *= i;
b *= i;
}
for (i = 0; i < 3; i++) {
rgb[i] *= 1 - w - b;
rgb[i] += w;
}
return rgb;
}
function hueValue(r, g, b, d, max) {
if (r === max) {
return ((g - b) / d) + (g < b ? 6 : 0);
}
if (g === max) {
return (b - r) / d + 2;
}
return (r - g) / d + 4;
}
function rgb2hsl(v) {
const range = 255;
const r = v.r / range;
const g = v.g / range;
const b = v.b / range;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h, s, d;
if (max !== min) {
d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
h = hueValue(r, g, b, d, max);
h = h * 60 + 0.5;
}
return [h | 0, s || 0, l];
}
function calln(f, a, b, c) {
return (
Array.isArray(a)
? f(a[0], a[1], a[2])
: f(a, b, c)
).map(n2b);
}
function hsl2rgb(h, s, l) {
return calln(hsl2rgbn, h, s, l);
}
function hwb2rgb(h, w, b) {
return calln(hwb2rgbn, h, w, b);
}
function hsv2rgb(h, s, v) {
return calln(hsv2rgbn, h, s, v);
}
function hue(h) {
return (h % 360 + 360) % 360;
}
function hueParse(str) {
const m = HUE_RE.exec(str);
let a = 255;
let v;
if (!m) {
return;
}
if (m[5] !== v) {
a = m[6] ? p2b(+m[5]) : n2b(+m[5]);
}
const h = hue(+m[2]);
const p1 = +m[3] / 100;
const p2 = +m[4] / 100;
if (m[1] === 'hwb') {
v = hwb2rgb(h, p1, p2);
} else if (m[1] === 'hsv') {
v = hsv2rgb(h, p1, p2);
} else {
v = hsl2rgb(h, p1, p2);
}
return {
r: v[0],
g: v[1],
b: v[2],
a: a
};
}
function rotate(v, deg) {
var h = rgb2hsl(v);
h[0] = hue(h[0] + deg);
h = hsl2rgb(h);
v.r = h[0];
v.g = h[1];
v.b = h[2];
}
function hslString(v) {
if (!v) {
return;
}
const a = rgb2hsl(v);
const h = a[0];
const s = n2p(a[1]);
const l = n2p(a[2]);
return v.a < 255
? `hsla(${h}, ${s}%, ${l}%, ${b2n(v.a)})`
: `hsl(${h}, ${s}%, ${l}%)`;
}
const map = {
x: 'dark',
Z: 'light',
Y: 're',
X: 'blu',
W: 'gr',
V: 'medium',
U: 'slate',
A: 'ee',
T: 'ol',
S: 'or',
B: 'ra',
C: 'lateg',
D: 'ights',
R: 'in',
Q: 'turquois',
E: 'hi',
P: 'ro',
O: 'al',
N: 'le',
M: 'de',
L: 'yello',
F: 'en',
K: 'ch',
G: 'arks',
H: 'ea',
I: 'ightg',
J: 'wh'
};
const names$1 = {
OiceXe: 'f0f8ff',
antiquewEte: 'faebd7',
aqua: 'ffff',
aquamarRe: '7fffd4',
azuY: 'f0ffff',
beige: 'f5f5dc',
bisque: 'ffe4c4',
black: '0',
blanKedOmond: 'ffebcd',
Xe: 'ff',
XeviTet: '8a2be2',
bPwn: 'a52a2a',
burlywood: 'deb887',
caMtXe: '5f9ea0',
KartYuse: '7fff00',
KocTate: 'd2691e',
cSO: 'ff7f50',
cSnflowerXe: '6495ed',
cSnsilk: 'fff8dc',
crimson: 'dc143c',
cyan: 'ffff',
xXe: '8b',
xcyan: '8b8b',
xgTMnPd: 'b8860b',
xWay: 'a9a9a9',
xgYF: '6400',
xgYy: 'a9a9a9',
xkhaki: 'bdb76b',
xmagFta: '8b008b',
xTivegYF: '556b2f',
xSange: 'ff8c00',
xScEd: '9932cc',
xYd: '8b0000',
xsOmon: 'e9967a',
xsHgYF: '8fbc8f',
xUXe: '483d8b',
xUWay: '2f4f4f',
xUgYy: '2f4f4f',
xQe: 'ced1',
xviTet: '9400d3',
dAppRk: 'ff1493',
dApskyXe: 'bfff',
dimWay: '696969',
dimgYy: '696969',
dodgerXe: '1e90ff',
fiYbrick: 'b22222',
flSOwEte: 'fffaf0',
foYstWAn: '228b22',
fuKsia: 'ff00ff',
gaRsbSo: 'dcdcdc',
ghostwEte: 'f8f8ff',
gTd: 'ffd700',
gTMnPd: 'daa520',
Way: '808080',
gYF: '8000',
gYFLw: 'adff2f',
gYy: '808080',
honeyMw: 'f0fff0',
hotpRk: 'ff69b4',
RdianYd: 'cd5c5c',
Rdigo: '4b0082',
ivSy: 'fffff0',
khaki: 'f0e68c',
lavFMr: 'e6e6fa',
lavFMrXsh: 'fff0f5',
lawngYF: '7cfc00',
NmoncEffon: 'fffacd',
ZXe: 'add8e6',
ZcSO: 'f08080',
Zcyan: 'e0ffff',
ZgTMnPdLw: 'fafad2',
ZWay: 'd3d3d3',
ZgYF: '90ee90',
ZgYy: 'd3d3d3',
ZpRk: 'ffb6c1',
ZsOmon: 'ffa07a',
ZsHgYF: '20b2aa',
ZskyXe: '87cefa',
ZUWay: '778899',
ZUgYy: '778899',
ZstAlXe: 'b0c4de',
ZLw: 'ffffe0',
lime: 'ff00',
limegYF: '32cd32',
lRF: 'faf0e6',
magFta: 'ff00ff',
maPon: '800000',
VaquamarRe: '66cdaa',
VXe: 'cd',
VScEd: 'ba55d3',
VpurpN: '9370db',
VsHgYF: '3cb371',
VUXe: '7b68ee',
VsprRggYF: 'fa9a',
VQe: '48d1cc',
VviTetYd: 'c71585',
midnightXe: '191970',
mRtcYam: 'f5fffa',
mistyPse: 'ffe4e1',
moccasR: 'ffe4b5',
navajowEte: 'ffdead',
navy: '80',
Tdlace: 'fdf5e6',
Tive: '808000',
TivedBb: '6b8e23',
Sange: 'ffa500',
SangeYd: 'ff4500',
ScEd: 'da70d6',
pOegTMnPd: 'eee8aa',
pOegYF: '98fb98',
pOeQe: 'afeeee',
pOeviTetYd: 'db7093',
papayawEp: 'ffefd5',
pHKpuff: 'ffdab9',
peru: 'cd853f',
pRk: 'ffc0cb',
plum: 'dda0dd',
powMrXe: 'b0e0e6',
purpN: '800080',
YbeccapurpN: '663399',
Yd: 'ff0000',
Psybrown: 'bc8f8f',
PyOXe: '4169e1',
saddNbPwn: '8b4513',
sOmon: 'fa8072',
sandybPwn: 'f4a460',
sHgYF: '2e8b57',
sHshell: 'fff5ee',
siFna: 'a0522d',
silver: 'c0c0c0',
skyXe: '87ceeb',
UXe: '6a5acd',
UWay: '708090',
UgYy: '708090',
snow: 'fffafa',
sprRggYF: 'ff7f',
stAlXe: '4682b4',
tan: 'd2b48c',
teO: '8080',
tEstN: 'd8bfd8',
tomato: 'ff6347',
Qe: '40e0d0',
viTet: 'ee82ee',
JHt: 'f5deb3',
wEte: 'ffffff',
wEtesmoke: 'f5f5f5',
Lw: 'ffff00',
LwgYF: '9acd32'
};
function unpack() {
const unpacked = {};
const keys = Object.keys(names$1);
const tkeys = Object.keys(map);
let i, j, k, ok, nk;
for (i = 0; i < keys.length; i++) {
ok = nk = keys[i];
for (j = 0; j < tkeys.length; j++) {
k = tkeys[j];
nk = nk.replace(k, map[k]);
}
k = parseInt(names$1[ok], 16);
unpacked[nk] = [k >> 16 & 0xFF, k >> 8 & 0xFF, k & 0xFF];
}
return unpacked;
}
let names;
function nameParse(str) {
if (!names) {
names = unpack();
names.transparent = [0, 0, 0, 0];
}
const a = names[str.toLowerCase()];
return a && {
r: a[0],
g: a[1],
b: a[2],
a: a.length === 4 ? a[3] : 255
};
}
const RGB_RE = /^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;
function rgbParse(str) {
const m = RGB_RE.exec(str);
let a = 255;
let r, g, b;
if (!m) {
return;
}
if (m[7] !== r) {
const v = +m[7];
a = m[8] ? p2b(v) : lim(v * 255, 0, 255);
}
r = +m[1];
g = +m[3];
b = +m[5];
r = 255 & (m[2] ? p2b(r) : lim(r, 0, 255));
g = 255 & (m[4] ? p2b(g) : lim(g, 0, 255));
b = 255 & (m[6] ? p2b(b) : lim(b, 0, 255));
return {
r: r,
g: g,
b: b,
a: a
};
}
function rgbString(v) {
return v && (
v.a < 255
? `rgba(${v.r}, ${v.g}, ${v.b}, ${b2n(v.a)})`
: `rgb(${v.r}, ${v.g}, ${v.b})`
);
}
const to = v => v <= 0.0031308 ? v * 12.92 : Math.pow(v, 1.0 / 2.4) * 1.055 - 0.055;
const from = v => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
function interpolate(rgb1, rgb2, t) {
const r = from(b2n(rgb1.r));
const g = from(b2n(rgb1.g));
const b = from(b2n(rgb1.b));
return {
r: n2b(to(r + t * (from(b2n(rgb2.r)) - r))),
g: n2b(to(g + t * (from(b2n(rgb2.g)) - g))),
b: n2b(to(b + t * (from(b2n(rgb2.b)) - b))),
a: rgb1.a + t * (rgb2.a - rgb1.a)
};
}
function modHSL(v, i, ratio) {
if (v) {
let tmp = rgb2hsl(v);
tmp[i] = Math.max(0, Math.min(tmp[i] + tmp[i] * ratio, i === 0 ? 360 : 1));
tmp = hsl2rgb(tmp);
v.r = tmp[0];
v.g = tmp[1];
v.b = tmp[2];
}
}
function clone(v, proto) {
return v ? Object.assign(proto || {}, v) : v;
}
function fromObject(input) {
var v = {r: 0, g: 0, b: 0, a: 255};
if (Array.isArray(input)) {
if (input.length >= 3) {
v = {r: input[0], g: input[1], b: input[2], a: 255};
if (input.length > 3) {
v.a = n2b(input[3]);
}
}
} else {
v = clone(input, {r: 0, g: 0, b: 0, a: 1});
v.a = n2b(v.a);
}
return v;
}
function functionParse(str) {
if (str.charAt(0) === 'r') {
return rgbParse(str);
}
return hueParse(str);
}
class Color {
constructor(input) {
if (input instanceof Color) {
return input;
}
const type = typeof input;
let v;
if (type === 'object') {
v = fromObject(input);
} else if (type === 'string') {
v = hexParse(input) || nameParse(input) || functionParse(input);
}
this._rgb = v;
this._valid = !!v;
}
get valid() {
return this._valid;
}
get rgb() {
var v = clone(this._rgb);
if (v) {
v.a = b2n(v.a);
}
return v;
}
set rgb(obj) {
this._rgb = fromObject(obj);
}
rgbString() {
return this._valid ? rgbString(this._rgb) : undefined;
}
hexString() {
return this._valid ? hexString(this._rgb) : undefined;
}
hslString() {
return this._valid ? hslString(this._rgb) : undefined;
}
mix(color, weight) {
if (color) {
const c1 = this.rgb;
const c2 = color.rgb;
let w2;
const p = weight === w2 ? 0.5 : weight;
const w = 2 * p - 1;
const a = c1.a - c2.a;
const w1 = ((w * a === -1 ? w : (w + a) / (1 + w * a)) + 1) / 2.0;
w2 = 1 - w1;
c1.r = 0xFF & w1 * c1.r + w2 * c2.r + 0.5;
c1.g = 0xFF & w1 * c1.g + w2 * c2.g + 0.5;
c1.b = 0xFF & w1 * c1.b + w2 * c2.b + 0.5;
c1.a = p * c1.a + (1 - p) * c2.a;
this.rgb = c1;
}
return this;
}
interpolate(color, t) {
if (color) {
this._rgb = interpolate(this._rgb, color._rgb, t);
}
return this;
}
clone() {
return new Color(this.rgb);
}
alpha(a) {
this._rgb.a = n2b(a);
return this;
}
clearer(ratio) {
const rgb = this._rgb;
rgb.a *= 1 - ratio;
return this;
}
greyscale() {
const rgb = this._rgb;
const val = round(rgb.r * 0.3 + rgb.g * 0.59 + rgb.b * 0.11);
rgb.r = rgb.g = rgb.b = val;
return this;
}
opaquer(ratio) {
const rgb = this._rgb;
rgb.a *= 1 + ratio;
return this;
}
negate() {
const v = this._rgb;
v.r = 255 - v.r;
v.g = 255 - v.g;
v.b = 255 - v.b;
return this;
}
lighten(ratio) {
modHSL(this._rgb, 2, ratio);
return this;
}
darken(ratio) {
modHSL(this._rgb, 2, -ratio);
return this;
}
saturate(ratio) {
modHSL(this._rgb, 1, ratio);
return this;
}
desaturate(ratio) {
modHSL(this._rgb, 1, -ratio);
return this;
}
rotate(deg) {
rotate(this._rgb, deg);
return this;
}
}
function index_esm(input) {
return new Color(input);
}
export { Color, b2n, b2p, index_esm as default, hexParse, hexString, hsl2rgb, hslString, hsv2rgb, hueParse, hwb2rgb, lim, n2b, n2p, nameParse, p2b, rgb2hsl, rgbParse, rgbString, rotate, round };

View File

@@ -0,0 +1,77 @@
{
"name": "@kurkle/color",
"type": "module",
"version": "0.3.4",
"description": "css color parsing, manupulation and conversion",
"sideEffects": false,
"main": "dist/color.cjs",
"module": "dist/color.esm.js",
"types": "dist/color.d.ts",
"exports": {
"types": "./dist/color.d.ts",
"import": "./dist/color.esm.js",
"require": "./dist/color.cjs"
},
"scripts": {
"build": "node util/copy_dist.js && rollup -c",
"lint": "eslint src/*.js test/*.js util/*.js",
"test": "node test/index.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/kurkle/color.git"
},
"files": [
"dist/*",
"dist/color.d.ts"
],
"keywords": [
"color",
"colour",
"css",
"hsl",
"hex",
"rgb",
"rgba",
"hwb",
"hsv",
"cmyk"
],
"author": "Jukka Kurkela",
"license": "MIT",
"bugs": {
"url": "https://github.com/kurkle/color/issues"
},
"homepage": "https://github.com/kurkle/color#readme",
"devDependencies": {
"@rollup/plugin-terser": "^0.4.0",
"assert": "^2.0.0",
"benchmark": "^2.1.4",
"chartjs-color": "^2.4.1",
"chartjs-color-string": "^0.6.0",
"child_process": "^1.0.2",
"chroma-js": "^3.1.1",
"color-name": "^2.0.0",
"color-names": "^2.0.0",
"color-parse": "^2.0.2",
"color-parser": "^0.1.0",
"color-string": "^1.5.5",
"csscolorparser": "^1.0.3",
"eslint": "^9.15.0",
"eslint-config-chartjs": "^0.3.0",
"eslint-config-defaults": "^9.0.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-react": "^7.22.0",
"fs": "0.0.1-security",
"perf_hooks": "0.0.1",
"rollup": "^4.25.0",
"rollup-plugin-analyzer": "^4.0.0",
"rollup-plugin-cleanup": "^3.2.1",
"rollup-plugin-istanbul": "^5.0.0",
"rollup-plugin-visualizer": "^5.8.3",
"tinycolor2": "^1.4.2",
"typedoc": "^0.26.7",
"typescript": "^5.6.2",
"util": "^0.12.3"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,139 @@
{
"name": "chart.js",
"homepage": "https://www.chartjs.org",
"description": "Simple HTML5 charts using the canvas element.",
"version": "4.5.1",
"license": "MIT",
"type": "module",
"sideEffects": [
"./auto/auto.js",
"./auto/auto.cjs",
"./dist/chart.umd.min.js",
"./dist/chart.umd.js"
],
"jsdelivr": "./dist/chart.umd.min.js",
"unpkg": "./dist/chart.umd.min.js",
"main": "./dist/chart.cjs",
"module": "./dist/chart.js",
"exports": {
".": {
"types": "./dist/types.d.ts",
"import": "./dist/chart.js",
"require": "./dist/chart.cjs"
},
"./auto": {
"types": "./auto/auto.d.ts",
"import": "./auto/auto.js",
"require": "./auto/auto.cjs"
},
"./helpers": {
"types": "./helpers/helpers.d.ts",
"import": "./helpers/helpers.js",
"require": "./helpers/helpers.cjs"
}
},
"types": "./dist/types.d.ts",
"keywords": [
"canvas",
"charts",
"data",
"graphs",
"html5",
"responsive"
],
"repository": {
"type": "git",
"url": "https://github.com/chartjs/Chart.js.git"
},
"bugs": {
"url": "https://github.com/chartjs/Chart.js/issues"
},
"files": [
"auto/**",
"dist/**",
"!dist/docs/**",
"helpers/**"
],
"scripts": {
"autobuild": "rollup -c -w",
"copyDeclarations": "node -e \"fs.cpSync('./src/types/', './dist/types/', {recursive:true})\"",
"emitDeclarations": "tsc --emitDeclarationOnly && pnpm copyDeclarations",
"build": "rollup -c && pnpm emitDeclarations",
"dev": "karma start ./karma.conf.cjs --auto-watch --no-single-run --browsers chrome --grep",
"dev:ff": "karma start ./karma.conf.cjs --auto-watch --no-single-run --browsers firefox --grep",
"docs": "pnpm run build && pnpm --filter \"./docs/**\" build",
"docs:dev": "pnpm run build && pnpm --filter \"./docs/**\" dev",
"lint-js": "eslint \"src/**/*.{js,ts}\" \"test/**/*.js\" \"docs/**/*.js\" --cache",
"lint-md": "eslint \"**/*.md\" --cache",
"lint-types": "pnpm build && node test/types/autogen.js && tsc -p test/types",
"lint": "concurrently \"pnpm:lint-*\"",
"test": "pnpm lint && pnpm test-ci",
"test-ci": "concurrently \"pnpm:test-ci-*\"",
"test-ci-karma": "cross-env NODE_ENV=test karma start ./karma.conf.cjs --auto-watch --single-run --coverage --grep",
"test-ci-integration": "pnpm --filter \"./test/integration/**\" test"
},
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-inject": "^5.0.2",
"@rollup/plugin-json": "^5.0.1",
"@rollup/plugin-node-resolve": "^15.0.1",
"@swc/core": "^1.3.18",
"@types/estree": "^1.0.0",
"@types/offscreencanvas": "^2019.7.0",
"@typescript-eslint/eslint-plugin": "^5.32.0",
"@typescript-eslint/parser": "^5.32.0",
"chartjs-adapter-luxon": "^1.2.0",
"chartjs-adapter-moment": "^1.0.0",
"chartjs-test-utils": "^0.4.0",
"concurrently": "^7.3.0",
"coveralls": "^3.1.1",
"cross-env": "^7.0.3",
"eslint": "^8.21.0",
"eslint-config-chartjs": "^0.3.0",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-html": "^7.1.0",
"eslint-plugin-markdown": "^3.0.0",
"esm": "^3.2.25",
"glob": "^8.0.3",
"jasmine": "^3.7.0",
"jasmine-core": "^3.7.1",
"karma": "^6.3.2",
"karma-chrome-launcher": "^3.1.0",
"karma-coverage": "^2.0.3",
"karma-edge-launcher": "^0.4.2",
"karma-firefox-launcher": "^2.1.0",
"karma-jasmine": "^4.0.1",
"karma-jasmine-html-reporter": "^1.5.4",
"karma-rollup-preprocessor": "7.0.7",
"karma-safari-private-launcher": "^1.0.0",
"karma-spec-reporter": "0.0.32",
"luxon": "^3.0.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.34",
"pixelmatch": "^5.3.0",
"rollup": "^3.3.0",
"rollup-plugin-cleanup": "^3.2.1",
"rollup-plugin-istanbul": "^4.0.0",
"rollup-plugin-swc3": "^0.7.0",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.7.4",
"yargs": "^17.5.1"
},
"engines": {
"pnpm": ">=8"
},
"packageManager": "pnpm@8.13.0",
"pnpm": {
"overrides": {
"html-entities": "1.4.0"
},
"peerDependencyRules": {
"ignoreMissing": [
"chart.js"
]
}
}
}

View File

@@ -5,8 +5,12 @@
"//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"
"pdfjs-dist": "pdfjs-dist/build/pdf.mjs",
"chart.js": "chart.js/dist/chart.js",
"@kurkle/color": "@kurkle/color/dist/color.esm.js"
}
}

View File

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

View File

@@ -1,4 +1,4 @@
// Package handlers serves the server-rendered public pages of the Kjol JS Web
// Package handlers serves the server-rendered public pages of the Kjøl JS Web
// section.
//
// This file is the APP side of a coupling inversion. kjol's bundler renders each

View File

@@ -21,27 +21,34 @@ import (
"kjol/webui"
"kjolweb/app"
"kjolweb/buildsteps"
"kjolweb/build"
"kjolweb/internal/handlers"
)
func main() {
addr := flag.String("addr", ":8085", "listen address")
watch := flag.Bool("watch", true, "watch sources, rebuild wasm, hot-reload")
buildOnly := flag.Bool("build", false, "run the full build once and exit (no server)")
flag.Parse()
if *buildOnly {
build.Cold()
return
}
log.Fatal(wasmdevserver.Serve(wasmdevserver.Config{
Addr: *addr,
Dir: "./wwwroot",
Watch: *watch,
WatchDirs: []string{
"app", "wasm", "css", // this app's Go/WASM half
"frontend", // its Solid half — a .tsx save rebuilds the JS bundle
"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
},
Build: buildsteps.All,
BuildCSS: buildsteps.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet
Build: build.All,
BuildCSS: build.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet
Render: render,
Document: document,
Handle: routes,
@@ -71,10 +78,10 @@ func routes(mux *http.ServeMux) {
// document — the client router reads the URL and decides what to render, which is what
// makes it a single-page app.
//
// It carries no server-rendered markup, and that is a real difference from the Go/WASM
// half rather than an oversight: this section is a docs section behind a click, where a
// blank first frame costs nothing. Where it WOULD cost something, the public-page path
// (see internal/handlers) renders on the server instead — /js/ssr is that, and it is
// It carries no server-rendered markup, and that is a real difference from Kjøl Wasm Web
// rather than an oversight: this is a docs section behind a click, where a blank first
// frame costs nothing. Where it WOULD cost something, the public-page path (see
// internal/handlers) renders on the server instead — /js/ssr is that, and it is
// registered above, so it never reaches this handler.
func serveJSApp(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -83,7 +90,7 @@ func serveJSApp(w http.ResponseWriter, r *http.Request) {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kjol JS Web</title>
<title>Kjøl JS Web</title>
`+webui.ThemeBootScript+`
<link rel="stylesheet" href="/bundle.min.css" />
</head>
@@ -130,7 +137,7 @@ func document(inner string) string {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>kjol — a shared base layer</title>
<title>kjøl — a shared base layer</title>
` + webui.ThemeBootScript + `
<link rel="stylesheet" href="/app.css" />
</head>

View File

@@ -37,5 +37,17 @@ func main() {
// boot script put it there before the first paint — so this is not what makes the
// page dark; it is what makes the SWITCH know which way it is pointing, and what
// keeps the page following the OS if the user never touched the switch.
//
// The server rendered this button not knowing the theme (it cannot: the preference is
// in localStorage), so it drew the wrong icon. Init's signal write re-renders it, and
// the reconciler patches the one button. That mismatch is inherent, not a bug — it is
// the same reason the boot script has to exist at all.
//
// This line used to be unreachable: Hydrate ended in a `select {}` and never returned.
// See wasmruntime.Wait.
app.Theme.Init()
// Last line, always: a Go/wasm main that returns is a dead program, and every event
// handler on the page dies with it.
wasmruntime.Wait()
}

View File

@@ -1,4 +1,4 @@
// Package jsruntime is the JS/TS half of kjol: the Solid component kit, the
// Package jsruntime is the JS/TS tree of kjol: the Solid component kit, the
// vendored Solid runtime, the FontAwesome SVG source kit, the shared Tailwind
// @theme scaffold, and the generic TS scaffolding (auth, hooks, ssr, utils).
//

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M320 64C306.7 64 296 74.7 296 88L296 97.7C214.6 109.3 152 179.4 152 264L152 278.5C152 316.2 142 353.2 123 385.8L101.1 423.2C97.8 429 96 435.5 96 442.2C96 463.1 112.9 480 133.8 480L506.2 480C527.1 480 544 463.1 544 442.2C544 435.5 542.2 428.9 538.9 423.2L517 385.7C498 353.1 488 316.1 488 278.4L488 263.9C488 179.3 425.4 109.2 344 97.6L344 87.9C344 74.6 333.3 63.9 320 63.9zM488.4 432L151.5 432L164.4 409.9C187.7 370 200 324.6 200 278.5L200 264C200 197.7 253.7 144 320 144C386.3 144 440 197.7 440 264L440 278.5C440 324.7 452.3 370 475.5 409.9L488.4 432zM252.1 528C262 556 288.7 576 320 576C351.3 576 378 556 387.9 528L252.1 528z"/></svg>

After

Width:  |  Height:  |  Size: 895 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M112 120C112 106.7 101.3 96 88 96C74.7 96 64 106.7 64 120L64 464C64 508.2 99.8 544 144 544L552 544C565.3 544 576 533.3 576 520C576 506.7 565.3 496 552 496L144 496C126.3 496 112 481.7 112 464L112 120zM536 128C522.7 128 512 138.7 512 152L512 392C512 405.3 522.7 416 536 416C549.3 416 560 405.3 560 392L560 152C560 138.7 549.3 128 536 128zM312 192C298.7 192 288 202.7 288 216L288 392C288 405.3 298.7 416 312 416C325.3 416 336 405.3 336 392L336 216C336 202.7 325.3 192 312 192zM200 288C186.7 288 176 298.7 176 312L176 392C176 405.3 186.7 416 200 416C213.3 416 224 405.3 224 392L224 312C224 298.7 213.3 288 200 288zM448 280C448 266.7 437.3 256 424 256C410.7 256 400 266.7 400 280L400 392C400 405.3 410.7 416 424 416C437.3 416 448 405.3 448 392L448 280z"/></svg>

After

Width:  |  Height:  |  Size: 1015 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M320 64C302.3 64 288 78.3 288 96L288 99.2C215 114 160 178.6 160 256L160 277.7C160 325.8 143.6 372.5 113.6 410.1L103.8 422.3C98.7 428.6 96 436.4 96 444.5C96 464.1 111.9 480 131.5 480L508.4 480C528 480 543.9 464.1 543.9 444.5C543.9 436.4 541.2 428.6 536.1 422.3L526.3 410.1C496.4 372.5 480 325.8 480 277.7L480 256C480 178.6 425 114 352 99.2L352 96C352 78.3 337.7 64 320 64zM258 528C265.1 555.6 290.2 576 320 576C349.8 576 374.9 555.6 382 528L258 528z"/></svg>

After

Width:  |  Height:  |  Size: 716 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M96 96C113.7 96 128 110.3 128 128L128 464C128 472.8 135.2 480 144 480L544 480C561.7 480 576 494.3 576 512C576 529.7 561.7 544 544 544L144 544C99.8 544 64 508.2 64 464L64 128C64 110.3 78.3 96 96 96zM208 288C225.7 288 240 302.3 240 320L240 384C240 401.7 225.7 416 208 416C190.3 416 176 401.7 176 384L176 320C176 302.3 190.3 288 208 288zM352 224L352 384C352 401.7 337.7 416 320 416C302.3 416 288 401.7 288 384L288 224C288 206.3 302.3 192 320 192C337.7 192 352 206.3 352 224zM432 256C449.7 256 464 270.3 464 288L464 384C464 401.7 449.7 416 432 416C414.3 416 400 401.7 400 384L400 288C400 270.3 414.3 256 432 256zM576 160L576 384C576 401.7 561.7 416 544 416C526.3 416 512 401.7 512 384L512 160C512 142.3 526.3 128 544 128C561.7 128 576 142.3 576 160z"/></svg>

After

Width:  |  Height:  |  Size: 1013 B

View File

@@ -70,7 +70,7 @@
The vocabulary is deliberately identical to the Go/WASM kit's (webui.
ThemeTokens). Two kits, one contract: a designer changes `surface` once and
both halves of the site move together. */
both layers of the site move together. */
/* Surfaces, in ascending prominence. */
--color-surface: #ffffff;
@@ -96,12 +96,23 @@
--color-fill-neutral-hover: #262626;
--color-on-fill-neutral: #ffffff;
/* The accent. `primary` is a FILL — it carries white text, so it must stay dark
enough for white to sit on it. Brand supplies the value; see the app's
style.css. These defaults keep the kit legible before an app has themed
anything, NOT so that it can skip theming. */
/* The accent, in four parts, because a single "brand colour" cannot do four jobs.
Same names as the Go kit (webui.ThemeTokens) — one vocabulary, two kits.
primary is a FILL: it carries white text, so it has to stay dark enough for white
to sit on it. accent is TEXT and icons: it has to stay readable ON the surface.
One value cannot be both, and in dark mode they diverge completely — the fill
stays put while the text has to climb.
primary-subtle / primary-border are the tinted panel: a callout, a selected row.
Brand supplies the values; see the app's style.css. These defaults keep the kit
legible before an app has themed anything, NOT so that it can skip theming. */
--color-primary: #404040;
--color-primary-hover: #262626;
--color-primary-subtle: #f5f5f5;
--color-primary-border: #d4d4d4;
--color-accent: #262626;
/* ---- deliberately-dark chrome (the kit's `onDark` prop) ----------------
Not the same thing as dark MODE. Several components take onDark, for when
@@ -164,6 +175,13 @@
--color-fill-neutral: #e5e5e5;
--color-fill-neutral-hover: #ffffff;
--color-on-fill-neutral: #171717;
/* The fill stays where it is — white text still has to be readable on it — but the
tinted panel and the accent TEXT both have to move, or they vanish into a near
black surface. This is exactly why they are separate tokens. */
--color-primary-subtle: #1f1f24;
--color-primary-border: #3d3d45;
--color-accent: #e5e5e5;
}
/* The page's own background — painted before anything mounts, and behind it

View File

@@ -7,7 +7,7 @@
// the kit are none the wiser.
//
// The storage key is deliberately the SAME one the Go/WASM kit uses
// (webui.ThemeBootScript, webui.themeStorageKey). Both halves of kjol-web are served
// (webui.ThemeBootScript, webui.themeStorageKey). Both layers of kjol-web are served
// from one origin, so they share a localStorage: choose dark in the /wasm section,
// walk over to /js, and it is still dark. Two front-ends, one preference.

124
go/lexer/lexer.go Normal file
View File

@@ -0,0 +1,124 @@
// Package lexer turns source code into syntax-highlighted HTML.
//
// It is a LEXER, not a parser: it classifies tokens and gives up gracefully on anything it
// does not understand. A highlighter that can fail to render is worse than one that
// occasionally paints an identifier the wrong colour — the reader can see past a wrong
// colour, and cannot see past a blank page. Unterminated strings and comments run to a
// sensible boundary rather than throwing, and a language nobody has written a lexer for
// comes back escaped and unpainted rather than not coming back.
//
// Output is HTML, and every run of source text passes through html.EscapeString on the way
// out. That is not a nicety: the input is source code, which is full of `<`, `>` and `&` —
// a C file is nothing but shifts and arrows, and one of the snippets kjøl's own docs
// display is literally a block of HTML. Unescaped, a snippet containing `<div>` renders a
// div, and half the line disappears into it.
//
// It lives beside webui rather than inside it because it has nothing to do with the DOM:
// it is a string in and a string out, it imports nothing but the standard library, and it
// is as usable from a static site generator or a terminal as from a component. webui just
// happened to be where it was first needed.
//
// The one thing tying it to a UI is the palette — the class names below are Tailwind's, so
// whatever compiles your CSS has to scan THIS package too, or the colours will not exist.
// (In kjøl's own site that is cmd/kjol-web/build.Tailwind's source globs.)
package lexer
import (
"html"
"strings"
)
// A code block is dark in BOTH themes — a light code block on a light page is a different
// kind of thing, and switching it with the theme means the snippet you were reading changes
// colour under you. So these are fixed on-dark colours, not theme tokens: the surface they
// sit on never changes.
//
// They are shared by every language in the package, so that a Go snippet and a C snippet on
// the same page agree about what a string looks like. A reader should not have to relearn
// the palette per section.
const (
CommentClass = "text-ink-faint"
StringClass = "text-emerald-300"
KeywordClass = "text-sky-300"
NumberClass = "text-amber-300"
FuncClass = "text-violet-300"
// The two C needs and Go does not. C has a preprocessor, and C declarations are mostly
// TYPE — so painting `U64` the same colour as `static` throws away the one distinction
// that makes a header skimmable.
TypeClass = "text-teal-300"
DirectiveClass = "text-rose-300"
)
// Highlight returns HTML for src, painted for the named language.
//
// The name is matched case-insensitively, and an UNKNOWN one is not an error: the source
// comes back escaped and unpainted. That is deliberate, and it is why callers can pass a
// caption's language label straight through. A docs page that showed nothing for a shell
// snippet because nobody has written a shell lexer would be trading a small loss of colour
// for a total loss of content — and painting it with the WRONG lexer (Jai through Go's
// keyword table) would be worse than either.
//
// The result is meant for a raw-HTML node inside a <pre>: it contains no block elements and
// preserves the source's whitespace exactly, so the <pre> does the layout.
func Highlight(lang, src string) string {
switch strings.ToLower(lang) {
case "go":
return HighlightGo(src)
case "c":
return HighlightC(src)
default:
return html.EscapeString(src)
}
}
// ---- the pieces every language in here shares ----------------------------
// quoted consumes a quoted literal starting at i and writes it as a string span.
// escapes reports whether a backslash escapes the next byte (false for Go's raw strings).
func quoted(b *strings.Builder, src string, i int, quote byte, escapes bool) int {
j := i + 1
for j < len(src) {
if escapes && src[j] == '\\' && j+1 < len(src) {
j += 2
continue
}
if src[j] == quote {
j++
break
}
if escapes && src[j] == '\n' {
break // unterminated: stop at the line end rather than eating the file
}
j++
}
span(b, StringClass, src[i:j])
return j
}
// callAhead reports whether the next non-space byte at or after i is an opening paren —
// which is the whole of the "is this identifier a function" test. It is a guess, and a good
// one: an identifier immediately before a `(` is being called, declared, or converted to,
// and all three are worth seeing at a glance.
func callAhead(src string, i int) bool {
for i < len(src) && (src[i] == ' ' || src[i] == '\t') {
i++
}
return i < len(src) && src[i] == '('
}
func span(b *strings.Builder, class, text string) {
b.WriteString(`<span class="`)
b.WriteString(class)
b.WriteString(`">`)
b.WriteString(html.EscapeString(text))
b.WriteString(`</span>`)
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
func isHexish(c byte) bool {
return c == '.' || c == 'x' || c == 'X' || c == '_' ||
(c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
func isIdentStart(c byte) bool { return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') }
func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) }

433
go/lexer/lexer_c.go Normal file
View File

@@ -0,0 +1,433 @@
package lexer
import (
"html"
"strings"
)
// C syntax highlighting. Same contract as the rest of the package (see lexer.go).
//
// The classification tables below MIRROR the ones in kjøl's own C layer, c/lexer/lexer_c.c
// — a highlighter that colours the same source inside the editor it was written for. Two
// highlighters for one language is one too many, but they cannot share: that one is C
// compiled into an editor, this one is Go compiled to WebAssembly, and nothing is upstream
// of both. So they are two tables that agree, and this comment is the thing that says they
// have to. If you add a type there, add it here.
//
// That is also why U8/S32/F64/B32 are in the type table. They are not C types — they are
// kjøl's — but this highlighter exists to render kjøl's C, and a page about base_core.h
// that paints its own typedefs as bare identifiers has failed at the one job it has.
// The reserved words. Order of the maps below is also the order they are tested in, and
// the categories do not overlap.
var cKeywords = map[string]bool{
"auto": true, "break": true, "case": true, "continue": true, "default": true,
"do": true, "else": true, "enum": true, "for": true, "goto": true, "if": true,
"inline": true, "restrict": true, "return": true, "sizeof": true, "struct": true,
"switch": true, "typedef": true, "union": true, "while": true,
"_Alignas": true, "alignas": true, "_Alignof": true, "alignof": true,
"_Atomic": true, "_Generic": true, "_Noreturn": true,
"static_assert": true, "_Static_assert": true,
}
// Storage class and qualifiers. They read as keywords and are coloured as keywords — the
// C layer's lexer keeps them as a separate TOK_MODIFIER, but that distinction buys a
// reader of a documentation page nothing, so it is not carried over.
var cModifiers = map[string]bool{
"const": true, "constexpr": true, "extern": true, "register": true, "signed": true,
"static": true, "unsigned": true, "volatile": true,
"thread_local": true, "_Thread_local": true,
// Not C. base_core.h's three names for the three meanings of `static`, and the whole
// point of them is that they are visible — so they are coloured like what they are.
"internal": true, "global": true, "local_persist": true,
}
var cTypes = map[string]bool{
"char": true, "double": true, "float": true, "int": true, "long": true,
"short": true, "void": true, "bool": true, "_Bool": true,
"_Complex": true, "_Imaginary": true,
"int8_t": true, "int16_t": true, "int32_t": true, "int64_t": true,
"uint8_t": true, "uint16_t": true, "uint32_t": true, "uint64_t": true,
"size_t": true, "ssize_t": true, "ptrdiff_t": true,
"intptr_t": true, "uintptr_t": true, "nullptr_t": true, "FILE": true,
// kjøl's base layer (c/base/base_core.h).
"U8": true, "U16": true, "U32": true, "U64": true,
"S8": true, "S16": true, "S32": true, "S64": true,
"B32": true, "F32": true, "F64": true,
}
var cValues = map[string]bool{
"false": true, "true": true, "NULL": true, "nullptr": true,
}
// HighlightC turns C source into HTML with the tokens wrapped in coloured spans.
//
// The result is meant for vdom.Raw inside a <pre>: it contains no block elements and
// preserves the source's whitespace exactly, so the <pre> does the layout.
func HighlightC(src string) string {
var b strings.Builder
b.Grow(len(src) * 2)
// The types this snippet declares about itself.
//
// C cannot be highlighted correctly in one pass, and this is the reason: `Arena *a` is
// a declaration and `a * b` is a multiplication, and they are the same three tokens.
// Telling them apart needs to know that Arena is a type — which is the oldest problem
// in parsing C, and the reason C compilers feed the symbol table back into the lexer.
//
// The alternative most highlighters take is to guess from the spacing, which is wrong
// as often as the author's style differs from theirs. So this does not guess. It reads
// the declarations first — `typedef struct Arena {…} Arena;` says Arena is a type, in
// so many words — and then highlights knowing what the snippet said. A type the
// snippet never declares stays an identifier, which is honest: nothing in the text the
// reader is looking at claims otherwise.
declared := cDeclaredTypes(src)
// Two pieces of state, and both exist for the preprocessor.
//
// lineStart tracks whether we have seen anything but whitespace since the last
// newline, because a `#` is only a directive at the start of a line — everywhere else
// it is the stringize or paste operator inside a macro body, and painting THAT as a
// directive turns the inside of every macro red.
//
// inInclude is set for the rest of the line after `#include`, and it is what makes
// <stdio.h> a header path rather than a less-than, an identifier, a dot and a
// greater-than. It is the one place in C where `<` does not mean what it usually does.
lineStart := true
inInclude := false
// prevWord is the last identifier-shaped token seen. After `struct`, `union` or `enum`
// the next identifier IS a type name — that one is syntax, not a guess, so it is worth
// tracking one word of history to get it right.
prevWord := ""
i := 0
for i < len(src) {
c := src[i]
switch {
case c == '\n':
b.WriteByte('\n')
lineStart = true
inInclude = false
prevWord = ""
i++
continue
case c == ' ' || c == '\t' || c == '\r':
b.WriteByte(c)
i++
continue // whitespace does not end lineStart, and does not clear prevWord
// Line comment. // is C99 and every C anyone writes today uses it.
case c == '/' && i+1 < len(src) && src[i+1] == '/':
end := strings.IndexByte(src[i:], '\n')
if end < 0 {
end = len(src)
} else {
end += i
}
span(&b, CommentClass, src[i:end])
i = end
// Block comment.
case c == '/' && i+1 < len(src) && src[i+1] == '*':
end := strings.Index(src[i+2:], "*/")
if end < 0 {
end = len(src)
} else {
end = i + 2 + end + 2
}
span(&b, CommentClass, src[i:end])
i = end
// A preprocessor directive: `#` and the word after it, and only at the start of a
// line. `#define`, `#include`, `#ifdef`, `#pragma`.
case c == '#' && lineStart:
j := i + 1
for j < len(src) && (src[j] == ' ' || src[j] == '\t') {
j++ // `# define` is legal, and rare, and free to support
}
for j < len(src) && isIdentPart(src[j]) {
j++
}
word := src[i:j]
span(&b, DirectiveClass, word)
// The rest of an #include line reads differently. Nothing else does.
if strings.HasSuffix(word, "include") || strings.HasSuffix(word, "import") {
inInclude = true
}
i = j
// <stdio.h> — but ONLY on an #include line. Anywhere else this is an operator.
case c == '<' && inInclude:
end := i + 1
for end < len(src) && src[end] != '>' && src[end] != '\n' {
end++
}
if end < len(src) && src[end] == '>' {
end++ // include the closing bracket
}
span(&b, StringClass, src[i:end])
i = end
case c == '"':
i = quoted(&b, src, i, '"', true)
// A character literal. In C this is an INT, not a string — but every editor paints
// it like a string, and a reader looking for '\0' is looking for a literal.
case c == '\'':
i = quoted(&b, src, i, '\'', true)
case isDigit(c):
j := cNumberEnd(src, i)
span(&b, NumberClass, src[i:j])
i = j
case isIdentStart(c):
j := i
for j < len(src) && isIdentPart(src[j]) {
j++
}
word := src[i:j]
switch {
case cKeywords[word]:
span(&b, KeywordClass, word)
case cModifiers[word]:
span(&b, KeywordClass, word)
case cTypes[word]:
span(&b, TypeClass, word)
case cValues[word]:
span(&b, NumberClass, word) // NULL and true are constants; colour them as such
case declared[word]:
// The snippet said so itself, in a typedef or a struct tag. Not a guess.
span(&b, TypeClass, word)
case prevWord == "struct" || prevWord == "union" || prevWord == "enum":
// Syntax, not a guess: what follows one of these IS a type name.
span(&b, TypeClass, word)
case isTypeSuffixed(word):
span(&b, TypeClass, word)
case callAhead(src, j):
span(&b, FuncClass, word)
default:
b.WriteString(html.EscapeString(word))
}
prevWord = word
lineStart = false
i = j
continue
default:
b.WriteString(html.EscapeString(string(c)))
i++
}
lineStart = false
prevWord = ""
}
return b.String()
}
// cDeclaredTypes reads a snippet's type declarations and returns the names they introduce.
//
// It is a scan, not a parse, and it recognises exactly two shapes — the two that declare a
// type name in C:
//
// struct Arena / union Foo / enum Lang the word after the tag is a type
// typedef ... Name ; the last word before the semicolon
// typedef ... (*Name)(...) ; unless it is a function pointer, whose
// name hides inside the parens
//
// The typedef rule is why brace depth is tracked. `typedef struct Arena { U8 *base; } Arena;`
// contains semicolons that do not end it, and taking the first one would declare a type
// called `base`.
//
// Comments and string literals are skipped, or the word `struct` inside a comment would
// declare the next word in the prose as a type.
func cDeclaredTypes(src string) map[string]bool {
types := map[string]bool{}
inTypedef := false // inside a typedef statement, up to its semicolon
lastWord := "" // last identifier seen in it — the name, for the common shape
fnPtrName := "" // ...unless it is a function pointer, whose name is in (*Name)
prevWord := "" // one word of history, for `struct Arena`
depth := 0 // brace depth: only a depth-0 semicolon ends the typedef
i := 0
for i < len(src) {
c := src[i]
switch {
case c == ' ' || c == '\t' || c == '\r' || c == '\n':
i++ // whitespace must NOT clear prevWord: `struct Arena` has a space in it
case c == '/' && i+1 < len(src) && src[i+1] == '/':
if end := strings.IndexByte(src[i:], '\n'); end < 0 {
i = len(src)
} else {
i += end
}
case c == '/' && i+1 < len(src) && src[i+1] == '*':
if end := strings.Index(src[i+2:], "*/"); end < 0 {
i = len(src)
} else {
i += 2 + end + 2
}
case c == '"' || c == '\'':
i = skipQuoted(src, i, c)
case c == '{':
depth++
i++
case c == '}':
depth--
i++
// (*Name)(...) — a function-pointer typedef. The name is here and nowhere else.
case c == '(' && i+1 < len(src) && src[i+1] == '*':
j := i + 2
for j < len(src) && (src[j] == ' ' || src[j] == '\t') {
j++
}
k := j
for k < len(src) && isIdentPart(src[k]) {
k++
}
if inTypedef && k > j {
fnPtrName = src[j:k]
}
i = k
prevWord = ""
case c == ';' && depth == 0:
if inTypedef {
name := fnPtrName
if name == "" {
name = lastWord
}
if name != "" {
types[name] = true
}
}
inTypedef, lastWord, fnPtrName, prevWord = false, "", "", ""
i++
case isIdentStart(c):
j := i
for j < len(src) && isIdentPart(src[j]) {
j++
}
word := src[i:j]
if word == "typedef" {
inTypedef, lastWord, fnPtrName = true, "", ""
} else {
if prevWord == "struct" || prevWord == "union" || prevWord == "enum" {
types[word] = true
}
if inTypedef {
lastWord = word
}
}
prevWord = word
i = j
default:
prevWord = ""
i++
}
}
return types
}
// skipQuoted returns the index one past a quoted literal, without emitting anything.
func skipQuoted(src string, i int, quote byte) int {
j := i + 1
for j < len(src) {
if src[j] == '\\' && j+1 < len(src) {
j += 2
continue
}
if src[j] == quote {
return j + 1
}
if src[j] == '\n' {
return j // unterminated
}
j++
}
return j
}
// isTypeSuffixed reports whether a name ends in _t.
//
// This is a HEURISTIC, and the only one in this file. C has no way to know what is a type
// without a symbol table, and building one to colour a documentation snippet would be
// absurd — so the choice is between guessing and not guessing. `_t` is the one convention
// universal enough to guess on: it is what the standard library does (size_t, uint32_t),
// and a project that uses it for something that is not a type is doing so to be confusing.
//
// Everything else that is not in the tables above stays an identifier. A highlighter that
// paints too little is quietly unhelpful; one that paints too much is actively misleading.
func isTypeSuffixed(word string) bool {
return len(word) > 2 && strings.HasSuffix(word, "_t")
}
// cNumberEnd returns the index one past the number starting at i.
//
// It is its own function because C numbers are a small zoo: 0x25, 0b1011, 1024, 3.14f,
// 1e9, 0xFFull. Go's number scanner (code.go) is a single character-class loop, which is
// enough for Go and would swallow the `x` of `0xFF` into a hex-ish blur and then choke on
// the `ull`.
func cNumberEnd(src string, i int) int {
j := i
switch {
case src[j] == '0' && j+1 < len(src) && (src[j+1] == 'x' || src[j+1] == 'X'):
j += 2
for j < len(src) && isHexDigit(src[j]) {
j++
}
case src[j] == '0' && j+1 < len(src) && (src[j+1] == 'b' || src[j+1] == 'B'):
j += 2
for j < len(src) && (src[j] == '0' || src[j] == '1') {
j++
}
default:
for j < len(src) && (isDigit(src[j]) || src[j] == '.') {
j++
}
// An exponent, but only if it actually has digits after it — otherwise the `e` of
// `1end` is a number and `nd` is an identifier, which is nonsense.
if j < len(src) && (src[j] == 'e' || src[j] == 'E') {
k := j + 1
if k < len(src) && (src[k] == '+' || src[k] == '-') {
k++
}
if k < len(src) && isDigit(src[k]) {
for k < len(src) && isDigit(src[k]) {
k++
}
j = k
}
}
}
// u, U, l, L, f, F, in any order and any number: 0xFFull, 1.0f, 10UL.
for j < len(src) && isNumSuffix(src[j]) {
j++
}
return j
}
func isHexDigit(c byte) bool {
return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
func isNumSuffix(c byte) bool {
return c == 'u' || c == 'U' || c == 'l' || c == 'L' || c == 'f' || c == 'F'
}

198
go/lexer/lexer_c_test.go Normal file
View File

@@ -0,0 +1,198 @@
package lexer
import (
"strings"
"testing"
)
// The escaping test is the one that matters most, and C is worse than Go for it.
//
// A C header opens with `#include <stdio.h>`. If that reaches the page unescaped, the
// browser sees an unknown tag, swallows it, and the line silently loses its second half.
// The same goes for `<<`, `->` and `&&`, which are on nearly every line of real C.
func TestHighlightCEscapes(t *testing.T) {
got := HighlightC("#include <stdio.h>\nx = a << 2 & b->c;\n")
for _, raw := range []string{"<stdio.h>", "<<", "&&", "->"} {
// The only `<` in the output should be the ones opening our own spans.
if strings.Contains(stripSpans(got), raw) {
t.Errorf("%q survived unescaped into the HTML — the browser will eat it:\n%s", raw, got)
}
}
for _, want := range []string{"&lt;stdio.h&gt;", "&lt;&lt;", "-&gt;"} {
if !strings.Contains(got, want) {
t.Errorf("expected %q in the output, got:\n%s", want, got)
}
}
}
func TestHighlightCClassifies(t *testing.T) {
src := "typedef struct Arena { U8 *base; U64 pos; } Arena;\n" +
"\n" +
"internal U64 arena_push(Arena *a, U64 size) {\n" +
" // bump\n" +
" U64 pos = a->pos + 0x10;\n" +
" return pos;\n" +
"}\n"
got := HighlightC(src)
cases := []struct{ class, text, why string }{
{KeywordClass, "internal", "base_core's name for a file-static reads as a storage class"},
{TypeClass, "U64", "kjøl's own base types are types, not bare identifiers"},
{TypeClass, "Arena", "and so is a struct the snippet declared"},
{FuncClass, "arena_push", "an identifier before ( is being declared or called"},
{NumberClass, "0x10", "hex is a number, not a 0 followed by an identifier"},
{CommentClass, "// bump", "a line comment"},
{KeywordClass, "return", "a keyword"},
}
for _, c := range cases {
want := `<span class="` + c.class + `">` + c.text + `</span>`
if !strings.Contains(got, want) {
t.Errorf("%s: expected %s\ngot:\n%s", c.why, want, got)
}
}
}
// The pre-pass. This is what lets `Arena *a` be a declaration rather than a guess — so the
// shapes it has to recognise are worth pinning down one at a time.
func TestCDeclaredTypes(t *testing.T) {
cases := []struct {
src, want, why string
}{
{"typedef struct Arena { U8 *base; U64 pos; } Arena;", "Arena",
"the inner semicolons must not end the typedef — the first one would declare `base`"},
{"typedef enum Lang { LANG_C, LANG_GO } Lang;", "Lang",
"an enum typedef names its type the same way"},
{"typedef struct Arena Arena;", "Arena",
"a forward declaration still declares the name"},
{"struct Tokenizer { S32 at; };", "Tokenizer",
"a plain struct tag, with no typedef at all"},
{"typedef void (*LexerTokenizeFn)(const char *data, S32 len);", "LexerTokenizeFn",
"a function pointer hides its name in the parens — the last word is a parameter"},
{"typedef U64 Handle;", "Handle",
"the simple case"},
}
for _, c := range cases {
if got := cDeclaredTypes(c.src); !got[c.want] {
t.Errorf("%s\n src: %s\n want: %q declared, got %v", c.why, c.src, c.want, keysOf(got))
}
}
// The word `struct` inside a comment or a string declares nothing.
for _, src := range []string{
"// struct Ghost is not real\n",
`const char *s = "struct Ghost";`,
"/* struct Ghost */",
} {
if got := cDeclaredTypes(src); got["Ghost"] {
t.Errorf("a type was declared from inside a comment or string: %s", src)
}
}
}
func keysOf(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// `#` is a directive only at the start of a line. Inside a macro body it is the stringize
// operator, and painting THAT as a directive turns the inside of every macro red.
func TestHighlightCDirectivesOnlyAtLineStart(t *testing.T) {
got := HighlightC("#define STR(x) #x\n")
if !strings.Contains(got, `<span class="`+DirectiveClass+`">#define</span>`) {
t.Errorf("#define should be a directive:\n%s", got)
}
// The second # (the stringize operator) must NOT be a directive span.
if strings.Count(got, `<span class="`+DirectiveClass+`">`) != 1 {
t.Errorf("the stringize # was painted as a directive too:\n%s", got)
}
}
// The one place in C where `<` is not an operator.
func TestHighlightCAngleBracketsOnlyOnIncludeLines(t *testing.T) {
got := HighlightC("#include <stdio.h>\nif (a < b && c > d) return;\n")
if !strings.Contains(got, `<span class="`+StringClass+`">&lt;stdio.h&gt;</span>`) {
t.Errorf("the header path should be a string span:\n%s", got)
}
// On the NEXT line, `<` is a comparison. If inInclude leaked past the newline, the
// rest of the file from `< b &&...` would be swallowed into one green string.
if strings.Contains(got, `<span class="`+StringClass+`">&lt; b`) {
t.Errorf("the include state leaked onto the following line:\n%s", got)
}
}
func TestHighlightCNumbers(t *testing.T) {
for _, n := range []string{"0x25", "0xFFull", "0b1011", "1024", "3.14f", "1e9", "10UL"} {
got := HighlightC("x = " + n + ";")
want := `<span class="` + NumberClass + `">` + n + `</span>`
if !strings.Contains(got, want) {
t.Errorf("%q was not lexed as one number:\n%s", n, got)
}
}
}
// A snippet in a documentation page is a fragment. It gets cut off mid-string and
// mid-comment all the time, and it must still render — a highlighter that can panic takes
// the whole page with it.
func TestHighlightCSurvivesMalformedInput(t *testing.T) {
for _, src := range []string{
`char *s = "unterminated`,
"/* unterminated block",
"'",
"#",
"#include <unterminated",
"0x",
"",
} {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("HighlightC panicked on %q: %v", src, r)
}
}()
HighlightC(src)
}()
}
}
// The highlighter must not change the code. Strip the spans, unescape, and you should have
// exactly what you started with — byte for byte. A highlighter that drops a character is
// showing the reader a program that does not exist.
func TestHighlightCIsLossless(t *testing.T) {
src := "#include <stdio.h>\n" +
"#define KB(n) (((U64)(n)) << 10)\n" +
"\n" +
"typedef struct Arena { U8 *base; U64 pos; } Arena;\n" +
"\n" +
"static inline B32 str8_match(Str8 a, Str8 b) {\n" +
" if (a.size != b.size) return 0; /* cheap out */\n" +
" return MemoryCompare(a.str, b.str, a.size) == 0; // memcmp\n" +
"}\n"
if plain := stripTags(HighlightC(src)); plain != src {
t.Errorf("the highlighter changed the source.\n got: %q\nwant: %q", plain, src)
}
}
// stripSpans removes only our own span tags, leaving the escaped entities alone — so the
// escaping test can ask "is there a raw < left in here" without the spans' own angle
// brackets answering for it.
func stripSpans(s string) string {
s = strings.ReplaceAll(s, `</span>`, "")
for {
i := strings.Index(s, `<span class="`)
if i < 0 {
return s
}
j := strings.Index(s[i:], `">`)
if j < 0 {
return s
}
s = s[:i] + s[i+j+2:]
}
}

99
go/lexer/lexer_go.go Normal file
View File

@@ -0,0 +1,99 @@
package lexer
import (
"html"
"strings"
)
var goKeywords = map[string]bool{
"break": true, "case": true, "chan": true, "const": true, "continue": true,
"default": true, "defer": true, "else": true, "fallthrough": true, "for": true,
"func": true, "go": true, "goto": true, "if": true, "import": true,
"interface": true, "map": true, "package": true, "range": true, "return": true,
"select": true, "struct": true, "switch": true, "type": true, "var": true,
// Not keywords to the Go spec — predeclared identifiers — but every editor colours
// them, and a reader looking for `nil` is looking for the same kind of thing.
"nil": true, "true": true, "false": true, "iota": true,
"string": true, "int": true, "int64": true, "float64": true, "bool": true,
"byte": true, "rune": true, "any": true, "error": true,
}
// HighlightGo turns Go source into HTML with the tokens wrapped in coloured spans.
func HighlightGo(src string) string {
var b strings.Builder
b.Grow(len(src) * 2)
i := 0
for i < len(src) {
c := src[i]
switch {
// Line comment — including the //gowasm: directives, which are the most important
// line in several of the snippets this was written for.
case c == '/' && i+1 < len(src) && src[i+1] == '/':
end := strings.IndexByte(src[i:], '\n')
if end < 0 {
end = len(src)
} else {
end += i
}
span(&b, CommentClass, src[i:end])
i = end
// Block comment.
case c == '/' && i+1 < len(src) && src[i+1] == '*':
end := strings.Index(src[i+2:], "*/")
if end < 0 {
end = len(src)
} else {
end = i + 2 + end + 2
}
span(&b, CommentClass, src[i:end])
i = end
// Interpreted string. Ends at the closing quote or the line's end — an unterminated
// string is a typo in a snippet, not a reason to paint the rest of the file green.
case c == '"':
i = quoted(&b, src, i, '"', true)
// Raw string: no escapes, and it may span lines.
case c == '`':
i = quoted(&b, src, i, '`', false)
// Rune literal.
case c == '\'':
i = quoted(&b, src, i, '\'', true)
case isDigit(c):
j := i
for j < len(src) && (isDigit(src[j]) || isHexish(src[j])) {
j++
}
span(&b, NumberClass, src[i:j])
i = j
case isIdentStart(c):
j := i
for j < len(src) && isIdentPart(src[j]) {
j++
}
word := src[i:j]
switch {
case goKeywords[word]:
span(&b, KeywordClass, word)
case callAhead(src, j):
// Colouring the callee is what makes the shape of a snippet readable at a
// glance: you see what it DOES before you read what it says.
span(&b, FuncClass, word)
default:
b.WriteString(html.EscapeString(word))
}
i = j
default:
b.WriteString(html.EscapeString(string(c)))
i++
}
}
return b.String()
}

View File

@@ -1,4 +1,4 @@
package webui
package lexer
import (
"strings"
@@ -26,10 +26,10 @@ func TestHighlightGoClassifies(t *testing.T) {
got := HighlightGo("func main() { x := 42 // note\n}")
for _, want := range []struct{ what, class, text string }{
{"keyword", goKeywordClass, "func"},
{"call", goFuncClass, "main"},
{"number", goNumberClass, "42"},
{"comment", goCommentClass, "// note"},
{"keyword", KeywordClass, "func"},
{"call", FuncClass, "main"},
{"number", NumberClass, "42"},
{"comment", CommentClass, "// note"},
} {
if !strings.Contains(got, `<span class="`+want.class+`">`+want.text+`</span>`) {
t.Errorf("%s %q was not highlighted:\n%s", want.what, want.text, got)
@@ -41,7 +41,7 @@ func TestHighlightGoClassifies(t *testing.T) {
// comments, and must survive as such.
func TestHighlightGoKeepsDirectives(t *testing.T) {
got := HighlightGo("//gowasm:page / static layout=public\nfunc HomePage() {}")
if !strings.Contains(got, `<span class="`+goCommentClass+`">//gowasm:page / static layout=public</span>`) {
if !strings.Contains(got, `<span class="`+CommentClass+`">//gowasm:page / static layout=public</span>`) {
t.Errorf("the directive was not kept whole as a comment:\n%s", got)
}
}

View File

@@ -56,6 +56,7 @@ func Hydrate(component func() *vdom.VNode) {
finish(root)
}
// finish wires the hot-swap teardown hook. It RETURNS — see Wait.
func finish(root js.Value) {
// Allow a JS loader to tear this instance down before an in-place hot swap:
// snapshot signal state (so the next instance restores it), then clear #app.
@@ -65,7 +66,29 @@ func finish(root js.Value) {
root.Set("innerHTML", "")
return nil
}))
select {} // keep the runtime alive for event callbacks
}
// Wait blocks forever, keeping the Go runtime alive so its event callbacks can fire.
//
// It is the LAST line of main, and it is not optional: a Go/wasm program whose main
// returns is a dead program, and every handler you registered panics with "Go program has
// already exited" the first time it is touched.
//
// This used to be the last line of Run and Hydrate instead — a `select {}` buried inside
// them. That made both of them functions that never return, which is not what they look
// like, and it quietly turned everything written after the mount call into dead code:
//
// wasmruntime.Hydrate(render)
// app.Theme.Init() // never ran. Not once.
//
// The theme controller sat there for the life of the project not knowing what theme the
// page was in, so the first click of the switch did nothing at all. Anything you have to
// do AFTER the first render — adopt a stored preference, start a poller, measure the
// page — hits the same wall.
//
// So: mounting mounts, and blocking is its own line, where you can see it.
func Wait() {
select {}
}
func scheduleRender() {

View File

@@ -3137,7 +3137,7 @@ const (
FORMULA_EDIT_BASE = "block w-full h-[30px] font-mono text-sm p-1 rounded-default box-border whitespace-pre"
FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-ink"
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-line-strong focus:border-sky-500 outline-hidden"
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-ink resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-line-strong focus:border-sky-500 outline-hidden"
formulaPlaceholder = `<span class="text-ink-faint">e.g. [Revenue] / SUM({Revenue}) * 100</span>`

View File

@@ -48,7 +48,7 @@ func BorderCutCornerCard(class string, children ...*vdom.VNode) *vdom.VNode {
}
const cardHeader = "text-xl tracking-tight text-ink mb-5"
const cardHeaderHR = "text-neutral-200 mt-1 mb-3"
const cardHeaderHR = "text-line mt-1 mb-3"
// CardHeader renders a card title followed by a divider.
func CardHeader(class string, children ...*vdom.VNode) *vdom.VNode {

View File

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

View File

@@ -11,7 +11,7 @@ func PageContainer(children ...*vdom.VNode) *vdom.VNode {
}
// Divider is a thin horizontal rule.
func Divider() *vdom.VNode { return vdom.Hr(vdom.Attr("class", "text-neutral-200 mt-1 mb-3")) }
func Divider() *vdom.VNode { return vdom.Hr(vdom.Attr("class", "text-line mt-1 mb-3")) }
// CodeBox renders a dark monospace code block.
func CodeBox(code, class string) *vdom.VNode {
@@ -25,7 +25,7 @@ func PageHeader(text, class string) *vdom.VNode {
return vdom.Header(vdom.Attr("class", class),
vdom.Div(vdom.Attr("class", "mt-1"),
vdom.H1(vdom.Attr("class", "text-center text-2xl font-light text-ink mb-2"), vdom.Text(text)),
vdom.Hr(vdom.Attr("class", "text-neutral-200 mb-2")),
vdom.Hr(vdom.Attr("class", "text-line mb-2")),
),
)
}