Commit graph

2 commits

Author SHA1 Message Date
Roland
ad78cdee7c
review: the spread was overwriting the stack, and the guard was blind
Acting on the review of #622.  Two bugs, both real, and the second one is a
test of mine that could not fail for its stated reason.

The stack props were set BEFORE `{...props}`, so any call site passing
`closeOnEscape` overwrote the stack's decision and re-armed Mantine's
window-level Escape listener on a modal that is not on top -- reinstating the
bug the change exists to fix.  Three call sites pass it: CollectionReorganizeModal
and BatchReorganizeModal disable Escape while a reorganize runs, Flasher while
it writes an image.  The concrete breakage is the one place the app really does
stack two modals: ConflictResolutionModal opens as a sibling above the
reorganize modal, so the moment the reorganize finished and `closeOnEscape`
returned to true, one Escape closed both.  They are now destructured out and
composed -- `isTop && closeOnEscape` -- because "only the top modal answers
Escape" and "do not dismiss me while I am busy" are both true.  Mantine's own
Modal.Stack applies its stack props after the caller's for the same reason.
Two Cypress cases, one per direction; planting the old order reddens the first.

`Confirm` was still passing Mantine's raw `sm`, and the audit guard I added in
the same PR was supposed to catch exactly that.  It did not, and the reason is
worse than the miss: the scanner matched `<Modal\b[^>]*?\bsize='...'`, and
`onClose={() => ...}` contains a `>`, so it stopped at the arrow and never saw
the rest of the tag.  It read 44 of the 56 modals that declare a size; the
twelve it skipped included the single offender in the codebase, in this very
file.  A guard that scans a fifth of nothing looks identical to a clean
codebase.  It now walks the tag with a paren counter, accepts either quote
style, and asserts it scanned more than fifty tags first -- so a scanner that
silently matches nothing fails instead of passing.  Confirm says `tiny`.

Also from the review: a bare `expect(titles()).to.not.include('Parent')` in the
Cypress spec ran during the synchronous phase, before any cy command executed
and while nothing was open, so it always passed and guarded nothing.  Deleted
rather than repaired -- the retried assertion above it already makes the claim.

An unregistered-but-open modal reported `isTop` even with a parent registered,
which gave both the same z-index for the frame before the effect ran -- a visible
flash of the wrong modal in front.  It is only top now if the stack is empty.

Nit: the comment claimed Mantine ignores unknown size names.  It ignores unknown
SEMANTIC names and falls back to 440px; it recognises and honours its own.  Two
different hazards, both silent, and the comment now says which is which.

1124 jest / 62 suites, 10 in modal-stack.cy.js, 196 in ui-layout.cy.js,
clean npm run build.
2026-08-05 18:38:09 -06:00
Roland
afa127a992
ui: only the top modal answers Escape, and the audited widths
Two things, both reported from the running app.

Nested modals could not exist.  Mantine binds a window-level keydown listener
per open modal, gated only on whether that modal is open -- nothing checks which
one the user is looking at:

    useWindowEvent('keydown', (event) => {
      if (event.key === 'Escape' && closeOnEscape && ... && opened) onClose();
    }, {capture: true});

Two open modals meant two listeners, so one Escape closed both.  Reproduced on
the dashboard: the download modal plus the search modal over it, one press, both
gone, back to the page.  Both also sat at z-index 200, so which one drew on top
was DOM order rather than which the user opened.

Mantine's own answer is Modal.Stack with a stackId, which is the only thing that
gates closeOnEscape.  Not used, for two reasons: every call site would have to
be rewritten to hoist its modal into a stack, and it sets `__hidden` on
everything below the top -- the parent disappears while the child is open.  The
parent should be dimmed and still there, as it was before the migration.

So the wrapper keeps the register itself: a module-level list of open modals,
most recent on top, from which each modal takes closeOnEscape, trapFocus and a
z-index two above its parent.  Module-level rather than React context because
the two modals in the bug are not nested in the JSX at all -- the search modal
is rendered by KeyboardShortcutsProvider near the app root and the download
modal by the dashboard.  They are siblings that happen to be open together, and
no context relates them.  The increment is small on purpose: Mantine puts
popovers at 300, and a stack that climbed past it would paint over the select
dropdowns inside its own forms.

Widths: Roland audited the modal list and chose sizes against Mantine's scale.
Sixteen get wider -- twelve to `large` (620), four to `fullscreen` -- and eleven
keep the width they had, with the size now written down so the decision is in
the source rather than in a default.  Flasher was passing Mantine's raw `sm`,
which worked but bypassed the naming the audit used; it says `tiny` now.

The size table itself is guarded, because it is the single thing that halved
every modal during the migration and a quiet edit to it moves seventy at once.
Semantic's widths, measured on the pre-migration build still running on the QA
Pi: mini 360, tiny 540, small 720, large 1080, no-size 900.  Mantine's, read out
of its stylesheet: 320/380/440/620/780.  A `small` modal went 720 -> 440 and one
with no size went 900 -> 440.  Three cases now hold the scale, the mapping, and
the rule that no call site may name a size the table does not know -- an unknown
name is passed to Mantine, ignored, and silently rendered at the default.

Tested in a real browser: none of it is visible to jsdom -- the listener is on
window with capture, the stacking is z-index, and "still visible behind" is
geometry.  Note that `should('be.visible')` is the wrong assertion for the
parent: it is an occlusion check, so a child drawn correctly over its parent
trips it.  The test uses a wide viewport and asserts the parent is rendered and
shows to both sides of the child.  Planting the pre-fix behaviour reddens three.

1124 jest / 62 suites, 8 in modal-stack.cy.js, clean npm run build.  The 12
failures in Tags/Channels/Download/Inventory.cy.js are the known pre-existing
Semantic-era selector failures, unchanged.
2026-08-05 18:38:07 -06:00