From 02672975344cac1093048ebcbdfe5780287ec497 Mon Sep 17 00:00:00 2001 From: Max Amundsen Date: Wed, 15 Jul 2026 09:58:33 -0400 Subject: [PATCH] Begin go documentation (ai slop for now) --- go/cmd/kjol-web/app/golayer.go | 303 ++++++++++++++++++ go/cmd/kjol-web/app/layers.go | 39 +-- go/cmd/kjol-web/app/pages.go | 24 +- go/cmd/kjol-web/app/routes.gen.go | 3 + go/cmd/kjol-web/css/app.css | 63 +++- go/cmd/kjol-web/frontend/css/style.css | 44 ++- go/cmd/kjol-web/frontend/src/layout/Shell.tsx | 37 +-- 7 files changed, 431 insertions(+), 82 deletions(-) create mode 100644 go/cmd/kjol-web/app/golayer.go diff --git a/go/cmd/kjol-web/app/golayer.go b/go/cmd/kjol-web/app/golayer.go new file mode 100644 index 00000000..63fce230 --- /dev/null +++ b/go/cmd/kjol-web/app/golayer.go @@ -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/. 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."}, + ), + ) +} diff --git a/go/cmd/kjol-web/app/layers.go b/go/cmd/kjol-web/app/layers.go index 8e0f18ff..ef1e7c1c 100644 --- a/go/cmd/kjol-web/app/layers.go +++ b/go/cmd/kjol-web/app/layers.go @@ -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)), ) } diff --git a/go/cmd/kjol-web/app/pages.go b/go/cmd/kjol-web/app/pages.go index b12495ac..d2f824cb 100644 --- a/go/cmd/kjol-web/app/pages.go +++ b/go/cmd/kjol-web/app/pages.go @@ -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), ) } diff --git a/go/cmd/kjol-web/app/routes.gen.go b/go/cmd/kjol-web/app/routes.gen.go index 7f9cf671..4f99beb9 100644 --- a/go/cmd/kjol-web/app/routes.gen.go +++ b/go/cmd/kjol-web/app/routes.gen.go @@ -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", diff --git a/go/cmd/kjol-web/css/app.css b/go/cmd/kjol-web/css/app.css index a39d1dbd..8ba2de9c 100644 --- a/go/cmd/kjol-web/css/app.css +++ b/go/cmd/kjol-web/css/app.css @@ -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 , 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 27–49%, navy 32.5–43.5% + horizontal (centred, centre 50%): off-white 39–61%, navy 44.5–55.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), diff --git a/go/cmd/kjol-web/frontend/css/style.css b/go/cmd/kjol-web/frontend/css/style.css index e8822f4b..2e6cc110 100644 --- a/go/cmd/kjol-web/frontend/css/style.css +++ b/go/cmd/kjol-web/frontend/css/style.css @@ -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%); +} diff --git a/go/cmd/kjol-web/frontend/src/layout/Shell.tsx b/go/cmd/kjol-web/frontend/src/layout/Shell.tsx index 0db262b8..e8cac115 100644 --- a/go/cmd/kjol-web/frontend/src/layout/Shell.tsx +++ b/go/cmd/kjol-web/frontend/src/layout/Shell.tsx @@ -93,22 +93,18 @@ function LayerItem(props: { layer: Layer; current?: Layer }) { fallback={
- {props.layer.name} reference - {props.layer.tagline} + {props.layer.tagline}
} > - {/* A plain , not MenuLink and not the router's . 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 , not the router's : 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. */} - - - {props.layer.name} - - {props.layer.tagline} + {props.layer.name} + {props.layer.tagline} ); @@ -132,7 +125,10 @@ function Wordmark() { // back where you started. return ( - + {/* 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. */} + @@ -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 (