Skip to content

feat(AppShell): height="adaptive" scrolls the page on touch devices and keeps independent regions with a mouse - #8066

Draft
vjeux wants to merge 6 commits into
meta:mainfrom
vjeux:uikit/c10-appshell-adaptive
Draft

vjeux wants to merge 6 commits into
meta:mainfrom
vjeux:uikit/c10-appshell-adaptive

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review only this PR's change: vjeux/astryx@71601aa...64ba8ef

Why

On phones, browsers tie behaviors to the page's own scroll: the address bar shrinks as the page scrolls, and on iOS a tap on the status bar scrolls to the top. AppShell's default height="fill" scrolls the main region inside a screen-high shell, so neither happens.

What led to it

This is part of the touch-presentations program (spec:AST-094), which makes native-feeling touch behavior the default on every touch device. height="auto" scrolls the page and gets both behaviors, but costs desktops fill's independent regions, such as a side region that scrolls on its own, so apps had to pick one for every device.

Options and tradeoffs

  • Change fill under a finger: breaks apps that rely on main scrolling (spec:AST-017 FR1, FR3).
  • Switch at a width breakpoint: a phone rotating across it would change scrollers and lose its position.
  • A new value, height="adaptive": fill with a mouse or trackpad, auto under a finger, from the shared touch-presentation check.

Why this choice

adaptive follows the input rather than the width and changes no existing value: height?: 'fill' | 'auto' | 'adaptive'. data-scroll-owner on the root (content or document) tells code that restores scroll which element scrolls. Both are proposed additions to component:AppShell, pending owner review, as is making adaptive the default in a later minor (height="fill" would restore today's behavior).

How to check it

In the AppShell playground with 40 rows:

  1. On a phone or the iOS simulator, with adaptive: scroll down. The header stays pinned, the page scrolls and the address bar shrinks; tap the status bar and the page returns to the top.
  2. With fill: main scrolls inside the shell and the bars stay.
  3. On a desktop with a mouse, adaptive keeps independent regions, like fill.

Evidence

fill (main) and adaptive (this PR), iOS 26.2 simulator, then Android 15 emulator:

fill adaptive
fill, Android adaptive, Android

With the main thread blocked for 3 s, the page still scrolls: Main thread blocked

The storyboard: the page scrolls under the pinned header, main never does: Storyboard

@vercel

vercel Bot commented Oct 24, 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 26, 2026 4:19pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 30, 2026, 1:41 PM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026

@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.

Approval is on hold until an engineering owner decides the new public AppShell contract this adds: height="adaptive", the root data-scroll-owner attribute, and mobileNav.defaultIsMobile seeding the touch query on the server. No current spec covers any of them yet. An engineering owner can record that decision by commenting /approve-spec 72d78d90d603c7b549dbf3f7b76c794183b07586 on this pull request or by identifying the record that should hold it, and the author may do so if they are an engineering owner. Separately from that decision, a few fixes are needed:

  • The new AdaptiveHeight story fails the pr-a11y check (scrollable-region-focusable on the main region in its mouse, fill, state); make the overflowing main keyboard-reachable or get a reviewer's blessing for a baseline entry.
  • defaultIsMobile is still documented as ignored when breakpoint is none, but it now seeds adaptive's server render in that case too.
  • The AppShell component description still says it supports two height modes.

[Automated review]

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated
// server-rendered phone page starts out scrolling the page.
const isTouchPointer = useMediaQuery(
TOUCH_POINTER_QUERY,
mobileNavConfig?.defaultIsMobile ?? false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

defaultIsMobile now also seeds the touch query, including under breakpoint: 'none', where the prop's JSDoc (lines 172-175) and AppShell.doc.mjs say it is ignored. A server render with {breakpoint: 'none', defaultIsMobile: true} gives data-scroll-owner="document", and a fine-pointer client switches to content after hydration. Either document this second role (JSDoc plus the three doc entries) or gate it like the breakpoint seed at line 554.

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 7906356 by documenting the second role: the prop JSDoc and the three doc entries now say that with height="adaptive", defaultIsMobile also seeds the server render's touch assumption whatever the breakpoint, while breakpoint none still ignores it for the layout. The component summary now lists three height modes.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

AppShell (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 785B 785B 0B
Lines of Code 608 620 -
Complexity Very High Very High (119) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AppShell 55.5KB 55.7KB +246B 11.6KB 11.6KB 0B

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.

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.3KB 34.3KB 0B

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

@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.

Approval is on hold until an engineering owner decides whether height="adaptive", the root data-scroll-owner attribute, and mobileNav.defaultIsMobile seeding the touch query on the server become AppShell's public contract, since no current spec covers them yet. An engineering owner can record that decision by commenting /approve-spec 53b41e8e713f4a80409eaf90937c00d2a59df1fa on this pull request or by identifying the record that should hold it, and the author may do so if they are an engineering owner. The keyboard stop added to the Adaptive Height story resolves the earlier accessibility failure; two documentation fixes are still needed:

  • defaultIsMobile is still documented as ignored when breakpoint is none, but it now seeds the server render of adaptive in that case too.
  • The AppShell component description still says it supports two height modes.

[Automated review]

@cixzhang

Copy link
Copy Markdown
Contributor

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:

  • Touch gating. core/src/AppShell/AppShell.tsx:92 writes its own (pointer: coarse) check. This conflicts with AST-012 FR2 (touch is resolved once, through the closed adaptation vocabulary) and public-component-api INV16. Resolve through the shared touch resolver key so behavior matches the spec. If this behavior genuinely can't be expressed through the resolver, please raise it here as a spec question instead of shipping a separate check.

This is about where the behavior lives, not about the PR's intent.

@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.

Approval is on hold until an engineering owner decides whether height="adaptive", the root data-scroll-owner attribute, and mobileNav.defaultIsMobile seeding the touch query on the server become AppShell's public contract, since no current spec covers them yet. An engineering owner can record that decision by commenting /approve-spec 7906356a046c22de4e9b870c36235647a728ca86 on this pull request or by identifying the record that should hold it, and the author may do so if they are an engineering owner. Separately, one fix is needed: when the primary pointer changes at runtime, adaptive remounts the side navigation, so a sidebar the user collapsed expands again, because AppShell.tsx:801-806 wraps the side nav in a sticky div only in auto mode; keeping that element tree the same in both modes, with a test that a collapsed SideNav survives the switch, would fix it. The earlier documentation points are resolved on this head.

[Automated review]

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated
Comment on lines +618 to +621
const scrollsDocument =
height === 'auto' || (height === 'adaptive' && isTouchPointer);
const isFill = !scrollsDocument;
const isAuto = scrollsDocument;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When isTouchPointer changes under height="adaptive", isAuto flips and sideNavContent (lines 801-806) switches between the bare LayoutPanel and the sticky wrapper div, so React remounts the side navigation: a <SideNav collapsible> the user collapsed expands again after the pointer turns coarse, while main keeps its state. Keeping the side-nav element tree the same in both modes (render the wrapper in both and apply the sticky styles only for auto), and asserting that in the "adaptive follows the primary pointer when it changes" test, would cover it.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: one implementation.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
@vjeux
vjeux force-pushed the uikit/c10-appshell-adaptive branch from 7906356 to e1349f7 Compare October 24, 2026 23:34
@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Done in e1349f7: AppShell no longer has a (pointer: coarse) check of its own. height="adaptive" reads isTouch from useTouchPresentation, seeded by mobileNav.defaultIsMobile, and this PR now stacks on #8076.

@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.

Approval is on hold until an engineering owner decides whether component:AppShell should be amended to admit height="adaptive" (keyed on the experimental touch resolver), mobileNav.defaultIsMobile seeding it on the server under every breakpoint, and the root data-scroll-owner attribute, since the current AppShell record allows only fill and auto; an engineering owner can record that decision by commenting /approve-spec e1349f7d94d0d532e5d5913d5d1a35cc95390f36 on this pull request or by identifying the record that should hold it, and the author may do so as an engineering owner. Independently of that decision, these need changes:

  • This branch now carries the whole #8076 stack (about 29,100 of its 29,300 changed lines); rebase it onto main once that stack lands so only the five AppShell files remain.
  • A runtime pointer change under adaptive remounts the side navigation, so a collapsed SideNav expands again (AppShell.tsx lines 796-801); keep the same element tree in both modes and extend the pointer-change test.
  • Any other height value now behaves as fill instead of the recorded auto minimum height without sticky regions (AppShell.tsx line 615).
  • The carried theme platform adaptation condition goes beyond the closed condition vocabulary of spec:AST-012 FR2 and needs an amendment or removal in the stack's own pull request.

[Automated review]

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated
});
const scrollsDocument =
height === 'auto' || (height === 'adaptive' && isTouch);
const isFill = !scrollsDocument;

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 isFill = !scrollsDocument, any value outside fill, auto and adaptive now gets fill (root height: 100dvh, main overflow: auto), while the base gave it the auto minimum height without sticky regions, as component:AppShell records. Resolving only adaptive keeps that:

const resolvedHeight = height === 'adaptive' ? (isTouch ? 'auto' : 'fill') : height;
const isFill = resolvedHeight === 'fill';
const isAuto = resolvedHeight === 'auto';

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated
const scrollsDocument =
height === 'auto' || (height === 'adaptive' && isTouch);
const isFill = !scrollsDocument;
const isAuto = scrollsDocument;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still open on this head: when isTouch changes under height="adaptive", isAuto flips and sideNavContent switches between the bare LayoutPanel and the sticky wrapper div, so React remounts the side navigation and a collapsed <SideNav collapsible> expands again (reproduced in Chromium and jsdom). Rendering the wrapper in both modes with the sticky styles only for auto, and asserting collapsed state in "adaptive follows the primary pointer when it changes", would cover it.

@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.

Approval is on hold until an engineering owner decides whether component:AppShell should be amended to admit height="adaptive" (keyed on the experimental touch resolver), mobileNav.defaultIsMobile seeding it on the server under every breakpoint, and the root data-scroll-owner attribute, since the current AppShell record allows only fill and auto. An engineering owner can record that decision by commenting /approve-spec efa5550100bbbf0a2da4bf40949382918faf5706 on this pull request or by identifying the record that should hold it, and the author may do so as an engineering owner. Independently of that decision, these need changes:

  • This branch carries the whole #8076 stack (about 29,100 of its 29,350 changed lines); rebase it onto main once that stack lands so only the five AppShell files remain.
  • A runtime pointer change under adaptive remounts the side navigation, so a collapsed SideNav expands again (AppShell.tsx lines 796-801); keep the same element tree in both modes and extend the pointer-change test.
  • Any other height value now behaves as fill instead of the recorded auto minimum height without sticky regions (AppShell.tsx line 615).
  • The carried theme platform adaptation condition goes beyond the closed condition vocabulary of spec:AST-012 FR2 and needs an amendment or removal in the stack's own pull request.

[Automated review]

@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.

Approval is on hold until an engineering owner decides whether component:AppShell, which today allows only fill and auto, should be updated to admit height="adaptive" (keyed on the experimental touch resolver), mobileNav.defaultIsMobile seeding it on the server under every breakpoint, and the root data-scroll-owner attribute. An engineering owner can record that decision by commenting /approve-spec 5300d0a42c2a00fa50e5a7fb6503e5f8a480de93 on this pull request or by updating the record, and the author may do so as an engineering owner. Whatever the decision, the branch needs a rebase, and if adaptive stays it needs two fixes:

  • This branch carries the whole #8076 stack (about 30,600 of its 30,800 authored lines); rebase it onto main once that stack lands so only the five AppShell files remain.
  • A runtime pointer change under adaptive remounts the side navigation, so a collapsed SideNav expands again (AppShell.tsx lines 796-801); keep the same element tree in both modes and extend the pointer-change test.
  • Any other height value now behaves as fill instead of the recorded auto minimum height without sticky regions (AppShell.tsx line 615).

[Automated review]

@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.

Approval is on hold until an engineering owner decides whether component:AppShell, which today allows only fill and auto, should be updated to admit height="adaptive", mobileNav.defaultIsMobile seeding it on the server under every breakpoint, and the root data-scroll-owner attribute; an engineering owner records that decision by commenting /approve-spec a4844b1ce56f98b7bfdea1afb79cb230bb1398f6 on this pull request (the author may do so as an engineering owner), and whatever the decision the branch needs a rebase, plus two fixes if adaptive stays:

  • Rebase onto main once #8076 lands, so only the five AppShell files remain.
  • Keep the side navigation mounted when the primary pointer changes under adaptive, since today a collapsed SideNav expands again (AppShell.tsx lines 796-801).
  • Give a height outside the union the recorded auto minimum height instead of fill (AppShell.tsx lines 613-616), unless the record update settles it.

[Automated review]

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated
});
const scrollsDocument =
height === 'auto' || (height === 'adaptive' && isTouch);
const isFill = !scrollsDocument;

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 isFill = !scrollsDocument, a height outside fill, auto and adaptive now renders the fill frame (root height: 100dvh, main region scrolling); the base and component:AppShell line 142 give it the auto minimum height without sticky regions. Computing isFill as height === 'fill' || (height === 'adaptive' && !isTouch) and deriving data-scroll-owner from it keeps the recorded behavior.

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 64ba8ef: isFill is now height === 'fill' || (height === 'adaptive' && !isTouch), so a height outside the union keeps the recorded auto minimum height with no sticky regions, and data-scroll-owner follows it. A unit test pins it.

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated
const scrollsDocument =
height === 'auto' || (height === 'adaptive' && isTouch);
const isFill = !scrollsDocument;
const isAuto = scrollsDocument;

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 adaptive, isAuto now flips with the primary pointer, and sideNavContent (lines 796-801) wraps the side panel in a sticky div only while it is set, so every pointer change unmounts and remounts the side region and a collapsed SideNav expands again. Keeping the wrapper in both modes and toggling only its sticky style keeps it mounted; the pointer-change test could assert that a collapsed SideNav stays collapsed.

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 64ba8ef: under adaptive the side panel's wrapper stays in both pointer states and only its style changes (no box with a fine pointer), so a pointer change no longer remounts the side region. The pointer-change test collapses a SideNav and checks it stays collapsed across both changes.

