Skip to content

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, default true) — when false, 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 or null) — the element focus returns to on close, overriding "whatever was focused when the modal opened". null means "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 primitive adds it on open and removes it on close (rule-editor, generic-interaction, shortcuts). Pass showClass OR hideClass, never both.
  • opts.mechanism: 'display' — sets el.style.display = opts.displayValue || 'flex' / 'none'.
  • opts.mechanism: 'hidden-attr' — toggles the hidden attribute (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/closeModal only 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.js is a layer (like eidos-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.