Update kjol website with C documentation

This commit is contained in:
2026-07-14 13:05:12 -04:00
parent 02a6dc6c48
commit 7d7b7354df
66 changed files with 23884 additions and 2551 deletions

View File

@@ -8,6 +8,7 @@ import (
chart "github.com/wcharczuk/go-chart/v2"
. "kjol/vdom"
"kjol/wasmruntime"
ui "kjol/webui"
)
@@ -60,9 +61,56 @@ func pieSVG(values []int) string {
})
}
// chartSkeleton is what the SERVER puts where a chart is going to be: a box of the right
// height, so nothing jumps when the real one arrives.
func chartSkeleton(height string) *VNode {
return Div(Attr("class", "flex animate-pulse items-center justify-center rounded-default bg-surface-muted "+height),
Span(Attr("class", "text-xs text-ink-faint"), Text("drawing…")),
)
}
// newChartDrawing returns a signal that is FALSE on the server and on the client's first
// render, and true from the moment the WebAssembly has committed that first render.
//
// It is what keeps the charts CLIENT-DRAWN. go-chart is ordinary Go and would run just as
// happily on the server — it used to, and this page's markup carried two finished SVGs.
// Two reasons not to:
//
// - It is work the server does on every single request for a picture that only matters
// once the page is alive. Drawing it in the browser costs the server nothing and the
// reader nothing they can see.
// - It is the more honest demonstration. A Go charting library, compiled to WebAssembly,
// drawing an SVG in the browser is the thing this layer claims it can do. Shipping a
// server-rendered picture of one proves the opposite point.
//
// The false-on-first-render part is not optional: hydration walks the server's DOM
// alongside the client's first tree, so that tree has to be the SAME tree. Draw the charts
// on the client's first pass and the two disagree, and the reconciler has to rebuild what
// it should have adopted.
func newChartDrawing() *Signal[bool] {
drawn := NewSignal(false)
wasmruntime.AfterRender(func() {
if !drawn.Get() {
drawn.Set(true) // a write re-renders; the second pass draws for real
}
})
return drawn
}
// chartBox renders one chart, or the placeholder standing in for it. draw is a closure so
// that on the server go-chart is never called at all — not called and discarded, but never
// entered.
func chartBox(class, height string, drawn bool, draw func() string) *VNode {
if !drawn {
return Div(Attr("class", class), chartSkeleton(height))
}
return Div(Attr("class", class), Raw(draw()))
}
//gowasm:page /wasm/chart static layout=app
func ChartPage(d Deps) func() *VNode {
data := NewSignal(fixedChartData())
drawn := newChartDrawing()
return func() *VNode {
values := data.Get()
@@ -85,19 +133,27 @@ func ChartPage(d Deps) func() *VNode {
),
docSection("charts", "A worked example: charts",
prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing about "+
"the browser. The server draws them and ships the markup inline; there is no chart "+
"JavaScript, and no canvas that has to wait for the client to boot before it shows anything."),
prose("Shuffle re-runs the same drawing code in the browser. The first render came from the "+
"server and the next one comes from WebAssembly, and the page cannot tell the difference."),
prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing "+
"about the browser. They are drawn by the WEBASSEMBLY, in your browser, and never by the "+
"server: what the server sends is the two placeholders you may have seen for a moment, "+
"and the WebAssembly replaces them on its first commit."),
prose("That is the demonstration. A Go charting library, compiled to wasm, drawing an SVG in "+
"the browser is exactly what this layer claims it can do — and a server-rendered picture "+
"of a chart would prove the opposite point while looking identical. Shuffle redraws them, "+
"and no request is made."),
prose("The rest of the page IS server-rendered — the headings, the prose, the code you are "+
"reading. Static and client-drawn are not opposites: a route can be pre-rendered and still "+
"leave the expensive, browser-only parts of itself for the client."),
Div(Attr("class", "mt-4"),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Icon: "chart-column", Text: "Shuffle data",
OnClick: func() { data.Set(randomValues()) }}),
),
Div(Attr("class", "mt-4 grid gap-4 lg:grid-cols-12"),
Div(Attr("class", "lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto"), Raw(barSVG(values))),
Div(Attr("class", "lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto"), Raw(pieSVG(values))),
chartBox("lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
"h-[260px]", drawn.Get(), func() string { return barSVG(values) }),
chartBox("lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
"h-[320px]", drawn.Get(), func() string { return pieSVG(values) }),
),
note("go-chart lives in the EXAMPLE, not in kjol",
@@ -111,7 +167,7 @@ func ChartPage(d Deps) func() *VNode {
apiRow{"//gowasm:page /path static", "Pre-render this route on the server, then hydrate it."},
apiRow{"vdom.RenderHTML", "Render a tree to an HTML string. This is what the server calls."},
apiRow{"wasmruntime.Hydrate", "Adopt server-rendered DOM instead of building it. The client's entry point for a static route."},
apiRow{"vdom.Raw", "Insert markup verbatim — how the server-drawn SVG gets in. The reconciler clears it correctly when the element is reused."},
apiRow{"vdom.Raw", "Insert markup verbatim — how the SVG the WebAssembly drew gets in. The reconciler clears it correctly when the element is reused."},
),
),
)
@@ -120,17 +176,42 @@ func ChartPage(d Deps) func() *VNode {
const chartSnippet = `//gowasm:page /wasm/chart static layout=app
func ChartPage(d Deps) func() *VNode {
data := NewSignal(fixedChartData())
data := NewSignal(fixedChartData())
drawn := NewSignal(false) // false on the server AND on the first client render
// AfterRender is the post-commit hook. It fires once the WebAssembly has put its
// first tree on the page — the earliest moment at which drawing is a client act.
wasmruntime.AfterRender(func() {
if !drawn.Get() {
drawn.Set(true) // a write re-renders; the second pass draws
}
})
return func() *VNode {
// go-chart draws an SVG string — on the server for the first paint,
// and in the browser for every render after that.
return Div(
ui.Button(ui.ButtonProps{
Text: "Shuffle data",
OnClick: func() { data.Set(randomValues()) },
}),
Div(Raw(barSVG(data.Get()))),
// The server never enters barSVG: chartBox takes a CLOSURE, and calls it only
// once drawn is true. It renders the placeholder instead, and the WebAssembly
// swaps in the real chart on its first commit.
//
// drawn must be FALSE on the client's first render too. Hydration walks the
// server's DOM alongside the client's first tree, so the two have to BE the
// same tree; draw on that first pass and the reconciler rebuilds what it
// should have adopted.
chartBox("", "h-[260px]", drawn.Get(),
func() string { return barSVG(data.Get()) }),
)
}
}
// chartBox is the whole trick, and it is four lines.
func chartBox(class, height string, drawn bool, draw func() string) *VNode {
if !drawn {
return Div(Attr("class", class), chartSkeleton(height))
}
return Div(Attr("class", class), Raw(draw()))
}`

View File

@@ -0,0 +1,600 @@
package app
import (
. "kjol/vdom"
)
// The C layer's documentation.
//
// One page, like the component pages: a reader looking for the arena API should not have
// to guess which of six pages it was filed under. The sidebar calls the sections
// SUBSYSTEMS, which is what they are — each is a directory of C, and each has a different
// idea of what it depends on.
//
// Everything here is read off the source. Where a subsystem needs something the reader has
// to supply — a unity translation unit, a vendored ImGui, a resource script pointed at his
// own product — the page says what it needs and what to write, rather than filing it as a
// defect. This is a base layer: it is COPIED INTO a project and compiled with it, so "what
// you have to provide" is not a caveat, it is the interface.
type subsystem struct {
ID string
Label string
Icon string
Blurb string
}
// cSubsystems 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 cSubsystems() []subsystem {
return []subsystem{
{ID: "base", Label: "base", Icon: "cube",
Blurb: "The vocabulary: fixed-width types, a bump allocator, counted strings, and 2D rectangle math."},
{ID: "build", Label: "build", Icon: "bolt",
Blurb: "A single-header build system. Your build script is a C program that recompiles itself."},
{ID: "platform", Label: "platform", Icon: "server",
Blurb: "A window, its input, the clipboard. Win32, and it hands every message to Dear ImGui first."},
{ID: "lexer", Label: "lexer", Icon: "code",
Blurb: "Syntax highlighting. Five languages, thirteen themes, and a paint array rather than a token list."},
{ID: "config", Label: "config", Icon: "file",
Blurb: "An INI file beside the executable. Program-managed state, not user-authored settings."},
{ID: "installer", Label: "installer", Icon: "download",
Blurb: "A Win32 wizard that carries the product inside itself as a resource."},
}
}
// cNav is the sidebar while you are reading /c. The group is called Subsystems.
func cNav() []docsGroup {
items := make([]docsItem, 0, len(cSubsystems()))
for _, s := range cSubsystems() {
items = append(items, docsItem{
Path: "/c#" + s.ID,
Label: s.Label,
Icon: s.Icon,
Blurb: s.Blurb,
})
}
return []docsGroup{
{
Title: "Introduction",
Items: []docsItem{
{Path: "/c", Label: "Overview", Icon: "book-open",
Blurb: "What this layer is, and where it came from."},
{Path: "/c#compiling", Label: "Compiling it", Icon: "bolt",
Blurb: "The unity translation unit: what you write, and what each subsystem needs from you."},
},
},
{Title: "Subsystems", Items: items},
}
}
//gowasm:page /c static layout=app
func CPage(d Deps) func() *VNode {
return func() *VNode {
return docPage("Layers", "Kjøl C",
"A base layer in C — a bump allocator, counted strings, a syntax highlighter, a Win32 window, "+
"and a build system that is itself a C program. It was lifted out of codeMAX, a code editor, "+
"which is why the pieces are the pieces an editor needs.",
docSection("what-this-is", "What this is",
prose("Not a library you link against. A set of headers and translation units you COPY INTO "+
"a project and compile with it — the base-layer style you will recognise if you have read "+
"Ryan Fleury's RAD Debugger or watched Handmade Hero. The headers say as much: base_core, "+
"base_arena and base_strings all credit raddebugger, and the naming (U32, Str8, Rng2F32) "+
"comes straight from it."),
prose("There is no package manager, no version, and no ABI to keep stable — because there is "+
"nothing to keep stable BETWEEN. The code and its consumer are compiled together. Which is "+
"also why the next section is about the file YOU write: the layer does not build on its "+
"own, by design. It builds as part of your program."),
note("It arrived from codeMAX, and still carries its names",
"The whole directory came into Kjøl in one commit, out of codeMAX, a code editor. Its "+
"fingerprints are still on it and they are worth knowing before you go looking: the "+
"Win32 window class is codemax_wc, the installer installs codeMAX, and installer.rc "+
"names codeMAX's icon, manifest and payload. Those are the strings to change when you "+
"adopt it — they are the product's name, not the layer's."),
),
cCompiling(),
cBase(),
cBuild(),
cPlatform(),
cLexer(),
cConfig(),
cInstaller(),
)
}
}
// ---- compiling -----------------------------------------------------------
// How you actually compile the thing. It is second on the page, before any API, because a
// base layer's first question is "how does this get into my program" and the answer here is
// not the one a reader arriving from a package-manager language expects.
func cCompiling() *VNode {
return docSection("compiling", "Compiling it",
prose("One translation unit. You write a single .c that #includes the layer's .c files in order, "+
"and hand THAT to the compiler — the whole layer is one TU, compiled with your program. "+
"base/base_inc.c is the pattern in miniature: three lines, pulling in the arena and the "+
"strings. Your own unity file is the same idea, one level up."),
codeLang("app_inc.c — the file you write", "c", unityTUSnippet),
prose("This is not a workaround for the absence of a build system; it IS the build system. There is "+
"no per-file compilation, so there are no object files, no link order and no header guard "+
"archaeology — and the compiler sees the whole layer at once, which is what makes every helper "+
"in it `internal` and every call to one a candidate for inlining."),
docSubheading("What each subsystem needs from you"),
prose("They are not equally self-contained, and the differences are worth knowing before you pick "+
"the ones you want:"),
apiTable(
apiRow{"base", "Nothing. It stands on its own — base_inc.c is a complete TU, and everything above depends on it."},
apiRow{"lexer", "Include lexer.c AND the five backends in the same TU. Their helpers are `internal`, so separate compilation would leave lexer.c's dispatch with nothing to call."},
apiRow{"config", "Include config.h above config.c. config.c uses Config without including its own header — which is exactly what a unity build lets it do."},
apiRow{"platform", "Dear ImGui, vendored, and a C++ compiler for it. See the subsystem below — the window procedure hands it every message before it looks at any of them."},
apiRow{"installer", "Its own program and its own .rc, pointed at your icon, your manifest and your payload."},
apiRow{"build", "Nothing — it is the thing that runs the compiler. Write a build.c, compile it once, and never compile it again."},
),
note("There is no build.c in Kjøl yet, and that is the next job",
"build.h is here, complete and self-contained, and nothing in the repository includes it — so "+
"the layer currently has a build system and no build. Writing that build.c is what turns "+
"this directory into something you can type one command at, and it is the single most "+
"useful thing anyone could add to the C layer. The build subsystem below documents the "+
"header it would be written against, and the snippet there is a working sketch of it."),
)
}
// ---- base ----------------------------------------------------------------
func cBase() *VNode {
return docSection("base", "base",
prose("The vocabulary. Fixed-width integers with short names, a bump allocator, a counted string, "+
"and the rectangle math a user interface actually spends its time on. Everything else in the "+
"layer is written in these — which is why there is no malloc below this line, and no char* "+
"pretending to be a string."),
docSubheading("The types (base_core.h)"),
prose("U8 through U64, S8 through S64, B32 for a boolean, F32 and F64 — upper case, not the u8/i32 "+
"of the Rust-adjacent style. Sizes are written KB(4) and MB(64) rather than as a number of "+
"zeroes you have to count. And the three meanings of `static` in C get three different names: "+
"`internal` for a file-local function, `global` for a translation-unit variable, `local_persist` "+
"for a local that survives the call. They all expand to `static`; they do not all mean the same "+
"thing, and the code says which one it meant."),
codeLang("c/base/base_core.h", "c", baseCoreSnippet),
prose("There is more in the header than the types: Min/Max/Clamp, the nil-aware doubly and singly "+
"linked-list macros from raddebugger (DLLPushBack, SLLQueuePush and friends), DeferLoop — which "+
"is `defer` written as a for-loop, because C has no defer — and an Assert that traps on MSVC "+
"with __debugbreak."),
docSubheading("The arena (base_arena.h)"),
prose("One allocator, and it is a bump pointer: arena_push moves a cursor and hands you the memory. "+
"There is no free. You release the whole arena, or you roll it back to a saved position — which "+
"is what Temp is. A function that wants scratch space opens a Temp, allocates as freely as it "+
"likes, and closes it. Nothing has to be individually released, so nothing can be individually "+
"forgotten."),
codeLang("c/base/base_arena.h", "c", baseArenaSnippet),
note("It is malloc-backed and fixed-capacity — and that has a sharp edge",
"The header says so: this is raddebugger's arena with the virtual-memory reserve/commit taken "+
"out. arena_alloc is one malloc of the capacity you asked for, and that capacity is final — "+
"the arena does not grow and does not chain. Overflow hits Assert(!\"Arena overflow\") and "+
"returns NULL. But Assert compiles to nothing unless _DEBUG is defined, so in a release build "+
"an over-full arena hands you a silent NULL. Alignment is a hard-coded 8 bytes, so there is "+
"no SIMD guarantee. And there is no thread-local scratch pool — if you came here expecting "+
"raddebugger's scratch_begin, it is not in this snapshot."),
docSubheading("Strings (base_strings.h)"),
prose("Str8 is a pointer and a length. Not NUL-terminated, not owned, does not allocate — so a "+
"substring is free, and a Str8 can point into the middle of a file you mapped. The two "+
"operations that MUST allocate take the arena, and so they say so in their signature. That is "+
"the whole API: eight functions. It is early, and there is no split, join, trim or "+
"case-insensitive compare yet."),
codeLang("c/base/base_strings.h", "c", baseStringsSnippet),
docSubheading("Math (base_math.h)"),
prose("Look at the shape of this file and it tells you what it is for. Vec3F32 has a constructor "+
"and no operations. Rng2F32 — a rectangle — has nine: width, height, dim, center, contains, "+
"pad, shift, intersect. There are no matrices, no quaternions, no Mat4. This is 2D interface "+
"math, and a layout written in it is a sequence of rectangle operations rather than eight lines "+
"of x + w arithmetic with an off-by-one hiding in them."),
prose("The Axis2 / Side / Corner enums plus v2f32_axis are the raddebugger trick for axis-generic "+
"code: one code path handles both X and Y by indexing into the vector instead of naming .x "+
"and .y, so a horizontal and a vertical layout are the same function with a different argument."),
apiTable(
apiRow{"arena_alloc / arena_release", "One malloc, fixed capacity. It does not grow."},
apiRow{"arena_push / arena_push_no_zero", "Bump the cursor. Zeroed by default; the fast one has to be asked for by name."},
apiRow{"temp_begin / temp_end", "A scratch scope: save the position, allocate freely, roll it all back."},
apiRow{"push_array(arena, T, n)", "Typed sugar over arena_push. The macro you will actually use."},
apiRow{"str8 / str8_cstr / str8_lit", "Make a counted string. None of them allocate."},
apiRow{"str8_pushf / str8_push_copy", "The two that DO allocate — and so they take the arena."},
apiRow{"Rng2F32 + rng2f32_intersect / _pad / _shift", "Rectangles, and the three things a layout does to them."},
),
)
}
// ---- build ---------------------------------------------------------------
func cBuild() *VNode {
return docSection("build", "build",
prose("The build system is a C program, and the build system's build system is a C compiler. "+
"build.h is a single header in the stb style: define BUILD_IMPLEMENTATION in one file, include "+
"it, and write your build as a main() that shells out to a compiler. The lineage is nob.h — the "+
"self-rebuild macro here is called GO_REBUILD_URSELF, which is a straight nod to it."),
docSubheading("Bootstrapping"),
prose("You compile the build script once, by hand. After that you never compile it again, because "+
"the first thing it does when it runs is compare its own source against its own executable — "+
"and if the source is newer, it rebuilds itself, swaps the binary, and re-executes. Edit the "+
"build script and just run it. It will notice."),
codeLang("terminal", "sh", buildBootstrapSnippet),
note("On Windows you cannot overwrite a running .exe — so it doesn't",
"go_rebuild_urself RENAMES the current binary to build.exe.old, compiles the new one in its "+
"place, and re-execs. If the compile FAILS, it renames the old one back. That is the whole "+
"reason the dance exists, and it is the difference between a build script you can edit and "+
"one that bricks itself the first time you make a typo."),
docSubheading("Writing one"),
prose("GO_REBUILD_URSELF is the first line of main. Cmd is a growable argv you append to and run. "+
"needs_rebuild compares timestamps, so a target whose inputs have not changed is skipped. That "+
"is the whole of it — no rule syntax, no DSL, no dependency graph, because a C program already "+
"has if-statements and for-loops and you already know how to write them."),
codeLang("build.c — the file you write", "c", buildUsageSnippet),
note("What it deliberately does NOT do",
"There is no compiler detection: the compiler is cl.exe on Windows and cc everywhere else, "+
"hard-coded, and only for the self-rebuild — for your own targets you assemble the command "+
"yourself. There is no parallelism: cmd_run is synchronous, start to finish. There is no "+
"globbing and no header-dependency scanning: needs_rebuild compares mtimes against the input "+
"list YOU pass it, so if you edit a header that is not in that list, nothing rebuilds. Know "+
"that going in and it is a fine tool; expect make and you will be bitten."),
docSubheading("What else is in the header"),
prose("More than a build system strictly needs, and all of it is there because a real build wanted "+
"it. A temp allocator — an 8 MB ring buffer that silently wraps, so temp_sprintf can hand you a "+
"formatted path that you never free. A string builder. File I/O that reports its own errors. "+
"And two functions that are squarely about shipping a graphical program: embed_file, which "+
"turns any binary into a C array in a header, and compile_shader / embed_spirv, which run glslc "+
"over a GLSL source and embed the SPIR-V the same way. That last pair is a fossil of a Vulkan "+
"renderer, and it is the clearest evidence in the layer of what codeMAX was becoming."),
apiTable(
apiRow{"GO_REBUILD_URSELF(argc, argv)", "First line of main. Rebuilds and re-executes the script if its source changed."},
apiRow{"Cmd + cmd_append + cmd_run", "A growable argv, appended variadically, run synchronously. cmd_run resets it for reuse."},
apiRow{"needs_rebuild(out, inputs, n)", "1 if any input is newer than the output, 0 if up to date, -1 on error. Mtimes only."},
apiRow{"temp_sprintf / temp_reset", "Formatted strings from a ring buffer. You never free them; you reset the ring."},
apiRow{"sb_read_file / write_entire_file", "Whole-file I/O into a String_Builder, and back out."},
apiRow{"embed_file(in, out, var)", "Turn a binary into a C header: an array, and its size."},
apiRow{"compile_shader / embed_spirv", "glslc a .glsl to .spv, then embed the .spv as a C array."},
),
)
}
// ---- platform ------------------------------------------------------------
func cPlatform() *VNode {
return docSection("platform", "platform",
prose("The operating system, behind one header — and it is a narrower header than the name "+
"suggests. There is no file I/O here, no clock, no threads, and no virtual memory (which is why "+
"the arena is malloc-backed). What there is: a window, the input that arrives at it, the "+
"clipboard, and a way to spawn a terminal."),
prose("Input comes in two shapes on purpose. PlatformRawInput is what the OS actually said — UTF-16 "+
"characters, virtual key codes, mouse coordinates. PlatformInput is what the application wants "+
"to hear. platform_adapt_input converts one into the other, and that function is the seam a "+
"second backend would be written behind."),
note("It expects Dear ImGui in the build, and Windows underneath",
"platform_win32.c is the only backend, and its window procedure forward-declares "+
"ImGui_ImplWin32_WndProcHandler and calls it on every message BEFORE it looks at any of "+
"them — so ImGui gets first refusal on the input, which is what makes an ImGui text field "+
"inside the window behave like a text field rather than a hole the editor's keybindings "+
"fall through. To use this subsystem you vendor ImGui and compile it (it is C++) alongside; "+
"to use it WITHOUT ImGui you cut that one call, and everything else in the file is C and "+
"stands. A second backend would replace the PKEY_ enum — whose values ARE Windows virtual "+
"key codes, passed straight through — and find a home for platform_spawn_terminal, which "+
"hard-codes cmd.exe /k."),
docSubheading("Two decisions worth stealing"),
prose("WM_SIZE calls the frame callback synchronously, from inside the window procedure. That looks "+
"wrong and is the standard Win32 fix for a real problem: while you drag a window's edge, "+
"Windows runs a modal resize loop that never returns to your main loop, so the window goes "+
"blank or smears. Rendering from inside the message handler is the only way to keep drawing. "+
"platform_set_frame_callback exists for exactly this."),
prose("And platform_get_input drains its accumulator on read — it memsets the buffered keys and "+
"characters to zero on the way out, and returns was_mouse_down beside mouse_down so the caller "+
"can see an edge without keeping its own copy of last frame."),
apiTable(
apiRow{"platform_create_window / _destroy_window", "A window from a PlatformWindowDesc; and its teardown."},
apiRow{"platform_poll_events", "Pump the message queue. False means the user wants to close."},
apiRow{"platform_get_input / platform_adapt_input", "The raw OS input for this frame; and the conversion that is the portability seam."},
apiRow{"platform_set_frame_callback", "Called per frame — including from inside a live resize, which is the point."},
apiRow{"platform_get_dpi_scale", "GetDpiForWindow / 96. The layer is per-monitor DPI aware throughout."},
apiRow{"platform_clipboard_get / _set", "Get returns a pointer into a static 64 KB buffer. Do not free it, do not keep it."},
apiRow{"platform_get_native_handle", "The HWND, for the one place that genuinely needs it."},
),
)
}
// ---- lexer ---------------------------------------------------------------
func cLexer() *VNode {
return docSection("lexer", "lexer",
prose("Syntax highlighting, and the most complete thing in this layer — about 2,800 lines of it. "+
"The interesting decision is the output shape, and the header credits it to the Focus editor: a "+
"tokenizer here does not return a list of tokens. It takes the buffer and an out_tokens array "+
"of the SAME LENGTH, and paints one token-type byte per source byte."),
codeLang("c/lexer/lexer.h", "c", lexerSnippet),
prose("That sounds wasteful and is the opposite. An editor never wants to know what the tokens are; "+
"it wants to know what colour to draw the character at offset N — and with a parallel array "+
"that is one indexed read, not a search through a token list. The enum value IS the index into "+
"the theme's colour table, which is why TOK_DEFAULT has to be zero. Painting is a memset. The "+
"cost is one byte per byte of source, for a file you already have in memory."),
note("It lets you rewrite the past, which is why it can find function names",
"All five backends share one trick. When the tokenizer emits a `(` and the PREVIOUS token was "+
"an identifier, it goes back and re-paints that identifier as TOK_FUNCTION. With a token "+
"list you would have to look ahead, or fix up afterwards. With a paint array the past is "+
"just an array range, and repainting it costs a memset."),
docSubheading("Languages"),
prose("Five backends — C, Go, JavaScript, Lua, SQL — plus plain text, each a single .c file behind "+
"the same LexerTokenizeFn signature, selected by a hard-coded switch. There is no registry and "+
"no plugin: adding a language means editing four things in lexer.c, deliberately."),
prose("They are not toys. The JavaScript one carries an explicit depth stack for nested tagged "+
"template literals, so an html`…${css`…${x}`}…` lexes correctly at arbitrary nesting, and it "+
"has a regex-versus-division heuristic — after a keyword or an operator a slash starts a regex, "+
"after an identifier it is a divide. The Lua one counts the equals signs in a long bracket so "+
"[==[ is closed by ]==] and not by ]]. The SQL one is case-insensitive and treats \"quoted\" as "+
"an identifier rather than a string."),
docSubheading("Themes"),
prose("A theme is a colour per token type, and there are thirteen: Default, Default Light, Focus, "+
"Handmade Hero, Witness Classic, Witness, VS Classic, RAD Debugger, 4coder, Ryan Fleury, Gruber "+
"Darker, VS Dark, Freshcut Contrast. The file is pure data — not one function in five hundred "+
"lines."),
note("The Theme struct is a fossil, and you can date the rock",
"Every theme carries a 24-bit colour table AND a 256-colour ANSI fallback, with a use_truecolor "+
"flag — because this started life in a TERMINAL editor, where you cannot assume truecolor. "+
"But the platform layer beside it is a DPI-aware Win32 GUI window driven by ImGui, and the "+
"struct has since grown status_bg, minibuffer and file-browser colours that mean nothing to "+
"a terminal. It is a terminal-era struct with GUI-era fields bolted on. That is a seam, not "+
"a design."),
apiTable(
apiRow{"LexerTokenizeFn(data, len, out_tokens)", "The one signature every backend implements. One byte of token type per byte of source."},
apiRow{"lexer_get_tokenize_fn(lang)", "The backend for a language. A switch, not a registry."},
apiRow{"lexer_detect_lang(filename)", "Language from the file extension."},
apiRow{"Tokenizer + tokenizer_init / _eat_whitespace", "The shared cursor the backends are written against."},
apiRow{"g_themes / theme_active()", "Thirteen built-in themes, and the active one."},
),
)
}
// ---- config --------------------------------------------------------------
func cConfig() *VNode {
return docSection("config", "config",
prose("An INI file — [sections], key = value — that lives NEXT TO THE EXECUTABLE rather than in a "+
"home directory, which makes the program portable: copy the folder, keep your settings. It "+
"parses into one global Config struct and saves back out of it."),
codeLang("c/config/config.h", "c", configSnippet),
prose("The struct is the schema, and it is a fixed-size one: ten recent directories, every path a "+
"flat 1024 bytes. Nothing in this subsystem allocates — which is what lets it be loaded before "+
"an arena exists. If the file is absent, config_load writes a default one."),
note("It stores what the PROGRAM knows, not what the user wants",
"The header is explicit about the split, and it is a good one: this file is managed by the "+
"program — your window size, your recent folders, your active project — and per-project "+
"settings belong in .editorconfig, where other tools can read them. A config file that tries "+
"to be both ends up being neither."),
note("It needs a writable directory beside the executable",
"That is the one requirement the design carries, and it is easy to miss: the file is written "+
"NEXT TO the .exe, and config_save does not check whether the write succeeded. Run out of a "+
"folder you can write to — which is what \"copy the folder, keep your settings\" means — and "+
"it does exactly what it says. Install the same binary into C:\\Program Files and a "+
"non-elevated process cannot write there, so the save quietly does nothing. If you ship it "+
"through the installer, either keep the config beside the exe and expect an elevated write, "+
"or point config_get_path at %APPDATA% and give up the portability."),
apiTable(
apiRow{"config_load / config_save", "Read the INI into g_config; write g_config back out. Writes defaults if the file is absent."},
apiRow{"config_get_path", "Beside the executable — GetModuleFileNameA, then strip the filename."},
apiRow{"config_push_recent_dir", "Dedupes case-insensitively, moves the entry to the front, caps at ten, and saves itself."},
apiRow{"g_config", "The one global. There is no second one."},
),
)
}
// ---- installer -----------------------------------------------------------
func cInstaller() *VNode {
return docSection("installer", "installer",
prose("A Windows installer AND uninstaller, in one executable, with the thing it installs embedded "+
"inside it as a resource. No NSIS, no WiX, no MSI: it is a Win32 property-sheet wizard — "+
"welcome, directory, progress, finish — that extracts the payload, copies itself to "+
"uninstall.exe, optionally appends itself to the system PATH, writes a Start Menu shortcut via "+
"COM, and registers under HKLM so it shows up in Add/Remove Programs."),
prose("installer.manifest is what makes Windows ask for elevation UP FRONT rather than failing on "+
"the first write to Program Files. installer.h exists solely so the resource script and the C "+
"agree on the resource ids."),
note("The wizard has no dialog resources — it writes the DLGTEMPLATEs by hand, at runtime",
"There is a small serializer in installer.c that assembles the Win32 DLGTEMPLATE and "+
"DLGITEMTEMPLATE binary layout byte by byte, alignment padding and all, and hands the result "+
"to the property sheet with PSP_DLGINDIRECT. Which means the installer's entire user "+
"interface needs no resource compiler: only the icon, the manifest and the payload go "+
"through the .rc. That is either magnificent or deranged, and it is certainly deliberate."),
note("A running .exe cannot delete itself, so the uninstaller doesn't",
"It spawns a detached cmd.exe that waits two seconds, deletes uninstall.exe, and removes the "+
"directory. The process outlives the program that started it, which is the only way this can "+
"be done on Windows."),
docSubheading("Pointing it at your own product"),
prose("The .rc is the part you edit. It names codeMAX's icon, codeMAX's manifest and codeMAX's "+
"payload, at the paths the original repository had them at — so adopting the installer means "+
"pointing those three lines at your icon, your manifest and your executable, and changing the "+
"product name and the HKLM key the uninstall entry is written under. Nothing else in installer.c "+
"knows what it is installing."),
prose("There is also visible residue of a two-binary product that was collapsed into one, and it is "+
"worth recognising rather than copying: IDR_EXE_TERMINAL is still declared though nothing embeds "+
"or extracts it, do_install's numbered steps skip step 3, and the uninstaller still removes a "+
"second executable and a shortcut that the installer no longer creates. Those are the lines to "+
"delete on the way in."),
)
}
// ---- helpers -------------------------------------------------------------
// docSubheading is a heading INSIDE a section. The subsystems each have several parts, and
// six sections with no internal structure is a wall.
func docSubheading(text string) *VNode {
return H3(Attr("class", "mt-8 text-base font-semibold text-text-heading"), Text(text))
}
// ---- snippets ------------------------------------------------------------
//
// Every one of these is copied from the source, except two — unityTUSnippet and
// buildUsageSnippet, which are the two files the layer expects its CONSUMER to write and
// so cannot be copied from a layer that has no consumer yet. Both are written against the
// real API and both are captioned as the file you write, not as a file that is here.
//
// If you change the source, change these. A snippet that has drifted from the code it
// claims to show is the most expensive documentation there is, because it is believed.
const unityTUSnippet = `// The whole layer, as one translation unit — the only .c you hand to the
// compiler. Order matters: it is textual inclusion, not linking.
#include "base/base_inc.c" // core, arena, strings. Everything below needs it.
// The five backends declare their helpers `+ "`internal`" + ` (file-static), so they
// belong in the SAME TU as the dispatch that calls them.
#include "lexer/lexer.c"
#include "lexer/lexer_c.c"
#include "lexer/lexer_go.c"
#include "lexer/lexer_js.c"
#include "lexer/lexer_lua.c"
#include "lexer/lexer_sql.c"
// config.c uses Config without including its own header — which is exactly the
// thing a unity build is for. Put the header above it.
#include "config/config.h"
#include "config/config.c"
// ...and then your own program, compiled with all of it:
#include "app/app.c"`
const baseCoreSnippet = `// The three meanings of `+ "`static`" + ` in C, given three names.
#define internal static // a function private to this file
#define global static // a variable owned by this translation unit
#define local_persist static // a local that survives the call
typedef uint8_t U8; typedef int8_t S8;
typedef uint32_t U32; typedef int32_t S32;
typedef uint64_t U64; typedef int64_t S64;
typedef S32 B32; // a boolean, sized so it packs predictably
typedef float F32; typedef double F64;
#define KB(n) (((U64)(n)) << 10)
#define MB(n) (((U64)(n)) << 20)`
const baseArenaSnippet = `typedef struct Arena { U8 *base; U64 pos; U64 cap; } Arena;
typedef struct Temp { Arena *arena; U64 pos; } Temp;
// One malloc, of exactly cap. It does not grow and it does not chain.
Arena *arena_alloc(U64 cap);
void *arena_push(Arena *arena, U64 size); // zeroed
void *arena_push_no_zero(Arena *arena, U64 size); // not
void arena_pop_to(Arena *arena, U64 pos);
#define push_array(arena, T, count) ((T *)arena_push((arena), sizeof(T) * (count)))
// A scratch scope. Allocate as freely as you like inside it; none of it needs
// releasing, because temp_end rolls the cursor back over all of it at once.
Temp scratch = temp_begin(arena);
Node *nodes = push_array(arena, Node, 1024);
temp_end(scratch);`
const baseStringsSnippet = `// A pointer and a length. Not NUL-terminated, not owned, does not allocate —
// so a substring is free, and a Str8 can point into a file you mapped.
typedef struct Str8 { const char *str; U64 size; } Str8;
static inline Str8 str8_lit(const char *s);
static inline B32 str8_match(Str8 a, Str8 b);
static inline B32 str8_is_empty(Str8 s);
// The two that must allocate take the arena, and so they say so:
Str8 str8_pushf(Arena *arena, const char *fmt, ...);
Str8 str8_push_copy(Arena *arena, Str8 s);`
const buildBootstrapSnippet = `# Once, by hand. (On Windows, from a Visual Studio developer prompt —
# it shells out to cl.exe.)
cl /nologo build.c # Windows
cc build.c -o build # macOS / Linux
# Ever after, just run it. If build.c is newer than the binary, the binary
# rebuilds itself, swaps in the new one, and re-executes:
./build`
const buildUsageSnippet = `#define BUILD_IMPLEMENTATION
#include "build.h"
int main(int argc, char **argv) {
// Renames the running binary to .old, recompiles, and re-execs. If the
// compile fails it puts the old one back — so a typo in your build
// script cannot brick your build script.
GO_REBUILD_URSELF(argc, argv);
mkdir_if_not_exists("out");
// needs_rebuild compares MTIMES against the list you pass it. There is
// no header scanning: if a .h you depend on is not in this list, editing
// it will not trigger a rebuild.
const char *srcs[] = {"base/base_inc.c", "lexer/lexer.c"};
if (needs_rebuild("out/app.exe", srcs, ARRAY_LEN(srcs)) > 0) {
Cmd cmd = {0};
cmd_append(&cmd, "cl", "/nologo", "/I.", "/Fe:out/app.exe");
cmd_append(&cmd, srcs[0], srcs[1]);
if (!cmd_run(&cmd)) return 1; // synchronous; resets cmd for reuse
cmd_free(&cmd);
}
build_log(LOG_INFO, "done");
return 0;
}`
const lexerSnippet = `// A tokenizer does not RETURN tokens. It paints one token-type byte per
// source byte, into an array the same length as the buffer.
//
// The editor never asks "what are the tokens". It asks "what colour is the
// character at offset N" — and this answers that with one indexed read.
// The enum value IS the index into the theme's colour table, which is why
// TOK_DEFAULT has to be 0.
typedef void (*LexerTokenizeFn)(const char *data, S32 length, U8 *out_tokens);
typedef enum Lang { LANG_PLAIN_TEXT, LANG_C, LANG_GO, LANG_JS, LANG_LUA, LANG_SQL } Lang;
LexerTokenizeFn lexer_get_tokenize_fn(Lang lang); // a switch, not a registry
Lang lexer_detect_lang(const char *filename);`
const configSnippet = `// The struct IS the schema, and it is fixed-size throughout: this subsystem
// allocates nothing, so it can be loaded before an arena exists.
typedef struct Config {
char theme[64];
char recent_dirs[CONFIG_MAX_RECENT_DIRS][CONFIG_PATH_MAX]; // most recent first
S32 recent_dir_count;
B32 show_line_numbers;
B32 syntax_enabled;
F32 ui_scale;
char editor_font[64];
char active_project[CONFIG_PATH_MAX];
} Config;
extern Config g_config; // the one global
static void config_load(void); // <exe dir>/config.ini — not $HOME
static void config_save(void);`

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,14 @@
package app
import (
"strings"
"kjol/lexer" // syntax highlighting for the code blocks — a string in, HTML out
. "kjol/vdom"
// The host API is dual-build — real under js/wasm, no-ops natively — so neutral page
// code can measure the browser and still server-render. This page uses it for exactly
// one thing: reading the clock when hydration commits.
"kjol/wasmruntime"
ui "kjol/webui"
)
@@ -31,18 +38,38 @@ type docsItem struct {
Icon string
}
// The Components group is not a list of PAGES — it is a list of anchors into the one
// components page. There used to be three pages there ("UI kit", "Overlays",
// "AutoTable"), which split the kit along the lines of its source files rather than
// along anything a reader wants: a person hunting for a date picker does not know, and
// should not have to guess, whether it was filed under forms or under overlays.
//
// So the whole kit is one page, and the sidebar jumps you down it. The groups come from
// componentGroups(), which is also what BUILDS the sections — so the sidebar cannot
// offer a jump to a section that does not exist, and a section cannot go missing from
// the sidebar.
func docsNav() []docsGroup {
items := make([]docsItem, 0, len(componentGroups()))
for _, g := range componentGroups() {
items = append(items, docsItem{
Path: "/wasm/components#" + g.ID,
Label: g.Label,
Icon: g.Icon,
Blurb: g.Blurb,
})
}
return []docsGroup{{
Title: "Introduction",
Items: []docsItem{
{Path: "/wasm", Label: "Overview", Icon: "book-open",
Blurb: "What Kjol Web is, how a page becomes a WebAssembly binary, and what runs where."},
Blurb: "What Kjøl Wasm Web is, how a page becomes a WebAssembly binary, and what runs where."},
},
}, {
Title: "Rendering",
Items: []docsItem{
{Path: "/wasm/chart", Label: "SSR & hydration", Icon: "chart-column",
Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, server-drawn as SVG."},
Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, drawn by the WebAssembly."},
{Path: "/wasm/server", Label: "Server components", Icon: "server",
Blurb: "Components whose state and code stay on the server. Calling one looks like calling any other."},
{Path: "/wasm/data", Label: "Data fetching", Icon: "cloud-arrow-down",
@@ -50,14 +77,7 @@ func docsNav() []docsGroup {
},
}, {
Title: "Components",
Items: []docsItem{
{Path: "/wasm/kit", Label: "UI kit", Icon: "squares",
Blurb: "Buttons, forms, tabs, alerts, cards — the kjol/webui components, written in Go."},
{Path: "/wasm/overlays", Label: "Overlays", Icon: "layers",
Blurb: "Tooltips, popovers, menus, modals: measured against the real viewport, flipped and shifted to fit."},
{Path: "/wasm/table", Label: "AutoTable", Icon: "table",
Blurb: "Filtering, sorting, column management, calculated columns, CSV and PDF export."},
},
Items: items,
}}
}
@@ -71,7 +91,7 @@ func docPage(eyebrow, title, lede string, sections ...*VNode) *VNode {
Div(Attr("class", "border-b border-line pb-6"),
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text(eyebrow)),
H1(Attr("class", "mt-2 text-3xl font-semibold tracking-tight text-text-heading"), Text(title)),
P(Attr("class", "mt-3 max-w-3xl text-ink-muted leading-relaxed"), Text(lede)),
P(Attr("class", "mt-3 text-ink-muted leading-relaxed"), Text(lede)),
),
)
for _, s := range sections {
@@ -93,11 +113,15 @@ func docSection(id, title string, body ...*VNode) *VNode {
return El("section", mods...)
}
// prose is a paragraph of explanation. Constrained to a reading measure: a line of body
// text that runs the full width of a wide screen is genuinely harder to read, and the
// demos beside it are allowed to be as wide as they like.
// prose is a paragraph of explanation.
//
// It used to be pinned to a reading measure (max-w-3xl). It is not any more: on a
// documentation page the paragraphs sit directly above demos, tables and code blocks
// that are as wide as the column, and a narrow ribbon of text over a full-width panel
// reads as a mistake rather than as typographic care. The column itself (max-w-6xl, set
// by AppLayout) is the measure now.
func prose(text string) *VNode {
return P(Attr("class", "mt-3 max-w-3xl text-ink-soft leading-relaxed"), Text(text))
return P(Attr("class", "mt-3 text-ink-soft leading-relaxed"), Text(text))
}
// ---- code ---------------------------------------------------------------
@@ -109,23 +133,20 @@ func prose(text string) *VNode {
// lets you go and check.
func code(caption, src string) *VNode { return codeLang(caption, "Go", src) }
// codeLang is code() for a block that is not Go — a shell session, a formula. The label
// in the corner says what you are looking at, and a shell command labelled "Go" is worse
// than no label at all.
// codeLang is code() for a block that is not Go — a C header, a shell session, a formula.
// The label in the corner says what you are looking at, and a shell command labelled "Go"
// is worse than no label at all.
//
// Go blocks are syntax-highlighted (webui.HighlightGo); the others are shown verbatim.
// A shell transcript put through a Go lexer comes out with `serving` painted as an
// identifier and quotes as string literals — highlighting the wrong language is more
// distracting than not highlighting at all.
// The label is ALSO what picks the lexer, so the two cannot disagree: a block cannot be
// labelled C and painted as Go. A language kjol/lexer does not know comes back escaped and
// unpainted, which is what should happen — a shell transcript put through a Go lexer comes
// out with `serving` painted as an identifier and quotes as string literals, and
// highlighting the WRONG language is more distracting than not highlighting at all.
func codeLang(caption, lang, src string) *VNode {
var body *VNode
if lang == "Go" {
// Raw, not Text: HighlightGo returns HTML. It escapes every run of source on the
// way out, so the snippets that contain markup stay inert.
body = El("code", Raw(ui.HighlightGo(src)))
} else {
body = El("code", Text(src))
}
// Raw, not Text: the lexer returns HTML. It escapes every run of source on the way out
// — including for a language it does not know — so the snippets that contain markup,
// and every C snippet, which is all pointers and shifts, stay inert.
body := El("code", Raw(lexer.Highlight(lang, src)))
return Div(Attr("class", "mt-4 overflow-hidden rounded-default border border-neutral-800 bg-neutral-900"),
Div(Attr("class", "flex items-center gap-2 border-b border-neutral-800 px-4 py-2"),
@@ -156,7 +177,7 @@ func demo(title string, body ...*VNode) *VNode {
// note is an aside — a caveat, a gotcha, the reason something is the way it is.
func note(title, body string) *VNode {
return Div(Attr("class", "mt-4 max-w-3xl rounded-default border border-primary-border bg-primary-subtle px-4 py-3"),
return Div(Attr("class", "mt-4 rounded-default border border-primary-border bg-primary-subtle px-4 py-3"),
P(Attr("class", "text-sm font-semibold text-text-heading"), Text(title)),
P(Attr("class", "mt-1 text-sm text-ink-soft leading-relaxed"), Text(body)),
)
@@ -180,7 +201,10 @@ func apiTable(rows ...apiRow) *VNode {
for _, b := range body {
rowMods = append(rowMods, b)
}
return Div(Attr("class", "mt-4 max-w-5xl overflow-x-auto"),
// Full width, like everything else on the page. A reference table pinned to max-w-5xl
// inside a max-w-6xl column is not narrower for a reason — it is narrower by an inch,
// which reads as a misalignment rather than as a decision.
return Div(Attr("class", "mt-4 overflow-x-auto"),
El("table", Attr("class", "w-full border-collapse text-left"),
Tbody(rowMods...),
),
@@ -191,7 +215,38 @@ func apiTable(rows ...apiRow) *VNode {
//gowasm:page /wasm static layout=app
func DocsPage(d Deps) func() *VNode {
clicks := NewSignal(0)
// The one measurement on the page: performance.now() when the client's first render
// commits. Zero until then — which is what the SERVER renders, and what the client
// renders on its first pass, so the two agree and hydration stays clean.
hydratedAt := NewSignal(0.0)
wasmruntime.AfterRender(func() {
if hydratedAt.Get() == 0 {
hydratedAt.Set(wasmruntime.Now())
}
})
// demoTree is called TWICE per render below — once for the DOM, once for the HTML.
// That is the point: the two panes cannot drift, because there is only one of them.
//
// This demo used to be on the front page. It does not belong there — it is the Wasm
// Web engine's single best argument, and the front page is kjøl's, not this engine's.
// Here it is the first thing the section shows, which is where an argument like this
// one earns its place.
demoTree := func() *VNode {
return Div(Attr("class", "flex items-center gap-3"),
ui.Button(ui.ButtonProps{
Color: ui.ButtonPrimary, Text: "Click me",
OnClick: func() { clicks.Set(clicks.Get() + 1) },
}),
Span(Attr("class", "text-ink-soft"), Text("clicked "+itoa(clicks.Get())+" times")),
)
}
return func() *VNode {
markup := RenderHTML(demoTree())
var groups []*VNode
for _, g := range docsNav() {
grid := []Mod{Attr("class", "mt-3 grid gap-3 sm:grid-cols-2")}
@@ -213,10 +268,39 @@ func DocsPage(d Deps) func() *VNode {
}
return docPage("Introduction", "Overview",
"Kjol Web is kjol's Go→WebAssembly UI engine. You write components as ordinary Go functions "+
"returning a virtual DOM; the server renders them to HTML and the same code hydrates them "+
"in the browser. There is no JavaScript build step, and the engine depends on nothing "+
"outside the standard library.",
"Kjøl Wasm Web is Kjøl's Go→WebAssembly UI engine. You write components as ordinary Go "+
"functions returning a virtual DOM; the server renders them to HTML and the same code "+
"hydrates them in the browser. There is no JavaScript build step, and the engine depends "+
"on nothing outside the standard library.",
// ---- the demonstration ----
//
// The one thing on this site that cannot be faked: the same Go function, rendered
// twice at once, as live DOM and as the HTML string the server sent.
docSection("two-runtimes", "One function, two runtimes",
prose("Below is a single Go function, shown twice. On the left it has been reconciled into "+
"the DOM and you can use it. On the right is the HTML the same function produces when the "+
"server renders it — the markup that reached your browser before any WebAssembly had "+
"loaded. Click the button; both move."),
Div(Attr("class", "mt-5 border border-line sm:grid sm:grid-cols-2"),
Div(Attr("class", "border-b border-line sm:border-b-0 sm:border-r"),
paneLabel("in your browser"),
Div(Attr("class", "px-4 py-8"), demoTree()),
),
Div(
paneLabel(itoa(len(markup))+" bytes of HTML"),
Pre(Attr("class", "whitespace-pre-wrap px-4 py-4 font-mono text-[12px] leading-relaxed text-ink-muted"),
El("code", Text(prettyHTML(markup))),
),
),
),
P(Attr("class", "mt-3 text-sm leading-relaxed text-ink-muted"),
Text("The right pane is not a picture of the source. It is vdom.RenderHTML, called on the "+
"very tree the left pane is showing, recomputed on every click.")),
P(Attr("class", "mt-2 text-sm text-ink-muted"),
Text(hydrationNote(hydratedAt.Get()))),
),
docSection("what-runs-where", "What runs where",
prose("A page is Go, compiled twice. On the server it renders to an HTML string, so the first "+
@@ -230,15 +314,35 @@ func DocsPage(d Deps) func() *VNode {
"position itself, without a branch in the component."),
),
docSection("what-is-in-it", "What is in it",
Ul(Attr("class", "mt-3 space-y-1.5 leading-relaxed text-ink-soft"),
item("Server-side rendering and client hydration, from one codebase."),
item("Server components: mark a function and its code and state stay on the server."),
item("A component kit — forms, tabs, modals, tooltips, toasts — with dark mode."),
item("A table with filtering, sorting, column management, formulas, and CSV and PDF export."),
item("Tailwind, compiled by a Go program that reads your Go."),
),
prose("Two commands build it. The first produced the page you are reading; the second serves "+
"it and rebuilds on save."),
codeLang("terminal", "sh", buildTranscript),
),
appendNodes(Div(Attr("class", "mt-14 border-t border-line pt-2")), groups...),
)
}
}
func docsCard(d Deps, it docsItem) *VNode {
// A component card is a jump into the components page, not a page of its own — so it
// routes there and scrolls, exactly as the sidebar does.
click := navigate(d, it.Path)
if base, frag, ok := strings.Cut(it.Path, "#"); ok {
click = navigateAnchor(d, base, frag)
}
return A(
Attr("class", "group block rounded-default border border-line bg-surface p-4 no-underline shadow-xs transition hover:border-primary-border hover:shadow-sm"),
Attr("href", it.Path), navigate(d, it.Path),
Attr("href", it.Path), click,
Div(Attr("class", "flex items-center gap-2"),
Span(Attr("class", "inline-flex h-7 w-7 items-center justify-center rounded-default bg-primary-subtle text-accent"),
ui.IconInline(it.Icon, 14, "")),

View File

@@ -1,355 +0,0 @@
package app
import (
"strings"
. "kjol/vdom"
ui "kjol/webui"
)
// orElse is a fallback for an empty string.
func orElse(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
// row is a flex/grid container helper (appends *VNode children as Mods).
func row(class string, children ...*VNode) *VNode {
mods := []Mod{Attr("class", class)}
for _, c := range children {
mods = append(mods, c)
}
return Div(mods...)
}
// kitSection is one labelled block of the gallery — a live demo panel, so that what you
// are looking at is unmistakably the component running rather than a picture of it.
func kitSection(title string, body ...*VNode) *VNode {
return demo(title, row("flex flex-col gap-4", body...))
}
func ptRow(name, plan string, status *VNode) *VNode {
td := func(cls string, c *VNode) *VNode { return El("td", Attr("class", "px-3 py-2 text-sm "+cls), c) }
return El("tr",
td("text-ink", Text(name)),
td("text-ink-soft", Text(plan)),
El("td", Attr("class", "px-3 py-2 text-sm text-right"), status),
)
}
// languageOptions is deliberately longer than the pill limit, so the multi-select
// demonstrates both ways it collapses: past 3 selections it says "N items selected"
// outright, and below that it still collapses if the pills are too wide for the field.
func languageOptions() []ui.FormSelectOption {
return []ui.FormSelectOption{
{Value: "go", Label: "Go"},
{Value: "rust", Label: "Rust"},
{Value: "ts", Label: "TypeScript"},
{Value: "python", Label: "Python"},
{Value: "kotlin", Label: "Kotlin"},
{Value: "swift", Label: "Swift"},
}
}
//gowasm:page /wasm/kit layout=app
func KitPage(d Deps) func() *VNode {
// Interactive demos own their state via signals (a write re-renders).
tab := NewSignal(0)
acc := NewSignal(0)
notify := NewSignal(true)
span := NewSignal("week")
name := NewSignal("")
email := NewSignal("")
plan := NewSignal("pro")
langs := NewSignal([]string{"go"})
// Floating components are CONTROLLERS: they own refs, timers and open state, so
// they are built once here — never inside the render closure below, which would
// rebuild them (and lose their state) on every frame.
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
tip := ui.NewHoverTooltip(ui.PlacementTop, "")
skills := ui.NewMultiSelect(ui.DropdownOptions{})
// The controls the first port left out, now that the host API can carry them.
taxID := NewSignal("")
rate := NewSignal("")
signed := NewSignal("")
picked := NewSignal("")
tags := NewSignal([]string{"go"})
pad := ui.NewSignaturePad(ui.SignaturePadOptions{
OnChange: func(svg string) { signed.Set(svg) },
})
// The search is the caller's: the component knows how to debounce, order and render,
// and nothing at all about where options come from. Here it is a local slice; in an
// app it would be a fetch.
people := ui.NewAsyncCombobox(ui.AsyncComboboxOptions{
MinChars: 2,
Search: func(q string, done func([]ui.FormSelectOption)) {
var out []ui.FormSelectOption
for _, row := range employees() {
p, ok := row.(Employee)
if ok && strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) {
out = append(out, ui.FormSelectOption{Value: p.Email, Label: p.Name})
}
}
done(out)
},
})
tagPicker := ui.NewMultiSelectTrigger(ui.DropdownOptions{})
return func() *VNode {
return docPage("Components", "UI kit",
"kjol/webui is the component library: buttons, badges, forms, tabs, alerts, cards, tables. "+
"It is a Go port of the Solid.js kit the applications used before, styled with the same "+
"Tailwind utilities — so the two can be swapped for one another a screen at a time.",
docSection("using", "Using a component",
prose("Components are functions taking a props struct. There is no class hierarchy and nothing "+
"to register: a component is a value, so you can build one, store it, pass it around, and "+
"the compiler will tell you when you get it wrong."),
code("app/kit.go", kitSnippet),
note("Styling is Tailwind, compiled from your Go",
"The Tailwind engine scans .go files for class names, because that is where the markup is. "+
"There is no JavaScript build in this example at all — the CSS is compiled by a Go "+
"program from Go source."),
),
docSection("gallery", "The gallery",
prose("Everything below is running. Click it."),
),
kitSection("Buttons",
row("flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Text: "Red"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Text: "Blue"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Neutral"}),
),
row("flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Outline: true, Text: "Outline"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Icon: "check", Text: "Small + icon"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Icon: "plus"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Disabled", Disabled: true}),
),
),
kitSection("Badges",
row("flex flex-wrap items-center gap-2",
ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeRed}, Text("failed")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("info")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("default")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeMuted}, Text("muted")),
),
),
kitSection("Alerts",
ui.Alert(ui.AlertBlue, "Heads up", Text("An informational message with a header.")),
ui.Alert(ui.AlertGreen, "", Text("A success alert without a header.")),
ui.Alert(ui.AlertYellow, "Warning", Text("Something needs your attention.")),
ui.Alert(ui.AlertRed, "Error", Text("Something went wrong.")),
),
kitSection("Toggles & segmented control",
ui.ToggleSwitch(notify.Get(), func(v bool) { notify.Set(v) }, "Email notifications", "Send me product updates", false, ""),
ui.SegmentedButtons([]ui.SegmentedButtonOption{
{Value: "day", Label: "Day"},
{Value: "week", Label: "Week"},
{Value: "month", Label: "Month"},
}, span.Get(), func(v string) { span.Set(v) }, false, "max-w-xs"),
),
kitSection("Tabs",
ui.TabGroup(ui.TabGroupProps{
Items: []ui.TabItem{
{Title: "Overview", Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The overview panel."))},
{Title: "Details", Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The details panel."))},
{Title: "Activity", Badge: 3, Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The activity panel (3 new)."))},
},
ActiveIndex: tab.Get(),
OnTabChange: func(i int) { tab.Set(i) },
}),
),
kitSection("Accordion",
ui.SingleAccordion([]ui.AccordionItemData{
{Title: "What is Kjol Web?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("kjol's Go→WebAssembly UI engine."))},
{Title: "Is it isomorphic?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("Yes — the same Go renders on the server (SSR) and hydrates on the client."))},
{Title: "How is it styled?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("Tailwind utility classes, compiled by kjol's native Tailwind engine."))},
}, acc.Get(), func(i int) { acc.Set(i) }),
),
kitSection("Forms",
row("grid gap-4 sm:grid-cols-3",
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Name")),
ui.FormInput(ui.FormInputProps{Value: name.Get(), Placeholder: "Ada Lovelace", OnInput: func(v string) { name.Set(v) }})),
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Email")),
ui.FormEmailInput(ui.FormInputProps{Value: email.Get(), Placeholder: "ada@example.com", OnInput: func(v string) { email.Set(v) }}, true)),
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Plan")),
ui.FormSelect(ui.FormSelectProps{Value: plan.Get(), OnChange: func(v string) { plan.Set(v) }},
ui.FormOption("free", "Free", false),
ui.FormOption("pro", "Pro", false),
ui.FormOption("enterprise", "Enterprise", false))),
// A multi-select. Its rows carry checkboxes, and the field shows the
// selection as removable pills — until they stop fitting, at which point
// it collapses to "N items selected". Tick a few and watch it flip.
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Languages")),
skills.Render(ui.FormMultiSelectProps{
Options: languageOptions(),
Value: langs.Get(),
Placeholder: "Pick a few",
Searchable: true,
ShowSelectAll: true,
OnChange: func(v []string) { langs.Set(v) },
})),
),
P(Attr("class", "text-xs text-ink-muted"),
Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+
"\" languages="+strings.Join(langs.Get(), ","))),
),
kitSection("Masked inputs",
row("grid gap-4 sm:grid-cols-2",
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Tax ID")),
// The mask is a pure function of the string, applied on every keystroke.
// It must be idempotent — it is fed its own output — or the field
// corrupts itself as you type.
ui.FormInput(ui.FormInputProps{
Value: taxID.Get(),
Placeholder: "12-3456789",
OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) },
})),
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Rate")),
ui.FormInput(ui.FormInputProps{
Value: rate.Get(),
Placeholder: "5.25",
OnInput: func(v string) { rate.Set(ui.MaskRate(v)) },
})),
),
P(Attr("class", "text-xs text-ink-muted"),
Text("Type letters, extra dots, leading zeros — the mask takes what it can use.")),
),
kitSection("Async combobox",
row("max-w-sm",
people.Render(ui.FormAsyncComboboxProps{
Placeholder: "Search people…",
OnSelect: func(o ui.FormSelectOption) { picked.Set(o.Label + " <" + o.Value + ">") },
}),
),
P(Attr("class", "text-xs text-ink-muted"),
Text("Two characters before it asks; 200 ms after you stop typing. A response for a "+
"query you have already typed past is discarded rather than shown. Picked: "+
orElse(picked.Get(), "nothing yet"))),
),
kitSection("Multi-select behind your own trigger",
tagPicker.Render(ui.FormMultiSelectTriggerProps{
Trigger: ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Icon: "filter", Text: "Tags (" + itoa(len(tags.Get())) + ")"}),
Options: languageOptions(),
Value: tags.Get(),
Searchable: true,
ShowSelectAll: true,
OnChange: func(v []string) { tags.Set(v) },
}),
P(Attr("class", "text-xs text-ink-muted"),
Text("Same selection model as the field above; only the thing you click on differs.")),
),
kitSection("Signature pad",
pad.Render(ui.SignaturePadProps{}),
P(Attr("class", "text-xs text-ink-muted"),
Text("Draw in it. It is an SVG, not a canvas — so the markup you are looking at IS the "+
"value the caller gets ("+itoa(len(signed.Get()))+" bytes), and a stored signature "+
"renders on the server.")),
),
kitSection("Table",
ui.PrettyTable(
[]ui.PrettyTableColumn{
{DisplayName: "Name"},
{DisplayName: "Plan"},
{DisplayName: "Status", DisplayPosition: ui.PrettyTableColRight},
},
ui.PrettyTableOptions{Hover: true, Alternate: true, SurroundingBorder: true, HeaderBorderY: true},
ptRow("Ada Lovelace", "Pro", ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active"))),
ptRow("Alan Turing", "Free", ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("trial"))),
ptRow("Grace Hopper", "Enterprise", ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("invited"))),
),
),
kitSection("Overlays (measured, portaled)",
row("flex flex-wrap items-center gap-4",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
// The menu measures itself against the viewport: drag the window
// narrow, or scroll it to the bottom, and it flips/shifts to stay on
// screen. Items close the menu themselves — no callback plumbing.
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
caret := " ▾"
if open {
caret = " ▴"
}
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Menu" + caret})
}),
menu.Content("",
menu.Item(ui.MenuItemProps{Icon: "check"}, Text("Profile")),
menu.Item(ui.MenuItemProps{}, Text("Settings")),
ui.MenuDivider(""),
menu.Item(ui.MenuItemProps{}, Text("Sign out")),
),
// The tooltip's arrow tracks the trigger even when the panel gets
// shifted away from it near a viewport edge.
tip.Render(Span(Text("A measured tooltip — try it near the window edge")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Hover me"})),
),
modal.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
},
P(Attr("class", "text-ink-soft"),
Text("Portaled to document.body, so it is not clipped by any ancestor. Escape closes "+
"the topmost modal; the backdrop click closes too.")),
),
),
docSection("more", "Where to go next",
prose("The floating components on this page — the menu, the tooltip, the modal, the "+
"multi-select — are the shallow end. Overlays covers how they are positioned, and what "+
"happens when one would open off the edge of the screen."),
apiTable(
apiRow{"ui.Button / ui.Badge / ui.Alert", "The presentational set. Props structs, no state."},
apiRow{"ui.FormInput / FormSelect / FormCombobox", "Inputs. Value in, OnChange out — the caller owns the state."},
apiRow{"ui.NewMultiSelect", "A controller: checkboxed rows, pills that collapse to \"N items selected\" when they stop fitting."},
apiRow{"ui.Tabs / ui.Accordion / ui.Card", "Layout and disclosure."},
apiRow{"ui.RegisterIcon", "Add your own icons. The kit ships a small set; the app brings the rest."},
),
),
)
}
}
const kitSnippet = `// A component is a function taking a props struct.
ui.Button(ui.ButtonProps{
Color: ui.ButtonPrimary,
Icon: "check",
Text: "Save",
OnClick: func() { toaster.Success("Saved.") },
})
// Inputs are controlled: the caller owns the state.
name := NewSignal("")
ui.FormInput(ui.FormInputProps{
Value: name.Get(),
OnInput: func(v string) { name.Set(v) }, // a write re-renders
})`

View File

@@ -7,22 +7,42 @@ import (
ui "kjol/webui"
)
// The layers of kjol, as data.
// What kjøl is made of, as data.
//
// This is the Go mirror of frontend/src/layers.ts. The site has two front-ends built
// by two completely different pipelines, and the Layers menu has to be identical in
// both — so it is a LIST in each, not markup, and the two lists are the only thing
// that has to be kept in step.
// There are two kinds of thing here, and conflating them was the mistake this file used
// to make — one flat list called "the layers", holding both.
//
// (A shared source would be better than a mirrored one. There isn't one: this half
// compiles to WebAssembly and the other is bundled by esbuild, and nothing is upstream
// of both. Keeping it to a flat slice of plain data is what makes the duplication
// survivable — you can diff the two by eye.)
// LAYERS are LANGUAGES. What kjøl is written in, and what it gives you in each:
// the Go base, the TypeScript kit, the C base, the Jai modules. A layer is
// a directory of code you can use on its own.
//
// COMPOSITIONS are FRAMEWORKS. What you get when the layers are assembled into
// something that does a job — the two web engines. A composition is not
// another language; it is a use of them.
//
// Kjøl Wasm Web is Go, all the way down. Kjøl JS Web is TypeScript compiled by a Go
// toolchain — two layers, one framework. Listing that beside "C" as though they were the
// same kind of noun told the reader nothing about either.
//
// This is the Go mirror of frontend/src/layers.ts. The site has two front-ends built by
// two completely different pipelines, and these menus have to be identical in both — so
// each is a LIST, not markup, and the two lists are the only thing that has to be kept in
// step.
//
// (A shared source would be better than a mirrored one. There isn't one: this side
// compiles to WebAssembly and the other is bundled by esbuild, and nothing is upstream of
// both. Keeping each to a flat slice of plain data is what makes the duplication
// survivable — you can diff them by eye.)
type Layer struct {
Name string
Href string
Tagline string
// Sub is the half-line beside the wordmark while you are inside this layer. It says
// what you are standing in — "Go + WebAssembly", "arenas, strings, a lexer" — and a
// wordmark that says the same thing everywhere is one more thing the reader has to
// keep track of himself.
Sub string
// Live means you can click into worked examples. The others are documented but
// have no demo — they still appear, because a menu that silently omits half the
// library teaches the reader that the library is half the size it is.
@@ -30,66 +50,107 @@ type Layer struct {
Icon string
}
func Layers() []Layer {
// Wordmark is what the CHROME calls this layer — the top bar, and the page's own title.
// The menu calls it Name.
//
// They differ, and only for the languages. In a menu headed "Layers" the row says "C",
// because the row is answering "which language"; up in the top bar, alone, "C" is the name
// of a language rather than the name of the thing you are reading, and it has to say whose
// C this is. The compositions are already named "Kjøl Wasm Web" — the product's name is
// part of what they ARE, not a prefix bolted on — so they are returned unchanged.
func (l Layer) Wordmark() string {
if strings.HasPrefix(l.Name, "Kjøl") {
return l.Name
}
return "Kjøl " + l.Name
}
// Languages: what kjøl is written in.
func Languages() []Layer {
return []Layer{
{
Name: "Kjol Go",
Name: "Go",
Href: "/go",
Tagline: "The server base: config, database, logging, HTTP, mail, validation.",
Tagline: "The base: config, database, logging, HTTP, mail, validation — and both web engines.",
Icon: "server",
},
{
Name: "Kjol Wasm Web",
Href: "/wasm",
Tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, no JS build.",
Live: true,
Icon: "code",
},
{
Name: "Kjol JS Web",
Href: "/js",
Tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
Live: true,
Name: "TypeScript",
Href: "/ts",
Tagline: "The Solid component kit, the vendored runtime, and the generic scaffolding apps import as @kjol/*.",
Icon: "squares",
},
{
Name: "Kjol C",
Name: "C",
Href: "/c",
Tagline: "Arena allocator, strings, math, lexer, platform layer.",
Tagline: "Arena allocator, counted strings, math, a lexer, a platform layer — and a build system that is a C file.",
Sub: "a base layer in C",
Live: true,
Icon: "bolt",
},
{
Name: "Kjol Jai",
Name: "Jai",
Href: "/jai",
Tagline: "Console rendering module. Early.",
Tagline: "Console rendering. Early.",
Icon: "cube",
},
}
}
// CurrentLayer is the layer the given path belongs to, or nil on the front page.
// Compositions: what the languages are assembled into.
func Compositions() []Layer {
return []Layer{
{
Name: "Kjøl Wasm Web",
Href: "/wasm",
Tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, and no JavaScript build at all.",
Sub: "Go + WebAssembly",
Live: true,
Icon: "code",
},
{
Name: "Kjøl JS Web",
Href: "/js",
Tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
Sub: "Solid + Go toolchain",
Live: true,
Icon: "table",
},
}
}
// CurrentLayer is the layer or composition the given path belongs to, or nil on the front
// page. The wordmark uses it to name where you are standing.
func CurrentLayer(path string) *Layer {
for i, l := range Layers() {
all := append(Compositions(), Languages()...)
for i, l := range all {
if path == l.Href || strings.HasPrefix(path, l.Href+"/") {
return &Layers()[i]
return &all[i]
}
}
return nil
}
// LayersMenuCtl is the Layers menu's controller.
// The two menus' controllers.
//
// It is created ONCE, here, at package level — not inside layersMenu, which is called
// from a layout on every single render. A floating component is a controller: it owns
// an open signal, a positioning engine and document listeners, and building a fresh one
// per render would leak all three and give you a menu that never opens. Same rule as
// Theme, a few lines up in pages.go.
var LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
// They are created ONCE, here, at package level — not inside the functions below, which a
// layout calls on every single render. A floating component is a controller: it owns an
// open signal, a positioning engine and document listeners, and building a fresh one per
// render would leak all three and give you a menu that never opens. Same rule as Theme, a
// few lines up in pages.go.
//
// TWO menus, not one with two headings inside it. They are different questions — "what is
// this written in" and "what can I read" — and a reader who wants the second should not
// have to scroll past the first to find it. The kit's single-open manager means opening
// one closes the other, so they behave like one control with two halves.
var (
LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
CompositionsMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
)
// layersMenu is the site's primary navigation: kjol is a stack of layers, and this is
// how you get from any one of them to any other.
// layersMenu lists the LANGUAGES. compositionsMenu, below, lists the frameworks.
//
// A layer that is Live is a link. One that is not is inert and dimmed, with the word
// An entry that is Live is a link. One that is not is inert and dimmed, with the word
// "reference" on it — it exists, it is documented in the repository, there is simply
// nothing here to click.
//
@@ -98,35 +159,41 @@ var LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
// interception — an intercepted click would ask this WebAssembly to render a page it
// does not have.
func layersMenu(d Deps) *VNode {
content := []*VNode{
P(Attr("class", "px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint"),
Text("The layers of kjol")),
}
for _, l := range Layers() {
return dropdown(d, LayersMenuCtl, "Layers", Languages())
}
// compositionsMenu lists the FRAMEWORKS — the two things assembled out of the layers, and
// the two a reader can actually click into.
func compositionsMenu(d Deps) *VNode {
return dropdown(d, CompositionsMenuCtl, "Compositions", Compositions())
}
func dropdown(d Deps, ctl *ui.Menu, label string, rows []Layer) *VNode {
content := make([]*VNode, 0, len(rows))
for _, l := range rows {
content = append(content, layerItem(d, l))
}
return Div(Attr("class", "relative"),
LayersMenuCtl.Trigger(ui.MenuTriggerProps{
ctl.Trigger(ui.MenuTriggerProps{
Class: "inline-flex items-center gap-1.5 rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink",
},
Text("Layers"),
Text(label),
ui.IconInline("chevron-down", 11, "text-ink-faint"),
),
LayersMenuCtl.Content("w-96", content...),
ctl.Content("w-96", content...),
)
}
// layersGrid is the front page's list of layers — the same data as the menu, laid out
// to be read rather than navigated. A layer with no examples still gets a row: the
// point of the page is what kjol IS, and half of it having no demo yet does not make
// that half not exist.
func layersGrid(d Deps) *VNode {
rows := []Mod{Attr("class", "mt-5 divide-y divide-line rounded-default border border-line")}
for _, l := range Layers() {
rows = append(rows, layerRow(l))
// layerGrid is the front page's list — the same data as the menu, laid out to be read
// rather than navigated. A layer with no examples still gets a row: the point of the page
// is what kjøl IS, and half of it having no demo yet does not make that half not exist.
func layerGrid(rows []Layer) *VNode {
mods := []Mod{Attr("class", "mt-5 divide-y divide-line rounded-default border border-line")}
for _, l := range rows {
mods = append(mods, layerRow(l))
}
return Div(rows...)
return Div(mods...)
}
func layerRow(l Layer) *VNode {

View File

@@ -1,406 +0,0 @@
package app
import (
"strconv"
. "kjol/vdom"
ui "kjol/webui"
)
// Every floating component in the kit, on one page: tooltips, popovers, menus and
// submenus, the date picker, modals (plain, confirm, wizard, imperative), toasts,
// and the tutorial's spotlight coachmarks.
//
// All of them are CONTROLLERS. They own refs, timers and open state, so they are
// created once here — never inside the render closure, which runs on every signal
// write and would rebuild them (and their refs) from scratch every frame. That is
// the single rule to remember about the floating layer.
//
// The page is NOT `static`: nothing is open during SSR anyway, so pre-rendering it
// buys nothing, and it keeps the example honest about which routes need it.
//
//gowasm:page /wasm/overlays layout=app
func OverlaysPage(d Deps) func() *VNode {
// --- tooltips -----------------------------------------------------------
tipTop := ui.NewHoverTooltip(ui.PlacementTop, "")
tipRight := ui.NewHoverTooltip(ui.PlacementRight, "")
tipFocus := ui.NewFocusTooltip(ui.PlacementBottom, "")
tipFast := ui.NewTooltip(ui.TooltipProps{Placement: ui.PlacementTop, Delay: -1})
// --- popovers -----------------------------------------------------------
pop := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomStart})
popEnd := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomEnd})
hoverPop := ui.NewHoverPopover(ui.HoverPopoverProps{
Placement: ui.PlacementTop,
// The bridge: the cursor gets 300ms of grace to cross the gap from the
// trigger onto the panel. Without it, the panel closes in the dead space
// between them — which is exactly what happens once a panel is portaled and
// CSS :hover no longer reaches it.
HoverCloseDelay: 300,
})
// --- menus --------------------------------------------------------------
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
sub := ui.NewSubmenu(menu)
hoverMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart, OpenOnHover: true})
// --- date pickers -------------------------------------------------------
picked := NewSignal("")
dp := ui.NewDatePicker(ui.DatePickerProps{
Placeholder: "Pick a date",
Clearable: true,
OnChange: func(v string) { picked.Set(v) },
})
dob := ui.NewDateOfBirthPicker(ui.DatePickerProps{Placeholder: "Date of birth"})
// --- modals -------------------------------------------------------------
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
nested := ui.NewModal(ui.ModalOptions{Size: ui.ModalSmall})
deleted := NewSignal(false)
confirm := ui.NewModal(ui.ModalOptions{})
// --- wizard -------------------------------------------------------------
wizardName := NewSignal("")
wizardDone := NewSignal(false)
wizard := ui.NewWizard(ui.ModalOptions{})
// --- toasts -------------------------------------------------------------
// The Toaster owns the queue AND the clocks: it generates IDs, runs the
// auto-dismiss timer, and animates the countdown bar down to zero. (ToastProvider,
// the dumb half, renders a list you hand it and removes nothing — a toast pushed
// through it stays until you take it away yourself.)
toaster := ui.NewToaster(ui.ToasterOptions{Position: ui.ToastBottomRight})
pushToast := func(kind ui.ToastType, msg string) {
toaster.Push(ui.Toast{Message: msg, Type: kind})
}
// --- tutorial -----------------------------------------------------------
// Steps target elements by CSS SELECTOR. The tour resolves each one with
// document.querySelector, measures it, scrolls it into view, and cuts a hole in
// the dimmed overlay around it — the spotlight animates from target to target.
tour := ui.NewTutorial(ui.TutorialOptions{
Steps: []ui.TutorialStep{
{
Title: "Tooltips",
Target: "#demo-tooltips",
Content: func() *VNode { return Text("Measured, portaled, and they flip near a viewport edge.") },
},
{
Title: "Popovers",
Target: "#demo-popovers",
Placement: ui.PlacementBottom,
Content: func() *VNode { return Text("Click or hover. The hover bridge lets you reach the panel.") },
},
{
Title: "Menus",
Target: "#demo-menus",
Content: func() *VNode { return Text("Items close the menu themselves; submenus are portaled.") },
},
{
// No Target: the page dims flat and the card centres in the viewport.
Title: "That's the tour",
Content: func() *VNode { return Text("Escape ends it. Arrow keys and Enter move between steps.") },
},
},
})
return func() *VNode {
return docPage("Components", "Overlays",
"Tooltips, popovers, menus, modals and toasts — every one of them measured against the real "+
"viewport. A floating panel is portaled to document.body, positioned from its trigger's "+
"bounding box, and flipped or shifted when it would otherwise run off the screen.",
docSection("engine", "How a panel is placed",
prose("Positioning is a pure function: given the trigger's rectangle, the panel's size and the "+
"viewport, it returns coordinates. It is unit-tested natively, with no browser in sight, "+
"because none of it is about the browser — the browser only supplies the three rectangles."),
prose("The result is written to the element with SetStyle, NOT through a signal. A signal write "+
"re-renders the whole tree, and this runs on every scroll and resize frame; going through "+
"the vdom would rebuild the page sixty times a second to move one panel four pixels."),
code("webui/floating.go", floatingSnippet),
note("Controllers are built once",
"A floating component owns refs, timers and its open state. Build it alongside your signals, "+
"never inside the render closure — one built per frame can never stay open, because the "+
"thing holding \"open\" is thrown away and replaced before you can see it."),
row("mt-4 flex gap-2", tour.StartButton(0, "", Text("Take the tour"))),
),
// ---- tooltips ----
docSection("demo-tooltips", "Tooltips",
prose("Hover, or focus — a tooltip that only answers to a mouse is a tooltip a keyboard user "+
"cannot read. Narrow the window and hover the Right one: it flips to the left, and its "+
"arrow follows it. Near an edge the panel shifts back on screen and the arrow slides to "+
"keep pointing at the trigger; in the original kit the arrow detached and pointed at "+
"nothing."),
demo("Placement, delay, and focus triggers",
row("flex flex-wrap items-center gap-3",
tipTop.Render(Span(Text("Above — the default")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Top"})),
tipRight.Render(Span(Text("To the right, unless it would run off the edge")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Right"})),
tipFast.Render(Span(Text("No open delay")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Instant"})),
tipFocus.Render(Span(Text("Shown on focus, not hover — tab to the field")),
ui.FormInput(ui.FormInputProps{Placeholder: "Focus me"})),
),
),
),
// ---- popovers ----
docSection("demo-popovers", "Popovers",
prose("A popover closes on an outside click or on Escape — and only the TOPMOST one closes per "+
"press, so a dropdown inside a popover does not take the popover down with it. The hover "+
"variant keeps a bridge across the gap between trigger and panel, so the cursor can "+
"actually reach the thing it opened."),
demo("Click, alignment, and hover-with-a-bridge",
row("flex flex-wrap items-center gap-3",
pop.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Click me"})),
pop.Content(ui.PopoverContentProps{Class: "w-64"},
P(Attr("class", "text-sm text-ink-soft"),
Text("Click outside, or press Escape, to close. Only the topmost floating closes per press.")),
),
popEnd.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Aligned to my right edge"})),
popEnd.Content(ui.PopoverContentProps{Class: "w-56"},
P(Attr("class", "text-sm text-ink-soft"), Text("Placement bottom-end.")),
),
hoverPop.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Hover me, then reach the panel"})),
hoverPop.Content(ui.PopoverContentProps{Class: "w-64"},
P(Attr("class", "text-sm text-ink-soft"),
Text("Move the cursor across the gap and onto this panel — it stays open. "+
"Select this text to prove it.")),
),
),
),
),
// ---- menus ----
docSection("demo-menus", "Menus & submenus",
prose("Opening one menu closes the other: a single-open manager keeps the page from filling up "+
"with panels nobody asked for. Submenus are exempt from it — they are Standalone — or a "+
"submenu would close the very menu it belongs to as it opened."),
prose("A submenu is portaled too, which is not a detail: the parent menu scrolls its own "+
"contents, and a submenu rendered inside it was clipped by that overflow the moment it "+
"was taller than its parent."),
demo("Items, icons, a submenu, and KeepOpen",
row("flex flex-wrap items-center gap-3",
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
caret := " ▾"
if open {
caret = " ▴"
}
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Actions" + caret})
}),
menu.Content("",
menu.Item(ui.MenuItemProps{Icon: "check", OnClick: func() { pushToast(ui.ToastSuccess, "Profile opened") }},
Text("Profile")),
menu.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastInfo, "Settings opened") }},
Text("Settings")),
// The submenu is portaled — it used to be clipped by the parent
// menu's own overflow-y-auto.
sub.Submenu(ui.SubmenuProps{Trigger: "More", Icon: "ellipsis"},
sub.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastInfo, "Archived") }}, Text("Archive")),
sub.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastWarning, "Duplicated") }}, Text("Duplicate")),
),
ui.MenuDivider(""),
// KeepOpen is the TSX's closeOnClick inverted: by default an item
// closes the menu, which the first Go port dropped entirely.
menu.Item(ui.MenuItemProps{KeepOpen: true, OnClick: func() { pushToast(ui.ToastGeneric, "Menu stayed open") }},
Text("Stay open (KeepOpen)")),
menu.Item(ui.MenuItemProps{Icon: "arrow-right-from-bracket",
OnClick: func() { pushToast(ui.ToastError, "Signed out") }}, Text("Sign out")),
),
hoverMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(bool) *VNode {
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Opens on hover"})
}),
hoverMenu.Content("",
hoverMenu.Item(ui.MenuItemProps{}, Text("One")),
hoverMenu.Item(ui.MenuItemProps{}, Text("Two")),
),
),
),
),
// ---- date pickers ----
docSection("demo-dates", "Date picker",
prose("The field is typeable, not merely clickable. It parses loosely — 7/4/26, Jul 4 2026 and "+
"2026-07-04 all work — and commits what it understood on blur, so the calendar is an "+
"affordance rather than the only way in."),
demo("Picked: \""+picked.Get()+"\"",
row("grid gap-4 sm:grid-cols-2",
row("flex flex-col gap-1",
ui.FormLabel(ui.FormLabelProps{}, Text("Date (portaled, flips near the bottom)")),
dp.Render(),
),
row("flex flex-col gap-1",
ui.FormLabel(ui.FormLabelProps{}, Text("Date of birth (inline, three selects)")),
dob.Render(),
),
),
),
),
// ---- modals ----
docSection("demo-modals", "Modals",
prose("Portaled to document.body, so no ancestor's overflow:hidden or transform can clip them. "+
"Open the modal, then the nested one inside it, and press Escape twice: modals unwind one "+
"layer per press rather than all at once."),
prose("The last button opens a modal that no component in the tree owns — webui.OpenModal hands "+
"content to a shared host rendered once in the layout. That is what code far from the view "+
"needs: a confirmation raised from inside a save handler, say."),
demo("Deleted: "+strconv.FormatBool(deleted.Get()),
row("flex flex-wrap items-center gap-3",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Delete something…", OnClick: confirm.Open}),
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open wizard", OnClick: wizard.Open}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Open imperatively",
OnClick: func() {
// No component in the tree owns this one: OpenModal hands content
// to the shared host rendered in the layout.
ui.OpenModal(func() *VNode {
return ui.ModalContent(ui.ModalContentProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Opened from anywhere")),
},
P(Attr("class", "text-ink-soft"),
Text("This content was not rendered by any component — it was handed to "+
"ModalHost (see AppLayout) by webui.OpenModal.")),
)
}, ui.ModalOptions{Size: ui.ModalSmall})
}}),
),
),
// The modals themselves. They portal to document.body, so where they sit in
// the tree makes no difference to where they appear.
modal.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("A modal")),
Footer: ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Close", OnClick: modal.Close}),
},
P(Attr("class", "text-ink-soft"),
Text("Portaled to document.body, so no ancestor's overflow:hidden can clip it. It fades "+
"and scales in — a double requestAnimationFrame, because a single frame does not "+
"give the browser time to commit the initial style.")),
row("mt-4",
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open a nested modal", OnClick: nested.Open}),
),
),
nested.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Nested")),
},
P(Attr("class", "text-ink-soft"), Text("Escape closes THIS one first, not the one behind it.")),
),
confirm.Confirm(ui.ConfirmModalProps{
Title: "Delete row",
Message: "This cannot be undone.",
OnConfirm: func() {
deleted.Set(true)
pushToast(ui.ToastError, "Row deleted")
},
}),
wizard.Render(ui.WizardProps{
Title: "Set up your account",
FinishText: "Finish",
OnComplete: func() {
wizardDone.Set(true)
pushToast(ui.ToastSuccess, "Wizard complete: "+wizardName.Get())
},
Steps: []ui.WizardStep{
{
Title: "Your name",
// Each step gets its own context: SetCanContinue gates THIS step's
// Next button, which a single shared bool could not express.
Content: func(ctx ui.WizardStepContext) *VNode {
ctx.SetCanContinue(wizardName.Get() != "")
return row("flex flex-col gap-1",
ui.FormLabel(ui.FormLabelProps{}, Text("Name (required to continue)")),
ui.FormInput(ui.FormInputProps{
Value: wizardName.Get(),
Placeholder: "Ada Lovelace",
OnInput: func(v string) { wizardName.Set(v) },
}),
)
},
},
{
Title: "Confirm",
Content: func(ctx ui.WizardStepContext) *VNode {
ctx.SetCanContinue(true)
return P(Attr("class", "text-ink-soft"),
Text("All set for "+wizardName.Get()+". Finish to close."))
},
},
},
}),
),
// ---- toasts ----
docSection("demo-toasts", "Toasts",
prose("They dismiss themselves after five seconds. Watch the bar count down: it is one CSS "+
"transition, written straight at the element — not a re-render per frame, which is what a "+
"progress bar driven through a signal would cost you."),
prose("A sticky toast (Duration: ToastSticky) waits for the user instead. The menu items above "+
"raise toasts too, which is how you can see that an item really does close its own menu."),
demo("Push, dismiss, and a sticky one",
row("flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "Success",
OnClick: func() { toaster.Success("Saved.") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Small: true, Text: "Error",
OnClick: func() { toaster.Error("Something went wrong.") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Small: true, Text: "Info",
OnClick: func() { toaster.Info("Just so you know.") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Sticky (no timer)",
OnClick: func() {
toaster.Push(ui.Toast{
Message: "This one waits for you to dismiss it.",
Type: ui.ToastWarning,
Duration: ui.ToastSticky,
})
}}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear all",
OnClick: toaster.Clear}),
),
),
),
docSection("overlay-api", "Reference",
apiTable(
apiRow{"NewFloating", "The positioning engine behind every panel: placement, offset, flip, shift, arrow."},
apiRow{"NewTooltip / NewPopover / NewMenu", "Controllers. Build once, outside the render."},
apiRow{"Standalone", "Exempts a panel from the single-open manager. A submenu needs it, or it closes its own parent."},
apiRow{"vdom.Portal", "Mounts children at document.body — the escape hatch from an ancestor's overflow:hidden."},
apiRow{"webui.OpenModal / ModalHost", "Open a modal from code that owns no component. Render the host once, in your layout."},
),
),
// The toast container and the tutorial's overlay both render here; both are
// fixed-position, so where they sit in the tree does not matter.
toaster.Render(),
tour.Render(),
)
}
}
const floatingSnippet = `// Built ONCE — it owns refs, timers, and whether it is open.
pop := ui.NewPopover(ui.PopoverOptions{
Placement: ui.PlacementBottomStart,
Offset: 8,
})
// ...and in the render:
pop.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Text: "Click me"}),
)
pop.Content(ui.PopoverContentProps{Class: "w-64"},
P(Text("Outside click and Escape close me.")),
)
// The panel is portaled to document.body and positioned imperatively:
// render invisible -> AfterRender -> measure -> ComputePosition -> SetStyle -> reveal
// Never through a signal: this runs on every scroll frame.`

