ADR-0049 — The typed Fragment substrate is the universal render path; the legacy direct-template layer is retired mode-by-mode¶
Status: DELIVERED (2026-06-29) — all four surface modes migrated; the render is None direct-template fork is gone. Phase 1 (LIST) SHIPPED (v0.92.15–0.92.18): list surfaces render exclusively via the typed substrate (skeleton+hydrate); the legacy render_filterable_table is deleted. Phase 2 (VIEW) SHIPPED (v0.92.20–0.92.25): detail (mode: view) surfaces render exclusively via the substrate (fragment_adapter._build_view, field values through the shared _render_cell_display cell core, related-groups reproduced, peek content-only); the legacy render_detail_view / detail_renderer.py is deleted and the #1297 custom-VIEW delegation moved to render_generic_detail. Phase 3 (CREATE/EDIT) SHIPPED (v0.92.26–0.92.31): forms render exclusively via the substrate. 3a ported all 9 form widgets to typed primitives at parity (search_select, money, combobox, tags, picker, color, slider, rich_text, stepper — each emitting the existing client-controller mount attrs); 3b flipped the dispatch gate (review-caught + fixed 5 common-case-field regressions) then deleted form_renderer.py (~806 LOC, + dead multi_select/date_range). The pure FieldContext→primitive mapping moved to the render layer (render/fragment/form_field.py) so the page-layer experience form renderer reaches it (page → render). The typed Fragment substrate is now the universal server-side render path.
Builds on: ADR-0023 (typed Fragment emission — created the substrate), ADR-0038 (render/ is pure — relocated rendering into the substrate layer), ADR-0048 (#1505 — converged the list row engine onto render_data_row). Completes the de-Jinja migration (#1042) at the level of render-path shape, not just template syntax.
Origin: the #1494/#1505 work surfaced that production runs 100% on a legacy render path while the typed substrate sits unadopted.
Context¶
Dazzle has two parallel server-side render paths for every surface mode (list, view, create, edit), forked in page_routes.py::_maybe_dispatch_inner_html on surface.render is None:
- Legacy "direct-template" path —
page/runtime/{table_renderer,detail_renderer,form_renderer}.py(~2,322 LOC). f-strings since #1042, but its shape — a separate per-mode renderer, the skeleton+hydrate table, the hand-built<dt>/<dd>detail, the wrapper-less form — is a fossil of the Jinja era. - Typed Fragment substrate —
FragmentSurfaceAdapter+ therender/primitive renderers. Pure, composable, serverless-testable; the intended future per ADR-0023.
The inversion: across all example apps, zero surfaces set render:, so production runs 100% on the legacy path, and the substrate is built but unadopted (only test fixtures exercise it). The deprecated-shaped path is everything; the modern path is dead code in practice. Every agent operating the codebase must hold both models, and new behaviour keeps accreting on the fossil — the classic early-choice ossification (cf. the SQLite-before-ADR-0008 difficulty). For all four modes the two paths emit different DOM/CSS, and the legacy form path is actively decaying (it lacks a submit button, #1291, which the substrate fixes).
ADR-0048 already converged the list row engine: the legacy list path's rows hydrate via /api → render_data_row (dz-tr-row), the same core the substrate uses. So the remaining fossil is the chrome (table/detail/form wrappers + the dzTable mount + the skeleton), not the rows.
Decision¶
The typed Fragment substrate is the single, universal server-side render path. The legacy direct-template renderers are deprecated and deleted mode-by-mode as each mode reaches parity. After migration there is one render model: AppSpec → Fragment primitives → HTML.
Load-bearing decisions (rationale in the design spec docs/superpowers/specs/2026-06-29-substrate-universal-render-path-design.md):
- Visual parity, not byte parity. The substrate's DOM/CSS becomes canonical; we do not contort it to byte-match the legacy output. Each mode's goldens are re-baselined deliberately and verified for visual + a11y + card-safety parity. (Byte-matching a fossil would import its shape — the opposite of the goal.)
- List keeps skeleton+hydrate;
render_data_rowstays the sole row source. The substrate list path emits chrome + an empty<tbody hx-trigger="load">pointing at/api— so rows always come fromrender_data_row(ADR-0048), the substrate only needs chrome parity, and fast time-to-first-paint is preserved. Inline-rows remains a future per-surface option, not the default. - The
dzTablecontroller mounts on the list Region (the container that knows it's a stateful list), with the config (sort/bulk/inline/columns) threaded by the substrate builder. Refresh rows continue to assume the mounted controller. - No silent legacy fallback after retirement. The current default-deny fallback (substrate error → legacy) is a migration-only crutch; once a mode's legacy renderer is deleted, substrate errors surface as an error response. The substrate must be robust before each delete.
- Default flips per mode. Migration order: list → view → create/edit (list is closest — rows already converged). Per mode: close the substrate chrome gaps → flip the default (treat unset
renderas substrate for that mode) → re-baseline + verify → delete the legacy renderer. CUSTOM is already substrate-only.
Consequences¶
- One render model — a single mental map for agents; the fossil and its dual-path fork are gone; new behaviour has one home.
- Fleet-wide, deliberate golden churn (per mode, gated on visual/a11y/card-safety parity).
- ~2,322 LOC of legacy renderers deleted across the phases;
page/runtime/{table,detail,form}_renderer.pyretired. - The legacy form submit-button bug (#1291) is resolved by adoption.
- Rejected: keep both paths (the status quo — the fossil ossifies, agents pay the dual-model tax forever). Byte-match the substrate to legacy (imports the fossil's shape; defeats the purpose). Big-bang flip all modes at once (un-reviewable churn + no fallback during the riskiest moment) — phased, mode-by-mode, each independently shippable and verifiable.
Follow-ons¶
Phased design + per-mode parity gap-lists: docs/superpowers/specs/2026-06-29-substrate-universal-render-path-design.md. Phase 1 (list) gets the first implementation plan.