Skip to content

fix(AppShell): the header's items and the content's end stay inside the safe area - #8150

Open
vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:uikit/c16-safe-areas
Open

vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:uikit/c16-safe-areas

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

User impact

On a phone page with viewport-fit=cover (an installed web app with a translucent status bar), AppShell's header title and buttons sit under the status bar and Dynamic Island, and the last line on the home indicator. On iOS 26 every Home Screen icon opens as a web app.

Problem and solution fit

  • Cause: nothing in AppShell reads env(safe-area-inset-*).
  • Fix: the header pads its items by the top and side insets; its background still reaches under the status bar. Main's end padding grows by the bottom inset (border-box, so a child that fills main ends above it), in the page or in its own scroller. Under a banner, the sidenav-only top bar takes the side insets only.

Expected behavior and authority

UIKit bars attached to the top extend their background behind the status bar while their items stay in the safe area; WebKit's guidance for viewport-fit=cover is to pad content with env(safe-area-inset-*). Astryx does this for layers (spec:AST-059) and the fullscreen Dialog, Drawer and BottomSheet, but not AppShell (family:layout-regions DEC-2).

Smallest restoration

Two AppShell styles and a docs best practice. Without viewport-fit=cover, or in a portrait Safari tab, every inset is 0 and nothing moves.

Owner decisions (proposed; pending owner review):

  1. Content side insets in landscape are left out: they combine with --container-padding-* (architecture:container-padding), whose owners decide.
  2. AppShell does not yet publish its bar height to --astryx-layer-inset-block-start (AST-059): the toast viewport sits outside AppShell's subtree.

Evidence

  • Before (main): an iOS 26.2 installed web app reports insets 62 and 34; header padding 0, title under the clock (left). After: padding 62, items at y = 62, end padding +34 (right).
  • Unchanged: zero insets in a portrait Safari tab; identical geometry in Chromium without insets.

iOS 26.2 installed web app, main (left) and this branch (right): the header items move below the status bar

The end padding is proven in Chromium with emulated insets; the simulator's installed app stops its viewport 62 pt above the screen bottom, so nothing reaches the home indicator to film.

Same installed web app, main (top) and this branch (bottom):

P3: landscape, the header items stay inside the side insets

P4: right to left in landscape, the side insets swap

P5: the sidenav-only phone top bar keeps to the safe area

Siblings: #8111 (merged, kept here) and #8142 edit these AppShell lines.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

Local gates at 71d5e63e67, one commit on main bcb16db0e8:

  • vitest over AppShell, Layer and the keyboard-scroll helpers: 17 files, 348 tests pass. The new AppShellSafeArea.test.tsx reads the rules StyleX injects, because jsdom drops env(): the header's top and side insets (swapped in RTL), the sidenav-only phone top bar (sides only under a banner), and main's end padding, alone or added to the content padding.
  • AppShellSafeArea.a11y.chromium.spec.ts against a fresh pnpm storybook:build: 6 pass. Insets are emulated over the Chromium protocol: portrait 62/34 on "Top nav only" and "Auto height", landscape 62 on each side with 21 at the bottom, the sidenav-only top bar, no insets, and a child that fills main ending 34 px above the bottom with no outer scroll. Against main's build at c5383172d4: 5 of 6 fail; only "with no insets nothing moves" passes.
  • AppShellMainKeyboard.a11y.browser.spec.ts (fix(AppShell): with fill height, keyboard users can scroll an overflowing main region #8111, which shares main's style list): 16 pass in Chromium, 15 and 1 skip in WebKit.
  • iOS 26.2 simulator (iPhone 17 Pro): the story added to the Home Screen from main's build and from this branch, with viewport-fit=cover and black-translucent; the same page in a Safari tab reports zero insets.
  • layerViewportInset.test.ts: AppShell joins its GUTTER_OWNERS, the surfaces that read the device's insets outside the layer gutter (AST-059 non-goals); AppShell pads the page shell and is not an anchor-mode layer.
  • Typecheck; lint; Prettier on the touched files; changesets; pnpm test:ui: 681 files, 17,012 tests pass; pnpm test:node: everything passes except story-tree.test.ts (test(storybook): run the story-tree check where Storybook is built, and bring the tree back to its shape #8509) and two cache.test.mjs cases (fix(cli): a crashed run's release install is recognized by the Node it ran on, whatever its name #8520), which fail the same way on main.

@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 31, 2026 6:11am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 24, 2026
@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 31, 2026, 6:21 AM UTC

@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 1020 1060 -
Complexity Very High Very High (174) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AppShell 71.1KB 71.2KB +141B 12.0KB 12.1KB +104B
core/LayerProvider 66.4KB 66.4KB 0B 10.4KB 10.4KB 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 36.0KB 36.1KB +83B

Accessibility Audit

Status: The accessibility audit did not run on this pull request. It runs after merges to main and before each release; pull requests that touch components run it with the ci:heavy-lanes label.


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.

This needs an engineering owner's decision before it can be approved: no current spec defines how AppShell handles the device's safe-area insets, and the description itself lists the landscape content insets and the LayerProvider block-start inset as pending owner decisions. An engineering owner can record that decision by commenting /approve-spec <full head SHA> on this pull request (you may do so yourself if you are an engineering owner), or point to the spec that already governs this behavior. Whatever the decision, these fixes are needed:

  • With a banner over a sidenav-only layout on a phone, both header wrappers pad by the top inset, leaving a 62px blank band above the phone top bar (AppShell.tsx:875-879).
  • The bottom ::after spacer makes a child that fills main in fill mode, such as the shell-nav template's Layout height="fill", overflow by the inset, so main gains an outer scroll while the child's footer still sits on the home indicator.
  • The docs best practice and the Changeset should say that in landscape the SideNav and the content sides are not inset, and the header of AppShellSafeArea.test.tsx points to a spec file that does not exist.

[Automated review]

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated
stylex.props(
headerAreaStyle,
isAuto && styles.headerSticky,
styles.headerSafeArea,

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 a banner and no TopNav, this wrapper renders under the banner's header wrapper, which already pads by the top inset, so on a phone with a 62px inset the top bar gets a second 62px blank band. Apply the block-start inset here only when headerContent == null, the condition the role above already uses.

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 e7320d9: under a banner, the sidenav-only top bar now takes the side insets only (headerContent == null ? headerSafeArea : headerSafeAreaSides). New jsdom test “pads a sidenav-only phone top bar under a banner by the sides only” fails on the previous head.

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated
Comment on lines +444 to +448
mainSafeAreaEnd: {
'::after': {
content: '""',
display: 'block',
height: 'env(safe-area-inset-bottom, 0px)',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In fill mode a child that fills main (for example Layout height="fill" under contentPadding={0}, as the shell-nav template composes it) now overflows by the bottom inset: main scrolls 34px while the child's footer still sits on the home indicator. Carrying the inset in main's block-end padding (LayoutContent is border-box) would let a percentage-height child end above it instead.

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 e7320d9: the inset now lives in main's block-end padding (LayoutContent is border-box), alone or added to the content padding, instead of an ::after spacer. New Chromium case: a height: 100% child in main ends 34 px above the bottom with no outer scroll (fails on the previous head); jsdom checks both padding forms.

{
guidance: true,
description:
'On phones, opt the page into the full screen with `viewport-fit=cover` in the viewport meta tag (`width=device-width, initial-scale=1, viewport-fit=cover`). AppShell then extends its header under the status bar and out to the screen sides, and keeps the header\'s items and the end of the content inside the device\'s safe area. An installed web app can also draw under the status bar with `<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">`.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In landscape AppShell insets only the header: once the page opts into viewport-fit=cover, the inline SideNav and the content sides sit under the notch. This guidance (and the Changeset) should say so until that case is decided.

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 e7320d9: the best practice, its dense form and the changeset now say that in landscape only the header is inset (not an inline SideNav or the content sides).

* @position Testing; validates AppShell.tsx's safe-area styles. jsdom's CSS
* parser drops `env()` values, so the declarations are read from the rules
* StyleX hands to `insertRule`; the geometry under real insets is proven by
* __tests__/AppShellSafeArea.interaction.browser.spec.ts

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 file does not exist; the browser proof is __tests__/AppShellSafeArea.a11y.chromium.spec.ts.

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 e7320d9: the header now points to __tests__/AppShellSafeArea.a11y.chromium.spec.ts.

@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 decision before it can be approved. No current spec defines AppShell's safe-area behavior, and component:AppShell FR4 says main's padding is contentPadding, so the bottom inset also needs that claim amended. An engineering owner can approve this direction by commenting /approve-spec <full head SHA> on this pull request (you may do so yourself if you are an engineering owner), or reject it. Whatever the decision, AppShell.tsx:463-465 needs a fix: it adds the inset to --layout-padding-own-outer-y, which is 0px inside AppShell, so main loses its bottom contentPadding on every page (24px to 0px in Top nav only with no inset); composing with var(--container-padding-block-end, 0px) as Drawer does keeps it, and the Chromium spec should assert contentPadding plus the inset rather than a base measured on the same head.

[Automated review]

paddingBlockEnd: 'env(safe-area-inset-bottom, 0px)',
},
mainSafeAreaEndPadded: {
paddingBlockEnd: `calc(var(--layout-padding-own-outer-y, var(--layout-padding-outer-y, ${spacingVars['--spacing-4']})) + env(safe-area-inset-bottom, 0px))`,

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 adds the inset to --layout-padding-own-outer-y, which is 0px here: AppShell renders its own <Layout padding={0}> and main inherits that value, while LayoutContent applies contentPadding through paddingStyles[padding] and --container-padding-block-end. So main's block-end padding drops contentPadding on every page: Top nav only goes from 24px to 0px with no inset, and gets 34px instead of 58px with a 34px inset. calc(var(--container-padding-block-end, 0px) + env(safe-area-inset-bottom, 0px)), as Drawer.tsx composes it, resolves to 24px and 58px here and also covers the flush branch, since the variable is 0px when contentPadding is 0.

expect(g.padding.top).toBe(PORTRAIT.top);
expect(g.item.top - g.bannerTop).toBeGreaterThanOrEqual(PORTRAIT.top - 0.5);
// Main's end padding grows by exactly the bottom inset.
expect(g.spacer - base).toBe(PORTRAIT.bottom);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

base is measured on this same head, where main's block-end padding is already 0, so this checks only the delta and passes with contentPadding lost. Comparing against contentPadding plus the inset (24 + 34 on Top nav only) would catch it; the same applies at line 208, and line 182 should expect 58 rather than the bare inset.

@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 decision before it can be approved: no current spec defines AppShell's safe-area behavior, and component:AppShell FR4 says main's padding is contentPadding, so the bottom inset also needs that claim amended. An engineering owner can approve the direction by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or reject it. Whatever the decision, AppShell.tsx:475-477 still drops contentPadding: it adds the inset to --layout-padding-own-outer-y, which is 0px inside AppShell, so Top nav only loses its 24px bottom padding with no inset and gets 34px instead of 58px with a 34px inset. Composing with var(--container-padding-block-end, 0px), as Drawer.tsx does, keeps it, and the Chromium spec should assert contentPadding plus the inset rather than a base measured on the same head.

[Automated review]

paddingBlockEnd: 'env(safe-area-inset-bottom, 0px)',
},
mainSafeAreaEndPadded: {
paddingBlockEnd: `calc(var(--layout-padding-own-outer-y, var(--layout-padding-outer-y, ${spacingVars['--spacing-4']})) + env(safe-area-inset-bottom, 0px))`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

--layout-padding-own-outer-y is 0px here: AppShell renders its own <Layout padding={0}> and main inherits that value, while LayoutContent applies contentPadding through paddingStyles[padding], which this overrides. So main's block-end padding drops contentPadding on every page: Top nav only goes from 24px to 0px with no inset, and gets 34px instead of 58px with a 34px inset. calc(var(--container-padding-block-end, 0px) + env(safe-area-inset-bottom, 0px)), as Drawer.tsx composes it, resolves to 24px and 58px here and also covers the flush branch.

expect(g.padding.top).toBe(PORTRAIT.top);
expect(g.item.top - g.bannerTop).toBeGreaterThanOrEqual(PORTRAIT.top - 0.5);
// Main's end padding grows by exactly the bottom inset.
expect(g.spacer - base).toBe(PORTRAIT.bottom);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

base is measured on this same head, where main's block-end padding is already 0, so this checks only the delta and passes with contentPadding lost. Comparing against contentPadding plus the inset (24 + 34 on Top nav only) would catch it; the same applies at line 209, and line 183 should expect 58 rather than the bare inset.

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Oct 31, 2026
@cixzhang
cixzhang marked this pull request as ready for review October 31, 2026 04:00
…he device's safe area

On a phone page that opts into the full screen (`viewport-fit=cover`), an
installed web app above all, AppShell's header items sat under the status
bar and the Dynamic Island, and the last content sat on the home
indicator. The header now pads its items by the top and side safe-area
insets, so its background reaches under the status bar while its items
stay clear, as a UIKit bar does. Under a banner, the sidenav-only phone top
bar takes the side insets only, since the banner's wrapper already pads the
top. Main's block-end padding grows by the bottom inset, in the page or in
its own scroller; main is border-box, so a child that fills it ends above
the inset instead of overflowing. In landscape only the header is inset.
Without `viewport-fit=cover` every inset is zero and nothing moves. The
docs say how a page and an installed web app opt in.

Its browser proof, `AppShellSafeArea.a11y.chromium.spec.ts`, runs in pr-a11y
through its `@pr-a11y scope` line when a pull request changes AppShell or
its stories.

@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 decision before it can be approved: no current spec defines AppShell's safe-area behavior, and component:AppShell FR4 says main's padding is contentPadding, so the bottom inset also needs that claim amended and the header behavior a claim beside FR3. An engineering owner can approve the direction by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or reject it. Whatever the decision, AppShell.tsx:499-501 still drops contentPadding: it adds the inset to --layout-padding-own-outer-y, which is 0px inside AppShell, so Top nav only loses its 24px bottom padding with no inset and gets 34px instead of 58px with a 34px inset. Composing with var(--container-padding-block-end, 0px), as Drawer.tsx does, keeps it, and the Chromium spec and jsdom test should assert contentPadding plus the inset rather than a base measured on the same head.

[Automated review]

This branch was successfully deployed

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

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) 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