Skip to content

find focus/type dispatch the focus without the #2589 keyboard guard #2622

Description

@thymikee

Purpose

find <locator> focus and find <locator> type <text> dispatch the focus themselves, so they never pass the shared pipeline door where the #2589 keyboard guard runs. A match whose tap point sits behind the visible keyboard is focused anyway, activating a key instead of the target — the exact misfire #2589 was filed for.

Found while reviewing #2602 (src/daemon/interaction/internal/find.ts#L340-L367, at 69c67c7). It is outside the ADR 0011 matrix rows the PR covers, and the layering scan blocks the one-line fix, so the seam is tracked here.

Current behavior

dispatchFocusForFindMatch (src/daemon/interaction/internal/find.ts:340) runs:

  1. rejectCoveredFindMatch — the same-window occlusion refusal, so this seam does carry one pre-action guard;
  2. centerOfRect(match.resolvedNode.rect) — the point it then dispatches;
  3. expireRefFrame(session) — the ADR 0014 side-effect seam;
  4. executeFocusPoint(...).

assertTapTargetClearOfVisibleKeyboard (src/commands/interaction/runtime/keyboard-occlusion.ts) is called from runInteractionPipelineStages (src/commands/interaction/runtime/resolution.ts:678) and the native-ref preflight only. find click / find fill re-enter the interaction leaf and are covered; find focus / find type do not.

A keyboard is invisible to step 1 by construction (its own system surface, never a covering sibling) and passes the viewport rule, so nothing between the match and the device refuses this point.

Required behavior

The tap point this seam dispatches is refused before the frame is expired, with the shared decision and the shared failure:

  • same decision as the acting paths: resolveKeyboardTapOcclusion against match.nodes (the tree find matched against) and the point from step 2;
  • same failure: reason tap_keyboard_occludes_target, ref, rect, keyboardFrame details, and the passive-verb message the acting guards use for focus;
  • the refusal stays ahead of expireRefFrame, so a refused find focus leaves the captured frame pinnable, as the covered refusal already does;
  • one shared implementation, reached through a dependency direction the layering scan accepts (see below) — not a second copy of the band reading or the error shape.

Why the obvious patch is rejected

Calling the guard from find.ts is four lines and passes every test:

try {
  assertTapTargetClearOfVisibleKeyboard({
    nodes: match.nodes,
    node: match.resolvedNode,
    action: 'focus',
    label: `Ref ${match.ref}`,
    tapPoint: coords,
  });
} catch (error) {
  return { ok: false, error: normalizeError(error) };
}

It fails R2 commands-floor: daemon/ must not import commands/ … if two zones need the same rule, put the rule below both of them. A rule with two zone callers belongs at the shared Node stage door, so this issue owes a placement decision rather than a guard.

Completion conditions

  • find <locator> focus and find <locator> type <text> refuse a target whose tap point sits behind a docked keyboard, quoting the keyboard frame, and never reach focusPoint.
  • A refused seam leaves refFrameState(session) at active.
  • The refusal is produced by the same code the acting paths call; rg -n "behind the visible keyboard" src/ names one construction site.
  • pnpm run check:layering passes.
  • A matrix row exists for this seam and every cell is classified — see below.
  • pnpm check:affected --run is green on the commit being pushed, per docs/agents/pull-requests.md.

Matrix row

packages/contracts/src/interaction-guarantees.ts has no path id for this seam: runtime-ref lists press, click, fill, longpress, hover, and INTERACTION_PATH_IDS has nothing for a mutating find that dispatches on its own. Adding a path means classifying all InteractionGuarantee cells (compile-enforced), so the row is part of the work, not a formality. Starting points to verify rather than copy:

Guarantee Candidate cell
occlusion runtimesrc/daemon/interaction/internal/find.ts#rejectCoveredFindMatch, served from runNodePipelineStages(SELECTOR_PIPELINE_POLICIES.findAct, …)
keyboardOcclusion runtime — whatever shared symbol this issue lands
parentOwnedTouchPoint, offscreen, nonHittable read SELECTOR_PIPELINE_POLICIES.findAct and classify what that policy actually runs
disambiguation, errorTaxonomy, resolutionDisclosure find owns its own locator matching; classify against resolveFindMatch, not the runtime rows
responseConstruction, responseIdentity, verifyEvidence, settleObservation this seam returns executeFocusPoint's shape plus recordFindAction; classify what it can and cannot echo

Placement options

  1. Shared Node stage — make the guard a stage of the node pipeline in @agent-device/selectors, next to selector-pipeline.ts#runNodePipelineStages and interaction-touch-point.ts, and declare it in SELECTOR_PIPELINE_POLICIES.findAct. Then both find and the interaction leaf get it at the door they already run through, and the matrix cell reads like occlusion's. Costs: runNodePipelineStages stages must carry the action, the caller's label, and the resolved tap point, which is a wider stage contract than any current stage needs.
  2. Move the rule below both zones — relocate keyboard-occlusion.ts and the action vocabulary it needs (InteractionAction + interactionVerb, currently in the commands zone) into a package both zones already import. Costs: keyboard-occlusion.test.ts currently drives the guard through commands/interaction/runtime/__tests__/test-utils, so the test splits between the shared unit assertions and the pipeline-level ones.

Both stay within the interaction family; neither is a rename-only move.

Notes

  • The unit and seam tests for the patch above are written and pass with the guard wired in (two cases in src/daemon/interaction/internal/__tests__/find.test.ts, using the captured iPhone 17 Pro keyboard tree from test/integration/interaction-contract/fixtures.ts#keyboardCoveredTabBarSnapshot). They are recoverable from this issue's patch snippet plus that fixture.
  • Reproduction without a unit test: iOS Simulator, keyboard up, then find <label of an element inside the keyboard band> focus on a target that is not itself a keyboard control.
  • Dependency: feat(contracts): name the keyboard band that owns a tap point #2601 (packages/contracts/src/tap-keyboard-occlusion.ts) provides the decision this seam must call.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions