` anchor at the tree position), so overlays escape an ancestor's `overflow`/`transform`/stacking context. Popup/Drawer/BottomSheet render through it. The wasm runtime intercepts `Portal` in `newElement` → `portalElement`; SSR renders only the placeholder (the child is client-only, mounted into the root on hydrate).
+- Devtools: `gutter.Inspect()` returns the live element tree of every mounted root as a plain `InspectNode` data tree (Kind/Type/Tag/Key/Children, with `String()` + `Count()` — safe to log or assert on in tests); `gutter.EnableDevtools()` installs a `Ctrl+Shift+G` overlay that renders it. It's a structural inspector — no time-travel, prop editing, or profiler yet.
+- SSR + hydration (`RenderToHTML`/`RenderDocument`/`RenderDocumentCtx`/`ServeSSR`/`WithHydrate`): `RenderDocument` collects `` hints from `gutter.Head` widgets (title/meta/og/raw). **Async SSR**: a `State` implementing `gutter.SSRResolver` (AsyncBuilder does) resolves its `Load` synchronously during the render — the walk calls `ResolveSSR(ctx)` before `InitState`, so SSR emits the resolved UI, not the Pending placeholder; `RenderDocumentCtx`/`SSRHandler` thread the request context so loads honor deadlines. Caveat: without server→client data transfer the hydrating client re-runs the load (brief reflow on data-heavy views). **Hydration mismatch recovery** is fine-grained: on a tag mismatch only the offending element is recreated (with the correct tag) and the server-rendered descendant DOM is MOVED into it and hydrated — descendant node identity survives instead of the whole subtree being rebuilt — and a `console.warn` (`warnHydrationMismatch`) surfaces the divergence. Remaining gap: no HTTP **streaming** (chunked flush / Suspense-style) — SSR is one synchronous pass that blocks on async loads before sending. `gutter new --ssr` scaffolds a server-rendered + typed-RPC + hydration starter.
+- a11y: `Heading{Level}` renders a real ``–`` (margin:0; theme owns sizing). `Body`/`Caption` render ` ` (margin:0) for paragraph semantics — set `Body{Inline:true}` for a `` that flows in a line. `Scaffold` wraps Body in `` and Footer in `` (AppBar is ``) for landmark navigation; the overlays (Popup/Drawer/BottomSheet) carry `role="dialog"`/`aria-modal` (+ `aria-hidden` while closed) via `dialogAttrs`. `Link{Href}` is a crawlable anchor; `Image{Alt}`/`IconButton{Tooltip}` (→ `aria-label`) carry accessible names. Remaining gaps: `Badge`/`Text` are still ``s (visible text is accessible), no `` landmark yet, and most interactive widgets don't expose `aria-*` beyond the above — fuller ARIA coverage is future work.
+- `ListBuilder` supports uniform (`ItemHeight`) and variable (`ItemExtent func(i) float64`, backed by a prefix-sum offset cache + binary search in `listMetrics`) sizing, on either axis (`Direction: ListVertical|ListHorizontal`, bounded by `Height`/`Width`). The offset cache rebuilds when `ItemCount` changes — if `ItemExtent`'s results change without a count change, remount via `WithKey`. Per-item extents are still declared up front (no DOM measurement), so genuinely content-measured rows aren't supported.
- Form-element controlled inputs sync through DOM properties on `OnMount` (`setStringPropIfDifferent` for text inputs/textareas to preserve caret, `setBoolProp` for checkboxes/radios, `setStringProp` for sliders/selects) because `applyAttrs` only writes the default-state attribute and re-writing `value` via setAttribute on every keystroke would move the caret to the end.
-- Router (`widgets.Router`) parses query strings (`Router.Query()` returns `url.Values`, and matching strips the query so `/user/42?tab=x` still matches `/user/:id`) but still has no nested routers, no guards/redirects, and no route transitions. Wrap the `RouteBuilder` if you need those.
-- `AsyncBuilder` cannot detect when its `Load` closure has changed (Go function values are not comparable). To force a fresh invocation when inputs change, wrap it in `widgets.WithKey` with a key derived from those inputs.
-- Ambient DI exists (`Provider[T]`/`DependOn[T]`) but without fine-grained dependency invalidation: a changed `Provider.Value` propagates on the next top-down rebuild of that Provider, not via targeted dependent notifications. For frequently-changing cross-tree state, provide a `gutter.Notifier[T]` and read it with `widgets.ObserverBuilder`. `Theme` is not yet routed through this mechanism (it remains a dedicated `BuildContext` field).
+- Router (`widgets.Router`) parses query strings (`Router.Query()` returns `url.Values`, and matching strips the query so `/user/42?tab=x` still matches `/user/:id`) and supports **guards/redirects**: pass `NavGuard func(to string) string` values to `NewRouter` (or add them with `Guard`) and every navigation — `Push`/`Replace`, browser back/forward, and the initial seed — runs through them (re-checked until stable, capped against redirect loops). Still no nested routers and no route transitions.
+- `AsyncBuilder` re-runs `Load` when `Deps []any` changes across a parent rebuild (compared with `reflect.DeepEqual` in `DidUpdateWidget`); leave `Deps` nil to load once per mount. Go function values aren't comparable, so list the inputs `Load` closes over in `Deps`. `widgets.WithKey` still works as a heavier remount-everything alternative.
+- Ambient DI: `Provider[T]`/`DependOn[T]`, plus `ThemeProvider{Theme, Child}` which routes a theme through the same inherited scope so a subtree can override the theme (correct under isolated SetState rebuilds and on SSR; `activeTheme` prefers it over `BuildContext.Theme`). A `Provider.Value` is an immutable snapshot — it changes only when the Provider is rebuilt, which already propagates top-down — so there is intentionally no separate dependent-tracking invalidation; for reactive cross-tree state use `Provider[*gutter.Notifier[T]]` (or any `Listenable`) read by `widgets.ObserverBuilder`, which rebuilds exactly the observers.
diff --git a/app_stub.go b/app_stub.go
index 9d4c527..cd874bc 100644
--- a/app_stub.go
+++ b/app_stub.go
@@ -26,3 +26,14 @@ func MountInto(selector string, root Widget, opts ...Option) *App {
func MountWhenVisible(selector string, root Widget, opts ...Option) {
panic("gutter: MountWhenVisible is only available when built with GOOS=js GOARCH=wasm")
}
+
+// Transition just runs fn on host builds — there is no rebuild scheduler during
+// SSR, so the priority distinction is meaningful only under GOOS=js GOARCH=wasm.
+func Transition(fn func()) { fn() }
+
+// Inspect returns nil on host builds — there is no live element tree to walk
+// outside the browser runtime.
+func Inspect() []InspectNode { return nil }
+
+// EnableDevtools is a no-op on host builds; the overlay needs the browser DOM.
+func EnableDevtools() {}
diff --git a/app_wasm.go b/app_wasm.go
index 89fe018..79d2917 100644
--- a/app_wasm.go
+++ b/app_wasm.go
@@ -64,6 +64,7 @@ func MountInto(selector string, root Widget, opts ...Option) *App {
} else {
a.root.mount(container, js.Null(), a.ctx)
}
+ registerApp(a) // track for Inspect()/EnableDevtools
return a
}
diff --git a/cmd/gutter/build.go b/cmd/gutter/build.go
index 5bb36cb..473bec7 100644
--- a/cmd/gutter/build.go
+++ b/cmd/gutter/build.go
@@ -3,10 +3,13 @@
package main
import (
+ "compress/gzip"
"fmt"
+ "io"
"os"
"os/exec"
"path/filepath"
+ "strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
@@ -37,6 +40,13 @@ const nginxConfTemplate = `server {
image/jpeg jpg jpeg;
}
+ # Serve the *.gz written by ` + "`gutter build`" + ` directly (best ratio, zero CPU),
+ # and gzip anything else compressible on the fly. Cuts app.wasm transfer ~3-4x.
+ gzip_static on;
+ gzip on;
+ gzip_types application/wasm application/javascript text/css image/svg+xml application/json;
+ gzip_min_length 1024;
+
location / {
try_files $uri $uri/ /index.html;
}
@@ -72,11 +82,82 @@ func runBuild(tinygo bool) error {
if err := bundleInto(outDir, true, tinygo); err != nil {
return err
}
+ if err := precompressDist(outDir); err != nil {
+ return err
+ }
fmt.Println()
printInfo("Output: %s", styleAccent.Render("./"+outDir+"/"))
return nil
}
+// precompressDist writes a max-level gzip sibling (file.gz) next to every
+// compressible asset in dir. The SSR server (gutter.Serve) and a
+// gzip_static-enabled nginx both serve these pre-compressed bytes directly —
+// best ratio, zero per-request CPU. app.wasm is the big win (~3-4x smaller).
+// Small files aren't worth a separate request; existing .gz are refreshed.
+func precompressDist(dir string) error {
+ printTitle("Pre-compressing assets")
+ compressed := 0
+ err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() {
+ return err
+ }
+ ext := strings.ToLower(filepath.Ext(p))
+ if !gzipWorthExt(ext) || info.Size() < 1024 {
+ return nil
+ }
+ gzPath := p + ".gz"
+ if err := gzipFile(p, gzPath); err != nil {
+ return fmt.Errorf("gzip %s: %w", p, err)
+ }
+ compressed++
+ if oi, statErr := os.Stat(gzPath); statErr == nil && info.Size() > 0 {
+ printOK("%s %d KB → %d KB (%.0f%% smaller)", filepath.Base(p),
+ info.Size()/1024, oi.Size()/1024,
+ 100*(1-float64(oi.Size())/float64(info.Size())))
+ }
+ return nil
+ })
+ if err == nil && compressed == 0 {
+ printDim("(no compressible assets ≥1 KB found)")
+ }
+ return err
+}
+
+// gzipWorthExt mirrors the server's compressibleByExt: text-like + wasm.
+func gzipWorthExt(ext string) bool {
+ switch ext {
+ case ".wasm", ".js", ".mjs", ".css", ".html", ".htm", ".json", ".svg", ".xml", ".txt", ".map":
+ return true
+ }
+ return false
+}
+
+func gzipFile(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ out, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ gz, err := gzip.NewWriterLevel(out, gzip.BestCompression)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(gz, in); err != nil {
+ gz.Close()
+ return err
+ }
+ if err := gz.Close(); err != nil {
+ return err
+ }
+ return out.Close()
+}
+
// bundleInto compiles the project to WASM and assembles the supporting assets
// inside outDir. It creates outDir if missing, writes app.wasm, copies
// wasm_exec.js, and copies index.html and public/ when those exist in the
diff --git a/cmd/gutter/gutter.ico b/cmd/gutter/gutter.ico
new file mode 100644
index 0000000..be03e6b
Binary files /dev/null and b/cmd/gutter/gutter.ico differ
diff --git a/cmd/gutter/main.go b/cmd/gutter/main.go
index cb3b438..46a8691 100644
--- a/cmd/gutter/main.go
+++ b/cmd/gutter/main.go
@@ -12,7 +12,7 @@ import (
"github.com/spf13/cobra"
)
-const version = "0.5.0"
+const version = "0.6.0"
func main() {
root := &cobra.Command{
diff --git a/cmd/gutter/new.go b/cmd/gutter/new.go
index d295286..66d052a 100644
--- a/cmd/gutter/new.go
+++ b/cmd/gutter/new.go
@@ -3,6 +3,7 @@
package main
import (
+ _ "embed"
"fmt"
"os"
"os/exec"
@@ -14,6 +15,12 @@ import (
"github.com/spf13/cobra"
)
+// gutterICO is the Gutter brand icon, written into a scaffolded project's
+// public/favicon.ico (served at /favicon.ico) and shown in the starter UI.
+//
+//go:embed gutter.ico
+var gutterICO []byte
+
const mainGoTemplate = `package main
import (
@@ -27,7 +34,7 @@ import (
func Root() gutter.Widget {
return widgets.Scaffold{
Title: "__NAME__",
- Theme: themes.Apple,
+ Theme: themes.Meta, // swap for themes.Apple or themes.Neutral
AppBar: widgets.AppBar{
Title: "__NAME__",
},
@@ -38,10 +45,15 @@ func Root() gutter.Widget {
Variant: widgets.CardFeature,
Child: widgets.Column{
CrossAxisAlign: widgets.CrossAxisCenter,
- Spacing: 16,
+ Spacing: 12,
Children: []gutter.Widget{
+ // "/favicon.ico" is an absolute path, so it resolves the
+ // same on every route (CSR and SSR alike).
+ widgets.Image{Src: "/favicon.ico", Alt: "Gutter", Width: "72px", Height: "72px"},
widgets.Heading{Level: widgets.H2, Text: "Hello, Gutter!"},
- widgets.Body{Text: "Pick a theme and ship — no CSS needed."},
+ // Gutter's slogan.
+ widgets.Body{Text: "Build for the web in Go — the Flutter way."},
+ widgets.Caption{Text: "A project by Runway Club"},
widgets.Button{
Variant: widgets.ButtonPrimary,
Label: "Get started",
@@ -51,13 +63,142 @@ func Root() gutter.Widget {
},
},
},
+ Footer: widgets.Center{
+ Child: widgets.Caption{Text: "© Runway Club · Powered by Gutter"},
+ },
}
}
+// headHTML mirrors the favicon + font s from index.html, injected into
+// the of SSR pages ("gutter run --ssr") so they get the same icon and
+// fonts as the client-rendered page. The SSR document already supplies charset,
+// viewport, and a margin reset, so this only adds the brand bits.
+const headHTML = " " +
+ " " +
+ " " +
+ " " +
+ " "
+
func main() {
// One entry for both modes: "gutter run" serves this client-side; "gutter
// run --ssr" builds the wasm and runs this same program as an SSR server.
- gutter.Serve(gutter.Config{Root: Root})
+ gutter.Serve(gutter.Config{Root: Root, Head: headHTML})
+}
+`
+
+// ssrMainGoTemplate is the --ssr scaffold: one main.go that server-renders for
+// fast first paint + SEO (gutter.Head), hydrates on the client, and demonstrates
+// typed client↔server RPC. `gutter run --ssr` builds the wasm then runs this same
+// program as the SSR server; `gutter run` serves it client-side.
+const ssrMainGoTemplate = `package main
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/Runway-Club/gutter"
+ "github.com/Runway-Club/gutter/rpc"
+ "github.com/Runway-Club/gutter/themes"
+ "github.com/Runway-Club/gutter/widgets"
+)
+
+// PingRequest/PingResponse are shared by the wasm client and the host server.
+// Change a field and BOTH sides fail to compile — the point of typed RPC.
+type PingRequest struct {
+ Name string ` + "`json:\"name\"`" + `
+}
+
+type PingResponse struct {
+ Message string ` + "`json:\"message\"`" + `
+}
+
+// Root is server-rendered for instant first paint + SEO (gutter.Head injects
+// / into the page ), then hydrated on the client. The same
+// Root runs on both sides — gutter.Serve compiles this program twice.
+func Root() gutter.Widget {
+ return gutter.Head{
+ Title: "__NAME__ — built with Gutter",
+ Meta: map[string]string{"description": "A server-rendered Gutter app with typed RPC."},
+ Property: map[string]string{"og:title": "__NAME__"},
+ Child: pinger{},
+ }
+}
+
+type pinger struct{}
+
+func (pinger) CreateState() gutter.State { return &pingerState{} }
+
+type pingerState struct {
+ gutter.StateObject
+ reply string
+ busy bool
+}
+
+func (s *pingerState) Build(ctx *gutter.BuildContext) gutter.Widget {
+ label := "Ping the server"
+ if s.busy {
+ label = "…"
+ }
+ reply := s.reply
+ if reply == "" {
+ reply = "(not called yet)"
+ }
+ return widgets.Scaffold{
+ Title: "__NAME__",
+ Theme: themes.Meta,
+ AppBar: widgets.AppBar{Title: "__NAME__"},
+ Body: widgets.Surface{Variant: widgets.SurfaceAlt, Child: widgets.Center{Child: widgets.Card{
+ Variant: widgets.CardFeature,
+ Child: widgets.Column{
+ CrossAxisAlign: widgets.CrossAxisCenter,
+ Spacing: 12,
+ Children: []gutter.Widget{
+ widgets.Image{Src: "/favicon.ico", Alt: "Gutter", Width: "72px", Height: "72px"},
+ widgets.Heading{Level: widgets.H2, Text: "Server-rendered Gutter"},
+ widgets.Body{Text: "First paint is HTML from the server; then wasm hydrates and this button calls Go over typed RPC."},
+ widgets.Button{Variant: widgets.ButtonPrimary, Label: label, OnPressed: s.ping},
+ widgets.Caption{Text: "Reply: " + reply},
+ },
+ },
+ }}},
+ Footer: widgets.Center{Child: widgets.Caption{Text: "© Runway Club · Powered by Gutter"}},
+ }
+}
+
+// ping calls the server. rpc.Call blocks the goroutine on the fetch, so run it
+// off the UI path and SetState the reply back in.
+func (s *pingerState) ping() {
+ if s.busy {
+ return
+ }
+ s.SetState(func() { s.busy = true })
+ go func() {
+ res, err := rpc.Call[PingRequest, PingResponse](context.Background(), PingRequest{Name: "world"})
+ s.SetState(func() {
+ s.busy = false
+ if err != nil {
+ s.reply = "error: " + err.Error()
+ } else {
+ s.reply = res.Message
+ }
+ })
+ }()
+}
+
+func main() {
+ // "gutter run" serves this client-side; "gutter run --ssr" builds the wasm
+ // and runs this same program as the SSR server (render + RPC + assets).
+ gutter.Serve(gutter.Config{
+ Root: Root,
+ Theme: themes.Meta,
+ // Registered once, runs only on the server. The shared PingRequest type
+ // keys the route; the client's rpc.Call reaches it with no extra wiring.
+ RPC: func() {
+ rpc.Handle(func(_ context.Context, r PingRequest) (PingResponse, error) {
+ return PingResponse{Message: fmt.Sprintf("Hello, %s! (from the Go server)", r.Name)}, nil
+ })
+ },
+ })
}
`
@@ -70,6 +211,7 @@ const indexHTMLTemplate = `
/user/42. Required for widgets.Router to survive page reloads. -->
__NAME__
+
@@ -123,24 +265,26 @@ var (
func newCmd() *cobra.Command {
var modulePath string
+ var ssr bool
cmd := &cobra.Command{
Use: "new [name]",
Short: "Scaffold a new gutter project",
- Long: "Scaffold a new gutter project. Without arguments, prompts interactively for project name and module path.",
+ Long: "Scaffold a new gutter project. Without arguments, prompts interactively for project name and module path. Pass --ssr for a server-rendered + typed-RPC starter.",
Args: cobra.MaximumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
var name string
if len(args) == 1 {
name = args[0]
}
- return runNew(name, modulePath)
+ return runNew(name, modulePath, ssr)
},
}
cmd.Flags().StringVarP(&modulePath, "module", "m", "", "Go module path (e.g. github.com/you/project)")
+ cmd.Flags().BoolVar(&ssr, "ssr", false, "scaffold a server-rendered (SSR) app with typed RPC + hydration")
return cmd
}
-func runNew(name, modulePath string) error {
+func runNew(name, modulePath string, ssr bool) error {
// Interactive mode: prompt for any missing values.
if name == "" || modulePath == "" {
fields := []huh.Field{}
@@ -184,8 +328,12 @@ func runNew(name, modulePath string) error {
return err
}
+ mainTmpl := mainGoTemplate
+ if ssr {
+ mainTmpl = ssrMainGoTemplate
+ }
files := map[string]string{
- "main.go": strings.ReplaceAll(mainGoTemplate, "__NAME__", name),
+ "main.go": strings.ReplaceAll(mainTmpl, "__NAME__", name),
"index.html": strings.ReplaceAll(indexHTMLTemplate, "__NAME__", name),
"go.mod": strings.ReplaceAll(goModTemplate, "__MODULE__", modulePath),
".gitignore": gitignoreTemplate,
@@ -201,6 +349,16 @@ func runNew(name, modulePath string) error {
}
}
+ // The Gutter brand icon. public/ is copied to the dist root by the build, so
+ // this lands at /favicon.ico — referenced by index.html and the starter UI.
+ favicon := filepath.Join(name, "public", "favicon.ico")
+ if err := os.MkdirAll(filepath.Dir(favicon), 0o755); err != nil {
+ return fmt.Errorf("mkdir %s: %w", filepath.Dir(favicon), err)
+ }
+ if err := os.WriteFile(favicon, gutterICO, 0o644); err != nil {
+ return fmt.Errorf("write %s: %w", favicon, err)
+ }
+
printTitle("Project scaffolded")
printOK("Created %s/", styleAccent.Render(name))
printDim(" module: %s", modulePath)
@@ -215,7 +373,12 @@ func runNew(name, modulePath string) error {
if !gotLatest {
printDim(" go get github.com/Runway-Club/gutter@latest")
}
- printDim(" gutter run dev")
+ if ssr {
+ printDim(" gutter run --ssr # server-rendered + typed RPC")
+ printDim(" gutter run dev # or client-side with live reload")
+ } else {
+ printDim(" gutter run dev")
+ }
fmt.Println()
printDim("(Local checkout? Add a replace directive to %s/go.mod pointing at your gutter checkout.)", name)
return nil
diff --git a/element_wasm.go b/element_wasm.go
index 0d84758..4a5e27f 100644
--- a/element_wasm.go
+++ b/element_wasm.go
@@ -49,6 +49,11 @@ type Element interface {
// mounting it. The element has no DOM until mount is called.
func newElement(w Widget) Element {
wt := reflect.TypeOf(w)
+ // Portal is intercepted before the HostWidget branch: instead of rendering
+ // its placeholder, the runtime teleports its Child into the portal root.
+ if p, ok := w.(Portal); ok {
+ return &portalElement{w: p, wt: wt}
+ }
switch x := w.(type) {
case HostWidget:
return &hostElement{w: x, wt: wt}
@@ -60,6 +65,19 @@ func newElement(w Widget) Element {
panic("gutter: value does not implement HostWidget, StatelessWidget, or StatefulWidget")
}
+// portalRoot returns the shared body-level container all Portals mount into,
+// creating it on first use.
+func portalRoot() js.Value {
+ doc := js.Global().Get("document")
+ root := doc.Call("getElementById", "gutter-portal-root")
+ if root.IsNull() {
+ root = doc.Call("createElement", "div")
+ root.Call("setAttribute", "id", "gutter-portal-root")
+ doc.Get("body").Call("appendChild", root)
+ }
+ return root
+}
+
func widgetKey(w Widget) any {
if k, ok := w.(Keyed); ok {
return k.WidgetKey()
@@ -68,12 +86,33 @@ func widgetKey(w Widget) any {
}
// canUpdate reports whether an existing Element can be reused in place to
-// represent newW. We require both the Go type and the key to match.
+// represent newW. We require the Go type and key to match, and — for
+// HostWidgets — the rendered tag too.
func canUpdate(old Element, newW Widget) bool {
if old.widgetType() != reflect.TypeOf(newW) {
return false
}
- return old.key() == widgetKey(newW)
+ if old.key() != widgetKey(newW) {
+ return false
+ }
+ // Same Go type but a different rendered tag (a HostWidget that varies its
+ // Host().Tag by its fields) can't be updated in place: attribute-diffing a
+ // into a
leaves the wrong element in the DOM. Force a remount.
+ if he, ok := old.(*hostElement); ok {
+ if hw, ok := newW.(HostWidget); ok && normTag(he.host) != normTag(hw.Host()) {
+ return false
+ }
+ }
+ return true
+}
+
+// normTag is the effective tag of a Host, normalizing the empty default to the
+// "div" that mount's createElement uses.
+func normTag(h *Host) string {
+ if h == nil || h.Tag == "" {
+ return "div"
+ }
+ return h.Tag
}
// reconcile is the single-child counterpart of reconcileChildren. It either
@@ -215,14 +254,22 @@ func (e *hostElement) mount(parent, before js.Value, ctx *BuildContext) {
func (e *hostElement) hydrate(node js.Value, ctx *BuildContext) {
e.host = e.w.Host()
e.parent = node.Get("parentNode")
- // Tag mismatch → the client built a different element than the server.
- // Can't adopt: mount a fresh node in place and drop the stale SSR one.
- if !sameTag(node, e.host.Tag) {
- e.mount(e.parent, node, ctx)
+ if sameTag(node, e.host.Tag) {
+ e.node = node
+ } else {
+ // Tag mismatch: the client built a different element than the server.
+ // Recover fine-grainedly — recreate ONLY this element with the right tag
+ // and MOVE the server-rendered children into it, so their (potentially
+ // large) DOM subtrees survive and still get hydrated, instead of
+ // discarding the whole subtree. Warn so the divergence is visible.
+ warnHydrationMismatch(node, e.host.Tag)
+ e.node = js.Global().Get("document").Call("createElement", e.host.Tag)
+ for c := node.Get("firstChild"); !c.IsNull(); c = node.Get("firstChild") {
+ e.node.Call("appendChild", c)
+ }
+ e.parent.Call("insertBefore", e.node, node)
e.parent.Call("removeChild", node)
- return
}
- e.node = node
// Re-apply attrs/style/text idempotently so the client is authoritative
// even if the server markup drifted; then strip the SSR-only markers.
applyAttrs(e.node, nil, e.host.Attrs)
@@ -268,6 +315,22 @@ func sameTag(node js.Value, tag string) bool {
return strings.EqualFold(tn.String(), tag)
}
+// warnHydrationMismatch logs a console warning when the client builds a
+// different element tag than the server rendered — the usual cause of a
+// hydration recovery. Surfacing it helps developers find SSR/CSR divergence
+// (e.g. an AsyncBuilder whose pending and resolved states use different tags).
+func warnHydrationMismatch(node js.Value, want string) {
+ if want == "" {
+ want = "div"
+ }
+ got := "?"
+ if tn := node.Get("tagName"); !tn.IsUndefined() && !tn.IsNull() {
+ got = strings.ToLower(tn.String())
+ }
+ js.Global().Get("console").Call("warn",
+ "gutter: hydration mismatch — server rendered <"+got+">, client expected <"+strings.ToLower(want)+">; recreating the node and salvaging its children")
+}
+
func (e *hostElement) update(newW Widget, ctx *BuildContext) {
newHost := newW.(HostWidget).Host()
oldHost := e.host
@@ -523,6 +586,68 @@ func (e *statelessElement) unmount() {
}
}
+// =========== portalElement ===========
+
+// portalElement leaves a zero-size anchor at its tree position (so
+// the parent's reconciliation positioning is unaffected) and mounts its Child
+// into the shared body-level portal root instead.
+type portalElement struct {
+ w Portal
+ wt reflect.Type
+ anchor js.Value
+ root js.Value
+ child Element
+}
+
+func (e *portalElement) widget() Widget { return e.w }
+func (e *portalElement) widgetType() reflect.Type { return e.wt }
+func (e *portalElement) key() any { return widgetKey(e.w) }
+func (e *portalElement) dom() js.Value { return e.anchor }
+
+func (e *portalElement) mount(parent, before js.Value, ctx *BuildContext) {
+ doc := js.Global().Get("document")
+ e.anchor = doc.Call("createElement", "template")
+ e.anchor.Call("setAttribute", "data-gutter-portal", "1")
+ parent.Call("insertBefore", e.anchor, before)
+ e.root = portalRoot()
+ e.child = newElement(e.w.Child)
+ e.child.mount(e.root, js.Null(), ctx)
+}
+
+func (e *portalElement) hydrate(node js.Value, ctx *BuildContext) {
+ // node is the SSR placeholder . Adopt it as the
+ // anchor; Child wasn't server-rendered into the root, so mount it fresh.
+ if sameTag(node, "template") {
+ e.anchor = node
+ } else {
+ // Defensive: structure drifted — make our own anchor in place.
+ doc := js.Global().Get("document")
+ e.anchor = doc.Call("createElement", "template")
+ node.Get("parentNode").Call("insertBefore", e.anchor, node)
+ node.Get("parentNode").Call("removeChild", node)
+ }
+ e.root = portalRoot()
+ e.child = newElement(e.w.Child)
+ e.child.mount(e.root, js.Null(), ctx)
+}
+
+func (e *portalElement) update(newW Widget, ctx *BuildContext) {
+ e.w = newW.(Portal)
+ e.child = reconcile(e.root, e.child, e.w.Child, ctx)
+}
+
+func (e *portalElement) unmount() {
+ if e.child != nil {
+ e.child.unmount()
+ e.child = nil
+ }
+ if !e.anchor.IsUndefined() && !e.anchor.IsNull() {
+ if p := e.anchor.Get("parentNode"); !p.IsNull() && !p.IsUndefined() {
+ p.Call("removeChild", e.anchor)
+ }
+ }
+}
+
// =========== statefulElement ===========
type statefulElement struct {
@@ -655,14 +780,29 @@ func (e *statefulElement) unmount() {
//
// WASM Go runs on the single JS event loop, so no locking is needed: enqueue
// and flush never interleave.
+// There are two priorities. Urgent SetState (the default) drains on the next
+// microtask. Transition SetState — a SetState made inside Transition(fn) — drains
+// on a later macrotask (setTimeout 0), so it always yields to urgent work queued
+// in the meantime (e.g. a keystroke stays responsive while a transition rebuilds
+// a large filtered list). rebuild() is idempotent, so the rare element that sits
+// in both queues just rebuilds twice harmlessly.
var (
- rebuildQueue []*statefulElement
- rebuildQueued = map[*statefulElement]bool{}
- flushScheduled bool
- flushFn js.Func
+ rebuildQueue []*statefulElement
+ rebuildQueued = map[*statefulElement]bool{}
+ flushScheduled bool
+ flushFn js.Func
+ transitionQueue []*statefulElement
+ transitionQueued = map[*statefulElement]bool{}
+ transitionSched bool
+ transitionFn js.Func
+ inTransition bool // set while a Transition(fn) call runs
)
func enqueueRebuild(e *statefulElement) {
+ if inTransition {
+ enqueueTransition(e)
+ return
+ }
if rebuildQueued[e] {
return
}
@@ -681,6 +821,39 @@ func enqueueRebuild(e *statefulElement) {
js.Global().Call("queueMicrotask", flushFn)
}
+func enqueueTransition(e *statefulElement) {
+ if transitionQueued[e] {
+ return
+ }
+ transitionQueued[e] = true
+ transitionQueue = append(transitionQueue, e)
+ if transitionSched {
+ return
+ }
+ transitionSched = true
+ if transitionFn.IsUndefined() {
+ transitionFn = js.FuncOf(func(js.Value, []js.Value) any {
+ flushTransitions()
+ return nil
+ })
+ }
+ // A macrotask runs after the microtask checkpoint, so any urgent rebuild
+ // queued before this fires drains first.
+ js.Global().Call("setTimeout", transitionFn, 0)
+}
+
+// Transition runs fn with its SetState calls marked low-priority: they drain on
+// a macrotask, after any urgent updates. Mirrors React's startTransition — wrap
+// state changes that can lag behind input (search filtering, tab switches):
+//
+// gutter.Transition(func() { s.SetState(func() { s.query = q }) })
+func Transition(fn func()) {
+ prev := inTransition
+ inTransition = true
+ defer func() { inTransition = prev }()
+ fn()
+}
+
func flushRebuilds() {
// Snapshot and reset before rebuilding: a rebuild may itself call SetState,
// which must land in the next cycle's queue, not this drain.
@@ -692,3 +865,13 @@ func flushRebuilds() {
e.rebuild()
}
}
+
+func flushTransitions() {
+ queue := transitionQueue
+ transitionQueue = nil
+ transitionQueued = map[*statefulElement]bool{}
+ transitionSched = false
+ for _, e := range queue {
+ e.rebuild()
+ }
+}
diff --git a/element_wasm_test.go b/element_wasm_test.go
index 340964c..ebe8290 100644
--- a/element_wasm_test.go
+++ b/element_wasm_test.go
@@ -125,6 +125,96 @@ func TestReconcileReplacesDifferentType(t *testing.T) {
}
}
+// TestReconcileRemountsOnTagChange covers the tag-stability rule: a HostWidget
+// of the SAME Go type but a different rendered tag must remount (you can't morph
+// a into a
by attribute diffing), while an unchanged tag updates in
+// place and keeps its DOM node.
+func TestReconcileRemountsOnTagChange(t *testing.T) {
+ parent := freshParent()
+ el := reconcile(parent, nil, testHost{tag: "div", text: "x"}, testCtxVal)
+ divNode := el.dom()
+ if got := divNode.Get("tagName").String(); got != "DIV" {
+ t.Fatalf("initial tag = %q, want DIV", got)
+ }
+
+ // Same type, same tag → update in place, same node.
+ elSame := reconcile(parent, el, testHost{tag: "div", text: "y"}, testCtxVal)
+ if !elSame.dom().Equal(divNode) {
+ t.Error("same tag should update in place, not remount")
+ }
+ if divNode.Get("textContent").String() != "y" {
+ t.Errorf("in-place update text = %q, want y", divNode.Get("textContent").String())
+ }
+
+ // Same type, different tag → remount into a .
+ elDiff := reconcile(parent, elSame, testHost{tag: "span", text: "z"}, testCtxVal)
+ if elDiff.dom().Equal(divNode) {
+ t.Error("changed tag should remount, not reuse the node")
+ }
+ if got := elDiff.dom().Get("tagName").String(); got != "SPAN" {
+ t.Fatalf("remounted tag = %q, want SPAN", got)
+ }
+ if parent.Get("childNodes").Get("length").Int() != 1 {
+ t.Errorf("expected exactly 1 child after remount, got %d", parent.Get("childNodes").Get("length").Int())
+ }
+}
+
+// portalRootHas reports whether any child of #gutter-portal-root has the given
+// textContent (the portal root is shared across tests).
+func portalRootHas(text string) bool {
+ root := js.Global().Get("document").Call("getElementById", "gutter-portal-root")
+ if root.IsNull() {
+ return false
+ }
+ kids := root.Get("childNodes")
+ for i := range kids.Get("length").Int() {
+ if kids.Index(i).Get("textContent").String() == text {
+ return true
+ }
+ }
+ return false
+}
+
+func TestPortalTeleportsChild(t *testing.T) {
+ parent := freshParent()
+ el := newElement(Portal{Child: testHost{tag: "p", text: "ported"}})
+ el.mount(parent, js.Null(), testCtxVal)
+
+ // The tree position holds only the zero-size
anchor.
+ if n := parent.Get("childNodes").Get("length").Int(); n != 1 {
+ t.Fatalf("parent child count = %d, want 1 (the anchor)", n)
+ }
+ anchor := parent.Get("firstChild")
+ if got := anchor.Get("tagName").String(); got != "TEMPLATE" {
+ t.Fatalf("anchor tag = %q, want TEMPLATE", got)
+ }
+ if !el.dom().Equal(anchor) {
+ t.Error("portal dom() should be the anchor node")
+ }
+ // The child is teleported into the body-level portal root, not the parent.
+ if parent.Get("textContent").String() == "ported" {
+ t.Error("child should not be in the parent subtree")
+ }
+ if !portalRootHas("ported") {
+ t.Error("child not found in #gutter-portal-root")
+ }
+
+ // Update reconciles the child in place (in the portal root).
+ el.update(Portal{Child: testHost{tag: "p", text: "updated"}}, testCtxVal)
+ if !portalRootHas("updated") {
+ t.Error("updated child not found in portal root")
+ }
+
+ // Unmount removes the anchor and the teleported child.
+ el.unmount()
+ if n := parent.Get("childNodes").Get("length").Int(); n != 0 {
+ t.Errorf("anchor not removed on unmount: %d children remain", n)
+ }
+ if portalRootHas("updated") {
+ t.Error("teleported child not removed from portal root on unmount")
+ }
+}
+
// ---- keyed reconciliation ----
func childTexts(parent js.Value) []string {
@@ -322,6 +412,51 @@ func TestSetStateBatchesIntoOneRebuild(t *testing.T) {
}
}
+// A transition SetState must not drain on the urgent microtask flush; it waits
+// for the (macrotask) transition flush.
+func TestTransitionDefersBehindUrgent(t *testing.T) {
+ parent := freshParent()
+ el := newElement(batchWidget{}).(*statefulElement)
+ el.mount(parent, js.Null(), testCtxVal)
+ st := el.state.(*batchState)
+
+ Transition(func() { st.SetState(func() {}) })
+ flushRebuilds() // urgent drain — must NOT touch the transition update
+ if st.builds != 1 {
+ t.Fatalf("urgent flush rebuilt a transition update (builds=%d, want 1)", st.builds)
+ }
+ flushTransitions()
+ if st.builds != 2 {
+ t.Fatalf("transition flush did not rebuild (builds=%d, want 2)", st.builds)
+ }
+}
+
+// An urgent update flushes on the microtask even while a transition is pending
+// on another element — urgent work is never blocked by a transition.
+func TestUrgentFlushesWhileTransitionPending(t *testing.T) {
+ parent := freshParent()
+ a := newElement(batchWidget{}).(*statefulElement)
+ a.mount(parent, js.Null(), testCtxVal)
+ b := newElement(batchWidget{}).(*statefulElement)
+ b.mount(parent, js.Null(), testCtxVal)
+ sa, sb := a.state.(*batchState), b.state.(*batchState)
+
+ Transition(func() { sa.SetState(func() {}) }) // low priority
+ sb.SetState(func() {}) // urgent
+
+ flushRebuilds()
+ if sb.builds != 2 {
+ t.Fatalf("urgent update not flushed on microtask (builds=%d, want 2)", sb.builds)
+ }
+ if sa.builds != 1 {
+ t.Fatalf("transition flushed on the urgent microtask (builds=%d, want 1)", sa.builds)
+ }
+ flushTransitions()
+ if sa.builds != 2 {
+ t.Fatalf("transition not flushed (builds=%d, want 2)", sa.builds)
+ }
+}
+
func TestUnmountedElementNotRebuilt(t *testing.T) {
parent := freshParent()
el := newElement(batchWidget{}).(*statefulElement)
diff --git a/gutter.ico b/gutter.ico
new file mode 100644
index 0000000..be03e6b
Binary files /dev/null and b/gutter.ico differ
diff --git a/head.go b/head.go
new file mode 100644
index 0000000..ed43a8e
--- /dev/null
+++ b/head.go
@@ -0,0 +1,87 @@
+package gutter
+
+import (
+ "html"
+ "sort"
+ "strings"
+)
+
+// Head declares document- metadata for the subtree it wraps. During SSR
+// (RenderToHTML/RenderDocument/ServeSSR) the title, meta, and raw entries are
+// collected and injected into the page — giving server-rendered pages a
+// real and meta tags for SEO and social previews. On the client it sets
+// document.title via SetTitle (the rest of the head is already present from the
+// server render). Head is transparent in layout: it renders exactly its Child,
+// so it can wrap the app root or any subtree without affecting the DOM tree.
+//
+// gutter.Head{
+// Title: "Products — Acme",
+// Meta: map[string]string{"description": "Everything Acme makes."},
+// Property: map[string]string{"og:title": "Acme Products"},
+// Child: app,
+// }
+type Head struct {
+ // Title sets (SSR) and document.title (client).
+ Title string
+ // Meta maps a to its content, e.g. "description": "...".
+ Meta map[string]string
+ // Property maps a to its content for Open Graph/Twitter,
+ // e.g. "og:title": "...".
+ Property map[string]string
+ // Raw is extra head HTML appended verbatim, e.g. a .
+ Raw []string
+ // Child is the wrapped subtree, rendered in place of Head.
+ Child Widget
+}
+
+// Build makes Head a StatelessWidget: it sets the title on the client and
+// renders its Child unchanged. Head HTML collection happens in the SSR walk.
+func (h Head) Build(ctx *BuildContext) Widget {
+ if h.Title != "" {
+ SetTitle(h.Title)
+ }
+ return h.Child
+}
+
+// headProvider is implemented by widgets that contribute to the document head
+// during SSR. The walk in ssr.go checks for it and accumulates the result.
+type headProvider interface {
+ headHTML() string
+}
+
+func (h Head) headHTML() string {
+ var b strings.Builder
+ if h.Title != "" {
+ b.WriteString("")
+ b.WriteString(html.EscapeString(h.Title))
+ b.WriteString(" ")
+ }
+ writeMetaTags(&b, "name", h.Meta)
+ writeMetaTags(&b, "property", h.Property)
+ for _, raw := range h.Raw {
+ b.WriteString(raw)
+ }
+ return b.String()
+}
+
+// writeMetaTags emits for each entry, sorted
+// for deterministic output (golden tests, stable diffs).
+func writeMetaTags(b *strings.Builder, attr string, m map[string]string) {
+ if len(m) == 0 {
+ return
+ }
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ b.WriteString(" `)
+ }
+}
diff --git a/hydrate_wasm_test.go b/hydrate_wasm_test.go
index 425fd7f..dfac549 100644
--- a/hydrate_wasm_test.go
+++ b/hydrate_wasm_test.go
@@ -91,3 +91,28 @@ func TestHydrateTagMismatchFallback(t *testing.T) {
t.Fatalf("textContent = %q, want new", node.Get("textContent").String())
}
}
+
+// On a tag mismatch, recovery is fine-grained: only the mismatched element is
+// recreated, while its server-rendered descendant DOM is MOVED into the new
+// node (identity preserved) rather than the whole subtree being rebuilt.
+func TestHydrateTagMismatchSalvagesChildren(t *testing.T) {
+ parent := freshParent()
+ parent.Set("innerHTML", "")
+ deepNode := parent.Get("firstChild").Get("firstChild") // the
+
+ // Client renders (mismatches
) containing the same child.
+ w := testHost{tag: "div", children: []Widget{testHost{tag: "b", text: "deep"}}}
+ el := newElement(w)
+ el.hydrate(parent.Get("children").Index(0), testCtxVal)
+
+ root := el.dom()
+ if root.Get("tagName").String() != "DIV" {
+ t.Fatalf("root tag = %q, want DIV", root.Get("tagName").String())
+ }
+ if !root.Get("firstChild").Equal(deepNode) {
+ t.Error("descendant was not salvaged across the parent tag mismatch")
+ }
+ if root.Get("firstChild").Get("textContent").String() != "deep" {
+ t.Errorf("salvaged child text = %q, want deep", root.Get("firstChild").Get("textContent").String())
+ }
+}
diff --git a/inspect.go b/inspect.go
new file mode 100644
index 0000000..03087b1
--- /dev/null
+++ b/inspect.go
@@ -0,0 +1,53 @@
+package gutter
+
+import "strings"
+
+// InspectNode is a snapshot of one node in the live element tree, produced by
+// Inspect() for devtools and debugging. It is a plain data tree (no DOM
+// handles), so it is safe to log, diff, or assert on in tests.
+type InspectNode struct {
+ Kind string // "host" | "stateless" | "stateful" | "portal"
+ Type string // the widget's Go type, e.g. "widgets.Button"
+ Tag string // DOM tag for host nodes; "" otherwise
+ Key string // reconciliation key, "" if unkeyed
+ Children []InspectNode
+}
+
+// String renders the node and its descendants as an indented tree, one element
+// per line — the text the devtools overlay displays.
+func (n InspectNode) String() string {
+ var b strings.Builder
+ n.write(&b, 0)
+ return b.String()
+}
+
+func (n InspectNode) write(b *strings.Builder, depth int) {
+ b.WriteString(strings.Repeat(" ", depth))
+ b.WriteString(n.Kind)
+ if n.Type != "" {
+ b.WriteByte(' ')
+ b.WriteString(n.Type)
+ }
+ if n.Tag != "" {
+ b.WriteString(" <")
+ b.WriteString(n.Tag)
+ b.WriteByte('>')
+ }
+ if n.Key != "" {
+ b.WriteString(" key=")
+ b.WriteString(n.Key)
+ }
+ b.WriteByte('\n')
+ for _, c := range n.Children {
+ c.write(b, depth+1)
+ }
+}
+
+// Count returns the total number of nodes in the subtree (self + descendants).
+func (n InspectNode) Count() int {
+ total := 1
+ for _, c := range n.Children {
+ total += c.Count()
+ }
+ return total
+}
diff --git a/inspect_test.go b/inspect_test.go
new file mode 100644
index 0000000..65254f8
--- /dev/null
+++ b/inspect_test.go
@@ -0,0 +1,20 @@
+package gutter
+
+import "testing"
+
+func TestInspectNodeStringAndCount(t *testing.T) {
+ n := InspectNode{Kind: "stateful", Type: "app.Counter", Children: []InspectNode{
+ {Kind: "host", Type: "widgets.Styled", Tag: "div", Children: []InspectNode{
+ {Kind: "host", Tag: "button", Key: "k1"},
+ }},
+ }}
+ want := "stateful app.Counter\n" +
+ " host widgets.Styled \n" +
+ " host
key=k1\n"
+ if got := n.String(); got != want {
+ t.Fatalf("String() =\n%q\nwant\n%q", got, want)
+ }
+ if got := n.Count(); got != 3 {
+ t.Errorf("Count() = %d, want 3", got)
+ }
+}
diff --git a/inspect_wasm.go b/inspect_wasm.go
new file mode 100644
index 0000000..f1b5fe3
--- /dev/null
+++ b/inspect_wasm.go
@@ -0,0 +1,119 @@
+//go:build js && wasm
+
+package gutter
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+ "syscall/js"
+)
+
+// mountedApps tracks every root mounted by MountInto so Inspect/EnableDevtools
+// can walk the live element trees. It only grows (apps rarely unmount), which
+// is fine for a debug facility.
+var mountedApps []*App
+
+func registerApp(a *App) { mountedApps = append(mountedApps, a) }
+
+// Inspect returns the live element tree of every mounted app root as a plain
+// data tree, for devtools and debugging. Empty before anything mounts.
+func Inspect() []InspectNode {
+ out := make([]InspectNode, 0, len(mountedApps))
+ for _, a := range mountedApps {
+ if a != nil && a.root != nil {
+ out = append(out, inspectElement(a.root))
+ }
+ }
+ return out
+}
+
+func inspectElement(e Element) InspectNode {
+ switch x := e.(type) {
+ case *hostElement:
+ n := InspectNode{Kind: "host", Type: typeNameOf(x.wt), Tag: normTag(x.host), Key: keyString(x.key())}
+ for _, c := range x.children {
+ n.Children = append(n.Children, inspectElement(c))
+ }
+ return n
+ case *statelessElement:
+ n := InspectNode{Kind: "stateless", Type: typeNameOf(x.wt), Key: keyString(x.key())}
+ if x.child != nil {
+ n.Children = append(n.Children, inspectElement(x.child))
+ }
+ return n
+ case *statefulElement:
+ n := InspectNode{Kind: "stateful", Type: typeNameOf(x.wt), Key: keyString(x.key())}
+ if x.child != nil {
+ n.Children = append(n.Children, inspectElement(x.child))
+ }
+ return n
+ case *portalElement:
+ n := InspectNode{Kind: "portal", Type: typeNameOf(x.wt)}
+ if x.child != nil {
+ n.Children = append(n.Children, inspectElement(x.child))
+ }
+ return n
+ }
+ return InspectNode{Kind: "unknown"}
+}
+
+func typeNameOf(t reflect.Type) string {
+ if t == nil {
+ return ""
+ }
+ return t.String()
+}
+
+func keyString(k any) string {
+ if k == nil {
+ return ""
+ }
+ return fmt.Sprint(k)
+}
+
+// EnableDevtools installs a Ctrl+Shift+G keydown toggle that overlays the live
+// element tree (Inspect rendered as text) in a fixed panel. Call it once after
+// mounting (e.g. behind a build flag or env check) to inspect structure, types,
+// tags, and keys without external tooling.
+func EnableDevtools() {
+ doc := js.Global().Get("document")
+ var panel js.Value
+ visible := false
+
+ render := func() {
+ var b strings.Builder
+ for _, n := range Inspect() {
+ b.WriteString(n.String())
+ }
+ if b.Len() == 0 {
+ b.WriteString("(no mounted roots)")
+ }
+ panel.Set("textContent", b.String())
+ }
+
+ cb := js.FuncOf(func(_ js.Value, args []js.Value) any {
+ ev := args[0]
+ if !ev.Get("ctrlKey").Bool() || !ev.Get("shiftKey").Bool() || !strings.EqualFold(ev.Get("key").String(), "g") {
+ return nil
+ }
+ if panel.IsUndefined() || panel.IsNull() {
+ panel = doc.Call("createElement", "pre")
+ panel.Call("setAttribute", "id", "gutter-devtools")
+ panel.Get("style").Set("cssText",
+ "position:fixed;top:8px;right:8px;max-width:42vw;max-height:90vh;overflow:auto;"+
+ "z-index:2147483647;background:rgba(0,0,0,.85);color:#0f0;"+
+ "font:11px/1.4 ui-monospace,monospace;padding:8px;margin:0;border-radius:8px;white-space:pre")
+ doc.Get("body").Call("appendChild", panel)
+ }
+ visible = !visible
+ if visible {
+ render()
+ panel.Get("style").Set("display", "block")
+ } else {
+ panel.Get("style").Set("display", "none")
+ }
+ return nil
+ })
+ doc.Call("addEventListener", "keydown", cb)
+}
diff --git a/inspect_wasm_test.go b/inspect_wasm_test.go
new file mode 100644
index 0000000..31761e3
--- /dev/null
+++ b/inspect_wasm_test.go
@@ -0,0 +1,62 @@
+//go:build js && wasm
+
+package gutter
+
+import (
+ "syscall/js"
+ "testing"
+)
+
+func TestInspectWalksMountedTree(t *testing.T) {
+ doc := js.Global().Get("document")
+ c := doc.Call("createElement", "div")
+ c.Call("setAttribute", "id", "inspect-host")
+ doc.Get("body").Call("appendChild", c)
+
+ MountInto("#inspect-host", testHost{tag: "section", children: []Widget{
+ batchWidget{}, // a StatefulWidget
+ testHost{tag: "span", text: "x"},
+ }})
+
+ trees := Inspect()
+ if len(trees) == 0 {
+ t.Fatal("Inspect returned no roots")
+ }
+ root := trees[len(trees)-1] // the one we just mounted
+ if root.Kind != "host" || root.Tag != "section" {
+ t.Fatalf("root = %+v, want host ", root)
+ }
+ if len(root.Children) != 2 {
+ t.Fatalf("root has %d children, want 2", len(root.Children))
+ }
+ if root.Children[0].Kind != "stateful" {
+ t.Errorf("child 0 kind = %q, want stateful", root.Children[0].Kind)
+ }
+ if root.Children[1].Tag != "span" {
+ t.Errorf("child 1 tag = %q, want span", root.Children[1].Tag)
+ }
+}
+
+func TestEnableDevtoolsTogglesPanel(t *testing.T) {
+ EnableDevtools()
+ doc := js.Global().Get("document")
+
+ init := js.Global().Get("Object").New()
+ init.Set("key", "g")
+ init.Set("ctrlKey", true)
+ init.Set("shiftKey", true)
+ init.Set("bubbles", true)
+ ev := js.Global().Get("KeyboardEvent").New("keydown", init)
+ doc.Call("dispatchEvent", ev)
+
+ panel := doc.Call("getElementById", "gutter-devtools")
+ if panel.IsNull() || panel.IsUndefined() {
+ t.Fatal("devtools panel not created on Ctrl+Shift+G")
+ }
+ if panel.Get("style").Get("display").String() != "block" {
+ t.Errorf("panel display = %q, want block after first toggle", panel.Get("style").Get("display").String())
+ }
+ if panel.Get("textContent").String() == "" {
+ t.Error("panel should show the inspected tree text")
+ }
+}
diff --git a/portal.go b/portal.go
new file mode 100644
index 0000000..638a51c
--- /dev/null
+++ b/portal.go
@@ -0,0 +1,25 @@
+package gutter
+
+// Portal teleports its Child out of the normal tree position and mounts it into
+// a single body-level root (`#gutter-portal-root`) instead. This lets overlays
+// (Popup/Drawer/BottomSheet) escape an ancestor's `overflow:hidden`, `transform`,
+// or stacking context — a `position:fixed` child of a transformed ancestor is
+// otherwise positioned relative to that ancestor, not the viewport.
+//
+// At the original tree position Portal leaves only a zero-size
+// anchor, so sibling layout and reconciliation positioning are unaffected.
+//
+// SSR: Portal renders just the placeholder anchor — its Child is NOT
+// server-rendered (overlays are client-only and closed on first paint). On
+// hydration the client adopts the anchor and mounts Child into the portal root.
+type Portal struct {
+ Child Widget
+}
+
+// Host makes Portal a HostWidget for SSR and host builds: it renders the
+// zero-size placeholder anchor. The wasm runtime intercepts Portal in
+// newElement (see element_wasm.go) before this is used for mounting, and
+// teleports Child into the portal root instead.
+func (p Portal) Host() *Host {
+ return &Host{Tag: "template", Attrs: map[string]string{"data-gutter-portal": "1"}}
+}
diff --git a/ssr.go b/ssr.go
index 6ec261b..ea2048c 100644
--- a/ssr.go
+++ b/ssr.go
@@ -14,12 +14,27 @@ package gutter
// HTML for instant first paint + SEO, then ship app.wasm to take over.
import (
+ "context"
"fmt"
"html"
"sort"
"strings"
)
+// SSRResolver is implemented by a State that can resolve its asynchronous work
+// synchronously during server-side rendering, so SSR emits the resolved UI
+// instead of a loading placeholder. AsyncBuilder implements it: ResolveSSR runs
+// its Load with the render context and stores the result, and its InitState
+// then skips re-loading. The SSR walk calls ResolveSSR before InitState.
+//
+// Caveat: without server→client data transfer, the hydrating client re-runs the
+// async work (starting from its pending state), so a data-heavy view may briefly
+// reflow after hydration. SSR still gives crawlers and first paint the resolved
+// content.
+type SSRResolver interface {
+ ResolveSSR(ctx context.Context)
+}
+
// ssrVoidElements are HTML elements that have no closing tag or children.
var ssrVoidElements = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true,
@@ -32,40 +47,66 @@ var ssrVoidElements = map[string]bool{
// the same tokens they will at runtime. Returns an error if the tree contains a
// value that implements none of HostWidget/StatelessWidget/StatefulWidget.
func RenderToHTML(root Widget, opts ...Option) (string, error) {
+ _, body, err := RenderDocument(root, opts...)
+ return body, err
+}
+
+// RenderDocument renders the body HTML plus the HTML contributed by any
+// gutter.Head widgets in the tree (title/meta/raw). ServeSSR uses this so
+// server-rendered pages get a real and meta tags; call it directly if
+// you assemble your own document shell. The body is identical to RenderToHTML.
+func RenderDocument(root Widget, opts ...Option) (head, body string, err error) {
+ return RenderDocumentCtx(context.Background(), root, opts...)
+}
+
+// RenderDocumentCtx is RenderDocument with an explicit context, passed to any
+// SSRResolver (AsyncBuilder) so server-side data loads honor request deadlines
+// and cancellation. SSRHandler calls this with the HTTP request's context.
+func RenderDocumentCtx(rctx context.Context, root Widget, opts ...Option) (head, body string, err error) {
cfg := newRunConfig(opts)
ctx := &BuildContext{Theme: cfg.theme}
- var sb strings.Builder
- if err := ssrRender(&sb, root, ctx); err != nil {
- return "", err
+ var bodyB, headB strings.Builder
+ if err := ssrRender(&bodyB, &headB, rctx, root, ctx); err != nil {
+ return "", "", err
}
- return sb.String(), nil
+ return headB.String(), bodyB.String(), nil
}
-func ssrRender(sb *strings.Builder, w Widget, ctx *BuildContext) error {
+func ssrRender(sb, head *strings.Builder, rctx context.Context, w Widget, ctx *BuildContext) error {
if w == nil {
return nil
}
+ // Widgets may contribute to the document (gutter.Head) regardless of
+ // what they render in the body.
+ if hp, ok := w.(headProvider); ok {
+ head.WriteString(hp.headHTML())
+ }
// Mirror newElement's dispatch order: Host, Stateful, Stateless.
switch x := w.(type) {
case HostWidget:
- return ssrRenderHost(sb, x.Host(), w, ctx)
+ return ssrRenderHost(sb, head, rctx, x.Host(), w, ctx)
case StatefulWidget:
st := x.CreateState()
if b, ok := st.(widgetBinder); ok {
b.bindWidget(w) // so State.Widget() works during Build
}
+ // Resolve async work synchronously (AsyncBuilder) BEFORE InitState, so
+ // the snapshot is Done by Build time and InitState skips re-loading.
+ if r, ok := st.(SSRResolver); ok {
+ r.ResolveSSR(rctx)
+ }
if init, ok := st.(StateInitializer); ok {
init.InitState()
}
// No bindElement: s.elem stays nil, so any SetState during Build is a
// no-op (see StateObject.SetState) — correct for a one-shot render.
- return ssrRender(sb, st.Build(ctx), ctx)
+ return ssrRender(sb, head, rctx, st.Build(ctx), ctx)
case StatelessWidget:
saved := ctx.inherited
if p, ok := x.(inheritedProvider); ok {
ctx.inherited = p.provideInto(ctx.inherited)
}
- err := ssrRender(sb, x.Build(ctx), ctx)
+ err := ssrRender(sb, head, rctx, x.Build(ctx), ctx)
ctx.inherited = saved
return err
default:
@@ -73,7 +114,7 @@ func ssrRender(sb *strings.Builder, w Widget, ctx *BuildContext) error {
}
}
-func ssrRenderHost(sb *strings.Builder, h *Host, w Widget, ctx *BuildContext) error {
+func ssrRenderHost(sb, head *strings.Builder, rctx context.Context, h *Host, w Widget, ctx *BuildContext) error {
if h == nil {
return nil
}
@@ -110,7 +151,7 @@ func ssrRenderHost(sb *strings.Builder, h *Host, w Widget, ctx *BuildContext) er
sb.WriteString(html.EscapeString(h.Text))
}
for _, child := range h.Children {
- if err := ssrRender(sb, child, ctx); err != nil {
+ if err := ssrRender(sb, head, rctx, child, ctx); err != nil {
return err
}
}
diff --git a/ssr_server.go b/ssr_server.go
index c71bcd8..35a9473 100644
--- a/ssr_server.go
+++ b/ssr_server.go
@@ -8,12 +8,15 @@ package gutter
// RunApp(Root(), WithHydrate()) so the same Root() drives both sides.
import (
+ "compress/gzip"
"errors"
"fmt"
+ "io"
"mime"
"net/http"
"os"
"path/filepath"
+ "strings"
"github.com/Runway-Club/gutter/themes"
)
@@ -28,7 +31,8 @@ type SSRConfig struct {
}
const ssrDocTmpl = ` ` +
- ` %s` +
+ ` ` +
+ `%s` +
` %s
` +
`` +
`` +
@@ -56,22 +60,122 @@ func SSRHandler(cfg SSRConfig) (http.Handler, error) {
if r.URL.Path != "/" {
p := filepath.Join(cfg.Dist, filepath.Clean("/"+r.URL.Path))
if st, err := os.Stat(p); err == nil && !st.IsDir() {
- http.ServeFile(w, r, p)
+ serveStaticAsset(w, r, p, st)
return
}
}
// Otherwise render the app. Unknown paths fall through to SSR so
- // client-side routes still get server-rendered HTML.
- html, err := RenderToHTML(cfg.Root(), opts...)
+ // client-side routes still get server-rendered HTML. Head hints from
+ // gutter.Head widgets in the tree are appended after cfg.Head (so the
+ // app's title/meta can override static fonts/favicon links).
+ treeHead, body, err := RenderDocumentCtx(r.Context(), cfg.Root(), opts...)
if err != nil {
http.Error(w, "gutter SSR render error: "+err.Error(), http.StatusInternalServerError)
return
}
+ doc := fmt.Sprintf(ssrDocTmpl, cfg.Head+treeHead, body)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- fmt.Fprintf(w, ssrDocTmpl, cfg.Head, html)
+ w.Header().Set("Vary", "Accept-Encoding")
+ if acceptsEncoding(r.Header.Get("Accept-Encoding"), "gzip") {
+ w.Header().Set("Content-Encoding", "gzip")
+ gz := gzip.NewWriter(w)
+ io.WriteString(gz, doc)
+ gz.Close()
+ return
+ }
+ io.WriteString(w, doc)
}), nil
}
+// serveStaticAsset serves one file from Dist with content negotiation. The big
+// payload is app.wasm — gzip cuts its transfer ~3-4x — so for compressible types
+// we (1) prefer a pre-compressed sibling written at build time (best ratio, zero
+// per-request CPU), (2) else gzip on the fly, (3) else serve plain. Stable
+// filenames (no content hash) can't be marked immutable, so Cache-Control:
+// no-cache asks the browser to revalidate — the Last-Modified conditional then
+// returns 304 (no re-download) while unchanged, yet picks up a rebuild instantly.
+func serveStaticAsset(w http.ResponseWriter, r *http.Request, path string, st os.FileInfo) {
+ ext := strings.ToLower(filepath.Ext(path))
+ w.Header().Set("Cache-Control", "no-cache")
+
+ if !compressibleByExt(ext) {
+ http.ServeFile(w, r, path)
+ return
+ }
+ w.Header().Set("Vary", "Accept-Encoding")
+ ae := r.Header.Get("Accept-Encoding")
+
+ // (1) A sibling pre-compressed at build time. Brotli beats gzip, so prefer it.
+ for _, enc := range []struct{ name, suffix string }{{"br", ".br"}, {"gzip", ".gz"}} {
+ if !acceptsEncoding(ae, enc.name) {
+ continue
+ }
+ if f, err := os.Open(path + enc.suffix); err == nil {
+ defer f.Close()
+ w.Header().Set("Content-Encoding", enc.name)
+ w.Header().Set("Content-Type", contentTypeByExt(ext))
+ // ServeContent keeps Range/304 working; modTime is the source file's
+ // so revalidation tracks the asset the client actually sees.
+ http.ServeContent(w, r, filepath.Base(path), st.ModTime(), f)
+ return
+ }
+ }
+ // (2) No sibling — gzip on the fly when the client accepts it.
+ if acceptsEncoding(ae, "gzip") {
+ f, err := os.Open(path)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer f.Close()
+ w.Header().Set("Content-Encoding", "gzip")
+ w.Header().Set("Content-Type", contentTypeByExt(ext))
+ gz := gzip.NewWriter(w)
+ _, _ = io.Copy(gz, f)
+ gz.Close()
+ return
+ }
+ // (3) Client can't take gzip — serve the raw bytes.
+ http.ServeFile(w, r, path)
+}
+
+// compressibleByExt reports whether a file extension is worth gzipping. Already-
+// compressed formats (png/jpg/woff2/…) are deliberately excluded.
+func compressibleByExt(ext string) bool {
+ switch ext {
+ case ".wasm", ".js", ".mjs", ".css", ".html", ".htm", ".json", ".svg", ".xml", ".txt", ".map":
+ return true
+ }
+ return false
+}
+
+func contentTypeByExt(ext string) string {
+ if ct := mime.TypeByExtension(ext); ct != "" {
+ return ct
+ }
+ return "application/octet-stream"
+}
+
+// acceptsEncoding reports whether an Accept-Encoding header offers enc with a
+// non-zero quality. Crude but covers the real cases ("gzip, deflate, br" and
+// "gzip;q=1.0"); an explicit "enc;q=0" disable is honored.
+func acceptsEncoding(header, enc string) bool {
+ for _, part := range strings.Split(header, ",") {
+ part = strings.TrimSpace(part)
+ name := part
+ if i := strings.IndexByte(part, ';'); i >= 0 {
+ name = strings.TrimSpace(part[:i])
+ if q := part[i:]; strings.Contains(q, "q=0") && !strings.Contains(q, "q=0.") {
+ continue // q=0 → explicitly not acceptable
+ }
+ }
+ if name == enc || name == "*" {
+ return true
+ }
+ }
+ return false
+}
+
// ServeSSR builds the handler and blocks serving it on cfg.Addr (default
// ":8080"). Returns the ListenAndServe error.
func ServeSSR(cfg SSRConfig) error {
diff --git a/ssr_server_test.go b/ssr_server_test.go
index 2f723ab..a56966f 100644
--- a/ssr_server_test.go
+++ b/ssr_server_test.go
@@ -3,7 +3,11 @@
package gutter
import (
+ "compress/gzip"
+ "io"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strings"
"testing"
)
@@ -38,8 +42,117 @@ func TestSSRHandlerRendersFullDoc(t *testing.T) {
}
}
+func TestSSRHandlerInjectsTreeHead(t *testing.T) {
+ h, _ := SSRHandler(SSRConfig{Root: func() Widget {
+ return Head{Title: "Injected", Meta: map[string]string{"description": "d"}, Child: ssrSrvBox{tag: "main", text: "x"}}
+ }})
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
+ body := rec.Body.String()
+ if !strings.Contains(body, "Injected ") || !strings.Contains(body, `content="d"`) {
+ t.Fatalf("doc missing injected head:\n%s", body)
+ }
+}
+
func TestSSRHandlerRequiresRoot(t *testing.T) {
if _, err := SSRHandler(SSRConfig{}); err == nil {
t.Fatal("expected error when Root is nil")
}
}
+
+// The SSR document must carry the same CSS reset the CSR index.html ships, or
+// the browser's default 8px margin shows up as stray padding.
+func TestSSRDocHasMarginReset(t *testing.T) {
+ h, _ := SSRHandler(SSRConfig{Root: func() Widget { return ssrSrvBox{tag: "main"} }})
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
+ if body := rec.Body.String(); !strings.Contains(body, "margin:0") || !strings.Contains(body, "100%;height:100%") {
+ t.Fatalf("doc missing CSS reset:\n%s", body)
+ }
+}
+
+func TestSSRHandlerGzipsHTML(t *testing.T) {
+ h, _ := SSRHandler(SSRConfig{Root: func() Widget { return ssrSrvBox{tag: "main", text: "hi"} }})
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("Accept-Encoding", "gzip, deflate, br")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if enc := rec.Header().Get("Content-Encoding"); enc != "gzip" {
+ t.Fatalf("Content-Encoding = %q, want gzip", enc)
+ }
+ if v := rec.Header().Get("Vary"); !strings.Contains(v, "Accept-Encoding") {
+ t.Fatalf("Vary = %q, want Accept-Encoding", v)
+ }
+ if got := gunzip(t, rec.Body.Bytes()); !strings.Contains(got, "hi ") {
+ t.Fatalf("decompressed body missing content:\n%s", got)
+ }
+}
+
+func TestServeStaticAssetGzipsWasmOnTheFly(t *testing.T) {
+ dir := t.TempDir()
+ want := strings.Repeat("wasmbytes\x00", 4096) // >1KB, compressible
+ if err := os.WriteFile(filepath.Join(dir, "app.wasm"), []byte(want), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ h, _ := SSRHandler(SSRConfig{Dist: dir, Root: func() Widget { return ssrSrvBox{tag: "main"} }})
+
+ req := httptest.NewRequest("GET", "/app.wasm", nil)
+ req.Header.Set("Accept-Encoding", "gzip")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if enc := rec.Header().Get("Content-Encoding"); enc != "gzip" {
+ t.Fatalf("Content-Encoding = %q, want gzip", enc)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "wasm") {
+ t.Fatalf("Content-Type = %q, want application/wasm", ct)
+ }
+ if got := gunzip(t, rec.Body.Bytes()); got != want {
+ t.Fatalf("decompressed wasm mismatch (%d vs %d bytes)", len(got), len(want))
+ }
+}
+
+// A pre-compressed sibling (app.wasm.gz) must be served verbatim in preference
+// to gzipping on the fly. We prove it by giving the sibling distinguishable
+// contents and asserting those bytes come back.
+func TestServeStaticAssetPrefersPrecompressedSibling(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "app.wasm"), []byte("RAW-uncompressed-source"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ sentinel := "SIBLING-PRECOMPRESSED-PAYLOAD"
+ var buf strings.Builder
+ gz := gzip.NewWriter(&buf)
+ io.WriteString(gz, sentinel)
+ gz.Close()
+ if err := os.WriteFile(filepath.Join(dir, "app.wasm.gz"), []byte(buf.String()), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ h, _ := SSRHandler(SSRConfig{Dist: dir, Root: func() Widget { return ssrSrvBox{tag: "main"} }})
+
+ req := httptest.NewRequest("GET", "/app.wasm", nil)
+ req.Header.Set("Accept-Encoding", "gzip")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if enc := rec.Header().Get("Content-Encoding"); enc != "gzip" {
+ t.Fatalf("Content-Encoding = %q, want gzip", enc)
+ }
+ if got := gunzip(t, rec.Body.Bytes()); got != sentinel {
+ t.Fatalf("served %q, want the pre-compressed sibling %q", got, sentinel)
+ }
+}
+
+func gunzip(t *testing.T, b []byte) string {
+ t.Helper()
+ zr, err := gzip.NewReader(strings.NewReader(string(b)))
+ if err != nil {
+ t.Fatalf("gzip.NewReader: %v", err)
+ }
+ out, err := io.ReadAll(zr)
+ if err != nil {
+ t.Fatalf("gunzip: %v", err)
+ }
+ return string(out)
+}
diff --git a/ssr_test.go b/ssr_test.go
index 4f86f95..eabec7b 100644
--- a/ssr_test.go
+++ b/ssr_test.go
@@ -35,6 +35,38 @@ type ssrWrap struct{ child Widget }
func (w ssrWrap) Build(*BuildContext) Widget { return w.child }
+func TestRenderDocumentCollectsHead(t *testing.T) {
+ root := Head{
+ Title: "My Page",
+ Meta: map[string]string{"description": "hello & welcome"},
+ Property: map[string]string{"og:title": "OG Title"},
+ Raw: []string{` `},
+ Child: ssrBox{tag: "main", text: "body"},
+ }
+ head, body, err := RenderDocument(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{
+ "My Page ",
+ ` `, // escaped
+ ` `,
+ ` `,
+ } {
+ if !strings.Contains(head, want) {
+ t.Fatalf("head missing %q:\n%s", want, head)
+ }
+ }
+ // Head is transparent: the body is exactly its Child, no extra wrapper.
+ if body != "body " {
+ t.Fatalf("body = %q, want transparent child render", body)
+ }
+ // RenderToHTML still returns just the body.
+ if onlyBody, _ := RenderToHTML(root); onlyBody != body {
+ t.Fatalf("RenderToHTML body = %q, want %q", onlyBody, body)
+ }
+}
+
type ssrCounter struct{ start int }
func (c ssrCounter) CreateState() State { return &ssrCounterState{n: c.start} }
diff --git a/theme_provider.go b/theme_provider.go
new file mode 100644
index 0000000..fb059d7
--- /dev/null
+++ b/theme_provider.go
@@ -0,0 +1,35 @@
+package gutter
+
+import (
+ "reflect"
+
+ "github.com/Runway-Club/gutter/themes"
+)
+
+// ThemeProvider overrides the active theme for its subtree. Unlike the single
+// BuildContext.Theme field (set app-wide by RunApp's WithTheme or by Scaffold),
+// ThemeProvider scopes a theme to Child only — nest it to theme a section
+// (a dark card, a branded panel) differently from the rest of the app:
+//
+// gutter.ThemeProvider{Theme: themes.Meta, Child: panel}
+//
+// It rides the same inherited-scope machinery as Provider, so it is correct
+// under isolated SetState rebuilds (each element restores the scope it lives
+// under) and on the SSR path. Themed widgets read it via the package-private
+// activeTheme, which prefers a ThemeProvider over BuildContext.Theme.
+type ThemeProvider struct {
+ Theme *themes.Theme
+ Child Widget
+}
+
+// Build renders the child; the theme is injected into the scope by the runtime.
+func (p ThemeProvider) Build(*BuildContext) Widget { return p.Child }
+
+func (p ThemeProvider) provideInto(parent map[reflect.Type]any) map[reflect.Type]any {
+ m := make(map[reflect.Type]any, len(parent)+1)
+ for k, v := range parent {
+ m[k] = v
+ }
+ m[reflect.TypeFor[*themes.Theme]()] = p.Theme
+ return m
+}
diff --git a/widgets/a11y_test.go b/widgets/a11y_test.go
new file mode 100644
index 0000000..1ca37c6
--- /dev/null
+++ b/widgets/a11y_test.go
@@ -0,0 +1,74 @@
+package widgets
+
+import (
+ "testing"
+
+ "github.com/Runway-Club/gutter"
+ "github.com/Runway-Club/gutter/themes"
+)
+
+// Body is prose, so it renders a (paragraph semantics for screen readers)
+// with the browser's default margin reset; Inline opts back into a .
+func TestBodyRendersParagraph(t *testing.T) {
+ h := hostOf(t, Body{Text: "x"})
+ wantTag(t, h, "p")
+ wantStyle(t, h, "margin", "0")
+
+ inline := hostOf(t, Body{Text: "x", Inline: true})
+ wantTag(t, inline, "span")
+}
+
+// Scaffold wraps Body in a landmark and Footer in a landmark
+// (AppBar already renders ) so assistive tech can navigate by region.
+func TestScaffoldLandmarks(t *testing.T) {
+ root := hostOfCtx(t, Scaffold{
+ Body: Body{Text: "content"},
+ Footer: Caption{Text: "foot"},
+ }, testCtx(themes.Apple))
+
+ var tags []string
+ for _, c := range root.Children {
+ tags = append(tags, hostOf(t, c).Tag)
+ }
+ if len(tags) != 2 || tags[0] != "main" || tags[1] != "footer" {
+ t.Fatalf("landmark tags = %v, want [main footer]", tags)
+ }
+}
+
+func TestDialogAttrs(t *testing.T) {
+ open := dialogAttrs(true)
+ if open["role"] != "dialog" || open["aria-modal"] != "true" {
+ t.Fatalf("open dialog attrs = %v", open)
+ }
+ if _, ok := open["aria-hidden"]; ok {
+ t.Errorf("an open dialog must not be aria-hidden")
+ }
+ if dialogAttrs(false)["aria-hidden"] != "true" {
+ t.Errorf("a closed dialog must be aria-hidden")
+ }
+}
+
+// The dialog attrs must actually reach the overlay sheet, which now lives
+// inside a Portal.
+func TestPopupSheetHasDialogRole(t *testing.T) {
+ ctx := testCtx(themes.Apple)
+ openSheet := portalSheet(t, popupRender(ctx, Popup{Child: Text{Data: "x"}}, true))
+ if openSheet.Attrs["role"] != "dialog" {
+ t.Fatalf("popup sheet role = %q, want dialog", openSheet.Attrs["role"])
+ }
+ closedSheet := portalSheet(t, popupRender(ctx, Popup{Child: Text{Data: "x"}}, false))
+ if closedSheet.Attrs["aria-hidden"] != "true" {
+ t.Errorf("closed popup sheet should be aria-hidden")
+ }
+}
+
+// portalSheet unwraps Portal → display:contents Styled → [backdrop, sheet] and
+// returns the sheet (index 1).
+func portalSheet(t *testing.T, w gutter.Widget) Styled {
+ t.Helper()
+ contents, ok := w.(gutter.Portal)
+ if !ok {
+ t.Fatalf("overlay root = %T, want gutter.Portal", w)
+ }
+ return contents.Child.(Styled).Children[1].(Styled)
+}
diff --git a/widgets/async_builder.go b/widgets/async_builder.go
index 349a046..724fcdb 100644
--- a/widgets/async_builder.go
+++ b/widgets/async_builder.go
@@ -2,6 +2,7 @@ package widgets
import (
"context"
+ "reflect"
"github.com/Runway-Club/gutter"
)
@@ -45,14 +46,19 @@ type AsyncSnapshot[T any] struct {
// },
// }
//
-// Load is invoked exactly once per mount. Go function values cannot be
-// compared, so the framework cannot tell when Load itself has changed across
-// a parent rebuild. To force a fresh invocation (e.g. when the resource ID
-// changes), wrap the AsyncBuilder in widgets.WithKey with a key derived from
-// the inputs — that causes the old subtree to unmount and a new one to mount.
+// Load is invoked on mount and again whenever Deps changes across a parent
+// rebuild. Go function values cannot be compared, so the framework cannot tell
+// when Load itself has changed — list the inputs Load depends on (e.g. a
+// resource ID) in Deps, and AsyncBuilder cancels the in-flight call, resets to
+// AsyncPending, and re-runs Load when any of them change (compared with
+// reflect.DeepEqual). Leave Deps nil to load exactly once per mount. Wrapping
+// in widgets.WithKey still works as a heavier alternative (it remounts the
+// whole subtree, discarding child state).
type AsyncBuilder[T any] struct {
Load func(ctx context.Context) (T, error)
Builder func(ctx *gutter.BuildContext, snapshot AsyncSnapshot[T]) gutter.Widget
+ // Deps are re-run triggers: when they change (DeepEqual), Load runs again.
+ Deps []any
}
func (a AsyncBuilder[T]) CreateState() gutter.State {
@@ -63,13 +69,50 @@ type asyncState[T any] struct {
gutter.StateObject
snapshot AsyncSnapshot[T]
cancel context.CancelFunc
+ deps []any
+ resolved bool // set when ResolveSSR loaded synchronously on the server
}
func (s *asyncState[T]) widget() AsyncBuilder[T] {
return s.Widget().(AsyncBuilder[T])
}
+// ResolveSSR (gutter.SSRResolver) runs Load synchronously during server-side
+// rendering so SSR emits the resolved UI instead of the pending placeholder.
+// Called before InitState, which then skips spawning the async load.
+func (s *asyncState[T]) ResolveSSR(ctx context.Context) {
+ s.resolved = true
+ s.deps = s.widget().Deps
+ load := s.widget().Load
+ if load == nil {
+ s.snapshot = AsyncSnapshot[T]{State: AsyncDone}
+ return
+ }
+ data, err := load(ctx)
+ if err != nil {
+ s.snapshot = AsyncSnapshot[T]{State: AsyncFailed, Error: err}
+ } else {
+ s.snapshot = AsyncSnapshot[T]{State: AsyncDone, Data: data}
+ }
+}
+
func (s *asyncState[T]) InitState() {
+ if s.resolved {
+ return // server already resolved Load synchronously (see ResolveSSR)
+ }
+ s.deps = s.widget().Deps
+ s.start()
+}
+
+// start cancels any in-flight Load, resets to Pending, and launches Load again.
+// Called on mount (InitState) and when Deps change (DidUpdateWidget). It sets
+// snapshot synchronously so the rebuild that follows shows Pending immediately;
+// the goroutine SetStates the result when Load returns.
+func (s *asyncState[T]) start() {
+ if s.cancel != nil {
+ s.cancel()
+ s.cancel = nil
+ }
load := s.widget().Load
if load == nil {
s.snapshot = AsyncSnapshot[T]{State: AsyncDone}
@@ -81,9 +124,10 @@ func (s *asyncState[T]) InitState() {
go func() {
data, err := load(ctx)
if ctx.Err() != nil {
- return
+ return // canceled (unmounted or superseded by a newer Deps) — drop result
}
s.SetState(func() {
+ s.cancel = nil
if err != nil {
s.snapshot = AsyncSnapshot[T]{State: AsyncFailed, Error: err}
} else {
@@ -93,6 +137,18 @@ func (s *asyncState[T]) InitState() {
}()
}
+// DidUpdateWidget re-runs Load when Deps changes. Per the WidgetUpdater
+// contract a rebuild follows unconditionally, so start()'s synchronous reset to
+// Pending is enough — no SetState needed here.
+func (s *asyncState[T]) DidUpdateWidget(gutter.Widget) {
+ next := s.widget().Deps
+ if depsEqual(s.deps, next) {
+ return
+ }
+ s.deps = next
+ s.start()
+}
+
func (s *asyncState[T]) Dispose() {
if s.cancel != nil {
s.cancel()
@@ -100,6 +156,20 @@ func (s *asyncState[T]) Dispose() {
}
}
+// depsEqual compares two dependency lists element-wise with reflect.DeepEqual,
+// which tolerates non-comparable elements (slices, maps) without panicking.
+func depsEqual(a, b []any) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if !reflect.DeepEqual(a[i], b[i]) {
+ return false
+ }
+ }
+ return true
+}
+
func (s *asyncState[T]) Build(ctx *gutter.BuildContext) gutter.Widget {
w := s.widget()
if w.Builder == nil {
diff --git a/widgets/async_builder_test.go b/widgets/async_builder_test.go
new file mode 100644
index 0000000..5ad8b25
--- /dev/null
+++ b/widgets/async_builder_test.go
@@ -0,0 +1,87 @@
+package widgets
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/Runway-Club/gutter"
+)
+
+func TestAsyncBuilderResolvesDuringSSR(t *testing.T) {
+ w := AsyncBuilder[string]{
+ Load: func(context.Context) (string, error) { return "loaded!", nil },
+ Builder: func(_ *gutter.BuildContext, snap AsyncSnapshot[string]) gutter.Widget {
+ if snap.State == AsyncDone {
+ return Text{Data: snap.Data}
+ }
+ return Text{Data: "pending"}
+ },
+ }
+ out, err := gutter.RenderToHTML(w)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out, "loaded!") || strings.Contains(out, "pending") {
+ t.Fatalf("SSR should render the resolved value, got: %s", out)
+ }
+}
+
+func TestAsyncBuilderSSRRendersError(t *testing.T) {
+ w := AsyncBuilder[string]{
+ Load: func(context.Context) (string, error) { return "", errors.New("boom") },
+ Builder: func(_ *gutter.BuildContext, snap AsyncSnapshot[string]) gutter.Widget {
+ if snap.State == AsyncFailed {
+ return Text{Data: "err:" + snap.Error.Error()}
+ }
+ return Text{Data: "other"}
+ },
+ }
+ out, _ := gutter.RenderToHTML(w)
+ if !strings.Contains(out, "err:boom") {
+ t.Fatalf("SSR should render the failed snapshot, got: %s", out)
+ }
+}
+
+func TestAsyncSSRUsesRenderContext(t *testing.T) {
+ type ctxKey struct{}
+ ctx := context.WithValue(context.Background(), ctxKey{}, "from-request")
+ var seen string
+ w := AsyncBuilder[string]{
+ Load: func(c context.Context) (string, error) {
+ seen, _ = c.Value(ctxKey{}).(string)
+ return "ok", nil
+ },
+ Builder: func(_ *gutter.BuildContext, snap AsyncSnapshot[string]) gutter.Widget {
+ return Text{Data: snap.Data}
+ },
+ }
+ if _, _, err := gutter.RenderDocumentCtx(ctx, w); err != nil {
+ t.Fatal(err)
+ }
+ if seen != "from-request" {
+ t.Errorf("Load received context value %q, want from-request", seen)
+ }
+}
+
+func TestDepsEqual(t *testing.T) {
+ cases := []struct {
+ name string
+ a, b []any
+ want bool
+ }{
+ {"both nil", nil, nil, true},
+ {"same scalars", []any{1, "x"}, []any{1, "x"}, true},
+ {"different len", []any{1}, []any{1, 2}, false},
+ {"different value", []any{1, "x"}, []any{1, "y"}, false},
+ {"nil vs empty", nil, []any{}, true},
+ {"non-comparable slices equal", []any{[]int{1, 2}}, []any{[]int{1, 2}}, true},
+ {"non-comparable slices differ", []any{[]int{1, 2}}, []any{[]int{1, 3}}, false},
+ }
+ for _, c := range cases {
+ if got := depsEqual(c.a, c.b); got != c.want {
+ t.Errorf("%s: depsEqual = %v, want %v", c.name, got, c.want)
+ }
+ }
+}
diff --git a/widgets/bottom_sheet.go b/widgets/bottom_sheet.go
index 5b48eae..60552a7 100644
--- a/widgets/bottom_sheet.go
+++ b/widgets/bottom_sheet.go
@@ -83,10 +83,10 @@ func bottomSheetRender(ctx *gutter.BuildContext, b BottomSheet, isOpen bool) gut
if b.Child != nil {
sheetChildren = []gutter.Widget{b.Child}
}
- sheet := Styled{Style: sheetStyle, Children: sheetChildren}
+ sheet := Styled{Attrs: dialogAttrs(isOpen), Style: sheetStyle, Children: sheetChildren}
- return Styled{
+ return gutter.Portal{Child: Styled{
Style: map[string]string{"display": "contents"},
Children: []gutter.Widget{backdrop, sheet},
- }
+ }}
}
diff --git a/widgets/drawer.go b/widgets/drawer.go
index 70b28ef..f12abbc 100644
--- a/widgets/drawer.go
+++ b/widgets/drawer.go
@@ -98,10 +98,10 @@ func drawerRender(ctx *gutter.BuildContext, d Drawer, isOpen bool) gutter.Widget
if d.Child != nil {
panelChildren = []gutter.Widget{d.Child}
}
- panel := Styled{Style: panelStyle, Children: panelChildren}
+ panel := Styled{Attrs: dialogAttrs(isOpen), Style: panelStyle, Children: panelChildren}
- return Styled{
+ return gutter.Portal{Child: Styled{
Style: map[string]string{"display": "contents"},
Children: []gutter.Widget{backdrop, panel},
- }
+ }}
}
diff --git a/widgets/heading.go b/widgets/heading.go
index 79b1c2c..57fe4d9 100644
--- a/widgets/heading.go
+++ b/widgets/heading.go
@@ -84,10 +84,11 @@ func headingSpec(t *themes.Theme, level HeadingLevel) themes.TextSpec {
// strong variant; Small drops to caption size; both together gives the
// strong-caption role. Color defaults to the theme's ink color.
type Body struct {
- Text string
- Bold bool
- Small bool
- Color string
+ Text string
+ Bold bool
+ Small bool
+ Color string
+ Inline bool // render a instead of a so the text flows inline
}
func (b Body) Build(ctx *gutter.BuildContext) gutter.Widget {
@@ -103,7 +104,19 @@ func (b Body) Build(ctx *gutter.BuildContext) gutter.Widget {
default:
spec = t.Typography.Body
}
- return Text{Data: b.Text, Style: styleFromSpec(spec, fallback(resolveColor(t, b.Color), t.Colors.Ink))}
+ // Render a real
(margin reset to 0) so prose has paragraph semantics for
+ // screen readers; the theme spec owns sizing/weight/color. Use Inline:true
+ // for a when the text must flow within a line.
+ tag := "p"
+ if b.Inline {
+ tag = "span"
+ }
+ style := map[string]string{
+ "color": fallback(resolveColor(t, b.Color), t.Colors.Ink),
+ "margin": "0",
+ }
+ applySpec(style, spec)
+ return Styled{Tag: tag, Text: b.Text, Style: style}
}
// Caption is shorthand for Body{Small: true}.
diff --git a/widgets/internal.go b/widgets/internal.go
index e94a9c4..a603397 100644
--- a/widgets/internal.go
+++ b/widgets/internal.go
@@ -5,12 +5,18 @@ import (
"github.com/Runway-Club/gutter/themes"
)
-// activeTheme returns the theme on ctx, falling back to the framework
-// default (Apple) so widgets don't panic when used outside a normal RunApp
-// (e.g. in unit tests that don't construct a BuildContext).
+// activeTheme returns the theme in effect at this build position: a subtree
+// gutter.ThemeProvider wins, then the app-wide BuildContext.Theme (set by
+// WithTheme/Scaffold), then the framework default (Apple) so widgets don't
+// panic when used outside a normal RunApp (e.g. in unit tests).
func activeTheme(ctx *gutter.BuildContext) *themes.Theme {
- if ctx != nil && ctx.Theme != nil {
- return ctx.Theme
+ if ctx != nil {
+ if t, ok := gutter.DependOn[*themes.Theme](ctx); ok && t != nil {
+ return t
+ }
+ if ctx.Theme != nil {
+ return ctx.Theme
+ }
}
return themes.Apple
}
@@ -35,19 +41,6 @@ func applySpec(style map[string]string, spec themes.TextSpec) {
}
}
-// styleFromSpec produces a TextStyle from a TextSpec plus an explicit color.
-// Used by the typography widgets (Heading, Body, Caption).
-func styleFromSpec(spec themes.TextSpec, color string) *TextStyle {
- return &TextStyle{
- Color: color,
- FontFamily: spec.FontFamily,
- FontSize: spec.FontSize,
- FontWeight: spec.FontWeight,
- LineHeight: spec.LineHeight,
- LetterSpacing: spec.LetterSpacing,
- }
-}
-
func fallback(value, def string) string {
if value != "" {
return value
@@ -55,6 +48,18 @@ func fallback(value, def string) string {
return def
}
+// dialogAttrs returns the ARIA attributes for a modal overlay sheet (Popup,
+// Drawer, BottomSheet). role=dialog + aria-modal lets screen readers treat it
+// as a modal; aria-hidden hides the always-mounted-but-closed sheet from the
+// accessibility tree so it isn't reachable while invisible.
+func dialogAttrs(open bool) map[string]string {
+ a := map[string]string{"role": "dialog", "aria-modal": "true"}
+ if !open {
+ a["aria-hidden"] = "true"
+ }
+ return a
+}
+
// propSyncHost is a HostWidget that exposes OnMount and OnUnmount alongside
// the usual tag/attrs/style/events bundle. It's used by input-style widgets
// (Checkbox, Switch, Slider, Select, RadioGroup) that need to imperatively
diff --git a/widgets/list.go b/widgets/list.go
index 58e7219..326e803 100644
--- a/widgets/list.go
+++ b/widgets/list.go
@@ -114,20 +114,38 @@ func (l List) Host() *gutter.Host {
// scrolling reveals new content into row 3, even though the underlying
// data row changed.
//
-// ItemHeight must be a fixed CSS-pixel value; variable-height rows would
-// require measurement and an offset cache, which this implementation does
-// not do. Horizontal virtualization is not supported — use a plain [List]
-// for horizontal scrolling.
+// Sizing along the scroll axis ("extent" = height when vertical, width when
+// horizontal) can be uniform or variable:
+//
+// - Uniform: set ItemHeight to a fixed CSS-pixel extent (the fast path —
+// offsets are pure arithmetic).
+// - Variable: set ItemExtent(index) to return each item's extent. The state
+// builds a prefix-sum offset cache once (rebuilt when ItemCount changes)
+// and binary-searches it to find the visible window. ItemExtent wins over
+// ItemHeight when both are set.
+//
+// Direction selects the axis: ListVertical (default) scrolls vertically and
+// bounds the viewport with Height; ListHorizontal scrolls horizontally and
+// bounds it with Width (ItemHeight/ItemExtent then describe item WIDTH).
type ListBuilder struct {
ItemCount int
ItemHeight float64
ItemBuilder func(index int) gutter.Widget
- // Height bounds the viewport. Required — without it the list grows
- // with its (virtual) content and never scrolls.
+ // ItemExtent, when non-nil, gives each item's extent along the scroll axis
+ // (variable-size rows). Takes precedence over ItemHeight.
+ ItemExtent func(index int) float64
+
+ // Direction is the scroll axis; ListVertical (default) or ListHorizontal.
+ Direction ListDirection
+
+ // Height/Width bound the viewport. The one along the scroll axis is
+ // required (Height for vertical, Width for horizontal) — without it the
+ // list grows with its (virtual) content and never scrolls.
Height string
+ Width string
- // Overscan is the number of extra items rendered above and below the
+ // Overscan is the number of extra items rendered before and after the
// visible window. Defaults to 3. Higher values reduce flashes during
// fast scrolls at the cost of more DOM nodes.
Overscan int
@@ -140,7 +158,9 @@ type listBuilderState struct {
cleanup func()
scrollOffset float64
viewportSize float64
- firstVisible int
+ winFirst int // last-rendered window bounds, used to gate rebuilds
+ winLast int
+ metricsCache *listMetrics
}
func (s *listBuilderState) currentWidget() ListBuilder { return s.Widget().(ListBuilder) }
@@ -152,89 +172,166 @@ func (s *listBuilderState) Dispose() {
}
}
+// listMetrics maps between item indices and scroll-axis offsets. The uniform
+// (fixed>0) path is arithmetic; the variable path uses a prefix-sum cache where
+// offsets[i] is the start of item i and offsets[count] is the total extent.
+type listMetrics struct {
+ count int
+ fixed float64
+ offsets []float64
+}
+
+func (m *listMetrics) total() float64 {
+ if m.fixed > 0 {
+ return m.fixed * float64(m.count)
+ }
+ if m.count == 0 {
+ return 0
+ }
+ return m.offsets[m.count]
+}
+
+func (m *listMetrics) offset(i int) float64 {
+ if m.fixed > 0 {
+ return m.fixed * float64(i)
+ }
+ return m.offsets[i]
+}
+
+func (m *listMetrics) extent(i int) float64 {
+ if m.fixed > 0 {
+ return m.fixed
+ }
+ return m.offsets[i+1] - m.offsets[i]
+}
+
+// indexAt returns the item index whose span contains offset, clamped to
+// [0, count-1].
+func (m *listMetrics) indexAt(offset float64) int {
+ if m.count == 0 {
+ return 0
+ }
+ if offset <= 0 {
+ return 0
+ }
+ if m.fixed > 0 {
+ return min(int(offset/m.fixed), m.count-1)
+ }
+ // Largest i with offsets[i] <= offset.
+ lo, hi := 0, m.count
+ for lo < hi {
+ mid := (lo + hi) / 2
+ if m.offsets[mid+1] <= offset {
+ lo = mid + 1
+ } else {
+ hi = mid
+ }
+ }
+ return min(lo, m.count-1)
+}
+
+// metrics returns the cached metrics for w, rebuilding when ItemCount or the
+// fixed extent changes. (Changing ItemExtent's results without changing
+// ItemCount won't invalidate the cache — remount via WithKey if extents change.)
+func (s *listBuilderState) metrics(w ListBuilder) *listMetrics {
+ stale := s.metricsCache == nil || s.metricsCache.count != w.ItemCount ||
+ (w.ItemExtent == nil && s.metricsCache.fixed != w.ItemHeight) ||
+ (w.ItemExtent != nil && s.metricsCache.fixed != 0)
+ if !stale {
+ return s.metricsCache
+ }
+ m := &listMetrics{count: w.ItemCount}
+ if w.ItemExtent != nil {
+ m.offsets = make([]float64, w.ItemCount+1)
+ for i := range w.ItemCount {
+ m.offsets[i+1] = m.offsets[i] + w.ItemExtent(i)
+ }
+ } else {
+ m.fixed = w.ItemHeight
+ }
+ s.metricsCache = m
+ return m
+}
+
+// virtualWindow returns the inclusive [first, last] item range to render for a
+// given scroll offset and viewport size (with overscan padding).
+func virtualWindow(m *listMetrics, scrollOffset, viewportSize float64, overscan int) (first, last int) {
+ if m.count == 0 {
+ return 0, -1
+ }
+ first = max(m.indexAt(scrollOffset)-overscan, 0)
+ last = min(m.indexAt(scrollOffset+viewportSize)+overscan, m.count-1)
+ return first, max(last, first)
+}
+
func (s *listBuilderState) Build(ctx *gutter.BuildContext) gutter.Widget {
w := s.currentWidget()
- if w.ItemHeight <= 0 || w.ItemCount <= 0 || w.ItemBuilder == nil {
+ if (w.ItemHeight <= 0 && w.ItemExtent == nil) || w.ItemCount <= 0 || w.ItemBuilder == nil {
return Styled{}
}
+ horizontal := w.Direction == ListHorizontal
overscan := w.Overscan
if overscan == 0 {
overscan = 3
}
+ m := s.metrics(w)
- // First-mount fallback: we don't know the viewport size until the
- // scroll listener fires once after OnMount. Render a sensible window
- // based on Height-as-string when it's a px literal, otherwise assume
- // a 400px viewport. The first scroll callback will SetState with the
- // real clientHeight and trigger a corrective rebuild.
+ // First-mount fallback: the viewport size isn't known until the scroll
+ // listener fires once after OnMount. Guess from the bounding dimension when
+ // it's a px literal, else assume 400px; the first scroll callback corrects it.
viewportSize := s.viewportSize
if viewportSize <= 0 {
- viewportSize = parsePxFallback(w.Height, 400)
- }
-
- visibleCount := int(viewportSize/w.ItemHeight) + 1 + overscan*2
- if visibleCount > w.ItemCount {
- visibleCount = w.ItemCount
- }
- firstVisible := s.firstVisible - overscan
- if firstVisible < 0 {
- firstVisible = 0
- }
- if firstVisible+visibleCount > w.ItemCount {
- firstVisible = w.ItemCount - visibleCount
- if firstVisible < 0 {
- firstVisible = 0
+ bound := w.Height
+ if horizontal {
+ bound = w.Width
}
+ viewportSize = parsePxFallback(bound, 400)
}
- items := make([]gutter.Widget, 0, visibleCount)
- for i := firstVisible; i < firstVisible+visibleCount && i < w.ItemCount; i++ {
- // Wrap each item in a fixed-height slot so the row geometry is
- // predictable regardless of the child's own sizing.
- items = append(items, Styled{
- Style: map[string]string{
- "height": fmt.Sprintf("%gpx", w.ItemHeight),
- "flex": "none",
- "box-sizing": "border-box",
- },
- Children: []gutter.Widget{w.ItemBuilder(i)},
- })
- }
+ first, last := virtualWindow(m, s.scrollOffset, viewportSize, overscan)
- // The virtual sizer fills the viewport with itemCount*itemHeight of
- // empty space; the visible items sit in an absolutely-positioned
- // wrapper offset by firstVisible*itemHeight so scroll math lines up.
- sizer := Styled{
- Style: map[string]string{
- "position": "relative",
- "width": "100%",
- "height": fmt.Sprintf("%gpx", float64(w.ItemCount)*w.ItemHeight),
- },
- Children: []gutter.Widget{
- Styled{
- Style: map[string]string{
- "position": "absolute",
- "left": "0",
- "right": "0",
- "top": fmt.Sprintf("%gpx", float64(firstVisible)*w.ItemHeight),
- "display": "flex",
- "flex-direction": "column",
- },
- Children: items,
- },
- },
+ items := make([]gutter.Widget, 0, last-first+1)
+ for i := first; i <= last; i++ {
+ // Wrap each item in a slot sized to its extent so row geometry matches
+ // the offset math regardless of the child's own sizing.
+ slot := map[string]string{"flex": "none", "box-sizing": "border-box"}
+ if horizontal {
+ slot["width"] = fmt.Sprintf("%gpx", m.extent(i))
+ } else {
+ slot["height"] = fmt.Sprintf("%gpx", m.extent(i))
+ }
+ items = append(items, Styled{Style: slot, Children: []gutter.Widget{w.ItemBuilder(i)}})
}
- viewportStyle := map[string]string{
- "position": "relative",
- "box-sizing": "border-box",
- "overflow-y": "auto",
- "overflow-x": "hidden",
- }
- if w.Height != "" {
- viewportStyle["height"] = w.Height
+ // The virtual sizer reserves the full content extent; the visible items sit
+ // in an absolutely-positioned wrapper offset by offset(first) along the axis
+ // so scroll math lines up.
+ total := fmt.Sprintf("%gpx", m.total())
+ startOffset := fmt.Sprintf("%gpx", m.offset(first))
+ sizerStyle := map[string]string{"position": "relative"}
+ innerStyle := map[string]string{"position": "absolute", "display": "flex"}
+ viewportStyle := map[string]string{"position": "relative", "box-sizing": "border-box"}
+ if horizontal {
+ sizerStyle["width"], sizerStyle["height"] = total, "100%"
+ innerStyle["top"], innerStyle["bottom"], innerStyle["left"] = "0", "0", startOffset
+ innerStyle["flex-direction"] = "row"
+ viewportStyle["overflow-x"], viewportStyle["overflow-y"] = "auto", "hidden"
+ if w.Width != "" {
+ viewportStyle["width"] = w.Width
+ }
+ } else {
+ sizerStyle["width"], sizerStyle["height"] = "100%", total
+ innerStyle["left"], innerStyle["right"], innerStyle["top"] = "0", "0", startOffset
+ innerStyle["flex-direction"] = "column"
+ viewportStyle["overflow-y"], viewportStyle["overflow-x"] = "auto", "hidden"
+ if w.Height != "" {
+ viewportStyle["height"] = w.Height
+ }
}
+ sizer := Styled{Style: sizerStyle, Children: []gutter.Widget{
+ Styled{Style: innerStyle, Children: items},
+ }}
return propSyncHost{
tag: "div",
@@ -244,24 +341,21 @@ func (s *listBuilderState) Build(ctx *gutter.BuildContext) gutter.Widget {
if s.cleanup != nil {
return
}
- s.cleanup = attachScrollListener(node, func(scrollOffset, viewportSize float64) {
+ s.cleanup = attachScrollListener(node, horizontal, func(scrollOffset, viewportSize float64) {
w := s.currentWidget()
- if w.ItemHeight <= 0 {
- return
- }
- newFirst := int(scrollOffset / w.ItemHeight)
- // Gate rebuilds on the values that actually affect the
- // rendered window. scrollOffset itself changes every
- // scroll tick — we only care when we've crossed an item
- // boundary or the viewport resized.
- if newFirst == s.firstVisible && viewportSize == s.viewportSize {
+ m := s.metrics(w)
+ nf, nl := virtualWindow(m, scrollOffset, viewportSize, overscan)
+ // scrollOffset changes every tick; only rebuild when the window
+ // (or the viewport size) actually changed.
+ if nf == s.winFirst && nl == s.winLast && viewportSize == s.viewportSize {
s.scrollOffset = scrollOffset
return
}
s.SetState(func() {
s.scrollOffset = scrollOffset
s.viewportSize = viewportSize
- s.firstVisible = newFirst
+ s.winFirst = nf
+ s.winLast = nl
})
})
},
diff --git a/widgets/list_metrics_test.go b/widgets/list_metrics_test.go
new file mode 100644
index 0000000..5fd7177
--- /dev/null
+++ b/widgets/list_metrics_test.go
@@ -0,0 +1,61 @@
+package widgets
+
+import "testing"
+
+func TestListMetricsFixed(t *testing.T) {
+ m := &listMetrics{count: 100, fixed: 20}
+ if m.total() != 2000 {
+ t.Errorf("total = %g, want 2000", m.total())
+ }
+ if m.offset(5) != 100 {
+ t.Errorf("offset(5) = %g, want 100", m.offset(5))
+ }
+ if m.extent(5) != 20 {
+ t.Errorf("extent(5) = %g, want 20", m.extent(5))
+ }
+ if got := m.indexAt(105); got != 5 { // 105/20 = 5.25 → 5
+ t.Errorf("indexAt(105) = %d, want 5", got)
+ }
+ if got := m.indexAt(1e9); got != 99 { // clamps to last
+ t.Errorf("indexAt(huge) = %d, want 99", got)
+ }
+}
+
+func TestListMetricsVariable(t *testing.T) {
+ // Extents 10, 30, 20, 40 → offsets 0,10,40,60,100.
+ m := &listMetrics{count: 4, offsets: []float64{0, 10, 40, 60, 100}}
+ if m.total() != 100 {
+ t.Errorf("total = %g, want 100", m.total())
+ }
+ if m.offset(2) != 40 {
+ t.Errorf("offset(2) = %g, want 40", m.offset(2))
+ }
+ if m.extent(1) != 30 {
+ t.Errorf("extent(1) = %g, want 30", m.extent(1))
+ }
+ cases := map[float64]int{0: 0, 5: 0, 10: 1, 39: 1, 40: 2, 59: 2, 60: 3, 99: 3, 200: 3}
+ for off, want := range cases {
+ if got := m.indexAt(off); got != want {
+ t.Errorf("indexAt(%g) = %d, want %d", off, got, want)
+ }
+ }
+}
+
+func TestVirtualWindow(t *testing.T) {
+ m := &listMetrics{count: 100, fixed: 20}
+ // Offset 200, viewport 200: indexAt(200)=10, indexAt(400)=20 (item 20 starts
+ // exactly at 400); ±overscan 2 → [8, 22].
+ first, last := virtualWindow(m, 200, 200, 2)
+ if first != 8 || last != 22 {
+ t.Fatalf("window = [%d,%d], want [8,22]", first, last)
+ }
+ // At the top, first clamps to 0.
+ first, _ = virtualWindow(m, 0, 200, 3)
+ if first != 0 {
+ t.Errorf("first at top = %d, want 0", first)
+ }
+ // Empty list.
+ if f, l := virtualWindow(&listMetrics{count: 0, fixed: 20}, 0, 200, 3); f != 0 || l != -1 {
+ t.Errorf("empty window = [%d,%d], want [0,-1]", f, l)
+ }
+}
diff --git a/widgets/list_stub.go b/widgets/list_stub.go
index 604e1f3..c988c1f 100644
--- a/widgets/list_stub.go
+++ b/widgets/list_stub.go
@@ -5,6 +5,6 @@ package widgets
// attachScrollListener is a no-op on host builds. ListBuilder still
// renders its first-frame fallback layout, but virtualization needs the
// browser's scroll events which only exist under GOOS=js GOARCH=wasm.
-func attachScrollListener(node any, onScroll func(scrollTop, viewportHeight float64)) func() {
+func attachScrollListener(node any, horizontal bool, onScroll func(offset, viewport float64)) func() {
return func() {}
}
diff --git a/widgets/list_wasm.go b/widgets/list_wasm.go
index de3f479..f546d23 100644
--- a/widgets/list_wasm.go
+++ b/widgets/list_wasm.go
@@ -4,22 +4,28 @@ package widgets
import "syscall/js"
-// attachScrollListener registers a passive "scroll" listener on the
-// viewport node and invokes onScroll with the current scrollTop and
-// clientHeight on every event — plus once synchronously after mount so
-// ListBuilder can read the real viewport size before its first paint.
+// attachScrollListener registers a passive "scroll" listener on the viewport
+// node and invokes onScroll with the current scroll offset and viewport size
+// along the scroll axis (scrollTop/clientHeight when vertical, scrollLeft/
+// clientWidth when horizontal) on every event — plus once synchronously after
+// mount so ListBuilder can read the real viewport size before its first paint.
//
// Returns an idempotent cleanup that removes the listener and releases the
// js.Func allocation.
-func attachScrollListener(node any, onScroll func(scrollTop, viewportHeight float64)) func() {
+func attachScrollListener(node any, horizontal bool, onScroll func(offset, viewport float64)) func() {
n, ok := node.(js.Value)
if !ok || onScroll == nil {
return func() {}
}
+ offsetProp, viewportProp := "scrollTop", "clientHeight"
+ if horizontal {
+ offsetProp, viewportProp = "scrollLeft", "clientWidth"
+ }
+ fire := func() { onScroll(n.Get(offsetProp).Float(), n.Get(viewportProp).Float()) }
released := false
var cb js.Func
cb = js.FuncOf(func(this js.Value, _ []js.Value) any {
- onScroll(n.Get("scrollTop").Float(), n.Get("clientHeight").Float())
+ fire()
return nil
})
// passive: true tells Chrome we won't preventDefault, so it can pipe
@@ -28,11 +34,11 @@ func attachScrollListener(node any, onScroll func(scrollTop, viewportHeight floa
opts.Set("passive", true)
n.Call("addEventListener", "scroll", cb, opts)
- // Fire once synchronously so the State picks up the real clientHeight
- // before the first repaint. The "scroll" event itself fires only when
- // scrollTop actually changes, so without this we'd be stuck with the
+ // Fire once synchronously so the State picks up the real viewport size
+ // before the first repaint. The "scroll" event itself fires only when the
+ // offset actually changes, so without this we'd be stuck with the
// first-render fallback viewport size.
- onScroll(n.Get("scrollTop").Float(), n.Get("clientHeight").Float())
+ fire()
return func() {
if released {
diff --git a/widgets/list_wasm_test.go b/widgets/list_wasm_test.go
new file mode 100644
index 0000000..65aa319
--- /dev/null
+++ b/widgets/list_wasm_test.go
@@ -0,0 +1,101 @@
+//go:build js && wasm
+
+package widgets
+
+import (
+ "fmt"
+ "syscall/js"
+ "testing"
+
+ "github.com/Runway-Club/gutter"
+)
+
+// mountListBuilder mounts lb into a fresh body container and returns the
+// viewport node (the ListBuilder's root div). The render is synchronous; the
+// scroll listener's viewport correction is a queued microtask that hasn't run
+// yet, so assertions see the first-frame (fallback-viewport) window.
+func mountListBuilder(t *testing.T, lb ListBuilder) js.Value {
+ t.Helper()
+ doc := js.Global().Get("document")
+ id := fmt.Sprintf("lb-%d", listTestSeq)
+ listTestSeq++
+ c := doc.Call("createElement", "div")
+ c.Call("setAttribute", "id", id)
+ doc.Get("body").Call("appendChild", c)
+ gutter.MountInto("#"+id, lb)
+ return c.Get("firstChild")
+}
+
+var listTestSeq int
+
+func TestListBuilderVirtualizesVertical(t *testing.T) {
+ vp := mountListBuilder(t, ListBuilder{
+ ItemCount: 1000,
+ ItemHeight: 20,
+ Height: "100px",
+ ItemBuilder: func(i int) gutter.Widget {
+ return Text{Data: fmt.Sprintf("row-%d", i)}
+ },
+ })
+ sizer := vp.Get("firstChild")
+ if h := sizer.Get("style").Get("height").String(); h != "20000px" {
+ t.Fatalf("sizer height = %q, want 20000px (1000*20)", h)
+ }
+ inner := sizer.Get("firstChild")
+ n := inner.Get("children").Get("length").Int()
+ if n == 0 || n >= 1000 {
+ t.Fatalf("rendered %d slots; expected a small windowed subset of 1000", n)
+ }
+ if got := inner.Get("firstChild").Get("textContent").String(); got != "row-0" {
+ t.Errorf("first rendered item = %q, want row-0", got)
+ }
+}
+
+func TestListBuilderVariableExtents(t *testing.T) {
+ // Extents alternate 10/30 over 100 items → total 50*10 + 50*30 = 2000.
+ vp := mountListBuilder(t, ListBuilder{
+ ItemCount: 100,
+ ItemExtent: func(i int) float64 { return map[bool]float64{true: 10, false: 30}[i%2 == 0] },
+ Height: "120px",
+ ItemBuilder: func(i int) gutter.Widget {
+ return Text{Data: fmt.Sprintf("v-%d", i)}
+ },
+ })
+ sizer := vp.Get("firstChild")
+ if h := sizer.Get("style").Get("height").String(); h != "2000px" {
+ t.Fatalf("variable sizer height = %q, want 2000px", h)
+ }
+ // First slot is index 0 with extent 10px.
+ inner := sizer.Get("firstChild")
+ slot0 := inner.Get("firstChild")
+ if h := slot0.Get("style").Get("height").String(); h != "10px" {
+ t.Errorf("slot 0 height = %q, want 10px", h)
+ }
+}
+
+func TestListBuilderHorizontal(t *testing.T) {
+ vp := mountListBuilder(t, ListBuilder{
+ ItemCount: 500,
+ ItemHeight: 40, // width along the scroll axis
+ Direction: ListHorizontal,
+ Width: "200px",
+ ItemBuilder: func(i int) gutter.Widget {
+ return Text{Data: fmt.Sprintf("col-%d", i)}
+ },
+ })
+ if ox := vp.Get("style").Get("overflowX").String(); ox != "auto" {
+ t.Errorf("viewport overflow-x = %q, want auto", ox)
+ }
+ sizer := vp.Get("firstChild")
+ if w := sizer.Get("style").Get("width").String(); w != "20000px" {
+ t.Fatalf("horizontal sizer width = %q, want 20000px (500*40)", w)
+ }
+ inner := sizer.Get("firstChild")
+ if fd := inner.Get("style").Get("flexDirection").String(); fd != "row" {
+ t.Errorf("inner flex-direction = %q, want row", fd)
+ }
+ slot0 := inner.Get("firstChild")
+ if w := slot0.Get("style").Get("width").String(); w != "40px" {
+ t.Errorf("horizontal slot 0 width = %q, want 40px", w)
+ }
+}
diff --git a/widgets/popup.go b/widgets/popup.go
index a7b1dc8..a910c49 100644
--- a/widgets/popup.go
+++ b/widgets/popup.go
@@ -87,10 +87,12 @@ func popupRender(ctx *gutter.BuildContext, p Popup, isOpen bool) gutter.Widget {
if p.Child != nil {
sheetChildren = []gutter.Widget{p.Child}
}
- sheet := Styled{Style: sheetStyle, Children: sheetChildren}
+ sheet := Styled{Attrs: dialogAttrs(isOpen), Style: sheetStyle, Children: sheetChildren}
- return Styled{
+ // Teleport into the body-level portal root so the fixed backdrop/sheet aren't
+ // trapped by an ancestor's transform/overflow/stacking context.
+ return gutter.Portal{Child: Styled{
Style: map[string]string{"display": "contents"},
Children: []gutter.Widget{backdrop, sheet},
- }
+ }}
}
diff --git a/widgets/router.go b/widgets/router.go
index 1c7c2ee..edb4ec9 100644
--- a/widgets/router.go
+++ b/widgets/router.go
@@ -34,28 +34,82 @@ type RouteBuilder func(params RouteParams) gutter.Widget
//
// Pattern syntax is intentionally minimal: literal segments must match
// exactly, segments prefixed with ":" capture the corresponding path segment.
-// No wildcards, no nested routers, no guards — wrap the route builder if you
-// need those.
+// No wildcards and no nested routers — wrap the route builder if you need those.
+//
+// Guards/redirects ARE supported: pass NavGuards to NewRouter (or add them with
+// Guard). Every navigation — Push/Replace, browser back/forward, and the
+// initial load — is routed through the guards, which can rewrite the
+// destination (e.g. send "/dashboard" to "/login" when unauthenticated).
type Router struct {
routes map[string]RouteBuilder
notFound gutter.Widget
current *gutter.Notifier[string]
+ guards []NavGuard
}
+// NavGuard inspects an intended destination path and returns the path to
+// actually navigate to. Return the path unchanged to allow the navigation;
+// return a different path to redirect; return the current path to effectively
+// block it. Guards run in order and the result is re-checked, so one guard's
+// redirect is itself guarded (capped to avoid an infinite redirect loop).
+type NavGuard func(to string) string
+
// NewRouter creates the router, seeds its current path from the browser's
-// location (on WASM) or "/" (on host), and installs the popstate listener so
-// browser back/forward updates the tree. notFound is rendered when no route
-// pattern matches.
-func NewRouter(routes map[string]RouteBuilder, notFound gutter.Widget) *Router {
+// location (on WASM) or "/" (on host) — run through any guards — and installs
+// the popstate listener so browser back/forward updates the tree. notFound is
+// rendered when no route pattern matches.
+func NewRouter(routes map[string]RouteBuilder, notFound gutter.Widget, guards ...NavGuard) *Router {
r := &Router{
routes: routes,
notFound: notFound,
- current: gutter.NewNotifier(initialPath()),
+ guards: guards,
}
+ start := initialPath()
+ resolved := r.resolve(start)
+ r.current = gutter.NewNotifier(resolved)
r.installHistoryListener()
+ if resolved != start {
+ // Land on a guarded redirect: fix the URL bar without a history entry.
+ r.replaceHistory(resolved)
+ }
return r
}
+// Guard appends a NavGuard after construction (e.g. once an auth store exists).
+// Returns the router for chaining.
+func (r *Router) Guard(g NavGuard) *Router {
+ r.guards = append(r.guards, g)
+ return r
+}
+
+// resolve runs path through every guard, re-checking until the result is stable
+// (so a redirect target is itself guarded). The iteration cap prevents an
+// infinite loop if two guards bounce a path back and forth.
+func (r *Router) resolve(to string) string {
+ for range 10 {
+ next := to
+ for _, g := range r.guards {
+ next = g(next)
+ }
+ if next == to {
+ return to
+ }
+ to = next
+ }
+ return to
+}
+
+// navigated guards a path the browser restored (back/forward) and, if a guard
+// redirected, rewrites history before updating current. Called from the wasm
+// popstate listener.
+func (r *Router) navigated(path string) {
+ resolved := r.resolve(path)
+ if resolved != path {
+ r.replaceHistory(resolved)
+ }
+ r.current.Set(resolved)
+}
+
// Current returns the currently active path.
func (r *Router) Current() string { return r.current.Value() }
@@ -63,14 +117,16 @@ func (r *Router) Current() string { return r.current.Value() }
// directly (e.g. by an external ObserverBuilder for breadcrumbs or analytics).
func (r *Router) Listenable() gutter.Listenable[string] { return r.current }
-// Push navigates to path, pushing a new history entry.
+// Push navigates to path (after guards), pushing a new history entry.
func (r *Router) Push(path string) {
+ path = r.resolve(path)
r.pushHistory(path)
r.current.Set(path)
}
-// Replace navigates to path without growing the history stack.
+// Replace navigates to path (after guards) without growing the history stack.
func (r *Router) Replace(path string) {
+ path = r.resolve(path)
r.replaceHistory(path)
r.current.Set(path)
}
diff --git a/widgets/router_test.go b/widgets/router_test.go
index d156fd4..561c30a 100644
--- a/widgets/router_test.go
+++ b/widgets/router_test.go
@@ -25,6 +25,85 @@ func TestRouterStripsQueryWhenMatching(t *testing.T) {
}
}
+func TestRouterGuardRedirectsPush(t *testing.T) {
+ authed := false
+ guard := func(to string) string {
+ if to == "/dashboard" && !authed {
+ return "/login"
+ }
+ return to
+ }
+ r := NewRouter(map[string]RouteBuilder{
+ "/login": func(RouteParams) gutter.Widget { return Text{Data: "login"} },
+ "/dashboard": func(RouteParams) gutter.Widget { return Text{Data: "dash"} },
+ }, Text{Data: "notfound"}, guard)
+
+ r.Push("/dashboard")
+ if r.Current() != "/login" {
+ t.Fatalf("unauthenticated push to /dashboard went to %q, want /login", r.Current())
+ }
+ // Once authenticated the same push is allowed through.
+ authed = true
+ r.Push("/dashboard")
+ if r.Current() != "/dashboard" {
+ t.Fatalf("authenticated push went to %q, want /dashboard", r.Current())
+ }
+}
+
+func TestRouterGuardSeedsInitialPath(t *testing.T) {
+ // The seed path is run through guards at construction. Redirect everything
+ // but "/welcome" so the assertion holds regardless of what initialPath()
+ // returns (it's "/" on host, but the live browser shares window.location
+ // across tests, so don't depend on a specific value).
+ r := NewRouter(map[string]RouteBuilder{
+ "/welcome": func(RouteParams) gutter.Widget { return Text{Data: "welcome"} },
+ }, Text{Data: "notfound"}, func(to string) string {
+ if to != "/welcome" {
+ return "/welcome"
+ }
+ return to
+ })
+ if r.Current() != "/welcome" {
+ t.Fatalf("guarded initial path = %q, want /welcome", r.Current())
+ }
+}
+
+func TestRouterGuardChainStable(t *testing.T) {
+ // Two guards: first sends /a→/b, second sends /b→/c. resolve must settle on
+ // /c (re-checking after each redirect) without looping forever.
+ r := NewRouter(map[string]RouteBuilder{}, Text{Data: "nf"},
+ func(to string) string {
+ if to == "/a" {
+ return "/b"
+ }
+ return to
+ },
+ func(to string) string {
+ if to == "/b" {
+ return "/c"
+ }
+ return to
+ },
+ )
+ if got := r.resolve("/a"); got != "/c" {
+ t.Fatalf("resolve(/a) = %q, want /c", got)
+ }
+}
+
+func TestRouterAddGuardAfterConstruction(t *testing.T) {
+ r := NewRouter(map[string]RouteBuilder{}, Text{Data: "nf"})
+ r.Guard(func(to string) string {
+ if to == "/x" {
+ return "/y"
+ }
+ return to
+ })
+ r.navigated("/x") // simulate a browser back/forward to /x
+ if r.Current() != "/y" {
+ t.Fatalf("guarded popstate = %q, want /y", r.Current())
+ }
+}
+
func TestRouterQueryParsing(t *testing.T) {
r := NewRouter(map[string]RouteBuilder{
"/search": func(RouteParams) gutter.Widget { return Text{Data: "search"} },
diff --git a/widgets/router_wasm.go b/widgets/router_wasm.go
index 4fae6c6..95ab2a2 100644
--- a/widgets/router_wasm.go
+++ b/widgets/router_wasm.go
@@ -25,7 +25,7 @@ func (r *Router) installHistoryListener() {
if s := loc.Get("search").String(); s != "" {
p += s
}
- r.current.Set(p)
+ r.navigated(p)
return nil
})
js.Global().Get("window").Call("addEventListener", "popstate", cb)
diff --git a/widgets/scaffold.go b/widgets/scaffold.go
index 6fab77b..dad61e8 100644
--- a/widgets/scaffold.go
+++ b/widgets/scaffold.go
@@ -66,8 +66,10 @@ func (s Scaffold) Build(ctx *gutter.BuildContext) gutter.Widget {
}
if s.Body != nil {
// Body takes the remaining space (flex: 1) and is itself a flex
- // column so a Center inside it can use height: 100% reliably.
+ // column so a Center inside it can use height: 100% reliably. Rendered
+ // as a landmark so assistive tech can jump to the main content.
children = append(children, Styled{
+ Tag: "main",
Style: map[string]string{
"flex": "1",
"display": "flex",
@@ -78,7 +80,13 @@ func (s Scaffold) Build(ctx *gutter.BuildContext) gutter.Widget {
})
}
if s.Footer != nil {
- children = append(children, s.Footer)
+ // Wrap in a landmark (contentinfo) unless the caller already
+ // supplied one, so the footer is reachable by landmark navigation.
+ children = append(children, Styled{
+ Tag: "footer",
+ Style: map[string]string{"flex-shrink": "0"},
+ Children: []gutter.Widget{s.Footer},
+ })
}
return Styled{
diff --git a/widgets/theme_provider_test.go b/widgets/theme_provider_test.go
new file mode 100644
index 0000000..bb4de3f
--- /dev/null
+++ b/widgets/theme_provider_test.go
@@ -0,0 +1,53 @@
+package widgets
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/Runway-Club/gutter"
+ "github.com/Runway-Club/gutter/themes"
+)
+
+// RenderToHTML pushes inherited scopes, so a ThemeProvider over a themed widget
+// must make activeTheme resolve to the provided theme — end to end.
+func TestThemeProviderOverridesTheme(t *testing.T) {
+ metaBg := themes.Meta.Components.ButtonPrimary.Background
+ appleBg := themes.Apple.Components.ButtonPrimary.Background
+ if metaBg == appleBg {
+ t.Skip("Meta and Apple primary backgrounds coincide; test can't distinguish")
+ }
+
+ out, err := gutter.RenderToHTML(gutter.ThemeProvider{
+ Theme: themes.Meta,
+ Child: Button{Variant: ButtonPrimary, Label: "x"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out, metaBg) {
+ t.Errorf("button did not use the provided Meta theme background %q:\n%s", metaBg, out)
+ }
+ if strings.Contains(out, appleBg) {
+ t.Errorf("button used the Apple background %q despite the ThemeProvider", appleBg)
+ }
+}
+
+// A nested ThemeProvider shadows an outer one for its subtree only.
+func TestThemeProviderNestedShadowing(t *testing.T) {
+ out, err := gutter.RenderToHTML(gutter.ThemeProvider{
+ Theme: themes.Apple,
+ Child: Row{Children: []gutter.Widget{
+ Button{Variant: ButtonPrimary, Label: "outer"},
+ gutter.ThemeProvider{Theme: themes.Meta, Child: Button{Variant: ButtonPrimary, Label: "inner"}},
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out, themes.Apple.Components.ButtonPrimary.Background) {
+ t.Error("outer button should use Apple")
+ }
+ if !strings.Contains(out, themes.Meta.Components.ButtonPrimary.Background) {
+ t.Error("inner button should use Meta")
+ }
+}