Skip to content

feat(lab): under a finger, NavigationStack's bar takes the iOS 26 look - #8396

Draft
vjeux wants to merge 1745 commits into
meta:mainfrom
vjeux:uikit/c16-stack-bar
Draft

vjeux wants to merge 1745 commits into
meta:mainfrom
vjeux:uikit/c16-stack-bar

Conversation

@vjeux

@vjeux vjeux commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #8204 (NavigationStack search) and #8142 (the scroll edge effect). Review only this PR's change: vjeux/astryx@98fded9...297d301

Carries #8562 (Core's internal entries) as a dependency; if the entries land differently, this follows her decision.

User impact

Under a finger (spec:AST-094 FR13), NavigationStack's bars become iOS 26's: no background and no hairline. Once content is 10 px under the clear bar, a veil of the body colour fades in behind the title (iOS 26.2's scroll edge effect). Each screen's veil rides its card through a push, a pop or a back swipe. A mouse keeps today's opaque bar.

Visual authority

#8142's look (AppShell's touch bar) on Lab's NavigationStack: the decision governing #8142 governs this.

Before and after

iOS 26.2 Safari, before (left) and after: a finger drags the list.

iOS 26.2: the opaque bar becomes clear, with the veil fading in

A push, 4× slower: the incoming screen cuts the old title at its edge, as UIKit's does.

iOS 26.2: a push cuts the old title at the card's edge

Android 15 (emulator), mask off, then a still with it on:

Android: the bar clear, the veil (mask off for capture)

Android: the veil with its mask

State matrix

  • A finger's scroll fades the veil on UIKit's springs (internal constants, spec:AST-094 DEC-11); a programmatic scroll switches it at once, with no compositing layer (feat(AppShell): under a finger, the pinned touch header is an iOS 26 bar with its scroll edge effect #8142's iOS 26.2 fix).
  • The covered bar is cut at the top card's leading edge on the cards' spring (or the finger), mirrored right to left, shut at rest.
  • Reduce Motion: the cards cross-fade in place, the titles swap at once, as UIKit's do. Reduce Transparency: the veil is an opaque band.
  • A mouse, and a pointer that turns fine after a push: opaque bars, nothing cut.

Design

Size (INV6, analyze-pr, gzip, base → head): NavigationStack's import 64,053 → 66,452 B of JavaScript, CSS 1,105 → 1,180 B; AppShell's 85,267 → 85,645 B, CSS 12,281 → 12,305 B.

Scope

  • Appearance is the primary intent.
  • Behavior, API, interaction model, and unrelated layout changes were removed or split.
  • The evidence shows the changed pixels actively running, not only source or computed styles.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm exec vitest run packages/core/src/{motion,AppShell,gestures,theme,internal,Layout} packages/core/src/utils/touchPresentation.test.ts packages/lab/src/NavigationStack: 90 files, 2924 tests pass, motion/uikit/tableImports.test.ts included. New jsdom cases: the effect registers the springs it was handed before observing (nothing under a mouse); a scroll container's root is measured once and observed again from its padding edge where the engine starts it at the content box; a programmatic switch is written without promotion; a finger's landing composites the veil in a scroll container until it lifts, and through its momentum until scrollend; the caller's sentinel styles apply, and NavigationStack's sentinel is the content's first child, with no wrapper. The finger cases declare a coarse primary pointer, since the shared gesture host listens only there (spec:AST-094 FR19, FR24).
  • pnpm test:interaction NavigationStack AppShellScrollEdge gestures.browser against a fresh pnpm storybook:build: 116 pass in Chromium; 99 pass and 17 Chromium-only skips in WebKit. New proofs under a finger: the incoming screen cuts the old title while the main thread is blocked; mid-push the cut rides the incoming card's leading edge, left to right and right to left (within 2 px, read from the layers' animated translate: in a burst of animation frames WebKit can return one layer's box a frame behind its animation); at rest the covered bar is cut away and the top bar whole; Back reveals it whole; a push during a push; a pointer that turns fine after a push leaves the revealed bar whole (fails before the fix). feat(lab): NavigationStack hands its bar to a search field #8204's two opaque-bar proofs now run with a mouse, each asserting a fine pointer.
  • iOS 26.2 simulator (iPhone 17 Pro, Safari), at 8363522 (this PR's commit before the latest base merge): a finger's fade, 61 animated frames; a programmatic return, no animation and no blank title frame, 3 of 3 runs. At the previous head (the same bar code): a 300 ms main-thread block right after a push starts cuts the old title at the card's edge from the first moved frame; a pop from a screen scrolled 200 px keeps its veiled content under the leaving bar. The iOS recordings above are from an earlier head, which differs only in when the veil is composited and in the sentinel's wrapper; neither changes a pixel.
  • Element counts (Chromium, each story at rest; the same under a mouse and a finger), base → head: Settings 49 → 53, Pushed (three screens, two bars) 104 → 114, Large title 123 → 127, Search 113 → 117. Each screen adds the veil and its sentinel, each bar the clip box and its row. The sentinel is the box IntersectionObserver watches. The veil is an element because the hook is shared with AppShell: the animator drives a pseudo-element through a filling animation that holds its resting value, which keeps it composited at rest, and on iOS 26.2 a composited layer under AppShell's sticky header drops the header's items for about two frames on every programmatic scroll (reduced to plain HTML: no layer, 0 of 5 takes blank; a composited veil that never switches, 5 of 5). A cut that moves on the compositor needs an overflow box with its row moved back inside it, because a clip-path animation is not composited in WebKit. The sentinel's positioned wrapper is gone: it sits at its static position in the scroller.
  • Android 15 emulator (recording lease, real finger drag, no rest before the move). At 8363522, Chromium 158: a drag fades the veil, 63 animated frames (feat(lab): NavigationStack hands its bar to a search field #8204's head: none, no veil), and the still with the mask on. At the previous head, Chrome 124 (the recording and still above), 4 takes: 65 animated frames each. CDP traces (19 takes): a frame is lost at each drag's start on this emulator on every build, with no veil too (feat(lab): NavigationStack hands its bar to a search field #8204's head, 4 of 4), and sometimes two with the veil (7 of 15: the GPU process rasters its layer as the finger lands, RasterDecoderImpl::DoEndRasterCHROMIUM); no stall later in a drag.
  • Legibility under the veil, Chromium, black content in light mode and white in dark: the title keeps 9.5:1 / 7.6:1 at worst; the back chevron sits on its platter (5.4:1 / 5.9:1).
  • registry-contract (preview) (DOCSITE_TARGET=canary node apps/docsite/scripts/generate-data.mjs, then node internal/shadcn-registry/verify-full-catalog.mjs): 1038 items and 1042 routes build, with Lab taking Core's modules through internal/motion and internal/touch; the codemod (lab-internal-imports.mjs) leaves 0 imports to move.
  • scripts/touch-support-bundle.test.mjs › "is absent from the root entry" is red: AppShell's touch bar (feat(AppShell): under a finger, the pinned touch header is an iOS 26 bar with its scroll edge effect #8142) imports the gesture host statically, as INV13 prescribes, so the test's whole-root claim can't hold; F1's per-component bundle test, test(core): a touch component's import carries its gesture code, and nothing else does #8671, lists AppShell as a gesture user.
  • pnpm a11y:audit -- --components AppShell,NavigationStack: no new violations. pnpm exec vitest run --project node packages/cli/test: 309 pass. Root pnpm typecheck after pnpm build (CI's order), check:changesets, check:knowledge, the CI duration guard, internalModulesNotExperimental, scripts/lab-core-imports.test.mjs, scripts/core-internal-entries.test.mjs, eslint, prettier and the pre-commit checks pass.

@vercel

vercel Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 27, 2026 1:41pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 29, 2026, 10:36 PM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs an owner decision before it can be approved, and it needs changes either way. NavigationStack's touch bar look relies on spec:AST-094 FR13 and FR4, which are still in open spec pull requests (#8316, #8378, #8394), and main has no NavigationStack record, so an engineering owner needs to identify or provide the current spec that governs this appearance; an engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and as an engineering owner you may do so yourself. Independently of that decision, please:

  • rebase onto main once the 16 stacked pull requests land, so the diff is this change alone (it is 52,339 authored lines today against a 1,840-line commit);
  • keep Lab's back swipe off core's gesture tracker, as architecture:interaction-modality INV10 requires, or amend INV10 on main first;
  • reclassify the row-press change that hides consumers' xstyle ::before decoration under a finger, which ships as [fix];
  • record the architecture:component-style-authoring INV7 element counts for the elements the bar change adds, and drop the nonexistent @astryxdesign/core/motion/experimental path from two changesets.

[Automated review]

Comment on lines +38 to +42
import {
PanGestureRecognizer,
ScreenEdgePanGestureRecognizer,
useGestureRecognizer,
} from '@astryxdesign/core/gestures';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The back swipes now run on core's PanGestureRecognizer and ScreenEdgePanGestureRecognizer, and through them core's VelocityTracker, which the gestures barrel re-exports for Lab. architecture:interaction-modality INV10 says Lab cannot import the tracker and keeps one local tracker with the same blend and stillness interval. Either keep a Lab-local tracker here or amend INV10 on main first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This comes from the back swipe in #8108, which this pull request is stacked on, and its owner is taking INV10 up there. This pull request's own commit does not touch the tracker.

Comment on lines +435 to +441
opacity: {
default: null,
[COARSE]: {
default: 0,
[ENABLED]: {default: null, [PRESSED_ON]: 1, [PRESSED_HELD]: 1},
},
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a coarse pointer this gives every interactive row's ::before opacity: 0 at rest, so a consumer's existing xstyle ::before decoration disappears; list-row-press-compositor.md ships that as [fix]. spec:AST-017 DEC-1 asks for the consumer scenario that stops working to be named and kept out of a patch release.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the row-press change from #8114, lower in the stack, and its owner has it.

Comment on lines +677 to +683
<div {...stylex.props(styles.edgeSentinelAnchor)}>
<div {...edge.sentinelProps} data-navigation-scroll-edge-sentinel />
</div>
{props.children}
</div>
</div>
<div {...edge.layerProps} data-navigation-scroll-edge />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The anchor, sentinel and veil mount on every screen under every pointer, beside the clip box and row wrapper at lines 791-792. architecture:component-style-authoring INV7 asks for before-and-after element counts for added elements and puts decoration such as the veil on a pseudo-element; the anchor could go if the content wrapper positioned the sentinel.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 9f51e05. The sentinel's positioned wrapper is gone: it sits at its static position in the scroller. The description records the element counts before and after, with the reason each remaining element is needed: the sentinel is what IntersectionObserver watches, the veil is a layer the shared animator fades with inline values, and the moving cut needs an overflow box and a counter-moved row to stay on the compositor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A correction to the veil's reason above. The animator can drive a pseudo-element, but it holds a pseudo-element's resting value with a filling animation, and that keeps the layer composited at rest. This hook is shared with AppShell. There, a composited layer under the sticky header makes iOS 26.2 drop the header's items for about two frames on every programmatic scroll. Reduced to plain HTML: with no layer, 0 of 5 takes blank; with a composited veil that never switches, 5 of 5 do. The description now says so.

'@astryxdesign/core': patch
---

[experimental] The `uikit` table in `@astryxdesign/core/motion/experimental` gains the navigation values the lab NavigationStack runs on, each measured on iOS 26.2: `navigation.contentPopHysteresis` (the content back swipe's 27 pt start), `navigation.interactivePopVelocitySamples` (the release velocity averages the last 3), `navigation.interactivePopRubberBand` (past its start the back swipe's screen follows at 0.71 of the finger) and `springs.navigationInteractivePopReturn` (its return when released there), `springs.navigationBackButton` (the back chevron's fade), and `navigation.backChevronPushDelayMs` (when the chevron starts fading in on a push). The catalog gains `@astryx.navigationStack.back`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@astryxdesign/core/motion/experimental does not exist: core's exports list no such subpath and the motion modules are internal. Rewrite this as an internal [chore] note like the other motion changesets; uikit-navigation-large-title-rise.md has the same problem.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in #8107 (c6dc13d) and #8185 (01ba8a0), both merged into this head at 9f51e05.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 25, 2026
@vjeux
vjeux force-pushed the uikit/c16-stack-bar branch from dfc5838 to 9f51e05 Compare October 25, 2026 11:11
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. At 9f51e05:

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs an engineering owner's spec decision before it can be approved, and it needs changes either way. NavigationStack's touch bar appearance under a finger (no background or hairline, the veil once content is 10px under the bar, the covered bar cut at the top card's leading edge) has no current spec on main: spec:AST-094 is still in open pull requests, and its FR13 and FR4, as proposed in #8394, cover when a touch presentation applies and that its motion is themeable, not how the bar looks. An engineering owner needs to provide or identify the claim that governs this appearance, or record the decision by commenting /approve-spec <full head SHA> on this pull request; as an engineering owner, you may do so yourself. Independently of that decision:

  • the veil's new will-change: opacity composites AppShell's touch header at rest as well, the condition your own iOS 26.2 reduction says blanks its items on programmatic scrolls; scope it to NavigationStack and re-check AppShell, or carry the veil on a pseudo-element;
  • rebase onto main once the 19 stacked pull requests land or are dropped, so the diff is this change alone (52,398 authored lines today);
  • the stacked code still runs Lab's back swipes on core's gesture tracker against architecture:interaction-modality INV10, ships the row-press ::before change as [fix] although consumers must move their decoration, and changes AppShell's header and height contract without amending component:AppShell.

[Automated review]

Comment on lines +116 to +119
willChange: {
default: null,
'@media (pointer: coarse)': 'opacity',
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This composites the veil at rest for every caller under a finger, AppShell's touch header included. The pull request keeps the veil an element because a veil composited at rest under AppShell's sticky header blanks its items on iOS 26.2 programmatic scrolls, and these lines recreate that condition. Pass the will-change through NavigationStack's layerXstyle only and repeat the AppShell check on iOS 26.2, or carry the veil on a pseudo-element.

@vjeux
vjeux force-pushed the uikit/c16-stack-bar branch from 9f51e05 to 74e0a84 Compare October 25, 2026 12:22
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

An update on the three points that come from the pull requests below this one:

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs two engineering-owner spec decisions before it can be approved, and it needs changes either way. NavigationStack's touch bar appearance under a finger (no background or hairline, the veil once content is 10px under the bar, the covered bar cut at the top card's leading edge) has no current spec on main, since spec:AST-094 is still in open pull requests and there is no NavigationStack record, and the back swipes running on core's gesture recognizers need the architecture:interaction-modality INV10 amendment you sent to be made on main. An engineering owner needs to provide or identify the claim for the appearance and update INV10, or record each decision by commenting /approve-spec <full head SHA> on this pull request; as an engineering owner, you may do so yourself. Independently of those decisions:

  • Under a finger, the Large title story shows the large title through the clear bar beside the faded-in inline title from about 55px to 65px of scroll, so start the veil or clip the large title where it reaches the bar.
  • Rebase onto main once the stacked pull requests land or are dropped, so the diff is this change alone (52,635 authored lines today).
  • The stacked code still changes AppShell's header and height contract without amending component:AppShell, hides a row's xstyle ::before under a finger in a [fix] (your reported fix is not in this head yet), and prunes search state in a layout effect against architecture:react-component-runtime INV1.
  • Add the architecture:component-style-authoring INV6 gzip size report for NavigationStack and AppShell.

[Automated review]

</div>
) : null}
<div ref={content.ref}>
<div {...edge.sentinelProps} data-navigation-scroll-edge-sentinel />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a finger the bar is now clear, but this sentinel sits below the large title, so the veil comes in only once the large title has passed the bar and 10px more, while the inline title takes over as soon as the large title's bottom passes the bar's (lines 529-547). In the Large title story, from 55px to 65px of scroll the inline title is at opacity 1, the veil at 0 and the large title still shows in the bar: the two titles overlap. With a mouse the opaque bar hides it. Starting the veil, or clipping the large title, where the large title reaches the bar would close the gap.

Comment on lines +412 to +415
// Where the screen's scroll edge sentinel sits: the top of its content,
// below the large title and the search field (an expanded large title is
// outside iOS's veil). The sentinel is absolutely positioned in it.
// The scroll edge sentinel stays at its static position, the top of the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This first paragraph still describes an absolutely positioned sentinel in a positioned wrapper; the paragraph after it says the sentinel stays at its static position with no wrapper. The first one can go.

Comment on lines +2228 to +2252
useLayoutEffect(() => {
const present = new Set(view.models.map(model => model.key));
const forget = (records: Map<string, unknown> | Set<string>) => {
for (const key of [...records.keys()]) {
if (!present.has(key)) {
records.delete(key);
}
}
};
forget(searchingRef.current);
forget(heldCollapsesRef.current);
forget(searchRestRef.current);
forget(freshLiftsRef.current);
setLifts(current => {
const gone = [...current.keys()].filter(key => !present.has(key));
if (gone.length === 0) {
return current;
}
const next = new Map(current);
for (const key of gone) {
next.delete(key);
}
return next;
});
}, [view.models]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This layout effect sets state (setLifts) from view.models and names no external system, which architecture:react-component-runtime INV1 and INV3 rule out. Dropping the gone keys in the same update that produces the new view, or keeping the lift with the screen's mounted card, would avoid the repair render.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

This rides #8142: Cindy asked there for an approach within her guardrails (themeable visuals through the existing theme infrastructure, at most one new theme role, following the theming spec, respecting variants), and that same approach should cover NavigationStack.

@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

This follows the approach proposed on #8142 (#8142 (comment)): the same hook, the same single motion role, and the band painted from the screen's existing background token until NavigationStack's bar has a theme target. I'll update this pull request together with #8142 once the approach is agreed.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs an engineering owner's spec decision before it can be approved, and it needs changes either way. NavigationStack's touch bar appearance (no background or hairline under a coarse pointer, the veil once a screen's content is 10px under the bar, the covered bar cut at the top card's edge) has no current spec: spec:AST-094 governs touch behaviour only and leaves each component's presentation to its own record, and main has no NavigationStack record. An engineering owner needs to state that appearance in a record on main, or record the decision by commenting /approve-spec <full head SHA> on this pull request; as an engineering owner, you may do so yourself. Independently of that decision:

  • registry-contract (preview) fails because Lab's entry now imports @astryxdesign/core/motion/*, @astryxdesign/core/gestures and @astryxdesign/core/utils/touchPresentation, which core does not export and spec:AST-017 FR21 keeps internal.
  • AppShell's import grows by 23,522 bytes gzip with code that works only under a coarse pointer, so report component sizes and load that code lazily or justify it, as architecture:component-style-authoring INV6 asks.
  • Under a finger the large title shows through the clear bar beside the inline title for about 10px of scroll, so start the veil where the large title reaches the bar.
  • Each screen's scroll edge effect adds its own document-level scrollend listener against spec:AST-094 FR24, and the veil's springs need to be FR4 motion roles.
  • Rebase onto main once the stacked pull requests land, so the diff is this change alone (46,083 authored lines today).

[Automated review]

springs,
} from '@astryxdesign/core/motion/uikit';
import {useAnimator} from '@astryxdesign/core/motion/useAnimator';
import {useScrollEdgeEffect} from '@astryxdesign/core/motion/useScrollEdgeEffect';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Core's exports do not list ./motion/useScrollEdgeEffect (nor the other motion/*, gestures and utils/touchPresentation paths Lab now imports), and spec:AST-017 FR21 keeps them internal. Outside the workspace's own resolvers they do not resolve: CI's registry-contract (preview) fails at this head with 23 “Could not resolve” errors from Lab's built NavigationStack and SearchField files, this import among them.

import {focusOutlineProps} from '../utils/focusOutline.stylex';
import {useMediaQuery} from '../hooks/useMediaQuery';
import {useTouchPresentation} from '../utils/touchPresentation';
import {useScrollEdgeEffect} from '../motion/useScrollEdgeEffect';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This static import brings the gesture host, the finger-scroll watch and the animator into every AppShell import, mouse included; CI's size analysis puts AppShell's import at 80,359 bytes gzip against 56,837 at the merge base. architecture:component-style-authoring INV6 asks for the size report and for coarse-pointer-only code over 2 KB to load lazily or be justified.

const unsubscribeFingers = host.onFingersChange(updatePromotion);
// After the finger-scroll watch has heard the same `scrollend`.
const onScrollEnd = () => queueMicrotask(updatePromotion);
doc.addEventListener('scrollend', onScrollEnd, PASSIVE_CAPTURE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One document-level scrollend listener per running effect, and NavigationStack runs one effect per mounted screen. spec:AST-094 FR24 says the number of document- or window-level touch listeners must not grow with component instances; the shared finger-scroll watch already keeps one per document (gestures/fingerScroll.ts).

Comment on lines +127 to +137
* Registers the bars table (motion/roles.ts). It lists no roles: the scroll
* edge effect's springs stay internal constants until the public
* motion-token set is decided (`spec:AST-094` FR4, DEC-11), and the switch,
* the extent and the veil are not motion (not springs, durations or curves).
* Nothing registers on import: the component that animates with this table
* (AppShell; Lab's NavigationStack) hands it to the scroll edge effect
* (motion/useScrollEdgeEffect.ts), which calls it once before its first
* animation (it is idempotent).
*/
export function registerBarsTable(): void {
registerMotionTable('bars', bars);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With no roles registered, the veil's fades play these springs as plain constants. spec:AST-094 FR4 asks for every spring a touch presentation plays to be a motion role named for its use; DEC-11 keeps the roles internal (not theme-settable) rather than removing them.

startCompositorTrace keeps every Display::FrameDisplayed in the browser, and
those events carry no page identity: with a second page animating, a stall
inside the block passed busyWindowProof with presented, crediting the other
page's frames. It now throws when another page is open as it starts or
stops, and says the record is browser-wide (JSDoc and README). Negative
control: another animating page makes it refuse to start, and a page opened
mid-trace makes stop() refuse.
vjeux added 12 commits October 27, 2026 04:13
The bases merged with none of this PR's own change, so a compare from here
shows only that change.
…c65f0): meta#8320 ca71fce and meta#8562 cd3ad72

Core's internal entry files come from meta#8562 as they are; the next commit adds back
only the named re-exports this PR's imports need that meta#8562 lacks.
…on drives

Under load, in a frame that comes a few milliseconds late, WebKit evaluates
two layers that animate on one clock about a millisecond apart, their boxes
and their computed translates alike, so two layers read in one frame disagree
by a pixel or two while the screen shows them together. "a back swipe holds
the revealed bar still..." held the top bar to its card within 1 px through
the release's spring back as well, and failed there (1.03 px; 2 of 10 runs at
6 workers). traceBars now reads each layer's computed translate (at rest a
layer's box is its translate, checked when the trace starts) and records
whether an animation drives the frame; the proof keeps 1 px while the finger
drives both layers and takes the other bar proofs' 1 % of the width through
the release.
…e demoted

The story's own first motion leaves its layers promoted for the animator's
linger, half a second after that motion ends. The push started about 100 ms
after it, so the root bar title's demotion (a style pass and one Layout) came
386-400 ms into the push, at the 380 ms edge of the counted window, and inside
it when the push's first frame came late under load (373 ms; 2 of 24 runs at 8
workers). The proof now waits until no layer is promoted before it clicks, so
the window holds only the push's own work.
The bases merged with none of this PR's own change, so a compare from here
shows only that change. pressFeedback.ts's header names both sides' exports.
…st's cadence

Under load the screencast delivers frames at its own rate: 13 in the judged
window where the 60 Hz count wanted at least 15, while the title changed on
every frame delivered. The proof now counts against the cast's measured
cadence, the median interval between the frames it delivered in the window
(never faster than 60 Hz); a frozen title delivers none there and still fails.
The bases merged with none of this PR's own change, so a compare from here
shows only that change. The bases both add to the shared motion notes, the
UIKit table's entry header and the en catalog; each keeps both sides' entries.
Both re-export useAnimator from Core's internal motion entry; it keeps one,
meta#8181's, with its type.
vjeux added 3 commits October 27, 2026 05:05
… a mouse press

At the document, an event from inside an open shadow root is retargeted to
the shadow host, and so is a touch's target, so the host looked for
participants from the shadow host and found none inside the root: the
browser then sent its own mousedown and click. Both cases, a finger and a
mouse, attach a tap to an element in the shadow root. Fail until the host
resolves targets through the composed path.
…composed path

Inside an open shadow root the document's listeners see the shadow host,
in event.target and in a touch's target alike, so a recognizer on an
element in the root never got its finger, and nothing claimed it. landedOn
takes the composed path's first element for the event's own target and for
a touch whose target is the event's (another finger keeps its own): a
touch's start, a pointer's press and its pairing, the last touch a scroll
reads, a cancelled pointer's touch and hover.
…he host resolves targets through the composed path) into meta#8320

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs an engineering owner's spec decision before it can be approved, and it needs changes either way. NavigationStack's touch bar appearance (no background or hairline under a coarse pointer, the veil once a screen's content is 10px under the bar, the covered bar cut at the top card's edge) has no current spec: spec:AST-094 governs touch behaviour and leaves each component's presentation to its own record, and main has no NavigationStack record, so an engineering owner needs to state that appearance in a record on main, or record the decision by commenting /approve-spec <full head SHA> on this pull request; as an engineering owner, you may do so yourself. Independently of that decision:

  • Under a finger, a stack that opens two screens deep keeps the root's veil on at the top after Back, because the root offset is measured once from a hidden screen's empty first report (useScrollEdgeEffect.ts:323-336).
  • Large-title screens show both titles through the clear bar for about 10px of scroll, so start the veil where the inline title takes over (NavigationStack.tsx:545-547, :681-682).
  • Each screen's effect adds its own document scrollend listener against spec:AST-094 FR24, the veil's springs need to be FR4 motion roles, and Lab should reach the animator through @astryxdesign/core/internal/touch as architecture:react-component-runtime INV13 names, unless an engineering owner widens INV13.
  • Rebase onto main once the stacked pull requests land, so the diff is this change alone (44,759 authored lines today, including AppShell's touch header and height="adaptive", which contradict component:AppShell FR7 and its height contract, and a pinned search field that sits 44px below the bar).

[Automated review]

Comment on lines +323 to +336
if (rootOffset == null && container != null) {
const bounds = entry.rootBounds;
const paddingEdge =
container.getBoundingClientRect().top + container.clientTop;
const offset =
bounds == null ? 0 : Math.round(bounds.top - rootInset - paddingEdge);
rootOffset = offset;
if (offset !== 0) {
// WebKit: observe again from the padding edge; this report was
// measured from the content box.
observer.disconnect();
observe();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A screen two levels down mounts with content-visibility: hidden, so this first report has an empty root rectangle (top 0, height 0 in Chromium). The offset comes out as 0 - 44 - paddingEdge (−60 in the Pushed two levels story), the observer is recreated with a -104px margin, and rootOffset is never measured again: after two Backs the root sits at scroll 0 with its veil stuck at opacity 1. Measuring only from a report whose root rectangle has size (or re-measuring when the screen is revealed) avoids it; a proof for a stack that opens two deep would catch it.

</div>
) : null}
<div ref={content.ref}>
<div {...edge.sentinelProps} data-navigation-scroll-edge-sentinel />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On a large-title screen this sentinel sits below the large title, while the inline title takes over once the large title's bottom passes the bar's (:545-547). Under a finger the clear bar therefore shows the large title behind the inline one for about 10px of scroll: at 60px the large title spans −16px to 39px of the bar, the inline title is at opacity 1 and the veil at 0. Starting the veil where the inline title takes over (or clipping the large title at the bar's bottom) would fix it.

const unsubscribeFingers = host.onFingersChange(updatePromotion);
// After the finger-scroll watch has heard the same `scrollend`.
const onScrollEnd = () => queueMicrotask(updatePromotion);
doc.addEventListener('scrollend', onScrollEnd, PASSIVE_CAPTURE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One document-level scrollend listener per running effect, and NavigationStack runs one effect per mounted screen. spec:AST-094 FR24: “The number of document- or window-level touch listeners MUST NOT grow with the number of component instances”. The finger-scroll watch already keeps one per document for this event (gestures/fingerScroll.ts:131-132); the promotion update could ride it or the gesture host.

* animation (it is idempotent).
*/
export function registerBarsTable(): void {
registerMotionTable('bars', bars);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With no roles listed, the veil's fades play bars.scrollEdgeEffect.appear and .disappear as plain constants. spec:AST-094 FR4 asks for every spring a touch presentation plays to be a motion role named for its use; DEC-11 keeps roles internal rather than absent, so listing these two as internal roles would keep them out of the public theme set.

springs,
} from '@astryxdesign/core/internal/motion';
import {useAnimator} from '@astryxdesign/core/internal/motion';
import {useScrollEdgeEffect} from '@astryxdesign/core/internal/motion';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV13 has Core and Lab reach the shared touch modules, the animator included, through @astryxdesign/core/internal/touch; this adds the scroll edge effect to the second entry, @astryxdesign/core/internal/motion. Re-exporting from internal/touch, or an engineering owner widening INV13, would settle it.

},
searchPinned: {
position: 'sticky',
top: BAR_HEIGHT,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The scroller is already padded 44px for the bar (:403), so top: 44 pins the field 88px below the screen's top: at rest an empty 44px band sits under the bar and the field covers the first row's label, and scrolled rows pass through the band, under either pointer in Chromium. top: 0 (with a story for hidesSearchWhenScrolling={false}) would pin it under the bar.

vjeux added 6 commits October 27, 2026 06:02
…s its divider with a coarse-pointer rule, and drops its layer where no scrollend fires

Review findings on meta#8142:

- With a mouse, a variant added by module augmentation painted a surface
  header. A page-scrolling shell now keeps each variant's own fill (none for
  an augmented one) and adds only the coarse-pointer transparent rule.
- The section divider was dropped through the resolver's guess, so a server
  render with `defaultIsMobile` lost it. It stays in the markup, and a
  coarse-pointer rule takes its width away on the touch bar.
- Where the browser sends no `scrollend`, the layer stayed promoted after a
  fling (re-checked only at a finger change). The scroller's scroll events
  now time a re-check 200 ms after the last one, past the finger-scroll
  watch's 150 ms spell.
- One document listener per effect: `scrollend`, or `scroll` where the
  browser sends none. This base has no shared input stream (it arrives with
  meta#8665's line); the effect moves onto it there.
- The WebKit workaround names its removal condition.

Red first: the five new cases fail on ae06884.
…slates, the Layout/Paint proof waits for demotion, the handover counts at the cast's cadence) into the bar look's bases
…) into the NavigationStack bar look

# Conflicts:
#	packages/core/src/AppShell/AppShell.tsx
#	packages/core/src/AppShell/AppShellScrollEdge.test.tsx
#	packages/core/src/hooks/useScrollEdgeEffect.ts
#	packages/core/src/motion/useScrollEdgeEffect.test.tsx

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs an engineering owner's spec decision before it can be approved, and it needs changes either way. NavigationStack's touch bar appearance (no background or hairline under a coarse pointer, the veil once a screen's content is 10px under the bar, the covered bar cut at the top card's edge) has no current spec: spec:AST-094 governs touch behaviour and leaves each component's presentation to its own record, and main has no NavigationStack record, so an engineering owner needs to state that appearance in a record on main, or record the decision by commenting /approve-spec <full head SHA> on this pull request; as an engineering owner, you may do so yourself. Independently of that decision:

  • Under a finger, a stack that opens two screens deep keeps the root's veil on at the top after Back, because the root offset is measured once from a hidden screen's empty first report (useScrollEdgeEffect.ts:338-352).
  • Large-title screens show both titles through the clear bar for about 10px of scroll, so start the veil where the inline title takes over (NavigationStack.tsx:545-547, :681-682).
  • Each screen's effect adds its own document listener against spec:AST-094 FR24, the veil's springs need to be FR4 motion roles, and Lab should reach the animator through @astryxdesign/core/internal/touch as architecture:react-component-runtime INV13 names, unless an engineering owner widens INV13.
  • Rebase onto main once the stacked pull requests land, so the diff is this change alone (45,071 authored lines today, including AppShell's touch header and height="adaptive", which contradict component:AppShell FR7 and its height contract, and a pinned search field that sits 44px below the bar).

[Automated review]

const paddingEdge =
container.getBoundingClientRect().top + container.clientTop;
const offset =
bounds == null ? 0 : Math.round(bounds.top - rootInset - paddingEdge);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This offset is measured once, from the first report. A NavigationStack screen that mounts neither on top nor directly under it has content-visibility: hidden (NavigationStack.tsx:432-434, :2368), so its first report has an empty root rectangle (top 0, height 0): with the scroller 16px down this computes −60, observes again with a −104px top margin, and never measures again. Once Back reveals the screen, its sentinel sits above that shrunken root at rest, so the veil stays at opacity 1 at scroll 0. Take the measurement only from a report whose root rectangle has size (or measure again when the screen is revealed), and add a proof for a stack that opens two screens deep.

updatePromotion();
}, PROMOTION_RECHECK_MS);
};
doc.addEventListener(scrollType, onScrollEvent, PASSIVE_CAPTURE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One capture listener on the document per running effect, scrollend or, without it, scroll (which then runs on every scroll event in the document), and NavigationStack runs one effect per mounted screen. spec:AST-094 FR24: “The number of document- or window-level touch listeners MUST NOT grow with the number of component instances”. The finger-scroll watch already keeps one listener per document for both events (gestures/fingerScroll.ts:131-132); the promotion can be updated from it or from the gesture host.

</div>
) : null}
<div ref={content.ref}>
<div {...edge.sentinelProps} data-navigation-scroll-edge-sentinel />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On a large-title screen this sentinel sits below the large title, while the inline title takes over once the large title's bottom passes the bar's (:545-547). With the bar now clear under a finger, the large title shows through it under the inline title for about 10px of scroll, until the veil comes in. Start the veil where the inline title takes over under a finger (or clip the large title at the bar's bottom), and add a large-title case to NavigationStackScrollEdge.interaction.browser.spec.ts.

* animation (it is idempotent).
*/
export function registerBarsTable(): void {
registerMotionTable('bars', bars);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With no roles listed, the veil's fades play bars.scrollEdgeEffect.appear and .disappear as plain constants. spec:AST-094 FR4 asks for every spring a touch presentation plays to be a motion role named for its use, and DEC-11 keeps those roles internal rather than absent; listing both springs as internal roles keeps them out of the public theme set.

springs,
} from '@astryxdesign/core/internal/motion';
import {useAnimator} from '@astryxdesign/core/internal/motion';
import {useScrollEdgeEffect} from '@astryxdesign/core/internal/motion';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV13 has Core and Lab reach the shared touch modules, the animator included, through @astryxdesign/core/internal/touch; this adds the scroll edge effect to a second entry, @astryxdesign/core/internal/motion, beside the animator Lab already takes from it. Re-export what Lab needs from internal/touch, or have an engineering owner widen INV13 to core's ./internal/* entries.

},
searchPinned: {
position: 'sticky',
top: BAR_HEIGHT,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The scroller is already padded 44px for the bar (:403), so top: 44 pins the field 88px below the screen's top: at rest an empty 44px band sits under the bar and the field covers the first row, and scrolled rows pass through that band. top: 0 puts it right under the bar; no story uses hidesSearchWhenScrolling={false} yet.

},
// Where the screen's scroll edge sentinel sits: the top of its content,
// below the large title and the search field (an expanded large title is
// outside iOS's veil). The sentinel is absolutely positioned in it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This paragraph still says the sentinel “is absolutely positioned in it”; the paragraph below says it stays at its static position with no wrapper. The first one can go.

@cixzhang cixzhang closed this Oct 28, 2026
@cixzhang cixzhang reopened this Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:36

This branch was successfully deployed

1 active deployment
Preview — 297d3019 Deployed Oct 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants