Update kjol website with C documentation
This commit is contained in:
@@ -13,8 +13,8 @@ build the site out of both.
|
||||
|
||||
```sh
|
||||
cd go/cmd/kjol-web
|
||||
go run ./build # cold build: both halves
|
||||
go run ./server # SSR + /rsc + hot reload at http://localhost:8085
|
||||
go run ./server -build # cold build, then exit
|
||||
go run ./server # build, then SSR + /rsc + hot reload at http://localhost:8085
|
||||
```
|
||||
|
||||
`go run ./server` performs that same build on every save and hot-swaps the result into
|
||||
@@ -28,8 +28,8 @@ terminal you were not looking at.
|
||||
| Path | Rendered by | What it is |
|
||||
|---|---|---|
|
||||
| `/`, `/about` | Go → WebAssembly, SSR'd | The landing page. The **Layers** menu is the site's primary navigation. |
|
||||
| `/wasm/*` | Go → WebAssembly | **Kjol Wasm Web** — the gowasm engine: SSR + hydration, server components, the `webui` kit, overlays, AutoTable, charts, client fetching. |
|
||||
| `/js/*` | Solid → esbuild, client-rendered | **Kjol JS Web** — the Solid kit: components, forms, AutoTable, theming. |
|
||||
| `/wasm/*` | Go → WebAssembly | **Kjol Wasm Web** — the gowasm engine: SSR + hydration, server components, data fetching, and the whole `webui` kit on one page (`/wasm/components`). |
|
||||
| `/js/*` | Solid → esbuild, client-rendered | **Kjol JS Web** — the Solid kit, all of it on one page (`/js/components`), with a sidebar that jumps to each group. |
|
||||
| `/js/ssr` | Solid → **goja, at request time** | A public page server-rendered with live data injected — the ISR path. |
|
||||
|
||||
Crossing between `/wasm` and `/js` is a real page load. They are different binaries, and
|
||||
@@ -38,16 +38,19 @@ pretending otherwise would mean shipping both to every visitor.
|
||||
## Layout
|
||||
|
||||
```
|
||||
app/ the Go/WASM half — pages as plain Go functions returning a *VNode
|
||||
pages.go Deps + Shell + the public/app layouts + the landing page
|
||||
layers.go the Layers, as data. MIRRORED in frontend/src/layers.ts.
|
||||
docs.go the docs chrome; docsNav() is the sidebar AND the index
|
||||
kit.go table.go overlays.go chart.go data.go server_counter.go
|
||||
*.gen.go GENERATED by kjol/cmd/wasmgen (routes, layout dispatch, RSC stubs)
|
||||
app/ Kjol Wasm Web — pages as plain Go functions returning a *VNode
|
||||
pages.go Deps + Shell + the public/app layouts + the landing page
|
||||
layers.go the Layers, as data. MIRRORED in frontend/src/layers.ts.
|
||||
docs.go the docs chrome; docsNav() is the sidebar AND the index
|
||||
components.go THE WHOLE KIT, on one page. componentGroups() is the single source
|
||||
for both the sections and the sidebar that jumps to them.
|
||||
table.go the AutoTable's data + controller (shared with its tests)
|
||||
chart.go data.go server_counter.go
|
||||
*.gen.go GENERATED by kjol/cmd/wasmgen (routes, layout dispatch, RSC stubs)
|
||||
wasm/ the js/wasm client entry (main_native.go is a host stub)
|
||||
css/app.css its Tailwind entry — kjol/tw scans the .go files for class names
|
||||
|
||||
frontend/ the Solid half — .tsx pages written against @ui/*
|
||||
frontend/ Kjol JS Web — .tsx pages written against @ui/*
|
||||
css/style.css brand ONLY. kjol's theme.css is prepended by the bundler, and it
|
||||
is the one that does `@import "tailwindcss"`.
|
||||
vendor/ pdf-lib + pdfjs-dist. @ui/AutoTable imports them at the TOP LEVEL,
|
||||
@@ -57,18 +60,21 @@ frontend/ the Solid half — .tsx pages written against @ui/*
|
||||
src/layers.ts the Layers again. Keep in step with app/layers.go.
|
||||
|
||||
server/ ONE Go server: serves wwwroot, SSRs the wasm routes, hosts /rsc,
|
||||
mounts the Solid SPA at /js/*, serves the SSR'd public pages.
|
||||
mounts the Solid SPA at /js/*, serves the SSR'd public pages, and
|
||||
carries the -build flag.
|
||||
build/ the build pipeline, in Go rather than a shell script, so the cold build
|
||||
and the watch loop call the SAME functions and cannot drift. It is a
|
||||
library, not a command: the server imports it, and Go will not let you
|
||||
import a main — hence `go run ./server -build` rather than `./build`.
|
||||
internal/handlers/ the app side of the public-page inversion: kjol generates the
|
||||
registry; this owns the type and the document shell.
|
||||
buildsteps/ the build, in Go rather than a shell script, so the one-shot build and
|
||||
the dev server's watch loop call the SAME functions and cannot drift.
|
||||
wwwroot/ both halves write here. They never collide: app.css / app.wasm for one,
|
||||
wwwroot/ both layers write here. They never collide: app.css / app.wasm for one,
|
||||
bundle.min.* for the other. One static dir, one server.
|
||||
```
|
||||
|
||||
## Theming
|
||||
|
||||
One theme, both halves. The kits are themed by **semantic tokens** — components say
|
||||
One theme, both layers. The kits are themed by **semantic tokens** — components say
|
||||
`bg-surface`, `text-ink`, `border-line` and never name a colour — so dark mode
|
||||
re-points about a dozen CSS variables and not one component knows it happened.
|
||||
|
||||
@@ -84,11 +90,12 @@ to invert (the neutral button, whose label must go dark when the fill goes pale)
|
||||
|
||||
## Adding things
|
||||
|
||||
**A Go/WASM page:** write the function, mark it `//gowasm:page /wasm/thing layout=app`,
|
||||
**A Kjol Wasm Web page:** write the function, mark it `//gowasm:page /wasm/thing layout=app`,
|
||||
build. `wasmgen` regenerates the routing. Add `static` to have it server-rendered.
|
||||
|
||||
**A Solid page:** write the `.tsx`, add it to `routes` in `frontend/src/app.ts` and to
|
||||
`NAV` in `frontend/src/layout/Shell.tsx`.
|
||||
**A component:** add a section to `app/components.go` (Go) or `frontend/src/pages/Components.tsx`
|
||||
(Solid), and one entry to `componentGroups()` / `COMPONENT_GROUPS`. That single list drives the
|
||||
sections, the sidebar that jumps to them, and the index — so none of the three can drift.
|
||||
|
||||
**A layer:** one entry in `app/layers.go` *and* one in `frontend/src/layers.ts`. They are
|
||||
two files because nothing is upstream of both a WebAssembly binary and an esbuild bundle;
|
||||
@@ -103,7 +110,7 @@ keeping each to a flat list of plain data is what makes that duplication surviva
|
||||
| `kjol/rsc` | stateless server components over HTTP | `//gowasm:server` + its generated client stub |
|
||||
| `kjol/wasmdevserver` | reusable dev server: SSR, `/rsc`, hot reload, error overlay | `server/main.go` fills a `wasmdevserver.Config` |
|
||||
| `kjol/webui` | the Go component kit | every `/wasm/*` page |
|
||||
| `kjol/jsbundler` | TSX → Solid → esbuild, the Tailwind driver, the SSR bake | `buildsteps.JS`, and the ISR render at request time |
|
||||
| `kjol/jsbundler` | TSX → Solid → esbuild, the Tailwind driver, the SSR bake | `build.JS`, and the ISR render at request time |
|
||||
| `kjol/jsruntime` | the Solid kit, the vendored runtime, the icons, `theme.css` | everything under `/js/*` |
|
||||
| `kjol/tw` | the Tailwind v4 engine, in Go | both stylesheets — it scans `.go` for one and `.tsx` for the other |
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
chart "github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
. "kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
@@ -60,9 +61,56 @@ func pieSVG(values []int) string {
|
||||
})
|
||||
}
|
||||
|
||||
// chartSkeleton is what the SERVER puts where a chart is going to be: a box of the right
|
||||
// height, so nothing jumps when the real one arrives.
|
||||
func chartSkeleton(height string) *VNode {
|
||||
return Div(Attr("class", "flex animate-pulse items-center justify-center rounded-default bg-surface-muted "+height),
|
||||
Span(Attr("class", "text-xs text-ink-faint"), Text("drawing…")),
|
||||
)
|
||||
}
|
||||
|
||||
// newChartDrawing returns a signal that is FALSE on the server and on the client's first
|
||||
// render, and true from the moment the WebAssembly has committed that first render.
|
||||
//
|
||||
// It is what keeps the charts CLIENT-DRAWN. go-chart is ordinary Go and would run just as
|
||||
// happily on the server — it used to, and this page's markup carried two finished SVGs.
|
||||
// Two reasons not to:
|
||||
//
|
||||
// - It is work the server does on every single request for a picture that only matters
|
||||
// once the page is alive. Drawing it in the browser costs the server nothing and the
|
||||
// reader nothing they can see.
|
||||
// - It is the more honest demonstration. A Go charting library, compiled to WebAssembly,
|
||||
// drawing an SVG in the browser is the thing this layer claims it can do. Shipping a
|
||||
// server-rendered picture of one proves the opposite point.
|
||||
//
|
||||
// The false-on-first-render part is not optional: hydration walks the server's DOM
|
||||
// alongside the client's first tree, so that tree has to be the SAME tree. Draw the charts
|
||||
// on the client's first pass and the two disagree, and the reconciler has to rebuild what
|
||||
// it should have adopted.
|
||||
func newChartDrawing() *Signal[bool] {
|
||||
drawn := NewSignal(false)
|
||||
wasmruntime.AfterRender(func() {
|
||||
if !drawn.Get() {
|
||||
drawn.Set(true) // a write re-renders; the second pass draws for real
|
||||
}
|
||||
})
|
||||
return drawn
|
||||
}
|
||||
|
||||
// chartBox renders one chart, or the placeholder standing in for it. draw is a closure so
|
||||
// that on the server go-chart is never called at all — not called and discarded, but never
|
||||
// entered.
|
||||
func chartBox(class, height string, drawn bool, draw func() string) *VNode {
|
||||
if !drawn {
|
||||
return Div(Attr("class", class), chartSkeleton(height))
|
||||
}
|
||||
return Div(Attr("class", class), Raw(draw()))
|
||||
}
|
||||
|
||||
//gowasm:page /wasm/chart static layout=app
|
||||
func ChartPage(d Deps) func() *VNode {
|
||||
data := NewSignal(fixedChartData())
|
||||
drawn := newChartDrawing()
|
||||
|
||||
return func() *VNode {
|
||||
values := data.Get()
|
||||
@@ -85,19 +133,27 @@ func ChartPage(d Deps) func() *VNode {
|
||||
),
|
||||
|
||||
docSection("charts", "A worked example: charts",
|
||||
prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing about "+
|
||||
"the browser. The server draws them and ships the markup inline; there is no chart "+
|
||||
"JavaScript, and no canvas that has to wait for the client to boot before it shows anything."),
|
||||
prose("Shuffle re-runs the same drawing code in the browser. The first render came from the "+
|
||||
"server and the next one comes from WebAssembly, and the page cannot tell the difference."),
|
||||
prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing "+
|
||||
"about the browser. They are drawn by the WEBASSEMBLY, in your browser, and never by the "+
|
||||
"server: what the server sends is the two placeholders you may have seen for a moment, "+
|
||||
"and the WebAssembly replaces them on its first commit."),
|
||||
prose("That is the demonstration. A Go charting library, compiled to wasm, drawing an SVG in "+
|
||||
"the browser is exactly what this layer claims it can do — and a server-rendered picture "+
|
||||
"of a chart would prove the opposite point while looking identical. Shuffle redraws them, "+
|
||||
"and no request is made."),
|
||||
prose("The rest of the page IS server-rendered — the headings, the prose, the code you are "+
|
||||
"reading. Static and client-drawn are not opposites: a route can be pre-rendered and still "+
|
||||
"leave the expensive, browser-only parts of itself for the client."),
|
||||
|
||||
Div(Attr("class", "mt-4"),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Icon: "chart-column", Text: "Shuffle data",
|
||||
OnClick: func() { data.Set(randomValues()) }}),
|
||||
),
|
||||
Div(Attr("class", "mt-4 grid gap-4 lg:grid-cols-12"),
|
||||
Div(Attr("class", "lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto"), Raw(barSVG(values))),
|
||||
Div(Attr("class", "lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto"), Raw(pieSVG(values))),
|
||||
chartBox("lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
|
||||
"h-[260px]", drawn.Get(), func() string { return barSVG(values) }),
|
||||
chartBox("lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
|
||||
"h-[320px]", drawn.Get(), func() string { return pieSVG(values) }),
|
||||
),
|
||||
|
||||
note("go-chart lives in the EXAMPLE, not in kjol",
|
||||
@@ -111,7 +167,7 @@ func ChartPage(d Deps) func() *VNode {
|
||||
apiRow{"//gowasm:page /path static", "Pre-render this route on the server, then hydrate it."},
|
||||
apiRow{"vdom.RenderHTML", "Render a tree to an HTML string. This is what the server calls."},
|
||||
apiRow{"wasmruntime.Hydrate", "Adopt server-rendered DOM instead of building it. The client's entry point for a static route."},
|
||||
apiRow{"vdom.Raw", "Insert markup verbatim — how the server-drawn SVG gets in. The reconciler clears it correctly when the element is reused."},
|
||||
apiRow{"vdom.Raw", "Insert markup verbatim — how the SVG the WebAssembly drew gets in. The reconciler clears it correctly when the element is reused."},
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -120,17 +176,42 @@ func ChartPage(d Deps) func() *VNode {
|
||||
|
||||
const chartSnippet = `//gowasm:page /wasm/chart static layout=app
|
||||
func ChartPage(d Deps) func() *VNode {
|
||||
data := NewSignal(fixedChartData())
|
||||
data := NewSignal(fixedChartData())
|
||||
drawn := NewSignal(false) // false on the server AND on the first client render
|
||||
|
||||
// AfterRender is the post-commit hook. It fires once the WebAssembly has put its
|
||||
// first tree on the page — the earliest moment at which drawing is a client act.
|
||||
wasmruntime.AfterRender(func() {
|
||||
if !drawn.Get() {
|
||||
drawn.Set(true) // a write re-renders; the second pass draws
|
||||
}
|
||||
})
|
||||
|
||||
return func() *VNode {
|
||||
// go-chart draws an SVG string — on the server for the first paint,
|
||||
// and in the browser for every render after that.
|
||||
return Div(
|
||||
ui.Button(ui.ButtonProps{
|
||||
Text: "Shuffle data",
|
||||
OnClick: func() { data.Set(randomValues()) },
|
||||
}),
|
||||
Div(Raw(barSVG(data.Get()))),
|
||||
|
||||
// The server never enters barSVG: chartBox takes a CLOSURE, and calls it only
|
||||
// once drawn is true. It renders the placeholder instead, and the WebAssembly
|
||||
// swaps in the real chart on its first commit.
|
||||
//
|
||||
// drawn must be FALSE on the client's first render too. Hydration walks the
|
||||
// server's DOM alongside the client's first tree, so the two have to BE the
|
||||
// same tree; draw on that first pass and the reconciler rebuilds what it
|
||||
// should have adopted.
|
||||
chartBox("", "h-[260px]", drawn.Get(),
|
||||
func() string { return barSVG(data.Get()) }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// chartBox is the whole trick, and it is four lines.
|
||||
func chartBox(class, height string, drawn bool, draw func() string) *VNode {
|
||||
if !drawn {
|
||||
return Div(Attr("class", class), chartSkeleton(height))
|
||||
}
|
||||
return Div(Attr("class", class), Raw(draw()))
|
||||
}`
|
||||
|
||||
600
go/cmd/kjol-web/app/clayer.go
Normal file
600
go/cmd/kjol-web/app/clayer.go
Normal file
@@ -0,0 +1,600 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "kjol/vdom"
|
||||
)
|
||||
|
||||
// The C layer's documentation.
|
||||
//
|
||||
// One page, like the component pages: a reader looking for the arena API should not have
|
||||
// to guess which of six pages it was filed under. The sidebar calls the sections
|
||||
// SUBSYSTEMS, which is what they are — each is a directory of C, and each has a different
|
||||
// idea of what it depends on.
|
||||
//
|
||||
// Everything here is read off the source. Where a subsystem needs something the reader has
|
||||
// to supply — a unity translation unit, a vendored ImGui, a resource script pointed at his
|
||||
// own product — the page says what it needs and what to write, rather than filing it as a
|
||||
// defect. This is a base layer: it is COPIED INTO a project and compiled with it, so "what
|
||||
// you have to provide" is not a caveat, it is the interface.
|
||||
|
||||
type subsystem struct {
|
||||
ID string
|
||||
Label string
|
||||
Icon string
|
||||
Blurb string
|
||||
}
|
||||
|
||||
// cSubsystems is the single source for the page's sections AND the sidebar that jumps to
|
||||
// them — so the sidebar cannot offer a jump to a section that does not exist.
|
||||
func cSubsystems() []subsystem {
|
||||
return []subsystem{
|
||||
{ID: "base", Label: "base", Icon: "cube",
|
||||
Blurb: "The vocabulary: fixed-width types, a bump allocator, counted strings, and 2D rectangle math."},
|
||||
{ID: "build", Label: "build", Icon: "bolt",
|
||||
Blurb: "A single-header build system. Your build script is a C program that recompiles itself."},
|
||||
{ID: "platform", Label: "platform", Icon: "server",
|
||||
Blurb: "A window, its input, the clipboard. Win32, and it hands every message to Dear ImGui first."},
|
||||
{ID: "lexer", Label: "lexer", Icon: "code",
|
||||
Blurb: "Syntax highlighting. Five languages, thirteen themes, and a paint array rather than a token list."},
|
||||
{ID: "config", Label: "config", Icon: "file",
|
||||
Blurb: "An INI file beside the executable. Program-managed state, not user-authored settings."},
|
||||
{ID: "installer", Label: "installer", Icon: "download",
|
||||
Blurb: "A Win32 wizard that carries the product inside itself as a resource."},
|
||||
}
|
||||
}
|
||||
|
||||
// cNav is the sidebar while you are reading /c. The group is called Subsystems.
|
||||
func cNav() []docsGroup {
|
||||
items := make([]docsItem, 0, len(cSubsystems()))
|
||||
for _, s := range cSubsystems() {
|
||||
items = append(items, docsItem{
|
||||
Path: "/c#" + s.ID,
|
||||
Label: s.Label,
|
||||
Icon: s.Icon,
|
||||
Blurb: s.Blurb,
|
||||
})
|
||||
}
|
||||
|
||||
return []docsGroup{
|
||||
{
|
||||
Title: "Introduction",
|
||||
Items: []docsItem{
|
||||
{Path: "/c", Label: "Overview", Icon: "book-open",
|
||||
Blurb: "What this layer is, and where it came from."},
|
||||
{Path: "/c#compiling", Label: "Compiling it", Icon: "bolt",
|
||||
Blurb: "The unity translation unit: what you write, and what each subsystem needs from you."},
|
||||
},
|
||||
},
|
||||
{Title: "Subsystems", Items: items},
|
||||
}
|
||||
}
|
||||
|
||||
//gowasm:page /c static layout=app
|
||||
func CPage(d Deps) func() *VNode {
|
||||
return func() *VNode {
|
||||
return docPage("Layers", "Kjøl C",
|
||||
"A base layer in C — a bump allocator, counted strings, a syntax highlighter, a Win32 window, "+
|
||||
"and a build system that is itself a C program. It was lifted out of codeMAX, a code editor, "+
|
||||
"which is why the pieces are the pieces an editor needs.",
|
||||
|
||||
docSection("what-this-is", "What this is",
|
||||
prose("Not a library you link against. A set of headers and translation units you COPY INTO "+
|
||||
"a project and compile with it — the base-layer style you will recognise if you have read "+
|
||||
"Ryan Fleury's RAD Debugger or watched Handmade Hero. The headers say as much: base_core, "+
|
||||
"base_arena and base_strings all credit raddebugger, and the naming (U32, Str8, Rng2F32) "+
|
||||
"comes straight from it."),
|
||||
prose("There is no package manager, no version, and no ABI to keep stable — because there is "+
|
||||
"nothing to keep stable BETWEEN. The code and its consumer are compiled together. Which is "+
|
||||
"also why the next section is about the file YOU write: the layer does not build on its "+
|
||||
"own, by design. It builds as part of your program."),
|
||||
|
||||
note("It arrived from codeMAX, and still carries its names",
|
||||
"The whole directory came into Kjøl in one commit, out of codeMAX, a code editor. Its "+
|
||||
"fingerprints are still on it and they are worth knowing before you go looking: the "+
|
||||
"Win32 window class is codemax_wc, the installer installs codeMAX, and installer.rc "+
|
||||
"names codeMAX's icon, manifest and payload. Those are the strings to change when you "+
|
||||
"adopt it — they are the product's name, not the layer's."),
|
||||
),
|
||||
|
||||
cCompiling(),
|
||||
cBase(),
|
||||
cBuild(),
|
||||
cPlatform(),
|
||||
cLexer(),
|
||||
cConfig(),
|
||||
cInstaller(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- compiling -----------------------------------------------------------
|
||||
|
||||
// How you actually compile the thing. It is second on the page, before any API, because a
|
||||
// base layer's first question is "how does this get into my program" and the answer here is
|
||||
// not the one a reader arriving from a package-manager language expects.
|
||||
func cCompiling() *VNode {
|
||||
return docSection("compiling", "Compiling it",
|
||||
prose("One translation unit. You write a single .c that #includes the layer's .c files in order, "+
|
||||
"and hand THAT to the compiler — the whole layer is one TU, compiled with your program. "+
|
||||
"base/base_inc.c is the pattern in miniature: three lines, pulling in the arena and the "+
|
||||
"strings. Your own unity file is the same idea, one level up."),
|
||||
codeLang("app_inc.c — the file you write", "c", unityTUSnippet),
|
||||
prose("This is not a workaround for the absence of a build system; it IS the build system. There is "+
|
||||
"no per-file compilation, so there are no object files, no link order and no header guard "+
|
||||
"archaeology — and the compiler sees the whole layer at once, which is what makes every helper "+
|
||||
"in it `internal` and every call to one a candidate for inlining."),
|
||||
|
||||
docSubheading("What each subsystem needs from you"),
|
||||
prose("They are not equally self-contained, and the differences are worth knowing before you pick "+
|
||||
"the ones you want:"),
|
||||
apiTable(
|
||||
apiRow{"base", "Nothing. It stands on its own — base_inc.c is a complete TU, and everything above depends on it."},
|
||||
apiRow{"lexer", "Include lexer.c AND the five backends in the same TU. Their helpers are `internal`, so separate compilation would leave lexer.c's dispatch with nothing to call."},
|
||||
apiRow{"config", "Include config.h above config.c. config.c uses Config without including its own header — which is exactly what a unity build lets it do."},
|
||||
apiRow{"platform", "Dear ImGui, vendored, and a C++ compiler for it. See the subsystem below — the window procedure hands it every message before it looks at any of them."},
|
||||
apiRow{"installer", "Its own program and its own .rc, pointed at your icon, your manifest and your payload."},
|
||||
apiRow{"build", "Nothing — it is the thing that runs the compiler. Write a build.c, compile it once, and never compile it again."},
|
||||
),
|
||||
|
||||
note("There is no build.c in Kjøl yet, and that is the next job",
|
||||
"build.h is here, complete and self-contained, and nothing in the repository includes it — so "+
|
||||
"the layer currently has a build system and no build. Writing that build.c is what turns "+
|
||||
"this directory into something you can type one command at, and it is the single most "+
|
||||
"useful thing anyone could add to the C layer. The build subsystem below documents the "+
|
||||
"header it would be written against, and the snippet there is a working sketch of it."),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- base ----------------------------------------------------------------
|
||||
|
||||
func cBase() *VNode {
|
||||
return docSection("base", "base",
|
||||
prose("The vocabulary. Fixed-width integers with short names, a bump allocator, a counted string, "+
|
||||
"and the rectangle math a user interface actually spends its time on. Everything else in the "+
|
||||
"layer is written in these — which is why there is no malloc below this line, and no char* "+
|
||||
"pretending to be a string."),
|
||||
|
||||
docSubheading("The types (base_core.h)"),
|
||||
prose("U8 through U64, S8 through S64, B32 for a boolean, F32 and F64 — upper case, not the u8/i32 "+
|
||||
"of the Rust-adjacent style. Sizes are written KB(4) and MB(64) rather than as a number of "+
|
||||
"zeroes you have to count. And the three meanings of `static` in C get three different names: "+
|
||||
"`internal` for a file-local function, `global` for a translation-unit variable, `local_persist` "+
|
||||
"for a local that survives the call. They all expand to `static`; they do not all mean the same "+
|
||||
"thing, and the code says which one it meant."),
|
||||
codeLang("c/base/base_core.h", "c", baseCoreSnippet),
|
||||
prose("There is more in the header than the types: Min/Max/Clamp, the nil-aware doubly and singly "+
|
||||
"linked-list macros from raddebugger (DLLPushBack, SLLQueuePush and friends), DeferLoop — which "+
|
||||
"is `defer` written as a for-loop, because C has no defer — and an Assert that traps on MSVC "+
|
||||
"with __debugbreak."),
|
||||
|
||||
docSubheading("The arena (base_arena.h)"),
|
||||
prose("One allocator, and it is a bump pointer: arena_push moves a cursor and hands you the memory. "+
|
||||
"There is no free. You release the whole arena, or you roll it back to a saved position — which "+
|
||||
"is what Temp is. A function that wants scratch space opens a Temp, allocates as freely as it "+
|
||||
"likes, and closes it. Nothing has to be individually released, so nothing can be individually "+
|
||||
"forgotten."),
|
||||
codeLang("c/base/base_arena.h", "c", baseArenaSnippet),
|
||||
|
||||
note("It is malloc-backed and fixed-capacity — and that has a sharp edge",
|
||||
"The header says so: this is raddebugger's arena with the virtual-memory reserve/commit taken "+
|
||||
"out. arena_alloc is one malloc of the capacity you asked for, and that capacity is final — "+
|
||||
"the arena does not grow and does not chain. Overflow hits Assert(!\"Arena overflow\") and "+
|
||||
"returns NULL. But Assert compiles to nothing unless _DEBUG is defined, so in a release build "+
|
||||
"an over-full arena hands you a silent NULL. Alignment is a hard-coded 8 bytes, so there is "+
|
||||
"no SIMD guarantee. And there is no thread-local scratch pool — if you came here expecting "+
|
||||
"raddebugger's scratch_begin, it is not in this snapshot."),
|
||||
|
||||
docSubheading("Strings (base_strings.h)"),
|
||||
prose("Str8 is a pointer and a length. Not NUL-terminated, not owned, does not allocate — so a "+
|
||||
"substring is free, and a Str8 can point into the middle of a file you mapped. The two "+
|
||||
"operations that MUST allocate take the arena, and so they say so in their signature. That is "+
|
||||
"the whole API: eight functions. It is early, and there is no split, join, trim or "+
|
||||
"case-insensitive compare yet."),
|
||||
codeLang("c/base/base_strings.h", "c", baseStringsSnippet),
|
||||
|
||||
docSubheading("Math (base_math.h)"),
|
||||
prose("Look at the shape of this file and it tells you what it is for. Vec3F32 has a constructor "+
|
||||
"and no operations. Rng2F32 — a rectangle — has nine: width, height, dim, center, contains, "+
|
||||
"pad, shift, intersect. There are no matrices, no quaternions, no Mat4. This is 2D interface "+
|
||||
"math, and a layout written in it is a sequence of rectangle operations rather than eight lines "+
|
||||
"of x + w arithmetic with an off-by-one hiding in them."),
|
||||
prose("The Axis2 / Side / Corner enums plus v2f32_axis are the raddebugger trick for axis-generic "+
|
||||
"code: one code path handles both X and Y by indexing into the vector instead of naming .x "+
|
||||
"and .y, so a horizontal and a vertical layout are the same function with a different argument."),
|
||||
|
||||
apiTable(
|
||||
apiRow{"arena_alloc / arena_release", "One malloc, fixed capacity. It does not grow."},
|
||||
apiRow{"arena_push / arena_push_no_zero", "Bump the cursor. Zeroed by default; the fast one has to be asked for by name."},
|
||||
apiRow{"temp_begin / temp_end", "A scratch scope: save the position, allocate freely, roll it all back."},
|
||||
apiRow{"push_array(arena, T, n)", "Typed sugar over arena_push. The macro you will actually use."},
|
||||
apiRow{"str8 / str8_cstr / str8_lit", "Make a counted string. None of them allocate."},
|
||||
apiRow{"str8_pushf / str8_push_copy", "The two that DO allocate — and so they take the arena."},
|
||||
apiRow{"Rng2F32 + rng2f32_intersect / _pad / _shift", "Rectangles, and the three things a layout does to them."},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- build ---------------------------------------------------------------
|
||||
|
||||
func cBuild() *VNode {
|
||||
return docSection("build", "build",
|
||||
prose("The build system is a C program, and the build system's build system is a C compiler. "+
|
||||
"build.h is a single header in the stb style: define BUILD_IMPLEMENTATION in one file, include "+
|
||||
"it, and write your build as a main() that shells out to a compiler. The lineage is nob.h — the "+
|
||||
"self-rebuild macro here is called GO_REBUILD_URSELF, which is a straight nod to it."),
|
||||
|
||||
docSubheading("Bootstrapping"),
|
||||
prose("You compile the build script once, by hand. After that you never compile it again, because "+
|
||||
"the first thing it does when it runs is compare its own source against its own executable — "+
|
||||
"and if the source is newer, it rebuilds itself, swaps the binary, and re-executes. Edit the "+
|
||||
"build script and just run it. It will notice."),
|
||||
codeLang("terminal", "sh", buildBootstrapSnippet),
|
||||
|
||||
note("On Windows you cannot overwrite a running .exe — so it doesn't",
|
||||
"go_rebuild_urself RENAMES the current binary to build.exe.old, compiles the new one in its "+
|
||||
"place, and re-execs. If the compile FAILS, it renames the old one back. That is the whole "+
|
||||
"reason the dance exists, and it is the difference between a build script you can edit and "+
|
||||
"one that bricks itself the first time you make a typo."),
|
||||
|
||||
docSubheading("Writing one"),
|
||||
prose("GO_REBUILD_URSELF is the first line of main. Cmd is a growable argv you append to and run. "+
|
||||
"needs_rebuild compares timestamps, so a target whose inputs have not changed is skipped. That "+
|
||||
"is the whole of it — no rule syntax, no DSL, no dependency graph, because a C program already "+
|
||||
"has if-statements and for-loops and you already know how to write them."),
|
||||
codeLang("build.c — the file you write", "c", buildUsageSnippet),
|
||||
|
||||
note("What it deliberately does NOT do",
|
||||
"There is no compiler detection: the compiler is cl.exe on Windows and cc everywhere else, "+
|
||||
"hard-coded, and only for the self-rebuild — for your own targets you assemble the command "+
|
||||
"yourself. There is no parallelism: cmd_run is synchronous, start to finish. There is no "+
|
||||
"globbing and no header-dependency scanning: needs_rebuild compares mtimes against the input "+
|
||||
"list YOU pass it, so if you edit a header that is not in that list, nothing rebuilds. Know "+
|
||||
"that going in and it is a fine tool; expect make and you will be bitten."),
|
||||
|
||||
docSubheading("What else is in the header"),
|
||||
prose("More than a build system strictly needs, and all of it is there because a real build wanted "+
|
||||
"it. A temp allocator — an 8 MB ring buffer that silently wraps, so temp_sprintf can hand you a "+
|
||||
"formatted path that you never free. A string builder. File I/O that reports its own errors. "+
|
||||
"And two functions that are squarely about shipping a graphical program: embed_file, which "+
|
||||
"turns any binary into a C array in a header, and compile_shader / embed_spirv, which run glslc "+
|
||||
"over a GLSL source and embed the SPIR-V the same way. That last pair is a fossil of a Vulkan "+
|
||||
"renderer, and it is the clearest evidence in the layer of what codeMAX was becoming."),
|
||||
|
||||
apiTable(
|
||||
apiRow{"GO_REBUILD_URSELF(argc, argv)", "First line of main. Rebuilds and re-executes the script if its source changed."},
|
||||
apiRow{"Cmd + cmd_append + cmd_run", "A growable argv, appended variadically, run synchronously. cmd_run resets it for reuse."},
|
||||
apiRow{"needs_rebuild(out, inputs, n)", "1 if any input is newer than the output, 0 if up to date, -1 on error. Mtimes only."},
|
||||
apiRow{"temp_sprintf / temp_reset", "Formatted strings from a ring buffer. You never free them; you reset the ring."},
|
||||
apiRow{"sb_read_file / write_entire_file", "Whole-file I/O into a String_Builder, and back out."},
|
||||
apiRow{"embed_file(in, out, var)", "Turn a binary into a C header: an array, and its size."},
|
||||
apiRow{"compile_shader / embed_spirv", "glslc a .glsl to .spv, then embed the .spv as a C array."},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- platform ------------------------------------------------------------
|
||||
|
||||
func cPlatform() *VNode {
|
||||
return docSection("platform", "platform",
|
||||
prose("The operating system, behind one header — and it is a narrower header than the name "+
|
||||
"suggests. There is no file I/O here, no clock, no threads, and no virtual memory (which is why "+
|
||||
"the arena is malloc-backed). What there is: a window, the input that arrives at it, the "+
|
||||
"clipboard, and a way to spawn a terminal."),
|
||||
prose("Input comes in two shapes on purpose. PlatformRawInput is what the OS actually said — UTF-16 "+
|
||||
"characters, virtual key codes, mouse coordinates. PlatformInput is what the application wants "+
|
||||
"to hear. platform_adapt_input converts one into the other, and that function is the seam a "+
|
||||
"second backend would be written behind."),
|
||||
|
||||
note("It expects Dear ImGui in the build, and Windows underneath",
|
||||
"platform_win32.c is the only backend, and its window procedure forward-declares "+
|
||||
"ImGui_ImplWin32_WndProcHandler and calls it on every message BEFORE it looks at any of "+
|
||||
"them — so ImGui gets first refusal on the input, which is what makes an ImGui text field "+
|
||||
"inside the window behave like a text field rather than a hole the editor's keybindings "+
|
||||
"fall through. To use this subsystem you vendor ImGui and compile it (it is C++) alongside; "+
|
||||
"to use it WITHOUT ImGui you cut that one call, and everything else in the file is C and "+
|
||||
"stands. A second backend would replace the PKEY_ enum — whose values ARE Windows virtual "+
|
||||
"key codes, passed straight through — and find a home for platform_spawn_terminal, which "+
|
||||
"hard-codes cmd.exe /k."),
|
||||
|
||||
docSubheading("Two decisions worth stealing"),
|
||||
prose("WM_SIZE calls the frame callback synchronously, from inside the window procedure. That looks "+
|
||||
"wrong and is the standard Win32 fix for a real problem: while you drag a window's edge, "+
|
||||
"Windows runs a modal resize loop that never returns to your main loop, so the window goes "+
|
||||
"blank or smears. Rendering from inside the message handler is the only way to keep drawing. "+
|
||||
"platform_set_frame_callback exists for exactly this."),
|
||||
prose("And platform_get_input drains its accumulator on read — it memsets the buffered keys and "+
|
||||
"characters to zero on the way out, and returns was_mouse_down beside mouse_down so the caller "+
|
||||
"can see an edge without keeping its own copy of last frame."),
|
||||
|
||||
apiTable(
|
||||
apiRow{"platform_create_window / _destroy_window", "A window from a PlatformWindowDesc; and its teardown."},
|
||||
apiRow{"platform_poll_events", "Pump the message queue. False means the user wants to close."},
|
||||
apiRow{"platform_get_input / platform_adapt_input", "The raw OS input for this frame; and the conversion that is the portability seam."},
|
||||
apiRow{"platform_set_frame_callback", "Called per frame — including from inside a live resize, which is the point."},
|
||||
apiRow{"platform_get_dpi_scale", "GetDpiForWindow / 96. The layer is per-monitor DPI aware throughout."},
|
||||
apiRow{"platform_clipboard_get / _set", "Get returns a pointer into a static 64 KB buffer. Do not free it, do not keep it."},
|
||||
apiRow{"platform_get_native_handle", "The HWND, for the one place that genuinely needs it."},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- lexer ---------------------------------------------------------------
|
||||
|
||||
func cLexer() *VNode {
|
||||
return docSection("lexer", "lexer",
|
||||
prose("Syntax highlighting, and the most complete thing in this layer — about 2,800 lines of it. "+
|
||||
"The interesting decision is the output shape, and the header credits it to the Focus editor: a "+
|
||||
"tokenizer here does not return a list of tokens. It takes the buffer and an out_tokens array "+
|
||||
"of the SAME LENGTH, and paints one token-type byte per source byte."),
|
||||
codeLang("c/lexer/lexer.h", "c", lexerSnippet),
|
||||
prose("That sounds wasteful and is the opposite. An editor never wants to know what the tokens are; "+
|
||||
"it wants to know what colour to draw the character at offset N — and with a parallel array "+
|
||||
"that is one indexed read, not a search through a token list. The enum value IS the index into "+
|
||||
"the theme's colour table, which is why TOK_DEFAULT has to be zero. Painting is a memset. The "+
|
||||
"cost is one byte per byte of source, for a file you already have in memory."),
|
||||
|
||||
note("It lets you rewrite the past, which is why it can find function names",
|
||||
"All five backends share one trick. When the tokenizer emits a `(` and the PREVIOUS token was "+
|
||||
"an identifier, it goes back and re-paints that identifier as TOK_FUNCTION. With a token "+
|
||||
"list you would have to look ahead, or fix up afterwards. With a paint array the past is "+
|
||||
"just an array range, and repainting it costs a memset."),
|
||||
|
||||
docSubheading("Languages"),
|
||||
prose("Five backends — C, Go, JavaScript, Lua, SQL — plus plain text, each a single .c file behind "+
|
||||
"the same LexerTokenizeFn signature, selected by a hard-coded switch. There is no registry and "+
|
||||
"no plugin: adding a language means editing four things in lexer.c, deliberately."),
|
||||
prose("They are not toys. The JavaScript one carries an explicit depth stack for nested tagged "+
|
||||
"template literals, so an html`…${css`…${x}`}…` lexes correctly at arbitrary nesting, and it "+
|
||||
"has a regex-versus-division heuristic — after a keyword or an operator a slash starts a regex, "+
|
||||
"after an identifier it is a divide. The Lua one counts the equals signs in a long bracket so "+
|
||||
"[==[ is closed by ]==] and not by ]]. The SQL one is case-insensitive and treats \"quoted\" as "+
|
||||
"an identifier rather than a string."),
|
||||
|
||||
docSubheading("Themes"),
|
||||
prose("A theme is a colour per token type, and there are thirteen: Default, Default Light, Focus, "+
|
||||
"Handmade Hero, Witness Classic, Witness, VS Classic, RAD Debugger, 4coder, Ryan Fleury, Gruber "+
|
||||
"Darker, VS Dark, Freshcut Contrast. The file is pure data — not one function in five hundred "+
|
||||
"lines."),
|
||||
note("The Theme struct is a fossil, and you can date the rock",
|
||||
"Every theme carries a 24-bit colour table AND a 256-colour ANSI fallback, with a use_truecolor "+
|
||||
"flag — because this started life in a TERMINAL editor, where you cannot assume truecolor. "+
|
||||
"But the platform layer beside it is a DPI-aware Win32 GUI window driven by ImGui, and the "+
|
||||
"struct has since grown status_bg, minibuffer and file-browser colours that mean nothing to "+
|
||||
"a terminal. It is a terminal-era struct with GUI-era fields bolted on. That is a seam, not "+
|
||||
"a design."),
|
||||
|
||||
apiTable(
|
||||
apiRow{"LexerTokenizeFn(data, len, out_tokens)", "The one signature every backend implements. One byte of token type per byte of source."},
|
||||
apiRow{"lexer_get_tokenize_fn(lang)", "The backend for a language. A switch, not a registry."},
|
||||
apiRow{"lexer_detect_lang(filename)", "Language from the file extension."},
|
||||
apiRow{"Tokenizer + tokenizer_init / _eat_whitespace", "The shared cursor the backends are written against."},
|
||||
apiRow{"g_themes / theme_active()", "Thirteen built-in themes, and the active one."},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- config --------------------------------------------------------------
|
||||
|
||||
func cConfig() *VNode {
|
||||
return docSection("config", "config",
|
||||
prose("An INI file — [sections], key = value — that lives NEXT TO THE EXECUTABLE rather than in a "+
|
||||
"home directory, which makes the program portable: copy the folder, keep your settings. It "+
|
||||
"parses into one global Config struct and saves back out of it."),
|
||||
codeLang("c/config/config.h", "c", configSnippet),
|
||||
|
||||
prose("The struct is the schema, and it is a fixed-size one: ten recent directories, every path a "+
|
||||
"flat 1024 bytes. Nothing in this subsystem allocates — which is what lets it be loaded before "+
|
||||
"an arena exists. If the file is absent, config_load writes a default one."),
|
||||
|
||||
note("It stores what the PROGRAM knows, not what the user wants",
|
||||
"The header is explicit about the split, and it is a good one: this file is managed by the "+
|
||||
"program — your window size, your recent folders, your active project — and per-project "+
|
||||
"settings belong in .editorconfig, where other tools can read them. A config file that tries "+
|
||||
"to be both ends up being neither."),
|
||||
|
||||
note("It needs a writable directory beside the executable",
|
||||
"That is the one requirement the design carries, and it is easy to miss: the file is written "+
|
||||
"NEXT TO the .exe, and config_save does not check whether the write succeeded. Run out of a "+
|
||||
"folder you can write to — which is what \"copy the folder, keep your settings\" means — and "+
|
||||
"it does exactly what it says. Install the same binary into C:\\Program Files and a "+
|
||||
"non-elevated process cannot write there, so the save quietly does nothing. If you ship it "+
|
||||
"through the installer, either keep the config beside the exe and expect an elevated write, "+
|
||||
"or point config_get_path at %APPDATA% and give up the portability."),
|
||||
|
||||
apiTable(
|
||||
apiRow{"config_load / config_save", "Read the INI into g_config; write g_config back out. Writes defaults if the file is absent."},
|
||||
apiRow{"config_get_path", "Beside the executable — GetModuleFileNameA, then strip the filename."},
|
||||
apiRow{"config_push_recent_dir", "Dedupes case-insensitively, moves the entry to the front, caps at ten, and saves itself."},
|
||||
apiRow{"g_config", "The one global. There is no second one."},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- installer -----------------------------------------------------------
|
||||
|
||||
func cInstaller() *VNode {
|
||||
return docSection("installer", "installer",
|
||||
prose("A Windows installer AND uninstaller, in one executable, with the thing it installs embedded "+
|
||||
"inside it as a resource. No NSIS, no WiX, no MSI: it is a Win32 property-sheet wizard — "+
|
||||
"welcome, directory, progress, finish — that extracts the payload, copies itself to "+
|
||||
"uninstall.exe, optionally appends itself to the system PATH, writes a Start Menu shortcut via "+
|
||||
"COM, and registers under HKLM so it shows up in Add/Remove Programs."),
|
||||
prose("installer.manifest is what makes Windows ask for elevation UP FRONT rather than failing on "+
|
||||
"the first write to Program Files. installer.h exists solely so the resource script and the C "+
|
||||
"agree on the resource ids."),
|
||||
|
||||
note("The wizard has no dialog resources — it writes the DLGTEMPLATEs by hand, at runtime",
|
||||
"There is a small serializer in installer.c that assembles the Win32 DLGTEMPLATE and "+
|
||||
"DLGITEMTEMPLATE binary layout byte by byte, alignment padding and all, and hands the result "+
|
||||
"to the property sheet with PSP_DLGINDIRECT. Which means the installer's entire user "+
|
||||
"interface needs no resource compiler: only the icon, the manifest and the payload go "+
|
||||
"through the .rc. That is either magnificent or deranged, and it is certainly deliberate."),
|
||||
|
||||
note("A running .exe cannot delete itself, so the uninstaller doesn't",
|
||||
"It spawns a detached cmd.exe that waits two seconds, deletes uninstall.exe, and removes the "+
|
||||
"directory. The process outlives the program that started it, which is the only way this can "+
|
||||
"be done on Windows."),
|
||||
|
||||
docSubheading("Pointing it at your own product"),
|
||||
prose("The .rc is the part you edit. It names codeMAX's icon, codeMAX's manifest and codeMAX's "+
|
||||
"payload, at the paths the original repository had them at — so adopting the installer means "+
|
||||
"pointing those three lines at your icon, your manifest and your executable, and changing the "+
|
||||
"product name and the HKLM key the uninstall entry is written under. Nothing else in installer.c "+
|
||||
"knows what it is installing."),
|
||||
prose("There is also visible residue of a two-binary product that was collapsed into one, and it is "+
|
||||
"worth recognising rather than copying: IDR_EXE_TERMINAL is still declared though nothing embeds "+
|
||||
"or extracts it, do_install's numbered steps skip step 3, and the uninstaller still removes a "+
|
||||
"second executable and a shortcut that the installer no longer creates. Those are the lines to "+
|
||||
"delete on the way in."),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------------
|
||||
|
||||
// docSubheading is a heading INSIDE a section. The subsystems each have several parts, and
|
||||
// six sections with no internal structure is a wall.
|
||||
func docSubheading(text string) *VNode {
|
||||
return H3(Attr("class", "mt-8 text-base font-semibold text-text-heading"), Text(text))
|
||||
}
|
||||
|
||||
// ---- snippets ------------------------------------------------------------
|
||||
//
|
||||
// Every one of these is copied from the source, except two — unityTUSnippet and
|
||||
// buildUsageSnippet, which are the two files the layer expects its CONSUMER to write and
|
||||
// so cannot be copied from a layer that has no consumer yet. Both are written against the
|
||||
// real API and both are captioned as the file you write, not as a file that is here.
|
||||
//
|
||||
// If you change the source, change these. A snippet that has drifted from the code it
|
||||
// claims to show is the most expensive documentation there is, because it is believed.
|
||||
|
||||
const unityTUSnippet = `// The whole layer, as one translation unit — the only .c you hand to the
|
||||
// compiler. Order matters: it is textual inclusion, not linking.
|
||||
#include "base/base_inc.c" // core, arena, strings. Everything below needs it.
|
||||
|
||||
// The five backends declare their helpers `+ "`internal`" + ` (file-static), so they
|
||||
// belong in the SAME TU as the dispatch that calls them.
|
||||
#include "lexer/lexer.c"
|
||||
#include "lexer/lexer_c.c"
|
||||
#include "lexer/lexer_go.c"
|
||||
#include "lexer/lexer_js.c"
|
||||
#include "lexer/lexer_lua.c"
|
||||
#include "lexer/lexer_sql.c"
|
||||
|
||||
// config.c uses Config without including its own header — which is exactly the
|
||||
// thing a unity build is for. Put the header above it.
|
||||
#include "config/config.h"
|
||||
#include "config/config.c"
|
||||
|
||||
// ...and then your own program, compiled with all of it:
|
||||
#include "app/app.c"`
|
||||
|
||||
const baseCoreSnippet = `// The three meanings of `+ "`static`" + ` in C, given three names.
|
||||
#define internal static // a function private to this file
|
||||
#define global static // a variable owned by this translation unit
|
||||
#define local_persist static // a local that survives the call
|
||||
|
||||
typedef uint8_t U8; typedef int8_t S8;
|
||||
typedef uint32_t U32; typedef int32_t S32;
|
||||
typedef uint64_t U64; typedef int64_t S64;
|
||||
typedef S32 B32; // a boolean, sized so it packs predictably
|
||||
typedef float F32; typedef double F64;
|
||||
|
||||
#define KB(n) (((U64)(n)) << 10)
|
||||
#define MB(n) (((U64)(n)) << 20)`
|
||||
|
||||
const baseArenaSnippet = `typedef struct Arena { U8 *base; U64 pos; U64 cap; } Arena;
|
||||
typedef struct Temp { Arena *arena; U64 pos; } Temp;
|
||||
|
||||
// One malloc, of exactly cap. It does not grow and it does not chain.
|
||||
Arena *arena_alloc(U64 cap);
|
||||
void *arena_push(Arena *arena, U64 size); // zeroed
|
||||
void *arena_push_no_zero(Arena *arena, U64 size); // not
|
||||
void arena_pop_to(Arena *arena, U64 pos);
|
||||
|
||||
#define push_array(arena, T, count) ((T *)arena_push((arena), sizeof(T) * (count)))
|
||||
|
||||
// A scratch scope. Allocate as freely as you like inside it; none of it needs
|
||||
// releasing, because temp_end rolls the cursor back over all of it at once.
|
||||
Temp scratch = temp_begin(arena);
|
||||
Node *nodes = push_array(arena, Node, 1024);
|
||||
temp_end(scratch);`
|
||||
|
||||
const baseStringsSnippet = `// A pointer and a length. Not NUL-terminated, not owned, does not allocate —
|
||||
// so a substring is free, and a Str8 can point into a file you mapped.
|
||||
typedef struct Str8 { const char *str; U64 size; } Str8;
|
||||
|
||||
static inline Str8 str8_lit(const char *s);
|
||||
static inline B32 str8_match(Str8 a, Str8 b);
|
||||
static inline B32 str8_is_empty(Str8 s);
|
||||
|
||||
// The two that must allocate take the arena, and so they say so:
|
||||
Str8 str8_pushf(Arena *arena, const char *fmt, ...);
|
||||
Str8 str8_push_copy(Arena *arena, Str8 s);`
|
||||
|
||||
const buildBootstrapSnippet = `# Once, by hand. (On Windows, from a Visual Studio developer prompt —
|
||||
# it shells out to cl.exe.)
|
||||
cl /nologo build.c # Windows
|
||||
cc build.c -o build # macOS / Linux
|
||||
|
||||
# Ever after, just run it. If build.c is newer than the binary, the binary
|
||||
# rebuilds itself, swaps in the new one, and re-executes:
|
||||
./build`
|
||||
|
||||
const buildUsageSnippet = `#define BUILD_IMPLEMENTATION
|
||||
#include "build.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
// Renames the running binary to .old, recompiles, and re-execs. If the
|
||||
// compile fails it puts the old one back — so a typo in your build
|
||||
// script cannot brick your build script.
|
||||
GO_REBUILD_URSELF(argc, argv);
|
||||
|
||||
mkdir_if_not_exists("out");
|
||||
|
||||
// needs_rebuild compares MTIMES against the list you pass it. There is
|
||||
// no header scanning: if a .h you depend on is not in this list, editing
|
||||
// it will not trigger a rebuild.
|
||||
const char *srcs[] = {"base/base_inc.c", "lexer/lexer.c"};
|
||||
if (needs_rebuild("out/app.exe", srcs, ARRAY_LEN(srcs)) > 0) {
|
||||
Cmd cmd = {0};
|
||||
cmd_append(&cmd, "cl", "/nologo", "/I.", "/Fe:out/app.exe");
|
||||
cmd_append(&cmd, srcs[0], srcs[1]);
|
||||
if (!cmd_run(&cmd)) return 1; // synchronous; resets cmd for reuse
|
||||
cmd_free(&cmd);
|
||||
}
|
||||
|
||||
build_log(LOG_INFO, "done");
|
||||
return 0;
|
||||
}`
|
||||
|
||||
const lexerSnippet = `// A tokenizer does not RETURN tokens. It paints one token-type byte per
|
||||
// source byte, into an array the same length as the buffer.
|
||||
//
|
||||
// The editor never asks "what are the tokens". It asks "what colour is the
|
||||
// character at offset N" — and this answers that with one indexed read.
|
||||
// The enum value IS the index into the theme's colour table, which is why
|
||||
// TOK_DEFAULT has to be 0.
|
||||
typedef void (*LexerTokenizeFn)(const char *data, S32 length, U8 *out_tokens);
|
||||
|
||||
typedef enum Lang { LANG_PLAIN_TEXT, LANG_C, LANG_GO, LANG_JS, LANG_LUA, LANG_SQL } Lang;
|
||||
|
||||
LexerTokenizeFn lexer_get_tokenize_fn(Lang lang); // a switch, not a registry
|
||||
Lang lexer_detect_lang(const char *filename);`
|
||||
|
||||
const configSnippet = `// The struct IS the schema, and it is fixed-size throughout: this subsystem
|
||||
// allocates nothing, so it can be loaded before an arena exists.
|
||||
typedef struct Config {
|
||||
char theme[64];
|
||||
char recent_dirs[CONFIG_MAX_RECENT_DIRS][CONFIG_PATH_MAX]; // most recent first
|
||||
S32 recent_dir_count;
|
||||
B32 show_line_numbers;
|
||||
B32 syntax_enabled;
|
||||
F32 ui_scale;
|
||||
char editor_font[64];
|
||||
char active_project[CONFIG_PATH_MAX];
|
||||
} Config;
|
||||
|
||||
extern Config g_config; // the one global
|
||||
|
||||
static void config_load(void); // <exe dir>/config.ini — not $HOME
|
||||
static void config_save(void);`
|
||||
1383
go/cmd/kjol-web/app/components.go
Normal file
1383
go/cmd/kjol-web/app/components.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kjol/lexer" // syntax highlighting for the code blocks — a string in, HTML out
|
||||
. "kjol/vdom"
|
||||
// The host API is dual-build — real under js/wasm, no-ops natively — so neutral page
|
||||
// code can measure the browser and still server-render. This page uses it for exactly
|
||||
// one thing: reading the clock when hydration commits.
|
||||
"kjol/wasmruntime"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
@@ -31,18 +38,38 @@ type docsItem struct {
|
||||
Icon string
|
||||
}
|
||||
|
||||
// The Components group is not a list of PAGES — it is a list of anchors into the one
|
||||
// components page. There used to be three pages there ("UI kit", "Overlays",
|
||||
// "AutoTable"), which split the kit along the lines of its source files rather than
|
||||
// along anything a reader wants: a person hunting for a date picker does not know, and
|
||||
// should not have to guess, whether it was filed under forms or under overlays.
|
||||
//
|
||||
// So the whole kit is one page, and the sidebar jumps you down it. The groups come from
|
||||
// componentGroups(), which is also what BUILDS the sections — so the sidebar cannot
|
||||
// offer a jump to a section that does not exist, and a section cannot go missing from
|
||||
// the sidebar.
|
||||
func docsNav() []docsGroup {
|
||||
items := make([]docsItem, 0, len(componentGroups()))
|
||||
for _, g := range componentGroups() {
|
||||
items = append(items, docsItem{
|
||||
Path: "/wasm/components#" + g.ID,
|
||||
Label: g.Label,
|
||||
Icon: g.Icon,
|
||||
Blurb: g.Blurb,
|
||||
})
|
||||
}
|
||||
|
||||
return []docsGroup{{
|
||||
Title: "Introduction",
|
||||
Items: []docsItem{
|
||||
{Path: "/wasm", Label: "Overview", Icon: "book-open",
|
||||
Blurb: "What Kjol Web is, how a page becomes a WebAssembly binary, and what runs where."},
|
||||
Blurb: "What Kjøl Wasm Web is, how a page becomes a WebAssembly binary, and what runs where."},
|
||||
},
|
||||
}, {
|
||||
Title: "Rendering",
|
||||
Items: []docsItem{
|
||||
{Path: "/wasm/chart", Label: "SSR & hydration", Icon: "chart-column",
|
||||
Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, server-drawn as SVG."},
|
||||
Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, drawn by the WebAssembly."},
|
||||
{Path: "/wasm/server", Label: "Server components", Icon: "server",
|
||||
Blurb: "Components whose state and code stay on the server. Calling one looks like calling any other."},
|
||||
{Path: "/wasm/data", Label: "Data fetching", Icon: "cloud-arrow-down",
|
||||
@@ -50,14 +77,7 @@ func docsNav() []docsGroup {
|
||||
},
|
||||
}, {
|
||||
Title: "Components",
|
||||
Items: []docsItem{
|
||||
{Path: "/wasm/kit", Label: "UI kit", Icon: "squares",
|
||||
Blurb: "Buttons, forms, tabs, alerts, cards — the kjol/webui components, written in Go."},
|
||||
{Path: "/wasm/overlays", Label: "Overlays", Icon: "layers",
|
||||
Blurb: "Tooltips, popovers, menus, modals: measured against the real viewport, flipped and shifted to fit."},
|
||||
{Path: "/wasm/table", Label: "AutoTable", Icon: "table",
|
||||
Blurb: "Filtering, sorting, column management, calculated columns, CSV and PDF export."},
|
||||
},
|
||||
Items: items,
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -71,7 +91,7 @@ func docPage(eyebrow, title, lede string, sections ...*VNode) *VNode {
|
||||
Div(Attr("class", "border-b border-line pb-6"),
|
||||
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text(eyebrow)),
|
||||
H1(Attr("class", "mt-2 text-3xl font-semibold tracking-tight text-text-heading"), Text(title)),
|
||||
P(Attr("class", "mt-3 max-w-3xl text-ink-muted leading-relaxed"), Text(lede)),
|
||||
P(Attr("class", "mt-3 text-ink-muted leading-relaxed"), Text(lede)),
|
||||
),
|
||||
)
|
||||
for _, s := range sections {
|
||||
@@ -93,11 +113,15 @@ func docSection(id, title string, body ...*VNode) *VNode {
|
||||
return El("section", mods...)
|
||||
}
|
||||
|
||||
// prose is a paragraph of explanation. Constrained to a reading measure: a line of body
|
||||
// text that runs the full width of a wide screen is genuinely harder to read, and the
|
||||
// demos beside it are allowed to be as wide as they like.
|
||||
// prose is a paragraph of explanation.
|
||||
//
|
||||
// It used to be pinned to a reading measure (max-w-3xl). It is not any more: on a
|
||||
// documentation page the paragraphs sit directly above demos, tables and code blocks
|
||||
// that are as wide as the column, and a narrow ribbon of text over a full-width panel
|
||||
// reads as a mistake rather than as typographic care. The column itself (max-w-6xl, set
|
||||
// by AppLayout) is the measure now.
|
||||
func prose(text string) *VNode {
|
||||
return P(Attr("class", "mt-3 max-w-3xl text-ink-soft leading-relaxed"), Text(text))
|
||||
return P(Attr("class", "mt-3 text-ink-soft leading-relaxed"), Text(text))
|
||||
}
|
||||
|
||||
// ---- code ---------------------------------------------------------------
|
||||
@@ -109,23 +133,20 @@ func prose(text string) *VNode {
|
||||
// lets you go and check.
|
||||
func code(caption, src string) *VNode { return codeLang(caption, "Go", src) }
|
||||
|
||||
// codeLang is code() for a block that is not Go — a shell session, a formula. The label
|
||||
// in the corner says what you are looking at, and a shell command labelled "Go" is worse
|
||||
// than no label at all.
|
||||
// codeLang is code() for a block that is not Go — a C header, a shell session, a formula.
|
||||
// The label in the corner says what you are looking at, and a shell command labelled "Go"
|
||||
// is worse than no label at all.
|
||||
//
|
||||
// Go blocks are syntax-highlighted (webui.HighlightGo); the others are shown verbatim.
|
||||
// A shell transcript put through a Go lexer comes out with `serving` painted as an
|
||||
// identifier and quotes as string literals — highlighting the wrong language is more
|
||||
// distracting than not highlighting at all.
|
||||
// The label is ALSO what picks the lexer, so the two cannot disagree: a block cannot be
|
||||
// labelled C and painted as Go. A language kjol/lexer does not know comes back escaped and
|
||||
// unpainted, which is what should happen — a shell transcript put through a Go lexer comes
|
||||
// out with `serving` painted as an identifier and quotes as string literals, and
|
||||
// highlighting the WRONG language is more distracting than not highlighting at all.
|
||||
func codeLang(caption, lang, src string) *VNode {
|
||||
var body *VNode
|
||||
if lang == "Go" {
|
||||
// Raw, not Text: HighlightGo returns HTML. It escapes every run of source on the
|
||||
// way out, so the snippets that contain markup stay inert.
|
||||
body = El("code", Raw(ui.HighlightGo(src)))
|
||||
} else {
|
||||
body = El("code", Text(src))
|
||||
}
|
||||
// Raw, not Text: the lexer returns HTML. It escapes every run of source on the way out
|
||||
// — including for a language it does not know — so the snippets that contain markup,
|
||||
// and every C snippet, which is all pointers and shifts, stay inert.
|
||||
body := El("code", Raw(lexer.Highlight(lang, src)))
|
||||
|
||||
return Div(Attr("class", "mt-4 overflow-hidden rounded-default border border-neutral-800 bg-neutral-900"),
|
||||
Div(Attr("class", "flex items-center gap-2 border-b border-neutral-800 px-4 py-2"),
|
||||
@@ -156,7 +177,7 @@ func demo(title string, body ...*VNode) *VNode {
|
||||
|
||||
// note is an aside — a caveat, a gotcha, the reason something is the way it is.
|
||||
func note(title, body string) *VNode {
|
||||
return Div(Attr("class", "mt-4 max-w-3xl rounded-default border border-primary-border bg-primary-subtle px-4 py-3"),
|
||||
return Div(Attr("class", "mt-4 rounded-default border border-primary-border bg-primary-subtle px-4 py-3"),
|
||||
P(Attr("class", "text-sm font-semibold text-text-heading"), Text(title)),
|
||||
P(Attr("class", "mt-1 text-sm text-ink-soft leading-relaxed"), Text(body)),
|
||||
)
|
||||
@@ -180,7 +201,10 @@ func apiTable(rows ...apiRow) *VNode {
|
||||
for _, b := range body {
|
||||
rowMods = append(rowMods, b)
|
||||
}
|
||||
return Div(Attr("class", "mt-4 max-w-5xl overflow-x-auto"),
|
||||
// Full width, like everything else on the page. A reference table pinned to max-w-5xl
|
||||
// inside a max-w-6xl column is not narrower for a reason — it is narrower by an inch,
|
||||
// which reads as a misalignment rather than as a decision.
|
||||
return Div(Attr("class", "mt-4 overflow-x-auto"),
|
||||
El("table", Attr("class", "w-full border-collapse text-left"),
|
||||
Tbody(rowMods...),
|
||||
),
|
||||
@@ -191,7 +215,38 @@ func apiTable(rows ...apiRow) *VNode {
|
||||
|
||||
//gowasm:page /wasm static layout=app
|
||||
func DocsPage(d Deps) func() *VNode {
|
||||
clicks := NewSignal(0)
|
||||
|
||||
// The one measurement on the page: performance.now() when the client's first render
|
||||
// commits. Zero until then — which is what the SERVER renders, and what the client
|
||||
// renders on its first pass, so the two agree and hydration stays clean.
|
||||
hydratedAt := NewSignal(0.0)
|
||||
wasmruntime.AfterRender(func() {
|
||||
if hydratedAt.Get() == 0 {
|
||||
hydratedAt.Set(wasmruntime.Now())
|
||||
}
|
||||
})
|
||||
|
||||
// demoTree is called TWICE per render below — once for the DOM, once for the HTML.
|
||||
// That is the point: the two panes cannot drift, because there is only one of them.
|
||||
//
|
||||
// This demo used to be on the front page. It does not belong there — it is the Wasm
|
||||
// Web engine's single best argument, and the front page is kjøl's, not this engine's.
|
||||
// Here it is the first thing the section shows, which is where an argument like this
|
||||
// one earns its place.
|
||||
demoTree := func() *VNode {
|
||||
return Div(Attr("class", "flex items-center gap-3"),
|
||||
ui.Button(ui.ButtonProps{
|
||||
Color: ui.ButtonPrimary, Text: "Click me",
|
||||
OnClick: func() { clicks.Set(clicks.Get() + 1) },
|
||||
}),
|
||||
Span(Attr("class", "text-ink-soft"), Text("clicked "+itoa(clicks.Get())+" times")),
|
||||
)
|
||||
}
|
||||
|
||||
return func() *VNode {
|
||||
markup := RenderHTML(demoTree())
|
||||
|
||||
var groups []*VNode
|
||||
for _, g := range docsNav() {
|
||||
grid := []Mod{Attr("class", "mt-3 grid gap-3 sm:grid-cols-2")}
|
||||
@@ -213,10 +268,39 @@ func DocsPage(d Deps) func() *VNode {
|
||||
}
|
||||
|
||||
return docPage("Introduction", "Overview",
|
||||
"Kjol Web is kjol's Go→WebAssembly UI engine. You write components as ordinary Go functions "+
|
||||
"returning a virtual DOM; the server renders them to HTML and the same code hydrates them "+
|
||||
"in the browser. There is no JavaScript build step, and the engine depends on nothing "+
|
||||
"outside the standard library.",
|
||||
"Kjøl Wasm Web is Kjøl's Go→WebAssembly UI engine. You write components as ordinary Go "+
|
||||
"functions returning a virtual DOM; the server renders them to HTML and the same code "+
|
||||
"hydrates them in the browser. There is no JavaScript build step, and the engine depends "+
|
||||
"on nothing outside the standard library.",
|
||||
|
||||
// ---- the demonstration ----
|
||||
//
|
||||
// The one thing on this site that cannot be faked: the same Go function, rendered
|
||||
// twice at once, as live DOM and as the HTML string the server sent.
|
||||
docSection("two-runtimes", "One function, two runtimes",
|
||||
prose("Below is a single Go function, shown twice. On the left it has been reconciled into "+
|
||||
"the DOM and you can use it. On the right is the HTML the same function produces when the "+
|
||||
"server renders it — the markup that reached your browser before any WebAssembly had "+
|
||||
"loaded. Click the button; both move."),
|
||||
|
||||
Div(Attr("class", "mt-5 border border-line sm:grid sm:grid-cols-2"),
|
||||
Div(Attr("class", "border-b border-line sm:border-b-0 sm:border-r"),
|
||||
paneLabel("in your browser"),
|
||||
Div(Attr("class", "px-4 py-8"), demoTree()),
|
||||
),
|
||||
Div(
|
||||
paneLabel(itoa(len(markup))+" bytes of HTML"),
|
||||
Pre(Attr("class", "whitespace-pre-wrap px-4 py-4 font-mono text-[12px] leading-relaxed text-ink-muted"),
|
||||
El("code", Text(prettyHTML(markup))),
|
||||
),
|
||||
),
|
||||
),
|
||||
P(Attr("class", "mt-3 text-sm leading-relaxed text-ink-muted"),
|
||||
Text("The right pane is not a picture of the source. It is vdom.RenderHTML, called on the "+
|
||||
"very tree the left pane is showing, recomputed on every click.")),
|
||||
P(Attr("class", "mt-2 text-sm text-ink-muted"),
|
||||
Text(hydrationNote(hydratedAt.Get()))),
|
||||
),
|
||||
|
||||
docSection("what-runs-where", "What runs where",
|
||||
prose("A page is Go, compiled twice. On the server it renders to an HTML string, so the first "+
|
||||
@@ -230,15 +314,35 @@ func DocsPage(d Deps) func() *VNode {
|
||||
"position itself, without a branch in the component."),
|
||||
),
|
||||
|
||||
docSection("what-is-in-it", "What is in it",
|
||||
Ul(Attr("class", "mt-3 space-y-1.5 leading-relaxed text-ink-soft"),
|
||||
item("Server-side rendering and client hydration, from one codebase."),
|
||||
item("Server components: mark a function and its code and state stay on the server."),
|
||||
item("A component kit — forms, tabs, modals, tooltips, toasts — with dark mode."),
|
||||
item("A table with filtering, sorting, column management, formulas, and CSV and PDF export."),
|
||||
item("Tailwind, compiled by a Go program that reads your Go."),
|
||||
),
|
||||
prose("Two commands build it. The first produced the page you are reading; the second serves "+
|
||||
"it and rebuilds on save."),
|
||||
codeLang("terminal", "sh", buildTranscript),
|
||||
),
|
||||
|
||||
appendNodes(Div(Attr("class", "mt-14 border-t border-line pt-2")), groups...),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func docsCard(d Deps, it docsItem) *VNode {
|
||||
// A component card is a jump into the components page, not a page of its own — so it
|
||||
// routes there and scrolls, exactly as the sidebar does.
|
||||
click := navigate(d, it.Path)
|
||||
if base, frag, ok := strings.Cut(it.Path, "#"); ok {
|
||||
click = navigateAnchor(d, base, frag)
|
||||
}
|
||||
|
||||
return A(
|
||||
Attr("class", "group block rounded-default border border-line bg-surface p-4 no-underline shadow-xs transition hover:border-primary-border hover:shadow-sm"),
|
||||
Attr("href", it.Path), navigate(d, it.Path),
|
||||
Attr("href", it.Path), click,
|
||||
Div(Attr("class", "flex items-center gap-2"),
|
||||
Span(Attr("class", "inline-flex h-7 w-7 items-center justify-center rounded-default bg-primary-subtle text-accent"),
|
||||
ui.IconInline(it.Icon, 14, "")),
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// orElse is a fallback for an empty string.
|
||||
func orElse(s, fallback string) string {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// row is a flex/grid container helper (appends *VNode children as Mods).
|
||||
func row(class string, children ...*VNode) *VNode {
|
||||
mods := []Mod{Attr("class", class)}
|
||||
for _, c := range children {
|
||||
mods = append(mods, c)
|
||||
}
|
||||
return Div(mods...)
|
||||
}
|
||||
|
||||
// kitSection is one labelled block of the gallery — a live demo panel, so that what you
|
||||
// are looking at is unmistakably the component running rather than a picture of it.
|
||||
func kitSection(title string, body ...*VNode) *VNode {
|
||||
return demo(title, row("flex flex-col gap-4", body...))
|
||||
}
|
||||
|
||||
func ptRow(name, plan string, status *VNode) *VNode {
|
||||
td := func(cls string, c *VNode) *VNode { return El("td", Attr("class", "px-3 py-2 text-sm "+cls), c) }
|
||||
return El("tr",
|
||||
td("text-ink", Text(name)),
|
||||
td("text-ink-soft", Text(plan)),
|
||||
El("td", Attr("class", "px-3 py-2 text-sm text-right"), status),
|
||||
)
|
||||
}
|
||||
|
||||
// languageOptions is deliberately longer than the pill limit, so the multi-select
|
||||
// demonstrates both ways it collapses: past 3 selections it says "N items selected"
|
||||
// outright, and below that it still collapses if the pills are too wide for the field.
|
||||
func languageOptions() []ui.FormSelectOption {
|
||||
return []ui.FormSelectOption{
|
||||
{Value: "go", Label: "Go"},
|
||||
{Value: "rust", Label: "Rust"},
|
||||
{Value: "ts", Label: "TypeScript"},
|
||||
{Value: "python", Label: "Python"},
|
||||
{Value: "kotlin", Label: "Kotlin"},
|
||||
{Value: "swift", Label: "Swift"},
|
||||
}
|
||||
}
|
||||
|
||||
//gowasm:page /wasm/kit layout=app
|
||||
func KitPage(d Deps) func() *VNode {
|
||||
// Interactive demos own their state via signals (a write re-renders).
|
||||
tab := NewSignal(0)
|
||||
acc := NewSignal(0)
|
||||
notify := NewSignal(true)
|
||||
span := NewSignal("week")
|
||||
name := NewSignal("")
|
||||
email := NewSignal("")
|
||||
plan := NewSignal("pro")
|
||||
langs := NewSignal([]string{"go"})
|
||||
|
||||
// Floating components are CONTROLLERS: they own refs, timers and open state, so
|
||||
// they are built once here — never inside the render closure below, which would
|
||||
// rebuild them (and lose their state) on every frame.
|
||||
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
|
||||
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
|
||||
tip := ui.NewHoverTooltip(ui.PlacementTop, "")
|
||||
skills := ui.NewMultiSelect(ui.DropdownOptions{})
|
||||
|
||||
// The controls the first port left out, now that the host API can carry them.
|
||||
taxID := NewSignal("")
|
||||
rate := NewSignal("")
|
||||
signed := NewSignal("")
|
||||
picked := NewSignal("")
|
||||
tags := NewSignal([]string{"go"})
|
||||
|
||||
pad := ui.NewSignaturePad(ui.SignaturePadOptions{
|
||||
OnChange: func(svg string) { signed.Set(svg) },
|
||||
})
|
||||
// The search is the caller's: the component knows how to debounce, order and render,
|
||||
// and nothing at all about where options come from. Here it is a local slice; in an
|
||||
// app it would be a fetch.
|
||||
people := ui.NewAsyncCombobox(ui.AsyncComboboxOptions{
|
||||
MinChars: 2,
|
||||
Search: func(q string, done func([]ui.FormSelectOption)) {
|
||||
var out []ui.FormSelectOption
|
||||
for _, row := range employees() {
|
||||
p, ok := row.(Employee)
|
||||
if ok && strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) {
|
||||
out = append(out, ui.FormSelectOption{Value: p.Email, Label: p.Name})
|
||||
}
|
||||
}
|
||||
done(out)
|
||||
},
|
||||
})
|
||||
tagPicker := ui.NewMultiSelectTrigger(ui.DropdownOptions{})
|
||||
|
||||
return func() *VNode {
|
||||
return docPage("Components", "UI kit",
|
||||
"kjol/webui is the component library: buttons, badges, forms, tabs, alerts, cards, tables. "+
|
||||
"It is a Go port of the Solid.js kit the applications used before, styled with the same "+
|
||||
"Tailwind utilities — so the two can be swapped for one another a screen at a time.",
|
||||
|
||||
docSection("using", "Using a component",
|
||||
prose("Components are functions taking a props struct. There is no class hierarchy and nothing "+
|
||||
"to register: a component is a value, so you can build one, store it, pass it around, and "+
|
||||
"the compiler will tell you when you get it wrong."),
|
||||
code("app/kit.go", kitSnippet),
|
||||
note("Styling is Tailwind, compiled from your Go",
|
||||
"The Tailwind engine scans .go files for class names, because that is where the markup is. "+
|
||||
"There is no JavaScript build in this example at all — the CSS is compiled by a Go "+
|
||||
"program from Go source."),
|
||||
),
|
||||
|
||||
docSection("gallery", "The gallery",
|
||||
prose("Everything below is running. Click it."),
|
||||
),
|
||||
|
||||
kitSection("Buttons",
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Text: "Red"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Text: "Blue"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Neutral"}),
|
||||
),
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Outline: true, Text: "Outline"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Icon: "check", Text: "Small + icon"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Icon: "plus"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Disabled", Disabled: true}),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Badges",
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeRed}, Text("failed")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("info")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("default")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeMuted}, Text("muted")),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Alerts",
|
||||
ui.Alert(ui.AlertBlue, "Heads up", Text("An informational message with a header.")),
|
||||
ui.Alert(ui.AlertGreen, "", Text("A success alert without a header.")),
|
||||
ui.Alert(ui.AlertYellow, "Warning", Text("Something needs your attention.")),
|
||||
ui.Alert(ui.AlertRed, "Error", Text("Something went wrong.")),
|
||||
),
|
||||
|
||||
kitSection("Toggles & segmented control",
|
||||
ui.ToggleSwitch(notify.Get(), func(v bool) { notify.Set(v) }, "Email notifications", "Send me product updates", false, ""),
|
||||
ui.SegmentedButtons([]ui.SegmentedButtonOption{
|
||||
{Value: "day", Label: "Day"},
|
||||
{Value: "week", Label: "Week"},
|
||||
{Value: "month", Label: "Month"},
|
||||
}, span.Get(), func(v string) { span.Set(v) }, false, "max-w-xs"),
|
||||
),
|
||||
|
||||
kitSection("Tabs",
|
||||
ui.TabGroup(ui.TabGroupProps{
|
||||
Items: []ui.TabItem{
|
||||
{Title: "Overview", Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The overview panel."))},
|
||||
{Title: "Details", Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The details panel."))},
|
||||
{Title: "Activity", Badge: 3, Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The activity panel (3 new)."))},
|
||||
},
|
||||
ActiveIndex: tab.Get(),
|
||||
OnTabChange: func(i int) { tab.Set(i) },
|
||||
}),
|
||||
),
|
||||
|
||||
kitSection("Accordion",
|
||||
ui.SingleAccordion([]ui.AccordionItemData{
|
||||
{Title: "What is Kjol Web?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("kjol's Go→WebAssembly UI engine."))},
|
||||
{Title: "Is it isomorphic?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("Yes — the same Go renders on the server (SSR) and hydrates on the client."))},
|
||||
{Title: "How is it styled?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("Tailwind utility classes, compiled by kjol's native Tailwind engine."))},
|
||||
}, acc.Get(), func(i int) { acc.Set(i) }),
|
||||
),
|
||||
|
||||
kitSection("Forms",
|
||||
row("grid gap-4 sm:grid-cols-3",
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Name")),
|
||||
ui.FormInput(ui.FormInputProps{Value: name.Get(), Placeholder: "Ada Lovelace", OnInput: func(v string) { name.Set(v) }})),
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Email")),
|
||||
ui.FormEmailInput(ui.FormInputProps{Value: email.Get(), Placeholder: "ada@example.com", OnInput: func(v string) { email.Set(v) }}, true)),
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Plan")),
|
||||
ui.FormSelect(ui.FormSelectProps{Value: plan.Get(), OnChange: func(v string) { plan.Set(v) }},
|
||||
ui.FormOption("free", "Free", false),
|
||||
ui.FormOption("pro", "Pro", false),
|
||||
ui.FormOption("enterprise", "Enterprise", false))),
|
||||
|
||||
// A multi-select. Its rows carry checkboxes, and the field shows the
|
||||
// selection as removable pills — until they stop fitting, at which point
|
||||
// it collapses to "N items selected". Tick a few and watch it flip.
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Languages")),
|
||||
skills.Render(ui.FormMultiSelectProps{
|
||||
Options: languageOptions(),
|
||||
Value: langs.Get(),
|
||||
Placeholder: "Pick a few",
|
||||
Searchable: true,
|
||||
ShowSelectAll: true,
|
||||
OnChange: func(v []string) { langs.Set(v) },
|
||||
})),
|
||||
),
|
||||
P(Attr("class", "text-xs text-ink-muted"),
|
||||
Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+
|
||||
"\" languages="+strings.Join(langs.Get(), ","))),
|
||||
),
|
||||
|
||||
kitSection("Masked inputs",
|
||||
row("grid gap-4 sm:grid-cols-2",
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Tax ID")),
|
||||
// The mask is a pure function of the string, applied on every keystroke.
|
||||
// It must be idempotent — it is fed its own output — or the field
|
||||
// corrupts itself as you type.
|
||||
ui.FormInput(ui.FormInputProps{
|
||||
Value: taxID.Get(),
|
||||
Placeholder: "12-3456789",
|
||||
OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) },
|
||||
})),
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Rate")),
|
||||
ui.FormInput(ui.FormInputProps{
|
||||
Value: rate.Get(),
|
||||
Placeholder: "5.25",
|
||||
OnInput: func(v string) { rate.Set(ui.MaskRate(v)) },
|
||||
})),
|
||||
),
|
||||
P(Attr("class", "text-xs text-ink-muted"),
|
||||
Text("Type letters, extra dots, leading zeros — the mask takes what it can use.")),
|
||||
),
|
||||
|
||||
kitSection("Async combobox",
|
||||
row("max-w-sm",
|
||||
people.Render(ui.FormAsyncComboboxProps{
|
||||
Placeholder: "Search people…",
|
||||
OnSelect: func(o ui.FormSelectOption) { picked.Set(o.Label + " <" + o.Value + ">") },
|
||||
}),
|
||||
),
|
||||
P(Attr("class", "text-xs text-ink-muted"),
|
||||
Text("Two characters before it asks; 200 ms after you stop typing. A response for a "+
|
||||
"query you have already typed past is discarded rather than shown. Picked: "+
|
||||
orElse(picked.Get(), "nothing yet"))),
|
||||
),
|
||||
|
||||
kitSection("Multi-select behind your own trigger",
|
||||
tagPicker.Render(ui.FormMultiSelectTriggerProps{
|
||||
Trigger: ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
|
||||
Icon: "filter", Text: "Tags (" + itoa(len(tags.Get())) + ")"}),
|
||||
Options: languageOptions(),
|
||||
Value: tags.Get(),
|
||||
Searchable: true,
|
||||
ShowSelectAll: true,
|
||||
OnChange: func(v []string) { tags.Set(v) },
|
||||
}),
|
||||
P(Attr("class", "text-xs text-ink-muted"),
|
||||
Text("Same selection model as the field above; only the thing you click on differs.")),
|
||||
),
|
||||
|
||||
kitSection("Signature pad",
|
||||
pad.Render(ui.SignaturePadProps{}),
|
||||
P(Attr("class", "text-xs text-ink-muted"),
|
||||
Text("Draw in it. It is an SVG, not a canvas — so the markup you are looking at IS the "+
|
||||
"value the caller gets ("+itoa(len(signed.Get()))+" bytes), and a stored signature "+
|
||||
"renders on the server.")),
|
||||
),
|
||||
|
||||
kitSection("Table",
|
||||
ui.PrettyTable(
|
||||
[]ui.PrettyTableColumn{
|
||||
{DisplayName: "Name"},
|
||||
{DisplayName: "Plan"},
|
||||
{DisplayName: "Status", DisplayPosition: ui.PrettyTableColRight},
|
||||
},
|
||||
ui.PrettyTableOptions{Hover: true, Alternate: true, SurroundingBorder: true, HeaderBorderY: true},
|
||||
ptRow("Ada Lovelace", "Pro", ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active"))),
|
||||
ptRow("Alan Turing", "Free", ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("trial"))),
|
||||
ptRow("Grace Hopper", "Enterprise", ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("invited"))),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Overlays (measured, portaled)",
|
||||
row("flex flex-wrap items-center gap-4",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
|
||||
|
||||
// The menu measures itself against the viewport: drag the window
|
||||
// narrow, or scroll it to the bottom, and it flips/shifts to stay on
|
||||
// screen. Items close the menu themselves — no callback plumbing.
|
||||
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
|
||||
caret := " ▾"
|
||||
if open {
|
||||
caret = " ▴"
|
||||
}
|
||||
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Menu" + caret})
|
||||
}),
|
||||
menu.Content("",
|
||||
menu.Item(ui.MenuItemProps{Icon: "check"}, Text("Profile")),
|
||||
menu.Item(ui.MenuItemProps{}, Text("Settings")),
|
||||
ui.MenuDivider(""),
|
||||
menu.Item(ui.MenuItemProps{}, Text("Sign out")),
|
||||
),
|
||||
|
||||
// The tooltip's arrow tracks the trigger even when the panel gets
|
||||
// shifted away from it near a viewport edge.
|
||||
tip.Render(Span(Text("A measured tooltip — try it near the window edge")),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Hover me"})),
|
||||
),
|
||||
modal.Render(ui.ModalProps{
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
|
||||
},
|
||||
P(Attr("class", "text-ink-soft"),
|
||||
Text("Portaled to document.body, so it is not clipped by any ancestor. Escape closes "+
|
||||
"the topmost modal; the backdrop click closes too.")),
|
||||
),
|
||||
),
|
||||
|
||||
docSection("more", "Where to go next",
|
||||
prose("The floating components on this page — the menu, the tooltip, the modal, the "+
|
||||
"multi-select — are the shallow end. Overlays covers how they are positioned, and what "+
|
||||
"happens when one would open off the edge of the screen."),
|
||||
apiTable(
|
||||
apiRow{"ui.Button / ui.Badge / ui.Alert", "The presentational set. Props structs, no state."},
|
||||
apiRow{"ui.FormInput / FormSelect / FormCombobox", "Inputs. Value in, OnChange out — the caller owns the state."},
|
||||
apiRow{"ui.NewMultiSelect", "A controller: checkboxed rows, pills that collapse to \"N items selected\" when they stop fitting."},
|
||||
apiRow{"ui.Tabs / ui.Accordion / ui.Card", "Layout and disclosure."},
|
||||
apiRow{"ui.RegisterIcon", "Add your own icons. The kit ships a small set; the app brings the rest."},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const kitSnippet = `// A component is a function taking a props struct.
|
||||
ui.Button(ui.ButtonProps{
|
||||
Color: ui.ButtonPrimary,
|
||||
Icon: "check",
|
||||
Text: "Save",
|
||||
OnClick: func() { toaster.Success("Saved.") },
|
||||
})
|
||||
|
||||
// Inputs are controlled: the caller owns the state.
|
||||
name := NewSignal("")
|
||||
|
||||
ui.FormInput(ui.FormInputProps{
|
||||
Value: name.Get(),
|
||||
OnInput: func(v string) { name.Set(v) }, // a write re-renders
|
||||
})`
|
||||
@@ -7,22 +7,42 @@ import (
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// The layers of kjol, as data.
|
||||
// What kjøl is made of, as data.
|
||||
//
|
||||
// This is the Go mirror of frontend/src/layers.ts. The site has two front-ends built
|
||||
// by two completely different pipelines, and the Layers menu has to be identical in
|
||||
// both — so it is a LIST in each, not markup, and the two lists are the only thing
|
||||
// that has to be kept in step.
|
||||
// There are two kinds of thing here, and conflating them was the mistake this file used
|
||||
// to make — one flat list called "the layers", holding both.
|
||||
//
|
||||
// (A shared source would be better than a mirrored one. There isn't one: this half
|
||||
// compiles to WebAssembly and the other is bundled by esbuild, and nothing is upstream
|
||||
// of both. Keeping it to a flat slice of plain data is what makes the duplication
|
||||
// survivable — you can diff the two by eye.)
|
||||
// LAYERS are LANGUAGES. What kjøl is written in, and what it gives you in each:
|
||||
// the Go base, the TypeScript kit, the C base, the Jai modules. A layer is
|
||||
// a directory of code you can use on its own.
|
||||
//
|
||||
// COMPOSITIONS are FRAMEWORKS. What you get when the layers are assembled into
|
||||
// something that does a job — the two web engines. A composition is not
|
||||
// another language; it is a use of them.
|
||||
//
|
||||
// Kjøl Wasm Web is Go, all the way down. Kjøl JS Web is TypeScript compiled by a Go
|
||||
// toolchain — two layers, one framework. Listing that beside "C" as though they were the
|
||||
// same kind of noun told the reader nothing about either.
|
||||
//
|
||||
// This is the Go mirror of frontend/src/layers.ts. The site has two front-ends built by
|
||||
// two completely different pipelines, and these menus have to be identical in both — so
|
||||
// each is a LIST, not markup, and the two lists are the only thing that has to be kept in
|
||||
// step.
|
||||
//
|
||||
// (A shared source would be better than a mirrored one. There isn't one: this side
|
||||
// compiles to WebAssembly and the other is bundled by esbuild, and nothing is upstream of
|
||||
// both. Keeping each to a flat slice of plain data is what makes the duplication
|
||||
// survivable — you can diff them by eye.)
|
||||
|
||||
type Layer struct {
|
||||
Name string
|
||||
Href string
|
||||
Tagline string
|
||||
// Sub is the half-line beside the wordmark while you are inside this layer. It says
|
||||
// what you are standing in — "Go + WebAssembly", "arenas, strings, a lexer" — and a
|
||||
// wordmark that says the same thing everywhere is one more thing the reader has to
|
||||
// keep track of himself.
|
||||
Sub string
|
||||
// Live means you can click into worked examples. The others are documented but
|
||||
// have no demo — they still appear, because a menu that silently omits half the
|
||||
// library teaches the reader that the library is half the size it is.
|
||||
@@ -30,66 +50,107 @@ type Layer struct {
|
||||
Icon string
|
||||
}
|
||||
|
||||
func Layers() []Layer {
|
||||
// Wordmark is what the CHROME calls this layer — the top bar, and the page's own title.
|
||||
// The menu calls it Name.
|
||||
//
|
||||
// They differ, and only for the languages. In a menu headed "Layers" the row says "C",
|
||||
// because the row is answering "which language"; up in the top bar, alone, "C" is the name
|
||||
// of a language rather than the name of the thing you are reading, and it has to say whose
|
||||
// C this is. The compositions are already named "Kjøl Wasm Web" — the product's name is
|
||||
// part of what they ARE, not a prefix bolted on — so they are returned unchanged.
|
||||
func (l Layer) Wordmark() string {
|
||||
if strings.HasPrefix(l.Name, "Kjøl") {
|
||||
return l.Name
|
||||
}
|
||||
return "Kjøl " + l.Name
|
||||
}
|
||||
|
||||
// Languages: what kjøl is written in.
|
||||
func Languages() []Layer {
|
||||
return []Layer{
|
||||
{
|
||||
Name: "Kjol Go",
|
||||
Name: "Go",
|
||||
Href: "/go",
|
||||
Tagline: "The server base: config, database, logging, HTTP, mail, validation.",
|
||||
Tagline: "The base: config, database, logging, HTTP, mail, validation — and both web engines.",
|
||||
Icon: "server",
|
||||
},
|
||||
{
|
||||
Name: "Kjol Wasm Web",
|
||||
Href: "/wasm",
|
||||
Tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, no JS build.",
|
||||
Live: true,
|
||||
Icon: "code",
|
||||
},
|
||||
{
|
||||
Name: "Kjol JS Web",
|
||||
Href: "/js",
|
||||
Tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
|
||||
Live: true,
|
||||
Name: "TypeScript",
|
||||
Href: "/ts",
|
||||
Tagline: "The Solid component kit, the vendored runtime, and the generic scaffolding apps import as @kjol/*.",
|
||||
Icon: "squares",
|
||||
},
|
||||
{
|
||||
Name: "Kjol C",
|
||||
Name: "C",
|
||||
Href: "/c",
|
||||
Tagline: "Arena allocator, strings, math, lexer, platform layer.",
|
||||
Tagline: "Arena allocator, counted strings, math, a lexer, a platform layer — and a build system that is a C file.",
|
||||
Sub: "a base layer in C",
|
||||
Live: true,
|
||||
Icon: "bolt",
|
||||
},
|
||||
{
|
||||
Name: "Kjol Jai",
|
||||
Name: "Jai",
|
||||
Href: "/jai",
|
||||
Tagline: "Console rendering module. Early.",
|
||||
Tagline: "Console rendering. Early.",
|
||||
Icon: "cube",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentLayer is the layer the given path belongs to, or nil on the front page.
|
||||
// Compositions: what the languages are assembled into.
|
||||
func Compositions() []Layer {
|
||||
return []Layer{
|
||||
{
|
||||
Name: "Kjøl Wasm Web",
|
||||
Href: "/wasm",
|
||||
Tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, and no JavaScript build at all.",
|
||||
Sub: "Go + WebAssembly",
|
||||
Live: true,
|
||||
Icon: "code",
|
||||
},
|
||||
{
|
||||
Name: "Kjøl JS Web",
|
||||
Href: "/js",
|
||||
Tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
|
||||
Sub: "Solid + Go toolchain",
|
||||
Live: true,
|
||||
Icon: "table",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentLayer is the layer or composition the given path belongs to, or nil on the front
|
||||
// page. The wordmark uses it to name where you are standing.
|
||||
func CurrentLayer(path string) *Layer {
|
||||
for i, l := range Layers() {
|
||||
all := append(Compositions(), Languages()...)
|
||||
for i, l := range all {
|
||||
if path == l.Href || strings.HasPrefix(path, l.Href+"/") {
|
||||
return &Layers()[i]
|
||||
return &all[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LayersMenuCtl is the Layers menu's controller.
|
||||
// The two menus' controllers.
|
||||
//
|
||||
// It is created ONCE, here, at package level — not inside layersMenu, which is called
|
||||
// from a layout on every single render. A floating component is a controller: it owns
|
||||
// an open signal, a positioning engine and document listeners, and building a fresh one
|
||||
// per render would leak all three and give you a menu that never opens. Same rule as
|
||||
// Theme, a few lines up in pages.go.
|
||||
var LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
|
||||
// They are created ONCE, here, at package level — not inside the functions below, which a
|
||||
// layout calls on every single render. A floating component is a controller: it owns an
|
||||
// open signal, a positioning engine and document listeners, and building a fresh one per
|
||||
// render would leak all three and give you a menu that never opens. Same rule as Theme, a
|
||||
// few lines up in pages.go.
|
||||
//
|
||||
// TWO menus, not one with two headings inside it. They are different questions — "what is
|
||||
// this written in" and "what can I read" — and a reader who wants the second should not
|
||||
// have to scroll past the first to find it. The kit's single-open manager means opening
|
||||
// one closes the other, so they behave like one control with two halves.
|
||||
var (
|
||||
LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
|
||||
CompositionsMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
|
||||
)
|
||||
|
||||
// layersMenu is the site's primary navigation: kjol is a stack of layers, and this is
|
||||
// how you get from any one of them to any other.
|
||||
// layersMenu lists the LANGUAGES. compositionsMenu, below, lists the frameworks.
|
||||
//
|
||||
// A layer that is Live is a link. One that is not is inert and dimmed, with the word
|
||||
// An entry that is Live is a link. One that is not is inert and dimmed, with the word
|
||||
// "reference" on it — it exists, it is documented in the repository, there is simply
|
||||
// nothing here to click.
|
||||
//
|
||||
@@ -98,35 +159,41 @@ var LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
|
||||
// interception — an intercepted click would ask this WebAssembly to render a page it
|
||||
// does not have.
|
||||
func layersMenu(d Deps) *VNode {
|
||||
content := []*VNode{
|
||||
P(Attr("class", "px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint"),
|
||||
Text("The layers of kjol")),
|
||||
}
|
||||
for _, l := range Layers() {
|
||||
return dropdown(d, LayersMenuCtl, "Layers", Languages())
|
||||
}
|
||||
|
||||
// compositionsMenu lists the FRAMEWORKS — the two things assembled out of the layers, and
|
||||
// the two a reader can actually click into.
|
||||
func compositionsMenu(d Deps) *VNode {
|
||||
return dropdown(d, CompositionsMenuCtl, "Compositions", Compositions())
|
||||
}
|
||||
|
||||
func dropdown(d Deps, ctl *ui.Menu, label string, rows []Layer) *VNode {
|
||||
content := make([]*VNode, 0, len(rows))
|
||||
for _, l := range rows {
|
||||
content = append(content, layerItem(d, l))
|
||||
}
|
||||
|
||||
return Div(Attr("class", "relative"),
|
||||
LayersMenuCtl.Trigger(ui.MenuTriggerProps{
|
||||
ctl.Trigger(ui.MenuTriggerProps{
|
||||
Class: "inline-flex items-center gap-1.5 rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink",
|
||||
},
|
||||
Text("Layers"),
|
||||
Text(label),
|
||||
ui.IconInline("chevron-down", 11, "text-ink-faint"),
|
||||
),
|
||||
LayersMenuCtl.Content("w-96", content...),
|
||||
ctl.Content("w-96", content...),
|
||||
)
|
||||
}
|
||||
|
||||
// layersGrid is the front page's list of layers — the same data as the menu, laid out
|
||||
// to be read rather than navigated. A layer with no examples still gets a row: the
|
||||
// point of the page is what kjol IS, and half of it having no demo yet does not make
|
||||
// that half not exist.
|
||||
func layersGrid(d Deps) *VNode {
|
||||
rows := []Mod{Attr("class", "mt-5 divide-y divide-line rounded-default border border-line")}
|
||||
for _, l := range Layers() {
|
||||
rows = append(rows, layerRow(l))
|
||||
// layerGrid is the front page's list — the same data as the menu, laid out to be read
|
||||
// rather than navigated. A layer with no examples still gets a row: the point of the page
|
||||
// is what kjøl IS, and half of it having no demo yet does not make that half not exist.
|
||||
func layerGrid(rows []Layer) *VNode {
|
||||
mods := []Mod{Attr("class", "mt-5 divide-y divide-line rounded-default border border-line")}
|
||||
for _, l := range rows {
|
||||
mods = append(mods, layerRow(l))
|
||||
}
|
||||
return Div(rows...)
|
||||
return Div(mods...)
|
||||
}
|
||||
|
||||
func layerRow(l Layer) *VNode {
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// Every floating component in the kit, on one page: tooltips, popovers, menus and
|
||||
// submenus, the date picker, modals (plain, confirm, wizard, imperative), toasts,
|
||||
// and the tutorial's spotlight coachmarks.
|
||||
//
|
||||
// All of them are CONTROLLERS. They own refs, timers and open state, so they are
|
||||
// created once here — never inside the render closure, which runs on every signal
|
||||
// write and would rebuild them (and their refs) from scratch every frame. That is
|
||||
// the single rule to remember about the floating layer.
|
||||
//
|
||||
// The page is NOT `static`: nothing is open during SSR anyway, so pre-rendering it
|
||||
// buys nothing, and it keeps the example honest about which routes need it.
|
||||
//
|
||||
//gowasm:page /wasm/overlays layout=app
|
||||
func OverlaysPage(d Deps) func() *VNode {
|
||||
// --- tooltips -----------------------------------------------------------
|
||||
tipTop := ui.NewHoverTooltip(ui.PlacementTop, "")
|
||||
tipRight := ui.NewHoverTooltip(ui.PlacementRight, "")
|
||||
tipFocus := ui.NewFocusTooltip(ui.PlacementBottom, "")
|
||||
tipFast := ui.NewTooltip(ui.TooltipProps{Placement: ui.PlacementTop, Delay: -1})
|
||||
|
||||
// --- popovers -----------------------------------------------------------
|
||||
pop := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomStart})
|
||||
popEnd := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomEnd})
|
||||
hoverPop := ui.NewHoverPopover(ui.HoverPopoverProps{
|
||||
Placement: ui.PlacementTop,
|
||||
// The bridge: the cursor gets 300ms of grace to cross the gap from the
|
||||
// trigger onto the panel. Without it, the panel closes in the dead space
|
||||
// between them — which is exactly what happens once a panel is portaled and
|
||||
// CSS :hover no longer reaches it.
|
||||
HoverCloseDelay: 300,
|
||||
})
|
||||
|
||||
// --- menus --------------------------------------------------------------
|
||||
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
|
||||
sub := ui.NewSubmenu(menu)
|
||||
hoverMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart, OpenOnHover: true})
|
||||
|
||||
// --- date pickers -------------------------------------------------------
|
||||
picked := NewSignal("")
|
||||
dp := ui.NewDatePicker(ui.DatePickerProps{
|
||||
Placeholder: "Pick a date",
|
||||
Clearable: true,
|
||||
OnChange: func(v string) { picked.Set(v) },
|
||||
})
|
||||
dob := ui.NewDateOfBirthPicker(ui.DatePickerProps{Placeholder: "Date of birth"})
|
||||
|
||||
// --- modals -------------------------------------------------------------
|
||||
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
|
||||
nested := ui.NewModal(ui.ModalOptions{Size: ui.ModalSmall})
|
||||
deleted := NewSignal(false)
|
||||
confirm := ui.NewModal(ui.ModalOptions{})
|
||||
|
||||
// --- wizard -------------------------------------------------------------
|
||||
wizardName := NewSignal("")
|
||||
wizardDone := NewSignal(false)
|
||||
wizard := ui.NewWizard(ui.ModalOptions{})
|
||||
|
||||
// --- toasts -------------------------------------------------------------
|
||||
// The Toaster owns the queue AND the clocks: it generates IDs, runs the
|
||||
// auto-dismiss timer, and animates the countdown bar down to zero. (ToastProvider,
|
||||
// the dumb half, renders a list you hand it and removes nothing — a toast pushed
|
||||
// through it stays until you take it away yourself.)
|
||||
toaster := ui.NewToaster(ui.ToasterOptions{Position: ui.ToastBottomRight})
|
||||
pushToast := func(kind ui.ToastType, msg string) {
|
||||
toaster.Push(ui.Toast{Message: msg, Type: kind})
|
||||
}
|
||||
|
||||
// --- tutorial -----------------------------------------------------------
|
||||
// Steps target elements by CSS SELECTOR. The tour resolves each one with
|
||||
// document.querySelector, measures it, scrolls it into view, and cuts a hole in
|
||||
// the dimmed overlay around it — the spotlight animates from target to target.
|
||||
tour := ui.NewTutorial(ui.TutorialOptions{
|
||||
Steps: []ui.TutorialStep{
|
||||
{
|
||||
Title: "Tooltips",
|
||||
Target: "#demo-tooltips",
|
||||
Content: func() *VNode { return Text("Measured, portaled, and they flip near a viewport edge.") },
|
||||
},
|
||||
{
|
||||
Title: "Popovers",
|
||||
Target: "#demo-popovers",
|
||||
Placement: ui.PlacementBottom,
|
||||
Content: func() *VNode { return Text("Click or hover. The hover bridge lets you reach the panel.") },
|
||||
},
|
||||
{
|
||||
Title: "Menus",
|
||||
Target: "#demo-menus",
|
||||
Content: func() *VNode { return Text("Items close the menu themselves; submenus are portaled.") },
|
||||
},
|
||||
{
|
||||
// No Target: the page dims flat and the card centres in the viewport.
|
||||
Title: "That's the tour",
|
||||
Content: func() *VNode { return Text("Escape ends it. Arrow keys and Enter move between steps.") },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return func() *VNode {
|
||||
return docPage("Components", "Overlays",
|
||||
"Tooltips, popovers, menus, modals and toasts — every one of them measured against the real "+
|
||||
"viewport. A floating panel is portaled to document.body, positioned from its trigger's "+
|
||||
"bounding box, and flipped or shifted when it would otherwise run off the screen.",
|
||||
|
||||
docSection("engine", "How a panel is placed",
|
||||
prose("Positioning is a pure function: given the trigger's rectangle, the panel's size and the "+
|
||||
"viewport, it returns coordinates. It is unit-tested natively, with no browser in sight, "+
|
||||
"because none of it is about the browser — the browser only supplies the three rectangles."),
|
||||
prose("The result is written to the element with SetStyle, NOT through a signal. A signal write "+
|
||||
"re-renders the whole tree, and this runs on every scroll and resize frame; going through "+
|
||||
"the vdom would rebuild the page sixty times a second to move one panel four pixels."),
|
||||
code("webui/floating.go", floatingSnippet),
|
||||
note("Controllers are built once",
|
||||
"A floating component owns refs, timers and its open state. Build it alongside your signals, "+
|
||||
"never inside the render closure — one built per frame can never stay open, because the "+
|
||||
"thing holding \"open\" is thrown away and replaced before you can see it."),
|
||||
row("mt-4 flex gap-2", tour.StartButton(0, "", Text("Take the tour"))),
|
||||
),
|
||||
|
||||
// ---- tooltips ----
|
||||
docSection("demo-tooltips", "Tooltips",
|
||||
prose("Hover, or focus — a tooltip that only answers to a mouse is a tooltip a keyboard user "+
|
||||
"cannot read. Narrow the window and hover the Right one: it flips to the left, and its "+
|
||||
"arrow follows it. Near an edge the panel shifts back on screen and the arrow slides to "+
|
||||
"keep pointing at the trigger; in the original kit the arrow detached and pointed at "+
|
||||
"nothing."),
|
||||
demo("Placement, delay, and focus triggers",
|
||||
row("flex flex-wrap items-center gap-3",
|
||||
tipTop.Render(Span(Text("Above — the default")),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Top"})),
|
||||
tipRight.Render(Span(Text("To the right, unless it would run off the edge")),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Right"})),
|
||||
tipFast.Render(Span(Text("No open delay")),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Instant"})),
|
||||
tipFocus.Render(Span(Text("Shown on focus, not hover — tab to the field")),
|
||||
ui.FormInput(ui.FormInputProps{Placeholder: "Focus me"})),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- popovers ----
|
||||
docSection("demo-popovers", "Popovers",
|
||||
prose("A popover closes on an outside click or on Escape — and only the TOPMOST one closes per "+
|
||||
"press, so a dropdown inside a popover does not take the popover down with it. The hover "+
|
||||
"variant keeps a bridge across the gap between trigger and panel, so the cursor can "+
|
||||
"actually reach the thing it opened."),
|
||||
demo("Click, alignment, and hover-with-a-bridge",
|
||||
row("flex flex-wrap items-center gap-3",
|
||||
pop.Trigger(ui.PopoverTriggerProps{},
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Click me"})),
|
||||
pop.Content(ui.PopoverContentProps{Class: "w-64"},
|
||||
P(Attr("class", "text-sm text-ink-soft"),
|
||||
Text("Click outside, or press Escape, to close. Only the topmost floating closes per press.")),
|
||||
),
|
||||
|
||||
popEnd.Trigger(ui.PopoverTriggerProps{},
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Aligned to my right edge"})),
|
||||
popEnd.Content(ui.PopoverContentProps{Class: "w-56"},
|
||||
P(Attr("class", "text-sm text-ink-soft"), Text("Placement bottom-end.")),
|
||||
),
|
||||
|
||||
hoverPop.Trigger(ui.PopoverTriggerProps{},
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Hover me, then reach the panel"})),
|
||||
hoverPop.Content(ui.PopoverContentProps{Class: "w-64"},
|
||||
P(Attr("class", "text-sm text-ink-soft"),
|
||||
Text("Move the cursor across the gap and onto this panel — it stays open. "+
|
||||
"Select this text to prove it.")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- menus ----
|
||||
docSection("demo-menus", "Menus & submenus",
|
||||
prose("Opening one menu closes the other: a single-open manager keeps the page from filling up "+
|
||||
"with panels nobody asked for. Submenus are exempt from it — they are Standalone — or a "+
|
||||
"submenu would close the very menu it belongs to as it opened."),
|
||||
prose("A submenu is portaled too, which is not a detail: the parent menu scrolls its own "+
|
||||
"contents, and a submenu rendered inside it was clipped by that overflow the moment it "+
|
||||
"was taller than its parent."),
|
||||
demo("Items, icons, a submenu, and KeepOpen",
|
||||
row("flex flex-wrap items-center gap-3",
|
||||
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
|
||||
caret := " ▾"
|
||||
if open {
|
||||
caret = " ▴"
|
||||
}
|
||||
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Actions" + caret})
|
||||
}),
|
||||
menu.Content("",
|
||||
menu.Item(ui.MenuItemProps{Icon: "check", OnClick: func() { pushToast(ui.ToastSuccess, "Profile opened") }},
|
||||
Text("Profile")),
|
||||
menu.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastInfo, "Settings opened") }},
|
||||
Text("Settings")),
|
||||
|
||||
// The submenu is portaled — it used to be clipped by the parent
|
||||
// menu's own overflow-y-auto.
|
||||
sub.Submenu(ui.SubmenuProps{Trigger: "More", Icon: "ellipsis"},
|
||||
sub.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastInfo, "Archived") }}, Text("Archive")),
|
||||
sub.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastWarning, "Duplicated") }}, Text("Duplicate")),
|
||||
),
|
||||
|
||||
ui.MenuDivider(""),
|
||||
// KeepOpen is the TSX's closeOnClick inverted: by default an item
|
||||
// closes the menu, which the first Go port dropped entirely.
|
||||
menu.Item(ui.MenuItemProps{KeepOpen: true, OnClick: func() { pushToast(ui.ToastGeneric, "Menu stayed open") }},
|
||||
Text("Stay open (KeepOpen)")),
|
||||
menu.Item(ui.MenuItemProps{Icon: "arrow-right-from-bracket",
|
||||
OnClick: func() { pushToast(ui.ToastError, "Signed out") }}, Text("Sign out")),
|
||||
),
|
||||
|
||||
hoverMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(bool) *VNode {
|
||||
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Opens on hover"})
|
||||
}),
|
||||
hoverMenu.Content("",
|
||||
hoverMenu.Item(ui.MenuItemProps{}, Text("One")),
|
||||
hoverMenu.Item(ui.MenuItemProps{}, Text("Two")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- date pickers ----
|
||||
docSection("demo-dates", "Date picker",
|
||||
prose("The field is typeable, not merely clickable. It parses loosely — 7/4/26, Jul 4 2026 and "+
|
||||
"2026-07-04 all work — and commits what it understood on blur, so the calendar is an "+
|
||||
"affordance rather than the only way in."),
|
||||
demo("Picked: \""+picked.Get()+"\"",
|
||||
row("grid gap-4 sm:grid-cols-2",
|
||||
row("flex flex-col gap-1",
|
||||
ui.FormLabel(ui.FormLabelProps{}, Text("Date (portaled, flips near the bottom)")),
|
||||
dp.Render(),
|
||||
),
|
||||
row("flex flex-col gap-1",
|
||||
ui.FormLabel(ui.FormLabelProps{}, Text("Date of birth (inline, three selects)")),
|
||||
dob.Render(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- modals ----
|
||||
docSection("demo-modals", "Modals",
|
||||
prose("Portaled to document.body, so no ancestor's overflow:hidden or transform can clip them. "+
|
||||
"Open the modal, then the nested one inside it, and press Escape twice: modals unwind one "+
|
||||
"layer per press rather than all at once."),
|
||||
prose("The last button opens a modal that no component in the tree owns — webui.OpenModal hands "+
|
||||
"content to a shared host rendered once in the layout. That is what code far from the view "+
|
||||
"needs: a confirmation raised from inside a save handler, say."),
|
||||
demo("Deleted: "+strconv.FormatBool(deleted.Get()),
|
||||
row("flex flex-wrap items-center gap-3",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Delete something…", OnClick: confirm.Open}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open wizard", OnClick: wizard.Open}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Open imperatively",
|
||||
OnClick: func() {
|
||||
// No component in the tree owns this one: OpenModal hands content
|
||||
// to the shared host rendered in the layout.
|
||||
ui.OpenModal(func() *VNode {
|
||||
return ui.ModalContent(ui.ModalContentProps{
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Opened from anywhere")),
|
||||
},
|
||||
P(Attr("class", "text-ink-soft"),
|
||||
Text("This content was not rendered by any component — it was handed to "+
|
||||
"ModalHost (see AppLayout) by webui.OpenModal.")),
|
||||
)
|
||||
}, ui.ModalOptions{Size: ui.ModalSmall})
|
||||
}}),
|
||||
),
|
||||
),
|
||||
|
||||
// The modals themselves. They portal to document.body, so where they sit in
|
||||
// the tree makes no difference to where they appear.
|
||||
modal.Render(ui.ModalProps{
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("A modal")),
|
||||
Footer: ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Close", OnClick: modal.Close}),
|
||||
},
|
||||
P(Attr("class", "text-ink-soft"),
|
||||
Text("Portaled to document.body, so no ancestor's overflow:hidden can clip it. It fades "+
|
||||
"and scales in — a double requestAnimationFrame, because a single frame does not "+
|
||||
"give the browser time to commit the initial style.")),
|
||||
row("mt-4",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open a nested modal", OnClick: nested.Open}),
|
||||
),
|
||||
),
|
||||
nested.Render(ui.ModalProps{
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Nested")),
|
||||
},
|
||||
P(Attr("class", "text-ink-soft"), Text("Escape closes THIS one first, not the one behind it.")),
|
||||
),
|
||||
confirm.Confirm(ui.ConfirmModalProps{
|
||||
Title: "Delete row",
|
||||
Message: "This cannot be undone.",
|
||||
OnConfirm: func() {
|
||||
deleted.Set(true)
|
||||
pushToast(ui.ToastError, "Row deleted")
|
||||
},
|
||||
}),
|
||||
wizard.Render(ui.WizardProps{
|
||||
Title: "Set up your account",
|
||||
FinishText: "Finish",
|
||||
OnComplete: func() {
|
||||
wizardDone.Set(true)
|
||||
pushToast(ui.ToastSuccess, "Wizard complete: "+wizardName.Get())
|
||||
},
|
||||
Steps: []ui.WizardStep{
|
||||
{
|
||||
Title: "Your name",
|
||||
// Each step gets its own context: SetCanContinue gates THIS step's
|
||||
// Next button, which a single shared bool could not express.
|
||||
Content: func(ctx ui.WizardStepContext) *VNode {
|
||||
ctx.SetCanContinue(wizardName.Get() != "")
|
||||
return row("flex flex-col gap-1",
|
||||
ui.FormLabel(ui.FormLabelProps{}, Text("Name (required to continue)")),
|
||||
ui.FormInput(ui.FormInputProps{
|
||||
Value: wizardName.Get(),
|
||||
Placeholder: "Ada Lovelace",
|
||||
OnInput: func(v string) { wizardName.Set(v) },
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Confirm",
|
||||
Content: func(ctx ui.WizardStepContext) *VNode {
|
||||
ctx.SetCanContinue(true)
|
||||
return P(Attr("class", "text-ink-soft"),
|
||||
Text("All set for "+wizardName.Get()+". Finish to close."))
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// ---- toasts ----
|
||||
docSection("demo-toasts", "Toasts",
|
||||
prose("They dismiss themselves after five seconds. Watch the bar count down: it is one CSS "+
|
||||
"transition, written straight at the element — not a re-render per frame, which is what a "+
|
||||
"progress bar driven through a signal would cost you."),
|
||||
prose("A sticky toast (Duration: ToastSticky) waits for the user instead. The menu items above "+
|
||||
"raise toasts too, which is how you can see that an item really does close its own menu."),
|
||||
demo("Push, dismiss, and a sticky one",
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "Success",
|
||||
OnClick: func() { toaster.Success("Saved.") }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Small: true, Text: "Error",
|
||||
OnClick: func() { toaster.Error("Something went wrong.") }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Small: true, Text: "Info",
|
||||
OnClick: func() { toaster.Info("Just so you know.") }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Sticky (no timer)",
|
||||
OnClick: func() {
|
||||
toaster.Push(ui.Toast{
|
||||
Message: "This one waits for you to dismiss it.",
|
||||
Type: ui.ToastWarning,
|
||||
Duration: ui.ToastSticky,
|
||||
})
|
||||
}}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear all",
|
||||
OnClick: toaster.Clear}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
docSection("overlay-api", "Reference",
|
||||
apiTable(
|
||||
apiRow{"NewFloating", "The positioning engine behind every panel: placement, offset, flip, shift, arrow."},
|
||||
apiRow{"NewTooltip / NewPopover / NewMenu", "Controllers. Build once, outside the render."},
|
||||
apiRow{"Standalone", "Exempts a panel from the single-open manager. A submenu needs it, or it closes its own parent."},
|
||||
apiRow{"vdom.Portal", "Mounts children at document.body — the escape hatch from an ancestor's overflow:hidden."},
|
||||
apiRow{"webui.OpenModal / ModalHost", "Open a modal from code that owns no component. Render the host once, in your layout."},
|
||||
),
|
||||
),
|
||||
|
||||
// The toast container and the tutorial's overlay both render here; both are
|
||||
// fixed-position, so where they sit in the tree does not matter.
|
||||
toaster.Render(),
|
||||
tour.Render(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const floatingSnippet = `// Built ONCE — it owns refs, timers, and whether it is open.
|
||||
pop := ui.NewPopover(ui.PopoverOptions{
|
||||
Placement: ui.PlacementBottomStart,
|
||||
Offset: 8,
|
||||
})
|
||||
|
||||
// ...and in the render:
|
||||
pop.Trigger(ui.PopoverTriggerProps{},
|
||||
ui.Button(ui.ButtonProps{Text: "Click me"}),
|
||||
)
|
||||
pop.Content(ui.PopoverContentProps{Class: "w-64"},
|
||||
P(Text("Outside click and Escape close me.")),
|
||||
)
|
||||
|
||||
// The panel is portaled to document.body and positioned imperatively:
|
||||
// render invisible -> AfterRender -> measure -> ComputePosition -> SetStyle -> reveal
|
||||
// Never through a signal: this runs on every scroll frame.`
|
||||
@@ -17,10 +17,6 @@ import (
|
||||
|
||||
. "kjol/vdom"
|
||||
|
||||
// The host API is dual-build — real under js/wasm, no-ops natively — so neutral page
|
||||
// code can measure the browser and still server-render. The landing page uses it for
|
||||
// exactly one thing: reading the clock when hydration commits.
|
||||
"kjol/wasmruntime"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
@@ -67,16 +63,18 @@ func notFound(path string) *VNode {
|
||||
|
||||
// wordmark is the brand lockup, shared by both layouts so they cannot drift.
|
||||
//
|
||||
// The boat is the point of the name: kjol is Norwegian for KEEL — the spine of a hull,
|
||||
// The boat is the point of the name: kjøl is Norwegian for KEEL — the spine of a hull,
|
||||
// the thing every other part is built onto. Which is what this library is meant to be
|
||||
// for the applications that share it.
|
||||
func wordmark(d Deps, href string) *VNode {
|
||||
// The lockup names the LAYER you are standing in, not the site. On the front page
|
||||
// that is kjol itself; inside /wasm it is Kjol Wasm Web. A wordmark that says the
|
||||
// same thing everywhere is one more thing the reader has to keep track of himself.
|
||||
name, sub := "kjol", "a shared base layer"
|
||||
// that is Kjøl itself; inside /wasm it is Kjøl Wasm Web; inside /c it is Kjøl C —
|
||||
// Wordmark, not Name, because up here "C" alone names a language rather than the thing
|
||||
// you are reading. A wordmark that says the same thing everywhere is one more thing the
|
||||
// reader has to keep track of himself.
|
||||
name, sub := "Kjøl", "a shared base layer"
|
||||
if l := CurrentLayer(d.Path()); l != nil {
|
||||
name, sub = l.Name, "Go + WebAssembly"
|
||||
name, sub = l.Wordmark(), l.Sub
|
||||
}
|
||||
|
||||
return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
|
||||
@@ -103,22 +101,39 @@ func PublicLayout(d Deps, content *VNode) *VNode {
|
||||
// read aloud.
|
||||
Div(Attr("class", "pointer-events-none fixed inset-0 -z-10 bg-grid grid-fade"), Attr("aria-hidden", "true")),
|
||||
|
||||
// The nav, the content and the footer are ONE column, and the way to get that is for
|
||||
// all three to be built the same way: gutter on the outside, measure on the inside.
|
||||
//
|
||||
// <div class="px-4"> <div class="mx-auto max-w-3xl"> …
|
||||
//
|
||||
// This used to be `mx-auto max-w-3xl px-4` on the nav's inner div — measure and gutter
|
||||
// on the SAME element. On a wide screen the gutter has nothing to do (the centring has
|
||||
// already pushed the box in much further), so all it did was inset the nav's contents
|
||||
// by another 16px: the wordmark sat a finger's width to the right of the headline
|
||||
// underneath it. Close enough to look like a mistake, far enough to see.
|
||||
//
|
||||
// The footer was worse — it was max-w-2xl, a different measure entirely.
|
||||
Nav(Attr("class", "site-nav border-b border-line"),
|
||||
Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 px-4 py-4"),
|
||||
wordmark(d, "/"),
|
||||
Div(Attr("class", "ml-auto flex items-center gap-1"),
|
||||
layersMenu(d),
|
||||
Ul(Attr("class", "flex items-center gap-1"),
|
||||
navItem(d, "/about", "About", false),
|
||||
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
|
||||
),
|
||||
))),
|
||||
Div(Attr("class", "px-4"),
|
||||
Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 py-4"),
|
||||
wordmark(d, "/"),
|
||||
Div(Attr("class", "ml-auto flex items-center gap-1"),
|
||||
layersMenu(d),
|
||||
compositionsMenu(d),
|
||||
Ul(Attr("class", "flex items-center gap-1"),
|
||||
navItem(d, "/about", "About", false),
|
||||
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
|
||||
),
|
||||
)))),
|
||||
|
||||
Main(Attr("class", "px-4 py-14"), content),
|
||||
|
||||
Footer(Attr("class", "mx-auto max-w-2xl px-4 pb-14"),
|
||||
P(Attr("class", "text-sm text-ink-faint"),
|
||||
Text("kjol is a shared base layer, factored out of several applications so they stay in sync. It is Norwegian for keel.")),
|
||||
Footer(Attr("class", "px-4 pb-14"),
|
||||
Div(Attr("class", "mx-auto max-w-3xl"),
|
||||
P(Attr("class", "text-sm text-ink-faint"),
|
||||
Text("Kjøl is a shared base layer, factored out of several applications so they stay in "+
|
||||
"sync. It is Norwegian for keel.")),
|
||||
),
|
||||
),
|
||||
ui.ModalHost(),
|
||||
)
|
||||
@@ -127,7 +142,7 @@ func PublicLayout(d Deps, content *VNode) *VNode {
|
||||
// wideRoutes get a roomier container. A table with a dozen columns, a drag handle
|
||||
// and three calculated columns has no business being squeezed into a reading-width
|
||||
// column; prose pages still are.
|
||||
var wideRoutes = map[string]bool{"/wasm/table": true}
|
||||
var wideRoutes = map[string]bool{"/wasm/components": true}
|
||||
|
||||
// AppLayout is the DOCUMENTATION shell: a sidebar of sections on the left, the page on
|
||||
// the right. The app routes are the framework's docs — each one explains a capability,
|
||||
@@ -155,6 +170,7 @@ func AppLayout(d Deps, content *VNode) *VNode {
|
||||
Span(Attr("class", "rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint"), Text("Docs")),
|
||||
Div(Attr("class", "ml-auto flex items-center gap-2"),
|
||||
layersMenu(d),
|
||||
compositionsMenu(d),
|
||||
Ul(Attr("class", "flex items-center gap-2"),
|
||||
navItem(d, "/", "Home", false),
|
||||
Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
|
||||
@@ -179,9 +195,22 @@ func AppLayout(d Deps, content *VNode) *VNode {
|
||||
// docsSidebar is the section list. Sticky, so it stays put while a long page scrolls —
|
||||
// on a documentation site the nav is how you know where you are, and a nav that scrolls
|
||||
// away leaves you nowhere.
|
||||
// sidebarNav is the sidebar's contents, which depend on WHICH LAYER you are reading.
|
||||
//
|
||||
// AppLayout is shared by every documentation page in this binary, and those pages are no
|
||||
// longer all about the same thing: /wasm/* documents the Go→WebAssembly engine, /c
|
||||
// documents the C base layer. A sidebar listing the engine's chapters while you are
|
||||
// reading about arenas would be worse than no sidebar at all.
|
||||
func sidebarNav(path string) []docsGroup {
|
||||
if path == "/c" || strings.HasPrefix(path, "/c/") {
|
||||
return cNav()
|
||||
}
|
||||
return docsNav()
|
||||
}
|
||||
|
||||
func docsSidebar(d Deps) *VNode {
|
||||
mods := []Mod{Attr("class", "sticky top-[3.75rem] hidden h-[calc(100vh-3.75rem)] w-56 shrink-0 overflow-y-auto py-10 lg:block")}
|
||||
for _, g := range docsNav() {
|
||||
for _, g := range sidebarNav(d.Path()) {
|
||||
items := []Mod{Attr("class", "mt-2 space-y-0.5")}
|
||||
for _, it := range g.Items {
|
||||
items = append(items, Li(sidebarLink(d, it)))
|
||||
@@ -197,13 +226,30 @@ func docsSidebar(d Deps) *VNode {
|
||||
}
|
||||
|
||||
func sidebarLink(d Deps, it docsItem) *VNode {
|
||||
base, frag, isAnchor := strings.Cut(it.Path, "#")
|
||||
|
||||
cls := "flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline text-ink-soft hover:bg-surface-raised hover:text-ink"
|
||||
iconCls := "text-ink-faint"
|
||||
if d.Path() == it.Path {
|
||||
|
||||
// A section link is NEVER "active", and that is deliberate. It cannot be: it would
|
||||
// have to know which section you had scrolled to, which means measuring all fifteen of
|
||||
// them on every scroll frame, and the only way to act on the answer is a signal write
|
||||
// — which re-renders this entire page. Sixty times a second, to move a highlight.
|
||||
//
|
||||
// (Marking them active by PAGE instead lights up all fifteen at once, which is worse
|
||||
// than no highlight: it tells you nothing and looks broken.)
|
||||
active := !isAnchor && d.Path() == it.Path
|
||||
if active {
|
||||
cls = "active flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline bg-primary-subtle font-medium text-accent"
|
||||
iconCls = "text-accent"
|
||||
}
|
||||
return A(Attr("class", cls), Attr("href", it.Path), navigate(d, it.Path),
|
||||
|
||||
click := navigate(d, it.Path)
|
||||
if isAnchor {
|
||||
click = navigateAnchor(d, base, frag)
|
||||
}
|
||||
|
||||
return A(Attr("class", cls), Attr("href", it.Path), click,
|
||||
ui.IconInline(it.Icon, 14, iconCls),
|
||||
Text(it.Label),
|
||||
)
|
||||
@@ -251,122 +297,56 @@ func Counter(label string, count *Signal[int]) *VNode {
|
||||
|
||||
// ---- landing ------------------------------------------------------------
|
||||
|
||||
// The landing page is one narrow column of plain text, a demo, and a list.
|
||||
// The landing page is a column of plain text and two lists.
|
||||
//
|
||||
// It used to be a framework marketing page: an oversized headline, a hero glow, feature
|
||||
// cards in a grid, numbered chapters, a call to action repeated at both ends. All of it
|
||||
// was arguing. None of it was showing. A library this small does not need to argue — it
|
||||
// needs to say what it is, show that it works, and get out of the way, and a reader who
|
||||
// wants to be convinced can click into the docs and find every page running the code it
|
||||
// documents.
|
||||
// It used to carry the Wasm Web engine's own highlights: the two-runtime demo, a list of
|
||||
// SSR/hydration/server-component features, the build transcript. All of it was true, and
|
||||
// none of it belonged HERE — the front page is kjøl's, and kjøl is not the Go/WebAssembly
|
||||
// engine any more than it is the C arena allocator. A reader landing on it should learn
|
||||
// what the thing IS, not be pitched one of its five parts.
|
||||
//
|
||||
// What survives is the part that could not be faked: the same Go function rendered twice
|
||||
// at once, as live DOM and as the HTML string the server sends.
|
||||
// So the demo moved to /wasm, where it is the first thing that section shows, and the
|
||||
// front page says what is actually true of the whole: here are the languages, here are the
|
||||
// frameworks assembled out of them, go and read one.
|
||||
//
|
||||
//gowasm:page / static layout=public
|
||||
func HomePage(d Deps) func() *VNode {
|
||||
clicks := NewSignal(0)
|
||||
|
||||
// The one measurement on the page: performance.now() when the client's first render
|
||||
// commits. Zero until then — which is what the SERVER renders, and what the client
|
||||
// renders on its first pass, so the two agree and hydration stays clean.
|
||||
hydratedAt := NewSignal(0.0)
|
||||
wasmruntime.AfterRender(func() {
|
||||
if hydratedAt.Get() == 0 {
|
||||
hydratedAt.Set(wasmruntime.Now())
|
||||
}
|
||||
})
|
||||
|
||||
// demoTree is called TWICE per render below — once for the DOM, once for the HTML.
|
||||
// That is the point: the two panes cannot drift, because there is only one of them.
|
||||
demoTree := func() *VNode {
|
||||
return Div(Attr("class", "flex items-center gap-3"),
|
||||
ui.Button(ui.ButtonProps{
|
||||
Color: ui.ButtonPrimary, Text: "Click me",
|
||||
OnClick: func() { clicks.Set(clicks.Get() + 1) },
|
||||
}),
|
||||
Span(Attr("class", "text-ink-soft"), Text("clicked "+itoa(clicks.Get())+" times")),
|
||||
)
|
||||
}
|
||||
|
||||
return func() *VNode {
|
||||
markup := RenderHTML(demoTree())
|
||||
|
||||
return Div(Attr("class", "mx-auto max-w-3xl"),
|
||||
H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
|
||||
Text("kjol")),
|
||||
Text("Kjøl")),
|
||||
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
||||
Text("A shared base layer, factored out of several applications so they stay in sync. "+
|
||||
"Kjol is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
|
||||
"Kjøl is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
|
||||
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
||||
Text("It is not one library. It is a stack of them, in several languages, and each one is "+
|
||||
"documented here.")),
|
||||
Text("It is not one library. It is a set of them, in several languages, and a couple of "+
|
||||
"frameworks assembled out of those. Each one is documented here, and every page of that "+
|
||||
"documentation runs the code it documents.")),
|
||||
|
||||
// ---- the layers ----
|
||||
//
|
||||
// The layers are the site. Everything else on this page is evidence that they
|
||||
// work; this is the part you are meant to click.
|
||||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("The layers")),
|
||||
layersGrid(d),
|
||||
|
||||
// ---- the demonstration ----
|
||||
//
|
||||
// This survives from the old landing page because it is the one thing on the site
|
||||
// that cannot be faked: the same Go function, rendered twice at once, as live DOM
|
||||
// and as the HTML string the server sent.
|
||||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("One function, two runtimes")),
|
||||
// ---- layers: the languages ----
|
||||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Layers")),
|
||||
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
|
||||
Text("Below is a single Go function, shown twice. On the left it has been reconciled into "+
|
||||
"the DOM and you can use it. On the right is the HTML the same function produces when "+
|
||||
"the server renders it — the markup that reached your browser before any WebAssembly "+
|
||||
"had loaded. Click the button; both move.")),
|
||||
Text("What Kjøl is written in, and what it gives you in each. A layer is a directory of "+
|
||||
"code you can use on its own — the Go base does not know the C one exists.")),
|
||||
layerGrid(Languages()),
|
||||
|
||||
Div(Attr("class", "mt-5 border border-line sm:grid sm:grid-cols-2"),
|
||||
Div(Attr("class", "border-b border-line sm:border-b-0 sm:border-r"),
|
||||
paneLabel("in your browser"),
|
||||
Div(Attr("class", "px-4 py-8"), demoTree()),
|
||||
),
|
||||
Div(
|
||||
paneLabel(itoa(len(markup))+" bytes of HTML"),
|
||||
Pre(Attr("class", "whitespace-pre-wrap px-4 py-4 font-mono text-[12px] leading-relaxed text-ink-muted"),
|
||||
El("code", Text(prettyHTML(markup))),
|
||||
),
|
||||
),
|
||||
),
|
||||
P(Attr("class", "mt-3 text-sm leading-relaxed text-ink-muted"),
|
||||
Text("The right pane is not a picture of the source. It is vdom.RenderHTML, called on the "+
|
||||
"very tree the left pane is showing, recomputed on every click.")),
|
||||
|
||||
// ---- what is in it ----
|
||||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("What is in it")),
|
||||
Ul(Attr("class", "mt-3 space-y-1.5 leading-relaxed text-ink-soft"),
|
||||
item("Server-side rendering and client hydration, from one codebase."),
|
||||
item("Server components: mark a function and its code and state stay on the server."),
|
||||
item("A component kit — forms, tabs, modals, tooltips, toasts — with dark mode."),
|
||||
item("A table with filtering, sorting, column management, formulas, and CSV and PDF export."),
|
||||
item("Tailwind, compiled by a Go program that reads your Go."),
|
||||
),
|
||||
|
||||
// ---- building ----
|
||||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Building it")),
|
||||
// ---- compositions: the frameworks ----
|
||||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Compositions")),
|
||||
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
|
||||
Text("Two commands. The first produced the page you are reading; the second serves it and "+
|
||||
"rebuilds on save.")),
|
||||
codeLang("terminal", "sh", buildTranscript),
|
||||
Text("What the layers become when they are assembled into something that does a job. A "+
|
||||
"composition is not another language: Kjøl Wasm Web is Go all the way down, and Kjøl JS "+
|
||||
"Web is TypeScript compiled by a Go toolchain. These are the two you can click into.")),
|
||||
layerGrid(Compositions()),
|
||||
|
||||
// ---- close ----
|
||||
P(Attr("class", "mt-12 border-t border-line pt-6 leading-relaxed text-ink-soft"),
|
||||
Text("Every page of the documentation runs the code it documents — there are no screenshots "+
|
||||
"of components anywhere on this site. "),
|
||||
Text("There is not a screenshot of a component anywhere on this site. Every example is the "+
|
||||
"real thing, running — which is the only way a documentation page can tell you when it "+
|
||||
"has gone stale. "),
|
||||
A(Attr("class", "text-accent underline underline-offset-4"),
|
||||
Attr("href", "/wasm"), navigate(d, "/wasm"), Text("Read the docs")),
|
||||
Text(", or "),
|
||||
A(Attr("class", "text-accent underline underline-offset-4"),
|
||||
Attr("href", "/wasm/kit"), navigate(d, "/wasm/kit"), Text("look at the components")),
|
||||
Attr("href", "/about"), navigate(d, "/about"), Text("Why this exists")),
|
||||
Text("."),
|
||||
),
|
||||
P(Attr("class", "mt-4 text-sm text-ink-muted"),
|
||||
Text(hydrationNote(hydratedAt.Get()))),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -404,10 +384,14 @@ func prettyHTML(s string) string {
|
||||
return strings.ReplaceAll(s, "><", ">\n<")
|
||||
}
|
||||
|
||||
const buildTranscript = `$ go run ./build
|
||||
// The real transcript. It is on the front page, so it is the first thing anybody copies —
|
||||
// which makes it the first thing to notice when it goes stale.
|
||||
const buildTranscript = `$ go run ./server -build
|
||||
==> generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)
|
||||
==> compiling Tailwind CSS -> wwwroot/app.css
|
||||
==> compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)
|
||||
==> bundling the Solid app -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)
|
||||
==> copying Go's wasm_exec.js shim into wwwroot/
|
||||
|
||||
$ go run ./server
|
||||
serving "./wwwroot" on http://localhost:8085`
|
||||
@@ -422,16 +406,22 @@ func AboutPage(d Deps) func() *VNode {
|
||||
H1(Attr("class", "mt-2 text-4xl font-semibold tracking-tight text-text-heading"), Text("Why this exists")),
|
||||
|
||||
P(Attr("class", "mt-6 text-lg leading-relaxed text-ink-soft"),
|
||||
Text("Kjol Web is one part of kjol — a shared base layer factored out of several applications "+
|
||||
"so they stay in sync. (Kjol is Norwegian for KEEL: the spine of a hull, the thing every "+
|
||||
"other part is built onto.) The applications had drifted: the same table, the same forms, "+
|
||||
"the same charts, each subtly different in each app, each fixed twice.")),
|
||||
Text("Kjøl is a shared base layer, factored out of several applications so they stay in sync. "+
|
||||
"(Kjøl is Norwegian for KEEL: the spine of a hull, the thing every other part is built "+
|
||||
"onto.) The applications had drifted: the same table, the same forms, the same charts, "+
|
||||
"each subtly different in each app, each fixed twice.")),
|
||||
|
||||
P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
|
||||
Text("The UI kit began as Solid.js components. Kjol Web is the same kit, written in Go and "+
|
||||
"compiled to WebAssembly — the same components, the same Tailwind, no JavaScript build. "+
|
||||
"That means one language across the server and the browser, and a table you can share "+
|
||||
"between a web app and a native one because it is a Go function, not a JSX file.")),
|
||||
Text("The UI kit began as Solid.js components, and it still is — that is Kjøl JS Web, and it "+
|
||||
"is what those applications run today. Kjøl Wasm Web is the same kit written a second time "+
|
||||
"in Go and compiled to WebAssembly: the same components, the same Tailwind, no JavaScript "+
|
||||
"build at all. One language across the server and the browser, and a table you could share "+
|
||||
"with a native app, because it is a Go function rather than a JSX file.")),
|
||||
|
||||
P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
|
||||
Text("Neither of them is Kjøl. They are two compositions of it — two uses of the layers "+
|
||||
"underneath, which are just directories of Go, TypeScript, C and Jai. The front page lists "+
|
||||
"both, and does not argue for either.")),
|
||||
|
||||
H2(Attr("class", "mt-12 text-2xl font-semibold tracking-tight text-text-heading"), Text("The rules it keeps")),
|
||||
Div(Attr("class", "mt-6 space-y-4"),
|
||||
|
||||
@@ -6,15 +6,14 @@ import "kjol/vdom"
|
||||
// Routes maps each //gowasm:page path to its instantiated render function.
|
||||
func Routes(d Deps) map[string]func() *vdom.VNode {
|
||||
return map[string]func() *vdom.VNode{
|
||||
"/": HomePage(d),
|
||||
"/about": AboutPage(d),
|
||||
"/wasm": DocsPage(d),
|
||||
"/wasm/chart": ChartPage(d),
|
||||
"/wasm/data": DataPage(d),
|
||||
"/wasm/kit": KitPage(d),
|
||||
"/wasm/overlays": OverlaysPage(d),
|
||||
"/wasm/server": ServerPage(d),
|
||||
"/wasm/table": TablePage(d),
|
||||
"/": HomePage(d),
|
||||
"/about": AboutPage(d),
|
||||
"/c": CPage(d),
|
||||
"/wasm": DocsPage(d),
|
||||
"/wasm/chart": ChartPage(d),
|
||||
"/wasm/components": ComponentsPage(d),
|
||||
"/wasm/data": DataPage(d),
|
||||
"/wasm/server": ServerPage(d),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,23 +21,22 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
|
||||
var StaticPaths = map[string]bool{
|
||||
"/": true,
|
||||
"/about": true,
|
||||
"/c": true,
|
||||
"/wasm": true,
|
||||
"/wasm/chart": true,
|
||||
"/wasm/data": true,
|
||||
"/wasm/table": true,
|
||||
}
|
||||
|
||||
// RouteLayout maps each route to the name of the layout that wraps it.
|
||||
var RouteLayout = map[string]string{
|
||||
"/": "public",
|
||||
"/about": "public",
|
||||
"/wasm": "app",
|
||||
"/wasm/chart": "app",
|
||||
"/wasm/data": "app",
|
||||
"/wasm/kit": "app",
|
||||
"/wasm/overlays": "app",
|
||||
"/wasm/server": "app",
|
||||
"/wasm/table": "app",
|
||||
"/": "public",
|
||||
"/about": "public",
|
||||
"/c": "app",
|
||||
"/wasm": "app",
|
||||
"/wasm/chart": "app",
|
||||
"/wasm/components": "app",
|
||||
"/wasm/data": "app",
|
||||
"/wasm/server": "app",
|
||||
}
|
||||
|
||||
// LayoutFor wraps a page's content in the layout declared for its route.
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func TestSSRPages(t *testing.T) {
|
||||
for _, path := range []string{"/", "/about", "/wasm/chart", "/wasm/data", "/wasm/table", "/wasm/overlays", "/wasm/kit"} {
|
||||
for _, path := range []string{"/", "/about", "/wasm/chart", "/wasm/data", "/wasm/components"} {
|
||||
deps := Deps{Path: func() string { return path }}
|
||||
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
|
||||
t.Logf("%-8s %6d bytes portals=%d", path, len(html), strings.Count(html, "data-portal"))
|
||||
@@ -22,7 +22,7 @@ func TestSSRPages(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSSRTablePage(t *testing.T) {
|
||||
deps := Deps{Path: func() string { return "/wasm/table" }}
|
||||
deps := Deps{Path: func() string { return "/wasm/components" }}
|
||||
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
|
||||
|
||||
// The table persists a personal layout in localStorage, which the SERVER CANNOT
|
||||
@@ -33,13 +33,21 @@ func TestSSRTablePage(t *testing.T) {
|
||||
// This is a real cost — the page ships no table content — and it is the price of
|
||||
// never showing the wrong table. See webui.RestoreLayout.
|
||||
if !strings.Contains(html, `aria-busy="true"`) {
|
||||
t.Error("SSR /table should render the loading skeleton, not a table")
|
||||
t.Error("SSR should render the AutoTable's loading skeleton, not a table")
|
||||
}
|
||||
if !strings.Contains(html, "animate-pulse") {
|
||||
t.Error("the skeleton bars are missing")
|
||||
}
|
||||
if strings.Contains(html, "Ada Lovelace") {
|
||||
t.Error("SSR rendered table CONTENT — a user with a saved layout would watch it rearrange")
|
||||
|
||||
// A salary, which ONLY the AutoTable renders.
|
||||
//
|
||||
// This used to look for "Ada Lovelace", which was a fine proxy back when the table
|
||||
// had a page to itself. It is not one any more: the components page also demos
|
||||
// PrettyTable, and PrettyTable's rows are Ada, Alan and Grace — so the old assertion
|
||||
// failed on a page that was behaving perfectly. A test that names a value only the
|
||||
// component under test can produce cannot be fooled by its neighbours.
|
||||
if strings.Contains(html, "$1,610.25") {
|
||||
t.Error("SSR rendered AutoTable CONTENT — a user with a saved layout would watch it rearrange")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,159 +174,6 @@ func newEmployeeTable(highlight *Signal[string]) *ui.AutoTableState {
|
||||
})
|
||||
}
|
||||
|
||||
//gowasm:page /wasm/table layout=app static
|
||||
func TablePage(d Deps) func() *VNode {
|
||||
// Which row to spotlight, if any.
|
||||
highlight := NewSignal("")
|
||||
|
||||
table := newEmployeeTable(highlight)
|
||||
table.SetRows(employees())
|
||||
|
||||
// The export menu, with a submenu for the PDF's page orientation. Both are
|
||||
// controllers, both built once. A submenu is Standalone — opening it must not
|
||||
// close the menu it lives in.
|
||||
exportMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
|
||||
pdfSub := ui.NewSubmenu(exportMenu)
|
||||
|
||||
// What the PDF prints above the table.
|
||||
//
|
||||
// Note what is NOT here: the footer lines. The export takes the table's OWN
|
||||
// summary rows — including any the user builds at runtime in the Calculated
|
||||
// editor — and evaluates them against the same filtered rows it is printing. Only
|
||||
// pass Summaries explicitly to print something that is not one of the table's own
|
||||
// rows.
|
||||
pdfHeader := func(landscape bool) ui.AutoTablePDFHeader {
|
||||
orientation := ui.PDF_ORIENTATION_PORTRAIT
|
||||
if landscape {
|
||||
orientation = ui.PDF_ORIENTATION_LANDSCAPE
|
||||
}
|
||||
return ui.AutoTablePDFHeader{
|
||||
Title: "Employees",
|
||||
Subtitle: "Exported from the Kjol Web example",
|
||||
ShowDate: true,
|
||||
Orientation: orientation,
|
||||
}
|
||||
}
|
||||
|
||||
return func() *VNode {
|
||||
return docPage("Components", "AutoTable",
|
||||
"A table that filters, sorts, pages, reorders, resizes, computes and exports — configured with "+
|
||||
"a column list and a slice of rows. Everything a user changes about it is theirs and persists; "+
|
||||
"everything it exports is what they filtered, not what happened to be on screen.",
|
||||
|
||||
docSection("defining", "Defining one",
|
||||
prose("A column says how to read a field, how to sort it, and how to render it. The state object "+
|
||||
"is a CONTROLLER: build it once, alongside your signals — never inside the render, which "+
|
||||
"would hand it fresh refs and a fresh idea of which page it was on every frame."),
|
||||
code("app/table.go", tableSnippet),
|
||||
note("The server renders a skeleton, on purpose",
|
||||
"The layout — column order, widths, what is hidden, the calculated columns — lives in the "+
|
||||
"browser's localStorage, which the server cannot read. So the server ships a skeleton "+
|
||||
"rather than the DEFAULT table: a user who had reordered their columns would otherwise "+
|
||||
"watch them rearrange themselves the moment the WebAssembly booted."),
|
||||
),
|
||||
|
||||
docSection("try-it", "Try it",
|
||||
prose("Search matches name or email. Sort by Salary and it parses the currency, so $980 sorts "+
|
||||
"below $1,200.50. Unhide Rank and sort that: \"Item 2\" comes before \"Item 10\", because "+
|
||||
"numbers inside text are compared as numbers. Drag a header to reorder it, drag its right "+
|
||||
"edge to resize — reload the page and both are still where you left them."),
|
||||
prose("Filter it, then export. You get every matching row across every page, in the column order "+
|
||||
"you dragged them into, with the calculated columns computed per row."),
|
||||
),
|
||||
|
||||
table.Render(
|
||||
ui.AutoTableWithHover(),
|
||||
ui.AutoTableWithAlternate(),
|
||||
ui.AutoTableWithSurroundingBorder(),
|
||||
ui.AutoTableWithPaginationShowAll(),
|
||||
ui.AutoTableWithSearchFields(
|
||||
// One box, several fields: a global search.
|
||||
table.GlobalSearch("Search name or email…", "Name", "Email"),
|
||||
// Exact-match dropdown.
|
||||
table.SelectSearch("Status", []string{"active", "inactive"}, "Any status"),
|
||||
// IN-set: matches any of the selected teams.
|
||||
table.MultiSelectSearch("Team", "Any team", []string{"Engineering", "Research", "Networking"}),
|
||||
),
|
||||
ui.AutoTableWithToolbarActions(
|
||||
table.ColumnPicker(),
|
||||
|
||||
// Build calculated columns and footer rows at runtime. Basic picks a
|
||||
// function and the columns it combines across each row; Advanced writes
|
||||
// a formula, with insert menus for columns, functions and constants.
|
||||
// The formula is compiled and previewed against the real first row as
|
||||
// you type, so a typo shows up immediately rather than as a column of
|
||||
// dashes. What you build is persisted with the rest of the layout.
|
||||
table.CalculatedColumnEditor(),
|
||||
|
||||
// Export writes what the FILTER selected — every matching row across
|
||||
// every page — not the five rows on screen. And it writes the columns
|
||||
// you can actually see, in the order you dragged them into.
|
||||
exportMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
|
||||
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
|
||||
Icon: "download", Text: "Export"})
|
||||
}),
|
||||
exportMenu.Content("",
|
||||
exportMenu.Item(ui.MenuItemProps{Icon: "file-csv",
|
||||
OnClick: func() { table.DownloadCSV("employees") }}, Text("Download CSV")),
|
||||
|
||||
// A submenu — portaled, so it is not clipped by the menu's own
|
||||
// overflow-y-auto, which is what broke it before.
|
||||
pdfSub.Submenu(ui.SubmenuProps{Trigger: "Download PDF", Icon: "file-pdf"},
|
||||
pdfSub.Item(ui.MenuItemProps{
|
||||
OnClick: func() { table.DownloadPDF("employees", pdfHeader(false)) }}, Text("Portrait")),
|
||||
pdfSub.Item(ui.MenuItemProps{
|
||||
OnClick: func() { table.DownloadPDF("employees", pdfHeader(true)) }}, Text("Landscape")),
|
||||
),
|
||||
|
||||
ui.MenuDivider(""),
|
||||
exportMenu.Item(ui.MenuItemProps{Icon: "print",
|
||||
OnClick: func() { table.PrintPDF(pdfHeader(true)) }}, Text("Print")),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Highlight + auto-page-jump: Radia is on page 3 by default, and the table
|
||||
// pages itself to wherever she actually is once filters and sorting move her.
|
||||
row("mt-4 flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
|
||||
Text: "Find Radia Perlman",
|
||||
OnClick: func() { highlight.Set("radia@example.com") }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
|
||||
Text: "Clear highlight",
|
||||
OnClick: func() { highlight.Set("") }}),
|
||||
),
|
||||
|
||||
docSection("calculated", "Calculated columns",
|
||||
prose("The toolbar's calculator builds new columns at runtime, in two modes. Basic picks a "+
|
||||
"function and the columns it combines ACROSS each row — sum of Salary and Bonus, per "+
|
||||
"person. Advanced writes a formula, with insert menus for columns, functions and constants: "+
|
||||
"([Salary] + [Bonus]) * 12."),
|
||||
prose("A summary row is the other axis: it aggregates ONE column DOWN the filtered rows and "+
|
||||
"prints the result in the footer. Confusing the two is the classic bug here — a column that "+
|
||||
"aggregates down shows every row the same number, and it looks plausible enough to ship."),
|
||||
codeLang("formulas", "syntax", formulaSnippet),
|
||||
note("Compiled as you type",
|
||||
"The formula is parsed and evaluated against the real first row while you write it, so a "+
|
||||
"typo shows up as an error under the box — not as a column of dashes discovered later."),
|
||||
),
|
||||
|
||||
docSection("export", "Export",
|
||||
prose("CSV and PDF are written in Go, standard library only — the PDF writer builds its own "+
|
||||
"xref table and embeds Helvetica metrics. Export takes the FILTERED rows, the VISIBLE "+
|
||||
"columns, in the user's order, including whatever they calculated."),
|
||||
apiTable(
|
||||
apiRow{"NewAutoTableState", "Build the controller: the columns, and where to persist the layout."},
|
||||
apiRow{".SetRows", "Hand it the data. It filters, sorts and pages from there."},
|
||||
apiRow{".RestoreLayout", "Read the saved layout and reveal the table over its skeleton. Call it once, on the client."},
|
||||
apiRow{".FilteredRows / .ExportColumns", "What the user selected, and what they can see — the inputs to any export."},
|
||||
apiRow{"ExportCSV / ExportPDF", "Write the bytes. DownloadCSV / DownloadPDF / PrintPDF do it and hand them to the browser."},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const tableSnippet = `// A controller: built ONCE, next to your signals — never inside the render.
|
||||
table := ui.NewAutoTableState([]ui.AutoTableColumn{
|
||||
{DisplayName: "Name", SortIdentifier: "Name", Sortable: true,
|
||||
|
||||
@@ -7,15 +7,18 @@ import (
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// The landing page's whole claim is that its two panes are ONE function: the live
|
||||
// component on the left, and the HTML string the server sends on the right. If they
|
||||
// could drift, the page would be a lie told in the most embarrassing possible place.
|
||||
// The two-runtime demo.s whole claim is that its two panes are ONE function: the live
|
||||
// component on the left, and the HTML string the server sends on the right. If they could
|
||||
// drift, the page would be a lie told in the most embarrassing possible place.
|
||||
//
|
||||
// It lives on /wasm now, not on the front page — it is the Wasm Web engine.s argument,
|
||||
// and the front page is kjøl.s. The test followed it.
|
||||
//
|
||||
// So: render it, click the button the way the browser would, render again, and check
|
||||
// that BOTH panes moved. A pane rendered from a stale copy of the tree — or from a
|
||||
// second, hand-written one — fails here.
|
||||
func TestLandingPanesShareOneTree(t *testing.T) {
|
||||
page := HomePage(Deps{Path: func() string { return "/" }})
|
||||
func TestTwoRuntimePanesShareOneTree(t *testing.T) {
|
||||
page := DocsPage(Deps{Path: func() string { return "/wasm" }})
|
||||
|
||||
html := vdom.RenderHTML(page())
|
||||
if !strings.Contains(html, "clicked 0 times") {
|
||||
@@ -38,8 +41,8 @@ func TestLandingPanesShareOneTree(t *testing.T) {
|
||||
|
||||
// The byte count under the right-hand pane is the length of the string actually shown,
|
||||
// not a number typed in by hand — so it has to move when the markup does.
|
||||
func TestLandingByteCountIsReal(t *testing.T) {
|
||||
page := HomePage(Deps{Path: func() string { return "/" }})
|
||||
func TestTwoRuntimeByteCountIsReal(t *testing.T) {
|
||||
page := DocsPage(Deps{Path: func() string { return "/wasm" }})
|
||||
|
||||
before := byteCountLabel(t, vdom.RenderHTML(page()))
|
||||
clickButton(t, page(), "Click me")
|
||||
@@ -60,7 +63,7 @@ func byteCountLabel(t *testing.T, html string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(html, " bytes of HTML")
|
||||
if i < 0 {
|
||||
t.Fatal("no byte-count caption on the landing page")
|
||||
t.Fatal("no byte-count caption on the /wasm overview")
|
||||
}
|
||||
start := strings.LastIndexByte(html[:i], '>') + 1
|
||||
return html[start : i+len(" bytes of HTML")]
|
||||
177
go/cmd/kjol-web/build/build.go
Normal file
177
go/cmd/kjol-web/build/build.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Package build is the build pipeline: directive codegen, Tailwind, the wasm binary,
|
||||
// the Solid bundle, and Go's JS shim.
|
||||
//
|
||||
// It used to be two packages — a `buildsteps` library and a `build` command that did
|
||||
// nothing but loop over it and print. There was nothing for that split to be: the
|
||||
// library had exactly two importers, both in this directory tree, and a package with no
|
||||
// outside consumers is an import statement pretending to be a boundary. It is one
|
||||
// package now.
|
||||
//
|
||||
// It cannot be `package main`, because the dev server imports it and Go will not let you
|
||||
// import a main. So the cold build is a flag on the server rather than a second binary:
|
||||
//
|
||||
// go run ./server -build # build once and exit
|
||||
// go run ./server # build, then watch and serve
|
||||
//
|
||||
// It is Go rather than a shell script for three reasons. The dev server has to call
|
||||
// these steps on every save and cannot shell out to bash on Windows. Editors need to run
|
||||
// them as tasks, and a task that only works on one platform is a task half the team
|
||||
// cannot use. And the cold build and the watch build must be the SAME steps — the moment
|
||||
// they are two scripts they drift, and the bug only shows up in whichever one you use
|
||||
// less.
|
||||
package build
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kjol/jsbundler"
|
||||
)
|
||||
|
||||
// kjolRoot is the kjol Go module root, relative to the app. The Tailwind and codegen
|
||||
// commands are run FROM there so the engine's dependencies resolve in kjol's own go.mod,
|
||||
// and this app's stays lean.
|
||||
const kjolRoot = "../.."
|
||||
|
||||
// Wwwroot is where every build artefact lands, and what the server serves.
|
||||
const Wwwroot = "wwwroot"
|
||||
|
||||
// Step is one named stage. Naming them lets the cold build narrate itself without the
|
||||
// watch loop having to care what they are called.
|
||||
type Step struct {
|
||||
Name string
|
||||
Run func() ([]byte, error)
|
||||
}
|
||||
|
||||
func Steps() []Step {
|
||||
return []Step{
|
||||
{"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", Codegen},
|
||||
{"compiling Tailwind CSS -> wwwroot/app.css", Tailwind},
|
||||
{"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", Wasm},
|
||||
{"bundling the Solid app -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)", JS},
|
||||
{"copying Go's wasm_exec.js shim into wwwroot/", Shim},
|
||||
}
|
||||
}
|
||||
|
||||
// All is the full build, in order. It is what the dev server runs on a code change.
|
||||
//
|
||||
// The returned bytes are the failing command's combined stdout+stderr, which the dev
|
||||
// server puts straight into the browser's error overlay — so a compile error lands in
|
||||
// front of you rather than in a terminal you were not looking at.
|
||||
func All() ([]byte, error) {
|
||||
for _, s := range Steps() {
|
||||
if out, err := s.Run(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Cold runs every step once, narrating as it goes, and exits non-zero on the first
|
||||
// failure. This is `go run ./server -build`: CI, a cold start, or an editor's pre-launch
|
||||
// task — anywhere there is nobody watching a browser overlay.
|
||||
func Cold() {
|
||||
log.SetFlags(0)
|
||||
for _, s := range Steps() {
|
||||
log.Println("==>", s.Name)
|
||||
if out, err := s.Run(); err != nil {
|
||||
os.Stderr.Write(out)
|
||||
log.Fatalln("build failed:", err)
|
||||
}
|
||||
}
|
||||
log.Println("==> Done. Serve it with: go run ./server")
|
||||
}
|
||||
|
||||
// Codegen regenerates app/*.gen.go from the //gowasm: directives — the routes, the
|
||||
// layouts, and the client stubs for server components. It runs FIRST: everything after
|
||||
// it compiles the code it writes.
|
||||
func Codegen() ([]byte, error) {
|
||||
return exec.Command("go", "run", "kjol/cmd/wasmgen", "./app").CombinedOutput()
|
||||
}
|
||||
|
||||
// Tailwind compiles css/app.css to wwwroot/app.css, scanning the webui kit, the lexer's
|
||||
// palette and this app's Go markup for utility candidates.
|
||||
//
|
||||
// It scans .go files, which is the whole point of kjol's native engine: the markup is
|
||||
// written in Go, so that is where the class names are. Nothing in this stage involves
|
||||
// JavaScript.
|
||||
//
|
||||
// EVERY package whose class names have to exist has to be listed here. That is not a
|
||||
// warning about carelessness — it is the failure mode: a package left off this list still
|
||||
// compiles, still renders, and just comes out unstyled, because the class it asked for was
|
||||
// never generated. kjol/lexer is here for exactly that reason; its whole output is class
|
||||
// names, and nothing else in the tree mentions text-teal-300.
|
||||
func Tailwind() ([]byte, error) {
|
||||
cmd := exec.Command("go", "run", "./cmd/twcss",
|
||||
"-entry", "cmd/kjol-web/css/app.css",
|
||||
"-out", "cmd/kjol-web/wwwroot/app.css",
|
||||
"-base", ".",
|
||||
"webui/**/*.go",
|
||||
"lexer/**/*.go",
|
||||
"cmd/kjol-web/app/**/*.go",
|
||||
"cmd/kjol-web/server/**/*.go",
|
||||
)
|
||||
cmd.Dir = kjolRoot
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// Wasm compiles ./wasm to wwwroot/app.wasm.
|
||||
func Wasm() ([]byte, error) {
|
||||
cmd := exec.Command("go", "build", "-o", filepath.Join(Wwwroot, "app.wasm"), "./wasm")
|
||||
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// JS builds the Solid app: the SPA under /js, the server-rendered public pages, and
|
||||
// their stylesheet. It is kjol/jsbundler — TSX compiled to Solid by a Go program, bundled
|
||||
// by esbuild's Go API, styled by kjol/tw — run in-process rather than shelled out to, so
|
||||
// a compile error comes back as a Go error and lands in the browser's error overlay like
|
||||
// every other failure.
|
||||
//
|
||||
// It writes bundle.min.{js,css} and public.bundle.min.{js,css} into the SAME wwwroot as
|
||||
// the wasm build. They never collide: different filenames, one static dir, one server.
|
||||
//
|
||||
// WebDir points at the shared tree — the kit, the vendored Solid runtime, the icon SVGs
|
||||
// and the @theme scaffold all live there.
|
||||
func JS() ([]byte, error) {
|
||||
err := jsbundler.Build(jsbundler.Config{
|
||||
AppFrontend: "frontend",
|
||||
WebDir: filepath.Join(kjolRoot, "jsruntime"),
|
||||
Output: Wwwroot,
|
||||
GenTSDir: filepath.Join("frontend", "src", "ui", "generated"),
|
||||
})
|
||||
if err != nil {
|
||||
return []byte(err.Error()), err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Shim copies Go's wasm_exec.js into wwwroot. It is the loader the browser needs to start
|
||||
// a Go wasm binary, it ships with the toolchain, and it must match the compiler that
|
||||
// produced the binary — so it is copied from GOROOT rather than vendored.
|
||||
func Shim() ([]byte, error) {
|
||||
out, err := exec.Command("go", "env", "GOROOT").Output()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
goroot := strings.TrimSpace(string(out))
|
||||
|
||||
for _, src := range []string{
|
||||
filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go >= 1.24
|
||||
filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // Go <= 1.23
|
||||
} {
|
||||
b, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(Wwwroot, "wasm_exec.js")
|
||||
// The GOROOT copy is read-only, and so is the copy we made last time. Remove it
|
||||
// first, or the write fails with a permission error that says nothing useful.
|
||||
os.Remove(dst)
|
||||
return nil, os.WriteFile(dst, b, 0o644)
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Command build runs the example's full pre-compile step once: directive codegen,
|
||||
// Tailwind, the wasm binary, and Go's JS shim.
|
||||
//
|
||||
// go run ./build # from go/cmd/kjol-web
|
||||
//
|
||||
// For day-to-day work run the dev server instead (`go run ./server`) — it performs
|
||||
// these same steps on every save and hot-swaps the result into the browser. This
|
||||
// command is for a cold build, CI, or an editor's pre-launch task.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"kjolweb/buildsteps"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
|
||||
steps := []struct {
|
||||
name string
|
||||
run func() ([]byte, error)
|
||||
}{
|
||||
{"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", buildsteps.Codegen},
|
||||
{"compiling Tailwind CSS -> wwwroot/app.css", buildsteps.Tailwind},
|
||||
{"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", buildsteps.Wasm},
|
||||
{"bundling the Solid half -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)", buildsteps.JS},
|
||||
{"copying Go's wasm_exec.js shim into wwwroot/", buildsteps.Shim},
|
||||
}
|
||||
|
||||
for _, s := range steps {
|
||||
log.Println("==>", s.name)
|
||||
if out, err := s.run(); err != nil {
|
||||
os.Stderr.Write(out)
|
||||
log.Fatalln("build failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("==> Done. Run the server with: go run ./server")
|
||||
log.Println(" then open http://localhost:8085")
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
// Package buildsteps is the example's build pipeline: directive codegen, Tailwind,
|
||||
// the wasm binary, and Go's JS shim.
|
||||
//
|
||||
// It is Go, not a shell script, for three reasons. The dev server needs to call these
|
||||
// steps on every save and cannot shell out to bash on Windows. Editors need to run
|
||||
// them as tasks, and a task that only works on one platform is a task half the team
|
||||
// cannot use. And the one-off build and the watch build must be the SAME steps — the
|
||||
// moment they are two scripts, they drift, and the bug only shows up in whichever one
|
||||
// you use less.
|
||||
//
|
||||
// Run the whole thing with `go run ./build`.
|
||||
package buildsteps
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kjol/jsbundler"
|
||||
)
|
||||
|
||||
// kjolRoot is the kjol Go module root, relative to the example directory. The Tailwind
|
||||
// and codegen commands are run FROM there so the engine's dependencies resolve in
|
||||
// kjol's own go.mod, and this example's stays lean.
|
||||
const kjolRoot = "../.."
|
||||
|
||||
// Wwwroot is where every build artefact lands, and what the dev server serves.
|
||||
const Wwwroot = "wwwroot"
|
||||
|
||||
// Codegen regenerates app/*.gen.go from the //gowasm: directives — the routes, the
|
||||
// layouts, and the client stubs for server components. It runs FIRST: everything after
|
||||
// it compiles the code it writes.
|
||||
func Codegen() ([]byte, error) {
|
||||
return run("go", "run", "kjol/cmd/wasmgen", "./app")
|
||||
}
|
||||
|
||||
// Tailwind compiles css/app.css to wwwroot/app.css, scanning the webui kit and this
|
||||
// example's Go markup for utility candidates.
|
||||
//
|
||||
// It scans .go files, which is the whole point of kjol's native engine: the markup is
|
||||
// written in Go, so that is where the class names are. There is no JS build here at
|
||||
// all.
|
||||
func Tailwind() ([]byte, error) {
|
||||
cmd := exec.Command("go", "run", "./cmd/twcss",
|
||||
"-entry", "cmd/kjol-web/css/app.css",
|
||||
"-out", "cmd/kjol-web/wwwroot/app.css",
|
||||
"-base", ".",
|
||||
"webui/**/*.go",
|
||||
"cmd/kjol-web/app/**/*.go",
|
||||
"cmd/kjol-web/server/**/*.go",
|
||||
)
|
||||
cmd.Dir = kjolRoot
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// Wasm compiles ./wasm to wwwroot/app.wasm.
|
||||
func Wasm() ([]byte, error) {
|
||||
cmd := exec.Command("go", "build", "-o", filepath.Join(Wwwroot, "app.wasm"), "./wasm")
|
||||
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// JS builds the OTHER half of the site: the Solid SPA under /js, the SSR'd public
|
||||
// pages, and their stylesheet. It is kjol/jsbundler — TSX compiled to Solid by a Go
|
||||
// program, bundled by esbuild's Go API, styled by kjol/tw — run in-process rather than
|
||||
// shelled out to, so a compile error comes back as a Go error and lands in the dev
|
||||
// server's browser overlay like every other failure.
|
||||
//
|
||||
// It writes bundle.min.{js,css} and public.bundle.min.{js,css} into the SAME wwwroot as
|
||||
// the wasm build. The two halves never collide: different filenames, one static dir, one
|
||||
// server.
|
||||
//
|
||||
// -web points at the shared tree, which is where the kit, the vendored Solid runtime,
|
||||
// the icon SVGs and the @theme scaffold all live.
|
||||
func JS() ([]byte, error) {
|
||||
err := jsbundler.Build(jsbundler.Config{
|
||||
AppFrontend: "frontend",
|
||||
WebDir: filepath.Join(kjolRoot, "jsruntime"),
|
||||
Output: Wwwroot,
|
||||
GenTSDir: filepath.Join("frontend", "src", "ui", "generated"),
|
||||
})
|
||||
if err != nil {
|
||||
return []byte(err.Error()), err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Shim copies Go's wasm_exec.js into wwwroot. It is the loader the browser needs to
|
||||
// start a Go wasm binary, it ships with the toolchain, and it must match the compiler
|
||||
// that produced the binary — so it is copied from GOROOT rather than vendored.
|
||||
func Shim() ([]byte, error) {
|
||||
out, err := exec.Command("go", "env", "GOROOT").Output()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
goroot := strings.TrimSpace(string(out))
|
||||
|
||||
for _, src := range []string{
|
||||
filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go >= 1.24
|
||||
filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // Go <= 1.23
|
||||
} {
|
||||
b, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(Wwwroot, "wasm_exec.js")
|
||||
// The GOROOT copy is read-only, and so is the copy we made last time. Remove it
|
||||
// first, or the write fails with a permission error that says nothing useful.
|
||||
os.Remove(dst)
|
||||
return nil, os.WriteFile(dst, b, 0o644)
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
// All is the full build, in order. It is what the dev server runs on a code change and
|
||||
// what `go run ./build` runs once.
|
||||
//
|
||||
// Returned output is the failing command's combined stdout+stderr, which the dev server
|
||||
// puts straight into the browser's error overlay — so a compile error lands in front of
|
||||
// you rather than in a terminal you were not looking at.
|
||||
func All() ([]byte, error) {
|
||||
for _, step := range []func() ([]byte, error){Codegen, Tailwind, Wasm, JS, Shim} {
|
||||
if out, err := step(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func run(name string, args ...string) ([]byte, error) {
|
||||
return exec.Command(name, args...).CombinedOutput()
|
||||
}
|
||||
@@ -77,19 +77,20 @@
|
||||
@theme {
|
||||
--radius-default: 0.375rem;
|
||||
|
||||
/* Sky: a cool, neutral blue. The page is mostly prose, code and tables, and the
|
||||
accent's job is to mark the few things you can act on — a saturated indigo or a
|
||||
primary blue competes with the content for attention instead of directing it. */
|
||||
--color-primary: #0284c7; /* sky-600 — accent FILLS; they carry white text */
|
||||
--color-primary-hover: #0369a1; /* sky-700 */
|
||||
--color-primary-subtle: #f0f9ff; /* sky-50 — tinted panels, badges, callouts */
|
||||
--color-primary-border: #bae6fd; /* sky-200 */
|
||||
/* Navy and red — the flag, muted. kjøl is a Norwegian word and the palette says so.
|
||||
Both are dark and low-key: the page is mostly prose, code and tables, and the brand's
|
||||
job is to mark the few things you can act on, not to compete with them for attention.
|
||||
(The previous sky blue did the same job, but said nothing.) */
|
||||
--color-primary: #1e3a63; /* muted navy — FILLS; they carry white text */
|
||||
--color-primary-hover: #16294a;
|
||||
--color-primary-subtle: #eef2f8; /* a navy wash — tinted panels, badges, callouts */
|
||||
--color-primary-border: #c5d1e2;
|
||||
|
||||
/* accent is for TEXT and icons. It is a separate token from primary because the two
|
||||
have opposite constraints: a fill must be dark enough for white text on top of it,
|
||||
and accent text must be readable ON the surface. One value cannot be both, and in
|
||||
dark mode they diverge completely. */
|
||||
--color-accent: #0369a1; /* sky-700 */
|
||||
/* accent is for TEXT and icons, and it is the RED — which is why it is a separate token
|
||||
from primary rather than a lighter shade of it. The two have opposite constraints: a
|
||||
fill must be dark enough for white text on top of it, and accent text must be readable
|
||||
ON the surface. Here they are not even the same hue: navy fills, red links. */
|
||||
--color-accent: #9e1b32; /* dark red */
|
||||
|
||||
/* Surfaces, lines, ink — the kit's theme contract. */
|
||||
--color-surface: #ffffff;
|
||||
@@ -129,7 +130,7 @@
|
||||
as graph paper you have to read the page through.
|
||||
--------------------------------------------------------------------------- */
|
||||
:root {
|
||||
--grid-line: rgba(15, 23, 42, 0.055);
|
||||
--grid-line: rgba(30, 58, 99, 0.06); /* the navy, at the edge of visible */
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
@@ -156,12 +157,21 @@
|
||||
--color-ink-muted: #9a9aa3;
|
||||
--color-ink-faint: #71717a;
|
||||
|
||||
/* The fill stays put — white text has to remain readable on it — but accent TEXT has
|
||||
to climb to stay readable on a near-black surface. This is exactly why they are two
|
||||
tokens. */
|
||||
--color-accent: #7dd3fc; /* sky-300 */
|
||||
--color-primary-subtle: #0b2c3f;
|
||||
--color-primary-border: #0e4966;
|
||||
/* Both brand tokens move in the dark, and for different reasons.
|
||||
|
||||
The accent has to CLIMB: a dark red on a near-black surface is unreadable, so it goes
|
||||
up to a light rose. It is still the flag's red, just the only version of it you can
|
||||
read here.
|
||||
|
||||
The fill has to climb too — which the sky blue it replaced did not. Navy is dark
|
||||
enough that on a #101013 page a navy button loses its edges and reads as a hole in the
|
||||
surface. So it lifts to a steel blue that still carries white text (~7:1) and still
|
||||
looks like a button. */
|
||||
--color-accent: #f0a3ad;
|
||||
--color-primary: #2b4f80;
|
||||
--color-primary-hover: #37619b;
|
||||
--color-primary-subtle: #182234;
|
||||
--color-primary-border: #2c3e5c;
|
||||
|
||||
--color-text-heading: #f5f5f5;
|
||||
|
||||
|
||||
@@ -10,20 +10,25 @@
|
||||
|
||||
The values below match the Go/WASM section's css/app.css on purpose — same
|
||||
Lora, same sky accent — so that crossing between /wasm and /js reads as two
|
||||
halves of ONE site rather than two demos that happen to share a domain. The
|
||||
parts of ONE site rather than two demos that happen to share a domain. The
|
||||
two sections are built by completely different pipelines; they should not look
|
||||
like it.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
@theme {
|
||||
/* The accent. `primary` is the ONE semantic token the Solid kit actually
|
||||
honours (bg-primary / text-primary / border-primary / bg-primary-hover);
|
||||
everything else in the kit names a raw Tailwind neutral directly. That is a
|
||||
real difference from the Go/WASM kit — which is themed end to end by tokens
|
||||
and can therefore switch to dark by changing ten values — and the /js/theming
|
||||
page says so out loud rather than pretending otherwise. */
|
||||
--color-primary: #0284c7; /* sky-600 — fills; they carry white text */
|
||||
--color-primary-hover: #0369a1; /* sky-700 */
|
||||
/* The brand: navy and red, the Norwegian flag muted down. These are the SAME values
|
||||
the Go/WASM section's css/app.css sets, and under the same names — so the Layers
|
||||
menu, the sidebar highlight and the callouts are the same navy on both sides of the
|
||||
site rather than two navies that happen to be close.
|
||||
|
||||
primary FILLS (white text sits on it) and is the NAVY; accent is TEXT and icons and
|
||||
is the RED (it has to be readable on the surface); primary-subtle/-border are the
|
||||
tinted panel. */
|
||||
--color-primary: #1e3a63; /* muted navy — fills; they carry white text */
|
||||
--color-primary-hover: #16294a;
|
||||
--color-primary-subtle: #eef2f8; /* a navy wash — tinted panels, callouts */
|
||||
--color-primary-border: #c5d1e2;
|
||||
--color-accent: #9e1b32; /* dark red — accent TEXT */
|
||||
|
||||
/* Lora, the same body face the Go/WASM section vendors. The woff2 files are
|
||||
served out of wwwroot/fonts by the same server, so this section pays no
|
||||
@@ -77,3 +82,21 @@
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* The dark values for the brand.
|
||||
---------------------------------------------------------------------------
|
||||
Both tokens move, for different reasons. The accent CLIMBS: a dark red on a near-black
|
||||
surface is unreadable, so it goes up to a light rose — still the flag's red, just the
|
||||
only version of it you can read here. The FILL climbs too, which the sky blue it
|
||||
replaced did not have to: navy is dark enough that a navy button on a #101013 page loses
|
||||
its edges and reads as a hole. And the tinted panel inverts outright, because a pale
|
||||
wash on #101013 is not a tint, it is a white box.
|
||||
|
||||
Same values, same names, as the Go/WASM section's css/app.css. */
|
||||
.dark {
|
||||
--color-accent: #f0a3ad;
|
||||
--color-primary: #2b4f80;
|
||||
--color-primary-hover: #37619b;
|
||||
--color-primary-subtle: #182234;
|
||||
--color-primary-border: #2c3e5c;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPA entry for the Kjol JS Web section (/js/*).
|
||||
// SPA entry for the Kjøl JS Web section (/js/*).
|
||||
//
|
||||
// This file is .ts and NOT .tsx on purpose — it is not a style choice. The bundler
|
||||
// resolves the SPA entry as src/app.ts (falling back to src/app.js) and nothing
|
||||
@@ -6,13 +6,13 @@
|
||||
// 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
|
||||
// and the whole /wasm section are served by Kjøl Wasm Web, a different binary, which
|
||||
// this bundle knows nothing about. `base: "/js"` keeps every route in here relative
|
||||
// to that, so a link to "/kit" resolves to /js/kit and the two SPAs never fight
|
||||
// over a URL.
|
||||
// to that, so a link to "/components" resolves to /js/components and the two SPAs
|
||||
// never fight over a URL.
|
||||
//
|
||||
// Crossing OUT of /js (to the front page, or into /wasm) is a plain <a href> and a
|
||||
// real page load — the other half of the site is a different binary. That is the
|
||||
// real page load — the rest of the site is a different binary. That is the
|
||||
// honest cost of running two front-ends behind one server, and it is one navigation.
|
||||
|
||||
import { render, createComponent } from "solid-js/web";
|
||||
@@ -21,19 +21,26 @@ 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";
|
||||
import { Components } from "./pages/Components.tsx";
|
||||
import { NotFound } from "./pages/NotFound.tsx";
|
||||
|
||||
// Routes as plain data: solid-router accepts RouteDefinition[] as `children`, which
|
||||
// is what lets a JSX-free entry declare a full route tree.
|
||||
//
|
||||
// There are only two. The kit used to be spread across /kit, /forms, /table and
|
||||
// /theming — a split along the lines of the SOURCE FILES rather than along anything a
|
||||
// reader wants: a person looking for a date picker does not know, and should not have
|
||||
// to guess, whether it was filed under forms or under overlays. It is one page now,
|
||||
// and the sidebar jumps you down it.
|
||||
const routes: RouteDefinition[] = [
|
||||
{ path: "/", component: Overview },
|
||||
{ path: "/kit", component: Kit },
|
||||
{ path: "/forms", component: Forms },
|
||||
{ path: "/table", component: Table },
|
||||
{ path: "/theming", component: Theming },
|
||||
{ path: "/components", component: Components },
|
||||
|
||||
// The catch-all, and it is not optional. The SERVER answers every /js/* URL with this
|
||||
// shell — it has no idea which paths the router knows about — so without a fallback an
|
||||
// unknown one renders the chrome around an empty <main>: a blank page, with a 200, and
|
||||
// nothing to tell you why. Kjøl Wasm Web has the same catch-all for the same reason.
|
||||
{ path: "*", component: NotFound },
|
||||
];
|
||||
|
||||
const root = document.getElementById("app");
|
||||
|
||||
36
go/cmd/kjol-web/frontend/src/componentGroups.ts
Normal file
36
go/cmd/kjol-web/frontend/src/componentGroups.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// The component groups: one entry per section of /js/components, AND one line in the
|
||||
// sidebar that jumps to it.
|
||||
//
|
||||
// Declaring them once, as data, is what keeps those two in step — the sidebar cannot
|
||||
// offer a jump to a section that does not exist, and a section cannot go missing from
|
||||
// the sidebar.
|
||||
//
|
||||
// This is the JS mirror of app/components.go's componentGroups(). The ids match, so the
|
||||
// two sections of the site have the same shape and a reader crossing between them lands
|
||||
// in the same place. The ICONS differ, and have to: this side names FontAwesome, the Go
|
||||
// side names webui's own hand-drawn registry, and where the two have no glyph in common
|
||||
// the names diverge.
|
||||
|
||||
export interface ComponentGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const COMPONENT_GROUPS: ComponentGroup[] = [
|
||||
{ id: "buttons", label: "Buttons", icon: "check" },
|
||||
{ id: "badges", label: "Badges & alerts", icon: "circle-info" },
|
||||
{ id: "cards", label: "Cards & layout", icon: "table-columns" },
|
||||
{ id: "icons", label: "Icons", icon: "star" },
|
||||
{ id: "forms", label: "Forms & inputs", icon: "pen-to-square" },
|
||||
{ id: "selects", label: "Selects & comboboxes", icon: "sliders" },
|
||||
{ id: "toggles", label: "Toggles & signature", icon: "check" },
|
||||
{ id: "dates", label: "Dates", icon: "calendar" },
|
||||
{ id: "tables", label: "Tables", icon: "table" },
|
||||
{ id: "overlays", label: "Overlays", icon: "copy" },
|
||||
{ id: "feedback", label: "Toasts & tours", icon: "bell" },
|
||||
{ id: "navigation", label: "Tabs & navigation", icon: "bars" },
|
||||
{ id: "search", label: "Fuzzy search", icon: "magnifying-glass" },
|
||||
{ id: "charts", label: "Charts", icon: "chart-column" },
|
||||
{ id: "theming", label: "Theming", icon: "palette" },
|
||||
];
|
||||
@@ -1,14 +1,22 @@
|
||||
// The layers of kjol, as data.
|
||||
// What kjøl is made of, 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.
|
||||
// There are two kinds of thing here, and conflating them was the mistake this file used
|
||||
// to make — one flat list called "the layers", holding both.
|
||||
//
|
||||
// (A shared source would be better than a mirrored one. There isn't one: 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.)
|
||||
// LAYERS are LANGUAGES. What kjøl is written in, and what it gives you in each:
|
||||
// the Go base, the TypeScript kit, the C base, the Jai modules. A layer is
|
||||
// a directory of code you can use on its own.
|
||||
//
|
||||
// COMPOSITIONS are FRAMEWORKS. What you get when the layers are assembled into
|
||||
// something that does a job — the two web engines. A composition is not
|
||||
// another language; it is a use of them.
|
||||
//
|
||||
// Kjøl Wasm Web is Go, all the way down. Kjøl JS Web is TypeScript compiled by a Go
|
||||
// toolchain — two layers, one framework. Listing that beside "C" as though they were the
|
||||
// same kind of noun told the reader nothing about either.
|
||||
//
|
||||
// This is the JS mirror of app/layers.go. The ids, the order and the taglines match; only
|
||||
// the ICONS diverge, and they have to — see below.
|
||||
|
||||
export interface Layer {
|
||||
name: string;
|
||||
@@ -20,7 +28,7 @@ export interface Layer {
|
||||
* 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`).
|
||||
* glyph in common the names diverge.
|
||||
*
|
||||
* Both sides fail loudly rather than quietly — a name neither registry knows renders
|
||||
* an empty box, and app/icons_test.go fails the build over it.
|
||||
@@ -28,45 +36,59 @@ export interface Layer {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const LAYERS: Layer[] = [
|
||||
/** Languages: what kjøl is written in. */
|
||||
export const LANGUAGES: Layer[] = [
|
||||
{
|
||||
name: "Kjol Go",
|
||||
name: "Go",
|
||||
href: "/go",
|
||||
tagline: "The server base: config, database, logging, HTTP, mail, validation.",
|
||||
tagline: "The base: config, database, logging, HTTP, mail, validation — and both web engines.",
|
||||
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,
|
||||
name: "TypeScript",
|
||||
href: "/ts",
|
||||
tagline: "The Solid component kit, the vendored runtime, and the generic scaffolding apps import as @kjol/*.",
|
||||
live: false,
|
||||
icon: "code",
|
||||
},
|
||||
{
|
||||
name: "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",
|
||||
name: "C",
|
||||
href: "/c",
|
||||
tagline: "Arena allocator, strings, math, lexer, platform layer.",
|
||||
live: false,
|
||||
tagline: "Arena allocator, counted strings, math, a lexer, a platform layer — and a build system that is a C file.",
|
||||
live: true,
|
||||
icon: "bolt",
|
||||
},
|
||||
{
|
||||
name: "Kjol Jai",
|
||||
name: "Jai",
|
||||
href: "/jai",
|
||||
tagline: "Console rendering module. Early.",
|
||||
tagline: "Console rendering. Early.",
|
||||
live: false,
|
||||
icon: "cube",
|
||||
},
|
||||
];
|
||||
|
||||
/** The layer the current path belongs to, or undefined on the front page. */
|
||||
/** Compositions: what the languages are assembled into. */
|
||||
export const COMPOSITIONS: Layer[] = [
|
||||
{
|
||||
name: "Kjøl Wasm Web",
|
||||
href: "/wasm",
|
||||
tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, and no JavaScript build at all.",
|
||||
live: true,
|
||||
icon: "code",
|
||||
},
|
||||
{
|
||||
name: "Kjøl JS Web",
|
||||
href: "/js",
|
||||
tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
|
||||
live: true,
|
||||
icon: "table-columns",
|
||||
},
|
||||
];
|
||||
|
||||
/** The layer or composition the current path belongs to, or undefined on the front page. */
|
||||
export function currentLayer(path: string): Layer | undefined {
|
||||
return LAYERS.find((l) => path === l.href || path.startsWith(l.href + "/"));
|
||||
return [...COMPOSITIONS, ...LANGUAGES].find(
|
||||
(l) => path === l.href || path.startsWith(l.href + "/"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// The Kjol JS Web shell: top bar (wordmark + Layers menu), sidebar, content.
|
||||
// The Kjøl JS Web shell: top bar (wordmark + Layers menu), sidebar, content.
|
||||
//
|
||||
// It is deliberately a near-copy of the Go/WASM section's AppLayout. Two front-ends,
|
||||
// one site: if the chrome drifted, crossing from /wasm to /js would feel like leaving
|
||||
@@ -7,11 +7,12 @@
|
||||
// and the page should not betray that.
|
||||
|
||||
import { For, Show } from "solid-js";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { A, useLocation, useNavigate } from "@solidjs/router";
|
||||
import { Icon } from "@ui/Icons";
|
||||
import { Menu, MenuTrigger, MenuContent, MenuLink, MenuSection } from "@ui/Menu";
|
||||
import { Menu, MenuTrigger, MenuContent } from "@ui/Menu";
|
||||
import { ThemeToggle, initTheme } from "@ui/Theme";
|
||||
import { LAYERS } from "../layers.ts";
|
||||
import { LANGUAGES, COMPOSITIONS, currentLayer, Layer } from "../layers.ts";
|
||||
import { COMPONENT_GROUPS } from "../componentGroups.ts";
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
@@ -22,68 +23,109 @@ interface NavItem {
|
||||
// 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" },
|
||||
{ path: "/components", label: "Components", icon: "table-columns" },
|
||||
];
|
||||
|
||||
// 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.
|
||||
// jumpTo scrolls a section into view, routing there first if we are somewhere else.
|
||||
//
|
||||
// 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() {
|
||||
// A plain <a href="#forms"> would work if the reader were already on the components
|
||||
// page, and would do nothing useful from anywhere else. The router's <A> is no good
|
||||
// either — it would try to navigate to a route called "#forms".
|
||||
//
|
||||
// The queueMicrotask is not superstition: after navigate() the target section does not
|
||||
// exist yet, because the page it lives on has not rendered. Scrolling on the next tick
|
||||
// is the earliest moment the element is actually there to scroll to.
|
||||
function jumpTo(navigate: (to: string) => void, onComponentsPage: boolean, id: string) {
|
||||
const scroll = () => document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
|
||||
if (onComponentsPage) {
|
||||
scroll();
|
||||
return;
|
||||
}
|
||||
navigate("/components");
|
||||
queueMicrotask(scroll);
|
||||
}
|
||||
|
||||
// The site's primary navigation, as TWO dropdowns: Layers (the languages) and
|
||||
// Compositions (the frameworks assembled out of them — see layers.ts).
|
||||
//
|
||||
// Two menus, not one with two headings inside it. They answer different questions —
|
||||
// "what is this written in" and "what can I read" — and a reader who wants the second
|
||||
// should not have to scroll past the first to find it. The kit's single-open manager
|
||||
// means opening one closes the other, so they behave like one control with two halves.
|
||||
//
|
||||
// Anything not `live` still appears, greyed, with the reason. A menu that silently omits
|
||||
// half the library teaches the reader that the library is half the size it is.
|
||||
//
|
||||
// Same shape as the Go side (app/layers.go: layersMenu / compositionsMenu).
|
||||
function Dropdown(props: { label: string; rows: Layer[] }) {
|
||||
const location = useLocation();
|
||||
const current = () => currentLayer(location.pathname);
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
// bottom-end, because the triggers sit at the right-hand end of the bar and a 24rem
|
||||
// panel hanging off the left edge of one would run past the window.
|
||||
<Menu placement="bottom-end">
|
||||
<MenuTrigger>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink">
|
||||
Layers
|
||||
{props.label}
|
||||
<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>
|
||||
<For each={props.rows}>{(layer) => <LayerItem layer={layer} current={current()} />}</For>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
// LayerItem is one row of the menu.
|
||||
//
|
||||
// The markup is deliberately the same shape and the same classes as app/layers.go's
|
||||
// layerItem. Two front-ends, one menu: if they drifted, this is where it would show,
|
||||
// because it is the one component a reader sees on both sides within seconds of each
|
||||
// other.
|
||||
function LayerItem(props: { layer: Layer; current?: Layer }) {
|
||||
return (
|
||||
<Show
|
||||
when={props.layer.live}
|
||||
fallback={
|
||||
<div class="flex cursor-default flex-col gap-0.5 px-3 py-2 opacity-55">
|
||||
<span class="flex items-center gap-2 text-sm font-medium text-ink-muted">
|
||||
<Icon icon={props.layer.icon} size={14} class="shrink-0 text-ink-faint" />
|
||||
{props.layer.name}
|
||||
<span class="rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted">
|
||||
reference
|
||||
</span>
|
||||
</span>
|
||||
<span class="pl-6 text-xs text-ink-muted">{props.layer.tagline}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* A plain <a href>, not MenuLink and not the router's <A>. Both of the
|
||||
alternatives are wrong here: MenuLink lays its icon out BESIDE the whole
|
||||
two-line block (so the tagline never lines up under the name, which is what
|
||||
the Go menu does), and the router would try to handle the jump itself — but
|
||||
the other side is served by a different binary, so it has to be a real
|
||||
navigation. */}
|
||||
<a
|
||||
href={props.layer.href}
|
||||
class={
|
||||
"flex flex-col gap-0.5 px-3 py-2 no-underline hover:bg-surface-raised " +
|
||||
(props.current?.href === props.layer.href ? "bg-primary-subtle" : "")
|
||||
}
|
||||
>
|
||||
<span class="flex items-center gap-2 text-sm font-medium text-ink">
|
||||
<Icon icon={props.layer.icon} size={14} class="shrink-0 text-accent" />
|
||||
{props.layer.name}
|
||||
</span>
|
||||
<span class="pl-6 text-xs text-ink-muted">{props.layer.tagline}</span>
|
||||
</a>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
function Wordmark() {
|
||||
// A plain <a href>, not a router <A>: "/" is the front page, which belongs to the
|
||||
// Go/WASM binary. Routing to it inside this SPA would resolve to /js and land you
|
||||
@@ -94,7 +136,7 @@ function Wordmark() {
|
||||
<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-lg font-semibold tracking-tight text-ink">Kjøl JS Web</span>
|
||||
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
|
||||
</span>
|
||||
</a>
|
||||
@@ -103,29 +145,32 @@ function Wordmark() {
|
||||
|
||||
function Sidebar() {
|
||||
const location = useLocation();
|
||||
// The router's pathname is absolute (/js/kit); NAV paths are base-relative (/kit).
|
||||
const navigate = useNavigate();
|
||||
|
||||
// The router's pathname is absolute (/js/components); NAV paths are base-relative.
|
||||
const active = (path: string) => location.pathname === "/js" + (path === "/" ? "" : path);
|
||||
const onComponents = () => location.pathname === "/js/components";
|
||||
|
||||
// The same active treatment the Go sidebar uses (app/pages.go: sidebarLink) — a
|
||||
// tinted panel and accent text, not a grey fill. Now that the Solid theme carries the
|
||||
// primary-subtle / accent tokens, the two sidebars are the same sidebar.
|
||||
const linkCls = (on: boolean) =>
|
||||
on
|
||||
? "flex items-center gap-2 rounded-default bg-primary-subtle px-2 py-1.5 text-sm font-medium text-accent no-underline"
|
||||
: "flex items-center gap-2 rounded-default px-2 py-1.5 text-sm text-ink-soft no-underline hover:bg-surface-raised hover:text-ink";
|
||||
|
||||
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>
|
||||
<p class="px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">Kjøl JS Web</p>
|
||||
<ul class="mt-2 space-y-0.5">
|
||||
<For each={NAV}>
|
||||
{(item) => (
|
||||
<li>
|
||||
<A
|
||||
href={item.path}
|
||||
end={item.path === "/"}
|
||||
class={
|
||||
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"
|
||||
}
|
||||
>
|
||||
<A href={item.path} end={item.path === "/"} class={linkCls(active(item.path))}>
|
||||
<Icon
|
||||
icon={item.icon}
|
||||
size={14}
|
||||
class={active(item.path) ? "text-primary" : "text-ink-faint"}
|
||||
class={active(item.path) ? "text-accent" : "text-ink-faint"}
|
||||
/>
|
||||
{item.label}
|
||||
</A>
|
||||
@@ -133,6 +178,36 @@ function Sidebar() {
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
|
||||
{/* The component groups are not pages — they are anchors into the one components
|
||||
page, and clicking one scrolls you there.
|
||||
|
||||
They are not highlighted by which section you have scrolled to. Finding that
|
||||
out means measuring all fifteen of them on every scroll frame, and the only
|
||||
way to act on the answer is a state write, which re-renders. Sixty times a
|
||||
second, to move a highlight. The highlight is not worth the page. */}
|
||||
<p class="mt-8 px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">
|
||||
Components
|
||||
</p>
|
||||
<ul class="mt-2 space-y-0.5">
|
||||
<For each={COMPONENT_GROUPS}>
|
||||
{(g) => (
|
||||
<li>
|
||||
<a
|
||||
href={"/js/components#" + g.id}
|
||||
class={linkCls(false)}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
jumpTo(navigate, onComponents(), g.id);
|
||||
}}
|
||||
>
|
||||
<Icon icon={g.icon} size={14} class="text-ink-faint" />
|
||||
{g.label}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -155,7 +230,8 @@ export function Shell(props: { children?: any }) {
|
||||
Docs
|
||||
</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<LayersMenu />
|
||||
<Dropdown label="Layers" rows={LANGUAGES} />
|
||||
<Dropdown label="Compositions" rows={COMPOSITIONS} />
|
||||
<a
|
||||
href="/"
|
||||
class="rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:bg-surface-raised hover:text-ink"
|
||||
|
||||
1540
go/cmd/kjol-web/frontend/src/pages/Components.tsx
Normal file
1540
go/cmd/kjol-web/frontend/src/pages/Components.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,220 +0,0 @@
|
||||
// /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>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
// /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>
|
||||
);
|
||||
}
|
||||
41
go/cmd/kjol-web/frontend/src/pages/NotFound.tsx
Normal file
41
go/cmd/kjol-web/frontend/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// The fallback for any /js/* URL the router does not know.
|
||||
//
|
||||
// The server cannot 404 these: it answers every /js/* path with the same SPA shell,
|
||||
// because it has no idea which routes the bundle contains. So the router has to be the
|
||||
// one to say so — and if it does not, an unknown URL renders the chrome around an empty
|
||||
// <main>, which is a blank page with a 200 and no explanation.
|
||||
//
|
||||
// It names the paths that MOVED, because that is what a stale bookmark most likely wants:
|
||||
// /js/kit, /js/forms, /js/table and /js/theming were four pages, and are now four
|
||||
// sections of one.
|
||||
|
||||
import { useLocation, useNavigate } from "@solidjs/router";
|
||||
import { ButtonUI, BUTTON_COLOR_PRIMARY, BUTTON_COLOR_LIGHT_NEUTRAL } from "@ui/Buttons";
|
||||
|
||||
export function NotFound() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div class="max-w-2xl py-10">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">Page not found</h1>
|
||||
<p class="mt-2 leading-relaxed text-ink-soft">
|
||||
No route matches <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">{location.pathname}</code>.
|
||||
</p>
|
||||
<p class="mt-3 leading-relaxed text-ink-soft">
|
||||
The kit used to be spread across several pages — <code class="font-mono">/js/kit</code>,{" "}
|
||||
<code class="font-mono">/js/forms</code>, <code class="font-mono">/js/table</code>,{" "}
|
||||
<code class="font-mono">/js/theming</code>. It is one page now, and they are sections of it.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2">
|
||||
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => navigate("/components")}>
|
||||
Go to Components
|
||||
</ButtonUI>
|
||||
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL} onclick={() => navigate("/")}>
|
||||
Overview
|
||||
</ButtonUI>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
// /js — what the JS layer is, and how it is built.
|
||||
|
||||
import { For } from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { Card, CardHeader } from "@ui/Cards";
|
||||
import { AlertBlue } from "@ui/Alerts";
|
||||
import { CodeBox } from "@ui/General";
|
||||
import { Icon } from "@ui/Icons";
|
||||
import { COMPONENT_GROUPS } from "../componentGroups.ts";
|
||||
|
||||
export function Overview() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div class="max-w-3xl">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">
|
||||
A Solid kit, built by a Go toolchain
|
||||
</h1>
|
||||
@@ -48,7 +54,7 @@ export function Overview() {
|
||||
/>
|
||||
</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"} />
|
||||
<CodeBox class="mt-5" code={"$ go run ./server -build\nGenerating FA icon subset...\n FA icons: 94 defs for 47 names\nGenerating public routes...\n /js/ssr Ssr (rendered, 2498 bytes)\nBundling JS + CSS...\n\nBundle Files Size Time\n-------------------------------------------------------\nbundle.min.js 1 1.4 MB 198ms\nbundle.min.css 2500 74.6 KB 30ms"} />
|
||||
|
||||
<AlertBlue header="One reactive instance, always" class="mt-8">
|
||||
The single hardest invariant in this build is that there is exactly one copy of solid-js. Two
|
||||
@@ -57,26 +63,38 @@ export function Overview() {
|
||||
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>
|
||||
<h2 class="mt-10 text-lg font-semibold text-ink">The kit</h2>
|
||||
<p class="mt-2 leading-relaxed text-ink-soft">
|
||||
All of it is on one page. It used to be three — Components, Forms, AutoTable — which is a
|
||||
split along the lines of the source files rather than along anything a reader wants: a person
|
||||
looking for a date picker does not know, and should not have to guess, whether it was filed
|
||||
under forms or under overlays.
|
||||
</p>
|
||||
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
<For each={COMPONENT_GROUPS}>
|
||||
{(g) => (
|
||||
<a
|
||||
href={"/js/components#" + g.id}
|
||||
class="group flex items-center gap-2 rounded-default border border-line bg-surface px-3 py-2.5 no-underline shadow-xs transition hover:border-primary hover:shadow-sm"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/components");
|
||||
queueMicrotask(() =>
|
||||
document.getElementById(g.id)?.scrollIntoView({ behavior: "smooth", block: "start" }),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon icon={g.icon} size={14} class="shrink-0 text-primary" />
|
||||
<span class="text-sm font-medium text-ink">{g.label}</span>
|
||||
<Icon
|
||||
icon="arrow-right"
|
||||
size={11}
|
||||
class="ml-auto text-ink-faint transition group-hover:text-primary"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
// /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>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
// /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>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function PublicLayout(props: { currentPath: string; children?: JSXElement
|
||||
</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-lg font-semibold tracking-tight text-ink">Kjøl JS Web</span>
|
||||
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
|
||||
</span>
|
||||
</a>
|
||||
@@ -65,7 +65,7 @@ export function PublicLayout(props: { currentPath: string; children?: JSXElement
|
||||
|
||||
<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.
|
||||
Kjøl JS Web is one layer of kjol — a shared base layer. kjol is Norwegian for keel.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Ssr() {
|
||||
|
||||
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>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">
|
||||
This page was rendered by Go
|
||||
</h1>
|
||||
@@ -69,7 +69,7 @@ export function Ssr() {
|
||||
|
||||
<p class="mt-8 text-sm text-ink-muted">
|
||||
<a href="/js" class="text-primary underline underline-offset-4">
|
||||
Back to Kjol JS Web
|
||||
Back to Kjøl JS Web
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ export const publicPages: PublicPageDef[] = [
|
||||
path: "/js/ssr",
|
||||
module: "pages/public/Ssr.tsx",
|
||||
component: "Ssr",
|
||||
title: "Server-rendered — Kjol JS Web",
|
||||
title: "Server-rendered — Kjøl JS Web",
|
||||
// dynamic: the server may inject data for this route at request time, so bake
|
||||
// the render bundle too, not just the static skeleton.
|
||||
dynamic: true,
|
||||
|
||||
@@ -13,5 +13,5 @@ export const publicRoutes: Record<string, () => JSXElement> = {
|
||||
// <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",
|
||||
"/js/ssr": "Server-rendered — Kjøl JS Web",
|
||||
};
|
||||
|
||||
589
go/cmd/kjol-web/frontend/vendor/@kurkle/color/dist/color.esm.js
vendored
Normal file
589
go/cmd/kjol-web/frontend/vendor/@kurkle/color/dist/color.esm.js
vendored
Normal file
@@ -0,0 +1,589 @@
|
||||
/*!
|
||||
* @kurkle/color v0.3.4
|
||||
* https://github.com/kurkle/color#readme
|
||||
* (c) 2024 Jukka Kurkela
|
||||
* Released under the MIT License
|
||||
*/
|
||||
function round(v) {
|
||||
return v + 0.5 | 0;
|
||||
}
|
||||
const lim = (v, l, h) => Math.max(Math.min(v, h), l);
|
||||
function p2b(v) {
|
||||
return lim(round(v * 2.55), 0, 255);
|
||||
}
|
||||
function b2p(v) {
|
||||
return lim(round(v / 2.55), 0, 100);
|
||||
}
|
||||
function n2b(v) {
|
||||
return lim(round(v * 255), 0, 255);
|
||||
}
|
||||
function b2n(v) {
|
||||
return lim(round(v / 2.55) / 100, 0, 1);
|
||||
}
|
||||
function n2p(v) {
|
||||
return lim(round(v * 100), 0, 100);
|
||||
}
|
||||
|
||||
const map$1 = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, A: 10, B: 11, C: 12, D: 13, E: 14, F: 15, a: 10, b: 11, c: 12, d: 13, e: 14, f: 15};
|
||||
const hex = [...'0123456789ABCDEF'];
|
||||
const h1 = b => hex[b & 0xF];
|
||||
const h2 = b => hex[(b & 0xF0) >> 4] + hex[b & 0xF];
|
||||
const eq = b => ((b & 0xF0) >> 4) === (b & 0xF);
|
||||
const isShort = v => eq(v.r) && eq(v.g) && eq(v.b) && eq(v.a);
|
||||
function hexParse(str) {
|
||||
var len = str.length;
|
||||
var ret;
|
||||
if (str[0] === '#') {
|
||||
if (len === 4 || len === 5) {
|
||||
ret = {
|
||||
r: 255 & map$1[str[1]] * 17,
|
||||
g: 255 & map$1[str[2]] * 17,
|
||||
b: 255 & map$1[str[3]] * 17,
|
||||
a: len === 5 ? map$1[str[4]] * 17 : 255
|
||||
};
|
||||
} else if (len === 7 || len === 9) {
|
||||
ret = {
|
||||
r: map$1[str[1]] << 4 | map$1[str[2]],
|
||||
g: map$1[str[3]] << 4 | map$1[str[4]],
|
||||
b: map$1[str[5]] << 4 | map$1[str[6]],
|
||||
a: len === 9 ? (map$1[str[7]] << 4 | map$1[str[8]]) : 255
|
||||
};
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
const alpha = (a, f) => a < 255 ? f(a) : '';
|
||||
function hexString(v) {
|
||||
var f = isShort(v) ? h1 : h2;
|
||||
return v
|
||||
? '#' + f(v.r) + f(v.g) + f(v.b) + alpha(v.a, f)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const HUE_RE = /^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;
|
||||
function hsl2rgbn(h, s, l) {
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return [f(0), f(8), f(4)];
|
||||
}
|
||||
function hsv2rgbn(h, s, v) {
|
||||
const f = (n, k = (n + h / 60) % 6) => v - v * s * Math.max(Math.min(k, 4 - k, 1), 0);
|
||||
return [f(5), f(3), f(1)];
|
||||
}
|
||||
function hwb2rgbn(h, w, b) {
|
||||
const rgb = hsl2rgbn(h, 1, 0.5);
|
||||
let i;
|
||||
if (w + b > 1) {
|
||||
i = 1 / (w + b);
|
||||
w *= i;
|
||||
b *= i;
|
||||
}
|
||||
for (i = 0; i < 3; i++) {
|
||||
rgb[i] *= 1 - w - b;
|
||||
rgb[i] += w;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
function hueValue(r, g, b, d, max) {
|
||||
if (r === max) {
|
||||
return ((g - b) / d) + (g < b ? 6 : 0);
|
||||
}
|
||||
if (g === max) {
|
||||
return (b - r) / d + 2;
|
||||
}
|
||||
return (r - g) / d + 4;
|
||||
}
|
||||
function rgb2hsl(v) {
|
||||
const range = 255;
|
||||
const r = v.r / range;
|
||||
const g = v.g / range;
|
||||
const b = v.b / range;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
let h, s, d;
|
||||
if (max !== min) {
|
||||
d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
h = hueValue(r, g, b, d, max);
|
||||
h = h * 60 + 0.5;
|
||||
}
|
||||
return [h | 0, s || 0, l];
|
||||
}
|
||||
function calln(f, a, b, c) {
|
||||
return (
|
||||
Array.isArray(a)
|
||||
? f(a[0], a[1], a[2])
|
||||
: f(a, b, c)
|
||||
).map(n2b);
|
||||
}
|
||||
function hsl2rgb(h, s, l) {
|
||||
return calln(hsl2rgbn, h, s, l);
|
||||
}
|
||||
function hwb2rgb(h, w, b) {
|
||||
return calln(hwb2rgbn, h, w, b);
|
||||
}
|
||||
function hsv2rgb(h, s, v) {
|
||||
return calln(hsv2rgbn, h, s, v);
|
||||
}
|
||||
function hue(h) {
|
||||
return (h % 360 + 360) % 360;
|
||||
}
|
||||
function hueParse(str) {
|
||||
const m = HUE_RE.exec(str);
|
||||
let a = 255;
|
||||
let v;
|
||||
if (!m) {
|
||||
return;
|
||||
}
|
||||
if (m[5] !== v) {
|
||||
a = m[6] ? p2b(+m[5]) : n2b(+m[5]);
|
||||
}
|
||||
const h = hue(+m[2]);
|
||||
const p1 = +m[3] / 100;
|
||||
const p2 = +m[4] / 100;
|
||||
if (m[1] === 'hwb') {
|
||||
v = hwb2rgb(h, p1, p2);
|
||||
} else if (m[1] === 'hsv') {
|
||||
v = hsv2rgb(h, p1, p2);
|
||||
} else {
|
||||
v = hsl2rgb(h, p1, p2);
|
||||
}
|
||||
return {
|
||||
r: v[0],
|
||||
g: v[1],
|
||||
b: v[2],
|
||||
a: a
|
||||
};
|
||||
}
|
||||
function rotate(v, deg) {
|
||||
var h = rgb2hsl(v);
|
||||
h[0] = hue(h[0] + deg);
|
||||
h = hsl2rgb(h);
|
||||
v.r = h[0];
|
||||
v.g = h[1];
|
||||
v.b = h[2];
|
||||
}
|
||||
function hslString(v) {
|
||||
if (!v) {
|
||||
return;
|
||||
}
|
||||
const a = rgb2hsl(v);
|
||||
const h = a[0];
|
||||
const s = n2p(a[1]);
|
||||
const l = n2p(a[2]);
|
||||
return v.a < 255
|
||||
? `hsla(${h}, ${s}%, ${l}%, ${b2n(v.a)})`
|
||||
: `hsl(${h}, ${s}%, ${l}%)`;
|
||||
}
|
||||
|
||||
const map = {
|
||||
x: 'dark',
|
||||
Z: 'light',
|
||||
Y: 're',
|
||||
X: 'blu',
|
||||
W: 'gr',
|
||||
V: 'medium',
|
||||
U: 'slate',
|
||||
A: 'ee',
|
||||
T: 'ol',
|
||||
S: 'or',
|
||||
B: 'ra',
|
||||
C: 'lateg',
|
||||
D: 'ights',
|
||||
R: 'in',
|
||||
Q: 'turquois',
|
||||
E: 'hi',
|
||||
P: 'ro',
|
||||
O: 'al',
|
||||
N: 'le',
|
||||
M: 'de',
|
||||
L: 'yello',
|
||||
F: 'en',
|
||||
K: 'ch',
|
||||
G: 'arks',
|
||||
H: 'ea',
|
||||
I: 'ightg',
|
||||
J: 'wh'
|
||||
};
|
||||
const names$1 = {
|
||||
OiceXe: 'f0f8ff',
|
||||
antiquewEte: 'faebd7',
|
||||
aqua: 'ffff',
|
||||
aquamarRe: '7fffd4',
|
||||
azuY: 'f0ffff',
|
||||
beige: 'f5f5dc',
|
||||
bisque: 'ffe4c4',
|
||||
black: '0',
|
||||
blanKedOmond: 'ffebcd',
|
||||
Xe: 'ff',
|
||||
XeviTet: '8a2be2',
|
||||
bPwn: 'a52a2a',
|
||||
burlywood: 'deb887',
|
||||
caMtXe: '5f9ea0',
|
||||
KartYuse: '7fff00',
|
||||
KocTate: 'd2691e',
|
||||
cSO: 'ff7f50',
|
||||
cSnflowerXe: '6495ed',
|
||||
cSnsilk: 'fff8dc',
|
||||
crimson: 'dc143c',
|
||||
cyan: 'ffff',
|
||||
xXe: '8b',
|
||||
xcyan: '8b8b',
|
||||
xgTMnPd: 'b8860b',
|
||||
xWay: 'a9a9a9',
|
||||
xgYF: '6400',
|
||||
xgYy: 'a9a9a9',
|
||||
xkhaki: 'bdb76b',
|
||||
xmagFta: '8b008b',
|
||||
xTivegYF: '556b2f',
|
||||
xSange: 'ff8c00',
|
||||
xScEd: '9932cc',
|
||||
xYd: '8b0000',
|
||||
xsOmon: 'e9967a',
|
||||
xsHgYF: '8fbc8f',
|
||||
xUXe: '483d8b',
|
||||
xUWay: '2f4f4f',
|
||||
xUgYy: '2f4f4f',
|
||||
xQe: 'ced1',
|
||||
xviTet: '9400d3',
|
||||
dAppRk: 'ff1493',
|
||||
dApskyXe: 'bfff',
|
||||
dimWay: '696969',
|
||||
dimgYy: '696969',
|
||||
dodgerXe: '1e90ff',
|
||||
fiYbrick: 'b22222',
|
||||
flSOwEte: 'fffaf0',
|
||||
foYstWAn: '228b22',
|
||||
fuKsia: 'ff00ff',
|
||||
gaRsbSo: 'dcdcdc',
|
||||
ghostwEte: 'f8f8ff',
|
||||
gTd: 'ffd700',
|
||||
gTMnPd: 'daa520',
|
||||
Way: '808080',
|
||||
gYF: '8000',
|
||||
gYFLw: 'adff2f',
|
||||
gYy: '808080',
|
||||
honeyMw: 'f0fff0',
|
||||
hotpRk: 'ff69b4',
|
||||
RdianYd: 'cd5c5c',
|
||||
Rdigo: '4b0082',
|
||||
ivSy: 'fffff0',
|
||||
khaki: 'f0e68c',
|
||||
lavFMr: 'e6e6fa',
|
||||
lavFMrXsh: 'fff0f5',
|
||||
lawngYF: '7cfc00',
|
||||
NmoncEffon: 'fffacd',
|
||||
ZXe: 'add8e6',
|
||||
ZcSO: 'f08080',
|
||||
Zcyan: 'e0ffff',
|
||||
ZgTMnPdLw: 'fafad2',
|
||||
ZWay: 'd3d3d3',
|
||||
ZgYF: '90ee90',
|
||||
ZgYy: 'd3d3d3',
|
||||
ZpRk: 'ffb6c1',
|
||||
ZsOmon: 'ffa07a',
|
||||
ZsHgYF: '20b2aa',
|
||||
ZskyXe: '87cefa',
|
||||
ZUWay: '778899',
|
||||
ZUgYy: '778899',
|
||||
ZstAlXe: 'b0c4de',
|
||||
ZLw: 'ffffe0',
|
||||
lime: 'ff00',
|
||||
limegYF: '32cd32',
|
||||
lRF: 'faf0e6',
|
||||
magFta: 'ff00ff',
|
||||
maPon: '800000',
|
||||
VaquamarRe: '66cdaa',
|
||||
VXe: 'cd',
|
||||
VScEd: 'ba55d3',
|
||||
VpurpN: '9370db',
|
||||
VsHgYF: '3cb371',
|
||||
VUXe: '7b68ee',
|
||||
VsprRggYF: 'fa9a',
|
||||
VQe: '48d1cc',
|
||||
VviTetYd: 'c71585',
|
||||
midnightXe: '191970',
|
||||
mRtcYam: 'f5fffa',
|
||||
mistyPse: 'ffe4e1',
|
||||
moccasR: 'ffe4b5',
|
||||
navajowEte: 'ffdead',
|
||||
navy: '80',
|
||||
Tdlace: 'fdf5e6',
|
||||
Tive: '808000',
|
||||
TivedBb: '6b8e23',
|
||||
Sange: 'ffa500',
|
||||
SangeYd: 'ff4500',
|
||||
ScEd: 'da70d6',
|
||||
pOegTMnPd: 'eee8aa',
|
||||
pOegYF: '98fb98',
|
||||
pOeQe: 'afeeee',
|
||||
pOeviTetYd: 'db7093',
|
||||
papayawEp: 'ffefd5',
|
||||
pHKpuff: 'ffdab9',
|
||||
peru: 'cd853f',
|
||||
pRk: 'ffc0cb',
|
||||
plum: 'dda0dd',
|
||||
powMrXe: 'b0e0e6',
|
||||
purpN: '800080',
|
||||
YbeccapurpN: '663399',
|
||||
Yd: 'ff0000',
|
||||
Psybrown: 'bc8f8f',
|
||||
PyOXe: '4169e1',
|
||||
saddNbPwn: '8b4513',
|
||||
sOmon: 'fa8072',
|
||||
sandybPwn: 'f4a460',
|
||||
sHgYF: '2e8b57',
|
||||
sHshell: 'fff5ee',
|
||||
siFna: 'a0522d',
|
||||
silver: 'c0c0c0',
|
||||
skyXe: '87ceeb',
|
||||
UXe: '6a5acd',
|
||||
UWay: '708090',
|
||||
UgYy: '708090',
|
||||
snow: 'fffafa',
|
||||
sprRggYF: 'ff7f',
|
||||
stAlXe: '4682b4',
|
||||
tan: 'd2b48c',
|
||||
teO: '8080',
|
||||
tEstN: 'd8bfd8',
|
||||
tomato: 'ff6347',
|
||||
Qe: '40e0d0',
|
||||
viTet: 'ee82ee',
|
||||
JHt: 'f5deb3',
|
||||
wEte: 'ffffff',
|
||||
wEtesmoke: 'f5f5f5',
|
||||
Lw: 'ffff00',
|
||||
LwgYF: '9acd32'
|
||||
};
|
||||
function unpack() {
|
||||
const unpacked = {};
|
||||
const keys = Object.keys(names$1);
|
||||
const tkeys = Object.keys(map);
|
||||
let i, j, k, ok, nk;
|
||||
for (i = 0; i < keys.length; i++) {
|
||||
ok = nk = keys[i];
|
||||
for (j = 0; j < tkeys.length; j++) {
|
||||
k = tkeys[j];
|
||||
nk = nk.replace(k, map[k]);
|
||||
}
|
||||
k = parseInt(names$1[ok], 16);
|
||||
unpacked[nk] = [k >> 16 & 0xFF, k >> 8 & 0xFF, k & 0xFF];
|
||||
}
|
||||
return unpacked;
|
||||
}
|
||||
|
||||
let names;
|
||||
function nameParse(str) {
|
||||
if (!names) {
|
||||
names = unpack();
|
||||
names.transparent = [0, 0, 0, 0];
|
||||
}
|
||||
const a = names[str.toLowerCase()];
|
||||
return a && {
|
||||
r: a[0],
|
||||
g: a[1],
|
||||
b: a[2],
|
||||
a: a.length === 4 ? a[3] : 255
|
||||
};
|
||||
}
|
||||
|
||||
const RGB_RE = /^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;
|
||||
function rgbParse(str) {
|
||||
const m = RGB_RE.exec(str);
|
||||
let a = 255;
|
||||
let r, g, b;
|
||||
if (!m) {
|
||||
return;
|
||||
}
|
||||
if (m[7] !== r) {
|
||||
const v = +m[7];
|
||||
a = m[8] ? p2b(v) : lim(v * 255, 0, 255);
|
||||
}
|
||||
r = +m[1];
|
||||
g = +m[3];
|
||||
b = +m[5];
|
||||
r = 255 & (m[2] ? p2b(r) : lim(r, 0, 255));
|
||||
g = 255 & (m[4] ? p2b(g) : lim(g, 0, 255));
|
||||
b = 255 & (m[6] ? p2b(b) : lim(b, 0, 255));
|
||||
return {
|
||||
r: r,
|
||||
g: g,
|
||||
b: b,
|
||||
a: a
|
||||
};
|
||||
}
|
||||
function rgbString(v) {
|
||||
return v && (
|
||||
v.a < 255
|
||||
? `rgba(${v.r}, ${v.g}, ${v.b}, ${b2n(v.a)})`
|
||||
: `rgb(${v.r}, ${v.g}, ${v.b})`
|
||||
);
|
||||
}
|
||||
|
||||
const to = v => v <= 0.0031308 ? v * 12.92 : Math.pow(v, 1.0 / 2.4) * 1.055 - 0.055;
|
||||
const from = v => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
function interpolate(rgb1, rgb2, t) {
|
||||
const r = from(b2n(rgb1.r));
|
||||
const g = from(b2n(rgb1.g));
|
||||
const b = from(b2n(rgb1.b));
|
||||
return {
|
||||
r: n2b(to(r + t * (from(b2n(rgb2.r)) - r))),
|
||||
g: n2b(to(g + t * (from(b2n(rgb2.g)) - g))),
|
||||
b: n2b(to(b + t * (from(b2n(rgb2.b)) - b))),
|
||||
a: rgb1.a + t * (rgb2.a - rgb1.a)
|
||||
};
|
||||
}
|
||||
|
||||
function modHSL(v, i, ratio) {
|
||||
if (v) {
|
||||
let tmp = rgb2hsl(v);
|
||||
tmp[i] = Math.max(0, Math.min(tmp[i] + tmp[i] * ratio, i === 0 ? 360 : 1));
|
||||
tmp = hsl2rgb(tmp);
|
||||
v.r = tmp[0];
|
||||
v.g = tmp[1];
|
||||
v.b = tmp[2];
|
||||
}
|
||||
}
|
||||
function clone(v, proto) {
|
||||
return v ? Object.assign(proto || {}, v) : v;
|
||||
}
|
||||
function fromObject(input) {
|
||||
var v = {r: 0, g: 0, b: 0, a: 255};
|
||||
if (Array.isArray(input)) {
|
||||
if (input.length >= 3) {
|
||||
v = {r: input[0], g: input[1], b: input[2], a: 255};
|
||||
if (input.length > 3) {
|
||||
v.a = n2b(input[3]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
v = clone(input, {r: 0, g: 0, b: 0, a: 1});
|
||||
v.a = n2b(v.a);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
function functionParse(str) {
|
||||
if (str.charAt(0) === 'r') {
|
||||
return rgbParse(str);
|
||||
}
|
||||
return hueParse(str);
|
||||
}
|
||||
class Color {
|
||||
constructor(input) {
|
||||
if (input instanceof Color) {
|
||||
return input;
|
||||
}
|
||||
const type = typeof input;
|
||||
let v;
|
||||
if (type === 'object') {
|
||||
v = fromObject(input);
|
||||
} else if (type === 'string') {
|
||||
v = hexParse(input) || nameParse(input) || functionParse(input);
|
||||
}
|
||||
this._rgb = v;
|
||||
this._valid = !!v;
|
||||
}
|
||||
get valid() {
|
||||
return this._valid;
|
||||
}
|
||||
get rgb() {
|
||||
var v = clone(this._rgb);
|
||||
if (v) {
|
||||
v.a = b2n(v.a);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
set rgb(obj) {
|
||||
this._rgb = fromObject(obj);
|
||||
}
|
||||
rgbString() {
|
||||
return this._valid ? rgbString(this._rgb) : undefined;
|
||||
}
|
||||
hexString() {
|
||||
return this._valid ? hexString(this._rgb) : undefined;
|
||||
}
|
||||
hslString() {
|
||||
return this._valid ? hslString(this._rgb) : undefined;
|
||||
}
|
||||
mix(color, weight) {
|
||||
if (color) {
|
||||
const c1 = this.rgb;
|
||||
const c2 = color.rgb;
|
||||
let w2;
|
||||
const p = weight === w2 ? 0.5 : weight;
|
||||
const w = 2 * p - 1;
|
||||
const a = c1.a - c2.a;
|
||||
const w1 = ((w * a === -1 ? w : (w + a) / (1 + w * a)) + 1) / 2.0;
|
||||
w2 = 1 - w1;
|
||||
c1.r = 0xFF & w1 * c1.r + w2 * c2.r + 0.5;
|
||||
c1.g = 0xFF & w1 * c1.g + w2 * c2.g + 0.5;
|
||||
c1.b = 0xFF & w1 * c1.b + w2 * c2.b + 0.5;
|
||||
c1.a = p * c1.a + (1 - p) * c2.a;
|
||||
this.rgb = c1;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
interpolate(color, t) {
|
||||
if (color) {
|
||||
this._rgb = interpolate(this._rgb, color._rgb, t);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
clone() {
|
||||
return new Color(this.rgb);
|
||||
}
|
||||
alpha(a) {
|
||||
this._rgb.a = n2b(a);
|
||||
return this;
|
||||
}
|
||||
clearer(ratio) {
|
||||
const rgb = this._rgb;
|
||||
rgb.a *= 1 - ratio;
|
||||
return this;
|
||||
}
|
||||
greyscale() {
|
||||
const rgb = this._rgb;
|
||||
const val = round(rgb.r * 0.3 + rgb.g * 0.59 + rgb.b * 0.11);
|
||||
rgb.r = rgb.g = rgb.b = val;
|
||||
return this;
|
||||
}
|
||||
opaquer(ratio) {
|
||||
const rgb = this._rgb;
|
||||
rgb.a *= 1 + ratio;
|
||||
return this;
|
||||
}
|
||||
negate() {
|
||||
const v = this._rgb;
|
||||
v.r = 255 - v.r;
|
||||
v.g = 255 - v.g;
|
||||
v.b = 255 - v.b;
|
||||
return this;
|
||||
}
|
||||
lighten(ratio) {
|
||||
modHSL(this._rgb, 2, ratio);
|
||||
return this;
|
||||
}
|
||||
darken(ratio) {
|
||||
modHSL(this._rgb, 2, -ratio);
|
||||
return this;
|
||||
}
|
||||
saturate(ratio) {
|
||||
modHSL(this._rgb, 1, ratio);
|
||||
return this;
|
||||
}
|
||||
desaturate(ratio) {
|
||||
modHSL(this._rgb, 1, -ratio);
|
||||
return this;
|
||||
}
|
||||
rotate(deg) {
|
||||
rotate(this._rgb, deg);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
function index_esm(input) {
|
||||
return new Color(input);
|
||||
}
|
||||
|
||||
export { Color, b2n, b2p, index_esm as default, hexParse, hexString, hsl2rgb, hslString, hsv2rgb, hueParse, hwb2rgb, lim, n2b, n2p, nameParse, p2b, rgb2hsl, rgbParse, rgbString, rotate, round };
|
||||
77
go/cmd/kjol-web/frontend/vendor/@kurkle/color/package.json
vendored
Normal file
77
go/cmd/kjol-web/frontend/vendor/@kurkle/color/package.json
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "@kurkle/color",
|
||||
"type": "module",
|
||||
"version": "0.3.4",
|
||||
"description": "css color parsing, manupulation and conversion",
|
||||
"sideEffects": false,
|
||||
"main": "dist/color.cjs",
|
||||
"module": "dist/color.esm.js",
|
||||
"types": "dist/color.d.ts",
|
||||
"exports": {
|
||||
"types": "./dist/color.d.ts",
|
||||
"import": "./dist/color.esm.js",
|
||||
"require": "./dist/color.cjs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node util/copy_dist.js && rollup -c",
|
||||
"lint": "eslint src/*.js test/*.js util/*.js",
|
||||
"test": "node test/index.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/kurkle/color.git"
|
||||
},
|
||||
"files": [
|
||||
"dist/*",
|
||||
"dist/color.d.ts"
|
||||
],
|
||||
"keywords": [
|
||||
"color",
|
||||
"colour",
|
||||
"css",
|
||||
"hsl",
|
||||
"hex",
|
||||
"rgb",
|
||||
"rgba",
|
||||
"hwb",
|
||||
"hsv",
|
||||
"cmyk"
|
||||
],
|
||||
"author": "Jukka Kurkela",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/kurkle/color/issues"
|
||||
},
|
||||
"homepage": "https://github.com/kurkle/color#readme",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-terser": "^0.4.0",
|
||||
"assert": "^2.0.0",
|
||||
"benchmark": "^2.1.4",
|
||||
"chartjs-color": "^2.4.1",
|
||||
"chartjs-color-string": "^0.6.0",
|
||||
"child_process": "^1.0.2",
|
||||
"chroma-js": "^3.1.1",
|
||||
"color-name": "^2.0.0",
|
||||
"color-names": "^2.0.0",
|
||||
"color-parse": "^2.0.2",
|
||||
"color-parser": "^0.1.0",
|
||||
"color-string": "^1.5.5",
|
||||
"csscolorparser": "^1.0.3",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-chartjs": "^0.3.0",
|
||||
"eslint-config-defaults": "^9.0.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"fs": "0.0.1-security",
|
||||
"perf_hooks": "0.0.1",
|
||||
"rollup": "^4.25.0",
|
||||
"rollup-plugin-analyzer": "^4.0.0",
|
||||
"rollup-plugin-cleanup": "^3.2.1",
|
||||
"rollup-plugin-istanbul": "^5.0.0",
|
||||
"rollup-plugin-visualizer": "^5.8.3",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"typedoc": "^0.26.7",
|
||||
"typescript": "^5.6.2",
|
||||
"util": "^0.12.3"
|
||||
}
|
||||
}
|
||||
11599
go/cmd/kjol-web/frontend/vendor/chart.js/dist/chart.js
vendored
Normal file
11599
go/cmd/kjol-web/frontend/vendor/chart.js/dist/chart.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2915
go/cmd/kjol-web/frontend/vendor/chart.js/dist/chunks/helpers.dataset.cjs
vendored
Normal file
2915
go/cmd/kjol-web/frontend/vendor/chart.js/dist/chunks/helpers.dataset.cjs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
go/cmd/kjol-web/frontend/vendor/chart.js/dist/chunks/helpers.dataset.cjs.map
vendored
Normal file
1
go/cmd/kjol-web/frontend/vendor/chart.js/dist/chunks/helpers.dataset.cjs.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2788
go/cmd/kjol-web/frontend/vendor/chart.js/dist/chunks/helpers.dataset.js
vendored
Normal file
2788
go/cmd/kjol-web/frontend/vendor/chart.js/dist/chunks/helpers.dataset.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
go/cmd/kjol-web/frontend/vendor/chart.js/dist/chunks/helpers.dataset.js.map
vendored
Normal file
1
go/cmd/kjol-web/frontend/vendor/chart.js/dist/chunks/helpers.dataset.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
139
go/cmd/kjol-web/frontend/vendor/chart.js/package.json
vendored
Normal file
139
go/cmd/kjol-web/frontend/vendor/chart.js/package.json
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"name": "chart.js",
|
||||
"homepage": "https://www.chartjs.org",
|
||||
"description": "Simple HTML5 charts using the canvas element.",
|
||||
"version": "4.5.1",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"./auto/auto.js",
|
||||
"./auto/auto.cjs",
|
||||
"./dist/chart.umd.min.js",
|
||||
"./dist/chart.umd.js"
|
||||
],
|
||||
"jsdelivr": "./dist/chart.umd.min.js",
|
||||
"unpkg": "./dist/chart.umd.min.js",
|
||||
"main": "./dist/chart.cjs",
|
||||
"module": "./dist/chart.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/chart.js",
|
||||
"require": "./dist/chart.cjs"
|
||||
},
|
||||
"./auto": {
|
||||
"types": "./auto/auto.d.ts",
|
||||
"import": "./auto/auto.js",
|
||||
"require": "./auto/auto.cjs"
|
||||
},
|
||||
"./helpers": {
|
||||
"types": "./helpers/helpers.d.ts",
|
||||
"import": "./helpers/helpers.js",
|
||||
"require": "./helpers/helpers.cjs"
|
||||
}
|
||||
},
|
||||
"types": "./dist/types.d.ts",
|
||||
"keywords": [
|
||||
"canvas",
|
||||
"charts",
|
||||
"data",
|
||||
"graphs",
|
||||
"html5",
|
||||
"responsive"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/chartjs/Chart.js.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/chartjs/Chart.js/issues"
|
||||
},
|
||||
"files": [
|
||||
"auto/**",
|
||||
"dist/**",
|
||||
"!dist/docs/**",
|
||||
"helpers/**"
|
||||
],
|
||||
"scripts": {
|
||||
"autobuild": "rollup -c -w",
|
||||
"copyDeclarations": "node -e \"fs.cpSync('./src/types/', './dist/types/', {recursive:true})\"",
|
||||
"emitDeclarations": "tsc --emitDeclarationOnly && pnpm copyDeclarations",
|
||||
"build": "rollup -c && pnpm emitDeclarations",
|
||||
"dev": "karma start ./karma.conf.cjs --auto-watch --no-single-run --browsers chrome --grep",
|
||||
"dev:ff": "karma start ./karma.conf.cjs --auto-watch --no-single-run --browsers firefox --grep",
|
||||
"docs": "pnpm run build && pnpm --filter \"./docs/**\" build",
|
||||
"docs:dev": "pnpm run build && pnpm --filter \"./docs/**\" dev",
|
||||
"lint-js": "eslint \"src/**/*.{js,ts}\" \"test/**/*.js\" \"docs/**/*.js\" --cache",
|
||||
"lint-md": "eslint \"**/*.md\" --cache",
|
||||
"lint-types": "pnpm build && node test/types/autogen.js && tsc -p test/types",
|
||||
"lint": "concurrently \"pnpm:lint-*\"",
|
||||
"test": "pnpm lint && pnpm test-ci",
|
||||
"test-ci": "concurrently \"pnpm:test-ci-*\"",
|
||||
"test-ci-karma": "cross-env NODE_ENV=test karma start ./karma.conf.cjs --auto-watch --single-run --coverage --grep",
|
||||
"test-ci-integration": "pnpm --filter \"./test/integration/**\" test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^23.0.2",
|
||||
"@rollup/plugin-inject": "^5.0.2",
|
||||
"@rollup/plugin-json": "^5.0.1",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
"@swc/core": "^1.3.18",
|
||||
"@types/estree": "^1.0.0",
|
||||
"@types/offscreencanvas": "^2019.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.32.0",
|
||||
"@typescript-eslint/parser": "^5.32.0",
|
||||
"chartjs-adapter-luxon": "^1.2.0",
|
||||
"chartjs-adapter-moment": "^1.0.0",
|
||||
"chartjs-test-utils": "^0.4.0",
|
||||
"concurrently": "^7.3.0",
|
||||
"coveralls": "^3.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.21.0",
|
||||
"eslint-config-chartjs": "^0.3.0",
|
||||
"eslint-plugin-es": "^4.1.0",
|
||||
"eslint-plugin-html": "^7.1.0",
|
||||
"eslint-plugin-markdown": "^3.0.0",
|
||||
"esm": "^3.2.25",
|
||||
"glob": "^8.0.3",
|
||||
"jasmine": "^3.7.0",
|
||||
"jasmine-core": "^3.7.1",
|
||||
"karma": "^6.3.2",
|
||||
"karma-chrome-launcher": "^3.1.0",
|
||||
"karma-coverage": "^2.0.3",
|
||||
"karma-edge-launcher": "^0.4.2",
|
||||
"karma-firefox-launcher": "^2.1.0",
|
||||
"karma-jasmine": "^4.0.1",
|
||||
"karma-jasmine-html-reporter": "^1.5.4",
|
||||
"karma-rollup-preprocessor": "7.0.7",
|
||||
"karma-safari-private-launcher": "^1.0.0",
|
||||
"karma-spec-reporter": "0.0.32",
|
||||
"luxon": "^3.0.1",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.34",
|
||||
"pixelmatch": "^5.3.0",
|
||||
"rollup": "^3.3.0",
|
||||
"rollup-plugin-cleanup": "^3.2.1",
|
||||
"rollup-plugin-istanbul": "^4.0.0",
|
||||
"rollup-plugin-swc3": "^0.7.0",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"typescript": "^4.7.4",
|
||||
"yargs": "^17.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
},
|
||||
"packageManager": "pnpm@8.13.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"html-entities": "1.4.0"
|
||||
},
|
||||
"peerDependencyRules": {
|
||||
"ignoreMissing": [
|
||||
"chart.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
6
go/cmd/kjol-web/frontend/vendor/vendor.json
vendored
6
go/cmd/kjol-web/frontend/vendor/vendor.json
vendored
@@ -5,8 +5,12 @@
|
||||
|
||||
"//3": "Only the files the bundler actually pins are vendored, not the whole npm packages — 3.5 MB rather than 62 MB of type definitions, CJS builds and documentation. If a subpath import is ever added that reaches outside dist/ or build/, this is the first place it will fail.",
|
||||
|
||||
"//4": "chart.js (and its @kurkle/color dependency) are here for the same reason: @ui/Chart imports chart.js at the top level and registers every controller on module load. Its wrapper is h-full with maintainAspectRatio:false, so the PARENT must have an explicit height or the canvas collapses to nothing.",
|
||||
|
||||
"entrypoints": {
|
||||
"pdf-lib": "pdf-lib/dist/pdf-lib.esm.js",
|
||||
"pdfjs-dist": "pdfjs-dist/build/pdf.mjs"
|
||||
"pdfjs-dist": "pdfjs-dist/build/pdf.mjs",
|
||||
"chart.js": "chart.js/dist/chart.js",
|
||||
"@kurkle/color": "@kurkle/color/dist/color.esm.js"
|
||||
}
|
||||
}
|
||||
|
||||
31
go/cmd/kjol-web/grep.exe.stackdump
Normal file
31
go/cmd/kjol-web/grep.exe.stackdump
Normal file
@@ -0,0 +1,31 @@
|
||||
Stack trace:
|
||||
Frame Function Args
|
||||
0007FFFFBD80 000210060304 (0007FFFFBF88, 0007FFFFCE00, 000000000002, 0007FFFFDC10) msys-2.0.dll+0x20304
|
||||
FFFFFFFFFFFEFEDF 00021006237D (0007FFFFC730, 000000000000, 00000000017C, 000000000000) msys-2.0.dll+0x2237D
|
||||
0007FFFFC490 0002100C1394 (0007FFFFC6F0, 000000000001, 0007FFFFC700, 000000000000) msys-2.0.dll+0x81394
|
||||
000000000006 0002100BCE39 (000210221CCD, 000000000000, 000000000000, 7FF9520C00E8) msys-2.0.dll+0x7CE39
|
||||
0007FFFFC848 0002100BD23A (0007FFFFC858, 000A00000000, 0000000000B5, 0000000000B5) msys-2.0.dll+0x7D23A
|
||||
0007FFFFC848 0002102130B8 (000000000000, 000A00000030, 000000000000, 000000000000) msys-2.0.dll+0x1D30B8
|
||||
0007FFFFC848 00010042A1E5 (0002100A76B3, 7FF900000000, 000210221CCD, 000100000001) grep.exe+0x2A1E5
|
||||
0007FFFFCB80 000100404B7C (00000000000D, 000000000000, 000000000000, 000000000000) grep.exe+0x4B7C
|
||||
0007FFFFCB80 000100429678 (0002100455E0, 000000000000, 000000000148, 000000000000) grep.exe+0x29678
|
||||
0007FFFFCD30 000210047F01 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x7F01
|
||||
000000000000 000210045AC3 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x5AC3
|
||||
0007FFFFFFF0 000210045B74 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x5B74
|
||||
End of stack trace
|
||||
Loaded modules:
|
||||
000100400000 grep.exe
|
||||
7FF952CB0000 ntdll.dll
|
||||
7FF9520C0000 KERNEL32.DLL
|
||||
7FF94FFF0000 KERNELBASE.dll
|
||||
0005603F0000 msys-iconv-2.dll
|
||||
000430B30000 msys-intl-8.dll
|
||||
000210040000 msys-2.0.dll
|
||||
0004C36D0000 msys-pcre-1.dll
|
||||
7FF951F50000 advapi32.dll
|
||||
7FF9525E0000 msvcrt.dll
|
||||
7FF952010000 sechost.dll
|
||||
7FF9503A0000 bcrypt.dll
|
||||
7FF952380000 RPCRT4.dll
|
||||
7FF94F720000 CRYPTBASE.DLL
|
||||
7FF9508C0000 bcryptPrimitives.dll
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package handlers serves the server-rendered public pages of the Kjol JS Web
|
||||
// Package handlers serves the server-rendered public pages of the Kjøl JS Web
|
||||
// section.
|
||||
//
|
||||
// This file is the APP side of a coupling inversion. kjol's bundler renders each
|
||||
|
||||
@@ -21,27 +21,34 @@ import (
|
||||
"kjol/webui"
|
||||
|
||||
"kjolweb/app"
|
||||
"kjolweb/buildsteps"
|
||||
"kjolweb/build"
|
||||
"kjolweb/internal/handlers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":8085", "listen address")
|
||||
watch := flag.Bool("watch", true, "watch sources, rebuild wasm, hot-reload")
|
||||
buildOnly := flag.Bool("build", false, "run the full build once and exit (no server)")
|
||||
flag.Parse()
|
||||
|
||||
if *buildOnly {
|
||||
build.Cold()
|
||||
return
|
||||
}
|
||||
|
||||
log.Fatal(wasmdevserver.Serve(wasmdevserver.Config{
|
||||
Addr: *addr,
|
||||
Dir: "./wwwroot",
|
||||
Watch: *watch,
|
||||
WatchDirs: []string{
|
||||
"app", "wasm", "css", // this app's Go/WASM half
|
||||
"frontend", // its Solid half — a .tsx save rebuilds the JS bundle
|
||||
"app", "wasm", "css", // the Go/WASM app
|
||||
"frontend", // the Solid app — a .tsx save rebuilds the JS bundle
|
||||
"../../webui", "../../vdom", "../../wasmruntime", "../../rsc", // the wasm engine
|
||||
"../../lexer", // the code-block highlighter
|
||||
"../../jsruntime/uikit", "../../jsruntime/styles", // the Solid kit + the shared theme
|
||||
},
|
||||
Build: buildsteps.All,
|
||||
BuildCSS: buildsteps.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet
|
||||
Build: build.All,
|
||||
BuildCSS: build.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet
|
||||
Render: render,
|
||||
Document: document,
|
||||
Handle: routes,
|
||||
@@ -71,10 +78,10 @@ func routes(mux *http.ServeMux) {
|
||||
// document — the client router reads the URL and decides what to render, which is what
|
||||
// makes it a single-page app.
|
||||
//
|
||||
// It carries no server-rendered markup, and that is a real difference from the Go/WASM
|
||||
// half rather than an oversight: this section is a docs section behind a click, where a
|
||||
// blank first frame costs nothing. Where it WOULD cost something, the public-page path
|
||||
// (see internal/handlers) renders on the server instead — /js/ssr is that, and it is
|
||||
// It carries no server-rendered markup, and that is a real difference from Kjøl Wasm Web
|
||||
// rather than an oversight: this is a docs section behind a click, where a blank first
|
||||
// frame costs nothing. Where it WOULD cost something, the public-page path (see
|
||||
// internal/handlers) renders on the server instead — /js/ssr is that, and it is
|
||||
// registered above, so it never reaches this handler.
|
||||
func serveJSApp(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -83,7 +90,7 @@ func serveJSApp(w http.ResponseWriter, r *http.Request) {
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Kjol JS Web</title>
|
||||
<title>Kjøl JS Web</title>
|
||||
`+webui.ThemeBootScript+`
|
||||
<link rel="stylesheet" href="/bundle.min.css" />
|
||||
</head>
|
||||
@@ -130,7 +137,7 @@ func document(inner string) string {
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>kjol — a shared base layer</title>
|
||||
<title>kjøl — a shared base layer</title>
|
||||
` + webui.ThemeBootScript + `
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
|
||||
@@ -37,5 +37,17 @@ func main() {
|
||||
// boot script put it there before the first paint — so this is not what makes the
|
||||
// page dark; it is what makes the SWITCH know which way it is pointing, and what
|
||||
// keeps the page following the OS if the user never touched the switch.
|
||||
//
|
||||
// The server rendered this button not knowing the theme (it cannot: the preference is
|
||||
// in localStorage), so it drew the wrong icon. Init's signal write re-renders it, and
|
||||
// the reconciler patches the one button. That mismatch is inherent, not a bug — it is
|
||||
// the same reason the boot script has to exist at all.
|
||||
//
|
||||
// This line used to be unreachable: Hydrate ended in a `select {}` and never returned.
|
||||
// See wasmruntime.Wait.
|
||||
app.Theme.Init()
|
||||
|
||||
// Last line, always: a Go/wasm main that returns is a dead program, and every event
|
||||
// handler on the page dies with it.
|
||||
wasmruntime.Wait()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user