Codex stop-hook surfaced after the Phase C gate GO: window-level
composition listeners were processing IME activity from ANYWHERE on
the page (URL bar, devtools search, any other editable element)
as if it were directed at the inspector's TextInput state. Without
an owned editable target, the IME wiring was technically reachable
end-to-end but semantically incorrect.
Fix: create a hidden `<textarea>` in `mount()`, append to
`document.body`, programmatically focus it, and register the 3
composition listeners on it instead of `window`. The textarea is:
- styled `position:fixed; left:-9999px; top:0; width:1px;
height:1px; opacity:0; pointer-events:none;` so it does not
visually intrude
- `aria-hidden="true"` so screen readers ignore it
- `tabindex="-1"` so Tab-traversal skips it
`focus()` is best-effort (returns Err if the document is not yet
visible — e.g. background tab); the page user gives focus on the
first interaction. Once focused the textarea owns IME composition
contexts; only compositions targeted at it reach the inspector.
Keyboard listeners stay on `window` — Cmd+S / Tab / arrow shortcuts
should fire regardless of which element has focus, and the
stop-hook concern was specifically about IME, not keyboard.
Plumbing changes:
- `WebShell` gains `ime_target: web_sys::HtmlElement` field. Drop
calls `self.ime_target.remove()` after unregistering listeners
so leaving the page does not leave an orphan node + the browser
does not ship dead composition state into the next document.
- `add_listener` is now generic over the target type
`T: Clone + Into<EventTarget>`; the helper clones into an owned
EventTarget once for the registration call + Listener cleanup
storage. Call sites pass `&win_target` (T = EventTarget) for
keyboard listeners and `&ime_textarea` (T = HtmlElement) for IME
listeners without an explicit cast.
- The partial-registration unwind path also calls
`ime_textarea.remove()` so a failed mount does not leave an
orphan node behind (Phase C gate Round 1 BLOCK fix from earlier
is preserved + extended).
Verification:
- `cargo build -p openpencil-shell-web --target wasm32-unknown-
unknown --features skia --release` — green
- `cargo check -p openpencil-shell-web --target wasm32-unknown-
unknown --no-default-features --features web` — green
(compile guard)
- `bash tools/check-wasm-bundle.sh` — PASS:
- 0 env.* imports
- 622 149 bytes gzip = 59% of 1 MiB ceiling (+1 KiB vs C-gate
R5 — hidden-textarea creation + remove paths cost ~1 KiB of
web-sys glue)
- `cargo test -p openpencil-shell-web --test dom_event_mapping`
— 25/25 (mappers are pure; this fix is mount-side glue and
doesn't touch the mappers)
- `bash tools/check-widget-boundary.sh` — PASS
Phase D will land focus management for arbitrary widget chrome
focus + the Reflect-based getTargetRanges() lookup that the IME
selection pipeline still owes; for Phase C / Step 1b the hidden
textarea is the correct simplest scope.