diff --git a/CLAUDE.md b/CLAUDE.md index 9593a9a..224bbed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Gutter is a Go library for building web applications declaratively, inspired by ## Package layout -- `github.com/Runway-Club/gutter` — framework core: `Widget`, `Host`, `Event`, `State`, `StateObject` (exposes `Widget()` — the framework keeps the current widget pointer fresh on mount and on every update, so State code doesn't have to copy it), `WidgetUpdater` (optional hook called when a parent rebuild swaps in a new widget instance of the same type — used by `ObserverBuilder`/`AsyncBuilder` to resubscribe), `BuildContext` (carries `Theme` + the ambient dependency scope), `Provider[T]`/`DependOn[T]` (InheritedWidget-style ambient DI — `Provider[T]{Value, Child}` makes a `T` available to its subtree; `DependOn[T](ctx)` reads the nearest one; correct under isolated `SetState` rebuilds because each `statefulElement` captures its scope), `Keyed`, `Listenable[T]`/`Notifier[T]` (observable primitive — `Value`/`Set`/`Update`/`Listen` with idempotent cancel; pair with `ObserverBuilder` for reactive subtrees), `RunApp`, `Serve`/`Config` (the recommended one-call entry: `func main() { gutter.Serve(gutter.Config{Root: Root}) }` — `Config` carries `Root func() Widget`, optional `RPC func()` to register handlers, `Theme`, `Selector`, `Addr`, `Dist`, `Head`. `Serve` is build-tagged: on wasm it does `RunApp(Root(), WithHydrate())`; on host it registers RPC and runs the SSR server. So ONE main, no build tags, drives both client and server — see SSR in Architecture), `MountInto`/`MountWhenVisible` (islands — mount one or more independent widget trees into existing DOM elements in a non-Gutter page; `MountInto` is non-blocking so a `main` can mount several then `select{}`, `MountWhenVisible` defers mounting until the element scrolls near via IntersectionObserver; both honor `WithHydrate`), `NewWorkerTask`/`WorkerTask` (register an inline worker handler — `RunApp` dispatches to it when the worker bootstrap reloads `app.wasm` with `__GUTTER_WORKER_TASK` set), `RunWorker` (lower-level worker entry, used when a worker is its own binary), `Option`/`WithTheme`/`WithSelector`/`WithHydrate` (the latter makes `RunApp` adopt server-rendered DOM instead of building fresh — see SSR below), `RenderToHTML`/`ServeSSR`/`SSRHandler` (server-side rendering — pure-Go widget-tree→HTML plus an HTTP server; see the SSR section in Architecture), `SetTitle` (cross-platform document title helper — WASM impl + host stub), `AssetURL`/`SetAssetBase`/`AssetBaseURL` (resolve a relative asset path against a configurable base — defaults to `"assets/"`, matching what the CLI copies into `./dist/assets/`; absolute URLs and `data:` URIs pass through unchanged so widget `Asset`/`Src` fields can take either), and the runtime (Element tree, reconciler). +- `github.com/Runway-Club/gutter` — framework core: `Widget`, `Host`, `Event`, `State`, `StateObject` (exposes `Widget()` — the framework keeps the current widget pointer fresh on mount and on every update, so State code doesn't have to copy it), `WidgetUpdater` (optional hook called when a parent rebuild swaps in a new widget instance of the same type — used by `ObserverBuilder`/`AsyncBuilder` to resubscribe), `BuildContext` (carries `Theme` + the ambient dependency scope), `Provider[T]`/`DependOn[T]` (InheritedWidget-style ambient DI — `Provider[T]{Value, Child}` makes a `T` available to its subtree; `DependOn[T](ctx)` reads the nearest one; correct under isolated `SetState` rebuilds because each `statefulElement` captures its scope), `ThemeProvider` (`{Theme, Child}` — scopes a theme override to a subtree via the same inherited-scope machinery; `widgets.activeTheme` prefers it over `BuildContext.Theme`), `Keyed`, `Listenable[T]`/`Notifier[T]` (observable primitive — `Value`/`Set`/`Update`/`Listen` with idempotent cancel; pair with `ObserverBuilder` for reactive subtrees), `RunApp`, `Serve`/`Config` (the recommended one-call entry: `func main() { gutter.Serve(gutter.Config{Root: Root}) }` — `Config` carries `Root func() Widget`, optional `RPC func()` to register handlers, `Theme`, `Selector`, `Addr`, `Dist`, `Head`. `Serve` is build-tagged: on wasm it does `RunApp(Root(), WithHydrate())`; on host it registers RPC and runs the SSR server. So ONE main, no build tags, drives both client and server — see SSR in Architecture), `MountInto`/`MountWhenVisible` (islands — mount one or more independent widget trees into existing DOM elements in a non-Gutter page; `MountInto` is non-blocking so a `main` can mount several then `select{}`, `MountWhenVisible` defers mounting until the element scrolls near via IntersectionObserver; both honor `WithHydrate`), `NewWorkerTask`/`WorkerTask` (register an inline worker handler — `RunApp` dispatches to it when the worker bootstrap reloads `app.wasm` with `__GUTTER_WORKER_TASK` set), `RunWorker` (lower-level worker entry, used when a worker is its own binary), `Option`/`WithTheme`/`WithSelector`/`WithHydrate` (the latter makes `RunApp` adopt server-rendered DOM instead of building fresh — see SSR below), `RenderToHTML`/`RenderDocument`/`RenderDocumentCtx`/`ServeSSR`/`SSRHandler` (server-side rendering — pure-Go widget-tree→HTML plus an HTTP server; `RenderDocument` also returns `` HTML collected from `Head` widgets; `RenderDocumentCtx` threads a context to `SSRResolver` states for async server resolution; see the SSR section in Architecture), `SSRResolver` (a State that resolves async work synchronously during SSR — `AsyncBuilder` implements it), `Head` (a transparent StatelessWidget — `Title`/`Meta`/`Property`/`Raw`/`Child` — that contributes document-`` metadata during SSR and sets `document.title` on the client; renders exactly its Child), `SetTitle` (cross-platform document title helper — WASM impl + host stub), `AssetURL`/`SetAssetBase`/`AssetBaseURL` (resolve a relative asset path against a configurable base — defaults to `"assets/"`, matching what the CLI copies into `./dist/assets/`; absolute URLs and `data:` URIs pass through unchanged so widget `Asset`/`Src` fields can take either), `Transition` (wraps SetStates to run at low priority — see Known limitations), `Inspect`/`InspectNode`/`EnableDevtools` (devtools: walk the live element tree / toggle an inspector overlay), and the runtime (Element tree, reconciler). - `github.com/Runway-Club/gutter/themes` — theme data: `Theme`, `Colors`, `Typography`, `Rounded`, `Spacing`, `Components`, plus the `Apple`, `Meta`, and `Neutral` presets. **No runtime — pure data structs.** `Apple` is the framework default (set in `options.go`). - `github.com/Runway-Club/gutter/widgets` — the single widget catalog. Three flavors live here side by side: - **App shell + themed** (StatelessWidgets that read `ctx.Theme`): `Scaffold` (the recommended root — `Title`/`Theme`/`AppBar`/`StickyAppBar`/`Body`/`Footer`; when `StickyAppBar` is true the bar is wrapped in `position:sticky; top:0; z-index:900` so it pins to the viewport while the body scrolls past — z-index sits below the 1000 overlay tier so Popup/Drawer/BottomSheet still cover it), `AppBar`, `Heading`, `Body`, `Caption`, `Link`, `Button`, `IconButton` (square Button variant rendering an `Icon` as its only content), `Card`, `Surface`, `Badge`, `Image` (HTML `` with `Asset` resolved via `gutter.AssetURL` or absolute `Src`; supports `Fit` for object-fit), `Icon` (Google Material Symbols glyph; `Style: IconOutlined|IconRounded|IconSharp`, `Filled`, `Weight`, `Grade` — drives the FILL/wght/GRAD/opsz axes via font-variation-settings; the scaffolded `index.html` preloads all three stylesheets), `File` (themed file picker — `Label`/`Child` for the trigger styled like a Button, `Accept`/`Multiple`, callback receives `[]FilePick{Name, Size, MimeType, Data []byte}` with bytes pre-read via FileReader; reading is WASM-only, `file_wasm.go`/`file_stub.go` split). @@ -114,8 +114,8 @@ The presets (`themes.Apple`, `themes.Meta`) are extracted by hand from the desig Gutter can render the initial HTML on the server for instant first paint + SEO, then let WASM take over. This works because widgets are pure data: `Build()`/`Host()` are platform-neutral and compile on the host. - **`RenderToHTML(root, opts...) (string, error)`** (`ssr.go`, no build tag, **no `syscall/js`**) walks the widget tree the same way `newElement` dispatches (Host → Stateful → Stateless) and serializes each `*Host` to HTML. State is built **once** (no SetState loop, no Dispose); `Events`/`OnMount` are not invoked but their presence is recorded as a `data-gutter-h="1"` marker, and `Keyed` widgets emit `data-gutter-key`. Output is deterministic (attrs/style sorted) and whitespace-free between elements (so children line up 1:1 with `node.children` during hydration). Text/attrs are escaped via `html.EscapeString`. -- **`ServeSSR(SSRConfig)` / `SSRHandler(SSRConfig)`** (`ssr_server.go`, **host-only**, imports `net/http`): render `Root()` per request into a full HTML document with the WASM bootstrap, and serve the static `app.wasm`/`wasm_exec.js` from `Dist`. `Root func() Widget` is required; `Addr`/`Dist`/`Head`/`Theme` are optional. -- **Hydration** (`WithHydrate()` + `Element.hydrate(node, ctx)` in `element_wasm.go`): when `RunApp(root, WithHydrate())` finds existing children in the container (the SSR page), it **adopts** them instead of `createElement` — re-applying attrs/style/text idempotently, stripping the `data-gutter-*` markers, wiring event listeners (`syncEvents`) and `OnMount`, and recursing into `node.children` positionally (every widget resolves to exactly one DOM node, so they align). A **tag mismatch** falls back to a fresh mount + replace of that subtree. If the container is empty (a CSR page), `WithHydrate()` falls back to a normal mount — so one `main()` serves both SSR and CSR pages. +- **`ServeSSR(SSRConfig)` / `SSRHandler(SSRConfig)`** (`ssr_server.go`, **host-only**, imports `net/http`): render `Root()` per request into a full HTML document with the WASM bootstrap, and serve the static `app.wasm`/`wasm_exec.js` from `Dist`. `Root func() Widget` is required; `Addr`/`Dist`/`Head`/`Theme` are optional. The doc template carries the same `margin:0` CSS reset as the CSR `index.html` (without it the browser's default `` margin shows as stray padding); `cfg.Head` is injected after the reset so it can override. **Asset serving is compression-aware** (`serveStaticAsset`/`compressibleByExt`/`acceptsEncoding`): for compressible types (`.wasm`/`.js`/`.css`/…) it prefers a build-time-written pre-compressed sibling (`app.wasm.br` then `app.wasm.gz`, served via `http.ServeContent` so Range/304 still work — best ratio, zero per-request CPU), else gzips on the fly, else serves plain; the SSR HTML response is gzipped too. All compressible responses set `Vary: Accept-Encoding`; static assets set `Cache-Control: no-cache` (filenames aren't content-hashed, so revalidate-always + Last-Modified `304` is the safe choice — no stale bytes after a rebuild, no re-download while unchanged). Brotli is served only if a `.br` sibling exists (no brotli dependency in core; produce it externally). Net effect on the fullstack example: `app.wasm` transfers ~11.7 MB → ~3.0 MB (~74% / 3.8×). +- **Hydration** (`WithHydrate()` + `Element.hydrate(node, ctx)` in `element_wasm.go`): when `RunApp(root, WithHydrate())` finds existing children in the container (the SSR page), it **adopts** them instead of `createElement` — re-applying attrs/style/text idempotently, stripping the `data-gutter-*` markers, wiring event listeners (`syncEvents`) and `OnMount`, and recursing into `node.children` positionally (every widget resolves to exactly one DOM node, so they align). A **tag mismatch** recreates just that element with the correct tag and moves the server-rendered children into it (descendant DOM is salvaged + hydrated, not rebuilt), warning via `warnHydrationMismatch`. If the container is empty (a CSR page), `WithHydrate()` falls back to a normal mount — so one `main()` serves both SSR and CSR pages. - **DX — one `main`, `gutter.Serve`**: the recommended layout is a single `package main` (no build tags) whose `main` calls `gutter.Serve(gutter.Config{Root: Root, RPC: registerRPC})`. The CLI compiles that one program **twice**: `GOOS=js` → `dist/app.wasm` (where `Serve` = `RunApp(Root(), WithHydrate())`), and host → the SSR server (where `Serve` registers the RPC handlers, then serves SSR at `/`, `rpc.Handler()` at `rpc.Endpoint`, and the wasm assets from `Dist`). `gutter run` serves it CSR; `gutter run --ssr` builds the wasm then `go run .` for the host server. RPC handlers are registered once in `Config.RPC` (runs server-side only). `Serve` is the batteries-included wrapper over the lower-level `RunApp`/`SSRHandler`/`ServeSSR` + `rpc` building blocks (`serve.go` neutral `Config`, `serve_wasm.go`, `serve_host.go` which imports `rpc`). Reference: `examples/fullstack/` (single `main.go`). Measured FCP win vs CSR: ~5–8× on localhost; SSR also beats React on FCP at every tier (see `bench/ANALYSIS.md` §8). - **Islands** (`MountInto`/`MountWhenVisible`): instead of owning the whole page, mount one or more independent widget trees into placeholder elements in an existing HTML/SSR page. `MountInto(selector, root, opts...)` is non-blocking (returns `*App`), so a `main` mounts several then `select{}`; each island has its own Element tree, state, and (optional) hydration, and they don't interfere. Pair with the page-side lazy loader (a tiny IntersectionObserver that fetches `app.wasm` only when an island nears the viewport) so a mostly-static page pays zero WASM until interactivity is actually needed. `MountWhenVisible` further defers an individual island's build/mount. Reference: `examples/islands/`. @@ -158,8 +158,9 @@ CI (`.github/workflows/test.yml`) runs all three on every push/PR. When adding a ## CLI internals (`cmd/gutter`) -- `new.go` writes three templated files (`main.go`, `index.html`, `go.mod`) using `strings.ReplaceAll` rather than `text/template`. Keep it that way unless templates grow. +- `new.go` writes the templated project files (`main.go`, `index.html`, `go.mod`, `.gitignore`, embedded `public/favicon.ico`) using `strings.ReplaceAll` rather than `text/template`. Keep it that way unless templates grow. `--ssr` swaps `mainGoTemplate` for `ssrMainGoTemplate` — a single `main.go` demonstrating SSR + `gutter.Head` (SEO) + typed RPC (`PingRequest`/`PingResponse` + `rpc.Handle`/`rpc.Call`) + hydration, defaulting to `themes.Meta`. The Gutter icon is `//go:embed gutter.ico` (a copy lives in `cmd/gutter/`). - `build.go` / `wasm.go` shell out to `go build` with `GOOS=js GOARCH=wasm`, bundle into `./dist/`, and copy `wasm_exec.js` from `$GOROOT/lib/wasm/` (Go 1.24+) or `$GOROOT/misc/wasm/` (older). Both paths are tried. `bundleInto` also walks `./public/` (root-level assets — favicons, robots.txt, etc.) and `./assets/` (referenced by `widgets.Image{Asset: ...}` via `gutter.AssetURL`) into `./dist/` and `./dist/assets/` respectively. Both are no-ops if the source directory is missing. +- `gutter build`/`build deploy` (via `runBuild`) run `precompressDist` after bundling: it writes a max-level (`gzip.BestCompression`) `.gz` next to every compressible asset ≥1 KB (`gzipWorthExt` mirrors the server's `compressibleByExt`). The SSR server (`gutter.Serve`) serves these `.gz` directly (zero per-request CPU); the generated `nginx.conf` enables `gzip_static on` so a static deploy does too. **`gutter run`/`run --ssr`/`run dev` deliberately skip precompression** (they call `bundleInto` directly, not `runBuild`) — dev gets fast rebuilds and relies on the server's on-the-fly gzip instead. So precompression is a production-build concern only. - `--tinygo` (opt-in, on `build`/`run`/`run dev`/`build deploy`) threads a `tinygo bool` through `bundleInto` → `buildWasm`/`buildWasmPkg`/`bundleWorkers`/`ensureWasmExec`. In tinygo mode the CLI runs `tinygo build -o … -target wasm` instead of `go build`, and copies `wasm_exec.js` from `$(tinygo env TINYGOROOT)/targets/` instead of GOROOT. `ensureWasmExec` always overwrites (Go and TinyGo ship incompatible runtimes), and `ensureTinygo` fails fast with an install hint if the binary is missing. **`build deploy` defaults to TinyGo when it's on PATH** (production wants the smaller bundle) via `resolveDeployTinygo` — `--pure-go` opts out, `--tinygo` forces it, and a missing TinyGo falls back to the standard toolchain with a hint. Other commands (`build`/`run`) stay pure Go unless `--tinygo` is passed (fast dev compiles). **TinyGo gotcha**: its `fmt.Sscanf` panics on `%f` (float scan) rather than returning an error — don't use `Sscanf` for float parsing in widget code; use `strconv.ParseFloat` (see `parseSizePx` in `widgets/icon.go` and `parsePxFallback` in `widgets/list.go`). Default builds remain pure Go. - The primary worker pattern needs no CLI help — inline tasks via `gutter.NewWorkerTask` ship in the same `app.wasm` and the worker bootstrap reloads that single binary. `bundleWorkers` is a legacy escape hatch for genuinely separate worker binaries: `./worker/` → `dist/worker.wasm`, `./workers//` → `dist/workers/.wasm`. Use it only when you don't want the worker to pull in the whole app. - `run.go` serves `./dist/` over HTTP and registers `.wasm → application/wasm`. Don't drop that — browsers reject WASM with the wrong MIME. `gutter run dev` adds an fsnotify watcher + a tiny `/__gutter/build` poller injected into served HTML for live reload. `gutter run --ssr` builds the wasm into `dist/` and then `exec`s `go run .` (passing `GUTTER_ADDR`/`GUTTER_DIST` env) — i.e. it runs the project's own `main` compiled for the host, which must call `gutter.Serve` (whose host impl is the SSR server). One program, two compilations; no separate `server/` package. @@ -172,13 +173,14 @@ The module is `github.com/Runway-Club/gutter`. `examples/counter/go.mod` uses `r ## Known limitations -- `SetState` is microtask-batched (see "State persistence and SetState"), so the DOM is not updated synchronously on return. There is still no priority/transition scheduling — every batched rebuild is equal-priority and runs on the next microtask. -- Tag stability is assumed for HostWidgets: the canUpdate check uses Go type but not the rendered `Host.Tag`. If a single HostWidget type produced different tags based on its fields, the framework would try to update a `
` into a `` via attribute diffing, which produces a wrong DOM. None of the built-in widgets do this. -- No devtools, no portal/teleport (overlays are siblings under a `display:contents` wrapper, not in a true root portal). -- SSR + hydration exist (`RenderToHTML`/`ServeSSR`/`WithHydrate`) but are first-generation: hydration falls back to a full remount of a subtree on any tag mismatch (no fine-grained patch), `RenderToHTML` doesn't yet collect `` hints (title/meta) from the tree, and async/streaming rendering isn't supported (one synchronous pass — `AsyncBuilder` renders its Pending state). `gutter new` does not yet scaffold the SSR layout; copy `bench/ssr-demo/`. -- `Heading{Level}` now renders a real `

`–`

` (with `margin:0`; the theme spec owns sizing/weight), so screen-reader heading navigation and SEO structure work. `Link{Href}` renders a genuine crawlable anchor (empty `Href` falls back to a no-op JS link for `OnPressed`-driven links); `Image{Alt}` and `IconButton{Tooltip}` (→ `aria-label`) carry accessible names. Remaining a11y gaps: `Body`/`Caption`/`Badge`/`Text` are still ``s, there are no landmark roles (`