Skip to content

feat(ChatLayout): dragging the conversation to the composer dismisses the keyboard - #8217

Draft
vjeux wants to merge 4 commits into
meta:mainfrom
vjeux:uikit/c08-keyboard-dismiss
Draft

vjeux wants to merge 4 commits into
meta:mainfrom
vjeux:uikit/c08-keyboard-dismiss

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #9983 → #8217
Merge group: #9983, #8217

Review only this PR's change: vjeux/astryx@86c74e8...1fd9e0e and vjeux/astryx@6fd4c28...4c00682 (a pure merge of main between).

User need

In iOS Messages, dragging the conversation to the field puts the keyboard away, as does a swipe down on the input bar. ChatLayout kept it up.

Problem and solution fit

A watcher pan on the root lets the transcript scroll. With a software keyboard up, the field blurs when a touch or pen drag reaches the composer's top. The dock container then bounces once on springs.keyboard, from the finger's speed, at most the composer's bottom padding, unless motion is reduced. The composer, frosted layer and scroll-to-bottom button share that transform.

A drag down from the composer blurs it too, unless a long press began it (caret loupe). While the keyboard is up, the gesture system's page hold cancels one-finger drags that start on the composer, so nothing scrolls under it. Its own scrollers, a pinch and a zoomed page stay the browser's.

Current specification

  • component:ChatLayout FR10 and DEC-1, rewritten.
  • Owner decision, Nov 5 2026 (Cindy, card 76b): dismiss when the drag reaches the composer, with a brief spring bounce and no follow-the-finger cue. The composer and the scroll-to-bottom button move together.
  • Specs decision, Nov 5 2026: a drag that starts on the composer closes the keyboard and never scrolls content under it.
  • Specs decision, Nov 5 2026: spec:AST-094 FR36 and FR37 keep the keyboard with a scoped exception; FR19 lists the composer's hold.

Public delta and caller burden

No new prop or type. The follow code goes (composerFollow.ts, its armed cue, the animator): 9.8 kB gzip less (INV6).

Design and alternatives

  • Dismissing at the lift with an armed follow: rejected in DEC-1.
  • Claiming the composer drag: its touch-action blocks pinch-zoom.

Evidence

iOS 26.2 Simulator, "Full AI Chat", HID drags:

  • A drag down the conversation blurred the field at the composer, 0.4 to 1.6 s before the lift.
  • A drag down from the composer blurred it within 0.3 s; the transcript stayed put. A press-and-hold caret drag kept the keyboard.
  • In every traced frame, the composer stayed 44 px below the button.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Writing rubric results

component:ChatLayout: R4, DEC-1's two one-line rejections; others none. spec:AST-094 FR19, FR36, FR37 and FR43 lines and spec:AST-099 paths: R1–R6 none.

Testing

Behavior module: ChatLayout transcript dismissal (transcriptDismiss.ts)

ChatLayout's import, bytes (core build, esbuild, minified, React and StyleX external), both on today's main:

follow code (1fd9e0e + main, import updated) this head
minified 281,788 254,552
gzip 91,202 81,442

The animator, the rubber band and the follow module leave.

  • Red-first: on a build of 1fd9e0e, the browser spec's 4 crossing and composer cases fail in Chromium (2 of 2 runs) and in WebKit. On a build of 39a9502, the pinch case fails in both engines: every two-finger move was cancelled. On a build of d19cf69's code, the drifting-strip case fails in both engines (Chromium 2 of 2): every move went through.
  • The bounce case samples the dock's animation paused at each frame. WebKit reads a running animation at each call, and a starved page paints few frames. Under CPU load in the WebKit container, the per-frame version failed 7 of 80 runs; this one passed 40 of 40.
  • At this head, the Chat interaction specs: Chromium 14/14, 3 runs; WebKit container (4 CPUs, 1 worker) 13 passed, 1 skipped (pen, Chromium-only), 3 runs.
  • Units: transcriptDismiss.test.ts 32/32, ChatLayoutTranscriptDismiss.test.tsx 23/23, composerBounce.test.ts 5/5, dragAxes 2/2. The theme-mark case fails on 9d66f5f's source, and two strip cases on d19cf69's. Two cases cover a node made in another window; each fails with instanceof in place of isNode or isElement.
  • On today's main, 955564d's code fails Core's typecheck (isNodeTarget is gone) and its Storybook build. This head passes both.
  • Mutants, each caught. A dismissal at the lift fails 11 units and two Chromium cases. A bounce on the composer alone fails the button case. Without the page hold, 4 units and both composer drags in Chromium fail. Without the composer pan, the drag down fails. A bounce at twice the finger's speed fails 3 units. Ignoring composer scrollers fails 2 units and the strip case. Dropping the long-press exception, the zoom check or the keyboard check fails 1 unit each. Counting a drift as the strip's axis fails 2 units and the drifting case.
  • iOS 26.2 Simulator at a87cb24: a drag from the composer blurred the field at 246 ms with the transcript still; a 1 s press in a two-line draft, then a slow drag down, kept the keyboard, and the caret moved.
  • At this head: test:ui 21,760 passed; test:node 16,842 passed, and two CLI files timed out under heavy machine load (each passes alone, 3 of 3); Chat accessibility specs 39/39. Core tsc, ESLint, pnpm review:pre and the repo checks (sync:exports:check, verify-exports, check:repo, check:token-docs, check:motion-roles, check:changesets, check:knowledge) pass.

