Files
kjol/CLAUDE.md

10 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

  1. 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.
  2. 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.
  3. kjol is edited in place via the submodule + go.work — there is no publish / go get step. Editing a kjol file takes effect in the consuming app immediately.
  4. 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 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}.

jsbundler is the JS build (TSX → Solid → esbuild + the goja SSR bake). It was called webbundler.

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 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.

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 halves 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 .tsx component kit. Apps import components as @ui/*.
  • runtime/ — vendored Solid runtime + vendor.json (base entrypoints). The app merges its own vendor.json on 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.tsx imports pdf-lib and pdfjs-dist at 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-web reference. An app's own frontend/icons is searched FIRST, so an app with a fuller kit keeps it — see jsbundler.iconsDirs.
  • styles/theme.css — the @theme scaffold, the semantic tokens, the .dark overrides, and the :root fa vars. It does the @import "tailwindcss", because the bundler PREPENDS it to the app's style.css and an @import has to come first. An app adopting the shared tree therefore drops that import from its own stylesheet and keeps only brand.
  • auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts — generic TS scaffolding. Apps import as @kjol/*. (Concrete permission constants stay app-side.)

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.work at the app root:
    use (
        .
        ./kjol/go
    )
    
    then vendor with go work vendor (not go 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 thin config.Load wrapper over config.Load[T], and an app-side internal/httpauth for 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-web/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.