fix: DEBUG-506 CrisisKeyboardAccessory never attaches — 988 unreachable while a keyboard is up - #484
Merged
MP2EZ merged 13 commits intoSep 10, 2026
Conversation
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
…crisis-keyboard-accessory-attach
…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
…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>
…crisis-keyboard-accessory-attach
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes DEBUG-506
CrisisKeyboardAccessorynever attached. DEBUG-450 wired all seven text surfaces to ONEInputAccessoryViewmounted 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-82attaches only insidedidMoveToWindow, via a one-shot depth-first search of the window for aTextInputcarrying 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.:121setsself.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— aTextInputand its accessory from one mount, per-instanceuseId,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 owninputAccessoryViewIDandinputAccessoryView, so an input that unmounts under a still-mounted accessory leaves it holding a dead__weakref — and both crisis-relevant surfaces do exactly that (VoiceReflectionScreenonsetPhase,DailyLoopStepScreenas 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:DailyLoopStepScreenrenders up to three concurrent inputs, so a shared id left two of three uncovered.Verified, not reasoned
[0,335][375,407], aboveSystemInputAssistantView[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 reachescrisis-resources-screen.Guard and coverage
<TextInput>outside the composite. Matching onlyinputAccessoryViewID=would have been vacuous, since the composite uses the helper and never writes the prop.crisis-keyboard-reachability.yamlcloses the gap that let this ship: the accessory's only pin wassafety-device-only, sonpm run e2e:safetycould never select it. Nothing in it needscanOpenURL— the dial is owned bycrisis-988-dial.yaml. Flow-count tripwire bumped to 15.useKeyboardOccludesCrisisButtonhoisted to one module-level subscription (was 2 listeners per mount, on an event that fires every animation frame).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
<CrisisKeyboardAccessorysits outsideNavigatorA11yHostcannot survive the root mount's removal — but that placement never governed a11y exposure anyway: UIKit reparents_contentViewinto 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