Begin go documentation (ai slop for now)
This commit is contained in:
303
go/cmd/kjol-web/app/golayer.go
Normal file
303
go/cmd/kjol-web/app/golayer.go
Normal 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."},
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 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),
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user