Skip to content

fix: DEBUG-506 CrisisKeyboardAccessory never attaches — 988 unreachable while a keyboard is up - #484

Merged
MP2EZ merged 13 commits into
developmentfrom
fix/DEBUG-506-crisis-keyboard-accessory-attach
Sep 10, 2026
Merged

fix: DEBUG-506 CrisisKeyboardAccessory never attaches — 988 unreachable while a keyboard is up#484
MP2EZ merged 13 commits into
developmentfrom
fix/DEBUG-506-crisis-keyboard-accessory-attach

Conversation

@MP2EZ

@MP2EZ MP2EZ commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Closes DEBUG-506

CrisisKeyboardAccessory never attached. DEBUG-450 wired all seven text surfaces to ONE InputAccessoryView mounted at app root, on the recorded belief that "RN registers accessory content by nativeID app-wide from ONE mount". That belief is false on Fabric, and the control was inert for its entire shipped life — so on iOS the root crisis button was fully occluded whenever a keyboard was up, with no affordance replacing it.

Root cause

RCTInputAccessoryComponentView.mm:66-82 attaches only inside didMoveToWindow, via a one-shot depth-first search of the window for a TextInput carrying the id. From a root mount that search runs at app launch, finds nothing, and can never re-run — the root view never changes windows. :121 sets self.hidden = true, so a failed attach produces no view, no warning and no hierarchy node. That is why nine jest tests, a CI guard and a Maestro flow all stayed green over a control that rendered nothing.

The fix

CrisisTextInput — a TextInput and its accessory from one mount, per-instance useId, forwardRef, full prop pass-through, and no way for a call site to reach the id.

Co-location alone was not enough. RCTTextInputComponentView.mm:377-390 (prepareForRecycle) nils the input's own inputAccessoryViewID and inputAccessoryView, so an input that unmounts under a still-mounted accessory leaves it holding a dead __weak ref — and both crisis-relevant surfaces do exactly that (VoiceReflectionScreen on setPhase, DailyLoopStepScreen as beats change). A shared mount lifecycle is what survives it, which is why this is a component and not an ordering convention. The per-instance id matters too: DailyLoopStepScreen renders up to three concurrent inputs, so a shared id left two of three uncovered.

Verified, not reasoned

  • Simulator (iPhone SE 3 / iOS 18.6 / 375x667): accessory measured at [0,335][375,407], above SystemInputAssistantView [0,407][375,451]. The three pre-existing rows are byte-identical to the filing's pre-fix table, where the accessory row read absent. One tap reaches crisis-resources-screen.
  • Hardware (iPhone 16e / iOS 26.6.2 / 393x852, Release, non-seeded): the bar attaches and one tap reaches Crisis Support with the 988 action. Two form factors, two iOS majors, same result. Maestro cannot drive a physical iPhone (DEBUG-589), so this was an attended manual observation.

Guard and coverage

  • CI guard inverted: no bare <TextInput> outside the composite. Matching only inputAccessoryViewID= would have been vacuous, since the composite uses the helper and never writes the prop.
  • New sim flow crisis-keyboard-reachability.yaml closes the gap that let this ship: the accessory's only pin was safety-device-only, so npm run e2e:safety could never select it. Nothing in it needs canOpenURL — the dial is owned by crisis-988-dial.yaml. Flow-count tripwire bumped to 15.
  • useKeyboardOccludesCrisisButton hoisted to one module-level subscription (was 2 listeners per mount, on an event that fires every animation frame).
  • False premise corrected at every site that asserted it.

Eighth surface

FEAT-570's bug-report form landed after this item was filed, carrying the same inert wiring on a FAB-suppressed route where its own comment calls the accessory "the SOLE 988 route". Converted and verified on device.

DEBUG-575 interaction

Its assertion that <CrisisKeyboardAccessory sits outside NavigatorA11yHost cannot survive the root mount's removal — but that placement never governed a11y exposure anyway: UIKit reparents _contentView into the keyboard window at attach. Replaced with the inverse pin (the root mount must not return), mutation-proved. Whether VoiceOver can reach the accessory under a root-slot overlay is left explicitly OPEN and routed to INFRA-427.

Supersedes

DEBUG-590 (filed to move the reachability half into the sim suite — done here).

🤖 Generated with Claude Code

MP2EZ and others added 13 commits August 21, 2026 22:48
DEBUG-450's accessory has been inert since it merged. `RCTInputAccessoryComponentView.mm`
:66-82 attaches only inside `didMoveToWindow`, by a one-shot depth-first search of the
window for a TextInput carrying the id. The component was mounted once at app root, so
that search ran at app launch when no such input existed, and it can never re-run because
the root view never changes windows. The single-mount architecture was precisely what
guaranteed it could never attach.

Nothing caught it. `updateProps` sets `self.hidden = true` and `_contentView` is only ever
handed to UIKit as `textInput.inputAccessoryView`, so a failed attach produces no view, no
warning, and no node in any hierarchy. Nine jest tests, a CI guard and a Maestro flow all
stayed green over a control that did nothing.

Two further native facts decided the shape, not just the diagnosis:

  - `RCTTextInputComponentView.mm:377-390` (`prepareForRecycle`) nils the input's own
    `inputAccessoryViewID` AND `inputAccessoryView`. So co-locating an accessory as a
    sibling is not enough: an input that unmounts and remounts under a still-mounted
    accessory leaves it holding a dead __weak ref. Both of the most crisis-relevant
    surfaces do exactly that — VoiceReflectionScreen on setPhase('saved'|'idle'),
    DailyLoopStepScreen as beats change. Only a shared mount lifecycle survives it, which
    is why this is a component and not an ordering convention.
  - `RCTFindTextInputWithNativeId` returns the FIRST depth-first match. DailyLoopStepScreen
    renders up to three concurrent inputs, so one shared id leaves two of three with no
    crisis affordance while every call site looks correctly wired.

So: CrisisTextInput owns both halves from one mount with a per-instance useId-derived id,
and all 7 sites convert. Props and refs pass straight through — every site sets its own
accessibilityLabel/testID on the input, and a wrapper that swallowed them would have
shipped 7 surfaces of unlabelled inputs, a larger WCAG 4.1.2 failure than the one being
fixed. The accessory takes no prop that reaches its label, hint, role or styles: recycling
can leave one instance's content view cross-bound to another field, which is safe only
while the instances are interchangeable.

The CI guard is inverted rather than deleted. It asserted "every <TextInput> carries the
prop" — true throughout, and not 988 access, because the accessory the prop named never
attached. It now asserts the pairing: no bare <TextInput> outside the composite, and
neither wiring path named anywhere else. Matching only `inputAccessoryViewID=` would have
been vacuous (the composite uses the helper and never writes the prop), so both are
matched, and `runGuard` now reports `scanned` because an inverted guard passes by finding
nothing and a broken walk reports a clean tree in the same words.

Also here because they are prerequisites, not polish:
  - `useKeyboardOccludesCrisisButton` hoists to one module-level subscription. It
    registered 2 listeners per mount and `keyboardWillChangeFrame` fires every frame of
    the show animation; at 3 concurrent inputs that is 6 callbacks per frame.
  - TOUCH_TARGETS.large, not minimum — accessibility.ts:296-300 names 56 for "Crisis
    buttons" and CrisisResourcesScreen.tsx:793-795 already records that ruling.
  - maxWidth/flexShrink + label wrap: the bar's `overflow: 'hidden'` clipped the label
    well below AX5, the DEBUG-390 shape. Padding sheds at AX sizes instead; capping the
    label with maxFontSizeMultiplier stays refused per CrisisResourcesScreen.tsx:778-781.
  - The false premise is corrected in all three places that asserted it.

NOT here, and outstanding: AC1's measured bounds, AC5's sim-runnable reachability flow
(with the ConnectHardwareKeyboard seeding it needs first), AC6's hardware check, and the
AX5 keyboard-up re-measurement that gates the DailyLoop conversion. All need a device
session. The 83pt/>=88pt figures in daily-loop-ax5-entry.yaml and daily-loop-quick-depth
.yaml were measured against the inert build and are void.

  typecheck      0 errors
  lint:baseline  No new lint errors. 426/450 errors across 212 files.
  test:safety    508 passed, 19 suites
  test:clinical  253 passed, 9 suites
  test:unit      874 passed, 80 suites
  test:privacy   469 passed, 30 suites
  test:accessibility 424 passed, 27 suites
  guard mutations: bare TextInput -> exit 1, stray id -> exit 1, helper at call site -> exit 1, clean -> exit 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnd35Dzze9dXFh4YYavUZ2
The touch-target and label-wrap changes shipped in de46012 with no coverage, and both
are contracts rather than styling: accessibility.ts:296-300 names `large` for "Crisis
buttons" and CrisisResourcesScreen.tsx:778-781 refuses any cap on a crisis label. A
regression on either is invisible until someone measures a device.

The AX padding trade is extracted as `shouldShedAccessoryChrome` rather than tested
through `useWindowDimensions`, so the trade itself is assertable — including that a
NaN or absent scale reads as NOT-AX, which would otherwise strip the bar's clearance on
every device rather than only on the large ones.

  test:accessibility-adjacent suite (crisis/components): 75 passed
  check:crisis-keyboard-accessory: exit 0
  lint:baseline: No new lint errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnd35Dzze9dXFh4YYavUZ2