View File

@@ -17,10 +17,6 @@ import (
. "kjol/vdom"
// The host API is dual-build — real under js/wasm, no-ops natively — so neutral page
// code can measure the browser and still server-render. The landing page uses it for
// exactly one thing: reading the clock when hydration commits.
"kjol/wasmruntime"
ui "kjol/webui"
)
@@ -67,16 +63,18 @@ func notFound(path string) *VNode {
// wordmark is the brand lockup, shared by both layouts so they cannot drift.
//
// The boat is the point of the name: kjol is Norwegian for KEEL — the spine of a hull,
// The boat is the point of the name: kjøl is Norwegian for KEEL — the spine of a hull,
// the thing every other part is built onto. Which is what this library is meant to be
// for the applications that share it.
func wordmark(d Deps, href string) *VNode {
// The lockup names the LAYER you are standing in, not the site. On the front page
// that is kjol itself; inside /wasm it is Kjol Wasm Web. A wordmark that says the
// same thing everywhere is one more thing the reader has to keep track of himself.
name, sub := "kjol", "a shared base layer"
// that is Kjøl itself; inside /wasm it is Kjøl Wasm Web; inside /c it is Kjøl C —
// Wordmark, not Name, because up here "C" alone names a language rather than the thing
// you are reading. A wordmark that says the same thing everywhere is one more thing the
// reader has to keep track of himself.
name, sub := "Kjøl", "a shared base layer"
if l := CurrentLayer(d.Path()); l != nil {
name, sub = l.Name, "Go + WebAssembly"
name, sub = l.Wordmark(), l.Sub
}
return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
@@ -103,22 +101,39 @@ func PublicLayout(d Deps, content *VNode) *VNode {
// read aloud.
Div(Attr("class", "pointer-events-none fixed inset-0 -z-10 bg-grid grid-fade"), Attr("aria-hidden", "true")),
// The nav, the content and the footer are ONE column, and the way to get that is for
// all three to be built the same way: gutter on the outside, measure on the inside.
//
// <div class="px-4"> <div class="mx-auto max-w-3xl"> …
//
// This used to be `mx-auto max-w-3xl px-4` on the nav's inner div — measure and gutter
// on the SAME element. On a wide screen the gutter has nothing to do (the centring has
// already pushed the box in much further), so all it did was inset the nav's contents
// by another 16px: the wordmark sat a finger's width to the right of the headline
// underneath it. Close enough to look like a mistake, far enough to see.
//
// The footer was worse — it was max-w-2xl, a different measure entirely.
Nav(Attr("class", "site-nav border-b border-line"),
Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 px-4 py-4"),
wordmark(d, "/"),
Div(Attr("class", "ml-auto flex items-center gap-1"),
layersMenu(d),
Ul(Attr("class", "flex items-center gap-1"),
navItem(d, "/about", "About", false),
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
),
))),
Div(Attr("class", "px-4"),
Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 py-4"),
wordmark(d, "/"),
Div(Attr("class", "ml-auto flex items-center gap-1"),
layersMenu(d),
compositionsMenu(d),
Ul(Attr("class", "flex items-center gap-1"),
navItem(d, "/about", "About", false),
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
),
)))),
Main(Attr("class", "px-4 py-14"), content),
Footer(Attr("class", "mx-auto max-w-2xl px-4 pb-14"),
P(Attr("class", "text-sm text-ink-faint"),
Text("kjol is a shared base layer, factored out of several applications so they stay in sync. It is Norwegian for keel.")),
Footer(Attr("class", "px-4 pb-14"),
Div(Attr("class", "mx-auto max-w-3xl"),
P(Attr("class", "text-sm text-ink-faint"),
Text("Kjøl is a shared base layer, factored out of several applications so they stay in "+
"sync. It is Norwegian for keel.")),
),
),
ui.ModalHost(),
)
@@ -127,7 +142,7 @@ func PublicLayout(d Deps, content *VNode) *VNode {
// wideRoutes get a roomier container. A table with a dozen columns, a drag handle
// and three calculated columns has no business being squeezed into a reading-width
// column; prose pages still are.
var wideRoutes = map[string]bool{"/wasm/table": true}
var wideRoutes = map[string]bool{"/wasm/components": true}
// AppLayout is the DOCUMENTATION shell: a sidebar of sections on the left, the page on
// the right. The app routes are the framework's docs — each one explains a capability,
@@ -155,6 +170,7 @@ func AppLayout(d Deps, content *VNode) *VNode {
Span(Attr("class", "rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint"), Text("Docs")),
Div(Attr("class", "ml-auto flex items-center gap-2"),
layersMenu(d),
compositionsMenu(d),
Ul(Attr("class", "flex items-center gap-2"),
navItem(d, "/", "Home", false),
Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
@@ -179,9 +195,22 @@ func AppLayout(d Deps, content *VNode) *VNode {
// docsSidebar is the section list. Sticky, so it stays put while a long page scrolls —
// on a documentation site the nav is how you know where you are, and a nav that scrolls
// away leaves you nowhere.
// sidebarNav is the sidebar's contents, which depend on WHICH LAYER you are reading.
//
// AppLayout is shared by every documentation page in this binary, and those pages are no
// longer all about the same thing: /wasm/* documents the Go→WebAssembly engine, /c
// 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/") {
return cNav()
}
return docsNav()
}
func docsSidebar(d Deps) *VNode {
mods := []Mod{Attr("class", "sticky top-[3.75rem] hidden h-[calc(100vh-3.75rem)] w-56 shrink-0 overflow-y-auto py-10 lg:block")}
for _, g := range docsNav() {
for _, g := range sidebarNav(d.Path()) {
items := []Mod{Attr("class", "mt-2 space-y-0.5")}
for _, it := range g.Items {
items = append(items, Li(sidebarLink(d, it)))
@@ -197,13 +226,30 @@ func docsSidebar(d Deps) *VNode {
}
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"
if d.Path() == it.Path {
// 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
// them on every scroll frame, and the only way to act on the answer is a signal write
// — which re-renders this entire page. Sixty times a second, to move a highlight.
//
// (Marking them active by PAGE instead lights up all fifteen at once, which is worse
// 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"
}
return A(Attr("class", cls), Attr("href", it.Path), navigate(d, it.Path),
click := navigate(d, it.Path)
if isAnchor {
click = navigateAnchor(d, base, frag)
}
return A(Attr("class", cls), Attr("href", it.Path), click,
ui.IconInline(it.Icon, 14, iconCls),
Text(it.Label),
)
@@ -251,122 +297,56 @@ func Counter(label string, count *Signal[int]) *VNode {
// ---- landing ------------------------------------------------------------
// The landing page is one narrow column of plain text, a demo, and a list.
// The landing page is a column of plain text and two lists.
//
// It used to be a framework marketing page: an oversized headline, a hero glow, feature
// cards in a grid, numbered chapters, a call to action repeated at both ends. All of it
// was arguing. None of it was showing. A library this small does not need to argue — it
// needs to say what it is, show that it works, and get out of the way, and a reader who
// wants to be convinced can click into the docs and find every page running the code it
// documents.
// It used to carry the Wasm Web engine's own highlights: the two-runtime demo, a list of
// SSR/hydration/server-component features, the build transcript. All of it was true, and
// none of it belonged HERE — the front page is kjøl's, and kjøl is not the Go/WebAssembly
// engine any more than it is the C arena allocator. A reader landing on it should learn
// what the thing IS, not be pitched one of its five parts.
//
// What survives is the part that could not be faked: the same Go function rendered twice
// at once, as live DOM and as the HTML string the server sends.
// So the demo moved to /wasm, where it is the first thing that section shows, and the
// front page says what is actually true of the whole: here are the languages, here are the
// frameworks assembled out of them, go and read one.
//
//gowasm:page / static layout=public
func HomePage(d Deps) func() *VNode {
clicks := NewSignal(0)
// The one measurement on the page: performance.now() when the client's first render
// commits. Zero until then — which is what the SERVER renders, and what the client
// renders on its first pass, so the two agree and hydration stays clean.
hydratedAt := NewSignal(0.0)
wasmruntime.AfterRender(func() {
if hydratedAt.Get() == 0 {
hydratedAt.Set(wasmruntime.Now())
}
})
// demoTree is called TWICE per render below — once for the DOM, once for the HTML.
// That is the point: the two panes cannot drift, because there is only one of them.
demoTree := func() *VNode {
return Div(Attr("class", "flex items-center gap-3"),
ui.Button(ui.ButtonProps{
Color: ui.ButtonPrimary, Text: "Click me",
OnClick: func() { clicks.Set(clicks.Get() + 1) },
}),
Span(Attr("class", "text-ink-soft"), Text("clicked "+itoa(clicks.Get())+" times")),
)
}
return func() *VNode {
markup := RenderHTML(demoTree())
return Div(Attr("class", "mx-auto max-w-3xl"),
H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
Text("kjol")),
Text("Kjøl")),
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
Text("A shared base layer, factored out of several applications so they stay in sync. "+
"Kjol is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
"Kjøl is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
Text("It is not one library. It is a stack of them, in several languages, and each one is "+
"documented here.")),
Text("It is not one library. It is a set of them, in several languages, and a couple of "+
"frameworks assembled out of those. Each one is documented here, and every page of that "+
"documentation runs the code it documents.")),
// ---- the layers ----
//
// The layers are the site. Everything else on this page is evidence that they
// work; this is the part you are meant to click.
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("The layers")),
layersGrid(d),
// ---- the demonstration ----
//
// This survives from the old landing page because it is the one thing on the site
// that cannot be faked: the same Go function, rendered twice at once, as live DOM
// and as the HTML string the server sent.
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("One function, two runtimes")),
// ---- layers: the languages ----
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Layers")),
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
Text("Below is a single Go function, shown twice. On the left it has been reconciled into "+
"the DOM and you can use it. On the right is the HTML the same function produces when "+
"the server renders it — the markup that reached your browser before any WebAssembly "+
"had loaded. Click the button; both move.")),
Text("What Kjøl is written in, and what it gives you in each. A layer is a directory of "+
"code you can use on its own — the Go base does not know the C one exists.")),
layerGrid(Languages()),
Div(Attr("class", "mt-5 border border-line sm:grid sm:grid-cols-2"),
Div(Attr("class", "border-b border-line sm:border-b-0 sm:border-r"),
paneLabel("in your browser"),
Div(Attr("class", "px-4 py-8"), demoTree()),
),
Div(
paneLabel(itoa(len(markup))+" bytes of HTML"),
Pre(Attr("class", "whitespace-pre-wrap px-4 py-4 font-mono text-[12px] leading-relaxed text-ink-muted"),
El("code", Text(prettyHTML(markup))),
),
),
),
P(Attr("class", "mt-3 text-sm leading-relaxed text-ink-muted"),
Text("The right pane is not a picture of the source. It is vdom.RenderHTML, called on the "+
"very tree the left pane is showing, recomputed on every click.")),
// ---- what is in it ----
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("What is in it")),
Ul(Attr("class", "mt-3 space-y-1.5 leading-relaxed text-ink-soft"),
item("Server-side rendering and client hydration, from one codebase."),
item("Server components: mark a function and its code and state stay on the server."),
item("A component kit — forms, tabs, modals, tooltips, toasts — with dark mode."),
item("A table with filtering, sorting, column management, formulas, and CSV and PDF export."),
item("Tailwind, compiled by a Go program that reads your Go."),
),
// ---- building ----
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Building it")),
// ---- compositions: the frameworks ----
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Compositions")),
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
Text("Two commands. The first produced the page you are reading; the second serves it and "+
"rebuilds on save.")),
codeLang("terminal", "sh", buildTranscript),
Text("What the layers become when they are assembled into something that does a job. A "+
"composition is not another language: Kjøl Wasm Web is Go all the way down, and Kjøl JS "+
"Web is TypeScript compiled by a Go toolchain. These are the two you can click into.")),
layerGrid(Compositions()),
// ---- close ----
P(Attr("class", "mt-12 border-t border-line pt-6 leading-relaxed text-ink-soft"),
Text("Every page of the documentation runs the code it documents — there are no screenshots "+
"of components anywhere on this site. "),
Text("There is not a screenshot of a component anywhere on this site. Every example is the "+
"real thing, running — which is the only way a documentation page can tell you when it "+
"has gone stale. "),
A(Attr("class", "text-accent underline underline-offset-4"),
Attr("href", "/wasm"), navigate(d, "/wasm"), Text("Read the docs")),
Text(", or "),
A(Attr("class", "text-accent underline underline-offset-4"),
Attr("href", "/wasm/kit"), navigate(d, "/wasm/kit"), Text("look at the components")),
Attr("href", "/about"), navigate(d, "/about"), Text("Why this exists")),
Text("."),
),
P(Attr("class", "mt-4 text-sm text-ink-muted"),
Text(hydrationNote(hydratedAt.Get()))),
)
}
}
@@ -404,10 +384,14 @@ func prettyHTML(s string) string {
return strings.ReplaceAll(s, "><", ">\n<")
}
const buildTranscript = `$ go run ./build
// The real transcript. It is on the front page, so it is the first thing anybody copies —
// which makes it the first thing to notice when it goes stale.
const buildTranscript = `$ go run ./server -build
==> generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)
==> compiling Tailwind CSS -> wwwroot/app.css
==> compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)
==> bundling the Solid app -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)
==> copying Go's wasm_exec.js shim into wwwroot/
$ go run ./server
serving "./wwwroot" on http://localhost:8085`
@@ -422,16 +406,22 @@ func AboutPage(d Deps) func() *VNode {
H1(Attr("class", "mt-2 text-4xl font-semibold tracking-tight text-text-heading"), Text("Why this exists")),
P(Attr("class", "mt-6 text-lg leading-relaxed text-ink-soft"),
Text("Kjol Web is one part of kjol — a shared base layer factored out of several applications "+
"so they stay in sync. (Kjol is Norwegian for KEEL: the spine of a hull, the thing every "+
"other part is built onto.) The applications had drifted: the same table, the same forms, "+
"the same charts, each subtly different in each app, each fixed twice.")),
Text("Kjøl is a shared base layer, factored out of several applications so they stay in sync. "+
"(Kjøl is Norwegian for KEEL: the spine of a hull, the thing every other part is built "+
"onto.) The applications had drifted: the same table, the same forms, the same charts, "+
"each subtly different in each app, each fixed twice.")),
P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
Text("The UI kit began as Solid.js components. Kjol Web is the same kit, written in Go and "+
"compiled to WebAssembly — the same components, the same Tailwind, no JavaScript build. "+
"That means one language across the server and the browser, and a table you can share "+
"between a web app and a native one because it is a Go function, not a JSX file.")),
Text("The UI kit began as Solid.js components, and it still is — that is Kjøl JS Web, and it "+
"is what those applications run today. Kjøl Wasm Web is the same kit written a second time "+
"in Go and compiled to WebAssembly: the same components, the same Tailwind, no JavaScript "+
"build at all. One language across the server and the browser, and a table you could share "+
"with a native app, because it is a Go function rather than a JSX file.")),
P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
Text("Neither of them is Kjøl. They are two compositions of it — two uses of the layers "+
"underneath, which are just directories of Go, TypeScript, C and Jai. The front page lists "+
"both, and does not argue for either.")),
H2(Attr("class", "mt-12 text-2xl font-semibold tracking-tight text-text-heading"), Text("The rules it keeps")),
Div(Attr("class", "mt-6 space-y-4"),

View File

@@ -6,15 +6,14 @@ import "kjol/vdom"
// Routes maps each //gowasm:page path to its instantiated render function.
func Routes(d Deps) map[string]func() *vdom.VNode {
return map[string]func() *vdom.VNode{
"/": HomePage(d),
"/about": AboutPage(d),
"/wasm": DocsPage(d),
"/wasm/chart": ChartPage(d),
"/wasm/data": DataPage(d),
"/wasm/kit": KitPage(d),
"/wasm/overlays": OverlaysPage(d),
"/wasm/server": ServerPage(d),
"/wasm/table": TablePage(d),
"/": HomePage(d),
"/about": AboutPage(d),
"/c": CPage(d),
"/wasm": DocsPage(d),
"/wasm/chart": ChartPage(d),
"/wasm/components": ComponentsPage(d),
"/wasm/data": DataPage(d),
"/wasm/server": ServerPage(d),
}
}
@@ -22,23 +21,22 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
var StaticPaths = map[string]bool{
"/": true,
"/about": true,
"/c": true,
"/wasm": true,
"/wasm/chart": true,
"/wasm/data": true,
"/wasm/table": true,
}
// RouteLayout maps each route to the name of the layout that wraps it.
var RouteLayout = map[string]string{
"/": "public",
"/about": "public",
"/wasm": "app",
"/wasm/chart": "app",
"/wasm/data": "app",
"/wasm/kit": "app",
"/wasm/overlays": "app",
"/wasm/server": "app",
"/wasm/table": "app",
"/": "public",
"/about": "public",
"/c": "app",
"/wasm": "app",
"/wasm/chart": "app",
"/wasm/components": "app",
"/wasm/data": "app",
"/wasm/server": "app",
}
// LayoutFor wraps a page's content in the layout declared for its route.

View File

@@ -11,7 +11,7 @@ import (
)
func TestSSRPages(t *testing.T) {
for _, path := range []string{"/", "/about", "/wasm/chart", "/wasm/data", "/wasm/table", "/wasm/overlays", "/wasm/kit"} {
for _, path := range []string{"/", "/about", "/wasm/chart", "/wasm/data", "/wasm/components"} {
deps := Deps{Path: func() string { return path }}
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
t.Logf("%-8s %6d bytes portals=%d", path, len(html), strings.Count(html, "data-portal"))
@@ -22,7 +22,7 @@ func TestSSRPages(t *testing.T) {
}
func TestSSRTablePage(t *testing.T) {
deps := Deps{Path: func() string { return "/wasm/table" }}
deps := Deps{Path: func() string { return "/wasm/components" }}
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
// The table persists a personal layout in localStorage, which the SERVER CANNOT
@@ -33,13 +33,21 @@ func TestSSRTablePage(t *testing.T) {
// This is a real cost — the page ships no table content — and it is the price of
// never showing the wrong table. See webui.RestoreLayout.
if !strings.Contains(html, `aria-busy="true"`) {
t.Error("SSR /table should render the loading skeleton, not a table")
t.Error("SSR should render the AutoTable's loading skeleton, not a table")
}
if !strings.Contains(html, "animate-pulse") {
t.Error("the skeleton bars are missing")
}
if strings.Contains(html, "Ada Lovelace") {
t.Error("SSR rendered table CONTENT — a user with a saved layout would watch it rearrange")
// A salary, which ONLY the AutoTable renders.
//
// This used to look for "Ada Lovelace", which was a fine proxy back when the table
// had a page to itself. It is not one any more: the components page also demos
// PrettyTable, and PrettyTable's rows are Ada, Alan and Grace — so the old assertion
// failed on a page that was behaving perfectly. A test that names a value only the
// component under test can produce cannot be fooled by its neighbours.
if strings.Contains(html, "$1,610.25") {
t.Error("SSR rendered AutoTable CONTENT — a user with a saved layout would watch it rearrange")
}
}

View File

@@ -174,159 +174,6 @@ func newEmployeeTable(highlight *Signal[string]) *ui.AutoTableState {
})
}
//gowasm:page /wasm/table layout=app static
func TablePage(d Deps) func() *VNode {
// Which row to spotlight, if any.
highlight := NewSignal("")
table := newEmployeeTable(highlight)
table.SetRows(employees())
// The export menu, with a submenu for the PDF's page orientation. Both are
// controllers, both built once. A submenu is Standalone — opening it must not
// close the menu it lives in.
exportMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
pdfSub := ui.NewSubmenu(exportMenu)
// What the PDF prints above the table.
//
// Note what is NOT here: the footer lines. The export takes the table's OWN
// summary rows — including any the user builds at runtime in the Calculated
// editor — and evaluates them against the same filtered rows it is printing. Only
// pass Summaries explicitly to print something that is not one of the table's own
// rows.
pdfHeader := func(landscape bool) ui.AutoTablePDFHeader {
orientation := ui.PDF_ORIENTATION_PORTRAIT
if landscape {
orientation = ui.PDF_ORIENTATION_LANDSCAPE
}
return ui.AutoTablePDFHeader{
Title: "Employees",
Subtitle: "Exported from the Kjol Web example",
ShowDate: true,
Orientation: orientation,
}
}
return func() *VNode {
return docPage("Components", "AutoTable",
"A table that filters, sorts, pages, reorders, resizes, computes and exports — configured with "+
"a column list and a slice of rows. Everything a user changes about it is theirs and persists; "+
"everything it exports is what they filtered, not what happened to be on screen.",
docSection("defining", "Defining one",
prose("A column says how to read a field, how to sort it, and how to render it. The state object "+
"is a CONTROLLER: build it once, alongside your signals — never inside the render, which "+
"would hand it fresh refs and a fresh idea of which page it was on every frame."),
code("app/table.go", tableSnippet),
note("The server renders a skeleton, on purpose",
"The layout — column order, widths, what is hidden, the calculated columns — lives in the "+
"browser's localStorage, which the server cannot read. So the server ships a skeleton "+
"rather than the DEFAULT table: a user who had reordered their columns would otherwise "+
"watch them rearrange themselves the moment the WebAssembly booted."),
),
docSection("try-it", "Try it",
prose("Search matches name or email. Sort by Salary and it parses the currency, so $980 sorts "+
"below $1,200.50. Unhide Rank and sort that: \"Item 2\" comes before \"Item 10\", because "+
"numbers inside text are compared as numbers. Drag a header to reorder it, drag its right "+
"edge to resize — reload the page and both are still where you left them."),
prose("Filter it, then export. You get every matching row across every page, in the column order "+
"you dragged them into, with the calculated columns computed per row."),
),
table.Render(
ui.AutoTableWithHover(),
ui.AutoTableWithAlternate(),
ui.AutoTableWithSurroundingBorder(),
ui.AutoTableWithPaginationShowAll(),
ui.AutoTableWithSearchFields(
// One box, several fields: a global search.
table.GlobalSearch("Search name or email…", "Name", "Email"),
// Exact-match dropdown.
table.SelectSearch("Status", []string{"active", "inactive"}, "Any status"),
// IN-set: matches any of the selected teams.
table.MultiSelectSearch("Team", "Any team", []string{"Engineering", "Research", "Networking"}),
),
ui.AutoTableWithToolbarActions(
table.ColumnPicker(),
// Build calculated columns and footer rows at runtime. Basic picks a
// function and the columns it combines across each row; Advanced writes
// a formula, with insert menus for columns, functions and constants.
// The formula is compiled and previewed against the real first row as
// you type, so a typo shows up immediately rather than as a column of
// dashes. What you build is persisted with the rest of the layout.
table.CalculatedColumnEditor(),
// Export writes what the FILTER selected — every matching row across
// every page — not the five rows on screen. And it writes the columns
// you can actually see, in the order you dragged them into.
exportMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Icon: "download", Text: "Export"})
}),
exportMenu.Content("",
exportMenu.Item(ui.MenuItemProps{Icon: "file-csv",
OnClick: func() { table.DownloadCSV("employees") }}, Text("Download CSV")),
// A submenu — portaled, so it is not clipped by the menu's own
// overflow-y-auto, which is what broke it before.
pdfSub.Submenu(ui.SubmenuProps{Trigger: "Download PDF", Icon: "file-pdf"},
pdfSub.Item(ui.MenuItemProps{
OnClick: func() { table.DownloadPDF("employees", pdfHeader(false)) }}, Text("Portrait")),
pdfSub.Item(ui.MenuItemProps{
OnClick: func() { table.DownloadPDF("employees", pdfHeader(true)) }}, Text("Landscape")),
),
ui.MenuDivider(""),
exportMenu.Item(ui.MenuItemProps{Icon: "print",
OnClick: func() { table.PrintPDF(pdfHeader(true)) }}, Text("Print")),
),
),
),
// Highlight + auto-page-jump: Radia is on page 3 by default, and the table
// pages itself to wherever she actually is once filters and sorting move her.
row("mt-4 flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Text: "Find Radia Perlman",
OnClick: func() { highlight.Set("radia@example.com") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Text: "Clear highlight",
OnClick: func() { highlight.Set("") }}),
),
docSection("calculated", "Calculated columns",
prose("The toolbar's calculator builds new columns at runtime, in two modes. Basic picks a "+
"function and the columns it combines ACROSS each row — sum of Salary and Bonus, per "+
"person. Advanced writes a formula, with insert menus for columns, functions and constants: "+
"([Salary] + [Bonus]) * 12."),
prose("A summary row is the other axis: it aggregates ONE column DOWN the filtered rows and "+
"prints the result in the footer. Confusing the two is the classic bug here — a column that "+
"aggregates down shows every row the same number, and it looks plausible enough to ship."),
codeLang("formulas", "syntax", formulaSnippet),
note("Compiled as you type",
"The formula is parsed and evaluated against the real first row while you write it, so a "+
"typo shows up as an error under the box — not as a column of dashes discovered later."),
),
docSection("export", "Export",
prose("CSV and PDF are written in Go, standard library only — the PDF writer builds its own "+
"xref table and embeds Helvetica metrics. Export takes the FILTERED rows, the VISIBLE "+
"columns, in the user's order, including whatever they calculated."),
apiTable(
apiRow{"NewAutoTableState", "Build the controller: the columns, and where to persist the layout."},
apiRow{".SetRows", "Hand it the data. It filters, sorts and pages from there."},
apiRow{".RestoreLayout", "Read the saved layout and reveal the table over its skeleton. Call it once, on the client."},
apiRow{".FilteredRows / .ExportColumns", "What the user selected, and what they can see — the inputs to any export."},
apiRow{"ExportCSV / ExportPDF", "Write the bytes. DownloadCSV / DownloadPDF / PrintPDF do it and hand them to the browser."},
),
),
)
}
}
const tableSnippet = `// A controller: built ONCE, next to your signals — never inside the render.
table := ui.NewAutoTableState([]ui.AutoTableColumn{
{DisplayName: "Name", SortIdentifier: "Name", Sortable: true,

View File

@@ -7,15 +7,18 @@ import (
"kjol/vdom"
)
// The landing page's whole claim is that its two panes are ONE function: the live
// component on the left, and the HTML string the server sends on the right. If they
// could drift, the page would be a lie told in the most embarrassing possible place.
// The two-runtime demo.s whole claim is that its two panes are ONE function: the live
// component on the left, and the HTML string the server sends on the right. If they could
// drift, the page would be a lie told in the most embarrassing possible place.
//
// It lives on /wasm now, not on the front page — it is the Wasm Web engine.s argument,
// and the front page is kjøl.s. The test followed it.
//
// So: render it, click the button the way the browser would, render again, and check
// that BOTH panes moved. A pane rendered from a stale copy of the tree — or from a
// second, hand-written one — fails here.
func TestLandingPanesShareOneTree(t *testing.T) {
page := HomePage(Deps{Path: func() string { return "/" }})
func TestTwoRuntimePanesShareOneTree(t *testing.T) {
page := DocsPage(Deps{Path: func() string { return "/wasm" }})
html := vdom.RenderHTML(page())
if !strings.Contains(html, "clicked 0 times") {
@@ -38,8 +41,8 @@ func TestLandingPanesShareOneTree(t *testing.T) {
// The byte count under the right-hand pane is the length of the string actually shown,
// not a number typed in by hand — so it has to move when the markup does.
func TestLandingByteCountIsReal(t *testing.T) {
page := HomePage(Deps{Path: func() string { return "/" }})
func TestTwoRuntimeByteCountIsReal(t *testing.T) {
page := DocsPage(Deps{Path: func() string { return "/wasm" }})
before := byteCountLabel(t, vdom.RenderHTML(page()))
clickButton(t, page(), "Click me")
@@ -60,7 +63,7 @@ func byteCountLabel(t *testing.T, html string) string {
t.Helper()
i := strings.Index(html, " bytes of HTML")
if i < 0 {
t.Fatal("no byte-count caption on the landing page")
t.Fatal("no byte-count caption on the /wasm overview")
}
start := strings.LastIndexByte(html[:i], '>') + 1
return html[start : i+len(" bytes of HTML")]