@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 Nov 6, 2026 3:27pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🔄 In progress (for maintainers only) Nov 6, 2026, 3:37 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
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an owner decision: no current spec covers ChatLayout's new keyboardDismissMode prop or its default-on 'interactive' dismissal. An engineering owner needs to provide or identify the governing spec, meaning whether ChatLayout ships the prop and which default it uses, and record it by commenting /approve-spec <full head SHA> on this pull request; the author can do this if they are an engineering owner. Independently of that decision, these changes are needed:

  • The diff includes four open predecessor pull requests (#8045, #8067, #8074, #8176), about 27,000 authored lines, so please land them through their own reviews and rebase this branch down to its ChatLayout commit.
  • 'onDrag' blurs the composer even when no software keyboard is shown, unlike 'interactive', so please gate it on the keyboard being up and add a test.
  • The new draft spec:AST-094 needs its own owner ruling in the pull request that introduces it, and its FR7 cites spec:AST-090, which is not in the repository.

[Automated review]

};

// The browser took the touch to scroll: a drag began.
const onPointerCancel = (event: PointerEvent) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

'onDrag' dismisses here without checking that a software keyboard is up, while 'interactive' returns early when guide.inset <= 0 (line 127). On a touch screen used with a hardware keyboard, a ChatLayout set to onDrag loses the composer's focus on every touch scroll of the transcript. Consider requiring guide.inset > 0 for onDrag too (at the touch start or here), and adding the "no keyboard" case to the onDrag tests.

name: 'ChatLayout',
displayName: 'Chat Layout',
description: '完整聊天界面的布局外壳。消息在页面中自然流动,编写器固定在底部,带有毛玻璃效果。通过 density 属性控制密度。',
description: '完整聊天界面的布局外壳。消息在页面中自然流动,编写器固定在底部,带有毛玻璃效果。通过 density 属性控制密度。在手机上,编写器随软键盘同步上移并停在键盘上方,停留在最新消息的对话保持在最新消息;轻点编写器会聚焦它而不滚动页面。',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

