Uh oh!
There was an error while loading. Please reload this page.
Modal-to-native-dialog: PkDialog + BulkSelect + list-UI toolkit - #568
Merged
Conversation
Per dev_docs/quality_sweep.md Phase 1 playbook: every merged PR's folder gets a FOLLOW_UP.md documenting how each review finding was resolved (or skipped with rationale). PR BeamLabEU#565 review findings: - NITPICK BeamLabEU#2 (trailing-empty asymmetry) — fixed post-merge in 79f6dc5 - MEDIUM (extraction divergence comment) — fixed post-merge in 79f6dc5 - NITPICK BeamLabEU#3 (show_info gated on show_header) — skipped, doc-only resolution No new code changes — this is the after-action artifact only.
Native <dialog> renders in the browser top layer with a real
::backdrop pseudo-element, gets native Esc handling and focus trap,
and matches daisyUI 5's recommended pattern. The old div-based modal
left a visible 15px strip on the right of the viewport: daisyUI 5
ships a `:where(:root:has(.modal[open])) { scrollbar-gutter: stable }`
rule that reserves a scrollbar gutter on <html> when any modal is
open, which reduces the layout viewport that `position: fixed; inset: 0`
resolves against. The strip showed through where the backdrop didn't
reach.
Public API of <.modal> is unchanged: same attrs (show, on_close, id,
max_width, max_height, class, closeable, backdrop_class), same slots
(:title, :inner_block, :actions). Conditional rendering on @show is
preserved so consumers whose inner_block depends on assigns that
only exist while the modal is shown (e.g. roles.html.heex's @Form)
keep working.
When the consumer doesn't pass an explicit id (e.g. confirm_modal
callers in catalogue), an id is derived from the on_close event name
(pk-modal-<close_event>). phx-hook requires a unique element id and
this keeps it stable across renders of the same modal while staying
unique among different modals on the same page.
The existing PkDialog hook in phoenix_kit.js is extended to:
- Override html's scrollbar-gutter to auto while any modal is open
(refcounted across concurrent modals, restored on last close) so
the dialog can cover the full visual viewport.
- Handle backdrop click via `event.target === el` on the dialog.
- Use the 'close' event as the single cleanup point. A _closeFromLV
flag in destroyed() distinguishes LV-driven teardown (don't echo)
from user-driven closes (Esc/backdrop — echo via pushClose so server
state syncs).
Drops the explicit `phx-window-keydown` Esc handler (native cancel
event covers it) and the custom backdrop <div> (native ::backdrop).HTML has no attribute form of the checkbox `indeterminate` state — the property has to be assigned via JS. The hook reads `data-indeterminate` on mount and after each LV patch and reflects it on the element. Used by table headers that show "all / some / none selected" via a single checkbox.
Three function components for bulk-selectable admin tables: * <.bulk_select_header_cell> — drop-in <th> with the select-all checkbox. Cycles unchecked → indeterminate → checked via PkCheckboxIndeterminate. Consumer wires `on_toggle`; the LV handler decides "all" vs "none" from current count. * <.bulk_select_cell> — drop-in <td> with the per-row checkbox. Value is forwarded as phx-value-uuid so the consumer's handler can identify which row was toggled. * <.bulk_actions_toolbar> — floating toolbar above the table. Shows selection count + Reorder/Delete/Clear buttons. Reorder and Delete are opt-in via flags; Clear is automatic. All three are opt-in — the consumer LV decides whether to render them. Per-row checkboxes need consumer code anyway (row content varies per module). The header cell + toolbar are reusable shells. Auto-imported via phoenix_kit_web.ex so consumers using `use PhoenixKitWeb, :live_view` get them without per-module imports.
Two issues from the post-merge codex pass on modal-to-native-dialog: 1. **PkDialog refcount drift** (was MEDIUM bug). User-driven closes (Esc, backdrop click) used to decrement the scrollbar-gutter refcount only via destroyed(), which fires AFTER the LV processes the on_close event. If LV ignored, errored on, or didn't reach that event, window._PkDialogOpenCount stayed elevated and the `scrollbar-gutter: auto !important` override stuck on <html>. Track `_opened` per-hook so the close listener decrements exactly once regardless of who initiated the close. destroyed() now does a defensive decrement (gated by _opened) for the case where the element is already detached when destroyed runs and close() may not fire 'close'. Whichever path runs first wins; the other no-ops. 2. **on_bulk_delete required even when allow_delete=false**. Forced callers to wire a dead event name when they didn't want the delete button. Made nullable with a default of nil and documented that it's only required when allow_delete is true.
Every checkbox toggle used to be a `phx-click` round-trip to the LV
— at any meaningful network latency that's a visible lag on a
high-frequency interaction. Server learning the selection on every
click is also wasted bytes; it only needs to know at action time.
New model: the DOM owns the selection. A new `BulkSelectScope` JS
hook reads checkbox state, maintains the in-memory selection set,
and updates surrounding UI (toolbar count text, header
indeterminate state, button labels, show/hide elements) live as
the user toggles. When the user clicks an action button
(`data-bulk-action="<event>"`), the hook pushes the event to LV
with `{ uuids: [...] }` — the server gets the selection exactly
when it needs it.
Component changes:
* NEW <.bulk_select_scope> — wrapper with phx-hook="BulkSelectScope"
and data-bulk-total. Required as a sibling of the table + toolbar.
* <.bulk_select_header_cell> — pure markup (data-bulk-role="select-all").
Drops `selected_count`, `total_count`, `on_toggle` attrs (the hook
drives state).
* <.bulk_select_cell> — pure markup (data-bulk-role="row" +
data-uuid). Drops `checked`, `on_toggle`.
* <.bulk_actions_toolbar> — gettext_noop'd label templates carry
`%{count}` for the hook to substitute. data-bulk-show / data-bulk-
label-empty / data-bulk-label-selected drive live UI flips. Drops
`selected_count`, `on_toggle_select_all`, `on_clear_selection`
server-event attrs.
JS hook contract documented in the bulk_select.ex moduledoc.The static hint icon to the right of the dropdown (hero-bars-3 in a pointer-events-none span) was meant to suggest "you can drag rows to reorder" — but the row drag handles themselves already serve that affordance, and the icon next to the dropdown read as a sort-direction indicator (which is meaningless in manual mode). In manual mode the join now collapses to just the dropdown: the "Manual" option in the dropdown is the entire control. Other sort modes still get their asc/desc toggle next to the select as before.
The toolbar's left-side text used to flip between "No selection"
and "N selected" as the user toggled checkboxes. The "No selection"
state was visual noise — when nothing's selected, the action
buttons on the right communicate the available affordances on
their own and the empty caption was redundant.
Now: when count is 0, the left side of the toolbar is empty; the
buttons pin to the right via the existing ml-auto. When count > 0,
"%{count} selected" appears as before via the bulk-text-template
hook substitution.The toolbar gains a `:leading` slot so consumers can tuck things
into the left side (notably a sort selector) and have everything
read as a single control row. The empty-state "%{count} selected"
text span is gone — when nothing's selected, the toolbar has no
left-side text and the action buttons pin to the right.
`reorder_gate` controls when the Reorder button appears:
* `:always` (default) — visible at any count. Label flips between
"Reorder all" at 0 and "Reorder N selected" at >0. Use this in
contexts where the rendered order is the canonical manual order
(e.g. sort_by=:position).
* `:multi` — hidden unless count > 1. Use this when the surrounding
context has no meaningful "reorder all" interpretation: a list
sorted by name/date is a *view* of the manual positions, and a
single-row reorder is a no-op, so the button only makes sense
with a multi-row selection.
JS hook changes:
* `data-bulk-show="has-multiple"` — new visibility mode, count > 1.
* Label-flip selector relaxed to `[data-bulk-label-selected]` (was
requiring both empty + selected). `data-bulk-label-empty` is now
optional — when absent, count=0 leaves the server-rendered text
in place, which works well for buttons gated to hide at count=0
anyway.In :always reorder_gate mode (manual sort), the button previously showed "Reorder 1 selected" with a single row checked — clicking would open the modal but the permute-in-place result is just that row in its existing slot. Confusing affordance. Now: even in :always mode the button hides at exactly count=1. Cycle is visible (Reorder all) → hidden → visible (Reorder 2 selected) as the user toggles checkboxes one by one. JS hook gets a new `data-bulk-show="not-single"` mode (visible iff count != 1) which is applied to the Reorder button in :always mode. :multi mode still uses "has-multiple" (visible iff count > 1).
Hiding the Reorder button at count=1 was disruptive — the button flicker as the user clicked one row, then a second to bring it back, was worse UX than the original "Reorder 1 selected" label this branch was trying to fix. New design (in :always reorder_gate): * count 0 → "Reorder all" (no selection) * count 1 → "Reorder all" (treat single selection as ambient for reorder purposes — the consumer LV collapses the 1-uuid payload to :all when applying) * count > 1 → "Reorder N selected" JS hook label-flip threshold bumped from `> 0` to `> 1`. Affects ALL data-bulk-label-selected consumers — but the semantics are clearer: "N selected" only appears when a multi-selection scope is genuinely meaningful, never with N=1. Drops the `not-single` data-bulk-show value on the Reorder button in :always mode (no longer needed since the button stays visible at every count). The hook still implements `"not-single"` for any future consumer that wants it.
The hide-until-hover drag handle pattern was hand-rolled in 4+ call sites across the workspace (catalogue category rows, catalogue card items, entities card view, data_navigator card view), each with its own inline opacity-0 + group-hover:opacity-100 incantation. Same deal for the dim-and-brighten table-view variant across catalogue, entities, data_navigator, document_creator, ai/endpoints. The patterns differ on which 'feels right' but they all reach for the same primitives. Extract a single canonical cell to core: hide-until-hover semantics (opacity-0 default, fades in on row hover) plus the pk-drag-handle class SortableGrid needs and the gettext'd title. Sibling <.drag_handle_header_cell> renders the matching empty <th> so the column widths line up without consumers having to repeat the hardcoded `w-8` literal. `<.table_default_row>` now adds `group` to its class list automatically — required for the cell's `group-hover:*` selector to fire. The marker class is benign for rows that don't have any group-hover-styled children, so adding it unconditionally is safer than asking every consumer to remember to wire it.
Round out the table-DnD + bulk-select toolkit so consumers don't
have to hand-write the SortableGrid hook wiring or copy the reorder
modal between modules:
* <.reorder_modal> — moved as-is from phoenix_kit_projects' web/
components/ into core. Same API (show / on_close / on_apply /
selected_count / total_count / strategies / nouns), just now
available to every module via `use PhoenixKitWeb, :live_view`.
* <.sortable_tbody> — replaces the raw
<tbody phx-hook="SortableGrid" data-sortable* ...> block that
every DnD-enabled list otherwise has to spell out by hand.
Takes `id`, `enabled` (boolean — when false renders a plain
<tbody>), and the `event` name to push on drop. Encapsulates
the data-sortable-items=".sortable-item" /
data-sortable-handle=".pk-drag-handle" conventions used by
<.sortable_row> and <.drag_handle_cell>.
* <.sortable_row> — replaces <.table_default_row class="sortable-
item" data-id={uuid}> with a typed wrapper. The `item_id` attr
becomes data-id; "sortable-item" is always added.
Both new components are imported via phoenix_kit_web.ex so they're
available alongside <.table_default>, <.drag_handle_cell>, etc.
Nothing on the call site changes behaviour — same DOM, same hook,
same selector contracts. Just less boilerplate per consumer.Click-driven incremental loader as a counterpart to the existing
URL-param page-numbered components. Renders a centered "Showing N
of M %{noun}" line plus a "Load more" button hidden when
`loaded >= total`. Auto-suppresses when `total == 0` so consumers
don't have to gate the call site.
Fits the use cases where page-numbered pagination doesn't:
* Embeddable LVs that can't reach for URL params (no handle_params)
* Lists with DnD reorder (rows append, don't replace)
* Lists with client-side bulk-select (selection survives loads
because rows stay in the DOM)
Catalogue's hand-rolled search-results "load more" can adopt the
same component in a future pass.
Module moduledoc updated to document the two flavours (page-numbered
vs. load-more) and when to reach for each.Quorum (Codex + Gemini) both flagged the same UX issue: rapid double-clicking the button fires the load_more event twice before the first reload finishes, so loaded_count ends at +100 instead of +50. State-wise it's correct (the user gets more rows), but the intermediate jitter is avoidable. phx-disable-with grays the button + swaps its label to "Loading…" the moment the click pushes, and Phoenix automatically re-enables it after the LV ack. Low-cost belt-and-braces for the same correct end state.
Reported: on first page load the Clear button (and Delete, and the Reorder button in :multi mode) flash visible for a fraction of a second before the BulkSelectScope hook's first _sync() hides them. The gap is "server-rendered HTML has no inline display:none" → first paint shows the buttons → LV connects → hook mounts → _sync() hides them. On a fast localhost it's a flicker; on a slow page load it can be a couple of seconds of visible flash. Bake `style="display: none;"` into the server-rendered HTML for the three elements that hide at count = 0: - Clear button (data-bulk-show="has-selection") - Delete button (data-bulk-show="has-selection", when rendered) - Reorder button in :multi mode (data-bulk-show="has-multiple") The hook still overrides the inline style on _sync() when the threshold crosses, so the toggle behavior stays unchanged — only the initial paint state is no longer wrong.
Reported: clicking the Reorder button delays the modal opening by the LV round-trip + a few queued queries (~1.2s on a fresh load). The modal content is fully static after first render, so the round-trip is pure server-state bookkeeping; the modal should open the moment the user clicks. Three pieces: * **Core `<.modal>` gains a `keep_in_dom` opt.** When true, the `<dialog>` element is always rendered (with `data-show` driving visibility) instead of being conditionally appended on @show=true. The PkDialog hook reworks its `mounted()` to call a new `_sync()` that reads `data-show` — same behaviour for the legacy conditional path (data-show is "true" the moment the dialog enters DOM, which immediately opens it) and the new always-in-DOM path (initial data-show="false" leaves it closed, flips to "true" on LV state change). * **`<.reorder_modal>` opts in to `keep_in_dom={true}`.** Its inner block is static — strategies and nouns are attrs, the scope label is computed from `selected_count` / `total_count` which the LV always has values for. Safe to render at all times. * **`<.bulk_actions_toolbar>` gains `reorder_dialog_id`.** When set, the Reorder button carries `data-bulk-opens-dialog="..."`. The `BulkSelectScope` hook's `_onActionClick` looks up the dialog by id and calls `showModal()` locally before pushing the event, so the modal appears instantly. The push still runs in parallel to update `@show_reorder_modal` and capture `captured_uuids` on the server — the next LV ack patches `data-show="true"` which the `_sync()` no-ops since the dialog is already open. Round-trip race: between client-open and server ack, a user clicking Apply would beat the `captured_uuids` capture. Window is ~30ms; the user has to read strategies + click Apply within that window, which is humanly unreachable. Acceptable. Closes work the same way: Esc/backdrop fire 'close', hook pushes the close event, server flips @show=false, `data-show="false"` patches in, `_sync()` is a no-op (dialog already closed by browser).
…pter whitelist + FOLLOW_UP.md The NITPICK from CLAUDE_REVIEW.md was the only still-live finding — `creator_uuid` was castable from adapter event payloads even though the adapter resolves it server-side from actor opts (a forged payload could claim authorship). Mirroring the existing `:file_uuid` exclusion. Other findings (V121 constraint guard, `:uuid` castable on update, re-UPDATE of unchanged annotations) all fixed pre-existing in commit 5198eb3. Hard-delete of linked comments is intentional per moduledoc. See FOLLOW_UP.md for the full triage.
…d stub All three review findings (dead @see ref, doctest evaluation hazard, undefined Setting.t() in @SPEC) fixed pre-existing in post-merge commits 42722ff and 0b6ec6f. The NITPICK about free-floating comments inside cond clauses is N/A — resolved by the dedup PR BeamLabEU#554 which moved sitemap-source policy into LocalePath.emit_prefix?/2.
…test setter symmetry Three still-live findings from CLAUDE_REVIEW.md: * IMPROVEMENT-MEDIUM — redirect_invalid_locale/2 evaluated prefixless_primary?() twice (once for segment, once for suffix). Single read into a destructured tuple; also closes a small torn-read window if a concurrent setting flip lands between the two calls. * IMPROVEMENT-MEDIUM — LocalePath moduledoc said 'three rules' but listed four. Edited to 'four rules:'. * NITPICK — auth_locale_test.exs cleanup mixed Settings.update_boolean_setting with Languages.set_default_language_no_prefix elsewhere in the same file. Cleanup now uses the typed setter for symmetry; any future cache/invalidation logic the typed setter wires in will run on cleanup too. Two NITPICKs left as-is (intentional residue documented in the FOLLOW_UP): the prefixless_primary_safe?() / mix_task_context?() sentinel duplication (acknowledged in the original review), and the LocalePath.emit_prefix?(nil, _) clause whose rationale lives in moduledoc rather than inline.
…d stub BUG-HIGH (dialyzer failure on ask_with_prompt/4) fixed pre-existing via .dialyzer_ignore.exs entry. Two NITPICKs (doc example re-enqueue phrasing, regex /i flag) fixed in post-merge commits. Two items N/A (completed-key doc trimmed; empty-fields-guard was scope of PR BeamLabEU#558).
…d stub Core fix (validate_non_empty/1 guard) was the scope of this PR and shipped. One NITPICK on the error category atom (:parse_error vs a dedicated :empty_fields) documented as Skipped — the review accepted the sentinel reuse to avoid forcing callers to handle a second arm with no behavioural difference.
…s + FOLLOW_UP.md The only still-live finding was the cosmetic class divergence on 2 of 8 auth forms: registration and magic_link_registration carried 'fieldset w-full min-w-0', the other 6 just 'fieldset min-w-0'. Behaviorally identical (parent's align-items: stretch provides the full width), but the inconsistent class string is a future-grep gotcha. Dropped 'w-full' from the two so all 8 forms now share the same shape.
C12 Phase 2 re-validation triage (agent BeamLabEU#1, security/async-UX) flagged the only async-UX gap in this session's new code: the reorder modal's Apply button submits a phx-event form to the LV that does a multi-write transaction (write_permutation/2 or Reorder.reorder/4) — racing double-submits would either deadlock or double-stamp. phx-disable-with guards both: Phoenix grays the button + swaps label to 'Applying…' on click and re-enables on the LV ack. Strategy radios with required attr block empty submits separately; the consumer LV's whitelist guard catches forged payloads. This fix is for the legitimate-but-impatient user.
PkDialog hook had two latent bugs surfaced by the bulk-toolbar's instant-open flow: - When BulkSelectScope called dialog.showModal() before the LV round-trip, Phoenix's DOM patcher then stripped the browser-added `open` attribute on the next re-render. Dialog stayed in the top-layer (still capturing all clicks) while CSS `dialog:not([open])` made it `display: none` — visually closed but blocking the rest of the page. _sync() now uses the `:modal` pseudo-class as the truth source instead of el.open; restores the stripped attribute before close() so the top-layer is actually released. - _sync()-driven close echoed close_reorder_modal back to the LV (LV already knows). Now sets _closeFromLV around the close() and resets it inside _onClose so user-initiated closes (Esc, backdrop) still echo as intended. - :modal pseudo-class is wrapped in a try/catch with `el.open` fallback for older engines that lack support. - destroyed() uses the same browser-modal predicate so cleanup runs even when morphdom already stripped `open`. BulkSelectScope.updated() now preserves the in-memory selection Set against the freshly-rendered DOM instead of clearing and re-reading from `r.checked` (which the server template always renders as false). Rows whose uuids are no longer in the DOM drop out automatically. Assigns checked=true OR false on every row so a recycled input node can't carry a stale checked state. table_default.ex: fix unreachable default title on drag_handle_cell (attr default: nil shadowed the assign_new gettext default). modal.ex: keep_in_dom docstring warns about the auto-derived-id collision risk. Tests: 80 new render tests across bulk_select, sortable, reorder_modal, load_more (pagination), sort_selector, modal keep_in_dom branch, and table_default's drag_handle / row classes. media_gallery_test.exs: align two stale max_count tests to the current "Add tile is omitted at limit" behavior (production code hides the trigger entirely instead of disabling it).
ddon pushed a commit
that referenced
this pull request
May 25, 2026
… group Post-merge review fixes for #568 (back-merged from origin/main into dev): - bulk_select.ex: the "Reorder %{count} selected" toolbar label used gettext_noop/1, which only marks the string for extraction and returns the English msgid — the BulkSelectScope JS hook does no translation, so the label rendered untranslated in every non-default locale. Switch to gettext("Reorder %{count} selected", count: "%{count}"), which translates at render time while preserving the placeholder for client-side interp. - table_default.ex: table_default_row carried a bare unnamed `group`, which also satisfied any descendant's unnamed `group-hover:` (e.g. the sortable header chevron's `group-hover:opacity-70`), brightening it on row hover. Use a named `group/row` marker + `group-hover/row:` in drag_handle_cell so the reveal stays keyed to row hover. Update table_default_test.exs asserts and the AGENTS.md Sortable note to match. Review: dev_docs/pull_requests/2026/568-modal-to-native-dialog/CLAUDE_REVIEW.md Verified: mix format + compile --warnings-as-errors + credo --strict clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ddon pushed a commit
that referenced
this pull request
May 25, 2026
Release rollup since 1.7.120: - PR #568: native <dialog> modal (PkDialog), core list-UI toolkit (BulkSelect, Sortable, ReorderModal, load_more), race-free sort_selector - PR #568 post-merge review fixes (untranslated reorder label, named group/row) - PR #569: PhoenixKit.boot/1 hook, locale-aware Activity dates, broad i18n sweep - PR #550/#552/#554/#557/#558/#559 follow-ups Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
<.modal>from<div class="modal">to a native<dialog>with the newPkDialogJS hook. Adds akeep_in_dommode for client-driven instant-open (the kept-in-DOM dialog stays in the tree,data-showflips visibility).<.bulk_select_scope>+<.bulk_select_header_cell>+<.bulk_select_cell>+<.bulk_actions_toolbar>(client-side selection viaBulkSelectScopehook),<.sortable_tbody>+<.sortable_row>,<.drag_handle_cell>+<.drag_handle_header_cell>on<.table_default>,<.reorder_modal>strategy picker,<.load_more>pagination footer.<.sort_selector>race-free design (select sends onlysort_by, arrow sends onlysort_dir; LV handler derives the missing half from assigns).Critical bug fixed in this PR
When
BulkSelectScopeopened the reorder dialog viashowModal()for instant-open, Phoenix LV's DOM patcher then stripped the browser-addedopenattribute on the next re-render. The dialog stayed in the top-layer (still capturing all clicks) while CSSdialog:not([open])rendered itdisplay: none— visually closed but blocking the rest of the page.PkDialog._sync()now uses the:modalpseudo-class as the truth source instead ofel.open; restores the stripped attribute beforeclose()so the top-layer is actually released._closeFromLVflag suppresses the redundant close echo when LV initiated the close.:modalis wrapped in try/catch withel.openfallback for engines that lack the pseudo-class.BulkSelectScope.updated()now preserves the in-memory selection Set against the freshly-rendered DOM, so checked rows surviveapply_reorder/load_more/ sort changes. Rows whose uuids are no longer in the DOM drop out. Assigns checked=true OR false explicitly so a recycled input node can't leak stale state.Polish
<.drag_handle_cell>default title actually renders (attrdefault: nilwas shadowingassign_new).<.modal keep_in_dom>docstring warns about the auto-derived-id collision risk.Tests
table_default_test.exs(~80 render tests pinning bulk_select toolkit, sortable, reorder_modal, load_more, sort_selector, modal keep_in_dom branch, drag_handle hide-on-hover classes).media_gallery_test.exs: 2 stale max_count tests aligned to current omit-vs-disable behavior.Test plan
mix testpasses (1443 tests + 11 doctests).mix precommitpasses (format + compile + credo --strict + dialyzer).🤖 Generated with Claude Code