Files
2026-09-01 00:58:29 +02:00

26 KiB
Raw Permalink Blame History

Hemx effect and attribute kernel handoff

Status and authority

This document is design evidence, not product authority and not an implementation plan. Repository-root INTENT.tsv and SPEC.tsv are the sole authorities. Before changing public behavior, update those canonical TSV records, derive a current PLAN.md slice, and add direct Redgate proof.

Do not publish this file, INTENT.tsv, SPEC.tsv, PLAN.md, or requirement-proof scripts to the public mirror.

The current public Hemx 0.3 behavior remains supported until a separately authorized breaking release changes it.

Product objective

Hemx should cover ordinary server-owned web applications with a very small set of mechanisms:

semantic Hemplate
  -> generated typed resources and actions
  -> ordinary Rust handler and application state
  -> ordered typed effects
  -> one canonical wire format
  -> root-scoped browser interpreter

The target is broad capability, not a feature for every product noun. Forms, validation, toasts, dialogs, tables, navigation, search, pagination, server push, sortable lists, uploads, and similar behavior must compose from the same kernel. High-frequency browser-owned behavior remains an explicit island.

Design laws

  1. The server owns application state, authorization, validation, and recovery.
  2. HTML owns document semantics, forms, accessibility, and ordinary navigation.
  3. Hemplate owns rendering and escapes interpolation by default.
  4. Hemx owns generated identities, ordered effects, canonical bytes, and root-scoped browser application.
  5. Generated concrete resource types expose valid operations; invalid combinations do not compile.
  6. Application code never copies resource IDs, selectors, opcodes, fingerprints, or generated attributes.
  7. Effects address generated resources, never arbitrary CSS selectors.
  8. An effect batch is an ordered fail-stop script, not a claimed atomic DOM transaction.
  9. Decode and ABI validation happen before application; target validation occurs immediately before each ordered effect so later effects may address content created earlier in the batch.
  10. HTML, CSS, URLs, native controls, and browser APIs are reused before adding Hemx protocol.
  11. Optional behavior is a small direct adapter with explicit lifecycle, not a universal plugin framework.
  12. Public APIs describe current capability, never removed experiments or private repository structure.

Comparative evidence

The design was compared against local source checkouts using peek plus targeted source tracing:

Reference Inspected commit Materialized lesson
htmx ad56dff71e55d9c717447437b4c942a64575d4b2 server-controlled swap modes, out-of-band updates, response navigation/events, and conditional state-preserving moveBefore()
Turbo f3faa2daf3f9b96c986e7ac3ec4022d7c1eb0dbf small ordered stream actions: append, prepend, before, after, replace, update, remove, refresh
Phoenix LiveView a7dc7b65226ef42ba264c2724a6a66fdf5c113af ordered command algebra, focus/transition/navigation commands, but a component-diff model Hemx should not copy
Livewire 4616d99586c4dbcb48602db871d2c0ca1150f463 response-envelope effects for morph, dispatch, redirect, download, script, and streaming; several are intentionally too permissive for Hemx core
Unpoly deb75d6ebab1733f67928fb4a7dfe6bb52202e56 layered fragment changes and overlay policy; useful product patterns but not irreducible protocol primitives
Datastar cbe24718784dee446ddea3ef5625b7a696f8c044 very small SSE event vocabulary and explicit patch modes; signal state is not required for Hemx's server-owned model

The references prove useful mechanisms, not Hemx product authority.

Local experiments

Direct Move versus full keyed reconciliation

Artifact: /tmp/silly-orbit-dragon-ISwOgl/RESULTS.md

A visible Chromium experiment used 1,000 existing keyed rows. For 100 last-to-first operations per sample:

  • direct Element.moveBefore() had a median below the 0.1 ms timer resolution and p95 around 0.1 ms;
  • complete keyed-order reconciliation had a median around 13.1 ms and p95 around 15.3 ms.

A focused-input probe showed:

  • insertBefore() retained node identity and value but lost focus and selection;
  • moveBefore() retained identity, value, focus, and selection.

Decision: retain direct Move. Do not lower a known move to full Morph. Prefer Element.moveBefore() where supported. Any fallback to insertBefore() has a known state-preservation limitation that requires explicit browser coverage.

Typed capability API

Artifact: /tmp/hemx-kernel-proof/RESULTS.md

The dependency-free Rust prototype materialized toast insertion, invalid-form render plus focus, dialog render plus focus, typed autocomplete dispatch, navigation, and direct item movement. Unit tests pass. Separate fixture crates fail to compile for:

  • append on a focus-only resource;
  • focus on a non-focusable slot;
  • wrong payload type for a generated event.

