Files
kjol/CLAUDE.md

197 lines
12 KiB
Markdown

# 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 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}`.
`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 `.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-website` reference. An app's own `frontend/icons` is searched FIRST, so an app with
a fuller kit keeps it — see `jsbundler.iconsDirs`.
- `styles/theme.css` — the `@theme` scaffold, the semantic tokens, the `.dark` overrides, and the
`:root` fa vars. **It does the `@import "tailwindcss"`**, because the bundler PREPENDS it to the
app's `style.css` and an `@import` has to come first. An app adopting the shared tree therefore
drops that import from its own stylesheet and keeps only brand.
- `auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts` — generic TS
scaffolding. Apps import as `@kjol/*`. (Concrete permission constants stay app-side.)
**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-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`.