diff --git a/README.md b/README.md
index 0b04a91..ecc6f02 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
# layout
+
Predefined UI layouts for tinywasm modules
diff --git a/docs/CHECK_PLAN.md b/docs/CHECK_PLAN.md
new file mode 100644
index 0000000..ab7a8e8
--- /dev/null
+++ b/docs/CHECK_PLAN.md
@@ -0,0 +1,545 @@
+# PLAN: tinywasm/layout — RightPanel layout
+
+## Project purpose
+
+`tinywasm/layout` provides pre-built, consistent, reusable UI layout skeletons for
+tinywasm modules. A layout defines WHERE elements are placed (structure, grid, spacing)
+but NOT what those elements contain — that is the consumer's responsibility.
+
+Layouts are built exclusively with `tinywasm/dom` primitives (`*dom.Element`, factory
+functions like `dom.Div()`, `dom.Section()`, etc.). No HTML templates, no text/template,
+no embed of raw HTML.
+
+Each layout lives in its own sub-package so consumers import only what they use:
+
+```
+github.com/tinywasm/layout/rightpanel ← this plan
+github.com/tinywasm/layout/fullpage ← future
+github.com/tinywasm/layout/storefront ← future (e-commerce)
+```
+
+**Prerequisite:** Execute `tinywasm/dom` PLAN.md first. This plan depends on
+`github.com/tinywasm/dom` having `CssVars`, `ThemeCSS`, and `theme.css` available.
+
+---
+
+## Layout: RightPanel
+
+### Visual structure
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ div# (.rp-wrapper) │
+│ ┌────────────────────────────────┐ ┌────────────────┐ │
+│ │ section.rp-main │ │ aside.rp-aside │ │
+│ │ ┌────────────────────────────┐ │ │ ┌────────────┐ │ │
+│ │ │ div.rp-header │ │ │ │rp-aside-hdr│ │ │
+│ │ │ Title [Head] │ │ │ │[AsideCtrls]│ │ │
+│ │ │ [HeadControls] │ │ │ └────────────┘ │ │
+│ │ └────────────────────────────┘ │ │ ┌────────────┐ │ │
+│ │ ┌────────────────────────────┐ │ │ │rp-aside- │ │ │
+│ │ │ article.rp-article │ │ │ │content │ │ │
+│ │ │ [Article] │ │ │ │[Aside] │ │ │
+│ │ └────────────────────────────┘ │ │ └────────────┘ │ │
+│ └────────────────────────────────┘ └────────────────┘ │
+└─────────────────────────────────────────────────────────┘
+```
+
+Desktop: rp-main (~66vw) + rp-aside (~30vw) side by side.
+Mobile (<640px): rp-main full width, rp-aside stacked below or hidden (CSS-only).
+
+### Slots (all optional — nil = not rendered)
+
+| Field | Type | Position | Typical use |
+|---------------|---------------|---------------------------------|-------------------------|
+| Title | string | `` in rp-header | Module name |
+| Head | dom.Component | Beside `` in rp-header | Status badge, icon |
+| HeadControls | dom.Component | Below title row in rp-header | Select with search |
+| Article | dom.Component | rp-article (main content area) | Table, form, list |
+| AsideControls | dom.Component | Top of rp-aside | Search input + filters |
+| Aside | dom.Component | Content area of rp-aside | Detail panel, info card |
+
+### Module identification
+
+`RightPanel` does NOT generate its own ID. The consumer passes a value that satisfies:
+
+```go
+type Module interface {
+ ModelName() string
+}
+```
+
+The return value of `ModelName()` becomes the `id` attribute of the root `div.rp-wrapper`.
+This avoids reflect and integrates naturally with the orm model pattern already used in
+the ecosystem.
+
+---
+
+## Files to create
+
+### Directory layout
+
+```
+layout/
+├── go.mod
+├── go.sum
+├── layout.go ← package doc only (already created by gonew)
+├── LICENSE
+├── README.md
+├── docs/
+│ └── PLAN.md ← this file
+└── rightpanel/
+ ├── rightpanel.go
+ ├── rightpanel.css
+ ├── ssr.go
+ └── rightpanel_test.go
+```
+
+---
+
+### `go.mod` — update after creating files
+
+Add dependency on `tinywasm/dom`:
+
+```
+require github.com/tinywasm/dom v
+```
+
+Run `go get github.com/tinywasm/dom@latest` to resolve.
+
+---
+
+### `rightpanel/rightpanel.go`
+
+```go
+package rightpanel
+
+import "github.com/tinywasm/dom"
+
+// Module is the interface the consumer must satisfy to provide the layout ID.
+// Any struct with a ModelName() string method qualifies (e.g. ORM model structs).
+type Module interface {
+ ModelName() string
+}
+
+// RightPanel is a two-column layout skeleton:
+// - Left: main content area with header (title + controls) and article.
+// - Right: aside panel with its own header (controls) and content.
+//
+// All slots are optional. A nil slot is simply not rendered.
+// The layout does not define what the slots contain — that is the consumer's job.
+//
+// Usage:
+//
+// panel := &rightpanel.RightPanel{
+// Module: myModel, // implements ModelName() string
+// Title: "Users",
+// HeadControls: mySelectSearch,
+// Article: myTable,
+// AsideControls: myFilterBar,
+// Aside: myDetailPanel,
+// }
+// panel.Render("app")
+type RightPanel struct {
+ *dom.Element
+
+ // Module provides the ID for the root wrapper element.
+ Module Module
+
+ // Title is rendered as in the header.
+ Title string
+
+ // Head is rendered beside the (e.g. status badge, icon).
+ Head dom.Component
+
+ // HeadControls is rendered below the title row (e.g. select with search).
+ HeadControls dom.Component
+
+ // Article is the main content area.
+ Article dom.Component
+
+ // AsideControls is rendered at the top of the aside panel (e.g. search + filter).
+ AsideControls dom.Component
+
+ // Aside is the content area of the aside panel (e.g. detail view, info card).
+ Aside dom.Component
+}
+
+// Render builds the layout element tree.
+// Implements dom.ViewRenderer.
+func (r *RightPanel) Render() *dom.Element {
+ if r.Element == nil {
+ r.Element = &dom.Element{}
+ }
+
+ // ── root wrapper ─────────────────────────────────────────────────────────
+ id := ""
+ if r.Module != nil {
+ id = r.Module.ModelName()
+ }
+
+ wrapper := dom.Div().Class("rp-wrapper")
+ if id != "" {
+ wrapper.ID(id)
+ }
+
+ // ── main section ─────────────────────────────────────────────────────────
+ main := dom.Section().Class("rp-main")
+
+ // header row: title + Head slot + HeadControls slot
+ header := dom.Div().Class("rp-header")
+
+ titleRow := dom.Div().Class("rp-title-row")
+ if r.Title != "" {
+ titleRow.Add(dom.H1().Text(r.Title))
+ }
+ if r.Head != nil {
+ titleRow.Add(r.Head)
+ }
+ header.Add(titleRow)
+
+ if r.HeadControls != nil {
+ header.Add(dom.Div().Class("rp-head-controls").Add(r.HeadControls))
+ }
+ main.Add(header)
+
+ // article
+ if r.Article != nil {
+ main.Add(dom.Article().Class("rp-article").Add(r.Article))
+ } else {
+ main.Add(dom.Article().Class("rp-article"))
+ }
+
+ wrapper.Add(main)
+
+ // ── aside panel ──────────────────────────────────────────────────────────
+ if r.AsideControls != nil || r.Aside != nil {
+ aside := dom.Aside().Class("rp-aside")
+
+ if r.AsideControls != nil {
+ aside.Add(dom.Div().Class("rp-aside-header").Add(r.AsideControls))
+ }
+ if r.Aside != nil {
+ aside.Add(dom.Div().Class("rp-aside-content").Add(r.Aside))
+ }
+
+ wrapper.Add(aside)
+ }
+
+ return wrapper
+}
+```
+
+---
+
+### `rightpanel/rightpanel.css`
+
+The CSS uses `--color-*` and `--mag-*` tokens defined by `tinywasm/dom`'s `theme.css`.
+Layout-specific dimensions use `--rp-*` variables with fallbacks so the layout works
+even if the consumer has not injected the dom theme.
+
+```css
+/* ── RightPanel layout tokens (with fallbacks) ───────────────── */
+:root {
+ --rp-title-height: var(--title-height, 8vh);
+ --rp-content-height: var(--content-height, 89vh);
+ --rp-controls-height: var(--controls-height, 3vh);
+ --rp-main-width: 66vw;
+ --rp-aside-width: 30vw;
+ --rp-gap: var(--mag-pri, 0.5rem);
+ --rp-border-color: var(--color-tertiary, #94a3b8);
+ --rp-bg: var(--color-gray, #f8fafc);
+ --rp-aside-bg: var(--color-quaternary,#1e293b);
+ --rp-title-color: var(--color-secondary, #7c3aed);
+}
+
+/* ── Wrapper ─────────────────────────────────────────────────── */
+.rp-wrapper {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ height: var(--rp-content-height);
+ overflow: hidden;
+}
+
+/* ── Main section ────────────────────────────────────────────── */
+.rp-main {
+ display: grid;
+ grid-template-rows:
+ auto /* rp-header */
+ 1fr; /* rp-article */
+ width: var(--rp-main-width);
+ height: 100%;
+ overflow: hidden;
+ border-right: 0.1vw solid var(--rp-border-color);
+}
+
+/* ── Header ──────────────────────────────────────────────────── */
+.rp-header {
+ display: flex;
+ flex-direction: column;
+ background: var(--rp-bg);
+ padding: var(--mag-sec, 0.2rem) var(--mag-pri, 0.5rem);
+}
+
+.rp-title-row {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--rp-gap);
+ min-height: var(--rp-title-height);
+}
+
+.rp-title-row h1 {
+ font-size: 1.5rem;
+ color: var(--rp-title-color);
+ margin: 0;
+}
+
+.rp-head-controls {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ min-height: var(--rp-controls-height);
+ padding-bottom: var(--mag-sec, 0.2rem);
+}
+
+/* ── Article ─────────────────────────────────────────────────── */
+.rp-article {
+ overflow-y: auto;
+ padding: var(--mag-pri, 0.5rem);
+ background: var(--color-gray, #f8fafc);
+ border-radius: 0.4em 0.4em 0 0;
+}
+
+.rp-article::-webkit-scrollbar { width: 0.2em; background: none; }
+.rp-article::-webkit-scrollbar-thumb { background: var(--color-tertiary, #94a3b8); border-radius: 0.1em; }
+
+/* ── Aside panel ─────────────────────────────────────────────── */
+.rp-aside {
+ display: grid;
+ grid-template-rows:
+ auto /* rp-aside-header */
+ 1fr; /* rp-aside-content */
+ width: var(--rp-aside-width);
+ height: 100%;
+ overflow: hidden;
+}
+
+.rp-aside-header {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ min-height: var(--rp-controls-height);
+ padding: var(--mag-sec, 0.2rem) var(--mag-pri, 0.5rem);
+ background: var(--rp-aside-bg);
+}
+
+.rp-aside-content {
+ overflow-y: auto;
+ padding: var(--mag-pri, 0.5rem);
+ background: var(--rp-aside-bg);
+}
+
+.rp-aside-content::-webkit-scrollbar { width: 0.2em; background: none; }
+.rp-aside-content::-webkit-scrollbar-thumb { background: var(--color-tertiary, #94a3b8); border-radius: 0.1em; }
+
+/* ── Mobile (<640px): stack vertically ───────────────────────── */
+@media (max-width: 640px) {
+ .rp-wrapper {
+ flex-direction: column;
+ height: auto;
+ }
+
+ .rp-main {
+ width: 100%;
+ height: auto;
+ }
+
+ .rp-aside {
+ width: 100%;
+ height: auto;
+ }
+
+ .rp-article {
+ overflow-y: visible;
+ }
+
+ .rp-aside-content {
+ overflow-y: visible;
+ }
+}
+```
+
+---
+
+### `rightpanel/ssr.go`
+
+```go
+//go:build !wasm
+
+package rightpanel
+
+import _ "embed"
+
+//go:embed rightpanel.css
+var css string
+
+// RenderCSS implements dom.CSSProvider.
+// tinywasm/site collects this during SSR to inject into .
+func (r *RightPanel) RenderCSS() string {
+ return css
+}
+```
+
+---
+
+### `rightpanel/rightpanel_test.go`
+
+```go
+package rightpanel_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/tinywasm/layout/rightpanel"
+)
+
+// stubModule implements Module for tests.
+type stubModule struct{ name string }
+func (s stubModule) ModelName() string { return s.name }
+
+// stubComponent implements dom.Component for tests.
+type stubComponent struct{ html string }
+func (s *stubComponent) GetID() string { return "stub" }
+func (s *stubComponent) SetID(_ string) {}
+func (s *stubComponent) RenderHTML() string { return s.html }
+func (s *stubComponent) Children() []any { return nil }
+
+func TestRightPanel_RenderHTML_WithAllSlots(t *testing.T) {
+ panel := &rightpanel.RightPanel{
+ Module: stubModule{"users"},
+ Title: "Users",
+ Head: &stubComponent{"badge "},
+ HeadControls: &stubComponent{" "},
+ Article: &stubComponent{" "},
+ AsideControls: &stubComponent{" "},
+ Aside: &stubComponent{""},
+ }
+
+ el := panel.Render()
+ html := el.RenderHTML()
+
+ checks := []struct {
+ label, want string
+ }{
+ {"root id", "id='users'"},
+ {"wrapper class", "class='rp-wrapper'"},
+ {"main class", "class='rp-main'"},
+ {"header class", "class='rp-header'"},
+ {"title row", "class='rp-title-row'"},
+ {"h1 title", "Users "},
+ {"Head slot", "badge "},
+ {"HeadControls slot", " "},
+ {"article class", "class='rp-article'"},
+ {"Article slot", ""},
+ {"aside class", "class='rp-aside'"},
+ {"aside header", "class='rp-aside-header'"},
+ {"AsideControls slot", " "},
+ {"aside content", "class='rp-aside-content'"},
+ {"Aside slot", ""},
+ }
+
+ for _, c := range checks {
+ if !strings.Contains(html, c.want) {
+ t.Errorf("[%s] expected %q in HTML:\n%s", c.label, c.want, html)
+ }
+ }
+}
+
+func TestRightPanel_RenderHTML_AsideOmittedWhenNil(t *testing.T) {
+ panel := &rightpanel.RightPanel{
+ Module: stubModule{"orders"},
+ Title: "Orders",
+ Article: &stubComponent{""},
+ // No AsideControls, no Aside
+ }
+
+ html := panel.Render().RenderHTML()
+
+ if strings.Contains(html, "rp-aside") {
+ t.Error("expected rp-aside to be absent when both AsideControls and Aside are nil")
+ }
+}
+
+func TestRightPanel_RenderHTML_NoModuleNoID(t *testing.T) {
+ panel := &rightpanel.RightPanel{Title: "No ID"}
+ html := panel.Render().RenderHTML()
+
+ if strings.Contains(html, "id=") {
+ t.Error("expected no id attribute when Module is nil")
+ }
+}
+```
+
+---
+
+## Execution order for Jules
+
+1. Update `go.mod`: run `go get github.com/tinywasm/dom@latest`
+2. Create `rightpanel/rightpanel.go`
+3. Create `rightpanel/rightpanel.css`
+4. Create `rightpanel/ssr.go`
+5. Create `rightpanel/rightpanel_test.go`
+6. Run `go test ./rightpanel/...` — all tests must pass
+7. Commit: `feat: add rightpanel layout`
+
+---
+
+## Design rules for future layouts
+
+New layouts in this module (e.g. `fullpage/`, `storefront/`) must follow these rules:
+
+1. **Only `tinywasm/dom` primitives** — no `text/template`, no raw HTML strings, no embed
+ of `.html` files. Use `dom.Div()`, `dom.Section()`, etc.
+
+2. **CSS in own file** — each layout has its own `.css` file. No global styles. Only
+ classes prefixed with the layout abbreviation (e.g. `rp-` for rightpanel, `fp-` for
+ fullpage, `sf-` for storefront).
+
+3. **Consume `--color-*` tokens** — never hardcode colors. Use CSS variables from
+ `tinywasm/dom`'s `theme.css` with fallback values.
+
+4. **All slots optional** — a nil slot renders nothing. The layout must remain coherent
+ with any combination of nil slots.
+
+5. **Module interface for ID** — the root element ID comes from a `Module` interface
+ (`ModelName() string`). No positional string arguments for the ID.
+
+6. **SSR split** — CSS embed goes in `ssr.go` (`//go:build !wasm`).
+ `RenderCSS() string` implements `dom.CSSProvider`.
+
+7. **Mobile first** — all layouts must define a `@media (max-width: 640px)` block that
+ stacks the layout vertically and makes it usable on small screens.
+
+8. **No JS** — layout behavior (dark mode, responsiveness) must work without JavaScript.
+
+9. **Tests** — verify presence of key classes and slot content via `strings.Contains`.
+ At minimum: all slots present, aside omitted when nil, no id when Module is nil.
+
+---
+
+## Adding a new layout (example: storefront)
+
+```
+layout/
+└── storefront/
+ ├── storefront.go # struct + Render()
+ ├── storefront.css # sf-* classes only
+ ├── ssr.go # !wasm, embed CSS, RenderCSS()
+ └── storefront_test.go
+```
+
+Follow the same pattern as `rightpanel`. CSS prefix: `sf-`. Module interface: same
+`Module` interface (can be copied or moved to `layout` root package if shared).
diff --git a/docs/PLAN.md b/docs/PLAN.md
index ab7a8e8..55e2bcd 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -1,545 +1,37 @@
-# PLAN: tinywasm/layout — RightPanel layout
+# PLAN: layout/rightpanel — Component slot embedding rule
-## Project purpose
+## Problem
-`tinywasm/layout` provides pre-built, consistent, reusable UI layout skeletons for
-tinywasm modules. A layout defines WHERE elements are placed (structure, grid, spacing)
-but NOT what those elements contain — that is the consumer's responsibility.
+Passing a `dom.Component` implementor with a nil `*dom.Element` pointer to any RightPanel slot
+(Head, HeadControls, Article, AsideControls, Aside) causes a nil pointer panic in `dom` during render.
-Layouts are built exclusively with `tinywasm/dom` primitives (`*dom.Element`, factory
-functions like `dom.Div()`, `dom.Section()`, etc.). No HTML templates, no text/template,
-no embed of raw HTML.
+**Root cause:** documented in `tinywasm/dom/docs/PLAN.md`. Summary: `dom.renderToHTML` calls
+`GetID()` on every Component child before rendering, which panics on a nil embedded pointer.
-Each layout lives in its own sub-package so consumers import only what they use:
+## Rule for consumers of RightPanel
-```
-github.com/tinywasm/layout/rightpanel ← this plan
-github.com/tinywasm/layout/fullpage ← future
-github.com/tinywasm/layout/storefront ← future (e-commerce)
-```
-
-**Prerequisite:** Execute `tinywasm/dom` PLAN.md first. This plan depends on
-`github.com/tinywasm/dom` having `CssVars`, `ThemeCSS`, and `theme.css` available.
-
----
-
-## Layout: RightPanel
-
-### Visual structure
-
-```
-┌─────────────────────────────────────────────────────────┐
-│ div# (.rp-wrapper) │
-│ ┌────────────────────────────────┐ ┌────────────────┐ │
-│ │ section.rp-main │ │ aside.rp-aside │ │
-│ │ ┌────────────────────────────┐ │ │ ┌────────────┐ │ │
-│ │ │ div.rp-header │ │ │ │rp-aside-hdr│ │ │
-│ │ │ Title [Head] │ │ │ │[AsideCtrls]│ │ │
-│ │ │ [HeadControls] │ │ │ └────────────┘ │ │
-│ │ └────────────────────────────┘ │ │ ┌────────────┐ │ │
-│ │ ┌────────────────────────────┐ │ │ │rp-aside- │ │ │
-│ │ │ article.rp-article │ │ │ │content │ │ │
-│ │ │ [Article] │ │ │ │[Aside] │ │ │
-│ │ └────────────────────────────┘ │ │ └────────────┘ │ │
-│ └────────────────────────────────┘ └────────────────┘ │
-└─────────────────────────────────────────────────────────┘
-```
-
-Desktop: rp-main (~66vw) + rp-aside (~30vw) side by side.
-Mobile (<640px): rp-main full width, rp-aside stacked below or hidden (CSS-only).
-
-### Slots (all optional — nil = not rendered)
-
-| Field | Type | Position | Typical use |
-|---------------|---------------|---------------------------------|-------------------------|
-| Title | string | `` in rp-header | Module name |
-| Head | dom.Component | Beside `` in rp-header | Status badge, icon |
-| HeadControls | dom.Component | Below title row in rp-header | Select with search |
-| Article | dom.Component | rp-article (main content area) | Table, form, list |
-| AsideControls | dom.Component | Top of rp-aside | Search input + filters |
-| Aside | dom.Component | Content area of rp-aside | Detail panel, info card |
-
-### Module identification
-
-`RightPanel` does NOT generate its own ID. The consumer passes a value that satisfies:
-
-```go
-type Module interface {
- ModelName() string
-}
-```
-
-The return value of `ModelName()` becomes the `id` attribute of the root `div.rp-wrapper`.
-This avoids reflect and integrates naturally with the orm model pattern already used in
-the ecosystem.
-
----
-
-## Files to create
-
-### Directory layout
-
-```
-layout/
-├── go.mod
-├── go.sum
-├── layout.go ← package doc only (already created by gonew)
-├── LICENSE
-├── README.md
-├── docs/
-│ └── PLAN.md ← this file
-└── rightpanel/
- ├── rightpanel.go
- ├── rightpanel.css
- ├── ssr.go
- └── rightpanel_test.go
-```
-
----
-
-### `go.mod` — update after creating files
-
-Add dependency on `tinywasm/dom`:
-
-```
-require github.com/tinywasm/dom v
-```
-
-Run `go get github.com/tinywasm/dom@latest` to resolve.
-
----
-
-### `rightpanel/rightpanel.go`
+All structs passed as RightPanel slots must embed `dom.Element` as a **value**, not a pointer.
```go
-package rightpanel
-
-import "github.com/tinywasm/dom"
-
-// Module is the interface the consumer must satisfy to provide the layout ID.
-// Any struct with a ModelName() string method qualifies (e.g. ORM model structs).
-type Module interface {
- ModelName() string
-}
-
-// RightPanel is a two-column layout skeleton:
-// - Left: main content area with header (title + controls) and article.
-// - Right: aside panel with its own header (controls) and content.
-//
-// All slots are optional. A nil slot is simply not rendered.
-// The layout does not define what the slots contain — that is the consumer's job.
-//
-// Usage:
-//
-// panel := &rightpanel.RightPanel{
-// Module: myModel, // implements ModelName() string
-// Title: "Users",
-// HeadControls: mySelectSearch,
-// Article: myTable,
-// AsideControls: myFilterBar,
-// Aside: myDetailPanel,
-// }
-// panel.Render("app")
-type RightPanel struct {
+// ❌ Will panic at runtime
+type MyArticle struct {
*dom.Element
-
- // Module provides the ID for the root wrapper element.
- Module Module
-
- // Title is rendered as in the header.
- Title string
-
- // Head is rendered beside the (e.g. status badge, icon).
- Head dom.Component
-
- // HeadControls is rendered below the title row (e.g. select with search).
- HeadControls dom.Component
-
- // Article is the main content area.
- Article dom.Component
-
- // AsideControls is rendered at the top of the aside panel (e.g. search + filter).
- AsideControls dom.Component
-
- // Aside is the content area of the aside panel (e.g. detail view, info card).
- Aside dom.Component
+ ...
}
-// Render builds the layout element tree.
-// Implements dom.ViewRenderer.
-func (r *RightPanel) Render() *dom.Element {
- if r.Element == nil {
- r.Element = &dom.Element{}
- }
-
- // ── root wrapper ─────────────────────────────────────────────────────────
- id := ""
- if r.Module != nil {
- id = r.Module.ModelName()
- }
-
- wrapper := dom.Div().Class("rp-wrapper")
- if id != "" {
- wrapper.ID(id)
- }
-
- // ── main section ─────────────────────────────────────────────────────────
- main := dom.Section().Class("rp-main")
-
- // header row: title + Head slot + HeadControls slot
- header := dom.Div().Class("rp-header")
-
- titleRow := dom.Div().Class("rp-title-row")
- if r.Title != "" {
- titleRow.Add(dom.H1().Text(r.Title))
- }
- if r.Head != nil {
- titleRow.Add(r.Head)
- }
- header.Add(titleRow)
-
- if r.HeadControls != nil {
- header.Add(dom.Div().Class("rp-head-controls").Add(r.HeadControls))
- }
- main.Add(header)
-
- // article
- if r.Article != nil {
- main.Add(dom.Article().Class("rp-article").Add(r.Article))
- } else {
- main.Add(dom.Article().Class("rp-article"))
- }
-
- wrapper.Add(main)
-
- // ── aside panel ──────────────────────────────────────────────────────────
- if r.AsideControls != nil || r.Aside != nil {
- aside := dom.Aside().Class("rp-aside")
-
- if r.AsideControls != nil {
- aside.Add(dom.Div().Class("rp-aside-header").Add(r.AsideControls))
- }
- if r.Aside != nil {
- aside.Add(dom.Div().Class("rp-aside-content").Add(r.Aside))
- }
-
- wrapper.Add(aside)
- }
-
- return wrapper
+// ✅ Correct
+type MyArticle struct {
+ dom.Element
+ ...
}
```
----
-
-### `rightpanel/rightpanel.css`
-
-The CSS uses `--color-*` and `--mag-*` tokens defined by `tinywasm/dom`'s `theme.css`.
-Layout-specific dimensions use `--rp-*` variables with fallbacks so the layout works
-even if the consumer has not injected the dom theme.
-
-```css
-/* ── RightPanel layout tokens (with fallbacks) ───────────────── */
-:root {
- --rp-title-height: var(--title-height, 8vh);
- --rp-content-height: var(--content-height, 89vh);
- --rp-controls-height: var(--controls-height, 3vh);
- --rp-main-width: 66vw;
- --rp-aside-width: 30vw;
- --rp-gap: var(--mag-pri, 0.5rem);
- --rp-border-color: var(--color-tertiary, #94a3b8);
- --rp-bg: var(--color-gray, #f8fafc);
- --rp-aside-bg: var(--color-quaternary,#1e293b);
- --rp-title-color: var(--color-secondary, #7c3aed);
-}
-
-/* ── Wrapper ─────────────────────────────────────────────────── */
-.rp-wrapper {
- display: flex;
- flex-direction: row;
- width: 100%;
- height: var(--rp-content-height);
- overflow: hidden;
-}
-
-/* ── Main section ────────────────────────────────────────────── */
-.rp-main {
- display: grid;
- grid-template-rows:
- auto /* rp-header */
- 1fr; /* rp-article */
- width: var(--rp-main-width);
- height: 100%;
- overflow: hidden;
- border-right: 0.1vw solid var(--rp-border-color);
-}
-
-/* ── Header ──────────────────────────────────────────────────── */
-.rp-header {
- display: flex;
- flex-direction: column;
- background: var(--rp-bg);
- padding: var(--mag-sec, 0.2rem) var(--mag-pri, 0.5rem);
-}
-
-.rp-title-row {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: var(--rp-gap);
- min-height: var(--rp-title-height);
-}
-
-.rp-title-row h1 {
- font-size: 1.5rem;
- color: var(--rp-title-color);
- margin: 0;
-}
-
-.rp-head-controls {
- display: flex;
- flex-direction: row;
- align-items: center;
- min-height: var(--rp-controls-height);
- padding-bottom: var(--mag-sec, 0.2rem);
-}
-
-/* ── Article ─────────────────────────────────────────────────── */
-.rp-article {
- overflow-y: auto;
- padding: var(--mag-pri, 0.5rem);
- background: var(--color-gray, #f8fafc);
- border-radius: 0.4em 0.4em 0 0;
-}
-
-.rp-article::-webkit-scrollbar { width: 0.2em; background: none; }
-.rp-article::-webkit-scrollbar-thumb { background: var(--color-tertiary, #94a3b8); border-radius: 0.1em; }
-
-/* ── Aside panel ─────────────────────────────────────────────── */
-.rp-aside {
- display: grid;
- grid-template-rows:
- auto /* rp-aside-header */
- 1fr; /* rp-aside-content */
- width: var(--rp-aside-width);
- height: 100%;
- overflow: hidden;
-}
-
-.rp-aside-header {
- display: flex;
- flex-direction: row;
- align-items: center;
- min-height: var(--rp-controls-height);
- padding: var(--mag-sec, 0.2rem) var(--mag-pri, 0.5rem);
- background: var(--rp-aside-bg);
-}
-
-.rp-aside-content {
- overflow-y: auto;
- padding: var(--mag-pri, 0.5rem);
- background: var(--rp-aside-bg);
-}
-
-.rp-aside-content::-webkit-scrollbar { width: 0.2em; background: none; }
-.rp-aside-content::-webkit-scrollbar-thumb { background: var(--color-tertiary, #94a3b8); border-radius: 0.1em; }
-
-/* ── Mobile (<640px): stack vertically ───────────────────────── */
-@media (max-width: 640px) {
- .rp-wrapper {
- flex-direction: column;
- height: auto;
- }
-
- .rp-main {
- width: 100%;
- height: auto;
- }
-
- .rp-aside {
- width: 100%;
- height: auto;
- }
-
- .rp-article {
- overflow-y: visible;
- }
-
- .rp-aside-content {
- overflow-y: visible;
- }
-}
-```
-
----
-
-### `rightpanel/ssr.go`
-
-```go
-//go:build !wasm
-
-package rightpanel
-
-import _ "embed"
-
-//go:embed rightpanel.css
-var css string
-
-// RenderCSS implements dom.CSSProvider.
-// tinywasm/site collects this during SSR to inject into .
-func (r *RightPanel) RenderCSS() string {
- return css
-}
-```
-
----
-
-### `rightpanel/rightpanel_test.go`
-
-```go
-package rightpanel_test
-
-import (
- "strings"
- "testing"
-
- "github.com/tinywasm/layout/rightpanel"
-)
-
-// stubModule implements Module for tests.
-type stubModule struct{ name string }
-func (s stubModule) ModelName() string { return s.name }
-
-// stubComponent implements dom.Component for tests.
-type stubComponent struct{ html string }
-func (s *stubComponent) GetID() string { return "stub" }
-func (s *stubComponent) SetID(_ string) {}
-func (s *stubComponent) RenderHTML() string { return s.html }
-func (s *stubComponent) Children() []any { return nil }
-
-func TestRightPanel_RenderHTML_WithAllSlots(t *testing.T) {
- panel := &rightpanel.RightPanel{
- Module: stubModule{"users"},
- Title: "Users",
- Head: &stubComponent{"badge "},
- HeadControls: &stubComponent{" "},
- Article: &stubComponent{" "},
- AsideControls: &stubComponent{" "},
- Aside: &stubComponent{""},
- }
-
- el := panel.Render()
- html := el.RenderHTML()
-
- checks := []struct {
- label, want string
- }{
- {"root id", "id='users'"},
- {"wrapper class", "class='rp-wrapper'"},
- {"main class", "class='rp-main'"},
- {"header class", "class='rp-header'"},
- {"title row", "class='rp-title-row'"},
- {"h1 title", "Users "},
- {"Head slot", "badge "},
- {"HeadControls slot", " "},
- {"article class", "class='rp-article'"},
- {"Article slot", ""},
- {"aside class", "class='rp-aside'"},
- {"aside header", "class='rp-aside-header'"},
- {"AsideControls slot", " "},
- {"aside content", "class='rp-aside-content'"},
- {"Aside slot", ""},
- }
-
- for _, c := range checks {
- if !strings.Contains(html, c.want) {
- t.Errorf("[%s] expected %q in HTML:\n%s", c.label, c.want, html)
- }
- }
-}
-
-func TestRightPanel_RenderHTML_AsideOmittedWhenNil(t *testing.T) {
- panel := &rightpanel.RightPanel{
- Module: stubModule{"orders"},
- Title: "Orders",
- Article: &stubComponent{""},
- // No AsideControls, no Aside
- }
-
- html := panel.Render().RenderHTML()
-
- if strings.Contains(html, "rp-aside") {
- t.Error("expected rp-aside to be absent when both AsideControls and Aside are nil")
- }
-}
-
-func TestRightPanel_RenderHTML_NoModuleNoID(t *testing.T) {
- panel := &rightpanel.RightPanel{Title: "No ID"}
- html := panel.Render().RenderHTML()
-
- if strings.Contains(html, "id=") {
- t.Error("expected no id attribute when Module is nil")
- }
-}
-```
-
----
-
-## Execution order for Jules
-
-1. Update `go.mod`: run `go get github.com/tinywasm/dom@latest`
-2. Create `rightpanel/rightpanel.go`
-3. Create `rightpanel/rightpanel.css`
-4. Create `rightpanel/ssr.go`
-5. Create `rightpanel/rightpanel_test.go`
-6. Run `go test ./rightpanel/...` — all tests must pass
-7. Commit: `feat: add rightpanel layout`
-
----
-
-## Design rules for future layouts
-
-New layouts in this module (e.g. `fullpage/`, `storefront/`) must follow these rules:
-
-1. **Only `tinywasm/dom` primitives** — no `text/template`, no raw HTML strings, no embed
- of `.html` files. Use `dom.Div()`, `dom.Section()`, etc.
-
-2. **CSS in own file** — each layout has its own `.css` file. No global styles. Only
- classes prefixed with the layout abbreviation (e.g. `rp-` for rightpanel, `fp-` for
- fullpage, `sf-` for storefront).
-
-3. **Consume `--color-*` tokens** — never hardcode colors. Use CSS variables from
- `tinywasm/dom`'s `theme.css` with fallback values.
-
-4. **All slots optional** — a nil slot renders nothing. The layout must remain coherent
- with any combination of nil slots.
-
-5. **Module interface for ID** — the root element ID comes from a `Module` interface
- (`ModelName() string`). No positional string arguments for the ID.
-
-6. **SSR split** — CSS embed goes in `ssr.go` (`//go:build !wasm`).
- `RenderCSS() string` implements `dom.CSSProvider`.
-
-7. **Mobile first** — all layouts must define a `@media (max-width: 640px)` block that
- stacks the layout vertically and makes it usable on small screens.
-
-8. **No JS** — layout behavior (dark mode, responsiveness) must work without JavaScript.
-
-9. **Tests** — verify presence of key classes and slot content via `strings.Contains`.
- At minimum: all slots present, aside omitted when nil, no id when Module is nil.
-
----
-
-## Adding a new layout (example: storefront)
-
-```
-layout/
-└── storefront/
- ├── storefront.go # struct + Render()
- ├── storefront.css # sf-* classes only
- ├── ssr.go # !wasm, embed CSS, RenderCSS()
- └── storefront_test.go
-```
+## Action items
-Follow the same pattern as `rightpanel`. CSS prefix: `sf-`. Module interface: same
-`Module` interface (can be copied or moved to `layout` root package if shared).
+- [x] Add this rule to the RightPanel usage docs in `rightpanel.go`
+ - Added IMPORTANT note in RightPanel docstring referencing tinywasm/dom rules
+- [ ] Add a compile-time or runtime check that rejects nil-embedded components
+ - Deferred: runtime guard in dom_frontend.go is sufficient for now
+- [ ] Consider accepting `dom.ViewRenderer` instead of (or in addition to) `dom.Component`
+ for slots, since all practical consumers implement `Render() *dom.Element`
+ - Deferred: architectural decision, beyond immediate scope
diff --git a/docs/img/badges.svg b/docs/img/badges.svg
new file mode 100644
index 0000000..989509e
--- /dev/null
+++ b/docs/img/badges.svg
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+ License
+
+ MIT
+
+
+
+
+
+
+
+
+ Go
+
+ 1.25.2
+
+
+
+
+
+
+
+
+ Tests
+
+ Passing
+
+
+
+
+
+
+
+
+ Coverage
+
+ 96.9%
+
+
+
+
+
+
+
+
+ Race
+
+ Clean
+
+
+
+
+
+
+
+
+ Vet
+
+ OK
+
+
+
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 8403f74..25e0d66 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,7 @@
module github.com/tinywasm/layout
go 1.25.2
+
+require github.com/tinywasm/dom v0.7.10
+
+require github.com/tinywasm/fmt v0.23.6 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..0965d24
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,4 @@
+github.com/tinywasm/dom v0.7.10 h1:ioiZ0ZsdXyzss83AkHKs98yDEjFk5fbAO3sODerPyTU=
+github.com/tinywasm/dom v0.7.10/go.mod h1:z2iJL8qpP4dwcaIm441jc9XfM5bGUVtlH2YG+pfG45c=
+github.com/tinywasm/fmt v0.23.6 h1:DXugqAD98VEeBKeguQqAWHzUnxhuESHRTDinyNHlSpk=
+github.com/tinywasm/fmt v0.23.6/go.mod h1:L2GCAi6asgytPV6TVvGrRq5Ml+DkUt1Ijo5i/2J1jOY=
diff --git a/layout.go b/layout.go
index 4c26c63..c380162 100644
--- a/layout.go
+++ b/layout.go
@@ -1,7 +1,4 @@
+// Package layout provides pre-built, consistent, reusable UI layout skeletons for
+// tinywasm modules. A layout defines WHERE elements are placed (structure, grid, spacing)
+// but NOT what those elements contain — that is the consumer's responsibility.
package layout
-
-type Layout struct {}
-
-func New() *Layout {
- return &Layout{}
-}
diff --git a/rightpanel/rightpanel.css b/rightpanel/rightpanel.css
new file mode 100644
index 0000000..172b8a3
--- /dev/null
+++ b/rightpanel/rightpanel.css
@@ -0,0 +1,130 @@
+/* ── RightPanel layout tokens (with fallbacks) ───────────────── */
+:root {
+ --rp-title-height: var(--title-height, 8vh);
+ --rp-content-height: var(--content-height, 89vh);
+ --rp-controls-height: var(--controls-height, 3vh);
+ --rp-main-width: 66vw;
+ --rp-aside-width: 30vw;
+ --rp-gap: var(--mag-pri, 0.5rem);
+ --rp-border-color: var(--color-tertiary, #94a3b8);
+ --rp-bg: var(--color-gray, #f8fafc);
+ --rp-aside-bg: var(--color-quaternary,#1e293b);
+ --rp-title-color: var(--color-secondary, #7c3aed);
+}
+
+/* ── Wrapper ─────────────────────────────────────────────────── */
+.rp-wrapper {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ height: var(--rp-content-height);
+ overflow: hidden;
+}
+
+/* ── Main section ────────────────────────────────────────────── */
+.rp-main {
+ display: grid;
+ grid-template-rows:
+ auto /* rp-header */
+ 1fr; /* rp-article */
+ width: var(--rp-main-width);
+ height: 100%;
+ overflow: hidden;
+ border-right: 0.1vw solid var(--rp-border-color);
+}
+
+/* ── Header ──────────────────────────────────────────────────── */
+.rp-header {
+ display: flex;
+ flex-direction: column;
+ background: var(--rp-bg);
+ padding: var(--mag-sec, 0.2rem) var(--mag-pri, 0.5rem);
+}
+
+.rp-title-row {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--rp-gap);
+ min-height: var(--rp-title-height);
+}
+
+.rp-title-row h1 {
+ font-size: 1.5rem;
+ color: var(--rp-title-color);
+ margin: 0;
+}
+
+.rp-head-controls {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ min-height: var(--rp-controls-height);
+ padding-bottom: var(--mag-sec, 0.2rem);
+}
+
+/* ── Article ─────────────────────────────────────────────────── */
+.rp-article {
+ overflow-y: auto;
+ padding: var(--mag-pri, 0.5rem);
+ background: var(--color-gray, #f8fafc);
+ border-radius: 0.4em 0.4em 0 0;
+}
+
+.rp-article::-webkit-scrollbar { width: 0.2em; background: none; }
+.rp-article::-webkit-scrollbar-thumb { background: var(--color-tertiary, #94a3b8); border-radius: 0.1em; }
+
+/* ── Aside panel ─────────────────────────────────────────────── */
+.rp-aside {
+ display: grid;
+ grid-template-rows:
+ auto /* rp-aside-header */
+ 1fr; /* rp-aside-content */
+ width: var(--rp-aside-width);
+ height: 100%;
+ overflow: hidden;
+}
+
+.rp-aside-header {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ min-height: var(--rp-controls-height);
+ padding: var(--mag-sec, 0.2rem) var(--mag-pri, 0.5rem);
+ background: var(--rp-aside-bg);
+}
+
+.rp-aside-content {
+ overflow-y: auto;
+ padding: var(--mag-pri, 0.5rem);
+ background: var(--rp-aside-bg);
+}
+
+.rp-aside-content::-webkit-scrollbar { width: 0.2em; background: none; }
+.rp-aside-content::-webkit-scrollbar-thumb { background: var(--color-tertiary, #94a3b8); border-radius: 0.1em; }
+
+/* ── Mobile (<640px): stack vertically ───────────────────────── */
+@media (max-width: 640px) {
+ .rp-wrapper {
+ flex-direction: column;
+ height: auto;
+ }
+
+ .rp-main {
+ width: 100%;
+ height: auto;
+ }
+
+ .rp-aside {
+ width: 100%;
+ height: auto;
+ }
+
+ .rp-article {
+ overflow-y: visible;
+ }
+
+ .rp-aside-content {
+ overflow-y: visible;
+ }
+}
diff --git a/rightpanel/rightpanel.go b/rightpanel/rightpanel.go
new file mode 100644
index 0000000..d944757
--- /dev/null
+++ b/rightpanel/rightpanel.go
@@ -0,0 +1,119 @@
+package rightpanel
+
+import "github.com/tinywasm/dom"
+
+// Module is the interface the consumer must satisfy to provide the layout ID.
+// Any struct with a ModelName() string method qualifies (e.g. ORM model structs).
+type Module interface {
+ ModelName() string
+}
+
+// RightPanel is a two-column layout skeleton:
+// - Left: main content area with header (title + controls) and article.
+// - Right: aside panel with its own header (controls) and content.
+//
+// All slots are optional. A nil slot is simply not rendered.
+// The layout does not define what the slots contain — that is the consumer's job.
+//
+// IMPORTANT: All dom.Component implementors passed as slots MUST embed dom.Element as a value,
+// not as a pointer. See tinywasm/dom interface.dom.go for details.
+//
+// Usage:
+//
+// panel := &rightpanel.RightPanel{
+// Module: myModel, // implements ModelName() string
+// Title: "Users",
+// HeadControls: mySelectSearch,
+// Article: myTable,
+// AsideControls: myFilterBar,
+// Aside: myDetailPanel,
+// }
+// panel.Render()
+type RightPanel struct {
+ *dom.Element
+
+ // Module provides the ID for the root wrapper element.
+ Module Module
+
+ // Title is rendered as in the header.
+ Title string
+
+ // Head is rendered beside the (e.g. status badge, icon).
+ Head dom.Component
+
+ // HeadControls is rendered below the title row (e.g. select with search).
+ HeadControls dom.Component
+
+ // Article is the main content area.
+ Article dom.Component
+
+ // AsideControls is rendered at the top of the aside panel (e.g. search + filter).
+ AsideControls dom.Component
+
+ // Aside is the content area of the aside panel (e.g. detail view, info card).
+ Aside dom.Component
+}
+
+// Render builds the layout element tree.
+// Implements dom.ViewRenderer.
+func (r *RightPanel) Render() *dom.Element {
+ if r.Element == nil {
+ r.Element = &dom.Element{}
+ }
+
+ // ── root wrapper ─────────────────────────────────────────────────────────
+ id := ""
+ if r.Module != nil {
+ id = r.Module.ModelName()
+ }
+
+ wrapper := dom.Div().Class("rp-wrapper")
+ if id != "" {
+ wrapper.ID(id)
+ }
+
+ // ── main section ─────────────────────────────────────────────────────────
+ main := dom.Section().Class("rp-main")
+
+ // header row: title + Head slot + HeadControls slot
+ header := dom.Div().Class("rp-header")
+
+ titleRow := dom.Div().Class("rp-title-row")
+ if r.Title != "" {
+ titleRow.Add(dom.H1().Text(r.Title))
+ }
+ if r.Head != nil {
+ titleRow.Add(r.Head)
+ }
+ header.Add(titleRow)
+
+ if r.HeadControls != nil {
+ header.Add(dom.Div().Class("rp-head-controls").Add(r.HeadControls))
+ }
+ main.Add(header)
+
+ // article
+ if r.Article != nil {
+ main.Add(dom.Article().Class("rp-article").Add(r.Article))
+ } else {
+ main.Add(dom.Article().Class("rp-article"))
+ }
+
+ wrapper.Add(main)
+
+ // ── aside panel ──────────────────────────────────────────────────────────
+ if r.AsideControls != nil || r.Aside != nil {
+ aside := dom.Aside().Class("rp-aside")
+
+ if r.AsideControls != nil {
+ aside.Add(dom.Div().Class("rp-aside-header").Add(r.AsideControls))
+ }
+ if r.Aside != nil {
+ aside.Add(dom.Div().Class("rp-aside-content").Add(r.Aside))
+ }
+
+ wrapper.Add(aside)
+ }
+
+ return wrapper
+}
diff --git a/rightpanel/rightpanel_test.go b/rightpanel/rightpanel_test.go
new file mode 100644
index 0000000..d25d54e
--- /dev/null
+++ b/rightpanel/rightpanel_test.go
@@ -0,0 +1,87 @@
+package rightpanel_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/tinywasm/dom"
+ "github.com/tinywasm/layout/rightpanel"
+)
+
+// stubModule implements Module for tests.
+type stubModule struct{ name string }
+
+func (s stubModule) ModelName() string { return s.name }
+
+// stubComponent implements dom.Component for tests.
+type stubComponent struct{ html string }
+
+func (s *stubComponent) GetID() string { return "stub" }
+func (s *stubComponent) SetID(_ string) {}
+func (s *stubComponent) RenderHTML() string { return s.html }
+func (s *stubComponent) Children() []dom.Component { return nil }
+
+func TestRightPanel_RenderHTML_WithAllSlots(t *testing.T) {
+ panel := &rightpanel.RightPanel{
+ Module: stubModule{"users"},
+ Title: "Users",
+ Head: &stubComponent{"badge "},
+ HeadControls: &stubComponent{" "},
+ Article: &stubComponent{" "},
+ AsideControls: &stubComponent{" "},
+ Aside: &stubComponent{""},
+ }
+
+ el := panel.Render()
+ html := el.RenderHTML()
+
+ checks := []struct {
+ label, want string
+ }{
+ {"root id", "id='users'"},
+ {"wrapper class", "class='rp-wrapper'"},
+ {"main class", "class='rp-main'"},
+ {"header class", "class='rp-header'"},
+ {"title row", "class='rp-title-row'"},
+ {"h1 title", "Users "},
+ {"Head slot", "badge "},
+ {"HeadControls slot", " "},
+ {"article class", "class='rp-article'"},
+ {"Article slot", ""},
+ {"aside class", "class='rp-aside'"},
+ {"aside header", "class='rp-aside-header'"},
+ {"AsideControls slot", " "},
+ {"aside content", "class='rp-aside-content'"},
+ {"Aside slot", ""},
+ }
+
+ for _, c := range checks {
+ if !strings.Contains(html, c.want) {
+ t.Errorf("[%s] expected %q in HTML:\n%s", c.label, c.want, html)
+ }
+ }
+}
+
+func TestRightPanel_RenderHTML_AsideOmittedWhenNil(t *testing.T) {
+ panel := &rightpanel.RightPanel{
+ Module: stubModule{"orders"},
+ Title: "Orders",
+ Article: &stubComponent{""},
+ // No AsideControls, no Aside
+ }
+
+ html := panel.Render().RenderHTML()
+
+ if strings.Contains(html, "rp-aside") {
+ t.Error("expected rp-aside to be absent when both AsideControls and Aside are nil")
+ }
+}
+
+func TestRightPanel_RenderHTML_NoModuleNoID(t *testing.T) {
+ panel := &rightpanel.RightPanel{Title: "No ID"}
+ html := panel.Render().RenderHTML()
+
+ if strings.Contains(html, "id=") {
+ t.Error("expected no id attribute when Module is nil")
+ }
+}
diff --git a/rightpanel/ssr.go b/rightpanel/ssr.go
new file mode 100644
index 0000000..cb9c489
--- /dev/null
+++ b/rightpanel/ssr.go
@@ -0,0 +1,14 @@
+//go:build !wasm
+
+package rightpanel
+
+import _ "embed"
+
+//go:embed rightpanel.css
+var css string
+
+// RenderCSS implements dom.CSSProvider.
+// tinywasm/site collects this during SSR to inject into .
+func (r *RightPanel) RenderCSS() string {
+ return css
+}