diff --git a/docs/examples/shop/README.md b/docs/examples/shop/README.md
new file mode 100644
index 0000000..0d99509
--- /dev/null
+++ b/docs/examples/shop/README.md
@@ -0,0 +1,69 @@
+# shop — the writeonce program template
+
+A small store you can buy from, structured the way a real writeonce web
+app should be. **Copy this directory to start a new app**; every file
+has one concern, and the module system (one directory = one module,
+`pub` = the export line) enforces the separation the layout promises.
+
+## Run it
+
+```
+cd docs/examples/shop
+woc . && WO_DATA=./data ./target/shop 8080 # durable store
+./target/shop 8080 # RAM-only (dev)
+```
+
+Browse http://127.0.0.1:8080/ — products → product page → buy (stock
+checked and decremented) → confirmation → /orders. With `WO_DATA`, kill
+it and restart: the orders are still there (WAL replay).
+
+## The file map (Angular equivalents)
+
+| this template | concern | Angular analog |
+| --- | --- | --- |
+| `types.wo` | MODEL — `@table` classes ARE the WAL database | `models/*.ts` (+ the entire database) |
+| `layout/app.wo` | app shell: document, header+footer composition, `ok_html`/`html_error` transport helpers | `app.component.html` |
+| `layout/header.wo` / `footer.wo` | shared chrome fragments | `header.html` / `footer.html` |
+| `product_list/view.wo` | VIEW — classes with `fn render() -> Text`, fields = exactly what is displayed | `product-list/view.html` |
+| `product_list.controller.wo` | CONTROLLER — query the model, fill the view, answer a `Resp` (one file per feature, root module) | component `.ts` + service |
+| `product_page/`, `orders/` | one view module per feature + its root controller file | feature folders |
+| `static_files/controller.wo` | `/assets/*` from disk, traversal-safe, typed | `angular.json` assets |
+| `assets/style.css` | ONE real stylesheet, sectioned per feature | the `.scss` files |
+| `main.wo` | bootstrap: seed, routes, serve — nothing else | `app-routing.module.ts` + `main.ts` |
+
+Separation is compiler-enforced where the language allows it today:
+each feature's VIEW directory is a module — the root controllers see
+only its `pub` classes and `layout`'s exports. Controllers themselves
+sit in the root module beside `types.wo`, because of gap #1 below.
+
+## What is deliberately different (doctrine)
+
+- **No `.html`/`.scss` template files.** Views are `.wo` code — the
+ compiler type-checks them, `esc()` is the one escaping rule, and no
+ template engine runs at request time. Styles stay a real CSS file,
+ served statically (there is no scss preprocessor).
+- **No closures, no DI.** A view is a class with fields + `render()`;
+ a controller is a class satisfying `Handler`. Capture = a field.
+- **No sessions/cart yet.** Buying is per-product (qty → order). A cart
+ needs a session story that does not exist yet.
+- **No client-side JS.** Every interaction is a form round trip.
+- **`pub` + `@table` cannot combine yet (recorded gap #1).** An
+ annotated class cannot be exported, so a shared `types/` MODULE is
+ impossible today — which is why the controllers live in the root
+ module with `types.wo` instead of inside their feature folders. A
+ one-clause grammar fix closes this; until then the template shows the
+ honest layout.
+- **`@view` projection classes (recorded gap #2):** today controllers
+ copy row fields into view classes by hand. The wished-for form —
+ `class ProductCard @view { ... }` filled by
+ `from p in Product select p.name, p.price` — needs projection
+ queries; recorded, not worked around.
+
+## Judging the DX — what to look at
+
+1. `types.wo` — the entire persistence layer is 20 lines.
+2. `orders.controller.wo` — the whole buying flow (validate, stock
+ check, decrement, durable insert, render) with no framework magic.
+3. `product_list/view.wo` — is markup-as-code readable enough without
+ templates? This file is the referendum.
+4. `main.wo` — the app at a glance: five routes, one middleware, serve.
diff --git a/docs/examples/shop/assets/style.css b/docs/examples/shop/assets/style.css
new file mode 100644
index 0000000..cdafc59
--- /dev/null
+++ b/docs/examples/shop/assets/style.css
@@ -0,0 +1,47 @@
+/* shop/assets/style.css — one real stylesheet, sectioned per feature.
+ Served by static_files/controller.wo; app_shell links it. This file is
+ the .scss stand-in: the language ships no preprocessor, so styles are
+ plain CSS kept OUT of the markup code. */
+
+/* ---- layout (app.wo, header.wo, footer.wo) ---- */
+* { box-sizing: border-box; margin: 0; padding: 0; }
+body { font-family: system-ui, sans-serif; background: #f9fafb; color: #111827; line-height: 1.6; }
+.site-header { display: flex; align-items: center; justify-content: space-between;
+ padding: .75rem 1.5rem; background: #fff; border-bottom: 1px solid #e5e7eb;
+ position: sticky; top: 0; }
+.brand { font-size: 1.25rem; font-weight: 700; color: #111827; text-decoration: none; }
+.site-nav a { margin-left: 1rem; color: #2563eb; text-decoration: none; }
+.site-nav a:hover { text-decoration: underline; }
+.site-main { max-width: 64rem; margin: 0 auto; padding: 2rem 1rem; }
+.site-footer { border-top: 1px solid #e5e7eb; color: #6b7280; font-size: .875rem;
+ padding: 2rem 1rem; text-align: center; margin-top: 4rem; }
+h1 { margin-bottom: 1rem; }
+
+/* ---- product_list ---- */
+.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
+@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
+.card { background: #fff; border: 1px solid #e5e7eb; border-radius: .5rem;
+ padding: 1.5rem; box-shadow: 0 1px 2px rgba(0,0,0,.05); }
+.card h3 { margin-bottom: .5rem; }
+.card h3 a { color: #111827; text-decoration: none; }
+.card h3 a:hover { color: #2563eb; }
+.price { font-weight: 700; }
+.stock { color: #6b7280; font-size: .875rem; }
+.stock.out { color: #b91c1c; font-weight: 700; }
+
+/* ---- product_page ---- */
+.card.detail { max-width: 28rem; }
+.card.detail form { margin-top: 1rem; display: flex; flex-direction: column; gap: .5rem; }
+.card.detail label { font-size: .875rem; color: #374151; }
+.field { display: block; width: 100%; border: 1px solid #e5e7eb; border-radius: .25rem;
+ padding: .5rem; font-size: .875rem; }
+.btn { background: #2563eb; color: #fff; border: 0; border-radius: .25rem;
+ padding: .5rem 1rem; font-weight: 700; cursor: pointer; }
+.btn:hover { background: #1d4ed8; }
+
+/* ---- orders ---- */
+table.orders { width: 100%; border-collapse: collapse; background: #fff;
+ border: 1px solid #e5e7eb; border-radius: .5rem; }
+table.orders th, table.orders td { text-align: left; padding: .5rem .75rem;
+ border-bottom: 1px solid #e5e7eb; }
+table.orders th { background: #f9fafb; font-size: .875rem; color: #374151; }
diff --git a/docs/examples/shop/layout/app.wo b/docs/examples/shop/layout/app.wo
new file mode 100644
index 0000000..7e2905b
--- /dev/null
+++ b/docs/examples/shop/layout/app.wo
@@ -0,0 +1,34 @@
+-- layout/app.wo — the app shell (app.html's analog): one full document
+-- wrapping every page with the shared header and footer. Styles are NOT
+-- inlined here — the shop links /assets/style.css, a real stylesheet
+-- served by the static_files controller, so view markup and styling
+-- stay separate files exactly as the template promises.
+use framework/http
+use html
+
+pub fn app_shell(title: Text, content: Text) -> Text {
+ let d = "
";
+ d = d .. "";
+ d = d .. "${esc(title)}";
+ d = d .. "";
+ d = d .. "";
+ d = d .. header();
+ d = d .. el("main", "site-main", content);
+ d = d .. footer();
+ d = d .. "";
+ return d;
+}
+
+-- The one transport helper every controller shares: a 200 HTML Resp.
+pub fn ok_html(body: Text) -> Resp {
+ let h: map = {};
+ h["content-type"] = "text/html; charset=utf-8";
+ return Resp { status: 200, headers: h, body: body };
+}
+
+pub fn html_error(status: Int, title: Text, msg: Text) -> Resp {
+ let content = el("h1", "", esc(title)) .. el("p", "", esc(msg)) .. el("p", "", link("/", "", "Back to products"));
+ let h: map = {};
+ h["content-type"] = "text/html; charset=utf-8";
+ return Resp { status: status, headers: h, body: app_shell("shop — ${title}", content) };
+}
diff --git a/docs/examples/shop/layout/footer.wo b/docs/examples/shop/layout/footer.wo
new file mode 100644
index 0000000..e840075
--- /dev/null
+++ b/docs/examples/shop/layout/footer.wo
@@ -0,0 +1,6 @@
+-- layout/footer.wo — the footer fragment (footer.html's analog).
+use html
+
+pub fn footer() -> Text {
+ return el("footer", "site-footer", "writeonce shop — one binary: server, database, these pages.");
+}
diff --git a/docs/examples/shop/layout/header.wo b/docs/examples/shop/layout/header.wo
new file mode 100644
index 0000000..3426d4a
--- /dev/null
+++ b/docs/examples/shop/layout/header.wo
@@ -0,0 +1,8 @@
+-- layout/header.wo — the header fragment (header.html's analog).
+use html
+
+pub fn header() -> Text {
+ let brand = link("/", "brand", "writeonce shop");
+ let nav = link("/", "", "Products") .. link("/orders", "", "Orders");
+ return el("header", "site-header", brand .. el("nav", "site-nav", nav));
+}
diff --git a/docs/examples/shop/main.wo b/docs/examples/shop/main.wo
new file mode 100644
index 0000000..af005d6
--- /dev/null
+++ b/docs/examples/shop/main.wo
@@ -0,0 +1,49 @@
+-- shop/main.wo — the bootstrap (app-routing.module's analog): seed the
+-- store on first boot, wire routes to the feature controllers, serve.
+-- No rendering and no queries here beyond the seed.
+--
+-- WO_DATA=./data ./target/shop 8080 (run from the shop directory:
+-- /assets/* serves from ./assets)
+use framework
+use framework/router
+use product_list
+use product_page
+use orders
+use static_files
+
+fn seed_if_empty() {
+ let n = 0;
+ for p in from x in Product take 1 select x {
+ n = n + 1;
+ }
+ if n > 0 {
+ return;
+ }
+ insert Product { sku: "keyb-75", name: "75% mechanical keyboard", price: 89.0, stock: 12 };
+ insert Product { sku: "mug-wal", name: "WAL-backed coffee mug", price: 14.5, stock: 40 };
+ insert Product { sku: "tee-own", name: "Ownership-checked t-shirt", price: 24.9, stock: 25 };
+ insert Product { sku: "desk-pad", name: "Deskmat (one binary edition)", price: 19.0, stock: 0 };
+}
+
+fn main(args: multi Text) -> Int {
+ if len(args) < 1 {
+ print_err("usage: shop (WO_DATA= makes the store durable)");
+ return 2;
+ }
+ let port = parse_int(args[0]);
+ if port == nil {
+ print_err("shop: must be a number");
+ return 2;
+ }
+
+ seed_if_empty();
+
+ let app = App { middleware: [], routes: [] };
+ app.use_mw(Mw { m: Logging { pad: 0 } });
+ app.get("/", ListProducts { pad: 0 });
+ app.get("/p/:sku", ShowProduct { pad: 0 });
+ app.post("/orders/:sku", CreateOrder { pad: 0 });
+ app.get("/orders", ListOrders { pad: 0 });
+ app.get("/assets/*path", StaticFiles { dir: "assets" });
+ return app.serve("127.0.0.1", port);
+}
diff --git a/docs/examples/shop/orders.controller.wo b/docs/examples/shop/orders.controller.wo
new file mode 100644
index 0000000..71cd0d5
--- /dev/null
+++ b/docs/examples/shop/orders.controller.wo
@@ -0,0 +1,55 @@
+-- orders.controller.wo — the buying flow: stock-checked order creation
+-- (decrement + insert are each WAL-committed before they acknowledge)
+-- and the orders list (ref navigation: o.product.name).
+use framework/http
+use layout
+use orders
+use time
+
+pub class CreateOrder {
+ pad: Int
+ fn handle(req: Req) -> Resp {
+ let sku = req.params["sku"];
+ if sku == nil {
+ return html_error(404, "No such product", "The order names no product.");
+ }
+ let f = form_values(req);
+ if f == nil {
+ return html_error(400, "Bad order", "The form did not arrive form-encoded.");
+ }
+ let qraw = f["qty"];
+ if qraw == nil {
+ return html_error(400, "Bad order", "How many? The qty field is missing.");
+ }
+ let qty = parse_int(trim("${qraw}"));
+ if qty == nil or qty < 1 {
+ return html_error(400, "Bad order", "qty must be a positive number.");
+ }
+ let hits = from p in Product where p.sku == sku take 1 select p;
+ if len(hits) == 0 {
+ return html_error(404, "No such product", "Nothing is listed under that sku.");
+ }
+ let p = hits[0];
+ if p.stock < qty {
+ return html_error(409, "Not enough stock", "Only ${p.stock} left of ${p.name}.");
+ }
+ let total = p.price * float(qty);
+ p.stock = p.stock - qty;
+ insert Order { product: p, qty: qty, total: total, placed: time.now(), status: "placed" };
+ let page = OrderOk { name: p.name, qty: qty, total: total };
+ return ok_html(app_shell("shop — order placed", page.render()));
+ }
+}
+
+pub class ListOrders {
+ pad: Int
+ fn handle(req: Req) -> Resp {
+ let rows = "";
+ for o in from x in Order select x {
+ let row = OrderRow { name: o.product.name, qty: o.qty, total: o.total, status: o.status };
+ rows = rows .. row.render();
+ }
+ let page = OrdersPage { rows: rows };
+ return ok_html(app_shell("shop — orders", page.render()));
+ }
+}
diff --git a/docs/examples/shop/orders/view.wo b/docs/examples/shop/orders/view.wo
new file mode 100644
index 0000000..c7f60ba
--- /dev/null
+++ b/docs/examples/shop/orders/view.wo
@@ -0,0 +1,44 @@
+-- orders/view.wo — the confirmation page and the orders table.
+use html
+
+pub class OrderOk {
+ name: Text
+ qty: Int
+ total: Float
+
+ fn render() -> Text {
+ let head = el("h1", "", "Order placed");
+ let what = el("p", "", "${self.qty} × ${esc(self.name)} — total € ${self.total}");
+ let links = el("p", "", link("/orders", "", "See all orders") .. " · " .. link("/", "", "Keep shopping"));
+ return el("div", "card", head .. what .. links);
+ }
+}
+
+pub class OrderRow {
+ name: Text
+ qty: Int
+ total: Float
+ status: Text
+
+ fn render() -> Text {
+ let cells = el("td", "", esc(self.name));
+ cells = cells .. el("td", "", "${self.qty}");
+ cells = cells .. el("td", "", "€ ${self.total}");
+ cells = cells .. el("td", "", esc(self.status));
+ return el("tr", "", cells);
+ }
+}
+
+pub class OrdersPage {
+ rows: Text
+
+ fn render() -> Text {
+ let head = el("h1", "", "Orders");
+ if self.rows == "" {
+ return head .. el("p", "", "No orders yet — " .. link("/", "", "go buy something") .. ".");
+ }
+ let thead = el("tr", "", el("th", "", "Product") .. el("th", "", "Qty") .. el("th", "", "Total") .. el("th", "", "Status"));
+ let table = el("table", "orders", thead .. self.rows);
+ return head .. table;
+ }
+}
diff --git a/docs/examples/shop/product_list.controller.wo b/docs/examples/shop/product_list.controller.wo
new file mode 100644
index 0000000..6035d08
--- /dev/null
+++ b/docs/examples/shop/product_list.controller.wo
@@ -0,0 +1,20 @@
+-- product_list.controller.wo — the CONTROLLER for /: query the model,
+-- fill the view classes, answer a Resp. One controller file per feature;
+-- they live in the ROOT module because the @tables do (see types.wo's
+-- note on the pub+@table gap) — the views stay behind their module line.
+use framework/http
+use layout
+use product_list
+
+pub class ListProducts {
+ pad: Int
+ fn handle(req: Req) -> Resp {
+ let cards = "";
+ for p in from x in Product order by x.name select x {
+ let card = ProductCard { sku: p.sku, name: p.name, price: p.price, stock: p.stock };
+ cards = cards .. card.render();
+ }
+ let page = ProductListPage { cards: cards };
+ return ok_html(app_shell("shop — products", page.render()));
+ }
+}
diff --git a/docs/examples/shop/product_list/view.wo b/docs/examples/shop/product_list/view.wo
new file mode 100644
index 0000000..981406d
--- /dev/null
+++ b/docs/examples/shop/product_list/view.wo
@@ -0,0 +1,33 @@
+-- product_list/view.wo — the VIEW (view.html's analog). Classes with
+-- `fn render() -> Text`: fields are exactly the values displayed, the
+-- markup reads top to bottom. No queries here — the controller fills
+-- the fields. (A future `@view` class would let a projection query fill
+-- them directly; today the controller copies the fields in.)
+use html
+
+pub class ProductCard {
+ sku: Text
+ name: Text
+ price: Float
+ stock: Int
+
+ fn render() -> Text {
+ let h = el("h3", "", link("/p/${esc(self.sku)}", "", esc(self.name)));
+ let price = el("p", "price", "€ ${self.price}");
+ let stock = el("p", "stock", "${self.stock} in stock");
+ if self.stock == 0 {
+ stock = el("p", "stock out", "sold out");
+ }
+ return el("div", "card", h .. price .. stock);
+ }
+}
+
+pub class ProductListPage {
+ cards: Text
+
+ fn render() -> Text {
+ let head = el("h1", "", "Products");
+ let grid = el("div", "grid", self.cards);
+ return head .. grid;
+ }
+}
diff --git a/docs/examples/shop/product_page.controller.wo b/docs/examples/shop/product_page.controller.wo
new file mode 100644
index 0000000..8023481
--- /dev/null
+++ b/docs/examples/shop/product_page.controller.wo
@@ -0,0 +1,21 @@
+-- product_page.controller.wo — the CONTROLLER for /p/:sku.
+use framework/http
+use layout
+use product_page
+
+pub class ShowProduct {
+ pad: Int
+ fn handle(req: Req) -> Resp {
+ let sku = req.params["sku"];
+ if sku == nil {
+ return html_error(404, "No such product", "The address is missing a product.");
+ }
+ let hits = from p in Product where p.sku == sku take 1 select p;
+ if len(hits) == 0 {
+ return html_error(404, "No such product", "Nothing is listed under that sku.");
+ }
+ let p = hits[0];
+ let page = ProductPage { sku: p.sku, name: p.name, price: p.price, stock: p.stock };
+ return ok_html(app_shell("shop — ${p.name}", page.render()));
+ }
+}
diff --git a/docs/examples/shop/product_page/view.wo b/docs/examples/shop/product_page/view.wo
new file mode 100644
index 0000000..2acbc04
--- /dev/null
+++ b/docs/examples/shop/product_page/view.wo
@@ -0,0 +1,25 @@
+-- product_page/view.wo — the product detail view with the order form.
+use html
+
+pub class ProductPage {
+ sku: Text
+ name: Text
+ price: Float
+ stock: Int
+
+ fn render() -> Text {
+ let head = el("h1", "", esc(self.name));
+ let price = el("p", "price", "€ ${self.price}");
+ let stock = el("p", "stock", "${self.stock} in stock");
+ let form = "";
+ if self.stock > 0 {
+ let qty = text_input("qty", "1");
+ let buy = submit_btn("Buy");
+ form = form_post("/orders/${esc(self.sku)}", el("label", "", "Quantity") .. qty .. buy);
+ } else {
+ form = el("p", "stock out", "sold out");
+ }
+ let back = el("p", "", link("/", "", "← all products"));
+ return el("div", "card detail", head .. price .. stock .. form) .. back;
+ }
+}
diff --git a/docs/examples/shop/static_files/controller.wo b/docs/examples/shop/static_files/controller.wo
new file mode 100644
index 0000000..5533d36
--- /dev/null
+++ b/docs/examples/shop/static_files/controller.wo
@@ -0,0 +1,33 @@
+-- static_files/controller.wo — serves /assets/* from disk. Traversal-
+-- safe (any ".." answers 404, never touches the filesystem), extension-
+-- mapped content types, 2 MiB cap per file. Text is binary-safe, so
+-- images travel as-is. (Story 38 lifts this into the framework; until
+-- then the template carries its own copy — it is ~40 lines.)
+use framework/http
+use fs
+
+pub class StaticFiles {
+ dir: Text
+ fn handle(req: Req) -> Resp {
+ let rel = req.params["path"];
+ if rel == nil {
+ return not_found();
+ }
+ if index_of("${rel}", "..") != -1 {
+ return not_found();
+ }
+ let body = try fs.read_all("${self.dir}/${rel}", 2097152) catch (e) nil;
+ if body == nil {
+ return not_found();
+ }
+ let ct = "application/octet-stream";
+ if ends_with("${rel}", ".css") { ct = "text/css; charset=utf-8"; }
+ if ends_with("${rel}", ".js") { ct = "text/javascript"; }
+ if ends_with("${rel}", ".svg") { ct = "image/svg+xml"; }
+ if ends_with("${rel}", ".png") { ct = "image/png"; }
+ if ends_with("${rel}", ".webp") { ct = "image/webp"; }
+ let h: map = {};
+ h["content-type"] = ct;
+ return Resp { status: 200, headers: h, body: "${body}" };
+ }
+}
diff --git a/docs/examples/shop/types.wo b/docs/examples/shop/types.wo
new file mode 100644
index 0000000..2dd41a3
--- /dev/null
+++ b/docs/examples/shop/types.wo
@@ -0,0 +1,29 @@
+-- types.wo — the MODEL. Every @table class IS a WAL-backed table: rows
+-- persist under WO_DATA and replay on restart; without WO_DATA the
+-- store is RAM-only (handy while developing). Nothing else lives here —
+-- no rendering, no request handling.
+--
+-- Root module by NECESSITY, not choice: `pub` and `@table` cannot
+-- combine yet (recorded language gap), so tables cannot be exported to
+-- other modules — everything that queries them (the controllers) lives
+-- in the root module too. When the gap closes, this file becomes a
+-- `types/` module and the controllers move into their feature folders.
+
+@table(name: "products", index: [sku])
+class Product {
+ sku: Text @unique
+ name: Text
+ price: Float
+ stock: Int
+
+ orders: backlink Order.product
+}
+
+@table(name: "orders", index: [product])
+class Order {
+ product: ref Product
+ qty: Int
+ total: Float
+ placed: Int -- epoch ms (time.now at purchase)
+ status: Text -- "placed" in v1; a fulfilment flow would grow this
+}
diff --git a/docs/examples/shop/wo.toml b/docs/examples/shop/wo.toml
new file mode 100644
index 0000000..ee459bc
--- /dev/null
+++ b/docs/examples/shop/wo.toml
@@ -0,0 +1,12 @@
+name = "shop"
+version = "0.1.0"
+description = "The writeonce program template: an MVC-separated shop — @table model, render() view classes, controller handlers, static assets"
+
+[runtime]
+wo = ">= 0.1"
+
+# Two library dependencies, the site sample's proven shape. The [deps]
+# KEY is the module name `use` imports.
+[deps]
+framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" }
+html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" }
diff --git a/docs/stories/language-runtime-database/refine/37-wo-html-components.md b/docs/stories/language-runtime-database/refine/37-wo-html-components.md
index 91fb2b1..fa254cc 100644
--- a/docs/stories/language-runtime-database/refine/37-wo-html-components.md
+++ b/docs/stories/language-runtime-database/refine/37-wo-html-components.md
@@ -15,6 +15,29 @@ status: refine
> own dependency (the site sample's two-dep lesson). Unscheduled —
> independent of the concurrency chain; needs its spec brainstormed
> first.
+>
+> **REDIRECTED 2026-08-23** (developer review of the shop template
+> against a Vue SFC): render()-as-string-concatenation failed the DX
+> referendum — markup must be markup-FIRST. New direction:
+> **`view.html` template files COMPILED BY `woc` into render code** —
+> `{{ self.name }}` interpolation (auto-escaped; a raw opt-out spelling
+> for prebuilt fragments), `w:if`/`w:for` structural attributes, typed
+> against the view class's fields at compile time (a typo'd field is a
+> compile error), NO template engine at runtime — the doctrine's real
+> meaning becomes "no template interpreted at request time", not "no
+> template files". A runtime mustache-lite was considered and REJECTED:
+> reflection-free means values degrade to `map` — typing
+> lost. Client-side reactivity from the Vue sample (`ref`, `@click`,
+> `v-model`) stays out under the no-JS posture; qty steppers are form
+> fields, actions are POSTs. This makes 37 a COMPILER iteration too
+> (template-to-code lowering), gated behind the standing
+> "no compiler/VM/database changes yet" directive — schedule
+> accordingly. The shop template
+> ([`docs/examples/shop`](../../../examples/shop/README.md)) is the
+> consumer: its `view.wo` classes become `view.html` + view classes,
+> and its README's recorded gaps ride along (gap #1: `pub` + `@table`
+> cannot combine — blocks a shared model module; gap #2: `@view`
+> projection classes).
## Why this iteration exists
@@ -78,8 +101,9 @@ detection, event bindings, SPA router) deliberately does not.
- Client-side anything: change detection, event/two-way bindings, SPA
routing, hydration — the no-JS posture stands; interactivity is form
round trips until a directive says otherwise.
-- A template LANGUAGE (files parsed at build or runtime) — templates are
- `.wo` code by doctrine (no closures also means no template lambdas).
+- A RUNTIME template engine (files parsed per request, mustache-style) —
+ rejected 2026-08-23: reflection-free means untyped `map`
+ values. Templates compile to code at build time or they don't exist.
- Dependency injection / services — components are data-in, Text-out.
- Moving wo-html into the framework — settled 2026-08-23: separate
libraries, composed via `[deps]`.
@@ -88,21 +112,25 @@ detection, event bindings, SPA router) deliberately does not.
## Info
-Forks the spec must settle:
+Forks the spec must settle (REVISED 2026-08-23 for the compiled-template
+direction):
-1. **Interface shape** — `render() -> Text` alone, or `render(ctx) ->
- Text` with a context record (e.g. the request's principal for
- view-level decisions)? Leaning: bare `render()` — context smells like
- DI; whatever the view needs arrives as a field.
-2. **The framework seam** — does `ok_html(body)` move into the framework
+1. **Template pairing** — Vue-SFC style (one `view.html` whose
+ frontmatter/`