Fixed
Derived context vars synced when parent instance attr mutated in-place (#703) —
_sync_state_to_rust()now collectsid()s of all sub-objects reachable from changed instance attrs and includes any derived context var whoseid()appears in that set. Previously, context vars computed inget_context_data()that returned sub-objects of a mutated dict (e.g.,wizard_step_data.get("person", {})) were skipped because theirid()was unchanged, causing templates to render stale data. Depth-capped at 8 with cycle detection. 9 new regression tests.as_live_field()now respectswidget.input_typeoverride fortypeattribute (#683 re-open) — The initial #683 fix mergedwidget.attrsbuttypewas still ignored because Django movestype=fromattrsintowidget.input_typeduring widget__init__._get_field_type()now checkswidget.input_typeagainst the widget class's default and uses the override when they differ (e.g.TextInput(attrs={"type": "tel"})setsinput_type="tel"). 4 new regression tests coveringtype="tel",type="url",type="search", and the defaulttype="text"fallback.
Added
- LiveComponent events now propagate to parent LiveView waiters (ADR-002 Phase 1b/1c follow-up) — Closes the "known limitation" documented in the v0.4.2 tutorials guide:
await self.wait_for_event("foo")on a LiveView now resolves when the matching handler fires on an embeddedLiveComponent, not just when it fires on the view itself. Without this, aTutorialStep(wait_for="submit", ...)wheresubmitis a handler on a childFormComponentwould silently stall forever — the parent view's waiter would never resolve and the tour would hang. The fix is in the WebSocket consumer'shandle_eventcomponent-event branch: after the component handler runs, the consumer now callsself.view_instance._notify_waiters(event_name, notify_kwargs)with the handler's kwargs + an injectedcomponent_idkey, mirroring the notification that already happened in the main LiveView branch from Phase 1b. Thecomponent_idinjection means apps can use the waiter'spredicateargument to disambiguate events fired from multiple component instances:wait_for_event("submit", predicate=lambda kw: kw.get("component_id") == "project_form"). A notification failure is caught and logged via thedjust.websocketlogger so a buggy waiter/predicate can't break the component handler's observable behavior — the component's state mutations always happen even if the waiter notification raises. 5 new regression tests inpython/tests/test_waiter_component_propagation.pycovering: component event resolves parent waiter,component_idis injected into notify kwargs so predicates can filter by source, multiple parent waiters for the same event all resolve (fan-out), the non-component branch still notifies parent waiters (regression guard for the Phase 1b path), and a raising_notify_waitersis logged-and-swallowed rather than propagating.docs/website/guides/tutorials.mdLimitations section updated to document the new behavior with acomponent_idpredicate example.
Documentation
Tutorial bubble must be placed outside
dj-root(#699) — If the{% tutorial_bubble %}tag is placed inside thedj-rootcontainer, morphdom recovery (which replaces the entiredj-rootcontent on patch failure) destroys the bubble mid-tour, causing it to silently disappear. The tutorials guide now has a dedicated "Bubble Placement" section explaining the requirement, why it exists, and correct/incorrect examples. The simplest-possible example at the top of the guide is updated to show the bubble outsidedj-root. Thetutorial_bubbletemplate tag docstring is also updated with this requirement.data-*attribute naming convention documented in Events guide (#623) — Howdata-foo-baron an HTML element maps tofoo_barin the event handler's kwargs was undocumented. The Events guide now has a dedicated "Data Attribute Naming Convention" section covering: the dash-to-underscore rule, client-side type-hint suffixes (:int,:float,:bool,:json,:list), server-side Python type-hint coercion, thedj-value-*alternative, which internaldata-*attributes are excluded, and a quick-reference table.
Changed
System checks T002, V008, C003 now suppressible via
DJUST_CONFIG(#603) — These three informational checks fire on everymanage.pyinvocation and are noisy for projects that deliberately don't use the checked features (daphne, explicitdj-root, non-primitive mount state). A newsuppress_checksconfig key inDJUST_CONFIG(orLIVEVIEW_CONFIG) accepts a list of check IDs to silence:DJUST_CONFIG = {"suppress_checks": ["T002", "V008", "C003"]}. Both short IDs ("T002") and fully-qualified IDs ("djust.T002") are accepted, case-insensitive. Only the informational/advisory variants are suppressed — the C003 Warning (daphne misordered) still fires because it indicates a real misconfiguration. 7 new tests for the suppression mechanism.release-drafter/release-drafterv6 → v7 + droppull_requesttrigger — v7 validatestarget_commitishagainst the GitHub releases API and rejectsrefs/pull/<n>/mergerefs, which is whatgithub.refresolves to under apull_requesttrigger. v6 silently tolerated this; v7 does not, causing every PR to fail withValidation Failed: target_commitish invalid. The fix is to drop thepull_requesttrigger — release-drafter is designed to track changes that have landed on the release branch, not comment on in-flight PRs, sopush: branches: [main]is the right fit. Aligns with how Phoenix, Elixir, GitHub CLI, and other major projects wire release-drafter. Resolves the v7 bump that was deferred out of the v0.4.2 dependabot batch (#680).Dependency batch carry-over (v0.4.2) — Drains the dependabot backlog that was held behind the v0.4.1 release. Single consolidated PR so one CI run catches any inter-dep interactions:
- npm:
vitest/@vitest/ui/@vitest/coverage-v84.0.18 → 4.1.4 (patches + new test runner features),jsdom29.0.1 → 29.0.2,happy-dom20.8.4 → 20.8.9. Full JS suite remains green (1111 tests). - Cargo:
tokio1.50 → 1.51 (workspace),uuid1.22 → 1.23,proptest1.10 → 1.11 (djust_vdom),indexmap2.13.0 → 2.14.0 (transitive pickup via cargo update).cargo check --workspaceclean;cargo test -p djust_vdompasses all 42 proptest-driven tests on the new 1.11 runtime. - GitHub Actions:
actions/github-scriptv8 → v9 (two workflows),astral-sh/setup-uvv6 → v7 (test workflow). Workflow syntax unchanged. - Intentionally deferred:
html5ever0.36 → 0.39 is a 3-minor-version jump that requires a matchingmarkup5ever_rcdom0.39 release which has not yet been published to crates.io (only git snapshots exist in the html5ever workspace). Using git deps in our published workspace would breakcargo publishand leak unreleased upstream state, so this stays deferred until upstream publishes.release-drafter/release-drafterv6 → v7 was also deferred out of this chore batch because of atarget_commitishvalidation incompatibility — shipped as a separate follow-up PR alongside this one.
Closes 13 open dependabot PRs as superseded (#581, #582, #604, #606, #607, #609, #615, #616, #644, #645, #646, #647, #648).
- npm:
Fixed
@backgroundnatively supportsasync defhandlers (#697) — The@backgrounddecorator now detectsasyncio.iscoroutinefunctionand creates a native async closure so_run_async_workcanawaitit directly on the event loop instead of routing throughsync_to_async. The fragileinspect.iscoroutine(result)workaround from #692 is kept as a legacy fallback. 5 new regression tests.flush_push_events()resolves callback dynamically on WS reconnect (#698) —PushEventMixin.flush_push_events()now resolves the flush callback viaself._ws_consumer._flush_push_eventsat call time instead of relying on a stored_push_events_flush_callbackthat was only wired during initial mount. After a WebSocket reconnect the view instance is restored from session but the stored callback was stale. The dynamic lookup always finds the current consumer. Legacy stored callback kept as fallback. 7 new tests.push_commands-only handlers auto-skip VDOM re-render (#700) — Handlers that only call
push_commands()/push_event()without changing public state no longer trigger a VDOM re-render. The_snapshot_assignsdeep-copy comparison could report false positives for views with non-copyable public attributes (querysets, file handles) because sentinel objects never compare equal. A new identity-based check (id()comparison before/after) detects whether any public attribute was actually rebound and auto-sets_skip_render = Truewhen push events are pending but no state changed. 5 new tests.System check V010 detects wrong TutorialMixin MRO ordering at startup (#691) — Django's
View.__init__does not callsuper().__init__(), so writingclass MyView(LiveView, TutorialMixin)silently skips TutorialMixin's initialisation. A newdjust.V010system check scans all LiveView subclasses at startup and emits an Error with a clear fix hint when TutorialMixin appears after a View-derived base in the class declaration. Suppressible viaDJUST_CONFIG = {"suppress_checks": ["V010"]}. 5 new tests. Tutorials guide updated with correct ordering.@background async defhandlers now execute correctly (#692) —@backgroundwraps handlers in a sync closure; when the handler isasync def, the closure returned an unawaited coroutine and the handler body never ran. The fix in_run_async_work(already on main via workaround) detects coroutine returns and awaits them. 11 new regression tests intest_background_async.pyverify both sync and async handlers execute their bodies.push_commandsin@backgroundtasks now flush mid-execution (#693) — Push events queued bypush_commandsinside a@backgroundhandler only reached the client when the entire task completed. The_flush_pending_push_eventscallback mechanism (already on main) lets TutorialMixin and other background handlers flush events immediately. A new publicawait self.flush_push_events()method on PushEventMixin provides the same capability to any@backgroundhandler. 7 new tests intest_push_flush_background.py.get_context_datano longer includes non-serializable class attributes (#694) — The MRO walker inContextMixin.get_context_data()added class-level attributes (liketutorial_steps) to the template context. Non-JSON-serializable values were silently converted to theirstr()repr, corrupting state on subsequent events. The fix skips class-level attributes that fail a JSON serialisability probe. Additionally,TutorialMixinnow stores steps as_tutorial_steps(private) with a read-onlytutorial_stepsproperty, so they are excluded by both the_prefix convention and the serialisability check. 14 new tests.Debug panel SVG attributes no longer double-escaped (#613) — SVG attributes like
viewBoxandpath din the debug toolbar were rendered garbled because the Rust VDOM'sto_html()method HTML-escaped text content inside<script>and<style>elements. Per the HTML spec, these are "raw text elements" whose content must be emitted verbatim — escaping&to&or<to<corrupts JavaScript/CSS code and causes double-escaping when the HTML is round-tripped through the VDOM pipeline (parse with html5ever which decodes entities, then re-serialize withto_html()which re-encodes them). The fix adds anin_raw_textflag to the internal_to_html()serializer that propagates through<script>/<style>children, skippinghtml_escape()for their text nodes. SVG attribute values in templates (which don't contain HTML special characters) were already correct but now have explicit regression tests. 4 new Rust unit tests, 3 new Rust integration tests (script/style/SVG roundtrip), 3 new Python regression tests (JS source validation, JSON injection check, VDOM roundtrip), and 3 new JS tests (tab icon SVGs, path d attributes, header button SVGs all verified in DOM).form.cleaned_dataPython types no longer serialize to null (#628) —datetime.date,datetime.datetime,datetime.time,Decimal, andUUIDvalues inform.cleaned_datastored in public view state are now properly serialized to their JSON representations (ISO strings, floats, strings) instead of silently becomingnull. Both theDjangoJSONEncoderandnormalize_django_value()already handled these types; 10 new regression tests confirm the behavior.set()is now JSON-serializable as public state (#626) — Storing a Pythonset()orfrozenset()in public view state no longer crashesjson.dumps. Sets are serialized as sorted lists (falling back to unsorted when elements aren't comparable). BothDjangoJSONEncoder.default()andnormalize_django_value()now handleset/frozenset. 11 new regression tests.dictstate no longer corrupted tolistafter Rust state sync (#612) — Round-tripping state through the Rust MessagePack serialization boundary could corruptdictvalues intolistbecause#[serde(untagged)]on theValueenum letrmp_serdematch a msgpack map against theListvariant before tryingObject. The fix replaces the derivedDeserializewith a custom visitor-based implementation that uses the deserializer's type hints (visit_mapvsvisit_seq) to correctly distinguish maps from arrays. 4 new Rust regression tests + 1 Python end-to-end msgpack round-trip test.as_live_field()now mergeswidget.attrsinto rendered HTML (#683) — Theas_live_field()method (and{% live_field %}tag) dropped any attributes defined on a Django widget'sattrsdict —type="email",placeholder,pattern,min/max, customdata-*, and any other HTML attributes were silently lost. The fix adds_merge_widget_attrs()toBaseAdapter, called from_render_input,_render_checkbox, and_render_radio, which mergesfield.widget.attrsinto the output attributes with djust-specific keys (dj-change,name,class, etc.) taking precedence over widget defaults. BooleanFalse/Nonevalues in widget attrs are filtered out to avoid renderingdisabled="False". 17 new regression tests inpython/tests/test_live_field_widget_attrs.pycovering: EmailInput placeholder/type, pattern/min/max/step/title, djust attrs override clashing widget attrs, empty widget attrs, textarea rows/cols, checkbox data-attrs, radio data-attrs on each option, select data-attrs, and boolean True/False handling.VDOM patcher guards against text nodes for 5 patch types (#622) — The VDOM diff patcher called
setAttribute(),removeAttribute(),appendChild(),removeChild(), andreplaceChild()on#textnodes, which don't implement these methods. This crashed conditional rendering whenever a text node sat where the patcher expected an element (common in{% if %}blocks that switch between text and element content). The fix adds anisElement(node)guard at the top of each of the five patch-type branches in12-vdom-patch.js— when the target is a non-element node (text, comment, CDATA), the patch is skipped gracefully instead of throwing. 4 new JS tests intests/js/vdom_patch_errors.test.jscovering setAttribute, removeAttribute, appendChild, and replaceChild on text nodes.Autofocus handling on dynamically inserted elements (#617) — Dynamically inserted
<input autofocus>elements didn't receive focus after a VDOM patch because the browser only honours theautofocusattribute on initial page load. The patcher now detectsautofocuson newly inserted elements after each patch cycle and calls.focus()explicitly. 4 new JS tests intests/js/vdom-autofocus.test.jscovering single autofocus, multiple elements (last wins), elements without autofocus ignored, and no-op when no autofocus elements are present.Private
_attributes preserved across events and reconnects (#627, #611) — Two related state-management bugs caused any attribute starting with_(the documented convention for private/internal state) to be silently wiped. The root cause was that session save used the output ofget_context_data(), which by design strips_-prefixed attributes. For #627, every WebSocket event round-trip lost private state because_save_state_to_session()persisted only public context. For #611, the pre-rendered WS reconnect path restored session state but never included private attributes set during the HTTP GET mount. The fix adds two helpers —_get_private_state()(collects all_-prefixed instance attrs that aren't dunder or in the base-class exclusion set) and_restore_private_state(state_dict)— and wires them into_save_state_to_session()(now persists private state under a_private_statesession key) and_load_state_from_session()/ the reconnect path inRequestMixin._restore_session_state()(restores private attrs before the view resumes). 20 new regression tests inpython/tests/test_private_attr_preservation.pycovering: private attrs survive event dispatch, survive reconnect, survive multiple sequential events, coexist with public attrs, handle None/complex/nested values, are excluded for dunder attrs, are excluded for base-class internals, and round-trip through session save/load.Layout flash on pre-rendered mount: defer
reinitAfterDOMUpdateviarequestAnimationFrame(#619, fixes #618) — Carry-over bugfix from v0.4.1. When a page is pre-rendered via HTTP GET, the WebSocket mount used to callreinitAfterDOMUpdate()synchronously right after stampingdj-idattributes onto the existing DOM. That synchronous call triggered a full DOM traversal for event binding, which forced the browser to recalculate layout mid-paint — and on pages with large pre-rendered elements (e.g. big dashboard stat values) the elements briefly rendered at the wrong size before settling, producing a visible layout-flash on every initial load. The fix moves the post-mount block (reinit +_mountReadyflag + form recovery + auto-recover) into arunPostMountclosure and schedules it viarequestAnimationFrame(runPostMount)when available, falling back to a synchronous call whenrequestAnimationFrameis unavailable (JSDOM tests, exotic non-browser environments). Event binding now happens after the browser finishes its current paint, eliminating the flash entirely. The ordering invariant (reinit →_mountReady→ form recovery) is preserved inside the closure sodj-mountedhandlers and recovered form inputs still see bound event listeners. The non-prerendereddata.htmlinnerHTML-replace branch is unchanged — it already invalidates layout via the full DOM swap so there's no pre-paint to protect. 8 new regression tests intests/js/mount-deferred-reinit.test.jsasserting: the rAF wrapper is present, the synchronous fallback is preserved, the closure is namedrunPostMountfor stable debugging,reinitAfterDOMUpdate()runs before_mountReadyinside the closure,_mountReadyis set inside the closure (not synchronously), form recovery runs only on reconnect inside the closure, the non-prerendered branch calls reinit synchronously, and exactly one call-site ofreinitAfterDOMUpdate()exists in the skipMountHtml branch (so a refactor that reintroduces the sync call would immediately flip red). Closes #619 as superseded and closes the original #618 bug report.Scaffolded projects now default
DEBUG=Falseand generate.env.example(#637) — Carry-over bugfix from v0.4.1. Previously,python -m djust startproject mysiteandpython -m djust new mysiteboth generated asettings.pywithDEBUG = TrueandALLOWED_HOSTS = ["*"]as hardcoded literals. A developer who deployed the scaffolded output without remembering to flip those values ran production with full stack traces, thedjango-insecure-<random>default SECRET_KEY, and a wildcard host allowlist — the exact footgun that A001 (DEBUGenabled) and A014 (ALLOWED_HOSTStoo permissive) flag indjust_audit. Now both scaffold paths (cli.py'scmd_startprojectand the higher-leveldjust.scaffolding.generator.generate_project) emitDEBUG = os.environ.get("DEBUG", "False").lower() in ("true", "1", "yes")andALLOWED_HOSTS = [host.strip() for host in os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if host.strip()]— unconfigured deployments fail safe. The scaffold also writes a.env.exampletemplate alongside.gitignore(which already ignores.env) so local development picks up developer-friendly values viacp .env.example .env+ whatever.envloader the developer uses. The.env.exampleincludesDEBUG=True, a freshly-generatedSECRET_KEYtoken (viasecrets.token_urlsafe(50)), andALLOWED_HOSTS=localhost,127.0.0.1so the local experience hasn't changed. 4 new regression tests inpython/tests/test_cli_scaffold.pyasserting:DEBUG = Trueis no longer literal,DEBUGreads from env with"False"fallback,ALLOWED_HOSTS = ["*"]is no longer literal, narrowlocalhost,127.0.0.1env default,.env.exampleexists with the three documented vars and a real (not template-placeholder) secret key,.envremains in.gitignorewhile.env.exampledoes not. Closes #637.
Added
TutorialMixin+TutorialStep+{% tutorial_bubble %}— declarative guided tours (ADR-002 Phase 1c) — Capstone of ADR-002 Phase 1: a one-import, zero-JavaScript way for any djust app to ship a real guided tour, onboarding flow, or wizard. Apps declare the tour as a list ofTutorialStepdataclasses on aLiveViewthat mixes inTutorialMixin; the framework runs the state machine as a@backgroundtask, pushing a highlight + narrate + focus chain at each step's target viapush_commands(Phase 1a), then eitherasyncio.sleep'ing for auto-advance steps orawaitingwait_for_event(Phase 1b) until the user actually fires the matching@event_handler. Four event handlers come for free —start_tutorial,skip_tutorial,cancel_tutorial,restart_tutorial— along with three instance attributes (tutorial_running,tutorial_current_step,tutorial_total_steps) for progress display in the view state.TutorialStepsupports per-steptarget(CSS selector, required),message(narration text),position(top/bottom/left/rightbubble hint),wait_for(handler name to suspend on),timeout(seconds — pairs withwait_forfor bounded waits or used alone for auto-advance),on_enter/on_exit(optional extraJSChainpushes for per-step setup/teardown beyond the default highlight + narrate + focus), andhighlight_class/narrate_event(override per-step CSS class and CustomEvent name when you need different visual treatment). Skip and cancel signals are raced against the wait viaasyncio.wait(..., return_when=FIRST_COMPLETED)so either unblocks the current step immediately; WebSocket disconnect cancels the background task automatically so there's no lingering work, no leaked waiters, no stuck highlights. A new{% tutorial_bubble %}template tag renders a floating narration bubble that listens fortour:narrateCustomEvents atdocumentlevel (dispatched at the step's target withbubbles: true), positions itself next to the target per the step'spositionhint, displaysstep N / totalprogress, and includes "Skip" and "Close" buttons pre-bound to the mixin's event handlers — the default bubble is markeddj-update="ignore"so morphdom doesn't clobber it during VDOM patches. The new client-sidesrc/28-tutorial-bubble.jsmodule (~140 lines, bringsclient.jsto 30 modules) registers its listeners unconditionally at IIFE time, readsdetail.text/target/position/step/totalfrom the event, and updates the bubble's text + progress + position + visibility. The framework ships no CSS — apps style the bubble and highlight class themselves (the guide includes a minimal starter block). 26 new Python tests for the mixin covering TutorialStep dataclass (minimal, custom position, invalid position, empty target, empty message, wait_for+timeout, on_enter/on_exit), lifecycle (initial state, empty-steps no-op, single step, setup+cleanup chain order, multi-step order, idempotent start-while-running),wait_for_eventintegration (step suspends on user action, timeout advances silently, indefinite wait), skip/cancel paths (advance past current, abort loop, no-op when not running),on_enter/on_exitpushes, per-step highlight class override, and per-step narrate event override. 9 new Python tests for thetutorial_bubbletemplate tag covering defaults, customcss_class/event/position, invalid-position fallback to"bottom", skip+cancel button bindings, text/progress element classes, and XSS escaping of hostilecss_classandeventkwargs. 12 new JS tests intests/js/tutorial-bubble.test.jscovering listener registration, text content updates, progress text updates, show/hide viadata-visible, default/custom position application, missing-target graceful handling, missing-bubble graceful handling,tour:hideevent, and repeated updates on subsequent events. Zero new runtime dependencies — stdlibasyncio+dataclasses+ Django'sformat_html. Full documentation in the newdocs/website/guides/tutorials.mdguide with the simplest-possible example, state-machine description,TutorialStepreference,wait_for/timeoutcombinations table,on_enter/on_exitpatterns, the bubble template tag docs, a starter CSS block, four usage patterns (auto-advance walkthrough, interactive onboarding, mixed, branching with custom handlers), skip/cancel UX, disconnect cleanup, debugging tips, and honest limitations (LiveComponent events don't propagate to parent waiters yet, actor-mode views bypass the dispatch hook, handler validation failures prevent the waiter from resolving except via timeout, single-user only — multi-user broadcast is Phase 4 in v0.5.x).await self.wait_for_event(name, timeout=None, predicate=None)async primitive (ADR-002 Phase 1b) — Second half of the backend-driven UI Phase 1 primitives. Adds a newWaiterMixin(automatically included inLiveView) that lets a@backgroundhandler suspend until a specific@event_handleris called by the user, optionally filtered by a predicate, optionally bounded by a timeout. The returned dict is the kwargs that were passed to the matching handler. This is the primitive that makes "highlight this button, wait for the user to actually click it, then advance to the next step" work declaratively — required byTutorialMixin(Phase 1c) and by any server-driven flow that needs to pause mid-plan until real user input arrives. Implementation: ~180 lines inpython/djust/mixins/waiters.py, a ~15-line hook inpython/djust/websocket.pythat calls_notify_waitersafter every successful handler invocation, a ~10-line cleanup hook in the WebSocketdisconnectpath that cancels all pending waiters when the view tears down (so@backgroundtasks unblock withCancelledErrorinstead of leaking), and proper integration intoLiveView's MRO viapython/djust/mixins/__init__.py. The notify pass runs AFTER the handler completes so waiters created during a handler call aren't self-notified (prevents re-entrancy surprises wherewait_for_event("X")inside anXhandler would resolve against itself). Multiple concurrent waiters for the same event name all resolve with the same kwargs dict when that event fires — fan-out patterns work without manual coordination. Waiters for different event names are fully independent. A predicate that raises is treated as "no match" and logged via thedjust.waiterslogger, so a buggy predicate can't crash the event pipeline or deadlock a background task. 18 new Python tests covering: basic resolution, kwargs copy semantics, no-op on unmatched names, predicate filtering, predicate-that-raises treated as False with warning log, predicate=None matches any kwargs, timeout raisesasyncio.TimeoutError, expired waiters removed from registry, indefinite waits without timeout, concurrent waiters for same event all resolve, waiters for different events are independent, partial resolution (some predicates match, others don't),_cancel_all_waitersunblocks pending futures withCancelledErrorand clears the registry, task cancellation removes the waiter, and stability under mid-iteration waiter-list mutation. Full documentation in the existingdocs/website/guides/server-driven-ui.mdguide with signature, predicate examples, concurrency semantics, timeouts and cleanup, composition withpush_commands, and honest limitations (no component-event support yet, actor mode bypasses the hook, validation failures prevent handler execution which means waiters never resolve except via timeout).LiveView.push_commands(chain)+djust:execclient-side auto-executor (ADR-002 Phase 1a) — First half of the backend-driven UI primitives proposed in ADR-002. Adds a one-line server-side helperself.push_commands(chain)that takes adjust.js.JSChain(shipped in v0.4.1 as the JS Commands fluent API) and pushes it to the current session as adjust:execpush event carrying the chain's JSON-serializedopslist. The client half is a new framework-providedsrc/27-exec-listener.jsmodule that listens fordjust:push_eventCustomEvents onwindow, filters forevent === 'djust:exec', and runs the ops viawindow.djust.js._executeOps(ops, document.body)— the same function that runs inlinedj-click="[[...]]"JSON chains and fluent-API.exec()calls from hook code. No hook registration, no template markup, no user setup required: the auto-executor ships bound withclient.jsand is active on every djust page automatically. The server-side helper is type-safe — it rejects anything that isn't aJSChainwith a clearTypeErrorpointing at theJS.*factory methods, preventing raw ops-list smuggling through thepush_eventpath.push_commandsandpush_eventshare the same queue and preserve ordering, so handlers can interleave "push a flash message, add a CSS class, fire analytics, run an animation" in one deterministic sequence. 23 new Python tests covering single-op chains, multi-op ordering, empty chains, JSON round-trip, immutability of chains after push, type validation against strings/dicts/lists/None, queue composition withpush_event, and per-op factory parity across all 11 JS Commands. 13 new JS tests intests/js/exec-listener.test.jscovering listener registration, single-op execution, multi-op ordering, multiple-classadd_class,focus,dispatchwith detail, filtering for non-djust:execevents, malformed-payload rejection (missingops, non-arrayops, missing detail), error resilience (one bad op doesn't break the chain), multiple independent exec fires, and end-to-end integration with the fluentwindow.djust.jschain factory. Zero new runtime dependencies. Full documentation indocs/website/guides/server-driven-ui.mdwith patterns, debugging tips, and pointers to Phase 1b (wait_for_event) and Phase 1c (TutorialMixin) still to come in v0.4.2.