Decision: generated concrete capability types are ergonomic and valuable. Do not expose a public trait lattice. The prototype also showed that combining update and insertion into one generic RenderMode obscures target semantics, so retain separate erased Patch and Insert operations.

Trigger adapters

Artifact: /tmp/hemx-trigger-proof/RESULTS.md

A generic bridge required 58 nonblank lines and the instrumented timer/reveal/confirm adapters another 32. The current direct timer/reveal regions are about 78 lines before surrounding request logic. A new universal bridge therefore does not earn its complexity.

Decision:

  • keep native confirmation in the request seam;
  • do not create a universal plugin registry or callback bus;
  • make polling and revealed behavior optional direct adapters if retained;
  • give adapters only explicit scan(fragment) and dispose(fragment) lifecycle calls from Patch, Insert, Remove, and initial root binding;
  • do not add a global MutationObserver solely as an extension mechanism.

Atom evidence

No public example or real application journey depends on Atom, AtomState, SetAtom, or data-hemx-st. Their evidence is limited to implementation, isolated tests, generated surface, and testkit documentation. SetAtom stores bytes in a browser map and emits an event, creating a second client-state channel without owning rendering.

Decision candidate: remove Atom state from core. Use server-owned state plus Patch, typed Dispatch, or an explicit island.

Proposed erased effect kernel

The future canonical wire algebra should contain exactly these semantic operations:

enum Effect {
    Patch {
        target: ResourceRef,
        mode: PatchMode,       // Morph | Replace
        html: SafeHtml,
    },

    Insert {
        parent: ResourceRef,
        position: InsertPosition, // First | Last | Before(item) | After(item)
        html: SafeHtml,
    },

    Remove {
        target: ResourceRef,
    },

    Move {
        target: ResourceRef,
        position: MovePosition,   // First | Last | Before(item) | After(item)
    },

    Focus {
        target: ResourceRef,
        prevent_scroll: bool,
    },

    Scroll {
        target: ResourceRef,
        block: ScrollBlock,
        behavior: ScrollBehavior,
    },

    Visit {
        url: Url,
        history: HistoryMode,     // Push | Replace
    },

    Dispatch {
        event: EventRef,
        payload: Bytes,
    },
}

Why each operation remains

Operation Irreducible job
Patch update an existing resource while optionally preserving local DOM identity and browser state
Insert create new sibling/list content at a declared position
Remove delete one existing resource
Move reposition an existing stateful node without re-rendering it
Focus control keyboard and accessibility focus
Scroll reveal content without changing focus
Visit update URL/history through a server-owned partial navigation with ordinary navigation fallback
Dispatch carry typed non-visual intent or notification without inventing DOM mutation

Normalization from the current algebra

  • Put becomes Patch.
  • Current Insert and Prepend become one Insert plus position.
  • Remove { key: Option<_> } becomes removal of an already resolved typed Item resource.
  • Move { key, before } becomes movement between typed item resources.
  • Navigate becomes Visit; title and scroll are not bundled into it.
  • Emit { name, payload } becomes generated typed Dispatch.
  • Internal form commands must not be magic public event names.

Do not add wire effects for classes, styles, arbitrary attributes, validation errors, pending indicators, toasts, dialogs, downloads, clipboard, script evaluation, timers, overlays, or transport selection. Those are HTML, CSS, compositions, direct adapters, or islands.

Effect batch semantics

decode all bytes
  -> validate magic, ABI version, fingerprint, lengths, UTF-8, tags, trailing bytes
  -> for each effect in order:
       resolve against current root state
       validate resource capability and ownership
       apply effect
       stop on first failure

Later effects may address resources produced by earlier effects. Do not preflight every target only against pre-batch DOM state. Do not claim rollback or atomicity the browser cannot provide.

On failure:

  • stop applying remaining effects;
  • emit one structured root-scoped runtime diagnostic;
  • retain enough context for development diagnostics;
  • never guess a selector, opcode, resource, or compatibility fallback;
  • recover through the next authoritative render or full navigation when compatibility requires it.

Generated Rust API

Application code should use generated resource capabilities, not construct raw effects.

ui::profile.render(&view)
ui::toasts.append(&toast)
ui::todo(id).move_before(ui::todo(other))
ui::email.focus()
ui::errors.scroll_into_view()
ui::dialog.remove()
ui::search_changed.emit(&SearchChanged { query })
hemx::visit("/account")

Recommended generated concrete types:

