Add js web stuff to landing page + documentation
This commit is contained in:
52
go/cmd/kjol-web/frontend/src/app.ts
Normal file
52
go/cmd/kjol-web/frontend/src/app.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
72
go/cmd/kjol-web/frontend/src/layers.ts
Normal file
72
go/cmd/kjol-web/frontend/src/layers.ts
Normal 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 + "/"));
|
||||
}
|
||||
33
go/cmd/kjol-web/frontend/src/layout/Demo.tsx
Normal file
33
go/cmd/kjol-web/frontend/src/layout/Demo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
go/cmd/kjol-web/frontend/src/layout/Shell.tsx
Normal file
176
go/cmd/kjol-web/frontend/src/layout/Shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
go/cmd/kjol-web/frontend/src/pages/Forms.tsx
Normal file
220
go/cmd/kjol-web/frontend/src/pages/Forms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
go/cmd/kjol-web/frontend/src/pages/Kit.tsx
Normal file
199
go/cmd/kjol-web/frontend/src/pages/Kit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
go/cmd/kjol-web/frontend/src/pages/Overview.tsx
Normal file
97
go/cmd/kjol-web/frontend/src/pages/Overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
go/cmd/kjol-web/frontend/src/pages/Table.tsx
Normal file
167
go/cmd/kjol-web/frontend/src/pages/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
go/cmd/kjol-web/frontend/src/pages/Theming.tsx
Normal file
168
go/cmd/kjol-web/frontend/src/pages/Theming.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
go/cmd/kjol-web/frontend/src/pages/public/PublicLayout.tsx
Normal file
73
go/cmd/kjol-web/frontend/src/pages/public/PublicLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
go/cmd/kjol-web/frontend/src/pages/public/Ssr.tsx
Normal file
77
go/cmd/kjol-web/frontend/src/pages/public/Ssr.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
go/cmd/kjol-web/frontend/src/pages/public/pages.ts
Normal file
30
go/cmd/kjol-web/frontend/src/pages/public/pages.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
17
go/cmd/kjol-web/frontend/src/pages/public/routes.gen.ts
Normal file
17
go/cmd/kjol-web/frontend/src/pages/public/routes.gen.ts
Normal 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",
|
||||
};
|
||||
35
go/cmd/kjol-web/frontend/src/public.tsx
Normal file
35
go/cmd/kjol-web/frontend/src/public.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user