Saltar a contenido

Phase 11 — Native frontend in Fitz core

Status: sub-phases 11.1 + 11.2.a/b/c + view-lexer §7 + 11.3.a/b/c CLOSED 2026-07-14. Sub-phases 11.4.a/b/c/d CLOSED 2026-07-14 → 2026-07-15 (browser smoke manual on the counter demo validated the WASM emitter end-to-end within the 40 KB gzipped gate). Sub-phases 11.5.a/b/c/d/e CLOSED 2026-07-15 — Phase 11.5 shipped entirely. fitz build --bin <web> on a .fitzv with target = "wasm-client" produces a browser bundle end-to-end; single- component AND multi-component fixtures work, with the composition subset that 11.5.d anchored (static props, self-closing children, no event bubbling). Sub-phase 11.6.a CLOSED 2026-07-15 — research + decision doc pinning SSR emitter + fitz-liveviews migration as the 11.6 deliverable set, with client-side dynamic capabilities re-scoped to 11.7 (see §9.u). See docs/stack.md for the architectural constitution this plan implements.

Sub-phases still open: 11.6.e (partial as of §9.bb 2026-07-16 — §9.z shipped SSR emitter payload in event- body scope + fitz_liveviews missing-dep hint + counter migration draft uncommitted; §9.aa shipped event-body widening for let + nested if guards + Expr::If/ Expr::StructLit on RHS, unblocking kanban's card_editor_save + chat's send_message .fitzv migrations; §9.bb ships cross-module @live_component auto-inject — extends v0.20.1's implicit flv_register(...) to types declared in imported .fitzv/.fitz sibling modules via TypeEnv.imported_live_components + pre_scan_imported_live_components paralelo W12/B10; remaining: cross-file <Child /> composition (low priority, §9.y debt), migration commits in fitz-liveviews deferred until Fitz v0.21.0 release), 11.7 (client-side dynamic capabilities + kanban SPA port), 11.8 (LSP support), 11.9 (pedagogic docs).

Update 2026-07-20: 11.7 CLOSED entirely at v0.24.0, plus the cross-file composition refinements (v0.25.0/v0.26.0). The client-WASM composition surface is complete. The next Phase 11 iteration is fine-grained reactivity + fullstack (11.10–11.13) — signals, @server, SSR→client hydration, template hot reload. See §11 at the bottom of this doc + the "próxima iteración" block in docs/roadmap.md.

11.6.b + 11.6.c + 11.6.d CLOSED ENTIRELY 2026-07-15view::emit_module_ssr emits classic Fitz source with the FULL template grammar (Text, Interpolation, Element, static + interpolated + event attrs, {#if} + {#else}, {#for}, <style> inline, same-file <Child prop="v" /> composition) + full expression grammar (BinOp, UnaryOp, Call, Field, Index, StrInterp, List, Map, Range, Ok, Err, arrow FnExpr) with state-field rewriting + closure-param local-scope tracking. View lexer accepts . in event body context. Round-trip tests validate that emitted source lexes + parses through classic Fitz. The classic module loader — evaluator (fitz run), codegen loader (fitz build), CLI pre-scan paths (fitz check), and LSP cross-module resolvers — routes .fitzv files through the view pipeline transparently: .fitz wins if both siblings exist (backward-compat), .fitzv is fallback.

This document captures the decisions and the sub-phases shipped so far, plus the shape of the ones still open. Its purpose is to make the next sub-phase concrete enough that it can be picked up in a fresh session without re-litigating shape.


1. Extension: .fitzv

The Phase 11 file format is .fitzv — read as "fitz view". One component per file idiomatically; the parser accepts multiple top-level component blocks in the same file but the plan defers multi-component wiring to 11.2+.

Why not .flv? Collides with the FLV video container format; would confuse tooling and search results.

Why not .fitzc? Reads as "fitz C" more than "fitz component"; also collides with the compilation-artefact convention (.pyc, .class).

Why not .view.fitz (Nuxt-style suffix)? The classic .fitz parser has no reason to look at those files; splitting them off into .fitzv keeps Invariant 4 of docs/stack.md (the classic parser CANNOT touch view code) trivial to enforce — the CLI routes by extension, not by content sniffing.

Why .fitzv? Short (five chars), reads naturally, stays in the .fitz* family, no known collision.


2. What lives in a .fitzv file

The shape recognised by the POC parser (src/view/parser.rs):

component ComponentName {
  state {
    field_name: TypeExpr = default_expr
    // ... more fields
  }

  event handler_name(params) {
    // body — same statement grammar as classic Fitz fns
  }

  <template>
    <!-- HTML with {expr} interpolation and @click="handler" -->
  </template>

  <style scoped>
    /* opaque CSS (parsed in 11.3+) */
  </style>
}

Rules the POC enforces today:

  • Exactly one component per top-level block; multi-component per file is allowed but the plan defers "how does the codegen emit N components in one artefact?" to 11.2.
  • state/event/<template>/<style scoped> are all optional and can appear in any order.
  • Only one <template> and one <style scoped> per component (duplicates error clearly).
  • All state fields require a type annotation and a default (parallel to @live_component types today, so the compiler can synthesise TypeName {} at boot without arguments).
  • <style scoped> is the only style form accepted; unscoped styles are deferred to 11.3+ pending a decision on the global-style story.
  • Attribute values are fully static (class="card") or fully interpolated (value="{title}"). Mixed values (class="btn btn-{kind}") land in 11.2+.
  • No control flow inside <template> yet — {#if} / {#for} / <slot> are all deferred to 11.2+.

3. Isolation strategy (Invariant 4 of docs/stack.md)

The Phase 11 parser lives in src/view/, a dedicated module with its own lexer, parser, and AST. Concretely:

  • src/view/mod.rs — exports parse(source: &str) -> ViewParseResult<ViewFile>.
  • src/view/ast.rs — SFC AST (ViewFile, Component, StateField, EventHandler, Template, TemplateNode, Attr, Style, Loc).
  • src/view/lexer.rs — char-by-char tokenizer. Recognises the keywords component/state/event, identifiers, string literals, delimiters, and — critically — the raw blocks <template>...</template> and <style scoped>...</style> which it captures as single tokens.
  • src/view/parser.rs — recursive-descent parser over the tokens plus an embedded HTML sub-parser that walks the TemplateRaw blob.

Reuse from the classic pipeline: zero, deliberately. The view lexer does NOT invoke crate::lexer::tokenize. The view parser does NOT invoke crate::parser::parse. The AST does NOT share types with crate::ast. This is the price for Invariant 4: if a bug happens in the view module, it CANNOT reach the classic pipeline.

When 11.2 lands (parsing state defaults, event bodies, and template interpolations as crate::ast::Expr/Stmt), we will reuse crate::lexer::tokenize on the raw string blobs stored in the view AST — but the entry point remains dispatch-by-extension, and the view parser stays the front door.

Wiring into src/lib.rs: plain pub mod view;. No feature gate, because the module adds zero new deps and today no code path in the fitz binary dispatches to it — only tests and external tooling can call view::parse(...). A feature gate would add friction to the smoke without upside.


4. Contract with the checker

Today (POC): the view AST captures state field types, defaults, event handler params, event bodies, and template interpolations as raw source strings. No type-checking happens against this AST.

Phase 11.2 will type-check: - Every state field type — reuses crate::types::resolve_type_expr against a fresh TypeEnv seeded with the built-in nominals. - Every state field default — same rules as type Foo { field: T = expr } today: the default must resolve to a value compatible with the declared type. - Every event handler body — same rules as an async fn inside classic Fitz today: statements are checked in an env seeded with the component's state fields as let-bindings, plus the params declared on the handler. - Every template {expr} interpolation — checked in the same env as the event handler bodies (state fields visible as identifiers). The result type must be Str-friendly (Str, Int, Float, Bool, or a nominal with a Display impl). - Every @event="handler" attr — validates that handler is one of the declared event ... handlers in the same component.

What stays opaque to the checker in 11.2: - Attribute names (class, disabled, data-flv-value-foo) — not validated against an HTML spec; the SFC targets any HTML attribute the user names. - Tag names (div, custom-el) — not validated; the target might be a custom element registered client-side. - Inline styles — the style attr value is a plain string. - CSS inside <style scoped> — the checker does not parse CSS in the MVP.


5. Contract between codegen SSR and codegen client

Two targets Phase 11 will support, both derived from the same view AST:

Target A — SSR (Rust): emit a pub async fn render_ComponentName(state: ComponentNameState) -> String alongside the component's data struct. Server code composes components by calling these render fns and concatenating strings. <style scoped> blocks are hashed into a per-component class prefix and injected into the HTML alongside the markup.

Target B — Client (WASM or vanilla JS): emit a per-component module that owns the state, wires DOM events to the handler fns, and re-renders on state change. Two candidate lowering strategies (decision deferred to 11.4): - WASM-first: compile the component's Rust to WASM, ship a small JS shim that mounts the component into a DOM node. Bigger bundle but zero-language mismatch. - JS-vanilla: emit hand-written JS from the view AST — the SFC becomes a small vanilla JS module with a state store and event bindings. Smaller bundle, but the compiler grows a JS emitter.

What SSR and client SHARE: - The AST after Phase 11.1's parser. - The state field parsing (defaults resolved at compile time to values that both targets serialise the same way). - The event handler parsing to Vec<Stmt>. - The template AST post-11.2 (with {#if}/{#for}/<slot> parsed). - The <style scoped> hashing + class-prefix strategy.

What SSR and client DO NOT share: - The output format (Rust code vs WASM/JS module). - The event dispatch mechanism (server calls the handler on the next request/websocket message; client attaches DOM listeners). - The reactivity model (SSR is pure functional recompute; client needs a diffing / VDOM / signals mechanism).

Compiler flag (11.5+): fitz build --target ssr (default when the manifest has [[bin]]), fitz build --target wasm, fitz build --target js. Emitting BOTH from the same source is the Phase 11 promise.


6. Sub-phase plan

Sub-phase Scope Cierre criterion
11.1 POC parser: recognise the SFC shape, capture bodies as raw. Isolated src/view/ module. CLOSED 2026-07-14 — this doc + src/view/ + 19 unit tests. Card component test parses cleanly; classic pipeline untouched (Invariants 1-5 verified).
11.2 Parse state defaults / event bodies / template interpolations as crate::ast::Expr/Stmt. Add {#if} / {#for} / <slot> to the template AST. Type-check every expression in state + event + {...} interpolations. fitz check my.fitzv reports type errors for state field mismatches and template interpolation type errors, with source-accurate line/column pointing inside the .fitzv file.
11.2.a Sub-step of 11.2. Parse state defaults / event bodies / template interpolations / event params / attr interpolations as classic Fitz AST. Introduce crate::view::expand bridging the raw view AST back through the classic lexer + parser via 4 new pub entry points in crate::parser. CLOSED 2026-07-14src/view/expand.rs (~660 LoC) + 4 pub fns parse_expression_from_source / parse_type_expression_from_source / parse_statements_from_source / parse_parameters_from_source in src/parser.rs + 16 unit tests. No checker yet — that lands in 11.2.b. Positions inside spans are shifted approximately (blob-local + best-effort base); precise offset tracking still deferred. Card component expand()s end-to-end producing Expr::Str / Expr::Bool state defaults, Vec<Stmt::Assign> event bodies, Vec<Param> event params, and Expr::Ident template interpolations. Two error cases (bad default, bad body) carry the naming context so users can find the wrong blob.
11.2.b Sub-step of 11.2. Type-check every parsed AST from 11.2.a. Split into three mini-commits, ALL CLOSED 2026-07-14: (1) state field defaults compatible with declared type (src/view/check.rs ~325 LoC + 16 unit tests). (2) event handler bodies checked in an env seeded with state fields as let-bindings + params; template {expr} interpolations checked in the state env with the additional Str-friendly rule (rejected: Function, Result, Future, WsConn, DbConn/DbRow, QueryBuilder, Aggregated, Secret) (src/view/check.rs ~640 LoC total + 23 new unit tests). (3) @event="handler" attrs cross-check that handler names a declared event handler in the same component; broken references get a "did you mean ...?" hint via Levenshtein distance ≤ 3, or the full available handler list when the declared set is small (≤ 5) (src/view/check.rs ~825 LoC total + 11 new unit tests). Closes 11.2.b entirely — 50 view::check tests all green. .fitzv files with mismatched types (e.g. count: Int = "hi") or broken @click="handler" references surface at the correct field/blob with a friendly suggestion.
11.2.c Sub-step of 11.2. Extend the template AST with {#if cond}, {#for x in xs}, {#else}, and <slot name="X" />. Update the HTML sub-parser + expand + checker to handle them. Split into three mini-commits, ALL CLOSED 2026-07-14. Mini-commit 1: {#if cond}...{/if} end-to-end (raw AST + HTML sub-parser directive dispatch + expand parses cond as classic Expr + checker validates cond is Bool-compatible and recurses walkers into If children). ~450 LoC + 24 new unit tests (9 parser + 4 expand + 11 checker). See §9.f. Mini-commit 2: {#for x in xs}...{/for} end-to-end (raw AST + HTML sub-parser dispatch on for + expand parses iter as classic Expr + check_template_for_iters pass delegating to the classic Stmt::For checker for iterable typing + refactor of the 3 collectors to track a for_scope chain so interps and {#if} conds nested inside a for see the binding, wrapped in the corresponding Stmt::For chain when synthesising). ~570 LoC + 25 new unit tests (10 parser + 4 expand + 11 checker). Binding restricted to a single bare identifier — compound patterns (k, v) for Map and index bindings (x, i) deferred. See §9.g. Mini-commit 3: {#if cond}...{#else}...{/if} (extend TemplateNode::If with else_children: Option<...> + refactor parse_nodes with accept_else: bool returning (nodes, terminated_by_else) + {#else} sentinel intercepted by parse_nodes when accepting, dispatched as targeted error by parse_directive_open otherwise) and <slot /> / <slot name="X" /> (new TemplateNode::Slot { name: Option<String>, loc } variant intercepted in parse_element's self-closing branch; open-close form <slot>...</slot> rejected with a 11.5-pointer message; only the name attribute accepted, everything else rejected with targeted messages). All 4 checker collectors (collect_if_conds / collect_for_iters / collect_interpolations / collect_event_attrs) recurse through else_children when present; Slot is a leaf that every collector skips. ~740 LoC + 26 new unit tests (14 parser + 5 expand + 7 checker). Closes 11.2.c entirely — the template dialect now has the full set of directives promised for 11.2, and 11.2 itself is done. See §9.h. Nested control flow inside <template> parses, expands, type-checks; <slot> markers survive the pipeline so 11.5 composition can consume them.
11.3 CSS scoping. Parse <style scoped> into a small AST, apply per-component class prefix, emit scoped CSS in the SSR output. Decide unscoped style story (<style global> or a separate directive). Split into three mini-commits, ALL CLOSED 2026-07-14: 11.3.a<style global> as a first-class sibling of <style scoped> in the lexer + parser + AST, plus new StyleKind { Scoped, Global } discriminant; bare <style> is rejected at lex time with a targeted error naming both accepted forms (see §9.i). 11.3.b — standalone CSS mini-parser + apply_scope(css_raw, scope) -> Result<String, CssParseError> in src/view/css_parser.rs (~900 LoC + 45 unit tests + 1 doctest); walks the CSS char-by-char, suffixes every class selector .<ident> with -<scope>, recurses into @media/@supports/@container, keeps other at-rules opaque, handles strings + comments + attribute selectors + selector-arg pseudos (:not(.foo) correctly scopes the inner class) (see §9.j). 11.3.c — wire scoping end-to-end in expand: new enum ExpandedStyle { Scoped { css_scoped, scope_class, loc }, Global { css, loc } } replaces the raw-passthrough Option<RawStyle>; scope class synthesised via FNV-1a of <component>::<css_raw> truncated to 8 hex, shape <component-kebab>-c-<8hex>; apply_scope(...) runs on the CSS body; every Element with a static class attribute gets suffixed variants added (class="card"class="card card-<scope>") via a recursive walker that descends into If then/else branches + For bodies; interpolated class attributes left alone as a documented limitation. Global styles are pure passthrough — no CSS transform, no template rewrite. Includes 21 new unit tests + src/view/expand.rs grew from ~830 LoC to ~1650 LoC (see §9.k). Closes 11.3 entire — Phase 11.3 shipped end-to-end. A component with <style scoped> styling produces HTML + CSS where the styles apply only to that component's markup, verified against .fitzv fixtures.
11.4 Client target decision (WASM vs JS-vanilla). Prototype whichever wins on a two-page counter demo. Confirm bundle size is acceptable. Split into four sub-commits: 11.4.a — research + decision (docs-only, no code). CLOSED 2026-07-14 — decision recorded is WASM-first, hand-rolled wasm-bindgen + web-sys directly under opt-in feature client-wasm (Approach A2 in §9.l). Rationale: aligns with docs/stack.md v1 "WASM primero, JS/vanilla secundario"; preserves the "Fitz por sí solo" promise (no external framework, no npm); preserves Invariant 4 (emitter isolated in src/view/codegen_wasm.rs, cargo build default unchanged); bundle 15-25 KB gzipped acceptable for 90% of cases. Gate for 11.4.b/c: if the counter POC exceeds 40 KB gzipped, PIVOT to JS-vanilla and refresh docs/stack.md. See §9.l. 11.4.b — POC emitter of the chosen target on the counter subset (state Int + eventless params + template Text/Element/Interpolation/Event/Static-class). CLOSED 2026-07-14src/view/codegen_wasm.rs (~1500 LoC + 23 unit tests) emits Rust source for one component (struct + new() + event fns + mount() + render() + scoped/global style helper) via pub fn emit_component(&ExpandedComponent) -> EmitResult<String> and a whole .rs module via pub fn emit_module(&ExpandedViewFile) -> EmitResult<String>. Naive re-render on state mutation (D1), two-fn public API (D2), strictly conservative subset (D3 — Int state + @click-only + literal-Int + BinOp arithmetic; If/For/Slot/non-click/Str/Bool/Nominal/handler-params reject with EmitError citing 11.4.c/11.5), string-grep unit tests only (D4). Cargo.toml gains opt-in feature client-wasm with wasm-bindgen/web-sys/console_error_panic_hook; cargo build default dep tree unchanged. Verification: 3510 lib tests green (3487 baseline + 23 new), fmt + clippy -D warnings clean in both default and --features client-wasm modes. See §9.m. Deuda derivada VISIBLE that gated 11.4.c (now CLOSED via §9.n follow-up 2026-07-14): view lexer arithmetic gap. 11.4.c — counter demo runnable (examples/view/counter/) + browser smoke + bundle-size measurement recorded against the 40 KB gate. CLOSED 2026-07-15examples/view/counter/{Counter.fitzv, index.html, README.md, wasm-crate/} shipped end-to-end. Harness lives in tests/view_counter_wasm_smoke.rs: regenerate_counter_lib_rs (always runs on cargo test, keeps wasm-crate/src/lib.rs in sync with the emitter output + validates structural invariants) + build_counter_wasm_and_measure (#[ignore], opt-in via -- --ignored, runs wasm-pack build --release --target web and measures gzipped size against the 40 KB gate). flate2 = "1" added as dev-dependency for the size measurement. Composed entry point (#[wasm_bindgen(start)] pub fn start()) lives at the tail of the harness, NOT in view::emit_module() — same posture Phase 11.5 CLI will inherit. Verification: 3517 lib tests green (unchanged baseline, no regressions), fmt + clippy default + clippy --features client-wasm all clean. See §9.o. MEASUREMENT CLOSED 2026-07-15 — measured on Windows 11 with rustc + rust-std wasm32-unknown-unknown + wasm-pack 0.15.0: raw .wasm 26.1 KB / gzipped 11.4 KB (28.6 KB headroom under the 40 KB gate). A2 (hand-rolled wasm-bindgen) validated as the primary WASM target; no pivot to JS-vanilla needed, docs/stack.md lines 99-101 remain accurate. wasm-pack requires wasm-opt = ['-O', '--enable-bulk-memory'] metadata on the wasm-crate to accept modern rustc output — recorded in wasm-crate/Cargo.toml with the reason inline. Two cosmetic emitter warnings surfaced (unused_parens in BinOp assignment RHS + non_snake_case in style-injection helpers) and are documented as deuda derivada in §9.o Debt residual — not correctness bugs; deferred to the 11.5 emitter cleanup pass. 11.4.d — cierre formal + roadmap refresh. CLOSED 2026-07-15 — this doc's row + §9.o Result subsection + docs/roadmap.md Fase 11 section refreshed to reflect the gate outcome. Browser smoke validated manually on Windows 11 / Chrome (2026-07-15): counter renders in #app with initial 0, +/-/reset mutate the value and re-render is scoped to the component's subtree (no full-body redraw, per §9.m D1 naive-render policy). Two cosmetic emitter warnings (unused_parens + non_snake_case) intentionally deferred to the 11.5 CLI cleanup pass (see §9.o Debt residual). Closes 11.4 entirely — A2 (hand-rolled wasm-bindgen + web-sys under opt-in feature client-wasm) confirmed as the primary WASM target for Fitz, no pivot to JS-vanilla needed. fitz build --target <chosen> produces a working browser demo of the counter component with state persisting across events.
11.5 CLI integration — fitz build routes .fitzv files based on [[bin]] target / --target flag. Multi-component composition (parent embeds child via <Child prop="v" />). Split into five sub-commits: 11.5.a — research + decision (docs-only, no code). CLOSED 2026-07-15 — decision recorded is hybrid manifest + flag ([[bin]] multi-bin closes debt 9.y.8+ as a side-effect; target = "native" | "wasm-client" | "ssr" with ssr reserved for 11.6+; mount = "#app" required for wasm-client; --bin <name> selector + --target <t> override; legacy [bin] singular auto-migrates). See §9.p. 11.5.b — manifest extension: [[bin]] array-of-tables + name/target/mount fields + --bin/--target flags + auto-migration of legacy [bin] singular. Rejects target = "ssr" with targeted 11.6+ message. CLOSED 2026-07-15src/manifest.rs now models Manifest.bins: Vec<ManifestBin> (was Option<Bin>) with new fields name/target/mount, new Target enum (kebab-case serde: Native/WasmClient/Ssr), new ManifestWarning::SsrTargetReserved surface for the CLI to display. Custom Deserialize via RawManifest + untagged RawBinField { Single | Multiple } auto-migrates legacy [bin] singular (fills name from package.name when omitted). Cross-field validation eagerly rejects .fitzv + target = "native" (both explicit and default) and wasm-client without mount. Custom Serialize preserves the visual [bin] singular shape for the common scaffolded case. CLI: Commands::Build gains --bin <name> + --target <t> (clap); new resolve_entry_with_bin propagates the selection + override into ResolvedEntry.target_override and ManifestCtx.selected_bin; helper enforce_build_target_supported rejects wasm-client citing 11.5.c and ssr citing 11.6+ before touching disk. 23 new unit tests in manifest::tests (legacy migration, multi-bin parse, target enum roundtrip, mount validation, .fitzv + native rejection, SSR warning, select_bin) + 7 new cli_e2e tests (--bin selector on multi-bin, wasm-client + 11.5.c rejection, ssr + 11.6+ rejection, unknown target value, legacy [bin] regression, .fitzv + native rejection). Closes debt 9.y.8+ (multi-bin [[bin]]) — noted in docs/deudas-post-5b.md. See §9.q. 11.5.c — single-component wasm-client build. CLOSED 2026-07-15 — new module src/view/wasm_build.rs (~430 LoC + 15 unit tests) owns the composition helpers: compose_lib_rs(expanded, mount_selector, source_label) runs emit_module and appends the #[wasm_bindgen(start)] wrapper (first-declared component = root, per §9.p); compose_cargo_toml(pkg_name) renders the wasm-crate Cargo.toml with the crucial [package.metadata.wasm-pack.profile.release] wasm-opt = ['-O', '--enable-bulk-memory'] metadata knob (§9.o gotcha) + the exact web-sys feature subset the emitter uses; sanitise_wasm_pkg_name converts kebab-case bin names to snake_case Rust crate names; write_wasm_crate_scaffold(dst, expanded, bin_name, mount, source_label) materialises the scaffold on disk. Manifest.warnings() + the cross-field validation from 11.5.b keep the wasm-client path fail-fast. CLI: Commands::Build now dispatches WasmClient to build_wasm_client_cmd(&resolved) (in src/main.rs) which loads the .fitzv, runs the view pipeline (parse → expand → check), writes the scaffold under <manifest_dir>/target/wasm-build/<bin_name>/, shells wasm-pack build --release --target web inside it, and recursively copies pkg/ to <manifest_dir>/target/wasm/<bin_name>/. Classic .fitz sources with target = "wasm-client" are rejected with a targeted 11.5.d pointer (composition case). Missing wasm-pack on the runner emits an actionable install pointer. The smoke harness tests/view_counter_wasm_smoke.rs now routes through view::compose_lib_rs — same helper the CLI uses — so the committed examples/view/counter/wasm-crate/src/lib.rs baseline is bit-for-bit what fitz build --bin counter --target wasm-client would produce. Added 3 new cli_e2e tests: (a) scaffold shape verification WITHOUT wasm-pack (checks Cargo.toml + src/lib.rs present with the composed wrapper and the wasm-opt knob); (b) .fitz + wasm-client rejected citing 11.5.d; © empty .fitzv (zero components) rejected with a targeted "no component to mount" error. The [[bin]] name = "web" + mount = "#app" path is now the canonical way to emit a browser WASM bundle from a .fitzv. See §9.r. 11.5.d — multi-component composition. CLOSED 2026-07-15 — capitalised template tags (<Card />) now expand to a dedicated ExpandedTemplateNode::ChildComponent { name, props: Vec<ChildComponentProp>, loc } variant. Parser: unchanged (read_tag_name already accepted A-Z...). expand.rs: expand_template_node dispatches on starts_with_ascii_uppercase(tag) to a new expand_child_component helper that enforces the composition shape — self-closing only (fallback children rejected citing 11.6+), static-value attrs only (dynamic prop={expr} rejected citing 11.6+), no event attrs (@click="..." on children rejected citing 11.6+, points at defining the handler inside the child's own event ... block). check.rs: new check_child_components pass builds a HashMap<&str, &ExpandedComponent> snapshot of the file and validates every <Child /> site — child name exists (typo hint via Levenshtein ≤ 3, else full-list fallback), self-reference rejected with a "cannot mount itself" message, each prop's field_name matches a declared state field (typo hint), no duplicate props, and each raw_value coerces via the new pub(crate) fn coerce_child_prop_raw_value(raw, type_expr) -> Result<String, String> helper. Coercion supported for Str/Int/Float/Bool + Nullable<T> of those; compound types (List/Map/Nominal) rejected with 11.6+ pointers. codegen_wasm.rs: (a) emit_mount_and_render split — public mount(selector) now delegates to public mount_into(root: HtmlElement), so composition sites hand pre-created elements directly; (b) new emit_child_component creates a wrapper <div class="__fitz-child-<Name>">, instantiates Child::new(), writes each coerced prop into the corresponding RefCell<T> state field, then calls mount_into on the wrapper; © type_expr_to_rust + default_expr_to_rust extended beyond Int-only to support the four primitive scalars plus Nullable<T> (matches the coercion helper — any type that flows through props is also usable as a state field). Root convention: first-declared component of the .fitzv is the WASM root; parent components mount children via inline <Child /> sites. RenderCtx now carries &'a ExpandedViewFile so the emitter can look up the child's declared state field types when coercing. Smoke harness (tests/view_counter_wasm_smoke.rs) updated with 3 new structural invariants (both mount(selector) and mount_into(root) present, delegation call). emit_component wraps its single input in a synthetic ExpandedViewFile so the pre-existing single-component tests keep working. Counter baseline regenerated with the new mount/mount_into split — functionally identical output. Tests: 6 new expand tests + 16 new check tests + 4 new codegen tests + 1 new smoke test in wasm_build.rs + 2 updated pre-existing (Str state fields now emit successfully; nominal rejection message cites 11.6+ instead of 11.4.c). 321 view tests total, all green. See §9.s. Debt residual: fallback children via <slot> + dynamic props + event bubbling on children, all with targeted 11.6+ pointers in the rejection messages. Also: child state resets on parent re-render (documented consequence of naive-render §9.m D1 — persistent child state across parent renders needs a position-keyed component-instance cache, 11.6+). 11.5.e — cierre formal. CLOSED 2026-07-15 — three coordinated pieces landed together: (a) cosmetic emitter warnings fixed — crate-level #![allow(non_snake_case, unused_parens)] prepended by emit_module_header kills both warnings (§9.o Debt residual) uniformly across the emitted crate; chosen over per-fn attributes because the emitter's naming shape (__inject_style_<PascalCase>_...) is intentional and BinOp parens are correct for nested precedence, only redundant at the outermost RHS. (b) Multi-component showcase fixture examples/view/showcase/ with Dashboard.fitzv (a Board root composing three <MetricCard title="X" value="N" trend="Y" /> children with static Str + Int props), wasm-crate/ scaffold (Cargo.toml + generated src/lib.rs), index.html mount shim, and README documenting build/serve recipe + honest limits (dynamic props / event bubbling / <slot> fill-in / cross-file composition / persistent child state all cited as 11.6+ debt). This is NOT the kanban port the original criterion asked for — that criterion was scope-drifted when §9.p moved SSR to 11.6+ and 11.5.d confirmed dynamic props stay deferred. The showcase is the LARGEST fixture the 11.5.d subset permits and exercises multi-component composition + prop coercion + per-child state end-to-end via fitz::view::compose_lib_rs. Counter baseline regenerated to include the new #![allow] header (functionally identical). © New smoke test tests/view_showcase_wasm_smoke.rs with regenerate_showcase_lib_rs (always runs; keeps the committed baseline in sync + validates 3 <MetricCard /> composition sites + 5 total mount_into calls — 3 from composition + 2 from mount(selector) delegations) and build_showcase_wasm (#[ignore], shells wasm-pack when the toolchain is present). No hard bundle-size assertion — the 40 KB gate was per-component-count-1; re-baselining is documented in §9.t. Closes Phase 11.5 entirely. See §9.t for the full closure narrative + the re-scoped kanban plan for 11.6+. Multi-component showcase fixture (examples/view/showcase/) compiles + validates the end-to-end 11.5.d subset. Kanban port itself re-scoped to Phase 11.6+ (needs dynamic props + event bubbling + persistent child state — see §9.t rationale). Compile times of the fixture: view pipeline <100ms, wasm-pack build --release ~30s cold (cargo build --release + LTO + wasm-opt) — acceptable, no hard gate.
11.6 SSR emitter + migration of fitz-liveviews examples to .fitzv. New view::emit_ssr backend emits classic Fitz source (state type + @render_for fn returning html("""<...>""") + one @on fn per event block, all consumed by the existing fitz-liveviews framework runtime). Module loader detects .fitzv files transparently and runs the view pipeline before handing to classic lexer+parser. Kept isolated: the SSR emitter targets the fitz-liveviews API contract; client-side dynamic capabilities stay in 11.7. Split into five sub-commits: 11.6.a — Research + decision (docs-only, no code). CLOSED 2026-07-15 — SSR emitter approach pinned in §9.u (reconciles §9.t drift; original §6 row 11.6 intent restored: server-side .fitzv targeting fitz-liveviews now, client-side SPA capabilities re-scoped to 11.7). 11.6.b — Skeleton view::emit_ssr on a single-component fixture. CLOSED 2026-07-15 — new module src/view/codegen_ssr.rs (~700 LoC + 20 unit tests) with pub fn emit_module_ssr(&ExpandedViewFile) -> SsrEmitResult<String> + pub fn emit_component_ssr(&ExpandedComponent) entry points. Emits classic Fitz source text targeting the fitz-liveviews framework contract: from fitz_liveviews import Html, html + @live_component("<Name>") type <Name> { <fields> } + @render_for("<Name>") fn <Name>_render(state: <Name>) -> Html { return html("""<html>""") } + one @on("<Name>", "<event>") fn <Name>_<event>(state: <Name>, payload: Map<Str, Str>) -> <Name> per declared event. Two source-to-emit transformations pinned per §9.u: (1) @click="handler" in the template lowers to data-flv-click="handler" in the emitted HTML (fitz-liveviews's client runtime binds data-flv-<event> to WebSocket frames); (2) {field} template interpolation lowers to {state.field} inside the emitted html("""...""") string. Event body lowering: mutations accumulate into a single struct-literal return that carries EVERY declared state field — mutated fields take the assigned RHS, untouched fields carry over from state.<field>. Bare-ident RHS naming a state field rewrites as state.<field> (e.g., a = ba: state.b,). MVP scope guards (all with 11.6.c/d/7+ pointers in the rejection messages): non-literal RHS (BinOp, function calls) deferred to 11.6.c; multi-statement bodies with non-assignment statements deferred to 11.6.c; {#if}/{#for} template directives deferred to 11.6.c; <style scoped> / <style global> deferred to 11.6.c; <Child /> composition deferred to 11.6.d; <slot /> deferred to 11.7+; non-state-field ident in template interpolation deferred to 11.6.c (needs richer expression lowering). Acceptance criterion: the emitted Fitz source round-trips cleanly through classic crate::lexer::tokenize + crate::parser::parse — validated by emit_output_round_trips_through_classic_fitz_lexer_and_parser. See §9.v. 11.6.c — Full event body lowering (multi-mutation → struct literal build), {#if}/{#for} template lowering to string concat, <style scoped> inlined as <style> tag inside HTML output. 11.6.d — Module loader integration + same-file <Child /> composition. CLOSED 2026-07-15 — See §9.y. Loader entry points (src/evaluator.rs::resolve_module_path + src/codegen.rs::ModuleLoader::resolve_path + src/codegen.rs::resolve_loader_import_file_path + src/main.rs::resolve_import_file_path + src/lsp.rs::resolve_import_file_path_lsp) now try .fitz first and .fitzv as fallback (backward-compat: .fitz wins if both siblings exist), driven by two new pub helpers in src/view/mod.rs: is_fitzv_extension(&Path) -> bool and resolve_module_file_candidates(&Path, &str) -> Option<PathBuf>. When a .fitzv is resolved, the loader calls the new pub transform_fitzv_source(source, path) -> Result<String, FitzError> bridge which runs the view pipeline (parse → expand → check → emit_module_ssr) and returns classic Fitz source — the classic lexer + parser + checker + evaluator never see a view-side token. Any pipeline stage error wraps into a single FitzError naming the offending .fitzv path plus the stage that failed. Same-file <Child prop="v" /> composition lands in the SSR emitter: the composition site lowers to Expr("<Child>_render(<Child> { <props> }).raw") and splices the child's rendered HTML into the parent's chain-form html body. Prop coercion follows the 11.5.d subset (Str / Int / Float / Bool / Nullable<T> of a primitive) via the new coerce_child_prop_raw_value_to_fitz_literal helper — a Fitz-literal-returning parallel of 11.5.d's Rust-literal coerce_child_prop_raw_value. Same-file constraint enforced by resolving the child name against the parent's siblings: &[ExpandedComponent] slice; cross-file <Child /> (child in a sibling .fitzv imported into main.fitz) errors with a targeted 11.6.e pointer — that path needs the loader's expanded-file cache threaded through the checker, which is 11.6.e's scope. <slot /> still 11.7+. Auto-inject fitz-liveviews dep is DEFERRED to 11.6.e — the user must declare it in fitz.toml (fitz_liveviews = { path = "..." }); the emit still lists from fitz_liveviews import Html, html, and a missing dep surfaces as the normal classic-loader "module not found" error at the emitted-source stage. The 2-file E2E (main.fitz + Card.fitzv sibling with a broken variant + a shadowing-priority variant) validates the loader routing via fitz run. 7 SSR unit tests + 8 loader-bridge unit tests + 3 cli_e2e tests. 11.6.e — Migrate the 4 fitz-liveviews examples (counter → dashboard → chat → kanban) from raw-string HTML to .fitzv. Sub-step §9.z (2026-07-16) PARTIAL: SSR emitter payload in event-body local scope + fitz_liveviews missing-dep hint + counter migration draft applied to sibling repo uncommitted. Sub-step §9.aa (2026-07-16) PARTIAL: event-body widening — SSR emit_event_fn dispatches trivial vs widened bodies; wide path primes shadow locals + lowers let x = ... (assign to non-state ident) + if(cond){body} guards at stmt level + nested arms with scope truncation on exit; walker accepts Expr::If (single-expr arms) + Expr::StructLit. Unlocks kanban's card_editor_save (let new_text = if(payload.has("text")){payload["text"]} else {text}) and chat's send_message (nested if(payload.has("author")){if(payload.has("text")){...}}) migrations. Trivial-path regression zero (~10 pre-existing tests keep the compact struct-literal shape). +13 SSR unit tests. Remaining: cross-module @live_component auto-inject (removes manual flv_register(...) boilerplate for counter/dashboard/chat/kanban when component types live in imported .fitzv siblings, paralleling W12/B10 pre-scan pattern), cross-file <Child /> composition (§9.y debt, low priority since 4 examples use runtime component(name, id)), migration commits deferred to Fitz v0.21.0 release. Cierre formal. fitz-liveviews's 4 examples (counter/chat/dashboard/kanban) rewritten as .fitzv SFCs; migration is transparent to the framework runtime; each example's server-side behaviour is bit-for-bit equivalent to the pre-migration handwritten version (or documented intentional divergence).
11.7 Client-side dynamic capabilities on top of the 11.5 WASM emit surface — dynamic child props (<Card title={expr} />), event bubbling from children (<Card @select="handler" />), cross-file .fitzv composition, <slot> fallback children, persistent child state across parent re-renders (position-keyed component-instance cache), and the client-side kanban port as the concrete fixture that validates the whole surface. Scope re-set from §6 original (originally LSP support) — LSP moves to 11.8. Deferred here because SSR-first (11.6) delivers more real user value now; client-side SPA capabilities are a Phase 11.7+ story once demand appears. All rejection messages in src/view/ currently pointing at 11.6+ will be updated to point at 11.7+ when 11.6 closes. Kanban port from fitz-liveviews/examples/kanban/ rewritten as a client-side SPA .fitzv (target = "wasm-client") with drag-drop, dynamic card lists, per-column event bubbling. Compiles under a re-baselined bundle size gate (measured on the kanban itself).
11.8 LSP support inside .fitzv — hover over {expr} shows the type, autocomplete inside state { } and event ... bodies, template-attr completion knows about the declared event handlers of the enclosing component. Was originally 11.7; moved to 11.8 by the 11.6.a re-scoping in §9.u. VSCode extension bumped with the new grammar + LSP config; typing {sta inside a template completes to state field name when the component has that field.
11.9 Pedagogic docs — chapter in docs/guide.md covering .fitzv from scratch. Chapter in docs/curso/ (new module M9?) mirroring the pedagogic style of M1-M8. Update docs/architecture.md with the two-parser split. Was originally 11.8; renumbered by the 11.6.a re-scoping in §9.u. Chapter runs someone from zero to a working counter component. Course module has runnable examples. Neither confuses the reader about what's classic Fitz vs what's .fitzv.

Sub-phases are deliberately spaced. 11.2 alone is a multi-session job because it wires the type-checker end-to-end. 11.4 (client target) is the biggest unknown — the WASM vs JS decision is a research task that might turn into a two-week detour.


7. What the POC learned

Running the POC surfaced a few things worth naming:

  • Raw-block capture at the lexer level is the right layout. Emitting Token::TemplateRaw(String) and Token::StyleScopedRaw(String) keeps the shell parser small and lets each sub-parser (HTML, CSS) work on a clean blob. Trying to weave the HTML parser inline with the shell parser would have coupled the two and made error positions ambiguous.
  • The HTML sub-parser is char-by-char, not token-based. HTML syntax has too many edge cases (attribute values with = in them, self-closing vs open tags, quoting rules) to reuse the Fitz token stream. A dedicated pass is smaller, faster, and simpler to reason about.
  • Capturing bodies as raw source strings is not free. Preserving the user's exact formatting for later re-lexing means the parser keeps token gaps intact. The POC uses a append_token_source helper that reconstructs whitespace approximately from the token stream — good enough for the POC's opaque blobs, but 11.2 will need to preserve exact source ranges (probably by tracking char offsets alongside tokens).
  • Attribute value classification (Static vs Interpolation vs Event) at parse time is cheap and clean. Value shape decides everything downstream — the checker only cares about interpolations and events, and the codegen emits totally different code for each. Doing the classification at parse time removes an entire visitor pass later.
  • Duplicate-block detection is worth having early. Users will try <template> twice (once for main, once for a "loading" state), and the current error catches it clearly. 11.2+ may want to accept multiple templates keyed by name (<template name="loading">) as an ergonomic escape hatch.
  • State field type annotations no longer ASCII-limitedCLOSED 2026-07-14 as a view-lexer follow-up right after 11.2.b mini-commit 3. The view lexer now emits Lt, Gt, Question, LBracket, RBracket when they're not opening a <template> / <style scoped> block; the shell parser's capture_raw_until gained bracket-depth awareness ({}, (), [] count as nesting pairs) so a {} map literal default no longer looks like the closing brace of the state { } block. Consequence: List<Str> = [], Map<K, V> = {}, List<Map<K, V>> = [], and Str? = null now round-trip through .fitzv source end-to-end. The direct-construction tests from mini-commits 1+2 stay as coverage of the checker's internal paths; matching source-level tests were added alongside so the parse→expand→check pipeline is proved for each shape (see §9.e). The shell parser's "expected state, event, <template> or <style scoped>" error path still catches <foo/> and other stray < at the top level of a component — just from the parser now, not the lexer.

8. What the POC does NOT prove

  • That the checker will integrate cleanly. Feeding raw source blobs back through crate::lexer::tokenize inside a .fitzv context needs source position remapping so that errors point inside the .fitzv file at the correct offset (not offset 0 of the re-lex). 11.2 will hit this problem and might need to extend crate::lexer::TokenWithPos with a base offset.
  • That the codegen will emit two targets from one AST. The AST is small enough that this looks feasible, but the SSR / client contract is speculation until we write one.
  • That the LSP will work inside .fitzv. Hover / go-to-def currently assume classic Fitz syntax. .fitzv needs its own hover positions and its own scope model.
  • That existing fitz-liveviews users will migrate happily. The refactor of kanban//chat//etc. is at least a few days of work. If the migration hurts, we may want to keep the classic html("""...""") shim alive indefinitely.

9. Files added by 11.1

  • src/view/mod.rs — module declaration + re-exports
  • src/view/ast.rs — SFC AST types
  • src/view/lexer.rs — dedicated tokenizer
  • src/view/parser.rs — recursive parser + HTML sub-parser + hardcoded Card SFC + 12 unit tests
  • docs/fase-11-plan.md (this file)

Delta on src/lib.rs: one line, pub mod view;.

9.a Files added by 11.2.a

  • src/view/expand.rs (~660 LoC) — lowering from the raw view AST to ExpandedViewFile with classic Fitz TypeExpr / Expr / Stmt / Param produced by the classic parser via 4 new pub entry points. 16 unit tests.
  • Delta on src/parser.rs: 4 new pub fn wrappers (parse_expression_from_source, parse_type_expression_from_source, parse_statements_from_source, parse_parameters_from_source).
  • Delta on src/view/mod.rs: two lines to export expand::*.

9.b Files added by 11.2.b mini-commit 1

  • src/view/check.rs (~325 LoC) — type-checker for state field defaults. Synth-and-delegate: builds a synthetic Stmt::Assign per state field and pipes it through crate::types::check_program; remaps every FitzError back to a CheckError with the correct Loc + context label naming the component and field. 16 unit tests, including direct-construction cases for List<T> / Map<K, V> / Str? (deferred to a follow-up view-lexer extension — see §7).
  • Delta on src/view/mod.rs: two lines to export check::*.

9.c Files touched by 11.2.b mini-commit 2

src/view/check.rs grew from ~325 LoC to ~640 LoC (23 new unit tests, total 39 in view::check::tests). No new files; no changes outside src/view/.

New shape of check():

  • Every state field default is still checked in isolation (mini-commit 1 behaviour, preserved).
  • If any state field in a component errors, the component's handler + interpolation checks are skipped (cascade avoidance — see the file's doc-comment). This keeps the output focused on the actual bug instead of piling up consequential errors on every downstream reference.
  • Each event handler body is checked in a synth program built by build_env_program(component, Some(&handler.name), None). The helper emits state fields as annotated lets and every OTHER handler as an empty-body async fn (signatures only) so handler-to-handler calls resolve. The handler being checked is emitted with its full body.
  • Every template interpolation (text nodes AND HTML attribute values) walks through collect_interpolations and gets its own synth program via build_env_program(component, None, Some(<interp assign>)). Every handler is a signature (empty body); the interp expr is bound to a distinct __view_interp_check_N local so check_program populates the returned TypeInfo for the interp span. If TypeInfo::type_at(interp.span()) returns a Type that fails the is_str_friendly allow-list, the check emits a dedicated CheckError citing the unfriendly type.

Additions to the public surface:

  • No new pub API. view::check and view::CheckError re-export paths from 9.b remain unchanged.

Design decisions worth naming:

  • Handlers as signatures inside interpolation env: matches Vue/React template scope. {go} where go is a handler resolves to a Function value; the Str-friendly rule then produces a clear error naming Function as unrenderable. If handlers were absent, users would see "unknown variable go" which is misleading — the handler IS declared, just not displayable.
  • Emit body-less fn signatures for every non-focused handler when checking a specific handler's body. Alternative (pre-registering signatures into a TypeEnv and calling check_with_env) is more surgical but the empty-body approach is simpler and produces identical results. Empty bodies with -> Null inference type-check cleanly.
  • The Str-friendly allow-list is intentionally generous (accepts List/Map/Tuple/Nominal/Nullable-of-friendly-inner) because the codegen will emit format!("{}", value) at the interpolation site, and every listed type has an auto-Display impl. The block-list (Function, Result, Future, WsConn, DbConn/DbRow, QueryBuilder, Aggregated, Secret) captures the cases where the display would be wrong or the type is deliberately opaque (Secret<T> redacts).

Verification (delta at 11.2.b mini-commit 2 over mini-commit 1's baseline of 3273 unit + 39 view::check): cargo test --lib green (3312 total, 39 in view::check::tests), cargo test --lib --features lsp green (3448 total), cargo test --test cli_e2e --release (101/101), cargo test --test openapi_e2e --release (3/3), cargo fmt --all --check, cargo clippy --lib --tests --bins -- -D warnings. The GUIDE_EXAMPLES_COMPILE smoke (~290 ejemplos guía+curso+TaskHub, ~7 min) remains green — no regression outside src/view/.

No other files touched by 11.1 / 11.2.a / 11.2.b mini-commits ½.


9.d Files touched by 11.2.b mini-commit 3 — closes 11.2.b entirely

src/view/check.rs grew from ~640 LoC to ~825 LoC (11 new unit tests, total 50 in view::check::tests). No new files; no changes outside src/view/; no changes to the classic pipeline.

New shape of check():

  • State field defaults still checked in isolation (mini-commit 1 behaviour, preserved).
  • Every @event="handler" attr in the template walks through collect_event_attrs (parallel to collect_interpolations) and gets compared against the component's declared handler set. Broken references produce a CheckError with a context like "component 'Card': template event attr '@click'". The cross-ref pass runs independently of state validity — it doesn't route through the classic checker and doesn't depend on state field types, so a component with a broken state field AND a broken @click="undeclared" surfaces both errors together instead of hiding the second behind cascade avoidance. Cascade avoidance still applies to handler bodies + interpolations for the same reason it did in mini-commit 2.

Additions to the public surface:

  • No new pub API. view::check and view::CheckError re-export paths from 9.b remain unchanged.

Design decisions worth naming:

  • Cross-refs run even when state has errors. Reasoning: the event-attr check is a pure structural comparison — no check_program call, no type resolution. State errors don't contaminate it and hiding the second category would confuse users iterating on a template.
  • "Did you mean ...?" hints via Levenshtein distance ≤ 3. Threshold chosen to catch realistic typos (statstart, saevsave, stratstart) without suggesting unrelated matches on longer identifiers. When the declared set is small (≤ 5) and no near-miss exists, the error lists every available handler so users don't have to open another file.
  • Zero declared handlers is its own message. Component with @click="save" but no event ... declarations at all gets a distinct message ("this component declares no event ... handlers") instead of an awkward empty available list.
  • Levenshtein inlined, not a crate. Standard two-row DP, O(a·b) time, O(min(a,b)) space. Handler names are short (< 30 chars typical) and per-template counts are small, so performance is trivially fine. Pulling in strsim (~30 KB compiled) would be overkill.

Verification (delta at 11.2.b mini-commit 3 over mini-commit 2's baseline of 3312 unit + 39 view::check): cargo test --lib green (3323 total, 50 in view::check::tests), cargo test --lib --features lsp green (3459 total), cargo fmt --all --check, cargo clippy --lib --tests --bins -- -D warnings, cargo clippy --lib --tests --bins --features lsp -- -D warnings. Full cargo test suite from the "Fitz core — full test suite tras cada batch" memory (cli_e2e, openapi_e2e, compile_e2e, python feature when the local shim resolves) recommended before tagging a release that includes this change; deferred here since this is an internal-to-src/view/ addition with no user-facing surface yet (the .fitzv compilation entry point is still gated behind Phase 11.5).

No other files touched by 11.1 / 11.2.a / 11.2.b mini-commits ½/3.


9.e Files touched by view-lexer §7 follow-up (closes the state-annotation ASCII debt)

Small follow-up mini-commit right after 11.2.b mini-commit 3. Its only job is to destrabar List<T> / Map<K, V> / Str? / List<T>? shapes at the source level — the checker already handled them internally, but the raw-blob capture in the shell parser stopped short because the lexer errored on the first <.

Changes:

  • src/view/lexer.rs — 5 new Token variants (Lt, Gt, Question, LBracket, RBracket) with Display and dispatch. The <template> / <style scoped> block detection stays first in run(); a < that doesn't open one of those blocks falls back to Token::Lt. > / ? / [ / ] get their own match branches. 4 new unit tests + the pre-existing unknown_lt_at_top_level_is_error was renamed and repurposed to assert the new behaviour (lexer accepts, parser rejects).
  • src/view/parser.rsappend_token_source learned the 5 new tokens; needs_space_before puts Lt/Gt/Question/LBracket/RBracket in the "no space before" list so List<Str> and xs[0] reconstruct verbatim. capture_raw_until grew a bracket-depth counter ({}, (), [] count) so a {} map literal default no longer looks like the closing brace of the state { } block. </> are NOT counted — a < in a state default context could be a comparison operator (count < 5), so tracking it would confuse those cases.
  • src/view/check.rs — 7 new source-level unit tests mirroring the pre-existing direct-construction ones from mini-commits 1+2: Str? = null, Str? = "hello", List<Str> = [], List<Int> = ["nope"] (mismatch), Map<Str, Int> = {}, nested List<Map<Str, Int>> = [], and List<Str>? = null (Nullable wrapping a generic — exercises Question right after Gt). Direct-construction variants stay as coverage of the checker's internal paths.

Verification (delta at view-lexer §7 over 11.2.b mini-commit 3's baseline of 3323 unit + 50 view::check): cargo test --lib green (3334 total, 57 in view::check::tests, 11 in view::lexer::tests), cargo test --lib --features lsp green (3470 total), cargo fmt --all --check, cargo clippy --lib --tests --bins -- -D warnings, cargo clippy --lib --tests --bins --features lsp -- -D warnings.

Deuda residual (does NOT block 11.2.c):

  • . in state defaults. The view lexer still rejects . at the top level of a state default (Float literals like 0.5, field access chains like env.NAME, method calls). The state_field_int_to_float_coerces_no_errors test uses = 0 (an Int coerced to Float) because = 0.5 won't lex today. Fix scope for a next mini-commit if demand appears: emit Token::Dot and add it to the "no space" list in needs_space_before.
  • / in the top level of a component body. Non-issue in practice — / only shows up inside <template> / <style scoped> raw blocks (where it never reaches the shell lexer) or inside default expressions as a division operator (where the classic parser handles it after expand). Documented because the retired lexer test unknown_lt_at_top_level_is_error used <foo/> and had to be rewritten to <foo to isolate the new Lt behaviour from the still-existing "unexpected /" error.
  • Struct literal defaults with explicit generic args (e.g. Result<Str, Str>::Ok("x") if we ever add such syntax). The view lexer would tokenize it but capture_raw_until's balance counter does NOT track </> — a > outside any {}/()/[] at depth 0 still wouldn't confuse the current stop set, but if we grow syntax that needs generic tracking in defaults, revisit.

No other files touched by 11.1 / 11.2.a / 11.2.b mini-commits ½/3 or the §7 follow-up.


9.f Files touched by 11.2.c mini-commit 1 — {#if cond}...{/if}

First of the three mini-commits inside 11.2.c. Adds {#if} end-to- end (raw AST + HTML sub-parser + expand + checker) without touching mini-commits 2 ({#for}) or 3 (<slot>) — those will land separately because they raise independent design questions ({#for} needs a scoped binding for the loop variable; <slot> is mostly a marker until 11.5 wires component composition).

Files touched:

  • src/view/ast.rs — new TemplateNode::If { cond_raw, children, loc } variant. Doc-comment updated to name mini-commits ⅔ for the remaining directives.
  • src/view/parser.rs — HTML sub-parser refactor:
  • parse_nodes(&mut self, parent, directive_parent) gained a second parent-tracking parameter (directive_parent: Option<&str> holds the name of the enclosing {#...} block, if today). Elements and directives nest orthogonally: an element inside an {#if} and vice versa both walk uniformly.
  • Dispatch inside parse_nodes when the next char is {: # → new parse_directive_open (parses cond raw + recurses for children up to {/name}); / → close directive (validate match against directive_parent); otherwise → the existing parse_interpolation.
  • parse_directive_open restricts to if today; for is rejected with a message pointing at 11.2.c mini-commit 2, so the user knows it's planned, not a bug.
  • capture_directive_arg_raw reads the cond up to the matching } with brace-depth counting so a struct or map literal inside the cond doesn't terminate early.
  • read_directive_name, skip_ws_inline — small char-by-char helpers matching the shape of the existing read_tag_name.
  • 9 new unit tests: happy-path capture, nested braces in cond, element inside If, If inside element, nested If inside If, unterminated opener, mismatched closer, closer without opener, unknown directive name.
  • src/view/expand.rs — new ExpandedTemplateNode::If { cond, children, loc } variant. expand_template_node gains an arm that parses cond_raw via parse_expr_at with a component 'X': template \` conditioncontext label, then recurseschildren. 4 new unit tests: cond parses asExpr::Ident, cond parses as BinOp, bad cond syntax producesExpandError` with correct context, children expand recursively (interpolation inside If).
  • src/view/check.rs — new pass check_template_if_conds + helper collect_if_conds + struct IfCondRef:
  • Each {#if} cond gets its own synth-and-delegate check parallel to interpolations: build the state-seeded env, bind the cond to __view_if_cond_check_N, run check_program, then check TypeInfo::type_at(cond.span()) against is_bool_compatible (accepts Bool, Any, PyAny, Nullable<Bool/Any/PyAny>).
  • collect_interpolations and collect_event_attrs grow an If { children, .. } arm so interpolations + event attrs inside {#if} bodies get checked by their existing passes — no separate walk.
  • Runs inside the existing cascade-avoidance guard (skipped when state has errors, like handler bodies + interpolations).
  • 9 behavior tests (Bool state, BinOp Int > Int, non-Bool reports, undefined ident, interp inside body, event attr inside body, Bool? accepted, Int not accepted, nested If conds each checked) + 2 helper unit tests (is_bool_compatible accepts/rejects).

Design decisions worth naming:

  • Bool-compatibility instead of strict Bool. Accept Bool, gradual escapes (Any, PyAny), and Nullable<Bool/Any/PyAny>. The classic if in the checker has the same posture — see types.rs. Rejecting Int explicitly means the user must write count > 0, avoiding JS-style truthiness surprises.
  • Two independent parent trackers in parse_nodes. An enum (ParseCtx::Root | Element(&str) | Directive(&str)) would be slightly more idiomatic but the two axes are truly orthogonal and the pair of Option<&str> reads clearly. Preserving the original parent: Option<&str> shape also kept the diff to parse_element a single-line change.
  • Directive dispatch inside parse_nodes, not parse_interpolation. {#if} looks like an interpolation opener but it isn't — it opens a new node type, not an expression. Rerouting at the parse_nodes layer keeps parse_interpolation focused on its original job (single {expr} node) and mirrors how HTML tags vs <template>/<style> are distinguished at the view-lexer layer.
  • Cascade avoidance still applies to {#if} cond checks. Reason: cond checks route through the classic checker (like handler bodies + interpolations) and depend on state field types. When state is broken, running conds would flood the output with "undefined variable X" errors for every state field the cond references. Users fix state, then re-run — same ergonomics as mini-commits 2 and 3.

Verification (delta at 11.2.c mini-commit 1 over view-lexer §7's baseline of 3334 unit + 96 view tests): cargo test --lib green (3358 total, 68 in view::check::tests, 20 in view::expand::tests, 21 in view::parser::tests), cargo test --lib --features lsp green (3494 total), cargo fmt --all --check, cargo clippy --lib --tests --bins -- -D warnings, cargo clippy --lib --tests --bins --features lsp -- -D warnings.

Deuda residual (does NOT block 11.2.c mini-commit 3):

  • {#else} branch. Not modelled today — a {#if} has one set of children, no else path. Re-scoped from mini-commit 2 to mini-commit 3 when it became clear that adding {#else} independently of {#for} keeps commits bisectable. Mini-commit 3 will fold {#else} alongside <slot> since both extend the template AST without changing scope semantics.
  • Truthiness on Nullable<Bool> is deliberately loose. We accept Bool? as a cond but don't force the user to unwrap the null case explicitly. Same posture as classic if; may tighten later if lint feedback demands it (e.g. "always compare Bool? to null or true").
  • is_bool_compatible for Result<Bool, _>. We reject it — the user must match or ? first. Same rule as classic Fitz.

No other files touched by 11.1 / 11.2.a / 11.2.b mini-commits ½/3 or the §7 follow-up. docs/fase-11-plan.md gets the row update in §5 and this section.


9.g Files touched by 11.2.c mini-commit 2 — {#for x in xs}...{/for}

Second of the three mini-commits inside 11.2.c. Adds {#for} end-to-end (parser + expand + checker) parallel to {#if} in mini-commit 1, plus the scope-tracking refactor of the checker that mini-commit 1 promised. Same isolation posture as before — fitz check my.fitzv remains the ONE public entry to the view pipeline, and the classic crate::parser::parse() is untouched.

  • src/view/ast.rs
  • TemplateNode::For { var, iter_raw, children, loc } — new variant paralelo a If. var is a bare String (single identifier — compound patterns and index bindings deferred). iter_raw is the trimmed raw source text between in and the matching }; expand reparses it as fast::Expr.
  • Doc comment on the TemplateNode enum updated: {#else} moves from "deferred to mini-commit 2" to "deferred to mini-commit 3", and {#for} moves from deferred to supported.

  • src/view/parser.rs

  • parse_directive_open dispatches on the directive name: ifparse_if_directive (extracted from the previous inline body), forparse_for_directive (new), other → error with mention of both supported directives + 11.2.c mini-commit 3 for #else and <slot>.
  • parse_for_directive reads a bare identifier, expects the literal keyword in (via new consume_keyword_in helper with a word-boundary check so interior_var doesn't match), then reuses capture_directive_arg_raw (brace-depth aware, same as {#if}'s cond capture) to grab the iter expression. Recurses parse_nodes with directive_parent = Some("for") for the body. Targeted error messages for: missing var identifier, missing in, empty iter, unterminated {#for}.
  • Added 10 unit tests (template_for_block_*): basic shape, complex iter with nested braces, nested inside <ul>, nested <div> inside for, nested {#for} + {#if} in various interleavings, unterminated for, mismatched close, missing var / missing in / empty iter, and the corner case {#for in in xs} (binding named in — legal but ugly).

  • src/view/expand.rs

  • ExpandedTemplateNode::For { var, iter, children, loc } — new variant.
  • expand_template_node gains a match arm that reparses iter_raw as fast::Expr with context label "templateiter", then recurses children.
  • Added 4 unit tests (expand_for_block_*): iter parses as classic Expr, method-chain iter parses, malformed iter produces ExpandError with the correct context label, children expanded recursively.

  • src/view/check.rs

  • New pass check_template_for_iters runs as the third template pass (after handler bodies + before interpolations and if conds), invoked from check(). Iterates every {#for x in iter}, synthesises for <var> in <iter> { }, and lets the classic Stmt::For checker resolve iterable types + reject non-iterables (theforiterable must be List, Range or Map, received ...). Errors are shifted to the block's Loc with context "component 'X': templateiter".
  • New helper types + refactor: ForBinding<'a> { var, iter } captures one enclosing {#for} binding; wrap_stmt_in_for_scope envuelve el extra stmt in a chain of Stmt::For { ... } outer-most to inner-most; build_env_program takes a fourth param for_scope: &[ForBinding] and applies the wrap. Reads: when an interp / if-cond / nested for-iter lives inside one or more {#for} blocks, the synth program mirrors the source's real scoping so the classic checker walks the fors, resolves each binding's type, and enters the innermost body with all bindings visible.
  • Refactor of the 3 collectors: collect_interpolations, collect_if_conds, collect_for_iters (new) all take for_scope: &mut Vec<ForBinding<'a>> and push/pop on enter/leave of For children. Each ref type (InterpolationRef / IfCondRef / ForIterRef) carries a for_scope: Vec<ForBinding<'a>> snapshot so the check-time synth sees the exact enclosing chain. collect_event_attrs recurses into For children without scope (event attrs cross-ref only names, don't need bindings).
  • Added 11 unit tests (for_block_*): List / List / Range binding types, non-iterable (Str) rejected with the classic message shifted to the iter context, var visible inside body interp (text and attr), var invisible after {/for}, {#if} cond sees the for binding, nested for iter references the outer binding via nominal field access (state-error cascade documented for the case where the nominal isn't declared), inner iter with a bad method on the outer binding's type errors at the iter context, Map iter binds Tuple which is Str-friendly, method chain iter typechecks.

Design decisions taken at kick-off:

  • Solo binding x bare (sin (x, i) con index, sin (k, v) para Map). El clásico HOY no tiene index binding, y metering asymmetric syntax at the template level lo divergiría del lenguaje. (k, v) para Map con Pattern::Tuple es paralelo del clásico y refinable en un mini-commit dedicado si aparece demanda. El MVP acepta Map<K, V> bindeeando a Tuple[K, V] (heredado del clásico) — sigue siendo Str-friendly vía auto-Display, así que {kv} renderiza sin error, aunque awkward.
  • Delegación total al classic checker para tipar el binding
  • rechazar no-iterables. Cero lógica de tipos nueva en view/check.rs. La única regla propia es "el iter tiene su propio contexto de error" — el mensaje se shifta al bloque {#for} correcto.
  • Scope tracking en los 3 collectors — necesario para que interps y {#if} conds nested vean el binding. El costo es .clone() del Vec<ForBinding> en cada ref, insignificante en la práctica (templates casi nunca anidan más de 2-3 fors).
  • {#else} diferido a mini-commit 3 — mini-commit 1 dejó la promesa de "revisitar con mini-commit 2", pero mezclar {#for} + {#else} en el mismo commit rompe bisect. Mejor cerrar {#for} limpio y luego mini-commit 3 folds {#else} alongside <slot> (ambos extienden el template AST sin cambiar semántica de scoping).

Verification (delta at 11.2.c mini-commit 2 over mini-commit 1's baseline of 3358 unit + 3494 with --features lsp): cargo test --lib green (3383 total, 79 in view::check::tests, 24 in view::expand::tests, 31 in view::parser::tests), cargo test --lib --features lsp green (3519 total), cargo fmt --all --check, cargo clippy --lib --tests --bins -- -D warnings, cargo clippy --lib --tests --bins --features lsp -- -D warnings.

Deuda residual (does NOT block 11.2.c mini-commit 3):

  • {#for (k, v) in m} compound patterns. No modelado hoy — el HTML sub-parser solo lee una identifier bare tras #for. El clásico ya soporta Pattern::Tuple para iterar Maps con (k, v); llegar al template exige extender el sub-parser para leer un mini-Pattern. Refinable en un mini-commit dedicado si aparece demanda pedagógica real (hoy el workaround es {#for kv in m}<li>{kv}</li>{/for} que sale como ("clave", valor) vía Tuple auto-Display).
  • {#for (x, i) in xs} index bindings. El clásico HOY no tiene esto tampoco. Cualquier extensión al template debería aterrizar en paralelo con la del clásico para no divergir conceptualmente. Diferido sin fecha.
  • Codegen SSR/client del {#for}. Todavía es solo parser + checker; el evaluator / codegen no consume ExpandedTemplateNode::For — llega en 11.4/11.5 con el client target decidido. El checker cierra el ciclo de "el usuario escribe {#for} y ve errores útiles si se equivoca", pero el template no renderiza aún.
  • Refinar el error del iter no-iterable con hint del .iter()/.entries() idioms. Hoy sale el mensaje bruto del clásico. Aceptable — el mensaje es correcto y accionable.

No other files touched by 11.1 / 11.2.a / 11.2.b mini-commits ½/3 or the §7 follow-up or 11.2.c mini-commit 1. docs/fase-11-plan.md gets the row update in §6 and this section.


9.h Files touched by 11.2.c mini-commit 3 — <slot /> + {#else}

Third and last mini-commit inside 11.2.c. Closes 11.2.c entire — the template dialect now has the four directives promised for Phase 11.2 ({#if}, {#for}, {#else}, <slot>) end-to-end, and 11.2 itself is done. Two independent extensions folded into one commit because they both add template-AST variants without changing the scoping semantics that mini-commits 1 and 2 already paid for.

  • src/view/ast.rs
  • TemplateNode::If gained a new field else_children: Option<Vec<TemplateNode>>. None when the block is {#if}...{/if}; Some(vec![...]) when the block has {#else} (with an empty vec when the else branch is empty). Doc-comment on the If variant updated to describe the extended shape.
  • New variant TemplateNode::Slot { name: Option<String>, loc: Loc }. name is None for the default (unnamed) slot, Some("X") for a named slot. Opaque marker — the tree carries it through expand + check but the semantic wiring (cross-check against child components' declared slots) lands in Phase 11.5.
  • The enum-level doc updated: {#else} and <slot /> move from "deferred to mini-commit 3" to "supported"; the "Deferred to later mini-commits" list now names <slot>...</slot> fallback children and {#elseif} chains as the two follow- up refinements (neither blocks 11.3+).

  • src/view/parser.rs

  • parse_nodes signature refactor: gains an accept_else: bool param and returns (Vec<TemplateNode>, bool) where the second bool is true when the walk terminated on {#else} (only possible when accept_else was true). All four callers updated — three ignore the bool via let (nodes, _), one (parse_if_directive) matches on it.
  • parse_if_directive parses the then-body with accept_else = true; if the walk terminated on {#else}, it parses the else-body with a second call at accept_else = false (so a stray second {#else} inside the else branch is caught cleanly by parse_directive_open's new "else" arm).
  • parse_directive_open grew an "else" arm that emits "unexpected— must appear inside anbody, and only oneperis allowed". This covers both stray top-level {#else} and double {#else} inside an else branch, since neither case ever reaches parse_nodes' accept_else intercept.
  • Two new helpers on HtmlParser: peek_directive_name(&self) -> Option<String> reads the identifier immediately after {# WITHOUT consuming — used by parse_nodes to distinguish {#else} (an if-body terminator) from a regular directive opener; consume_else_marker(&mut self) -> ViewParseResult<()> consumes the six chars of {#else} and the closing }, validating brace balance with a clear error on the missing }.
  • parse_element gained two special cases for <slot>. In the self-closing branch (.../>), a slot tag hands off to a new free helper build_slot(attrs, line, column) which accepts zero or one Static { name: "name", value } attr and rejects everything else (extra static attrs, interpolated attrs, @event bindings, duplicate name) with targeted messages pointing at 11.5 for the richer slot APIs. In the open-close branch (<slot>...</slot>), the tag is rejected with the "fallback children not supported yet" message pointing at 11.5.
  • The mini-commit-2 "unknown directive" error message updated: it now names {#if}, {#for}, and {#else} as the supported directives rather than pointing at mini-commit 3 (which no longer exists — this IS mini-commit 3).
  • 14 new unit tests: three <slot> shape tests (default slot, named slot, slot inside an element), four <slot> rejection tests (open-close form, extra static attr, event attr, interpolated name), one duplicate-name rejection, one basic {#if...#else...} shape test, one regression that {#if} without {#else} produces else_children = None, one legal {#else}{/if} (empty else body), one nested-if scoping test (each if scopes its own else, outer's else doesn't leak into inner and vice versa), two {#else} rejection tests (double else inside one if, stray top-level else), and one updated error-message test that names the supported directives.

  • src/view/expand.rs

  • ExpandedTemplateNode::If mirrors the raw AST — gains else_children: Option<Vec<ExpandedTemplateNode>>.
  • New ExpandedTemplateNode::Slot { name, loc } variant — the expand pass copies the raw name verbatim (nothing to re-parse, the slot is opaque here).
  • expand_template_node gained a match arm that recursively expands else_children when present. Slot arm is a leaf.
  • 5 new unit tests: slot default expands with name = None; slot with name preserves it through expand; if/else with interpolations on both sides recurses through both branches; if without else preserves else_children = None; a bad interpolation inside the else branch reports the correct expand context (proves the recursion).

  • src/view/check.rs

  • All four collectors (collect_if_conds, collect_for_iters, collect_interpolations, collect_event_attrs) grew a match arm for Slot { .. } (leaf — skipped) and extended the If { ... } arm to recurse through else_children when present. The scoping logic is unchanged: else branches see the same for_scope chain as then branches, because {#else} doesn't introduce new bindings (bindings come from {#for} and state fields).
  • 7 new unit tests: interpolation inside else branch is checked (proves collect_interpolations walks in); event attr inside else is cross-checked; nested {#if} cond inside else is checked; {#for} iter inside else is checked; regression that plain {#if} (no else) still checks the then branch; slot without other content produces zero errors; slot mixed with interpolations + events + if/else still produces zero errors when the rest is well-formed.

Design decisions taken at kick-off:

  • name on <slot> is optional. <slot /> maps to the default (unnamed) slot; <slot name="X" /> to a named slot. Matches Vue/Svelte/Web Components convention. Making name mandatory would confuse users coming from those ecosystems and would require a synthetic name for the default slot ("default"?) that the compiler would have to invent — brittle.
  • Only self-closing <slot /> today. <slot>...</slot> with fallback children is deferred to 11.5 (composition wiring), where the actual semantics of "use the child's slotted content if any, else render this default" become meaningful. Today the slot is an opaque marker — accepting fallback children would look like it works when nothing consumes them. Cleaner to reject up front with a message that names 11.5.
  • accept_else: bool on parse_nodes vs a dedicated parse_if_body. The bool + (nodes, terminated_by_else) return is ~30 LoC leaner than duplicating the parse_nodes loop for a specialised if-body helper. All four call sites update trivially (let (nodes, _) = ... for the callers that don't accept else; only parse_if_directive matches on the terminator). The alternative was cleaner conceptually but bulkier in code, and this file already does the same kind of orthogonal parent-tracking trick with parent and directive_parent.
  • {#else} sentinel intercepted in parse_nodes when accept_else is true, dispatched as targeted error by parse_directive_open otherwise. Two paths reach a {#else} in the source: legitimately as an if-body terminator, or illegitimately (stray at top level, or second {#else} inside an else branch). The first path is handled by the intercept before it ever reaches parse_directive_open; the second path always ends up in parse_directive_open with the name "else" — so a dedicated "else" arm with a friendly message covers both illegitimate cases at once. peek_directive_name + consume_else_marker are two tiny helpers that keep the intercept clean.
  • Slot handling folded into parse_element rather than a separate top-level pass. <slot /> is syntactically an element — same tag+attrs+self-closing shape — so the cleanest place to intercept is right where parse_element decides whether to construct an Element variant. The conversion happens inside the self-closing branch (using the collected attrs) and the open-close branch rejects immediately. build_slot is a free helper (no self) so it reads clearly at file scope near extract_full_interp.
  • else_children: Option<Vec<...>> in the AST vs a flattened representation. The Option shape matches the source: {#if}...{/if} has no else branch, {#if}...{#else}...{/if} does. Turning that into e.g. arms: Vec<(cond, children)> or else_children: Vec<...> (empty for the no-else case) would gloss over a meaningful semantic distinction. {#elseif} chains, when they come, would extend this to Vec<(cond, children)> + else_children: Option<...> (Svelte-style) — clean upgrade path.
  • All four collectors recurse into else_children. Interpolations, {#if} conds, {#for} iters, and @event="handler" refs inside the else branch all get checked — no special treatment. The for_scope chain used by the interpolations / conds / iters walks unchanged into the else, because {#else} doesn't introduce new bindings. Slot is a leaf in every collector (no expressions to check, no cross-refs to make).

Verification (delta at 11.2.c mini-commit 3 over mini-commit 2's baseline of 3383 unit + 3519 with --features lsp): cargo test --lib green (3411 total, 173 in view::* — 66 in parser, 25 in expand, 79 in check plus lexer + Loc tests), cargo test --lib --features lsp green (3547 total), cargo fmt --all --check clean, cargo clippy --lib --tests --bins -- -D warnings clean, cargo clippy --lib --tests --bins --features lsp -- -D warnings clean.

Deuda residual (does NOT block 11.3+):

  • <slot>...</slot> with fallback children. Deferred with a targeted rejection message that names 11.5. When composition wiring lands, the fallback-children form becomes semantic: "render this default if the parent provides no slot content". Refinable then.
  • <slot> cross-reference against child components' declared slots. Purely a marker today — no component knows what slots its callers reference or vice versa. 11.5 (composition wiring) is when the parent-child relation exists as an AST concept; cross-checking rides that.
  • {#elseif cond} chains. Fitz stays at just {#if} and {#else} for MVP. {#elseif} is a shorthand for {#else}{#if ...}{/if} — nice to have, refinable behind demand. When it lands, the TemplateNode::If shape probably grows a Vec<(cond, children)> for chained branches + the existing else_children for the final catch-all.
  • Slot props / defaults / scoped bindings. Vue and Svelte both let slots forward data down to their content (<slot :prop="X" />). Deferred to 11.5+ where the composition model is real. Today <slot> accepts only the name attribute and rejects everything else with a targeted 11.5 message.
  • Position mapping inside the raw blob for {#else} and <slot>. Same debt as the rest of 11.2 — positions are approximate (blob-local + best-effort base). Precise offset tracking still deferred to a dedicated commit.

No other files touched by 11.1 / 11.2.a / 11.2.b mini-commits ½/3 or the §7 follow-up or 11.2.c mini-commits ½. docs/fase-11-plan.md gets the row update in §6 and this section.


9.i Files touched by 11.3.a — <style global> syntax + StyleKind enum

First of the three planned mini-commits inside 11.3. Opens the unscoped-style story with the smallest possible reversible change: a new sibling opener <style global> next to the existing <style scoped>, plus a StyleKind { Scoped, Global } discriminant on the AST + lexer token. Zero scoping is applied yet — that lands in 11.3.b (CSS mini-parser) and 11.3.c (expand wiring). The purpose of this mini-commit is to nail the surface syntax first so the CSS parser + expand can be written against a shape that won't shift.

Files touched:

  • src/view/ast.rsStyle gains a kind: StyleKind field; new enum StyleKind { Scoped, Global } next to it. Doc-comment on Style rewritten to describe both shapes; doc-comment on StyleKind documents the semantics of each variant (Scoped gets class-prefix rewriting in 11.3.c, Global emits as-is). Both types stay pub inside view::ast; no re-export at view::mod's pub use list because no external consumer needs them yet.
  • src/view/lexer.rsToken::StyleScopedRaw(String) refactored to Token::StyleRaw { kind: StyleKind, body: String }. The refactor was preferred over adding a parallel Token::StyleGlobalRaw variant because the two shapes share the same closer (</style>) and the same "opaque body" contract — distinguishing them as a payload field reads clearer than as two variants that would always be matched together. Detection in run() adds a <style global> branch parallel to the existing <style scoped> one; both delegate to the refactored consume_style_block(kind, opener, ...) helper that now takes the opener as a &'static str (so the "unterminated" error message reproduces the exact tag the user typed). Bare <style> or <style anything-else> is rejected at lex time with a targeted error naming both accepted forms — Vue defaults to global, Svelte to scoped, and Fitz refuses to pick a silent default. 5 new unit tests: <style global> captures body, <style> rejected, <style foo> rejected, unterminated <style scoped> names the scoped opener, unterminated <style global> names the global opener.
  • src/view/parser.rs — the match on Token::StyleScopedRaw updated to Token::StyleRaw { .. } with a subsequent destructure to extract both kind and body. Duplicate detection widened: any second <style> block regardless of kind produces duplicate