Slot<View>       -> render, replace, remove
List<View>       -> prepend, append
Item<View>       -> render, replace, remove, move_before, move_after, move_first, move_last
Field            -> focus, scroll_into_view
Form<Input>      -> typed extraction and action vocabulary
Event<Payload>   -> emit only its generated payload type

The generated module may lower these methods to erased wire effects internally. Application-facing APIs should not expose raw resource IDs, numeric opcodes, arbitrary targets, or interchangeable primitive parameters.

Minimal generated HTML protocol

Generated markers should converge on one uniform vocabulary:

data-hemx-root
data-hemx-build
data-hemx-resource
data-hemx-key
data-hemx-action
data-hemx-island
Marker Job
root ownership and effect scope
build generated contract fingerprint
resource opaque generated resource identity
key stable identity for repeated content and keyed movement/morphing
action opaque generated handler identity
island explicit client-owned preservation boundary

Slots, lists, items, handles, forms, and fields remain distinct Rust capabilities while sharing one browser resource marker. Authors do not copy generated marker values.

Remove or collapse protocol markers that expose internal resource kinds separately. Never add data-hemx-target or selector-based targeting.

Minimal authorable request policy

Only behavior that genuinely varies per interaction should remain authorable:

data-hemx-on
data-hemx-policy
data-hemx-debounce
data-hemx-throttle
data-hemx-confirm
data-hemx-nav
data-hemx-history

Rules:

  • native event defaults require no attribute;
  • on exists only to override the native event;
  • policy owns concurrency only: latest, queue, drop, or parallel;
  • debounce and throttle own timing only;
  • confirm uses the boring native confirmation gate and the cancellable request seam;
  • navigation uses real anchors and GET forms, with partial navigation as progressive enhancement;
  • history changes are explicit and retain ordinary browser fallback.

Remove delay from core unless a real journey proves it cannot be represented by debounce, CSS, a timer adapter, or an island.

Runtime-global request timeout is initialization configuration, not per-element markup.

Optional direct adapters

Possible optional adapters:

data-hemx-every
data-hemx-revealed
data-hemx-sse
data-hemx-ws

They are not new effect schemas. SSE and WebSocket transport the same canonical EffectBatch bytes. Timer and reveal adapters dispatch generated actions through the same request seam.

Each adapter owns exactly:

scan(fragment)
dispose(fragment)

Required adapter behavior:

  • no duplicate binding;
  • cleanup on removal;
  • binding for content created by Patch or Insert;
  • root ownership enforcement;
  • deterministic error reporting;
  • focused browser proof.

If an adapter needs global lifecycle orchestration, hidden application state, a second payload schema, or domain policy, it is no longer a leaf and must remain application-owned.

Input and validation contract

Native browser layer

Use semantic controls and browser behavior directly:

<input
  type="email"
  name="email"
  required
  minlength="3"
  autocomplete="email"
>

The browser continues to own keyboard input, IME, autofill, password managers, file selection, native constraint validation, and accessibility semantics.

Authoritative server layer

HTTP/browser input
  -> media-type, size, origin, authentication and authorization checks
  -> typed parsing
  -> domain validation
  -> state transition or typed validation view model
  -> generated resource effects

An invalid form is ordinary rendering plus focus/scroll:

ui::signup.render(&submitted_form)
    .then(ui::email.focus())
    .then(ui::error_summary.scroll_into_view())

Rendered markup owns field state:

<input
  name="email"
  value="submitted value"
  aria-invalid="true"
  aria-describedby="email-error"
>
<p id="email-error">Enter a valid email address.</p>

Validation rules:

  • retain submitted valid values;
  • do not replace file inputs or focused controls unnecessarily;
  • focus the first invalid field for small forms;
  • focus or scroll an error summary for large forms;
  • use aria-invalid, aria-describedby, labels, and live regions;
  • render expected validation errors in context, not as toasts;
  • never expose infrastructure error text as validation feedback;
  • do not add SetFieldError, SetValidity, or FormError wire effects.

Pending and transport errors

The core request seam should expose standard browser state:

aria-busy="true"
disabled

CSS composes directly:

button[aria-busy="true"] .spinner { display: inline-block }
form[aria-busy="true"] { cursor: progress }

Only controls that are safe to disable should be disabled; do not disable fields whose successful values must remain part of form submission.

Transport failure emits one structured root-scoped runtime event. Domain validation remains rendered application content.

Remove framework-specific presentation configuration where standard state is enough:

