UX-maturity primitive roadmap¶
The companion to the framework UX-maturity rubric.
The rubric scores; this sequences the work. Premise (per the maturity
ladder): every criterion implies primitive work — a level-2 criterion needs a
right-by-default primitive; a level-3 criterion needs the inference step that
takes it to 4 (adaptive). Even the green ones (3b, 3e at 4) define the
bar the others should clear, not a finish line.
dazzle ux maturity is the baseline (v0.88.0: overall 2.62). This doc maps
each of the 13 criteria to its target primitive, and — crucially — to how it
should be built, because the criteria split into two implementation classes
with very different cost/risk.
Two implementation classes¶
- (S) Server-render / IR primitives. Pure Dazzle: DSL grammar → IR → renderer. No client involvement. This is all of data-drives-UI (1a–1d) plus the inference steps for the negative-space criteria. The cost is IR + renderer work; the risk is low (typed-Fragment substrate, no escape hatch).
- (H) Declarative-over-htmx-4 primitives. The interaction criteria (progressive disclosure 2b–2d; interaction-shaped negative space 3a/3d). These must NOT be hand-written JS — per the SSR+htmx doctrine, a DSL keyword wraps a native htmx-4 trigger / a vendored extension, keeping markup semantic, class-light, build-free, and agent-legible (Locality of Behaviour). htmx 4 moved several of our learned requirements into direct expression, which makes this class cheaper than it was at htmx 2.
The htmx-4 examination — does it express our learned requirements?¶
Read against the htmx-4 (four-dev, 4.0.0-beta4) source + extension set. The
answer is yes for the interaction layer, no for the data layer (the data
layer was never htmx's job).
| htmx-4 capability | native / ext | Expresses (criteria) | Verdict for Dazzle |
|---|---|---|---|
intersect / revealed triggers |
native | lazy region; reveal-rest; row-peek-on-scroll | adopt — already used (fold_count lazy regions) |
every Ns polling |
native | live dashboards | adopt — already refresh: every Ns |
morph swaps (idiomorph in core) |
native | every in-place update keeps focus/scroll/input | adopt — already default |
View Transitions (transition:true) |
native | answer-first nav, drill (2a/2b) | adopt — already wired |
hx-optimistic |
ext (NEW in 4) | optimistic inline-edit / action (2c) | adopt — reverses the doctrine's "no optimistic-render" gap; the row-peek/inline-edit primitive can now be optimistic declaratively |
hx-upsert |
ext (NEW in 4) | live list/row insert-or-update by id (2c, live 1c) |
adopt — declarative live-collection maintenance, no custom JS |
hx-preload |
ext | drill perceived-perf (2b) | adopt — preload detail on hover/intersect |
hx-ptag |
ext | skip-unchanged polling (efficient live regions) | adopt — ETag-style refresh for every Ns regions |
hx-ws / hx-sse |
ext | realtime push | adopt-as-needed (SSE already triggered) |
hx-live (reactive q() signal graph) |
ext | client reactive expressions | AVOID — a Datastar-shaped client signal graph; breaks Locality of Behaviour and agent-legibility (the doctrine's explicit rejection). Not a path to any criterion worth the cost. |
So: the interaction primitives below are thin declarative wrappers over
native triggers + the hx-optimistic / hx-upsert / hx-preload / hx-ptag
extensions — no bespoke JS, no build chain. The data primitives are pure
server-side. And one explicit non-goal: do not reach for hx-live to fake
client reactivity.
Per-criterion roadmap¶
| # | Criterion | now→target | Primitive (the work) | Class | htmx-4 leverage |
|---|---|---|---|---|---|
| 1a | region form inference | display: auto — infer the region form from the source's data shape. Default SHIPPED (#1492): the opt-in resolver (v0.88.3, resolve_auto_display) is now the default for an unset display: via resolve_region_display_mode — a genuinely-unset region infers its form (aggregate→summary/chart, state-machine→kanban, temporal→timeline, else list); an explicit display: list stays authoritative (true-unset discriminator WorkspaceRegion.display_unset). Byte-identical across the example fleet (subsumes the EX-047/#1082 aggregate→SUMMARY promotion). Level 3 reached (data-right form is the default; author writes nothing). Remaining (3→4): runtime/usage-driven (adaptive) form selection. |
S | — | |
| 1b | semantic-state binding | semantic: on enums SHIPPED (#1493). Declare a status field's lifecycle role (open=warning, done=positive), validated against the token palette; resolve_status_tone consults it before the _STATUS_TONE_MAP name guess. Level 4 reached: WCAG colour+icon+text (badge_icon_html) on every badge surface + state-machine-terminal inference (status_tone_map/infer_terminal_tone_map) for undeclared, name-guess-miss terminal states. Precedence declared > name-guess > SM-terminal > neutral. Known limit: the IR doesn't yet classify a terminal as success-vs-failure (a custom-named failure terminal needs an explicit semantic:). |
S | — | |
| 1c | comparison context | scalar-with-context default — SHIPPED to L4 (#1491). resolve_comparison (page/runtime/comparison_resolver) synthesises a default 30-day period-over-period DeltaSpec for an unset metrics/summary tile whose aggregate sources an entity with created_at, applied at the shared _compute_aggregate_metrics seam (server-render + htmx lazy-fetch both light up) — so a tile shows a trend arrow + vs prior 30 days instead of a lone KPI. Level 4 (adaptive): the inference adapts to the aggregate grain — count AND scalar sum/avg/min/max (the prior-window query fires per-grain via _prior_period_task), so a revenue-sum or rating-avg tile gets a trend too, not just count tiles. Inferred deltas are neutral-sentiment (magnitude/direction without asserting good/bad; declared semantic:/1b owns tone). An explicit delta: wins; no created_at gracefully stays a lone KPI. Remaining (the only un-built L4 piece): an explicit priority:-style override is N/A here; a richer live comparison form is the opt-in #1470 kinds. |
S | — | |
| 1d | raw-data honesty | exhaustive format coverage — SHIPPED (#1491). The shared cell core (_render_cell_display) now humanises every type, list AND detail. The detail view fed the core form-input types (checkbox/datetime/number/select/textarea) it didn't recognise, leaking raw True / ISO timestamps / full-precision floats / JSON-mangled-to-one-value; _detail_field_value reconciles form→display types and the core gained datetime (date+time), number (rounded float), json (compact key: val · … summary) branches + a dict/float-aware default + null→— guards. List columns map FLOAT→number/JSON→json (decimal keeps str() precision; datetime stays date-only in dense lists). _probe_1d is now a render-the-real-core coverage gate. Adversarial review caught + fixed two null-cell leaks (number→0, json→None) before flip. |
S | — | |
| 2a | answer-first landing | 3→4 | landing inference — pick the persona's default workspace/region order from their rhythms/stories, not hand-set default_workspace. |
S | view-transitions (already) |
| 2b | depth ≤1 action | preload-drill — SHIPPED (#1491). Every clickable list row carries hx-preload="mouseover"; the vendored htmx-4 preload extension (bundled into dazzle.min.js after the core) warms the detail GET on hover so the click serves the cached prefetch — perceived-instant drill, fleet-wide. The extension dedups per row (one prefetch / 5s) so a mouse-sweep doesn't storm the server; the prefetch is the same scope-filtered detail GET (no RBAC change). |
H | hx-preload, view-transitions |
|
| 2c | action-proximate detail | 2→4 | peek: / inline-edit primitive — declarative row-peek (expand-in-place) + click-to-edit, on by default where the entity has a detail surface; optimistic via hx-optimistic. |
H | native intersect/<details>, hx-optimistic, hx-upsert |
| 2d | field economy | 2→4 | priority: columns — show top-N by declared/inferred priority, reveal the rest via a revealed-triggered lazy expand. At 4, infer priority from field salience. |
H | native revealed |
| 3a | frequency-weighted prominence | action prominence inference — Default SHIPPED (#1491). resolve_action_prominence (page/runtime/action_prominence_resolver) keeps the top-3 workspace-heading actions prominent by declaration order (inferred + New <Entity> create-CTAs first, so protected) and demotes the tail to a native <details> More ⋯ overflow menu (JS-free), applied at the page_routes action-assembly seam. An action-heavy heading declutters to a clear primary row; a ≤3-action heading is byte-unchanged. Level 3 reached (declared-signal default, no usage). Remaining (3→4): derive prominence from observed usage frequency, and extend to row-action / bulk-toolbar / action-grid placements. |
S (+native) | — | |
| 3b | role-gated affordance | 4 | bar, not work — provable RBAC matrix already infers role-gated visibility. Keep as the reference for the others. | S | — |
| 3c | state-gated affordance | 3→4 | transition-driven affordances everywhere — any action (not just buttons) auto-appears only when the state graph allows it; today partial. | S | hx-put (action mechanism) |
| 3d | empty-state suppression | 2→4 | self-suppressing region — an empty lazy region removes/collapses itself instead of rendering dead scaffolding; declarative when_empty: suppress. |
H | native intersect + hx-upsert/<partial> (hx-ptag) self-remove-on-empty |
| 3e | scope concealment | 4 | bar, not work — scope: → predicate algebra + RLS already conceals by construction. Reference for negative space. |
S | — |
Sequencing (highest leverage first)¶
- 1a
display: auto(S) — the single highest-leverage gap; the vocabulary exists, this adds the chooser. Retires "table-by-default" across every app. - 1b
semantic:on enums (S) — cheap, app-wide credibility; turns colour from a guess into a declared+validated binding. - 2c
peek:+ inline-edit (H) — first declarative-over-htmx-4 primitive; proves thehx-optimistic/intersectwrapper pattern. High user-visible value. - 3d self-suppressing region (H) — kills
empty_on_pathdead scaffolding; small, native-intersect-backed. - 2d
priority:columns (H) — field economy viarevealedlazy reveal. - 1c comparison-default / 1d format closure / 2b preload-drill / 3a placement / 2a + 3c inference — the level-3→4 inference steps, after the level-2 gaps are primitives.
After each lands, re-run dazzle ux maturity --drift: the probe should flip
(e.g. a display: auto key appears → 1a's probe trips → re-score 1a upward). The
drift gate makes the roadmap self-measuring — a shipped primitive is a forced
re-score, not a manual edit.
Guardrails (carried from the SSR+htmx doctrine)¶
- Interaction primitives are declarative wrappers over native htmx-4 / vendored extensions — never bespoke per-app JS, never a second client app.
- No
hx-live— no client reactive signal graph; everything that matters round-trips (server is the source of truth;morphpreserves focus for free). - A missing capability is a framework RFC, not a
mode: customsurface (custom-renderer reach for one of these is itself the maturity signal the rubric's attribution rule promotes).