Add js web stuff to landing page + documentation

This commit is contained in:
2026-07-14 10:33:12 -04:00
parent fec8ef4a3e
commit 02a6dc6c48
435 changed files with 69567 additions and 1522 deletions

View File

@@ -0,0 +1,52 @@
// SPA entry for the Kjol 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 the Go/WASM half of this site, which
// this bundle knows nothing about. `base: "/js"` keeps every route in here relative
// to that, so a link to "/kit" resolves to /js/kit 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 other half 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 { Kit } from "./pages/Kit.tsx";
import { Forms } from "./pages/Forms.tsx";
import { Table } from "./pages/Table.tsx";
import { Theming } from "./pages/Theming.tsx";
// Routes as plain data: solid-router accepts RouteDefinition[] as `children`, which
// is what lets a JSX-free entry declare a full route tree.
const routes: RouteDefinition[] = [
{ path: "/", component: Overview },
{ path: "/kit", component: Kit },
{ path: "/forms", component: Forms },
{ path: "/table", component: Table },
{ path: "/theming", component: Theming },
];
const root = document.getElementById("app");
if (root) {
render(
() =>
createComponent(Router, {
base: "/js",
root: Shell,
get children() {
return routes;
},
}),
root,
);
}

View File

@@ -0,0 +1,72 @@
// The layers of kjol, as data.
//
// This is the JS mirror of app/layers.go on the Go/WASM side. 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.
//
// (A shared source would be better than a mirrored one. There isn't one: the Go
// side compiles to WebAssembly and the JS side is bundled by esbuild, and nothing
// is upstream of both. Keeping it to a flat array of plain data is what makes the
// duplication survivable — you can diff the two by eye.)
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 (here `table-columns`, there `squares`).
*
* 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;
}
export const LAYERS: Layer[] = [
{
name: "Kjol Go",
href: "/go",
tagline: "The server base: config, database, logging, HTTP, mail, validation.",
live: false,
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,
icon: "table-columns",
},
{
name: "Kjol C",
href: "/c",
tagline: "Arena allocator, strings, math, lexer, platform layer.",
live: false,
icon: "bolt",
},
{
name: "Kjol Jai",
href: "/jai",
tagline: "Console rendering module. Early.",
live: false,
icon: "cube",
},
];
/** The layer the current path belongs to, or undefined on the front page. */
export function currentLayer(path: string): Layer | undefined {
return LAYERS.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,176 @@
// The Kjol 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 } from "@solidjs/router";
import { Icon } from "@ui/Icons";
import { Menu, MenuTrigger, MenuContent, MenuLink, MenuSection } from "@ui/Menu";
import { ThemeToggle, initTheme } from "@ui/Theme";
import { LAYERS } from "../layers.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: "/kit", label: "Components", icon: "table-columns" },
{ path: "/forms", label: "Forms", icon: "pen-to-square" },
{ path: "/table", label: "AutoTable", icon: "table" },
{ path: "/theming", label: "Theming", icon: "palette" },
];
// The Layers menu — 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. It is rendered from the
// LAYERS array so adding a layer is one object, not a nav edit in two front-ends.
//
// Layers that are not `live` still appear. A menu that silently omits half the
// library teaches the reader that the library is half the size it is; showing them
// greyed, with the reason, is the more honest shape.
function LayersMenu() {
return (
<Menu>
<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">
Layers
<Icon icon="chevron-down" size={11} class="text-ink-faint" />
</span>
</MenuTrigger>
<MenuContent class="w-96">
<MenuSection>
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">
The layers of kjol
</p>
<For each={LAYERS}>
{(layer) => (
<Show
when={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">
<Icon icon={layer.icon} size={14} class="shrink-0 text-ink-faint" />
{layer.name}
<span class="rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted">
reference
</span>
</span>
<span class="pl-6 text-xs text-ink-muted">{layer.tagline}</span>
</div>
}
>
{/* MenuLink is a real <a href> (not a router link), which is what a
cross-layer jump has to be: the other layers are served by a
different binary. */}
<MenuLink href={layer.href} icon={layer.icon}>
<span class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-ink">{layer.name}</span>
<span class="text-xs text-ink-muted">{layer.tagline}</span>
</span>
</MenuLink>
</Show>
)}
</For>
</MenuSection>
</MenuContent>
</Menu>
);
}
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">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-default bg-fill-neutral text-on-fill-neutral">
<Icon icon="sailboat" size={17} />
</span>
<span class="flex items-baseline gap-1.5">
<span class="text-lg font-semibold tracking-tight text-ink">Kjol JS Web</span>
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
</span>
</a>
);
}
function Sidebar() {
const location = useLocation();
// The router's pathname is absolute (/js/kit); NAV paths are base-relative (/kit).
const active = (path: string) => location.pathname === "/js" + (path === "/" ? "" : path);
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">Kjol JS Web</p>
<ul class="mt-2 space-y-0.5">
<For each={NAV}>
{(item) => (
<li>
<A
href={item.path}
end={item.path === "/"}
class={
active(item.path)
? "flex items-center gap-2 rounded-default bg-surface-raised px-2 py-1.5 text-sm font-medium text-primary no-underline"
: "flex items-center gap-2 rounded-default px-2 py-1.5 text-sm text-ink-soft no-underline hover:bg-surface-muted hover:text-ink"
}
>
<Icon
icon={item.icon}
size={14}
class={active(item.path) ? "text-primary" : "text-ink-faint"}
/>
{item.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">
<LayersMenu />
<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>
);
}

View File

@@ -0,0 +1,220 @@
// /js/forms — the form fields, and the masks that make them worth having.
import { createSignal } from "solid-js";
import {
FormInput,
FormLabel,
FormSelect,
FormTextarea,
FormCurrencyInput,
FormPercentInput,
FormPhoneInput,
FormEmailInput,
FormNumberInput,
FormCombobox,
FormMultiSelect,
FormFieldset,
US_STATES,
} from "@ui/Forms";
import { ToggleSwitch } from "@ui/ToggleSwitch";
import { ButtonUI, BUTTON_COLOR_PRIMARY } from "@ui/Buttons";
import { AlertBlue } from "@ui/Alerts";
import { isEmailValid } from "@ui/Validation";
import { Demo } from "../layout/Demo.tsx";
export function Forms() {
const [name, setName] = createSignal("");
const [email, setEmail] = createSignal("");
const [amount, setAmount] = createSignal("");
const [rate, setRate] = createSignal("");
const [phone, setPhone] = createSignal("");
const [term, setTerm] = createSignal("90");
const [state, setState] = createSignal("");
const [tags, setTags] = createSignal<string[]>(["cd"]);
const [notify, setNotify] = createSignal(true);
const [notes, setNotes] = createSignal("");
// The error is a derived value, not a second piece of state — so it cannot get
// out of step with the field it describes. Blank is not "invalid", it is unfilled.
const emailError = () => (email() && !isEmailValid(email()) ? "That is not an email address." : "");
return (
<div class="max-w-4xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Forms</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
The fields carry their own input masks. A currency field will not let you type a letter into
it; a percent field keeps one trailing symbol; a phone field formats as you go. That behaviour
is in the component, not in the page — which is the only reason it is the same in every app.
</p>
<AlertBlue header="Handlers are lowercase" class="mt-6">
These are Solid components, so DOM handlers keep their DOM names:{" "}
<code class="font-mono">oninput</code>, <code class="font-mono">onchange</code>,{" "}
<code class="font-mono">onclick</code> — not <code class="font-mono">onInput</code>. It is the
single most common thing to get wrong when writing against this kit.
</AlertBlue>
<Demo
title="Text, email, and validation"
code={`const emailError = () =>
email() && !isEmailValid(email()) ? "That is not an email address." : "";
<FormEmailInput
value={email}
oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()}
showIcon
/>`}
>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<FormLabel for="f-name">Name</FormLabel>
<FormInput
id="f-name"
placeholder="Ada Lovelace"
value={name}
oninput={(e) => setName(e.currentTarget.value)}
/>
</div>
<div>
<FormLabel for="f-email">Email</FormLabel>
<FormEmailInput
id="f-email"
placeholder="ada@example.com"
value={email}
oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()}
showIcon
/>
</div>
</div>
</Demo>
<Demo
title="Masked inputs"
code={`<FormCurrencyInput value={amount} oninput={…} />
<FormPercentInput value={rate} oninput={…} />
<FormPhoneInput value={phone} oninput={…} />
<FormNumberInput int unsigned />`}
>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<FormLabel for="f-amt">Amount</FormLabel>
<FormCurrencyInput
id="f-amt"
value={amount}
oninput={(e) => setAmount(e.currentTarget.value)}
/>
</div>
<div>
<FormLabel for="f-rate">Rate</FormLabel>
<FormPercentInput id="f-rate" value={rate} oninput={(e) => setRate(e.currentTarget.value)} />
</div>
<div>
<FormLabel for="f-phone">Phone</FormLabel>
<FormPhoneInput id="f-phone" value={phone} oninput={(e) => setPhone(e.currentTarget.value)} />
</div>
<div>
<FormLabel for="f-int">Whole number</FormLabel>
<FormNumberInput id="f-int" int unsigned placeholder="0" />
</div>
</div>
<p class="mt-4 text-sm text-ink-muted">
Try typing letters into any of them.
</p>
</Demo>
<Demo
title="Select, combobox, multi-select"
code={`<FormCombobox
options={US_STATES}
value={state}
onchange={setState}
searchable
placeholder="Pick a state"
/>
<FormMultiSelect options={…} value={tags} onchange={setTags} showSelectAll />`}
>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<FormLabel for="f-term">Term (plain select)</FormLabel>
<FormSelect id="f-term" value={term} onchange={(e) => setTerm(e.currentTarget.value)}>
<option value="90">90 day</option>
<option value="180">180 day</option>
<option value="365">1 year</option>
</FormSelect>
</div>
<div>
<FormLabel>State (searchable)</FormLabel>
<FormCombobox
options={US_STATES}
value={state}
onchange={setState}
searchable
placeholder="Pick a state"
/>
</div>
<div>
<FormLabel>Products (multi)</FormLabel>
<FormMultiSelect
options={[
{ value: "cd", label: "Certificates of deposit" },
{ value: "mm", label: "Money market" },
{ value: "sv", label: "Savings" },
{ value: "tr", label: "Treasuries" },
]}
value={tags}
onchange={setTags}
showSelectAll
searchable
/>
</div>
</div>
<p class="mt-4 text-sm text-ink-muted">
selected: <span class="font-mono text-ink">{tags().join(", ") || "—"}</span>
</p>
</Demo>
<Demo
title="Toggles and textareas"
code={`// there is no FormCheckbox — booleans are a ToggleSwitch
<ToggleSwitch
checked={notify}
onchange={setNotify}
label="Email me when a rate changes"
description="At most one message a day."
/>`}
>
<FormFieldset legend="Notifications">
<ToggleSwitch
checked={notify}
onchange={setNotify}
label="Email me when a rate changes"
description="At most one message a day."
/>
<div class="mt-4">
<FormLabel for="f-notes">Notes</FormLabel>
<FormTextarea
id="f-notes"
rows={3}
placeholder="Anything worth remembering about this account…"
value={notes}
oninput={(e) => setNotes(e.currentTarget.value)}
/>
</div>
</FormFieldset>
<div class="mt-5 flex items-center gap-3">
<ButtonUI color={BUTTON_COLOR_PRIMARY} disabled={!!emailError()}>
Save
</ButtonUI>
<span class="text-sm text-ink-muted">
{emailError() ? "Fix the email address first." : "The button disables itself off derived state."}
</span>
</div>
</Demo>
</div>
);
}

View File

@@ -0,0 +1,199 @@
// /js/kit — the components, running.
import { createSignal, For } from "solid-js";
import {
ButtonUI,
SegmentedButtons,
BUTTON_COLOR_PRIMARY,
BUTTON_COLOR_NEUTRAL,
BUTTON_COLOR_GREEN,
BUTTON_COLOR_RED,
BUTTON_COLOR_BLUE,
} from "@ui/Buttons";
import { Badge, BADGE_GREEN, BADGE_RED, BADGE_BLUE, BADGE_AMBER, BADGE_NEUTRAL } from "@ui/Badges";
import { AlertBlue, AlertGreen, AlertRed, AlertYellow } from "@ui/Alerts";
import { Card, CardHeader } from "@ui/Cards";
import { TabGroup } from "@ui/Tabs";
import { Modal, ConfirmModal } from "@ui/Modal";
import { Tooltip } from "@ui/Tooltips";
import { Icon } from "@ui/Icons";
import { Demo } from "../layout/Demo.tsx";
export function Kit() {
const [count, setCount] = createSignal(0);
const [seg, setSeg] = createSignal("day");
const [modalOpen, setModalOpen] = createSignal(false);
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [confirmed, setConfirmed] = createSignal(0);
return (
<div class="max-w-4xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Components</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
Every component below is the real one from{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">@ui/*</code>, imported
and rendered on this page. Nothing here is a picture of a component.
</p>
<Demo
title="Buttons"
code={`import { ButtonUI, BUTTON_COLOR_PRIMARY } from "@ui/Buttons";
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => setCount(count() + 1)}>
Clicked {count()} times
</ButtonUI>`}
>
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => setCount(count() + 1)}>
Clicked {count()} times
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL}>Neutral</ButtonUI>
<ButtonUI color={BUTTON_COLOR_GREEN}>Green</ButtonUI>
<ButtonUI color={BUTTON_COLOR_RED}>Red</ButtonUI>
<ButtonUI color={BUTTON_COLOR_BLUE} outline>
Outline
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL} small>
Small
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL} disabled>
Disabled
</ButtonUI>
</div>
</Demo>
<Demo
title="Segmented buttons"
code={`<SegmentedButtons
options={[{ value: "day", label: "Day" }, ...]}
value={seg}
onchange={setSeg}
/>`}
>
<div class="flex flex-col gap-3">
<SegmentedButtons
options={[
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
]}
value={seg}
onchange={setSeg}
/>
<p class="text-sm text-ink-muted">
selected: <span class="font-mono text-ink">{seg()}</span>
</p>
</div>
</Demo>
<Demo
title="Badges"
code={`<Badge color={BADGE_GREEN} pill>Active</Badge>`}
>
<div class="flex flex-wrap items-center gap-2">
<Badge color={BADGE_GREEN} pill>
Active
</Badge>
<Badge color={BADGE_RED} pill>
Overdue
</Badge>
<Badge color={BADGE_BLUE}>Info</Badge>
<Badge color={BADGE_AMBER}>Pending</Badge>
<Badge color={BADGE_NEUTRAL}>Draft</Badge>
</div>
</Demo>
<Demo
title="Alerts"
code={`<AlertGreen header="Saved">Your changes have been written.</AlertGreen>`}
>
<div class="space-y-3">
<AlertGreen header="Saved">Your changes have been written.</AlertGreen>
<AlertBlue header="Heads up">The rate table refreshes every fifteen minutes.</AlertBlue>
<AlertYellow header="Check this">Two rows are missing a maturity date.</AlertYellow>
<AlertRed header="Failed">The upload was rejected by the server.</AlertRed>
</div>
</Demo>
<Demo
title="Tabs"
code={`<TabGroup items={[{ title: "Summary", content: <p>…</p> }, …]} />`}
>
<TabGroup
items={[
{ title: "Summary", content: <p class="text-sm text-ink-soft">Three accounts, two of them funded.</p> },
{ title: "Activity", badge: 3, content: <p class="text-sm text-ink-soft">Three events since Tuesday.</p> },
{ title: "Settings", content: <p class="text-sm text-ink-soft">Nothing configurable yet.</p> },
]}
/>
</Demo>
<Demo
title="Modals"
code={`<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} header="A modal">
…
</Modal>
// no provider needed — it portals itself to document.body`}
>
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_NEUTRAL} onclick={() => setModalOpen(true)}>
Open modal
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_RED} outline onclick={() => setConfirmOpen(true)}>
Delete something
</ButtonUI>
<span class="text-sm text-ink-muted">confirmed {confirmed()} times</span>
</div>
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} header={<h3 class="text-lg font-semibold">A modal</h3>}>
<p class="text-sm leading-relaxed text-ink-soft">
It portals itself to <code class="font-mono">document.body</code>, so it escapes any
ancestor with <code class="font-mono">overflow: hidden</code> or a transform — the two
things that silently clip a floating panel.
</p>
</Modal>
<ConfirmModal
isOpen={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={() => setConfirmed(confirmed() + 1)}
title="Delete this?"
message="This cannot be undone. (Nothing is actually deleted — this is a docs page.)"
confirmText="Delete"
/>
</Demo>
<Demo
title="Tooltips and icons"
code={`<Tooltip content="…"><Icon icon="circle-info" /></Tooltip>`}
>
<div class="flex flex-wrap items-center gap-5">
<For each={["circle-info", "calendar", "download", "print", "trash-can", "pen-to-square", "globe"]}>
{(name) => (
<Tooltip content={name}>
<span class="inline-flex cursor-help items-center gap-2 text-ink-soft">
<Icon icon={name} size={18} />
</span>
</Tooltip>
)}
</For>
</div>
<p class="mt-3 text-sm text-ink-muted">
Only the icons actually referenced in the source are bundled. The registry for this whole
site is a few dozen paths, not FontAwesome's 41.5 MB kit.
</p>
</Demo>
<Card class="mt-8">
<CardHeader>Not shown here</CardHeader>
<p class="text-sm leading-relaxed text-ink-soft">
The kit also carries a calendar, a date picker, popovers, an accordion, a signature pad, a
chart wrapper, a toast system, a guided-tour overlay and a fuzzy matcher. They are in{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">go/jsruntime/uikit</code>.
</p>
</Card>
</div>
);
}