…ce-only rationale

Measured on the gate simulator (iPhone SE 3 / iOS 18.6 / 375x667), using a stock app as
the probe so no Being build was needed.

`crisis-keyboard-accessory.yaml` carries `safety-device-only` on the recorded grounds that
"the simulator boots with Connect Hardware Keyboard enabled, so tapping into a TextInput
raises NO software keyboard". That is false here. ConnectHardwareKeyboard is unset both
globally and per-device, a software keyboard rises at the default, and
`UIKeyboardLayoutStar Preview` lands at [0,451][375,667] — the same bounds DEBUG-506's own
evidence table records. `journal-crisis-scan.yaml` runs in the default suite and asserts
that node three times, which was the standing counter-example all along. That tag is why a
regression in this affordance is invisible to `npm run e2e:safety` by construction, so the
reason it holds is worth getting right.

What actually blocks the keyboard is iOS's QuickPath first-run tutorial: it occupies the
keyboard's whole region, it is NOT a keyboard node, and `simctl erase` restores it. So on a
freshly erased simulator, journal-crisis-scan fails its first keyboard assertion against a
perfectly healthy app. That is a live false-red on the existing gate and is not this
branch's doing — same class as DEBUG-422's scheme approval, invisible on a long-green
machine because someone dismissed it by hand once.

Pre-flight now seeds `DidShowContinuousPathIntroduction` with a read-back, and REFUSES
outright if a hardware keyboard is on. Asserted rather than seeded on that half
deliberately: it is host-side state that needs Simulator.app to reload, and a write that
cannot be proven to have landed is the defect class this gate already refuses elsewhere.

Scoped to runs that can raise a keyboard, mirroring the scheme block's `openLink` scoping —
the shared simulator rule. The predicate is deliberately NOT `inputText`:
crisis-keyboard-accessory taps a field and asserts without typing, so an inputText-only
scan would skip seeding for the one flow the seeding exists to serve. An unscoped first
attempt exited 2 against the harness's stub simulator and reddened 28 tests in
e2e-sim-build.test.js; the scoping is what the scheme block already uses to stay clear.

The split of the reachability half into the sim gate (AC5) is NOT authored here. It has not
been run once, and a safety flow red on arrival gates nothing and trains a --skip-e2e
bypass. The corrected header says so.

  test:scripts  632 passed, 27 suites (was 604 passed / 28 failed on the unscoped attempt)
  live sim, block sourced from the script rather than retyped:
    keyboard flow + flag present  -> already dismissed        exit 0
    keyboard flow + flag deleted  -> seeded, read back = 1    exit 0
    non-keyboard flow             -> pre-flight skipped       exit 0
    keyboard flow + hw keyboard   -> refuses                  exit 2
    host state restored           -> ConnectHardwareKeyboard unset (original)
  predicate mutation: inputText-only -> exit 1; restored -> exit 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnd35Dzze9dXFh4YYavUZ2
…that quote it

The pair "scroll viewport collapses to 83pt, below the >=88pt residual floor" was measured
on 2026-08-21 against a build in which CrisisKeyboardAccessory never attached. It therefore
describes no build that will ever ship again, and left standing it reads as a post-fix
baseline — a future reader would take it as "the number before we made it worse".

The floor is re-scoped rather than only re-measured. >=88pt was an instrument for ORDINARY
content competing with a crisis affordance, where the support line was the only candidate
and "untruncated and above the fold" was the reachability question. When the competitor is
itself a crisis affordance rendered in the keyboard's own window — which cannot be scrolled
off, folded or clipped — the invariant to hold is "at least one untruncated, tappable
crisis affordance is on screen without scrolling". The residual floor survives as a
content-legibility floor owned by accessibility, not as a crisis floor.

Both flows keep their deliberate non-assertion of the keyboard-up case, and the reason is
unchanged: a flow red on arrival gates nothing and trains a bypass. Only the void number is
removed; the defect is still recorded.

Scope checked rather than find-replaced. `journal-crisis-scan.yaml:79` also says 83pt and
is a DIFFERENT measurement — profile-card-voice-reflection sitting behind the tab bar — and
is untouched. `daily-loop-ax5-entry.yaml:20` matched only as a substring of 583pt.

DEBUG-518's DailyLoopStepScreen change is paddingRight (CRISIS_FAB_CLEARANCE), horizontal
only, so it does NOT independently invalidate the figure; the inert accessory is the sole
reason. Its premises for this branch's rulings were re-checked and hold: config.fields.map
+ showPremeditatio still allow three concurrent inputs, and the support line is still
outside the ScrollView (closes :423, supportBar :468).

