Layer architecture
Phase 1 defined the Layer model used to unify local overlay mechanics without introducing a generic overlay engine prematurely. Phase 2 implemented that model as a small native-first runtime, Phase 3 added opt-in modal/drawer presentation, Phase 4 migrated Search Tool and Application Shell onto the shared lifecycle, Phase 5 added native popovers/tooltips, Phase 6 added typed local interaction outcomes, Phase 7 added an optional HTML-first remote navigation module, and Phase 8 locks the resulting public/degradation contract with permanent checks. The full normative contract lives inLAYER-API.md at the repository root.
Boundary
A Layer owns local open/close lifecycle, trigger ownership, focus restoration, parent/child relationships, normalized lifecycle observation, and opaque local interaction outcomes. It does not know about HTTP, URLs, CRUD entities, remote forms, redirects, fragment refresh, or server frameworks.
The guiding rule is: attributes identify behavior; JavaScript composes workflows. Promises sequence interaction flow; DOM events observe the lifecycle.
Existing behavior inventory
| Consumer | Current mechanics | Layer model | Component-specific behavior |
|---|---|---|---|
| Search Tool | Generated native <dialog> opened through layers.open(); native/Layer close requests and Layer focus restoration | modal | Fetching, Fuse.js, debounce, query/result rendering, initial input focus, generated DOM cleanup |
| Application Shell | Persistent sidebar content is moved into a transient native <dialog> opened through the same Layer core; native backdrop/Escape and Layer focus restoration | drawer | Navigation layout, responsive/no-JS behavior, content transfer, dismiss focus, disclosure ARIA |
| Legacy Side-Top-Top navigation | Independent overlay/classes/Escape listener; no focus restoration | Confirms duplicated mechanics, but does not expand the API | Handled by legacy cleanup/migration work |
Other keyboard-focus code such as tabs or resettable file inputs is ordinary component behavior, not Layer behavior. The existing floatingElementutility is a CSS float, not a top-layer primitive.
Phase 4 consumer architecture
[data-mr-search] ── creates modal ──┐
├──> Layer core ──> native <dialog> lifecycle
[data-mr-shell] ── creates drawer ─┘ │
└──> focus + ownership + lifecycle events
Search-specific: query / loading / results
Shell-specific: navigation / responsive transfer / disclosure ARIADependency direction is one-way: both components depend on src/library/layers/; Layer core does not import either component.
Shared lifecycle versus consumer behavior
| Concern | Shared Layer core | Search Tool | Application Shell |
|---|---|---|---|
| Open/close state | Yes | No private state machine | No private state machine |
| Trigger ownership/focus restoration | Yes | Passes search trigger | Passes menu toggle |
| Escape/request-close/backdrop lifecycle | Native dialog + Layer normalization; backdrop click closes the document's active Layer stack | No private Escape/backdrop engine | No private Escape/custom overlay |
| Initial focus | Consumer choice | Search input | Dismiss/navigation control |
| Domain behavior | None | Query, loading, results, status | Navigation, responsive layout, disclosure ARIA |
This split is the Phase 4 proof: deleting either consumer leaves the Layer registry, lifecycle, modal/drawer behavior, and other consumer intact. The Layer core imports neither component.
Lifecycle and modes
closed → opening → open → closing → closedopening and closing are Markup Refine coordination states. Settled visibility should remain native wherever the platform already exposes it.
modal- Blocking interaction backed by modal
<dialog>. drawer- Primarily a presentation mode. A transient modal drawer may use the same
<dialog>primitive as a modal rather than owning a second state engine. popover- Non-modal floating interaction backed by the native Popover API where appropriate.
Ownership
- Trigger/invoker: the element responsible for the current open request.
- Surface: the element presented as the Layer.
- Parent: the direct owning Layer, if the interaction originates inside one.
- Children: direct nested Layers.
- State:
closed | opening | open | closing.
Parent/child ownership is logical, not a custom visual stack. A child never implicitly refreshes, replaces, reloads, or closes its parent.
Programmatic API
type LayerMode = "modal" | "drawer" | "popover";
type LayerState = "closed" | "opening" | "open" | "closing";
interface Layer {
readonly element: HTMLElement;
readonly mode: LayerMode;
readonly state: LayerState;
readonly trigger: HTMLElement | null;
readonly parent: Layer | null;
readonly children: readonly Layer[];
open(options?: LayerOpenOptions): Promise<void>;
close(options?: LayerCloseOptions): Promise<void>;
accept<T>(value: T): Promise<void>;
dismiss<R = unknown>(reason?: R): Promise<void>;
ask<T, R = unknown>(options?: LayerOpenOptions): Promise<LayerOutcome<T, R>>;
}The implemented manager exposes get(target), closest(node),open(target, options), ask(target, options), current, and stack. Individual Layers add accept(value), dismiss(reason), and ask(). Accepted values remain opaque; the API deliberately contains no URL, fetch, form, template, or result-to-field mapping.
import { initLayers, layers } from "markup-refine-lib/layers";
initLayers(document);
const settings = await layers.open("#settings-dialog", { trigger: settingsButton });
console.log(settings.state, layers.current, layers.stack);Local results and nested workflows
LayerOutcome<T, R>
├─ { status: "accepted", value: T }
└─ { status: "dismissed", reason?: R }
parent Layer
└─ await child.ask(...)
├─ child.accept(value) → caller receives accepted value
└─ child close/dismiss → caller receives dismissed
No automatic parent DOM mutation or parent completion.close() remains a lifecycle operation; accept() anddismiss() are semantic completion. If a pending ask() is closed normally (including native Escape/request-close), it resolves as a dismissal rather than rejecting. Result Promises are the workflow API;mr:layer:result is an observation hook carrying the same outcome. See Layer results and local subinteractions.
Modal and drawer presentation
.mr-layer provides the shared dialog surface presentation;.mr-layer--modal and .mr-layer--drawer identify the visual variant. Both behavior modes still use native modal <dialog> andshowModal(). See modal and drawer Layersfor live examples, tokens, request-close semantics, and unstyled HTML fallback.
Declarative discovery
<button commandfor="settings-dialog" command="show-modal">Settings</button>
<dialog id="settings-dialog"
class="mr-layer mr-layer--modal"
data-mr-layer="modal">
<button commandfor="settings-dialog" command="request-close">Close</button>
</dialog>data-mr-layer identifies the surface; mr-layer* classes are optional presentation hooks. Native invoker commands and popover controls remain responsible for the browser action. Repeated initialization is safe. The standard markup-refine-lib/behaviors entry initializes Layers automatically, while the focused markup-refine-lib/layers export provides explicit initLayers().
Lifecycle events
| Event | Meaning | Cancellation |
|---|---|---|
mr:layer:beforeopen | Before an MR-controlled open commits | Cancelable only while the request can still be prevented |
mr:layer:open | Settled open | No |
mr:layer:beforeclose | Before a preventable close/request-close commits | Only when the underlying close is actually preventable |
mr:layer:close | Settled closed | No |
mr:layer:result | Semantic outcome / implicit dismissal for a pending ask() | No |
Events originate on the Layer surface and bubble. Lifecycle details stay minimal;mr:layer:result additionally carries the same opaque outcome returned byask(). Events remain observational rather than the primary workflow API.
State ownership
| Concern | Owner |
|---|---|
| Dialog open/closed, modal inertness, top layer | Native <dialog>/browser |
| Popover shown/hidden, top layer, light dismiss, implicit anchor | Native Popover API |
| Tooltip delays, preview-to-pin promotion, whole-tree outside dismissal, and topmost Escape dismissal | Focused Tooltip module |
| Backdrop appearance | CSS ::backdrop |
| Disclosure/accessibility relationship | ARIA only where needed, e.g. shell aria-expanded/aria-controls |
| Opening/closing transition and parent/child ownership | Markup Refine Layer runtime |
| Visual variant | mr-layer* classes/tokens |
Popover and Tooltip layering
Tooltip trigger
├─ transient preview: native popover=hint + role=tooltip (non-focusable, hover-persistent)
│ (not registered; never in layers.stack)
└─ pinned surface: native popover=manual + data-mr-layer=popover
└─ nested pinned tooltip Layer
└─ …Phase 5 keeps tooltip timing/content discovery in a focused Tooltip module above Layer core. Promotion is the boundary: only the interactive pinned surface becomes a Layer. Parent/child ownership is then inferred from the nested trigger exactly as for any other Layer, and parent close cascades through descendants child-first.
Generic popover="auto" Layers keep native light dismiss. Pinned Tooltips deliberately use popover="manual" so the focused Tooltip module can dismiss the whole active Tooltip stack on outside pointer-down while keeping Escapetopmost-only. Both paths still use the native top layer, and showPopover({ source: trigger })supplies the invoker/anchor relationship for programmatic opens. Seepopovers and pinned tooltips for the authoring API and decision matrix.
Remote interactions remain separate
Application / semantic href + form action
│
▼
markup-refine-lib/layer-navigation
fetch + fragment extraction + form enhancement
validation replacement + server-result protocol
│
▼
Layer core
local lifecycle + ownership + outcomes
│
▼
native dialog / Popover primitivesPhase 7 implements the optional layer-navigation entry above Layer core. It can fetch standalone HTML URLs, extract a declared fragment, progressively enhance opted-in forms/links, and translate a small server result marker into the existing accept()/dismiss() contract. Layer core still has no fetch, URL, redirect, form, or backend-protocol dependency.
Remote navigation never refreshes a parent implicitly. The direct JavaScript caller receives the child outcome and decides whether to update a field, reload another fragment, or do nothing, preserving unsaved parent state. The module is also absent from the default behaviors bundle so HTTP interception remains an explicit opt-in. See remote Layer navigation.
Stable contract
The finalized public surface and permanent graceful-degradation matrix are documented inStable Layer API. The Layer core remains independent from Search Tool, Application Shell, and optional remote navigation.