Repository navigation
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis ReportModified components
25 more components
Package size:
Touched components checked; add Generated by PR Enrichment workflow | View full report |
There was a problem hiding this comment.
This needs an owner decision before it can be approved. SearchField is a new Lab component with no governing spec, and because the PR also changes Core, the CLI, Storybook and scripts, the Lab-only exemption does not apply. An engineering owner can identify the governing record, or record the decision by commenting /approve-spec 38cc72f4acbfdced954510147422367c5b799a0c on this pull request (the author may, as an engineering owner). Independently of that decision, these need changes:
useKeyboardHeighttakes Chrome on Android's keyboard height fromnavigator.virtualKeyboardand setsoverlaysContent = true, which AST-099 DEC-4 and DEC-2 reject, so measure by FR2's visual viewport in every browser.- The new
internal/motionentry adds a second route to the animator and re-exports the internal factory ascreateAnimator, the public factory's name, against react-component-runtime INV13, so re-export it frominternal/touchunder a non-public name. - At about 7,600 authored lines the PR should be split (Core entries and shared Lab modules, the stacked field, the bottom placement, the tab placement), and the two latest-ref layout effects in
SearchField.tsxand the body's every-placement tap-claim statement need fixing.
[Automated review]
| const hold = () => { | ||
| if (appOverlay == null) { | ||
| appOverlay = keyboard.overlaysContent; | ||
| keyboard.overlaysContent = true; |
There was a problem hiding this comment.
Setting overlaysContent = true overrides the app's own keyboard setting page-wide while the field has focus, and the height then comes from boundingRect. AST-099 DEC-4 and DEC-2 reject both ("Rejected: the browser's keyboard geometry report as the height"; "Rejected: ... overriding the app's viewport setting"). Measuring by FR2 (keyboardInsetOf) in every browser would conform.
There was a problem hiding this comment.
Fixed in 3eb0d70: useKeyboardHeight measures the keyboard from the visual viewport in every browser, and never touches navigator.virtualKeyboard or overlaysContent. New tests with Chrome's API present check both.
| * SYNC: When modified, update motion.test.ts and scripts/sync-exports.js. | ||
| */ | ||
|
|
||
| export {createAnimator} from '../motion/animator'; |
There was a problem hiding this comment.
react-component-runtime INV13 routes the animator through internal/touch and says no internal factory shares a public factory's name; AST-107 FR6 names the public factory createAnimator(element). Re-exporting from internal/touch under a non-public name would keep both claims.
There was a problem hiding this comment.
Fixed in 3eb0d70: the internal/motion entry is gone. internal/touch re-exports the factory as createMotionAnimator, and its test checks that the entry exports no public factory name.
| useLayoutEffect(() => { | ||
| focusOptionsRef.current = focusOptions; | ||
| }); |
There was a problem hiding this comment.
This layout effect (and the cancelRef one at 588-590) synchronizes no external system. useImperativeHandle(handleRef, () => ({...}), [placement, cancel]) can compute the focus options and call cancel directly, and handleClear can read focusOptions with placement in its deps.
There was a problem hiding this comment.
Fixed in 3eb0d70: both effects are gone. useImperativeHandle(handleRef, …, [placement, cancel]) computes the focus options and calls cancel directly, and handleClear reads them with placement in its deps.
38cc72f to
3eb0d70
Compare
|
The review's summary points, at 3eb0d70:
|
|
/approve-spec 3eb0d70 |
There was a problem hiding this comment.
Changes are needed before this can merge; SearchField itself needs no further spec decision, so what remains is code and scope.
SearchFieldTouch.tsadds its own document-level swallower for a claimed tap's late mouse events beside Core's click guard; extend the click guard, which the gesture host already arms on the claimed lift, and delete the local copy.- At about 7,200 authored lines with no behavior-module exception, please split it (Core entries and shared Lab modules, the stacked field, the bottom placement, the tab placement).
- Derive
claimsTapduring render instead of setting it in a layout effect, and give the two motion effects the note naming the system they synchronize. - In the story, drop the
navigator.virtualKeyboardcheck in the stand-in tab bar and the deep import of Core's touch-presentation module, and remove the unreadbarandscrollAwaytables fromsearchMotion.ts.
[Automated review]
| // lets go (its click, the next press, or its window), the late events are | ||
| // over, and this swallow lets go too. | ||
| const disarmRef = useRef<(() => void) | null>(null); | ||
| const armSwallow = useCallback((doc: Document) => { |
There was a problem hiding this comment.
This adds a second stray-click swallower (document capture listeners for mousedown, mouseup and click) beside Core's click guard, which the gesture host already arms on a claimed lift (utils/clickGuard.ts, GestureHost.ts). Extending the click guard to also take that lift's late mousedown/mouseup would keep one path, and this block, its unmount effect and the isClickGuardArmed re-export could go.
There was a problem hiding this comment.
Kept here: extending Core's shared click guard changes behavior for every gesture host (useMenuPress and GestureHost, under AST-057 and AST-094). That needs its own Core PR, with an AST-094 clause and tests, rather than a ride in a Lab batch. It is #9623; when it lands, this swallower and the isClickGuardArmed re-export go.
| // Firefox, no software keyboard rises. There a mouse or a pen tablet keeps | ||
| // the browser's own focus and click. | ||
| const [claimsTap, setClaimsTap] = useState(false); | ||
| useLayoutEffect(() => { |
There was a problem hiding this comment.
claimsTap comes from capability reads, not a DOM measurement, so it can be derived during render: !isStacked && isTouch && typeof window !== 'undefined' && keyboardCoversPage(window). isTouch is false on the server and during hydration, so no markup differs, and touch mounts stop rendering twice.
There was a problem hiding this comment.
Done in 1748182: claimsTap is derived during render, from the same window useTouchPresentation reads. isTouch is false on the server and while hydrating, so no markup differs.
| const viewport = win.visualViewport; | ||
| const inset = | ||
| viewport == null || | ||
| 'virtualKeyboard' in win.navigator || |
There was a problem hiding this comment.
This keeps the rule the head removed from useKeyboardHeight: with navigator.virtualKeyboard present (Chromium, Chrome on Android) the stand-in bar reads 0 and stays under the keyboard. In a Chromium phone viewport with a 337 px stand-in keyboard, the bar's bottom stays 33px; with the API removed it is 349px.
There was a problem hiding this comment.
Fixed in 1748182: the stand-in bar measures the visual viewport whatever the API. A new SearchFieldTab case keeps Chrome's API on the page and checks the 36 px lift; it failed on the old story.
| import {useEffect, useLayoutEffect, useRef, useState} from 'react'; | ||
| import type {Meta, StoryObj} from '@storybook/react'; | ||
| import {SearchField, type SearchFieldHandle} from '@astryxdesign/lab'; | ||
| import {useTouchPresentation} from '@astryxdesign/core/utils/touchPresentation'; |
There was a problem hiding this comment.
This reaches Core's touch-presentation module by a source path Core does not export (it resolves only through Storybook's alias), which INV13 reserves for Core and Lab through internal/touch. matchMedia('(pointer: coarse)'), as the interaction specs use, would do for the stand-in host.
There was a problem hiding this comment.
Fixed in 1748182: the story reads matchMedia('(pointer: coarse)') itself, with no Core import.
| bar: { | ||
| /** | ||
| * The fraction of the activation by which the navigation bar's back | ||
| * button and large title are gone. With p the activation's progress, the | ||
| * back button's opacity is 1 − p / 0.35, and it does not move. The large | ||
| * title's opacity is (1 − p / 0.35)², and the inline title stays hidden. | ||
| * The bar itself shrinks to its compact height on the activation's clock. | ||
| * @source measured: a pushed screen with a large title: the bar's height | ||
| * and the back button's and titles' alphas per frame over an activation | ||
| */ | ||
| fadeOutProgress: 0.35, | ||
| /** | ||
| * On cancel the large title fades back in over the activation's last | ||
| * stretch, from this fraction: opacity (p − 0.65) / 0.35. The back button | ||
| * is back at full opacity from the cancel's first frame. | ||
| * @source measured: the same screen over a cancel with the close button | ||
| */ | ||
| largeTitleFadeInProgress: 0.65, | ||
| }, | ||
| scrollAway: { | ||
| /** | ||
| * How far the content must scroll before the field (with | ||
| * `hidesSearchBarWhenScrolling`) starts collapsing with it, pt. After that | ||
| * it follows 1:1. | ||
| * @source measured: contentOffset and the field's frame per frame over | ||
| * slow pans | ||
| */ | ||
| hysteresisPt: 10, | ||
| /** | ||
| * The collapse released past this many points snaps the field closed; | ||
| * short of it the field springs back open. | ||
| * @source GUESS — between the two measured releases: at 15 pt the field | ||
| * springs open, at 30 pt it snaps closed | ||
| * @measure M-SE1 | ||
| */ | ||
| collapseThresholdPt: 22, | ||
| /** | ||
| * The snap closed (or open) after a release, in ms. | ||
| * @source measured: contentOffset per frame after a release at 30 pt | ||
| * (−138 → −116) | ||
| */ | ||
| snapMs: 230, | ||
| }, |
There was a problem hiding this comment.
No shipped code reads bar or scrollAway (only a test sums two bar values), and collapseThresholdPt is a GUESS. These could land with the bar or scroll-away behavior that reads them.
There was a problem hiding this comment.
Removed in 1748182, with the test that summed them. They can land with the behavior that reads them.
3eb0d70 to
1748182
Compare
|
The review's summary points, at 1748182:
|
|
/approve-spec 1748182 |
There was a problem hiding this comment.
Changes requested: SearchField itself is approved for this head and its size fits the owner-directed batch, but SearchFieldTouch.ts still carries its own swallower for a claimed tap's late mousedown, mouseup and click beside Core's click guard, and the touch runtime contract allows only one stray-click swallower. Please land the click-guard extension tracked in #9623 first, then delete armSwallow, its unmount effect, its call in the tap handler and the isClickGuardArmed re-export from internal/touch. The inline comments also suggest four small comment rewrites; those are optional.
[Automated review]
| // lets go (its click, the next press, or its window), the late events are | ||
| // over, and this swallow lets go too. | ||
| const disarmRef = useRef<(() => void) | null>(null); | ||
| const armSwallow = useCallback((doc: Document) => { |
There was a problem hiding this comment.
This arms a second document-level swallower for a claimed tap's late mousedown, mouseup and click, beside Core's click guard, which the gesture host already arms at a claimed lift. The touch runtime contract (react-component-runtime INV13) allows only one stray-click swallower, and when the guard takes the click first these listeners stay on the document until the next press or key. Extending the guard (#9623) and deleting this copy keeps one path for every recognizer that claims a held tap.
There was a problem hiding this comment.
Fixed in ee42864: #9677 extended Core's click guard to take a claimed touch's late mousedown and mouseup, so armSwallow, its unmount Effect and the tap handler's call are gone. The interaction case now checks that the shared guard keeps those events from the page; built against the guard before #9677, it fails.
| } from '../motion/animator'; | ||
| export {cubicBezier} from '../motion/bezier'; | ||
| export {timing} from '../motion/uikit/timing'; | ||
| export {isClickGuardArmed} from '../utils/clickGuard'; |
There was a problem hiding this comment.
Only the local swallower in SearchFieldTouch.ts reads this; it can go with that swallower once the click guard takes the late mouse events itself.
There was a problem hiding this comment.
Removed in ee42864, along with its test and the changeset wording.
| * iframe's visual viewport does not change for the keyboard. Chrome on | ||
| * Android counts too: its visual viewport shrinks for the keyboard as Mobile | ||
| * Safari's does. The page never takes the keyboard's own report or overlays | ||
| * the keyboard. That report comes later and overrides the app's own viewport |
There was a problem hiding this comment.
Optional: reading the report needs overlaysContent, and that is what overrides the app's setting. Suggest: "The browser's own keyboard report is not read. It needs overlaysContent, which overrides the app's viewport setting."
| /** | ||
| * The software keyboard's height as of its last report, while `enabled`, | ||
| * with no ride and no prediction: keyboardInsetOf on each visual-viewport | ||
| * resize, in every browser. `focusRoot` is the surface whose text controls |
There was a problem hiding this comment.
Optional: the hook no longer filters by focus; it reads only focusRoot's window (line 57). Suggest: "focusRoot names the document whose window is measured."
| /** | ||
| * @file touch.ts | ||
| * @input The gesture core: the arena, the recognizers, and their geometry | ||
| * @input The gesture core: the arena, the recognizers, their geometry and |
There was a problem hiding this comment.
Optional: the @input now lists every re-export and the header runs to 22 lines. Suggest: "@input The gesture core and the motion, touch and direction modules Lab uses".
There was a problem hiding this comment.
Taken in ee42864. The rest of the header is main's own; its citations are the file's only links to their records, so it stays at main's length.
| const fieldRef = useRef<HTMLDivElement>(null); | ||
| const isBottom = placement === 'bottom'; | ||
| const isTab = placement === 'tab'; | ||
| // The touch layer (SearchFieldTouch.ts): the parts' poses, UIKit's |
There was a problem hiding this comment.
Optional: this restates what useSearchFieldMotion returns; it can be deleted.
…ternal touch entry Internal entries land with their first user, per meta#8562. Lab's SearchField animates with Core's animator, solves UIKit's timing curves with Core's cubic-bézier solver, listens for taps with Core's gesture hook, reads the writing direction as Core does and claims focus moves as BottomSheet's keyboard hook does, so `@astryxdesign/core/internal/touch` re-exports, by name: - the animator's internal factory as `createMotionAnimator`, a name no public factory has (architecture:react-component-runtime INV13; spec:AST-107 FR6's public factory is `createAnimator`), with its types `MotionAnimator`, `MotionAnimatorOptions`, `AnimateOptions`, `MotionCurve` and `MotionHandle`; - `cubicBezier` and UIKit's `timing` table; - `useGestureRecognizer`, `useTouchPresentation` and `isRtlElement`; - `claimFocusMoves`, new in `utils/`: the capture-phase `blur` claim that BottomSheet's keyboard hook made inline, which that hook now calls. Its BottomSheet tests prove the move unchanged: with the claim's focus removed, four of them fail. Named re-exports of code already on main, plus that one extraction: no behavior changes, no docs, no root re-export, and the `@astryx/no-core-internal-import` lint rule already limits the entry to Core and Lab. The entry's test checks it re-exports the same values Core imports by path, and that it carries no public factory's name. Two Lab modules that other Lab components will share come with them: `internal/useMotionAnimator`, a stable animator bound to an element through a callback ref, and `keyboard/useKeyboardHeight`, Lab's listener for the software keyboard's height: spec:AST-099 FR2's measurement from the visual viewport, in every browser (DEC-4).
1748182 to
ee42864
Compare
|
At ee42864, rebased on main after #9677:
It is a draft again; the new head needs a fresh |
|
/approve-spec ee42864 |
There was a problem hiding this comment.
Changes requested: SearchField is approved for this head and its size fits the owner-directed batch, but one concrete fix remains.
SearchFieldTouch.tssubscribes its focus-move claim with the input node read at setup, so afterplacementswitches between'tab'and'bottom'(which remounts the input) focus moved into the field from another control is no longer claimed and the browser can pan the page; readinputRef.currentinside the pick and add a placement-switch test.
Two optional comment tidy-ups are noted inline.
[Automated review]
| return claimFocusMoves(doc, event => | ||
| event.relatedTarget === input ? input : null, | ||
| ); |
There was a problem hiding this comment.
This pick compares against the input read when the effect set up, and the effect re-runs only when claimsTap changes. Switching placement between 'tab' and 'bottom' keeps claimsTap true but remounts the input (it moves into the content div only for bottom), so the claim then holds the detached node and a focus move into the new input is left to the browser. Reading the node at event time keeps it current:
return claimFocusMoves(doc, event =>
event.relatedTarget === inputRef.current ? inputRef.current : null,
);A SearchField.test.tsx case that rerenders tab → bottom and checks the claim would hold it.
| }, | ||
| }); | ||
|
|
||
| // Where the clear button sits inside the field's trailing end, px. |
There was a problem hiding this comment.
Optional: this restates the constant. Naming it CLEAR_INSET_PX would say the unit, and the comment could go.
There was a problem hiding this comment.
Taken in 7b86fe2: CLEAR_INSET_PX, and the comment is gone.
| // The state the parts last settled toward (null before the first | ||
| // layout), whether a move is still playing, and the latest move's number. |
There was a problem hiding this comment.
Optional: this narrates the three ref names. Keeping only the non-obvious part reads: "settledRef is null until the first layout places the parts."
ee42864 to
7b86fe2
Compare
|
/approve-spec 7b86fe2 |
There was a problem hiding this comment.
Changes requested: SearchField is approved for this head and its size fits the owner-directed batch, but one concrete fix remains.
SearchFieldTouch.tsresets only the current placement's motion values (placeforstacked,placeBottomforbottom, nothing fortab), so a runtimeplacementswitch leaves the previous placement's values on the shared parts: afterbottom→stackedthe active close button is drawn at a fifth of its size, after a liftedstacked→bottomthe field sits 53 px above its bar, and afterbottom→tabthe clear button sits 44 px inside the box; reset the other placements' channels on each switch and add a test per switch.
The earlier focus-move finding is fixed, with a test that covers the remount.
[Automated review]
| const place = useCallback( | ||
| (pose: SearchFieldPose) => { | ||
| rootAnimator.animator.set({y: pose.y}); | ||
| middleAnimator.animator.set({scaleX: pose.middleScale}); | ||
| endAnimator.animator.set({x: pose.endX}); | ||
| closeAnimator.animator.set({x: pose.closeX, opacity: pose.closeOpacity}); | ||
| }, | ||
| [rootAnimator, middleAnimator, endAnimator, closeAnimator], | ||
| ); |
There was a problem hiding this comment.
place writes only the stacked channels, and placeBottom (below) only the bottom ones; tab writes none. The close button and the trailing part are the same nodes in every placement, so a runtime placement switch keeps the old values: after bottom → stacked the close button keeps scale: 0.2 (8.8 × 8.8 px when active), a lifted active stacked → bottom keeps the root's translate: 0 -53px, and an active bottom → tab keeps the trailing part's translate: -44px. Reset the other placements' channels here and in placeBottom (close scale 1, start/content/middle x 0, scaleY 1, root y 0, trailing x 0 for tab), and add a switch case per pair to SearchField.test.tsx.
There was a problem hiding this comment.
Fixed in 0c4167a: place also rests the channels only the bottom placement moves (close scale, start, content and middle x, the scaleYs), placeBottom rests the stacked lift (root y), and a switch into tab rests every part. Three cases in SearchField.motion.test.tsx cover bottom → stacked, lifted stacked → bottom and bottom → tab; each failed at 7b86fe2 with the stale value (0.2, −53, −44).
7b86fe2 to
0c4167a
Compare
There was a problem hiding this comment.
Changes requested: SearchField needs no further spec decision and its size fits the owner-directed batch, but the placement-switch fix is incomplete.
- Under a finger, a
placementswitch that lands in the same render as anisActivechange still takes the animated path inSearchFieldTouch.ts, which moves only the new placement's parts, sobottom→ activestackeddraws an 8.8 px close button, activebottom→ inactivestackedshifts the leading cap and middle 16 px, and a lifted activestacked→ inactivebottomleaves the field 53 px above its bar; place every part at once wheneverplacementchanged, whatever the pointer, and repeat the three switch cases inSearchField.motion.test.tsxunder a coarse pointer.
Under a mouse, all three switches now land correctly.
[Automated review]
| place(poseFor(target, geometry())); | ||
| return; | ||
| } | ||
| settledRef.current = target; |
There was a problem hiding this comment.
Under a finger, a placement switch that also flips isActive reaches this animated path, which plays only root y, middle scaleX, end x and close x/opacity from presented(). The channels only bottom drives stay as they were: after bottom → active stacked the close button keeps scale: 0.2 (8.8 × 8.8 px in Chromium), and after active bottom → inactive stacked the leading cap and middle keep x: -16. The bottom effect's animated path has the same gap for root y (a lifted field stays 53 px up). When placement changed since the last layout, place every part at once whatever the pointer, and add the three switch cases under a coarse pointer.
There was a problem hiding this comment.
Fixed in 3f2a38c: the stacked and bottom effects place every part at once whenever placement changed since the parts were last placed, whatever the pointer. The three switch cases now also run under a coarse pointer with the active state changing in the same render; each failed at 0c4167a with the stale value (0.2, −16, −53).
…it's clock Lab's SearchField is the phone search field of iOS 26's UISearchController, in three placements: - stacked: under a navigation bar's title, a tap activates it into the bar over UIKit's 383 ms ease-in-ease-out: the field narrows, a glass close button slides in beside it, and the large title rides up and fades; the close button cancels on the same clock; - bottom: iOS 26's toolbar field, resting above the keyboard by the keyboard's height as spec:AST-099 FR2 measures it from the visual viewport, in every browser (DEC-4), never overlaying the keyboard on the page (DEC-2); its gel plays the activation and cancel frames measured on iOS, and a change of mind mid-motion turns from where the field is, without a snap; - tab: the content a tab bar's search tab holds. In the bottom and tab placements, under a finger or a pen on a touch screen, wherever the keyboard covers the page (Mobile Safari, Chrome on Android), the field claims a tap on its text, so it focuses without the browser's reveal, which would pan the page (FR4), and the caret lands where iOS puts it; Core's click guard keeps the claimed tap's late mouse events from the page. The stacked field keeps the browser's own tap. With a fine primary pointer (a mouse, a desktop pen tablet) the browser keeps its own focus and click, and the parts take their places at once. With reduced motion the travel becomes a cross-fade under a finger (design:reduced-motion DR2). The values measured on the iOS 26.2 simulator live in Lab (internal/searchMotion.ts), where the bars that host the field can read them too. Its close and clear buttons' names are in the shared message catalog, as Lab's other components' are. `astryx discover` lists it. Its unit tests cover the model, the motion and the field; its interaction specs prove the stacked, bottom and tab placements in Chromium and WebKit, including the change-of-mind turns on the motion's own clock and, with Chrome on Android's keyboard report present, the claimed tap and the visual-viewport measurement.
0c4167a to
3f2a38c
Compare
There was a problem hiding this comment.
Recommending approval: SearchField's direction is settled, every change outside Lab rests on a current record, and the size fits the owner-directed batch.
- The placement-switch fix holds: under a finger, a switch that also changes the active state now places every part at once, and the three new coarse-pointer cases in
SearchField.motion.test.tsxfail against the previous touch layer and pass now.
No blocking findings remain.
[Automated review]
|
/approve-spec 3f2a38c |
|
/revoke-spec 3f2a38c |
User need
Astryx has no phone search field like iOS 26's UISearchController: a glass close button on UIKit's 383 ms clock, a bottom field above the keyboard with no page pan, and a tab bar's search tab.
Evidence
The Android bottom clip is from this code; the others, from the replaced drafts.
Problem and solution fit
handleRef.cancel().Current specification
SearchField.internal/touchre-exports existing modules for Lab, asspec:AST-017FR21 allows. The animator's factory iscreateMotionAnimator, since no internal factory takes a public name (architecture:react-component-runtimeINV13).claimFocusMovesis BottomSheet's keyboard hook's focus-move claim, extracted so both share it.astryx discoverlists SearchField becauseofficial-packages.jsonis regenerated from the repository, asspec:AST-051FR11 requires.Public delta and caller burden
SearchFieldwithplacement('stacked' | 'bottom' | 'tab') andhandleRef(focus(),cancel()).internal/touch:createMotionAnimatorand its types,cubicBezier,timing,useGestureRecognizer,useTouchPresentation,isRtlElement,claimFocusMoves; two catalog names.internal/useMotionAnimator,keyboard/useKeyboardHeight,internal/searchMotion.Why one PR of this size
Batch: L1 · Lab: iOS chrome (TabBar, SearchField, NavigationStack, PageControl)
About 7,220 added lines replace five stacked drafts; over half are proofs (2,900 lines of interaction specs, 1,400 of unit tests).
Design and alternatives
observeResizeandclaimFocusMoves.Scope
Testing
What I ran and what it showed (Lab/SearchField stories):
Phone evidence on this head: a maintainer's check on a real phone, from this head's preview, of the bottom and tab placements with the keyboard up (pending).
On the drafts this replaces (the same component code apart from its imports, the keyboard measurement and Chrome's tap claim):
On this component code:
spec:AST-099DEC-3): first a bare fixed bottom input. With the browser's own tap, once the keyboard was up, the document scrolled 169 px and the visual viewport moved 312 px: the pan FR4 forbids. With a claimed tap (focus withpreventScrollfrom the tap), Gboard came up and nothing moved. Then the bottom story: the tap focused the field, Gboard came up (visual viewport 754 → 442 px), and the field lifted 296 px, so its centre sat 36 px above the keyboard. The page did not scroll (scroll 0, visual-viewport offset 0), andoverlaysContentstayed false. The close button brought it back to rest.overlaysContentstays false. The story's stand-in tab bar also keeps above the keyboard with that API on the page. Written first, both tests failed before their fixes. A claimed tap's late mouse events never reach the page: built against Core's guard before fix(core): the click guard takes a claimed touch's late mousedown and mouseup #9677, that case failed in Chromium and WebKit. A resize while the field is active rescales the middle at once; with the resize subscription removed, that case fails.overlaysContent. A move that stops early, with no newer move, leaves the next one to start from rest. Each of these was written first and failed before its fix.claimFocusMoveshas its own cases; with its focus removed, four BottomSheet keyboard tests fail.Records and clauses checked:
spec:AST-017FR21 andarchitecture:react-component-runtimeINV3 (each Effect names what it syncs), INV11 and INV13 (Lab keeps its own keyboard listener and reaches Core only through internal entries, none named like a public factory; one stray-click swallower, Core's);spec:AST-051FR11 (the generated official list);architecture:knowledge-contractsINV29 (source comments and file headers);architecture:internationalizationINV15 (per-module English is for Core modules; Lab keeps the public translator);architecture:component-style-authoringINV7 (decoration as pseudo-elements);design:reduced-motionDR2; and, followed though Lab is not governed,spec:AST-099FR2, FR4, FR6, FR8, DEC-2 and DEC-4.Local gates at ee42864, on main 4a8a76e, with node 24.22.1 from
.nvmrc: install,pnpm review:pre, strict ESLint over the whole repo (0 errors), typecheck, the full build, Changeset coverage, export sync, the CLI readme and API types, i18n, token docs, Lab readiness, knowledge records, the ShadCN checks,pnpm api:report(no change), and docsite data and tests (63 files) all pass.pnpm testpasses every project: 1,617 files, 34,999 tests. Interaction specs: all 46 SearchField cases pass in Chromium. BottomSheet's six a11y contracts pass in Chromium (44 tests), since its keyboard hook now callsclaimFocusMoves. WebKit's CI profile (4 CPUs, 1 worker): the bottom placement ×10, 210 passed and 20 skipped; the stacked and tab placements ×1, 20 passed and 3 skipped. The skips are the Chromium-only trace and Android tests, WebKit's busy-thread proof, and the pen tests WebKit cannot drive. The gates ran on the tree at c4317b1b91. This head adds fixes since, each with cases that fail without it: the focus-move claim reads the live input after a placement switch remounts it, and a runtime placement switch places every part at once, resting the parts the previous placement moved, under a mouse and under a finger. At this head the Lab and core-internal unit tests (77), strict ESLint on the changed files, typecheck, the 46 SearchField interaction cases in Chromium and the three SearchField specs in WebKit's CI profile (41 passed, 5 skipped) pass again.Supersedes #8180 #8181 #8216 #8224 #8689.
Other references: #8562, #8360 and #9677 (click guard), #8192 (TabBar's search tab).