Layer results and local subinteractions
Phase 6 adds semantic completion without adding navigation or HTTP. A Layer can close normally, accept a caller-defined value, or dismiss with an optional reason. Direct workflow composition uses a Promise returned by ask().
Outcome contract
type LayerOutcome<T, R = unknown> =
| { status: "accepted"; value: T }
| { status: "dismissed"; reason?: R };Ordinary user cancellation is data, not an exception. Runtime/programming failures may still reject, but Escape, request-close, light dismiss, or an ordinaryclose() while an ask() is pending resolve as{ status: "dismissed" }.
Three completion operations
| Operation | Meaning | Result behavior |
|---|---|---|
close() | Close the surface without declaring success. | A pending ask() resolves as dismissal with no reason. |
accept(value) | Complete successfully with an opaque caller-defined value. | Closes, emits mr:layer:result, resolves ask() as accepted. |
dismiss(reason) | Complete without acceptance, optionally explaining why. | Closes, emits mr:layer:result, resolves ask() as dismissed. |
Book → Author local proof of concept
This example has no fetch, router, remote fragment, entity protocol, or parent refresh primitive. The Author Layer returns an opaque object. The caller alone decides to copy its name into the Book form.
type Author = { name: string };
authorButton.addEventListener("click", async () => {
const outcome = await layers.ask<Author, "cancel-button">(
authorDialog,
{ trigger: authorButton },
);
if (outcome.status === "accepted") {
bookAuthorInput.value = outcome.value.name;
}
});The Author opener sits inside the active Book Layer, so normal ownership inference makes Book the parent. Accepting or dismissing Author closes only Author. The Book Layer stays open, focus returns to the quick-create button, and only then does the awaiting caller apply an accepted value to its own form.
Arbitrary nesting uses the same API
Parent modal → child modal
const outcome = await layers.ask<ChildValue>(childDialog, {
trigger: openChildButton, // inside the parent modal
});
if (outcome.status === "accepted") {
renderChildValue(outcome.value); // caller owns parent mutation
}Drawer → confirmation modal
const confirmation = await layers.ask<boolean, "cancel">(confirmDialog, {
trigger: deleteButton, // inside the open drawer
});
if (confirmation.status === "accepted" && confirmation.value) {
removeLocalItem();
}Modal → popover
const choice = await layers.ask<string>(statusPopover, {
trigger: statusButton, // inside the open modal
});
if (choice.status === "accepted") {
statusOutput.textContent = choice.value;
}There is no nestedModal, nestedNestedModal, or special workflow subtype. Parentage comes from the opener (or an explicitparent option), and every child independently produces its own result.
Result events are observational
surface.addEventListener("mr:layer:result", (event) => {
console.log(event.detail.layer, event.detail.outcome);
});The event carries the same discriminated outcome and bubbles for diagnostics or integration. Direct parent/child workflow composition should still use the returned Promise. No automatic parent DOM mutation or completion occurs: a child result never implicitly changes parent DOM or completes the parent.
Propagation rules
- Accepting/dismissing a child completes only that child's active interaction.
- The parent stays open unless caller code explicitly closes or completes it.
- The result value is opaque to Layer core; no IDs, labels, CRUD records, JSON schema, or form model are assumed.
- Closing a parent still closes owned descendants; a descendant with a pending
ask()resolves as dismissed. - Parent UI changes happen only in caller code after inspecting the child's result.
Lower-capability fallback
Nested ask() is an enhancement, not a requirement on the underlying task. A quick-create control for an essential workflow should retain a normal destination that works when JavaScript or native Layer capability is unavailable.
<a href="/authors/new">Create author on the standalone page</a>