docsZh was not updated for this change: its description omits the drag-to-dismiss behavior that is now the default, and propDescriptions has no keyboardDismissMode entry, so Chinese readers see the English text for the new prop.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an owner decision: no current spec covers ChatLayout's new keyboardDismissMode prop or its default-on 'interactive' dismissal. An engineering owner needs to provide or identify the governing spec, meaning whether ChatLayout ships the prop and which default it uses, and record it by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Independently of that decision, these changes are needed:

  • The diff includes four open predecessor pull requests (#8045, #8067, #8074, #8176), about 27,000 authored lines, so please land them through their own reviews and rebase this branch down to its ChatLayout commit.
  • 'onDrag' blurs the composer even when no software keyboard is shown, unlike 'interactive', so please gate it on the keyboard being up and add a test for that case.
  • The new draft spec:AST-094 needs its own owner ruling in the pull request that introduces it, and its FR7 cites spec:AST-090, which is not in the repository.
  • docsZh in ChatLayout.doc.mjs has no keyboardDismissMode entry and does not mention the new default.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an owner decision: no current spec covers ChatLayout's new keyboardDismissMode prop or its default-on 'interactive' dismissal. An engineering owner needs to provide or identify the governing spec, meaning whether ChatLayout ships the prop and which default it uses, and record it by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Independently of that decision, these changes are needed:

  • The diff includes six open predecessor pull requests (#8045, #8067, #8074, #8176, #8258, #8287), about 29,800 authored lines, so please land them through their own reviews and rebase this branch down to its ChatLayout commit.
  • 'onDrag' blurs the composer even when no software keyboard is shown, unlike 'interactive', so please gate it on the keyboard being up and add a test for that case.
  • The draft spec:AST-094 needs its own owner ruling in the pull request that introduces it: its FR7 cites spec:AST-090, which is not in the repository, and its FR4 rules out the theme-tunable touch motion that #8258 adds.
  • docsZh in ChatLayout.doc.mjs has no keyboardDismissMode entry and does not mention the new default.

[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:

  • Commit or spring back on release. core/src/keyboard/keyboardDismiss.ts:72 decides on its own whether a release finishes or springs back. A shared release rule is being specified (AST-057 FR5 covers row swipe today); avoid a component-specific threshold model.

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

@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: one implementation.

  • Commit or spring back on release: the dismissal will take its decision from the shared release rule once that rule is specified, with no threshold model of its own. In the next push it reads the keyboard height from the shared keyboard module (AST-099).

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
@vjeux
vjeux force-pushed the uikit/c08-keyboard-dismiss branch from edc7843 to cd49b1b Compare October 25, 2026 01:51
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Pushed in cd49b1b: the dismissal reads the shared height from the keyboard module, stacked on #8176.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering-owner decision: component:ChatLayout FR10 and DEC-1 add drag-to-dismiss as built-in behavior with no caller control, and the new spec:AST-094 FR43 entry keeps it off under a mouse, but nothing approves them at this head.

To settle it, an engineering owner approves FR10, DEC-1 and the FR43 entry as written by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or names the pointer scope they want, since FR10 follows a finger and a pen while DEC-1 records the decision as touch behavior.

Blocking, independent of that decision:

  • Miss: focusedEditable and nodeOf in packages/core/src/Chat/transcriptDismiss.ts (lines 33–48) copy deepActiveElement from utils/flatTree.ts and isNodeTarget from keyboard/keyboardHeight.ts, so call those instead and a fix to either reaches this drag.
  • Miss: the changeset and ChatLayout.doc.mjs say the drag dismisses "with a software keyboard up", but the tests prove it does nothing in a frame or under interactive-widget=resizes-content, so say "in a top-level page (not under resizes-content)" as FR10 does.

The earlier FR10 wording fix is in, and making both fixes before the approval avoids approving twice.

[Automated review]

Comment on lines +33 to +37
let active = doc.activeElement as HTMLElement | null;
// Focus inside a shadow root shows as its host.
while (active?.shadowRoot?.activeElement != null) {
active = active.shadowRoot.activeElement as HTMLElement;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss (One shared path): this walk repeats deepActiveElement in utils/flatTree.ts, which the input hold and the selection lock call. Suggested: const active = deepActiveElement(doc); return bringsUpKeyboard(active) ? (active as HTMLElement) : null;

Comment on lines +42 to +48
function nodeOf(target: unknown): Node | null {
return target != null &&
typeof target === 'object' &&
typeof (target as Node).nodeType === 'number'
? (target as Node)
: null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss (One shared path): isNodeTarget in keyboard/keyboardHeight.ts, the module this file already imports getKeyboardHeight from, makes the same check. Suggested: return isNodeTarget(target as EventTarget | null) ? (target as Node) : null;, or call it where the touch's target is read.

'@astryxdesign/core': patch
---

[feat] ChatLayout: with a software keyboard up, dragging the conversation down to the composer with a finger or a pen and lifting there now dismisses the keyboard, as in Messages; a release moving back up keeps it. A mouse drag still selects text and moves focus as before. A web page cannot move the system keyboard, so the keyboard leaves on its own at the lift instead of following the finger. Drags that start in the composer never dismiss it, and dismissing moves focus nowhere. Drag-to-dismiss has no caller control. Typing with a keyboard is unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss (claims match scope): the tests prove nothing happens in a frame or under interactive-widget=resizes-content, as FR10 states. Suggested: "with a software keyboard up in a top-level page (not under interactive-widget=resizes-content)", and the same in ChatLayout.doc.mjs.

…pans to a focused field

While Safari on iOS 26.2 pans the page to a focused field near the bottom,
it reports innerHeight as the visual viewport's height (377 of 714 with
the keyboard up). layoutViewportHeight read innerHeight, so the keyboard
module measured 0, as if no keyboard were up, against AST-099 FR2 ("a page
pan is never a smaller keyboard"). The root's client height keeps the
layout viewport's height. At a visual-viewport scale of 1, where
innerHeight equals the visual viewport's height and the root is as wide
as the visual viewport, layoutViewportHeight now takes the larger of the
two. Everywhere else it is unchanged: a zoomed-out page's scaled
innerHeight and the root's unscaled height are in different units, a
layout wider than the screen gives a root taller than the screen, and the
scroll clamp reads as before.

The README's ride formula now takes the keyboard's top from
layoutViewportHeight(window), not innerHeight.
@vjeux
vjeux force-pushed the uikit/c08-keyboard-dismiss branch from 6d366f5 to 97e4115 Compare November 5, 2026 14:15
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 5, 2026
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 97e4115

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 5, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. An engineering owner has approved component:ChatLayout FR10 and DEC-1 and the new spec:AST-094 FR43 entry at this head, so the drag-to-dismiss and the composer's follow are settled direction; three fixes remain.

  • Miss: focusedEditable and nodeOf in packages/core/src/Chat/transcriptDismiss.ts (lines 33–50) copy deepActiveElement from utils/flatTree.ts and isNodeTarget from keyboard/keyboardHeight.ts, so call those instead and a fix to either reaches this drag.
  • Miss: the changeset and ChatLayout.doc.mjs say the drag dismisses "with a software keyboard up", but the tests prove it does nothing in a frame or under interactive-widget=resizes-content, so say "in a top-level page (not under resizes-content)" as FR10 does.
  • The diff is 2,050 authored lines, 175 of them the keyboard-height commit that is open on its own as #9983, so add the identical line Stack: #9983 → #8217 to both pull request bodies or land #9983 first and rebase.

Non-blocking: FR10 says "the keyboard's edge" follows the finger where the code moves the frosted layer, which is worth fixing in the same push. A new commit needs a fresh /approve-spec for the new head, which the author may give as an engineering owner.

[Automated review]

function focusedEditable(doc: Document): HTMLElement | null {
let active = doc.activeElement as HTMLElement | null;
// Focus inside a shadow root shows as its host.
while (active?.shadowRoot?.activeElement != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss (One shared path): this walk repeats deepActiveElement in utils/flatTree.ts, which the input hold and the selection lock call. Suggested: const active = deepActiveElement(doc); return bringsUpKeyboard(active) ? (active as HTMLElement) : null;

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 1fd9e0e: focusedEditable now calls deepActiveElement.


/** The node that a contact began on, from any window. */
function nodeOf(target: unknown): Node | null {
return target != null &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss (One shared path): isNodeTarget in keyboard/keyboardHeight.ts, the module this file already imports getKeyboardHeight from, makes the same check. Suggested: return isNodeTarget(target as EventTarget | null) ? (target as Node) : null;

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 1fd9e0e: nodeOf now calls isNodeTarget.

'@astryxdesign/core': patch
---

[feat] ChatLayout: with a software keyboard up, dragging the conversation down to the composer with a finger or a pen and lifting there now dismisses the keyboard, as in Messages; a release moving back up keeps it. Once the finger is over the composer, the composer and the frosted layer under it follow the finger down, and they spring back to rest after the lift; under Reduce Motion they stay still and the pressed overlay fades in over the composer instead. A mouse drag still selects text and moves focus as before. A web page cannot move the system keyboard, so the keyboard leaves on its own at the lift instead of following the finger. Drags that start in the composer never dismiss it, and dismissing moves focus nowhere. Drag-to-dismiss has no caller control. Typing with a keyboard is unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss (claims match scope): the tests prove nothing happens in a frame or under interactive-widget=resizes-content, as FR10 states. Suggested: "with a software keyboard up in a top-level page (not under interactive-widget=resizes-content)", and the same in ChatLayout.doc.mjs.

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 1fd9e0e, in the changeset and in both ChatLayout.doc.mjs strings.

| FR7 | `density` MUST reflect on the root as the `chat-layout` target's `data-density`, and MUST select the dock, message-area, dock-inner, and blur-layer geometry together. | Current source, tests, and docs |
| FR8 | The layout MUST publish its scroll container and message content refs through `ChatLayoutContext` so the message list can register the observed content box. | Current source |
| FR9 | Accepted `ref`, DOM/data/ARIA props, `className`, `style`, and `xstyle` MUST reach and compose on the layout root. | `architecture:public-component-api/INV5–INV6, INV8` |
| FR10 | While the keyboard module measures a software keyboard (`spec:AST-099` FR2; none where a page sets `interactive-widget=resizes-content`, FR8), a drag down the transcript MUST dismiss the keyboard, as in Messages, when it lifts at or past the composer's top as the page draws it, with no projection (`spec:AST-094` FR36; UIKit's interactive dismissal, measured on the iOS 26.2 simulator, in `Chat/transcriptDismiss.ts`), and a release moving back up MUST keep it (FR37). The drag attaches for every pointer (`spec:AST-094` FR43) and dismisses the keyboard for a touch or pen pointer while a software keyboard is showing; a mouse drag is the browser's: it selects text, and the press moves focus. Dismissing only blurs the focused field; focus moves nowhere else. While a touch or pen drag can dismiss the keyboard, the composer and the keyboard's edge follow the finger on a rubber band (`spec:AST-094` FR3), show an armed state once the finger is over the composer, and on lift either dismiss or spring back to rest from the release speed (`spec:AST-094` FR38); under Reduce Motion nothing moves and the armed state is a non-moving cue; a mouse drag keeps the browser's behavior. Drag-to-dismiss is part of ChatLayout's behavior, with no caller control (DEC-1). The drag is a pan on the layout's root that never claims a touch or delays a scroll (`spec:AST-094` FR15, FR24). | `spec:AST-094` DEC-1, FR3, FR15, FR24, FR36–FR38, FR43; `spec:AST-099`; DEC-1 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: "the composer and the keyboard's edge follow the finger" names a part the code does not move; edgeRef is the frosted blur layer, and the changeset says a page cannot move the keyboard. Suggested: "the composer and the frosted layer under it follow the finger".

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.

Kept as written. This FR10 line is the spec owners' approved wording, and "the keyboard's edge" names the frosted layer that sits at that edge. Any rewording is theirs.

if (isReduced()) {
return;
}
// An armed composer moves at the finger's speed times the band's slope.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): this comment restates the expression below it; consider dropping it.

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.

Dropped in 1fd9e0e.

…ses the keyboard

With a software keyboard up, a touch or pen drag down ChatLayout's
transcript that lifts at or past the composer's top dismisses the keyboard,
as in Messages. A release moving back up keeps it, and a drag that starts in
the composer's text never dismisses. The drag decides once, at the lift, and
the dismissal only blurs the field. A mouse never reaches it, so a mouse drag
keeps the browser's text selection and focus.

Once the finger is over the composer, the drag is armed: the composer and
the frosted layer under it settle down by the composer's bottom padding,
then follow the finger on UIKit's rubber band. Any lift springs them back to
rest from the release speed, on the keyboard's spring. Under Reduce Motion
nothing moves, and an armed drag fades the pressed overlay in over the
composer.

Chat/transcriptDismiss.ts makes a pan from the gesture system that never
claims a touch, so the transcript still scrolls natively. It compares the
lift with the composer's top as the page draws it, so a browser's own pan to
the focused field moves the line with the page. Chat/composerFollow.ts moves
the composer and the frosted layer with the shared motion animator.
ChatLayout attaches the pan to its root with useGestureRecognizer, and the
follow to its composer and frosted layer. component:ChatLayout gains FR10
and DEC-1, and spec:AST-094 FR43 lists the dismissal under a mouse among
what stays off.
@vjeux
vjeux force-pushed the uikit/c08-keyboard-dismiss branch from 97e4115 to 1fd9e0e Compare November 5, 2026 15:09
@vjeux

vjeux commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

The identical line Stack: #9983 → #8217 now heads both descriptions. FR10 and DEC-1 are unchanged at 1fd9e0e. The changes since 97e4115 are the two Misses, the docs wording and one dropped comment.

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 1fd9e0e

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both pull requests in this stack are approved, pending CI.

#9983: approve. The layout-viewport floor restores the keyboard height while Safari pans to a focused field (spec:AST-099 FR2), and the Android emulator readings at this head cover Chrome with Gboard and Firefox.

#8217: approve. FR10, DEC-1 and the spec:AST-094 FR43 entry carry an engineering owner's approval for this head, the earlier fixes are in (shared helpers, release-note scope, stack line), and the drag, the composer's follow and the Reduce Motion cue behave as FR10 says in a Chromium check.

Whole stack: pass. Merged onto main, it typechecks and its unit tests pass.

A pull request lands only when it and every pull request below it are approved. Approved pending CI: CI has not run on these heads yet; once #8217 is ready for review, a maintainer adds the ci:run label to both so CI runs.

Non-blocking: a test asserting that the composer's spring-back starts from the release speed would pin spec:AST-094 FR38.

[Automated review]

@vjeux

vjeux commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Noted: the test that pins the spring-back to the release speed (spec:AST-094 FR38) will come with the next version this PR needs, or in a follow-up. That keeps this head's approvals and card check in place.

@github-actions github-actions Bot added needs:spec-owner-review Current knowledge records await owner approval and removed needs:design-review Affects visuals — Design should review labels Nov 6, 2026
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 39a9502

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested on #8217; #9983 is approved.

#9983: approve. The keyboard height now reads the whole keyboard while Safari pans to a focused field, restoring spec:AST-099 FR2, and nothing has changed since its approval.

#8217: request changes. The rewritten FR10, DEC-1 and the spec:AST-094 FR36 and FR43 edits carry an engineering owner's approval for this head, but the composer's scroll hold needs these fixes:

  • A two-finger pinch that starts on the composer has every move cancelled while the keyboard is up, so the page cannot be zoomed from there, which spec:AST-094 FR10 requires to stay the browser's; cancel only while exactly one finger is down (transcriptDismiss.ts:278-285).
  • The hold cancels the moves of touches no recognizer owns, from the first move, through a blocking listener beside a pan that never claims, which spec:AST-094 FR19 does not allow; add the case to FR19's exceptions for approval, or let the composer drag claim its touch (transcriptDismiss.ts:291-296).
  • holdComposerScroll and isInComposerScroller copy the gesture system's page hold (gestures/pageHold.ts) and scroll test (gestures/nativeScroll.ts); extend holdPageScroll to take the composer's decision and delete the copy (transcriptDismiss.ts:168-304).
  • The crossing dismissal keeps the keyboard down when the finger moves back up before the lift, which spec:AST-094 FR37 still forbids because the new FR36 exception names FR36 alone; extend the exception to FR37.

Whole stack: fails on #8217's fixes, and a pull request lands only when it and every pull request below it are approved; CI has not run on either draft head yet.

[Automated review]

Comment on lines +278 to +285
const onMove = (event: TouchEvent) => {
if (
event.cancelable &&
Array.from(event.changedTouches).some(touch => held.has(touch.identifier))
) {
event.preventDefault();
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This cancels the move whenever any changed touch started on the composer, however many fingers are down, so a two-finger pinch on the composer is cancelled too (real Chromium: 12 of 12 two-finger moves). spec:AST-094 FR10 keeps page pinch-zoom the browser's when no pinch recognizer claims the fingers. Cancelling only while exactly one finger is down, as gestures/pageHold.ts:145-153 does, plus a two-finger case, would fix it.

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 d19cf69: the composer now uses the gesture system's holdPageScroll, which cancels only while exactly one finger is down. A two-finger browser case (the harness's pinch()) holds it; it failed on 39a9502 in Chromium and WebKit.

// Not passive: a passive listener cannot keep a touch's scroll from
// starting.
composer.addEventListener('touchstart', onStart, {passive: true});
composer.addEventListener('touchmove', onMove, {passive: false});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The composer's only recognizer is a pan that never claims (claimsTouches: false, line 214), yet this blocking listener cancels its touches from the first move, under the pan's 10 px start, in any direction. spec:AST-094 FR19 says a touch no recognizer owns MUST NOT be cancelled outside five exceptions, none of which covers the composer, and that only an element with a recognizer that must claim mid-gesture registers a blocking listener. It also sits on every ChatLayout, so a native scroll starting on the composer waits for the main thread (FR19's known limit). Adding this case to FR19's exceptions for the owners to approve, or letting the composer drag claim its touch, would reconcile them.

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.

Took the exception route in d19cf69. FR19 lists the composer's page hold as a fifth exception, in the spec owners' wording. The hold exists only while a software keyboard is up, so no ChatLayout carries a blocking listener otherwise. Claiming would give the composer a host touch-action that blocks pinch-zoom.

Comment on lines +268 to +270
export function holdComposerScroll(composer: HTMLElement): () => void {
// The fingers held still, by identifier.
const held = new Set<number>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One shared path: this and isInComposerScroller (line 168) repeat what the gesture system already owns. holdPageScroll in gestures/pageHold.ts holds a page still under one finger with a per-touch decision, leaves a second finger alone, skips touches a recognizer owns and listens only while the pointer is coarse; canElementScroll in gestures/nativeScroll.ts:176 tests overflow and room per axis. Extending holdPageScroll to take the composer's decision while a keyboard is up, and deleting this copy, keeps one implementation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d19cf69: holdComposerScroll and isInComposerScroller are gone. The composer holds holdPageScroll with its own decision, and both the hold and the composer pan test scrollers with canElementScroll.

Comment on lines +587 to +589
NavigationStack, and dismissing the keyboard (except ChatLayout's
transcript-drag dismissal, which follows `component:ChatLayout` FR10/DEC-1:
it dismisses when the drag reaches the composer); each use's commit point

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The exception names FR36 only, but FR37 just below still says "A release whose velocity points back toward the start MUST cancel, whatever the travel so far", while the crossing dismissal keeps the keyboard down when the finger moves back up (ChatLayoutTranscriptDismiss.test.tsx:252-261). Extending the exception to FR37 would keep the record consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d19cf69: FR37 carries the same scoped exception as FR36.

| FR7 | `density` MUST reflect on the root as the `chat-layout` target's `data-density`, and MUST select the dock, message-area, dock-inner, and blur-layer geometry together. | Current source, tests, and docs |
| FR8 | The layout MUST publish its scroll container and message content refs through `ChatLayoutContext` so the message list can register the observed content box. | Current source |
| FR9 | Accepted `ref`, DOM/data/ARIA props, `className`, `style`, and `xstyle` MUST reach and compose on the layout root. | `architecture:public-component-api/INV5–INV6, INV8` |
| FR10 | While the keyboard module measures a software keyboard (`spec:AST-099` FR2; none where a page sets `interactive-widget=resizes-content`, FR8), a touch or pen drag down the transcript MUST dismiss the keyboard at the moment it reaches the composer's top as the page draws it, or lifts there, with no projection: a flick that lifts above the line keeps the keyboard (DEC-1). The composer MUST then give one small spring bounce on the keyboard's spring (`spec:AST-094` FR3), started at the finger's speed and no deeper than the composer's bottom padding, so it never moves faster than the finger; the bounce moves the dock container, so the composer, the frosted layer and the scroll-to-bottom button move together, and under Reduce Motion nothing moves. A touch or pen drag that starts on the composer MUST dismiss the keyboard once it moves down, more down than across, past the pan's hysteresis, unless it held still for a long press first (iOS's text loupe, whose drag moves the caret). While a software keyboard is up, no drag that starts on the composer MAY scroll content: its moves are cancelled, except in a part of the composer that scrolls on its own on either axis, such as a long draft or a strip of attachments (DEC-1). The drags attach for every pointer (`spec:AST-094` FR43); a mouse drag is the browser's: it selects text, and the press moves focus. Dismissing only blurs the focused field; focus moves nowhere else. Drag-to-dismiss is part of ChatLayout's behavior, with no caller control (DEC-1). Both drags are pans that never claim a touch (`spec:AST-094` FR15, FR24); the composer's scroll hold is one touchmove listener on the composer alone. | `spec:AST-094` DEC-1, FR3, FR15, FR24, FR43; `spec:AST-099`; DEC-1 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: "the composer's scroll hold is one touchmove listener on the composer alone" — holdComposerScroll registers four (touchstart, touchmove, touchend, touchcancel), as ChatLayoutTranscriptDismiss.test.tsx:385-391 asserts.

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 d19cf69: FR10 now says the page hold listens only while a software keyboard is up.

Comment on lines +291 to +292
// Not passive: a passive listener cannot keep a touch's scroll from
// starting.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): this comment sits over a touchstart registered passive: true. Moving it to the touchmove line keeps the reason next to the listener it explains.

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.

That listener is gone in d19cf69; the shared page hold owns the one blocking listener.

@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec d19cf69

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested on #8217; #9983 is approved.

#9983: approve. The keyboard height reads the whole keyboard while Safari pans to a focused field, restoring spec:AST-099 FR2.

#8217: request changes. The rewritten FR10, DEC-1 and spec:AST-094 edits carry an engineering owner's approval for this head, and straight drags behave as FR10 says, but two fixes remain:

  • A drag that starts on a sideways-scrolling strip in the composer and drifts sideways at all is not held, because canElementScroll counts any nonzero x travel, so the conversation scrolls under the keyboard (40 px for a 120 px drag up with 8 px of drift in Chromium) against FR10's "MUST NOT scroll content"; count an axis only when its travel is at least half the other's, as the gesture system's scroll probe does, and add a drifting-strip case (transcriptDismiss.ts:188-204).
  • On main, spec:AST-094 FR19 already reads "five exceptions" after a toast's swipe was added, so this head's composer exception leaves six under "five" once merged; rebase and say "six" in FR19 and in the FR18–FR22 verification row (spec.md:411, :955).

Whole stack: fails on #8217's two fixes; it merges cleanly onto main, where the changed suites and the core typecheck pass. A pull request lands only when it and every pull request below it are approved, and CI has not run on either draft head yet.

[Automated review]

at != null && at !== composer;
at = parentAcrossShadow(at)
) {
if (at instanceof Element && canElementScroll(at, dx, dy)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

canElementScroll(at, dx, dy) tests every axis whose delta is nonzero, so a drag up on a strip that scrolls sideways counts as the strip's scroll as soon as it drifts left at all. The page hold then cancels nothing and the conversation scrolls under the keyboard: in Chromium a 120 px drag up with a 20 px drift moved it 129 px. Counting an axis only when its travel is at least half the other's, as createNativeScrollProbe does (gestures/nativeScroll.ts:217-220), and adding a drifting strip case would close it.

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 9d66f5f: the composer's scroller test counts an axis only at half the other's travel or more, as createNativeScrollProbe does. A drifting-strip browser case holds it; it failed on d19cf69's code in Chromium and WebKit.

`touchmove` through a non-passive listener on its element; a watcher,
which never claims, is exempt (DEC-7). A `touchmove` of a touch no recognizer owns MUST NOT be cancelled, with
four exceptions. While a scroll lock under a modal presentation holds the
five exceptions. While a scroll lock under a modal presentation holds the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

On main this line already reads "five exceptions" (a toast's swipe, component:Toast FR4). Merged, FR19 lists six exceptions under "five", and the FR18–FR22 row's "FR19's five exceptions" goes stale too. Rebasing and saying "six" in both places would keep the record consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 9d66f5f, on a merge of main: FR19 and the FR18–FR22 row now say "six".

@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 9d66f5f

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested on #8217; #9983 is approved.

#9983: approve. The keyboard height reads the whole keyboard while Safari pans to a focused field, restoring spec:AST-099 FR2.

#8217: request changes. The record edits carry an engineering owner's approval for this head, and the drifting-strip hold and the FR19 count from the last review are fixed. Two shared-path fixes remain:

  • Miss: bounceComposer skips the bounce only on the system Reduce Motion setting, while Core's Reduced Motion policy (reducedMotionFade, and the animator) also honours a theme's reduced-motion mark, so once a theme can reduce motion for everyone the chat dock would keep bouncing; decide it with reducedMotionFade(dock, {touchRelease: true}), or one shared predicate if that import is too heavy (composerBounce.ts:41-46).
  • scrollsInComposer restates createNativeScrollProbe's half-travel axis rule instead of calling it; export that filter from gestures/nativeScroll.ts and use it in both places (transcriptDismiss.ts:194-198).

Whole stack: fails on #8217's two fixes; the changed suites pass on this head. A pull request lands only when it and every pull request below it are approved, and CI has not run on either draft head yet.

[Automated review]

if (
win == null ||
typeof dock.animate !== 'function' ||
win.matchMedia?.('(prefers-reduced-motion: reduce)').matches

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss (One shared path): this reads only the system setting. Core's Reduced Motion policy, reducedMotionFade (motion/reducedMotion.ts:88), and the animator (motion/animator.ts:973) also count a theme's reduced-motion mark (design:reduced-motion DR2a), so once a theme can set 'always' the dock keeps bouncing while every other touch animation stops. Suggested: if (reducedMotionFade(dock, {touchRelease: true}) != null) return;, or one exported predicate for the two-part check if that import is too heavy for the size report.

Comment on lines +197 to +198
const x = Math.abs(dx) >= Math.abs(dy) / 2 ? dx : 0;
const y = Math.abs(dy) >= Math.abs(dx) / 2 ? dy : 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One shared path: this restates createNativeScrollProbe's half-travel rule (gestures/nativeScroll.ts:219-220). Exporting that axis filter from nativeScroll.ts (for example majorAxes(dx, dy)) and calling it here and in the probe's canScroll keeps one rule.

@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 955564d

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested on #8217; #9983 is approved.

#9983: approve. It restores spec:AST-099 FR2: the keyboard height now reads the whole keyboard while Safari pans the page to a focused field, and its tests also pass on today's main.

#8217: request changes. The record changes carry an engineering owner's approval for this head, and one blocking finding remains:

  • Regression: transcriptDismiss.ts:29 imports isNodeTarget, which main removed in #10224, so once merged with main Core's typecheck fails (TS2305) and every touch drag throws isNodeTarget is not a function; merge main and use isNode from utils/nodeType (and isElement/isNode at lines 202 and 293).

Whole stack: blocked by that fix; a pull request in this stack lands only when it and every pull request below it are approved, and CI has not run on this draft head yet.

[Automated review]

import {bringsUpKeyboard} from '../keyboard/keyboardLayoutGuide';
import {
getKeyboardHeight,
isNodeTarget,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Regression: main no longer exports isNodeTarget from keyboard/keyboardHeight (#10224 moved the check to isNode in utils/nodeType.ts). Merged with main, this import fails Core's typecheck (TS2305), and nodeOf throws isNodeTarget is not a function on every drag, so nothing dismisses. Suggested, after merging main:

import {isElement, isNode} from '../utils/nodeType';
// nodeOf
return isNode(target) ? target : null;

Use isElement(at) at line 202 and isNode(origin) at line 293 as well, so the composer's checks accept another window's nodes, as nodeOf already does.

vjeux added 2 commits November 6, 2026 02:05
A drag down the conversation now blurs the field the moment it reaches
the composer. The dock container then gives one small bounce on the
keyboard's spring, started at the finger's speed, so the composer, the
frosted layer and the scroll-to-bottom button move as one. A drag down
that starts on the composer dismisses the keyboard too, and while the
keyboard is up no drag on the composer scrolls the page. The follow
code and its armed cue are gone.
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 4c00682

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested on #8217; #9983 is approved.

#9983: approve. It restores spec:AST-099 FR2, and its tests also pass on today's main.

#8217: request changes. The record changes carry an engineering owner's approval for this head and the build against today's main is fixed; one blocking finding remains:

  • Miss: transcriptDismiss.ts:105-110 (isFinished) and :264 restate the gesture system's isTerminal and isActive (gestures/GestureRecognizer.ts:159-165); call those instead, drop isFinished, and add the import to the gesture-import list the unit test pins.

Whole stack: blocked by that fix; a pull request in this stack lands only when it and every pull request below it are approved, and CI has not run on this draft head yet.

[Automated review]

Comment on lines +105 to +110
/** Whether the pan's drag is over: released, cancelled or failed. */
function isFinished(pan: PanGestureRecognizer): boolean {
return (
pan.state === 'ended' || pan.state === 'cancelled' || pan.state === 'failed'
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss (One shared path): this is isTerminal(state) from gestures/GestureRecognizer.ts:159-161, which Core's internal touch entry re-exports; line 264 is likewise !isActive(pan.state) (:163-165, already imported by useActionGroupTracking.ts). Suggested:

import {isActive, isTerminal} from '../gestures/GestureRecognizer';
// :169 and :255
if (isTerminal(pan.state) || reachesLine) {
// :264
if (!isActive(pan.state)) {

Then drop isFinished and add '../gestures/GestureRecognizer' to the import list ChatLayoutTranscriptDismiss.test.tsx pins.

This branch was successfully deployed

1 active deployment
Preview — 4c006821 Deployed Nov 6, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants