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/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/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.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()
}
}
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/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/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/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/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/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/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/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/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/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)
+}
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/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/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/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() {}
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()
+}