writeonce/docs/plan/exploration/ui/08-mvc-structure.md
shoney.arickathil a55971d857 docs: status board at docs/00-status.md; gap-closure spec applied; recover lost doc
- Board renamed docs/plan/00-kanban.md -> docs/00-status.md and rebuilt: ▶ NEXT
  PLAN pointer (iteration 4 — emitter, corpus, `woc build`) then six buckets —
  stories, in progress, done, pending, discarded, learnings. It covered only the
  Rust runtime before, so the whole OOP track was invisible. All 16 inbound refs
  repointed; `Kanban:` banners renamed to `Status:`.
- New discarded.md (settled rejections with reasons: inheritance, `abstract`,
  Money/SKU/Float, Dynamic/cast/macro/extern, AOT-to-C, Menhir, shared engine
  state) and learnings.md (plumbed≠enforced, vacuous goldens, exit-0-wrong-
  output, malloc-path ASan trick, deferred checks that never reach the VM).
- RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk,
  never committed (gitignored path), cited by five docs incl. principle 12.
  Root cause was broader: all seven forward-roadmap plans in
  docs/superpowers/plans/ were untracked and ignored, on one disk only. Dropped
  the docs ignore rules with a do-not-re-add note; added __pycache__/*.pyc.
- Repaired broken links across docs/, 270 -> 36: fixes a regression from the
  earlier reference/ -> .dev/reference/ move (relative paths at ../../ and
  deeper were skipped), plus depth and reorg drift. The 36 residual point at
  content that does not exist and need decisions, not paths.
- New spec docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md,
  applied: `and`/`or` verdict row; Part 3 gains `env` (six modules), swaps
  time.mono for iso/local, adds 22 bare core builtins; throw/time.mono/is cut
  (0 uses in the sample). Plan 8: Task 2 gains and/or, Task 5 drops throw,
  abstract+`is` task deleted, 8/9 renumber to 7/8. Plan 9 gains core builtins.
  Plan 10 gains the 307 -> 0 diagnostic gate. WO-E205 re-filed unreachable-by-
  design. types.ml header drops its false satisfaction-set claim. 00-code-
  review.md reduced to a stub — its rival Phase 1-4 roadmap retired.
2026-08-10 23:42:26 +02:00

6.7 KiB
Raw Blame History

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

  1. 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
    
  2. The view is plain .htmlx, logic-free. Mustache paths, {{#each}}/{{#if}}, partials, helpers, <wo:live> subtrees, wo:bind attributes — nothing else. A <wo:live source="products"> whose source is a bare name resolves against the controller's model: block (the M→V binding); an inline query in source remains legal for controller-less partials. Views never call methods — they raise actions (wo:action="set-price"), the controller dispatches.

  3. Styles are external SCSS, compiled at wo build. No <style> blocks in views, no inline styles, one .scss per screen plus shared partials (ui/styles/_*.scss). wo build compiles a strict SCSS subset — variables, nesting, @use of partials; no mixins/functions in the first cut — to flat CSS served as a static asset via sendfile (../../08-sendfile-static-assets.md). Hand-rolled in crates/ui (~400 LOC scanner + nesting flattener), zero external dependencies — same stance as every other phase.

  4. One binary, unchanged. wo build links 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.

  5. The ##ui table shorthand survives as sugar. The earlier declarative screen spec (columns: / sort: / pagination:, as in the ecommerce home.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)

  1. crates/ui resolves a controller file: route:/view:/styles:/model:/actions: parsed, view rendered with model scope, actions dispatched to class methods.
  2. SCSS subset compiler: pricing.scss → flat CSS at build, golden-file tested.
  3. The pricing screen works end-to-end per plan 13d's exit criterion: a set_price commit patches the price cell in every open browser without reload.