12 KiB
kjol
kjol ("keel" in Norwegian) is a shared, multilingual base layer factored out of the
user's applications so they stay in sync. It is consumed as a git submodule inside each
app (at <app>/kjol). Current consumers: cdrateline.com_2.0 and Hotlap — near-identical
forks it was extracted from. Scope will grow to more projects and languages.
Golden rules
- The framework NEVER imports application code. Wherever kjol needs app-specific
behavior, the app injects it (interfaces, registration functions, config structs,
callbacks). See Coupling inversions below. If you find yourself wanting to
import "<app>/internal/..."from kjol, invert it instead. - Never run git operations here on the user's behalf. The user creates the submodule and makes all commits. You may edit files, build, and test.
- kjol is edited in place via the submodule +
go.work— there is no publish /go getstep. Editing a kjol file takes effect in the consuming app immediately. - When a file exists in both apps and has drifted, reconcile by merging best-of-both.
Organization — by build root
Each top-level directory is one language / build root:
kjol/
go/ the Go module `kjol` (go.mod lives here). Imports are `kjol/<pkg>`.
ALSO holds jsruntime/ — all the JS/TS. See below.
c/ C base layer (arena, strings, math, lexer, platform).
jai/ Jai modules. Early.
# future: kotlin/ swift/
The JS tree lives inside go/, at go/jsruntime. It has no Go in it beyond a doc
file — it is the Solid kit, the vendored Solid runtime, the FontAwesome SVGs and the
Tailwind @theme scaffold. It sits there because the thing that BUILDS it is Go
(go/jsbundler), the thing that styles it is Go (go/tw), and a sibling web/ at the
repo root was one more directory the build had to go hunting for. go build ./...
ignores it; nothing imports it as a package.
Consequence, and don't "fix" it: the web bundler is Go. tw is the Tailwind v4
compiler and is deliberately NOT inside the bundler — Tailwind only reads text and
writes CSS, and the text is just as likely to be Go (the gowasm kit writes its markup in
Go and has no JS build at all). Keeping it in the bundler made every Go-only consumer
drag a JavaScript bundler along for a CSS file.
go/ — module kjol
Packages, imported as kjol/<name>: appenv basic chrono config csv dbutil finance httputil l4g lexer security snailmail validation jsbundler tw, plus the gowasm web-UI engine
(wasmruntime and its sub-packages wasmruntime/vdom + wasmruntime/rsc, wasmdevserver,
and webui — a Tailwind-styled component kit ported from jsruntime/uikit; author
components in pure Go compiled to WebAssembly; all stdlib-only), and
cmd/{bundle,twcss,migrate,loc,passgen,typecheck,wasmgen}.
vdom and rsc are sub-packages of wasmruntime — imported as kjol/wasmruntime/vdom
and kjol/wasmruntime/rsc (the package names stay vdom / rsc, so call sites are still
vdom.VNode, rsc.Mount). They nest cleanly because vdom imports nothing and both rsc
and wasmruntime import only vdom — no child imports the parent, so there is no cycle.
wasmgen emits these import paths in the *.gen.go glue, so if they ever move again, its
templates move with them.
jsbundler is the JS build (TSX → Solid → esbuild + the goja SSR bake). It was called
webbundler.
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-website/build.Tailwind). The C tables deliberately
mirror c/lexer/lexer_c.c; add a type to one, add it to the other.
cmd/kjol-website is the website: the landing page and documentation for the whole
codebase, and the runnable example of both web layers. It is its own nested module (so its
go-chart / esbuild / goja deps stay out of kjol) and it is ONE server running TWO
front-ends — /wasm/* is the Go→WebAssembly SPA, /js/* is the Solid SPA, and / is a
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 ./...
go -C go vet ./...
go -C go test ./...
The wasm host API (wasmruntime/host.go + host_wasm.go / host_native.go) is how neutral
component code reaches the browser: element measurement (Measure, Viewport), imperative style
writes (SetStyle — positioning must NOT go through signals, which re-render the whole tree),
the post-commit hook (AfterRender, the only point at which a just-rendered element can be
measured), document/window listeners, timers, localStorage, and file download. It is dual-build:
real under js && wasm, no-op stubs natively — which is what lets webui call it unconditionally
and still SSR. Refs come from vdom.Ref + vdom.WithRef; vdom.Portal mounts children at
document.body (needed to escape overflow:hidden / transform ancestors).
The reconciler is wasm-only and needs a DOM, so go test ./... cannot reach it. It has its own
harness — a minimal DOM under node:
GOOS=js GOARCH=wasm go -C go test -exec="node testdata/domexec.js" ./wasmruntime
webui components that measure the page (Tooltip, Popover, Menu, Modal, DatePicker, Tutorial,
AutoTable, SignaturePad, AsyncCombobox) are controllers: create them once alongside your
signals, never inside a render closure. Floating panels share one positioning engine
(webui/position.go, pure math, unit-tested natively) driven by the Floating controller
(webui/floating.go).
Theming / dark mode — BOTH kits, one vocabulary. Neither kit names a colour: components say
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 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
neutral button — its label must darken when the fill goes pale, hence the three fill-neutral
tokens).
@custom-variant dark (&:where(.dark, .dark *)); is required — the built-in dark variant is a
prefers-color-scheme media query, which a site with its own switch cannot use (the OS says one
thing, the switch says another, and the media query wins). For gowasm the app defines the tokens
(see webui.ThemeTokens); for the Solid kit jsruntime/styles/theme.css defines them and the
bundler prepends it, so the app's style.css carries brand only.
Controllers: webui.Theme (Go) and jsruntime/uikit/Theme.tsx (Solid) — both read the same
kjol-theme localStorage key, so a preference survives crossing between two front-ends.
webui.ThemeBootScript goes in the document head before the stylesheet, or dark-mode users
get a white flash until the bundle loads. It is the only hand-written JavaScript in a gowasm app.
go/jsruntime — the JS/TS tree
uikit/— Solid.js.tsxcomponent kit. Apps import components as@ui/*.runtime/— vendored Solid runtime +vendor.json(base entrypoints). The app merges its ownvendor.jsonon top; kjol's solid-js must resolve first so there is a single reactive instance (a split one does not error — it silently stops flushing effects). ⚠uikit/AutoTable.tsximportspdf-libandpdfjs-distat the TOP LEVEL, so any app using AutoTable must vendor them or the bundle fails to evaluate at all.icons/— FontAwesome SVG source kit (the bundler scans usage and generates a per-app registry; the generated file is app-owned, not committed here). kjol ships only the SUBSET its own kit +kjol-websitereference. An app's ownfrontend/iconsis searched FIRST, so an app with a fuller kit keeps it — seejsbundler.iconsDirs.styles/theme.css— the@themescaffold, the semantic tokens, the.darkoverrides, and the:rootfa vars. It does the@import "tailwindcss", because the bundler PREPENDS it to the app'sstyle.cssand an@importhas to come first. An app adopting the shared tree therefore drops that import from its own stylesheet and keeps only brand.auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts— generic TS scaffolding. Apps import as@kjol/*. (Concrete permission constants stay app-side.)
Frontend import aliases (resolved by the bundler and mirrored in each app's tsconfig
paths): @ui/* → go/jsruntime/uikit, @kjol/* → go/jsruntime/, @appgen/* → the app's
generated dir (e.g. the FA faIcons registry — app-owned, gitignored, regenerated each build).
The kit's own imports of sibling components stay relative (./Buttons.tsx).
Solid gotchas (they bite every time): DOM handlers keep their DOM names — onclick,
oninput, onchange, not onClick. Everything is a named export. And Tailwind finds classes
by scanning source for literal strings, so "bg-" + name compiles to nothing — write the
class out in full.
Consumption (per app)
go.workat the app root:then vendor withuse ( . ./kjol/go )go work vendor(notgo mod vendor).- Startup wiring the app performs (this is how the inversions get their app-side halves):
dbutil.RegisterAll(models.Tables),l4g.SetDatabaseWriter(...),dbutil.Init(dbutil.ConnConfig{...}),snailmail.Configure(snailmail.Settings{...}), a thinconfig.Loadwrapper overconfig.Load[T], and an app-sideinternal/httpauthfor the session/authn middleware.
Coupling inversions (how the framework stays app-agnostic)
| Package | Inversion |
|---|---|
dbutil |
table names via Register/RegisterAll (not the app's models.Tables) |
l4g |
owns the Entry type; DB persistence via SetDatabaseWriter(func(Entry) error) |
config |
generic Load[T](file, *T) error; each app defines its own config struct |
dbutil.ConnConfig, snailmail.Settings |
DB / mail credentials injected, never read from app config |
appenv |
compile-time environment via build tags (-tags staging / -tags production); the bundler reads appenv.Environment for the JS __ENV_TYPE__ define |
httputil.CorsMiddleware(CorsConfig{...}) |
allowed domains + bundle-version source injected |
wasmdevserver |
the app injects Build / Render / Document / Handle via Config |
jsbundler public pages |
kjol generates the registry (public_pages.gen.go); the APP owns the publicPage type it is written against, and the document shell. See cmd/kjol-website/internal/handlers. |
Stays app-side (never moves into kjol)
Domain models/repository/handlers-api, migrations, pages/routes/layouts, brand UI
(TopBar/AppSidebar/TransitionOverlay), concrete permission constants, the app config
struct, embed.go + wwwroot/, and generated artifacts (faIcons registry, routes.gen.ts,
public_pages.gen.go).
Provenance
Big-bang extraction from cdrateline + Hotlap. The detailed migration plan lives on the
author's machine at ~/.claude/plans/foamy-humming-tulip.md.