Skip to content

ADR 0058: Inline expansion is the default container; a modal is a ruled exception

Status: Accepted Date: 2026-08-14 Issue: #1533 Related: #1532 (epic), #704 (DcsModal), #1545 (ceremony rulings)

Context

The founder ruled on 2026-08-14 that the modal dialog stops being the default container for gateway UI interaction (#1532). A modal obscures the rest of the screen for the sake of its own content. The preferred pattern is inline expansion, where the element that invoked the detail grows in place and the surrounding page stays visible and live.

At that point the tree held three partial versions of the idea:

  • The HMI faceplate (js/hmi/hmi-faceplate.js) — the pattern's origin and its visual model (grid-column: span 3, neighbours reflow), but keyboard-inaccessible: no Escape, no focus management, no ARIA, and an innerHTML snapshot/restore whose state dies on any full repaint (#818 is the repair that bug required).
  • The audit-trail row expansion (js/views/audit-trail.js) — the state model that actually works: the expanded key lives in state, the view re-renders from it, and the expansion therefore survives every repaint (#956).
  • DcsModal (js/modal.js, #704) — the only place the accessibility behaviours (Escape, focus trap and restore, role/aria-modal) exist. The container being retired was, before this ADR, strictly more accessible than the pattern replacing it.

Decision

1. Inline expansion is the default. A detail view, an entry form, or a plain confirmation renders by expanding in place. A new modal is either a ceremony the founder has ruled modal-by-design or a defect. The per-ceremony rulings are #1545's deliverable and will be recorded in the ADR it produces. Until a ceremony is ruled it keeps its current modal container, because the status quo governs and this ADR migrates no ceremony by default.

2. One shared component, DcsExpand (js/expand.js), owns the pattern. It combines the three partial versions: the audit trail's state discipline, the faceplate's grow-in-place presentation, and DcsModal's accessibility behaviours. Its contract:

  • Views own markup and re-render it from state. DcsExpand owns no DOM. A view registers a group (getExpanded / setExpanded / rerender) and emits DcsExpand.triggerAttrs(...) / DcsExpand.bodyAttrs(...) in its markup. There is no innerHTML snapshot/restore anywhere in the pattern. Collapse re-renders from state, which is what makes repaint survival structural.
  • Single expansion per group. Opening one item closes the group's previous item. A group is one container's worth of peers (a table, a card grid), and independent surfaces are independent groups.
  • A group may nest inside another group's body. The HMI faceplate holds the tag-write editor (#1540), which is a group of its own keyed by the row it acts on. Escape unwinds one layer per press, because the stack closes the most recently opened expansion. The bubble guard that keeps a click on expanded content from collapsing its own container tests whether the trigger contains that body. A trigger nested in another group's body is still a trigger, and a guard testing for any enclosing body made every one of them inert without an error anywhere.
  • Accessibility is the component's job, once. The trigger carries role="button", tabindex="0", aria-expanded, aria-controls, and Enter/Space activation. The body is a focusable role="region". Focus moves into the body on open and back to the trigger on close, located by data attributes, because re-rendering destroys element identity and any held reference with it.
  • Escape closes the most recently opened expansion. The layering falls out of listener phases and needs no coordination. dcs-select's capture listener stops Escape while a dropdown is open, DcsModal's capture listener stops it while a modal is up, and DcsExpand listens in the bubble phase. It therefore only ever sees Escape when an expansion is the topmost surface.
  • Outside-click dismiss is opt-in, off by default. Where enabled, it structurally exempts anything inside [aria-modal="true"], because a satellite dialog above an expansion must not collapse it (#915), and [data-expand-keep] marks a satellite surface rendered outside both the trigger and the body. Hardcoded exemption selector lists are what #915 showed does not survive contact with the next overlay.
  • Live polls follow the state rule. A poller repaints through the same state-driven render (the expansion persists because state says expanded) or patches targeted leaf nodes (#1144). Rewriting an ancestor of an expanded body from markup built without consulting the group's state is a defect, and it is the exact shape the faceplate's snapshot pattern existed to dodge.

3. DcsModal stays. Whatever ceremonies remain modal after #1545's rulings keep composing it. #704's component narrows in scope, and it is retired only if every ceremony migrates.

Alternatives Considered

Generalize the faceplate mechanism as-is. Rejected. Its expansion is a grid-only CSS trick, its state is an element reference that dangles on any repaint, and it has no keyboard path at all. Adopting it everywhere would have spread its gaps to every surface and made the UI less accessible than the modals being removed.

An imperative, DOM-owning component in DcsModal's image (component builds the body, view hands it content). Rejected. Every gateway view already re-renders its markup from state, and a component-owned DOM node inside a view-owned innerHTML render is exactly the detached-element bug the faceplate spent #818 repairing. The component that survives here is the one that owns conventions over state the view keeps.

Keep hand-rolling per surface. Rejected by the evidence: the epic's inventory found 12 hand-rolled overlays, no two missing the same subset of Escape, focus, and ARIA, plus two z-index inversions. Divergence is the observed steady state.

Consequences

  • Every #1532 migration sub-issue composes DcsExpand. Reviews treat a new DcsModal.open caller outside the ceremony set the same way they treat a raw fetch() against /api/v1/ (#788): as a defect unless a ruling says otherwise. A mechanical gate in the lint-js family may follow once the ceremony set is settled.
  • The faceplate, the pattern's origin, does not yet satisfy this ADR's contract. #1534 retrofits it onto the component.
  • The first consumer is the audit-trail row expansion, migrated with the component itself so the contract was validated against a real surface from day one (test/ui/dcs-expand.spec.ts pins keyboard, Escape, focus movement, repaint survival, and modal layering).
  • Surfaces that migrate change their captures' covers: reality: epic ruling 4 (gradual staleSince:/retakeIssue:, re-shoot per sub-issue) applies to every migration.