Update 3d chart mode, add US heatmap, move kjol-web -> kjol-website

This commit is contained in:
2026-07-16 12:40:49 -04:00
parent 550e97aa9b
commit 2477c2d6a2
75 changed files with 701 additions and 416 deletions

View File

@@ -0,0 +1,59 @@
// SPA entry for the Kjøl JS Web section (/js/*).
//
// This file is .ts and NOT .tsx on purpose — it is not a style choice. The bundler
// resolves the SPA entry as src/app.ts (falling back to src/app.js) and nothing
// else, so the entry cannot contain JSX. Hence createComponent() here, and JSX in
// the pages it points at.
//
// The section is mounted under a base path rather than at the root: the front page
// and the whole /wasm section are served by Kjøl Wasm Web, a different binary, which
// this bundle knows nothing about. `base: "/js"` keeps every route in here relative
// to that, so a link to "/components" resolves to /js/components and the two SPAs
// never fight over a URL.
//
// Crossing OUT of /js (to the front page, or into /wasm) is a plain <a href> and a
// real page load — the rest of the site is a different binary. That is the
// honest cost of running two front-ends behind one server, and it is one navigation.
import { render, createComponent } from "solid-js/web";
import { Router } from "@solidjs/router";
import type { RouteDefinition } from "@solidjs/router";
import { Shell } from "./layout/Shell.tsx";
import { Overview } from "./pages/Overview.tsx";
import { Components } from "./pages/Components.tsx";
import { NotFound } from "./pages/NotFound.tsx";
// Routes as plain data: solid-router accepts RouteDefinition[] as `children`, which
// is what lets a JSX-free entry declare a full route tree.
//
// There are only two. The kit used to be spread across /kit, /forms, /table and
// /theming — a split along the lines of the SOURCE FILES rather than along anything a
// reader wants: a person looking for a date picker does not know, and should not have
// to guess, whether it was filed under forms or under overlays. It is one page now,
// and the sidebar jumps you down it.
const routes: RouteDefinition[] = [
{ path: "/", component: Overview },
{ path: "/components", component: Components },
// The catch-all, and it is not optional. The SERVER answers every /js/* URL with this
// shell — it has no idea which paths the router knows about — so without a fallback an
// unknown one renders the chrome around an empty <main>: a blank page, with a 200, and
// nothing to tell you why. Kjøl Wasm Web has the same catch-all for the same reason.
{ path: "*", component: NotFound },
];
const root = document.getElementById("app");
if (root) {
render(
() =>
createComponent(Router, {
base: "/js",
root: Shell,
get children() {
return routes;
},
}),
root,
);
}

View File

@@ -0,0 +1,36 @@
// The component groups: one entry per section of /js/components, AND one line in the
// sidebar that jumps to it.
//
// Declaring them once, as data, is what keeps those two in step — the sidebar cannot
// offer a jump to a section that does not exist, and a section cannot go missing from
// the sidebar.
//
// This is the JS mirror of app/components.go's componentGroups(). The ids match, so the
// two sections of the site have the same shape and a reader crossing between them lands
// in the same place. The ICONS differ, and have to: this side names FontAwesome, the Go
// side names webui's own hand-drawn registry, and where the two have no glyph in common
// the names diverge.
export interface ComponentGroup {
id: string;
label: string;
icon: string;
}
export const COMPONENT_GROUPS: ComponentGroup[] = [
{ id: "buttons", label: "Buttons", icon: "check" },
{ id: "badges", label: "Badges & alerts", icon: "circle-info" },
{ id: "cards", label: "Cards & layout", icon: "table-columns" },
{ id: "icons", label: "Icons", icon: "star" },
{ id: "forms", label: "Forms & inputs", icon: "pen-to-square" },
{ id: "selects", label: "Selects & comboboxes", icon: "sliders" },
{ id: "toggles", label: "Toggles & signature", icon: "check" },
{ id: "dates", label: "Dates", icon: "calendar" },
{ id: "tables", label: "Tables", icon: "table" },
{ id: "overlays", label: "Overlays", icon: "copy" },
{ id: "feedback", label: "Toasts & tours", icon: "bell" },
{ id: "navigation", label: "Tabs & navigation", icon: "bars" },
{ id: "search", label: "Fuzzy search", icon: "magnifying-glass" },
{ id: "charts", label: "Charts", icon: "chart-column" },
{ id: "theming", label: "Theming", icon: "palette" },
];

