Skip to content

fix(interaction): refuse taps whose target sits behind the visible keyboard - #2602

Merged
thymikee merged 5 commits into
feat/tap-keyboard-occlusion-contractsfrom
fix/tap-keyboard-occlusion-tap
Sep 15, 2026
Merged

thymikee merged 5 commits into
feat/tap-keyboard-occlusion-contractsfrom
fix/tap-keyboard-occlusion-tap

Conversation

@thymikee

@thymikee thymikee commented Sep 14, 2026

Copy link
Copy Markdown
Member

Summary

An acting target whose dispatched tap point lands inside the visible keyboard's band is now refused with tap_keyboard_occludes_target instead of reporting a tap that a key ate (#2589). The guard measures the point the interaction actually sends — the touch point a parent with interactive descendants resolves, or the rect center where the platform aims when it is handed an element by name — so a row whose center sits above the key plane cannot have its aim land on a key without a refusal. The guard runs at the shared pipeline door, so selector, @ref, the native-ref preflight and both drag endpoints read one decision. Raw coordinates keep their escape hatch and disclose the same reason in warning, because a point names no element, is how a keyboard's own control is tapped, and is checked against a last-known tree. The keyboard's keys and dock items stay tappable; an element whose center stays above the key plane keeps pressing; an unmeasurable keyboard fails open. Versioned help and the iOS runner hint no longer say the keyboard usually does not block interactions.

Closes #2589. Base PR #2601 supplies the classifier. 17 files, +642/-164.

$ agent-device press @e21            # tab item under the keyboard
COMMAND_FAILED: Ref @e21 is behind the visible keyboard and cannot be tapped safely
  details: reason tap_keyboard_occludes_target, ref @e21, keyboardFrame y 590..874
$ agent-device press @e68            # the keyboard's own dock button
Tapped @e68 (43, 840)
$ agent-device press 200 822
Tapped (200, 822)  Warning: ... (tap_keyboard_occludes_target; keyboard frame 590..874)
$ agent-device keyboard dismiss && agent-device press text="Home"
Tapped text=Home (62, 822)

Before the change the first line read Tapped @e21 (199, 822) while Catalog stayed selected; fill into a field with the keyboard up was re-checked and still works.

Validation

Tested at 73a7aec338 on top of #2601 at dafab14379, rebased on origin/main 2cafab3ad0.

  • pnpm typecheck — clean. The installArgs breakage that red this stack and origin/main is repaired on the base branch (test(android): drop the retired installArgs from the install-timeout fixture), one dead key out of a typed literal in a test fixture.
  • pnpm check:affected --run — results posted as they land.
  • Other stages, run individually: lint, check:layering, check:di-seams, check:fallow --base origin/main, build, check:package, check:command-docs, test:integration:node, test:integration:macos-coverage, check:packaged-runner-swift — clean. check:production-exports reports 68 findings here and the identical 68 on detached origin/main in this worktree; mutation-model's ownership assertion fails identically there too. Neither is caused here.
  • Gate-mandated: four new ADR 0011 Layer-3 scenarios (runtime-selector, runtime-ref, native-ref, target-drag) plus their coverage manifests; the coordinate cell is waived with an "Intentional" reason rather than claimed.
  • Device lane (iPhone 17 Pro, iOS 26.2, portrait): the covered tab item refuses with the typed reason, the keyboard's own dock key taps, a coordinate behind the keyboard warns, and the same item presses normally once the keyboard is gone. keyboard dismiss refused on this keyboard, so the reworded UNSUPPORTED_OPERATION hint was exercised on device as well.
  • Android device run (Pixel 7 review emulator, Android 16, real Gboard via open --no-test-ime): keyboard status reports visible with com.google.android.inputmethod.latin, the capture carries 129 nodes with that package as provenance, and the classifier derives the band x 0, y 1517, 1080 x 883 from that real tree — a point at (540, 1800) reads occluded, (540, 1400) reads clear.

