Begin go documentation (ai slop for now)

This commit is contained in:
2026-07-15 09:58:33 -04:00
parent 7d7b7354df
commit 0267297534
7 changed files with 431 additions and 82 deletions

View File

@@ -0,0 +1,303 @@
package app
import (
. "kjol/vdom"
ui "kjol/webui"
)
// The Go layer's documentation.
//
// One page, like /c and the component pages. The Go module is the biggest layer by far —
// a dozen small packages plus both web engines — so the job here is a MAP, not a manual:
// say what each subsystem is for and name the handful of identifiers you would reach for,
// and leave the exhaustive reference to go doc.
//
// The web engines get a deliberately short section. They are the two compositions, and
// each already has a whole documentation section of its own (/wasm, /js) — repeating it
// here would be two maps of the same ground, kept in sync by hand. So this page points at
// them and moves on.
//
// The organising idea is the same as the C page: every section is a SUBSYSTEM, and the
// sidebar lists them. A subsystem here is a small group of packages that answer one
// question — "how does it talk to a database", "how does it not trust its input" — rather
// than one package per section, which for thirteen packages would be a wall.
// goSubsystems 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 goSubsystems() []subsystem {
return []subsystem{
{ID: "config", Label: "Configuration", Icon: "bolt",
Blurb: "Where the binary learns its world: the environment baked in at compile time, and the config struct read at startup."},
{ID: "data", Label: "Data", Icon: "table",
Blurb: "A PostgreSQL query builder and row-to-struct automapper, and CSV in and out."},
{ID: "http", Label: "HTTP & email", Icon: "globe",
Blurb: "Dependency-free HTTP glue — CORS, responses, a typed client — and pluggable mail."},
{ID: "values", Label: "Values", Icon: "calculator",
Blurb: "The small stuff done once: generic helpers, UTC-first time, and money as integer cents."},
{ID: "trust", Label: "Trust & logging", Icon: "shield-check",
Blurb: "Crypto, input validation, and logging — the three that decide what the program will believe and remember."},
{ID: "text", Label: "Text", Icon: "code",
Blurb: "Syntax highlighting: source in, coloured HTML out. A lexer, not a parser."},
{ID: "engines", Label: "Web engines", Icon: "layers",
Blurb: "The two front-end frameworks are Go too — but documented on their own pages. This is only the pointer."},
{ID: "tooling", Label: "Tooling", Icon: "cube",
Blurb: "The command-line tools, most of them run by the build rather than by hand."},
}
}
// goNav is the sidebar while you are reading /go. The group is called Subsystems, as on /c.
func goNav() []docsGroup {
items := make([]docsItem, 0, len(goSubsystems()))
for _, s := range goSubsystems() {
items = append(items, docsItem{
Path: "/go#" + s.ID,
Label: s.Label,
Icon: s.Icon,
Blurb: s.Blurb,
})
}
return []docsGroup{
{
Title: "Introduction",
Items: []docsItem{
{Path: "/go", Label: "Overview", Icon: "book-open",
Blurb: "What the Go layer is, and the one rule that shapes all of it."},
},
},
{Title: "Subsystems", Items: items},
}
}
//gowasm:page /go static layout=app
func GoPage(d Deps) func() *VNode {
return func() *VNode {
return docPage("Layers", "Kjøl Go",
"The oldest and largest layer: configuration, a database toolkit, logging, HTTP helpers, "+
"mail, money math, validation — the parts an application needs that are not the application. "+
"Both web engines live here too, but those have their own pages; this is the base beneath them.",
docSection("what-this-is", "What this is",
prose("A single Go module, imported package by package as kjol/<name>. Each package is small, "+
"stdlib-first, and does one thing — there is no framework object to construct and no "+
"lifecycle to learn. You import config, or dbutil, or chrono, and call it."),
prose("One rule shapes the whole layer, and it is worth stating before the parts: the framework "+
"NEVER imports application code. Where a package needs something only the app knows — the "+
"names of its tables, where to write a log, its mail credentials, the shape of its config — "+
"the app hands that in, and the package is written against the gap. That is why dbutil has a "+
"Register, l4g a SetDatabaseWriter, snailmail a Configure, and config a generic Load[T]. The "+
"inversions are not decoration; they are the reason one base layer can sit under several "+
"different applications without knowing anything about any of them."),
note("This layer is consumed in place, not published",
"kjøl is a git submodule inside each app, wired up with a go.work file — so editing a file "+
"here takes effect in the consuming app immediately, with no version to bump and no go get. "+
"There is no ABI to keep stable because there is nothing to keep stable between: the layer "+
"and its consumer are built together."),
),
goConfig(),
goData(),
goHTTP(),
goValues(),
goTrust(),
goText(),
goEngines(),
goTooling(),
)
}
}
// ---- configuration -------------------------------------------------------
func goConfig() *VNode {
return docSection("config", "Configuration",
prose("Two packages, and they answer the same question — what world is this binary running in — at "+
"two different times. appenv answers it at COMPILE time: Environment is a const chosen by a build "+
"tag, so a production binary cannot be talked into thinking it is staging by a stray environment "+
"variable. The value is fixed the moment go build runs, and the bundler reads it to define the "+
"same constant for the JavaScript side."),
prose("config answers it at RUNTIME. Load[T] fills the app's own config struct from environment "+
"variables and an optional .env file, with real environment variables always winning over the "+
"file. The app defines the struct — its fields, its env tags, its defaults — and the package only "+
"provides the generic loading, so no two apps have to agree on what configuration means."),
apiTable(
apiRow{"appenv.Environment", "The deployment environment, a const fixed by build tag (-tags staging / production). No runtime path can change it."},
apiRow{"config.Load[T](file, *T)", "Fill your config struct from env + .env. Generic over the struct; environment variables beat the file."},
),
)
}
// ---- data ----------------------------------------------------------------
func goData() *VNode {
return docSection("data", "Data",
prose("dbutil is the largest package in the module, and it is two things: a PostgreSQL query builder "+
"and a reflection-based row-to-struct automapper. It is deliberately NOT an ORM — there are no "+
"migrations here (that is a separate tool) and no magic persistence. You bind a model type to a "+
"table, build parameterized SQL through chainable Select / InsertInto / Update / DeleteFrom, and "+
"scan the result straight into your structs by their db tags — including LEFT JOINs, which map a "+
"missing joined row to a nil pointer rather than a lie."),
prose("The table names come from a registry the app fills at startup (RegisterAll), which is the "+
"inversion at work: the builder resolves a Go type to a table without ever importing the app's "+
"models. Field references are type-safe — you pass a pointer to a struct field and the builder "+
"turns it into a column — so a renamed field is a compile error, not a wrong query at runtime."),
prose("csv is the small sibling: build CSV text from headers and rows, or straight from a slice of "+
"structs, and stream it to the browser as a download."),
apiTable(
apiRow{"dbutil.Init / ConnConfig", "Open the pooled *sql.DB and pin the session to UTC. Credentials are injected, never read from app config."},
apiRow{"dbutil.Register / RegisterAll", "Map a model type to its table name. The inversion: dbutil never imports your models."},
apiRow{"dbutil.Select / InsertInto / Update / DeleteFrom", "Chainable builders — Where, joins, order, paging — that emit $1,$2 parameterized SQL and its args."},
apiRow{"dbutil.ScanAll / QueryOne / QueryScalar[T]", "Result rows into your structs by db tag; joined rows that are all-NULL become nil pointers."},
apiRow{"dbutil.ParseFilterFromRequest / ApplyPagination", "Turn a request's query-string filters and paging into WHERE and LIMIT."},
apiRow{"csv.MakeCSV / StructToCSV / WriteCSVtoHTTP", "CSV from rows or from a slice of structs, and the headers to send it as an attachment."},
),
)
}
// ---- http & mail ---------------------------------------------------------
func goHTTP() *VNode {
return docSection("http", "HTTP & email",
prose("httputil is the HTTP glue, and it imports nothing of the app's. CorsMiddleware is "+
"constructor-style — you hand it the allowed domains and a function that reports the current "+
"bundle version, and it never reads those from config itself. Alongside it are the response "+
"writers (JSON, gob, error) and their client-side mirrors: FetchGob[T] and FetchJSON[T] do a typed "+
"GET-and-decode, and are a deliberate no-op during server rendering, so an SSR pass keeps its "+
"loading state instead of blocking on a network call."),
prose("snailmail sends mail through a provider chosen at startup — SMTP or Cloudflare — with the "+
"credentials injected via Configure and the actual branded message composed on the app side. The "+
"package takes an already-rendered Email and a type (text or HTML) and sends it; it does not know "+
"or care what the mail says."),
apiTable(
apiRow{"httputil.CorsMiddleware(CorsConfig)", "CORS as constructor-style middleware; allowed domains and the bundle-version source are injected."},
apiRow{"httputil.RespondJSON / RespondGob / RespondError", "Encode and write a response."},
apiRow{"httputil.FetchGob[T] / FetchJSON[T]", "Client-side typed GET+decode. A no-op during SSR, so the server keeps a loading state."},
apiRow{"snailmail.Configure(Settings)", "Pick SMTP or Cloudflare and hand it credentials, once at startup."},
apiRow{"snailmail.SendMail(Email, type)", "Send an already-rendered message. TYPE_TEXT or TYPE_HTML."},
),
)
}
// ---- values --------------------------------------------------------------
func goValues() *VNode {
return docSection("values", "Values",
prose("Three packages of the small stuff, done once so the apps do not each do it slightly "+
"differently. basic is the personal standard library: generic slice, map and pointer helpers, "+
"name capitalization that knows about Mc and O', number-to-string with commas, a reflection-based "+
"struct diff. chrono treats every stored time as UTC and only localizes at the edge — format a "+
"time in a timezone for display, parse an HTML date input back to UTC, render \"3 days ago\". "+
"finance keeps money as integer cents, never a float, and formats it back out with a symbol and "+
"grouping."),
apiTable(
apiRow{"basic.Reverse / IndexOf / RemoveDuplicates / MapMerge", "Generic slice and map helpers, stdlib-only."},
apiRow{"basic.NormalizeName / Int64ToStringWithCommas / CompareStructs", "Name casing, grouped numbers, and a field-by-field struct diff."},
apiRow{"chrono.FormatWithTz / DateToHTMLString", "A UTC time localized for display, or fed into an HTML date field."},
apiRow{"chrono.HTMLDateToTime / TimeSinceToString", "An HTML input parsed back to UTC; and \"Just now\" / \"3 days ago\"."},
apiRow{"finance.Int64ToMoneyWithCommas / MoneyToInt64", "Cents-as-int64 to a dollar string and back — no float ever touches the money."},
apiRow{"finance.MultiplyByPercentage / DaysToRateTerm", "Percentage math on cents, and a day count as a best-fit term string."},
),
)
}
// ---- trust & logging -----------------------------------------------------
func goTrust() *VNode {
return docSection("trust", "Trust & logging",
prose("Three packages that decide what the program will believe and what it will remember. security "+
"is the crypto: bcrypt for passwords, AES-256-GCM for secrets (with generic EncryptData[T] that "+
"gob-serializes then encrypts), hashing, base58/64, random keys, and a bluemonday HTML "+
"sanitization policy you initialize at startup. validation cleans and checks input — email, "+
"phone, US state, tax id, ZIP — and its validators return descriptive errors rather than a bare "+
"false, so the caller can say what was wrong."),
prose("l4g is logging, and it carries the same inversion as dbutil. It owns the Entry type — the "+
"framework's mirror of the app's log-entry model — and persists to the database through a function "+
"the app registers with SetDatabaseWriter. If none is registered it falls back to the terminal, so "+
"a line is never silently dropped. The main logger is terminal, file, or database, chosen by an "+
"environment variable."),
apiTable(
apiRow{"security.HashPassword / ComparePasswords", "bcrypt."},
apiRow{"security.EncryptData[T] / DecryptData[T]", "gob-serialize then AES-256-GCM, generic over the value."},
apiRow{"security.Init / SanitizationPolicy", "The bluemonday UGC policy (extended to allow svg/path). Initialize it before use."},
apiRow{"validation.SanitizeEmail / ValidatePhoneNumber / ValidateStateCode", "Clean and check US-centric input; validators return an error, not a bool."},
apiRow{"l4g.Init / Write / Fatal", "Terminal, file, or database logging, selected by LOGGER_TYPE."},
apiRow{"l4g.SetDatabaseWriter(func(Entry) error)", "The inversion: l4g owns Entry, the app owns the table it lands in."},
),
)
}
// ---- text ----------------------------------------------------------------
func goText() *VNode {
return docSection("text", "Text",
prose("lexer is syntax highlighting: source code in, HTML with coloured spans out. It is a lexer and "+
"not a parser on purpose — it degrades to escaped plain text on anything it does not understand "+
"rather than failing, so an unknown language is not an error and a half-written snippet still "+
"renders. It lives beside webui rather than inside it because it touches no DOM; it is a string in "+
"and a string out. It is what colours the C snippets over on the C page."),
apiTable(
apiRow{"lexer.Highlight(lang, src)", "Dispatch by language name. An unknown language comes back escaped and unpainted, not wrong."},
apiRow{"lexer.HighlightGo / HighlightC", "The two languages implemented so far."},
),
note("Its output is class names, so the stylesheet has to know it exists",
"The spans lexer emits carry Tailwind classes (text-emerald-300 and the like), so any stylesheet "+
"that renders a code block has to scan lexer/**/*.go for them. A build that forgets still "+
"compiles and just renders the snippet unstyled — which is exactly how it is wired into this "+
"site's Tailwind step."),
)
}
// ---- web engines ---------------------------------------------------------
// The short section, on purpose: these are the two compositions, and each is documented in
// full elsewhere. All this page owes them is a sentence and a door.
func goEngines() *VNode {
return docSection("engines", "Web engines",
prose("Both of kjøl's web frameworks are assembled out of this layer — and both have their own "+
"documentation, so this is only the map. The gowasm engine (the packages vdom, wasmruntime, rsc "+
"and wasmdevserver, plus the webui component kit) lets you write user interfaces as ordinary Go "+
"compiled to WebAssembly, server-rendered and then hydrated, with no JavaScript build at all. "+
"jsbundler and tw are the other road: the JavaScript build — TSX to Solid to esbuild — and a "+
"Tailwind v4 compiler written in Go, which is what styles both engines."),
P(Attr("class", "mt-4 flex flex-wrap gap-3"),
engineLink("/wasm", "code", "Kjøl Wasm Web"),
engineLink("/js", "table", "Kjøl JS Web"),
),
)
}
// engineLink is a pill that crosses into a composition's documentation. A plain href, not a
// client-side route: /js is a different binary's SPA, and even /wasm is reached most simply
// by letting the browser navigate rather than asking this page to swap itself out. No Deps,
// therefore — there is no navigate() to intercept.
func engineLink(href, icon, label string) *VNode {
return A(Attr("class", "inline-flex items-center gap-2 rounded-default border border-line px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:border-primary-border hover:bg-primary-subtle hover:text-accent"),
Attr("href", href),
ui.IconInline(icon, 14, "text-ink-faint"),
Text(label),
ui.IconInline("arrow-right", 12, "text-ink-faint"),
)
}
// ---- tooling -------------------------------------------------------------
func goTooling() *VNode {
return docSection("tooling", "Tooling",
prose("The module ships a handful of command-line programs under cmd/. Most of them are run by the "+
"build rather than typed by hand: wasmgen reads the //gowasm: directives and writes the route and "+
"layout glue, twcss compiles the Tailwind stylesheet, bundle drives the JavaScript build, and "+
"typecheck runs the TypeScript checker. The rest are operational: migrate applies database "+
"migrations, loc reports the lines of code across the repository, and passgen bcrypt-hashes a "+
"password from the command line."),
apiTable(
apiRow{"cmd/wasmgen", "Preprocesses the //gowasm: directives into glue: the route map, the layouts, the server-component calls."},
apiRow{"cmd/twcss", "The Tailwind v4 compiler as a CLI — scan the sources, write the stylesheet."},
apiRow{"cmd/bundle", "A thin CLI over jsbundler: TSX → Solid → esbuild, plus the SSR bake."},
apiRow{"cmd/typecheck", "Runs the frontend TypeScript checker (tsgo, the native-Go TypeScript compiler)."},
apiRow{"cmd/migrate", "PostgreSQL migrations — up and down, behind an advisory lock."},
apiRow{"cmd/loc", "A lines-of-code report over git-tracked files (gocloc), vendored code excluded."},
apiRow{"cmd/passgen", "bcrypt-hash a password given on the command line."},
),
)
}

View File

@@ -72,6 +72,8 @@ func Languages() []Layer {
Name: "Go",
Href: "/go",
Tagline: "The base: config, database, logging, HTTP, mail, validation — and both web engines.",
Sub: "the base layer",
Live: true,
Icon: "server",
},
{
@@ -196,9 +198,12 @@ func layerGrid(rows []Layer) *VNode {
return Div(mods...)
}
// No icon beside the name, and none on the "Read the docs" link. The front page reads as a
// short list of what kjøl is, and a glyph next to every row — a boat, a table, a globe —
// asks to be decoded before the word beside it is read. The words are the point; they carry
// themselves. (The reference badge stays: it says something the name does not.)
func layerRow(l Layer) *VNode {
head := Span(Attr("class", "flex items-center gap-2"),
ui.IconInline(l.Icon, 15, iff(l.Live, "text-accent", "text-ink-muted")),
Span(Attr("class", "font-medium text-ink"), Text(l.Name)),
iff2(l.Live,
func() *VNode { return nil },
@@ -207,7 +212,7 @@ func layerRow(l Layer) *VNode {
Text("reference"))
}),
)
body := P(Attr("class", "mt-1 pl-[23px] text-sm leading-relaxed text-ink-muted"), Text(l.Tagline))
body := P(Attr("class", "mt-1 text-sm leading-relaxed text-ink-muted"), Text(l.Tagline))
if !l.Live {
return Div(Attr("class", "px-5 py-4 opacity-75"), head, body)
@@ -215,22 +220,13 @@ func layerRow(l Layer) *VNode {
// A real navigation: the next layer is a different binary.
return A(Attr("class", "block px-5 py-4 no-underline hover:bg-surface-muted"), Attr("href", l.Href),
head, body,
Span(Attr("class", "mt-2 inline-flex items-center gap-1.5 pl-[23px] text-sm text-accent"),
Text("Read the docs"),
ui.IconInline("arrow-right", 12, ""),
),
Span(Attr("class", "mt-2 inline-block text-sm font-medium text-accent"),
Text("Read the docs")),
)
}
// iff picks a string; iff2 picks a node. Go has no ternary, and a four-line if
// statement inside a tree literal breaks the shape of the markup worse than these do.
func iff(cond bool, a, b string) string {
if cond {
return a
}
return b
}
// iff2 picks a node. Go has no ternary, and a four-line if statement inside a tree literal
// breaks the shape of the markup worse than this does.
func iff2(cond bool, a, b func() *VNode) *VNode {
if cond {
return a()
@@ -238,18 +234,20 @@ func iff2(cond bool, a, b func() *VNode) *VNode {
return b()
}
// No icon on the menu rows either — the name and its one-line tagline are the whole item,
// same as the front-page list and the sidebar. (The chevron on the menu TRIGGER stays: it
// is not a layer's glyph, it is the cue that the thing opens.)
func layerItem(d Deps, l Layer) *VNode {
active := CurrentLayer(d.Path()) != nil && CurrentLayer(d.Path()).Href == l.Href
if !l.Live {
return Div(Attr("class", "flex cursor-default flex-col gap-0.5 px-3 py-2 opacity-55"),
Span(Attr("class", "flex items-center gap-2 text-sm font-medium text-ink-muted"),
ui.IconInline(l.Icon, 14, "text-ink-faint"),
Text(l.Name),
Span(Attr("class", "rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted"),
Text("reference")),
),
Span(Attr("class", "pl-6 text-xs text-ink-muted"), Text(l.Tagline)),
Span(Attr("class", "text-xs text-ink-muted"), Text(l.Tagline)),
)
}
@@ -258,10 +256,7 @@ func layerItem(d Deps, l Layer) *VNode {
cls += " bg-primary-subtle"
}
return A(Attr("class", cls), Attr("href", l.Href),
Span(Attr("class", "flex items-center gap-2 text-sm font-medium text-ink"),
ui.IconInline(l.Icon, 14, "text-accent"),
Text(l.Name),
),
Span(Attr("class", "pl-6 text-xs text-ink-muted"), Text(l.Tagline)),
Span(Attr("class", "text-sm font-medium text-ink"), Text(l.Name)),
Span(Attr("class", "text-xs text-ink-muted"), Text(l.Tagline)),
)
}

View File

@@ -78,7 +78,11 @@ func wordmark(d Deps, href string) *VNode {
}
return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default bg-ink text-surface"),
// text-white, not text-surface: the flag is the same in both themes, so the boat on
// top of it has to be too. text-surface inverts to near-black in dark mode, which
// would hide the boat against the navy cross. The flag itself carries a dark scrim
// (see .flag-no) so this plain white boat reads without a shadow of its own.
Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default flag-no text-white"),
ui.IconInline("sailboat", 17, "")),
Span(Attr("class", "flex items-baseline gap-1.5"),
Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text(name)),
@@ -202,10 +206,14 @@ func AppLayout(d Deps, content *VNode) *VNode {
// 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/") {
switch {
case path == "/c" || strings.HasPrefix(path, "/c/"):
return cNav()
case path == "/go" || strings.HasPrefix(path, "/go/"):
return goNav()
default:
return docsNav()
}
return docsNav()
}
func docsSidebar(d Deps) *VNode {
@@ -225,11 +233,13 @@ func docsSidebar(d Deps) *VNode {
return El("aside", mods...)
}
// No icon: the sidebar is a list of words, and a glyph on every row is noise the reader has
// to look past to read the label. The label is the navigation. (docsItem still carries an
// Icon — it is used on the /docs index cards, where a larger tile earns one.)
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"
cls := "block rounded-default px-2 py-1.5 text-sm no-underline text-ink-soft hover:bg-surface-raised hover:text-ink"
// 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
@@ -240,8 +250,7 @@ func sidebarLink(d Deps, it docsItem) *VNode {
// 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"
cls = "active block rounded-default px-2 py-1.5 text-sm no-underline bg-primary-subtle font-medium text-accent"
}
click := navigate(d, it.Path)
@@ -250,7 +259,6 @@ func sidebarLink(d Deps, it docsItem) *VNode {
}
return A(Attr("class", cls), Attr("href", it.Path), click,
ui.IconInline(it.Icon, 14, iconCls),
Text(it.Label),
)
}

View File

@@ -9,6 +9,7 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
"/": HomePage(d),
"/about": AboutPage(d),
"/c": CPage(d),
"/go": GoPage(d),
"/wasm": DocsPage(d),
"/wasm/chart": ChartPage(d),
"/wasm/components": ComponentsPage(d),
@@ -22,6 +23,7 @@ var StaticPaths = map[string]bool{
"/": true,
"/about": true,
"/c": true,
"/go": true,
"/wasm": true,
"/wasm/chart": true,
"/wasm/data": true,
@@ -32,6 +34,7 @@ var RouteLayout = map[string]string{
"/": "public",
"/about": "public",
"/c": "app",
"/go": "app",
"/wasm": "app",
"/wasm/chart": "app",
"/wasm/components": "app",

View File

@@ -86,11 +86,13 @@
--color-primary-subtle: #eef2f8; /* a navy wash — tinted panels, badges, callouts */
--color-primary-border: #c5d1e2;
/* 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 */
/* accent is for TEXT and icons — links, the eyebrow, an active sidebar row. It is still 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. Here they are the same hue navy — but not the same value: the accent is a
touch deeper so a navy link on white is unmistakably a link. (The red is gone; the
brand is navy throughout now. Only the flag keeps its red field.) */
--color-accent: #1c3a66; /* navy — accent TEXT */
/* Surfaces, lines, ink — the kit's theme contract. */
--color-surface: #ffffff;
@@ -157,17 +159,14 @@
--color-ink-muted: #9a9aa3;
--color-ink-faint: #71717a;
/* Both brand tokens move in the dark, and for different reasons.
/* Both brand tokens move in the dark, and both for the same reason now: navy is too dark
to read on a near-black page, so each climbs to a lighter blue.
The accent 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;
The accent (TEXT) climbs furthest — a link has to be legible at body-text weight, so it
goes to a soft sky. The fill climbs less: it only has to look like a button and still
carry white text (~7:1), so it lifts to a steel blue and stops there, well below where
the accent lands. */
--color-accent: #9fc1ec;
--color-primary: #2b4f80;
--color-primary-hover: #37619b;
--color-primary-subtle: #182234;
@@ -186,6 +185,40 @@ html {
color: var(--color-ink);
}
/* ---------------------------------------------------------------------------
The wordmark's logo tile: a muted Norwegian flag.
---------------------------------------------------------------------------
kjøl is a Norwegian word — the mark says so. It is the flag's Scandinavian cross: a
navy cross with an off-white outline on a red field, offset LEFT as the real flag is
(the vertical bar sits at ~38% rather than centre). Muted, not the flag's full
saturation — it is a 32px tile next to body text, not a banner.
Drawn in layered gradients over a red base rather than as an <img>, so it inherits the
tile's rounded corners and border, needs no asset request, and cannot 404. Layers paint
TOP-first, so the reading is: dim scrim, then navy cross, off-white cross, red field. Each
band is transparent outside its stripe (hard stops via doubled positions) so the layer
beneath shows through. The bands are constant across light and dark — a flag does not
change with the page theme.
The topmost layer is a flat dark scrim, and it is there so the WHITE sailboat on top of
the tile reads on its own — no outline, no shadow on the glyph. The boat is a thin white
stroke and the off-white cross is exactly the band it would vanish into; rather than trace
the boat in shadow, the whole flag is dimmed until white stands out against every band of
it, the pale cross included. A dimmer, moodier flag with a crisp white boat, on purpose.
The cross geometry, as percentages of the tile:
vertical (offset left, centre 38%): off-white 2749%, navy 32.543.5%
horizontal (centred, centre 50%): off-white 3961%, navy 44.555.5% */
.flag-no {
background-color: #a83f4c; /* the red field, muted (then dimmed by the scrim above) */
background-image:
linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42)), /* the dim scrim, on top */
linear-gradient(180deg, transparent 44.5%, #294c76 44.5%, #294c76 55.5%, transparent 55.5%),
linear-gradient(90deg, transparent 32.5%, #294c76 32.5%, #294c76 43.5%, transparent 43.5%),
linear-gradient(180deg, transparent 39%, #ece6da 39%, #ece6da 61%, transparent 61%),
linear-gradient(90deg, transparent 27%, #ece6da 27%, #ece6da 49%, transparent 49%);
}
.bg-grid {
background-image:
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),

View File

@@ -16,19 +16,19 @@
--------------------------------------------------------------------------- */
@theme {
/* 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.
/* The brand: navy throughout, a muted flag-navy. These are the SAME values the Go/WASM
section's css/app.css sets, and under the same names — so the Layers menu, the
sidebar highlight and the callouts are the same navy on both sides of the site rather
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. */
primary FILLS (white text sits on it); accent is TEXT and icons (it has to be
readable on the surface) and is a slightly deeper navy so a link reads as one;
primary-subtle/-border are the tinted panel. Only the flag tile keeps a red. */
--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 */
--color-accent: #1c3a66; /* navy — accent TEXT (links, eyebrow, active rows) */
/* 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
@@ -85,18 +85,32 @@ html {
/* 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.
Navy is too dark to read on a near-black page, so both brand tokens climb to a lighter
blue. The accent (TEXT) climbs furthest, to a soft sky a link stays legible in; the fill
climbs less, to a steel blue that still looks like a button and still carries white text.
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-accent: #9fc1ec;
--color-primary: #2b4f80;
--color-primary-hover: #37619b;
--color-primary-subtle: #182234;
--color-primary-border: #2c3e5c;
}
/* The wordmark's logo tile: a muted Norwegian flag. Identical to the Go/WASM section's
css/app.css — the two front-ends share one mark. See there for the full note; in short
it is the flag's Scandinavian cross (navy cross, off-white outline, red field, offset
left) drawn in layered gradients over a red base, constant across themes, with a flat
dark scrim on top so the plain white sailboat reads on it without an outline. */
.flag-no {
background-color: #a83f4c;
background-image:
linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42)),
linear-gradient(180deg, transparent 44.5%, #294c76 44.5%, #294c76 55.5%, transparent 55.5%),
linear-gradient(90deg, transparent 32.5%, #294c76 32.5%, #294c76 43.5%, transparent 43.5%),
linear-gradient(180deg, transparent 39%, #ece6da 39%, #ece6da 61%, transparent 61%),
linear-gradient(90deg, transparent 27%, #ece6da 27%, #ece6da 49%, transparent 49%);
}

View File

@@ -93,22 +93,18 @@ function LayerItem(props: { layer: Layer; current?: Layer }) {
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>
<span class="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 plain <a href>, not the router's <A>: the other side is served by a
different binary, so crossing to it has to be a real navigation, not a
client-side route the router would try to handle itself. */}
<a
href={props.layer.href}
class={
@@ -116,11 +112,8 @@ function LayerItem(props: { layer: Layer; current?: Layer }) {
(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>
<span class="text-sm font-medium text-ink">{props.layer.name}</span>
<span class="text-xs text-ink-muted">{props.layer.tagline}</span>
</a>
</Show>
);
@@ -132,7 +125,10 @@ function Wordmark() {
// back where you started.
return (
<a href="/" class="flex items-center gap-2.5 no-underline">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-default bg-fill-neutral text-on-fill-neutral">
{/* text-white, not a theme token: the flag tile is the same in both themes, so
the boat on top of it has to be too. The flag carries a dark scrim (.flag-no)
so the plain white boat reads without a shadow of its own. */}
<span class="inline-flex h-8 w-8 items-center justify-center rounded-default flag-no text-white">
<Icon icon="sailboat" size={17} />
</span>
<span class="flex items-baseline gap-1.5">
@@ -154,10 +150,13 @@ function Sidebar() {
// 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.
//
// No icons, also matching the Go sidebar: the sidebar is a list of words, and a glyph on
// every row is noise to read past. So `block`, not `flex items-center gap-2`.
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";
? "block rounded-default bg-primary-subtle px-2 py-1.5 text-sm font-medium text-accent no-underline"
: "block 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">
@@ -167,11 +166,6 @@ function Sidebar() {
{(item) => (
<li>
<A href={item.path} end={item.path === "/"} class={linkCls(active(item.path))}>
<Icon
icon={item.icon}
size={14}
class={active(item.path) ? "text-accent" : "text-ink-faint"}
/>
{item.label}
</A>
</li>
@@ -201,7 +195,6 @@ function Sidebar() {
jumpTo(navigate, onComponents(), g.id);
}}
>
<Icon icon={g.icon} size={14} class="text-ink-faint" />
{g.label}
</a>
</li>