View File

@@ -0,0 +1,94 @@
// What kjøl is made of, as data.
//
// 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.
//
// 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 JS mirror of app/layers.go. The ids, the order and the taglines match; only
// the ICONS diverge, and they have to — see below.
export interface Layer {
name: string;
href: string;
tagline: string;
/** Live = you can click into worked examples. Reference = documented, no demo. */
live: boolean;
/**
* The ONE field that does not match app/layers.go, and cannot: the two kits have
* different icon sets. This side names FontAwesome; the Go side names webui's own
* hand-drawn registry, which has no FontAwesome in it at all. Where the two have no
* glyph in common the names diverge.
*
* Both sides fail loudly rather than quietly — a name neither registry knows renders
* an empty box, and app/icons_test.go fails the build over it.
*/
icon: string;
}
/** Languages: what kjøl is written in. */
export const LANGUAGES: Layer[] = [
{
name: "Go",
href: "/go",
tagline: "The base: config, database, logging, HTTP, mail, validation — and both web engines.",
live: false,
icon: "server",
},
{
name: "TypeScript",
href: "/ts",
tagline: "The Solid component kit, the vendored runtime, and the generic scaffolding apps import as @kjol/*.",
live: false,
icon: "code",
},
{
name: "C",
href: "/c",
tagline: "Arena allocator, counted strings, math, a lexer, a platform layer — and a build system that is a C file.",
live: true,
icon: "bolt",
},
{
name: "Jai",
href: "/jai",
tagline: "Console rendering. Early.",
live: false,
icon: "cube",
},
];
/** Compositions: what the languages are assembled into. */
export const COMPOSITIONS: 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.",
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.",
live: true,
icon: "table-columns",
},
];
/** The layer or composition the current path belongs to, or undefined on the front page. */
export function currentLayer(path: string): Layer | undefined {
return [...COMPOSITIONS, ...LANGUAGES].find(
(l) => path === l.href || path.startsWith(l.href + "/"),
);
}

View File

@@ -0,0 +1,33 @@
// A worked example: the code on one side, that same code RUNNING on the other.
//
// The code string is written by hand rather than extracted from the source, and that
// is a known compromise — a hand-copied snippet can drift from the component beside
// it. The alternative (a build step that slices the real source) buys accuracy at the
// cost of a second thing to maintain, and the snippets here are short enough to read
// against the live demo in one glance. If they start getting long, that trade flips.
import { JSXElement } from "solid-js";
import { CodeBox } from "@ui/General";
export function Demo(props: { title: string; code: string; children?: JSXElement }) {
return (
<section class="mt-10">
<h2 class="text-lg font-semibold text-ink">{props.title}</h2>
<div class="mt-3 overflow-hidden rounded-default border border-line">
{/* The live half. It sits on the plain surface, not in a tinted "preview"
box, because a component that only looks right against a special
background is a component that will look wrong in the app. */}
<div class="border-b border-line px-4 py-2">
<span class="font-mono text-[11px] uppercase tracking-widest text-ink-faint">running</span>
</div>
<div class="px-4 py-6">{props.children}</div>
<div class="border-t border-line bg-surface-muted px-4 py-2">
<span class="font-mono text-[11px] uppercase tracking-widest text-ink-faint">source</span>
</div>
<CodeBox code={props.code} class="rounded-none border-0" />
</div>
</section>
);
}

