diff --git a/CHANGELOG.md b/CHANGELOG.md
index 042aec7..df86e4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,37 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
+- **`js.paste_into(selector)` — the clipboard-source trigger (#228).** A field
+ whose real `` is visually hidden (an OTP cell UI painted by a
+ `reactive_compute` reducer) has no mouse-reachable paste path: right-click →
+ Paste targets the decorative cells, never the off-screen input. One declared
+ line — `button(hidden: true, **on_client(:click, js.paste_into("[name=code]")))
+ { "Paste code" }` — replaces the bespoke Stimulus controller:
+ - **The op** (the one async, value-reading member of the vocabulary): on the
+ user's gesture it starts `navigator.clipboard.readText()` fire-and-forget
+ (the browser's own permission UX — Chromium prompts, Safari shows its
+ paste pill) and, when the read resolves, feeds the text through the
+ **normal `input` pipeline** — set `.value`,
+ dispatch a bubbling `input` (reducers / `reactive_show` /
+ `reactive_on_complete` run exactly as if typed), then focus the field so a
+ partial paste continues from the caret. A denied/dismissed read, empty
+ text, or a missing API is a **silent no-op**; the op is fire-and-forget,
+ so chained siblings never wait.
+ - **Availability gating**: `on_client` marks a paste trigger with
+ `data-reactive-clipboard`; on connect (and every `turbo:morph-element`)
+ the controller sets `hidden = !available` on owned markers. Author the
+ trigger `hidden` and it is revealed only where the Async Clipboard API
+ exists — a dead button never shows in insecure contexts or webviews.
+ - **Actor-only, default-deny**: `paste_into` joins `focus`/`focus_first`/
+ `submit` in `BROADCAST_REFUSED_OPS` — `broadcast_to(js:)` raises
+ (a broadcast that reads every subscriber's clipboard would be hostile);
+ `reply.js` and gesture paths remain allowed.
+ - Dummy flagship: the `VerificationCodeComponent` OTP field gains the
+ hidden "Paste code" trigger — paste drives the otp reducer's normalize +
+ `$ops` auto-submit end to end, with a real-browser system spec covering
+ reveal-on-connect, dirty-paste auto-commit, partial-paste focus, and the
+ denied-read no-op.
+
- **Reducer-emitted client ops (`$ops`), a `submit` op, and declarative
`reactive_on_complete` (#226).** The "normalize on input, commit when
complete" field (a one-time-code entry being the canonical case) is now
diff --git a/README.md b/README.md
index 5ec821a..5de9ac1 100644
--- a/README.md
+++ b/README.md
@@ -385,7 +385,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
| `busy_on(:save)` | Mark any element so it carries `data-reactive-busy` **only while `save` is in flight** — a spinner styled with pure CSS, zero Ruby. See [Loading states](#declarative-loading-states-loading--disable_with). |
| `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
| `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers. See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
-| `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), and `dispatch` — chainable. |
+| `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form), and `paste_into` (read the clipboard into a field, gesture-gated) — chainable. |
| `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. |
| `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
| `reactive_show(if:/if_any:/unless:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the fields' **current values**, client-only, zero round trip. One conditions language: a **Hash is an AND**, an **Array is membership**, a **Range is a threshold**, `if_any:` is OR-of-AND, `unless:` negates. `reactive_values` computes first paint; `disable:` disables a hidden section's controls. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
@@ -863,6 +863,25 @@ button(**on_client(:click, js
`broadcast_to(js:)` (a broadcast would force-submit every subscriber's form).
Binding a submit op to the `submit` event itself raises at render — it would
re-fire itself forever.
+- **`paste_into(to)`** reads the clipboard into a field on a **user gesture**
+ (issue #228): `navigator.clipboard.readText()`, then the field gets the text
+ through the **normal `input` pipeline** — `.value` is set, a bubbling `input`
+ event fires (compute reducers, `reactive_show`, `reactive_on_complete` all run
+ exactly as if the user had typed), and the field is focused so a partial paste
+ continues from the caret. Built for fields whose real `` is visually
+ hidden (an OTP cell UI), where right-click → Paste can't reach the editable
+ input. The permission UX is the **browser's own** (Chromium prompts, Safari
+ shows its paste pill); a denied/dismissed read, empty text, or a missing API
+ is a **silent no-op**. `on_client` marks the trigger with
+ `data-reactive-clipboard` and the controller sets `hidden = !available` on
+ connect — author the trigger `hidden` and it is revealed only where the
+ clipboard API exists, so a dead button never shows. The gate **owns** the
+ trigger's `hidden` flag: render the trigger unconditionally and don't also
+ bind `reactive_show` to it (the two passes would fight over the same
+ attribute). **Actor-only like focus/submit**: refused in `broadcast_to(js:)`
+ (a broadcast that reads every subscriber's clipboard would be hostile). The
+ op is async fire-and-forget — chained siblings apply immediately, never
+ waiting for the read.
- **`transition: { during:, from:, to: }`** on `show`/`hide`/`toggle` animates the
visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next
frame, and the helper classes are cleaned up on `animationend` (with a timeout
@@ -1136,7 +1155,9 @@ setComputeReducer("preview", ({ title }) => ({
write fields and text; the reserved **`$ops`** key lets it emit a **conditional
side effect** — the missing piece that used to force a bespoke controller next
to an otherwise-declarative compute. Return an op chain (the `ops` builder
-mirrors the Ruby `js` verbs, or use a raw `[[op, args], …]` array) and the
+mirrors the Ruby `js` verbs — minus `paste_into`, deliberately: a reducer runs
+on every input event, and a clipboard read per keystroke would spam permission
+prompts — or use a raw `[[op, args], …]` array) and the
controller runs it through the **same frozen op whitelist** `on_client` uses,
as a final phase **after** the field writes, text sinks, and their dispatched
`input` events settle. The canonical one-time-code field:
@@ -1977,10 +1998,12 @@ Notifications::Badge.broadcast_to(user, :alerts,
js: js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
```
-`broadcast_to` with `js:` **refuses focus-class ops** (`focus`/`focus_first` raise
-`ArgumentError`): broadcasting focus would steal it in every subscriber's tab, so
-focus is an actor-reply concern only. Everything else is a fair broadcast (class
-and attribute toggles, `dispatch`). As with `on_client`, the ops are
+`broadcast_to` with `js:` **refuses the actor-only ops** (`focus`/`focus_first`/
+`submit`/`paste_into` raise `ArgumentError`): broadcasting focus would steal it
+in every subscriber's tab, a broadcast submit would force-submit every
+subscriber's form, and a broadcast clipboard read would be hostile — these
+belong to the actor's own reply or gesture. Everything else is a fair broadcast
+(class and attribute toggles, `text`, `dispatch`). As with `on_client`, the ops are
whitelist-interpreted client-side — an unknown op warns and is skipped — and the
ops attribute is HTML-escaped, so a value can't break out of it. `reactive:js`
is not a self-render: it never counts toward the token refresh, so the reply's
diff --git a/app/javascript/phlex/reactive/compute.js b/app/javascript/phlex/reactive/compute.js
index 0be1d75..fbf0995 100644
--- a/app/javascript/phlex/reactive/compute.js
+++ b/app/javascript/phlex/reactive/compute.js
@@ -103,6 +103,11 @@
// event fires, so an on(:verify, event: "submit") interception (or a native/
// Turbo form) handles it exactly like a user submit. submit/focus are
// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.
+// paste_into (issue #228) is actor-only too but DELIBERATELY absent from this
+// builder: a reducer runs on every input event, and a clipboard read per
+// keystroke (each changed chain fires) would spam permission prompts. Use it
+// from on_client / reply.js / reactive_on_complete instead; a raw
+// [["paste_into", …]] pair still interprets if you truly need it.
const reducers = new Map()
@@ -127,7 +132,8 @@ export function __resetComputeRegistryForTest() {
// --- The reducer-side op-chain builder (issue #226) --------------------------
//
-// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder: verbs carry
+// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus
+// paste_into — see the actor-only note above): verbs carry
// the WIRE op names (snake_case) and append [name, args] pairs; every verb
// returns a NEW instance, so a chain held in a constant can never be mutated by
// later use. `.ops` exposes the raw [[name, args], ...] list the controller
diff --git a/app/javascript/phlex/reactive/compute.min.js.map b/app/javascript/phlex/reactive/compute.min.js.map
index d22a2a4..c42258d 100644
--- a/app/javascript/phlex/reactive/compute.min.js.map
+++ b/app/javascript/phlex/reactive/compute.min.js.map
@@ -2,9 +2,9 @@
"version": 3,
"sources": ["compute.js"],
"sourcesContent": [
- "// The client-side compute (data-binding) registry — the \"instant\" half of the\n// new/unpersisted-record UX.\n//\n// A record-backed reactive component round-trips every change to the server\n// (the signed identity re-finds the record; the server re-renders). A NEW,\n// unpersisted record has no such server truth to re-render against on every\n// keystroke — the classic answer is a bespoke Stimulus controller doing the math\n// in the browser (carlqvist's new_order_controller.js). This registry lets that\n// math be a DECLARED part of the component instead: `reactive_compute :name,\n// inputs:, outputs:` (Ruby) names a reducer registered here, and the generic\n// reactive controller runs it on `input` — writing the outputs with NO round\n// trip. When the component ALSO carries on(...) (a persisted record, or a draft\n// you sync), the debounced POST reconciles from the authoritative server reply.\n//\n// The seam mirrors confirm.js: a settable registry with a lookup the controller\n// calls. Register once at boot:\n//\n// import { setComputeReducer } from \"phlex/reactive/compute\"\n// setComputeReducer(\"payment_split\", ({ allowance, cash, leasing, total }) => ({\n// allowance, leasing, cash: total - allowance - leasing,\n// }))\n//\n// The reducer's signature is (values, meta):\n//\n// values — a plain object of { inputName: value } over the declared inputs.\n// Untyped inputs (the array form) AND :number-typed inputs arrive as\n// Numbers (blank/NaN → 0); :string-typed inputs (the hash form,\n// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:\n// { title: :string, qty: :number }` is what selects per-input types;\n// `inputs: %i[a b]` stays all-numeric (backward compatible).\n// meta — { changed }: the name (string) of the declared input the\n// triggering event edited, or null (a direct recompute() call, or a\n// target this root doesn't own / didn't declare as an input).\n//\n// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name\n// matches an owned control writes its .value (+ the change-guarded input\n// dispatch below). An output with NO matching field writes textContent to every\n// owned [data-reactive-text=\"\"] node (reactive_text(:name)) — XSS-safe by\n// construction, change-guarded, NO input dispatch (a text node has no listener\n// contract). A declared INPUT also mirrors into its own text node on every\n// input via an always-run pass — so reactive_text(:title) is a live field\n// preview with NO registered reducer at all.\n//\n// It returns a plain object of { outputName: value } — only the outputs it\n// names are written, so it can leave the edited field (and its caret)\n// untouched. A one-argument reducer keeps working unchanged (it just ignores\n// meta). `changed` is what makes a MULTI-WAY / MUTUAL rebalance expressible as\n// one reducer (issue #75) — branch on which field the user edited:\n//\n// setComputeReducer(\"three_way_split\", ({ field_a, field_b, field_c, total }, { changed }) => {\n// if (changed === \"field_c\") return { field_a: total - field_c - field_b }\n// return { field_c: total - field_a - field_b }\n// })\n//\n// Output writes are CHANGE-GUARDED: the controller writes a field and\n// dispatches a bubbling `input` event on it ONLY when the new value differs\n// from the field's current value (real browsers never fire `input` on a\n// programmatic .value write, so the controller dispatches explicitly — that's\n// what drives a chained summary repaint, matching the server's set_value +\n// dispatch(\"input\") contract). Returning the SAME value a field already holds\n// is skipped entirely — no write, no event — which is why a reducer with\n// overlapping inputs/outputs (like payment_split above) settles instead of\n// re-entering itself forever.\n//\n// CONVERGENCE REQUIREMENT: because an output write dispatches a REAL input\n// event (issue #76), recompute re-enters with changed = that OUTPUT field's\n// name (when it's also a declared input). A branching reducer must therefore\n// be convergent: the re-entrant pass must compute values EQUAL to what the\n// first pass already wrote to the DOM, so the change guard settles the chain.\n// The three_way_split above is: after `changed === \"field_a\"` writes field_c,\n// the re-entrant `changed === \"field_c\"` pass derives field_a back to the\n// value it already holds — no write, no event, settled in one bounce.\n\n// THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a\n// reducer may return the reserved key `$ops` holding a chain of client DOM\n// ops — built with the `ops` builder below, or a raw [[name, args], ...]\n// array. The controller consumes it as a PHASE 4 of the single-pass write set:\n// the ops run AFTER the field writes, text sinks, and phase-3 input dispatches\n// settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an\n// unknown op warns + is skipped). `null`/`undefined`/absent = no effect.\n//\n// RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it\n// DIFFERS from the previous pass's chain (including from \"absent\"), and only\n// on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no\n// re-fire (a 7th keystroke capped back to the same complete value can't\n// re-submit), mirroring the change-guarded field writes. Returning a\n// DIFFERENT chain fires again — a multi-box reducer advancing focus\n// box-by-box emits a new focus target per digit, each a new intent. The\n// connect/morph SEED pass (issue #199 — recompute with no event) ARMS the\n// latch but never fires — a form re-rendered with an already-complete value\n// (a validation-error morph, a browser restore) must not auto-fire; that is\n// what breaks the submit → error re-render → re-seed → submit loop. A later\n// pass returning no $ops re-arms. The canonical use — a one-time-code field\n// that normalizes on input and commits when complete:\n//\n// import { setComputeReducer, ops } from \"phlex/reactive/compute\"\n// setComputeReducer(\"otp\", ({ code }) => {\n// const digits = code.replace(/\\D/g, \"\").slice(0, 6)\n// return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }\n// })\n//\n// `submit` commits the target's own form via requestSubmit() — the real submit\n// event fires, so an on(:verify, event: \"submit\") interception (or a native/\n// Turbo form) handles it exactly like a user submit. submit/focus are\n// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.\n\nconst reducers = new Map()\n\n// Register (or replace) the reducer for `key`. `fn` is\n// (values: Record, meta: { changed: string | null })\n// => Record.\nexport function setComputeReducer(key, fn) {\n reducers.set(key, fn)\n}\n\n// Look up a registered reducer; undefined when none — the controller then makes\n// #recompute a no-op rather than throwing (a missing reducer must not break the\n// page; it just means no client-side binding for that root).\nexport function computeReducer(key) {\n return reducers.get(key)\n}\n\n// Test seam: clear the registry so a reducer registered in one test can't leak.\nexport function __resetComputeRegistryForTest() {\n reducers.clear()\n}\n\n// --- The reducer-side op-chain builder (issue #226) --------------------------\n//\n// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder: verbs carry\n// the WIRE op names (snake_case) and append [name, args] pairs; every verb\n// returns a NEW instance, so a chain held in a constant can never be mutated by\n// later use. `.ops` exposes the raw [[name, args], ...] list the controller\n// interprets (and toJSON serializes it, so a chain can also feed a hand-built\n// ops attr). Targets: a CSS selector string, or omit for \"@root\" (the\n// component's own root). No build-time attr validation here — the interpreter's\n// allowlist (guardAttr) is the enforcement point; this builder only shapes the\n// wire.\nconst ROOT_SENTINEL = \"@root\"\n\nfunction targetArgs(to, { global, transition } = {}) {\n const args = { to: to ?? ROOT_SENTINEL }\n if (global) args.global = true\n if (transition) args.transition = normalizeTransition(transition)\n return args\n}\n\n// Named legs { during, from, to } → the [during, from, to] wire array (the\n// issue #186 vocabulary). Loud at authoring time, like the Ruby builder.\n// Frozen, like every nested payload — the chain's immutability contract must\n// hold all the way down (the Ruby twin freezes its legs array too).\nfunction normalizeTransition(transition) {\n const named =\n transition && typeof transition === \"object\" && !Array.isArray(transition) &&\n [\"during\", \"from\", \"to\"].every((k) => k in transition)\n if (!named) throw new Error(\"[phlex-reactive] ops transition takes named legs { during, from, to }\")\n return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])\n}\n\nclass OpsChain {\n constructor(list = Object.freeze([])) {\n this.ops = list\n Object.freeze(this)\n }\n\n show(to, opts) {\n return this.#append(\"show\", targetArgs(to, opts))\n }\n\n hide(to, opts) {\n return this.#append(\"hide\", targetArgs(to, opts))\n }\n\n toggle(to, opts) {\n return this.#append(\"toggle\", targetArgs(to, opts))\n }\n\n add_class(to, classes, opts) {\n return this.#append(\"add_class\", classArgs(to, classes, opts))\n }\n\n remove_class(to, classes, opts) {\n return this.#append(\"remove_class\", classArgs(to, classes, opts))\n }\n\n toggle_class(to, classes, opts) {\n return this.#append(\"toggle_class\", classArgs(to, classes, opts))\n }\n\n set_attr(to, name, value, opts) {\n return this.#append(\"set_attr\", { ...targetArgs(to, opts), name: String(name), value: String(value) })\n }\n\n remove_attr(to, name, opts) {\n return this.#append(\"remove_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n toggle_attr(to, name, opts) {\n return this.#append(\"toggle_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n focus(to, opts) {\n return this.#append(\"focus\", targetArgs(to, opts))\n }\n\n focus_first(to, opts) {\n return this.#append(\"focus_first\", targetArgs(to, opts))\n }\n\n text(to, value, opts) {\n return this.#append(\"text\", { ...targetArgs(to, opts), value: String(value ?? \"\") })\n }\n\n dispatch(name, { to, detail, global } = {}) {\n const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }\n if (global) args.global = true\n return this.#append(\"dispatch\", args)\n }\n\n submit(to, opts) {\n return this.#append(\"submit\", targetArgs(to, opts))\n }\n\n toJSON() {\n return this.ops\n }\n\n #append(name, args) {\n return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))\n }\n}\n\n// classes: one class string or an array of them (never whitespace-split — a\n// classList token can't contain spaces, so splitting would only mask a bug).\n// Loud on an empty/missing list, and frozen (the Ruby twin freezes its class\n// list too) — a chain held in a constant must stay immutable all the way down.\nfunction classArgs(to, classes, opts) {\n const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)\n if (list.length === 0) throw new Error(\"[phlex-reactive] a class op needs at least one class\")\n return { ...targetArgs(to, opts), classes: Object.freeze(list) }\n}\n\n// The shared empty chain — start every reducer effect from here:\n// $ops: done ? ops.dispatch(\"code:complete\").submit() : null\nexport const ops = new OpsChain()\n"
+ "// The client-side compute (data-binding) registry — the \"instant\" half of the\n// new/unpersisted-record UX.\n//\n// A record-backed reactive component round-trips every change to the server\n// (the signed identity re-finds the record; the server re-renders). A NEW,\n// unpersisted record has no such server truth to re-render against on every\n// keystroke — the classic answer is a bespoke Stimulus controller doing the math\n// in the browser (carlqvist's new_order_controller.js). This registry lets that\n// math be a DECLARED part of the component instead: `reactive_compute :name,\n// inputs:, outputs:` (Ruby) names a reducer registered here, and the generic\n// reactive controller runs it on `input` — writing the outputs with NO round\n// trip. When the component ALSO carries on(...) (a persisted record, or a draft\n// you sync), the debounced POST reconciles from the authoritative server reply.\n//\n// The seam mirrors confirm.js: a settable registry with a lookup the controller\n// calls. Register once at boot:\n//\n// import { setComputeReducer } from \"phlex/reactive/compute\"\n// setComputeReducer(\"payment_split\", ({ allowance, cash, leasing, total }) => ({\n// allowance, leasing, cash: total - allowance - leasing,\n// }))\n//\n// The reducer's signature is (values, meta):\n//\n// values — a plain object of { inputName: value } over the declared inputs.\n// Untyped inputs (the array form) AND :number-typed inputs arrive as\n// Numbers (blank/NaN → 0); :string-typed inputs (the hash form,\n// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:\n// { title: :string, qty: :number }` is what selects per-input types;\n// `inputs: %i[a b]` stays all-numeric (backward compatible).\n// meta — { changed }: the name (string) of the declared input the\n// triggering event edited, or null (a direct recompute() call, or a\n// target this root doesn't own / didn't declare as an input).\n//\n// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name\n// matches an owned control writes its .value (+ the change-guarded input\n// dispatch below). An output with NO matching field writes textContent to every\n// owned [data-reactive-text=\"\"] node (reactive_text(:name)) — XSS-safe by\n// construction, change-guarded, NO input dispatch (a text node has no listener\n// contract). A declared INPUT also mirrors into its own text node on every\n// input via an always-run pass — so reactive_text(:title) is a live field\n// preview with NO registered reducer at all.\n//\n// It returns a plain object of { outputName: value } — only the outputs it\n// names are written, so it can leave the edited field (and its caret)\n// untouched. A one-argument reducer keeps working unchanged (it just ignores\n// meta). `changed` is what makes a MULTI-WAY / MUTUAL rebalance expressible as\n// one reducer (issue #75) — branch on which field the user edited:\n//\n// setComputeReducer(\"three_way_split\", ({ field_a, field_b, field_c, total }, { changed }) => {\n// if (changed === \"field_c\") return { field_a: total - field_c - field_b }\n// return { field_c: total - field_a - field_b }\n// })\n//\n// Output writes are CHANGE-GUARDED: the controller writes a field and\n// dispatches a bubbling `input` event on it ONLY when the new value differs\n// from the field's current value (real browsers never fire `input` on a\n// programmatic .value write, so the controller dispatches explicitly — that's\n// what drives a chained summary repaint, matching the server's set_value +\n// dispatch(\"input\") contract). Returning the SAME value a field already holds\n// is skipped entirely — no write, no event — which is why a reducer with\n// overlapping inputs/outputs (like payment_split above) settles instead of\n// re-entering itself forever.\n//\n// CONVERGENCE REQUIREMENT: because an output write dispatches a REAL input\n// event (issue #76), recompute re-enters with changed = that OUTPUT field's\n// name (when it's also a declared input). A branching reducer must therefore\n// be convergent: the re-entrant pass must compute values EQUAL to what the\n// first pass already wrote to the DOM, so the change guard settles the chain.\n// The three_way_split above is: after `changed === \"field_a\"` writes field_c,\n// the re-entrant `changed === \"field_c\"` pass derives field_a back to the\n// value it already holds — no write, no event, settled in one bounce.\n\n// THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a\n// reducer may return the reserved key `$ops` holding a chain of client DOM\n// ops — built with the `ops` builder below, or a raw [[name, args], ...]\n// array. The controller consumes it as a PHASE 4 of the single-pass write set:\n// the ops run AFTER the field writes, text sinks, and phase-3 input dispatches\n// settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an\n// unknown op warns + is skipped). `null`/`undefined`/absent = no effect.\n//\n// RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it\n// DIFFERS from the previous pass's chain (including from \"absent\"), and only\n// on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no\n// re-fire (a 7th keystroke capped back to the same complete value can't\n// re-submit), mirroring the change-guarded field writes. Returning a\n// DIFFERENT chain fires again — a multi-box reducer advancing focus\n// box-by-box emits a new focus target per digit, each a new intent. The\n// connect/morph SEED pass (issue #199 — recompute with no event) ARMS the\n// latch but never fires — a form re-rendered with an already-complete value\n// (a validation-error morph, a browser restore) must not auto-fire; that is\n// what breaks the submit → error re-render → re-seed → submit loop. A later\n// pass returning no $ops re-arms. The canonical use — a one-time-code field\n// that normalizes on input and commits when complete:\n//\n// import { setComputeReducer, ops } from \"phlex/reactive/compute\"\n// setComputeReducer(\"otp\", ({ code }) => {\n// const digits = code.replace(/\\D/g, \"\").slice(0, 6)\n// return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }\n// })\n//\n// `submit` commits the target's own form via requestSubmit() — the real submit\n// event fires, so an on(:verify, event: \"submit\") interception (or a native/\n// Turbo form) handles it exactly like a user submit. submit/focus are\n// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.\n// paste_into (issue #228) is actor-only too but DELIBERATELY absent from this\n// builder: a reducer runs on every input event, and a clipboard read per\n// keystroke (each changed chain fires) would spam permission prompts. Use it\n// from on_client / reply.js / reactive_on_complete instead; a raw\n// [[\"paste_into\", …]] pair still interprets if you truly need it.\n\nconst reducers = new Map()\n\n// Register (or replace) the reducer for `key`. `fn` is\n// (values: Record, meta: { changed: string | null })\n// => Record.\nexport function setComputeReducer(key, fn) {\n reducers.set(key, fn)\n}\n\n// Look up a registered reducer; undefined when none — the controller then makes\n// #recompute a no-op rather than throwing (a missing reducer must not break the\n// page; it just means no client-side binding for that root).\nexport function computeReducer(key) {\n return reducers.get(key)\n}\n\n// Test seam: clear the registry so a reducer registered in one test can't leak.\nexport function __resetComputeRegistryForTest() {\n reducers.clear()\n}\n\n// --- The reducer-side op-chain builder (issue #226) --------------------------\n//\n// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus\n// paste_into — see the actor-only note above): verbs carry\n// the WIRE op names (snake_case) and append [name, args] pairs; every verb\n// returns a NEW instance, so a chain held in a constant can never be mutated by\n// later use. `.ops` exposes the raw [[name, args], ...] list the controller\n// interprets (and toJSON serializes it, so a chain can also feed a hand-built\n// ops attr). Targets: a CSS selector string, or omit for \"@root\" (the\n// component's own root). No build-time attr validation here — the interpreter's\n// allowlist (guardAttr) is the enforcement point; this builder only shapes the\n// wire.\nconst ROOT_SENTINEL = \"@root\"\n\nfunction targetArgs(to, { global, transition } = {}) {\n const args = { to: to ?? ROOT_SENTINEL }\n if (global) args.global = true\n if (transition) args.transition = normalizeTransition(transition)\n return args\n}\n\n// Named legs { during, from, to } → the [during, from, to] wire array (the\n// issue #186 vocabulary). Loud at authoring time, like the Ruby builder.\n// Frozen, like every nested payload — the chain's immutability contract must\n// hold all the way down (the Ruby twin freezes its legs array too).\nfunction normalizeTransition(transition) {\n const named =\n transition && typeof transition === \"object\" && !Array.isArray(transition) &&\n [\"during\", \"from\", \"to\"].every((k) => k in transition)\n if (!named) throw new Error(\"[phlex-reactive] ops transition takes named legs { during, from, to }\")\n return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])\n}\n\nclass OpsChain {\n constructor(list = Object.freeze([])) {\n this.ops = list\n Object.freeze(this)\n }\n\n show(to, opts) {\n return this.#append(\"show\", targetArgs(to, opts))\n }\n\n hide(to, opts) {\n return this.#append(\"hide\", targetArgs(to, opts))\n }\n\n toggle(to, opts) {\n return this.#append(\"toggle\", targetArgs(to, opts))\n }\n\n add_class(to, classes, opts) {\n return this.#append(\"add_class\", classArgs(to, classes, opts))\n }\n\n remove_class(to, classes, opts) {\n return this.#append(\"remove_class\", classArgs(to, classes, opts))\n }\n\n toggle_class(to, classes, opts) {\n return this.#append(\"toggle_class\", classArgs(to, classes, opts))\n }\n\n set_attr(to, name, value, opts) {\n return this.#append(\"set_attr\", { ...targetArgs(to, opts), name: String(name), value: String(value) })\n }\n\n remove_attr(to, name, opts) {\n return this.#append(\"remove_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n toggle_attr(to, name, opts) {\n return this.#append(\"toggle_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n focus(to, opts) {\n return this.#append(\"focus\", targetArgs(to, opts))\n }\n\n focus_first(to, opts) {\n return this.#append(\"focus_first\", targetArgs(to, opts))\n }\n\n text(to, value, opts) {\n return this.#append(\"text\", { ...targetArgs(to, opts), value: String(value ?? \"\") })\n }\n\n dispatch(name, { to, detail, global } = {}) {\n const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }\n if (global) args.global = true\n return this.#append(\"dispatch\", args)\n }\n\n submit(to, opts) {\n return this.#append(\"submit\", targetArgs(to, opts))\n }\n\n toJSON() {\n return this.ops\n }\n\n #append(name, args) {\n return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))\n }\n}\n\n// classes: one class string or an array of them (never whitespace-split — a\n// classList token can't contain spaces, so splitting would only mask a bug).\n// Loud on an empty/missing list, and frozen (the Ruby twin freezes its class\n// list too) — a chain held in a constant must stay immutable all the way down.\nfunction classArgs(to, classes, opts) {\n const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)\n if (list.length === 0) throw new Error(\"[phlex-reactive] a class op needs at least one class\")\n return { ...targetArgs(to, opts), classes: Object.freeze(list) }\n}\n\n// The shared empty chain — start every reducer effect from here:\n// $ops: done ? ops.dispatch(\"code:complete\").submit() : null\nexport const ops = new OpsChain()\n"
],
- "mappings": "AA0GA,IAAM,EAAW,IAAI,IAKd,SAAS,CAAiB,CAAC,EAAK,EAAI,CACzC,EAAS,IAAI,EAAK,CAAE,EAMf,SAAS,CAAc,CAAC,EAAK,CAClC,OAAO,EAAS,IAAI,CAAG,EAIlB,SAAS,CAA6B,EAAG,CAC9C,EAAS,MAAM,EAcjB,IAAM,EAAgB,QAEtB,SAAS,CAAU,CAAC,GAAM,SAAQ,cAAe,CAAC,EAAG,CACnD,IAAM,EAAO,CAAE,GAAI,GAAM,CAAc,EACvC,GAAI,EAAQ,EAAK,OAAS,GAC1B,GAAI,EAAY,EAAK,WAAa,EAAoB,CAAU,EAChE,OAAO,EAOT,SAAS,CAAmB,CAAC,EAAY,CAIvC,GAAI,EAFF,GAAc,OAAO,IAAe,UAAY,CAAC,MAAM,QAAQ,CAAU,GACzE,CAAC,SAAU,OAAQ,IAAI,EAAE,MAAM,CAAC,KAAM,KAAK,EAAU,GAC3C,MAAU,MAAM,uEAAuE,EACnG,OAAO,OAAO,OAAO,CAAC,OAAO,EAAW,MAAM,EAAG,OAAO,EAAW,IAAI,EAAG,OAAO,EAAW,EAAE,CAAC,CAAC,EAGlG,MAAM,CAAS,CACb,WAAW,CAAC,EAAO,OAAO,OAAO,CAAC,CAAC,EAAG,CACpC,KAAK,IAAM,EACX,OAAO,OAAO,IAAI,EAGpB,IAAI,CAAC,EAAI,EAAM,CACb,OAAO,KAAK,GAAQ,OAAQ,EAAW,EAAI,CAAI,CAAC,EAGlD,IAAI,CAAC,EAAI,EAAM,CACb,OAAO,KAAK,GAAQ,OAAQ,EAAW,EAAI,CAAI,CAAC,EAGlD,MAAM,CAAC,EAAI,EAAM,CACf,OAAO,KAAK,GAAQ,SAAU,EAAW,EAAI,CAAI,CAAC,EAGpD,SAAS,CAAC,EAAI,EAAS,EAAM,CAC3B,OAAO,KAAK,GAAQ,YAAa,EAAU,EAAI,EAAS,CAAI,CAAC,EAG/D,YAAY,CAAC,EAAI,EAAS,EAAM,CAC9B,OAAO,KAAK,GAAQ,eAAgB,EAAU,EAAI,EAAS,CAAI,CAAC,EAGlE,YAAY,CAAC,EAAI,EAAS,EAAM,CAC9B,OAAO,KAAK,GAAQ,eAAgB,EAAU,EAAI,EAAS,CAAI,CAAC,EAGlE,QAAQ,CAAC,EAAI,EAAM,EAAO,EAAM,CAC9B,OAAO,KAAK,GAAQ,WAAY,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,EAAG,MAAO,OAAO,CAAK,CAAE,CAAC,EAGvG,WAAW,CAAC,EAAI,EAAM,EAAM,CAC1B,OAAO,KAAK,GAAQ,cAAe,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,CAAE,CAAC,EAGpF,WAAW,CAAC,EAAI,EAAM,EAAM,CAC1B,OAAO,KAAK,GAAQ,cAAe,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,CAAE,CAAC,EAGpF,KAAK,CAAC,EAAI,EAAM,CACd,OAAO,KAAK,GAAQ,QAAS,EAAW,EAAI,CAAI,CAAC,EAGnD,WAAW,CAAC,EAAI,EAAM,CACpB,OAAO,KAAK,GAAQ,cAAe,EAAW,EAAI,CAAI,CAAC,EAGzD,IAAI,CAAC,EAAI,EAAO,EAAM,CACpB,OAAO,KAAK,GAAQ,OAAQ,IAAK,EAAW,EAAI,CAAI,EAAG,MAAO,OAAO,GAAS,EAAE,CAAE,CAAC,EAGrF,QAAQ,CAAC,GAAQ,KAAI,SAAQ,UAAW,CAAC,EAAG,CAC1C,IAAM,EAAO,CAAE,KAAM,OAAO,CAAI,EAAG,GAAI,GAAM,EAAe,OAAQ,GAAU,CAAC,CAAE,EACjF,GAAI,EAAQ,EAAK,OAAS,GAC1B,OAAO,KAAK,GAAQ,WAAY,CAAI,EAGtC,MAAM,CAAC,EAAI,EAAM,CACf,OAAO,KAAK,GAAQ,SAAU,EAAW,EAAI,CAAI,CAAC,EAGpD,MAAM,EAAG,CACP,OAAO,KAAK,IAGd,EAAO,CAAC,EAAM,EAAM,CAClB,OAAO,IAAI,EAAS,OAAO,OAAO,CAAC,GAAG,KAAK,IAAK,OAAO,OAAO,CAAC,EAAM,OAAO,OAAO,CAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAEhG,CAMA,SAAS,CAAS,CAAC,EAAI,EAAS,EAAM,CACpC,IAAM,EAAO,GAAW,KAAO,CAAC,GAAK,MAAM,QAAQ,CAAO,EAAI,EAAU,CAAC,CAAO,GAAG,IAAI,MAAM,EAC7F,GAAI,EAAK,SAAW,EAAG,MAAU,MAAM,sDAAsD,EAC7F,MAAO,IAAK,EAAW,EAAI,CAAI,EAAG,QAAS,OAAO,OAAO,CAAI,CAAE,EAK1D,IAAM,EAAM,IAAI",
+ "mappings": "AA+GA,IAAM,EAAW,IAAI,IAKd,SAAS,CAAiB,CAAC,EAAK,EAAI,CACzC,EAAS,IAAI,EAAK,CAAE,EAMf,SAAS,CAAc,CAAC,EAAK,CAClC,OAAO,EAAS,IAAI,CAAG,EAIlB,SAAS,CAA6B,EAAG,CAC9C,EAAS,MAAM,EAejB,IAAM,EAAgB,QAEtB,SAAS,CAAU,CAAC,GAAM,SAAQ,cAAe,CAAC,EAAG,CACnD,IAAM,EAAO,CAAE,GAAI,GAAM,CAAc,EACvC,GAAI,EAAQ,EAAK,OAAS,GAC1B,GAAI,EAAY,EAAK,WAAa,EAAoB,CAAU,EAChE,OAAO,EAOT,SAAS,CAAmB,CAAC,EAAY,CAIvC,GAAI,EAFF,GAAc,OAAO,IAAe,UAAY,CAAC,MAAM,QAAQ,CAAU,GACzE,CAAC,SAAU,OAAQ,IAAI,EAAE,MAAM,CAAC,KAAM,KAAK,EAAU,GAC3C,MAAU,MAAM,uEAAuE,EACnG,OAAO,OAAO,OAAO,CAAC,OAAO,EAAW,MAAM,EAAG,OAAO,EAAW,IAAI,EAAG,OAAO,EAAW,EAAE,CAAC,CAAC,EAGlG,MAAM,CAAS,CACb,WAAW,CAAC,EAAO,OAAO,OAAO,CAAC,CAAC,EAAG,CACpC,KAAK,IAAM,EACX,OAAO,OAAO,IAAI,EAGpB,IAAI,CAAC,EAAI,EAAM,CACb,OAAO,KAAK,GAAQ,OAAQ,EAAW,EAAI,CAAI,CAAC,EAGlD,IAAI,CAAC,EAAI,EAAM,CACb,OAAO,KAAK,GAAQ,OAAQ,EAAW,EAAI,CAAI,CAAC,EAGlD,MAAM,CAAC,EAAI,EAAM,CACf,OAAO,KAAK,GAAQ,SAAU,EAAW,EAAI,CAAI,CAAC,EAGpD,SAAS,CAAC,EAAI,EAAS,EAAM,CAC3B,OAAO,KAAK,GAAQ,YAAa,EAAU,EAAI,EAAS,CAAI,CAAC,EAG/D,YAAY,CAAC,EAAI,EAAS,EAAM,CAC9B,OAAO,KAAK,GAAQ,eAAgB,EAAU,EAAI,EAAS,CAAI,CAAC,EAGlE,YAAY,CAAC,EAAI,EAAS,EAAM,CAC9B,OAAO,KAAK,GAAQ,eAAgB,EAAU,EAAI,EAAS,CAAI,CAAC,EAGlE,QAAQ,CAAC,EAAI,EAAM,EAAO,EAAM,CAC9B,OAAO,KAAK,GAAQ,WAAY,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,EAAG,MAAO,OAAO,CAAK,CAAE,CAAC,EAGvG,WAAW,CAAC,EAAI,EAAM,EAAM,CAC1B,OAAO,KAAK,GAAQ,cAAe,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,CAAE,CAAC,EAGpF,WAAW,CAAC,EAAI,EAAM,EAAM,CAC1B,OAAO,KAAK,GAAQ,cAAe,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,CAAE,CAAC,EAGpF,KAAK,CAAC,EAAI,EAAM,CACd,OAAO,KAAK,GAAQ,QAAS,EAAW,EAAI,CAAI,CAAC,EAGnD,WAAW,CAAC,EAAI,EAAM,CACpB,OAAO,KAAK,GAAQ,cAAe,EAAW,EAAI,CAAI,CAAC,EAGzD,IAAI,CAAC,EAAI,EAAO,EAAM,CACpB,OAAO,KAAK,GAAQ,OAAQ,IAAK,EAAW,EAAI,CAAI,EAAG,MAAO,OAAO,GAAS,EAAE,CAAE,CAAC,EAGrF,QAAQ,CAAC,GAAQ,KAAI,SAAQ,UAAW,CAAC,EAAG,CAC1C,IAAM,EAAO,CAAE,KAAM,OAAO,CAAI,EAAG,GAAI,GAAM,EAAe,OAAQ,GAAU,CAAC,CAAE,EACjF,GAAI,EAAQ,EAAK,OAAS,GAC1B,OAAO,KAAK,GAAQ,WAAY,CAAI,EAGtC,MAAM,CAAC,EAAI,EAAM,CACf,OAAO,KAAK,GAAQ,SAAU,EAAW,EAAI,CAAI,CAAC,EAGpD,MAAM,EAAG,CACP,OAAO,KAAK,IAGd,EAAO,CAAC,EAAM,EAAM,CAClB,OAAO,IAAI,EAAS,OAAO,OAAO,CAAC,GAAG,KAAK,IAAK,OAAO,OAAO,CAAC,EAAM,OAAO,OAAO,CAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAEhG,CAMA,SAAS,CAAS,CAAC,EAAI,EAAS,EAAM,CACpC,IAAM,EAAO,GAAW,KAAO,CAAC,GAAK,MAAM,QAAQ,CAAO,EAAI,EAAU,CAAC,CAAO,GAAG,IAAI,MAAM,EAC7F,GAAI,EAAK,SAAW,EAAG,MAAU,MAAM,sDAAsD,EAC7F,MAAO,IAAK,EAAW,EAAI,CAAI,EAAG,QAAS,OAAO,OAAO,CAAI,CAAE,EAK1D,IAAM,EAAM,IAAI",
"debugId": "3CB48262B60C70DD64756E2164756E21",
"names": []
}
\ No newline at end of file
diff --git a/app/javascript/phlex/reactive/reactive_controller.js b/app/javascript/phlex/reactive/reactive_controller.js
index ab581e2..3f4e169 100644
--- a/app/javascript/phlex/reactive/reactive_controller.js
+++ b/app/javascript/phlex/reactive/reactive_controller.js
@@ -1047,9 +1047,11 @@ function runTransition(el, transition, flip) {
// Phlex::Reactive::JS's vocabulary; an op name not in this map is
// warn-and-skipped by #applyOps (client-side default-deny — a stale or newer
// ops attr must never break the page). Each op is a pure, local DOM mutation:
-// nothing is read back, nothing is sent anywhere. Frozen so nothing can be
-// registered into it at runtime — extending the vocabulary is a gem change,
-// not an app hook.
+// nothing is sent anywhere, and nothing is read back — with ONE deliberate
+// exception, paste_into (issue #228), which reads the clipboard behind the
+// browser's own gesture + permission gates and still only writes locally.
+// Frozen so nothing can be registered into it at runtime — extending the
+// vocabulary is a gem change, not an app hook.
const CLIENT_OPS = Object.freeze({
show: (el, args) => setHidden(el, false, args),
hide: (el, args) => setHidden(el, true, args),
@@ -1099,6 +1101,24 @@ const CLIENT_OPS = Object.freeze({
// exactly like a user submit. No form → no-op. ACTOR-ONLY like focus: the
// broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS).
submit: (el) => submitFormFor(el)?.requestSubmit?.(),
+
+ // Clipboard-source paste (issue #228): on a user gesture, read
+ // navigator.clipboard.readText() and feed the text into the target field
+ // through the normal input pipeline — exactly what a native Cmd/Ctrl+V does.
+ // The ONE op that reads a browser API and is async: fire-and-forget, so
+ // applyOps stays sync and chain siblings never wait (the runTransition
+ // posture). A rejected/dismissed read, empty text, or a missing API is a
+ // SILENT no-op — page state must not change. ACTOR-ONLY like focus/submit:
+ // the broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS) —
+ // and that server gate is the REAL one. The browser only partially backs it
+ // up: Safari gates every read on a fresh gesture and Firefox shows its
+ // paste picker per read, but Chromium's clipboard-read is a PERSISTENT
+ // per-origin permission — once granted (the legit paste button itself
+ // induces that), readText() succeeds with no gesture. No client-side
+ // refusal is possible here: the reactive:js interpreter cannot distinguish
+ // an actor reply's stream from a broadcast's, and reply.js legitimately
+ // carries this op.
+ paste_into: (el) => pasteClipboardInto(el),
})
// The form a submit op commits (issue #226), in order: the target itself when
@@ -1111,6 +1131,32 @@ function submitFormFor(el) {
return el?.form ?? el?.closest?.("form") ?? null
}
+// Read the clipboard into a field (issue #228) — the body of the paste_into
+// op. The write mirrors a native paste: set .value, dispatch a bubbling
+// `input` event (the set-value + dispatch contract, issue #183 — compute
+// reducers, show bindings, and on_complete all run exactly as if the user
+// had typed), then focus (the caret lands where the user continues typing on
+// a partial paste). Availability-guarded: insecure contexts and some
+// webviews have no navigator.clipboard — the connect()-time gate hides
+// marked triggers there, so this guard is belt-and-braces. Empty text is a
+// no-op: "paste nothing" must not clear a half-typed field.
+function pasteClipboardInto(field) {
+ const clipboard = globalThis.navigator?.clipboard
+ if (typeof clipboard?.readText !== "function") return
+ clipboard
+ .readText()
+ .then((text) => {
+ if (!text) return
+ field.value = text
+ if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
+ field.focus?.()
+ })
+ .catch(() => {
+ // Permission denied or the prompt dismissed — the browser's own UX said
+ // no. The issue-#228 contract: a silent no-op, never an error.
+ })
+}
+
// Apply a hidden-flag change, optionally animated by a [during, from, to]
// transition (issue #96). Split out so show/hide/toggle share it.
function setHidden(el, hidden, args) {
@@ -1554,6 +1600,9 @@ export default class extends Controller {
// Lazy initial mount (issue #165): the bound re-probe attached to
// turbo:morph-element so a Turbo page-refresh morph re-fires the defer fetch.
#boundProbeLazyDefer
+ // Clipboard-trigger availability gate (issue #228): the bound morph re-sync,
+ // held for teardown.
+ #boundSyncClipboard
// Mark that a reactive controller actually connected, so the registration
// guard above knows the controller was registered (issue #26 part 2).
@@ -1735,6 +1784,24 @@ export default class extends Controller {
this.element.addEventListener?.("turbo:morph-element", this.#boundSeedCompute)
this.recompute()
}
+
+ // Clipboard-trigger availability gate (issue #228) — ONLY when this root
+ // owns a paste trigger (on_client marks one with data-reactive-clipboard),
+ // so every other component pays a single probe (the show/filter/tags gate
+ // precedent). The Async Clipboard API is absent in insecure contexts and
+ // some webviews; a paste button that can never work must not show. The
+ // gate OWNS a marked trigger's `hidden` flag: author the trigger `hidden`
+ // and this pass reveals it where the API exists (a dead button never
+ // paints); turbo:morph-element re-syncs because a morph rewrites the
+ // trigger back to its authored hidden state. Like every sibling gate the
+ // decision is made ONCE at connect — render the paste trigger
+ // unconditionally: a trigger first INTRODUCED by a later morph stays
+ // ungated (hidden) until a full replace re-connects the controller.
+ if (this.#clipboardGateEnabled()) {
+ this.#boundSyncClipboard = () => this.#syncClipboardTriggers()
+ this.element.addEventListener?.("turbo:morph-element", this.#boundSyncClipboard)
+ this.#syncClipboardTriggers()
+ }
}
// Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
@@ -1765,6 +1832,7 @@ export default class extends Controller {
this.#teardownTagsSync()
this.#teardownNestedJsonSync()
this.#teardownComputeSeed()
+ this.#teardownClipboardGate()
if (this.#boundProbeLazyDefer) {
this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
}
@@ -3551,6 +3619,40 @@ export default class extends Controller {
return !!this.element.getAttribute?.("data-reactive-on-complete")
}
+ // Whether this root owns a clipboard-marked paste trigger (issue #228) —
+ // the connect() gate. The ROOT itself counts (a button-only component that
+ // mixes on_client(paste_into) onto reactive_root — the #dirtyTrackingEnabled
+ // root-then-descendants precedent), then one scoped query; a NESTED root's
+ // triggers are its own controller's to gate (issue #15 ownership).
+ #clipboardGateEnabled() {
+ if (this.element.getAttribute?.("data-reactive-clipboard")) return true
+ const nodes = this.element.querySelectorAll?.("[data-reactive-clipboard]") ?? []
+ for (const el of nodes) if (this.#ownsField(el)) return true
+ return false
+ }
+
+ // Set every owned paste trigger's `hidden` from clipboard availability
+ // (issue #228): available → revealed (the authored `hidden` was only the
+ // no-dead-button first paint), missing → hidden (insecure context /
+ // webview). The gate owns the flag on MARKED elements only — nothing else
+ // is ever touched. A marked ROOT is gated too: when the component IS the
+ // paste button, hiding the root is exactly "the dead button never shows".
+ #syncClipboardTriggers() {
+ const available = typeof globalThis.navigator?.clipboard?.readText === "function"
+ if (this.element.getAttribute?.("data-reactive-clipboard")) this.element.hidden = !available
+ for (const el of this.element.querySelectorAll?.("[data-reactive-clipboard]") ?? []) {
+ if (this.#ownsField(el)) el.hidden = !available
+ }
+ }
+
+ // Remove the clipboard gate's morph listener on disconnect, so a stray
+ // morph event after a Turbo navigation never re-syncs a detached root.
+ #teardownClipboardGate() {
+ if (!this.#boundSyncClipboard) return
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncClipboard)
+ this.#boundSyncClipboard = undefined
+ }
+
// Parse-and-memoize the completion bindings, keyed on the RAW attr string:
// a morph that rewrote the payload re-parses and RESETS the latches (the
// morph listener's own arm pass then re-arms without firing). A removed
diff --git a/app/javascript/phlex/reactive/reactive_controller.min.js b/app/javascript/phlex/reactive/reactive_controller.min.js
index e8a6200..5a8a1e6 100644
--- a/app/javascript/phlex/reactive/reactive_controller.min.js
+++ b/app/javascript/phlex/reactive/reactive_controller.min.js
@@ -1,4 +1,4 @@
-import{Controller as WX}from"@hotwired/stimulus";import{confirmResolver as I}from"phlex/reactive/confirm";import{computeReducer as JX}from"phlex/reactive/compute";import{confirmPredicate as LX}from"phlex/reactive/confirm_predicate";function VX(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:visit"])return;X["reactive:visit"]=function(){let Z=this.getAttribute("data-url");if(Z)window.Turbo.visit(Z,{action:"advance"})}}function BX(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:token"])return;X["reactive:token"]=function(){let Z=this.getAttribute("data-reactive-token-value"),$=this.getAttribute("target");if(!Z||!$)return;let Q=document.getElementById($);if(Q)Q.setAttribute("data-reactive-token-value",Z)}}function _X(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:js"])return;X["reactive:js"]=function(){let Z=UX(this.getAttribute("data-reactive-ops"));if(!Z.length)return;let $=this.getAttribute("target"),Q=$?document.getElementById($):null;if($&&!Q)return;N(Z,(j)=>qZ(j,Q))}}var _=new Map;function n(X,Z){let $=!_.has(X);if(_.set(X,Z),$)jX()}function R(X){if(_.delete(X))zX()}function UZ(){_.clear(),w=!1}function WZ(X){return _.get(X)?.via}var w=!1;function MX(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:defer"])return;if(X["reactive:defer"]=function(){let Z=this.getAttribute("target");if(!Z)return;if(this.getAttribute("data-reactive-defer-via")==="stream"){xX(Z,this);return}let $=this.getAttribute("data-reactive-defer-token");if(!$)return;F(Z,$)},!w&&typeof document<"u"&&document.addEventListener)w=!0,document.addEventListener("turbo:before-stream-render",AX)}function AX(X){let $=X.target?.getAttribute?.("target");if(!$)return;let Q=$.startsWith("reactive-defer-src-")?$.slice(19):$;if(_.get(Q)?.via==="stream")R(Q)}function F(X,Z){let $=document.getElementById(X);if(!$){console.warn(`[phlex-reactive] reactive:defer target #${X} is not on the page — skipped`);return}r(X),t($);let Q={via:"fetch",abort:new AbortController,timedOut:!1};n(X,Q),PX(X,Q,Z)}function xX(X,Z){let $=document.getElementById(X);if(!$){console.warn(`[phlex-reactive] reactive:defer target #${X} is not on the page — skipped`);return}let Q=Z.getAttribute("data-reactive-defer-src");if(!Q)return;if(!globalThis.customElements?.get?.("pgbus-stream-source")){let z=Z.getAttribute("data-reactive-defer-token");if(z){F(X,z);return}console.error("[phlex-reactive] reactive:defer via=stream but is not registered "+"and no fallback token was provided — is the pgbus client loaded on this page?");return}r(X),t($);let j=document.createElement("pgbus-stream-source");j.id=a(X),j.setAttribute("src",Q),j.setAttribute("since-id",Z.getAttribute("data-reactive-defer-since-id")??"0"),j.setAttribute("hidden",""),document.body.appendChild(j),n(X,{via:"stream"})}function a(X){return`reactive-defer-src-${X}`}async function PX(X,Z,$){let Q=setTimeout(()=>{Z.timedOut=!0,Z.abort.abort()},DX()),j;try{j=await fetch(NX(),{method:"POST",headers:{Accept:"text/vnd.turbo-stream.html","Content-Type":"application/json","X-CSRF-Token":OX()},body:JSON.stringify({token:$}),credentials:"same-origin",signal:Z.abort.signal})}catch(q){if(clearTimeout(Q),_.get(X)!==Z)return;console.error("[phlex-reactive] deferred render failed",q),T(X,$);return}if(_.get(X)!==Z){clearTimeout(Q);return}if(j.status===204){clearTimeout(Q),c(X);return}if(!j.ok){clearTimeout(Q),console.error(`[phlex-reactive] deferred render failed: HTTP ${j.status}`),T(X,$,j.status);return}let z;try{z=await j.text()}catch(q){if(clearTimeout(Q),_.get(X)!==Z)return;console.error("[phlex-reactive] deferred render failed reading the body",q),T(X,$);return}if(clearTimeout(Q),_.get(X)!==Z)return;c(X),window.Turbo.renderStreamMessage(z)}function r(X){let Z=_.get(X);if(!Z)return;if(R(X),Z.via==="fetch")Z.abort.abort();else document.getElementById(a(X))?.remove?.()}function t(X){X.setAttribute("data-reactive-defer-pending","true"),X.setAttribute("aria-busy","true")}function e(X){X.removeAttribute("data-reactive-defer-pending"),X.removeAttribute("aria-busy")}function c(X){R(X);let Z=document.getElementById(X);if(!Z)return;e(Z),Z.removeAttribute("data-reactive-error")}function T(X,Z,$){R(X);let Q=document.getElementById(X);if(!Q)return;e(Q),Q.setAttribute("data-reactive-error","defer");let j=()=>{let z=document.getElementById(X);if(!z){console.warn("[phlex-reactive] defer retry() ignored — the target left the DOM");return}z.removeAttribute("data-reactive-error"),F(X,Z)};Q.dispatchEvent(new CustomEvent("reactive:error",{bubbles:!0,composed:!0,detail:{kind:"defer",target:X,status:$,retry:j}}))}function NX(){return document.querySelector('meta[name="phlex-reactive-defer-path"]')?.content||"/reactive/defer"}function OX(){return document.querySelector('meta[name="csrf-token"]')?.content??""}function DX(){let X=document.querySelector('meta[name="phlex-reactive-timeout"]')?.content,Z=Number(X);return Number.isFinite(Z)&&Z>0?Z:30000}var h=!1;function RX(){if(h)return;if(typeof document>"u"||!document.addEventListener)return;h=!0,document.addEventListener("turbo:before-stream-render",FX)}function FX(X){let Z=X.detail,$=Z?.render;if(typeof $!=="function"||$.__reactiveDismissWrapped){if(typeof requestAnimationFrame==="function")requestAnimationFrame(E);else setTimeout(E,0);return}let Q=async(j)=>{await $(j),E()};Q.__reactiveDismissWrapped=!0,Z.render=Q}function E(){let X=document.querySelectorAll("[data-reactive-dismiss-after]");for(let Z of X){if(Z.hasAttribute("data-reactive-dismiss-scheduled"))continue;let $=Number(Z.getAttribute("data-reactive-dismiss-after"));if(!Number.isFinite($)||$<=0)continue;Z.setAttribute("data-reactive-dismiss-scheduled",""),setTimeout(()=>Z.remove(),$)}}function JZ(){h=!1}var CX=Object.freeze({append:"enter",prepend:"enter",replace:"update",update:"update",remove:"exit"}),k=Object.freeze(["fade","slide","scale","highlight","shake"]),y="data-reactive-fx-pending",XX=1000,v=!1;function IX(){if(v)return;if(typeof document>"u"||typeof document.addEventListener!=="function")return;v=!0,document.addEventListener("turbo:before-stream-render",TX)}function LZ(){v=!1}function TX(X){let Z=X.detail,$=Z?.render;if(typeof $!=="function"||$.__reactiveEffectsWrapped)return;let Q=Z?.newStream??X.target,j=CX[Q?.getAttribute?.("action")];if(!j||SX())return;let z=EX(Q,j);if(!z)return;let q=j==="exit"?async(G)=>{await hX(O(Q),z),await $(G)}:async(G)=>{let Y=j==="enter"?bX(Q):null;if(await $(G),j==="enter")wX(Y,z);else ZX(O(Q),z)};q.__reactiveEffectsWrapped=!0,Z.render=q}function EX(X,Z){let $=X.getAttribute?.("data-reactive-effect");if($==="off")return null;if($)return d($,Z);let j=(Z==="enter"?kX(X):O(X))?.getAttribute?.(`data-reactive-effect-${Z}`);return j?d(j,Z):null}function O(X){let Z=X.getAttribute?.("target");return Z?document.getElementById?.(Z)??null:null}function kX(X){return X.querySelector?.("template")?.content?.firstElementChild??null}function d(X,Z){if(X.startsWith("[")){let Q=null;try{let j=JSON.parse(X);if(Array.isArray(j)&&j.length===3)Q=j.map(String)}catch{}if(Q)return{legs:Q};return console.warn(`[phlex-reactive] malformed effect legs ${JSON.stringify(X)} — skipped`),null}let $=X==="random"?k[Math.floor(Math.random()*k.length)]:X;if(!k.includes($))return console.warn(`[phlex-reactive] unknown effect ${JSON.stringify(X)} — skipped`),null;return{className:`reactive-fx--${$}-${Z}`}}function SX(){try{return typeof matchMedia==="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}catch{return!1}}function bX(X){let Z=X.querySelector?.("template")?.content;if(!Z)return null;for(let $ of Array.from(Z.children??[]))$.setAttribute?.(y,"");return O(X)}function wX(X,Z){if(typeof X?.querySelectorAll!=="function")return;for(let $ of Array.from(X.querySelectorAll(`[${y}]`)))$.removeAttribute(y),ZX($,Z)}async function hX(X,Z){if(!X?.classList)return;if(Z.legs){await $X(X,Z.legs);return}X.classList.add(Z.className);let $=p(X);if($<=0){X.classList.remove(Z.className);return}await u(X,$),X.classList.remove(Z.className)}function ZX(X,Z){if(!X?.classList)return;if(Z.legs){$X(X,Z.legs);return}if(X.classList.contains(Z.className))X.classList.remove(Z.className),X.offsetWidth;X.classList.add(Z.className);let $=p(X);if($<=0){X.classList.remove(Z.className);return}let Q=X.__reactiveFxToken=(X.__reactiveFxToken??0)+1;u(X,$).then(()=>{if(X.__reactiveFxToken===Q)X.classList.remove(Z.className)})}async function $X(X,Z){let[$,Q,j]=Z.map(yX),z=X.__reactiveFxToken=(X.__reactiveFxToken??0)+1;if(X.classList.remove(...$,...Q,...j),X.classList.add(...$,...Q),await vX(),X.__reactiveFxToken!==z)return;X.classList.remove(...Q),X.classList.add(...j);let q=p(X);if(q>0)await u(X,q);if(X.__reactiveFxToken!==z)return;X.classList.remove(...$,...j)}function yX(X){return String(X??"").split(/\s+/).filter(Boolean)}function p(X){if(typeof getComputedStyle!=="function")return 0;try{let Z=getComputedStyle(X),$=(z)=>String(z??"").split(",").reduce((q,G)=>Math.max(q,parseFloat(G)||0),0),Q=$(Z.animationDuration)+$(Z.animationDelay),j=$(Z.transitionDuration)+$(Z.transitionDelay);return Math.min(Math.max(Q,j)*1000,XX)}catch{return 0}}function u(X,Z){return new Promise(($)=>{let Q=!1,j=()=>{if(Q)return;Q=!0,$()};X.addEventListener?.("animationend",j,{once:!0}),X.addEventListener?.("transitionend",j,{once:!0}),setTimeout(j,Math.min(Z+50,XX))})}function vX(){return new Promise((X)=>{if(typeof requestAnimationFrame==="function")requestAnimationFrame(()=>X());else setTimeout(X,16)})}var f=!1;function fX(){if(f)return;if(typeof window>"u"||typeof document>"u")return;if(typeof window.addEventListener!=="function")return;f=!0;let X=()=>{let Z=document.documentElement;if(typeof Z?.toggleAttribute!=="function")return;Z.toggleAttribute("data-reactive-offline",globalThis.navigator?.onLine===!1)};X(),window.addEventListener("online",X),window.addEventListener("offline",X)}function VZ(){f=!1}var m="phlex-reactive:latency",D=!1;function pX(X){if(typeof sessionStorage>"u")return;sessionStorage.setItem(m,String(X))}function uX(){if(typeof sessionStorage>"u")return;sessionStorage.removeItem(m),D=!1}function mX(){if(typeof window>"u"||typeof document>"u")return;if(document.querySelector?.('meta[name="phlex-reactive-env"]')?.content!=="development")return;window.PhlexReactive={enableLatencySim:pX,disableLatencySim:uX}}function BZ(){D=!1}var QX="data-reactive-active",M=0;function jX(){if(M++,M===1)qX("reactive:busy")}function zX(){if(M===0)return;if(M--,M===0)qX("reactive:idle")}function _Z(){return M}function MZ(){M=0,(typeof document<"u"?document.documentElement:null)?.removeAttribute?.(QX)}function qX(X){if(typeof document>"u")return;let Z=document.documentElement;if(typeof Z?.toggleAttribute==="function")Z.toggleAttribute(QX,M>0);if(typeof document.dispatchEvent==="function"&&typeof CustomEvent==="function")document.dispatchEvent(new CustomEvent(X,{detail:{count:M}}))}function l(){VX(),BX(),_X(),MX(),RX(),IX(),fX(),mX()}function gX(X){return X.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}if(typeof window<"u")if(window.Turbo)l();else document.addEventListener("turbo:load",l,{once:!0});var C=!1;function cX(){if(C)return;if(typeof document>"u")return;let X=document.querySelectorAll('[data-controller~="reactive"]');if(!X||X.length===0)return;console.warn("[phlex-reactive] found "+X.length+' element(s) with data-controller="reactive" '+"but the reactive controller never connected. It is loaded but not registered — "+'add `application.register("reactive", ReactiveController)` (importmap) or import it into app/javascript/controllers/ for lazyLoadControllersFrom apps. See the README.')}function AZ(){C=!1}function xZ(){C=!0}if(typeof window<"u"&&typeof document<"u"){let X=()=>setTimeout(cX,0);if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",X,{once:!0});else X()}var dX=new Set(["href","src","srcdoc","action","formaction","xlink:href","style"]);function lX(X){let Z=String(X).toLowerCase();return Z.startsWith("on")||dX.has(Z)}function iX(X,Z,$){let[Q,j,z]=Z;X.classList.add(Q,j),$(),requestAnimationFrame(()=>{X.classList.remove(j),X.classList.add(z)});let q=!1,G=()=>{if(q)return;q=!0,X.classList.remove(Q,z)};X.addEventListener("animationend",G,{once:!0}),setTimeout(G,350)}var i=Object.freeze({show:(X,Z)=>S(X,!1,Z),hide:(X,Z)=>S(X,!0,Z),toggle:(X,Z)=>S(X,!X.hidden,Z),add_class:(X,Z)=>X.classList.add(...Z.classes??[]),remove_class:(X,Z)=>X.classList.remove(...Z.classes??[]),toggle_class:(X,Z)=>(Z.classes??[]).forEach(($)=>X.classList.toggle($)),set_attr:(X,Z)=>{if(b(Z.name))X.setAttribute(Z.name,Z.value??"")},remove_attr:(X,Z)=>{if(b(Z.name))X.removeAttribute(Z.name)},toggle_attr:(X,Z)=>{if(!b(Z.name))return;if(X.hasAttribute(Z.name))X.removeAttribute(Z.name);else X.setAttribute(Z.name,"")},focus:(X)=>X.focus?.(),focus_first:(X)=>jZ(X)?.focus?.(),text:(X,Z)=>{let $=String(Z.value??"");if(X.textContent!==$)X.textContent=$},dispatch:(X,Z)=>{X.dispatchEvent(new CustomEvent(Z.name,{bubbles:!0,composed:!0,detail:Z.detail??{}}))},submit:(X)=>oX(X)?.requestSubmit?.()});function oX(X){if(X?.tagName==="FORM")return X;return X?.form??X?.closest?.("form")??null}function S(X,Z,$){if($?.transition)iX(X,$.transition,()=>X.hidden=Z);else X.hidden=Z}function b(X){if(!lX(X))return!0;return console.warn(`[phlex-reactive] refused client attr op on ${JSON.stringify(X)} — skipped`),!1}var GX=/^#[A-Za-z_][\w-]*$/;function sX(X){if(typeof X==="string"&&GX.test(X))return!0;return console.warn(`[phlex-reactive] refused cross-root mirror target ${JSON.stringify(X)} — skipped`),!1}function nX(X,Z){let $=X.getAttribute("data-reactive-show-equals");if($!==null)return Z===$;let Q=X.getAttribute("data-reactive-show-not");if(Q!==null)return Z!==Q;let j=X.getAttribute("data-reactive-show-in");if(j!==null){try{let z=JSON.parse(j);if(Array.isArray(z))return z.includes(Z)}catch{}return console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(j)} — skipped`),null}for(let z of YX){let q=X.getAttribute(`data-reactive-show-${z}`);if(q!==null)return KX(z,q,Z)}return console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped"),null}var YX=["gte","gt","lte","lt"],aX=["len_eq","len_gte","len_gt","len_lte","len_lt"];function rX(X,Z,$){if(!Number.isInteger(Z))return console.warn(`[phlex-reactive] reactive_show ${X}: needs an integer literal, got ${JSON.stringify(Z)} — skipped`),null;let Q=[...String($??"")].length;switch(X){case"len_eq":return Q===Z;case"len_gte":return Q>=Z;case"len_gt":return Q>Z;case"len_lte":return Q<=Z;case"len_lt":return Q=Q;case"gt":return z>Q;case"lte":return z<=Q;case"lt":return z$&&typeof $==="object"&&Array.isArray($.any)&&Array.isArray($.ops)))return Z}catch{}return console.warn(`[phlex-reactive] malformed reactive_on_complete payload ${JSON.stringify(X)} — skipped`),[]}function g(X,Z){if(!X||typeof X!=="object"||typeof X.field!=="string")return!1;let $=Z(X.field)??"";return HX(X,$)===!0}function P(X,Z){if(!Array.isArray(X)||X.length===0)return null;return X.some(($)=>Array.isArray($)&&$.length>0&&$.every((Q)=>g(Q,Z)))}function XZ(X){if(!Array.isArray(X)||X.length===0)return null;let Z=new Set;for(let $ of X){if(!Array.isArray($))continue;for(let Q of $)if(Q&&typeof Q==="object"&&typeof Q.field==="string")Z.add(Q.field)}return Z.size>0?[...Z]:null}function ZZ(X,Z){if(!X||typeof X!=="object")return null;let $=X.any;if(Array.isArray($)&&($.length===0||Array.isArray($[0])))return P($,Z);return $Z(X,Z)}function $Z(X,Z){let $=Array.isArray(X.all)?"all":Array.isArray(X.any)?"any":null;if(!$)return null;let Q=X[$];if(Q.length===0)return null;let j=Q.map((z)=>g(z,Z));return $==="all"?j.every(Boolean):j.some(Boolean)}function s(X){if(typeof X==="string"&&GX.test(X))return!0;return console.warn(`[phlex-reactive] refused cross-root show target ${JSON.stringify(X)} — skipped`),!1}var QZ='a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';function jZ(X){return X.querySelectorAll?.(QZ)?.[0]??null}function UX(X){if(Array.isArray(X))return X;if(typeof X!=="string")return[];try{let Z=JSON.parse(X);return Array.isArray(Z)?Z:[]}catch{return[]}}function zZ(X){if(X==null)return null;let Z=Array.isArray(X)?X:X.ops;if(Array.isArray(Z))return Z.length>0?Z:null;return console.warn("[phlex-reactive] $ops must be an ops chain or a [[op, args], ...] list — skipped"),null}function N(X,Z){for(let $ of X){if(!Array.isArray($))continue;let[Q,j={}]=$;if(!Object.hasOwn(i,Q)){console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(Q)} — skipped`);continue}for(let z of Z(j))i[Q](z,j)}}function qZ(X,Z){let $=X.to;if(Z){if($==="@root")return[Z];if(typeof $!=="string"||$==="")return[];if(X.global)return[...document.querySelectorAll($)];return[...Z.querySelectorAll($)]}if(typeof $!=="string"||$===""||$==="@root")return[];return[...document.querySelectorAll($)]}class PZ extends WX{static values={token:String};#i;#x=new Map;#U=new Map;#RX;#E;#o;#k=0;#W=new Map;#S=new WeakMap;#P=new Map;#s=new WeakSet;#n=null;#J;#L;#V;#Z;#z;#b;#a;#w;#h;#Q;#B;#r=!1;#y=0;#t=!1;#j;#_;#M;#N;connect(){if(C=!0,this.element.id==="")console.warn("[phlex-reactive] a reactive root has no id; its next-action token can't self-match "+"and may fall back to the first token in the response → a silent HTTP 403 on the NEXT action. "+"Put id: on the SAME element as reactive_attrs — use div(**reactive_root) (emits id + token together), "+"or div(id:, **reactive_attrs). The id: must NOT be on a child. See the README.");if(this.element.getAttribute?.("data-reactive-defer-token"))this.#e(),this.#N=()=>this.#e(),this.element.addEventListener?.("turbo:morph-element",this.#N);if(this.#FX()){if(this.#J=()=>this.#u(),this.element.addEventListener?.("turbo:morph-element",this.#J),this.#u(),this.element.getAttribute?.("data-reactive-warn-unsaved")==="true")this.#tX()}if(this.#XZ())this.#Z=()=>this.#LX(),this.element.addEventListener?.("input",this.#Z),this.element.addEventListener?.("change",this.#Z),this.element.addEventListener?.("turbo:morph-element",this.#Z),this.#LX();if(this.#ZZ())this.#z=(X)=>this.#m(X),this.#b=()=>this.#m(null),this.element.addEventListener?.("input",this.#z),this.element.addEventListener?.("change",this.#z),this.element.addEventListener?.("turbo:morph-element",this.#b),this.#m(null);if(this.#C())this.#Q=(X)=>{if(X?.type==="input"&&!this.#KZ(X))return;this.#A()},this.element.addEventListener?.("input",this.#Q),this.element.addEventListener?.("turbo:morph-element",this.#Q),this.#A();if(this.#I())this.#B=()=>this.#d(),this.element.addEventListener?.("turbo:morph-element",this.#B),this.#d();if(this.#UZ())this.#j=(X)=>this.syncNestedJson(X),this.#_=()=>this.#D(),this.element.addEventListener?.("input",this.#j),this.element.addEventListener?.("change",this.#j),this.element.addEventListener?.("turbo:morph-element",this.#_),this.#D();if(this.#YZ())this.#M=()=>this.recompute(),this.element.addEventListener?.("turbo:morph-element",this.#M),this.recompute()}#FX(){if((this.element.getAttribute?.("data-action")??"").includes("reactive#trackDirty"))return!0;let X=this.element.querySelectorAll?.('[data-action*="reactive#trackDirty"]')??[];for(let Z of X)if(this.#$(Z))return!0;return!1}disconnect(){if(this.#mX(),this.#cX(),this.#eX(),this.#GZ(),this.#QZ(),this.#HZ(),this.#MZ(),this.#AZ(),this.#xZ(),this.#N)this.element.removeEventListener?.("turbo:morph-element",this.#N)}#e(){let X=this.element;if(!X?.id)return;let Z=X.getAttribute?.("data-reactive-defer-token");if(!Z)return;if(X.getAttribute?.("data-reactive-defer-pending")!=="true")return;F(X.id,Z)}dispatch(X){let{action:Z,params:$,debounce:Q,throttle:j,confirm:z,confirmWhen:q,outside:G,window:Y,optimistic:K}=X.params;if(!Z)return;let U=X.params.busy??this.#EZ(X.params.loading);if(G&&this.element.contains(X.target))return;let B=X.currentTarget??X.target;if(!Y&&!this.#OZ(K,B))X.preventDefault();let W=this.#p(z,q);if(!W)return this.#GX(B,Z,$,Q,j,K,U);Promise.resolve().then(()=>I(W,{el:B})).catch(()=>!1).then((V)=>{if(V)this.#GX(B,Z,$,Q,j,K,U)})}runOps(X){let{ops:Z,confirm:$,confirmWhen:Q,outside:j,window:z}=X.params,q=X.currentTarget??X.target;if(j&&this.element.contains(X.target))return;if(!z)X.preventDefault();let G=this.#p($,Q);if(!G)return this.#PX(this.#xX(Z));Promise.resolve().then(()=>I(G,{el:q})).catch(()=>!1).then((Y)=>{if(Y)this.#PX(this.#xX(Z))})}trackDirty(){this.#u()}recompute(X){if(X&&this.#s.has(X))return;let Z=this.#wX(),$=Z.map(([H])=>H),Q=this.element.getAttribute?.("data-reactive-scope")||null,j=(H)=>Q&&!H.includes("[")?`${Q}[${H}]`:H,z=this.#X(),q=new Map,G=(H)=>{if(q.has(H))return q.get(H);let J=null;for(let L of this.element.querySelectorAll(`[name="${j(H)}"]`))if(z(L)){J=L;break}return q.set(H,J),J};for(let H of $)this.#zX(H,G(H)?.value??"");let Y=this.element.getAttribute("data-reactive-compute-reducer-param"),K=Y?JX(Y):null;if(!K){this.#qX({},G);return}let U=this.#bX("data-reactive-compute-outputs-param"),B={};for(let[H,J]of Z){let L=G(H);if(J==="string")B[H]=L?.value??"";else{let A=Number(L?.value);B[H]=Number.isFinite(A)?A:0}}let W=K(B,{changed:this.#hX(X,$,Q)})||{},V=zZ(W.$ops),x=[];for(let H of U){if(H==="$ops"||!(H in W))continue;let J=G(H);if(!J)continue;if(String(W[H])===J.value)continue;J.value=W[H],x.push(J)}for(let H of Object.keys(W)){if(H==="$ops")continue;let J=W[H];if(J===void 0||J===null)continue;this.#zX(H,J)}this.#qX(W,G);for(let H of x){let J=new Event("input",{bubbles:!0});this.#s.add(J),H.dispatchEvent(J)}this.#CX(V,Boolean(X))}#CX(X,Z){let $=X===null?null:JSON.stringify(X),Q=$!==null&&$!==this.#n&&Z;if(this.#n=$,!Q)return;N(X,(j)=>this.#T(j.to==null?{...j,to:"@root"}:j))}listnavNext(X){this.#XX(X,1)}listnavPrev(X){this.#XX(X,-1)}listnavPick(X){let Z=this.#O(X),$=Z.findIndex((Q)=>Q.hasAttribute("data-reactive-highlighted"));if($<0)return;X.preventDefault(),Z[$].click()}listnavClose(X){for(let Z of this.#O(X))Z.removeAttribute("data-reactive-highlighted")}#XX(X,Z){let $=this.#O(X);if(!$.length)return;X.preventDefault();let Q=$.findIndex((q)=>q.hasAttribute("data-reactive-highlighted")),j=Q<0?Z>0?0:$.length-1:(Q+Z+$.length)%$.length;for(let q of $)q.removeAttribute("data-reactive-highlighted");let z=$[j];z.setAttribute("data-reactive-highlighted","true"),z.scrollIntoView?.({block:"nearest"})}#O(X){let $=(X?.currentTarget??X?.target??this.element).getAttribute?.("data-reactive-listnav-option-param")??this.element.getAttribute("data-reactive-listnav-option-param");if(!$)return[];let Q=this.#X();return Array.from(this.element.querySelectorAll($)).filter((j)=>!j.hidden&&Q(j))}tagsAdd(X){if(!this.#I())return;if(X?.defaultPrevented)return;if(this.#O(X).some((Q)=>Q.hasAttribute?.("data-reactive-highlighted")))return;X?.preventDefault?.();let Z=X?.currentTarget??X?.target;if(!Z)return;if(!this.#_X(String(Z.value??"").split(",")))return;if(Z.value="",this.#C())this.#A()}tagsPick(X){if(!this.#I())return;X?.preventDefault?.();let $=(X?.currentTarget??X?.target)?.getAttribute?.("data-reactive-tag-param");if(!$)return;if(!this.#_X([$]))return;let Q=this.#WZ();if(!Q)return;Q.value="",this.#A(),Q.focus?.()}tagsRemove(X){if(!this.#I())return;X?.preventDefault?.();let $=(X?.currentTarget??X?.target)?.getAttribute?.("data-reactive-tag-param");if(!$)return;let Q=this.#g();if(!Q)return;let j=this.#c(Q),z=j.filter((q)=>q.toLowerCase()!==$.toLowerCase());if(z.length===j.length)return;this.#MX(Q,z)}nestedAdd(X){X?.preventDefault?.();let Z=X?.currentTarget??X?.target,$=Z?.getAttribute?.("data-reactive-association-param");if(!$)return;if(typeof this.element?.querySelectorAll!=="function")return;let Q=this.#X(),j=[...this.element.querySelectorAll(`[data-reactive-nested-list="${$}"]`)].find(Q),q=[...this.element.querySelectorAll(`[data-reactive-nested-template="${$}"]`)].find(Q)?.content?.firstElementChild;if(!j||!q){this.#_Z($);return}let G=q.cloneNode(!0);this.#BZ(G,this.#VZ()),j.appendChild(G);let Y=Z?.getAttribute?.("data-reactive-nested-from-param"),K=Z?.getAttribute?.("data-reactive-nested-clear-param")==="true",U=this.#IX(G,Y,K);if(Y)U?.focus?.();else[...G.querySelectorAll?.("input, select, textarea")??[]][0]?.focus?.();if(j.getAttribute?.("data-reactive-nested-json")===$)this.#$X($)}#IX(X,Z,$){if(!Z)return null;let Q;try{Q=JSON.parse(Z)}catch{return null}if(!Q||typeof Q!=="object")return null;let j=this.#X(),z=[...X.querySelectorAll?.("input, select, textarea")??[]],q=[];for(let[G,Y]of Object.entries(Q)){let K=[...this.element.querySelectorAll?.(Y)??[]].find(j);if(!K)continue;let U=z.find((B)=>this.#jX(B.getAttribute?.("name"))===G);if(!U)continue;this.#TX(U,K),q.push(K)}if($)for(let G of q)this.#EX(G);return q[0]??null}#TX(X,Z){if(X.type==="checkbox")X.checked=Z.type==="checkbox"?!!Z.checked:this.#v(Z)!=="";else X.value=this.#v(Z);if(typeof X.dispatchEvent==="function")X.dispatchEvent(new Event("input",{bubbles:!0}))}#EX(X){if(X.type==="checkbox")X.checked=!1;else X.value="";if(typeof X.dispatchEvent==="function")X.dispatchEvent(new Event("input",{bubbles:!0}))}nestedRemove(X){X?.preventDefault?.();let Z=X?.currentTarget??X?.target,$=Z?.closest?.("[data-reactive-nested-row]");if(!$)return;if($.closest?.('[data-controller~="reactive"]')!==this.element)return;let Q=Z?.getAttribute?.("data-reactive-confirm-param"),j=Z?.getAttribute?.("data-reactive-confirm-when-param"),z=this.#p(Q,j);if(!z)return this.#ZX($);let q=this.#QX($),G=this.#kX(z,q);return Promise.resolve().then(()=>I(G,{el:Z,row:$,fields:q})).catch(()=>!1).then((Y)=>{if(Y)this.#ZX($)})}#kX(X,Z){if(!X.includes("%{"))return X;return X.replace(/%\{(\w+)\}/g,($,Q)=>Object.prototype.hasOwnProperty.call(Z,Q)?Z[Q]:$)}#ZX(X){let Z=[...X.querySelectorAll?.('input[name$="[_destroy]"]')??[]][0];if(Z){if(Z.value="1",typeof Z.dispatchEvent==="function")Z.dispatchEvent(new Event("input",{bubbles:!0}));X.hidden=!0}else X.parentNode?.removeChild?.(X);this.#D()}syncNestedJson(X){let Z=X?.target;if(!Z||!this.#$(Z))return;this.#D()}#D(){if(typeof this.element?.querySelectorAll!=="function")return;let X=this.#X();for(let Z of[...this.element.querySelectorAll("[data-reactive-nested-json]")].filter(X))this.#$X(Z.getAttribute("data-reactive-nested-json"))}#$X(X){let Z=this.#X(),$=[...this.element.querySelectorAll(`[data-reactive-nested-list="${X}"]`)].find(Z);if(!$)return;let Q=this.#SX($);if(!Q)return;let j=[];for(let q of[...$.querySelectorAll?.("[data-reactive-nested-row]")??[]]){if(!Z(q)||q.hidden)continue;j.push(this.#QX(q))}let z=JSON.stringify(j);if(Q.value===z)return;if(Q.value=z,typeof Q.dispatchEvent==="function")Q.dispatchEvent(new Event("input",{bubbles:!0}))}#SX(X){let Z=X.getAttribute?.("data-reactive-nested-json-field");if(!Z)return null;let $=this.#X();return[...this.element.querySelectorAll(Z)].find($)??null}#QX(X){let Z={};for(let $ of[...X.querySelectorAll?.("input, select, textarea")??[]]){let Q=this.#jX($.getAttribute?.("name"));if(Q===null||Q==="_destroy")continue;Z[Q]=this.#v($)}return Z}#jX(X){if(!X)return null;let Z=X.match(/\[([^\][]+)\]$/);return Z?Z[1]:X}#v(X){if(X.type==="checkbox")return X.checked?X.value||"on":"";return X.value??""}#bX(X){let Z=this.element.getAttribute(X);if(!Z)return[];try{let $=JSON.parse(Z);return Array.isArray($)?$:[]}catch{return[]}}#wX(){let X=this.element.getAttribute("data-reactive-compute-inputs-param");if(!X)return[];try{let Z=JSON.parse(X);if(Array.isArray(Z))return Z.map(($)=>[$,"number"]);if(Z&&typeof Z==="object")return Object.entries(Z);return[]}catch{return[]}}#hX(X,Z,$){let Q=X?.target;if(!Q?.name||typeof Q.closest!=="function")return null;let j=this.#yX(Q.name,$);if(!Z.includes(j))return null;return this.#$(Q)?j:null}#yX(X,Z){if(!Z)return X;let $=`${Z}[`;return X.startsWith($)&&X.endsWith("]")?X.slice($.length,-1):X}#zX(X,Z){let $=String(Z);for(let Q of this.#vX(X)){if(Q.textContent===$)continue;Q.textContent=$}}#vX(X){let Z=this.element.querySelectorAll(`[data-reactive-text="${X}"]`);return Array.from(Z).filter(($)=>this.#$($))}#qX(X,Z){let $=this.#fX();for(let[Q,j]of Object.entries($)){let z=Q in X?X[Q]:Z(Q)?.value;if(z===void 0||z===null)continue;let q=String(z);for(let G of Array.isArray(j)?j:[j]){if(!sX(G))continue;for(let Y of document.querySelectorAll(G)){if(Y.textContent===q)continue;Y.textContent=q}}}}#fX(){let X=this.element.getAttribute("data-reactive-compute-mirror-param");if(!X)return{};try{let Z=JSON.parse(X);return Z&&typeof Z==="object"&&!Array.isArray(Z)?Z:{}}catch{return{}}}#GX(X,Z,$,Q,j,z,q){if(this.#F("reactive:before-dispatch",{action:Z,params:this.#AX($),element:this.element},{cancelable:!0}).defaultPrevented)return;let Y=Number(Q)||0;if(Y>0)return this.#uX(X,Y,Z,$,z,q);let K=Number(j)||0;if(K>0)return this.#gX(X,K,Z,$,z,q);return this.#R(Z,$,z,X,q)}#R(X,Z,$,Q,j){let z=this.#DZ($,Q),q=this.#RZ(X,Q,j),G=this.#YX()?this.#pX($,Q):null;return this.queue=(this.queue??Promise.resolve()).then(()=>this.#nX(X,Z,z,q,G)),this.queue}#pX(X,Z){if(!X?.hide)return null;let $=this.#OX(X,Z);if(!$.length)return null;return()=>{let Q=$.filter((j)=>j.isConnected&&!j.hidden);if(!Q.length)return;console.warn("[phlex-reactive] optimistic: { hide: true } was undone by the reply's re-render — "+"the element is visible again. For an instant delete, return reply.remove so the server removes it; otherwise the hide only flashes.",Q)}}#uX(X,Z,$,Q,j,z){this.#f(X);let q=()=>{this.#f(X),this.#R($,Q,j,X,z)},G=setTimeout(q,Z);X?.addEventListener?.("blur",q,{once:!0}),this.#x.set(X,{timer:G,flush:q})}#f(X){let Z=this.#x.get(X);if(!Z)return;clearTimeout(Z.timer),X?.removeEventListener?.("blur",Z.flush),this.#x.delete(X)}#mX(){for(let X of[...this.#x.keys()])this.#f(X)}#gX(X,Z,$,Q,j,z){let q=this.#U.get(X)??new Map;if(q.has($))return;let G=setTimeout(()=>{if(q.delete($),q.size===0)this.#U.delete(X)},Z);return q.set($,G),this.#U.set(X,q),this.#R($,Q,j,X,z)}#cX(){for(let X of this.#U.values())for(let Z of X.values())clearTimeout(Z);this.#U.clear()}#F(X,Z,{cancelable:$=!1}={}){let Q=new CustomEvent(X,{bubbles:!0,composed:!0,cancelable:$,detail:Z});return(this.element.isConnected?this.element:document).dispatchEvent(Q),Q}#q(X,Z,$,Q){let j=()=>{if(!this.element.isConnected){console.warn("[phlex-reactive] retry() ignored — the reactive root left the DOM");return}return this.#R(X,Z)};this.#F("reactive:error",{action:X,params:$,...Q,retry:j})}#G(X){if(this.element?.isConnected===!1)return;this.element?.setAttribute?.("data-reactive-error",X)}#dX(){this.element?.removeAttribute?.("data-reactive-error")}#lX(){let X=document.querySelector("[data-reactive-error-flash]");if(!X?.content)return;let Z=X.getAttribute("data-reactive-error-flash")||"flash",$=document.getElementById(Z);if(!$)return;$.appendChild(X.content.cloneNode(!0))}#iX(){if(typeof sessionStorage>"u")return Promise.resolve();let X=Number(sessionStorage.getItem(m));if(!Number.isFinite(X)||X<=0)return Promise.resolve();if(!D)D=!0,console.warn(`[phlex-reactive] latency simulator ACTIVE — every action is delayed by ${X}ms. Call PhlexReactive.disableLatencySim() (or clear sessionStorage) to turn it off.`);return new Promise((Z)=>setTimeout(Z,X))}#YX(){return this.element?.getAttribute?.("data-reactive-debug")==="true"}#KX(){return typeof performance<"u"&&typeof performance.now==="function"?performance.now():Date.now()}#oX(X){if(!X)return[];let Z=[],$=/]*)>/g,Q;while((Q=$.exec(X))!==null){let j=Q[1],z=j.match(/\baction="([^"]*)"/)?.[1]??"?",q=j.match(/\btarget="([^"]*)"/)?.[1];Z.push(q?`${z} → #${q}`:z)}return Z}#sX(X){let{action:Z,status:$,ms:Q}=X,z=`reactive ${this.element?.id?`#${this.element.id} `:""}${Z} → ${$??"—"} (${Math.round(Q)}ms)`;if(console.groupCollapsed(z),console.log(`params: [${X.paramNames.join(", ")}] + collected: [${X.fieldNames.join(", ")}]`),console.log(`encoding: ${X.encoding}`),X.streams.length)console.log(`streams: ${X.streams.join(" ")}`);console.log(`token: ${X.tokenRefreshed?"refreshed ✓":"unchanged"}`),console.groupEnd()}async#nX(X,Z,$,Q,j){let{fields:z,files:q}=this.#WX(),G=this.#AX(Z),Y={...z,...G},K=this.#Y,U=q.length>0,B=U?this.#PZ(K,X,Y,q):JSON.stringify({token:K,act:X,params:Y}),W=this.#YX()?{action:X,paramNames:Object.keys(G),fieldNames:Object.keys(z),encoding:U?"multipart":"json",status:null,streams:[],tokenRefreshed:!1,started:this.#KX()}:null;await this.#iX();try{if(navigator.onLine===!1){this.#K($),this.#G("offline"),this.#q(X,Z,Y,{kind:"offline"});return}let V;try{let L={Accept:"text/vnd.turbo-stream.html","X-CSRF-Token":this.#bZ()};if(!U)L["Content-Type"]="application/json";let A=this.#wZ();if(A)L["X-Pgbus-Connection"]=A;V=await fetch(this.#kZ(),{method:"POST",headers:L,body:B,credentials:"same-origin",signal:AbortSignal.timeout(this.#SZ())})}catch(L){if(console.error("[phlex-reactive] action error",L),this.#K($),L?.name==="TimeoutError"||L?.name==="AbortError"){this.#G("timeout"),this.#q(X,Z,Y,{kind:"timeout"});return}this.#lX(),this.#G("network"),this.#q(X,Z,Y,{kind:"network"});return}if(W)W.status=V.status;if(V.redirected){console.error("[phlex-reactive] action was redirected (auth/CSRF?) — no update applied"),this.#K($),this.#G("redirected"),this.#q(X,Z,Y,{kind:"redirected",status:V.status});return}if(!V.ok){let L=await V.text();if(console.error(`[phlex-reactive] action failed: HTTP ${V.status}`,L),this.#K($),(V.headers.get("Content-Type")||"").includes("turbo-stream")){let A=this.#UX(L);if(this.#Y=A??this.#Y,W)this.#HX(W,L,A);window.Turbo.renderStreamMessage(L)}this.#G("http"),this.#q(X,Z,Y,{kind:"http",status:V.status,body:L});return}let x=V.headers.get("Content-Type")||"";if(!x.includes("turbo-stream")){console.error(`[phlex-reactive] expected a turbo-stream, got "${x}" — no update applied`),this.#K($),this.#G("content-type"),this.#q(X,Z,Y,{kind:"content-type",status:V.status});return}let H=await V.text(),J=this.#UX(H);if(this.#Y=J??this.#Y,W)this.#HX(W,H,J);if(window.Turbo.renderStreamMessage(H),j)queueMicrotask(j);this.#dX(),this.#F("reactive:applied",{action:X,params:Y,html:H})}catch(V){console.error("[phlex-reactive] action error",V),this.#K($),this.#F("reactive:error",{action:X,params:Y,kind:"apply"})}finally{if(Q?.(),W)this.#sX({...W,ms:this.#KX()-W.started})}}#HX(X,Z,$){X.streams=this.#oX(Z),X.tokenRefreshed=$!=null}get#Y(){return this.#i??this.tokenValue}set#Y(X){this.#i=X}#UX(X){let Z=this.element.id;if(!Z)return X.match(/data-reactive-token-value="([^"]+)"/)?.[1];let{token:$,self:Q}=this.#aX(Z),j=X.match($);if(j)return j[1];let z=X.match(Q);if(z)return z[1].match(/data-reactive-token-value="([^"]+)"/)?.[1];return}#aX(X){let Z=this.#o;if(Z&&Z.id===X)return Z;let $=gX(X);return this.#o={id:X,token:new RegExp(`]*\\baction="reactive:token"[^>]*\\btarget="${$}"[^>]*\\bdata-reactive-token-value="([^"]+)"`),self:new RegExp(`]*\\baction="(?:replace|update)"[^>]*\\btarget="${$}"[^>]*>([\\s\\S]*?)`)}}#$(X){return X.closest('[data-controller~="reactive"]')===this.element}#X(){if(this.element.querySelectorAll('[data-controller~="reactive"]').length===0)return()=>!0;return(Z)=>this.#$(Z)}#p(X,Z){if(X)return X;if(!Z)return null;let $=Z;if(typeof Z==="string")try{$=JSON.parse(Z)}catch{return console.warn(`[phlex-reactive] malformed conditional confirm payload ${JSON.stringify(Z)} — skipped`),null}if(!$||typeof $!=="object")return null;let{fields:Q}=this.#WX(),j=(q)=>Q[q],z;if(typeof $.predicate==="string"){let q=LX($.predicate);if(!q)return console.warn(`[phlex-reactive] confirm predicate "${$.predicate}" is not registered — proceeding without a dialog (register it with setConfirmPredicate)`),null;z=!!q(Q)}else z=P($.groups?.any,j)===!0;return z?$.message:null}#WX(){let X={},Z=[],$=this.#X();return this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((Q)=>{if(!$(Q))return;if(Q.type==="file")for(let j of Q.files??[])Z.push({name:Q.name,file:j,multiple:Q.multiple});else if(Q.type==="checkbox")X[Q.name]=Q.checked;else if(Q.type==="radio"){if(Q.checked)X[Q.name]=Q.value}else X[Q.name]=Q.value}),this.element.querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])").forEach((Q)=>{if(!$(Q))return;let j=Q.getAttribute("name");if(!j)return;let z=X[j];if(z==null||z==="")X[j]=Q.value??Q.textContent??Q.innerHTML??""}),{fields:X,files:Z}}#u(){if(typeof this.element?.querySelectorAll!=="function")return;let X=0;if(this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((Z)=>{if(!this.#$(Z))return;if(Z.type==="file")return;if(this.#rX(Z))Z.setAttribute("data-reactive-dirty","true"),X++;else Z.removeAttribute("data-reactive-dirty")}),X>0)this.element.setAttribute("data-reactive-dirty",String(X));else this.element.removeAttribute("data-reactive-dirty")}#rX(X){if(X.type==="checkbox"||X.type==="radio")return X.checked!==X.defaultChecked;if(X.tag==="select"||X.options)return Array.from(X.options??[]).some((Z)=>Z.selected!==Z.defaultSelected);return X.value!==X.defaultValue}#JX(){let X=this.element.getAttribute?.("data-reactive-dirty"),Z=Number(X);return Number.isFinite(Z)&&Z>0?Z:0}#tX(){if(typeof window>"u"||typeof window.addEventListener!=="function")return;this.#L=(X)=>{if(this.#JX()===0)return;return X.preventDefault(),X.returnValue="You have unsaved changes.",X.returnValue},this.#V=(X)=>{if(this.#JX()===0)return;if(!(typeof window.confirm==="function"?window.confirm("You have unsaved changes. Leave anyway?"):!0))X.preventDefault?.()},window.addEventListener("beforeunload",this.#L),window.addEventListener("turbo:before-visit",this.#V)}#eX(){if(this.#J)this.element.removeEventListener?.("turbo:morph-element",this.#J),this.#J=void 0;if(typeof window<"u"&&typeof window.removeEventListener==="function"){if(this.#L)window.removeEventListener("beforeunload",this.#L);if(this.#V)window.removeEventListener("turbo:before-visit",this.#V)}this.#L=void 0,this.#V=void 0}#XZ(){if(this.element.getAttribute?.("data-reactive-show-targets"))return!0;let X=this.element.querySelectorAll?.(o)??[];for(let Z of X)if(this.#$(Z))return!0;return!1}#ZZ(){return!!this.element.getAttribute?.("data-reactive-on-complete")}#$Z(){let X=this.element.getAttribute?.("data-reactive-on-complete")??null;if(X!==this.#a)this.#a=X,this.#w=X==null?[]:eX(X),this.#h=this.#w.map(()=>!1);return this.#w}#m(X){let Z=this.#$Z();if(!Z.length)return;let $=this.#X(),Q=this.element.getAttribute?.("data-reactive-scope")||null,j=new Map,z=(q)=>{if(!j.has(q))j.set(q,this.#BX(q,$,Q));return j.get(q)};Z.forEach((q,G)=>{let Y=P(q.any,z);if(Y===null)return;let K=Y&&!this.#h[G]&&Boolean(X);if(this.#h[G]=Y,K)N(q.ops,(U)=>this.#T(U.to==null?{...U,to:"@root"}:U))})}#QZ(){if(!this.#z)return;this.element.removeEventListener?.("input",this.#z),this.element.removeEventListener?.("change",this.#z),this.element.removeEventListener?.("turbo:morph-element",this.#b)}#LX(){if(typeof this.element?.querySelectorAll!=="function")return;let X=this.#X(),Z=this.element.getAttribute?.("data-reactive-scope")||null,$=new Map,Q=(j)=>{if(!$.has(j))$.set(j,this.#BX(j,X,Z));return $.get(j)};for(let j of this.element.querySelectorAll(o)){if(!X(j))continue;let z=j.getAttribute("data-reactive-show");if(z!==null){let K=ZZ(tX(z),Q);if(K!==null)this.#VX(j,K,X,Z);continue}let q=j.getAttribute("data-reactive-show-field");if(!q)continue;let G=Q(q);if(G===null)continue;let Y=nX(j,G);if(Y===null)continue;this.#VX(j,Y,X,Z)}this.#jZ(Q)}#VX(X,Z,$,Q){if(X.hidden=!Z,X.getAttribute("data-reactive-show-disable")!=="true")return;if(typeof X.querySelectorAll!=="function")return;for(let j of X.querySelectorAll("input[name], select[name], textarea[name]"))if($(j))j.disabled=!Z;if(X.name&&$(X))X.disabled=!Z}#jZ(X){let Z=this.#qZ();for(let[$,Q]of Object.entries(Z)){if($.startsWith("#")){this.#zZ($,Q,X);continue}if(!Q||typeof Q!=="object"||Array.isArray(Q))continue;let j=X($);if(j===null)continue;let z=()=>j;for(let[q,G]of Object.entries(Q)){if(!s(q))continue;let Y;if(Array.isArray(G)){if(G.length===0){console.warn(`[phlex-reactive] malformed reactive_show_targets group for ${q} — skipped`);continue}Y=G.every((K)=>g(K,z))}else{let K=HX(G,j);if(K===null){console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${q} — skipped`);continue}Y=K}for(let K of document.querySelectorAll(q))K.hidden=!Y}}}#zZ(X,Z,$){if(!s(X))return;let Q=Z&&typeof Z==="object"&&!Array.isArray(Z)?Z.any:null,j=XZ(Q);if(j===null){console.warn(`[phlex-reactive] malformed reactive_show_targets conditions for ${X} — skipped`);return}if(j.every((q)=>$(q)===null))return;let z=P(Q,$);if(z===null)return;for(let q of document.querySelectorAll(X))q.hidden=!z}#qZ(){let X=this.element.getAttribute?.("data-reactive-show-targets");if(!X)return{};try{let Z=JSON.parse(X);if(Z&&typeof Z==="object"&&!Array.isArray(Z))return Z}catch{}return console.warn("[phlex-reactive] malformed data-reactive-show-targets — ignored. "+"Did two reactive_show_targets calls collide on one root? Declare every field in ONE call: reactive_show_targets(mode: { ... }, kind: { ... })"),{}}#BX(X,Z,$){let Q=$&&!X.includes("[")?`${$}[${X}]`:X,j=!1,z=null;for(let q of this.element.querySelectorAll(`[name="${Q}"]`)){if(!Z(q))continue;if(q.type==="checkbox")return q.checked?"true":"false";if(q.type==="radio"){if(q.checked)return q.value??"";j=!0;continue}z??=q}if(z)return z.value??"";return j?"":null}#GZ(){if(!this.#Z)return;this.element.removeEventListener?.("input",this.#Z),this.element.removeEventListener?.("change",this.#Z),this.element.removeEventListener?.("turbo:morph-element",this.#Z),this.#Z=void 0}#C(){return!!(this.element.getAttribute?.("data-reactive-filter-input")&&this.element.getAttribute?.("data-reactive-filter-option"))}#YZ(){return this.element.getAttribute?.("data-reactive-compute-seed")==="true"}#KZ(X){let Z=this.element.getAttribute("data-reactive-filter-input");return!!Z&&typeof X.target?.matches==="function"&&X.target.matches(Z)}#A(){if(typeof this.element?.querySelectorAll!=="function")return;let X=this.element.getAttribute("data-reactive-filter-input"),Z=this.element.getAttribute("data-reactive-filter-option");if(!X||!Z)return;let $=this.#X(),Q=[...this.element.querySelectorAll(X)].find($);if(!Q)return;let j=(Q.value??"").trim().toLowerCase(),z=0;for(let Y of this.element.querySelectorAll(Z)){if(!$(Y))continue;let K=(Y.getAttribute("data-reactive-filter-text")??Y.textContent??"").toLowerCase(),U=Y.hasAttribute?.("data-reactive-tags-selected")||j!==""&&!K.includes(j);if(Y.hidden=U,U)Y.removeAttribute("data-reactive-highlighted");else z++}let q=this.element.getAttribute("data-reactive-filter-group");if(q)for(let Y of this.element.querySelectorAll(q)){if(!$(Y))continue;let K=[...Y.querySelectorAll(Z)].filter($);if(K.length===0)continue;Y.hidden=K.every((U)=>U.hidden)}let G=this.element.getAttribute("data-reactive-filter-empty");if(G){for(let Y of this.element.querySelectorAll(G))if($(Y))Y.hidden=z>0}}#HZ(){if(!this.#Q)return;this.element.removeEventListener?.("input",this.#Q),this.element.removeEventListener?.("turbo:morph-element",this.#Q),this.#Q=void 0}#I(){return!!this.element.getAttribute?.("data-reactive-tags-field")}#UZ(){if(typeof this.element?.querySelector!=="function")return!1;return!!this.element.querySelector("[data-reactive-nested-json]")}#g(){if(typeof this.element?.querySelectorAll!=="function")return null;let X=this.element.getAttribute("data-reactive-tags-field");if(!X)return null;let Z=this.#X();return[...this.element.querySelectorAll(X)].find(Z)??null}#c(X){let Z=new Set,$=[];for(let Q of String(X.value??"").split(",")){let j=Q.trim();if(j===""||Z.has(j.toLowerCase()))continue;Z.add(j.toLowerCase()),$.push(j)}return $}#_X(X){let Z=this.#g();if(!Z)return!1;let $=this.#c(Z),Q=new Set($.map((z)=>z.toLowerCase())),j=!1;for(let z of X){let q=String(z??"").trim();if(q===""||Q.has(q.toLowerCase()))continue;Q.add(q.toLowerCase()),$.push(q),j=!0}if(j)this.#MX(Z,$);return j}#MX(X,Z){if(X.value=Z.join(","),typeof X.dispatchEvent==="function")X.dispatchEvent(new Event("input",{bubbles:!0}));this.#d()}#WZ(){if(typeof this.element?.querySelectorAll!=="function")return null;let X=this.element.getAttribute("data-reactive-filter-input");if(!X)return null;let Z=this.#X();return[...this.element.querySelectorAll(X)].find(Z)??null}#d(){let X=this.#g();if(!X)return;let Z=this.#c(X),$=this.#X();this.#JZ(Z,$),this.#LZ(Z,$)}#JZ(X,Z){let $=[...this.element.querySelectorAll("[data-reactive-tags-list]")].find(Z);if(!$)return;let j=[...this.element.querySelectorAll("[data-reactive-tags-template]")].find(Z)?.content?.firstElementChild;if(!j){if(!this.#r)console.warn("[phlex-reactive] reactive_tags: no chip found in this root — "+"chips will not render (the hidden field still updates). Add a template with a [data-reactive-tag-text] node and a reactive_tags_remove button."),this.#r=!0;return}while($.firstChild)$.removeChild($.firstChild);for(let z of X){let q=j.cloneNode(!0);q.setAttribute?.("data-reactive-tag",z);let G=q.matches?.("[data-reactive-tag-text]")?q:(q.querySelectorAll?.("[data-reactive-tag-text]")??[])[0];if(G)G.textContent=z;let Y=[...q.querySelectorAll?.('[data-action*="reactive#tagsRemove"]')??[]];if(q.matches?.('[data-action*="reactive#tagsRemove"]'))Y.push(q);for(let K of Y)K.setAttribute?.("data-reactive-tag-param",z);$.appendChild(q)}}#LZ(X,Z){let $=new Set(X.map((Q)=>Q.toLowerCase()));for(let Q of this.element.querySelectorAll("[role=option]")){if(!Z(Q))continue;let j=Q.getAttribute?.("data-reactive-tag-param");if(!j)continue;if($.has(j.toLowerCase()))Q.setAttribute("data-reactive-tags-selected","true"),Q.hidden=!0,Q.removeAttribute?.("data-reactive-highlighted");else if(Q.hasAttribute?.("data-reactive-tags-selected")){if(Q.removeAttribute("data-reactive-tags-selected"),!this.#C())Q.hidden=!1}}if(this.#C())this.#A()}#VZ(){return this.#y=Math.max(this.#y+1,Date.now()),this.#y}#BZ(X,Z){let $=[X,...X.querySelectorAll?.("*")??[]];for(let Q of $)for(let j of["name","id","for"]){let z=Q.getAttribute?.(j);if(z&&z.includes("NEW_ROW"))Q.setAttribute?.(j,z.replaceAll("NEW_ROW",String(Z)))}}#_Z(X){if(this.#t)return;console.warn(`[phlex-reactive] nested rows: no owned [data-reactive-nested-list="${X}"] container + pair found in this root — the add `+"trigger did nothing. Render both inside the same reactive root (reactive_nested_list / reactive_nested_template)."),this.#t=!0}#MZ(){if(!this.#B)return;this.element.removeEventListener?.("turbo:morph-element",this.#B),this.#B=void 0}#AZ(){if(this.#j)this.element.removeEventListener?.("input",this.#j),this.element.removeEventListener?.("change",this.#j),this.#j=void 0;if(this.#_)this.element.removeEventListener?.("turbo:morph-element",this.#_),this.#_=void 0}#xZ(){if(!this.#M)return;this.element.removeEventListener?.("turbo:morph-element",this.#M),this.#M=void 0}#PZ(X,Z,$,Q){let j=new FormData;j.append("token",X),j.append("act",Z);for(let[q,G]of Object.entries($))this.#l(j,`params[${q}]`,G);let z=this.#NZ(Q);for(let{name:q,file:G,multiple:Y}of Q){let U=Y||z.has(q)?`params[${q}][]`:`params[${q}]`;j.append(U,G,G.name)}return j}#l(X,Z,$){if($==null)X.append(Z,"");else if(Array.isArray($))$.forEach((Q,j)=>this.#l(X,`${Z}[${j}]`,Q));else if(typeof $==="object")for(let[Q,j]of Object.entries($))this.#l(X,`${Z}[${Q}]`,j);else X.append(Z,String($))}#NZ(X){let Z=new Map;for(let{name:$}of X)Z.set($,(Z.get($)??0)+1);return new Set([...Z].filter(([,$])=>$>1).map(([$])=>$))}#AX(X){if(!X)return{};try{return typeof X==="string"?JSON.parse(X):X}catch{return{}}}#xX(X){return UX(X)}#PX(X){N(X,(Z)=>this.#T(Z))}#T(X){let Z=X.to;if(Z==="@root")return[this.element];if(typeof Z!=="string"||Z==="")return[];if(X.global)return[...document.querySelectorAll(Z)];return[...this.element.querySelectorAll(Z)].filter(($)=>this.#$($))}#OZ(X,Z){if(X?.checked!=="keep")return!1;let $=Z?.type;return $==="checkbox"||$==="radio"}#DZ(X,Z){if(!X)return null;let $=this.#NX(X,Z,!0);return $.length?$:null}#K(X){if(!X)return;if(!this.element.isConnected)return;for(let Z of X)Z()}#RZ(X,Z,$){this.#CZ(X,Z);let Q=$?this.#NX($,Z,!1):[];jX();let j=!1;return()=>{if(j)return;j=!0,this.#IZ(X,Z),zX();for(let z of Q)z()}}#NX(X,Z,$){let Q=[];for(let j of this.#OX(X,Z)){if(X.add_class){let z=X.add_class.filter((q)=>!j.classList.contains(q));if(j.classList.add(...z),z.length)Q.push(()=>j.classList.remove(...z))}if(X.remove_class){let z=X.remove_class.filter((q)=>j.classList.contains(q));if(j.classList.remove(...z),z.length)Q.push(()=>j.classList.add(...z))}if(X.toggle_class)X.toggle_class.forEach((z)=>j.classList.toggle(z)),Q.push(()=>X.toggle_class.forEach((z)=>j.classList.toggle(z)));if(X.hide)j.hidden=!0,Q.push(()=>j.hidden=!1);if(X.show)j.hidden=!1,Q.push(()=>j.hidden=!0)}if(Z&&(X.disable||X.text!=null))Q.push(this.#FZ(X,Z));if($&&X.checked==="keep"&&Z&&"checked"in Z){let j=Z.checked;Q.push(()=>Z.checked=!j)}return Q}#OX(X,Z){if(X.to==null)return Z?[Z]:[];return this.#T({to:X.to})}#FZ(X,Z){let $=this.#P.get(Z);if($)$.count++;else this.#P.set(Z,{count:1,disabled:Z.disabled,html:Z.innerHTML,hadText:X.text!=null,swappedTo:X.text});if(X.disable)Z.disabled=!0;if(X.text!=null)Z.innerHTML=X.text;return()=>this.#TZ(Z,X)}#CZ(X,Z){if(this.#H(Z,X,1),this.#H(this.element,X,1),this.#W.set(X,(this.#W.get(X)??0)+1),this.#k++===0)this.element.setAttribute("aria-busy","true");for(let $ of this.#DX(X))this.#H($,X,1)}#IZ(X,Z){this.#H(Z,X,-1),this.#H(this.element,X,-1);let $=(this.#W.get(X)??1)-1;if($<=0)this.#W.delete(X);else this.#W.set(X,$);if(--this.#k<=0)this.#k=0,this.element.removeAttribute("aria-busy");for(let Q of this.#DX(X))this.#H(Q,X,-1)}#H(X,Z,$){if(!X||typeof X.getAttribute!=="function")return;let Q=this.#S.get(X)??new Map,j=(Q.get(Z)??0)+$;if(j<=0)Q.delete(Z);else Q.set(Z,j);if(Q.size===0){this.#S.delete(X),X.removeAttribute("data-reactive-busy");return}this.#S.set(X,Q),X.setAttribute("data-reactive-busy",[...Q.keys()].join(" "))}#DX(X){return[...this.element.querySelectorAll?.("[data-reactive-busy-on]")??[]].filter(($)=>$.getAttribute("data-reactive-busy-on")===X&&this.#$($))}#TZ(X,Z){let $=this.#P.get(X);if(!$)return;if(--$.count>0)return;if(this.#P.delete(X),!X.isConnected)return;if(Z.disable)X.disabled=$.disabled;if($.hadText&&X.innerHTML===$.swappedTo)X.innerHTML=$.html}#EZ(X){if(!X||typeof X!=="object")return null;let{class:Z,...$}=X;return Z==null?X:{...$,add_class:Z}}#kZ(){return this.#RX??=document.querySelector('meta[name="phlex-reactive-action-path"]')?.content||"/reactive/actions"}#SZ(){if(this.#E!=null)return this.#E;let X=document.querySelector('meta[name="phlex-reactive-timeout"]')?.content,Z=Number(X);return this.#E=Number.isFinite(Z)&&Z>0?Z:30000}#bZ(){return document.querySelector('meta[name="csrf-token"]')?.content??""}#wZ(){return document.querySelector("pgbus-stream-source[connection-id]")?.getAttribute("connection-id")||document.querySelector('meta[name="pgbus-connection-id"]')?.content||null}}export{UZ as resetReactiveDefers,MZ as resetReactiveActivity,VX as registerReactiveVisit,BX as registerReactiveToken,fX as registerReactiveOffline,_X as registerReactiveJs,IX as registerReactiveEffects,RX as registerReactiveDismiss,MX as registerReactiveDefer,l as registerReactiveActions,_Z as reactiveActivityCount,WZ as pendingDeferVia,zX as exitReactiveActivity,gX as escapeRegExp,jX as enterReactiveActivity,pX as enableLatencySim,uX as disableLatencySim,PZ as default,cX as checkReactiveRegistration,AZ as __resetReactiveRegistrationForTest,VZ as __resetReactiveOfflineForTest,BZ as __resetReactiveLatencyForTest,LZ as __resetReactiveEffectsForTest,JZ as __resetReactiveDismissForTest,xZ as __markReactiveConnectedForTest,m as LATENCY_KEY,QX as ACTIVE_ATTR};
+import{Controller as WX}from"@hotwired/stimulus";import{confirmResolver as C}from"phlex/reactive/confirm";import{computeReducer as JX}from"phlex/reactive/compute";import{confirmPredicate as LX}from"phlex/reactive/confirm_predicate";function VX(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:visit"])return;X["reactive:visit"]=function(){let Z=this.getAttribute("data-url");if(Z)window.Turbo.visit(Z,{action:"advance"})}}function BX(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:token"])return;X["reactive:token"]=function(){let Z=this.getAttribute("data-reactive-token-value"),$=this.getAttribute("target");if(!Z||!$)return;let Q=document.getElementById($);if(Q)Q.setAttribute("data-reactive-token-value",Z)}}function _X(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:js"])return;X["reactive:js"]=function(){let Z=UX(this.getAttribute("data-reactive-ops"));if(!Z.length)return;let $=this.getAttribute("target"),Q=$?document.getElementById($):null;if($&&!Q)return;N(Z,(j)=>YZ(j,Q))}}var _=new Map;function n(X,Z){let $=!_.has(X);if(_.set(X,Z),$)jX()}function R(X){if(_.delete(X))zX()}function WZ(){_.clear(),w=!1}function JZ(X){return _.get(X)?.via}var w=!1;function MX(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:defer"])return;if(X["reactive:defer"]=function(){let Z=this.getAttribute("target");if(!Z)return;if(this.getAttribute("data-reactive-defer-via")==="stream"){xX(Z,this);return}let $=this.getAttribute("data-reactive-defer-token");if(!$)return;F(Z,$)},!w&&typeof document<"u"&&document.addEventListener)w=!0,document.addEventListener("turbo:before-stream-render",AX)}function AX(X){let $=X.target?.getAttribute?.("target");if(!$)return;let Q=$.startsWith("reactive-defer-src-")?$.slice(19):$;if(_.get(Q)?.via==="stream")R(Q)}function F(X,Z){let $=document.getElementById(X);if(!$){console.warn(`[phlex-reactive] reactive:defer target #${X} is not on the page — skipped`);return}r(X),t($);let Q={via:"fetch",abort:new AbortController,timedOut:!1};n(X,Q),PX(X,Q,Z)}function xX(X,Z){let $=document.getElementById(X);if(!$){console.warn(`[phlex-reactive] reactive:defer target #${X} is not on the page — skipped`);return}let Q=Z.getAttribute("data-reactive-defer-src");if(!Q)return;if(!globalThis.customElements?.get?.("pgbus-stream-source")){let z=Z.getAttribute("data-reactive-defer-token");if(z){F(X,z);return}console.error("[phlex-reactive] reactive:defer via=stream but is not registered "+"and no fallback token was provided — is the pgbus client loaded on this page?");return}r(X),t($);let j=document.createElement("pgbus-stream-source");j.id=a(X),j.setAttribute("src",Q),j.setAttribute("since-id",Z.getAttribute("data-reactive-defer-since-id")??"0"),j.setAttribute("hidden",""),document.body.appendChild(j),n(X,{via:"stream"})}function a(X){return`reactive-defer-src-${X}`}async function PX(X,Z,$){let Q=setTimeout(()=>{Z.timedOut=!0,Z.abort.abort()},DX()),j;try{j=await fetch(NX(),{method:"POST",headers:{Accept:"text/vnd.turbo-stream.html","Content-Type":"application/json","X-CSRF-Token":OX()},body:JSON.stringify({token:$}),credentials:"same-origin",signal:Z.abort.signal})}catch(q){if(clearTimeout(Q),_.get(X)!==Z)return;console.error("[phlex-reactive] deferred render failed",q),E(X,$);return}if(_.get(X)!==Z){clearTimeout(Q);return}if(j.status===204){clearTimeout(Q),c(X);return}if(!j.ok){clearTimeout(Q),console.error(`[phlex-reactive] deferred render failed: HTTP ${j.status}`),E(X,$,j.status);return}let z;try{z=await j.text()}catch(q){if(clearTimeout(Q),_.get(X)!==Z)return;console.error("[phlex-reactive] deferred render failed reading the body",q),E(X,$);return}if(clearTimeout(Q),_.get(X)!==Z)return;c(X),window.Turbo.renderStreamMessage(z)}function r(X){let Z=_.get(X);if(!Z)return;if(R(X),Z.via==="fetch")Z.abort.abort();else document.getElementById(a(X))?.remove?.()}function t(X){X.setAttribute("data-reactive-defer-pending","true"),X.setAttribute("aria-busy","true")}function e(X){X.removeAttribute("data-reactive-defer-pending"),X.removeAttribute("aria-busy")}function c(X){R(X);let Z=document.getElementById(X);if(!Z)return;e(Z),Z.removeAttribute("data-reactive-error")}function E(X,Z,$){R(X);let Q=document.getElementById(X);if(!Q)return;e(Q),Q.setAttribute("data-reactive-error","defer");let j=()=>{let z=document.getElementById(X);if(!z){console.warn("[phlex-reactive] defer retry() ignored — the target left the DOM");return}z.removeAttribute("data-reactive-error"),F(X,Z)};Q.dispatchEvent(new CustomEvent("reactive:error",{bubbles:!0,composed:!0,detail:{kind:"defer",target:X,status:$,retry:j}}))}function NX(){return document.querySelector('meta[name="phlex-reactive-defer-path"]')?.content||"/reactive/defer"}function OX(){return document.querySelector('meta[name="csrf-token"]')?.content??""}function DX(){let X=document.querySelector('meta[name="phlex-reactive-timeout"]')?.content,Z=Number(X);return Number.isFinite(Z)&&Z>0?Z:30000}var h=!1;function RX(){if(h)return;if(typeof document>"u"||!document.addEventListener)return;h=!0,document.addEventListener("turbo:before-stream-render",FX)}function FX(X){let Z=X.detail,$=Z?.render;if(typeof $!=="function"||$.__reactiveDismissWrapped){if(typeof requestAnimationFrame==="function")requestAnimationFrame(T);else setTimeout(T,0);return}let Q=async(j)=>{await $(j),T()};Q.__reactiveDismissWrapped=!0,Z.render=Q}function T(){let X=document.querySelectorAll("[data-reactive-dismiss-after]");for(let Z of X){if(Z.hasAttribute("data-reactive-dismiss-scheduled"))continue;let $=Number(Z.getAttribute("data-reactive-dismiss-after"));if(!Number.isFinite($)||$<=0)continue;Z.setAttribute("data-reactive-dismiss-scheduled",""),setTimeout(()=>Z.remove(),$)}}function LZ(){h=!1}var IX=Object.freeze({append:"enter",prepend:"enter",replace:"update",update:"update",remove:"exit"}),k=Object.freeze(["fade","slide","scale","highlight","shake"]),y="data-reactive-fx-pending",XX=1000,v=!1;function CX(){if(v)return;if(typeof document>"u"||typeof document.addEventListener!=="function")return;v=!0,document.addEventListener("turbo:before-stream-render",EX)}function VZ(){v=!1}function EX(X){let Z=X.detail,$=Z?.render;if(typeof $!=="function"||$.__reactiveEffectsWrapped)return;let Q=Z?.newStream??X.target,j=IX[Q?.getAttribute?.("action")];if(!j||SX())return;let z=TX(Q,j);if(!z)return;let q=j==="exit"?async(Y)=>{await hX(O(Q),z),await $(Y)}:async(Y)=>{let G=j==="enter"?bX(Q):null;if(await $(Y),j==="enter")wX(G,z);else ZX(O(Q),z)};q.__reactiveEffectsWrapped=!0,Z.render=q}function TX(X,Z){let $=X.getAttribute?.("data-reactive-effect");if($==="off")return null;if($)return d($,Z);let j=(Z==="enter"?kX(X):O(X))?.getAttribute?.(`data-reactive-effect-${Z}`);return j?d(j,Z):null}function O(X){let Z=X.getAttribute?.("target");return Z?document.getElementById?.(Z)??null:null}function kX(X){return X.querySelector?.("template")?.content?.firstElementChild??null}function d(X,Z){if(X.startsWith("[")){let Q=null;try{let j=JSON.parse(X);if(Array.isArray(j)&&j.length===3)Q=j.map(String)}catch{}if(Q)return{legs:Q};return console.warn(`[phlex-reactive] malformed effect legs ${JSON.stringify(X)} — skipped`),null}let $=X==="random"?k[Math.floor(Math.random()*k.length)]:X;if(!k.includes($))return console.warn(`[phlex-reactive] unknown effect ${JSON.stringify(X)} — skipped`),null;return{className:`reactive-fx--${$}-${Z}`}}function SX(){try{return typeof matchMedia==="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}catch{return!1}}function bX(X){let Z=X.querySelector?.("template")?.content;if(!Z)return null;for(let $ of Array.from(Z.children??[]))$.setAttribute?.(y,"");return O(X)}function wX(X,Z){if(typeof X?.querySelectorAll!=="function")return;for(let $ of Array.from(X.querySelectorAll(`[${y}]`)))$.removeAttribute(y),ZX($,Z)}async function hX(X,Z){if(!X?.classList)return;if(Z.legs){await $X(X,Z.legs);return}X.classList.add(Z.className);let $=p(X);if($<=0){X.classList.remove(Z.className);return}await u(X,$),X.classList.remove(Z.className)}function ZX(X,Z){if(!X?.classList)return;if(Z.legs){$X(X,Z.legs);return}if(X.classList.contains(Z.className))X.classList.remove(Z.className),X.offsetWidth;X.classList.add(Z.className);let $=p(X);if($<=0){X.classList.remove(Z.className);return}let Q=X.__reactiveFxToken=(X.__reactiveFxToken??0)+1;u(X,$).then(()=>{if(X.__reactiveFxToken===Q)X.classList.remove(Z.className)})}async function $X(X,Z){let[$,Q,j]=Z.map(yX),z=X.__reactiveFxToken=(X.__reactiveFxToken??0)+1;if(X.classList.remove(...$,...Q,...j),X.classList.add(...$,...Q),await vX(),X.__reactiveFxToken!==z)return;X.classList.remove(...Q),X.classList.add(...j);let q=p(X);if(q>0)await u(X,q);if(X.__reactiveFxToken!==z)return;X.classList.remove(...$,...j)}function yX(X){return String(X??"").split(/\s+/).filter(Boolean)}function p(X){if(typeof getComputedStyle!=="function")return 0;try{let Z=getComputedStyle(X),$=(z)=>String(z??"").split(",").reduce((q,Y)=>Math.max(q,parseFloat(Y)||0),0),Q=$(Z.animationDuration)+$(Z.animationDelay),j=$(Z.transitionDuration)+$(Z.transitionDelay);return Math.min(Math.max(Q,j)*1000,XX)}catch{return 0}}function u(X,Z){return new Promise(($)=>{let Q=!1,j=()=>{if(Q)return;Q=!0,$()};X.addEventListener?.("animationend",j,{once:!0}),X.addEventListener?.("transitionend",j,{once:!0}),setTimeout(j,Math.min(Z+50,XX))})}function vX(){return new Promise((X)=>{if(typeof requestAnimationFrame==="function")requestAnimationFrame(()=>X());else setTimeout(X,16)})}var f=!1;function fX(){if(f)return;if(typeof window>"u"||typeof document>"u")return;if(typeof window.addEventListener!=="function")return;f=!0;let X=()=>{let Z=document.documentElement;if(typeof Z?.toggleAttribute!=="function")return;Z.toggleAttribute("data-reactive-offline",globalThis.navigator?.onLine===!1)};X(),window.addEventListener("online",X),window.addEventListener("offline",X)}function BZ(){f=!1}var m="phlex-reactive:latency",D=!1;function pX(X){if(typeof sessionStorage>"u")return;sessionStorage.setItem(m,String(X))}function uX(){if(typeof sessionStorage>"u")return;sessionStorage.removeItem(m),D=!1}function mX(){if(typeof window>"u"||typeof document>"u")return;if(document.querySelector?.('meta[name="phlex-reactive-env"]')?.content!=="development")return;window.PhlexReactive={enableLatencySim:pX,disableLatencySim:uX}}function _Z(){D=!1}var QX="data-reactive-active",M=0;function jX(){if(M++,M===1)qX("reactive:busy")}function zX(){if(M===0)return;if(M--,M===0)qX("reactive:idle")}function MZ(){return M}function AZ(){M=0,(typeof document<"u"?document.documentElement:null)?.removeAttribute?.(QX)}function qX(X){if(typeof document>"u")return;let Z=document.documentElement;if(typeof Z?.toggleAttribute==="function")Z.toggleAttribute(QX,M>0);if(typeof document.dispatchEvent==="function"&&typeof CustomEvent==="function")document.dispatchEvent(new CustomEvent(X,{detail:{count:M}}))}function l(){VX(),BX(),_X(),MX(),RX(),CX(),fX(),mX()}function gX(X){return X.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}if(typeof window<"u")if(window.Turbo)l();else document.addEventListener("turbo:load",l,{once:!0});var I=!1;function cX(){if(I)return;if(typeof document>"u")return;let X=document.querySelectorAll('[data-controller~="reactive"]');if(!X||X.length===0)return;console.warn("[phlex-reactive] found "+X.length+' element(s) with data-controller="reactive" '+"but the reactive controller never connected. It is loaded but not registered — "+'add `application.register("reactive", ReactiveController)` (importmap) or import it into app/javascript/controllers/ for lazyLoadControllersFrom apps. See the README.')}function xZ(){I=!1}function PZ(){I=!0}if(typeof window<"u"&&typeof document<"u"){let X=()=>setTimeout(cX,0);if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",X,{once:!0});else X()}var dX=new Set(["href","src","srcdoc","action","formaction","xlink:href","style"]);function lX(X){let Z=String(X).toLowerCase();return Z.startsWith("on")||dX.has(Z)}function iX(X,Z,$){let[Q,j,z]=Z;X.classList.add(Q,j),$(),requestAnimationFrame(()=>{X.classList.remove(j),X.classList.add(z)});let q=!1,Y=()=>{if(q)return;q=!0,X.classList.remove(Q,z)};X.addEventListener("animationend",Y,{once:!0}),setTimeout(Y,350)}var i=Object.freeze({show:(X,Z)=>S(X,!1,Z),hide:(X,Z)=>S(X,!0,Z),toggle:(X,Z)=>S(X,!X.hidden,Z),add_class:(X,Z)=>X.classList.add(...Z.classes??[]),remove_class:(X,Z)=>X.classList.remove(...Z.classes??[]),toggle_class:(X,Z)=>(Z.classes??[]).forEach(($)=>X.classList.toggle($)),set_attr:(X,Z)=>{if(b(Z.name))X.setAttribute(Z.name,Z.value??"")},remove_attr:(X,Z)=>{if(b(Z.name))X.removeAttribute(Z.name)},toggle_attr:(X,Z)=>{if(!b(Z.name))return;if(X.hasAttribute(Z.name))X.removeAttribute(Z.name);else X.setAttribute(Z.name,"")},focus:(X)=>X.focus?.(),focus_first:(X)=>zZ(X)?.focus?.(),text:(X,Z)=>{let $=String(Z.value??"");if(X.textContent!==$)X.textContent=$},dispatch:(X,Z)=>{X.dispatchEvent(new CustomEvent(Z.name,{bubbles:!0,composed:!0,detail:Z.detail??{}}))},submit:(X)=>oX(X)?.requestSubmit?.(),paste_into:(X)=>sX(X)});function oX(X){if(X?.tagName==="FORM")return X;return X?.form??X?.closest?.("form")??null}function sX(X){let Z=globalThis.navigator?.clipboard;if(typeof Z?.readText!=="function")return;Z.readText().then(($)=>{if(!$)return;if(X.value=$,typeof X.dispatchEvent==="function")X.dispatchEvent(new Event("input",{bubbles:!0}));X.focus?.()}).catch(()=>{})}function S(X,Z,$){if($?.transition)iX(X,$.transition,()=>X.hidden=Z);else X.hidden=Z}function b(X){if(!lX(X))return!0;return console.warn(`[phlex-reactive] refused client attr op on ${JSON.stringify(X)} — skipped`),!1}var YX=/^#[A-Za-z_][\w-]*$/;function nX(X){if(typeof X==="string"&&YX.test(X))return!0;return console.warn(`[phlex-reactive] refused cross-root mirror target ${JSON.stringify(X)} — skipped`),!1}function aX(X,Z){let $=X.getAttribute("data-reactive-show-equals");if($!==null)return Z===$;let Q=X.getAttribute("data-reactive-show-not");if(Q!==null)return Z!==Q;let j=X.getAttribute("data-reactive-show-in");if(j!==null){try{let z=JSON.parse(j);if(Array.isArray(z))return z.includes(Z)}catch{}return console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(j)} — skipped`),null}for(let z of GX){let q=X.getAttribute(`data-reactive-show-${z}`);if(q!==null)return KX(z,q,Z)}return console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped"),null}var GX=["gte","gt","lte","lt"],rX=["len_eq","len_gte","len_gt","len_lte","len_lt"];function tX(X,Z,$){if(!Number.isInteger(Z))return console.warn(`[phlex-reactive] reactive_show ${X}: needs an integer literal, got ${JSON.stringify(Z)} — skipped`),null;let Q=[...String($??"")].length;switch(X){case"len_eq":return Q===Z;case"len_gte":return Q>=Z;case"len_gt":return Q>Z;case"len_lte":return Q<=Z;case"len_lt":return Q=Q;case"gt":return z>Q;case"lte":return z<=Q;case"lt":return z$&&typeof $==="object"&&Array.isArray($.any)&&Array.isArray($.ops)))return Z}catch{}return console.warn(`[phlex-reactive] malformed reactive_on_complete payload ${JSON.stringify(X)} — skipped`),[]}function g(X,Z){if(!X||typeof X!=="object"||typeof X.field!=="string")return!1;let $=Z(X.field)??"";return HX(X,$)===!0}function P(X,Z){if(!Array.isArray(X)||X.length===0)return null;return X.some(($)=>Array.isArray($)&&$.length>0&&$.every((Q)=>g(Q,Z)))}function ZZ(X){if(!Array.isArray(X)||X.length===0)return null;let Z=new Set;for(let $ of X){if(!Array.isArray($))continue;for(let Q of $)if(Q&&typeof Q==="object"&&typeof Q.field==="string")Z.add(Q.field)}return Z.size>0?[...Z]:null}function $Z(X,Z){if(!X||typeof X!=="object")return null;let $=X.any;if(Array.isArray($)&&($.length===0||Array.isArray($[0])))return P($,Z);return QZ(X,Z)}function QZ(X,Z){let $=Array.isArray(X.all)?"all":Array.isArray(X.any)?"any":null;if(!$)return null;let Q=X[$];if(Q.length===0)return null;let j=Q.map((z)=>g(z,Z));return $==="all"?j.every(Boolean):j.some(Boolean)}function s(X){if(typeof X==="string"&&YX.test(X))return!0;return console.warn(`[phlex-reactive] refused cross-root show target ${JSON.stringify(X)} — skipped`),!1}var jZ='a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';function zZ(X){return X.querySelectorAll?.(jZ)?.[0]??null}function UX(X){if(Array.isArray(X))return X;if(typeof X!=="string")return[];try{let Z=JSON.parse(X);return Array.isArray(Z)?Z:[]}catch{return[]}}function qZ(X){if(X==null)return null;let Z=Array.isArray(X)?X:X.ops;if(Array.isArray(Z))return Z.length>0?Z:null;return console.warn("[phlex-reactive] $ops must be an ops chain or a [[op, args], ...] list — skipped"),null}function N(X,Z){for(let $ of X){if(!Array.isArray($))continue;let[Q,j={}]=$;if(!Object.hasOwn(i,Q)){console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(Q)} — skipped`);continue}for(let z of Z(j))i[Q](z,j)}}function YZ(X,Z){let $=X.to;if(Z){if($==="@root")return[Z];if(typeof $!=="string"||$==="")return[];if(X.global)return[...document.querySelectorAll($)];return[...Z.querySelectorAll($)]}if(typeof $!=="string"||$===""||$==="@root")return[];return[...document.querySelectorAll($)]}class NZ extends WX{static values={token:String};#o;#P=new Map;#U=new Map;#IX;#k;#s;#S=0;#W=new Map;#b=new WeakMap;#N=new Map;#n=new WeakSet;#a=null;#J;#L;#V;#$;#z;#w;#r;#h;#y;#Q;#B;#t=!1;#v=0;#e=!1;#j;#_;#M;#O;#A;connect(){if(I=!0,this.element.id==="")console.warn("[phlex-reactive] a reactive root has no id; its next-action token can't self-match "+"and may fall back to the first token in the response → a silent HTTP 403 on the NEXT action. "+"Put id: on the SAME element as reactive_attrs — use div(**reactive_root) (emits id + token together), "+"or div(id:, **reactive_attrs). The id: must NOT be on a child. See the README.");if(this.element.getAttribute?.("data-reactive-defer-token"))this.#XX(),this.#O=()=>this.#XX(),this.element.addEventListener?.("turbo:morph-element",this.#O);if(this.#CX()){if(this.#J=()=>this.#m(),this.element.addEventListener?.("turbo:morph-element",this.#J),this.#m(),this.element.getAttribute?.("data-reactive-warn-unsaved")==="true")this.#XZ()}if(this.#$Z())this.#$=()=>this.#BX(),this.element.addEventListener?.("input",this.#$),this.element.addEventListener?.("change",this.#$),this.element.addEventListener?.("turbo:morph-element",this.#$),this.#BX();if(this.#QZ())this.#z=(X)=>this.#g(X),this.#w=()=>this.#g(null),this.element.addEventListener?.("input",this.#z),this.element.addEventListener?.("change",this.#z),this.element.addEventListener?.("turbo:morph-element",this.#w),this.#g(null);if(this.#C())this.#Q=(X)=>{if(X?.type==="input"&&!this.#JZ(X))return;this.#x()},this.element.addEventListener?.("input",this.#Q),this.element.addEventListener?.("turbo:morph-element",this.#Q),this.#x();if(this.#E())this.#B=()=>this.#l(),this.element.addEventListener?.("turbo:morph-element",this.#B),this.#l();if(this.#VZ())this.#j=(X)=>this.syncNestedJson(X),this.#_=()=>this.#R(),this.element.addEventListener?.("input",this.#j),this.element.addEventListener?.("change",this.#j),this.element.addEventListener?.("turbo:morph-element",this.#_),this.#R();if(this.#WZ())this.#M=()=>this.recompute(),this.element.addEventListener?.("turbo:morph-element",this.#M),this.recompute();if(this.#jZ())this.#A=()=>this.#VX(),this.element.addEventListener?.("turbo:morph-element",this.#A),this.#VX()}#CX(){if((this.element.getAttribute?.("data-action")??"").includes("reactive#trackDirty"))return!0;let X=this.element.querySelectorAll?.('[data-action*="reactive#trackDirty"]')??[];for(let Z of X)if(this.#Z(Z))return!0;return!1}disconnect(){if(this.#cX(),this.#lX(),this.#ZZ(),this.#UZ(),this.#YZ(),this.#LZ(),this.#NZ(),this.#OZ(),this.#DZ(),this.#zZ(),this.#O)this.element.removeEventListener?.("turbo:morph-element",this.#O)}#XX(){let X=this.element;if(!X?.id)return;let Z=X.getAttribute?.("data-reactive-defer-token");if(!Z)return;if(X.getAttribute?.("data-reactive-defer-pending")!=="true")return;F(X.id,Z)}dispatch(X){let{action:Z,params:$,debounce:Q,throttle:j,confirm:z,confirmWhen:q,outside:Y,window:G,optimistic:K}=X.params;if(!Z)return;let U=X.params.busy??this.#wZ(X.params.loading);if(Y&&this.element.contains(X.target))return;let B=X.currentTarget??X.target;if(!G&&!this.#IZ(K,B))X.preventDefault();let W=this.#u(z,q);if(!W)return this.#GX(B,Z,$,Q,j,K,U);Promise.resolve().then(()=>C(W,{el:B})).catch(()=>!1).then((V)=>{if(V)this.#GX(B,Z,$,Q,j,K,U)})}runOps(X){let{ops:Z,confirm:$,confirmWhen:Q,outside:j,window:z}=X.params,q=X.currentTarget??X.target;if(j&&this.element.contains(X.target))return;if(!z)X.preventDefault();let Y=this.#u($,Q);if(!Y)return this.#OX(this.#NX(Z));Promise.resolve().then(()=>C(Y,{el:q})).catch(()=>!1).then((G)=>{if(G)this.#OX(this.#NX(Z))})}trackDirty(){this.#m()}recompute(X){if(X&&this.#n.has(X))return;let Z=this.#yX(),$=Z.map(([H])=>H),Q=this.element.getAttribute?.("data-reactive-scope")||null,j=(H)=>Q&&!H.includes("[")?`${Q}[${H}]`:H,z=this.#X(),q=new Map,Y=(H)=>{if(q.has(H))return q.get(H);let J=null;for(let L of this.element.querySelectorAll(`[name="${j(H)}"]`))if(z(L)){J=L;break}return q.set(H,J),J};for(let H of $)this.#qX(H,Y(H)?.value??"");let G=this.element.getAttribute("data-reactive-compute-reducer-param"),K=G?JX(G):null;if(!K){this.#YX({},Y);return}let U=this.#hX("data-reactive-compute-outputs-param"),B={};for(let[H,J]of Z){let L=Y(H);if(J==="string")B[H]=L?.value??"";else{let A=Number(L?.value);B[H]=Number.isFinite(A)?A:0}}let W=K(B,{changed:this.#vX(X,$,Q)})||{},V=qZ(W.$ops),x=[];for(let H of U){if(H==="$ops"||!(H in W))continue;let J=Y(H);if(!J)continue;if(String(W[H])===J.value)continue;J.value=W[H],x.push(J)}for(let H of Object.keys(W)){if(H==="$ops")continue;let J=W[H];if(J===void 0||J===null)continue;this.#qX(H,J)}this.#YX(W,Y);for(let H of x){let J=new Event("input",{bubbles:!0});this.#n.add(J),H.dispatchEvent(J)}this.#EX(V,Boolean(X))}#EX(X,Z){let $=X===null?null:JSON.stringify(X),Q=$!==null&&$!==this.#a&&Z;if(this.#a=$,!Q)return;N(X,(j)=>this.#T(j.to==null?{...j,to:"@root"}:j))}listnavNext(X){this.#ZX(X,1)}listnavPrev(X){this.#ZX(X,-1)}listnavPick(X){let Z=this.#D(X),$=Z.findIndex((Q)=>Q.hasAttribute("data-reactive-highlighted"));if($<0)return;X.preventDefault(),Z[$].click()}listnavClose(X){for(let Z of this.#D(X))Z.removeAttribute("data-reactive-highlighted")}#ZX(X,Z){let $=this.#D(X);if(!$.length)return;X.preventDefault();let Q=$.findIndex((q)=>q.hasAttribute("data-reactive-highlighted")),j=Q<0?Z>0?0:$.length-1:(Q+Z+$.length)%$.length;for(let q of $)q.removeAttribute("data-reactive-highlighted");let z=$[j];z.setAttribute("data-reactive-highlighted","true"),z.scrollIntoView?.({block:"nearest"})}#D(X){let $=(X?.currentTarget??X?.target??this.element).getAttribute?.("data-reactive-listnav-option-param")??this.element.getAttribute("data-reactive-listnav-option-param");if(!$)return[];let Q=this.#X();return Array.from(this.element.querySelectorAll($)).filter((j)=>!j.hidden&&Q(j))}tagsAdd(X){if(!this.#E())return;if(X?.defaultPrevented)return;if(this.#D(X).some((Q)=>Q.hasAttribute?.("data-reactive-highlighted")))return;X?.preventDefault?.();let Z=X?.currentTarget??X?.target;if(!Z)return;if(!this.#AX(String(Z.value??"").split(",")))return;if(Z.value="",this.#C())this.#x()}tagsPick(X){if(!this.#E())return;X?.preventDefault?.();let $=(X?.currentTarget??X?.target)?.getAttribute?.("data-reactive-tag-param");if(!$)return;if(!this.#AX([$]))return;let Q=this.#BZ();if(!Q)return;Q.value="",this.#x(),Q.focus?.()}tagsRemove(X){if(!this.#E())return;X?.preventDefault?.();let $=(X?.currentTarget??X?.target)?.getAttribute?.("data-reactive-tag-param");if(!$)return;let Q=this.#c();if(!Q)return;let j=this.#d(Q),z=j.filter((q)=>q.toLowerCase()!==$.toLowerCase());if(z.length===j.length)return;this.#xX(Q,z)}nestedAdd(X){X?.preventDefault?.();let Z=X?.currentTarget??X?.target,$=Z?.getAttribute?.("data-reactive-association-param");if(!$)return;if(typeof this.element?.querySelectorAll!=="function")return;let Q=this.#X(),j=[...this.element.querySelectorAll(`[data-reactive-nested-list="${$}"]`)].find(Q),q=[...this.element.querySelectorAll(`[data-reactive-nested-template="${$}"]`)].find(Q)?.content?.firstElementChild;if(!j||!q){this.#PZ($);return}let Y=q.cloneNode(!0);this.#xZ(Y,this.#AZ()),j.appendChild(Y);let G=Z?.getAttribute?.("data-reactive-nested-from-param"),K=Z?.getAttribute?.("data-reactive-nested-clear-param")==="true",U=this.#TX(Y,G,K);if(G)U?.focus?.();else[...Y.querySelectorAll?.("input, select, textarea")??[]][0]?.focus?.();if(j.getAttribute?.("data-reactive-nested-json")===$)this.#QX($)}#TX(X,Z,$){if(!Z)return null;let Q;try{Q=JSON.parse(Z)}catch{return null}if(!Q||typeof Q!=="object")return null;let j=this.#X(),z=[...X.querySelectorAll?.("input, select, textarea")??[]],q=[];for(let[Y,G]of Object.entries(Q)){let K=[...this.element.querySelectorAll?.(G)??[]].find(j);if(!K)continue;let U=z.find((B)=>this.#zX(B.getAttribute?.("name"))===Y);if(!U)continue;this.#kX(U,K),q.push(K)}if($)for(let Y of q)this.#SX(Y);return q[0]??null}#kX(X,Z){if(X.type==="checkbox")X.checked=Z.type==="checkbox"?!!Z.checked:this.#f(Z)!=="";else X.value=this.#f(Z);if(typeof X.dispatchEvent==="function")X.dispatchEvent(new Event("input",{bubbles:!0}))}#SX(X){if(X.type==="checkbox")X.checked=!1;else X.value="";if(typeof X.dispatchEvent==="function")X.dispatchEvent(new Event("input",{bubbles:!0}))}nestedRemove(X){X?.preventDefault?.();let Z=X?.currentTarget??X?.target,$=Z?.closest?.("[data-reactive-nested-row]");if(!$)return;if($.closest?.('[data-controller~="reactive"]')!==this.element)return;let Q=Z?.getAttribute?.("data-reactive-confirm-param"),j=Z?.getAttribute?.("data-reactive-confirm-when-param"),z=this.#u(Q,j);if(!z)return this.#$X($);let q=this.#jX($),Y=this.#bX(z,q);return Promise.resolve().then(()=>C(Y,{el:Z,row:$,fields:q})).catch(()=>!1).then((G)=>{if(G)this.#$X($)})}#bX(X,Z){if(!X.includes("%{"))return X;return X.replace(/%\{(\w+)\}/g,($,Q)=>Object.prototype.hasOwnProperty.call(Z,Q)?Z[Q]:$)}#$X(X){let Z=[...X.querySelectorAll?.('input[name$="[_destroy]"]')??[]][0];if(Z){if(Z.value="1",typeof Z.dispatchEvent==="function")Z.dispatchEvent(new Event("input",{bubbles:!0}));X.hidden=!0}else X.parentNode?.removeChild?.(X);this.#R()}syncNestedJson(X){let Z=X?.target;if(!Z||!this.#Z(Z))return;this.#R()}#R(){if(typeof this.element?.querySelectorAll!=="function")return;let X=this.#X();for(let Z of[...this.element.querySelectorAll("[data-reactive-nested-json]")].filter(X))this.#QX(Z.getAttribute("data-reactive-nested-json"))}#QX(X){let Z=this.#X(),$=[...this.element.querySelectorAll(`[data-reactive-nested-list="${X}"]`)].find(Z);if(!$)return;let Q=this.#wX($);if(!Q)return;let j=[];for(let q of[...$.querySelectorAll?.("[data-reactive-nested-row]")??[]]){if(!Z(q)||q.hidden)continue;j.push(this.#jX(q))}let z=JSON.stringify(j);if(Q.value===z)return;if(Q.value=z,typeof Q.dispatchEvent==="function")Q.dispatchEvent(new Event("input",{bubbles:!0}))}#wX(X){let Z=X.getAttribute?.("data-reactive-nested-json-field");if(!Z)return null;let $=this.#X();return[...this.element.querySelectorAll(Z)].find($)??null}#jX(X){let Z={};for(let $ of[...X.querySelectorAll?.("input, select, textarea")??[]]){let Q=this.#zX($.getAttribute?.("name"));if(Q===null||Q==="_destroy")continue;Z[Q]=this.#f($)}return Z}#zX(X){if(!X)return null;let Z=X.match(/\[([^\][]+)\]$/);return Z?Z[1]:X}#f(X){if(X.type==="checkbox")return X.checked?X.value||"on":"";return X.value??""}#hX(X){let Z=this.element.getAttribute(X);if(!Z)return[];try{let $=JSON.parse(Z);return Array.isArray($)?$:[]}catch{return[]}}#yX(){let X=this.element.getAttribute("data-reactive-compute-inputs-param");if(!X)return[];try{let Z=JSON.parse(X);if(Array.isArray(Z))return Z.map(($)=>[$,"number"]);if(Z&&typeof Z==="object")return Object.entries(Z);return[]}catch{return[]}}#vX(X,Z,$){let Q=X?.target;if(!Q?.name||typeof Q.closest!=="function")return null;let j=this.#fX(Q.name,$);if(!Z.includes(j))return null;return this.#Z(Q)?j:null}#fX(X,Z){if(!Z)return X;let $=`${Z}[`;return X.startsWith($)&&X.endsWith("]")?X.slice($.length,-1):X}#qX(X,Z){let $=String(Z);for(let Q of this.#pX(X)){if(Q.textContent===$)continue;Q.textContent=$}}#pX(X){let Z=this.element.querySelectorAll(`[data-reactive-text="${X}"]`);return Array.from(Z).filter(($)=>this.#Z($))}#YX(X,Z){let $=this.#uX();for(let[Q,j]of Object.entries($)){let z=Q in X?X[Q]:Z(Q)?.value;if(z===void 0||z===null)continue;let q=String(z);for(let Y of Array.isArray(j)?j:[j]){if(!nX(Y))continue;for(let G of document.querySelectorAll(Y)){if(G.textContent===q)continue;G.textContent=q}}}}#uX(){let X=this.element.getAttribute("data-reactive-compute-mirror-param");if(!X)return{};try{let Z=JSON.parse(X);return Z&&typeof Z==="object"&&!Array.isArray(Z)?Z:{}}catch{return{}}}#GX(X,Z,$,Q,j,z,q){if(this.#I("reactive:before-dispatch",{action:Z,params:this.#PX($),element:this.element},{cancelable:!0}).defaultPrevented)return;let G=Number(Q)||0;if(G>0)return this.#gX(X,G,Z,$,z,q);let K=Number(j)||0;if(K>0)return this.#dX(X,K,Z,$,z,q);return this.#F(Z,$,z,X,q)}#F(X,Z,$,Q,j){let z=this.#CZ($,Q),q=this.#EZ(X,Q,j),Y=this.#KX()?this.#mX($,Q):null;return this.queue=(this.queue??Promise.resolve()).then(()=>this.#rX(X,Z,z,q,Y)),this.queue}#mX(X,Z){if(!X?.hide)return null;let $=this.#RX(X,Z);if(!$.length)return null;return()=>{let Q=$.filter((j)=>j.isConnected&&!j.hidden);if(!Q.length)return;console.warn("[phlex-reactive] optimistic: { hide: true } was undone by the reply's re-render — "+"the element is visible again. For an instant delete, return reply.remove so the server removes it; otherwise the hide only flashes.",Q)}}#gX(X,Z,$,Q,j,z){this.#p(X);let q=()=>{this.#p(X),this.#F($,Q,j,X,z)},Y=setTimeout(q,Z);X?.addEventListener?.("blur",q,{once:!0}),this.#P.set(X,{timer:Y,flush:q})}#p(X){let Z=this.#P.get(X);if(!Z)return;clearTimeout(Z.timer),X?.removeEventListener?.("blur",Z.flush),this.#P.delete(X)}#cX(){for(let X of[...this.#P.keys()])this.#p(X)}#dX(X,Z,$,Q,j,z){let q=this.#U.get(X)??new Map;if(q.has($))return;let Y=setTimeout(()=>{if(q.delete($),q.size===0)this.#U.delete(X)},Z);return q.set($,Y),this.#U.set(X,q),this.#F($,Q,j,X,z)}#lX(){for(let X of this.#U.values())for(let Z of X.values())clearTimeout(Z);this.#U.clear()}#I(X,Z,{cancelable:$=!1}={}){let Q=new CustomEvent(X,{bubbles:!0,composed:!0,cancelable:$,detail:Z});return(this.element.isConnected?this.element:document).dispatchEvent(Q),Q}#q(X,Z,$,Q){let j=()=>{if(!this.element.isConnected){console.warn("[phlex-reactive] retry() ignored — the reactive root left the DOM");return}return this.#F(X,Z)};this.#I("reactive:error",{action:X,params:$,...Q,retry:j})}#Y(X){if(this.element?.isConnected===!1)return;this.element?.setAttribute?.("data-reactive-error",X)}#iX(){this.element?.removeAttribute?.("data-reactive-error")}#oX(){let X=document.querySelector("[data-reactive-error-flash]");if(!X?.content)return;let Z=X.getAttribute("data-reactive-error-flash")||"flash",$=document.getElementById(Z);if(!$)return;$.appendChild(X.content.cloneNode(!0))}#sX(){if(typeof sessionStorage>"u")return Promise.resolve();let X=Number(sessionStorage.getItem(m));if(!Number.isFinite(X)||X<=0)return Promise.resolve();if(!D)D=!0,console.warn(`[phlex-reactive] latency simulator ACTIVE — every action is delayed by ${X}ms. Call PhlexReactive.disableLatencySim() (or clear sessionStorage) to turn it off.`);return new Promise((Z)=>setTimeout(Z,X))}#KX(){return this.element?.getAttribute?.("data-reactive-debug")==="true"}#HX(){return typeof performance<"u"&&typeof performance.now==="function"?performance.now():Date.now()}#nX(X){if(!X)return[];let Z=[],$=/]*)>/g,Q;while((Q=$.exec(X))!==null){let j=Q[1],z=j.match(/\baction="([^"]*)"/)?.[1]??"?",q=j.match(/\btarget="([^"]*)"/)?.[1];Z.push(q?`${z} → #${q}`:z)}return Z}#aX(X){let{action:Z,status:$,ms:Q}=X,z=`reactive ${this.element?.id?`#${this.element.id} `:""}${Z} → ${$??"—"} (${Math.round(Q)}ms)`;if(console.groupCollapsed(z),console.log(`params: [${X.paramNames.join(", ")}] + collected: [${X.fieldNames.join(", ")}]`),console.log(`encoding: ${X.encoding}`),X.streams.length)console.log(`streams: ${X.streams.join(" ")}`);console.log(`token: ${X.tokenRefreshed?"refreshed ✓":"unchanged"}`),console.groupEnd()}async#rX(X,Z,$,Q,j){let{fields:z,files:q}=this.#JX(),Y=this.#PX(Z),G={...z,...Y},K=this.#G,U=q.length>0,B=U?this.#RZ(K,X,G,q):JSON.stringify({token:K,act:X,params:G}),W=this.#KX()?{action:X,paramNames:Object.keys(Y),fieldNames:Object.keys(z),encoding:U?"multipart":"json",status:null,streams:[],tokenRefreshed:!1,started:this.#HX()}:null;await this.#sX();try{if(navigator.onLine===!1){this.#K($),this.#Y("offline"),this.#q(X,Z,G,{kind:"offline"});return}let V;try{let L={Accept:"text/vnd.turbo-stream.html","X-CSRF-Token":this.#vZ()};if(!U)L["Content-Type"]="application/json";let A=this.#fZ();if(A)L["X-Pgbus-Connection"]=A;V=await fetch(this.#hZ(),{method:"POST",headers:L,body:B,credentials:"same-origin",signal:AbortSignal.timeout(this.#yZ())})}catch(L){if(console.error("[phlex-reactive] action error",L),this.#K($),L?.name==="TimeoutError"||L?.name==="AbortError"){this.#Y("timeout"),this.#q(X,Z,G,{kind:"timeout"});return}this.#oX(),this.#Y("network"),this.#q(X,Z,G,{kind:"network"});return}if(W)W.status=V.status;if(V.redirected){console.error("[phlex-reactive] action was redirected (auth/CSRF?) — no update applied"),this.#K($),this.#Y("redirected"),this.#q(X,Z,G,{kind:"redirected",status:V.status});return}if(!V.ok){let L=await V.text();if(console.error(`[phlex-reactive] action failed: HTTP ${V.status}`,L),this.#K($),(V.headers.get("Content-Type")||"").includes("turbo-stream")){let A=this.#WX(L);if(this.#G=A??this.#G,W)this.#UX(W,L,A);window.Turbo.renderStreamMessage(L)}this.#Y("http"),this.#q(X,Z,G,{kind:"http",status:V.status,body:L});return}let x=V.headers.get("Content-Type")||"";if(!x.includes("turbo-stream")){console.error(`[phlex-reactive] expected a turbo-stream, got "${x}" — no update applied`),this.#K($),this.#Y("content-type"),this.#q(X,Z,G,{kind:"content-type",status:V.status});return}let H=await V.text(),J=this.#WX(H);if(this.#G=J??this.#G,W)this.#UX(W,H,J);if(window.Turbo.renderStreamMessage(H),j)queueMicrotask(j);this.#iX(),this.#I("reactive:applied",{action:X,params:G,html:H})}catch(V){console.error("[phlex-reactive] action error",V),this.#K($),this.#I("reactive:error",{action:X,params:G,kind:"apply"})}finally{if(Q?.(),W)this.#aX({...W,ms:this.#HX()-W.started})}}#UX(X,Z,$){X.streams=this.#nX(Z),X.tokenRefreshed=$!=null}get#G(){return this.#o??this.tokenValue}set#G(X){this.#o=X}#WX(X){let Z=this.element.id;if(!Z)return X.match(/data-reactive-token-value="([^"]+)"/)?.[1];let{token:$,self:Q}=this.#tX(Z),j=X.match($);if(j)return j[1];let z=X.match(Q);if(z)return z[1].match(/data-reactive-token-value="([^"]+)"/)?.[1];return}#tX(X){let Z=this.#s;if(Z&&Z.id===X)return Z;let $=gX(X);return this.#s={id:X,token:new RegExp(`]*\\baction="reactive:token"[^>]*\\btarget="${$}"[^>]*\\bdata-reactive-token-value="([^"]+)"`),self:new RegExp(`]*\\baction="(?:replace|update)"[^>]*\\btarget="${$}"[^>]*>([\\s\\S]*?)`)}}#Z(X){return X.closest('[data-controller~="reactive"]')===this.element}#X(){if(this.element.querySelectorAll('[data-controller~="reactive"]').length===0)return()=>!0;return(Z)=>this.#Z(Z)}#u(X,Z){if(X)return X;if(!Z)return null;let $=Z;if(typeof Z==="string")try{$=JSON.parse(Z)}catch{return console.warn(`[phlex-reactive] malformed conditional confirm payload ${JSON.stringify(Z)} — skipped`),null}if(!$||typeof $!=="object")return null;let{fields:Q}=this.#JX(),j=(q)=>Q[q],z;if(typeof $.predicate==="string"){let q=LX($.predicate);if(!q)return console.warn(`[phlex-reactive] confirm predicate "${$.predicate}" is not registered — proceeding without a dialog (register it with setConfirmPredicate)`),null;z=!!q(Q)}else z=P($.groups?.any,j)===!0;return z?$.message:null}#JX(){let X={},Z=[],$=this.#X();return this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((Q)=>{if(!$(Q))return;if(Q.type==="file")for(let j of Q.files??[])Z.push({name:Q.name,file:j,multiple:Q.multiple});else if(Q.type==="checkbox")X[Q.name]=Q.checked;else if(Q.type==="radio"){if(Q.checked)X[Q.name]=Q.value}else X[Q.name]=Q.value}),this.element.querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])").forEach((Q)=>{if(!$(Q))return;let j=Q.getAttribute("name");if(!j)return;let z=X[j];if(z==null||z==="")X[j]=Q.value??Q.textContent??Q.innerHTML??""}),{fields:X,files:Z}}#m(){if(typeof this.element?.querySelectorAll!=="function")return;let X=0;if(this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((Z)=>{if(!this.#Z(Z))return;if(Z.type==="file")return;if(this.#eX(Z))Z.setAttribute("data-reactive-dirty","true"),X++;else Z.removeAttribute("data-reactive-dirty")}),X>0)this.element.setAttribute("data-reactive-dirty",String(X));else this.element.removeAttribute("data-reactive-dirty")}#eX(X){if(X.type==="checkbox"||X.type==="radio")return X.checked!==X.defaultChecked;if(X.tag==="select"||X.options)return Array.from(X.options??[]).some((Z)=>Z.selected!==Z.defaultSelected);return X.value!==X.defaultValue}#LX(){let X=this.element.getAttribute?.("data-reactive-dirty"),Z=Number(X);return Number.isFinite(Z)&&Z>0?Z:0}#XZ(){if(typeof window>"u"||typeof window.addEventListener!=="function")return;this.#L=(X)=>{if(this.#LX()===0)return;return X.preventDefault(),X.returnValue="You have unsaved changes.",X.returnValue},this.#V=(X)=>{if(this.#LX()===0)return;if(!(typeof window.confirm==="function"?window.confirm("You have unsaved changes. Leave anyway?"):!0))X.preventDefault?.()},window.addEventListener("beforeunload",this.#L),window.addEventListener("turbo:before-visit",this.#V)}#ZZ(){if(this.#J)this.element.removeEventListener?.("turbo:morph-element",this.#J),this.#J=void 0;if(typeof window<"u"&&typeof window.removeEventListener==="function"){if(this.#L)window.removeEventListener("beforeunload",this.#L);if(this.#V)window.removeEventListener("turbo:before-visit",this.#V)}this.#L=void 0,this.#V=void 0}#$Z(){if(this.element.getAttribute?.("data-reactive-show-targets"))return!0;let X=this.element.querySelectorAll?.(o)??[];for(let Z of X)if(this.#Z(Z))return!0;return!1}#QZ(){return!!this.element.getAttribute?.("data-reactive-on-complete")}#jZ(){if(this.element.getAttribute?.("data-reactive-clipboard"))return!0;let X=this.element.querySelectorAll?.("[data-reactive-clipboard]")??[];for(let Z of X)if(this.#Z(Z))return!0;return!1}#VX(){let X=typeof globalThis.navigator?.clipboard?.readText==="function";if(this.element.getAttribute?.("data-reactive-clipboard"))this.element.hidden=!X;for(let Z of this.element.querySelectorAll?.("[data-reactive-clipboard]")??[])if(this.#Z(Z))Z.hidden=!X}#zZ(){if(!this.#A)return;this.element.removeEventListener?.("turbo:morph-element",this.#A),this.#A=void 0}#qZ(){let X=this.element.getAttribute?.("data-reactive-on-complete")??null;if(X!==this.#r)this.#r=X,this.#h=X==null?[]:XZ(X),this.#y=this.#h.map(()=>!1);return this.#h}#g(X){let Z=this.#qZ();if(!Z.length)return;let $=this.#X(),Q=this.element.getAttribute?.("data-reactive-scope")||null,j=new Map,z=(q)=>{if(!j.has(q))j.set(q,this.#MX(q,$,Q));return j.get(q)};Z.forEach((q,Y)=>{let G=P(q.any,z);if(G===null)return;let K=G&&!this.#y[Y]&&Boolean(X);if(this.#y[Y]=G,K)N(q.ops,(U)=>this.#T(U.to==null?{...U,to:"@root"}:U))})}#YZ(){if(!this.#z)return;this.element.removeEventListener?.("input",this.#z),this.element.removeEventListener?.("change",this.#z),this.element.removeEventListener?.("turbo:morph-element",this.#w)}#BX(){if(typeof this.element?.querySelectorAll!=="function")return;let X=this.#X(),Z=this.element.getAttribute?.("data-reactive-scope")||null,$=new Map,Q=(j)=>{if(!$.has(j))$.set(j,this.#MX(j,X,Z));return $.get(j)};for(let j of this.element.querySelectorAll(o)){if(!X(j))continue;let z=j.getAttribute("data-reactive-show");if(z!==null){let K=$Z(eX(z),Q);if(K!==null)this.#_X(j,K,X,Z);continue}let q=j.getAttribute("data-reactive-show-field");if(!q)continue;let Y=Q(q);if(Y===null)continue;let G=aX(j,Y);if(G===null)continue;this.#_X(j,G,X,Z)}this.#GZ(Q)}#_X(X,Z,$,Q){if(X.hidden=!Z,X.getAttribute("data-reactive-show-disable")!=="true")return;if(typeof X.querySelectorAll!=="function")return;for(let j of X.querySelectorAll("input[name], select[name], textarea[name]"))if($(j))j.disabled=!Z;if(X.name&&$(X))X.disabled=!Z}#GZ(X){let Z=this.#HZ();for(let[$,Q]of Object.entries(Z)){if($.startsWith("#")){this.#KZ($,Q,X);continue}if(!Q||typeof Q!=="object"||Array.isArray(Q))continue;let j=X($);if(j===null)continue;let z=()=>j;for(let[q,Y]of Object.entries(Q)){if(!s(q))continue;let G;if(Array.isArray(Y)){if(Y.length===0){console.warn(`[phlex-reactive] malformed reactive_show_targets group for ${q} — skipped`);continue}G=Y.every((K)=>g(K,z))}else{let K=HX(Y,j);if(K===null){console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${q} — skipped`);continue}G=K}for(let K of document.querySelectorAll(q))K.hidden=!G}}}#KZ(X,Z,$){if(!s(X))return;let Q=Z&&typeof Z==="object"&&!Array.isArray(Z)?Z.any:null,j=ZZ(Q);if(j===null){console.warn(`[phlex-reactive] malformed reactive_show_targets conditions for ${X} — skipped`);return}if(j.every((q)=>$(q)===null))return;let z=P(Q,$);if(z===null)return;for(let q of document.querySelectorAll(X))q.hidden=!z}#HZ(){let X=this.element.getAttribute?.("data-reactive-show-targets");if(!X)return{};try{let Z=JSON.parse(X);if(Z&&typeof Z==="object"&&!Array.isArray(Z))return Z}catch{}return console.warn("[phlex-reactive] malformed data-reactive-show-targets — ignored. "+"Did two reactive_show_targets calls collide on one root? Declare every field in ONE call: reactive_show_targets(mode: { ... }, kind: { ... })"),{}}#MX(X,Z,$){let Q=$&&!X.includes("[")?`${$}[${X}]`:X,j=!1,z=null;for(let q of this.element.querySelectorAll(`[name="${Q}"]`)){if(!Z(q))continue;if(q.type==="checkbox")return q.checked?"true":"false";if(q.type==="radio"){if(q.checked)return q.value??"";j=!0;continue}z??=q}if(z)return z.value??"";return j?"":null}#UZ(){if(!this.#$)return;this.element.removeEventListener?.("input",this.#$),this.element.removeEventListener?.("change",this.#$),this.element.removeEventListener?.("turbo:morph-element",this.#$),this.#$=void 0}#C(){return!!(this.element.getAttribute?.("data-reactive-filter-input")&&this.element.getAttribute?.("data-reactive-filter-option"))}#WZ(){return this.element.getAttribute?.("data-reactive-compute-seed")==="true"}#JZ(X){let Z=this.element.getAttribute("data-reactive-filter-input");return!!Z&&typeof X.target?.matches==="function"&&X.target.matches(Z)}#x(){if(typeof this.element?.querySelectorAll!=="function")return;let X=this.element.getAttribute("data-reactive-filter-input"),Z=this.element.getAttribute("data-reactive-filter-option");if(!X||!Z)return;let $=this.#X(),Q=[...this.element.querySelectorAll(X)].find($);if(!Q)return;let j=(Q.value??"").trim().toLowerCase(),z=0;for(let G of this.element.querySelectorAll(Z)){if(!$(G))continue;let K=(G.getAttribute("data-reactive-filter-text")??G.textContent??"").toLowerCase(),U=G.hasAttribute?.("data-reactive-tags-selected")||j!==""&&!K.includes(j);if(G.hidden=U,U)G.removeAttribute("data-reactive-highlighted");else z++}let q=this.element.getAttribute("data-reactive-filter-group");if(q)for(let G of this.element.querySelectorAll(q)){if(!$(G))continue;let K=[...G.querySelectorAll(Z)].filter($);if(K.length===0)continue;G.hidden=K.every((U)=>U.hidden)}let Y=this.element.getAttribute("data-reactive-filter-empty");if(Y){for(let G of this.element.querySelectorAll(Y))if($(G))G.hidden=z>0}}#LZ(){if(!this.#Q)return;this.element.removeEventListener?.("input",this.#Q),this.element.removeEventListener?.("turbo:morph-element",this.#Q),this.#Q=void 0}#E(){return!!this.element.getAttribute?.("data-reactive-tags-field")}#VZ(){if(typeof this.element?.querySelector!=="function")return!1;return!!this.element.querySelector("[data-reactive-nested-json]")}#c(){if(typeof this.element?.querySelectorAll!=="function")return null;let X=this.element.getAttribute("data-reactive-tags-field");if(!X)return null;let Z=this.#X();return[...this.element.querySelectorAll(X)].find(Z)??null}#d(X){let Z=new Set,$=[];for(let Q of String(X.value??"").split(",")){let j=Q.trim();if(j===""||Z.has(j.toLowerCase()))continue;Z.add(j.toLowerCase()),$.push(j)}return $}#AX(X){let Z=this.#c();if(!Z)return!1;let $=this.#d(Z),Q=new Set($.map((z)=>z.toLowerCase())),j=!1;for(let z of X){let q=String(z??"").trim();if(q===""||Q.has(q.toLowerCase()))continue;Q.add(q.toLowerCase()),$.push(q),j=!0}if(j)this.#xX(Z,$);return j}#xX(X,Z){if(X.value=Z.join(","),typeof X.dispatchEvent==="function")X.dispatchEvent(new Event("input",{bubbles:!0}));this.#l()}#BZ(){if(typeof this.element?.querySelectorAll!=="function")return null;let X=this.element.getAttribute("data-reactive-filter-input");if(!X)return null;let Z=this.#X();return[...this.element.querySelectorAll(X)].find(Z)??null}#l(){let X=this.#c();if(!X)return;let Z=this.#d(X),$=this.#X();this.#_Z(Z,$),this.#MZ(Z,$)}#_Z(X,Z){let $=[...this.element.querySelectorAll("[data-reactive-tags-list]")].find(Z);if(!$)return;let j=[...this.element.querySelectorAll("[data-reactive-tags-template]")].find(Z)?.content?.firstElementChild;if(!j){if(!this.#t)console.warn("[phlex-reactive] reactive_tags: no chip found in this root — "+"chips will not render (the hidden field still updates). Add a template with a [data-reactive-tag-text] node and a reactive_tags_remove button."),this.#t=!0;return}while($.firstChild)$.removeChild($.firstChild);for(let z of X){let q=j.cloneNode(!0);q.setAttribute?.("data-reactive-tag",z);let Y=q.matches?.("[data-reactive-tag-text]")?q:(q.querySelectorAll?.("[data-reactive-tag-text]")??[])[0];if(Y)Y.textContent=z;let G=[...q.querySelectorAll?.('[data-action*="reactive#tagsRemove"]')??[]];if(q.matches?.('[data-action*="reactive#tagsRemove"]'))G.push(q);for(let K of G)K.setAttribute?.("data-reactive-tag-param",z);$.appendChild(q)}}#MZ(X,Z){let $=new Set(X.map((Q)=>Q.toLowerCase()));for(let Q of this.element.querySelectorAll("[role=option]")){if(!Z(Q))continue;let j=Q.getAttribute?.("data-reactive-tag-param");if(!j)continue;if($.has(j.toLowerCase()))Q.setAttribute("data-reactive-tags-selected","true"),Q.hidden=!0,Q.removeAttribute?.("data-reactive-highlighted");else if(Q.hasAttribute?.("data-reactive-tags-selected")){if(Q.removeAttribute("data-reactive-tags-selected"),!this.#C())Q.hidden=!1}}if(this.#C())this.#x()}#AZ(){return this.#v=Math.max(this.#v+1,Date.now()),this.#v}#xZ(X,Z){let $=[X,...X.querySelectorAll?.("*")??[]];for(let Q of $)for(let j of["name","id","for"]){let z=Q.getAttribute?.(j);if(z&&z.includes("NEW_ROW"))Q.setAttribute?.(j,z.replaceAll("NEW_ROW",String(Z)))}}#PZ(X){if(this.#e)return;console.warn(`[phlex-reactive] nested rows: no owned [data-reactive-nested-list="${X}"] container + pair found in this root — the add `+"trigger did nothing. Render both inside the same reactive root (reactive_nested_list / reactive_nested_template)."),this.#e=!0}#NZ(){if(!this.#B)return;this.element.removeEventListener?.("turbo:morph-element",this.#B),this.#B=void 0}#OZ(){if(this.#j)this.element.removeEventListener?.("input",this.#j),this.element.removeEventListener?.("change",this.#j),this.#j=void 0;if(this.#_)this.element.removeEventListener?.("turbo:morph-element",this.#_),this.#_=void 0}#DZ(){if(!this.#M)return;this.element.removeEventListener?.("turbo:morph-element",this.#M),this.#M=void 0}#RZ(X,Z,$,Q){let j=new FormData;j.append("token",X),j.append("act",Z);for(let[q,Y]of Object.entries($))this.#i(j,`params[${q}]`,Y);let z=this.#FZ(Q);for(let{name:q,file:Y,multiple:G}of Q){let U=G||z.has(q)?`params[${q}][]`:`params[${q}]`;j.append(U,Y,Y.name)}return j}#i(X,Z,$){if($==null)X.append(Z,"");else if(Array.isArray($))$.forEach((Q,j)=>this.#i(X,`${Z}[${j}]`,Q));else if(typeof $==="object")for(let[Q,j]of Object.entries($))this.#i(X,`${Z}[${Q}]`,j);else X.append(Z,String($))}#FZ(X){let Z=new Map;for(let{name:$}of X)Z.set($,(Z.get($)??0)+1);return new Set([...Z].filter(([,$])=>$>1).map(([$])=>$))}#PX(X){if(!X)return{};try{return typeof X==="string"?JSON.parse(X):X}catch{return{}}}#NX(X){return UX(X)}#OX(X){N(X,(Z)=>this.#T(Z))}#T(X){let Z=X.to;if(Z==="@root")return[this.element];if(typeof Z!=="string"||Z==="")return[];if(X.global)return[...document.querySelectorAll(Z)];return[...this.element.querySelectorAll(Z)].filter(($)=>this.#Z($))}#IZ(X,Z){if(X?.checked!=="keep")return!1;let $=Z?.type;return $==="checkbox"||$==="radio"}#CZ(X,Z){if(!X)return null;let $=this.#DX(X,Z,!0);return $.length?$:null}#K(X){if(!X)return;if(!this.element.isConnected)return;for(let Z of X)Z()}#EZ(X,Z,$){this.#kZ(X,Z);let Q=$?this.#DX($,Z,!1):[];jX();let j=!1;return()=>{if(j)return;j=!0,this.#SZ(X,Z),zX();for(let z of Q)z()}}#DX(X,Z,$){let Q=[];for(let j of this.#RX(X,Z)){if(X.add_class){let z=X.add_class.filter((q)=>!j.classList.contains(q));if(j.classList.add(...z),z.length)Q.push(()=>j.classList.remove(...z))}if(X.remove_class){let z=X.remove_class.filter((q)=>j.classList.contains(q));if(j.classList.remove(...z),z.length)Q.push(()=>j.classList.add(...z))}if(X.toggle_class)X.toggle_class.forEach((z)=>j.classList.toggle(z)),Q.push(()=>X.toggle_class.forEach((z)=>j.classList.toggle(z)));if(X.hide)j.hidden=!0,Q.push(()=>j.hidden=!1);if(X.show)j.hidden=!1,Q.push(()=>j.hidden=!0)}if(Z&&(X.disable||X.text!=null))Q.push(this.#TZ(X,Z));if($&&X.checked==="keep"&&Z&&"checked"in Z){let j=Z.checked;Q.push(()=>Z.checked=!j)}return Q}#RX(X,Z){if(X.to==null)return Z?[Z]:[];return this.#T({to:X.to})}#TZ(X,Z){let $=this.#N.get(Z);if($)$.count++;else this.#N.set(Z,{count:1,disabled:Z.disabled,html:Z.innerHTML,hadText:X.text!=null,swappedTo:X.text});if(X.disable)Z.disabled=!0;if(X.text!=null)Z.innerHTML=X.text;return()=>this.#bZ(Z,X)}#kZ(X,Z){if(this.#H(Z,X,1),this.#H(this.element,X,1),this.#W.set(X,(this.#W.get(X)??0)+1),this.#S++===0)this.element.setAttribute("aria-busy","true");for(let $ of this.#FX(X))this.#H($,X,1)}#SZ(X,Z){this.#H(Z,X,-1),this.#H(this.element,X,-1);let $=(this.#W.get(X)??1)-1;if($<=0)this.#W.delete(X);else this.#W.set(X,$);if(--this.#S<=0)this.#S=0,this.element.removeAttribute("aria-busy");for(let Q of this.#FX(X))this.#H(Q,X,-1)}#H(X,Z,$){if(!X||typeof X.getAttribute!=="function")return;let Q=this.#b.get(X)??new Map,j=(Q.get(Z)??0)+$;if(j<=0)Q.delete(Z);else Q.set(Z,j);if(Q.size===0){this.#b.delete(X),X.removeAttribute("data-reactive-busy");return}this.#b.set(X,Q),X.setAttribute("data-reactive-busy",[...Q.keys()].join(" "))}#FX(X){return[...this.element.querySelectorAll?.("[data-reactive-busy-on]")??[]].filter(($)=>$.getAttribute("data-reactive-busy-on")===X&&this.#Z($))}#bZ(X,Z){let $=this.#N.get(X);if(!$)return;if(--$.count>0)return;if(this.#N.delete(X),!X.isConnected)return;if(Z.disable)X.disabled=$.disabled;if($.hadText&&X.innerHTML===$.swappedTo)X.innerHTML=$.html}#wZ(X){if(!X||typeof X!=="object")return null;let{class:Z,...$}=X;return Z==null?X:{...$,add_class:Z}}#hZ(){return this.#IX??=document.querySelector('meta[name="phlex-reactive-action-path"]')?.content||"/reactive/actions"}#yZ(){if(this.#k!=null)return this.#k;let X=document.querySelector('meta[name="phlex-reactive-timeout"]')?.content,Z=Number(X);return this.#k=Number.isFinite(Z)&&Z>0?Z:30000}#vZ(){return document.querySelector('meta[name="csrf-token"]')?.content??""}#fZ(){return document.querySelector("pgbus-stream-source[connection-id]")?.getAttribute("connection-id")||document.querySelector('meta[name="pgbus-connection-id"]')?.content||null}}export{WZ as resetReactiveDefers,AZ as resetReactiveActivity,VX as registerReactiveVisit,BX as registerReactiveToken,fX as registerReactiveOffline,_X as registerReactiveJs,CX as registerReactiveEffects,RX as registerReactiveDismiss,MX as registerReactiveDefer,l as registerReactiveActions,MZ as reactiveActivityCount,JZ as pendingDeferVia,zX as exitReactiveActivity,gX as escapeRegExp,jX as enterReactiveActivity,pX as enableLatencySim,uX as disableLatencySim,NZ as default,cX as checkReactiveRegistration,xZ as __resetReactiveRegistrationForTest,BZ as __resetReactiveOfflineForTest,_Z as __resetReactiveLatencyForTest,VZ as __resetReactiveEffectsForTest,LZ as __resetReactiveDismissForTest,PZ as __markReactiveConnectedForTest,m as LATENCY_KEY,QX as ACTIVE_ATTR};
-//# debugId=B44B95BDB680993364756E2164756E21
+//# debugId=5DD971D088374C7264756E2164756E21
//# sourceMappingURL=reactive_controller.min.js.map
diff --git a/app/javascript/phlex/reactive/reactive_controller.min.js.map b/app/javascript/phlex/reactive/reactive_controller.min.js.map
index 439f8bb..d8ab239 100644
--- a/app/javascript/phlex/reactive/reactive_controller.min.js.map
+++ b/app/javascript/phlex/reactive/reactive_controller.min.js.map
@@ -2,9 +2,9 @@
"version": 3,
"sources": ["reactive_controller.js"],
"sourcesContent": [
- "import { Controller } from \"@hotwired/stimulus\"\n// Import the BARE specifier the engine already pins (phlex/reactive/confirm),\n// NOT a relative \"./confirm.js\" (issue #57). Under importmap-rails + Propshaft\n// the controller is served at its DIGESTED url; a relative sibling import is\n// left untouched (Propshaft rewrites only RAILS_ASSET_URL(...), and the import\n// map resolves ONLY bare specifiers), so \"./confirm.js\" resolves against the\n// digested controller url → an undigested /assets/.../confirm.js that 404s, and\n// the throwing import takes down every Stimulus controller on the page. The\n// bare specifier resolves to the digested asset through the import map, and\n// bundlers/bun resolve it the same way they already resolve\n// \"phlex/reactive/reactive_controller\" (see tsconfig.json paths for the tests).\nimport { confirmResolver } from \"phlex/reactive/confirm\"\n// Client-side computes (data bindings): the reducer registry behind\n// reactive_compute. Bare specifier for the same import-map reason as confirm.\nimport { computeReducer } from \"phlex/reactive/compute\"\n// Conditional-confirm predicates (issue #179): the registry behind the\n// confirm: { predicate: \"name\" } escape hatch. Same bare-specifier reason.\nimport { confirmPredicate } from \"phlex/reactive/confirm_predicate\"\n\n// The ONE generic controller behind every reactive Phlex component. It\n// replaces the per-feature Stimulus controllers you'd otherwise hand-write\n// for interactive components. A component declares its actions in Ruby (via\n// Phlex::Reactive::Component); this controller binds DOM events to a single\n// HTTP round trip and lets Turbo apply the re-rendered component back in\n// (replace by default; method=\"morph\" — Response.morph — preserves focus).\n//\n// Wire format (client -> server), POST , turbo-stream Accept:\n// { token: \"\", act: \"\", params: {...} } (JSON)\n// (`act`, not `action`: `action` is a reserved Rails routing param.)\n// The token is a MessageVerifier-signed { component, gid } — NO state is sent.\n// When the root holds a chosen , the SAME payload is sent as\n// multipart FormData instead (token/act flat, params bracketed, files appended)\n// so an upload reaches the action (issue #34) — only the encoding differs.\n// The response is a that replaces the component by its id.\n//\n// Server -> client live updates use the SAME element id, pushed over the\n// stream transport (pgbus SSE / Action Cable) via the Streamable\n// .broadcast_* methods — so a click and a background broadcast converge on\n// one re-render unit.\n//\n// Custom turbo-stream action: the server tells the actor to full-navigate\n// (e.g. the record's slug changed and the current URL is now dead). It rides a\n// 200 turbo-stream — NOT an HTTP 3xx — so it never trips the response.redirected\n// bail below (which still correctly catches real auth/CSRF redirects). Registered\n// once on the Turbo global (no @hotwired/turbo import — the gem uses window.Turbo\n// everywhere, and a named import is unreliable under importmap/esbuild).\nexport function registerReactiveVisit() {\n const actions = window.Turbo?.StreamActions\n if (!actions || actions[\"reactive:visit\"]) return\n actions[\"reactive:visit\"] = function () {\n const url = this.getAttribute(\"data-url\")\n if (url) window.Turbo.visit(url, { action: \"advance\" })\n }\n}\n\n// Custom turbo-stream action: a TOKEN-ONLY refresh (issue #30). A partial\n// update (Response.streams / reply.streams) re-renders only PART of a component\n// — so there's no full-self replace to carry the next signed token. The server\n// instead emits `\"\n// data-reactive-token-value=\"\">`. #perform's #extractToken already reads\n// the token out of the response body for the NEXT queued request; this handler\n// keeps the DOM in sync too, writing the attribute onto the root element so the\n// `tokenValue` fallback stays fresh. It's a pure attribute set — no node is\n// replaced — so a focused + caret survive (the whole point: update a\n// total cell without tearing down the field the user is typing in).\nexport function registerReactiveToken() {\n const actions = window.Turbo?.StreamActions\n if (!actions || actions[\"reactive:token\"]) return\n actions[\"reactive:token\"] = function () {\n const token = this.getAttribute(\"data-reactive-token-value\")\n const target = this.getAttribute(\"target\")\n if (!token || !target) return\n const el = document.getElementById(target)\n // Stimulus reads the token via the `token` value -> data-reactive-token-value.\n if (el) el.setAttribute(\"data-reactive-token-value\", token)\n }\n}\n\n// Custom turbo-stream action: SERVER-PUSHED client DOM ops (issue #97). The\n// server-side sibling of on_client's runOps — a reply (reply..js(ops)) or\n// a broadcast (Streamable.broadcast_js_to) emits\n//\n// \"\n// data-reactive-ops=\"[[op, args], ...]\">\n//\n// and Turbo invokes this handler with `this` bound to that \n// element. It runs the ops through the SAME frozen CLIENT_OPS whitelist as\n// runOps (client-side default-deny — an unknown op warns + is skipped), so a\n// forged/stale ops attr can never break the page or execute anything off the\n// vocabulary. NO token, NO fetch — a pure local DOM mutation.\n//\n// `target` (optional, an element id) scopes op resolution to that root: \"@root\"\n// resolves to the target element itself and a selector resolves WITHIN it.\n// Without a target, ops resolve document-wide (a broadcast op like\n// add_class(\"#bell\", ...) that isn't anchored to one component). The op stream\n// is emitted AFTER all render streams in the reply (the endpoint appends it\n// last), so focus(\"[name=next]\") sees the freshly morphed DOM — Turbo applies\n// streams in document order.\nexport function registerReactiveJs() {\n const actions = window.Turbo?.StreamActions\n if (!actions || actions[\"reactive:js\"]) return\n actions[\"reactive:js\"] = function () {\n const list = parseOps(this.getAttribute(\"data-reactive-ops\"))\n if (!list.length) return\n const targetId = this.getAttribute(\"target\")\n // With a target: scope to that element (missing → no-op). Without: document.\n const root = targetId ? document.getElementById(targetId) : null\n if (targetId && !root) return\n applyOps(list, (args) => streamOpTargets(args, root))\n }\n}\n\n// --- Deferred reply segments (issue #165) ----------------------------------\n// The client half of reply.defer: the server's reply carries a\n// `\">` directive and the\n// real render reaches the SAME actor later — via a parallel fetch (pull) or a\n// pgbus one-shot stream (push). Everything here is MODULE-level, deliberately\n// OFF the per-controller request queue: the whole point is that the expensive\n// segment never blocks the actor's next action.\n//\n// Supersession is the correctness core: pendingDefers keys one in-flight\n// delivery per target id. A newer directive for the same target aborts the\n// older fetch (or removes the older stream source), and an arrival applies\n// ONLY while its entry is still current — so a fast typist's debounced\n// keystrokes can never paint stale totals over fresh ones.\nconst pendingDefers = new Map()\n\n// Register/drop a target's pending defer entry, keeping the GLOBAL activity\n// counter (issue #201) balanced against the Map's ACTUAL key presence — a defer\n// is one in-flight reactive operation for as long as its registry entry lives.\n// Keying enter/exit on the presence TRANSITION (not the raw call) means a\n// re-set of an already-present key, or a delete of an absent one, can never\n// unbalance the count. Both the fetch (pull) and stream (push) lane route their\n// registry mutations through here, so the push lane is counted correctly even\n// though its DOM pending markers clear by a node swap, not clearDeferPending.\nfunction setPendingDefer(targetId, entry) {\n const isNew = !pendingDefers.has(targetId)\n pendingDefers.set(targetId, entry)\n if (isNew) enterReactiveActivity()\n}\n\nfunction deletePendingDefer(targetId) {\n if (pendingDefers.delete(targetId)) exitReactiveActivity()\n}\n\n// Test seam: clear the module-level registry between unit tests. Also resets\n// the one-shot settle-listener guard so a test's fresh document re-registers\n// the turbo:before-stream-render settler. Does NOT touch the activity counter —\n// resetReactiveActivity is its own seam (the two are reset together in tests).\nexport function resetReactiveDefers() {\n pendingDefers.clear()\n deferStreamSettleRegistered = false\n}\n\n// Test seam: the `via` of a target's pending defer entry (or undefined) — lets\n// tests assert an entry was SETTLED (dropped) on arrival without exposing the\n// Map. Not used by the runtime.\nexport function pendingDeferVia(targetId) {\n return pendingDefers.get(targetId)?.via\n}\n\nlet deferStreamSettleRegistered = false\n\nexport function registerReactiveDefer() {\n const actions = window.Turbo?.StreamActions\n if (!actions || actions[\"reactive:defer\"]) return\n actions[\"reactive:defer\"] = function () {\n const target = this.getAttribute(\"target\")\n if (!target) return\n if (this.getAttribute(\"data-reactive-defer-via\") === \"stream\") {\n startStreamDefer(target, this)\n return\n }\n const token = this.getAttribute(\"data-reactive-defer-token\")\n if (!token) return\n startFetchDefer(target, token)\n }\n\n // Settle a STREAM-lane pendingDefers entry when its arrival lands: the job's\n // broadcast is a turbo-stream that replaces the target (and removes the\n // source). A document-level turbo:before-stream-render hook drops the Map\n // entry for a stream target the moment a stream renders against it — so the\n // entry never outlives the delivery (the fetch lane settles inline; the\n // stream lane's arrival is a broadcast this controller doesn't await, so it\n // needs this hook). Registered once; a no-op without document.\n if (!deferStreamSettleRegistered && typeof document !== \"undefined\" && document.addEventListener) {\n deferStreamSettleRegistered = true\n document.addEventListener(\"turbo:before-stream-render\", settleStreamDeferOnRender)\n }\n}\n\n// Drop a stream-lane pendingDefers entry when a turbo-stream renders against\n// its target id (the job's replace) OR removes its source element. Keyed by the\n// stream's target so an unrelated stream never settles a defer. Pure Map\n// cleanup — the DOM apply is Turbo's; this only releases our bookkeeping.\nfunction settleStreamDeferOnRender(event) {\n const streamEl = event.target\n const target = streamEl?.getAttribute?.(\"target\")\n if (!target) return\n // The arrival replaces #; the source removal targets\n // reactive-defer-src-. Either signals the stream delivered.\n const targetId = target.startsWith(\"reactive-defer-src-\")\n ? target.slice(\"reactive-defer-src-\".length)\n : target\n const entry = pendingDefers.get(targetId)\n if (entry?.via === \"stream\") deletePendingDefer(targetId)\n}\n\n// The pull lane: mark the target pending and POST the signed defer token to\n// the defer endpoint, in parallel with everything else the page is doing.\nfunction startFetchDefer(targetId, token) {\n const el = document.getElementById(targetId)\n if (!el) {\n console.warn(`[phlex-reactive] reactive:defer target #${targetId} is not on the page — skipped`)\n return\n }\n supersedeDefer(targetId)\n markDeferPending(el)\n const entry = { via: \"fetch\", abort: new AbortController(), timedOut: false }\n setPendingDefer(targetId, entry)\n performDeferFetch(targetId, entry, token)\n}\n\n// The push lane: subscribe a to the server-signed\n// one-shot stream. Arrival + teardown need no client logic — the job's\n// broadcast carries the replace AND a remove of this source element (its\n// disconnectedCallback closes the SSE connection). since-id=0 on a fresh key\n// replays a broadcast that beat the subscription (the durable-lane guarantee).\nfunction startStreamDefer(targetId, directive) {\n const el = document.getElementById(targetId)\n if (!el) {\n console.warn(`[phlex-reactive] reactive:defer target #${targetId} is not on the page — skipped`)\n return\n }\n const src = directive.getAttribute(\"data-reactive-defer-src\")\n if (!src) return\n if (!globalThis.customElements?.get?.(\"pgbus-stream-source\")) {\n // The server chose push on server-side capability, but this page has no\n // pgbus client. Degrade to the fetch lane using the fallback token the push\n // directive carries — rather than dead-end the shimmer. No token (an app on\n // a bespoke transport) is a loud no-op.\n const fallbackToken = directive.getAttribute(\"data-reactive-defer-token\")\n if (fallbackToken) {\n startFetchDefer(targetId, fallbackToken)\n return\n }\n console.error(\n \"[phlex-reactive] reactive:defer via=stream but is not registered \" +\n \"and no fallback token was provided — is the pgbus client loaded on this page?\",\n )\n return\n }\n supersedeDefer(targetId)\n markDeferPending(el)\n const source = document.createElement(\"pgbus-stream-source\")\n // Deterministic id: the JOB's broadcast removes it by this exact id — the\n // subscription tears itself down with the payload it delivered.\n source.id = deferSourceId(targetId)\n source.setAttribute(\"src\", src)\n source.setAttribute(\"since-id\", directive.getAttribute(\"data-reactive-defer-since-id\") ?? \"0\")\n source.setAttribute(\"hidden\", \"\")\n document.body.appendChild(source)\n // Record only { via } — NOT a strong ref to the source element. The job's\n // broadcast removes the source by its deterministic id (its\n // disconnectedCallback closes the SSE), so holding srcEl here would pin the\n // detached node in this module-level Map forever (a leak). Supersession\n // re-finds the element by id instead. The entry is dropped on\n // supersession or when the arriving broadcast replaces the target (a\n // turbo:before-stream-render hook, below).\n setPendingDefer(targetId, { via: \"stream\" })\n}\n\n// The deterministic id of a target's one-shot .\nfunction deferSourceId(targetId) {\n return `reactive-defer-src-${targetId}`\n}\n\nasync function performDeferFetch(targetId, entry, token) {\n // Bound the wait like the action fetch (issue #101) — a hung defer must not\n // shimmer forever. A manual timer (not AbortSignal.timeout) so the catch can\n // tell a TIMEOUT (fail loudly) from a SUPERSEDED abort (stay silent).\n const timer = setTimeout(() => {\n entry.timedOut = true\n entry.abort.abort()\n }, deferTimeoutMs())\n\n // The timeout is cleared ONLY after the body is fully read (below), not the\n // moment headers arrive — a server that streams headers then stalls the body\n // must still abort, or the shimmer hangs forever (the abort signal covers the\n // whole fetch + body read, mirroring #perform's AbortSignal.timeout).\n let response\n try {\n response = await fetch(deferPath(), {\n method: \"POST\",\n headers: {\n Accept: \"text/vnd.turbo-stream.html\",\n \"Content-Type\": \"application/json\",\n \"X-CSRF-Token\": deferCsrfToken(),\n },\n body: JSON.stringify({ token }),\n credentials: \"same-origin\",\n signal: entry.abort.signal,\n })\n } catch (error) {\n clearTimeout(timer)\n if (pendingDefers.get(targetId) !== entry) return // superseded — silent\n console.error(\"[phlex-reactive] deferred render failed\", error)\n failDefer(targetId, token)\n return\n }\n if (pendingDefers.get(targetId) !== entry) {\n clearTimeout(timer)\n return // superseded mid-flight\n }\n\n if (response.status === 204) {\n clearTimeout(timer)\n // render? false — keep the current content, just clear the pending state.\n settleDefer(targetId)\n return\n }\n if (!response.ok) {\n clearTimeout(timer)\n console.error(`[phlex-reactive] deferred render failed: HTTP ${response.status}`)\n failDefer(targetId, token, response.status)\n return\n }\n\n let html\n try {\n html = await response.text()\n } catch (error) {\n clearTimeout(timer)\n if (pendingDefers.get(targetId) !== entry) return\n console.error(\"[phlex-reactive] deferred render failed reading the body\", error)\n failDefer(targetId, token)\n return\n }\n clearTimeout(timer)\n if (pendingDefers.get(targetId) !== entry) return // superseded during read\n\n settleDefer(targetId)\n // A normal replace/morph of the target — the fresh root carries no pending\n // markers and a fresh action token, so the component lands interactive.\n window.Turbo.renderStreamMessage(html)\n}\n\n// Abort/unsubscribe whatever delivery is in flight for this target. The\n// deleted entry makes every late arrival fail its identity check — stale\n// content can never paint.\nfunction supersedeDefer(targetId) {\n const existing = pendingDefers.get(targetId)\n if (!existing) return\n deletePendingDefer(targetId)\n if (existing.via === \"fetch\") existing.abort.abort()\n // Stream lane: re-find the old source by its deterministic id and remove it\n // (unsubscribe) — we deliberately don't hold a strong ref to the detached\n // node. Its disconnectedCallback closes the SSE.\n else document.getElementById(deferSourceId(targetId))?.remove?.()\n}\n\nfunction markDeferPending(el) {\n el.setAttribute(\"data-reactive-defer-pending\", \"true\")\n el.setAttribute(\"aria-busy\", \"true\")\n}\n\nfunction clearDeferPending(el) {\n el.removeAttribute(\"data-reactive-defer-pending\")\n el.removeAttribute(\"aria-busy\")\n}\n\n// Success/204: drop the registry entry, clear pending, and clear any prior\n// defer failure marker (recovery resets error-driven CSS, issue #100 style).\nfunction settleDefer(targetId) {\n deletePendingDefer(targetId)\n const el = document.getElementById(targetId)\n if (!el) return\n clearDeferPending(el)\n el.removeAttribute(\"data-reactive-error\")\n}\n\n// Failure: clear pending (the shimmer must not lie), mark the root\n// (data-reactive-error=\"defer\" — style it in pure CSS), and emit a bubbling\n// reactive:error whose retry() re-enters the defer fetch with the SAME token\n// (still valid inside the TTL; an expired token 400s into this same path).\nfunction failDefer(targetId, token, status) {\n deletePendingDefer(targetId)\n const el = document.getElementById(targetId)\n if (!el) return\n clearDeferPending(el)\n el.setAttribute(\"data-reactive-error\", \"defer\")\n const retry = () => {\n const fresh = document.getElementById(targetId)\n if (!fresh) {\n console.warn(\"[phlex-reactive] defer retry() ignored — the target left the DOM\")\n return\n }\n fresh.removeAttribute(\"data-reactive-error\")\n startFetchDefer(targetId, token)\n }\n el.dispatchEvent(\n new CustomEvent(\"reactive:error\", {\n bubbles: true,\n composed: true,\n detail: { kind: \"defer\", target: targetId, status, retry },\n }),\n )\n}\n\nfunction deferPath() {\n return document.querySelector('meta[name=\"phlex-reactive-defer-path\"]')?.content || \"/reactive/defer\"\n}\n\n// CSRF is read LIVE per request (Rails can rotate it) — same contract as the\n// controller's #csrfToken.\nfunction deferCsrfToken() {\n return document.querySelector('meta[name=\"csrf-token\"]')?.content ?? \"\"\n}\n\n// Same page-stable meta + default as the controller's #timeoutMs (issue #101),\n// parsed defensively so a typo'd meta can never disable the bound.\nfunction deferTimeoutMs() {\n const raw = document.querySelector('meta[name=\"phlex-reactive-timeout\"]')?.content\n const ms = Number(raw)\n return Number.isFinite(ms) && ms > 0 ? ms : 30000\n}\n\n// Document-level self-dismissing flashes (issue #100). A flash rendered with\n// dismiss_after: carries data-reactive-dismiss-after=\"\"; after the timeout\n// it removes itself. This is deliberately NOT a Stimulus controller — the flash\n// container is a plain host-app div (Response#flash appends into it) with no\n// controller attached, so nothing would honor the attr. A document-level scan\n// on turbo:before-stream-render (which fires for EVERY render —\n// a reply AND a broadcast) schedules removal for any newly-arrived dismissing\n// flash. Each is marked data-reactive-dismiss-scheduled so re-scans (a later\n// stream render) never double-schedule the same node. Registered once; the\n// guard flag makes a second call a no-op (bun imports the module once per run).\nlet dismissRegistered = false\nexport function registerReactiveDismiss() {\n if (dismissRegistered) return\n if (typeof document === \"undefined\" || !document.addEventListener) return\n dismissRegistered = true\n // turbo:before-stream-render fires BEFORE the stream is applied — and Turbo\n // then does `await nextRepaint(); await event.detail.render(this)`, so a bare\n // setTimeout(0) can run BEFORE the node is inserted (observed under Falcon).\n // WRAP event.detail.render instead: run Turbo's own render, then scan once it\n // has resolved — timing-independent and correct on every server. The event\n // fires for EVERY (a reply AND a broadcast), so both delivery\n // paths self-clean. detail.render may be absent on exotic streams — guard it.\n document.addEventListener(\"turbo:before-stream-render\", wrapStreamRenderForDismiss)\n}\n\n// Chain the dismissing-flash scan after Turbo's own stream render resolves, so\n// the scan sees the freshly-inserted node. Idempotent per event (marks\n// detail.render as already-wrapped) and defensive if detail/render is missing.\nfunction wrapStreamRenderForDismiss(event) {\n const detail = event.detail\n const original = detail?.render\n if (typeof original !== \"function\" || original.__reactiveDismissWrapped) {\n // No render to wrap (or already wrapped) — fall back to a post-repaint scan.\n if (typeof requestAnimationFrame === \"function\") requestAnimationFrame(scheduleReactiveDismissals)\n else setTimeout(scheduleReactiveDismissals, 0)\n return\n }\n const wrapped = async (streamElement) => {\n await original(streamElement)\n scheduleReactiveDismissals()\n }\n wrapped.__reactiveDismissWrapped = true\n detail.render = wrapped\n}\n\n// Scan for un-scheduled dismissing flashes and schedule each one's removal.\n// Kept a module function so the scan logic is testable and re-run on every\n// stream render.\nfunction scheduleReactiveDismissals() {\n const flashes = document.querySelectorAll(\"[data-reactive-dismiss-after]\")\n for (const el of flashes) {\n if (el.hasAttribute(\"data-reactive-dismiss-scheduled\")) continue\n const ms = Number(el.getAttribute(\"data-reactive-dismiss-after\"))\n if (!Number.isFinite(ms) || ms <= 0) continue\n el.setAttribute(\"data-reactive-dismiss-scheduled\", \"\")\n setTimeout(() => el.remove(), ms)\n }\n}\n\n// Test seam: reset the one-time registration guard so a fresh document stub in\n// the next test registers its own listener (bun runs all specs in one process).\nexport function __resetReactiveDismissForTest() {\n dismissRegistered = false\n}\n\n// Reactive effects (issue #215): animate ENTER (append/prepend), EXIT (remove)\n// and UPDATE (replace/update, plain or morph) when a renders.\n// Document-level and render-wrapping like the dismiss hook above, so ONE\n// interceptor covers both delivery paths (a reply and a broadcast). Strictly\n// data-driven and default-deny: the per-call data-reactive-effect on the\n// stream element wins (\"off\" suppresses), else the carrier element's\n// data-reactive-effect- — the DOM target for exit/update, the INCOMING\n// template root for enter. No attribute → no work; unknown names and\n// malformed legs warn + skip (a newer or forged attr must never break the\n// page). prefers-reduced-motion disables everything (the shipped CSS is also\n// media-wrapped — defense in depth).\n//\n// Timing:\n// * exit — the animation runs BEFORE Turbo's render (the removal), awaited\n// via animationend/transitionend with a timeout fallback; a ZERO computed\n// duration (no effects CSS loaded, reduced-motion CSS gate) skips the wait\n// entirely, so a missing stylesheet can never freeze a removal.\n// * enter/update — Turbo renders first, then the effect class is applied to\n// the inserted/updated element(s) and removed on settle (fire-and-forget).\n// Re-applying an update effect restarts it (class off → reflow → on).\n//\n// A named effect maps to the shipped CSS class reactive-fx---\n// (app/assets/stylesheets/phlex/reactive/effects.css); \"random\" picks a\n// built-in per application; a \"[\"-prefixed value is a custom\n// [during, from, to] class-legs triple (the #96/#186 vocabulary), run with\n// runTransition's add → frame → swap → settle choreography.\nconst EFFECT_HOOKS = Object.freeze({\n append: \"enter\",\n prepend: \"enter\",\n replace: \"update\",\n update: \"update\",\n remove: \"exit\",\n})\nconst EFFECT_BUILT_INS = Object.freeze([\"fade\", \"slide\", \"scale\", \"highlight\", \"shake\"])\n// Marks an incoming template root so the post-render scan finds the inserted\n// CLONE (Turbo clones template content on render — attrs ride the clone).\nconst EFFECT_PENDING_ATTR = \"data-reactive-fx-pending\"\n// The hard ceiling on any effect wait — an exit's removal is delayed at most\n// this long even if animationend/transitionend never fire.\nconst EFFECT_SETTLE_FALLBACK_MS = 1000\n\nlet effectsRegistered = false\nexport function registerReactiveEffects() {\n if (effectsRegistered) return\n if (typeof document === \"undefined\" || typeof document.addEventListener !== \"function\") return\n effectsRegistered = true\n document.addEventListener(\"turbo:before-stream-render\", wrapStreamRenderForEffects)\n}\n\nexport function __resetReactiveEffectsForTest() {\n effectsRegistered = false\n}\n\n// Wrap event.detail.render (the dismiss-hook pattern) when this stream both\n// maps to a hook AND resolves to an effect. Resolution happens HERE, before\n// the render, because exit must read the target while it is still in the DOM\n// and enter must read (and mark) the template content before Turbo clones it.\nfunction wrapStreamRenderForEffects(event) {\n const detail = event.detail\n const original = detail?.render\n if (typeof original !== \"function\" || original.__reactiveEffectsWrapped) return\n const streamEl = detail?.newStream ?? event.target\n const hook = EFFECT_HOOKS[streamEl?.getAttribute?.(\"action\")]\n if (!hook || effectsReducedMotion()) return\n const effect = resolveStreamEffect(streamEl, hook)\n if (!effect) return\n\n const wrapped =\n hook === \"exit\"\n ? async (el) => {\n await runExitEffect(effectTarget(streamEl), effect)\n await original(el)\n }\n : async (el) => {\n const container = hook === \"enter\" ? markIncomingRoots(streamEl) : null\n await original(el)\n if (hook === \"enter\") animateMarkedRoots(container, effect)\n else runEnterOrUpdateEffect(effectTarget(streamEl), effect)\n }\n wrapped.__reactiveEffectsWrapped = true\n detail.render = wrapped\n}\n\n// The effect for this stream: per-call data-reactive-effect first (\"off\" →\n// none), else the carrier's declared data-reactive-effect-.\nfunction resolveStreamEffect(streamEl, hook) {\n const perCall = streamEl.getAttribute?.(\"data-reactive-effect\")\n if (perCall === \"off\") return null\n if (perCall) return parseEffect(perCall, hook)\n const carrier = hook === \"enter\" ? incomingEffectRoot(streamEl) : effectTarget(streamEl)\n const declared = carrier?.getAttribute?.(`data-reactive-effect-${hook}`)\n return declared ? parseEffect(declared, hook) : null\n}\n\n// The stream's CURRENT DOM target (re-queried at use, so a post-replace call\n// sees the freshly-swapped element). Our builders always emit `target` —\n// multi-`targets` streams are not ours and pass through unanimated.\nfunction effectTarget(streamEl) {\n const target = streamEl.getAttribute?.(\"target\")\n return target ? (document.getElementById?.(target) ?? null) : null\n}\n\n// The incoming content's root element (an append/prepend's arriving\n// component) — the carrier of a declared enter effect.\nfunction incomingEffectRoot(streamEl) {\n return streamEl.querySelector?.(\"template\")?.content?.firstElementChild ?? null\n}\n\n// A wire value → an executable effect: { className } for a shipped built-in\n// (\"random\" picks one per application), { legs } for a custom triple. null +\n// console.warn for anything else (default-deny).\nfunction parseEffect(value, hook) {\n if (value.startsWith(\"[\")) {\n let legs = null\n try {\n const parsed = JSON.parse(value)\n if (Array.isArray(parsed) && parsed.length === 3) legs = parsed.map(String)\n } catch {\n // malformed JSON → the shared warn below\n }\n if (legs) return { legs }\n console.warn(`[phlex-reactive] malformed effect legs ${JSON.stringify(value)} — skipped`)\n return null\n }\n const name =\n value === \"random\" ? EFFECT_BUILT_INS[Math.floor(Math.random() * EFFECT_BUILT_INS.length)] : value\n if (!EFFECT_BUILT_INS.includes(name)) {\n console.warn(`[phlex-reactive] unknown effect ${JSON.stringify(value)} — skipped`)\n return null\n }\n return { className: `reactive-fx--${name}-${hook}` }\n}\n\nfunction effectsReducedMotion() {\n try {\n return typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n } catch {\n return false\n }\n}\n\n// Stamp each incoming template root with the pending marker (Turbo's render\n// clones the content, so the marker rides the inserted clone) and return the\n// container the post-render scan searches. Pre-render on purpose.\nfunction markIncomingRoots(streamEl) {\n const content = streamEl.querySelector?.(\"template\")?.content\n if (!content) return null\n for (const child of Array.from(content.children ?? [])) child.setAttribute?.(EFFECT_PENDING_ATTR, \"\")\n return effectTarget(streamEl)\n}\n\n// Post-render: find the just-inserted clones by their marker, unmark, animate.\nfunction animateMarkedRoots(container, effect) {\n if (typeof container?.querySelectorAll !== \"function\") return\n for (const el of Array.from(container.querySelectorAll(`[${EFFECT_PENDING_ATTR}]`))) {\n el.removeAttribute(EFFECT_PENDING_ATTR)\n runEnterOrUpdateEffect(el, effect)\n }\n}\n\n// EXIT: animate on the still-present element, resolve when settled, and only\n// then does the wrapper run Turbo's removal. Zero computed duration (no\n// effects CSS) resolves immediately — never a dead 1s freeze.\nasync function runExitEffect(el, effect) {\n if (!el?.classList) return\n if (effect.legs) {\n await runLegsEffect(el, effect.legs)\n return\n }\n el.classList.add(effect.className)\n const duration = effectDurationMs(el)\n if (duration <= 0) {\n el.classList.remove(effect.className)\n return\n }\n await effectSettled(el, duration)\n el.classList.remove(effect.className)\n}\n\n// ENTER/UPDATE: fire-and-forget after the render. A re-applied class is\n// removed + reflowed first so rapid successive updates restart the flash; the\n// per-element token keeps an older settle from clearing a newer application.\nfunction runEnterOrUpdateEffect(el, effect) {\n if (!el?.classList) return\n if (effect.legs) {\n runLegsEffect(el, effect.legs)\n return\n }\n if (el.classList.contains(effect.className)) {\n el.classList.remove(effect.className)\n void el.offsetWidth // force a reflow so re-adding restarts the animation\n }\n el.classList.add(effect.className)\n const duration = effectDurationMs(el)\n if (duration <= 0) {\n el.classList.remove(effect.className)\n return\n }\n const token = (el.__reactiveFxToken = (el.__reactiveFxToken ?? 0) + 1)\n effectSettled(el, duration).then(() => {\n if (el.__reactiveFxToken === token) el.classList.remove(effect.className)\n })\n}\n\n// Custom class legs — runTransition's choreography (add during+from, swap\n// from→to on the next frame, settle, clean up), promise-shaped so an exit can\n// await it. Class lists are space-separated (the #96/#186 wire).\n//\n// Rapid re-application on the same element RESTARTS, mirroring the named\n// path's token guard: each run takes the per-element token, clears any\n// earlier run's leg classes, and a superseded run stops touching the element\n// the moment a newer run owns it — so a stale settle can never strip classes\n// mid-animation or double-swap the legs. A superseded EXIT run resolves\n// early, which only lets Turbo's removal proceed sooner (never later).\nasync function runLegsEffect(el, legs) {\n const [during, from, to] = legs.map(splitEffectClasses)\n const token = (el.__reactiveFxToken = (el.__reactiveFxToken ?? 0) + 1)\n el.classList.remove(...during, ...from, ...to)\n el.classList.add(...during, ...from)\n await effectNextFrame()\n if (el.__reactiveFxToken !== token) return\n el.classList.remove(...from)\n el.classList.add(...to)\n const duration = effectDurationMs(el)\n if (duration > 0) await effectSettled(el, duration)\n if (el.__reactiveFxToken !== token) return\n el.classList.remove(...during, ...to)\n}\n\nfunction splitEffectClasses(list) {\n return String(list ?? \"\")\n .split(/\\s+/)\n .filter(Boolean)\n}\n\n// The longest computed animation/transition (duration + delay, comma lists\n// included) in ms, capped at the hard fallback. 0 when getComputedStyle is\n// unavailable or nothing animates — callers skip the wait entirely.\nfunction effectDurationMs(el) {\n if (typeof getComputedStyle !== \"function\") return 0\n try {\n const style = getComputedStyle(el)\n const longest = (value) =>\n String(value ?? \"\")\n .split(\",\")\n .reduce((max, part) => Math.max(max, parseFloat(part) || 0), 0)\n const animation = longest(style.animationDuration) + longest(style.animationDelay)\n const transition = longest(style.transitionDuration) + longest(style.transitionDelay)\n return Math.min(Math.max(animation, transition) * 1000, EFFECT_SETTLE_FALLBACK_MS)\n } catch {\n return 0\n }\n}\n\n// Resolve on animationend/transitionend — whichever fires first — with a\n// timeout slightly past the computed duration, so a canceled animation (a\n// display:none ancestor, an interrupted transition) can't hang an exit.\nfunction effectSettled(el, durationMs) {\n return new Promise((resolve) => {\n let done = false\n const settle = () => {\n if (done) return\n done = true\n resolve()\n }\n el.addEventListener?.(\"animationend\", settle, { once: true })\n el.addEventListener?.(\"transitionend\", settle, { once: true })\n setTimeout(settle, Math.min(durationMs + 50, EFFECT_SETTLE_FALLBACK_MS))\n })\n}\n\nfunction effectNextFrame() {\n return new Promise((resolve) => {\n if (typeof requestAnimationFrame === \"function\") requestAnimationFrame(() => resolve())\n else setTimeout(resolve, 16)\n })\n}\n\n// Offline CSS hook (issue #101). Mirror data-reactive-offline on\n// document.documentElement from navigator.onLine, kept in sync by the window\n// online/offline events — so an app can dim a save button or show a banner with\n// PURE CSS and zero JS ([data-reactive-offline] .save { pointer-events: none }).\n// Guarded on window (needed for addEventListener AND navigator) so importing the\n// module in a non-browser (bun test) context is a no-op, and registered once\n// (the online/offline listeners are NOT {once}, so a second registerReactiveActions\n// call must not stack duplicates) — mirroring the dismiss guard + reset seam.\nlet offlineRegistered = false\nexport function registerReactiveOffline() {\n if (offlineRegistered) return\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n if (typeof window.addEventListener !== \"function\") return\n offlineRegistered = true\n // toggleAttribute(name, force) writes data-reactive-offline=\"\" (a bare boolean\n // attr the [data-reactive-offline] selector matches) or removes it — never the\n // \"true\" string. navigator.onLine === false is the reliable direction (a false\n // \"online\" is spec-permitted but rare, and this is only a presentational hook —\n // the authoritative offline signal is the #perform gate, not this attribute).\n // Fully defensive: a missing documentElement/toggleAttribute/navigator degrades\n // to a no-op — a presentational hook must NEVER throw during bootstrap.\n const sync = () => {\n const root = document.documentElement\n if (typeof root?.toggleAttribute !== \"function\") return\n root.toggleAttribute(\"data-reactive-offline\", globalThis.navigator?.onLine === false)\n }\n sync() // seed synchronously so first paint is correct\n window.addEventListener(\"online\", sync)\n window.addEventListener(\"offline\", sync)\n}\n\nexport function __resetReactiveOfflineForTest() {\n offlineRegistered = false\n}\n\n// Latency simulator dev aid (issue #102). On localhost the click→morph round\n// trip is ~5ms, so the pending/loading/optimistic affordances (aria-busy,\n// disable_with, busy_on, optimistic hints) flash by too fast to see while\n// developing or demoing them — the reason LiveView ships enableLatencySim(ms).\n//\n// enableLatencySim(ms) persists the delay to sessionStorage (session-scoped, so\n// it clears when the tab closes — never a config you forget you left on);\n// #perform reads it right before the fetch and awaits setTimeout(ms), stretching\n// the already-set busy window to something visible. disableLatencySim() clears\n// it. NAMED exports (the setConfirmResolver precedent) — but importmap module\n// exports are unreachable from the browser console, so registerReactiveActions\n// ALSO attaches these to window.PhlexReactive, and ONLY when the app opts in with\n// (see #attachLatencyHandle).\nexport const LATENCY_KEY = \"phlex-reactive:latency\"\n\n// One-time \"sim active\" banner guard (module-level, mirroring offlineRegistered):\n// #maybeSimulateLatency warns ONCE while the sim is on, not once per request.\nlet latencyBannerShown = false\n\nexport function enableLatencySim(ms) {\n if (typeof sessionStorage === \"undefined\") return\n sessionStorage.setItem(LATENCY_KEY, String(ms))\n}\n\nexport function disableLatencySim() {\n if (typeof sessionStorage === \"undefined\") return\n sessionStorage.removeItem(LATENCY_KEY)\n // Re-arm the one-time \"sim active\" banner: turning the sim OFF is the lifecycle\n // boundary, so a later enableLatencySim() in the same session re-announces that\n // the sim is on (otherwise the guard would stay set across an off→on cycle and\n // swallow the banner). Matches the __resetReactiveLatencyForTest seam.\n latencyBannerShown = false\n}\n\n// The dev gate. importmap module exports aren't reachable from the DevTools\n// console, so we expose the two functions on a window handle — but ONLY when the\n// app authored . There is\n// NO engine-emitted meta (the engine can't inject into the host layout); the\n// install generator ships the snippet commented. Without the meta: no global\n// handle at all, and #perform short-circuits on the null sessionStorage read —\n// zero production surface. The `?.content` chain is fully defensive (a stubbed\n// document with no querySelector, a missing meta) so bootstrap never throws.\nfunction attachLatencyHandle() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n const env = document.querySelector?.('meta[name=\"phlex-reactive-env\"]')?.content\n if (env !== \"development\") return\n window.PhlexReactive = { enableLatencySim, disableLatencySim }\n}\n\n// Test seam: forget the one-time active-sim banner so the next test re-warns.\nexport function __resetReactiveLatencyForTest() {\n latencyBannerShown = false\n}\n\n// --- Global reactive-activity signal (issue #201) --------------------------\n// A DOCUMENT-LEVEL count of in-flight reactive operations — the direct analogue\n// of Turbo's progress bar, but for reactive round trips and deferred renders\n// instead of navigations. Anything that starts an async reactive operation calls\n// enterReactiveActivity(); when it settles (success OR failure, on every path) it\n// calls exitReactiveActivity(). The count is exposed two ways so an app — or a\n// system test — can key off \"is the reactive layer settling?\" without knowing\n// about any individual root:\n//\n// * a marker on : data-reactive-active present while count > 0 (CSS can\n// drive a global spinner; code/tests can read it). A DISTINCT name from the\n// per-root data-reactive-busy so a [data-reactive-busy] selector never also\n// matches the document element.\n// * events on document: reactive:busy on the 0 -> >0 edge, reactive:idle on the\n// >0 -> 0 edge — EDGES ONLY (not once per op), each carrying { count }.\n//\n// It sums ACROSS all reactive roots (module-level, not per-controller) and across\n// the two async lifecycles wired below:\n// * dispatch — entered in #applyBusy (at ENQUEUE, so the queue wait counts too),\n// exited in the settle closure #perform runs in its finally.\n// * defer — entered/exited with the pendingDefers registry (set/delete), the\n// ONE registry both the fetch (pull) and the stream (push) lane maintain — so\n// the push lane stays balanced even though it clears its pending markers by a\n// node swap, not clearDeferPending. A supersede is delete-then-set (net zero),\n// which is correct: a fast typist's replaced defer is still \"layer busy\".\n//\n// compute-seed is deliberately NOT counted: recompute() is synchronous, so a seed\n// is fully applied by the time the call returns — there is no async window to await\n// (the \"value settles a beat after a morph/seed\" case the issue describes is\n// covered by the System test helpers' re-resolve-by-id polling, not this counter).\nexport const ACTIVE_ATTR = \"data-reactive-active\"\n\nlet activityCount = 0\n\n// Increment the global in-flight count; on the 0 -> 1 edge, mark and fire\n// reactive:busy. Fully defensive — a non-browser/test document with no\n// documentElement/dispatchEvent still tracks the count and simply skips the DOM\n// side effects (a global signal must never throw during bootstrap or a round trip).\nexport function enterReactiveActivity() {\n activityCount++\n if (activityCount === 1) syncReactiveActivity(\"reactive:busy\")\n}\n\n// Decrement the global in-flight count, clamped at 0 so an unbalanced exit can\n// never drive it negative (which would wedge the marker on forever). On the\n// 1 -> 0 edge, clear the marker and fire reactive:idle.\nexport function exitReactiveActivity() {\n if (activityCount === 0) return\n activityCount--\n if (activityCount === 0) syncReactiveActivity(\"reactive:idle\")\n}\n\n// The current in-flight count — a test seam and a runtime read (an app can gate an\n// \"unsaved changes\" prompt on `reactiveActivityCount() > 0`).\nexport function reactiveActivityCount() {\n return activityCount\n}\n\n// Test seam: reset the module-level counter (and clear the marker) between tests,\n// since the module is imported once per bun run.\nexport function resetReactiveActivity() {\n activityCount = 0\n const root = typeof document !== \"undefined\" ? document.documentElement : null\n root?.removeAttribute?.(ACTIVE_ATTR)\n}\n\n// Write the marker from the current count and fire the edge event on\n// document. Both sides are independently guarded so a partial document stub (a\n// documentElement without toggleAttribute, or a document without dispatchEvent)\n// degrades to a no-op rather than throwing.\nfunction syncReactiveActivity(eventName) {\n if (typeof document === \"undefined\") return\n const root = document.documentElement\n if (typeof root?.toggleAttribute === \"function\") {\n root.toggleAttribute(ACTIVE_ATTR, activityCount > 0)\n }\n if (typeof document.dispatchEvent === \"function\" && typeof CustomEvent === \"function\") {\n document.dispatchEvent(new CustomEvent(eventName, { detail: { count: activityCount } }))\n }\n}\n\nexport function registerReactiveActions() {\n registerReactiveVisit()\n registerReactiveToken()\n registerReactiveJs()\n registerReactiveDefer()\n registerReactiveDismiss()\n registerReactiveEffects()\n registerReactiveOffline()\n attachLatencyHandle()\n}\n\n// Escape a DOM id for safe interpolation into a RegExp (an id can legally contain\n// regex metacharacters like `.`/`:` — e.g. an `escape:`-namespaced or dotted id).\n// Used by #extractToken to match the stream that re-renders THIS element by id.\nexport function escapeRegExp(string) {\n return string.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")\n}\n\nif (typeof window !== \"undefined\") {\n if (window.Turbo) registerReactiveActions()\n else document.addEventListener(\"turbo:load\", registerReactiveActions, { once: true })\n}\n\n// --- Registration guard (issue #26 part 2) -------------------------------\n// In a `lazyLoadControllersFrom(\"controllers\", application)` app, only\n// controllers under app/javascript/controllers/ are registered. This module\n// lives outside that dir, so importing it isn't enough — `data-controller=\n// \"reactive\"` does NOTHING until the host runs application.register(\"reactive\",\n// ...). The failure is silent: components render, but no action ever fires.\n//\n// We can't warn from connect() in that case (connect never runs). Instead, once\n// the page is ready, if reactive elements exist but no controller has connected,\n// the controller wasn't registered — so we warn, pointing at the fix.\nlet reactiveConnected = false\n\nexport function checkReactiveRegistration() {\n if (reactiveConnected) return\n if (typeof document === \"undefined\") return\n const els = document.querySelectorAll('[data-controller~=\"reactive\"]')\n if (!els || els.length === 0) return\n console.warn(\n \"[phlex-reactive] found \" + els.length + ' element(s) with data-controller=\"reactive\" ' +\n \"but the reactive controller never connected. It is loaded but not registered — \" +\n 'add `application.register(\"reactive\", ReactiveController)` (importmap) or import it ' +\n \"into app/javascript/controllers/ for lazyLoadControllersFrom apps. See the README.\"\n )\n}\n\n// Test seams (no-ops in production usage).\nexport function __resetReactiveRegistrationForTest() {\n reactiveConnected = false\n}\nexport function __markReactiveConnectedForTest() {\n reactiveConnected = true\n}\n\nif (typeof window !== \"undefined\" && typeof document !== \"undefined\") {\n // Defer past initial controller connection (a microtask/tick after ready).\n const scheduleCheck = () => setTimeout(checkReactiveRegistration, 0)\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", scheduleCheck, { once: true })\n } else {\n scheduleCheck()\n }\n}\n\n// The interpret-time attribute-name allowlist (issue #96) — the SECOND half of\n// the two-sided default-deny. The Ruby builder already refuses these at build\n// time; this guards a hand-built / forged ops attr from bypassing it. Refused:\n// event handlers (on*, XSS), URL-bearing names (a javascript: navigation\n// surface), and style (CSS injection). Case-insensitive, mirroring js.rb.\nconst REFUSED_ATTR_URL = new Set([\"href\", \"src\", \"srcdoc\", \"action\", \"formaction\", \"xlink:href\", \"style\"])\nfunction attrRefused(name) {\n const lower = String(name).toLowerCase()\n return lower.startsWith(\"on\") || REFUSED_ATTR_URL.has(lower)\n}\n\n// Run an animated visibility change (issue #96 `transition:`). `flip` performs\n// the actual hidden-flag change; `[during, from, to]` are class lists applied\n// AROUND it. Cleanup (removing during+to) is awaited via `animationend` OR a\n// setTimeout fallback — whichever comes first — so an element with NO animation\n// never leaves the helper classes stuck (the op chain itself is not blocked:\n// cleanup is fire-and-forget, later ops run immediately). The fallback and the\n// listener share a one-shot `done` guard so cleanup runs exactly once.\nfunction runTransition(el, transition, flip) {\n const [during, from, to] = transition\n el.classList.add(during, from)\n flip()\n requestAnimationFrame(() => {\n el.classList.remove(from)\n el.classList.add(to)\n })\n\n let done = false\n const cleanup = () => {\n if (done) return\n done = true\n el.classList.remove(during, to)\n }\n el.addEventListener(\"animationend\", cleanup, { once: true })\n // ~10% over a common 300ms transition; also the ONLY path for a non-animated\n // element (animationend never fires there), so it must always be scheduled.\n setTimeout(cleanup, 350)\n}\n\n// The client-op whitelist behind on_client (issue #95, extended in #96). Mirrors\n// Phlex::Reactive::JS's vocabulary; an op name not in this map is\n// warn-and-skipped by #applyOps (client-side default-deny — a stale or newer\n// ops attr must never break the page). Each op is a pure, local DOM mutation:\n// nothing is read back, nothing is sent anywhere. Frozen so nothing can be\n// registered into it at runtime — extending the vocabulary is a gem change,\n// not an app hook.\nconst CLIENT_OPS = Object.freeze({\n show: (el, args) => setHidden(el, false, args),\n hide: (el, args) => setHidden(el, true, args),\n toggle: (el, args) => setHidden(el, !el.hidden, args),\n add_class: (el, args) => el.classList.add(...(args.classes ?? [])),\n remove_class: (el, args) => el.classList.remove(...(args.classes ?? [])),\n toggle_class: (el, args) => (args.classes ?? []).forEach((c) => el.classList.toggle(c)),\n\n // Attribute ops (issue #96), interpret-time allowlisted. set_attr writes the\n // (already-stringified) value; toggle_attr adds a missing attr (value \"\") or\n // removes a present one; remove_attr removes it. A refused name warns + skips.\n set_attr: (el, args) => {\n if (guardAttr(args.name)) el.setAttribute(args.name, args.value ?? \"\")\n },\n remove_attr: (el, args) => {\n if (guardAttr(args.name)) el.removeAttribute(args.name)\n },\n toggle_attr: (el, args) => {\n if (!guardAttr(args.name)) return\n if (el.hasAttribute(args.name)) el.removeAttribute(args.name)\n else el.setAttribute(args.name, \"\")\n },\n\n // Focus ops (issue #96). focus targets the match itself; focus_first targets\n // its first focusable descendant (opened-menu → first menuitem).\n focus: (el) => el.focus?.(),\n focus_first: (el) => firstFocusable(el)?.focus?.(),\n\n // Text op (issue #159): set textContent — XSS-safe by construction (never\n // innerHTML), strictly less powerful than set_attr. Change-guarded like\n // #mirrorText. With global: true it is the cross-root text escape: paint a\n // value into a recap node OUTSIDE the component's root.\n text: (el, args) => {\n const text = String(args.value ?? \"\")\n if (el.textContent !== text) el.textContent = text\n },\n\n // Dispatch a bubbling CustomEvent (issue #96). RAW element.dispatchEvent — the\n // controller SHADOWS Stimulus's this.dispatch helper, so it must not be used.\n dispatch: (el, args) => {\n el.dispatchEvent(new CustomEvent(args.name, { bubbles: true, composed: true, detail: args.detail ?? {} }))\n },\n\n // Submit the target's OWN form (issue #226) via requestSubmit() — constraint\n // validation runs and a REAL cancelable `submit` event fires, so an\n // on(:action, event: \"submit\") interception or a native/Turbo form handles it\n // exactly like a user submit. No form → no-op. ACTOR-ONLY like focus: the\n // broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS).\n submit: (el) => submitFormFor(el)?.requestSubmit?.(),\n})\n\n// The form a submit op commits (issue #226), in order: the target itself when\n// it IS a form (tagName, not instanceof — fake-node/test friendly), its form\n// owner for a control (input.form — honors a form= attribute), else the nearest\n// ancestor form. closest() may cross the component root by design — the\n// FIELD'S OWN form is the submit scope, not the reactive boundary.\nfunction submitFormFor(el) {\n if (el?.tagName === \"FORM\") return el\n return el?.form ?? el?.closest?.(\"form\") ?? null\n}\n\n// Apply a hidden-flag change, optionally animated by a [during, from, to]\n// transition (issue #96). Split out so show/hide/toggle share it.\nfunction setHidden(el, hidden, args) {\n if (args?.transition) runTransition(el, args.transition, () => (el.hidden = hidden))\n else el.hidden = hidden\n}\n\n// The interpret-time attribute guard: refuse (warn + skip) a name off the\n// allowlist. Returns true when the op may proceed.\nfunction guardAttr(name) {\n if (!attrRefused(name)) return true\n console.warn(`[phlex-reactive] refused client attr op on ${JSON.stringify(name)} — skipped`)\n return false\n}\n\n// A cross-root mirror target must be a single ID selector (issue #159) — \"#\" +\n// a CSS identifier, nothing else. The client half of the two-sided default-deny\n// (reactive_compute's `mirror:` validates the SAME shape loudly at declare\n// time): a hand-built mirror attr must not widen a declared text mirror into a\n// page-wide selector write. A refused selector warns + skips (its siblings\n// still apply), matching the attr-allowlist posture.\nconst MIRROR_ID_SELECTOR = /^#[A-Za-z_][\\w-]*$/\nfunction guardMirrorSelector(selector) {\n if (typeof selector === \"string\" && MIRROR_ID_SELECTOR.test(selector)) return true\n console.warn(`[phlex-reactive] refused cross-root mirror target ${JSON.stringify(selector)} — skipped`)\n return false\n}\n\n// Evaluate a show binding's declared literal predicate (issue #161) against\n// the controlling field's current value. Exactly one of the three predicate\n// attrs decides: equals (value === literal), not (value !== literal), in\n// (value ∈ a JSON string list). The vocabulary is fixed and literal-only —\n// never an expression, so there is no eval surface (the reactive_show helper\n// enforces the same shape loudly at render; this is the client half of the\n// two-sided posture). Returns true/false for a decidable binding, or null for\n// a malformed/missing predicate — the caller SKIPS a null so a hand-built or\n// stale binding never flips visibility it doesn't understand (default-deny,\n// like the op whitelist).\nfunction showBindingMatches(el, value) {\n const equals = el.getAttribute(\"data-reactive-show-equals\")\n if (equals !== null) return value === equals\n const not = el.getAttribute(\"data-reactive-show-not\")\n if (not !== null) return value !== not\n const inRaw = el.getAttribute(\"data-reactive-show-in\")\n if (inRaw !== null) {\n try {\n const list = JSON.parse(inRaw)\n if (Array.isArray(list)) return list.includes(value)\n } catch {\n // fall through to the warn below — malformed JSON and a non-array both skip\n }\n console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(inRaw)} — skipped`)\n return null\n }\n // Numeric threshold predicates (issue #176 part B): gte/gt/lte/lt read the\n // literal off its own flat attr and compare Number(value) against it. Any\n // present numeric attr decides the binding — a non-numeric field value (NaN)\n // is false (hidden), and a non-numeric LITERAL warn-skips (null).\n for (const key of SHOW_NUMERIC_KEYS) {\n const raw = el.getAttribute(`data-reactive-show-${key}`)\n if (raw !== null) return numericPredicateMatches(key, raw, value)\n }\n console.warn(\"[phlex-reactive] a reactive_show binding declares no predicate — skipped\")\n return null\n}\n\n// The numeric threshold keys (issue #176 part B) — the client half of the Ruby\n// SHOW_NUMERIC_KEYS. Order-independent; the evaluator reads the one that's\n// present. Each coerces BOTH sides to Number and compares.\nconst SHOW_NUMERIC_KEYS = [\"gte\", \"gt\", \"lte\", \"lt\"]\n\n// The length predicate keys (issue #226) — the client half of Ruby's\n// ShowConditions::LENGTH_KEYS. Length is counted in CODEPOINTS\n// ([...str].length), NOT UTF-16 code units (str.length), so Ruby's\n// String#length and this evaluator agree on multibyte values — the shared\n// fixture's emoji vector proves it.\nconst SHOW_LENGTH_KEYS = [\"len_eq\", \"len_gte\", \"len_gt\", \"len_lte\", \"len_lt\"]\n\n// Evaluate one length predicate. Length is a TOTAL function (blank/absent →\n// 0), so every field value is decidable — no fail-closed special case like the\n// numeric thresholds ({ length: 0 } legitimately matches a blank field). A\n// non-Integer LITERAL is a malformed binding — warn-skip (null), default-deny.\nfunction lengthPredicateMatches(key, literal, value) {\n if (!Number.isInteger(literal)) {\n console.warn(`[phlex-reactive] reactive_show ${key}: needs an integer literal, got ${JSON.stringify(literal)} — skipped`)\n return null\n }\n const length = [...String(value ?? \"\")].length\n switch (key) {\n case \"len_eq\":\n return length === literal\n case \"len_gte\":\n return length >= literal\n case \"len_gt\":\n return length > literal\n case \"len_lte\":\n return length <= literal\n case \"len_lt\":\n return length < literal\n default:\n return null\n }\n}\n\n// Evaluate one numeric threshold predicate against a field value. Returns\n// true/false for a decidable comparison, or null when the LITERAL itself is\n// non-numeric (a malformed binding — warn-skip, default-deny). A non-numeric\n// FIELD value (empty/blank/garbage) is treated as NaN → false: the\n// reveal-on-threshold notice stays hidden, the safe default. Shared by the\n// owned-binding evaluator (raw string literal off an attr) and the\n// cross-root/compound evaluator (a literal that arrived as a JSON number or\n// string).\nfunction numericPredicateMatches(key, literal, value) {\n const rhs = Number(literal)\n if (Number.isNaN(rhs)) {\n console.warn(`[phlex-reactive] reactive_show ${key}: needs a numeric literal, got ${JSON.stringify(literal)} — skipped`)\n return null\n }\n // A blank/whitespace field value must fail closed. Number(\"\") and\n // Number(\" \") are 0 (NOT NaN), so a bare Number()+isNaN check would wrongly\n // reveal a `lte:`/`lt:`/`gte: 0` binding on an EMPTY field. Force the\n // empty/blank case to NaN so the \"blank → hidden\" contract holds for every\n // operator, not just the ones where 0 happens to fail the comparison.\n const trimmed = value == null ? \"\" : String(value).trim()\n const n = trimmed === \"\" ? NaN : Number(trimmed)\n if (Number.isNaN(n)) return false\n switch (key) {\n case \"gte\":\n return n >= rhs\n case \"gt\":\n return n > rhs\n case \"lte\":\n return n <= rhs\n case \"lt\":\n return n < rhs\n default:\n return null\n }\n}\n\n// Evaluate an ALREADY-PARSED show predicate object (issue #164) — the\n// reactive_show_targets map embeds { equals/not/in } directly in its JSON, so\n// unlike showBindingMatches there are no attrs to read or re-parse. The same\n// literal-only vocabulary; anything else (empty, unknown keys, a non-array\n// in:) returns null and the caller warn-skips that target (default-deny — a\n// hand-built map entry must never flip visibility it doesn't declare).\nfunction showPredicateMatches(pred, value) {\n if (!pred || typeof pred !== \"object\") return null\n if (typeof pred.equals === \"string\") return value === pred.equals\n if (typeof pred.not === \"string\") return value !== pred.not\n if (Array.isArray(pred.in)) return pred.in.includes(value)\n // Numeric threshold predicates (issue #176 part B): the literal arrives as a\n // JSON number (or a numeric string) embedded in the predicate object — one\n // shared numericPredicateMatches with the owned-binding evaluator.\n for (const key of SHOW_NUMERIC_KEYS) {\n if (key in pred) return numericPredicateMatches(key, pred[key], value)\n }\n // Length predicates (issue #226): codepoint count vs an Integer literal.\n for (const key of SHOW_LENGTH_KEYS) {\n if (key in pred) return lengthPredicateMatches(key, pred[key], value)\n }\n return null\n}\n\n// The selector matching every OWNED-element show binding: single-field\n// (data-reactive-show-field, issue #161) OR compound all:/any:\n// (data-reactive-show, issue #176). Both the connect() gate and the sync walk\n// use it so a compound-only root still enables the sync.\nconst SHOW_BINDING_SELECTOR = \"[data-reactive-show-field], [data-reactive-show]\"\n\n// Parse a compound show binding's JSON payload (issue #176 part A). Malformed\n// JSON degrades to null WITH a warn — a bad binding must never throw or blank\n// the page (client-side default-deny), but a collision (two bindings' JSON\n// mix-joined) is worth surfacing.\nfunction parseShowCompound(raw) {\n try {\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) return parsed\n } catch {\n // fall through to the warn\n }\n console.warn(`[phlex-reactive] malformed compound reactive_show payload ${JSON.stringify(raw)} — skipped`)\n return null\n}\n\n// Parse a data-reactive-on-complete payload (issue #226): a JSON array of\n// { any: [[term, …], …], ops: [[op, args], …] } bindings. Malformed JSON, a\n// non-array, or a binding missing either half degrades to [] WITH a warn — a\n// bad payload must never throw or fire an op (client-side default-deny, the\n// parseShowCompound posture).\nfunction parseOnComplete(raw) {\n try {\n const list = JSON.parse(raw)\n if (\n Array.isArray(list) &&\n list.every((b) => b && typeof b === \"object\" && Array.isArray(b.any) && Array.isArray(b.ops))\n ) {\n return list\n }\n } catch {\n // fall through to the warn\n }\n console.warn(`[phlex-reactive] malformed reactive_on_complete payload ${JSON.stringify(raw)} — skipped`)\n return []\n}\n\n// Evaluate one DNF TERM against a resolved field value (issue #180). A missing\n// field (null) or a malformed/unknown predicate folds to FALSE — fail-closed\n// (default-deny): a broken AND term can't pass, a broken OR term can't reveal.\nfunction dnfTermMatches(term, fieldValue) {\n if (!term || typeof term !== \"object\" || typeof term.field !== \"string\") return false\n // An absent owned field reads as \"\" — identical to the server evaluator\n // (ShowConditions.match? treats a missing field as blank). This keeps the\n // Ruby first-paint and the client live-toggle in exact agreement (the shared\n // fixture proves it). A malformed predicate still folds to false.\n const value = fieldValue(term.field) ?? \"\"\n return showPredicateMatches(term, value) === true\n}\n\n// Evaluate a DNF show payload (issue #180): { any: [group, …] } where each\n// GROUP is an array of terms (terms AND within a group, groups OR). Returns\n// true/false for a decidable payload, or null for a malformed one (no groups)\n// so the caller warn-skips and leaves visibility alone. This is the ONE shape\n// the 0.10 wire emits; showPayloadMatches routes the legacy shapes here or to\n// the compatibility arm below.\nfunction anyOfAllsMatches(groups, fieldValue) {\n if (!Array.isArray(groups) || groups.length === 0) return null\n // groups OR; within a group, terms AND (an empty group can't decide → false).\n return groups.some((group) => Array.isArray(group) && group.length > 0 &&\n group.every((term) => dnfTermMatches(term, fieldValue)))\n}\n\n// Every field a DNF payload's groups reference (issue #209) — drives the\n// \"leave the target alone when NO referenced field is owned\" skip, the\n// single-field-target skip generalized. Returns null for a malformed payload\n// (no groups, or no term names a field) so the caller warn-skips instead of\n// toggling on garbage (default-deny, like every other malformed-wire arm).\nfunction dnfGroupFields(groups) {\n if (!Array.isArray(groups) || groups.length === 0) return null\n const fields = new Set()\n for (const group of groups) {\n if (!Array.isArray(group)) continue\n for (const term of group) {\n if (term && typeof term === \"object\" && typeof term.field === \"string\") fields.add(term.field)\n }\n }\n return fields.size > 0 ? [...fields] : null\n}\n\n// Route a parsed data-reactive-show payload to the right evaluator. The 0.10\n// wire is { any: [ [term,…], … ] } (DNF — groups are ARRAYS). For a stale tab\n// still serving pre-0.10 HTML (deploy overlap), fall back to the 0.9.5 compound\n// shape { all: [term,…] } / { any: [term,…] } where the values are flat TERM\n// OBJECTS, not arrays. The nesting distinguishes them: DNF's any[0] is an Array.\n// DELETE the legacy arm in 0.11.\nfunction showPayloadMatches(payload, fieldValue) {\n if (!payload || typeof payload !== \"object\") return null\n const any = payload.any\n if (Array.isArray(any) && (any.length === 0 || Array.isArray(any[0]))) {\n return anyOfAllsMatches(any, fieldValue)\n }\n return legacyCompoundShowMatches(payload, fieldValue)\n}\n\n// LEGACY (0.9.5, deploy-overlap only — DELETE in 0.11): the flat all:/any:\n// compound fold, where terms are objects (not groups). Preserved so a morph of\n// stale pre-0.10 HTML doesn't go dead.\nfunction legacyCompoundShowMatches(payload, fieldValue) {\n const connective = Array.isArray(payload.all) ? \"all\" : Array.isArray(payload.any) ? \"any\" : null\n if (!connective) return null\n const terms = payload[connective]\n if (terms.length === 0) return null\n const results = terms.map((term) => dnfTermMatches(term, fieldValue))\n return connective === \"all\" ? results.every(Boolean) : results.some(Boolean)\n}\n\n// A cross-root show target must be a single ID selector (issue #164) — the\n// SAME shape the #159 mirror enforces (one shared regex), with its own warn so\n// a refused show target is distinguishable in the console. The client half of\n// the two-sided default-deny: reactive_show_targets raises at declare time; a\n// hand-built wire attr must not widen the escape to class/compound selectors.\n// A refused selector warns + skips — its siblings still apply.\nfunction guardShowTargetSelector(selector) {\n if (typeof selector === \"string\" && MIRROR_ID_SELECTOR.test(selector)) return true\n console.warn(`[phlex-reactive] refused cross-root show target ${JSON.stringify(selector)} — skipped`)\n return false\n}\n\n// The first focusable descendant of `el`, in document order — the natural\n// keyboard target inside an opened menu/dialog. Covers the standard focusable\n// set; :not([tabindex=\"-1\"]) drops explicitly-removed nodes. Returns null when\n// nothing inside is focusable (focus_first then no-ops).\nconst FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\nfunction firstFocusable(el) {\n return el.querySelectorAll?.(FOCUSABLE)?.[0] ?? null\n}\n\n// Parse a [[name, args], ...] op list from a raw attr/param. An array passes\n// through; a JSON string is parsed; anything malformed degrades to [] — a bad\n// ops attr must NEVER break the page (client-side default-deny). Shared by the\n// controller's runOps and the reactive:js stream action (issue #97).\nfunction parseOps(raw) {\n if (Array.isArray(raw)) return raw\n if (typeof raw !== \"string\") return []\n try {\n const list = JSON.parse(raw)\n return Array.isArray(list) ? list : []\n } catch {\n return []\n }\n}\n\n// Normalize a reducer's reserved $ops output (issue #226): the compute `ops`\n// builder (its .ops list), a raw [[name, args], ...] array, or null/undefined\n// (no effect this pass). An EMPTY list is the same as null — \"nothing to run\"\n// must not latch the rising edge. Anything else warns + is dropped\n// (default-deny, like every other malformed op source). Reducers are trusted\n// app code, but their ops still run through the frozen CLIENT_OPS whitelist.\nfunction computeOpsList(raw) {\n if (raw == null) return null\n const list = Array.isArray(raw) ? raw : raw.ops\n if (Array.isArray(list)) return list.length > 0 ? list : null\n console.warn(\"[phlex-reactive] $ops must be an ops chain or a [[op, args], ...] list — skipped\")\n return null\n}\n\n// Interpret a [[name, args], ...] op list against the frozen CLIENT_OPS\n// whitelist (issues #95/#96/#97). `resolveTargets(args)` returns the element(s)\n// an op applies to — the controller scopes to its root (excluding nested\n// reactive roots); the reactive:js stream action scopes to its target root (or\n// the document). An unknown name warns and is SKIPPED while the rest of the\n// chain still applies — client-side default-deny, one bad op never takes down\n// its siblings. Object.hasOwn (not a bare read) so inherited Object members\n// (\"constructor\") can't masquerade as ops.\nfunction applyOps(list, resolveTargets) {\n for (const entry of list) {\n if (!Array.isArray(entry)) continue\n const [name, args = {}] = entry\n if (!Object.hasOwn(CLIENT_OPS, name)) {\n console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)\n continue\n }\n for (const el of resolveTargets(args)) CLIENT_OPS[name](el, args)\n }\n}\n\n// Resolve a reactive:js op's targets against its `target` root (issue #97).\n// \"@root\" is the root element itself; a selector resolves WITHIN it; a bare\n// selector with no root (no `target` attr on the stream) resolves document-wide\n// — a broadcast op anchored by a global selector (#bell) rather than a\n// component. Unlike the controller path there is no nested-reactive-root\n// ownership filter: a server-pushed op names its own scope explicitly —\n// including `global: true`, which opts a single op out of the target-root\n// scope to document-wide resolution (issue #159; the same escape the builder\n// documents for the controller path).\nfunction streamOpTargets(args, root) {\n const to = args.to\n if (root) {\n if (to === \"@root\") return [root]\n if (typeof to !== \"string\" || to === \"\") return []\n if (args.global) return [...document.querySelectorAll(to)]\n return [...root.querySelectorAll(to)]\n }\n // No target root: document-scoped. \"@root\" is meaningless here (nothing to\n // anchor to) → no-op; a selector matches document-wide.\n if (typeof to !== \"string\" || to === \"\" || to === \"@root\") return []\n return [...document.querySelectorAll(to)]\n}\n\n// Register this controller eagerly (not lazily) so a click immediately after\n// page load is never missed. The phlex-reactive engine auto-pins it with\n// preload: true for importmap apps; see the README for esbuild/webpack.\nexport default class extends Controller {\n static values = {\n token: String, // signed identity token (component + record gid/state)\n }\n\n #tokenCache // freshest token, threaded synchronously across queued requests\n #debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch\n #throttleTimers = new Map() // trigger element -> Map(action -> suppression timer)\n #actionPathCache // page-stable action path, resolved once per controller\n #timeoutMsCache // page-stable request timeout (ms), resolved once per controller (issue #101)\n #tokenRegexCache // { id, token, self } — #extractToken's two per-id RegExps, rebuilt on id change (issue #118)\n // Loading-state bookkeeping (issue #99). All keyed so overlapping enqueues\n // refcount correctly and never clobber each other:\n #busyPending = 0 // root aria-busy pending counter (remove only at zero)\n #busyActions = new Map() // action -> in-flight count (root's space-separated busy set + busy_on)\n #busyTokenCounts = new WeakMap() // element -> Map(action -> count): its data-reactive-busy token set\n #textDisableSnapshots = new Map() // trigger -> { count, disabled, html } refcounted text/disable snapshot (issue #181)\n // Issue #183: the `input` events recompute dispatches for its OWN output writes,\n // marked so a re-entrant recompute on THIS root skips re-running the reducer\n // (single-pass write set). Per-instance, so another root's events are never\n // swallowed. WeakSet: entries drop when the short-lived Event is GC'd.\n #computeSelfDispatched = new WeakSet()\n // Issue #226: the serialized $ops chain of the LAST reducer pass (null when\n // absent) — the rising-edge latch, keyed on CONTENT: an identical chain\n // never re-fires (a capped extra keystroke can't re-submit), while a chain\n // that CHANGED fires again (a multi-box reducer advancing focus box-by-box\n // emits a different focus target per digit). The seed pass arms this\n // without firing.\n #computeOpsSignature = null\n // Dirty tracking (issue #103): the bound re-scan (turbo:morph-element) and the\n // navigate-away guard handlers, held so disconnect() can remove exactly them.\n #boundScanDirty\n #boundBeforeUnload\n #boundBeforeVisit\n // Show bindings (issue #161): the ONE delegated sync handler shared by the\n // root's input/change/turbo:morph-element listeners, held for teardown.\n #boundSyncShow\n // Completion bindings (issue #226): the delegated gesture handler + the\n // no-gesture morph arm, held for teardown; the per-binding rising-edge\n // latches; and the raw-attr memo that re-parses (and resets the latches)\n // when a morph rewrites the payload.\n #boundSyncOnComplete\n #boundArmOnComplete\n #onCompleteRaw\n #onCompleteParsed\n #onCompleteStates\n // Option filtering (issue #163): the ONE delegated sync handler shared by the\n // root's input/turbo:morph-element listeners, held for teardown.\n #boundSyncFilter\n // Tag-chip input (issue #203): the bound re-projection attached to\n // turbo:morph-element (a morph rewrites the hidden field to server truth, so\n // the chip projection must follow), held for teardown — plus the once-only\n // missing-template warning latch.\n #boundSyncTags\n #tagsWarnedTemplate = false\n // Draft nested-attribute rows (issue #208): the strictly-monotonic index\n // counter (clock-seeded so it never collides with server-rendered 0..n\n // indexes) plus the once-only missing-list/template warning latch.\n #nestedIndex = 0\n #nestedWarned = false\n // JSON-mode nested rows (issue #208): the bound delegated input/change\n // handler and the bound morph re-seed, held so disconnect() removes exactly\n // them.\n #boundSyncNestedJson\n #boundSeedNestedJson\n // Connect-time compute seed (issue #199): the bound re-seed attached to\n // turbo:morph-element so an in-place morph re-runs the compute, held for teardown.\n #boundSeedCompute\n // Lazy initial mount (issue #165): the bound re-probe attached to\n // turbo:morph-element so a Turbo page-refresh morph re-fires the defer fetch.\n #boundProbeLazyDefer\n\n // Mark that a reactive controller actually connected, so the registration\n // guard above knows the controller was registered (issue #26 part 2).\n connect() {\n reactiveConnected = true\n\n // Root-id guard (issue #48). The token round trip assumes the reactive root\n // element's id == component.id: the server targets component.id and the client\n // self-matches its NEXT token by this.element.id (#extractToken, issue #46).\n // If `id:` landed on a CHILD instead of the `**reactive_attrs` root, this id is\n // \"\" — #extractToken falls back to the FIRST token in the response (a child's),\n // so the next action POSTs a foreign token → endpoint default-deny → silent 403.\n // Warn NOW (on connect) so the failure surfaces on page load, not on click 2.\n if (this.element.id === \"\") {\n console.warn(\n \"[phlex-reactive] a reactive root has no id; its next-action token can't self-match \" +\n \"and may fall back to the first token in the response → a silent HTTP 403 on the NEXT action. \" +\n \"Put id: on the SAME element as reactive_attrs — use div(**reactive_root) (emits id + token together), \" +\n \"or div(id:, **reactive_attrs). The id: must NOT be on a child. See the README.\"\n )\n }\n\n // Lazy initial mount (issue #165): a reactive_lazy shell carries its defer\n // token as a ROOT attribute — enter the SAME module-level fetch path a\n // reply directive uses (supersession, pending markers, error handling\n // included). Probe on connect (a plain replace / cache restoration\n // re-connects) AND on turbo:morph-element: a Turbo page-refresh MORPH\n // re-shows the shell while keeping the element CONNECTED and firing no\n // Stimulus lifecycle, so a connect-only probe would leave the morphed-in\n // shell shimmering forever. The supersession registry makes a duplicate\n // probe a no-op (same target id), so re-probing is safe. The attribute\n // stays on the shell precisely so a re-appearance re-fires.\n // Only wire the morph re-probe for a root that IS a lazy shell (carries the\n // token) — a component that never uses reactive_lazy pays nothing (no\n // listener), matching the dirty-tracking / show-sync gating precedent.\n if (this.element.getAttribute?.(\"data-reactive-defer-token\")) {\n this.#probeLazyDefer()\n this.#boundProbeLazyDefer = () => this.#probeLazyDefer()\n this.element.addEventListener?.(\"turbo:morph-element\", this.#boundProbeLazyDefer)\n }\n\n // Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a\n // reactive_field(dirty:)), so a component that never uses it pays nothing (no\n // baseline scan, no morph listener on every broadcast). A plain (outerHTML)\n // replace re-connects the controller, so seed the baseline scan here — the root\n // reflects current-vs-default WITHOUT waiting for the first input. An in-place\n // morph / broadcast morph keeps the element CONNECTED and fires no Stimulus\n // lifecycle, so ALSO listen for turbo:morph-element on this.element to re-scan\n // after the morph writes fresh default* attributes (reactive:applied is NOT a\n // valid hook — it fires when streams are handed to Turbo, BEFORE the DOM\n // mutation). Both listeners are torn down in disconnect().\n if (this.#dirtyTrackingEnabled()) {\n this.#boundScanDirty = () => this.#scanDirty()\n this.element.addEventListener?.(\"turbo:morph-element\", this.#boundScanDirty)\n this.#scanDirty()\n\n // warn_unsaved: arm a navigate-away guard gated on a LIVE dirty-count read\n // (never a cached snapshot — the count is re-derived from the DOM each time).\n // beforeunload covers a real browser unload; turbo:before-visit covers a\n // Turbo in-app navigation (it does NOT fire on restoration visits — the\n // documented gap). Registered on window only when the marker is present.\n if (this.element.getAttribute?.(\"data-reactive-warn-unsaved\") === \"true\") {\n this.#armUnsavedGuard()\n }\n }\n\n // Show bindings (issue #161) — ONLY when this root owns one, so a component\n // without any pays a single probe (the dirty-tracking gate precedent). ONE\n // delegated listener pair on the root (input + change bubble from every\n // owned field — no per-field wiring, and a reactive_compute output write\n // dispatches a real input event, so computed values drive visibility too).\n // The connect sync seeds the initial state — a plain replace re-connects —\n // and turbo:morph-element re-syncs after an in-place morph (which keeps the\n // element connected, fires no Stimulus lifecycle, and may preserve a\n // user-edited field value the server's hidden attrs don't reflect).\n if (this.#showSyncEnabled()) {\n this.#boundSyncShow = () => this.#syncShow()\n this.element.addEventListener?.(\"input\", this.#boundSyncShow)\n this.element.addEventListener?.(\"change\", this.#boundSyncShow)\n this.element.addEventListener?.(\"turbo:morph-element\", this.#boundSyncShow)\n this.#syncShow()\n }\n\n // Completion bindings (issue #226) — ONLY when the root declares\n // data-reactive-on-complete (the show/filter gate precedent). ONE\n // delegated input+change listener evaluates every binding's DNF over the\n // owned fields and runs its ops on the RISING EDGE — the event-driven\n // flip to true. The connect pass ARMS without firing (a fresh render with\n // already-satisfied conditions must never self-fire — the $ops seed\n // precedent), and turbo:morph-element re-arms the same way after an\n // in-place morph. Listeners added HERE run after the Stimulus-wired\n // recompute delegation for the same event, so the evaluation reads\n // compute-NORMALIZED values (and a compute output write dispatches a real\n // input event that re-evaluates anyway).\n if (this.#onCompleteEnabled()) {\n this.#boundSyncOnComplete = (event) => this.#syncOnComplete(event)\n this.#boundArmOnComplete = () => this.#syncOnComplete(null)\n this.element.addEventListener?.(\"input\", this.#boundSyncOnComplete)\n this.element.addEventListener?.(\"change\", this.#boundSyncOnComplete)\n this.element.addEventListener?.(\"turbo:morph-element\", this.#boundArmOnComplete)\n this.#syncOnComplete(null)\n }\n\n // Option filtering (issue #163) — ONLY when the root declares the binding\n // (reactive_filter emits both attrs together), so a component without one\n // pays two attribute reads. ONE delegated input listener on the root — the\n // handler re-filters only for events from the NAMED input, so keystrokes in\n // unrelated fields on a wide form never pay a filter pass. The connect sync\n // seeds from the input's current value (a plain replace re-connects; back\n // navigation may restore typed text), and turbo:morph-element re-applies\n // after an in-place morph (which keeps the element connected, fires no\n // Stimulus lifecycle, and may preserve the user's typed query while the\n // server re-rendered every option visible).\n if (this.#filterEnabled()) {\n this.#boundSyncFilter = (event) => {\n if (event?.type === \"input\" && !this.#filterInputEvent(event)) return\n this.#syncFilter()\n }\n this.element.addEventListener?.(\"input\", this.#boundSyncFilter)\n this.element.addEventListener?.(\"turbo:morph-element\", this.#boundSyncFilter)\n this.#syncFilter()\n }\n\n // Tag-chip input (issue #203) — ONLY when the root names the hidden value\n // field (reactive_tags), so a component without one pays one attribute\n // read. The chip list is a CLIENT PROJECTION of the hidden field's\n // comma-joined value: connect seeds it (a plain replace re-connects with\n // the server-rendered value), and turbo:morph-element re-projects after an\n // in-place morph (the morph wrote server truth into the hidden field while\n // the chips DOM kept the pre-morph projection). Registered AFTER the\n // filter's listeners so a morph re-filters first and the tags pass then\n // re-marks selected options on the fresh visibility state.\n if (this.#tagsEnabled()) {\n this.#boundSyncTags = () => this.#syncTags()\n this.element.addEventListener?.(\"turbo:morph-element\", this.#boundSyncTags)\n this.#syncTags()\n }\n\n // JSON-mode nested rows (issue #208) — ONLY when the root owns a list with\n // `as: :json`, so a form without one pays a single probe (the show/filter/\n // tags gate precedent). ONE delegated input + change listener re-serializes\n // the rows into the hidden field on every owned edit (nestedAdd/Remove call\n // the sync directly; this covers typing into a row's fields). The connect\n // seed writes the initial array (a plain replace re-connects), and\n // turbo:morph-element re-seeds after an in-place morph (which keeps the\n // element connected, fires no Stimulus lifecycle, and may have rewritten\n // the rows to server truth while the hidden field kept its pre-morph value).\n if (this.#nestedJsonEnabled()) {\n this.#boundSyncNestedJson = (event) => this.syncNestedJson(event)\n this.#boundSeedNestedJson = () => this.#syncAllNestedJson()\n this.element.addEventListener?.(\"input\", this.#boundSyncNestedJson)\n this.element.addEventListener?.(\"change\", this.#boundSyncNestedJson)\n this.element.addEventListener?.(\"turbo:morph-element\", this.#boundSeedNestedJson)\n this.#syncAllNestedJson()\n }\n\n // Connect-time compute seed (issue #199) — ONLY when the root carries a\n // reactive_compute binding that opts in (data-reactive-compute-seed). A\n // freshly-rendered compute root (a first paint, or a server validation-error\n // re-render that replaced the body) computed NOTHING until the first user\n // `input`; apps worked around it by dispatching a synthetic seed `input` on\n // connect, but the compute root is a distinct Stimulus controller that may\n // connect a frame later, so the seed raced its own wiring — the reported\n // symptom being a PARTIAL apply (an early output paints; a later output + the\n // mirror stay blank). Running ONE recompute() HERE — after Stimulus has fully\n // connected the controller and wired the input->recompute delegation — runs\n // the whole single-pass write set (issue #183) synchronously, so every\n // declared output, text sink, and cross-root mirror paints from one reducer\n // result. It is client-only (recompute never enqueues a round trip) and\n // idempotent (change-guarded writes make a re-seed a no-op — an app still\n // dispatching a synthetic input is harmless). A plain replace re-connects and\n // re-seeds; an in-place morph keeps the element CONNECTED and fires no\n // Stimulus lifecycle, so ALSO re-seed on turbo:morph-element (the show/filter/\n // dirty precedent). No event is passed, so meta.changed is null — the correct\n // \"no field edited yet\" seed semantics; a convergent reducer's default branch\n // computes the full settled set (see compute.js CONVERGENCE REQUIREMENT).\n if (this.#computeSeedEnabled()) {\n this.#boundSeedCompute = () => this.recompute()\n this.element.addEventListener?.(\"turbo:morph-element\", this.#boundSeedCompute)\n this.recompute()\n }\n }\n\n // Whether this root opts into dirty tracking (issue #103): track_dirty: puts the\n // trackDirty descriptor on the ROOT's data-action; a per-field reactive_field(\n // dirty:) puts it on a descendant field. Either turns tracking on. A quick\n // attribute read + one scoped query, evaluated once per connect (a cold path).\n #dirtyTrackingEnabled() {\n if ((this.element.getAttribute?.(\"data-action\") ?? \"\").includes(\"reactive#trackDirty\")) return true\n const nodes = this.element.querySelectorAll?.('[data-action*=\"reactive#trackDirty\"]') ?? []\n for (const el of nodes) if (this.#ownsField(el)) return true\n return false\n }\n\n // Tear down any pending debounce timers when the controller leaves the DOM\n // (Turbo morph/navigation removes the element). Otherwise a timer that hasn't\n // fired yet would later call #enqueue on a disconnected controller — a round\n // trip against a detached element / stale token (issue #17 follow-up).\n // Throttle suppression timers (issue #80) are torn down the same way — a\n // leading-edge timer holds no pending POST, but leaving it running would leak\n // it past the element's life.\n disconnect() {\n this.#clearAllDebounces()\n this.#clearAllThrottles()\n this.#teardownDirtyTracking()\n this.#teardownShowSync()\n this.#teardownOnCompleteSync()\n this.#teardownFilterSync()\n this.#teardownTagsSync()\n this.#teardownNestedJsonSync()\n this.#teardownComputeSeed()\n if (this.#boundProbeLazyDefer) {\n this.element.removeEventListener?.(\"turbo:morph-element\", this.#boundProbeLazyDefer)\n }\n }\n\n // Lazy initial mount probe (issue #165): fetch the real content when THIS\n // root is a reactive_lazy shell that still carries its defer token AND the\n // pending marker. Gating on the pending marker is what makes a re-probe (a\n // Turbo morph re-showing the shell) fire while a re-probe of an already\n // RESOLVED root (real content, no token, no marker) is a no-op. The\n // module-level supersession registry dedupes a duplicate in-flight fetch for\n // the same id, so calling this on both connect and every morph is safe.\n #probeLazyDefer() {\n const el = this.element\n if (!el?.id) return\n const token = el.getAttribute?.(\"data-reactive-defer-token\")\n if (!token) return\n if (el.getAttribute?.(\"data-reactive-defer-pending\") !== \"true\") return\n startFetchDefer(el.id, token)\n }\n\n // Serialize requests per component. Each round trip rewrites the signed\n // token in the DOM (state lives in the token, not the client). If events\n // fire faster than round trips complete, concurrent requests would all read\n // the SAME stale token and clobber each other (last-write-wins). Chaining on\n // a per-controller promise makes each dispatch wait for the previous one, so\n // it always uses the freshest token.\n dispatch(event) {\n // `window` (renamed: never shadow the global) and `outside` are the event-\n // modifier params (issue #80). The client decides preventDefault behavior\n // from event.params — set by the Ruby on() — never by sniffing the\n // Stimulus descriptor.\n const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =\n event.params\n if (!action) return\n\n // The pending-state hint (issue #181): data-reactive-busy-param. During a\n // deploy overlap a page rendered by the PREVIOUS gem still emits the old\n // data-reactive-loading-param — read it as a fallback so an in-flight page\n // keeps its pending affordance until the next full render. The old `class:`\n // key is remapped to add_class: so it flows through the one hint applier.\n const busy = event.params.busy ?? this.#legacyLoadingHint(event.params.loading)\n\n // Outside guard FIRST (issue #80): an outside: trigger only fires for\n // events whose target is OUTSIDE this component's ROOT (containment against\n // this.element — .contains includes the root itself). An event inside the\n // root must be a COMPLETE no-op — before preventDefault (the page's native\n // click behavior is untouched) and before the reactive:before-dispatch\n // lifecycle event (nothing to announce, nothing to veto).\n if (outside && this.element.contains(event.target)) return\n\n // The trigger is event.currentTarget — the element on(...) was spread onto —\n // NOT event.target (issue #99). A `` click\n // has target === the span, which carries no params and is the wrong element\n // to disable / swap text on. currentTarget is the bound element; fall back to\n // target for a directly-invoked/synthetic event. Captured now because\n // #proceed runs in a later microtask (after the confirm resolver), by which\n // point currentTarget is reset to null.\n const target = event.currentTarget ?? event.target\n\n // Stop native behavior (button submit / FORM NAVIGATION) HERE, synchronously\n // within the event dispatch — BEFORE the (possibly async) confirm gate below.\n // preventDefault() only works while the event is still being handled; once we\n // await the confirm resolver it's too late, and a `submit` trigger would\n // natively POST the form and navigate before the reactive round trip runs\n // (issue #11). For a `click` trigger there's no default to miss. This holds\n // for debounced triggers too — the round trip is deferred, but the native\n // default must still be prevented now. (Moved ahead of the confirm branch in\n // issue #55: an async resolver means we can't preventDefault after awaiting.)\n //\n // ONLY for element-bound triggers: a window-bound trigger (window:/outside:,\n // issue #80) hears EVERY matching event on the page — preventDefault-ing\n // those would kill every link click while a dropdown is mounted. The page's\n // native behavior proceeds alongside the reactive round trip.\n //\n // The `checked: :keep` optimistic hint (issue #98) OPTS OUT: for a click-bound\n // checkbox/radio the unconditional preventDefault is exactly what stops the\n // native flip from happening before the morph — so a bare checkbox click\n // (which has no form-navigation default to lose) skips it and flips now, and\n // the failure revert snaps it back. A `change`-bound trigger is unaffected —\n // `change` isn't cancelable, so preventDefault was already a no-op there.\n if (!windowBound && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()\n\n // Resolve the EFFECTIVE confirm message (issue #179): a plain string confirm:\n // is that string (static, #52); a Hash confirm: (confirmWhen) evaluates its\n // condition/predicate over the collected fields and returns the message ONLY\n // when it fires, else null → no dialog. No confirm at all → also null.\n const message = this.#effectiveConfirmMessage(confirm, confirmWhen)\n\n // No message → proceed straight away (unchanged fast path).\n if (!message) return this.#proceed(target, action, params, debounce, throttle, optimistic, busy)\n\n // Confirmation gate (issue #52, made overridable + async in #55). A reactive\n // trigger can't use Hotwire's data-turbo-confirm — this controller preempts\n // the event — so a `confirm:` message routes through confirmResolver (default\n // window.confirm; an app can override it to reuse Turbo.config.forms.confirm).\n // The resolver may be sync or async; call it INSIDE the chain (via the leading\n // .then) so even a SYNCHRONOUS override throw rejects this promise instead of\n // escaping dispatch — a throwing dialog is treated as a cancel, like the user\n // dismissing it. The .catch is scoped to the resolver step (→ false = cancel),\n // so a dismissed/erroring dialog never surfaces as an unhandled rejection AND a\n // genuine bug inside #proceed is NOT silently swallowed. Enqueue ONLY on a\n // truthy resolution — nothing is enqueued, no timer scheduled, otherwise.\n // The resolver's optional 2nd arg (issue #222) carries the trigger element,\n // so an override has the same ctx shape here as on nestedRemove ({ el, … }).\n Promise.resolve()\n .then(() => confirmResolver(message, { el: target }))\n .catch(() => false)\n .then((ok) => {\n if (ok) this.#proceed(target, action, params, debounce, throttle, optimistic, busy)\n })\n }\n\n // CLIENT-ONLY trigger entry point (issue #95) — the zero-round-trip sibling\n // of dispatch(). Wired by on_client: applies the declared op chain\n // (data-reactive-ops-param, built by Phlex::Reactive::JS) locally. NO token,\n // NO params, NO fetch, ever. Ops are ephemeral UI: any server re-render of\n // the component resets whatever they toggled (by design — a signed action\n // owns state that must survive re-renders).\n runOps(event) {\n const { ops, confirm, confirmWhen, outside, window: windowBound } = event.params\n // The trigger element on_client was spread onto (issue #222 ctx: { el }),\n // captured now — currentTarget resets before the confirm resolver's microtask.\n const trigger = event.currentTarget ?? event.target\n\n // Outside guard FIRST — identical semantics to dispatch() (issue #80): an\n // outside: trigger is a COMPLETE no-op for events inside this root, before\n // preventDefault and before any op runs.\n if (outside && this.element.contains(event.target)) return\n\n // Element-bound triggers preventDefault (a bare button inside a