diff --git a/.changeset/dialog-inner-popups.md b/.changeset/dialog-inner-popups.md
deleted file mode 100644
index a642f33..0000000
--- a/.changeset/dialog-inner-popups.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@dunky.dev/dom-dialog': patch
----
-
-A popup opened inside the dialog owns Tab and Escape while it holds focus.
-
-A select menu, a popover, a menu, or a combobox list inside a modal dialog —
-from any library, as long as it carries ARIA popup semantics — is not a layer
-the stack knows, so the dialog kept treating itself as topmost: its
-capture-phase Escape closed the dialog together with the popup, and its focus
-trap cancelled Tab inside the popup and pulled focus back into the window. Both
-now stand down while such a popup holds focus and resume once focus is back in
-the window, so one Escape closes the popup and the next reaches the dialog. A
-control whose popup is expanded (a combobox input) hands over Escape only; Tab
-is how its popup is left, so the trap keeps it.
diff --git a/.changeset/dialog-restore-focus.md b/.changeset/dialog-restore-focus.md
deleted file mode 100644
index cc42aaa..0000000
--- a/.changeset/dialog-restore-focus.md
+++ /dev/null
@@ -1,27 +0,0 @@
----
-'@dunky.dev/dom-dialog': minor
-'@dunky.dev/react-dialog': minor
-'@dunky.dev/solid-dialog': minor
----
-
-`Dialog.Content` gains `restoreFocus` — the close-side counterpart to
-`initialFocus`. Closing still returns focus to whatever held it before the
-dialog opened; `restoreFocus` names where it goes when that holder can't
-meaningfully take focus back: focus sat on the body (a pointer press can
-leave it there), or on an element removed from the document since.
-Typically the dialog's trigger.
-
-```tsx
-// React — a ref, read at close time
-…
-
-// Solid — an element or accessor, resolved at close time
- trigger}>…
-```
-
-Before, those two cases silently dropped focus: restoring to the body goes
-nowhere, and focusing a disconnected element is a no-op, leaving focus
-stranded on the closing layer. The element focused before opening still
-always wins when it is meaningful — the fallback never overrides it. At the
-DOM layer, `openDialogLayer` takes `restoreFocus?: () => HTMLElement | null`,
-resolved at close.
diff --git a/.changeset/dom-element-is-rendered.md b/.changeset/dom-element-is-rendered.md
deleted file mode 100644
index 3407059..0000000
--- a/.changeset/dom-element-is-rendered.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-'@dunky.dev/dom-element': minor
----
-
-New package: `@dunky.dev/dom-element`, framework-free predicates about a single
-element.
-
-`isRendered(element)` answers whether an element actually rendered, and so can
-take focus, be pressed, or be read out. Presence in the DOM is not enough: an
-element inside a collapsed section still answers `querySelector`, but `focus()`
-on it does nothing and reports nothing.
-
-```ts
-import { isRendered } from '@dunky.dev/dom-element'
-
-for (const field of content.querySelectorAll('input, select, textarea')) {
- if (isRendered(field)) {
- field.focus()
- break
- }
-}
-```
-
-Checked: the `hidden` attribute (`hidden="until-found"` included),
-`display: none` on the element or any ancestor — `display` doesn't inherit, so
-ancestors are walked — `visibility: hidden | collapse`, and being detached.
-Not checked: `opacity: 0` and `content-visibility`, which do render, and
-rendering is what decides focusability.
-
-`isFocusable(element)` is the sibling facet: whether anything bars the element
-from taking focus. Disabling and inertness also arrive from ancestors — a
-control inside a `fieldset[disabled]` subtree (with the native exception for
-its first `legend`) or anything inside `[inert]` refuses `focus()` — which a
-selector's own-attribute checks (`:not([disabled])`) can't see. The facets are
-deliberately narrow and compose; the tab order stays the caller's question.
-
-It's a package of its own because two utils have to agree on the answers:
-`@dunky.dev/dom-focus-trap` filters its Tab cycle with them and
-`@dunky.dev/dom-overlay` filters its initial-focus candidates, both guarding
-against the same silent `focus()` no-op.
diff --git a/.changeset/focus-candidates-barred-elements.md b/.changeset/focus-candidates-barred-elements.md
deleted file mode 100644
index 3df9c6a..0000000
--- a/.changeset/focus-candidates-barred-elements.md
+++ /dev/null
@@ -1,27 +0,0 @@
----
-'@dunky.dev/dom-focus-trap': patch
-'@dunky.dev/dom-overlay': patch
-'@dunky.dev/dom-dialog': patch
----
-
-Focus candidates barred by an ancestor are excluded: controls disabled through
-`fieldset[disabled]` and anything inside `[inert]`.
-
-`FOCUSABLE_SELECTOR` and the form-field selector gate on an element's own
-attributes (`input:not([disabled])`), but both bars also arrive from
-ancestors, so a barred control satisfied the selector while a browser refuses
-to focus it — silently.
-
-In the focus trap that was a hard dead end: the Tab keydown is already
-`preventDefault()`-ed when focus is stepped by hand, so every press recomputed
-the same refused target and focus never moved again. The cycle now only holds
-what a browser would actually focus, keeping the native exception that
-controls in a disabled fieldset's first `legend` stay enabled.
-
-In the initial-focus chain it was the quieter failure mode: the barred field
-won the draw, `focus()` no-opped, and focus fell to the overlay window even
-when a viable field came later. Every candidate — designated element and form
-fields alike — is now also filtered for these bars.
-
-Both use the new `isFocusable` from `@dunky.dev/dom-element`, beside the
-`isRendered` filter they already shared.
diff --git a/.changeset/focus-trap-shared-rendered-check.md b/.changeset/focus-trap-shared-rendered-check.md
deleted file mode 100644
index 6739e21..0000000
--- a/.changeset/focus-trap-shared-rendered-check.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-'@dunky.dev/dom-focus-trap': patch
----
-
-The Tab cycle's rendered check now comes from `@dunky.dev/dom-element` instead
-of a private copy.
-
-`@dunky.dev/dom-overlay` needs the same predicate to filter its initial-focus
-candidates, and two packages answering the question separately would drift.
-The check itself is unchanged in intent — a non-rendered element is a no-op to
-focus, so keeping one in the cycle would stall the trap on it — but sharing it
-tightens two cases:
-
-- A **detached** element is now excluded. It can't take focus, and computed
- style on one reports the property defaults rather than `none`, so the display
- walk alone let it through.
-- A `display: none` ancestor **above the container** now excludes the
- focusables under it. The private copy stopped its walk at the container.
- Nothing inside a hidden container can take focus either way, so this lands on
- the trap's documented behavior for an empty cycle: Tab is a no-op.
diff --git a/.changeset/navigation-spec-scenarios.md b/.changeset/navigation-spec-scenarios.md
deleted file mode 100644
index 20797c2..0000000
--- a/.changeset/navigation-spec-scenarios.md
+++ /dev/null
@@ -1,24 +0,0 @@
----
-'@dunky.dev/browser-navigation': patch
----
-
-`SPEC.md` (shipped with the package) gains a Scenarios section: 26 compact
-traces of `interceptBackNavigation` and `watchSpentEntry` behavior, grouped
-by one layer, release and consumption, forward/claims/reload, stacked
-layers, and timing edges.
-
-The behavior contract was already fully stated, but as one mechanism per
-prose bullet — nothing let a reader replay a concrete flow end to end.
-Each trace is a replayable episode in the module's own vocabulary (`arm`,
-`release`, `Back`, `Forward`), e.g.:
-
-```
-arm A -> arm B -> arm C -> Back -> release B -> Forward -> Forward
- => the Back closes C; the first Forward soaks into B's abandoned entry;
- the second asks C, which reopens only if it still can without B.
-```
-
-Two outcomes the prose previously left implicit are now stated outright: a
-deliberately released entry absorbs one Forward press (it can't be deleted,
-only left to soak the traversal), and any re-plant — a new layer or a
-veto's re-arm — truncates every parked Forward watch above it.
diff --git a/.changeset/overlay-initial-focus-rendered.md b/.changeset/overlay-initial-focus-rendered.md
deleted file mode 100644
index e39f292..0000000
--- a/.changeset/overlay-initial-focus-rendered.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-'@dunky.dev/dom-overlay': patch
-'@dunky.dev/dom-dialog': patch
----
-
-Initial focus now skips a candidate that didn't render.
-
-`getInitialFocus` filtered `[disabled]` and `[type="hidden"]` but never asked
-whether the element actually rendered. A field inside a collapsed section
-satisfied the selector and won the draw; `focus()` on it did nothing — and said
-nothing — so focus fell back to the dialog window, with the fallback's warning
-unable to fire, because from its point of view the fallback had succeeded. The
-overlay opened on its window instead of the field: degraded, not broken, and
-silent.
-
-A designated `initialFocus` that hadn't rendered was worse. It went straight to
-the window and skipped the form-field step entirely, contradicting the
-documented "when one is set **and can take focus**". So `getInitialFocus` now
-takes the designated element as a second argument and resolves the whole chain
-in one call, filtering every step rather than just the last:
-
-```ts
-// designated -> first form field -> the overlay window itself
-getInitialFocus(content, designatedElement).focus({ preventScroll: true })
-```
-
-Callers that were writing `initialFocus ?? getInitialFocus(content)` should
-pass the designated element in instead — the `??` is what spent it on a
-candidate that couldn't take focus. `@dunky.dev/dom-dialog` does this for every
-DOM substrate already, so a dialog's `initialFocus` inherits the fix without a
-change on the consumer's side.
-
-The predicate is `isRendered` from `@dunky.dev/dom-element`, shared with the
-focus trap so the two can't disagree on what counts as rendered.
diff --git a/.changeset/overlay-popup-focus.md b/.changeset/overlay-popup-focus.md
deleted file mode 100644
index d827cdd..0000000
--- a/.changeset/overlay-popup-focus.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-'@dunky.dev/dom-overlay': minor
----
-
-Two queries tell a layer when a popup it cannot see owns the keyboard:
-`foreignPopupHoldsFocus(id)` and `expandedPopupControlHoldsFocus(id)`.
-
-A popup can hold focus inside a layer without ever registering in the stack —
-a third-party listbox or menu, a combobox's list. The stack still names the
-layer topmost, so the layer would keep answering Escape and trapping Tab under
-the popup. The queries read the real owner from ARIA instead: focus in an
-element with a popup role (`aria-haspopup`'s values) that is neither the
-layer's window nor a registered layer, or on a control inside the window whose
-popup is expanded (`aria-expanded` with `aria-haspopup`). No cooperation from
-the popup is required.
-
-`POPUP_SELECTOR` — the selector naming those popup roles — is exported too, so
-a consumer can match the same set the queries do.
-
-```ts
-enabled: () => isTopmostLayer(id) && !foreignPopupHoldsFocus(id)
-```
diff --git a/.changeset/overlay-portal-branch-containment.md b/.changeset/overlay-portal-branch-containment.md
deleted file mode 100644
index 26c33d5..0000000
--- a/.changeset/overlay-portal-branch-containment.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-'@dunky.dev/dom-overlay': patch
----
-
-Page content beside a layer portalled into an app branch is now hidden by a
-modal layer's containment.
-
-Containment holds a few elements out of the hiding — the topmost modal layer,
-its backdrop, and the layers stacked above it — and it matched them by
-ancestry, so a branch that _contained_ one was skipped whole. Where a layer
-sits is the consumer's choice: `container` on the Portal part lets it land
-anywhere, and when that branch also held page content, the entire branch went
-unhidden — the page reachable by pointer, keyboard, and screen reader for as
-long as the layer was open.
-
-```tsx
-// The menu lands inside the app branch, beside the page content.
-
-```
-
-Hiding now descends from the body instead of walking up from the layer. A
-branch that holds one of those retained elements is descended into rather than
-spared, so the content beside it is hidden individually while the layer itself
-stays reachable. A layer at or above the body is a no-op — nothing sits
-outside it.
diff --git a/packages/dom/components/dialog/CHANGELOG.md b/packages/dom/components/dialog/CHANGELOG.md
index 73f6025..5f98c41 100644
--- a/packages/dom/components/dialog/CHANGELOG.md
+++ b/packages/dom/components/dialog/CHANGELOG.md
@@ -1,5 +1,102 @@
# @dunky.dev/dom-dialog
+## 0.3.0
+
+### Minor Changes
+
+- [#53](https://github.com/dunky-dev/ui/pull/53) [`2cc4a1b`](https://github.com/dunky-dev/ui/commit/2cc4a1b00966526497da15b4123060a2b2c104f0) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `Dialog.Content` gains `restoreFocus` — the close-side counterpart to
+ `initialFocus`. Closing still returns focus to whatever held it before the
+ dialog opened; `restoreFocus` names where it goes when that holder can't
+ meaningfully take focus back: focus sat on the body (a pointer press can
+ leave it there), or on an element removed from the document since.
+ Typically the dialog's trigger.
+
+ ```tsx
+ // React — a ref, read at close time
+ …
+
+ // Solid — an element or accessor, resolved at close time
+ trigger}>…
+ ```
+
+ Before, those two cases silently dropped focus: restoring to the body goes
+ nowhere, and focusing a disconnected element is a no-op, leaving focus
+ stranded on the closing layer. The element focused before opening still
+ always wins when it is meaningful — the fallback never overrides it. At the
+ DOM layer, `openDialogLayer` takes `restoreFocus?: () => HTMLElement | null`,
+ resolved at close.
+
+### Patch Changes
+
+- [#57](https://github.com/dunky-dev/ui/pull/57) [`da982c0`](https://github.com/dunky-dev/ui/commit/da982c0520c64d092be9aa87b4fc5b70a2852842) Thanks [@ivanbanov](https://github.com/ivanbanov)! - A popup opened inside the dialog owns Tab and Escape while it holds focus.
+
+ A select menu, a popover, a menu, or a combobox list inside a modal dialog —
+ from any library, as long as it carries ARIA popup semantics — is not a layer
+ the stack knows, so the dialog kept treating itself as topmost: its
+ capture-phase Escape closed the dialog together with the popup, and its focus
+ trap cancelled Tab inside the popup and pulled focus back into the window. Both
+ now stand down while such a popup holds focus and resume once focus is back in
+ the window, so one Escape closes the popup and the next reaches the dialog. A
+ control whose popup is expanded (a combobox input) hands over Escape only; Tab
+ is how its popup is left, so the trap keeps it.
+
+- [#50](https://github.com/dunky-dev/ui/pull/50) [`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Focus candidates barred by an ancestor are excluded: controls disabled through
+ `fieldset[disabled]` and anything inside `[inert]`.
+
+ `FOCUSABLE_SELECTOR` and the form-field selector gate on an element's own
+ attributes (`input:not([disabled])`), but both bars also arrive from
+ ancestors, so a barred control satisfied the selector while a browser refuses
+ to focus it — silently.
+
+ In the focus trap that was a hard dead end: the Tab keydown is already
+ `preventDefault()`-ed when focus is stepped by hand, so every press recomputed
+ the same refused target and focus never moved again. The cycle now only holds
+ what a browser would actually focus, keeping the native exception that
+ controls in a disabled fieldset's first `legend` stay enabled.
+
+ In the initial-focus chain it was the quieter failure mode: the barred field
+ won the draw, `focus()` no-opped, and focus fell to the overlay window even
+ when a viable field came later. Every candidate — designated element and form
+ fields alike — is now also filtered for these bars.
+
+ Both use the new `isFocusable` from `@dunky.dev/dom-element`, beside the
+ `isRendered` filter they already shared.
+
+- [#50](https://github.com/dunky-dev/ui/pull/50) [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Initial focus now skips a candidate that didn't render.
+
+ `getInitialFocus` filtered `[disabled]` and `[type="hidden"]` but never asked
+ whether the element actually rendered. A field inside a collapsed section
+ satisfied the selector and won the draw; `focus()` on it did nothing — and said
+ nothing — so focus fell back to the dialog window, with the fallback's warning
+ unable to fire, because from its point of view the fallback had succeeded. The
+ overlay opened on its window instead of the field: degraded, not broken, and
+ silent.
+
+ A designated `initialFocus` that hadn't rendered was worse. It went straight to
+ the window and skipped the form-field step entirely, contradicting the
+ documented "when one is set **and can take focus**". So `getInitialFocus` now
+ takes the designated element as a second argument and resolves the whole chain
+ in one call, filtering every step rather than just the last:
+
+ ```ts
+ // designated -> first form field -> the overlay window itself
+ getInitialFocus(content, designatedElement).focus({ preventScroll: true })
+ ```
+
+ Callers that were writing `initialFocus ?? getInitialFocus(content)` should
+ pass the designated element in instead — the `??` is what spent it on a
+ candidate that couldn't take focus. `@dunky.dev/dom-dialog` does this for every
+ DOM substrate already, so a dialog's `initialFocus` inherits the fix without a
+ change on the consumer's side.
+
+ The predicate is `isRendered` from `@dunky.dev/dom-element`, shared with the
+ focus trap so the two can't disagree on what counts as rendered.
+
+- Updated dependencies [[`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814), [`f5becd4`](https://github.com/dunky-dev/ui/commit/f5becd4f5e08e0fbf0930e65961c92e281f9e463), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814), [`da982c0`](https://github.com/dunky-dev/ui/commit/da982c0520c64d092be9aa87b4fc5b70a2852842), [`772a7df`](https://github.com/dunky-dev/ui/commit/772a7dfe18a58d070c1872b48ef8e6acec180723)]:
+ - @dunky.dev/dom-focus-trap@0.1.3
+ - @dunky.dev/dom-overlay@0.3.0
+ - @dunky.dev/browser-navigation@0.2.1
+
## 0.2.0
### Minor Changes
diff --git a/packages/dom/components/dialog/package.json b/packages/dom/components/dialog/package.json
index 91ebebf..9738d8d 100644
--- a/packages/dom/components/dialog/package.json
+++ b/packages/dom/components/dialog/package.json
@@ -1,6 +1,6 @@
{
"name": "@dunky.dev/dom-dialog",
- "version": "0.2.0",
+ "version": "0.3.0",
"description": "Framework-free DOM behavior for @dunky.dev/dialog: the document-level effects, the open and exit sequences, and the outside-press gating every DOM substrate shares.",
"license": "MIT",
"repository": {
diff --git a/packages/dom/utils/element/CHANGELOG.md b/packages/dom/utils/element/CHANGELOG.md
new file mode 100644
index 0000000..a0fb555
--- /dev/null
+++ b/packages/dom/utils/element/CHANGELOG.md
@@ -0,0 +1,42 @@
+# @dunky.dev/dom-element
+
+## 0.1.0
+
+### Minor Changes
+
+- [#50](https://github.com/dunky-dev/ui/pull/50) [`6c249f9`](https://github.com/dunky-dev/ui/commit/6c249f96dd6e3f821d4b71bae250f1d94e40298c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - New package: `@dunky.dev/dom-element`, framework-free predicates about a single
+ element.
+
+ `isRendered(element)` answers whether an element actually rendered, and so can
+ take focus, be pressed, or be read out. Presence in the DOM is not enough: an
+ element inside a collapsed section still answers `querySelector`, but `focus()`
+ on it does nothing and reports nothing.
+
+ ```ts
+ import { isRendered } from '@dunky.dev/dom-element'
+
+ for (const field of content.querySelectorAll('input, select, textarea')) {
+ if (isRendered(field)) {
+ field.focus()
+ break
+ }
+ }
+ ```
+
+ Checked: the `hidden` attribute (`hidden="until-found"` included),
+ `display: none` on the element or any ancestor — `display` doesn't inherit, so
+ ancestors are walked — `visibility: hidden | collapse`, and being detached.
+ Not checked: `opacity: 0` and `content-visibility`, which do render, and
+ rendering is what decides focusability.
+
+ `isFocusable(element)` is the sibling facet: whether anything bars the element
+ from taking focus. Disabling and inertness also arrive from ancestors — a
+ control inside a `fieldset[disabled]` subtree (with the native exception for
+ its first `legend`) or anything inside `[inert]` refuses `focus()` — which a
+ selector's own-attribute checks (`:not([disabled])`) can't see. The facets are
+ deliberately narrow and compose; the tab order stays the caller's question.
+
+ It's a package of its own because two utils have to agree on the answers:
+ `@dunky.dev/dom-focus-trap` filters its Tab cycle with them and
+ `@dunky.dev/dom-overlay` filters its initial-focus candidates, both guarding
+ against the same silent `focus()` no-op.
diff --git a/packages/dom/utils/element/package.json b/packages/dom/utils/element/package.json
index 0b5ffd8..998323e 100644
--- a/packages/dom/utils/element/package.json
+++ b/packages/dom/utils/element/package.json
@@ -1,6 +1,6 @@
{
"name": "@dunky.dev/dom-element",
- "version": "0.0.0",
+ "version": "0.1.0",
"description": "Framework-free element predicates — the DOM questions every primitive asks.",
"license": "MIT",
"repository": {
diff --git a/packages/dom/utils/focus-trap/CHANGELOG.md b/packages/dom/utils/focus-trap/CHANGELOG.md
index b674905..e6eab5a 100644
--- a/packages/dom/utils/focus-trap/CHANGELOG.md
+++ b/packages/dom/utils/focus-trap/CHANGELOG.md
@@ -1,5 +1,50 @@
# @dunky.dev/dom-focus-trap
+## 0.1.3
+
+### Patch Changes
+
+- [#50](https://github.com/dunky-dev/ui/pull/50) [`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Focus candidates barred by an ancestor are excluded: controls disabled through
+ `fieldset[disabled]` and anything inside `[inert]`.
+
+ `FOCUSABLE_SELECTOR` and the form-field selector gate on an element's own
+ attributes (`input:not([disabled])`), but both bars also arrive from
+ ancestors, so a barred control satisfied the selector while a browser refuses
+ to focus it — silently.
+
+ In the focus trap that was a hard dead end: the Tab keydown is already
+ `preventDefault()`-ed when focus is stepped by hand, so every press recomputed
+ the same refused target and focus never moved again. The cycle now only holds
+ what a browser would actually focus, keeping the native exception that
+ controls in a disabled fieldset's first `legend` stay enabled.
+
+ In the initial-focus chain it was the quieter failure mode: the barred field
+ won the draw, `focus()` no-opped, and focus fell to the overlay window even
+ when a viable field came later. Every candidate — designated element and form
+ fields alike — is now also filtered for these bars.
+
+ Both use the new `isFocusable` from `@dunky.dev/dom-element`, beside the
+ `isRendered` filter they already shared.
+
+- [#50](https://github.com/dunky-dev/ui/pull/50) [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814) Thanks [@ivanbanov](https://github.com/ivanbanov)! - The Tab cycle's rendered check now comes from `@dunky.dev/dom-element` instead
+ of a private copy.
+
+ `@dunky.dev/dom-overlay` needs the same predicate to filter its initial-focus
+ candidates, and two packages answering the question separately would drift.
+ The check itself is unchanged in intent — a non-rendered element is a no-op to
+ focus, so keeping one in the cycle would stall the trap on it — but sharing it
+ tightens two cases:
+ - A **detached** element is now excluded. It can't take focus, and computed
+ style on one reports the property defaults rather than `none`, so the display
+ walk alone let it through.
+ - A `display: none` ancestor **above the container** now excludes the
+ focusables under it. The private copy stopped its walk at the container.
+ Nothing inside a hidden container can take focus either way, so this lands on
+ the trap's documented behavior for an empty cycle: Tab is a no-op.
+
+- Updated dependencies [[`6c249f9`](https://github.com/dunky-dev/ui/commit/6c249f96dd6e3f821d4b71bae250f1d94e40298c)]:
+ - @dunky.dev/dom-element@0.1.0
+
## 0.1.2
### Patch Changes
diff --git a/packages/dom/utils/focus-trap/package.json b/packages/dom/utils/focus-trap/package.json
index a8a40e6..eecad59 100644
--- a/packages/dom/utils/focus-trap/package.json
+++ b/packages/dom/utils/focus-trap/package.json
@@ -1,6 +1,6 @@
{
"name": "@dunky.dev/dom-focus-trap",
- "version": "0.1.2",
+ "version": "0.1.3",
"description": "Framework-free Tab/Shift+Tab containment for a DOM subtree.",
"license": "MIT",
"repository": {
diff --git a/packages/dom/utils/navigation/CHANGELOG.md b/packages/dom/utils/navigation/CHANGELOG.md
index 1158995..fcf6649 100644
--- a/packages/dom/utils/navigation/CHANGELOG.md
+++ b/packages/dom/utils/navigation/CHANGELOG.md
@@ -1,5 +1,30 @@
# @dunky.dev/browser-navigation
+## 0.2.1
+
+### Patch Changes
+
+- [#52](https://github.com/dunky-dev/ui/pull/52) [`f5becd4`](https://github.com/dunky-dev/ui/commit/f5becd4f5e08e0fbf0930e65961c92e281f9e463) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `SPEC.md` (shipped with the package) gains a Scenarios section: 26 compact
+ traces of `interceptBackNavigation` and `watchSpentEntry` behavior, grouped
+ by one layer, release and consumption, forward/claims/reload, stacked
+ layers, and timing edges.
+
+ The behavior contract was already fully stated, but as one mechanism per
+ prose bullet — nothing let a reader replay a concrete flow end to end.
+ Each trace is a replayable episode in the module's own vocabulary (`arm`,
+ `release`, `Back`, `Forward`), e.g.:
+
+ ```
+ arm A -> arm B -> arm C -> Back -> release B -> Forward -> Forward
+ => the Back closes C; the first Forward soaks into B's abandoned entry;
+ the second asks C, which reopens only if it still can without B.
+ ```
+
+ Two outcomes the prose previously left implicit are now stated outright: a
+ deliberately released entry absorbs one Forward press (it can't be deleted,
+ only left to soak the traversal), and any re-plant — a new layer or a
+ veto's re-arm — truncates every parked Forward watch above it.
+
## 0.2.0
### Minor Changes
diff --git a/packages/dom/utils/navigation/package.json b/packages/dom/utils/navigation/package.json
index 9af86d6..fd942f0 100644
--- a/packages/dom/utils/navigation/package.json
+++ b/packages/dom/utils/navigation/package.json
@@ -1,6 +1,6 @@
{
"name": "@dunky.dev/browser-navigation",
- "version": "0.2.0",
+ "version": "0.2.1",
"description": "Framework-free browser-navigation helpers: a session-history guard so the host's Back dismisses a layer instead of leaving the page.",
"license": "MIT",
"repository": {
diff --git a/packages/dom/utils/overlay/CHANGELOG.md b/packages/dom/utils/overlay/CHANGELOG.md
index c8878ec..58ab9f6 100644
--- a/packages/dom/utils/overlay/CHANGELOG.md
+++ b/packages/dom/utils/overlay/CHANGELOG.md
@@ -1,5 +1,107 @@
# @dunky.dev/dom-overlay
+## 0.3.0
+
+### Minor Changes
+
+- [#57](https://github.com/dunky-dev/ui/pull/57) [`da982c0`](https://github.com/dunky-dev/ui/commit/da982c0520c64d092be9aa87b4fc5b70a2852842) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Two queries tell a layer when a popup it cannot see owns the keyboard:
+ `foreignPopupHoldsFocus(id)` and `expandedPopupControlHoldsFocus(id)`.
+
+ A popup can hold focus inside a layer without ever registering in the stack —
+ a third-party listbox or menu, a combobox's list. The stack still names the
+ layer topmost, so the layer would keep answering Escape and trapping Tab under
+ the popup. The queries read the real owner from ARIA instead: focus in an
+ element with a popup role (`aria-haspopup`'s values) that is neither the
+ layer's window nor a registered layer, or on a control inside the window whose
+ popup is expanded (`aria-expanded` with `aria-haspopup`). No cooperation from
+ the popup is required.
+
+ `POPUP_SELECTOR` — the selector naming those popup roles — is exported too, so
+ a consumer can match the same set the queries do.
+
+ ```ts
+ enabled: () => isTopmostLayer(id) && !foreignPopupHoldsFocus(id)
+ ```
+
+### Patch Changes
+
+- [#50](https://github.com/dunky-dev/ui/pull/50) [`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Focus candidates barred by an ancestor are excluded: controls disabled through
+ `fieldset[disabled]` and anything inside `[inert]`.
+
+ `FOCUSABLE_SELECTOR` and the form-field selector gate on an element's own
+ attributes (`input:not([disabled])`), but both bars also arrive from
+ ancestors, so a barred control satisfied the selector while a browser refuses
+ to focus it — silently.
+
+ In the focus trap that was a hard dead end: the Tab keydown is already
+ `preventDefault()`-ed when focus is stepped by hand, so every press recomputed
+ the same refused target and focus never moved again. The cycle now only holds
+ what a browser would actually focus, keeping the native exception that
+ controls in a disabled fieldset's first `legend` stay enabled.
+
+ In the initial-focus chain it was the quieter failure mode: the barred field
+ won the draw, `focus()` no-opped, and focus fell to the overlay window even
+ when a viable field came later. Every candidate — designated element and form
+ fields alike — is now also filtered for these bars.
+
+ Both use the new `isFocusable` from `@dunky.dev/dom-element`, beside the
+ `isRendered` filter they already shared.
+
+- [#50](https://github.com/dunky-dev/ui/pull/50) [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Initial focus now skips a candidate that didn't render.
+
+ `getInitialFocus` filtered `[disabled]` and `[type="hidden"]` but never asked
+ whether the element actually rendered. A field inside a collapsed section
+ satisfied the selector and won the draw; `focus()` on it did nothing — and said
+ nothing — so focus fell back to the dialog window, with the fallback's warning
+ unable to fire, because from its point of view the fallback had succeeded. The
+ overlay opened on its window instead of the field: degraded, not broken, and
+ silent.
+
+ A designated `initialFocus` that hadn't rendered was worse. It went straight to
+ the window and skipped the form-field step entirely, contradicting the
+ documented "when one is set **and can take focus**". So `getInitialFocus` now
+ takes the designated element as a second argument and resolves the whole chain
+ in one call, filtering every step rather than just the last:
+
+ ```ts
+ // designated -> first form field -> the overlay window itself
+ getInitialFocus(content, designatedElement).focus({ preventScroll: true })
+ ```
+
+ Callers that were writing `initialFocus ?? getInitialFocus(content)` should
+ pass the designated element in instead — the `??` is what spent it on a
+ candidate that couldn't take focus. `@dunky.dev/dom-dialog` does this for every
+ DOM substrate already, so a dialog's `initialFocus` inherits the fix without a
+ change on the consumer's side.
+
+ The predicate is `isRendered` from `@dunky.dev/dom-element`, shared with the
+ focus trap so the two can't disagree on what counts as rendered.
+
+- [#49](https://github.com/dunky-dev/ui/pull/49) [`772a7df`](https://github.com/dunky-dev/ui/commit/772a7dfe18a58d070c1872b48ef8e6acec180723) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Page content beside a layer portalled into an app branch is now hidden by a
+ modal layer's containment.
+
+ Containment holds a few elements out of the hiding — the topmost modal layer,
+ its backdrop, and the layers stacked above it — and it matched them by
+ ancestry, so a branch that _contained_ one was skipped whole. Where a layer
+ sits is the consumer's choice: `container` on the Portal part lets it land
+ anywhere, and when that branch also held page content, the entire branch went
+ unhidden — the page reachable by pointer, keyboard, and screen reader for as
+ long as the layer was open.
+
+ ```tsx
+ // The menu lands inside the app branch, beside the page content.
+
+ ```
+
+ Hiding now descends from the body instead of walking up from the layer. A
+ branch that holds one of those retained elements is descended into rather than
+ spared, so the content beside it is hidden individually while the layer itself
+ stays reachable. A layer at or above the body is a no-op — nothing sits
+ outside it.
+
+- Updated dependencies [[`6c249f9`](https://github.com/dunky-dev/ui/commit/6c249f96dd6e3f821d4b71bae250f1d94e40298c)]:
+ - @dunky.dev/dom-element@0.1.0
+
## 0.2.0
### Minor Changes
diff --git a/packages/dom/utils/overlay/package.json b/packages/dom/utils/overlay/package.json
index 2672b41..13ea300 100644
--- a/packages/dom/utils/overlay/package.json
+++ b/packages/dom/utils/overlay/package.json
@@ -1,6 +1,6 @@
{
"name": "@dunky.dev/dom-overlay",
- "version": "0.2.0",
+ "version": "0.3.0",
"description": "Framework-free DOM behavior for overlay substrates: the shared layer stack with assistive-tech containment, the exit window, and initial focus.",
"license": "MIT",
"repository": {
diff --git a/packages/react/dialog/CHANGELOG.md b/packages/react/dialog/CHANGELOG.md
index 5a8051d..441b1f4 100644
--- a/packages/react/dialog/CHANGELOG.md
+++ b/packages/react/dialog/CHANGELOG.md
@@ -1,5 +1,37 @@
# @dunky.dev/react-dialog
+## 0.5.0
+
+### Minor Changes
+
+- [#53](https://github.com/dunky-dev/ui/pull/53) [`2cc4a1b`](https://github.com/dunky-dev/ui/commit/2cc4a1b00966526497da15b4123060a2b2c104f0) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `Dialog.Content` gains `restoreFocus` — the close-side counterpart to
+ `initialFocus`. Closing still returns focus to whatever held it before the
+ dialog opened; `restoreFocus` names where it goes when that holder can't
+ meaningfully take focus back: focus sat on the body (a pointer press can
+ leave it there), or on an element removed from the document since.
+ Typically the dialog's trigger.
+
+ ```tsx
+ // React — a ref, read at close time
+ …
+
+ // Solid — an element or accessor, resolved at close time
+ trigger}>…
+ ```
+
+ Before, those two cases silently dropped focus: restoring to the body goes
+ nowhere, and focusing a disconnected element is a no-op, leaving focus
+ stranded on the closing layer. The element focused before opening still
+ always wins when it is meaningful — the fallback never overrides it. At the
+ DOM layer, `openDialogLayer` takes `restoreFocus?: () => HTMLElement | null`,
+ resolved at close.
+
+### Patch Changes
+
+- Updated dependencies [[`da982c0`](https://github.com/dunky-dev/ui/commit/da982c0520c64d092be9aa87b4fc5b70a2852842), [`2cc4a1b`](https://github.com/dunky-dev/ui/commit/2cc4a1b00966526497da15b4123060a2b2c104f0), [`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814)]:
+ - @dunky.dev/dom-dialog@0.3.0
+ - @dunky.dev/react-use-focus-trap@0.1.3
+
## 0.4.0
### Minor Changes
diff --git a/packages/react/dialog/package.json b/packages/react/dialog/package.json
index 2d9d979..b22e7cc 100644
--- a/packages/react/dialog/package.json
+++ b/packages/react/dialog/package.json
@@ -1,6 +1,6 @@
{
"name": "@dunky.dev/react-dialog",
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "React binding for @dunky.dev/dialog.",
"license": "MIT",
"repository": {
diff --git a/packages/react/hooks/use-focus-trap/CHANGELOG.md b/packages/react/hooks/use-focus-trap/CHANGELOG.md
index 82b4e2e..308f7a5 100644
--- a/packages/react/hooks/use-focus-trap/CHANGELOG.md
+++ b/packages/react/hooks/use-focus-trap/CHANGELOG.md
@@ -1,5 +1,12 @@
# @dunky.dev/react-use-focus-trap
+## 0.1.3
+
+### Patch Changes
+
+- Updated dependencies [[`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814)]:
+ - @dunky.dev/dom-focus-trap@0.1.3
+
## 0.1.2
### Patch Changes
diff --git a/packages/react/hooks/use-focus-trap/package.json b/packages/react/hooks/use-focus-trap/package.json
index 9bd09e1..b01d277 100644
--- a/packages/react/hooks/use-focus-trap/package.json
+++ b/packages/react/hooks/use-focus-trap/package.json
@@ -1,6 +1,6 @@
{
"name": "@dunky.dev/react-use-focus-trap",
- "version": "0.1.2",
+ "version": "0.1.3",
"description": "React binding for @dunky.dev/dom-focus-trap.",
"license": "MIT",
"repository": {
diff --git a/packages/solid/dialog/CHANGELOG.md b/packages/solid/dialog/CHANGELOG.md
index 6182145..7fdc12f 100644
--- a/packages/solid/dialog/CHANGELOG.md
+++ b/packages/solid/dialog/CHANGELOG.md
@@ -1,5 +1,37 @@
# @dunky.dev/solid-dialog
+## 0.3.0
+
+### Minor Changes
+
+- [#53](https://github.com/dunky-dev/ui/pull/53) [`2cc4a1b`](https://github.com/dunky-dev/ui/commit/2cc4a1b00966526497da15b4123060a2b2c104f0) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `Dialog.Content` gains `restoreFocus` — the close-side counterpart to
+ `initialFocus`. Closing still returns focus to whatever held it before the
+ dialog opened; `restoreFocus` names where it goes when that holder can't
+ meaningfully take focus back: focus sat on the body (a pointer press can
+ leave it there), or on an element removed from the document since.
+ Typically the dialog's trigger.
+
+ ```tsx
+ // React — a ref, read at close time
+ …
+
+ // Solid — an element or accessor, resolved at close time
+ trigger}>…
+ ```
+
+ Before, those two cases silently dropped focus: restoring to the body goes
+ nowhere, and focusing a disconnected element is a no-op, leaving focus
+ stranded on the closing layer. The element focused before opening still
+ always wins when it is meaningful — the fallback never overrides it. At the
+ DOM layer, `openDialogLayer` takes `restoreFocus?: () => HTMLElement | null`,
+ resolved at close.
+
+### Patch Changes
+
+- Updated dependencies [[`da982c0`](https://github.com/dunky-dev/ui/commit/da982c0520c64d092be9aa87b4fc5b70a2852842), [`2cc4a1b`](https://github.com/dunky-dev/ui/commit/2cc4a1b00966526497da15b4123060a2b2c104f0), [`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814)]:
+ - @dunky.dev/dom-dialog@0.3.0
+ - @dunky.dev/solid-use-focus-trap@0.1.2
+
## 0.2.0
### Minor Changes
diff --git a/packages/solid/dialog/package.json b/packages/solid/dialog/package.json
index c885d16..8d0e5a6 100644
--- a/packages/solid/dialog/package.json
+++ b/packages/solid/dialog/package.json
@@ -1,6 +1,6 @@
{
"name": "@dunky.dev/solid-dialog",
- "version": "0.2.0",
+ "version": "0.3.0",
"description": "Solid binding for @dunky.dev/dialog.",
"license": "MIT",
"repository": {
diff --git a/packages/solid/hooks/use-focus-trap/CHANGELOG.md b/packages/solid/hooks/use-focus-trap/CHANGELOG.md
index 0115914..f4cebc8 100644
--- a/packages/solid/hooks/use-focus-trap/CHANGELOG.md
+++ b/packages/solid/hooks/use-focus-trap/CHANGELOG.md
@@ -1,5 +1,12 @@
# @dunky.dev/solid-use-focus-trap
+## 0.1.2
+
+### Patch Changes
+
+- Updated dependencies [[`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814)]:
+ - @dunky.dev/dom-focus-trap@0.1.3
+
## 0.1.1
### Patch Changes
diff --git a/packages/solid/hooks/use-focus-trap/package.json b/packages/solid/hooks/use-focus-trap/package.json
index 7ffa5c4..402b0f0 100644
--- a/packages/solid/hooks/use-focus-trap/package.json
+++ b/packages/solid/hooks/use-focus-trap/package.json
@@ -1,6 +1,6 @@
{
"name": "@dunky.dev/solid-use-focus-trap",
- "version": "0.1.1",
+ "version": "0.1.2",
"description": "Solid binding for @dunky.dev/dom-focus-trap.",
"license": "MIT",
"repository": {