View File

@@ -0,0 +1,245 @@
// The Kjøl JS Web shell: top bar (wordmark + Layers menu), sidebar, content.
//
// It is deliberately a near-copy of the Go/WASM section's AppLayout. Two front-ends,
// one site: if the chrome drifted, crossing from /wasm to /js would feel like leaving
// for somebody else's website. The components underneath are completely different —
// these are Solid components from the kit, those are Go functions returning a VNode —
// and the page should not betray that.
import { For, Show } from "solid-js";
import { A, useLocation, useNavigate } from "@solidjs/router";
import { Icon } from "@ui/Icons";
import { Menu, MenuTrigger, MenuContent } from "@ui/Menu";
import { ThemeToggle, initTheme } from "@ui/Theme";
import { LANGUAGES, COMPOSITIONS, currentLayer, Layer } from "../layers.ts";
import { COMPONENT_GROUPS } from "../componentGroups.ts";
interface NavItem {
path: string;
label: string;
icon: string;
}
// The section's own pages. Paths are relative to the router base (/js).
const NAV: NavItem[] = [
{ path: "/", label: "Overview", icon: "circle-info" },
{ path: "/components", label: "Components", icon: "table-columns" },
];
// jumpTo scrolls a section into view, routing there first if we are somewhere else.
//
// A plain <a href="#forms"> would work if the reader were already on the components
// page, and would do nothing useful from anywhere else. The router's <A> is no good
// either — it would try to navigate to a route called "#forms".
//
// The queueMicrotask is not superstition: after navigate() the target section does not
// exist yet, because the page it lives on has not rendered. Scrolling on the next tick
// is the earliest moment the element is actually there to scroll to.
function jumpTo(navigate: (to: string) => void, onComponentsPage: boolean, id: string) {
const scroll = () => document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
if (onComponentsPage) {
scroll();
return;
}
navigate("/components");
queueMicrotask(scroll);
}
// The site's primary navigation, as TWO dropdowns: Layers (the languages) and
// Compositions (the frameworks assembled out of them — see layers.ts).
//
// Two menus, not one with two headings inside it. They answer 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.
//
// Anything not `live` still appears, greyed, with the reason. A menu that silently omits
// half the library teaches the reader that the library is half the size it is.
//
// Same shape as the Go side (app/layers.go: layersMenu / compositionsMenu).
function Dropdown(props: { label: string; rows: Layer[] }) {
const location = useLocation();
const current = () => currentLayer(location.pathname);
return (
// bottom-end, because the triggers sit at the right-hand end of the bar and a 24rem
// panel hanging off the left edge of one would run past the window.
<Menu placement="bottom-end">
<MenuTrigger>
<span 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">
{props.label}
<Icon icon="chevron-down" size={11} class="text-ink-faint" />
</span>
</MenuTrigger>
<MenuContent class="w-96">
<For each={props.rows}>{(layer) => <LayerItem layer={layer} current={current()} />}</For>
</MenuContent>
</Menu>
);
}
// LayerItem is one row of the menu.
//
// The markup is deliberately the same shape and the same classes as app/layers.go's
// layerItem. Two front-ends, one menu: if they drifted, this is where it would show,
// because it is the one component a reader sees on both sides within seconds of each
// other.
function LayerItem(props: { layer: Layer; current?: Layer }) {
return (
<Show
when={props.layer.live}
fallback={
<div class="flex cursor-default flex-col gap-0.5 px-3 py-2 opacity-55">
<span class="flex items-center gap-2 text-sm font-medium text-ink-muted">
{props.layer.name}
<span class="rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted">
reference
</span>
</span>
<span class="text-xs text-ink-muted">{props.layer.tagline}</span>
</div>
}
>
{/* A plain <a href>, not the router's <A>: the other side is served by a
different binary, so crossing to it has to be a real navigation, not a
client-side route the router would try to handle itself. */}
<a
href={props.layer.href}
class={
"flex flex-col gap-0.5 px-3 py-2 no-underline hover:bg-surface-raised " +
(props.current?.href === props.layer.href ? "bg-primary-subtle" : "")
}
>
<span class="text-sm font-medium text-ink">{props.layer.name}</span>
<span class="text-xs text-ink-muted">{props.layer.tagline}</span>
</a>
</Show>
);
}
function Wordmark() {
// A plain <a href>, not a router <A>: "/" is the front page, which belongs to the
// Go/WASM binary. Routing to it inside this SPA would resolve to /js and land you
// back where you started.
return (
<a href="/" class="flex items-center gap-2.5 no-underline">
{/* text-white, not a theme token: the flag tile is the same in both themes, so
the boat on top of it has to be too. The flag carries a dark scrim (.flag-no)
so the plain white boat reads without a shadow of its own. */}
<span class="inline-flex h-8 w-8 items-center justify-center rounded-default flag-no text-white">
<Icon icon="sailboat" size={17} />
</span>
<span class="flex items-baseline gap-1.5">
<span class="text-lg font-semibold tracking-tight text-ink">Kjøl JS Web</span>
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
</span>
</a>
);
}
function Sidebar() {
const location = useLocation();
const navigate = useNavigate();
// The router's pathname is absolute (/js/components); NAV paths are base-relative.
const active = (path: string) => location.pathname === "/js" + (path === "/" ? "" : path);
const onComponents = () => location.pathname === "/js/components";
// The same active treatment the Go sidebar uses (app/pages.go: sidebarLink) — a
// tinted panel and accent text, not a grey fill. Now that the Solid theme carries the
// primary-subtle / accent tokens, the two sidebars are the same sidebar.
//
// No icons, also matching the Go sidebar: the sidebar is a list of words, and a glyph on
// every row is noise to read past. So `block`, not `flex items-center gap-2`.
const linkCls = (on: boolean) =>
on
? "block rounded-default bg-primary-subtle px-2 py-1.5 text-sm font-medium text-accent no-underline"
: "block rounded-default px-2 py-1.5 text-sm text-ink-soft no-underline hover:bg-surface-raised hover:text-ink";
return (
<aside class="sticky top-[3.75rem] hidden h-[calc(100vh-3.75rem)] w-56 shrink-0 overflow-y-auto py-10 lg:block">
<p class="px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">Kjøl JS Web</p>
<ul class="mt-2 space-y-0.5">
<For each={NAV}>
{(item) => (
<li>
<A href={item.path} end={item.path === "/"} class={linkCls(active(item.path))}>
{item.label}
</A>
</li>
)}
</For>
</ul>
{/* The component groups are not pages — they are anchors into the one components
page, and clicking one scrolls you there.
They are not highlighted by which section you have scrolled to. Finding that
out means measuring all fifteen of them on every scroll frame, and the only
way to act on the answer is a state write, which re-renders. Sixty times a
second, to move a highlight. The highlight is not worth the page. */}
<p class="mt-8 px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">
Components
</p>
<ul class="mt-2 space-y-0.5">
<For each={COMPONENT_GROUPS}>
{(g) => (
<li>
<a
href={"/js/components#" + g.id}
class={linkCls(false)}
onclick={(e) => {
e.preventDefault();
jumpTo(navigate, onComponents(), g.id);
}}
>
{g.label}
</a>
</li>
)}
</For>
</ul>
</aside>
);
}
export function Shell(props: { children?: any }) {
// Once, at the root. The boot script in the document head has ALREADY put the right
// class on <html> — this only syncs the toggle's signals with it and starts
// following the OS while the mode is "system". Calling it late is harmless; not
// calling it just leaves the button showing the wrong icon.
initTheme();
return (
<div class="min-h-screen bg-surface">
{/* bg-surface/90, not bg-white/90: the translucent sticky bar has to be
translucent over whatever the surface currently IS. */}
<nav class="sticky top-0 z-20 border-b border-line bg-surface/90 backdrop-blur">
<div class="mx-auto flex max-w-[110rem] items-center gap-3 px-6 py-3">
<Wordmark />
<span class="rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
Docs
</span>
<div class="ml-auto flex items-center gap-2">
<Dropdown label="Layers" rows={LANGUAGES} />
<Dropdown label="Compositions" rows={COMPOSITIONS} />
<a
href="/"
class="rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:bg-surface-raised hover:text-ink"
>
Home
</a>
<ThemeToggle small />
</div>
</div>
</nav>
<div class="mx-auto flex max-w-[110rem] gap-8 px-6">
<Sidebar />
<main class="min-w-0 flex-1 py-10">{props.children}</main>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
// The fallback for any /js/* URL the router does not know.
//
// The server cannot 404 these: it answers every /js/* path with the same SPA shell,
// because it has no idea which routes the bundle contains. So the router has to be the
// one to say so — and if it does not, an unknown URL renders the chrome around an empty
// <main>, which is a blank page with a 200 and no explanation.
//
// It names the paths that MOVED, because that is what a stale bookmark most likely wants:
// /js/kit, /js/forms, /js/table and /js/theming were four pages, and are now four
// sections of one.
import { useLocation, useNavigate } from "@solidjs/router";
import { ButtonUI, BUTTON_COLOR_PRIMARY, BUTTON_COLOR_LIGHT_NEUTRAL } from "@ui/Buttons";
export function NotFound() {
const location = useLocation();
const navigate = useNavigate();
return (
<div class="max-w-2xl py-10">
<h1 class="text-2xl font-semibold tracking-tight text-ink">Page not found</h1>
<p class="mt-2 leading-relaxed text-ink-soft">
No route matches <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">{location.pathname}</code>.
</p>
<p class="mt-3 leading-relaxed text-ink-soft">
The kit used to be spread across several pages <code class="font-mono">/js/kit</code>,{" "}
<code class="font-mono">/js/forms</code>, <code class="font-mono">/js/table</code>,{" "}
<code class="font-mono">/js/theming</code>. It is one page now, and they are sections of it.
</p>
<div class="mt-6 flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => navigate("/components")}>
Go to Components
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL} onclick={() => navigate("/")}>
Overview
</ButtonUI>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
// /js — what the JS layer is, and how it is built.
import { For } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { Card, CardHeader } from "@ui/Cards";
import { AlertBlue } from "@ui/Alerts";
import { CodeBox } from "@ui/General";
import { Icon } from "@ui/Icons";
import { COMPONENT_GROUPS } from "../componentGroups.ts";
export function Overview() {
const navigate = useNavigate();
return (
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">
A Solid kit, built by a Go toolchain
</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
This layer is the original one: a Solid.js component kit forms, tables, modals, menus,
tooltips, charts that the applications shared before any of it was rewritten in Go. It is
still what those applications run.
</p>
<p class="mt-3 leading-relaxed text-ink-soft">
What is unusual is the build. There is no Node, no Vite, no Babel, and no{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">node_modules</code>.
The TSX is compiled to Solid's runtime calls by a Go program, the CSS by a Go implementation
of Tailwind v4, and the whole thing is bundled by esbuild's Go API. The toolchain is a Go
package you import.
</p>
<h2 class="mt-10 text-lg font-semibold text-ink">The pipeline</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
One command builds this section. Every stage of it is Go:
</p>
<div class="mt-4 space-y-3">
<Stage
n="1"
title="TSX → Solid"
body="kjol/jsbundler compiles each .tsx into dom-expressions calls — the same output Babel's Solid preset produces. It is checked against Babel by a render-equivalence test: both are compiled, both are rendered, and the HTML must match."
/>
<Stage
n="2"
title="Solid → bundle"
body="esbuild's Go API bundles it. Vendored packages resolve out of a pinned manifest rather than their own exports maps, because solid-js's bare entry mis-resolves to its SSR build — where every effect is a silent no-op."
/>
<Stage
n="3"
title="Tailwind"
body="kjol/tw scans the sources for candidate class names and compiles the stylesheet. It is a Go implementation, so it can just as happily scan .go files — which is exactly what the Wasm Web layer needs it to do."
/>
</div>
<CodeBox class="mt-5" code={"$ go run ./server -build\nGenerating FA icon subset...\n FA icons: 94 defs for 47 names\nGenerating public routes...\n /js/ssr Ssr (rendered, 2498 bytes)\nBundling JS + CSS...\n\nBundle Files Size Time\n-------------------------------------------------------\nbundle.min.js 1 1.4 MB 198ms\nbundle.min.css 2500 74.6 KB 30ms"} />
<AlertBlue header="One reactive instance, always" class="mt-8">
The single hardest invariant in this build is that there is exactly one copy of solid-js. Two
copies do not error they render fine and then silently stop flushing effects, so onMount
never fires and nothing updates. kjol's vendor manifest is searched before the app's for
precisely this reason.
</AlertBlue>
<h2 class="mt-10 text-lg font-semibold text-ink">The kit</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
All of it is on one page. It used to be three Components, Forms, AutoTable which is a
split along the lines of the source files rather than along anything a reader wants: a person
looking for a date picker does not know, and should not have to guess, whether it was filed
under forms or under overlays.
</p>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<For each={COMPONENT_GROUPS}>
{(g) => (
<a
href={"/js/components#" + g.id}
class="group flex items-center gap-2 rounded-default border border-line bg-surface px-3 py-2.5 no-underline shadow-xs transition hover:border-primary hover:shadow-sm"
onclick={(e) => {
e.preventDefault();
navigate("/components");
queueMicrotask(() =>
document.getElementById(g.id)?.scrollIntoView({ behavior: "smooth", block: "start" }),
);
}}
>
<Icon icon={g.icon} size={14} class="shrink-0 text-primary" />
<span class="text-sm font-medium text-ink">{g.label}</span>
<Icon
icon="arrow-right"
size={11}
class="ml-auto text-ink-faint transition group-hover:text-primary"
/>
</a>
)}
</For>
</div>
</div>
);
}
function Stage(props: { n: string; title: string; body: string }) {
return (
<div class="flex gap-4 border-l-2 border-line pl-4">
<span class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-surface-raised text-xs font-semibold text-ink-soft">
{props.n}
</span>
<div>
<h3 class="font-semibold text-ink">{props.title}</h3>
<p class="mt-1 text-sm leading-relaxed text-ink-soft">{props.body}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
// The chrome around every server-rendered public page.
//
// The bundler's SSR entry is hardcoded to import { PublicLayout } from this exact
// path and to call it with { currentPath, children } — it is a contract, not a
// convention. The client takeover (public.tsx) wraps the same body in the same
// layout with the same currentPath, which is what makes the server markup and the
// post-takeover markup identical. If they diverged, the page would visibly rebuild
// itself the moment the bundle landed.
//
// Deliberately plain. This renders inside goja against a DOM shim at BUILD time,
// where there is no layout, no getBoundingClientRect and no window — so nothing in
// here may measure the page. That rules out the kit's floating components (Menu,
// Tooltip, Popover), which is why the Layers menu is a row of links here and a real
// menu everywhere else.
import { JSXElement } from "solid-js";
export function PublicLayout(props: { currentPath: string; children?: JSXElement }) {
return (
<div class="min-h-screen bg-surface">
<nav class="border-b border-line">
<div class="mx-auto flex max-w-2xl items-center gap-2 px-4 py-4">
<a href="/" class="flex items-center gap-2.5 no-underline">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-default bg-fill-neutral text-on-fill-neutral">
{/* The boat is the point of the name: kjol is Norwegian for KEEL. Inlined
rather than pulled from the icon kit, because the kit's <Icon> reads a
CSS custom property at runtime to pick its style — and under SSR there
is no computed style to read. */}
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11.25 3.75v12M11.25 15.75H4.5l6.75-12M14.25 15.75h4.5l-4.5-7.5zM2.25 18.75h19.5l-2.4 3H4.65z" />
</svg>
</span>
<span class="flex items-baseline gap-1.5">
<span class="text-lg font-semibold tracking-tight text-ink">Kjøl JS Web</span>
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
</span>
</a>
<ul class="ml-auto flex items-center gap-1">
<li>
<a
href="/js"
class={
props.currentPath === "/js"
? "rounded-default bg-surface-raised px-3 py-1.5 text-sm font-medium text-ink no-underline"
: "rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:bg-surface-raised hover:text-ink"
}
>
Docs
</a>
</li>
<li>
<a
href="/wasm"
class="rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:bg-surface-raised hover:text-ink"
>
Wasm Web
</a>
</li>
</ul>
</div>
</nav>
<main>{props.children}</main>
<footer class="mx-auto max-w-2xl px-4 pb-14">
<p class="text-sm text-ink-faint">
Kjøl JS Web is one layer of kjol a shared base layer. kjol is Norwegian for keel.
</p>
</footer>
</div>
);
}

View File

@@ -0,0 +1,77 @@
// A server-rendered public page.
//
// The SPA under /js/* is client-only: the browser gets an empty #app and Solid fills
// it. That is fine for a docs section behind a click, and wrong for anything a search
// engine or a slow phone has to read.
//
// This page takes the other route. The bundler renders it at BUILD time — the real
// component, executed in goja against a DOM shim — and bakes the resulting HTML into
// a Go registry (internal/handlers/public_pages.gen.go). The server ships that HTML
// directly, so the page is complete before any JavaScript loads. The client bundle
// then re-renders the same component over the top and it becomes interactive.
//
// serverData() is what makes it more than a static file: the handler can inject data
// for a request, and the SAME component renders it — on the server at request time,
// and again in the browser after takeover, from the same inlined JSON. No refetch, no
// flash of a skeleton.
import { serverData } from "@kjol/ssr/serverData.ts";
interface BuildInfo {
renderedAt: string;
stage: string;
}
export function Ssr() {
// Read inside the reactive body, never captured at module load — the value has to
// be observed at render time, and there are three different render times.
const info = () => serverData<BuildInfo>();
return (
<div class="page-ssr mx-auto max-w-2xl px-4 py-14">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">
This page was rendered by Go
</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
Not by a Node renderer, and not in your browser. A Go program executed this Solid component
in an embedded JavaScript engine, serialized the DOM it produced, and compiled the result
into the server binary. View source: the markup arrived complete.
</p>
{/* No data → the skeleton. This is exactly what the build-time bake sees, because
the bake injects nothing; it is also what a crawler sees. With data injected at
request time, the same three lines render the real values instead. */}
{!info() ? (
<div class="mt-8 animate-pulse rounded-default border border-line p-5">
<div class="h-3 w-40 rounded bg-surface-strong" />
<div class="mt-3 h-3 w-64 rounded bg-surface-raised" />
</div>
) : (
<dl class="mt-8 rounded-default border border-line p-5">
<div class="flex justify-between text-sm">
<dt class="text-ink-muted">rendered at</dt>
<dd class="font-mono text-ink">{info()!.renderedAt}</dd>
</div>
<div class="mt-2 flex justify-between text-sm">
<dt class="text-ink-muted">stage</dt>
<dd class="font-mono text-ink">{info()!.stage}</dd>
</div>
</dl>
)}
<p class="mt-8 leading-relaxed text-ink-soft">
The skeleton above is the honest default. The bake runs with no data, so a component that
cannot render without data cannot be baked which is a useful constraint to discover at build
time rather than in production.
</p>
<p class="mt-8 text-sm text-ink-muted">
<a href="/js" class="text-primary underline underline-offset-4">
Back to Kjøl JS Web
</a>
</p>
</div>
);
}

View File

@@ -0,0 +1,30 @@
// SINGLE SOURCE OF TRUTH for server-rendered public pages.
//
// Add an entry here, then write the component it points at, then run the bundler.
// It regenerates:
// - internal/handlers/public_pages.gen.go Go registry: route → <title> + baked HTML
// - frontend/src/pages/public/routes.gen.ts client takeover map: route → component
//
// Both generated files are read back by code that is committed, so neither is
// optional — but neither is hand-edited either.
export interface PublicPageDef {
path: string; // URL pathname
module: string; // component file, relative to frontend/src
component: string; // exported component name
title: string; // <title> text
dynamic?: boolean; // ISR: also bake the render bundle so the server can render
// this page with live data at request time
}
export const publicPages: PublicPageDef[] = [
{
path: "/js/ssr",
module: "pages/public/Ssr.tsx",
component: "Ssr",
title: "Server-rendered — Kjøl JS Web",
// dynamic: the server may inject data for this route at request time, so bake
// the render bundle too, not just the static skeleton.
dynamic: true,
},
];

View File

@@ -0,0 +1,17 @@
// Code generated by cmd/bundle; DO NOT EDIT.
// Source: frontend/src/pages/public/pages.ts
import { JSXElement } from "solid-js";
import { Ssr } from "./Ssr.tsx";
// Body component for each public route, keyed by URL pathname. The client
// router (public.ts) renders these when navigating without a full reload.
export const publicRoutes: Record<string, () => JSXElement> = {
"/js/ssr": Ssr,
};
// <title> for each public route, applied by the client router on navigation
// (the first load gets its title from the server-rendered shell).
export const publicTitles: Record<string, string> = {
"/js/ssr": "Server-rendered — Kjøl JS Web",
};

View File

@@ -0,0 +1,35 @@
// Client takeover for the server-rendered public pages.
//
// The server ships each page's HTML inside #page-root — fast first paint, readable by
// a crawler, works with JavaScript off. This boots the same component and swaps it in,
// making the page interactive.
//
// It wraps the body in the SAME PublicLayout with the SAME currentPath the build-time
// bake used (see jsbundler/genssr.go: ssrEntrySolid). That is not tidiness — if the two
// trees differed, the page would visibly rebuild itself the instant this bundle landed.
//
// It is a re-render takeover, not attach-hydration: Solid renders the client tree into
// a detached node FIRST, then replaces #page-root's children in one step. The server
// markup stays on screen until identical client markup is ready to replace it, so there
// is no window in which the page is half-built.
import { render } from "solid-js/web";
import { PublicLayout } from "./pages/public/PublicLayout.tsx";
import { publicRoutes } from "./pages/public/routes.gen.ts";
const root = document.getElementById("page-root");
const path = window.location.pathname;
const Body = root ? publicRoutes[path] : undefined;
if (root && Body) {
const staging = document.createElement(root.tagName);
render(
() => (
<PublicLayout currentPath={path}>
<Body />
</PublicLayout>
),
staging,
);
root.replaceChildren(...staging.childNodes);
}

View File

@@ -0,0 +1,8 @@
// Ambient shims for tsserver only. The vendored pdf-lib / pdfjs-dist here are trimmed to
// the runtime files the bundler pins (frontend/vendor/vendor.json), so their `.d.ts` type
// trees are absent and each package.json `types` field points at a file that was not
// vendored. @ui/AutoTable imports both; the bundler resolves them at build time, but the
// editor needs a declaration or it reports "cannot find module". These make them `any`,
// which is all this example needs — it does not exercise the PDF export path itself.
declare module "pdf-lib";
declare module "pdfjs-dist";