UI Primitives (EidosModal)¶
Verified against the code · 421c63ba3fab5894cdb9e707eed8ef694881d60d · 2026-10-20
956 D1b-1 (2026-10-20): 3 finding(s) fixed — closeAllModals runs each onClose (no focus restore), app.js copies the modal methods from _preEidosCore into a fresh literal (not Object.assign), eidos-mutations.js loads after app.js. Re-verified against main 421c63ba.¶
Layer: frontend
Overview — one place to open a modal¶
Before EidosModal, every overlay in the app opened and closed itself by hand: one
dialog toggled a hidden class, another flipped style.display, each wired its own
(or no) Escape handler, its own (or no) backdrop-click close, and almost none moved
keyboard focus into the dialog or restored it afterwards. That scatter is exactly the
kind of inconsistency the frontend canon (EPIC #510, Wave 4) exists to remove.
EidosModal is the canonical modal primitive. It lives in frontend/eidos-modal.js
as a layer script — loaded in index.html before app.js (after eidos-paths.js),
alongside eidos-api.js / eidos-paths.js (index.html:6978-6984; eidos-mutations.js
loads after app.js, at line 7006) — and exposes two
methods on the global EidosCore object:
EidosCore.openModal(el, opts) // show el as a modal and take over its a11y contract
EidosCore.closeModal(el) // hide it and hand control back
app.js captures _preEidosCore = window.EidosCore || {}, then reassigns
window.EidosCore to a fresh object literal (with live getters — Object.assign would copy
their values once and freeze them) and copies openModal / closeModal / closeAllModals /
_modalStackDepth from _preEidosCore into it (app.js:8530-8562), so the primitive defined
before it is carried over rather than clobbered.
Native <dialog> is deliberately not used (yet): EidosModal is a div-overlay
primitive that preserves today's behaviour exactly. Adopting <dialog> is a separate,
later decision (spec docs/specs/eidos-modal.md §3.1 / §8).
What the primitive owns¶
When you call openModal(el, opts), the primitive takes responsibility for the whole
dialog lifecycle:
| Concern | Behaviour |
|---|---|
| Show / hide | Via the overlay's existing mechanism — remove/add a hidden class (default), or set style.display when opts.mechanism === 'display'. Behaviour is preserved per overlay; nothing changes underneath it. |
| Initial focus | Focus moves into the dialog — to opts.initialFocus (a selector or element), else the first focusable, else the dialog container itself. |
| Focus trap | Tab / Shift-Tab cycle stays inside the dialog (WCAG 2.1.2 "no keyboard trap" out, 2.4.3 focus order). If focus has escaped, the next Tab pulls it back in. |
| Restore focus | On close, focus returns to whatever was focused when the modal opened — the opener button, or the modal beneath it in the stack (Contract 14 R17). |
| Background inertness | Everything outside the dialog gets inert + aria-hidden while it is open (pointer + screen-reader isolation), restored exactly on close. |
| Scroll lock | document.body scroll is locked while any modal is open, restored when the last one closes. |
| Stack | Modal-over-modal is allowed (stack-and-inert): only the top modal is interactive; the ones beneath are inert. Closing the top reactivates the one below. |
| Escape | Opt-in (opts.closeOnEscape). On an alertdialog, Escape is Cancel — never a destructive action. |
| Backdrop click | Opt-in (opts.closeOnBackdrop). A click on the overlay itself (not a child) closes it. |
| Role / label | role="dialog" (unless one is already set) + aria-modal="true" + a label — aria-labelledby an existing heading id (opts.labelledby) or aria-label (opts.label) — applied at open time, with no change to the static DOM. |
opts is validated like EidosApi: an unknown option key throws, so a typo can never
be silently ignored. Two more options tune the behaviour:
opts.trapFocus(boolean, defaulttrue) — whenfalse, the primitive stops handling Tab for that overlay (Escape, inertness, scroll-lock and aria are unchanged). The global-search palette uses this because its own Tab / Shift-Tab switch the search scope.opts.opener(Element ornull) — the element focus returns to on close, overriding "whatever was focused when the modal opened".nullmeans "do not restore" (the caller manages focus). The add-information dialog passes the anchor it was invoked from.
The show/hide mechanism¶
An overlay is shown/hidden the way it already was, chosen per overlay:
- default (
class) — toggles a hide class:el.classList.remove(opts.hideClass || 'hidden')on open,.add(...)on close. Suits overlays hidden by a.hidden { display:none }rule. opts.showClass— the inverse: the SHOWN state is a present class (e.g..open), so the primitiveadds it on open andremoves it on close (rule-editor, generic-interaction, shortcuts). PassshowClassORhideClass, never both.opts.mechanism: 'display'— setsel.style.display = opts.displayValue || 'flex'/'none'.opts.mechanism: 'hidden-attr'— toggles thehiddenattribute (el.hidden), used by the global-search palette.opts.mode: 'managed'— the primitive does not touch visibility at all. The caller — a React-conditional overlay (connections modal, attach-document preview) or a JS-built dialog — owns mount/unmount;openModal/closeModalonly layer focus, Escape, backdrop, inertness, scroll-lock and role/aria on top. This is how React overlays get the same a11y contract without the primitive fighting React over the DOM.
The mechanism is a property of the overlay, not a behaviour change — the migration passes whichever one the overlay used before, so the DOM stays byte-identical.
closeAllModals() semantics¶
EidosCore.closeAllModals() is a hard teardown (used by showLogin on a 401): it pops the whole
stack, restores inertness and scroll-lock, and sweeps every remaining inert marker regardless of
prior state so nothing can be left inert. It does run each popped entry's opts.onClose()
(inside try/catch, so a managed overlay whose onClose unmounts it — e.g. the JS-mounted
#changelog-overlay — is removed on a 401, not left over the login form; B1). It deliberately
does not restore focus — the caller decides
where focus goes next (showLogin immediately re-opens the login gate through the primitive, whose
initialFocus puts focus in the username field).
Escape and backdrop are never defaulted on¶
This is the load-bearing rule. Each overlay passes its current behaviour explicitly.
An overlay that never closed on Escape keeps closeOnEscape: false; one whose backdrop
already closed it keeps closeOnBackdrop: true. The primitive never assumes a default,
because "always closeable on Escape" would be a silent behaviour change for dialogs that
deliberately trap the user in a required step.
The single-path ratchet¶
Consolidation is only real if it stays real. tests/test_frontend_modal_single_path.py,
measured by the committed inventory script tools/modal_inventory.py, freezes — downward
only — how much raw modal show/hide (classList/style.display/hidden) still lives
outside the primitive:
- Every overlay/dialog candidate discovered in the deployed frontend must be classified in the inventory's registry (modal / popover / not-overlay). An unclassified candidate fails the guard, so a new overlay cannot appear silently.
- The sum of raw modal toggle-sites is frozen and may only fall. Re-introducing a raw toggle on a migrated overlay pushes the sum back above the frozen number and fails.
eidos-modal.jsis a layer (likeeidos-api.js): it is excluded from the scan, so the primitive's own internal toggles never count against a modal.
As of trin 4.6d the frozen sum is 0 — no raw modal toggle survives anywhere in the
deployed frontend. With every registered modal migrated, the per-family assertions collapse
into a single register-wide guard: every modal in the inventory's registry must carry
zero raw toggle-sites. There is no family list left to maintain — the day someone adds a new
overlay and shows/hides it with a raw classList/style write instead of the primitive, it
is discovered, classified, counted, and the register-wide guard fails, with no test edit
needed. A dedicated bite proves that a fresh overlay with a raw toggle is counted.
The inventory scan is scope-aware: it follows a variable read from getElementById,
but ends that variable's window at a function/arrow parameter re-binding the same name, at
any reassignment, or at the end of the enclosing function body — so an arrow-parameter
el => el.classList.add(…) after a modal's const el = … is not mis-attributed to the
modal.
Migration status¶
The overlays are migrated onto the primitive one family at a time.
The imports family migrated first (Wave 4, trin 4.6a, #706): prop-import-overlay,
rel-import-overlay, product-import-overlay, project-import-modal, and
domain-import-modal. That removed 13 raw toggle sites and lowered the ratchet from 59 to 46;
a later scan-accuracy fix corrected it to 47 (it now also sees a second real global-search-root
toggle it had missed). Backdrop-close is owned by the primitive for the three overlays that used
it (closeOnBackdrop:true on prop/rel/product import) — the old ad-hoc listeners and the inline
onclick were retired, so there is one owner.
The admin + insights family migrated next (trin 4.6b, #719): admin-overlay,
heatmap-overlay, iis-overlay, iam-overview-overlay, pq-script-modal, and
ref-arch-dialog — 12 more raw toggle sites removed, ratchet lowered 47 → 35. ref-arch-dialog
and pq-script-modal keep their backdrop-close (now closeOnBackdrop:true; ref-arch's
document-level outside-click listener and pq-script's inline onclick were retired, so the
primitive is the single owner). The ratchet enforces that single-owner rule: once an overlay is
opened through the primitive, any surviving ad-hoc Escape/backdrop handler is reported as a
conflict (an un-retired second owner) and fails the guard. The one deliberate behaviour change is iis-overlay gaining Escape-to-close
(closeOnEscape:true) — a Contract 14 R14 keyboard-trap remediation, because it has no visible
close control (spec §3.3). (Two overlays that the raw inventory heuristic had flagged as
backdrop-closing — admin-overlay, iam-overview-overlay — turned out to have no real backdrop
handler; they pass closeOnBackdrop:false, and the inventory now derives that column from the
openModal options so it reads the true value.)
The editor + misc + dynamic family migrated next (trin 4.6c, #722): the rule-book editor,
the generic add-information/relation dialog, my-notes, documents, the export model + preview,
the shortcuts overview, the add-information overlay, the global-search palette, and the
changelog — 31 more raw toggle sites removed, ratchet lowered 35 → 4 (only the auth family,
login/setpw, remains raw, for 4.6d). This slice exercises every mechanism: showClass (the
.open overlays), hidden-attr (global search), and managed mode (the changelog, which is
JS-mounted/unmounted). changelog-overlay gains Escape-to-close (R14). Two overlays keep a
dedicated Escape/backdrop handler the primitive's simple Cancel-Escape cannot express — the
rule-book editor (Escape closes the inner compose pane first) and the generic dialog (Escape and
backdrop route through the active sub-dialog's own Cancel/close) — so they are migrated for
show/hide only and are recorded as sanctioned exceptions, not conflicts.
The auth family migrated last (trin 4.6d, #726): the login gate (login-overlay) and
the set-password gate (setpw-overlay) — the final 4 raw toggle sites removed, ratchet
lowered 4 → 0. These two are gates, not dialogs: closeOnEscape:false and
closeOnBackdrop:false, because nothing is behind the gate to reveal — there is no close
control by design. That is a sanctioned Contract 14 R14 exception, recorded through the
openModal options (so the inventory derives has_escape/has_backdrop = false and reports
no conflict), not as a new allowlist. opener:null on both, so a 401 never tries to restore
focus to whatever was behind the gate.
Both overlays stay visible-by-default in the markup — the #419 no-JS fail-safe: a static
login form renders even if app.js never runs. The boot path registers the visible gate with
the primitive rather than toggling its visibility: on every boot except the invite/reset path
(where setpw owns the gate), after DOM-ready it calls openModal(#login-overlay, …) — including
the ?launch= magic-link path, so a successful launch's hideLogin() can actually close the gate.
Because the overlay is already visible the primitive's class-_show is a no-op — nothing changes
visually — but the stack now owns the gate (focus trap, #app-shell inert, aria-modal) across the
async loadConfig/restoreSession gap, with no blank page. On a restored session (or launch
success) hideLogin() → closeModal hides it; on a 401 (or launch failure) showLogin()
runs closeAllModals() (so a 401 raised while the admin panel is open lands on a live login form)
then openModal — a close-then-reopen, never a suppressed re-push. Initial focus is the login
username field (or, for set-password, the first password field once the form is revealed),
replacing the old setTimeout focus hack.
Gates open only after DOM-ready — never at app.js eval time. The overlays parsed after
app.js (#global-search-root, #shortcuts-overlay, #rule-editor-overlay, #pq-script-modal)
do not yet exist when the script runs; opening a gate then would leave them un-inerted behind it.
So both the boot IIFE and the invite/reset-token check await DOM-ready before openModal, letting
the primitive's sibling-walk inert the whole body. The set-password gate opens once from that token
check (on the invite/reset path the boot IIFE early-returns, so it never registers the login gate —
the token check registers-then-closes the visible login overlay so it is hidden and never stacked
under setpw) and is never closed by code (the flow ends in a reload). Its accessible name is
per mode: "Set your password" for an invite, "Reset your password" for a reset link
(#setpw-title is display:none until the token validates, so the primitive's label carries the
name).
Because the gate is a real modal on the stack, global keyboard shortcuts are inert while it is
open: the shortcut dispatcher's "is a modal open?" test now reads the primitive's stack depth
(EidosCore._modalStackDepth() > 0) rather than probing individual overlays, so pressing e.g. ?
cannot open the shortcuts overview over the login / set-password gate.
The two React-conditional overlays (modal-overlay, adl-preview-overlay) are dead code —
their component (AttachDocumentFlow) is no longer wired (the "+" opens the generic
add-information dialog instead), so they carry 0 toggle sites and are not migrated; the
managed-mode contract that would cover them is delivered and used live by the changelog.
Whether to delete them or re-wire them via managed mode is tracked in #727.
See also the Frontend CRUD engine (the browser-side write surface), EidosApi (the
one HTTP layer), and EidosMutations (the CRUD two-phase). EidosModal is the modal-dialog
sibling of those layers.