Landscape iPhone is fail-open, not covered: Apple reports the landscape keyboard's rects in the keyboard's own rotated space (key plane 162 x 327 for a keyboard drawn full width across the bottom 327 pt), so there is no band to measure. Tracked in #2612.

find <locator> focus / find type dispatch the focus themselves and still miss this guard. The four-line call passes every test and fails R2 commands-floor, so it belongs below both zones; #2622 carries the patch, the matrix row and the two placement options.

Residual false positive: an app-drawn bottom keypad wearing the Key accessibility type reads as the keyboard, so content behind it is refused rather than silently misfired.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.56 MB 4.56 MB +3.9 kB
Package (unpacked) 4.56 MB 4.56 MB +3.9 kB
Package (download) 1.35 MB 1.35 MB +1.2 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.6 ms 28.1 ms -0.5 ms
CLI --help 80.6 ms 80.6 ms +0.0 ms

@thymikee

Copy link
Copy Markdown
Member Author

At 38fb5ed the refusal sits in one place, runInteractionPipelineStages, so selector, @ref and native-ref preflight taps all pass through it. Two issues can still send a tap to the wrong place or refuse a valid one.

The check and the tap use different points. assertTapTargetClearOfVisibleKeyboard checks the rect center, but the ref path then taps resolveNodeTouchPoint, which can move the point away from children that cover the center. A row whose center is just above the band can get a touch point inside it, and a key takes the tap without a refusal; the reverse gives a false refusal. Please check the point that is actually dispatched.

