6.7 KiB
08 — MVC structure: model = class, view = htmlx + scss, controller = .wo
Context sources: reference/writeonce-app/src/app/ (the v1 Angular app whose component anatomy this formalizes), ./00-overview.md ("Angular-component-style layout" — home/{home.wo, home.htmlx, home.css}), ./01-htmlx-format-spec.md (the view grammar: Mustache + <wo:live> + wo:bind), ./02-ui-compiler.md, ./03-client-runtime.md, ../../13-class-model-live-pricing.md (the class methods controllers call), ../../../examples/pricing/ui/pricing/ (the reference screen).
Goal
Every writeonce UI screen follows Model–View–Controller, with the same file anatomy the v1 Angular app used — but collapsed into the single binary. One screen = one directory with three files:
ui/pricing/
├── pricing.wo # Controller — binds the model into the view, exposes actions
├── pricing.htmlx # View — plain htmlx (Mustache + wo:live/wo:bind), no logic
└── pricing.scss # View styles — external, compiled at `wo build`
The mapping, against the v1 Angular app
| MVC role | v1 Angular (reference/writeonce-app/src/app/) |
writeonce |
|---|---|---|
| Model | models/article.ts (interface) + services/article.service.ts (HTTP fetch) |
the class / type declaration itself (types/product.wo). No service layer: the database is in-process, and a model binding is a query — LIVE select for push, select for snapshot |
| View | article.component.html + article.component.css |
pricing.htmlx + pricing.scss. Plain markup; the only dynamic constructs are Mustache paths and <wo:live> / wo:bind from 01-htmlx-format-spec.md |
| Controller | article.component.ts (@Component({templateUrl, styleUrl}), fields, methods, service.subscribe(...)) |
pricing.wo — declares view: / styles: (≈ templateUrl / styleUrl), a model: block (≈ component fields), and an actions: block whose handlers call class methods |
What Angular needed four layers for (interface, service, component class, template) writeonce does in three files against one runtime — there is no HTTP client between controller and model because there is no network between them.
Design decisions
-
The controller is declarative, like everything else in
.wo. It does not contain imperative rendering code; it declares what is bound and which method each action invokes. Shape:##ui #pricing route: /pricing view: pricing.htmlx -- ≈ Angular templateUrl styles: pricing.scss -- ≈ Angular styleUrl -- Model → View binding. Names declared here are the root scope of -- the .htmlx file; LIVE bindings re-patch the view on every commit. model: products: LIVE select Product{ name, sku, prices } watchlist: $session.watchlist -- Controller actions: the only place UI may invoke class methods. actions: set-price(id, amount): Product{ id == id }.set_price(amount) role: Ops | Admin watch(id): session.watchlist += id -
The view is plain
.htmlx, logic-free. Mustache paths,{{#each}}/{{#if}}, partials, helpers,<wo:live>subtrees,wo:bindattributes — nothing else. A<wo:live source="products">whosesourceis a bare name resolves against the controller'smodel:block (the M→V binding); an inline query insourceremains legal for controller-less partials. Views never call methods — they raise actions (wo:action="set-price"), the controller dispatches. -
Styles are external SCSS, compiled at
wo build. No<style>blocks in views, no inline styles, one.scssper screen plus shared partials (ui/styles/_*.scss).wo buildcompiles a strict SCSS subset — variables, nesting,@useof partials; no mixins/functions in the first cut — to flat CSS served as a static asset viasendfile(../../08-sendfile-static-assets.md). Hand-rolled incrates/ui(~400 LOC scanner + nesting flattener), zero external dependencies — same stance as every other phase. -
One binary, unchanged.
wo buildlinks the SSR renderer, the compiled views + manifest, the flattened CSS, the database engine, the REST/WS API, and the kernel-primitive concurrency runtime (epoll today, thread-per-core io_uring per../../09-concurrency-scaleout.md) into the single output. MVC changes the source layout, not the deployment shape. -
The
##uitable shorthand survives as sugar. The earlier declarative screen spec (columns:/sort:/pagination:, as in the ecommercehome.wo) compiles to a generated view + controller pair. Writing the triplet by hand is the general form; the shorthand is the 80% case. Either way the compiler output is identical: SSR HTML + manifest + bindings.
Request flow
GET /pricing
→ router (controller route:) [crates/http]
→ controller resolves model: bindings against the engine (in-process)
→ SSR renders pricing.htmlx with model scope [crates/ui, per 01/02]
→ emits HTML + <script data-wo-manifest> + <link pricing.css>
browser action wo:action="set-price"
→ POST dispatched to the controller action
→ action calls Product.set_price(amount) [class method, plan 13b]
→ commit → delta → every <wo:live> subscriber [plan 13c]
→ client runtime patches wo:bind cells [03-client-runtime.md]
Migration note
The two existing screen specs (docs/examples/ecommerce/apps/*/ui/*/, single-file ##ui shorthand) stay valid under decision 5. New screens — starting with docs/examples/pricing/ui/pricing/ — use the triplet. The v1 Angular app stays archived; its components are the shape reference, not a port source (the htmlx port source remains reference/crates/wo-htmlx).
Exit criteria (implementation sequenced in plan 14, landing with plan 13d)
crates/uiresolves a controller file:route:/view:/styles:/model:/actions:parsed, view rendered with model scope, actions dispatched to class methods.- SCSS subset compiler:
pricing.scss→ flat CSS at build, golden-file tested. - The pricing screen works end-to-end per plan 13d's exit criterion: a
set_pricecommit patches the price cell in every open browser without reload.