data-hemx-indicator
data-hemx-pending-class
data-hemx-disable-while-pending
data-hemx-error
data-hemx-error-for

Common product compositions

Product behavior Composition
Toast typed toast list + Insert::Last; later Remove or optional timeout adapter
Dialog native <dialog> view + Patch + Focus; close through action and Remove/Patch
Field validation typed form Patch + Focus + optional Scroll
Error summary semantic summary + Focus/Scroll
Autocomplete debounce action + result-list Patch
Pagination real links/GET forms + progressively enhanced Visit
Infinite list optional revealed action + Insert::Last
Sortable list generated reorder action + direct Move
Inline edit item Patch + field Focus
Chat/server push SSE or WebSocket carrying canonical batches
Upload native multipart form; optional progress adapter or island
Tabs links/buttons plus CSS, URL state, or a small Patch
CRUD/table UI semantic forms, typed views, Patch/Insert/Remove
Charts, maps, canvas, rich editor explicit island receiving typed boundary data/events

A product noun does not earn a protocol opcode. Toasts and dialogs remain first-class ergonomic generated helpers built from the kernel.

Island boundary

Use an island only when browser-owned high-frequency state materially improves the interaction: editor internals, canvas, maps, drag motion, media timelines, chart interaction, or similar behavior.

An island owns its local DOM subtree and lifecycle. Hemx may Patch around it, remove it explicitly, or Dispatch typed boundary events. Hemx must not silently morph through island-owned nodes.

Do not use islands for ordinary form validation, CRUD, navigation, toasts, dialogs, pagination, or server push.

Explicit cuts

Remove from future core unless a confirmed current requirement proves otherwise:

  • Atom, AtomState, SetAtom, and data-hemx-st;
  • separate Insert and Prepend wire tags;
  • optional key fields on generic Remove;
  • magic public event names for internal form reset/error commands;
  • selector targets and server-selected DOM queries;
  • class, style, and arbitrary attribute wire operations;
  • script evaluation or execute-script effects;
  • a client application store mirroring server truth;
  • a universal adapter/plugin registry;
  • global mutation observation solely for extension discovery;
  • transport-specific effect schemas;
  • product-specific core effects for toast, dialog, download, clipboard, overlay, timer, or validation.

Keep:

  • native HTML, forms, URLs, history, focus, scroll, and accessibility;
  • escaped Hemplate rendering and explicit SafeHtml trust boundaries;
  • typed generated resources and actions;
  • deterministic ordered canonical bytes and fingerprints;
  • root-scoped application;
  • direct state-preserving Move;
  • explicit islands;
  • transport adapters carrying unchanged canonical bytes.

Compatibility and release posture

Do not preserve redundant public primitives merely for internal convenience. Do not silently break Hemx 0.3 either. This is a deliberate future breaking-contract candidate.

A migration release should provide semantic source-level replacements where useful, but must not retain two permanent effect or attribute languages. Compatibility helpers may lower old source calls into the new kernel for one documented transition window only if they do not preserve old wire semantics or duplicate runtime branches.

Required authority reconciliation

Before implementation, revise and confirm at least these current requirement areas:

  • core/001–core/010: closed algebra, resource targeting, safe HTML, ordered wire behavior, compatibility, navigation, generated helpers, Atom removal;
  • build/001–build/003: one generated resource/action vocabulary and capability-specific APIs;
  • derive/001–derive/004: generated surface and compile-fail capability proof;
  • axum/001–axum/005: canonical bytes, fingerprint, partial navigation and diagnostics;
  • runtime/001–runtime/007: new interpreter, request policy, adapters, focus/history/push behavior;
  • test/001–test/005: inspector support for the normalized effects and generated types;
  • wasm/001: portable server-Wasm boundary remains unchanged.

Do not treat this list as an automatic requirement change. create-specification must split and confirm individually falsifiable obligations.

Keep the work in independently useful breaking-release slices rather than one rewrite.

Slice 1 — Remove the second client-state channel

  • remove Atom resource generation, state bootstrap, SetAtom, runtime map, events, tests, docs, and inspector support;
  • preserve typed Dispatch and islands as explicit alternatives;
  • prove no real public example depends on Atom.

Slice 2 — Correct state-preserving movement

  • retain Move;
  • apply it with Element.moveBefore() where available;
  • define and test fallback behavior;
  • add real-browser focus, selection, form-value, media/animation, listener, and custom-element lifecycle proof;
  • retain keyed Morph for complete desired-tree updates, not known single moves.

