Repository navigation
feat(AppShell): under a finger, the pinned touch header is an iOS 26 bar with its scroll edge effect - #8142
feat(AppShell): under a finger, the pinned touch header is an iOS 26 bar with its scroll edge effect#8142vjeux wants to merge 1369 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
This needs an engineering owner's decision before it can be approved. No current spec covers AppShell's touch header becoming a transparent iOS 26-style bar for every touch height="auto" shell, and draft spec:AST-094, which narrows the theme-tokens architecture for touch motion, is not approved. An engineering owner can record that decision by commenting /approve-spec b1344d389e3fd89b82cf2d900d45eb3ab6ee6b3f (the author can, if they are one), naming or adding the record that governs the header and updating theme-tokens for FR4. These need fixing whatever the decision:
test-uiandpr-a11yfail: document--_app-shell-scroll-edge-fillinAppShell.doc.mjs, and give theTouchBarOverContentstory a keyboard stop in its scroll region.useScrollEdgeEffectkeeps the sentinel element in React state, which architecture:react-component-runtime INV1 forbids, and the veil'szIndex: -1needs anisolation: isolateowner on the header (spec:AST-027 FR2).- WebKit does not recognize
prefers-reduced-transparency, so the changeset's Reduce Transparency band needs iOS Safari evidence or a narrower claim, and header text over scrolled content needs contrast evidence. - At about 15,000 authored lines the stack is far over the 2,000-line limit; landing #8043–#8067 first leaves this change at 1,464 lines.
[Automated review]
| root, | ||
| }: UseScrollEdgeEffectOptions): UseScrollEdgeEffectResult { | ||
| const {ref: layerRef, animator} = useAnimator({initial: {opacity: 0}}); | ||
| const [sentinel, setSentinel] = useState<HTMLElement | null>(null); |
There was a problem hiding this comment.
This keeps the sentinel element in React state, which architecture:react-component-runtime INV1 forbids ("DOM nodes … MUST NOT be stored in React state; store only the minimal rendering key or own the node through a ref"). Own the node through the callback ref instead: create and disconnect the observer there (INV6 node replacement), or hold it in a ref and key the effect on enabled.
| default: 'calc(100% + 54.8px)', | ||
| '@media (prefers-reduced-transparency: reduce)': '100%', | ||
| }, | ||
| zIndex: -1, |
There was a problem hiding this comment.
zIndex: -1 orders this layer behind the header's content, but no style on the header declares isolation: isolate, which spec:AST-027 FR2 requires for every local stacking relationship. It works today only because the sticky header's zIndex: 1 forms a stacking context. Add isolation: 'isolate' to the header surface that holds the layer.
| }, | ||
| }, | ||
| scrollEdgeFillBody: { | ||
| '--_app-shell-scroll-edge-fill': colorVars['--color-background-body'], |
There was a problem hiding this comment.
--_app-shell-scroll-edge-fill is not listed in AppShell.doc.mjs (theming.vars[] plus a derived[] entry), so derivedVarRegistry.test.ts › "AppShell: all source vars are in doc file" fails (CI test-ui (1/4)).
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] AppShell: on a touch screen whose page scrolls (`height="auto"`, or `height="adaptive"` on a touch device), the pinned header looks like an iOS 26 bar. It paints no background of its own and draws no divider. Once the content has moved more than 10px under it, a veil of the header's colour fades in at its top on iOS's measured spring (0.29 s): strongest at the top, gone just below the header. It fades out (0.51 s) when the content comes back. Under Reduce Transparency the veil is an opaque band ending at the header's bottom edge. With a mouse nothing changes. |
There was a problem hiding this comment.
The Reduce Transparency band keys on prefers-reduced-transparency, which Playwright's WebKit does not recognize (neither reduce nor no-preference matches), and the PR's evidence is Chromium emulation only. spec:AST-013 FR10 needs iOS Safari evidence for this claim; otherwise limit the note to engines that support the feature.
| * colour fades in at the top of the screen, and it fades out when they come | ||
| * back. With a mouse the header stays opaque. | ||
| */ | ||
| export const TouchBarOverContent: Story = { |
There was a problem hiding this comment.
pr-a11y flags a new serious scrollable-region-focusable violation here. With a mouse, adaptive resolves to fill and the main region scrolls with only non-focusable tiles in it. Give it a keyboard stop, as AdaptiveHeight does with its action.
PR Analysis Report📚 Storybook Preview🧪 Sandbox PreviewModified ComponentsAppShell (@astryxdesign/core) · View in Storybook
Component Size
Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.
|
| Component | JavaScript | CSS |
|---|---|---|
| core/AppShell | +24.4KB |
+338B |
Bundle Size Summary
Gzip figures measure each package's entry file, not the code an import pulls in.
| Package | Entry (ESM) | Entry gzip before | Entry gzip after | Delta |
|---|---|---|---|---|
| @astryxdesign/core | 4.9KB | 1.2KB | 1.2KB | 0B |
| Package stylesheet | Gzip before | Gzip after | Delta |
|---|---|---|---|
| @astryxdesign/core | 34.6KB | 34.8KB | +268B |
Accessibility Audit
Status: No accessibility violations detected.
Visual Regression
55 of 502 shot(s) changed.
A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.
| component | story | theme | mode | pixels |
|---|---|---|---|---|
| Selector | Indicator Space Bottom Sheet Narrow End RTL | neutral | light | 753,933 |
| Selector | Indicator Space Bottom Sheet Wide Start | neutral | light | 740,072 |
| RichTextEditor | Markdown parity: side by side | probe | dark | 558,840 |
| RichTextEditor | Markdown parity: side by side | probe | light | 540,756 |
| Chat | Full AI Chat | probe | light | 230,823 |
| Chat | Full AI Chat | probe | dark | 229,805 |
| AppShell | With Mobile Nav | probe | light | 217,010 |
| AppShell | With Mobile Nav | probe | dark | 216,960 |
| Collapsible | Dividers | probe | dark | 66,445 |
| Collapsible | Dividers | probe | light | 66,445 |
| AppShell | Playground | neutral | dark | 65,198 |
| AppShell | Playground | neutral | light | 63,788 |
| Chat | Default | neutral | dark | 36,304 |
| Chat | Default | neutral | light | 35,767 |
| Chat | Empty State | probe | light | 23,531 |
| Chat | Empty State | probe | dark | 22,622 |
| ChatAutoScroll | Scroll Behavior Comparison | neutral | dark | 15,378 |
| ChatAutoScroll | Scroll Behavior Comparison | neutral | light | 14,107 |
| Chat | With Attachments | probe | light | 13,406 |
| Chat | With Attachments | probe | dark | 11,232 |
and 35 more.
Generated by PR Enrichment workflow | Storybook | Sandbox | View full report
b1344d3 to
9a29be3
Compare
There was a problem hiding this comment.
This needs an engineering owner's decision before it can be approved: no current spec covers AppShell's touch header becoming a transparent iOS 26-style bar for every touch height="auto" shell, and draft spec:AST-094, which narrows the theme-tokens architecture for touch motion, is not approved. An engineering owner settles that by naming or adding the record that governs the header and commenting /approve-spec 9a29be3744a362a650bf1512eed1a77389d0ad11 on this pull request; the author may do so if they are an engineering owner. These need fixing whatever the decision:
useScrollEdgeEffectkeeps the sentinel element in React state, which architecture:react-component-runtime INV1 forbids, and the veil'szIndex: -1needs anisolation: isolateowner on the header (spec:AST-027 FR2).- Safari has not shipped
prefers-reduced-transparency, so the changeset's Reduce Transparency band needs iOS Safari evidence or a narrower claim, and header text over scrolled content needs WCAG 1.4.3 evidence (the white title meets scrolled text at 3.79:1 in dark mode). - At about 15,000 authored lines the stack is far over the 2,000-line limit; landing #8043-#8067 first leaves this change at about 1,460 lines.
[Automated review]
|
Posted by Cindy's agent on her behalf. Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:
This is about where the behavior lives, not about the PR's intent. |
|
Agreed: one implementation.
Both land in the next push of this PR. |
9a29be3 to
407c5e9
Compare
|
Pushed 407c5e9: touch reads through the resolver ( |
407c5e9 to
b110fb4
Compare
There was a problem hiding this comment.
This needs an engineering owner's decision before it can be approved: the current AppShell spec says each variant paints the header's background and section divides it from the content (FR7) and lists the root's children (FR1), while this change makes the touch header transparent with a veil, drops the divider and adds a sentinel for every touch height="auto" shell, with no opt-out. An engineering owner settles it by amending those claims in packages/core/src/AppShell/AppShell.spec.md, or ruling the touch bar out, and recording approval by commenting /approve-spec b110fb4d583bd5dcb97edc7ddebbb549b361dca8 (or the full SHA of a later head that carries the amendment); the author may do so as an engineering owner, and the same decision covers draft spec:AST-094, whose FR4 forbids the theme-settable springs this change adds. Whatever the decision, these need changes:
- Land #8076, #8331, #8336 and #8066 first and rebase: the diff carries about 36,000 authored lines against a 2,000-line limit, while this change alone is about 1,860.
- Augmented
AppShellVariantMapvalues onheight="auto"now get a surface-coloured header even with a mouse (AppShell.tsx:721-726); keep their previous path or treat them aselevated. astryx docs motionstill says no shipped component reads motion roles (motion.doc.mjs:151), AppShell's dense doc limits the bar to iOS (AppShell.doc.mjs:376), and the description reports no public delta although two role ids are added.
[Automated review]
| const barFillsWithBody = variant === 'wash' || variant === 'elevated'; | ||
| const headerAreaStyle = isAuto | ||
| ? barFillsWithBody | ||
| ? styles.navAreaWashTouchBar | ||
| : styles.navAreaSurfaceTouchBar | ||
| : navAreaStyle; |
There was a problem hiding this comment.
Every variant other than wash and elevated lands on navAreaSurfaceTouchBar, so a module-augmented AppShellVariantMap value on height="auto" now gets a header painted --color-background-surface even with a mouse, where the previous navAreaStyle ?? (isAuto && variant === 'section' ? styles.navAreaSurface : undefined) painted nothing over the elevated frame. Apply the touch-bar styles only to the four built-in variants (or treat augmented values as elevated), so "With a mouse nothing changes" holds.
| content: [ | ||
| { | ||
| type: 'prose', | ||
| text: 'Touch motion has a table of springs, durations and curves measured on iPhone and iPad. Each value is a motion role named by its table path, such as `springs.sheetPresent` or `timing.curves.easeInOut`. A theme retunes a role through `motion.springs`, `motion.durations` or `motion.curves`, at its root or in an adaptation rule; a role it leaves alone keeps its measured default. No shipped component reads these roles yet.', |
There was a problem hiding this comment.
"No shipped component reads these roles yet" stops being true with this pull request: AppShell's touch header plays bars.scrollEdgeEffect.appear and .disappear, which the animator resolves against the element's theme (motion/animator.ts:1535). Name AppShell as the first reader here and in the changeset.
| banner: 'slot for system-wide announcements above topNav', | ||
| height: | ||
| 'fill=viewport 100dvh w/ independent scroll; auto=content-driven w/ sticky nav', | ||
| 'fill=viewport 100dvh w/ independent scroll; auto=content-driven w/ sticky nav; adaptive=fill w/ mouse, auto on touch (page scroll); root data-scroll-owner=content|document; iOS touch+page scroll: header has no background, iOS 26 scroll-edge veil fades in past 10px of content under it (elsewhere opaque)', |
There was a problem hiding this comment.
"iOS touch+page scroll" narrows the bar to iOS, but the code applies it under any coarse primary pointer (useTouchPresentation() and @media (pointer: coarse)), Android included, as the verbose doc says. Say "coarse pointer" here.
b110fb4 to
36d2fbe
Compare
…ntries and internal/motion Main's resolveSnapPoint test (meta#8663) keeps the release policy internal: not on the package root, not in a barrel, and no package export naming motion. The last check also caught ./internal/motion, Core's internal entry for Astryx's own packages (architecture:react-component-runtime INV13), which re-exports no release policy. The export check now covers the public keys only, and a new assertion keeps the claim as strong: internal/motion must not re-export resolveSnapPoint either. On this branch with main the test failed on ./internal/motion; it now passes, and it fails when internal/motion re-exports resolveSnapPoint.
…fix) into the scroll edge effect's bases
…opNav phone-bar capsule
…opNav phone-bar capsule
…Z2) into the bar look's bases
…nStack bar look
…NavigationStack bar look
… 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
There was a problem hiding this comment.
Approval waits on an owner decision: under a finger this pull request makes AppShell’s pinned header transparent, drops the section divider and fades in a 0.95-alpha veil on springs played as constants, which AppShell’s current record (FR7, variant backgrounds) and AST-094 FR4 (motion roles) do not provide for. An engineering owner needs to decide whether component:AppShell adopts this touch presentation and records its springs as roles, and records that decision by commenting /approve-spec ae0688418231a9eabdbefa7591effdb9b248a819 on this pull request; the author may do so as an engineering owner. Whatever the decision, these need changes:
- Land the stacked bases separately so the bar (2,146 of 27,423 authored lines) is reviewed on its own.
scripts/touch-support-bundle.test.mjsfails on this head because AppShell’s import of the scroll edge hook brings 12 gesture modules (about 47 KB) into the root-entry bundle it measures.- Augmented variants now paint a surface header with a mouse, and a server render with
defaultIsMobiledrops thesectiondivider. - The scroll edge hook adds a
documentscrollendlistener per shell and keeps its layer promoted where noscrollendfires, and its WebKit workaround names no removal condition.
[Automated review]
| const barFillsWithBody = variant === 'wash' || variant === 'elevated'; | ||
| const headerAreaStyle = isAuto | ||
| ? barFillsWithBody | ||
| ? styles.navAreaWashTouchBar | ||
| : styles.navAreaSurfaceTouchBar | ||
| : navAreaStyle; |
There was a problem hiding this comment.
Every variant other than wash and elevated lands on navAreaSurfaceTouchBar, whose default arm paints surface, so a module-augmented variant’s header goes from unpainted over the elevated frame to rgb(255, 255, 255) for mouse users. AppShell’s record says an augmented value paints the elevated frame background, and AST-017 FR59 keeps fine-pointer appearance as it was; keeping the previous fine-pointer default and adding only the coarse-pointer transparent arm would avoid it.
| <LayoutHeader padding={0} hasDivider={navHasDividers && hasTopNav}> | ||
| <LayoutHeader | ||
| padding={0} | ||
| hasDivider={navHasDividers && hasTopNav && !isTouchBar}> |
There was a problem hiding this comment.
isTouchBar comes from useTouchPresentation seeded with mobileNav.defaultIsMobile, so a server render with that hint has no divider (no data-divider in the markup) and a fine-pointer client adds it back after hydration. AST-094 FR13 wants the server and hydrating renders to produce the pointer presentation; a (pointer: coarse) style, as the background uses, conforms (DEC-6).
| {isAuto && ( | ||
| <div | ||
| aria-hidden="true" | ||
| ref={scrollEdge.sentinelRef} | ||
| {...stylex.props(styles.scrollEdgeSentinel)} | ||
| /> | ||
| )} |
There was a problem hiding this comment.
The sentinel becomes the root’s first child, ahead of the skip link, in every auto shell; AppShell FR1 orders the root as the skip link, the Layout frame, then any drawer. It is absolutely positioned, so rendering it after the skip link keeps it where it is.
| const onScrollEnd = () => queueMicrotask(updatePromotion); | ||
| doc.addEventListener('scrollend', onScrollEnd, PASSIVE_CAPTURE); |
There was a problem hiding this comment.
This adds a capture scrollend listener on the document for every shell, which AST-094 FR24’s verification fails (“a per-instance document or window listener”). It is also the only end-of-scroll signal updatePromotion gets: where no scrollend fires, fingerScroll.ts ends the finger scroll on its 150 ms fallback without notifying, so the layer keeps will-change: opacity after the lift, the layer the header above says blanks the sticky header on a programmatic scroll back to the top.
| * momentum is still running: `useFingerScroll`, over the gesture host, | ||
| * which ends at the scroller's `scrollend`). A scroll the program starts | ||
| * (`scrollTo`, a status-bar tap) switches it at once, with no compositing | ||
| * layer. That is a WebKit engine workaround: on iOS 26.2, while a composited |
There was a problem hiding this comment.
AST-013 FR6 allows a narrow engine workaround “only for a reproduced defect with a regression fixture and a review or removal condition”; this one describes the iOS 26.2 repro but names no review or removal condition, and its tests pin the workaround in jsdom rather than reproduce the engine defect.
…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.
…) 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
There was a problem hiding this comment.
Approval waits on an engineering owner's decision. Under a finger, this pull request makes AppShell's pinned header transparent with no divider and fades in a veil on two springs. The current AppShell spec does not allow that (FR7's header fill and divider, FR1's root children), and spec:AST-094 leaves it to AppShell's own record. An engineering owner can settle it by updating packages/core/src/AppShell/AppShell.spec.md or ruling the touch bar out, and records the decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. The decision covers the touch header, its veil and denser alpha, its springs as roles, and whether existing callers keep a way back.
Whatever the decision, these need changes:
- Land the stacked pull requests (#8320, #8336, #8076, #8066, #8562) on their own first: this diff carries 27,682 authored lines, and the touch bar alone is 2,346.
scripts/touch-support-bundle.test.mjs› "is absent from the root entry" fails on this head and needs amending here.- Replace the per-shell
documentlistener inuseScrollEdgeEffect(AST-094 FR24), and fix the changeset's theme-colour claim and the dense doc's iOS-only wording. - Add real-device iOS Safari and Android Chrome evidence for the WebKit workaround, and commit its fixture.
[Automated review]
| updatePromotion(); | ||
| }, PROMOTION_RECHECK_MS); | ||
| }; | ||
| doc.addEventListener(scrollType, onScrollEvent, PASSIVE_CAPTURE); |
There was a problem hiding this comment.
Each instance of this hook adds its own capture listener on document, which spec:AST-094 FR24's check fails ("a per-instance document or window listener"). The shared finger-scroll watch already detects the end of a finger's scroll; taking that end through the gesture host would keep one listener however many shells mount.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] AppShell: under a finger (one touch model on every touch device), when the page scrolls (`height="auto"`, or `height="adaptive"` on a touch device), the pinned header looks like an iOS 26 bar. It paints no background of its own and draws no divider. Once the content has moved more than 10px under it, a veil of the header's colour fades in at its top on iOS's measured spring (0.29 s): strongest at the top, gone just below the header. It fades out (0.51 s) when the content comes back. A scroll the program drives (`scrollTo`, a status-bar tap) switches the veil at once instead: on iOS 26.2 a fade that starts on such a scroll makes WebKit drop the header's items for a frame or two. Under Reduce Motion the fades are the animator's 200 ms cross-fade. Where the browser supports `prefers-reduced-transparency` (Safari 26.2 does not yet), Reduce Transparency makes the veil an opaque band ending at the header's bottom edge. With a mouse nothing changes. The springs and the veil's shape are internal measured constants; the veil takes the header's theme colour. |
There was a problem hiding this comment.
"the veil takes the header's theme colour" does not hold: the veil paints --color-background-body or --color-background-surface. A theme that paints app-shell-header, such as the probe theme, keeps an opaque header under a finger and gets a body-colour band below it.
| banner: 'slot for system-wide announcements above topNav', | ||
| height: | ||
| 'fill=viewport 100dvh w/ independent scroll; auto=content-driven w/ sticky nav', | ||
| 'fill=viewport 100dvh w/ independent scroll; auto=content-driven w/ sticky nav; adaptive=fill w/ mouse, auto on touch (page scroll); root data-scroll-owner=content|document; iOS touch+page scroll: header has no background, iOS 26 scroll-edge veil fades in past 10px of content under it (elsewhere opaque)', |
There was a problem hiding this comment.
"iOS touch+page scroll … (elsewhere opaque)" limits the bar to iOS, but it applies under any coarse primary pointer, Android included, as the verbose height doc says. "coarse pointer + page scroll" would match.
| const scrollEdgeLayer = isAuto ? ( | ||
| <div | ||
| aria-hidden="true" | ||
| ref={scrollEdge.layerRef} | ||
| {...stylex.props( | ||
| styles.scrollEdge, | ||
| barFillsWithBody | ||
| ? styles.scrollEdgeFillBody | ||
| : styles.scrollEdgeFillSurface, | ||
| )} | ||
| /> | ||
| ) : null; |
There was a problem hiding this comment.
This veil is a decorative element with no role, and it renders in every auto shell, where it stays display: none with a mouse. architecture:component-style-authoring INV7 puts such decoration in a pseudo-element. Painting it as the header's pseudo-element (the animator drives pseudo-elements) and rendering the sentinel only while the bar is on would leave mouse shells' DOM unchanged.
|
Fixed in 4fe659b, red first (the five new cases fail at ae06884):
The description answers the stacked bases and the root-entry red (#8671). |
|
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. |
…nothing else does (#8671) ## Why Every pull request that gives a component a gesture fails `test-node` once merged with main, and the failure names no component: `scripts/touch-support-bundle.test.mjs` requires Core's root and hooks entries to carry no gesture code. At least six open pull requests (#8142, #8190, #8337, #8159, #8098, #8658) fail it this way. ## What led to it This comes from the work to make Astryx's touch components behave as native iOS ones do. Since #8465, a component that uses a gesture imports the touch modules directly (`architecture:react-component-runtime` INV13), and the root entry re-exports every component, so it carries gesture code as soon as one component does. ## Options and tradeoffs - Exempt the root entry: unblocks everyone, but stops catching a component that pulls gesture code it does not use. - Check each public import on its own: catches exactly that, at the cost of a list of the imports allowed to carry gesture code. ## Why this choice The per-import check keeps INV13's promise, a component without gestures ships none of their code, while letting gesture users through by name. It was offered on #8562 and aimed at main's test. ## What changes - Each public import of Core is built the way the size report builds it, and may carry gesture code only if `GESTURE_USERS` names it. A failure lists the import, its gesture modules with their sizes, and the chain of imports that brings them. - The root entry may carry only what a named import carries; the utils entry stays gesture-free. - A strict case keeps Button and TextInput free of gesture code whatever the list says. - `GESTURE_USERS` starts empty, so every import stays as strict as on main. The pull request that gives a component a gesture adds that component's import, and each import that reaches it, with a comment naming the gesture, so the new gesture user shows up in review. ## How to check it 1. Run `pnpm exec vitest run --project node scripts/touch-support-bundle.test.mjs` on main with this branch merged: it passes. 2. On a tree whose component uses a gesture, run it without naming that import in `GESTURE_USERS`: the test fails and names that import and the chain that brings the gesture code. Name it, and the test passes. 3. Import a gesture module from Button: the strict case fails. ## Evidence - With the list empty, the test passes on main: no public import carries gesture code today. - On open trees that give a component a gesture (AppShell's scroll-edge bar, Slider, the List reorder), the per-import case fails naming exactly those imports and their chains, and passes once each is named. - No product code changes. ## Scope - [x] No intended public API, product behavior, visual direction, or policy change. - [x] Product changes discovered during the work were removed or split. - [x] Public text and artifacts contain no internal Meta context.
Stacked on #8320 (on #8076) and #8066. Review only this PR's change: vjeux/astryx@4f519b0...4fe659b
Carries #8562 (Core's internal entries) as a dependency; if the entries land differently, this follows her decision.
User need
AppShell's pinned header is opaque; an iOS 26 bar paints nothing until content is 10 pt under it, then fades a veil in.
Problem and solution fit
Under a finger (
spec:AST-094FR13), when the page scrolls; measured on iOS 26.2 (motion/uikit/bars.ts):scrollTo, a status-bar tap) switches it.A mouse keeps today's bar.
Current specification
No AppShell record.
Owner decisions (proposed; pending owner review):
Per #8257 (
spec:AST-094DEC-11), the springs and the veil's shape stay internal constants.Public delta and caller burden
None: no theme key, CSS variable or export.
Design and alternatives
An IntersectionObserver watches a 10 px sentinel.
useFingerScroll(#8320) says whose scroll it is: a finger's untilscrollend. Fill and divider follow(pointer: coarse)in CSS: a server render suits either pointer. Rejected: scroll-driven animation,backdrop-filter, a pseudo-element transition.Size (INV6,
analyze-pr, gzip, base → head): AppShell's import 59,757 → 84,253 B of JavaScript; its CSS 11,922 → 12,260 B.Evidence
iOS 26.2 Safari, main (top) and this branch (bottom):
This branch: 1/4-speed springs (P3), a 300 ms block (P5), Reduce Motion (P6):
Android 15, Chromium 158 (emulator), mask off:
WebKit defect (iOS 26.2), worked around: a composited layer under a sticky header drops its items for ~2 frames when the program scrolls back to the top (reduction in Testing). The veil is composited only from a finger's landing until its scroll ends (or 200 ms after its last scroll, without
scrollend); the program's switch writes none: 2/2/2 blank frames → 0/0/0. Remaining:scrollToduring a finger's fade (1/1/1).Scope
Testing
pnpm exec vitest run packages/core/src/{motion,AppShell,gestures,theme,hooks,internal,Layout} packages/core/src/utils/touchPresentation.test.ts: 126 files, 3394 tests pass,motion/uikit/tableImports.test.tsincluded (AppShell imports the bars table and hands its springs andregisterBarsTableto the hook). New jsdom cases:will-change(fails before the fix: the old switch leftwill-change: opacity);spec:AST-094FR19, FR24); with a mouse a finger never composites the veil;useFingerScroll, only while it runs; a mouse observes and watches nothing;defaultIsMobileguesses (failed at ae06884, which dropped it through the guess);scrollend, the layer drops 200 ms after the finger's last scroll event, past the finger-scroll watch's 150 ms; each shell keeps one document listener,scrollendor elsescroll(both failed at ae06884);bars.ts, its denser contrast values included;@astryxdesign/core/internal/motionhands LabuseScrollEdgeEffect,barsandregisterBarsTableby name, the same values Core imports by path (failed before the re-exports).pnpm exec vitest run --project node packages/cli/test: 309 pass, no golden change (the bars table lists no roles).pnpm test:interaction AppShellScrollEdge gestures.browseragainst a freshpnpm storybook:build: 49 pass in Chromium; 39 pass and 10 Chromium-only skips in WebKit (official Playwright image), the gesture host's watcher spec included. A finger's scroll is a finger resting on the content while the page scrolls, or a real touch drag in Chromium. New cases: a programmatic scroll switches with no animation; a finger's fade, then a programmatic switch; a mouse keeps the opaque bar. Every mouse case, unit and browser, first asserts that its pointer is fine.scrollToright after a swipe 1/1/1 (the remaining case above). At an earlier head with the same effect: flick crossing during the momentum 0/0/0, quick back-and-forth 0/0/0. Reduce Motion was set throughidb. The mid-fade turnaround is covered by the specs (the simulator's injected swipes come about 0.5 s apart, longer than the fade).scrollTo120 px and back to 0), blank frames per take: no layer 0 in 5;will-change: opacityon the child, never switching, 3/1/2/1/3;will-changeadded with the switch and removed 500 ms later (what the animator's promotion did) 2/2/1; aposition: fixedchild outside the header 2/3/2; the layer dropped in the same task as the scroll 0 in 6.SkiaOutputSurfaceImplOnGpu::SwapBuffers, 78 ms) with nothing of ours waiting: the emulator's software-GL swap. A composited veil makes it more frequent there (9 of 13 takes withwill-changethroughout, 4 of 10 without), and a hidden veil removes it (0 of 5).scripts/touch-support-bundle.test.mjs› "is absent from the root entry" is red: AppShell's touch bar 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: 0 violations. Rootpnpm typecheckafterpnpm build(CI's order): 0 errors.check:changesets, 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.