From e0088cf1947d5dde5e78902222c613373c6dfe23 Mon Sep 17 00:00:00 2001 From: Kiet Nguyen Tuan Date: Mon, 25 May 2026 23:12:01 +0700 Subject: [PATCH 1/8] style: normalize pre-existing files to current gofmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These files predate the Go 1.19+ gofmt doc-comment reformatting and were flagged by `gofmt -l`. Whitespace/comment-format only, no logic changes — so the new CI gofmt gate starts green. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/gutter/new.go | 10 ++++---- cmd/gutter/style.go | 6 +++-- widgets/bottom_sheet.go | 26 +++++++++---------- widgets/canvas_stub.go | 56 ++++++++++++++++++++--------------------- widgets/file.go | 1 - widgets/image.go | 12 ++++----- widgets/router_stub.go | 8 +++--- widgets/scaffold.go | 6 ++--- widgets/worker_stub.go | 4 +-- 9 files changed, 65 insertions(+), 64 deletions(-) diff --git a/cmd/gutter/new.go b/cmd/gutter/new.go index 65c0c6e..8aac420 100644 --- a/cmd/gutter/new.go +++ b/cmd/gutter/new.go @@ -183,11 +183,11 @@ func runNew(name, modulePath string) error { } files := map[string]string{ - "main.go": strings.ReplaceAll(mainGoTemplate, "__NAME__", name), - "index.html": strings.ReplaceAll(indexHTMLTemplate, "__NAME__", name), - "go.mod": strings.ReplaceAll(goModTemplate, "__MODULE__", modulePath), - ".gitignore": gitignoreTemplate, - "assets/.gitkeep": "", + "main.go": strings.ReplaceAll(mainGoTemplate, "__NAME__", name), + "index.html": strings.ReplaceAll(indexHTMLTemplate, "__NAME__", name), + "go.mod": strings.ReplaceAll(goModTemplate, "__MODULE__", modulePath), + ".gitignore": gitignoreTemplate, + "assets/.gitkeep": "", } for fname, content := range files { path := filepath.Join(name, fname) diff --git a/cmd/gutter/style.go b/cmd/gutter/style.go index 1f3c96f..a5550b5 100644 --- a/cmd/gutter/style.go +++ b/cmd/gutter/style.go @@ -19,8 +19,10 @@ var ( styleAccent = lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6")).Bold(true) ) -func printTitle(s string) { fmt.Println(styleTitle.Render("▶ " + s)) } -func printOK(format string, a ...any) { fmt.Println(styleOK.Render("✓") + " " + fmt.Sprintf(format, a...)) } +func printTitle(s string) { fmt.Println(styleTitle.Render("▶ " + s)) } +func printOK(format string, a ...any) { + fmt.Println(styleOK.Render("✓") + " " + fmt.Sprintf(format, a...)) +} func printWarn(format string, a ...any) { fmt.Println(styleWarn.Render("!") + " " + fmt.Sprintf(format, a...)) } diff --git a/widgets/bottom_sheet.go b/widgets/bottom_sheet.go index eff8192..5b48eae 100644 --- a/widgets/bottom_sheet.go +++ b/widgets/bottom_sheet.go @@ -57,21 +57,21 @@ func bottomSheetRender(ctx *gutter.BuildContext, b BottomSheet, isOpen bool) gut radius := fallback(t.Rounded.Large, "12px") sheetStyle := map[string]string{ - "position": "fixed", - "left": "0", - "right": "0", - "bottom": "0", - "max-height": height, - "background": fallback(t.Colors.Canvas, "#ffffff"), - "color": fallback(t.Colors.Ink, "#000000"), - "padding": fallback(t.Spacing.LG, "24px"), + "position": "fixed", + "left": "0", + "right": "0", + "bottom": "0", + "max-height": height, + "background": fallback(t.Colors.Canvas, "#ffffff"), + "color": fallback(t.Colors.Ink, "#000000"), + "padding": fallback(t.Spacing.LG, "24px"), "border-top-left-radius": radius, "border-top-right-radius": radius, - "z-index": z, - "transition": "transform 0.25s ease-out", - "box-shadow": "0 -8px 32px rgba(0,0,0,0.18)", - "box-sizing": "border-box", - "overflow-y": "auto", + "z-index": z, + "transition": "transform 0.25s ease-out", + "box-shadow": "0 -8px 32px rgba(0,0,0,0.18)", + "box-sizing": "border-box", + "overflow-y": "auto", } if isOpen { sheetStyle["transform"] = "translateY(0)" diff --git a/widgets/canvas_stub.go b/widgets/canvas_stub.go index 43cd518..ac2c71c 100644 --- a/widgets/canvas_stub.go +++ b/widgets/canvas_stub.go @@ -9,33 +9,33 @@ package widgets // tooling and `go vet`. type CanvasPainter struct{} -func (p *CanvasPainter) Size() (float64, float64) { return 0, 0 } -func (p *CanvasPainter) Clear() {} -func (p *CanvasPainter) ClearRect(x, y, w, h float64) {} -func (p *CanvasPainter) FillStyle(v string) {} -func (p *CanvasPainter) StrokeStyle(v string) {} -func (p *CanvasPainter) LineWidth(v float64) {} -func (p *CanvasPainter) LineCap(v string) {} -func (p *CanvasPainter) LineJoin(v string) {} -func (p *CanvasPainter) Font(v string) {} -func (p *CanvasPainter) TextAlign(v string) {} -func (p *CanvasPainter) TextBaseline(v string) {} -func (p *CanvasPainter) FillRect(x, y, w, h float64) {} -func (p *CanvasPainter) StrokeRect(x, y, w, h float64) {} -func (p *CanvasPainter) BeginPath() {} -func (p *CanvasPainter) ClosePath() {} -func (p *CanvasPainter) MoveTo(x, y float64) {} -func (p *CanvasPainter) LineTo(x, y float64) {} -func (p *CanvasPainter) Rect(x, y, w, h float64) {} -func (p *CanvasPainter) Arc(x, y, r, start, end float64) {} -func (p *CanvasPainter) Fill() {} -func (p *CanvasPainter) Stroke() {} -func (p *CanvasPainter) FillText(text string, x, y float64) {} -func (p *CanvasPainter) StrokeText(text string, x, y float64) {} -func (p *CanvasPainter) Save() {} -func (p *CanvasPainter) Restore() {} -func (p *CanvasPainter) Translate(x, y float64) {} -func (p *CanvasPainter) Rotate(rad float64) {} -func (p *CanvasPainter) Scale(sx, sy float64) {} +func (p *CanvasPainter) Size() (float64, float64) { return 0, 0 } +func (p *CanvasPainter) Clear() {} +func (p *CanvasPainter) ClearRect(x, y, w, h float64) {} +func (p *CanvasPainter) FillStyle(v string) {} +func (p *CanvasPainter) StrokeStyle(v string) {} +func (p *CanvasPainter) LineWidth(v float64) {} +func (p *CanvasPainter) LineCap(v string) {} +func (p *CanvasPainter) LineJoin(v string) {} +func (p *CanvasPainter) Font(v string) {} +func (p *CanvasPainter) TextAlign(v string) {} +func (p *CanvasPainter) TextBaseline(v string) {} +func (p *CanvasPainter) FillRect(x, y, w, h float64) {} +func (p *CanvasPainter) StrokeRect(x, y, w, h float64) {} +func (p *CanvasPainter) BeginPath() {} +func (p *CanvasPainter) ClosePath() {} +func (p *CanvasPainter) MoveTo(x, y float64) {} +func (p *CanvasPainter) LineTo(x, y float64) {} +func (p *CanvasPainter) Rect(x, y, w, h float64) {} +func (p *CanvasPainter) Arc(x, y, r, start, end float64) {} +func (p *CanvasPainter) Fill() {} +func (p *CanvasPainter) Stroke() {} +func (p *CanvasPainter) FillText(text string, x, y float64) {} +func (p *CanvasPainter) StrokeText(text string, x, y float64) {} +func (p *CanvasPainter) Save() {} +func (p *CanvasPainter) Restore() {} +func (p *CanvasPainter) Translate(x, y float64) {} +func (p *CanvasPainter) Rotate(rad float64) {} +func (p *CanvasPainter) Scale(sx, sy float64) {} func paintCanvas(node any, width, height float64, paint func(*CanvasPainter)) {} diff --git a/widgets/file.go b/widgets/file.go index 3c3c88d..92ea574 100644 --- a/widgets/file.go +++ b/widgets/file.go @@ -135,4 +135,3 @@ func (s *fileState) Dispose() { s.cleanup = nil } } - diff --git a/widgets/image.go b/widgets/image.go index dd9ef88..fbcf7a6 100644 --- a/widgets/image.go +++ b/widgets/image.go @@ -31,12 +31,12 @@ const ( // If both are set, Asset wins. Width/Height accept any CSS length ("48px", // "100%", etc.); the image's object-fit is controlled by Fit. type Image struct { - Asset string - Src string - Alt string - Width string - Height string - Fit ImageFit + Asset string + Src string + Alt string + Width string + Height string + Fit ImageFit Rounded string // optional border-radius (CSS), e.g. "50%" for an avatar } diff --git a/widgets/router_stub.go b/widgets/router_stub.go index 8c575fa..40f52e3 100644 --- a/widgets/router_stub.go +++ b/widgets/router_stub.go @@ -5,8 +5,8 @@ package widgets // Host-build stubs so `go vet ./...` and editor analysis work outside WASM. // None of these are functional — Router is only meaningful in the browser. -func initialPath() string { return "/" } -func (r *Router) installHistoryListener() {} -func (r *Router) pushHistory(path string) {} +func initialPath() string { return "/" } +func (r *Router) installHistoryListener() {} +func (r *Router) pushHistory(path string) {} func (r *Router) replaceHistory(path string) {} -func (r *Router) popHistory() {} +func (r *Router) popHistory() {} diff --git a/widgets/scaffold.go b/widgets/scaffold.go index c5128ea..6fab77b 100644 --- a/widgets/scaffold.go +++ b/widgets/scaffold.go @@ -10,11 +10,11 @@ import ( // // - Title — pushed to document.title // - Theme — switches the active theme for this subtree (and, since -// gutter has one BuildContext per app, effectively the -// whole app) +// gutter has one BuildContext per app, effectively the +// whole app) // - AppBar — the top navigation strip (use widgets.AppBar) // - StickyAppBar — when true, pin the AppBar to the viewport top while -// the rest of the page scrolls +// the rest of the page scrolls // - Body — your main content; takes the remaining vertical space // - Footer — an optional bottom strip (legal, build info, etc.) // diff --git a/widgets/worker_stub.go b/widgets/worker_stub.go index 6e6cddf..374e8d4 100644 --- a/widgets/worker_stub.go +++ b/widgets/worker_stub.go @@ -17,5 +17,5 @@ func newWorkerHandle(w Worker, onMsg, onErr func(string)) *workerHandle { return &workerHandle{} } -func (h *workerHandle) post(msg string) {} -func (h *workerHandle) terminate() {} +func (h *workerHandle) post(msg string) {} +func (h *workerHandle) terminate() {} From db20b4a617bd9c9a598697bdbee2d04f77419e0c Mon Sep 17 00:00:00 2001 From: Kiet Nguyen Tuan Date: Mon, 25 May 2026 23:12:16 +0700 Subject: [PATCH 2/8] perf: batch SetState, persistent event listeners, lazy payload, cached type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce work crossing the syscall/js boundary — the real cost in a WASM-DOM framework, where naive per-update churn makes it slower than React rather than faster. - SetState is now microtask-batched: scheduleRebuild enqueues the element and a single queueMicrotask flush drains the queue, deduping repeated SetStates on one element and coalescing across siblings into one rebuild pass. A mounted flag skips elements unmounted before the flush. DOM updates on the next microtask (before paint), no longer synchronously. - Event listeners are persistent per event NAME: syncEvents registers one js.Func per name that dispatches to the live e.host.Events[name] at fire time, so a rebuild swapping the handler closure does zero DOM work. Only added or removed names touch addEventListener/removeEventListener — eliminating the release-all/recreate-all churn on every reconcile. - fillEvent reads only the raw-event fields relevant to the event name (coords for pointer/mouse, key for keyboard, value for form/typing) instead of probing all six on every fire. - canUpdate compares a reflect.Type cached on the element instead of reflecting on the old widget each reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) --- element_wasm.go | 252 ++++++++++++++++++++++++++++++++++++------------ state.go | 12 ++- 2 files changed, 203 insertions(+), 61 deletions(-) diff --git a/element_wasm.go b/element_wasm.go index 64923e9..159c622 100644 --- a/element_wasm.go +++ b/element_wasm.go @@ -31,6 +31,11 @@ type Element interface { dom() js.Value // widget returns the current Widget instance. widget() Widget + // widgetType returns the cached reflect.Type of the current widget. It is + // fixed for the element's lifetime: reconcile only reuses an element for a + // widget of the same Go type, so the type never changes across update. + // Caching it here avoids reflecting on the old widget every reconcile. + widgetType() reflect.Type // key returns the widget's reconciliation key (nil if unkeyed). key() any } @@ -38,13 +43,14 @@ type Element interface { // newElement creates the right Element type for a given Widget without // mounting it. The element has no DOM until mount is called. func newElement(w Widget) Element { + wt := reflect.TypeOf(w) switch x := w.(type) { case HostWidget: - return &hostElement{w: x} + return &hostElement{w: x, wt: wt} case StatefulWidget: - return &statefulElement{w: x} + return &statefulElement{w: x, wt: wt} case StatelessWidget: - return &statelessElement{w: x} + return &statelessElement{w: x, wt: wt} } panic("gutter: value does not implement HostWidget, StatelessWidget, or StatefulWidget") } @@ -59,7 +65,7 @@ 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. func canUpdate(old Element, newW Widget) bool { - if reflect.TypeOf(old.widget()) != reflect.TypeOf(newW) { + if old.widgetType() != reflect.TypeOf(newW) { return false } return old.key() == widgetKey(newW) @@ -167,14 +173,16 @@ type hostElement struct { parent js.Value node js.Value w HostWidget + wt reflect.Type host *Host children []Element listeners map[string]js.Func } -func (e *hostElement) widget() Widget { return e.w } -func (e *hostElement) dom() js.Value { return e.node } -func (e *hostElement) key() any { return widgetKey(e.w) } +func (e *hostElement) widget() Widget { return e.w } +func (e *hostElement) widgetType() reflect.Type { return e.wt } +func (e *hostElement) dom() js.Value { return e.node } +func (e *hostElement) key() any { return widgetKey(e.w) } func (e *hostElement) mount(parent, before js.Value, ctx *BuildContext) { e.parent = parent @@ -186,7 +194,7 @@ func (e *hostElement) mount(parent, before js.Value, ctx *BuildContext) { if e.host.Text != "" { e.node.Set("textContent", e.host.Text) } - e.attachEvents(e.host.Events) + e.syncEvents(e.host.Events) parent.Call("insertBefore", e.node, before) e.children = make([]Element, 0, len(e.host.Children)) for _, childW := range e.host.Children { @@ -201,24 +209,27 @@ func (e *hostElement) mount(parent, before js.Value, ctx *BuildContext) { func (e *hostElement) update(newW Widget, ctx *BuildContext) { newHost := newW.(HostWidget).Host() - if newHost.Text != e.host.Text { + oldHost := e.host + // Assign w/host before reconciling children or syncing events: the + // persistent event listeners dispatch through e.host.Events at fire time, + // so e.host must already point at the new handlers. + e.w = newW.(HostWidget) + e.host = newHost + if newHost.Text != oldHost.Text { // textContent rewrites all children; do this BEFORE children // reconciliation if the widget actually uses text. In practice a // HostWidget either has text or children, not both. e.node.Set("textContent", newHost.Text) } - applyAttrs(e.node, e.host.Attrs, newHost.Attrs) - applyStyle(e.node, e.host.Style, newHost.Style) - e.detachEvents() - e.attachEvents(newHost.Events) + applyAttrs(e.node, oldHost.Attrs, newHost.Attrs) + applyStyle(e.node, oldHost.Style, newHost.Style) + e.syncEvents(newHost.Events) e.children = reconcileChildren(e.node, e.children, newHost.Children, ctx) - e.w = newW.(HostWidget) - e.host = newHost - if e.host.OnMount != nil { + if newHost.OnMount != nil { // Treat update as a remount signal for hook-style widgets (Canvas // re-paints when its size or paint callback changes). The hook // fires after the DOM is reconciled so handlers see the new state. - e.host.OnMount(e.node) + newHost.OnMount(e.node) } } @@ -230,51 +241,50 @@ func (e *hostElement) unmount() { child.unmount() } e.children = nil - e.detachEvents() + e.releaseEvents() if !e.parent.IsUndefined() && !e.parent.IsNull() { e.parent.Call("removeChild", e.node) } } -func (e *hostElement) attachEvents(events map[string]func(Event)) { - if len(events) == 0 { +// syncEvents reconciles the set of attached DOM listeners against newEvents by +// NAME only. For each event name we register exactly one persistent js.Func +// that, when fired, looks up the current handler via e.host.Events[name] — so +// a rebuild that swaps the handler closure (the common case, since handlers are +// fresh closures every Build) requires no DOM work at all: the name set is +// unchanged and the listener already dispatches to the latest handler. Only +// added or removed event NAMES touch the DOM. This avoids the +// release-all/recreate-all churn that made every reconcile pay for +// js.FuncOf + addEventListener on every handler. +func (e *hostElement) syncEvents(newEvents map[string]func(Event)) { + // Remove listeners whose event name is gone. + for name, cb := range e.listeners { + if _, ok := newEvents[name]; !ok { + e.node.Call("removeEventListener", name, cb) + cb.Release() + delete(e.listeners, name) + } + } + if len(newEvents) == 0 { return } if e.listeners == nil { - e.listeners = make(map[string]js.Func, len(events)) + e.listeners = make(map[string]js.Func, len(newEvents)) } - for name, handler := range events { + // Add a persistent dispatcher for each newly-seen event name. + for name := range newEvents { + if _, ok := e.listeners[name]; ok { + continue + } n := name - h := handler - cb := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + cb := js.FuncOf(func(this js.Value, args []js.Value) any { + h := e.host.Events[n] + if h == nil { + return nil + } ev := Event{Type: n} if len(args) > 0 { - raw := args[0] - target := raw.Get("target") - if !target.IsUndefined() && !target.IsNull() { - v := target.Get("value") - if !v.IsUndefined() && !v.IsNull() { - ev.Value = v.String() - } - } - // Pointer/mouse coordinates. clientX/clientY exist on - // MouseEvent and PointerEvent; offsetX/offsetY would be - // element-local but aren't part of the PointerEvent spec. - if x := raw.Get("clientX"); !x.IsUndefined() && !x.IsNull() { - ev.X = x.Float() - } - if y := raw.Get("clientY"); !y.IsUndefined() && !y.IsNull() { - ev.Y = y.Float() - } - if x := raw.Get("offsetX"); !x.IsUndefined() && !x.IsNull() { - ev.OffsetX = x.Float() - } - if y := raw.Get("offsetY"); !y.IsUndefined() && !y.IsNull() { - ev.OffsetY = y.Float() - } - if k := raw.Get("key"); !k.IsUndefined() && !k.IsNull() { - ev.Key = k.String() - } + fillEvent(&ev, n, args[0]) } h(ev) return nil @@ -284,7 +294,7 @@ func (e *hostElement) attachEvents(events map[string]func(Event)) { } } -func (e *hostElement) detachEvents() { +func (e *hostElement) releaseEvents() { for name, cb := range e.listeners { e.node.Call("removeEventListener", name, cb) cb.Release() @@ -292,6 +302,62 @@ func (e *hostElement) detachEvents() { e.listeners = nil } +// fillEvent reads only the fields of the raw DOM event that are meaningful for +// the given event name, instead of probing all six on every fire. Each +// raw.Get crosses the Go↔JS boundary, so a click that used to cost six reads +// (value + 4 coords + key) now costs four, and a text "input" event costs one. +func fillEvent(ev *Event, name string, raw js.Value) { + if eventCarriesValue(name) { + if target := raw.Get("target"); !target.IsUndefined() && !target.IsNull() { + if v := target.Get("value"); !v.IsUndefined() && !v.IsNull() { + ev.Value = v.String() + } + } + } + if eventCarriesPointer(name) { + ev.X = floatProp(raw, "clientX") + ev.Y = floatProp(raw, "clientY") + ev.OffsetX = floatProp(raw, "offsetX") + ev.OffsetY = floatProp(raw, "offsetY") + } + if strings.HasPrefix(name, "key") { + if k := raw.Get("key"); !k.IsUndefined() && !k.IsNull() { + ev.Key = k.String() + } + } +} + +func floatProp(raw js.Value, name string) float64 { + if v := raw.Get(name); !v.IsUndefined() && !v.IsNull() { + return v.Float() + } + return 0 +} + +// eventCarriesValue reports whether target.value is worth reading for this +// event. Form/typing events carry it; pure pointer events don't. +func eventCarriesValue(name string) bool { + switch name { + case "input", "change", "blur", "focus", "submit", "search", "paste": + return true + } + return strings.HasPrefix(name, "key") +} + +// eventCarriesPointer reports whether clientX/clientY/offsetX/offsetY are +// meaningful for this event. +func eventCarriesPointer(name string) bool { + switch name { + case "click", "dblclick", "contextmenu", "wheel", "auxclick": + return true + } + return strings.HasPrefix(name, "pointer") || + strings.HasPrefix(name, "mouse") || + strings.HasPrefix(name, "drag") || + strings.HasPrefix(name, "touch") || + name == "drop" +} + func applyAttrs(node js.Value, oldAttrs, newAttrs map[string]string) { for k, v := range newAttrs { if oldAttrs == nil || oldAttrs[k] != v { @@ -340,10 +406,12 @@ func styleEqual(a, b map[string]string) bool { type statelessElement struct { parent js.Value w StatelessWidget + wt reflect.Type child Element } -func (e *statelessElement) widget() Widget { return e.w } +func (e *statelessElement) widget() Widget { return e.w } +func (e *statelessElement) widgetType() reflect.Type { return e.wt } func (e *statelessElement) dom() js.Value { if e.child == nil { return js.Undefined() @@ -375,14 +443,17 @@ func (e *statelessElement) unmount() { // =========== statefulElement =========== type statefulElement struct { - parent js.Value - ctx *BuildContext - w StatefulWidget - state State - child Element + parent js.Value + ctx *BuildContext + w StatefulWidget + wt reflect.Type + state State + child Element + mounted bool } -func (e *statefulElement) widget() Widget { return e.w } +func (e *statefulElement) widget() Widget { return e.w } +func (e *statefulElement) widgetType() reflect.Type { return e.wt } func (e *statefulElement) dom() js.Value { if e.child == nil { return js.Undefined() @@ -410,6 +481,7 @@ func (e *statefulElement) mount(parent, before js.Value, ctx *BuildContext) { childW := e.state.Build(ctx) e.child = newElement(childW) e.child.mount(parent, before, ctx) + e.mounted = true } // update is called when an ancestor rebuild produces a new widget instance of @@ -428,14 +500,26 @@ func (e *statefulElement) update(newW Widget, ctx *BuildContext) { e.rebuild() } -// rebuild is invoked by the State when SetState fires. It rebuilds only this -// subtree, reusing the parent DOM and the current Element. +// rebuild rebuilds only this subtree, reusing the parent DOM and the current +// Element. It is the synchronous path taken by an ancestor-driven update. func (e *statefulElement) rebuild() { + if !e.mounted { + return + } newChildW := e.state.Build(e.ctx) e.child = reconcile(e.parent, e.child, newChildW, e.ctx) } +// scheduleRebuild is the SetState path: rather than rebuilding synchronously, +// it enqueues this element and lets the microtask flush coalesce repeated +// SetState calls (and SetStates across sibling elements) into a single rebuild +// pass per element. Mirrors React's batched updates. +func (e *statefulElement) scheduleRebuild() { + enqueueRebuild(e) +} + func (e *statefulElement) unmount() { + e.mounted = false if disp, ok := e.state.(StateDisposer); ok { disp.Dispose() } @@ -444,3 +528,51 @@ func (e *statefulElement) unmount() { e.child = nil } } + +// =========== batched rebuild scheduler =========== +// +// SetState enqueues its element here instead of rebuilding inline. The first +// enqueue of a flush cycle schedules a microtask (queueMicrotask) that drains +// the queue. Repeated SetState on the same element within one tick collapses to +// one rebuild; SetStates on different elements all flush together. Elements +// unmounted before the flush are skipped via their mounted flag. +// +// WASM Go runs on the single JS event loop, so no locking is needed: enqueue +// and flush never interleave. +var ( + rebuildQueue []*statefulElement + rebuildQueued = map[*statefulElement]bool{} + flushScheduled bool + flushFn js.Func +) + +func enqueueRebuild(e *statefulElement) { + if rebuildQueued[e] { + return + } + rebuildQueued[e] = true + rebuildQueue = append(rebuildQueue, e) + if flushScheduled { + return + } + flushScheduled = true + if flushFn.IsUndefined() { + flushFn = js.FuncOf(func(js.Value, []js.Value) any { + flushRebuilds() + return nil + }) + } + js.Global().Call("queueMicrotask", flushFn) +} + +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. + queue := rebuildQueue + rebuildQueue = nil + rebuildQueued = map[*statefulElement]bool{} + flushScheduled = false + for _, e := range queue { + e.rebuild() + } +} diff --git a/state.go b/state.go index e094591..a147ed5 100644 --- a/state.go +++ b/state.go @@ -32,8 +32,13 @@ type WidgetUpdater interface { // stateElement is the slice of statefulElement that the State sees: just // enough to request a rebuild of its own subtree. The concrete implementation // lives in element_wasm.go. +// +// scheduleRebuild is the SetState path: it requests a rebuild that the runtime +// batches into a microtask so back-to-back SetState calls collapse into one +// pass. rebuild() is the synchronous variant the runtime uses internally. type stateElement interface { rebuild() + scheduleRebuild() } // elementBinder is satisfied by StateObject (via embedding). The framework @@ -58,10 +63,15 @@ type StateObject struct { // SetState mutates state and asks the framework to rebuild the subtree owned // by this State. If the state has not yet been mounted, the call is a no-op. +// +// The rebuild is batched: multiple SetState calls in the same event-loop turn +// (e.g. several Notifier listeners firing, or a loop calling SetState) coalesce +// into a single rebuild on the next microtask. Don't rely on the DOM being +// updated synchronously when SetState returns. func (s *StateObject) SetState(fn func()) { fn() if s.elem != nil { - s.elem.rebuild() + s.elem.scheduleRebuild() } } From 4fd811ff4e6dbe4401ffec119e99cdfaf5d8f71e Mon Sep 17 00:00:00 2001 From: Kiet Nguyen Tuan Date: Mon, 25 May 2026 23:12:16 +0700 Subject: [PATCH 3/8] feat: layout widgets, theme-aware Container, and color tokens Let apps express richer layouts and reference theme colors by role instead of hand-writing CSS or hard-coding hex. - New layout primitives (widgets/layout_ext.go): Expanded, Flexible, Spacer, Stack+Positioned, Grid (incl. MinColumnWidth for media-query-free responsive reflow), Wrap, Align (nine presets), AspectRatio, ConstrainedBox. - Color tokens (widgets/color.go): ColorPrimary, ColorInk, ... "theme:*" sentinel strings resolved against the active theme; raw CSS colors pass through unchanged, so fields stay backward-compatible. - Container is now a theme-aware StatelessWidget (so it can read ctx.Theme): resolves Color/BorderColor tokens and gains Shadow, Overflow, Position+inset, Gap, Min/Max sizing, Opacity, Cursor, Transition, Flex/AlignSelf. - Heading/Body/Caption/Link resolve color tokens too. - Showcase: new "Layout & color tokens" section demoing all of the above. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/showcase/main.go | 127 ++++++++++++++++ widgets/color.go | 84 +++++++++++ widgets/container.go | 120 +++++++++++---- widgets/heading.go | 6 +- widgets/layout_ext.go | 310 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 618 insertions(+), 29 deletions(-) create mode 100644 widgets/color.go create mode 100644 widgets/layout_ext.go diff --git a/examples/showcase/main.go b/examples/showcase/main.go index 617f950..508a901 100644 --- a/examples/showcase/main.go +++ b/examples/showcase/main.go @@ -152,6 +152,7 @@ func (s *showcaseState) Build(ctx *gutter.BuildContext) gutter.Widget { surfaceVariantsSection(), badgesSection(), imagesSection(), + layoutSection(), inputsSection(s), formControlsSection(s), textAreaSection(s), @@ -420,6 +421,132 @@ func imagesSection() gutter.Widget { }) } +// layoutSection tours the flex/grid layout primitives and the theme Color +// tokens. Every coloured box here is a Container tinted with a token +// (ColorSurfaceSoft, ColorSurfaceDark, …) rather than a hard-coded hex — swap +// the build-time theme and the whole section recolours itself. +func layoutSection() gutter.Widget { + tile := func(label string) gutter.Widget { + return widgets.Container{ + Color: widgets.ColorSurfaceSoft, + BorderColor: widgets.ColorHairline, + BorderRadius: "10px", + Padding: widgets.EdgeInsetsAll(14), + Child: widgets.Body{Text: label, Small: true}, + } + } + gridTiles := make([]gutter.Widget, 6) + for i := range gridTiles { + gridTiles[i] = tile(fmt.Sprintf("cell %d", i+1)) + } + chipLabels := []string{"flutter", "wasm", "go", "declarative", "no-css", "reactive", "themed"} + chips := make([]gutter.Widget, len(chipLabels)) + for i, c := range chipLabels { + chips[i] = widgets.Container{ + Color: widgets.ColorCanvasAlt, + BorderColor: widgets.ColorHairline, + BorderRadius: "999px", + Padding: widgets.EdgeInsetsSymmetric(6, 14), + Child: widgets.Caption{Text: c}, + } + } + tokens := []struct{ name, token string }{ + {"Primary", widgets.ColorPrimary}, + {"Accent", widgets.ColorAccent}, + {"SurfaceSoft", widgets.ColorSurfaceSoft}, + {"CanvasAlt", widgets.ColorCanvasAlt}, + {"SurfaceDark", widgets.ColorSurfaceDark}, + {"Success", widgets.ColorSuccess}, + {"Warning", widgets.ColorWarning}, + {"Critical", widgets.ColorCritical}, + } + swatches := make([]gutter.Widget, len(tokens)) + for i, tk := range tokens { + swatches[i] = widgets.Column{ + Spacing: 4, + CrossAxisAlign: widgets.CrossAxisCenter, + Children: []gutter.Widget{ + widgets.Container{Color: tk.token, BorderRadius: "8px", Width: "72px", Height: "44px", BorderColor: widgets.ColorHairline}, + widgets.Caption{Text: tk.name}, + }, + } + } + + return sectionFrame("Layout & color tokens", widgets.Column{ + Spacing: 24, + Children: []gutter.Widget{ + // Row + Expanded + Spacer: a fixed label, an Expanded that eats the + // free space, then an action pushed to the far right by a Spacer. + widgets.Caption{Text: "Row · Expanded · Spacer"}, + widgets.Row{ + Spacing: 8, + CrossAxisAlign: widgets.CrossAxisCenter, + Children: []gutter.Widget{ + tile("fixed"), + widgets.Expanded{Child: tile("Expanded — fills the remaining width")}, + widgets.Spacer{}, + widgets.Button{Variant: widgets.ButtonGhost, Label: "Action"}, + }, + }, + + // Stack + Positioned: a card with a badge pinned to its corner. + widgets.Caption{Text: "Stack · Positioned (corner badge)"}, + widgets.Stack{ + Width: "180px", + Height: "104px", + Children: []gutter.Widget{ + widgets.Container{ + Color: widgets.ColorCanvasAlt, + BorderColor: widgets.ColorHairline, + BorderRadius: "12px", + Width: "180px", + Height: "104px", + Child: widgets.Center{Child: widgets.Body{Text: "base layer"}}, + }, + widgets.Positioned{Top: "-8px", Right: "-8px", Child: widgets.Badge{Text: "NEW"}}, + }, + }, + + // Responsive grid — no media query, just minmax(auto-fill). + widgets.Caption{Text: "Grid · MinColumnWidth 140px (resize the window — it reflows)"}, + widgets.Grid{MinColumnWidth: "140px", Gap: 12, Children: gridTiles}, + + // Wrap: chips that flow onto new lines. + widgets.Caption{Text: "Wrap · chips"}, + widgets.Wrap{Spacing: 8, RunSpacing: 8, Children: chips}, + + // AspectRatio inside a ConstrainedBox. + widgets.Caption{Text: "ConstrainedBox MaxWidth 320 · AspectRatio 16:9"}, + widgets.ConstrainedBox{ + MaxWidth: "320px", + Child: widgets.AspectRatio{ + Ratio: 16.0 / 9.0, + Child: widgets.Container{ + Color: widgets.ColorSurfaceDark, + BorderRadius: "12px", + Child: widgets.Center{Child: widgets.Body{Text: "16 : 9", Color: widgets.ColorOnDark}}, + }, + }, + }, + + // Align: child anchored bottom-right of a fixed box. + widgets.Caption{Text: "Align · BottomRight"}, + widgets.Container{ + Color: widgets.ColorSurfaceSoft, + BorderColor: widgets.ColorHairline, + BorderRadius: "12px", + Width: "100%", + Height: "96px", + Child: widgets.Align{Alignment: widgets.AlignBottomRight, Child: widgets.Padding{Padding: widgets.EdgeInsetsAll(10), Child: widgets.Badge{Text: "pinned"}}}, + }, + + // The palette, every box tinted by a token. + widgets.Caption{Text: "Color tokens — resolved against the active theme"}, + widgets.Wrap{Spacing: 16, RunSpacing: 12, Children: swatches}, + }, + }) +} + func inputsSection(s *showcaseState) gutter.Widget { return sectionFrame("Inputs (Type variants)", widgets.Column{ Spacing: 12, diff --git a/widgets/color.go b/widgets/color.go new file mode 100644 index 0000000..5592b19 --- /dev/null +++ b/widgets/color.go @@ -0,0 +1,84 @@ +package widgets + +import ( + "strings" + + "github.com/Runway-Club/gutter/themes" +) + +// Color tokens name a role in the active theme's palette instead of a literal +// CSS color. Pass one anywhere a themed widget takes a color string (e.g. +// Container.Color, Container.BorderColor) and the widget resolves it against +// ctx.Theme.Colors at build time — so app code references intent ("ink", +// "primary") rather than hard-coding hex, and a theme swap recolors everything. +// +// These are ordinary strings, so a raw CSS color ("#fff", "rgb(...)", +// "tomato") still works in the same field; only values carrying the "theme:" +// sentinel are looked up. resolveColor leaves everything else untouched. +const ( + ColorPrimary = "theme:primary" + ColorOnPrimary = "theme:on-primary" + ColorAccent = "theme:accent" + ColorOnAccent = "theme:on-accent" + ColorCanvas = "theme:canvas" + ColorCanvasAlt = "theme:canvas-alt" + ColorSurfaceSoft = "theme:surface-soft" + ColorSurfaceDark = "theme:surface-dark" + ColorOnDark = "theme:on-dark" + ColorInk = "theme:ink" + ColorInkMuted = "theme:ink-muted" + ColorInkSubtle = "theme:ink-subtle" + ColorHairline = "theme:hairline" + ColorHairlineSoft = "theme:hairline-soft" + ColorSuccess = "theme:success" + ColorWarning = "theme:warning" + ColorCritical = "theme:critical" +) + +// resolveColor maps a color value to a concrete CSS color. A value carrying +// the "theme:" sentinel is looked up in t.Colors; anything else (a raw CSS +// color, or "") is returned unchanged. An unknown token resolves to "" so it +// is simply omitted from the style rather than emitting a broken value. +func resolveColor(t *themes.Theme, v string) string { + if v == "" || !strings.HasPrefix(v, "theme:") { + return v + } + c := t.Colors + switch v { + case ColorPrimary: + return c.Primary + case ColorOnPrimary: + return c.OnPrimary + case ColorAccent: + return c.Accent + case ColorOnAccent: + return c.OnAccent + case ColorCanvas: + return c.Canvas + case ColorCanvasAlt: + return c.CanvasAlt + case ColorSurfaceSoft: + return c.SurfaceSoft + case ColorSurfaceDark: + return c.SurfaceDark + case ColorOnDark: + return c.OnDark + case ColorInk: + return c.Ink + case ColorInkMuted: + return c.InkMuted + case ColorInkSubtle: + return c.InkSubtle + case ColorHairline: + return c.Hairline + case ColorHairlineSoft: + return c.HairlineSoft + case ColorSuccess: + return c.Success + case ColorWarning: + return c.Warning + case ColorCritical: + return c.Critical + } + return "" +} diff --git a/widgets/container.go b/widgets/container.go index 1090784..9630fff 100644 --- a/widgets/container.go +++ b/widgets/container.go @@ -32,44 +32,112 @@ func (e EdgeInsets) CSS() string { return fmt.Sprintf("%gpx %gpx %gpx %gpx", e.Top, e.Right, e.Bottom, e.Left) } -// Container is a styled
with a single optional child. Use it as a -// general-purpose layout box for background color, padding, sizing, etc. +// Container is the general-purpose layout box: a styled
with one +// optional child. It covers the styling most screens need — background, +// spacing, sizing, borders, shadow, positioning, overflow — so app code rarely +// has to drop down to Styled and raw CSS. +// +// Color and BorderColor accept either a raw CSS color or a theme Color token +// (ColorPrimary, ColorInk, …), resolved against the active theme at build +// time. Container is a StatelessWidget for exactly this reason: a HostWidget +// can't see ctx.Theme, so it could never reference theme colors by role. +// +// Most fields map to the obvious CSS property and are omitted when empty. type Container struct { - Child gutter.Widget - Padding EdgeInsets - Margin EdgeInsets + Child gutter.Widget + + // Spacing. + Padding EdgeInsets + Margin EdgeInsets + Gap float64 // gap between children when AlignChildren lays them out + + // Background and border. Color/BorderColor take a raw CSS color or a + // theme Color token. Border (a full CSS shorthand) wins over the + // BorderColor/BorderWidth pair. Color string - Width string - Height string BorderRadius string Border string + BorderColor string + BorderWidth string // defaults to "1px" when BorderColor is set + + // Sizing. + Width string + Height string + MinWidth string + MaxWidth string + MinHeight string + MaxHeight string + + // Box behaviour. + Shadow string // box-shadow + Overflow string // overflow + Cursor string // cursor + Opacity string // opacity ("0".."1"); raw string so 0 is expressible + + // Positioning. Position is "relative"/"absolute"/"fixed"/"sticky"; the + // inset fields are applied when set. + Position string + Top, Right, Bottom, Left string + ZIndex string + + // Flex participation, for when this Container is a child of Row/Column. + Flex string // flex shorthand, e.g. "1" + AlignSelf string + + // Transition shorthand for animating the above on state changes. + Transition string } -func (c Container) Host() *gutter.Host { - h := &gutter.Host{Tag: "div", Style: map[string]string{}} +func (c Container) Build(ctx *gutter.BuildContext) gutter.Widget { + t := activeTheme(ctx) + style := map[string]string{} + if !c.Padding.IsZero() { - h.Style["padding"] = c.Padding.CSS() + style["padding"] = c.Padding.CSS() } if !c.Margin.IsZero() { - h.Style["margin"] = c.Margin.CSS() - } - if c.Color != "" { - h.Style["background-color"] = c.Color + style["margin"] = c.Margin.CSS() } - if c.Width != "" { - h.Style["width"] = c.Width + if c.Gap > 0 { + style["gap"] = px(c.Gap) } - if c.Height != "" { - h.Style["height"] = c.Height - } - if c.BorderRadius != "" { - h.Style["border-radius"] = c.BorderRadius - } - if c.Border != "" { - h.Style["border"] = c.Border + + setIf(style, "background-color", resolveColor(t, c.Color)) + setIf(style, "border-radius", c.BorderRadius) + switch { + case c.Border != "": + style["border"] = c.Border + case c.BorderColor != "": + width := fallback(c.BorderWidth, "1px") + style["border"] = width + " solid " + resolveColor(t, c.BorderColor) } + + setIf(style, "width", c.Width) + setIf(style, "height", c.Height) + setIf(style, "min-width", c.MinWidth) + setIf(style, "max-width", c.MaxWidth) + setIf(style, "min-height", c.MinHeight) + setIf(style, "max-height", c.MaxHeight) + + setIf(style, "box-shadow", c.Shadow) + setIf(style, "overflow", c.Overflow) + setIf(style, "cursor", c.Cursor) + setIf(style, "opacity", c.Opacity) + + setIf(style, "position", c.Position) + setIf(style, "top", c.Top) + setIf(style, "right", c.Right) + setIf(style, "bottom", c.Bottom) + setIf(style, "left", c.Left) + setIf(style, "z-index", c.ZIndex) + + setIf(style, "flex", c.Flex) + setIf(style, "align-self", c.AlignSelf) + setIf(style, "transition", c.Transition) + + var children []gutter.Widget if c.Child != nil { - h.Children = []gutter.Widget{c.Child} + children = []gutter.Widget{c.Child} } - return h + return Styled{Style: style, Children: children} } diff --git a/widgets/heading.go b/widgets/heading.go index 61a6c7f..393ed96 100644 --- a/widgets/heading.go +++ b/widgets/heading.go @@ -30,7 +30,7 @@ type Heading struct { func (h Heading) Build(ctx *gutter.BuildContext) gutter.Widget { t := activeTheme(ctx) spec := headingSpec(t, h.Level) - return Text{Data: h.Text, Style: styleFromSpec(spec, fallback(h.Color, t.Colors.Ink))} + return Text{Data: h.Text, Style: styleFromSpec(spec, fallback(resolveColor(t, h.Color), t.Colors.Ink))} } func headingSpec(t *themes.Theme, level HeadingLevel) themes.TextSpec { @@ -75,7 +75,7 @@ func (b Body) Build(ctx *gutter.BuildContext) gutter.Widget { default: spec = t.Typography.Body } - return Text{Data: b.Text, Style: styleFromSpec(spec, fallback(b.Color, t.Colors.Ink))} + return Text{Data: b.Text, Style: styleFromSpec(spec, fallback(resolveColor(t, b.Color), t.Colors.Ink))} } // Caption is shorthand for Body{Small: true}. @@ -101,7 +101,7 @@ type Link struct { func (l Link) Build(ctx *gutter.BuildContext) gutter.Widget { t := activeTheme(ctx) style := map[string]string{ - "color": fallback(l.Color, t.Colors.Primary), + "color": fallback(resolveColor(t, l.Color), t.Colors.Primary), "text-decoration": "none", "cursor": "pointer", } diff --git a/widgets/layout_ext.go b/widgets/layout_ext.go new file mode 100644 index 0000000..a92b1e2 --- /dev/null +++ b/widgets/layout_ext.go @@ -0,0 +1,310 @@ +package widgets + +import ( + "fmt" + + "github.com/Runway-Club/gutter" +) + +// px formats a pixel length, returning "" for a non-positive value so callers +// can omit the property entirely. +func px(v float64) string { + if v == 0 { + return "" + } + return fmt.Sprintf("%gpx", v) +} + +// one wraps a single optional child in a *Host with the given tag/style. +func box(style map[string]string, child gutter.Widget) *gutter.Host { + h := &gutter.Host{Tag: "div", Style: style} + if child != nil { + h.Children = []gutter.Widget{child} + } + return h +} + +// =========== flex children =========== + +// Expanded makes its child fill the free space along the main axis of the +// enclosing Row or Column, the way Flutter's Expanded does. Flex weights the +// share when several Expanded siblings compete; the zero value means 1. +// +// Use this instead of writing Styled{Style:{"flex":"1"}} by hand — it also +// sets min-width/min-height:0 so a long child can shrink below its content +// size rather than forcing the row to overflow (the classic flexbox gotcha). +type Expanded struct { + Child gutter.Widget + Flex int +} + +func (e Expanded) Host() *gutter.Host { + flex := e.Flex + if flex <= 0 { + flex = 1 + } + return box(map[string]string{ + "flex": fmt.Sprintf("%d 1 0%%", flex), + "min-width": "0", + "min-height": "0", + }, e.Child) +} + +// Flexible is like Expanded but keeps the child's natural size as its basis: it +// may grow into free space but won't force itself to zero. Flex weights how +// growth is shared; the zero value means 1. +type Flexible struct { + Child gutter.Widget + Flex int +} + +func (f Flexible) Host() *gutter.Host { + flex := f.Flex + if flex <= 0 { + flex = 1 + } + return box(map[string]string{"flex": fmt.Sprintf("%d 1 auto", flex)}, f.Child) +} + +// Spacer is flexible empty space inside a Row or Column — handy for pushing +// siblings apart (e.g. a title on the left, actions on the right). Flex weights +// it against other Spacers/Expanded; the zero value means 1. +type Spacer struct { + Flex int +} + +func (s Spacer) Host() *gutter.Host { + flex := s.Flex + if flex <= 0 { + flex = 1 + } + return &gutter.Host{Tag: "div", Style: map[string]string{"flex": fmt.Sprintf("%d 1 0%%", flex)}} +} + +// =========== stack / positioned =========== + +// Stack layers its children in a positioning context. Plain children flow +// normally and size the stack; wrap any child in Positioned to pin it with +// absolute offsets over the others (badges, overlays, corner ribbons). Width +// and Height size the stack explicitly when no in-flow child does. +type Stack struct { + Children []gutter.Widget + Width string + Height string +} + +func (s Stack) Host() *gutter.Host { + style := map[string]string{"position": "relative"} + if s.Width != "" { + style["width"] = s.Width + } + if s.Height != "" { + style["height"] = s.Height + } + return &gutter.Host{Tag: "div", Style: style, Children: s.Children} +} + +// Positioned pins its child with absolute offsets inside the nearest Stack. +// Offsets and size are CSS lengths ("0", "8px", "50%"); empty means unset. +// Fill is a shortcut that stretches the child to all four edges (inset:0). +type Positioned struct { + Child gutter.Widget + Top, Right, Bottom, Left string + Width, Height string + Fill bool +} + +func (p Positioned) Host() *gutter.Host { + style := map[string]string{"position": "absolute"} + if p.Fill { + style["inset"] = "0" + } + setIf(style, "top", p.Top) + setIf(style, "right", p.Right) + setIf(style, "bottom", p.Bottom) + setIf(style, "left", p.Left) + setIf(style, "width", p.Width) + setIf(style, "height", p.Height) + return box(style, p.Child) +} + +// =========== grid =========== + +// Grid arranges children with CSS Grid. Pick exactly one column strategy: +// +// - Columns: a fixed number of equal-width columns (repeat(N, 1fr)). +// - MinColumnWidth: a responsive track that fits as many columns of at least +// this width as the row allows (repeat(auto-fill, minmax(MIN, 1fr))) — this +// is the no-media-query way to get a grid that reflows on its own. +// - Template: a raw grid-template-columns string for full control. +// +// Template wins over MinColumnWidth, which wins over Columns. Gap sets both +// axes; RowGap/ColumnGap override per axis. +type Grid struct { + Children []gutter.Widget + Columns int + MinColumnWidth string + Template string + Gap float64 + RowGap float64 + ColumnGap float64 + AlignItems string + JustifyItems string +} + +func (g Grid) Host() *gutter.Host { + style := map[string]string{"display": "grid"} + switch { + case g.Template != "": + style["grid-template-columns"] = g.Template + case g.MinColumnWidth != "": + style["grid-template-columns"] = fmt.Sprintf("repeat(auto-fill, minmax(%s, 1fr))", g.MinColumnWidth) + case g.Columns > 0: + style["grid-template-columns"] = fmt.Sprintf("repeat(%d, 1fr)", g.Columns) + } + if g.Gap > 0 { + style["gap"] = px(g.Gap) + } + if g.RowGap > 0 { + style["row-gap"] = px(g.RowGap) + } + if g.ColumnGap > 0 { + style["column-gap"] = px(g.ColumnGap) + } + setIf(style, "align-items", g.AlignItems) + setIf(style, "justify-items", g.JustifyItems) + return &gutter.Host{Tag: "div", Style: style, Children: g.Children} +} + +// =========== wrap =========== + +// Wrap lays children out along the main axis like a Row/Column but wraps to a +// new line when they run out of room — chips, tags, responsive card strips. +// Spacing is the gap between items on a line; RunSpacing is the gap between +// lines. Direction defaults to horizontal. +type Wrap struct { + Children []gutter.Widget + Direction string // "row" (default) or "column" + Spacing float64 + RunSpacing float64 + Alignment string // justify-content + CrossAlignment string // align-items +} + +func (w Wrap) Host() *gutter.Host { + dir := w.Direction + if dir == "" { + dir = "row" + } + style := map[string]string{ + "display": "flex", + "flex-wrap": "wrap", + "flex-direction": dir, + } + if w.RunSpacing > 0 || w.Spacing > 0 { + // gap: . For a row, run spacing is vertical + // (row-gap) and item spacing is horizontal (column-gap). + row, col := w.RunSpacing, w.Spacing + if dir == "column" { + row, col = w.Spacing, w.RunSpacing + } + style["gap"] = fmt.Sprintf("%gpx %gpx", row, col) + } + setIf(style, "justify-content", w.Alignment) + setIf(style, "align-items", w.CrossAlignment) + return &gutter.Host{Tag: "div", Style: style, Children: w.Children} +} + +// =========== align =========== + +// Alignment positions a child within a box. Use the named presets +// (AlignCenter, AlignTopRight, …) rather than building one by hand. +type Alignment struct { + Justify string // horizontal: justify-content + Align string // vertical: align-items +} + +// Alignment presets covering the nine anchor points of a box. +var ( + AlignTopLeft = Alignment{CrossAxisStart, CrossAxisStart} + AlignTopCenter = Alignment{MainAxisCenter, CrossAxisStart} + AlignTopRight = Alignment{MainAxisEnd, CrossAxisStart} + AlignCenterLeft = Alignment{CrossAxisStart, CrossAxisCenter} + AlignCenter = Alignment{MainAxisCenter, CrossAxisCenter} + AlignCenterRight = Alignment{MainAxisEnd, CrossAxisCenter} + AlignBottomLeft = Alignment{CrossAxisStart, CrossAxisEnd} + AlignBottomCenter = Alignment{MainAxisCenter, CrossAxisEnd} + AlignBottomRight = Alignment{MainAxisEnd, CrossAxisEnd} +) + +// Align positions its child at one of the nine anchor points of a full-size +// box. Center is the special case Align{Alignment: AlignCenter} covers — Center +// stays as its own widget for the common path. +type Align struct { + Alignment Alignment + Child gutter.Widget +} + +func (a Align) Host() *gutter.Host { + just := a.Alignment.Justify + if just == "" { + just = MainAxisCenter + } + align := a.Alignment.Align + if align == "" { + align = CrossAxisCenter + } + return box(map[string]string{ + "display": "flex", + "justify-content": just, + "align-items": align, + "width": "100%", + "height": "100%", + }, a.Child) +} + +// =========== aspect ratio / constraints =========== + +// AspectRatio forces its child into a fixed width:height proportion (Ratio is +// width divided by height: 16/9 for video, 1 for a square). By default the box +// takes the available width and derives its height; set Width to constrain it. +type AspectRatio struct { + Ratio float64 + Width string + Child gutter.Widget +} + +func (a AspectRatio) Host() *gutter.Host { + ratio := a.Ratio + if ratio <= 0 { + ratio = 1 + } + style := map[string]string{ + "aspect-ratio": fmt.Sprintf("%g", ratio), + "overflow": "hidden", + } + if a.Width != "" { + style["width"] = a.Width + } else { + style["width"] = "100%" + } + return box(style, a.Child) +} + +// ConstrainedBox clamps its child's size with CSS min/max constraints. The +// most common use is a readable content column: ConstrainedBox{MaxWidth: +// "720px"} inside a Center. All fields are CSS lengths; empty means unset. +type ConstrainedBox struct { + MinWidth, MaxWidth string + MinHeight, MaxHeight string + Child gutter.Widget +} + +func (c ConstrainedBox) Host() *gutter.Host { + style := map[string]string{} + setIf(style, "min-width", c.MinWidth) + setIf(style, "max-width", c.MaxWidth) + setIf(style, "min-height", c.MinHeight) + setIf(style, "max-height", c.MaxHeight) + return box(style, c.Child) +} From 1669f6afe9bb01be6879c2955bd3ec3bc5ed8c2e Mon Sep 17 00:00:00 2001 From: Kiet Nguyen Tuan Date: Mon, 25 May 2026 23:12:28 +0700 Subject: [PATCH 4/8] test: add three-layer test suite (host / wasm runtime / e2e) + CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard framework quality so changes can't silently break products built on Gutter. - Layer 1 (host go test): every widget's rendered *gutter.Host, color-token resolution, typography, Notifier, AssetURL, options, and the SetState-batching contract. Core 97.7% / themes 100% coverage. - Layer 2 (element_wasm_test.go via wasmbrowsertest): the reconciler against a real DOM — mount/update/unmount, attr/style diffing, keyed + positional reconcileChildren (asserting DOM node identity survives reorder), event payload, persistent-listener handler swap, batched-SetState coalescing, dispose lifecycle. - Layer 3 (e2e/): a deterministic testapp driven by Playwright — render, batched counter, controlled-input caret, keyed reorder value retention, conditional mount/unmount. - CI (.github/workflows/test.yml) runs all three on every push/PR. - TESTING.md documents the layers and the wasmbrowsertest setup. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 79 ++++++++ TESTING.md | 79 ++++++++ assets_test.go | 44 +++++ e2e/.gitignore | 7 + e2e/package-lock.json | 78 ++++++++ e2e/package.json | 14 ++ e2e/playwright.config.js | 27 +++ e2e/serve.sh | 13 ++ e2e/testapp/go.mod | 7 + e2e/testapp/index.html | 21 +++ e2e/testapp/main.go | 157 ++++++++++++++++ e2e/tests/app.spec.js | 100 ++++++++++ element_wasm_test.go | 367 +++++++++++++++++++++++++++++++++++++ observable_test.go | 88 +++++++++ options_test.go | 44 +++++ state_test.go | 73 ++++++++ themes/themes_test.go | 59 ++++++ widgets/color_test.go | 106 +++++++++++ widgets/components_test.go | 134 ++++++++++++++ widgets/helpers_test.go | 67 +++++++ widgets/layout_ext_test.go | 120 ++++++++++++ widgets/primitives_test.go | 124 +++++++++++++ widgets/themed_test.go | 157 ++++++++++++++++ worker_task_test.go | 55 ++++++ 24 files changed, 2020 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 TESTING.md create mode 100644 assets_test.go create mode 100644 e2e/.gitignore create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.js create mode 100755 e2e/serve.sh create mode 100644 e2e/testapp/go.mod create mode 100644 e2e/testapp/index.html create mode 100644 e2e/testapp/main.go create mode 100644 e2e/tests/app.spec.js create mode 100644 element_wasm_test.go create mode 100644 observable_test.go create mode 100644 options_test.go create mode 100644 state_test.go create mode 100644 themes/themes_test.go create mode 100644 widgets/color_test.go create mode 100644 widgets/components_test.go create mode 100644 widgets/helpers_test.go create mode 100644 widgets/layout_ext_test.go create mode 100644 widgets/primitives_test.go create mode 100644 widgets/themed_test.go create mode 100644 worker_task_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..291c758 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,79 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + # Layer 1: platform-neutral logic — widgets' rendered output, theme tokens, + # Notifier, assets, options, SetState batching. Plus formatting, vet on both + # targets, and a WASM build smoke check. + host: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: gofmt + run: test -z "$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1) + - name: vet (host) + run: go vet ./... + - name: vet (wasm) + run: GOOS=js GOARCH=wasm go vet ./... + - name: test (host, race) + run: go test -race -cover ./... + - name: build (wasm) + run: GOOS=js GOARCH=wasm go build ./... + + # Layer 2: the reconciler (element_wasm.go) running as real WASM against a + # real DOM, via wasmbrowsertest in headless Chrome. + wasm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: browser-actions/setup-chrome@v1 + - name: install wasmbrowsertest + run: | + go install github.com/agnivade/wasmbrowsertest@latest + cp "$(go env GOPATH)/bin/wasmbrowsertest" "$(go env GOPATH)/bin/go_js_wasm_exec" + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + - name: test (wasm runtime) + run: GOOS=js GOARCH=wasm go test -count=1 ./... + + # Layer 3: full end-to-end — the testapp built and served by the gutter CLI, + # driven through a real browser by Playwright. + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: install playwright + working-directory: e2e + run: | + npm install + npx playwright install --with-deps chromium + - name: e2e + working-directory: e2e + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: e2e/playwright-report + retention-days: 7 diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..7c00567 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,79 @@ +# Testing Gutter + +Gutter's tests are layered to match where the code actually runs. A change is +"safe to ship" only when the layer that exercises it is green. + +| Layer | What it covers | Tooling | Command | +|-------|----------------|---------|---------| +| 1. Host unit | Platform-neutral logic: every widget's rendered `*gutter.Host` (tags/styles/attrs/children), theme color tokens, typography mapping, `Notifier`, `AssetURL`, options, `SetState` batching contract, theme presets | `go test` (no browser) | `go test ./...` | +| 2. WASM runtime | The reconciler in `element_wasm.go` against a **real DOM**: mount/update/unmount, attribute/style diffing, keyed + positional `reconcileChildren`, event dispatch + payload, **batched `SetState` coalescing**, dispose lifecycle | `wasmbrowsertest` (headless Chrome) | `GOOS=js GOARCH=wasm go test ./...` | +| 3. End-to-end | Full user flows through a built WASM app served by the gutter CLI: render, batched counter, controlled-input caret, keyed reorder identity, conditional mount/unmount | Playwright (real browser) | `cd e2e && npm test` | + +Why three layers: most widget logic is pure CSS generation and is fastest and +most exhaustively tested on the host (layer 1). But the reconciler only exists +under `//go:build js && wasm` and needs a DOM, so it's tested in a browser +(layers 2 and 3). Layer 2 pokes the runtime's internals directly in Go; layer 3 +proves the whole stack works the way a product built on Gutter would use it. + +## Layer 1 — host unit tests + +```sh +go test ./... # fast +go test -race -cover ./... # what CI runs +``` + +No setup. These run anywhere `go` runs. + +## Layer 2 — WASM runtime tests + +These are normal `_test.go` files tagged `//go:build js && wasm` (e.g. +`element_wasm_test.go`). The Go toolchain runs a `GOOS=js GOARCH=wasm` test +binary through an exec wrapper named `go_js_wasm_exec`; we point that at +[`wasmbrowsertest`](https://github.com/agnivade/wasmbrowsertest), which loads +the binary into headless Chrome. + +One-time setup: + +```sh +go install github.com/agnivade/wasmbrowsertest@latest +cp "$(go env GOPATH)/bin/wasmbrowsertest" "$(go env GOPATH)/bin/go_js_wasm_exec" +# ensure $(go env GOPATH)/bin is on PATH +``` + +Then: + +```sh +GOOS=js GOARCH=wasm go test -count=1 ./... +``` + +Requires a Chrome/Chromium binary on the machine (chromedp finds it +automatically). The harmless `Error: Go program has already exited` line printed +after a passing run is a wasmbrowsertest artifact, not a failure — trust the +`ok` / `PASS`. + +## Layer 3 — end-to-end (Playwright) + +The app under test is [`e2e/testapp`](e2e/testapp): a deterministic gutter app +whose every interactive surface has a stable selector. Playwright's config +builds the gutter CLI, has it build + serve the testapp on `:8080` +(`e2e/serve.sh`), then drives it. + +```sh +cd e2e +npm install +npx playwright install chromium # first time only +npm test +``` + +To watch it run: `npm run test:headed`. + +## Adding tests + +- **New widget?** Add a layer-1 test asserting its rendered `*gutter.Host` + (see `widgets/*_test.go` and the `hostOf` helper). If it's a `StatefulWidget` + or imperative (`_wasm.go`), cover it in layer 2 or 3 instead. +- **Reconciler/runtime change?** Add a layer-2 test in `element_wasm_test.go`. +- **New user-facing behavior?** Add a surface to `e2e/testapp` (with a + `data-testid` via the `testID` helper) and a spec in `e2e/tests`. + +CI (`.github/workflows/test.yml`) runs all three layers on every push and PR. diff --git a/assets_test.go b/assets_test.go new file mode 100644 index 0000000..7d3a5b1 --- /dev/null +++ b/assets_test.go @@ -0,0 +1,44 @@ +package gutter + +import "testing" + +func TestAssetURL(t *testing.T) { + // Restore the default base afterwards: AssetBase is package-global state. + defer SetAssetBase("") + + cases := []struct { + name, base, in, want string + }{ + {"relative default base", "", "logo.png", "assets/logo.png"}, + {"relative strips leading slash off base join", "", "/logo.png", "/logo.png"}, // absolute → unchanged + {"empty path stays empty", "", "", ""}, + {"http absolute unchanged", "", "http://cdn/x.png", "http://cdn/x.png"}, + {"https absolute unchanged", "", "https://cdn/x.png", "https://cdn/x.png"}, + {"protocol-relative unchanged", "", "//cdn/x.png", "//cdn/x.png"}, + {"root-absolute unchanged", "", "/static/x.png", "/static/x.png"}, + {"data uri unchanged", "", "data:image/svg+xml,", "data:image/svg+xml,"}, + {"custom base", "https://cdn.example.com/v3/", "logo.png", "https://cdn.example.com/v3/logo.png"}, + {"custom base no trailing slash gets one", "https://cdn.example.com/v3", "logo.png", "https://cdn.example.com/v3/logo.png"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + SetAssetBase(c.base) + if got := AssetURL(c.in); got != c.want { + t.Fatalf("AssetURL(%q) with base %q = %q, want %q", c.in, c.base, got, c.want) + } + }) + } +} + +func TestSetAssetBaseNormalization(t *testing.T) { + defer SetAssetBase("") + + SetAssetBase("cdn/assets") + if got := AssetBaseURL(); got != "cdn/assets/" { + t.Fatalf("AssetBaseURL() = %q, want trailing slash added", got) + } + SetAssetBase("") // reset + if got := AssetBaseURL(); got != "assets/" { + t.Fatalf("AssetBaseURL() after reset = %q, want %q", got, "assets/") + } +} diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..5e20894 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +test-results/ +playwright-report/ +playwright/.cache/ +testapp/dist/ +testapp/wasm_exec.js +testapp/app.wasm diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..f9737e4 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "gutter-e2e", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gutter-e2e", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "^1.48.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..4d6b4b8 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "gutter-e2e", + "version": "0.0.0", + "private": true, + "description": "End-to-end tests for the Gutter framework, driving the testapp in a real browser.", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "install-browsers": "playwright install chromium" + }, + "devDependencies": { + "@playwright/test": "^1.48.0" + } +} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 0000000..240cbed --- /dev/null +++ b/e2e/playwright.config.js @@ -0,0 +1,27 @@ +// Playwright config for the gutter end-to-end suite. It builds and serves the +// testapp (e2e/testapp) via the gutter CLI, then drives it in headless Chromium. +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests', + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['list'], ['github']] : 'list', + use: { + baseURL: 'http://localhost:8080', + trace: 'on-first-retry', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: { + command: 'bash serve.sh', + url: 'http://localhost:8080', + reuseExistingServer: !process.env.CI, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/e2e/serve.sh b/e2e/serve.sh new file mode 100755 index 0000000..f729ce9 --- /dev/null +++ b/e2e/serve.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Build the gutter CLI, then build + serve the e2e testapp on :8080. +# Playwright's webServer config launches this and waits for the URL. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/.." && pwd)" + +cli="$(mktemp -d)/gutter" +( cd "$root" && go build -o "$cli" ./cmd/gutter ) + +cd "$here/testapp" +exec "$cli" run diff --git a/e2e/testapp/go.mod b/e2e/testapp/go.mod new file mode 100644 index 0000000..f73f0ab --- /dev/null +++ b/e2e/testapp/go.mod @@ -0,0 +1,7 @@ +module testapp + +go 1.23.0 + +require github.com/Runway-Club/gutter v0.0.0 + +replace github.com/Runway-Club/gutter => ../.. diff --git a/e2e/testapp/index.html b/e2e/testapp/index.html new file mode 100644 index 0000000..f1342b3 --- /dev/null +++ b/e2e/testapp/index.html @@ -0,0 +1,21 @@ + + + + + Gutter E2E + + + +
+ + + + diff --git a/e2e/testapp/main.go b/e2e/testapp/main.go new file mode 100644 index 0000000..cd33877 --- /dev/null +++ b/e2e/testapp/main.go @@ -0,0 +1,157 @@ +// Command testapp is a deterministic gutter app whose only purpose is to give +// the Playwright end-to-end suite stable surfaces to drive. Every interactive +// element is reachable by a stable selector (a data-testid attribute or a +// known placeholder/label) so the specs in ../tests don't depend on layout. +// +// Build + serve via the gutter CLI (see ../serve.sh); the specs talk to it on +// http://localhost:8080. +package main + +import ( + "strconv" + + "github.com/Runway-Club/gutter" + "github.com/Runway-Club/gutter/widgets" +) + +func main() { + gutter.RunApp(App{}) +} + +// App is the root StatefulWidget. One state object holds every surface so the +// specs can exercise SetState, controlled inputs, keyed reordering, and +// conditional mount/unmount through a single tree. +type App struct{} + +func (App) CreateState() gutter.State { return &appState{} } + +type appState struct { + gutter.StateObject + count int + echo string + order []string + itemValues map[string]string + dialogOpen bool +} + +func (s *appState) InitState() { + s.order = []string{"A", "B", "C"} + s.itemValues = map[string]string{} +} + +// testID wraps a child in a div carrying a data-testid attribute so Playwright +// can select it. Uses display:contents so it doesn't perturb layout. +func testID(id string, child gutter.Widget) gutter.Widget { + return widgets.Styled{ + Attrs: map[string]string{"data-testid": id}, + Style: map[string]string{"display": "contents"}, + Children: []gutter.Widget{child}, + } +} + +func (s *appState) Build(ctx *gutter.BuildContext) gutter.Widget { + return widgets.Scaffold{ + Title: "Gutter E2E", + Body: widgets.Padding{ + Padding: widgets.EdgeInsetsAll(24), + Child: widgets.Column{ + Spacing: 16, + Children: []gutter.Widget{ + widgets.Heading{Level: widgets.H3, Text: "Gutter E2E"}, + s.counterSection(), + s.echoSection(), + s.keyedListSection(), + s.dialogSection(), + }, + }, + }, + } +} + +// counterSection: a count display plus an increment and a "burst" button. Burst +// calls SetState five times in one turn — correctness here proves batched +// SetState applies every mutation while coalescing the rebuilds. +func (s *appState) counterSection() gutter.Widget { + return widgets.Row{ + Spacing: 12, + Children: []gutter.Widget{ + testID("count", widgets.Body{Text: "count: " + strconv.Itoa(s.count)}), + widgets.Button{Label: "increment", OnPressed: func() { + s.SetState(func() { s.count++ }) + }}, + widgets.Button{Label: "burst", OnPressed: func() { + for range 5 { + s.SetState(func() { s.count++ }) + } + }}, + }, + } +} + +// echoSection: a controlled input mirrored into a label. Tests caret-preserving +// controlled-input sync under batched rebuilds. +func (s *appState) echoSection() gutter.Widget { + return widgets.Row{ + Spacing: 12, + Children: []gutter.Widget{ + widgets.Input{ + Placeholder: "echo", + Value: s.echo, + OnChanged: func(v string) { s.SetState(func() { s.echo = v }) }, + }, + testID("echo", widgets.Body{Text: s.echo}), + }, + } +} + +// keyedListSection: keyed rows, each with its own input, plus a reverse button. +// Reversing must move the existing DOM nodes (keyed reconcile), so an input's +// typed value and focus survive the reorder. +func (s *appState) keyedListSection() gutter.Widget { + rows := make([]gutter.Widget, 0, len(s.order)+1) + for _, label := range s.order { + rows = append(rows, widgets.WithKey{Key: label, Child: widgets.Row{ + Spacing: 8, + Children: []gutter.Widget{ + widgets.Body{Text: label}, + widgets.Input{ + Placeholder: "input-" + label, + Value: s.itemValues[label], + OnChanged: func(v string) { s.SetState(func() { s.itemValues[label] = v }) }, + }, + }, + }}) + } + rows = append(rows, widgets.Button{Label: "reverse", OnPressed: func() { + s.SetState(func() { + for i, j := 0, len(s.order)-1; i < j; i, j = i+1, j-1 { + s.order[i], s.order[j] = s.order[j], s.order[i] + } + }) + }}) + return testID("keyed-list", widgets.Column{Spacing: 8, Children: rows}) +} + +// dialogSection: a conditionally-mounted panel. Toggling exercises reconcile +// mount/unmount of a subtree. +func (s *appState) dialogSection() gutter.Widget { + children := []gutter.Widget{ + widgets.Button{Label: "open dialog", OnPressed: func() { + s.SetState(func() { s.dialogOpen = true }) + }}, + } + if s.dialogOpen { + children = append(children, testID("dialog", widgets.Card{ + Child: widgets.Column{ + Spacing: 8, + Children: []gutter.Widget{ + widgets.Body{Text: "Dialog is open"}, + widgets.Button{Label: "close dialog", OnPressed: func() { + s.SetState(func() { s.dialogOpen = false }) + }}, + }, + }, + })) + } + return widgets.Column{Spacing: 8, Children: children} +} diff --git a/e2e/tests/app.spec.js b/e2e/tests/app.spec.js new file mode 100644 index 0000000..474d007 --- /dev/null +++ b/e2e/tests/app.spec.js @@ -0,0 +1,100 @@ +// End-to-end tests against the gutter testapp running as real WASM in a real +// browser. These cover the engine-critical flows a product built on Gutter +// depends on: first render, batched SetState, controlled inputs, keyed +// reordering, and conditional mount/unmount. +const { test, expect } = require('@playwright/test'); + +// waitForApp navigates to the app and waits for the WASM runtime to mount. +async function waitForApp(page) { + await page.goto('/'); + await page.waitForFunction( + () => document.getElementById('app') && document.getElementById('app').children.length > 0, + null, + { timeout: 20_000 }, + ); +} + +test('renders without panicking', async ({ page }) => { + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + await waitForApp(page); + + // NB: gutter's Heading renders a styled , not a semantic

-

, + // so we match by text rather than by ARIA role. + await expect(page.getByText('Gutter E2E')).toBeVisible(); + expect(errors, `page errors: ${errors.join('\n')}`).toEqual([]); +}); + +test('counter increments on click', async ({ page }) => { + await waitForApp(page); + const count = page.getByTestId('count'); + await expect(count).toContainText('count: 0'); + await page.getByRole('button', { name: 'increment' }).click(); + await expect(count).toContainText('count: 1'); + await page.getByRole('button', { name: 'increment' }).click(); + await expect(count).toContainText('count: 2'); +}); + +test('burst applies all five SetStates (batched but not lost)', async ({ page }) => { + await waitForApp(page); + const count = page.getByTestId('count'); + await expect(count).toContainText('count: 0'); + // One click fires five SetState calls in a single turn. Batching coalesces + // the rebuilds, but every mutation must still apply: count must reach 5. + await page.getByRole('button', { name: 'burst' }).click(); + await expect(count).toContainText('count: 5'); +}); + +test('controlled input echoes its value', async ({ page }) => { + await waitForApp(page); + const input = page.getByPlaceholder('echo'); + await input.click(); + await input.type('hello world'); + await expect(page.getByTestId('echo')).toContainText('hello world'); + await expect(input).toHaveValue('hello world'); +}); + +test('controlled input preserves caret on mid-string insert', async ({ page }) => { + await waitForApp(page); + const input = page.getByPlaceholder('echo'); + await input.click(); + await input.type('abcdef'); + // Park the caret at index 3 and type three chars one at a time. Each + // keystroke triggers a (batched) rebuild that re-syncs value; if that reset + // the caret to the end, Y and Z would append rather than stay contiguous. + await input.evaluate((el) => el.setSelectionRange(3, 3)); + await page.keyboard.type('XYZ'); + await expect(input).toHaveValue('abcXYZdef'); + const caret = await input.evaluate((el) => el.selectionStart); + expect(caret).toBe(6); +}); + +test('keyed reorder preserves each input value', async ({ page }) => { + await waitForApp(page); + // Type distinct values into A and B's inputs. + await page.getByPlaceholder('input-A').fill('alpha'); + await page.getByPlaceholder('input-B').fill('bravo'); + + // Reverse the list: A,B,C -> C,B,A. Because the rows are keyed, the existing + // DOM nodes move rather than being recreated, so the typed values ride along. + await page.getByRole('button', { name: 'reverse' }).click(); + + await expect(page.getByPlaceholder('input-A')).toHaveValue('alpha'); + await expect(page.getByPlaceholder('input-B')).toHaveValue('bravo'); + + // Confirm the visual order actually changed (C is now first). + const labels = await page.getByTestId('keyed-list').locator('input').evaluateAll( + (els) => els.map((e) => e.placeholder), + ); + expect(labels).toEqual(['input-C', 'input-B', 'input-A']); +}); + +test('dialog mounts and unmounts on toggle', async ({ page }) => { + await waitForApp(page); + await expect(page.getByTestId('dialog')).toHaveCount(0); + await page.getByRole('button', { name: 'open dialog' }).click(); + await expect(page.getByTestId('dialog')).toBeVisible(); + await expect(page.getByTestId('dialog')).toContainText('Dialog is open'); + await page.getByRole('button', { name: 'close dialog' }).click(); + await expect(page.getByTestId('dialog')).toHaveCount(0); +}); diff --git a/element_wasm_test.go b/element_wasm_test.go new file mode 100644 index 0000000..340964c --- /dev/null +++ b/element_wasm_test.go @@ -0,0 +1,367 @@ +//go:build js && wasm + +package gutter + +import ( + "syscall/js" + "testing" +) + +// These tests exercise the real reconciler (element_wasm.go) against a live +// browser DOM. Run with wasmbrowsertest: +// +// go install github.com/agnivade/wasmbrowsertest@latest +// mv $(go env GOPATH)/bin/wasmbrowsertest $(go env GOPATH)/bin/go_js_wasm_exec +// GOOS=js GOARCH=wasm go test ./... + +var ( + doc = js.Global().Get("document") + testCtxVal = &BuildContext{} +) + +// freshParent returns a detached
to mount into, isolating each test. +func freshParent() js.Value { return doc.Call("createElement", "div") } + +// ---- test widgets ---- + +// testHost is a minimal HostWidget with configurable tag/text/attrs/style and +// children, used to drive the reconciler directly. +type testHost struct { + tag string + text string + attrs map[string]string + style map[string]string + children []Widget + events map[string]func(Event) +} + +func (h testHost) Host() *Host { + return &Host{ + Tag: h.tag, + Text: h.text, + Attrs: h.attrs, + Style: h.style, + Children: h.children, + Events: h.events, + } +} + +// keyedHost participates in keyed reconciliation. +type keyedHost struct { + k string + label string +} + +func (h keyedHost) Host() *Host { + return &Host{Tag: "div", Text: h.label, Attrs: map[string]string{"data-k": h.k}} +} +func (h keyedHost) WidgetKey() any { return h.k } + +// ---- mount / update / unmount ---- + +func TestMountCreatesDOM(t *testing.T) { + parent := freshParent() + el := newElement(testHost{tag: "p", text: "hi", attrs: map[string]string{"id": "x"}, style: map[string]string{"color": "red"}}) + el.mount(parent, js.Null(), testCtxVal) + + node := parent.Get("firstChild") + if node.Get("tagName").String() != "P" { + t.Fatalf("tagName = %q, want P", node.Get("tagName").String()) + } + if node.Get("textContent").String() != "hi" { + t.Errorf("textContent = %q", node.Get("textContent").String()) + } + if node.Call("getAttribute", "id").String() != "x" { + t.Errorf("id attr = %q", node.Call("getAttribute", "id").String()) + } + if got := node.Get("style").Get("color").String(); got != "red" { + t.Errorf("style.color = %q, want red", got) + } +} + +func TestUpdateMutatesInPlace(t *testing.T) { + parent := freshParent() + el := newElement(testHost{tag: "div", text: "before", attrs: map[string]string{"id": "a"}}) + el.mount(parent, js.Null(), testCtxVal) + node := parent.Get("firstChild") + + el.update(testHost{tag: "div", text: "after", attrs: map[string]string{"id": "b"}}, testCtxVal) + + // Same node object — updated in place, not replaced. + if !parent.Get("firstChild").Equal(node) { + t.Fatal("update replaced the node instead of mutating in place") + } + if node.Get("textContent").String() != "after" { + t.Errorf("textContent = %q, want after", node.Get("textContent").String()) + } + if node.Call("getAttribute", "id").String() != "b" { + t.Errorf("id = %q, want b", node.Call("getAttribute", "id").String()) + } +} + +func TestUpdateRemovesDroppedAttr(t *testing.T) { + parent := freshParent() + el := newElement(testHost{tag: "div", attrs: map[string]string{"id": "a", "title": "t"}}) + el.mount(parent, js.Null(), testCtxVal) + node := parent.Get("firstChild") + + el.update(testHost{tag: "div", attrs: map[string]string{"id": "a"}}, testCtxVal) + if node.Call("hasAttribute", "title").Bool() { + t.Error("dropped attribute 'title' was not removed") + } +} + +func TestReconcileReplacesDifferentType(t *testing.T) { + parent := freshParent() + el := reconcile(parent, nil, testHost{tag: "div", text: "x"}, testCtxVal) + first := el.dom() + // A different Go type can't update in place — it must remount. + el2 := reconcile(parent, el, keyedHost{k: "1", label: "y"}, testCtxVal) + if el2.dom().Equal(first) { + t.Error("different widget types should remount, not reuse the DOM node") + } + if parent.Get("childNodes").Get("length").Int() != 1 { + t.Errorf("expected exactly 1 child after replace, got %d", parent.Get("childNodes").Get("length").Int()) + } +} + +// ---- keyed reconciliation ---- + +func childTexts(parent js.Value) []string { + n := parent.Get("childNodes").Get("length").Int() + out := make([]string, n) + for i := range n { + out[i] = parent.Get("childNodes").Index(i).Get("textContent").String() + } + return out +} + +func TestKeyedReorderPreservesNodeIdentity(t *testing.T) { + parent := freshParent() + el := newElement(testHost{tag: "div", children: []Widget{ + keyedHost{k: "A", label: "A"}, keyedHost{k: "B", label: "B"}, keyedHost{k: "C", label: "C"}, + }}) + el.mount(parent, js.Null(), testCtxVal) + host := parent.Get("firstChild") + + // Grab the node for key "A" before reordering. + nodeA := host.Get("childNodes").Index(0) + if nodeA.Call("getAttribute", "data-k").String() != "A" { + t.Fatal("setup: first child is not key A") + } + + // Reorder to C, A, B. + el.update(testHost{tag: "div", children: []Widget{ + keyedHost{k: "C", label: "C"}, keyedHost{k: "A", label: "A"}, keyedHost{k: "B", label: "B"}, + }}, testCtxVal) + + got := childTexts(host) + if !(got[0] == "C" && got[1] == "A" && got[2] == "B") { + t.Fatalf("after reorder, child texts = %v, want [C A B]", got) + } + // Key A must be the SAME DOM node (moved, not recreated). + movedA := host.Get("childNodes").Index(1) + if !movedA.Equal(nodeA) { + t.Error("keyed reorder recreated node A instead of moving it") + } +} + +func TestUnkeyedPositionalReuse(t *testing.T) { + parent := freshParent() + el := newElement(testHost{tag: "div", children: []Widget{ + testHost{tag: "span", text: "1"}, testHost{tag: "span", text: "2"}, + }}) + el.mount(parent, js.Null(), testCtxVal) + host := parent.Get("firstChild") + firstSpan := host.Get("childNodes").Index(0) + + el.update(testHost{tag: "div", children: []Widget{ + testHost{tag: "span", text: "one"}, testHost{tag: "span", text: "two"}, + }}, testCtxVal) + + if !host.Get("childNodes").Index(0).Equal(firstSpan) { + t.Error("unkeyed same-type child should be reused in place") + } + if got := childTexts(host); got[0] != "one" || got[1] != "two" { + t.Errorf("child texts = %v, want [one two]", got) + } +} + +func TestReconcileChildrenGrowsAndShrinks(t *testing.T) { + parent := freshParent() + el := newElement(testHost{tag: "div", children: []Widget{testHost{tag: "i", text: "1"}}}) + el.mount(parent, js.Null(), testCtxVal) + host := parent.Get("firstChild") + + el.update(testHost{tag: "div", children: []Widget{ + testHost{tag: "i", text: "1"}, testHost{tag: "i", text: "2"}, testHost{tag: "i", text: "3"}, + }}, testCtxVal) + if n := host.Get("childNodes").Get("length").Int(); n != 3 { + t.Fatalf("after grow, children = %d, want 3", n) + } + el.update(testHost{tag: "div", children: []Widget{testHost{tag: "i", text: "1"}}}, testCtxVal) + if n := host.Get("childNodes").Get("length").Int(); n != 1 { + t.Fatalf("after shrink, children = %d, want 1", n) + } +} + +// ---- events ---- + +func TestEventDispatchPayloadPointer(t *testing.T) { + parent := freshParent() + var got Event + el := newElement(testHost{tag: "button", events: map[string]func(Event){ + "click": func(e Event) { got = e }, + }}) + el.mount(parent, js.Null(), testCtxVal) + node := parent.Get("firstChild") + + evt := js.Global().Get("MouseEvent").New("click", map[string]any{"clientX": 12, "clientY": 34, "bubbles": true}) + node.Call("dispatchEvent", evt) + + if got.Type != "click" { + t.Errorf("event type = %q, want click", got.Type) + } + if got.X != 12 || got.Y != 34 { + t.Errorf("pointer coords = (%v,%v), want (12,34)", got.X, got.Y) + } +} + +func TestEventDispatchInputValue(t *testing.T) { + parent := freshParent() + var got Event + el := newElement(testHost{tag: "input", events: map[string]func(Event){ + "input": func(e Event) { got = e }, + }}) + el.mount(parent, js.Null(), testCtxVal) + node := parent.Get("firstChild") + node.Set("value", "typed text") + + evt := js.Global().Get("Event").New("input") + node.Call("dispatchEvent", evt) + if got.Value != "typed text" { + t.Errorf("input event value = %q, want %q", got.Value, "typed text") + } +} + +func TestEventHandlerSwapsAcrossUpdate(t *testing.T) { + parent := freshParent() + var which string + el := newElement(testHost{tag: "button", events: map[string]func(Event){ + "click": func(Event) { which = "first" }, + }}) + el.mount(parent, js.Null(), testCtxVal) + node := parent.Get("firstChild") + + // Update with a new handler closure for the same event name. The + // persistent per-name listener must now dispatch to the new handler. + el.update(testHost{tag: "button", events: map[string]func(Event){ + "click": func(Event) { which = "second" }, + }}, testCtxVal) + + node.Call("dispatchEvent", js.Global().Get("MouseEvent").New("click")) + if which != "second" { + t.Errorf("after update, click routed to %q, want second", which) + } +} + +func TestEventRemovedOnUpdate(t *testing.T) { + parent := freshParent() + fired := false + el := newElement(testHost{tag: "button", events: map[string]func(Event){ + "click": func(Event) { fired = true }, + }}) + el.mount(parent, js.Null(), testCtxVal) + node := parent.Get("firstChild") + + // Remove the click handler entirely. + el.update(testHost{tag: "button"}, testCtxVal) + node.Call("dispatchEvent", js.Global().Get("MouseEvent").New("click")) + if fired { + t.Error("click handler fired after it was removed on update") + } +} + +// ---- batched SetState ---- + +type batchWidget struct{} + +func (batchWidget) CreateState() State { return &batchState{} } + +type batchState struct { + StateObject + builds int +} + +func (s *batchState) Build(*BuildContext) Widget { + s.builds++ + return testHost{tag: "div"} +} + +func TestSetStateBatchesIntoOneRebuild(t *testing.T) { + parent := freshParent() + el := newElement(batchWidget{}).(*statefulElement) + el.mount(parent, js.Null(), testCtxVal) + st := el.state.(*batchState) + + if st.builds != 1 { + t.Fatalf("after mount builds = %d, want 1", st.builds) + } + + st.SetState(func() {}) + st.SetState(func() {}) + st.SetState(func() {}) + // Still batched — no synchronous rebuild yet. + if st.builds != 1 { + t.Fatalf("SetState rebuilt synchronously (builds=%d); batching is broken", st.builds) + } + + flushRebuilds() // drain the microtask queue deterministically + if st.builds != 2 { + t.Fatalf("after flush builds = %d, want 2 (three SetStates coalesced into one rebuild)", st.builds) + } +} + +func TestUnmountedElementNotRebuilt(t *testing.T) { + parent := freshParent() + el := newElement(batchWidget{}).(*statefulElement) + el.mount(parent, js.Null(), testCtxVal) + st := el.state.(*batchState) + + st.SetState(func() {}) // enqueue + el.unmount() // unmount before the flush + flushRebuilds() + if st.builds != 1 { + t.Errorf("unmounted element was rebuilt (builds=%d); mounted-guard failed", st.builds) + } +} + +// ---- dispose lifecycle ---- + +var disposeFlag bool + +type dispWidget struct{} + +func (dispWidget) CreateState() State { return &dispState{} } + +type dispState struct{ StateObject } + +func (s *dispState) Build(*BuildContext) Widget { return testHost{tag: "div"} } +func (s *dispState) Dispose() { disposeFlag = true } + +func TestUnmountRemovesNodeAndDisposes(t *testing.T) { + parent := freshParent() + disposeFlag = false + el := newElement(dispWidget{}) + el.mount(parent, js.Null(), testCtxVal) + if parent.Get("childNodes").Get("length").Int() != 1 { + t.Fatal("setup: expected one child after mount") + } + el.unmount() + if parent.Get("childNodes").Get("length").Int() != 0 { + t.Error("unmount did not remove the DOM node") + } + if !disposeFlag { + t.Error("State.Dispose was not called on unmount") + } +} diff --git a/observable_test.go b/observable_test.go new file mode 100644 index 0000000..d2db818 --- /dev/null +++ b/observable_test.go @@ -0,0 +1,88 @@ +package gutter + +import ( + "sync" + "testing" +) + +func TestNotifierValue(t *testing.T) { + n := NewNotifier(42) + if got := n.Value(); got != 42 { + t.Fatalf("Value() = %d, want 42", got) + } +} + +func TestNotifierSetFiresListeners(t *testing.T) { + n := NewNotifier("a") + var got string + var calls int + n.Listen(func(v string) { got = v; calls++ }) + n.Set("b") + if n.Value() != "b" { + t.Fatalf("Value() = %q, want %q", n.Value(), "b") + } + if got != "b" || calls != 1 { + t.Fatalf("listener got %q after %d calls, want %q after 1", got, calls, "b") + } +} + +func TestNotifierUpdate(t *testing.T) { + n := NewNotifier(10) + var seen int + n.Listen(func(v int) { seen = v }) + n.Update(func(v int) int { return v + 5 }) + if n.Value() != 15 || seen != 15 { + t.Fatalf("after Update: value=%d seen=%d, want 15/15", n.Value(), seen) + } +} + +func TestNotifierMultipleListeners(t *testing.T) { + n := NewNotifier(0) + var a, b int + n.Listen(func(v int) { a = v }) + n.Listen(func(v int) { b = v }) + n.Set(7) + if a != 7 || b != 7 { + t.Fatalf("listeners a=%d b=%d, want both 7", a, b) + } +} + +func TestNotifierCancelStopsListener(t *testing.T) { + n := NewNotifier(0) + var calls int + cancel := n.Listen(func(int) { calls++ }) + n.Set(1) + cancel() + n.Set(2) + if calls != 1 { + t.Fatalf("listener fired %d times, want 1 (cancel should stop it)", calls) + } +} + +func TestNotifierCancelIdempotent(t *testing.T) { + n := NewNotifier(0) + cancel := n.Listen(func(int) {}) + cancel() + // A second cancel must be a safe no-op, not a panic. + cancel() +} + +func TestNotifierConcurrentSet(t *testing.T) { + // The mutex must let concurrent Set/Listen run without a data race + // (run with -race to be meaningful). We only assert it doesn't deadlock + // or panic and that the final value is one of the written values. + n := NewNotifier(0) + n.Listen(func(int) {}) + var wg sync.WaitGroup + for i := 1; i <= 50; i++ { + wg.Add(1) + go func(v int) { defer wg.Done(); n.Set(v) }(i) + } + wg.Wait() + if v := n.Value(); v < 1 || v > 50 { + t.Fatalf("final value %d out of written range [1,50]", v) + } +} + +// Notifier must satisfy the Listenable interface it claims to implement. +var _ Listenable[int] = (*Notifier[int])(nil) diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..1980cc9 --- /dev/null +++ b/options_test.go @@ -0,0 +1,44 @@ +package gutter + +import ( + "testing" + + "github.com/Runway-Club/gutter/themes" +) + +func TestNewRunConfigDefaults(t *testing.T) { + cfg := newRunConfig(nil) + if cfg.selector != "#app" { + t.Fatalf("default selector = %q, want %q", cfg.selector, "#app") + } + if cfg.theme != themes.Apple { + t.Fatalf("default theme = %v, want themes.Apple", cfg.theme) + } +} + +func TestWithThemeAndSelector(t *testing.T) { + cfg := newRunConfig([]Option{ + WithTheme(themes.Meta), + WithSelector("#root"), + }) + if cfg.theme != themes.Meta { + t.Fatalf("theme = %v, want themes.Meta", cfg.theme) + } + if cfg.selector != "#root" { + t.Fatalf("selector = %q, want %q", cfg.selector, "#root") + } +} + +func TestWithThemeNilIgnored(t *testing.T) { + cfg := newRunConfig([]Option{WithTheme(nil)}) + if cfg.theme != themes.Apple { + t.Fatalf("WithTheme(nil) should keep default Apple, got %v", cfg.theme) + } +} + +func TestWithSelectorEmptyIgnored(t *testing.T) { + cfg := newRunConfig([]Option{WithSelector("")}) + if cfg.selector != "#app" { + t.Fatalf("WithSelector(\"\") should keep default, got %q", cfg.selector) + } +} diff --git a/state_test.go b/state_test.go new file mode 100644 index 0000000..a18e324 --- /dev/null +++ b/state_test.go @@ -0,0 +1,73 @@ +package gutter + +import "testing" + +// fakeElem records how the State asked the framework to rebuild. SetState must +// go through the batched scheduleRebuild path, never the synchronous rebuild. +type fakeElem struct { + rebuilds int + schedules int +} + +func (f *fakeElem) rebuild() { f.rebuilds++ } +func (f *fakeElem) scheduleRebuild() { f.schedules++ } + +var _ stateElement = (*fakeElem)(nil) + +func TestSetStateRunsMutationAndSchedules(t *testing.T) { + var so StateObject + fe := &fakeElem{} + so.bindElement(fe) + + ran := false + so.SetState(func() { ran = true }) + + if !ran { + t.Fatal("SetState did not run the mutation function") + } + if fe.schedules != 1 { + t.Fatalf("scheduleRebuild called %d times, want 1", fe.schedules) + } + if fe.rebuilds != 0 { + t.Fatalf("SetState must batch, not rebuild synchronously; rebuilds=%d", fe.rebuilds) + } +} + +func TestSetStateBeforeMountIsNoop(t *testing.T) { + var so StateObject + ran := false + // No element bound yet (state created but not mounted). Must not panic. + so.SetState(func() { ran = true }) + if !ran { + t.Fatal("mutation should still run even before mount") + } +} + +func TestStateObjectWidgetBinding(t *testing.T) { + var so StateObject + if so.Widget() != nil { + t.Fatalf("Widget() before bind = %v, want nil", so.Widget()) + } + type myWidget struct{ n int } + w := myWidget{n: 7} + so.bindWidget(w) + got, ok := so.Widget().(myWidget) + if !ok || got.n != 7 { + t.Fatalf("Widget() = %v, want %v", so.Widget(), w) + } +} + +func TestSetStateEachCallSchedules(t *testing.T) { + // The scheduler dedups at the element level, but StateObject.SetState + // itself should ask to be scheduled on every call — coalescing is the + // runtime's job, not StateObject's. + var so StateObject + fe := &fakeElem{} + so.bindElement(fe) + so.SetState(func() {}) + so.SetState(func() {}) + so.SetState(func() {}) + if fe.schedules != 3 { + t.Fatalf("scheduleRebuild called %d times across 3 SetState, want 3", fe.schedules) + } +} diff --git a/themes/themes_test.go b/themes/themes_test.go new file mode 100644 index 0000000..eafff17 --- /dev/null +++ b/themes/themes_test.go @@ -0,0 +1,59 @@ +package themes + +import ( + "strings" + "testing" +) + +func allPresets() map[string]*Theme { + return map[string]*Theme{ + "Apple": Apple, + "Meta": Meta, + "Neutral": Neutral, + } +} + +func TestPresetsAreComplete(t *testing.T) { + for name, th := range allPresets() { + t.Run(name, func(t *testing.T) { + if th == nil { + t.Fatal("preset is nil") + } + if th.Name == "" { + t.Error("Name is empty") + } + // Core semantic colors every themed widget relies on. + for field, v := range map[string]string{ + "Colors.Primary": th.Colors.Primary, + "Colors.OnPrimary": th.Colors.OnPrimary, + "Colors.Canvas": th.Colors.Canvas, + "Colors.Ink": th.Colors.Ink, + } { + if strings.TrimSpace(v) == "" { + t.Errorf("%s is empty", field) + } + } + // Body typography must be set, and per the design system every + // built-in theme leads its font stack with Lexend. + if th.Typography.Body.FontFamily == "" { + t.Error("Typography.Body.FontFamily is empty") + } else if !strings.Contains(th.Typography.Body.FontFamily, "Lexend") { + t.Errorf("Typography.Body.FontFamily = %q, expected it to lead with Lexend", th.Typography.Body.FontFamily) + } + if th.Typography.Body.FontSize == "" { + t.Error("Typography.Body.FontSize is empty") + } + }) + } +} + +func TestAppleIsFrameworkDefaultColorShape(t *testing.T) { + // Sanity: Apple and Meta are distinct presets (guards against a + // copy-paste regression where one aliases the other). + if Apple == Meta { + t.Fatal("Apple and Meta point at the same Theme value") + } + if Apple.Name == Meta.Name { + t.Fatalf("Apple.Name == Meta.Name == %q", Apple.Name) + } +} diff --git a/widgets/color_test.go b/widgets/color_test.go new file mode 100644 index 0000000..6f0cf84 --- /dev/null +++ b/widgets/color_test.go @@ -0,0 +1,106 @@ +package widgets + +import ( + "testing" + + "github.com/Runway-Club/gutter/themes" +) + +func TestResolveColorTokens(t *testing.T) { + th := themes.Apple + cases := map[string]string{ + ColorPrimary: th.Colors.Primary, + ColorOnPrimary: th.Colors.OnPrimary, + ColorAccent: th.Colors.Accent, + ColorCanvas: th.Colors.Canvas, + ColorCanvasAlt: th.Colors.CanvasAlt, + ColorSurfaceSoft: th.Colors.SurfaceSoft, + ColorSurfaceDark: th.Colors.SurfaceDark, + ColorOnDark: th.Colors.OnDark, + ColorInk: th.Colors.Ink, + ColorInkMuted: th.Colors.InkMuted, + ColorInkSubtle: th.Colors.InkSubtle, + ColorHairline: th.Colors.Hairline, + ColorHairlineSoft: th.Colors.HairlineSoft, + ColorSuccess: th.Colors.Success, + ColorWarning: th.Colors.Warning, + ColorCritical: th.Colors.Critical, + } + for token, want := range cases { + if got := resolveColor(th, token); got != want { + t.Errorf("resolveColor(%q) = %q, want %q", token, got, want) + } + } +} + +func TestResolveColorPassThrough(t *testing.T) { + th := themes.Apple + for _, raw := range []string{"#fff", "rgb(1,2,3)", "tomato", "var(--x)", ""} { + if got := resolveColor(th, raw); got != raw { + t.Errorf("resolveColor(%q) = %q, want it unchanged", raw, got) + } + } +} + +func TestResolveColorUnknownToken(t *testing.T) { + if got := resolveColor(themes.Apple, "theme:does-not-exist"); got != "" { + t.Errorf("unknown token resolved to %q, want \"\"", got) + } +} + +func TestContainerResolvesColorToken(t *testing.T) { + h := hostOfCtx(t, Container{Color: ColorPrimary}, testCtx(themes.Apple)) + wantStyle(t, h, "background-color", themes.Apple.Colors.Primary) +} + +func TestContainerRawColorUnchanged(t *testing.T) { + h := hostOfCtx(t, Container{Color: "#abcdef"}, testCtx(themes.Apple)) + wantStyle(t, h, "background-color", "#abcdef") +} + +func TestContainerBorderColorComposesBorder(t *testing.T) { + h := hostOfCtx(t, Container{BorderColor: ColorHairline}, testCtx(themes.Apple)) + want := "1px solid " + themes.Apple.Colors.Hairline + wantStyle(t, h, "border", want) +} + +func TestContainerBorderShorthandWins(t *testing.T) { + h := hostOfCtx(t, Container{Border: "2px dashed red", BorderColor: ColorHairline}, testCtx(themes.Apple)) + wantStyle(t, h, "border", "2px dashed red") +} + +func TestTokenResolvesPerTheme(t *testing.T) { + // The same token must resolve to each theme's own palette value. + apple := hostOfCtx(t, Container{Color: ColorPrimary}, testCtx(themes.Apple)) + meta := hostOfCtx(t, Container{Color: ColorPrimary}, testCtx(themes.Meta)) + if apple.Style["background-color"] != themes.Apple.Colors.Primary { + t.Error("Apple primary mismatch") + } + if meta.Style["background-color"] != themes.Meta.Colors.Primary { + t.Error("Meta primary mismatch") + } + if themes.Apple.Colors.Primary != themes.Meta.Colors.Primary && + apple.Style["background-color"] == meta.Style["background-color"] { + t.Error("token did not re-resolve when the theme changed") + } +} + +func TestHeadingResolvesColorToken(t *testing.T) { + h := hostOfCtx(t, Heading{Level: H1, Text: "x", Color: ColorPrimary}, testCtx(themes.Apple)) + wantStyle(t, h, "color", themes.Apple.Colors.Primary) +} + +func TestBodyResolvesColorToken(t *testing.T) { + h := hostOfCtx(t, Body{Text: "x", Color: ColorOnDark}, testCtx(themes.Apple)) + wantStyle(t, h, "color", themes.Apple.Colors.OnDark) +} + +func TestLinkResolvesColorToken(t *testing.T) { + h := hostOfCtx(t, Link{Text: "x", Color: ColorAccent}, testCtx(themes.Apple)) + wantStyle(t, h, "color", themes.Apple.Colors.Accent) +} + +func TestHeadingDefaultColorIsInk(t *testing.T) { + h := hostOfCtx(t, Heading{Level: H2, Text: "x"}, testCtx(themes.Apple)) + wantStyle(t, h, "color", themes.Apple.Colors.Ink) +} diff --git a/widgets/components_test.go b/widgets/components_test.go new file mode 100644 index 0000000..2d7e375 --- /dev/null +++ b/widgets/components_test.go @@ -0,0 +1,134 @@ +package widgets + +import ( + "strings" + "testing" + + "github.com/Runway-Club/gutter" + "github.com/Runway-Club/gutter/themes" +) + +func TestInputDefaultType(t *testing.T) { + h := hostOfCtx(t, Input{Placeholder: "Name"}, testCtx(themes.Apple)) + wantTag(t, h, "input") + if h.Attrs["type"] != "text" { + t.Errorf("default input type = %q, want text", h.Attrs["type"]) + } + if h.Attrs["placeholder"] != "Name" { + t.Errorf("placeholder = %q", h.Attrs["placeholder"]) + } + // Controlled value syncs through OnMount, not the value attribute. + if h.OnMount == nil { + t.Error("Input must set OnMount to sync the value property") + } + if _, ok := h.Attrs["value"]; ok { + t.Error("Input must not set the value attribute (caret churn); it uses the property") + } +} + +func TestInputTypeAndConstraints(t *testing.T) { + h := hostOfCtx(t, Input{Type: InputNumber, Min: "0", Max: "10", Step: "any"}, testCtx(themes.Apple)) + if h.Attrs["type"] != "number" { + t.Errorf("type = %q, want number", h.Attrs["type"]) + } + if h.Attrs["min"] != "0" || h.Attrs["max"] != "10" || h.Attrs["step"] != "any" { + t.Errorf("constraints not mapped: %v", h.Attrs) + } +} + +func TestInputOnChangedFiresWithValue(t *testing.T) { + var got string + h := hostOfCtx(t, Input{OnChanged: func(v string) { got = v }}, testCtx(themes.Apple)) + if h.Events["input"] == nil { + t.Fatal("Input with OnChanged must wire the input event") + } + h.Events["input"](gutter.Event{Type: "input", Value: "typed"}) + if got != "typed" { + t.Errorf("OnChanged received %q, want %q", got, "typed") + } +} + +func TestInputErrorBorder(t *testing.T) { + th := themes.Apple + normal := hostOfCtx(t, Input{}, testCtx(th)) + errored := hostOfCtx(t, Input{Error: true}, testCtx(th)) + if normal.Style["border"] == errored.Style["border"] { + t.Error("Error:true should change the border color") + } + if !strings.Contains(errored.Style["border"], th.Components.Input.BorderColorError) { + t.Errorf("error border = %q, want it to use BorderColorError", errored.Style["border"]) + } +} + +func TestCard(t *testing.T) { + th := themes.Apple + h := hostOfCtx(t, Card{Variant: CardFeature, Child: Text{Data: "x"}}, testCtx(th)) + wantTag(t, h, "div") + wantStyle(t, h, "background-color", th.Components.CardFeature.Background) + wantChildren(t, h, 1) +} + +func TestCardPaddingOverride(t *testing.T) { + h := hostOfCtx(t, Card{Padding: "40px"}, testCtx(themes.Apple)) + wantStyle(t, h, "padding", "40px") +} + +func TestIconButton(t *testing.T) { + clicked := false + h := hostOfCtx(t, IconButton{Icon: "menu", Tooltip: "Open menu", OnPressed: func() { clicked = true }}, testCtx(themes.Apple)) + wantTag(t, h, "button") + if h.Attrs["title"] != "Open menu" || h.Attrs["aria-label"] != "Open menu" { + t.Errorf("tooltip not exposed as title/aria-label: %v", h.Attrs) + } + wantChildren(t, h, 1) // the Icon + h.Events["click"](gutter.Event{}) + if !clicked { + t.Error("IconButton OnPressed not invoked") + } +} + +func TestScaffoldAppliesThemeAndCanvas(t *testing.T) { + ctx := testCtx(themes.Apple) + // Scaffold.Theme should override the ctx theme for the subtree. + h := hostOfCtx(t, Scaffold{Theme: themes.Meta, Body: Text{Data: "x"}}, ctx) + if ctx.Theme != themes.Meta { + t.Error("Scaffold should set ctx.Theme to its Theme") + } + wantStyle(t, h, "background-color", themes.Meta.Colors.Canvas) + wantStyle(t, h, "color", themes.Meta.Colors.Ink) + wantStyle(t, h, "display", "flex") + wantStyle(t, h, "flex-direction", "column") +} + +func TestScaffoldComposesChrome(t *testing.T) { + h := hostOfCtx(t, Scaffold{ + AppBar: Text{Data: "bar"}, + Body: Text{Data: "body"}, + Footer: Text{Data: "foot"}, + }, testCtx(themes.Apple)) + // AppBar + Body wrapper + Footer. + wantChildren(t, h, 3) +} + +func TestScaffoldTitleDoesNotPanicOnHost(t *testing.T) { + // SetTitle has a host stub; calling Build with a Title must be safe. + _ = hostOfCtx(t, Scaffold{Title: "Hello", Body: Text{Data: "x"}}, testCtx(themes.Apple)) +} + +func TestTransformIdentityZeroValue(t *testing.T) { + h := hostOf(t, Transform{Child: Text{Data: "x"}}) + // Zero value is the identity: no transform, but display:inline-block set. + wantNoStyle(t, h, "transform") + wantStyle(t, h, "display", "inline-block") + wantChildren(t, h, 1) +} + +func TestTransformComposition(t *testing.T) { + h := hostOf(t, Transform{TranslateX: 10, TranslateY: -5, Rotate: 90, Scale: 2}) + tr := h.Style["transform"] + for _, want := range []string{"translate(10px, -5px)", "rotate(90deg)", "scale(2, 2)"} { + if !strings.Contains(tr, want) { + t.Errorf("transform %q missing %q", tr, want) + } + } +} diff --git a/widgets/helpers_test.go b/widgets/helpers_test.go new file mode 100644 index 0000000..e737ed7 --- /dev/null +++ b/widgets/helpers_test.go @@ -0,0 +1,67 @@ +package widgets + +import ( + "testing" + + "github.com/Runway-Club/gutter" + "github.com/Runway-Club/gutter/themes" +) + +// testCtx is the BuildContext threaded into Build() during unit tests. Default +// theme is Apple; tests that need a specific theme build their own ctx. +func testCtx(th *themes.Theme) *gutter.BuildContext { + return &gutter.BuildContext{Theme: th} +} + +// hostOf resolves a widget down to the single *gutter.Host it ultimately +// renders, recursing through StatelessWidget.Build layers (Container → Styled, +// Heading → Text, etc.). It fails the test for StatefulWidgets, which need the +// full runtime and are covered by the WASM/e2e layers instead. +func hostOf(t *testing.T, w gutter.Widget) *gutter.Host { + t.Helper() + return hostOfCtx(t, w, testCtx(themes.Apple)) +} + +func hostOfCtx(t *testing.T, w gutter.Widget, ctx *gutter.BuildContext) *gutter.Host { + t.Helper() + switch x := w.(type) { + case gutter.HostWidget: + return x.Host() + case gutter.StatelessWidget: + return hostOfCtx(t, x.Build(ctx), ctx) + case gutter.StatefulWidget: + t.Fatalf("hostOf: %T is a StatefulWidget; test it via the runtime/e2e layer", w) + } + t.Fatalf("hostOf: %T implements no widget interface", w) + return nil +} + +// wantStyle asserts a single CSS property equals want. +func wantStyle(t *testing.T, h *gutter.Host, prop, want string) { + t.Helper() + if got := h.Style[prop]; got != want { + t.Errorf("style[%q] = %q, want %q", prop, got, want) + } +} + +// wantNoStyle asserts a CSS property is absent (zero value). +func wantNoStyle(t *testing.T, h *gutter.Host, prop string) { + t.Helper() + if got, ok := h.Style[prop]; ok { + t.Errorf("style[%q] = %q, want it to be unset", prop, got) + } +} + +func wantTag(t *testing.T, h *gutter.Host, want string) { + t.Helper() + if h.Tag != want { + t.Errorf("tag = %q, want %q", h.Tag, want) + } +} + +func wantChildren(t *testing.T, h *gutter.Host, n int) { + t.Helper() + if len(h.Children) != n { + t.Errorf("child count = %d, want %d", len(h.Children), n) + } +} diff --git a/widgets/layout_ext_test.go b/widgets/layout_ext_test.go new file mode 100644 index 0000000..b200ff8 --- /dev/null +++ b/widgets/layout_ext_test.go @@ -0,0 +1,120 @@ +package widgets + +import ( + "testing" + + "github.com/Runway-Club/gutter" +) + +func TestExpandedDefaultFlex(t *testing.T) { + h := hostOf(t, Expanded{Child: Text{Data: "x"}}) + wantStyle(t, h, "flex", "1 1 0%") + wantStyle(t, h, "min-width", "0") + wantStyle(t, h, "min-height", "0") + wantChildren(t, h, 1) +} + +func TestExpandedExplicitFlex(t *testing.T) { + h := hostOf(t, Expanded{Flex: 3, Child: Text{Data: "x"}}) + wantStyle(t, h, "flex", "3 1 0%") +} + +func TestFlexible(t *testing.T) { + h := hostOf(t, Flexible{Flex: 2, Child: Text{Data: "x"}}) + wantStyle(t, h, "flex", "2 1 auto") +} + +func TestSpacer(t *testing.T) { + h := hostOf(t, Spacer{}) + wantStyle(t, h, "flex", "1 1 0%") + wantChildren(t, h, 0) +} + +func TestStack(t *testing.T) { + h := hostOf(t, Stack{ + Width: "100px", + Height: "50px", + Children: []gutter.Widget{Text{Data: "base"}, Positioned{Child: Text{Data: "over"}}}, + }) + wantStyle(t, h, "position", "relative") + wantStyle(t, h, "width", "100px") + wantStyle(t, h, "height", "50px") + wantChildren(t, h, 2) +} + +func TestPositioned(t *testing.T) { + h := hostOf(t, Positioned{Top: "0", Right: "8px", Child: Text{Data: "x"}}) + wantStyle(t, h, "position", "absolute") + wantStyle(t, h, "top", "0") + wantStyle(t, h, "right", "8px") + wantNoStyle(t, h, "bottom") + wantNoStyle(t, h, "left") +} + +func TestPositionedFill(t *testing.T) { + h := hostOf(t, Positioned{Fill: true, Child: Text{Data: "x"}}) + wantStyle(t, h, "position", "absolute") + wantStyle(t, h, "inset", "0") +} + +func TestGridFixedColumns(t *testing.T) { + h := hostOf(t, Grid{Columns: 3, Gap: 16, Children: []gutter.Widget{Text{Data: "a"}}}) + wantStyle(t, h, "display", "grid") + wantStyle(t, h, "grid-template-columns", "repeat(3, 1fr)") + wantStyle(t, h, "gap", "16px") +} + +func TestGridResponsiveMinColumnWidth(t *testing.T) { + h := hostOf(t, Grid{MinColumnWidth: "140px"}) + wantStyle(t, h, "grid-template-columns", "repeat(auto-fill, minmax(140px, 1fr))") +} + +func TestGridTemplateWinsOverColumns(t *testing.T) { + // Template has the highest precedence. + h := hostOf(t, Grid{Columns: 4, MinColumnWidth: "100px", Template: "1fr 2fr"}) + wantStyle(t, h, "grid-template-columns", "1fr 2fr") +} + +func TestWrap(t *testing.T) { + h := hostOf(t, Wrap{Spacing: 8, RunSpacing: 4, Children: []gutter.Widget{Text{Data: "a"}}}) + wantStyle(t, h, "display", "flex") + wantStyle(t, h, "flex-wrap", "wrap") + wantStyle(t, h, "flex-direction", "row") + // gap is " " for a row. + wantStyle(t, h, "gap", "4px 8px") +} + +func TestAlignPresets(t *testing.T) { + h := hostOf(t, Align{Alignment: AlignBottomRight, Child: Text{Data: "x"}}) + wantStyle(t, h, "display", "flex") + wantStyle(t, h, "justify-content", MainAxisEnd) + wantStyle(t, h, "align-items", CrossAxisEnd) + wantStyle(t, h, "width", "100%") + wantStyle(t, h, "height", "100%") +} + +func TestAlignZeroValueDefaultsToCenter(t *testing.T) { + h := hostOf(t, Align{Child: Text{Data: "x"}}) + wantStyle(t, h, "justify-content", MainAxisCenter) + wantStyle(t, h, "align-items", CrossAxisCenter) +} + +func TestAspectRatio(t *testing.T) { + h := hostOf(t, AspectRatio{Ratio: 16.0 / 9.0, Child: Text{Data: "x"}}) + wantStyle(t, h, "aspect-ratio", "1.7777777777777777") + wantStyle(t, h, "width", "100%") + wantStyle(t, h, "overflow", "hidden") +} + +func TestAspectRatioZeroDefaultsToSquare(t *testing.T) { + h := hostOf(t, AspectRatio{}) + wantStyle(t, h, "aspect-ratio", "1") +} + +func TestConstrainedBox(t *testing.T) { + h := hostOf(t, ConstrainedBox{MaxWidth: "720px", MinHeight: "100px", Child: Text{Data: "x"}}) + wantStyle(t, h, "max-width", "720px") + wantStyle(t, h, "min-height", "100px") + wantNoStyle(t, h, "min-width") + wantNoStyle(t, h, "max-height") +} diff --git a/widgets/primitives_test.go b/widgets/primitives_test.go new file mode 100644 index 0000000..dcd51c0 --- /dev/null +++ b/widgets/primitives_test.go @@ -0,0 +1,124 @@ +package widgets + +import ( + "testing" + + "github.com/Runway-Club/gutter" +) + +func TestText(t *testing.T) { + h := hostOf(t, Text{Data: "hello"}) + wantTag(t, h, "span") + if h.Text != "hello" { + t.Errorf("Text = %q, want %q", h.Text, "hello") + } + // No style map when Style is nil. + if h.Style != nil { + t.Errorf("nil TextStyle should leave Host.Style nil, got %v", h.Style) + } +} + +func TestTextStyleMapsToCSS(t *testing.T) { + h := hostOf(t, Text{Data: "x", Style: &TextStyle{ + Color: "#111", + FontSize: "14px", + FontWeight: "600", + }}) + wantStyle(t, h, "color", "#111") + wantStyle(t, h, "font-size", "14px") + wantStyle(t, h, "font-weight", "600") + // Empty fields are omitted. + wantNoStyle(t, h, "line-height") + wantNoStyle(t, h, "letter-spacing") +} + +func TestColumnFlex(t *testing.T) { + h := hostOf(t, Column{ + MainAxisAlign: MainAxisSpaceBetween, + CrossAxisAlign: CrossAxisCenter, + Spacing: 12, + Children: []gutter.Widget{Text{Data: "a"}, Text{Data: "b"}}, + }) + wantTag(t, h, "div") + wantStyle(t, h, "display", "flex") + wantStyle(t, h, "flex-direction", "column") + wantStyle(t, h, "justify-content", "space-between") + wantStyle(t, h, "align-items", "center") + wantStyle(t, h, "gap", "12px") + wantChildren(t, h, 2) +} + +func TestRowFlexDirection(t *testing.T) { + h := hostOf(t, Row{Children: []gutter.Widget{Text{Data: "a"}}}) + wantStyle(t, h, "display", "flex") + wantStyle(t, h, "flex-direction", "row") + // No alignment / spacing → those properties stay unset. + wantNoStyle(t, h, "justify-content") + wantNoStyle(t, h, "align-items") + wantNoStyle(t, h, "gap") +} + +func TestCenterFillsAndCenters(t *testing.T) { + h := hostOf(t, Center{Child: Text{Data: "x"}}) + wantStyle(t, h, "display", "flex") + wantStyle(t, h, "justify-content", "center") + wantStyle(t, h, "align-items", "center") + wantStyle(t, h, "width", "100%") + wantStyle(t, h, "height", "100%") + wantChildren(t, h, 1) +} + +func TestCenterNilChild(t *testing.T) { + h := hostOf(t, Center{}) + wantChildren(t, h, 0) +} + +func TestPadding(t *testing.T) { + h := hostOf(t, Padding{Padding: EdgeInsetsAll(8), Child: Text{Data: "x"}}) + wantStyle(t, h, "padding", "8px 8px 8px 8px") + wantChildren(t, h, 1) +} + +func TestSizedBox(t *testing.T) { + h := hostOf(t, SizedBox{Width: "100px", Child: Text{Data: "x"}}) + wantStyle(t, h, "width", "100px") + wantNoStyle(t, h, "height") + wantChildren(t, h, 1) +} + +func TestStyledDefaultsToDiv(t *testing.T) { + h := hostOf(t, Styled{}) + wantTag(t, h, "div") +} + +func TestStyledPassesThrough(t *testing.T) { + h := hostOf(t, Styled{ + Tag: "section", + Text: "body", + Attrs: map[string]string{"data-testid": "x"}, + Style: map[string]string{"color": "red"}, + }) + wantTag(t, h, "section") + if h.Text != "body" { + t.Errorf("Text = %q, want %q", h.Text, "body") + } + if h.Attrs["data-testid"] != "x" { + t.Errorf("attr data-testid = %q, want x", h.Attrs["data-testid"]) + } + wantStyle(t, h, "color", "red") +} + +func TestEdgeInsets(t *testing.T) { + if got := EdgeInsetsAll(4).CSS(); got != "4px 4px 4px 4px" { + t.Errorf("EdgeInsetsAll(4).CSS() = %q", got) + } + if got := EdgeInsetsSymmetric(8, 16).CSS(); got != "8px 16px 8px 16px" { + t.Errorf("EdgeInsetsSymmetric(8,16).CSS() = %q", got) + } + if !(EdgeInsets{}).IsZero() { + t.Error("zero EdgeInsets should be IsZero") + } + if (EdgeInsets{Top: 1}).IsZero() { + t.Error("non-zero EdgeInsets should not be IsZero") + } +} diff --git a/widgets/themed_test.go b/widgets/themed_test.go new file mode 100644 index 0000000..9fb8087 --- /dev/null +++ b/widgets/themed_test.go @@ -0,0 +1,157 @@ +package widgets + +import ( + "strings" + "testing" + + "github.com/Runway-Club/gutter" + "github.com/Runway-Club/gutter/themes" +) + +func TestButtonRendersButtonTag(t *testing.T) { + clicked := false + h := hostOfCtx(t, Button{Label: "Go", OnPressed: func() { clicked = true }}, testCtx(themes.Apple)) + wantTag(t, h, "button") + if h.Text != "Go" { + t.Errorf("button text = %q, want %q", h.Text, "Go") + } + wantStyle(t, h, "cursor", "pointer") + // The click handler is wired. + if h.Events["click"] == nil { + t.Fatal("button has no click handler") + } + h.Events["click"](gutter.Event{Type: "click"}) + if !clicked { + t.Error("OnPressed not invoked by click handler") + } +} + +func TestButtonChildWins(t *testing.T) { + h := hostOfCtx(t, Button{Label: "ignored", Child: Text{Data: "x"}}, testCtx(themes.Apple)) + if h.Text != "" { + t.Errorf("Child should suppress Label text, got %q", h.Text) + } + wantChildren(t, h, 1) +} + +func TestButtonVariantsUseThemeColors(t *testing.T) { + th := themes.Apple + primary := hostOfCtx(t, Button{Variant: ButtonPrimary, Label: "x"}, testCtx(th)) + wantStyle(t, primary, "background-color", th.Components.ButtonPrimary.Background) + ghost := hostOfCtx(t, Button{Variant: ButtonGhost, Label: "x"}, testCtx(th)) + wantStyle(t, ghost, "background-color", th.Components.ButtonGhost.Background) +} + +func TestImageAssetResolvesThroughAssetURL(t *testing.T) { + defer gutter.SetAssetBase("") + gutter.SetAssetBase("") + h := hostOf(t, Image{Asset: "logo.png", Alt: "Logo"}) + wantTag(t, h, "img") + if h.Attrs["src"] != "assets/logo.png" { + t.Errorf("img src = %q, want %q", h.Attrs["src"], "assets/logo.png") + } + if h.Attrs["alt"] != "Logo" { + t.Errorf("img alt = %q, want %q", h.Attrs["alt"], "Logo") + } +} + +func TestImageSrcUsedDirectly(t *testing.T) { + h := hostOf(t, Image{Src: "https://cdn/x.png", Fit: ImageFitCover, Rounded: "50%"}) + if h.Attrs["src"] != "https://cdn/x.png" { + t.Errorf("img src = %q, want absolute URL unchanged", h.Attrs["src"]) + } + wantStyle(t, h, "object-fit", "cover") + wantStyle(t, h, "border-radius", "50%") +} + +func TestImageAssetWinsOverSrc(t *testing.T) { + defer gutter.SetAssetBase("") + gutter.SetAssetBase("") + h := hostOf(t, Image{Asset: "a.png", Src: "https://cdn/b.png"}) + if h.Attrs["src"] != "assets/a.png" { + t.Errorf("Asset should win over Src; src = %q", h.Attrs["src"]) + } +} + +func TestBadgeVariants(t *testing.T) { + th := themes.Apple + h := hostOfCtx(t, Badge{Variant: BadgeSuccess, Text: "In stock"}, testCtx(th)) + wantTag(t, h, "span") + if h.Text != "In stock" { + t.Errorf("badge text = %q", h.Text) + } + wantStyle(t, h, "background-color", th.Components.BadgeSuccess.Background) +} + +func TestIconRendersGlyphAndClass(t *testing.T) { + h := hostOf(t, Icon{Name: "home"}) + wantTag(t, h, "span") + if h.Text != "home" { + t.Errorf("icon glyph text = %q, want %q", h.Text, "home") + } + if h.Attrs["class"] != "material-symbols-outlined" { + t.Errorf("icon class = %q", h.Attrs["class"]) + } + wantStyle(t, h, "font-size", "24px") // default size + if !strings.Contains(h.Style["font-variation-settings"], "'FILL' 0") { + t.Errorf("unfilled icon should have FILL 0, got %q", h.Style["font-variation-settings"]) + } +} + +func TestIconFilledAndStyle(t *testing.T) { + h := hostOf(t, Icon{Name: "star", Filled: true, Style: IconRounded, Size: "32px", Weight: 600}) + if h.Attrs["class"] != "material-symbols-rounded" { + t.Errorf("icon class = %q, want rounded", h.Attrs["class"]) + } + wantStyle(t, h, "font-size", "32px") + v := h.Style["font-variation-settings"] + if !strings.Contains(v, "'FILL' 1") || !strings.Contains(v, "'wght' 600") { + t.Errorf("variation settings = %q, want FILL 1 + wght 600", v) + } +} + +func TestHeadingLevelsMapToTypography(t *testing.T) { + th := themes.Apple + cases := []struct { + level HeadingLevel + spec themes.TextSpec + }{ + {H1, th.Typography.HeroDisplay}, + {H2, th.Typography.DisplayLarge}, + {H3, th.Typography.DisplayMedium}, + {H4, th.Typography.HeadingLarge}, + {H5, th.Typography.HeadingMedium}, + {H6, th.Typography.HeadingSmall}, + } + for _, c := range cases { + h := hostOfCtx(t, Heading{Level: c.level, Text: "x"}, testCtx(th)) + if h.Style["font-size"] != c.spec.FontSize { + t.Errorf("H%d font-size = %q, want %q", c.level, h.Style["font-size"], c.spec.FontSize) + } + } +} + +func TestBodyVariants(t *testing.T) { + th := themes.Apple + strong := hostOfCtx(t, Body{Text: "x", Bold: true}, testCtx(th)) + if strong.Style["font-size"] != th.Typography.BodyStrong.FontSize { + t.Errorf("Bold body should use BodyStrong spec") + } + small := hostOfCtx(t, Body{Text: "x", Small: true}, testCtx(th)) + if small.Style["font-size"] != th.Typography.Caption.FontSize { + t.Errorf("Small body should use Caption spec") + } + // Caption is shorthand for Body{Small:true}. + cap := hostOfCtx(t, Caption{Text: "x"}, testCtx(th)) + if cap.Style["font-size"] != small.Style["font-size"] { + t.Errorf("Caption should match Body{Small:true}") + } +} + +func TestActiveThemeFallsBackToApple(t *testing.T) { + // nil ctx / nil theme must not panic — activeTheme falls back to Apple. + h := hostOfCtx(t, Body{Text: "x"}, &gutter.BuildContext{}) + wantStyle(t, h, "color", themes.Apple.Colors.Ink) + h2 := hostOfCtx(t, Body{Text: "x"}, nil) + wantStyle(t, h2, "color", themes.Apple.Colors.Ink) +} diff --git a/worker_task_test.go b/worker_task_test.go new file mode 100644 index 0000000..cc80429 --- /dev/null +++ b/worker_task_test.go @@ -0,0 +1,55 @@ +package gutter + +import "testing" + +func TestNewWorkerTaskRegistersAndLooksUp(t *testing.T) { + tok := NewWorkerTask("test-reverse", func(s string) string { return s + "!" }) + if tok.Name != "test-reverse" { + t.Fatalf("token Name = %q, want %q", tok.Name, "test-reverse") + } + if tok.URL != "app.wasm" { + t.Fatalf("token URL = %q, want default %q", tok.URL, "app.wasm") + } + h := lookupWorkerTask("test-reverse") + if h == nil { + t.Fatal("lookupWorkerTask returned nil for a registered task") + } + if got := h("hi"); got != "hi!" { + t.Fatalf("handler(%q) = %q, want %q", "hi", got, "hi!") + } +} + +func TestLookupUnknownWorkerTaskIsNil(t *testing.T) { + if lookupWorkerTask("does-not-exist-xyz") != nil { + t.Fatal("lookupWorkerTask for unknown name should be nil") + } +} + +func TestNewWorkerTaskDuplicatePanics(t *testing.T) { + NewWorkerTask("dup-task", func(s string) string { return s }) + assertPanics(t, "duplicate name", func() { + NewWorkerTask("dup-task", func(s string) string { return s }) + }) +} + +func TestNewWorkerTaskEmptyNamePanics(t *testing.T) { + assertPanics(t, "empty name", func() { + NewWorkerTask("", func(s string) string { return s }) + }) +} + +func TestNewWorkerTaskNilHandlerPanics(t *testing.T) { + assertPanics(t, "nil handler", func() { + NewWorkerTask("nil-handler-task", nil) + }) +} + +func assertPanics(t *testing.T, what string, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatalf("expected panic for %s, got none", what) + } + }() + fn() +} From a924b2d7e5a8b80c39f375e3f78b51c31628bf22 Mon Sep 17 00:00:00 2001 From: Kiet Nguyen Tuan Date: Mon, 25 May 2026 23:12:28 +0700 Subject: [PATCH 5/8] docs: document new widgets, tokens, engine changes, and testing Update CLAUDE.md for the layout widgets, color tokens, theme-aware Container, persistent event dispatch + batched SetState, and the three-layer test suite. Note the Heading-renders-span accessibility gap surfaced by the e2e suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 78f759f..c642c05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ Gutter is a Go library for building web applications declaratively, inspired by - `github.com/Runway-Club/gutter/widgets` — the single widget catalog. Three flavors live here side by side: - **App shell + themed** (StatelessWidgets that read `ctx.Theme`): `Scaffold` (the recommended root — `Title`/`Theme`/`AppBar`/`StickyAppBar`/`Body`/`Footer`; when `StickyAppBar` is true the bar is wrapped in `position:sticky; top:0; z-index:900` so it pins to the viewport while the body scrolls past — z-index sits below the 1000 overlay tier so Popup/Drawer/BottomSheet still cover it), `AppBar`, `Heading`, `Body`, `Caption`, `Link`, `Button`, `IconButton` (square Button variant rendering an `Icon` as its only content), `Card`, `Surface`, `Badge`, `Image` (HTML `` with `Asset` resolved via `gutter.AssetURL` or absolute `Src`; supports `Fit` for object-fit), `Icon` (Google Material Symbols glyph; `Style: IconOutlined|IconRounded|IconSharp`, `Filled`, `Weight`, `Grade` — drives the FILL/wght/GRAD/opsz axes via font-variation-settings; the scaffolded `index.html` preloads all three stylesheets), `File` (themed file picker — `Label`/`Child` for the trigger styled like a Button, `Accept`/`Multiple`, callback receives `[]FilePick{Name, Size, MimeType, Data []byte}` with bytes pre-read via FileReader; reading is WASM-only, `file_wasm.go`/`file_stub.go` split). - **Input family** (all themed via `theme.Components.Input` / `theme.Colors.Primary`; controlled — declarative `Value`/`Checked`/`Selected` field is the source of truth, change callback fires on user edits, parent rebuilds with the new value): `Input` (single-line; `Type: InputText|Password|Email|Number|Tel|URL|Search|Date|Time|DateTimeLocal|Month|Week|Color` plus `Min/Max/Step/Pattern/AutoComplete/Disabled/ReadOnly/Name`), `TextArea` (multi-line; `Rows`, `Resize`, `MaxLength`), `Checkbox` (label-wrapped native checkbox themed via `accent-color`), `Switch` (custom CSS sliding pill — implemented as a styled `