Slice 3 — Normalize the erased wire algebra

  • introduce Patch, Insert+position, Remove, Move+position, Focus, Scroll, Visit, Dispatch;
  • remove duplicate tags and magic internal events;
  • update deterministic encode/decode, compatibility fixtures, malformed-input tests, inspectors, and TypeScript declarations;
  • make batch application sequential against current post-effect state.

Slice 4 — Generate capability-specific APIs and uniform markers

  • generate Slot/List/Item/Field/Form/Event concrete types and methods;
  • collapse browser resource-kind markers to data-hemx-resource plus key;
  • preserve root, build, action, and island boundaries;
  • add compile-fail proof for invalid capability combinations;
  • reject copied IDs and selector targets.

Slice 5 — Reduce request attributes and standardize UX state

  • retain native event defaults, concurrency policy, debounce, throttle, confirm, navigation/history;
  • replace custom pending/error presentation attributes with standard aria-busy, safe disabled state, semantic server-rendered validation, and one structured transport-error event;
  • prove form values, focus, error summaries, password/file controls, and accessibility behavior in a real browser.

Slice 6 — Extract optional leaf adapters

  • polling, revealed, SSE, and WebSocket remain direct optional modules;
  • all adapters use scan/dispose and canonical action/effect boundaries;
  • no plugin registry or second schema;
  • prove dynamic insertion, removal cleanup, no duplicate binding, reconnection, root ownership, and cancellation.

Slice 7 — Public migration and registry proof

  • update public docs to current behavior only;
  • provide bounded source migration notes without describing removed private experiments;
  • run full package, registry-only native/Axum/Wasm, browser, and hygiene gates;
  • publish only after explicit effect-boundary authorization.

Proof matrix

Risk Required proof
invalid capability combination compile-fail test against generated API
malformed/incompatible wire canonical fixture, truncation/tag/UTF-8/magic/version/trailing-byte rejection
dependent ordered effects Patch/Insert then Focus/Dispatch/Move to newly created resource in one batch
identity-preserving Morph real browser: node identity, input value, focus, selection, open/details state, island preservation
direct Move real browser using moveBefore(), including focus/selection and custom element callbacks
fallback Move explicit browser matrix and documented state limitation or equivalent preservation algorithm
validation UX real Axum route and browser: retained values, field association, summary, focus, announcement
request concurrency latest/queue/drop/parallel tests with cancellation and pending cleanup
partial navigation URL, history back/forward, title, scroll restoration, full-navigation fallback
adapter lifecycle initial and inserted fragments, dispose on removal, no duplicate timers/listeners/observers
SSE/WebSocket same canonical bytes, root ownership, reconnection, incompatibility rejection
server-Wasm actual generated Hemplate/Hemx consumer compiles without parser or Tree-sitter target dependencies
public package integrity fmt, clippy, workspace tests, cargo-deny, archive checks, registry-only consumers, hygiene scans

Full mandatory repository checks

redgate list
redgate refs
redgate lint
redgate check
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-targets --all-features
cargo deny check licenses sources
cargo check -p hemx-server-wasm-test --target wasm32-unknown-unknown
cargo tree -p hemx-server-wasm-test --target wasm32-unknown-unknown --edges normal,no-proc-macro

The target tree must contain neither tree-sitter nor hemplate-parser. Publishable packages also require archive-based cargo package --list and cargo package checks.

Open decisions requiring explicit confirmation

  1. Whether Atom removal is accepted as a breaking product decision.
  2. Browser support floor for mandatory Element.moveBefore() versus fallback behavior.
  3. Final public naming: Patch/Insert/Visit/Dispatch and source helper names.
  4. Whether Scroll remains standalone or a confirmed workload demonstrates that Focus and URL fragments cover all supported cases.
  5. Whether polling and revealed adapters ship in hemx-js as optional modules or remain application snippets.
  6. Exact migration window from Hemx 0.3 source APIs and wire ABI.

These decisions must be resolved in INTENT.tsv and SPEC.tsv, not by implementation convenience.

Final recommendation

Adopt the architecture direction, but do not implement it as one rewrite. The strongest immediately supported decisions are:

  1. keep direct Move and correct it toward state-preserving moveBefore();
  2. remove Atom as the unproved second client-state channel after authority confirmation;
  3. normalize duplicate insertion operations;
  4. generate capability-specific concrete APIs;
  5. reduce browser markers to one resource namespace;
  6. keep validation in semantic HTML plus typed server rendering;
  7. reject a universal plugin framework;
  8. use small direct adapters and explicit islands for the remainder.

The result is not a smaller product. It is a smaller machine capable of building a large product surface.