A ref tap after the keyboard goes away may still be refused. The ref path reads the stored tree, and the keyboard commands do not appear to refresh it. So snapshot, then keyboard dismiss, then press @e21 would be refused because of a keyboard that is gone, where before this PR the press worked. The offscreen refusal re-checks with the runner before it refuses (#1542). Please do the same here, or at least name snapshot in the hint.

#2589 says coordinate-only taps must be guarded too, but this PR only warns on them and says Closes #2589. If a warning is the intended stance, please update the issue so the two match.

Smaller: keyboard-occlusion.test.ts rebuilds the geometry that keyboardCoveredTabBarSnapshot in the fixtures already has, and the viewport is resolved twice (resolution.ts:222 and keyboard-occlusion.ts:67).

A smaller-design question: could one guard take the dispatched touch point and reuse the #1542 re-check hook, instead of adding help text for the recovery?

The live run covers iOS only; Android IME detection still needs a device run. CI is green, and this PR is stacked on #2601.

@thymikee

Copy link
Copy Markdown
Member Author

Rebased on the updated #2601 and force-pushed as 206e461859, 17574a558f, 09856dc1b3 over 38fb5ed221.

The check and the tap used different points (finding 1). Fixed, and now one point is in play per acting tap. runInteractionPipelineStages takes the caller's own aim resolver, measures the point it returns against the node the stages actually produced, and hands that point back for the dispatch — so the guard reads the coordinate the interaction sends, and nothing derives a second one. The overloads say which paths have a point (@ref, selector, both drag endpoints) and which do not: the native-ref fast path keeps the rect center because it hands the element to the backend and dispatches by ref, which is what the #1542 comment beside it already claimed.

The new case pins the dangerous direction from both sides: a target whose center stays 3 pt above the key plane while its interactive child pushes the dispatched aim below it is refused, and the same tree with the keypad lifted off the bottom edge shows the tap aiming under the plane. So the assertion is about the aim, not about the center.

A ref tap after the keyboard goes away (finding 2). The double-check cannot be spent here, because there is nothing to spend it on. keyboard status and keyboard get are Android-only — Apple never had a live keyboard read and its cells refuse both — and #1542's hook answers "is this target's own rect live on screen", which a covering keyboard leaves true: that is the whole bug. So the hint now names the source and the recovery (6e13b1ccd7 on #2601): the verdict reads the snapshot the command measured against, and snapshot -i is what clears it. On device, a tap that changes the UI also expires the ref frame, so the stalest case in your sequence already errors with the frame-expiry reason before the keyboard guard is consulted; what remains honestly stale is snapshotkeyboard dismisspress @ref, and that is the case the hint is written for.

Coordinate-only stance (finding 3). The warning is the stance, so I said so on #2589 rather than leaving the issue and the code telling different stories: #2589 (comment). One line of why: a raw coordinate names a point and not an element, it is the only way to tap a keyboard's own control, and that path never captures the tree the band is measured against — refusing on an arbitrarily stale capture is a guess, and a wrong guess there costs a user a tap they cannot re-explain. The disclosure carries the same typed reason and the band it measured.

Smaller items. keyboard-occlusion.test.ts builds on keyboardCoveredTabBarSnapshot() now, with the variants derived from it (src/commands/interaction/runtime/keyboard-occlusion.test.ts:5), and the coordinate path resolves the viewport once and passes it to both disclosures.

Could one guard take the dispatched point and reuse the #1542 hook? It takes the dispatched point now, which is the half that was missing. The hook is a different question with a different owner: it re-confirms the target's rect through a tree-independent read to rescue an off-screen refusal, and its rescue contract is a live rect to tap. Here the target's rect is fine — the thing that moved is what is covering it, and no owner can answer that live on Apple platforms. Adding help text is the honest remainder.

Android device run. Ran on a Pixel 7 review emulator (Android 16) with the real Gboard, open --no-test-ime so the headless test IME stayed out of the way. keyboard status reports visible with com.google.android.inputmethod.latin; the capture carries 129 nodes with that package as provenance, and the classifier derives the band x 0, y 1517, 1080 x 883 from that real tree — docked to the 2400 pt bottom edge and 1080 pt wide, so it passes both new eligibility rules on the Android shape as well. A point at (540, 1800) in that capture reads occluded, (540, 1400) reads clear.

What that run does not do: it does not exercise an end-to-end refusal on Android, because nothing app-owned sat under the band on the screen I used (Settings search keeps its content above the keyboard). The acting-path wiring is what the ADR 0011 native-ref / runtime-ref / runtime-selector / target-drag cells pin with a synthetic IME tree; if you want the RN test app on the emulator for a real refusal too, I can spend the build.

Gate on this branch: Every stage check:affected selects on 09856dc1b3 passes, vitest related green at 328 files / 2375 tests. mutation-model still fails a kernel is owned by tests that reach it indirectly, which fails identically at detached origin/main b1c068da4e.

thymikee added a commit that referenced this pull request Sep 14, 2026
… its top

The width rule took a second span over the surface rects while the band was already about to be
built from the anchor span. Measured trees place the dock's buttons inside the key columns, and the
landscape read the rule exists to catch — key plane 162 x 327 — fails on either span, so the extra
Math.min/max pair only widened the distance between what is checked and what is returned.

The module doc stopped calling the point rule "the center rule", which is a description of the
version #2602 no longer ships, and one case name gets its apostrophe.
@thymikee
thymikee force-pushed the fix/tap-keyboard-occlusion-tap branch 2 times, most recently from 95031db to 34088bc Compare September 14, 2026 18:57
@thymikee

Copy link
Copy Markdown
Member Author

Holistic pass over what the two review rounds added; pushed as 280ebeccdd..34088bcfd2 (stack rebased on #2601 at 839fcc7ceb). No new behavior, three pieces of ceremony removed:

  • the stage door's two overload declarations and two parameter aliases collapse into one constrained generic — TPoint extends Point | null on a resolver every path must pass. The native-ref fast path now writes its own nullability into its resolver (resolveRectCenter(node.rect) ?? null) instead of into a signature above the function it calls. No cast, and no unreachable branch behind it.
  • the coordinate path's one-caller session-lookup helper went back inline, and the guard stopped rebounding params.tapPoint to a local for a single use.
  • the runtime cases lifted the keyboard off the bottom edge twice — once through a translation knob with one caller, once inline with the offset spelled out again. One named helper now.

Post-refactor checks: pnpm lint, pnpm typecheck, pnpm build clean; vitest related green at 328 files / 2375 tests (two earlier attempts tripped the slow-test gate on unrelated subprocess tests — runner-client's xctestrun case and daemon-entrypoint — while the host's load average was in the hundreds, and the detector itself marked the last pair "within the 2x load-variance band, not failing"). mutation-model still fails its pre-existing assertion.

Device spot-check on iPhone 17 Pro after the shape change, because the point now travels through a different signature: covered @ref refuses with tap_keyboard_occludes_target, the keyboard's own key taps, a coordinate behind the keyboard warns, and fill with the keyboard up fills.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 34088bc. Every acting path now guards the point it dispatches: the selector, @ref and drag paths pass one resolver through the pipeline, and the native-ref fast path guards the rect center it taps by ref. The aim test fails if the guard and the dispatch diverge, and the other points from the last review are addressed.

One small thing: resolveRectCenter(node.rect) ?? null at resolution.ts#L1020 never falls back, because resolveRectCenter already returns Point | null. The latest PR comment says this is where the fast path's nullability comes from, which will mislead the next reader. Could it be (node) => resolveRectCenter(node.rect)?

CI is green on 34088bc, and there are no conflicts. This PR is stacked on #2601, which still has open findings, so it waits on that one.

@thymikee
thymikee force-pushed the fix/tap-keyboard-occlusion-tap branch from 34088bc to 314879e Compare September 15, 2026 05:53
@thymikee
thymikee force-pushed the fix/tap-keyboard-occlusion-tap branch from 314879e to 69c67c7 Compare September 15, 2026 06:02
@thymikee

Copy link
Copy Markdown
Member Author

Fixed, pushed as 0a59df0a11 after git rebase --onto: the fast path now reads resolveTapPoint: (node) => resolveRectCenter(node.rect), and its nullability comes from that function's own return type, which is what the generic on runInteractionPipelineStages constrains. No ?? null, and the comment above the stage door already describes a resolver's return type as the place nullability lives, so nothing now claims the call site supplies it.

This head also carries two things the last round of #2601 needed from here:

  • the shared runtime fixture keyboardCoveredTabBarSnapshot() now reports the iPhone 17 Pro bottom row (globe, .?123, space, return, adjoining across 395 pt) instead of two keys with 20 px of nothing between them, because feat(contracts): name the keyboard band that owns a tap point #2601's tiling rule refuses to measure a band whose keys are not one run of columns. Nine cells went red the moment the rule landed, which is the rule doing its job on a model that had been overstating the gap. One assertion moved from @e4 to @e6 with the key it names.
  • chore(tests): drop runner payloads no test consumes — five runner-side payloads in test/integration/interaction-contract/fixtures.ts had no consumer anywhere in the tree and fallow reported them as dead exports because this stack changes the file. Deleted rather than suppressed.

Fallow is clean on the stack now (the complexity finding on resolveVisibleKeyboardSurface was repaired on #2601 by splitting the measurement rules into named functions and putting the two ancestry walks behind one primitive).

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 69c67c7. The fast-path finding from 34088bc is fixed, and one acting path still skips the keyboard guard.

dispatchFocusForFindMatch (find.ts#L347), used by find <locator> focus and find <locator> type, taps centerOfRect(match.resolvedNode.rect) through executeFocusPoint. It never reaches runInteractionPipelineStages or assertTapTargetClearOfVisibleKeyboard; its only pre-tap check is rejectCoveredFindMatch. So with the #2589 tab-bar shape, find "Form" type hello can still tap a keyboard key and type into whatever gets focus, which is the misfire this PR closes for click, fill and press. This path is outside the ADR 0011 matrix rows and the #2589 command list, so it can be a follow-up. Could you either call assertTapTargetClearOfVisibleKeyboard with the match nodes and tap point before expireRefFrame, returning the error as an errorResponse, or file an issue with a matrix row for find focus/type?

The Smoke Tests iOS runner job failed in testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt (RunnerTests+AlertObservationTests.swift:75), which expected First actions: 1 and read 0. That test drives alert handling in the fixture app. The only Swift change here is the hint text on the keyboard-dismiss error (RunnerTests+CommandExecution.swift:2190), which that test never reads, and the hunk is unchanged since 34088bc, where the job passed. So the failure is likely unrelated; a rerun should confirm it.

There are no conflicts. This PR is stacked on #2601, so it lands after that one. The next step is to settle the find focus/type path and rerun Smoke Tests.

thymikee added a commit that referenced this pull request Sep 15, 2026
… its top

The width rule took a second span over the surface rects while the band was already about to be
built from the anchor span. Measured trees place the dock's buttons inside the key columns, and the
landscape read the rule exists to catch — key plane 162 x 327 — fails on either span, so the extra
Math.min/max pair only widened the distance between what is checked and what is returned.

The module doc stopped calling the point rule "the center rule", which is a description of the
version #2602 no longer ships, and one case name gets its apostrophe.
@thymikee
thymikee force-pushed the fix/tap-keyboard-occlusion-tap branch from 69c67c7 to ae5a4a1 Compare September 15, 2026 08:53
@thymikee

Copy link
Copy Markdown
Member Author

Both items landed as asked — one deferred with the reason on record.

dispatchFocusForFindMatch — filed as #2622 with the patch, the matrix row and the completion conditions, rather than merged here.

The four-line guard is real and passes: refuse before expireRefFrame, reason: tap_keyboard_occludes_target, ref/rect/keyboardFrame, { ok: false, error: normalizeError(error) } in the shape interaction-touch-runtime.ts already uses, and a refused seam leaves refFrameState(session) at active. Two tests in find.test.ts on the captured iPhone keyboard fixture — find focus and find type both refuse without reaching focusPoint.

It fails the layering scan:

R2 commands-floor: daemon/ must not import commands/ … if two zones need the same rule, put the rule below both of them

assertTapTargetClearOfVisibleKeyboard is called from the pipeline door in commands/, and find is daemon/. The seam already carries rejectCoveredFindMatch, so this is one missing guard on a path the matrix has never carried, and the honest repair is the one the hint asks for: make the guard a Node stage of runNodePipelineStages declared in SELECTOR_PIPELINE_POLICIES.findAct — which is exactly the door find already runs at line 169 — or move the rule and the action vocabulary (InteractionAction, interactionVerb, both currently commands/) into a package both zones import. Both cross a module boundary beyond this PR's family, and keyboard-occlusion.test.ts drives the guard through commands test utils, so option two splits that test too. #2622 carries both, plus the cells to verify: runtime-ref lists press, click, fill, longpress, hover, so a mutating find dispatching its own focus needs its own path id, which means all twelve cells, not one.

Say the word if you would rather have the relocation in this stack.

Smoke TeststestAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt (RunnerTests+AlertObservationTests.swift:75, expected First actions: 1, read 0) is the iOS lane on the old head 69c67c7459. Nothing here touches alert observation; the only Swift in this diff is a hint string in RunnerTests+CommandExecution.swift. The push of ae5a4a1eab starts a fresh iOS lane (34949484994), which is the rerun and on better content — I will report what it says rather than assume the flake.

The head moved twice since your look: rebased on origin/main 2cafab3ad0, then #2601's tiling commit amended with the two review rows. Body's Validation section is current at ae5a4a1eab, including the one stage that cannot pass locally — typecheck dies on installArgs, identical on origin/main, whose Typecheck & Package job is red on the same commit.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at ae5a4a1, as a follow-up to the review at 69c67c7. The earlier findings are fixed, and I found nothing new. The iOS and Android device runs in the PR are author-reported; they still apply because the logical patch is unchanged from the head where they were reported. The find focus/type guard stays with #2622.

Typecheck & Package fails with TS2353 on installArgs in packages/platform-android/src/__tests__/snapshot-helper-install.test.ts. This PR does not touch that file, and main fails the same way at 2cafab3 because #2618 removed the field while that test still sets it, so the failure is unrelated to this PR. A rebase will not clear it until main is fixed.

Next: this stays stacked on #2601.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 15, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Smoke rerun, as promised: the iOS lane on ae5a4a1eab (34949484994) passed Smoke Tests on the first attempt — including testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt, the one that read 0 on 69c67c7459.

The same lane on #2601 at ceff9c3d34 failed its first attempt on a different test in that file, testAlertAcceptDoesNotActivateAReplacementWithASharedButton (RunnerTests+AlertObservationTests.swift:72, ALERT_DEADLINE_EXCEEDED), and passed on the rerun. Two different alert-observation assertions failing once each across two heads, one of them on a contracts-only diff with no Swift in it, reads as deadline pressure in that file's tests rather than anything either PR changed.

What is still red on both heads is Typecheck & Package, and it is the same upstream error at the same line: snapshot-helper-install.test.ts(29,3): error TS2353 … 'installArgs' — main's own copy of the job is red on 2cafab3ad0. Everything else on #2602 is green.

…yboard

An acting target whose rect center lands inside the visible keyboard's band is refused with
`tap_keyboard_occludes_target`, because the touch activates a key instead of the named element
(#2589). Neither sibling guarantee reached it: the keyboard is a separate system surface, so it is
never a covering sibling for `occlusion`, and a covered tab bar stays inside the app's own window
rect for `offscreen`.

The guard runs at the shared pipeline door, so selector, `@ref`, the native-ref preflight, and both
drag endpoints read one decision, and a backend that "succeeds" cannot do so on a target the shared
rules refuse. Raw coordinates keep their escape hatch and disclose the same reason in the response
warning instead: they name a point rather than an element, they are how a keyboard's own control is
tapped, and that path never captures the tree the band is measured against.

The keyboard's own keys and dock items stay tappable, an element whose center stays above the key
plane keeps pressing, and an unmeasurable keyboard fails open. Versioned help and the iOS runner
hint no longer claim the keyboard usually does not block interactions.

Closes #2589
…d guess at it

The guard measured the target's rect center while the acting paths dispatch whatever
`resolveNodeTouchPoint` resolves, and those two points are not the same node geometry: a
parent whose interactive descendants own its center has its aim pushed into a child-free
region. Center-first could wave through a tap whose real aim had moved into the key plane,
and refuse one whose real aim had moved clear of it.

The stage owner now resolves the aim once, measures that, and returns it for the dispatch,
so one point is in play per acting tap. The native-ref fast path keeps the rect center and
says why: it hands the element to the backend and dispatches by ref, so there is no
coordinate to re-derive.

The coordinate path's two disclosures shared their viewport lookup, which the keyboard
warning had been resolving a second time for the same point.

The runtime cases now build on the ADR 0011 fixture's #2589 tree instead of restating its
rects, and one case pins the point rule from both sides: a target whose center stays above
the key plane while its dispatched aim falls below it is refused, and the same tree with
the keypad lifted off the bottom edge shows the tap aiming under the key plane.
…'s type

The stage door carried two overload declarations and two parameter aliases to say that a path
handing in a resolver returning `Point` gets a `Point` back and a path without one gets `null`.
One constrained generic — `TPoint extends Point | null` on a resolver every path must pass — says
exactly that, and the native-ref fast path reads its nullability off the rect center it asks for
rather than into a signature above the function it calls. No cast, no branch that cannot be
reached.

Also removes ceremony the previous commit left behind: a session-lookup helper with one caller, a
guard that rebound `params.tapPoint` to a local for one use, and runtime cases that lifted the
keyboard off the bottom edge twice, once through a translation knob with one caller and once
inline with the offset spelled out again.
Five of the runner-side snapshot payloads here have no consumer anywhere in the tree, and fallow
reports them as dead exports because this stack changes the file. Deleted rather than suppressed or
de-exported into unused locals: the two that remain are the ones the provider-transcript scenarios
actually read.
@thymikee
thymikee force-pushed the fix/tap-keyboard-occlusion-tap branch from ae5a4a1 to 73a7aec Compare September 15, 2026 10:04
@thymikee
thymikee merged commit 5f6481c into main Sep 15, 2026
17 of 18 checks passed
@thymikee
thymikee deleted the fix/tap-keyboard-occlusion-tap branch September 15, 2026 10:19
thymikee added a commit that referenced this pull request Sep 15, 2026
* feat(contracts): name the keyboard band that owns a tap point

A software keyboard is its own system surface: it is never a covering sibling of app content,
so the same-window occlusion classifier cannot see it, and it leaves the app window rect
intact, so the viewport rule passes a covered tab bar. Nothing in the vocabulary said when a
tap point belongs to the keyboard rather than to the app behind it (#2589).

Derive the band from the captured tree -- topmost keyboard node down to the viewport bottom,
across the keyboard's own columns -- and report one decision with four outcomes, failing open
whenever the keyboard cannot be measured. Ownership reads accessibility type, role, and Android
input-method provenance, never a locale-dependent label. Derivation and outcomes are pinned by
contracts/fixtures/tap-keyboard-occlusion-policy.json.

* chore(gates): export the keyboard tap-occlusion contract and pin its surface

* fix(contracts): dock the keyboard band to the bottom edge, not the midline

Band eligibility asked whether the topmost keyboard rect sat in the lower half of the
viewport — a height claim dressed as a docking claim. Measured on iPhone 17 Pro
(iOS 26.2) with Safari raising the system keyboard, portrait reports the key plane at
583..816 of an 874 pt viewport and landscape at 75..402 of a 402 pt one. The landscape
keyboard therefore starts above that orientation's midline at 201, so the old rule
declined to measure it and the guard was silently off in landscape: the same silent
misfire #2589 reports, in the second orientation.

Eligibility is now the measured question. Keyboard geometry has to come within 80 pt of
the viewport's bottom edge to own the band down to it. Portrait's 58 pt home-indicator
strip passes with 22 pt of slack, landscape passes exactly on the edge, and a surface
that stops further up — an iPad floating or split keyboard, which the height heuristic
could also misread as a system band — leaves the band unmeasured so the tap fails open
rather than refusing on geometry that says nothing about the bottom of the screen.

Docking cannot be read off height, which is what the golden table now pins: the same
keyboard is 233 pt of an 874 pt viewport in portrait and 327 pt of a 402 pt one in
landscape. Six cases gained the docked bottom row a real tree reports beside its top
row, and five new cases pin docking itself plus the boundary it accepts: a
bottom-anchored app keypad wearing the keyboard types owns its band.

`resolveVisibleKeyboardSurface` is module-private. The table derives its band through
`expected.frame`, so nothing outside the module needed it, and the surface derivation
assertions that only that file could see now live in the table.

* fix(contracts): say which tree the keyboard verdict read, and how to re-check it

A refusal after `keyboard dismiss` reads like a lie: the keyboard is gone, and the band that
refused the tap came from the tree the command measured against. The off-screen guard can
spend one runner round trip confirming its target before it refuses (#1542); the keyboard
guard cannot spend anything, because there is nothing to spend it on. `keyboard status` and
`keyboard get` are Android-only — Apple never had a live keyboard read, and its cells refuse
both — and the double-check hook answers whether the target itself is on screen, which a
covering keyboard leaves true.

So the refusal stops implying a probe it does not have: the hint names the snapshot as the
source of the verdict and `snapshot -i` as the re-measurement that clears it. Same words from
the coordinate disclosure, which has the same stale-tree exposure and no probe either.

* fix(contracts): refuse to measure keyboard geometry that arrived rotated

Verified on iPhone 17 Pro with the landscape keyboard up: the screenshot shows the keyboard
full width across the bottom 327 pt of an 874 x 402 viewport, and both the regular and the
interactive projection report the key plane as 162 x 327 with the QWERTY columns running down
the short axis and the dock's globe button at y 8. iOS hands over the landscape keyboard's
rects in the keyboard's own rotated space, so a band derived from them would cover a 141 pt
strip on the left edge from y 77 down — refusing a headline the keyboard is nowhere near
while letting a tab-bar item at (264, 360) tap straight through into a key.

A docked keyboard is wide in both orientations, 402 x 233 in measured portrait and 874 x 327
in measured landscape, so geometry that comes back taller than it is wide is refused
measurement and the tap fails open, which is the same stance an unresolvable viewport gets.
Landscape coverage is not lost, only iOS's rotated read of it: the table now pins the
measured rotated tree as `undetermined` and pins a wide, docked landscape keyboard as
measurable against the landscape viewport's own bottom edge.

Recording the misfire rather than hiding it: `#2589`'s silent tap-into-a-key stays open on
landscape iOS until the Apple capture reports keyboard rects in the app's orientation space.
That is a capture-side fix, not a classifier rule, and gets its own issue.

* refactor(contracts): measure the band's width from the span that sets its top

The width rule took a second span over the surface rects while the band was already about to be
built from the anchor span. Measured trees place the dock's buttons inside the key columns, and the
landscape read the rule exists to catch — key plane 162 x 327 — fails on either span, so the extra
Math.min/max pair only widened the distance between what is checked and what is returned.

The module doc stopped calling the point rule "the center rule", which is a description of the
version #2602 no longer ships, and one case name gets its apostrophe.

* fix(contracts): refuse a band whose keys do not tile the width it claims

A split iPad keyboard docks both clusters at the bottom edge, and its surface container still
reports the whole screen width, so the docking and width rules passed and the band ran across the
middle of the screen — where the app content between the clusters is visible and tappable. The
container says where the platform thinks the keyboard is; only the keys say where its controls are,
so the band now measures whether the keys form one unbroken run of columns before trusting any of
it. A gap is not something the band can be built around: nothing in the tree says where either
cluster ends, so the geometry stays unmeasured and the tap fails open, as it does for a rotated
keyboard or a floating one.

Measured, not assumed. iPhone 17 Pro (26.2) reports a key row adjoining across 395 pt, iPad Pro
11-inch (M4) across 743.5 pt, and Gboard on Pixel 7 across 1070 px, none with a gap above a point,
so the allowance is for projection rounding rather than layout — and the reduced two-key keyboards
the table had been modelling now carry the measured row, which is what makes the new rule bite on a
modelled split cluster instead of on a reporting seam. The same pass measured a three-button
navigation bar on Android (126 px at 2274..2400 inside Gboard's reported region, which runs to the
physical display bottom) and a docked iPad keyboard stopping 3 pt short of its viewport bottom, both
now table rows.

The rule is blunt in both directions and the table pins both costs: a sparse layout such as the iOS
emoji panel reports corner keys over a wide container, does not tile, and reads undetermined, because
choosing which gaps are app content is exactly the invention that produced the split-keyboard bug; and
a projection reporting the container with no keys has nothing left to check, so the band is the
container's own claim — every captured iOS and Android keyboard so far reports its keys, so that shape
is the harness's rather than a captured one.

Cases that reach a band also owe their whole shape now: the frame the refusal and the disclosure
quote, and the control rects, which is where plane exclusion shows up. Previously the frame was
compared only on occluded verdicts and the control rects never, so a regression in plane exclusion or
in a clear verdict's band passed silently.

The measurement rules are each one named function now, and the two ancestry walks the module carried
— one asking whether a node belongs to the keyboard, one asking which nodes have keyboard nodes below
them — read the projection's parent links through one primitive.

* test(android): drop the retired installArgs from the install-timeout fixture

`2cafab3ad0` typed this fixture as `AndroidSnapshotHelperManifest` while carrying `installArgs`, a
field the manifest no longer has: #2603 retired the `-t` flag the array used to spell, the helper now
installs with a fixed `adb install -r`, and the parser ignores the key on older blobs — which is what
`snapshot-helper.test.ts` pins. The literal therefore failed `pnpm typecheck` with TS2353 on every
branch, reding `Typecheck & Package` on `origin/main` itself.

Nothing read the key. The install path builds its adb arguments from the provider's own `replace`
option, and the assertion this fixture serves is about the timeout hint derived from the adb failure.
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-15 10:20 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Treat interaction targets behind the visible keyboard as occluded/off-screen (parity with scroll)

1 participant