vjeux added 6 commits October 26, 2026 08:43
…ve's server render

The component summary still listed two height modes. defaultIsMobile was
documented as ignored under breakpoint 'none', but with height="adaptive"
it also seeds the server render's touch assumption, whatever the
breakpoint; the JSDoc and the three doc entries now say so.
… touch resolver

AppShell kept its own (pointer: coarse) query for height="adaptive". It
now reads isTouch from useTouchPresentation, the one definition of when a
component presents itself the touch way (AST-012 FR2), seeded on the
server by mobileNav.defaultIsMobile as before. A source test keeps AppShell
on the resolver; the changeset says "under a finger".
…eries, not StyleX keys

Touch is read in script only through useTouchPresentation: the check
rejects a media query on the pointer in matchMedia or useMediaQuery,
literal or through a constant. A StyleX key spelled as the resolver's
constant ('@media (pointer: coarse)') stays allowed, since the
resolver's spelling test pins it.
…r change, and an unknown height stays auto

Under height="adaptive" the side panel's sticky wrapper came and went with
the primary pointer, so every pointer change remounted the side region and a
collapsed SideNav expanded again: the wrapper now stays in both pointer
states and only its style changes (no box with a fine pointer). And a height
outside the union rendered the fill frame; it keeps the recorded auto minimum
height with no sticky regions again, and data-scroll-owner follows.
@vjeux
vjeux force-pushed the uikit/c10-appshell-adaptive branch from a4844b1 to 64ba8ef Compare October 26, 2026 16:03
@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

component:AppShell records height as fill or auto, so this pull request's three public additions need an engineering owner's decision before review can approve them. One question each:

  1. Should component:AppShell admit height="adaptive" (fill with a mouse or trackpad, auto where the primary pointer is touch)? Our reading: yes. Under a finger, browsers tie page behaviors (the toolbar that minimizes as the page scrolls, iOS's status-bar tap to the top) to the document's own scroll, which fill never gives (the takes above); fill and auto are unchanged.
  2. Should mobileNav.defaultIsMobile also seed adaptive's server render, under every breakpoint? Our reading: yes. A server can't read the pointer, and the caller's phone hint is the signal it has, so a phone page starts out scrolling the page instead of switching after hydration (the server-render unit test).
  3. Should the root report its scroller in data-scroll-owner (content or document)? Our reading: yes. Once the scroller follows the pointer, code that restores or listens to the scroll needs to know which element scrolls; the attribute follows every mode, including an unknown height (unit tests).

An engineering owner's spec approval on this pull request's head records the decision.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026

@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.

Approval is on hold until an engineering owner decides whether component:AppShell should be updated to admit height="adaptive", the root data-scroll-owner attribute (which now replaces a caller's own value), and mobileNav.defaultIsMobile seeding adaptive's server render under every breakpoint, a seed that also departs from spec:AST-094 FR13, where the server and hydrating renders produce the pointer presentation. An engineering owner records that decision by commenting /approve-spec 64ba8ef17da62c06585e7dfa52f90d4f036b2757 on this pull request, and the author may do so as an engineering owner. If the seed stays, it also needs the real-browser server-render and hydration evidence that spec:AST-013 FR10 requires; the earlier side-navigation remount and out-of-range height findings are resolved on this head.

[Automated review]

// gets none of them. The SSR hint for mobile layout also seeds it, so a
// server-rendered phone page starts out scrolling the page.
const {isTouch} = useTouchPresentation({
isTouch: mobileNavConfig?.defaultIsMobile ?? false,

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 seed makes adaptive's server and hydrating render the touch layout whenever defaultIsMobile is set, including under breakpoint: 'none'. spec:AST-094 FR13 has both renders produce the pointer presentation and component:AppShell FR8 ignores the hint under none, so the seed is part of the owner decision; if it stays, spec:AST-013 FR10 asks for a real-browser server-render-and-hydrate check, which the jsdom test at AppShell.test.tsx:1337 does not provide.

{...rest}
ref={useMergedRefs(ref, shellRef)}
data-testid={dataTestId}
data-scroll-owner={scrollsDocument ? 'document' : 'content'}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Written after {...rest}, this replaces a caller's own data-scroll-owner: a caller's custom reaches the root on the base and reads content here. component:AppShell FR1 and architecture:public-component-api INV5 pass data inputs through today, so whether AppShell owns this attribute is part of the owner decision.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…names

Byte-identical copies of each clip and still, named by pull request only.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:30
cixzhang pushed a commit that referenced this pull request Oct 31, 2026
…wing main region (#8111)

Siblings: #8066, #6314.

## Why

With `height="fill"`, a keyboard user can't scroll AppShell's main region when it holds nothing focusable: Tab skips it, so Page Down and the arrow keys do nothing. axe reports it as `scrollable-region-focusable`, a serious violation.

## What led to it

Part of the touch-presentations program's work on AppShell scrolling (`spec:AST-094`), alongside #8066, which changes which element scrolls. Main was only the skip link's `tabIndex={-1}` target, which Tab never stops on.

## Options and tradeoffs

- **Always make main a tab stop:** simple, but adds a stop even when main fits, and an extra stop before its first link.
- **A stop only while main overflows, with Tab entry moving on to its first link or button:** what `spec:AST-025` FR12 asks; it costs measuring main, which happens only with `fill`.

## Why this choice

The second follows FR12 and AppShell FR4: a viewport joins the tab order only while it overflows, and Shift+Tab skips it. A fitting main, and `height="auto"`, keep `tabIndex={-1}`. Only Tab focus draws main's ring, inset and following the surface's rounded corner; a click, the skip link or a script's focus draws none.

Main has no inner content box, so an observer internal to AppShell watches main's direct children; the public `useScrollableArea` is unchanged, and its one-element form is a separate draft that waits on AST-025 OQ1. The AppShell record states the observation (PR1) and the ring (FR4), and drops the gap this closes. Main's stop is named by its `main` role alone, which the FR12 ruling allows for the single main.

## How to check it

In Chromium, on the AppShell Playground story with `height="fill"` and 40 rows:

1. Press Tab until focus reaches main: a ring appears inside its edge, and Page Down scrolls it. Before, Tab left the page.
2. Add a link inside main: Tab lands on the link instead, and Shift+Tab from it skips main.
3. Click into main, or use the skip link: no ring.
4. Show fewer rows so main fits: main leaves the tab order.

## Evidence

Before (main) and after (this PR):

![main](https://raw.githubusercontent.com/vjeux/astryx/ac93708b7e1bbaa6be17a20c8f7fd61bf4b7bbe8/8111-kbd-main-v2.gif) ![this PR](https://raw.githubusercontent.com/vjeux/astryx/ac93708b7e1bbaa6be17a20c8f7fd61bf4b7bbe8/8111-kbd-pr-v3.gif)

Tab, then Shift+Tab, with a link inside; the ring: ![Tab, then Shift+Tab](https://raw.githubusercontent.com/vjeux/astryx/ac93708b7e1bbaa6be17a20c8f7fd61bf4b7bbe8/8111-kbd-link-pr-v3.gif) ![Focus ring](https://raw.githubusercontent.com/vjeux/astryx/ac93708b7e1bbaa6be17a20c8f7fd61bf4b7bbe8/8111-ring-v3.png)

- The browser test `AppShellMainKeyboard.a11y.browser.spec.ts` covers each step: 16 of 16 in Chromium, and 15 of 15 in WebKit, where the window-switch case is skipped.
- Tab onto main exposes the landmark: Chromium's accessibility tree shows the focused node as `main`, not ignored and unnamed, so its role is announced; WebKit computes the same role. The test fails on main (Tab never reaches main) and when main is given a name.
- AppShell's keyboard tests fail without the observer, or when a `fill`/`auto` switch doesn't restart it; each of the observer's 5 tests fails when its behavior is removed.
- Local gates at 7d439ae: core UI suite (655 files), build, type check, lint, Storybook, the keyboard spec in both browsers, knowledge records and changeset coverage pass; the node project fails only on `cache.test.mjs` and `story-tree.test.ts`, which #8520 and #8509 fix.

## Writing rubric results

- R1 narration: the PR1, FR4 and verification-map edits are present tense; the closed current-state gap is removed, and the glance row counts four gaps.
- R2 research: none in the record.
- R3 evidence: the new verification cells name test files, not changes.
- R4 rejected alternatives: none added.
- R5 change references: none.
- R6 open questions: none touched; AST-025 OQ1 stays open with its options.

This branch was successfully deployed

1 active deployment
Preview — 64ba8ef1 Deployed Oct 26, 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