View File

@@ -0,0 +1,97 @@
// /js — what the JS layer is, and how it is built.
import { Card, CardHeader } from "@ui/Cards";
import { AlertBlue } from "@ui/Alerts";
import { CodeBox } from "@ui/General";
export function Overview() {
return (
<div class="max-w-3xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol 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 ./build\nGenerating FA icon subset...\nGenerating public routes...\nBundling JS + CSS...\n\nBundle Files Size Time\n-------------------------------------------------------\nbundle.min.js 84 241.3 KB 412ms\nbundle.min.css 1418 68.1 KB 31ms"} />
<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">What is on the other pages</h2>
<div class="mt-4 grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader>Components</CardHeader>
<p class="text-sm text-ink-soft">
Buttons, badges, alerts, cards, tabs and menus — rendered live, not screenshotted.
</p>
</Card>
<Card>
<CardHeader>Forms</CardHeader>
<p class="text-sm text-ink-soft">
Masked inputs, comboboxes, multi-select, toggles, and the validation helpers.
</p>
</Card>
<Card>
<CardHeader>AutoTable</CardHeader>
<p class="text-sm text-ink-soft">
Sorting, search, column management, CSV export — from one array of column defs.
</p>
</Card>
</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,167 @@
// /js/table — AutoTable, driven by an array of column definitions.
import AutoTable, {
AutoTableColumn,
AutoTableSearch,
AutoTableFilterFields,
TdLeft,
TdRight,
TdCenter,
COL_POS_LEFT,
COL_POS_RIGHT,
COL_POS_CENTER,
AUTOTABLE_SIZE_COMPACT,
} from "@ui/AutoTable";
import { Badge, BADGE_GREEN, BADGE_RED, BADGE_NEUTRAL } from "@ui/Badges";
import { AlertBlue } from "@ui/Alerts";
interface Institution {
name: string;
state: string;
term: string;
rate: number;
minimum: number;
status: "open" | "closed" | "waitlist";
}
// Static rows: the point of the page is the table, not where the rows came from.
// Swapping `data` for `url` is the only change needed to make it fetch, sort and
// paginate against a server instead.
const ROWS: Institution[] = [
{ name: "First Meridian Bank", state: "CA", term: "90 day", rate: 4.85, minimum: 1000, status: "open" },
{ name: "Harborline Credit Union", state: "WA", term: "180 day", rate: 5.1, minimum: 2500, status: "open" },
{ name: "Cascade Federal", state: "OR", term: "1 year", rate: 5.35, minimum: 500, status: "waitlist" },
{ name: "Ironwood Savings", state: "IL", term: "90 day", rate: 4.6, minimum: 10000, status: "closed" },
{ name: "Great Lakes Trust", state: "MI", term: "2 year", rate: 5.55, minimum: 1000, status: "open" },
{ name: "Sunbelt National", state: "TX", term: "180 day", rate: 4.95, minimum: 5000, status: "open" },
{ name: "Granite State Bank", state: "NH", term: "1 year", rate: 5.2, minimum: 2000, status: "waitlist" },
{ name: "Pacific Crest", state: "CA", term: "5 year", rate: 5.75, minimum: 25000, status: "open" },
{ name: "Copper Ridge Bank", state: "AZ", term: "90 day", rate: 4.4, minimum: 1000, status: "closed" },
{ name: "Bayou Community", state: "LA", term: "1 year", rate: 5.05, minimum: 1500, status: "open" },
{ name: "Northern Pine FCU", state: "MN", term: "2 year", rate: 5.45, minimum: 500, status: "open" },
{ name: "Chesapeake First", state: "MD", term: "180 day", rate: 4.75, minimum: 3000, status: "waitlist" },
];
// The whole table is this list. Sorting, column ordering, hiding, resizing and CSV
// export are all driven from it — there is no per-column wiring anywhere else.
const COLUMNS: AutoTableColumn[] = [
{ displayName: "Institution", sortable: true, sortIdentifier: "name", displayPosition: COL_POS_LEFT },
{ displayName: "State", sortable: true, sortIdentifier: "state", displayPosition: COL_POS_CENTER, toggleable: true },
{ displayName: "Term", sortable: true, sortIdentifier: "term", displayPosition: COL_POS_LEFT },
{
displayName: "Rate",
sortable: true,
sortIdentifier: "rate",
sortType: "numeric",
displayPosition: COL_POS_RIGHT,
csvValue: (i: Institution) => i.rate,
},
{
displayName: "Minimum",
sortable: true,
sortIdentifier: "minimum",
sortType: "money",
displayPosition: COL_POS_RIGHT,
toggleable: true,
csvValue: (i: Institution) => i.minimum,
},
{ displayName: "Status", displayPosition: COL_POS_CENTER, sortable: true, sortIdentifier: "status" },
];
const money = (n: number) => "$" + n.toLocaleString("en-US");
function StatusBadge(props: { status: Institution["status"] }) {
if (props.status === "open") return <Badge color={BADGE_GREEN} pill>open</Badge>;
if (props.status === "closed") return <Badge color={BADGE_RED} pill>closed</Badge>;
return <Badge color={BADGE_NEUTRAL} pill>waitlist</Badge>;
}
export function Table() {
return (
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">AutoTable</h1>
<p class="mt-4 max-w-3xl leading-relaxed text-ink-soft">
One array of column definitions produces sorting, per-column search, column reordering by
drag, column show/hide, column resizing, pagination and CSV export. The page below writes no
table markup — only a <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">rowRenderer</code>{" "}
to say what a cell looks like.
</p>
<AlertBlue header="Try it" class="mt-6 max-w-3xl">
Sort by clicking a header. Drag a header to reorder. Use the toolbar to hide a column or
export what you are looking at. The column layout persists — it is keyed to localStorage, so
it survives a reload.
</AlertBlue>
<div class="mt-8">
<AutoTable
data={ROWS}
columns={COLUMNS}
emptyMessage="No institutions match those filters."
options={{
size: AUTOTABLE_SIZE_COMPACT,
hover: true,
alternate: true,
surroundingBorder: true,
headerBorderY: true,
draggableColumns: true,
toggleColumns: true,
resizableColumns: true,
resetButton: true,
exportCSV: true,
exportFilename: "kjol-rates",
inlineToolbar: true,
columnOrderStorageKey: "kjolweb.table.order",
columnVisibilityStorageKey: "kjolweb.table.visible",
columnWidthStorageKey: "kjolweb.table.widths",
}}
searchFields={(ctx) => (
<AutoTableFilterFields>
<AutoTableSearch
label="Institution"
placeholder="Search by name…"
value={ctx.getSearchValue("name")}
onchange={(v) => ctx.setSearchValue("name", v)}
/>
<AutoTableSearch
label="State"
placeholder="CA"
value={ctx.getSearchValue("state")}
onchange={(v) => ctx.setSearchValue("state", v)}
/>
</AutoTableFilterFields>
)}
rowRenderer={(item: Institution) => (
<>
<TdLeft class="font-medium text-ink">{item.name}</TdLeft>
<TdCenter>{item.state}</TdCenter>
<TdLeft>{item.term}</TdLeft>
<TdRight class="font-mono">{item.rate.toFixed(2)}%</TdRight>
<TdRight class="font-mono">{money(item.minimum)}</TdRight>
<TdCenter>
<StatusBadge status={item.status} />
</TdCenter>
</>
)}
/>
</div>
<div class="mt-10 max-w-3xl">
<h2 class="text-lg font-semibold text-ink">Local rows, or a server</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
This table is passed <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">data</code>.
Give it <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">url</code> instead and
the same column list drives a server-side query — the sort identifier becomes the sort key,
the search fields become query parameters, and pagination is handled for you. Nothing else
on the page changes.
</p>
<p class="mt-3 leading-relaxed text-ink-soft">
The Go/WASM layer has this same table, rewritten as Go returning a virtual DOM. Same
behaviour, no JavaScript — which is the whole argument the other half of this site is
making.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,168 @@
// /js/theming — how the kit is themed, and the switch that proves it.
import { AlertBlue, AlertGreen } from "@ui/Alerts";
import { Card, CardHeader } from "@ui/Cards";
import { ButtonUI, BUTTON_COLOR_PRIMARY, BUTTON_COLOR_NEUTRAL, BUTTON_COLOR_WHITE } from "@ui/Buttons";
import { Badge, BADGE_GREEN, BADGE_NEUTRAL } from "@ui/Badges";
import { CodeBox } from "@ui/General";
import { ThemeToggle, useTheme } from "@ui/Theme";
import { Demo } from "../layout/Demo.tsx";
// The swatch class is written out in full, not built as "bg-" + name. Tailwind finds
// the classes it must compile by SCANNING THE SOURCE for literal strings — a
// concatenation is invisible to it, and every swatch here would come out colourless.
// It is the one thing about a utility CSS engine you cannot forget.
const TOKENS: { swatch: string; name: string; role: string }[] = [
{ swatch: "bg-surface", name: "surface", role: "the page" },
{ swatch: "bg-surface-muted", name: "surface-muted", role: "a recessed strip" },
{ swatch: "bg-surface-raised", name: "surface-raised", role: "a panel, a hover" },
{ swatch: "bg-surface-strong", name: "surface-strong", role: "a track, a divider fill" },
{ swatch: "bg-line", name: "line", role: "an ordinary border" },
{ swatch: "bg-line-strong", name: "line-strong", role: "a border that has to be seen" },
{ swatch: "bg-ink", name: "ink", role: "body text, headings" },
{ swatch: "bg-ink-soft", name: "ink-soft", role: "secondary text" },
{ swatch: "bg-ink-muted", name: "ink-muted", role: "captions, labels" },
{ swatch: "bg-ink-faint", name: "ink-faint", role: "placeholders, disabled" },
];
export function Theming() {
const { isDark, mode } = useTheme();
return (
<div class="max-w-3xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Theming</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
No component in this kit names a colour. They say{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">bg-surface</code>,{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">text-ink</code>,{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">border-line</code> — and
what those mean is decided in one place. That is the whole of the theme system, and it is why
dark mode is a rule that re-points ten variables rather than a{" "}
<code class="font-mono">dark:</code> variant on four hundred class strings.
</p>
<Demo
title="The switch"
code={`// styles/theme.css
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-surface: #ffffff;
--color-ink: #171717;
--color-line: #e5e5e5;
}
.dark {
--color-surface: #101013; /* not black: black makes every border vanish */
--color-ink: #f2f2f3;
--color-line: #2a2a30;
}`}
>
<div class="flex flex-wrap items-center gap-4">
<ThemeToggle />
<div class="text-sm text-ink-soft">
currently <span class="font-mono text-ink">{isDark() ? "dark" : "light"}</span>, because
you asked for <span class="font-mono text-ink">{mode()}</span>
</div>
</div>
<p class="mt-4 text-sm leading-relaxed text-ink-muted">
Press it. Every component on every page of this section moves — none of them were told.
Your choice is remembered, and it is the <em>same</em> choice the Go/WASM section reads:
both halves of this site share one localStorage key, so the theme survives crossing between
two entirely different front-ends.
</p>
</Demo>
<h2 class="mt-12 text-lg font-semibold text-ink">The contract</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
These are the tokens a component is allowed to name. Each swatch below is drawn with the token
itself, so this table is not a picture of the theme — it <em>is</em> the theme, and it repaints
when you press the switch.
</p>
<div class="mt-5 overflow-hidden rounded-default border border-line">
{TOKENS.map((t, i) => (
<div
class={
"flex items-center gap-4 px-4 py-2.5 " +
(i > 0 ? "border-t border-line" : "")
}
>
<span class={"h-7 w-7 shrink-0 rounded border border-line-strong " + t.swatch} />
<code class="w-40 shrink-0 font-mono text-[13px] text-ink">{t.name}</code>
<span class="text-sm text-ink-muted">{t.role}</span>
</div>
))}
</div>
<AlertBlue header="Two kits, one vocabulary" class="mt-8">
The Go/WASM kit uses these exact token names. A designer changes{" "}
<code class="font-mono">surface</code> once and both halves of the site move together — even
though one is Solid compiled by esbuild and the other is Go compiled to WebAssembly.
</AlertBlue>
<h2 class="mt-12 text-lg font-semibold text-ink">Where a variant is still needed</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
Two things a re-pointed token cannot fix, so they are the only places the kit still carries a{" "}
<code class="font-mono">dark:</code> variant.
</p>
<div class="mt-5 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>Coloured tints</CardHeader>
<p class="text-sm leading-relaxed text-ink-soft">
A <code class="font-mono">red-50</code> wash is invisible on a near-black surface. An
alert's tint has to become a deep, transparent one — a different colour, not a
different value of the same one.
</p>
</Card>
<Card>
<CardHeader>Fills that invert</CardHeader>
<p class="text-sm leading-relaxed text-ink-soft">
The neutral button is dark on a light page and light on a dark one — so its label must
invert with it. <code class="font-mono">text-white</code> would disappear the moment
the fill went pale. Hence three tokens, not one.
</p>
</Card>
</div>
<Demo
title="The buttons that had to think about it"
code={`// the fill and its text move together, or the label vanishes
"neutral": "bg-fill-neutral text-on-fill-neutral hover:bg-fill-neutral-hover",
// a chromatic fill is dark enough for white text in BOTH themes — leave it
"red": "bg-red-700 text-white hover:bg-red-800",`}
>
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_NEUTRAL}>Neutral (inverts)</ButtonUI>
<ButtonUI color={BUTTON_COLOR_WHITE}>White (a surface)</ButtonUI>
<ButtonUI color={BUTTON_COLOR_PRIMARY}>Primary (a fill)</ButtonUI>
<Badge color={BADGE_GREEN} pill>solid</Badge>
<Badge color={BADGE_NEUTRAL} pill>fills stay put</Badge>
</div>
</Demo>
<AlertGreen header="No flash" class="mt-8">
The theme class is applied by a ten-line script in the document head, before the stylesheet and
before any markup. The server cannot read localStorage, so it cannot know which theme to send;
if the class waited for the bundle, every dark-mode reader would get a white page and then have
it snatched away. It is the only hand-written JavaScript on the Go/WASM side of this site.
</AlertGreen>
<CodeBox
class="mt-5"
code={`<head>
<script>(function(){try{
var m = localStorage.getItem("kjol-theme");
var dark = m === "dark" || (!m && matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
}catch(e){}})();</script>
<link rel="stylesheet" href="/bundle.min.css" />
</head>`}
/>
</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">Kjol 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">
Kjol 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">Kjol 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 Kjol 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 — Kjol 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 — Kjol 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);
}