Post-back-merge of origin/development (26 commits, no conflicts, npm ci after the lockfile
moved):
  typecheck      0 errors
  test:safety    525 passed, 20 suites
  test:clinical  253 passed, 9 suites
  test:unit      877 passed, 80 suites
  test:scripts   670 passed, 29 suites
  check:crisis-keyboard-accessory  ✓ 262 files scanned, exit 0
  safety-tagged flow count unchanged at 11 (dev's two new flows are safety-dynamic-type
  and safety-bottom-inset)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnd35Dzze9dXFh4YYavUZ2
…crisis-keyboard-accessory-attach

# Conflicts:
#	app/.eslint-baseline.json
…crisis-keyboard-accessory-attach

# Conflicts:
#	app/.eslint-baseline.json
#	app/src/features/journal/screens/VoiceReflectionScreen.tsx
The accessory's only pin was `crisis-keyboard-accessory.yaml`, tagged
`safety-device-only`, so `npm run e2e:safety` could not select it. That is how
DEBUG-450 shipped an accessory that never attached for its entire life with a
green gate over it.

`crisis-keyboard-reachability.yaml` carries the reachability half in the default
sim suite. Nothing in it needs `canOpenURL` — the device-only rationale covers
the DIAL, and the dial is owned by `crisis-988-dial.yaml`. It pins
DailyLoopStepScreen rather than the journal composer because DEBUG-431 found no
bounded exit there at all (`headerShown: false` AND `gestureEnabled: false`), so
that is the surface where the accessory is the only affordance.

The device-only flow keeps its tag, but the recorded reason is now its PREAMBLE:
it walks `_legal-and-onboarding.yaml`, correct for a non-seeded real-device build
and wrong for the gate build, where EXPO_PUBLIC_E2E_SEED_ONBOARDED routes past
the LegalGate. It is what AC 6 runs on hardware.

Flow-count tripwire bumped 12 → 13 in the same commit, per its own instruction.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…crisis-keyboard-accessory-attach

# Conflicts:
#	app/.eslint-baseline.json
#	app/__tests__/scripts/e2e-dynamic-type.test.js
#	app/src/core/navigation/CleanRootNavigator.tsx
FEAT-570 landed after this item was filed and wired its message field the old
way — `<TextInput {...crisisAccessoryProps()}>`. Its own comment states the
stake exactly: "the keyboard is necessarily up for the dwell here, so on iOS
this accessory is the SOLE 988 route while it is. Mandatory, not optional."
It was inert, like the other seven, on a route where the FAB is suppressed.

Not optional scope. `crisisAccessoryProps` now takes a per-instance id, so the
call no longer compiles, and the inverted guard rejects a bare <TextInput>
outside the composite. The only other way to compile it — a default argument —
would restore the shared-nativeID wiring this item proved cannot attach, on the
one surface with no fallback affordance.

Verified on device (iPhone 16e / iOS 26.6.2, Release build of this branch,
bug_reporting:true): shaking with a field focused raises the form, and tapping
its message field brings up the keyboard with the accessory attached above it.
That also confirms the slot's inline hosting is what makes it work —
RootOverlaySlot is not a <Modal>, so the input and the accessory share the
window that RCTFindTextInputWithNativeId searches.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…d root mount

DEBUG-575 asserted `<CrisisKeyboardAccessory` sits outside NavigatorA11yHost in
CleanRootNavigator. This item removes that mount, so the assertion cannot stand
— but the reason it goes is not that the control moved somewhere safer.

Its position in that file never governed its a11y exposure, before or after.
`RCTInputAccessoryComponentView.mm:72` assigns `_textInput.inputAccessoryView =
_contentView`, so UIKit reparents the visible content into the keyboard window,
and the component view left in the React tree is `hidden = true` (:121). The
old assertion was protective in appearance only: it guarded a control that, from
the root mount, never attached at all.

Replaced with the inverse pin — the root mount must not come back — which is
mutation-proved rather than assumed: re-adding `<CrisisKeyboardAccessory />` to
CleanRootNavigator reds it, reverting greens it. That matters because a root
mount IS the DEBUG-450 defect: it renders nothing, warns nothing, and leaves
every other gate green.

One question is recorded OPEN rather than answered, because this file reads
source and cannot answer it: whether VoiceOver can still reach the accessory
while a root-slot overlay holds the screen (INFRA-427). The trigger is live —
BugReportOverlay is shake-armed from the app root and deliberately neither
autofocuses nor dismisses the keyboard, so a navigator-resident field can keep
first responder with the overlay up. Device check confirmed the bar PAINTS in
that state; whether it is in the accessibility tree is untested.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@MP2EZ
MP2EZ merged commit 5dfaebf into development Sep 10, 2026
11 checks passed
@MP2EZ
MP2EZ deleted the fix/DEBUG-506-crisis-keyboard-accessory-attach branch September 10, 2026 06:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant