Skip to content

feat(ChatLayout): the composer rides the software keyboard - #8176

Draft
vjeux wants to merge 1848 commits into
meta:mainfrom
vjeux:uikit/c08-keyboard-guide
Draft

vjeux wants to merge 1848 commits into
meta:mainfrom
vjeux:uikit/c08-keyboard-guide

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review only this PR's change: vjeux/astryx@39ab09d...7373545, plus its docs fixes vjeux/astryx@7f457b3...ba15b99, vjeux@4e590d0ade and vjeux@3fb83346bb (the merges between them add nothing of their own).

Stacked on #8566 (on #8559, #8360 and #8484), #8554 and #8549. The carried gesture code's open points are fixed below this PR: #8665 makes the gesture host listen for every pointer (FR43), and #8360 tells recognizers apart by internals(recognizer).family instead of instanceof.

Why

On a phone, the on-screen keyboard (337 px tall on an iPhone 17 Pro) covers ChatLayout's composer. Safari then scrolls the whole page to show it, and Chrome on Android makes it jump up once the keyboard is reported. In Messages, the text field sits on the keyboard and moves with it.

What led to it

Part of bringing Astryx's touch behavior to parity with iOS's UIKit, text entry first. spec:AST-099 (current) gives core one keyboard source (#8559) and asks surfaces that sit on the keyboard to move with it. ChatLayout's composer is the main one.

Options and tradeoffs

  • The VirtualKeyboard API: Safari doesn't have it.
  • Moving the composer frame by frame from the measured height: it trails the keyboard and recalculates layout every frame.
  • Jumping to the resting place once Chrome reports the keyboard: simplest, but the owner decided Android gets the same smooth motion as iPhone (spec:AST-099 DEC-2).

Why this choice

ChatLayout lays out from the keyboard's height: the composer rests on the keyboard, and the conversation gets room to scroll its newest message above it. On top, one compositor animation moves the composer with the keyboard's own motion (its spring on iOS, Gboard's measured slide on Android), so it stays in step with no per-frame work. On a touch screen, every way into the text field (a tap, Tab, the keyboard's arrows) focuses it without scrolling the page, and Done rests focus on the composer's group. With a mouse, nothing changes.

How to check it

  1. On an iPhone or the iOS simulator, open ChatLayout's "Full AI Chat" story, in Safari or added to the Home Screen, and tap the composer. Expected: it rises with the keyboard and stays on it; the page doesn't scroll.
  2. Tap Done. Expected: it comes back down with the keyboard.
  3. Turn on Reduce Motion and repeat. Expected: it fades to its new place instead of sliding.
  4. On Android (Chrome with Gboard), repeat steps 1–2. Expected: it moves with the keyboard's slide, with no jump.

Evidence

iOS home-screen web app: main, this PR rise
iOS home-screen web app: scrolled up; refocus while hiding; busy page; Reduce Motion scrolled refocus busy reduce-motion
iOS Safari, in the browser (address masked): this PR safari
Android: Chromium 158, Chrome 124 chrome
Firefox for Android firefox

Unit tests and browser tests (Chromium, WebKit) cover the layout, the motion against each keyboard, Reduce Motion, focus under touch and mouse, iframes and zoom. Known failures from main, fixed elsewhere: slow first loads (#8353, #8471, #8352).

Size (INV6, gzip, main → head): ChatLayout JS 50,010 → 66,504 B, CSS 10,173 → 10,212 B.

@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 29, 2026 10:32pm 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
@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Oct 29, 2026, 10:52 PM 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

MultiSelector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 273B 273B 0B
Lines of Code 2642 2642 -
Complexity Very High Very High (344) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 57.3KB 57.4KB +134B 12.5KB 12.5KB 0B
core/AppShell 66.8KB 66.9KB +101B 11.8KB 11.8KB 0B
core/AvatarGroupOverflow 40.3KB 40.4KB +139B 6.4KB 6.4KB 0B
core/Banner 48.5KB 48.6KB +125B 10.1KB 10.1KB 0B
core/BaseTypeahead 55.0KB 55.1KB +118B 10.1KB 10.1KB 0B
core/BottomSheet 33.9KB 49.1KB +15.2KB ⚠️ 7.9KB 7.9KB +2B
core/BottomSheetSwitcher 14.8KB 14.8KB 0B 3.3KB 3.3KB 0B
core/BreadcrumbItem 67.3KB 67.4KB +116B 11.2KB 11.2KB 0B
core/Button 43.7KB 43.8KB +112B 9.3KB 9.3KB 0B
core/Calendar 55.6KB 55.7KB +114B 10.2KB 10.2KB 0B
core/Carousel 50.9KB 50.8KB -66B 10.3KB 10.3KB 0B
core/ChatComposer 61.3KB 61.9KB +586B 11.0KB 11.0KB 0B
core/ChatComposerDrawer 24.3KB 24.3KB +15B 4.5KB 4.5KB 0B
core/ChatComposerInput 56.2KB 56.7KB +548B 10.2KB 10.2KB 0B
core/ChatComposerTokenElement 56.2KB 56.7KB +546B 10.2KB 10.2KB 0B
core/ChatDictationButton 47.2KB 47.3KB +128B 9.6KB 9.6KB 0B
core/ChatLayout 49.5KB 65.7KB +16.2KB ⚠️ 10.1KB 10.1KB +39B
core/ChatLayoutScrollButton 47.0KB 47.1KB +106B 9.7KB 9.7KB 0B
core/ChatMessage 23.4KB 23.4KB +14B 3.5KB 3.5KB 0B
core/ChatMessageBubble 7.0KB 7.0KB 0B 3.6KB 3.6KB 0B
core/ChatMessageList 29.3KB 29.3KB +13B 7.8KB 7.8KB 0B
core/ChatMessageMetadata 26.4KB 26.4KB +13B 4.0KB 4.0KB 0B
core/ChatSendButton 46.4KB 46.5KB +109B 9.3KB 9.3KB 0B
core/ChatSystemMessage 6.7KB 6.7KB 0B 3.7KB 3.7KB 0B
core/ChatTokenizedText 6.9KB 6.9KB 0B 3.9KB 3.9KB 0B
core/ChatToolCalls 32.4KB 32.5KB +14B 9.1KB 9.1KB 0B
core/CheckboxInput 50.5KB 50.6KB +107B 9.5KB 9.5KB 0B
core/CheckboxListItem 54.7KB 54.8KB +104B 10.6KB 10.6KB 0B
core/ClickableCard 12.8KB 12.9KB +117B 6.9KB 6.9KB 0B
core/CodeBlock 61.2KB 61.3KB +121B 10.8KB 10.8KB 0B
core/Collapsible 18.7KB 18.8KB +102B 4.4KB 4.4KB 0B
core/CommandPalette 51.5KB 51.6KB +107B 12.0KB 12.0KB 0B
core/CommandPaletteItem 8.4KB 8.5KB +110B 3.5KB 3.5KB 0B
core/ComplexSelector 56.5KB 56.6KB +120B 10.8KB 10.8KB 0B
core/ContextMenu 75.3KB 75.2KB -109B 14.8KB 14.8KB +3B
core/ContextMenuItem 53.5KB 53.5KB -33B 9.8KB 9.8KB 0B
core/DateInput 100.2KB 114.2KB +14.0KB ⚠️ 14.9KB 14.9KB +4B
core/DateRangeInput 70.1KB 69.8KB -259B 11.4KB 11.4KB 0B
core/DateTimeInput 110.4KB 124.2KB +13.8KB ⚠️ 15.4KB 15.4KB +3B
core/DialogHeader 49.5KB 49.6KB +118B 10.0KB 10.0KB 0B
core/DrawerHeader 49.6KB 49.7KB +123B 11.4KB 11.4KB 0B
core/DropdownMenu 73.4KB 73.3KB -82B 15.0KB 15.0KB +5B
core/DropdownMenuCheckboxItem 16.4KB 16.5KB +98B 5.7KB 5.7KB 0B
core/DropdownMenuItem 15.6KB 15.7KB +100B 5.3KB 5.3KB 0B
core/DropdownMenuRadioItem 16.4KB 16.5KB +102B 5.7KB 5.7KB 0B
core/DropdownMenuSubMenu 58.7KB 58.8KB +104B 9.7KB 9.7KB 0B
core/FileInput 54.8KB 54.9KB +92B 10.5KB 10.5KB 0B
core/IconButton 43.7KB 43.8KB +131B 9.3KB 9.3KB 0B
core/Item 11.4KB 11.5KB +98B 4.7KB 4.7KB 0B
core/LayerProvider 64.0KB 64.1KB +108B 10.3KB 10.3KB 0B
core/Lightbox 51.9KB 52.0KB +96B 10.0KB 10.0KB 0B
core/Link 44.1KB 44.2KB +111B 7.7KB 7.7KB 0B
core/ListItem 54.1KB 54.2KB +100B 10.6KB 10.6KB 0B
core/Markdown 145.2KB 145.0KB -180B 17.8KB 17.8KB +3B
core/MobileNav 51.7KB 51.8KB +103B 10.2KB 10.2KB 0B
core/MobileNavToggle 46.9KB 47.0KB +121B 9.6KB 9.6KB 0B
core/MoreMenu 73.7KB 73.6KB -139B 15.0KB 15.0KB +5B
core/MultiSelector 73.6KB 73.3KB -320B 14.8KB 14.8KB +3B
core/NumberInput 56.4KB 56.5KB +131B 10.9KB 10.9KB 0B
core/Outline 29.0KB 29.1KB +126B 4.0KB 4.0KB 0B
core/Pagination 85.5KB 85.3KB -222B 15.5KB 15.5KB +4B
core/Popover 50.3KB 50.3KB +2B 12.2KB 12.2KB +3B
core/PowerSearch 166.6KB 178.7KB +12.1KB ⚠️ 20.1KB 20.1KB +4B
core/RadioListItem 52.8KB 52.9KB +106B 9.8KB 9.8KB 0B
core/SegmentedControlItem 8.6KB 8.7KB +116B 3.9KB 3.9KB 0B
core/SelectableCard 12.8KB 12.9KB +114B 7.1KB 7.1KB 0B
core/Selector 77.0KB 76.7KB -305B 15.2KB 15.2KB +4B
core/SelectorOption 14.7KB 14.8KB +99B 5.2KB 5.2KB 0B
core/SideNav 64.6KB 64.7KB +94B 10.7KB 10.7KB 0B
core/SideNavCollapseButton 47.3KB 47.4KB +116B 9.6KB 9.6KB 0B
core/SideNavHeading 59.2KB 59.3KB +126B 10.4KB 10.4KB 0B
core/SideNavItem 55.3KB 55.4KB +115B 10.3KB 10.3KB 0B
core/Step 33.0KB 33.1KB +121B 5.5KB 5.5KB 0B
core/Stepper 48.1KB 48.2KB +106B 9.7KB 9.7KB 0B
core/Switch 49.0KB 49.1KB +110B 9.5KB 9.5KB 0B
core/Tab 9.7KB 9.8KB +114B 4.1KB 4.1KB 0B
core/TabMenu 55.9KB 56.0KB +113B 10.2KB 10.2KB 0B
core/Table 84.7KB 84.4KB -295B 15.6KB 15.6KB +4B
core/TableCell 76.5KB 76.4KB -102B 15.3KB 15.3KB +6B
core/TableHeaderCell 76.1KB 76.0KB -128B 15.0KB 15.1KB +4B
core/TableSelectionToolbar 52.8KB 52.9KB +116B 11.3KB 11.3KB 0B
core/TextInput 54.2KB 54.3KB +98B 10.6KB 10.6KB 0B
core/Thumbnail 51.4KB 51.5KB +117B 11.1KB 11.1KB 0B
core/TimeInput 87.5KB 101.8KB +14.3KB ⚠️ 13.9KB 13.9KB +4B
core/Timestamp 27.9KB 28.0KB +12B 9.7KB 9.7KB 0B
core/Toast 60.6KB 60.7KB +126B 9.9KB 9.9KB 0B
core/ToggleButton 44.5KB 44.6KB +104B 9.4KB 9.4KB 0B
core/Token 30.2KB 30.3KB +118B 4.6KB 4.6KB 0B
core/Tokenizer 69.3KB 69.4KB +113B 16.7KB 16.7KB +4B
core/TopNav 52.5KB 52.6KB +105B 10.5KB 10.5KB 0B
core/TopNavHeading 58.6KB 58.7KB +123B 10.2KB 10.2KB 0B
core/TopNavItem 9.0KB 9.2KB +116B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 55.0KB 55.1KB +134B 11.0KB 11.0KB 0B
core/TopNavMegaMenuItem 9.1KB 9.2KB +119B 4.1KB 4.1KB 0B
core/TopNavMenu 58.3KB 58.4KB +114B 10.3KB 10.3KB 0B
core/TreeList 40.4KB 40.5KB +108B 4.8KB 4.8KB 0B
core/Typeahead 62.2KB 62.3KB +125B 11.4KB 11.4KB 0B
lab/ChatEmojiPicker N/A 65.7KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 66.2KB N/A N/A 1.1KB N/A
lab/CommentComposer N/A 96.2KB N/A N/A 1.9KB N/A
lab/CommentPin N/A 48.1KB N/A N/A 1.2KB N/A
lab/CommentThread N/A 218.2KB N/A N/A 2.4KB N/A
lab/Comments N/A 239.0KB N/A N/A 2.5KB N/A
lab/DiffViewer N/A 46.3KB N/A N/A 2.1KB N/A
lab/EmojiPicker N/A 63.7KB N/A N/A 722B N/A
lab/ListInput N/A 75.2KB N/A N/A 1.6KB N/A
lab/MobilePowerSearch N/A 161.2KB N/A N/A 1.4KB N/A
lab/MobileTokenizer N/A 107.1KB N/A N/A 809B N/A
lab/Schedule N/A 60.3KB N/A N/A 3.9KB N/A
lab/TourStep N/A 55.5KB N/A N/A 590B N/A
lab/TransferList N/A 67.2KB N/A N/A 1.7KB N/A
lab/TransferListSelector N/A 72.7KB N/A N/A 1.9KB N/A
richtext/RichTextEditor N/A 271.6KB N/A N/A 2.6KB N/A
richtext/RichTextEditorToolbar N/A 212.7KB N/A N/A 454B N/A
richtext/RichTextView N/A 252.0KB N/A N/A 2.3KB N/A

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.

⚠️ Touch-only size budget

These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.

Component JavaScript CSS
core/BottomSheet +15.2KB ⚠️ +2B
core/ChatLayout +16.2KB ⚠️ +39B
core/DateInput +14.0KB ⚠️ +4B
core/DateTimeInput +13.8KB ⚠️ +3B
core/PowerSearch +12.1KB ⚠️ +4B
core/TimeInput +14.3KB ⚠️ +4B

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 35.4KB 35.4KB +26B

Accessibility Audit

Status: 2 accessibility violation(s) found — 2 serious.

ChatComposer - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/19 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
ChatComposerInput - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/18 stories · Learn more
    • WCAG: 1.4.3 (Level AA)

Visual Regression

55 of 504 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 440,175
Chat Full AI Chat probe dark 438,105
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Chat Empty State probe light 122,185
Chat Empty State probe dark 120,076
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
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

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
…rinks to stay above it

On iOS 26.2 a popover that would run under the software keyboard shrinks,
its top held on its source, so that its far edge stays 10 pt above the
keyboard; its content scrolls in the shorter box, and it grows back when the
keyboard goes down. Popover's height cap is the layout viewport, which the
iOS keyboard does not shrink, so the keyboard covered the bottom of a tall
popover and a Save button there could not be reached.

usePopoverKeyboardAvoidance follows the keyboard layout guide (meta#8176): while
the popover is open, a keyboard that would cover it caps its height
popover.keyboardClearancePx above the keyboard and makes it scroll, keeping
the focused field in view. UIKit animates the bounds; a height cannot change
on the compositor, so it changes once where the keyboard hides it: when a
rising keyboard has arrived, and as a falling one starts down. A Keyboard
Avoidance story exercises 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.

This can't be approved yet: ChatLayout's new keyboard behavior has no governing spec, and the draft spec:AST-094 it builds on is unapproved and amends current records (architecture:theme-tokens INV3, design:user-states DR1 and DR3, family:buttons, spec:AST-025 FR14). An engineering owner needs to decide whether ChatLayout owns keyboard following as built here and which record states it, and whether to approve AST-094 and update the current records it amends; they record approval by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Independently of that decision:

  • Land #8045, #8067 and #8074 first and rebase, so this pull request is the 1,751-line keyboard change rather than 26,380 lines.
  • Make the lint check pass; ESLint runs out of heap on this head.
  • Add the writing rubric results for AST-094 to the description, and fix its citation of spec:AST-090, which does not exist in the repository.

[Automated review]

follows.
- **FR7 — Exits animate as UIKit's do.** A touch presentation MUST play the
exit its UIKit counterpart plays. **Proposed; pending owner review:** this
departs from the draft `spec:AST-090` FR1 (exits off by default) and FR10

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-090 does not exist on main or at this head, so owners are asked to rule on a departure from requirements nobody can read. Cite the record where FR1 (exits off by default) and FR10 live, or drop the departure until it lands; the same citation appears in Current-state impact (line 235).

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
# Conflicts:
#	packages/core/src/motion/README.md
#	packages/core/src/motion/uikit/index.ts
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
…rinks to stay above it

On iOS 26.2 a popover that would run under the software keyboard shrinks,
its top held on its source, so that its far edge stays 10 pt above the
keyboard; its content scrolls in the shorter box, and it grows back when the
keyboard goes down. Popover's height cap is the layout viewport, which the
iOS keyboard does not shrink, so the keyboard covered the bottom of a tall
popover and a Save button there could not be reached.

usePopoverKeyboardAvoidance follows the keyboard layout guide (meta#8176): while
the popover is open, a keyboard that would cover it caps its height
popover.keyboardClearancePx above the keyboard and makes it scroll, keeping
the focused field in view. UIKit animates the bounds; a height cannot change
on the compositor, so it changes once where the keyboard hides it: when a
rising keyboard has arrived, and as a falling one starts down. A Keyboard
Avoidance story exercises 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.

This can't be approved yet: ChatLayout's new keyboard behavior has no governing spec, and the draft spec:AST-094 it builds on is unapproved and amends current records (architecture:theme-tokens INV3, design:user-states DR1 and DR3, family:buttons, spec:AST-025 FR14). An engineering owner needs to decide whether ChatLayout owns keyboard following as built here and which record states it, and whether to approve AST-094 and update the records it amends; they record that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Independently of that decision:

  • Land #8045, #8067 and #8074 first and rebase, so this pull request is the 1,831-line keyboard change rather than 26,460 lines.
  • The claimed composer tap places the caret only in contenteditable editors, so a tap inside a <textarea> input's draft now leaves the caret at its previous position instead of under the finger.
  • The same claim walks past contenteditable="false" chips, so a tap on a button inside a chip in an unfocused composer focuses the editor and never fires the button's click.
  • The ChatLayout docs say tapping the composer focuses it without scrolling, but only taps on the text are claimed; a tap on the composer body still focuses without preventScroll.
  • Add the writing rubric results for AST-094 to the description, and fix its citation of spec:AST-090, which does not exist in the repository.

[Automated review]

return null;
}
const editable = element.closest<HTMLElement>(
'[contenteditable]:not([contenteditable="false"]), textarea, input',

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 claims taps on textarea and input, and on anything inside a contenteditable="false" island, but focusAtPoint can place the caret only in a contenteditable, and recognizing the tap swallows the touch's click. In Chromium with touch, a tap 180 px into a textarea draft leaves the caret at offset 0 instead of 23, and a tap on a button inside a chip in an unfocused composer never fires its click. Claiming only contenteditable text, and skipping [contenteditable="false"], button, a, [role="button"] and [data-astryx-token] targets as ChatComposer's click-to-focus already does, would keep both working.

subComponentOf: 'Chat',
displayName: 'Chat Layout',
description: 'Layout shell for full chat interfaces. Messages flow in normal page flow, composer is fixed to the bottom with a frosted glass dock. Set density (compact/balanced/spacious) to control spacing, message max-width, and blur layer sizing. Includes built-in auto-scroll, a "New messages" scroll-to-bottom button, and a frosted glass blur layer behind the composer. By default the layout root is the scroll container; pass scrollRef to delegate scrolling to a parent element or the document body.',
description: 'Layout shell for full chat interfaces. Messages flow in normal page flow, composer is fixed to the bottom with a frosted glass dock. Set density (compact/balanced/spacious) to control spacing, message max-width, and blur layer sizing. Includes built-in auto-scroll, a "New messages" scroll-to-bottom button, and a frosted glass blur layer behind the composer. By default the layout root is the scroll container; pass scrollRef to delegate scrolling to a parent element or the document body. On a phone the composer rides on top of the software keyboard, in step with it, and a conversation at its newest message stays there; tapping the composer focuses it without scrolling the page.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only taps on the composer's text are claimed; a tap on the composer's empty body still focuses through ChatComposer's click handler with a bare focus(), so Safari's reveal remains there. The changeset's wording ("A tap on the composer's text") matches what the code does.

@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 can't be approved yet: ChatLayout's keyboard following has no governing spec, and the new draft spec:AST-094 (FR1–FR23, DEC-1, DEC-2, including its amendments of architecture:theme-tokens, design:user-states, family:buttons and spec:AST-025 FR14) is unapproved, so an engineering owner needs to identify or provide the spec for keyboard following and approve or reject AST-094 by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Whatever that decision, these fixes are needed:

  • A touch tap on a button inside the composer's text (a chip in a contenteditable="false" island) is swallowed by the new composer tap recognizer, and a tapped <textarea> gets its caret at the start instead of under the finger (useChatKeyboard.ts).
  • Land #8045, #8067 and #8074 on their own and rebase, so this pull request carries only the keyboard change of about 1,850 lines.
  • Add AST-094's writing-rubric results to the description, fix its reference to spec:AST-090 (no such record exists), and narrow the tap claim in ChatLayout.doc.mjs.

[Automated review]

Comment on lines +93 to +95
const editable = element.closest<HTMLElement>(
'[contenteditable]:not([contenteditable="false"]), textarea, input',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

closest() walks past a contenteditable="false" island to the editor root, so a touch on a button inside a token (a custom token's render()) is claimed and its click is cancelled: a trusted touch tap on such a button clicks once on the base and never here. Return null when the target is inside [contenteditable="false"], button, a, [role="button"] or [data-astryx-token], as ChatComposer's own click-to-focus already does.

const range = doc.caretRangeFromPoint?.(x, y);
if (
range != null &&
editable.isContentEditable &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For a claimed <textarea> or <input> this places no caret, and the cancelled click takes the browser's own placement with it: a tap mid-line leaves the caret at offset 0 (offset 23 on the base). Set selectionStart/selectionEnd from the tap point for text controls, or stop claiming them.

subComponentOf: 'Chat',
displayName: 'Chat Layout',
description: 'Layout shell for full chat interfaces. Messages flow in normal page flow, composer is fixed to the bottom with a frosted glass dock. Set density (compact/balanced/spacious) to control spacing, message max-width, and blur layer sizing. Includes built-in auto-scroll, a "New messages" scroll-to-bottom button, and a frosted glass blur layer behind the composer. By default the layout root is the scroll container; pass scrollRef to delegate scrolling to a parent element or the document body.',
description: 'Layout shell for full chat interfaces. Messages flow in normal page flow, composer is fixed to the bottom with a frosted glass dock. Set density (compact/balanced/spacious) to control spacing, message max-width, and blur layer sizing. Includes built-in auto-scroll, a "New messages" scroll-to-bottom button, and a frosted glass blur layer behind the composer. By default the layout root is the scroll container; pass scrollRef to delegate scrolling to a parent element or the document body. On a phone the composer rides on top of the software keyboard, in step with it, and a conversation at its newest message stays there; tapping the composer focuses it without scrolling the page.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only touches on unfocused editable text are claimed; a tap on the composer's padding or empty footer reaches ChatComposer's click-to-focus, which calls focus() without preventScroll. Suggest "tapping the composer's text field focuses it without scrolling the page".

follows.
- **FR7 — Exits animate as UIKit's do.** A touch presentation MUST play the
exit its UIKit counterpart plays. **Proposed; pending owner review:** this
departs from the draft `spec:AST-090` FR1 (exits off by default) and FR10

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There is no spec:AST-090 on main, the base or this head, so the owner cannot see what FR7 departs from. Cite the record that holds those requirements, or drop the departure (also at line 235).

## Touch presentation

Whether a component presents itself the touch (UIKit) way is answered once,
in `../utils/touchPresentation.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.

../utils/touchPresentation.ts and the test said to enforce it do not exist on this head or on main. Remove this section or mark it as planned with AST-094 FR13.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
vjeux added 26 commits October 28, 2026 17:39
…llowance under every primary pointer

The useMenuPress record's FR9 puts the trigger's held finger under
spec:AST-094 FR29 under every primary pointer (FR43), so a finger on a
touchscreen laptop may drift 13 pt before the open, as on a phone. The
trigger read (pointer: coarse) as its press started and took the long
press's 10 px where the pointer was fine. Three cases fail until it does
not: a 12 pt drift opens under a fine pointer too, a (12, 12) settle
opens there, and nothing reads a pointer query, the trigger's press
included.
…lowance under every primary pointer

The useMenuPress record's FR9 governs the trigger's held finger by
spec:AST-094 FR29 under every primary pointer (FR43). The trigger now
hands the long press FR29's 13 px drift allowance whatever the primary
pointer, so it no longer reads (pointer: coarse) as its press starts, and
the menus drop their coarsePointer import. ContextMenu keeps the long
press's own allowable movement. The changeset says the same.
Strict ESLint reported six unused bindings in files this pull request
carries: the host's HAPTIC_SWITCH_ATTRIBUTE import, two unused imports
in the pan's tests (scrollProbe, ScreenEdgePanGestureRecognizer), the
swipe test's drift, the presented-hit-testing test's gestures, and a
GestureHost test's before, which now asserts what it was read for: a
second finger's moves leave the pan where it was.
…eal angular speed

The release velocity of a rotation whose listener sets rotation = 0 on
each change, at 2 degrees every 16 ms, read 4.363 rad/s against the
2.182 rad/s of the same stream without a re-base: the test asserted only
more than 1.5. It now asserts the 2.18 rad/s (spec:AST-100 FR2), and
fails.
The rotation's velocity tracker samples the raw unwrapped angle, which
setting rotation does not move, so the setter's shift of the samples read
each re-base as extra turn and doubled the release velocity of a
listener that re-bases on every change. The setter no longer touches the
tracker.
…mates while it runs

The proof read the element's animations 230 ms in, after the dip had
ended, and asserted only that every animated key is opacity, which an
empty list passes: with the cross-fade removed from the plan (the move
still landing), the proof still passed. It now reads the keyframe keys
right after the call starts and asserts they include opacity and nothing
else; the same mutant fails it. Chromium and WebKit pass. (F1's in-flight
read from c648034.)
…roller, and its claimed moves are cancelled under pointer events

Two comparisons stop at the shadow boundary, each failing a case:
- once the host's listener on a recognizer's inner node has seen the
  finger, isFingerDownIn reads it as outside a light-DOM scroller around
  that root, so a scroll under it is not the finger's (FR20);
- under inputMode 'pointer-events', a claimed pan on a shadow host
  cancels none of its finger's moves over the inner content, since the
  finger's target is the inner node and its touches reach the host
  retargeted (FR19).
…the flat tree

isFingerDownIn and the pointer-events finger match compare with
containsAlongFlatTree, as the scroll watch already does: a finger on
content inside an open shadow root is down in the scroller around that
root, and its touches, retargeted to the shadow host, match its pointer
finger, so a claimed pan cancels its moves there (spec:AST-094 FR19,
FR20).
…achments asks, and only then

The host let the last attachment decide whether an element is an action
group (useGestureRecognizer always passes the option), and never
recomputed it at a detach: a plain attachment after the group's turned
the group off, and a group's attachment released left a recognizer that
never asked taking presses under a fine pointer. Fails until the host
counts the attachments that ask.
…ents asks

The host counts the attachments that pass actionGroup: true and keeps the
element a group while one remains, recomputed at each detach. An
attachment that passes false, as useGestureRecognizer does by default,
no longer turns a group off, and a group's last asking attachment taking
its leave ends the group.
… that content

The presented hit test takes the document's elementFromPoint, which
answers a shadow host for anything inside its open root, and compares
with Node.contains, which stops at the boundary: a recognizer on a
control inside a moving sheet's shadow root gets no finger, and the tap's
click goes to the host. Fails until the hit test descends into open roots
and compares along the flat tree.
Where a moving element is drawn away from its layout box, the host
hit-tests what is drawn under the finger with the document's
elementFromPoint, which answers a shadow host for anything inside its
open root. The hit test now descends through each open root's own
elementFromPoint and compares with the movers along the flat tree, so a
recognizer on a control inside a moving sheet's shadow root gets its
finger and the tap clicks that control.
…nt lets go

The host's touch-action rules sit in an adopted sheet or a style element
per document or shadow root, and were never removed, so a page whose
last recognizer detached kept them, against spec:AST-094 FR24 (nothing
touch-related remains after the last instance unmounts). Fails until a
root's rules go with its last marked element.
…th its last marked element

The touch-action writer records which elements it marked in each
document or shadow root, and when the last one is unmarked (its last
recognizer detached) it removes that root's adopted sheet or style
element, so nothing touch-related remains after the last instance
unmounts (spec:AST-094 FR24). A later mark writes the rules again; an
element marked in another root first lets go of the old one.
… the inline style it reads

- The motion registry, the host's engine gate and the README name the
  presented hit test as an engine workaround (spec:AST-013 FR6) with its
  review condition (re-measure on each iOS release; remove it once iOS
  WebKit hit-tests a compositor-moved element where it is drawn) and its
  regression fixture.
- While a curve runs the animator holds the value it starts from inline,
  so presentation.ts and the README no longer say the inline style holds
  the end.
…core/internal/touch

Core ships the internal entry @astryxdesign/core/internal/touch, which
re-exports the gesture primitives for Astryx's own packages, and the
Lab-import guard fails a Lab import of core outside core's exports
(architecture:react-component-runtime INV13). The README and the barrel's
header named internal workspace imports and said nothing here is
exported; they now name that entry.
…ill needs the element, and the listeners that cancel

The release note, the stream's header, the host's comment and the README
said the stream is one passive listener per event type and that a
document-capture stopPropagation no longer starves the recognizers. Such a
stop never hid a listener on the same node, and some subscriptions cancel.
They now say what changed: an app's window-capture stopPropagation, or a
document-capture stopImmediatePropagation registered before core, no
longer hides a touch; a recognizer that must claim still needs the touch to
reach its element; the gesture host's touchend, click and contextmenu
cancel while a claimer is attached and the page hold's touchmove while it
holds; and the stream stays ahead of an app's window-capture listener only
until a type's listener is re-added. The README's list of blocking
touchmove listeners gains the claimed touch's target and the document
while a claiming element moves.
…default it moves

The {stylexOptions} form of astryxStylex now passes StyleX
treeshakeCompensation: true unless the caller sets it. spec:AST-017 FR3
asks the release note to name the old and new default and the option
that restores the old one; it now does.
…er and for the two stops the input stream fixes

- A still finger with sub-pixel jitter over a scroll view keeps its tap
  and scrolls nothing (spec:AST-094 FR18–FR22).
- A tap is still recognized under an app's window-capture
  stopPropagation, and under a document-capture
  stopImmediatePropagation registered before core's: the recognizers hear
  touches on the window's capture phase.
Chromium and WebKit pass.
…s once

Where nothing that claims is attached, the host's touchend listener is
passive and cannot cancel the engine's own click after a retargeted tap
clicks what is drawn. The shared click guard swallows that late click
whatever is attached; this case pins it, as asked on meta#8293.
…esture host's subscriptions

The gesture host subscribes no click: the one cancelling click subscriber
is the click guard's, added at a window's first arm and kept. The release
note, the stream's header and the gestures README listed click with the
host's touchend and contextmenu; they now name the guard's own.
BottomSheet.test.tsx's imports from both sides are kept; the search goldens
are regenerated after the remaining merges.
meta#8360 carries the gesture foundation this branch took from meta#8076. The only
conflict was the shared motion tables list in tableImports.test.ts, where
this branch adds the keyboard table and meta#8360 the long-press table; both
entries are kept, meta#8360's text unchanged.
… guide

The keyboard layout guide (this pull request) reads where focus moves to tell
a keyboard that comes up from one that stays: meta#8559 removed the store's focus
reports because nothing there read them, so they return here with their first
reader. One pair of capture listeners on the document, attached while
anything subscribes, reports each focusin and focusout with the current
height; BottomSheet's reveal still acts on viewport reports only.
Conflicts were in this stack's own keyboard files: the browser-support
sentence keeps ChatLayout's composer and takes meta#8559's page wording,
BottomSheet's test imports follow the sheetKeyboard split, and the keyboard
notes keep both the sheet's and ChatLayout's Chrome 124 findings. The search
goldens are regenerated after the merge.
…op-level page

The composer's tap route, its scroll-free focus and Done's park attach only
where the keyboard is measurable, which excludes a page inside an iframe
(spec:AST-099 FR7); there the browser's own focus applies. The docs and
changeset now say so.

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

Changes requested. The ChatLayout keyboard work follows spec:AST-099, but this branch now carries eight open pull requests (#8566, #8559, #8076, #8360, #8446, #8484, #8554 and #8549), and 196 of its 223 files, 28,674 of 38,099 changed lines, are identical to their current heads, so merging it would land their code without their own review. Please land or rebase onto them so this pull request shows only ChatLayout's layer, and move the keyboard/sheetKeyboard.ts split to #8208 or #8559.

  • The carried gesture host still listens only under a coarse pointer and adds a PressTracker class, against current architecture:react-component-runtime INV13 and spec:AST-094 FR43; that belongs in #8076, or in #8665 landing first.
  • rideKeyboard.ts, keyboardLayoutGuide.ts, BottomSheetPanel.tsx and the keyboard README should name BottomSheet's hook and its new file beside ChatLayout.
  • spec:AST-099 asks for simulator evidence of the home-screen app too, which the changeset promises; please add a take of the composer there.

[Automated review]

… ChatLayout

BottomSheet's useSheetKeyboard moved to keyboard/sheetKeyboard.ts, and it
subscribes to the keyboard layout guide and rides Gboard with rideKeyboard
as ChatLayout does, but the two modules' headers named only ChatLayout, and
BottomSheetPanel's related-files list and the keyboard notes still pointed
at keyboardHeight.ts. They now name the hook and its file.

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

Changes requested. The ChatLayout keyboard work follows spec:AST-099, but this branch still carries eight open pull requests (#8566, #8559, #8076, #8360, #8446, #8484, #8554 and #8549): 196 of its 223 files, 28,674 of 38,105 changed lines, are identical to their current heads, so merging it would land their code without their own review. Please land or rebase onto them so this pull request shows only ChatLayout's layer, and move the keyboard/sheetKeyboard.ts split to #8208 or #8559.

  • The carried gesture host still takes input only under a coarse pointer and adds a PressTracker class, against current architecture:react-component-runtime INV13 and spec:AST-094 FR43; that belongs in #8076 and #8360, or in #8665 landing first.
  • spec:AST-099 lists the iOS home-screen app among the simulator states that gate merge, and the changeset promises it; please add a take of the composer there.

[Automated review]

Comment on lines +911 to +913
private isActive(recognizer: GestureRecognizer): boolean {
return this.isCoarse || this.perEventRecognizers.has(recognizer);
}

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 makes a recognizer take input only under a coarse primary pointer (or with perEventPointer). Current architecture:react-component-runtime INV13 says the gesture host "attaches for every pointer, with no touch-only query", and spec:AST-094 FR43 says gestures attach for "a mouse, a pen, and a finger, not only a coarse primary pointer". Please conform this in #8076/#8360 (or land #8665 first) and keep it out of this pull request.

return tap.numberOfTapsRequired === 1 && tap.numberOfTouchesRequired === 1;
}

export class PressTracker implements TouchConsumer {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

INV13 keeps classes to the recognizer family (its base, the arena and the host) and builds every other touch module from create… factories; PressTracker sits outside that family. This belongs with #8076/#8360 rather than this pull request.

'@astryxdesign/cli': patch
---

[feat] ChatLayout keeps its composer on top of the software keyboard where the visual viewport shrinks for it, instead of leaving it underneath until the browser scrolls the whole page: in Safari on iOS (in the browser and as a home-screen app) and in Chrome on Android (on Gboard's measured slides) the dock rises and falls with the keyboard, in step with it, and in Firefox for Android it moves above the keyboard once Firefox reports it. Where a page sets `interactive-widget=resizes-content`, Chrome shrinks the page for the keyboard itself and the composer stays above it without moving. A conversation at its newest message stays there, and the rest of the transcript can still be scrolled above the composer. On a touch screen, in a top-level page, every way into the composer's text (a tap, the keyboard's previous and next buttons, Tab, the input's `focus()`) reaches it without the page scrolling; under a mouse or inside an iframe the browser's own focus applies. On a touch screen the composer's wrapper (without the scroll-to-bottom button) is a group named "Message composer"; in a top-level page, when the keyboard's Done ends typing, focus rests there (it is no tab stop), so the next tap or Tab starts from the composer. Under a mouse the wrapper is a plain container. Under Reduce Motion the composer never travels: it cross-fades to its place above the keyboard on a touch screen. Nothing changes where no software keyboard appears. The browser-support guide's section on the on-screen keyboard covers ChatLayout's composer.

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 promises the composer's behavior in Safari on iOS "as a home-screen app", and spec:AST-099 lists the home-screen app among the simulator states that gate merge (Platform support, and the FR2–FR5 verification row). The description's iOS recordings are of Safari in the browser only; please add a simulator take of the rise, Done and a focus route with the page added to the home screen.

@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

The iOS clips in the evidence table were recorded in a home-screen web app (the rows now say so), with a Safari-in-the-browser row beside them.

This branch was successfully deployed

1 active deployment
Preview — 3fb83346 Deployed Oct 29, 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