Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/layer-exit-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: the layer runtime gains one exit copy for touch presentations that animate a popover out: a static, inert copy of the closing popover, shown only in the top layer where the popover was drawn, for the close to play on. Core's other inert copies now join no form, carry no anchor name and keep their color scheme, as their documentation says.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/popover-uikit-presentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feature] Under a finger (a coarse primary pointer, on any touch device), Popover grows out of its trigger, wide before tall, with its edge held on the trigger, and shrinks back into the trigger when it closes. The growth plays the theme's `--duration-medium-min` (in the Storybook theme, the step nearest iOS's 206 ms growth) with its entrance easing, and the shrink plays its exit duration and easing, on the compositor. A close during the growth, or an open during the shrink, turns back from where the popover is. With reduced motion it cross-fades in and out, opacity only, in 200 ms or less. A fine primary pointer (a mouse) keeps its entrance. A tap that lands while it grows is ignored, and a pan started then scrolls nothing. The shrink plays on the layer runtime's exit copy, of which Popover is the first user.

@vjeux
49 changes: 44 additions & 5 deletions docs/architecture/layer-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ verified_by:
packages/core/src/Layer/useLayerDismissal.test.tsx,
packages/core/src/Layer/layerClickBoundary.test.tsx,
packages/core/src/Layer/layerDismissalInvariants.test.tsx,
packages/core/src/Layer/layerExitCopy.test.ts,
packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts,
packages/core/src/Popover/Popover.test.tsx,
packages/core/src/DropdownMenu/DropdownMenu.test.tsx,
packages/core/src/ContextMenu/ContextMenu.test.tsx,
Expand Down Expand Up @@ -67,6 +69,7 @@ deciding_specs: [spec:AST-038, spec:AST-059]
"INV7",
"INV11",
"INV12",
"INV13",
"INV14",
"INV15",
"INV17",
Expand Down Expand Up @@ -314,6 +317,26 @@ else closes the layer, a scroll or pinch that starts outside does not, and
Escape stays on the shared stack. Focus leaving the field stays each owner's to
handle (`family:overlay-dismissal` AV4).

### Exit copy

The browser hides a popover the moment it closes, whether by a pick, Escape, or
its own light dismiss. A touch presentation whose close stays on screen plays it
on an exit copy: a static copy of the layer that `placeLayerExitCopy` takes as
the layer closes. The copy is laid out on the layer's own box and carries the
transform and opacity the layer is drawn with, so a copy taken while the layer
is still entering holds where it was drawn. It carries no ids, form names, or
form owner; it is `inert`, hidden from assistive technology, and out of pointer
reach; it keeps the layer's scroll positions. It is shown only in the top layer,
as a manual popover, so a clipping, transformed, or sticky ancestor neither cuts
nor moves it; a fixed box or a larger `z-index` is no substitute
(`spec:AST-027` FR8). Where the browser refuses that show during the closing
popover's own toggle, the copy waits, hidden, and enters the top layer once the
toggle is over: after the script that closed the layer, or in the next frame
before it paints. Where there is no top layer (no popover API), or the browser
still refuses, there is no copy and the close is immediate. The presentation
that placed the copy plays its exit on it (`spec:AST-094` FR7) and removes it
when its close settles.

### Current dismissal plumbing

`family:overlay-dismissal` owns the shipped Escape/platform-close membership
Expand Down Expand Up @@ -524,6 +547,12 @@ layers use Layer rendering without joining Escape/platform dismissal.
correct, and the runtime polls nothing: an anchor that a scrolled
container hides is followed again when an intersection observer reports it
back in view.
- **INV13 — One exit copy per closing layer.** A layer's close plays on at most

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This INV13, the Exit copy section (lines 304-321) and the keyboard wording added to INV11 and INV12 are normative text that no engineering owner has approved at this head, and an engineering owner asked on this pull request that it be dropped here, since record text lands only through an approved spec pull request. It arrives from #8186 and #8208, so landing those first, with the text approved there or split into its own spec pull request, takes it out of this diff.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This INV13, with the Exit copy section above it, is record text CURRENT does not have, and an engineering owner asked that it not land through this pull request (“Record text lands only through an approved spec PR”). It arrives from #8186; landing it there with its own approval, or in a spec pull request, takes it out of this diff.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This INV13, with the Exit copy section (lines 304-321) and the rows at 565-571, 602-607, 629-632 and 682, is record text that main does not have, and an engineering owner asked on this pull request that record text land only through an approved spec pull request. It arrives from #8186 and #8587; landing those first and rebasing removes it from this diff.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This INV13, with the Exit copy section (lines 304-321) and the rows at 565-571, 602-607, 629-632 and 682, is record text main does not have, and an engineering owner asked on this pull request that record text land only through an approved spec pull request. It arrives with #8186 and #8587; landing those first and rebasing removes it from this diff.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This INV13, with the Exit copy section (lines 304-321) and the rows at 565-571, 602-607, 629-632 and 682, is record text main does not have, and an engineering owner asked that record text land only through an approved spec pull request. It comes from #8186: land it there, or in its own spec pull request, and rebase so this diff no longer carries it.

one exit copy, made by `placeLayerExitCopy`: drawn where the layer was drawn,
inert, hidden from assistive technology, without ids, form names, or form
owner, shown only in the top layer, and removed by its owner when the close
settles. Placing a second copy for the same layer removes the first; another
layer's copy stays.
- **INV14 — The runtime owns every layer's shared mechanism.** Internally, the layer runtime owns the shared
mechanism layers use: top-layer mounting, close-request delivery, the
presentation hold (keeping a layer's chosen presentation from the open until
Expand Down Expand Up @@ -591,11 +620,12 @@ Current gaps are observable facts, not current target behavior:
migration, verification, and completion criteria for these gaps. This record must
be updated only as that work ships.

INV14 and INV15 are not met yet, and `spec:AST-003` does not cover them: no
shared presentation hold, exit copy, runtime backdrop tint, or lift module
exists, and Dialog, Drawer, BottomSheet, Lightbox, MobileNav, ToastViewport,
and Selector each build their own top-layer mounting, exit, or backdrop, and
Popover builds its own presentation hold for its sheet presentation.
INV14 and INV15 are not met yet, and `spec:AST-003` does not cover them. The
runtime owns the shared exit copy (INV13, `placeLayerExitCopy`); no shared
presentation hold, runtime backdrop tint or lift module exists, Dialog, Drawer,
BottomSheet, Lightbox, MobileNav, ToastViewport, and Selector each build their
own top-layer mounting, exit, or backdrop, and Popover builds its own
presentation hold for its sheet presentation.
BottomSheet's own includes its sheet stack's mounting — the re-show that
keeps a document's sheets in open order, a step dialog's modal present, and
the non-modal sheet's popover host — alongside its scrim; the ordering rules
Expand Down Expand Up @@ -648,6 +678,12 @@ is staged as `[breaking]` (`spec:AST-017` FR32) and `component:SelectableCard`
entry/return, and controlled close behavior.
- A browser fallback change updates public `useLayer` and Popover docs and tests
for both the native path and actual fallback.
- An exit-copy change verifies, where each can be observed: the copy's reach,
accessibility and form exclusion in jsdom; its top-layer show (from script
and by light dismiss), its place mid-entrance, and that a layer keeps one
copy, in jsdom and in Chromium and WebKit; and the refused show's wait in
jsdom and on Chromium 150 and later, the engines that refuse a show during
another popover's toggle (Chromium 149 accepts it).

## Owning code

Expand Down Expand Up @@ -677,6 +713,8 @@ is staged as `[breaking]` (`spec:AST-017` FR32) and `component:SelectableCard`
`stopPropagation()` also stops the native event in React 19, which would
break ContextMenu's outside `mousedown`, Typeahead's pending click, and the
layer's keep-open `pointerup`, so the seam never stops the native event.
- `Layer/layerExitCopy.ts` owns the exit copy a touch presentation's close plays
on.
- `Layer/layerStack.ts`, `Layer/useLayerDismissal.ts`, and
`Layer/LayerDepthContext.tsx` own current registration, presence, ordering, and
Escape/platform routing.
Expand Down Expand Up @@ -730,6 +768,7 @@ this current architecture record.
| INV3–INV5 | `useLayer.test.tsx` and `anchorName.test.ts` | A mode leaks geometry, RTL resolves from the wrong context, fallback clips, or a sibling anchor is lost |
| INV11 | `layerViewportInset.test.ts`, `useLayer.test.tsx`, and the `Core/Layer` viewport-inset stories under the story play guard at a desktop and a phone viewport; `BaseTypeahead.incremental.test.tsx` and the Typeahead incremental-results stories for the `spec:AST-092` FR6 exception | A second gutter definition, a `100%`-of-span cap, a layer narrower than its explicit size, a layer box wider than the viewport, a layer inside the gutter, or a layer that slides toward an off-screen anchor; for an open Typeahead popup, a later list for the same query that flips it to the other side or grows it past the room on its side, or a first list for a query that is capped to the span beside the trigger |
| INV12 | The `Core/Layer` viewport-inset stories' first-frame comparison, and `useLayer.test.tsx`'s synchronous anchor read | A layer whose first painted rectangle differs from its settled one |
| INV13 | `layerExitCopy.test.ts` and `layerExitCopy.interaction.browser.spec.ts` | A copy with an id, a form name, a form owner, or pointer reach; a copy off the layer's drawn box; a copy outside the top layer; a second copy for one layer |
| INV6, INV7 | `useLayer.test.tsx`, `Popover.test.tsx`, `DropdownMenu.test.tsx`, and `useMenuHover.test.tsx` | Duplicate close callback or the same press/re-hover reopens a surface |
| INV8 | `useLayerDismissal.test.tsx`, `layerDismissalInvariants.test.tsx`, and `useFocusTrap.test.tsx` | Current top registered layer is skipped, two layers close, or a blocker leaks through |
| INV9 | Representative Dialog, ContextMenu, Tooltip/HoverCard, and BottomSheet source/tests; `useBackdropPress.test.tsx`; `outsidePress.test.ts` | A current local channel silently changes ownership or policy |
Expand Down
7 changes: 6 additions & 1 deletion docs/specs/components/Popover/Popover.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ table owns their semantic effect.
| FR6 | Component anatomy contains the caller trigger and content, one painted Popover surface, and the optional fallback close control. Popover owns no Header, Body, or separate shared-hook surface part. | Current source, docs target inventory, and tests | Accepted anatomy; stale consumer anatomy corrected by this contract |
| FR7 | The painted surface has one broad canonical target, `popover`; composed components may add one authoritative component-specific refinement target. | Owner direction on 2026-08-31 plus current target inventory | Accepted canonical contract; covered by focused target tests |
| FR8 | The surface padding is one spacing-scale rung applied on the painted surface. Popover defaults to step 3 (the released inset); `0` paints a flush surface; a direct `usePopover` composition paints none unless it asks for a rung. The rung sits where a theme's `padding` on the `popover` target replaces it. | PR #6683 and its Popover tests; `architecture:container-padding/INV8` (padding alone does not enroll) | Pending owner review (DEC-4); implemented and covered by the Popover surface-padding suite |
| FR9 | Under a finger, Popover grows out of its trigger on the theme's entrance duration and easing tokens and shrinks back into it on the theme's exit duration and easing, and a touch during its growth is ignored, as `spec:AST-094` FR31, FR35 and FR45 state. Its dismissal is unchanged, and a mouse keeps today's entrance. | `spec:AST-094` FR31, FR35 | Pending implementation; tests land with it |
| FR9 | Under a finger, Popover grows from its trigger on `--duration-medium-min`, the Storybook step nearest iOS's 206 ms growth, with `--ease-entrance` (`spec:AST-094` FR45), and shrinks back on the theme's exit duration and easing. A touch while it grows is ignored (FR35). Dismissal and a mouse's entrance are unchanged. | `spec:AST-094` FR31, FR35 | Implemented; gap: the open-to-close latch waits for the shared presentation hold |

### Allowed variation

Expand Down Expand Up @@ -252,6 +252,10 @@ table owns their semantic effect.
pending coalesce into one animation-frame callback.
- **PR3 — Stable state update.** Measurement updates overflow state only when the
overflow boolean changes.
- **PR4 — Touch presentation frames.** On a page with no other frame work, the
touch growth and shrink do no Layout or Paint per frame. Known platform cost:
in Chromium, a page with its own `requestAnimationFrame` loop makes an
anchored, animating layer lay out and paint every frame.

## Accessibility contract

Expand Down Expand Up @@ -357,6 +361,7 @@ consumers that need distinct theme reachability provide and document an owned
| ORD2, FR4 | `Popover.test.tsx` sizing/overflow suites; `Popover.stories.tsx` match-trigger and overflow scenarios; geometry under `Core/Layer` viewport-inset stories | Explicit width clamped to the viewport, trigger matching, fitting and overflowing content | Reversing width precedence, capping to the span beside the trigger, or always enabling scroll fails emitted-style or overflow assertions. | `audit:Popover/layout` |
| FR8 | `Popover.test.tsx` surface-padding suite | Default rung, `0`, an explicit rung, a direct hook composition with and without a rung | A default other than step 3, a rung landing on a box inside the surface, or the hook painting a rung it was not given fails the suite. | `audit:Popover/theming` |
| FR9 | `spec:AST-094` verification for FR31 and FR35 | Under a finger: open, close, a touch during growth; a mouse | A Popover that does not grow from and shrink into its trigger, a changed dismissal, or a changed mouse path fails. | `audit:Popover/behavior` |
| PR4 | `spec:AST-094` verification for FR5 | The touch growth and shrink in a Chromium trace, on a page with no other frame work | A Layout or a Paint during either motion on such a page | `audit:Popover/resources` |
| FR5 | `Popover.test.tsx` scheduling and observer-lifecycle suites | Closed, opened, repeated signals, cleanup | Constructing observers while closed or measuring more than once per pending frame fails lifecycle assertions. | `audit:Popover/resources` |
| FR6, FR7 and theming anatomy | `Popover.test.tsx`, `Popover.doc.mjs`, `usePopover.doc.mjs`, `astryx theme targets Popover --json`, and `scripts/check-knowledge.mjs` | One real surface with canonical `popover` plus composed-component refinements | A fake anatomy part, missing canonical output, or an active target without a real owner fails review or validation. | `audit:Popover/theming` |
| AR5 | `Popover.stories.tsx` manual-AT fixture and PR #5373 test record | Read-only dialog manual-AT fixture; no recorded NVDA/VoiceOver announcement result | A fixture without recorded AT/browser observations cannot be cited as announcement proof. | `audit:Popover/at-evidence` |
Expand Down
Loading