Tech-agnostic CRUD view contract: a domain module declares its list, record and operations; any renderer (DOM, HTMX, SSR, native, headless test) draws it.
This README is the official usage document. It is written so that an agent/LLM with no prior context can create or edit a visual component correctly guided only by the typed signatures below. If something here requires reading the implementation, that is a defect — report it.
Alignment note: this document describes the target API defined by the construction harness.
docs/PLAN.mdtracks the code's convergence to it (gated ondocs/PLAN_MODEL.mdbeing executed intinywasm/modelfirst).
| I want to… | Use |
|---|---|
| Create a list/detail view for my model | view.New(caller, &X{}, "x.list", func() model.ModelSlice { return &XList{} }, opts...) |
| Make my rows appear in the list | Implement Item() view.Item on the record type (view.Itemizer) |
| Enable saving | view.WithSaveOp("x.save") — the returned Presenter then satisfies view.Saver |
| Enable deleting | view.WithDeleteOp("x.delete") — the returned Presenter then satisfies view.Deleter |
| Know if the view can save/delete (renderer side) | s, ok := p.(view.Saver) / d, ok := p.(view.Deleter) |
| Load / refresh the list | p.Reload() (synchronous, returns error) |
| Pick a record and get its full model | m := p.Select(id) (nil if the id is unknown) |
| Clear the selection | p.Deselect() |
| Filter the list as the user types | p.Filter(term) (local, case-insensitive over Label+Description) |
| Send list arguments (pagination, remote search) | view.WithArgs(func() model.Encodable { … }) then p.Reload() |
| Show an error/success message | Renderer's job: branch on the error returned by Reload/Save/Delete |
| Test a renderer implementation | conformance.Run(t, factory) — it must pass every clause |
| Simulate a view without a browser | view/mock.Renderer |
The complete public API of view:
package view
import (
"github.com/tinywasm/model"
"github.com/tinywasm/router"
)
// Item is ONE projected row of the list — the neutral form any renderer can draw.
// No markup: only what a list needs to display and let the user pick a record.
type Item struct {
ID string // selection key
Label string // primary text
Description string // secondary text (a SKU, an IP, a subtitle)
}
// Itemizer is implemented by a domain record that knows how to project itself as a
// list row. It is the ONLY view-specific code a module writes on its model.
type Itemizer interface {
Item() Item
}
// Presenter is the UI-agnostic core behind any CRUD view: list, select, reload.
// Always present. Save/Delete are separate capabilities (see Saver/Deleter).
type Presenter interface {
Title() string
SearchPlaceholder() string
Record() model.Model
Items() []Item // projected list from the last Reload
Filter(term string) []Item // local case-insensitive match over Label+Description; "" returns all
Reload() error // synchronously calls ListOp, decodes, projects and indexes
Selected() string // currently selected id ("" if none)
Select(id string) model.Model // marks id and returns its record from the internal index; unknown id → nil, selection unchanged
Deselect() // clears the selection
}
// Capabilities. The renderer discovers them by type assertion at the seam —
// the same pattern the composition root uses with router.APIModule.
// They are only present when the matching option was given to New:
// no WithSaveOp ⇒ the returned value has no Save method ⇒ p.(Saver) fails.
type Saver interface {
Save(payload model.Model) error // synchronously ships the explicit payload to SaveOp
}
type Deleter interface {
Delete(id string) error // ships the indexed record of id to DeleteOp; unknown id → error
}
// Option is a functional configuration option for New.
type Option func(*config)
func WithTitle(title string) Option
func WithSearchPlaceholder(placeholder string) Option
func WithSaveOp(op string) Option
func WithDeleteOp(op string) Option
func WithArgs(args func() model.Encodable) Option
// New builds the presenter. Mandatory collaborators are positional (the compiler
// enforces their presence); a nil/empty mandatory value panics at construction —
// a loud development diagnostic, never a deferred runtime mystery.
func New(
caller router.Caller,
record model.Model,
listOp string,
newList func() model.ModelSlice,
opts ...Option,
) Presenterpackage catalog
import (
"github.com/tinywasm/model"
"github.com/tinywasm/router"
"github.com/tinywasm/view"
)
// Step 1 — the record projects itself as a list row.
func (c *CatalogItem) Item() view.Item {
return view.Item{ID: c.ID, Label: c.Name, Description: c.SKU}
}
// Step 2 — build the presenter. No projection loop, no cache, no fill:
// the presenter iterates the decoded list and indexes id → model itself.
func NewCatalogView(caller router.Caller) view.Presenter {
return view.New(
caller,
&CatalogItem{},
"catalog.list",
func() model.ModelSlice { return &CatalogItemList{} },
view.WithTitle("Catalog Management"),
view.WithSaveOp("catalog.save"), // Step 3 — declare capabilities you support
)
}That is the entire module-side code. CatalogItem/CatalogItemList are generated by
ormc and already satisfy model.Model/model.ModelSlice.
A renderer never imports the domain module. It draws Items(), generates form inputs
from Record().Schema(), and discovers capabilities by assertion:
package crudview
import "github.com/tinywasm/view"
type Renderer struct{ p view.Presenter }
func (r *Renderer) Mount() {
if err := r.p.Reload(); err != nil {
r.ShowError(err) // messages are the renderer's concern
return
}
r.drawList(r.p.Items())
if _, ok := r.p.(view.Saver); ok {
r.drawSaveButton() // only exists if the module declared WithSaveOp
}
if _, ok := r.p.(view.Deleter); ok {
r.drawDeleteButton()
}
}
func (r *Renderer) OnSaveClicked() {
s := r.p.(view.Saver) // safe: the button only exists if the assertion held
rec := r.p.Record()
r.syncFormToRecord(rec) // explicit, unidirectional: form → record → Save
if err := s.Save(rec); err != nil {
r.ShowError(err)
return
}
r.ShowSuccess()
}
func (r *Renderer) OnSearchTyped(term string) {
r.drawList(r.p.Filter(term)) // local filtering; remote search = WithArgs + Reload
}Reload,Save,Deleteare synchronous and returnerror. Theerrorreturn IS the user-message channel: the renderer decides how to present it (toast, inline, console).viewnever renders, logs, or swallows messages — there is noSetLog.Newpanics on nil/empty mandatory collaborators. These are programmer wiring bugs, detected deterministically at startup during development (thetemplate.Mustpattern). Logging and continuing would return a half-built presenter that crashes far from the cause — a deferred silent failure, which the harness forbids.- Misuse that cannot be made a compile error is a loud error, never silence:
Deleteof an unknown id errors (nothing is sent), a row type that does not implementItemizermakesReloadfail naming the offending type.
- Agnostic to UI technology — the module never imports
dom,formorhtml; any renderer can draw the contract. - Agnostic to codec and transport —
viewimports onlymodelandrouter; the transport's codec decodes into the module's typed list (model.ModelSlice). - Compile-time safety — mandatory collaborators are positional in
New; capabilities are method sets, so callingSaveon a view withoutSaveOpdoes not compile past the type assertion. - Synchronous Go idiomatic design —
Reload/Save/Deleteblock and returnerror; the async network caller is wrapped internally with channels. No CPS callbacks, no dangling UI states. - Explicit form synchronization —
Save(payload)takes the synchronized record explicitly: unidirectional data flow, no hidden shared-pointer mutations. - Glue lives here, once — projection loop, id→model index and capability wiring are implemented in this package, not repeated in every module.
To ensure 100% compatibility with WebAssembly (WASM) and TinyGo targets, standard library packages (such as fmt, encoding/json, or encoding/binary) should be avoided in production code. Use the following tech-agnostic, low-allocation alternatives instead:
github.com/tinywasm/fmtfor formatting and error creation.github.com/tinywasm/jsonfor JSON serialization/deserialization.github.com/tinywasm/binaryfor binary protocols.
view/mock— headless reference renderer for browser-less simulation and unit tests.view/conformance— exportsconformance.Run(t, Factory). A renderer is correct only if it passes every clause (list load on mount, label rendering viaItemizer, select/deselect, save/delete capability assertions, filter semantics, loud errors on unknown ids).docs/PLAN.md— the harness-alignment refactor plan for this library.docs/PLAN_MODEL.md— themodel.ModelSliceprerequisite, to be executed intinywasm/model.