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.
| I want to… | Use |
|---|---|
| Create a list/detail view for my model | view.New(lister, &X{}, opts...) where lister implements view.Lister |
| Make my rows appear in the list | Implement Item() view.Item on the record type (view.Itemizer) |
| Enable saving | Implement Save(recs []model.Model, done func(error)) — the returned Presenter then satisfies view.Saver |
| Enable field patches | Implement Update(ids []string, rec model.Model, fields []string, done func(error)) — the Presenter then satisfies view.Updater |
| Enable deleting | Implement Delete(ids []string, done func(error)) — 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(func(err error){ … }) — results arrive asynchronously |
| 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) |
| Connect over a transport (mcp, http) | view.NewCallerLister(caller, view.Ops{…}, newList) then view.New(l, &X{}, …) |
| Run a command on the whole list | Ops.Actions + presenter.(view.Actioner).Run(op, done) |
| Show an error/success message | Renderer's job: branch on the error passed to the done callback of 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 |
Two cases, side by side.
In-process — implement Lister (+ the capabilities you have) directly
on your store. There is no operation name to spell, no envelope to unpack:
type deviceStore struct{ db *orm.DB }
// Every operation is asynchronous: the result is handed to done, which is
// always non-nil. An in-process store may call it immediately; a transport
// calls it from its own callback. Never block, never return the result.
func (s *deviceStore) List(done func([]model.Model, error)) {
var rows []model.Model
err := s.db.Query(&Device{}).ReadAll(
func() model.Model { return &Device{} },
func(m model.Model) { rows = append(rows, m) },
)
done(rows, err)
}
func (s *deviceStore) Save(recs []model.Model, done func(error)) {
for _, m := range recs {
if err := s.upsert(m.(*Device)); err != nil {
done(err)
return
}
}
done(nil)
}
func (s *deviceStore) Update(ids []string, rec model.Model, fields []string, done func(error)) {
done(s.db.UpdateFields(rec, fields, storage.In("id", anyIDs(ids))))
}
func (s *deviceStore) Delete(ids []string, done func(error)) {
done(s.db.Delete(&Device{}, storage.In("id", anyIDs(ids))))
}
view.New(&deviceStore{db: deviceDB}, &Device{}, view.WithTitle("Computadores"))Remote — wrap your router.Caller transport in the adapter this package
owns, then build the view over it:
l := view.NewCallerLister(caller,
view.Ops{
Module: "device", List: "list", Save: "save", Delete: "delete",
Actions: []view.Action{{
Op: "apply_network", Label: "Apply",
Args: func(recs []model.Model) model.Encodable { return &ApplyArgs{Fingerprint: recs[0].(*Device).Plan} },
}},
},
func() model.ModelSlice { return &DeviceList{} })
view.New(b, &Device{}, view.WithTitle("Computadores"))Capabilities are methods, not strings — the renderer paints +/🗑/✏
from what your lister implements, so a missing method is a compile-time fact.
There is no string to misspell and no silent success-without-write: if the
lister does not implement Save, the presenter simply is not a view.Saver.
The contract is shared: a lister declares capabilities with
view.Saver/Updater/Deleter — the same interfaces the renderer asserts on
the presenter. One name per capability, used on both sides.
Lister.List, Saver.Save, Updater.Update, Deleter.Delete and
Presenter.Reload no longer return an error — they take a done callback,
delivered last, and every outcome (including validation errors like
"Save requires at least one record") travels through it. The variadic
Save(recs ...model.Model) / Delete(ids ...string) are gone: pass a slice
(the one-record case is a slice of one).
// v0.5.0 — synchronous, variadic
rows, err := lister.List()
if err != nil { … }
err = saver.Save(rec1, rec2)
// v0.6.0 — asynchronous, slice + done
lister.List(func(rows []model.Model, err error) { … })
saver.Save([]model.Model{rec1, rec2}, func(err error) { … })The change exists because the old shape blocked a channel over an inherently
asynchronous transport — a program that deadlocks on Go WASM (all goroutines
asleep) and only works on TinyGo. Every screen built on view now requests
work and paints the result from inside the callback, exactly like a React
useEffect or an Elm Cmd.
Backend is now Lister — the seam finally follows the verb, like
Saver/Updater/Deleter. Same for its derivatives: NewCallerBackend →
NewCallerLister, conformance.FakeBackend → conformance.FakeLister.
Pure renames; signatures and bodies are unchanged.
BackendSaver/BackendUpdater/BackendDeleter are gone; implement
Saver/Updater/Deleter instead:
// v0.3.0
func (s *deviceStore) Save(recs []model.Model) error
func (s *deviceStore) Delete(ids []string) error
// v0.4.0 — add the ellipsis; bodies are unchanged
func (s *deviceStore) Save(recs ...model.Model) error
func (s *deviceStore) Delete(ids ...string) errorUpdate is unchanged.
New lost listOp/newList; WithSaveOp/WithUpdateOp/WithDeleteOp and
WithArgs are gone (WithArgs had zero call sites). A router.Caller
consumer wraps it in NewCallerLister:
// before
view.New(caller, &User{}, OpListUsers, func() model.ModelSlice { return &UserList{} },
view.WithTitle("Usuarios"), view.WithSaveOp(OpUpsertUser), view.WithDeleteOp(OpDeleteUser))
// after
l := view.NewCallerLister(caller,
view.Ops{Module: "user", List: OpListUsers, Save: OpUpsertUser, Delete: OpDeleteUser},
func() model.ModelSlice { return &UserList{} })
view.New(b, &User{}, view.WithTitle("Usuarios"))The complete public API of view:
package view
import (
"webtyp.com/model"
"webtyp.com/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/Update/Delete are separate capabilities (see below).
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 asks the Lister for the records, then projects and indexes them.
// The result is asynchronous: done runs once, after List delivers, and the
// renderer paints Items() from inside it.
Reload(done func(error))
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.
// They are only present when the lister implements the matching interface:
// no Save method ⇒ the returned value has no Save method ⇒ p.(Saver) fails.
// All three deliver their outcome asynchronously through done (never nil,
// never blocking) — the same single channel every failure travels.
type Saver interface {
Save(recs []model.Model, done func(error))
}
type Updater interface {
Update(ids []string, rec model.Model, fields []string, done func(error))
}
type Deleter interface {
Delete(ids []string, done func(error))
}
// Lister is what a view needs from the application: the records to show.
type Lister interface {
// List asks for every record. The result arrives asynchronously through
// done, which is always non-nil; List must NOT block waiting for it.
List(done func(rows []model.Model, err error))
}
// Ops names the remote operations a CallerLister invokes. An empty name means
// the remote side does not offer that operation.
type Ops struct {
Module string
List string
Save string
Update string
Delete string
}
func NewCallerLister(c router.Caller, ops Ops, newList func() model.ModelSlice) Lister
// Option is a functional configuration option for New.
type Option func(*config)
func WithTitle(title string) Option
func WithSearchPlaceholder(placeholder string) Option
// New builds the presenter over a Lister. Mandatory collaborators are
// positional (the compiler enforces their presence); a nil mandatory value
// panics at construction — a loud development diagnostic, never a deferred
// runtime mystery. The Presenter's capabilities MIRROR the lister's.
func New(l Lister, record model.Model, opts ...Option) Presenterpackage catalog
import (
"webtyp.com/model"
"webtyp.com/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 — the store implements the domain seam (List + the writes it supports).
type catalogStore struct{ /* … */ }
func (s *catalogStore) List(done func([]model.Model, error)) { /* … done(rows, err) */ }
func (s *catalogStore) Save(recs []model.Model, done func(error)) { /* … done(err) */ }
// Step 3 — build the presenter. No projection loop, no cache, no fill:
// the presenter lists through the lister and indexes id → model itself.
func NewCatalogView(store *catalogStore) view.Presenter {
return view.New(store, &CatalogItem{}, view.WithTitle("Catalog Management"))
}That is the entire module-side code. CatalogItem is generated by ormc and
already satisfies model.Model.
A renderer never imports the domain module. It draws Items(), generates form inputs
from Record().Schema(), and discovers capabilities by assertion:
package crudview
import "webtyp.com/view"
type Renderer struct{ p view.Presenter }
// Mount: request the list and paint from INSIDE the callback — the result is
// not available when Reload returns.
func (r *Renderer) Mount() {
r.p.Reload(func(err error) {
if 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 lister implements Save
}
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
s.Save([]model.Model{rec}, func(err error) {
if err != nil {
r.ShowError(err)
return
}
r.ShowSuccess()
})
}
func (r *Renderer) OnSearchTyped(term string) {
r.drawList(r.p.Filter(term)) // local filtering
}Reload,Save,Update,Deleteare asynchronous: each takes adone func(error)(always non-nil) and returns nothing. That callback IS the single user-message channel — validation errors and transport errors alike arrive through it, and the renderer decides how to present them (toast, inline, console).viewnever renders, logs, or swallows messages — there is noSetLog, and there is no second way to report a result.NewandNewCallerListerpanic 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 that does not implementItemizermakesReloadfail naming the offending record (viamodel.ModuleNaming.ModelName()when the row provides it —webtyp/fmthas no reflect-based type-name formatter by design).
- Agnostic to UI technology — the module never imports
dom,formorhtml; any renderer can draw the contract. - Agnostic to codec and transport — in-process consumers import only
model; the transport's codec decodes into the module's typed list (model.ModelSlice) insideNewCallerLister, the single place that knows the wire shape. - Compile-time safety — mandatory collaborators are positional in
New; capabilities are method sets, so a view whose lister cannot save simply has noSavemethod to call. - Callback-based, runtime-agnostic —
Reload/Save/Update/Deletetake adonecallback;viewnever blocks and never uses a channel. The async network caller is adopted as-is byNewCallerLister, so the same library works on Go WASM and TinyGo alike. The renderer paints from inside the callback. - Explicit form synchronization —
Save([]model.Model{…}, done)takes the synchronized records explicitly: unidirectional data flow, no hidden shared-pointer mutations. - Glue lives here, once — projection loop, id→model index, capability wiring and the transport adapter 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:
webtyp.com/fmtfor formatting and error creation.webtyp.com/jsonfor JSON serialization/deserialization.webtyp.com/binaryfor binary protocols.
view/mock— headless reference renderer for browser-less simulation and unit tests.view/conformance— exportsconformance.Run(t, Factory)plus theconformance.FakeListertyped double. 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).conformance.Payload/conformance.Hasassert the wire shape arouter.Callerdouble saw — the tool for testing the transport path.
AGENTS.md— contributor rules: the async callback contract (never block, never channels), WASM/TinyGo restrictions, the capability-wrapper pattern, and testing withgotest. Read before touching any code.docs/SPECS.md— the exact contract: public surface, callback semantics, capability mirroring, every error message word for word, the wire shapes, and the conformance clause list. Every table is a test assertion.