feat(tv): search UI — chip, /search route, semantic search, results grid, browse surface (feat-106)#847
Merged
Merged
Conversation
…dep (U1)
- Add /search route placeholder at apps/tv/app/search.tsx (subsequent units
flesh it out per docs/plans/2026-04-24-001-feat-tv-search-ui-plan.md).
- Add SEMANTIC_SEARCH GraphQL op to apps/tv/src/lib/queries.ts, mirroring
mobile's 4-var shape and adding 'searchMode' to the selection so the TV
search hook can distinguish hybrid (healthy) from keyword-only (degraded
backend — OPENROUTER key missing) and render distinct UX. Mobile does
not consume this signal today; TV does. locale: String! (not
I18NLocaleCode!) because semanticSearch is a custom resolver.
- Add @react-native-async-storage/async-storage ^2.2.0 for the recent-
searches hook (U8).
- Update feat-106 roadmap ticket: duration 2→7; document scope cuts from
planning/doc review (external keyboards and voice both out of scope,
filed as separate follow-up tickets); correct the useLazyQuery guidance
(doesn't work with fetchMore) to getApolloClient().query() +
requestIdRef; link brainstorm + plan docs.
- Land the brainstorm and plan artifacts under docs/{brainstorms,plans}/.
New HomeHeader row at the top of the home ScrollView containing a focusable Search chip (magnifier glyph + 'Search' label, Crimson Gallery chip styling). NOT absolute-positioned — per react-native-tvos- porting-pitfalls, absolute focusables are ignored by the tvOS focus engine. Reachable via D-pad-up from the Experiences rail through natural flexbox ordering. Pressing center navigates to /search. Back-from-/search focus restoration uses useFocusEffect with a monotonic focus key — on every regain-focus after the first mount, the key bumps and HomeHeader re-applies hasTVPreferredFocus to the chip. First mount is skipped so the rail's TVFocusGuideView autoFocus wins initially (tvos#852 workaround). Component tests deferred — apps/tv has no component-test infrastructure yet (lib-only tests). U2 verification is simulator + typecheck.
…ow (U3) SearchKeyboard is a controlled component (value / onChange / onSubmit). Layout (top to bottom): - Frequency quick-pick row: E T A O I N S (7 most common English letters). Users who recognize the shortcut save D-pad presses; those who don't still have the alphabetical grid below, predictable and familiar. - Background-only separator (no 1 px border per Crimson Gallery). - Alphabetical grid: A-G / H-N / O-U / V-Z.'. - Numerals: 0-9 across two rows. - Action row: wide space + backspace + submit. Every cell is a FocusableCard, so focus-scale and crimson glow are inherited. TVFocusGuideView wraps the whole keyboard with trapFocusLeft so D-pad-left from the leftmost column is a no-op; D-pad-right / -down cross into the right pane naturally. hasTVPreferredFocus lands on the first alphabetical 'A' by default, or on the submit key when submitKeyPreferredFocus is true — U6 will use that prop to return focus here after empty-results state so the user can edit-and-resubmit in one press. Component tests deferred (no component-test infra in apps/tv yet).
search.tsx now owns the query state and renders a two-pane layout:
Left pane: QueryDisplay (read-only) + SearchKeyboard (controlled).
Right pane: stubbed behind a TVFocusGuideView trapFocusLeft wall;
later units fill this with SearchResultsGrid (U6) when
the query is non-empty and SearchBrowse (U7) when it is
empty.
QueryDisplay is intentionally not focusable — it is a readout above
the keyboard, styled as a chip (surface-container background, 16px
radius). Placeholder "Type to search" renders at muted color when the
value is empty; full-contrast Crimson Gallery text once typing begins.
Per doc-review P2, the muted placeholder is accepted transient UX
despite ~3.4:1 contrast.
Submit is a no-op for now so the keyboard's ⏎ key dispatches safely;
U5 wires real semantic-search submission.
… (U5)
Adds apps/tv/src/lib/search.ts exporting useSemanticSearch — a hook
that owns the debounced semantic-search state machine for /search.
Architecture:
- getApolloClient().query({ fetchPolicy: 'no-cache' }), not useLazyQuery
(mobile-search-ui-patterns learning: fetchMore silently drops page 1).
- requestIdRef stale-guard discards responses from superseded queries.
- isSubmittingRef ignores submit() calls while a prior search is in
flight, preventing rapid-⏎ duplicate calls.
- 600 ms trailing debounce (longer than web's 300 ms — TV input is
slower and we want fewer wasted round trips).
- Empty query is a no-op: no network call, state returns to 'idle'.
- State machine: idle | loading | ready | empty | error | degraded.
- 'degraded' is entered when the CMS response's searchMode is
'keyword-only' (OpenRouter embedding unavailable) so the UI can
render a distinct 'temporarily unavailable' message rather than
collapsing silently into 'no results'.
Sanitization lives in a separate React-free module (sanitizeQuery.ts)
so jest-expo can load the unit tests without pulling React through
babel. The helper does NFKC normalization, strips C0 + C1 control
characters, zero-width spaces / joiners, and RTL override codepoints,
trims, and caps at 256 chars. Applied at every setQuery site.
15 tests cover sanitizeQuery happy path, control-char stripping,
directional overrides, ZWSP, emoji preservation, length cap, NFKC
normalization, and accented-letter preservation. useSemanticSearch
itself remains untested at this commit — hooks need react-testing-
library setup which apps/tv does not have yet; verification is
simulator + typecheck.
useSearchHistory returns { recents, addRecent, clearAll }. Persistence
lives at the versioned key 'tv.searchHistory.v1' so a future schema
change (per-entry timestamps, locale tagging, source attribution) is a
migration rather than a breaking read.
Policy:
- Cap at 5 entries.
- Deduplicate case-insensitively and move the matched entry to the
front on every successful submit.
- Silently ignore empty / whitespace-only inputs so the recent rail
cannot accumulate blanks.
- Cap each stored entry at 256 chars (aligned with sanitizeQuery's cap
so longer strings cannot reach here, but double-clamping as defense
in depth).
- Best-effort writes: in-memory state updates first, a failing
AsyncStorage write is swallowed (the next mount just starts with
the previous on-disk state — no user-surfaced error).
The pure merge policy lives in searchHistoryMerge.ts with no React /
AsyncStorage imports so the jest-expo preset can load the unit tests
without tripping on the native module boundary (same pattern used for
sanitizeQuery). 6 tests cover empty-list, non-empty list, case-
insensitive dedupe, cap at max, input non-mutation.
The hook itself depends on RN AsyncStorage and is exercised at
simulator time + via the consumers in U7 (Recent rail) when that
lands. No hook-level unit tests; apps/tv has no react-testing-library
setup yet.
SearchResultsGrid renders the right pane when the query is non-empty,
switching between five distinct states:
loading: centered ActivityIndicator in Crimson accent color.
ready: 4-column FlatList of ResultCards wrapped in TVFocusGuideView
trapFocusLeft / trapFocusRight so D-pad motion stays inside
the grid. First result gets hasTVPreferredFocus so the
user's next press selects a result without re-navigating.
empty: "No results for {query}" with a focus-return hook — the
parent screen bumps a key that re-mounts SearchKeyboard
with submitKeyPreferredFocus, landing the user on the ⏎
key so they can edit-and-resubmit in one press (doc-review
P1 resolution).
error: generic failure message + focusable Retry button wired to
useSemanticSearch.retry() (re-runs the last query).
degraded: distinct UX when the backend response's searchMode is
'keyword-only' — the embedding service is unavailable but
keyword matches may still have returned something. Render
a banner labeling the limitation, the results we do have,
and a Retry button. Per the doc-review finding about silent
degradation, this state must not collapse into 'empty'.
ResultCard mirrors the home rail's visual language (FocusableCard,
image + title + snippet, Crimson Gallery palette). Focus and press
callbacks close over the result object directly rather than re-indexing
by id, per tv-focus-driven-hero-patterns.
search.tsx now conditionally renders the grid vs. the browse stub
(U7 fills the browse side). Retry is threaded from useSemanticSearch.
SearchBrowse is the right pane when the query is empty, stacking three rails that each offer a typing-free path to content — the whole point of feat-106 per the brainstorm. Rails (top to bottom): - Recent (conditional): horizontal row of pill chips, one per stored recent query, terminated by a muted 'Clear' chip that invokes clearAll on the useSearchHistory hook. Hidden entirely when recents.length === 0. - Browse topics: the 6 hardcoded category cards ported verbatim from apps/web/src/lib/search-categories.ts. RN cannot parse CSS linear-gradient strings, so the TV variant expresses each card as a 2-color stop array + a 135-degree diagonal rendered via expo-linear-gradient. Titles and search terms are the single source of truth — any change to the web list should be mirrored here until a shared token module extracts the constants. Clicking a card fires onRunQuery with the card's searchTerm, which the parent's runQueryImmediate wires to sanitizeQuery + setQuery + submit so the search fires instantly (bypassing the 600 ms debounce). - Popular experiences: rendered via the existing ContentRail, reusing home's LIST_EXPERIENCES query so the Apollo cache is warm when the user came from home. Cold cache (deep-link into /search) fires the same network request home would have. First POPULAR_COUNT experiences render; pressing a card navigates directly to /experience/[slug] without running a search. search.tsx now owns the full wiring: useSearchHistory for recents, an effect that calls addRecent once per successful non-empty ready state (guarded by lastRecordedQueryRef so debounce churn doesn't double-record), and runQueryImmediate for category / recent clicks. Final U7 visual verification remains simulator-level. No component tests for browse / result components; apps/tv still has no react- testing-library setup (lib tests only). Phase A feature-complete.
A dev client built before @react-native-async-storage/async-storage was added to package.json crashes at module-import time with "NativeModule: AsyncStorage is null". Static \`import AsyncStorage from ...\` would take down the whole bundle. Add safeStorage.ts: dynamic require + null-check + in-memory fallback. useSearchHistory now calls getStorage() lazily on each operation, so: - Existing dev clients (no native rebuild) keep working — Recents are in-memory only this session, a one-time console warning surfaces the gap, no crash on /search mount. - A fresh native build (EXPO_TV=1 npx expo prebuild --clean + pnpm --filter tv ios|android) transparently picks up the real AsyncStorage at first call — no consumer-side change required. The fallback path is forward-compatible: when the native rebuild lands, loadAsyncStorage() succeeds and persistence resumes. The dev-only warning then never fires.
Threaded fixes from the dev-loop session on the simulator: - SearchKeyboard: default to lowercase; add an ABC/abc shift toggle in the action row (persistent caps-lock-style, easier on D-pad than transient shift). Letter rows are generated dynamically per case; React keys are position-based so the cell currently focused does not get unmounted on each toggle. Already-typed characters in the query keep their original case (matches every desktop / mobile shift semantic). - SearchBrowse: increase per-cell padding around Browse-topic and Recent-chip cards so the focus glow (shadowRadius scale(16) + 1.05x scale ≈ 21dp halo) renders inside each cell's padded box rather than getting clipped at the ScrollView contentContainer edge. Same itemWrapper pattern home's ContentRail uses. - search.tsx + SearchResultsGrid: drop trapFocusLeft / trapFocusRight from the right pane and from the results list. The traps blocked D-pad-left from a leftmost cell on either rail or grid from returning to the keyboard. SearchKeyboard keeps its own trapFocusLeft so D-pad-left from its leftmost column is still a no-op (preventing focus from disappearing into nothing). - useSemanticSearch: add a 12-second client-side safety timeout that forces transition out of 'loading' if Apollo's promise neither resolves nor rejects (kicks in slightly before the HttpLink's 15-s fetch timeout so the user sees the client-side error message rather than waiting through both). Also add lifecycle console logs at firing / response / error / safety-timeout boundaries to surface what the network path is actually doing while the spinner is showing.
…ox fix) The previous safeStorage already wrapped require() in try/catch, which correctly caught the JS exception — `loadAsyncStorage` returned null, the in-memory fallback kicked in, and the app kept running. But: in dev mode, Metro's bundler dispatches synchronous require-time throws to React Native's global error handler BEFORE the JS-level catch can swallow them. That surfaced the "Uncaught Error: NativeModule: AsyncStorage is null" red overlay even though the JS state machine was handling the failure correctly. Fix: check NativeModules.RNCAsyncStorage and TurboModuleRegistry.get( "RNCAsyncStorage") BEFORE calling require. When the native module is not registered, skip the require entirely — AsyncStorage's top-level guard never fires, no throw to dispatch, no overlay. The try/catch around the require is preserved as belt-and-suspenders for the pre-check-says-yes-but-require-still-throws case.
…loop
EmptyState was calling onMount?.() directly in its render body. The
parent's onMount handler bumps emptyStateFocusReturnKey via setState,
which re-renders the parent, which re-renders EmptyState, which calls
onMount again — Maximum update depth exceeded.
The earlier comment ("calling on render is safe because React will
batch the update") was wrong. React batches setState calls within a
single render pass, but a setState that itself causes the calling
component to re-render is the textbook infinite-loop pattern, not a
batchable update.
Move the call into useEffect with [onMount] as the dep list. The
parent passes onMount as a useCallback with empty deps so its identity
is stable, meaning the effect fires exactly once per (re-)mount of
EmptyState — i.e., each time the search state transitions INTO 'empty'.
When the user edits the query and state leaves 'empty', EmptyState
unmounts; on next entry to 'empty' it re-mounts and the effect fires
again to claim focus on the ⏎ key. Same observable behaviour as the
original intent, no infinite loop.
The "auto-focus the ⏎ key when search returns empty" mechanism worked
by bumping a counter that lived on SearchKeyboard's React key. Each
bump unmounted the entire keyboard and remounted it with
submitKeyPreferredFocus=true. The unmount killed focus on whichever
key the user was actively typing on. Before the new keyboard finished
mounting, the tvOS focus engine picked a fallback (the first focusable
child it found = the 'a' key), and only then did the new instance's
hasTVPreferredFocus claim move focus to ⏎. Visible jump per keystroke:
'b' → 'a' → '⏎'.
The mechanism fired on every debounced empty-result search, which on
short queries is most of them — so it actively fought normal typing.
Removed:
- emptyStateFocusReturnKey state + handleEmptyState callback in
apps/tv/app/search.tsx.
- The dynamic React key on <SearchKeyboard>; the keyboard now stays
mounted across state transitions.
- The submitKeyPreferredFocus prop on SearchKeyboard (and its branch
in renderKey — the first-row 'a' key remains the preferred initial
focus on first mount only).
- The onEmpty prop on SearchResultsGrid + the useEffect inside
EmptyState that fired it.
EmptyState is now pure presentation. The "no results" message updates
copy to point users at backspace as the natural refinement gesture.
The original UX intent ("user can edit-and-resubmit in one press") is
preserved by the simple fact that the user is already on the keyboard
when this fires; nothing needs to move focus.
Same itemWrapper pattern that SearchBrowse uses: wrap each ResultCard in a View with paddingVertical: scale(28) + paddingHorizontal: scale(14) so the FocusableCard's focus halo (shadowRadius scale(16) + 1.05x scale ≈ 21dp outward halo) renders inside the wrapper's padded box rather than getting clipped at the FlatList contentContainer edge. Replace the FlatList's gap-based spacing with the wrapper-based spacing to avoid double padding: - listContent: drop gap, add paddingHorizontal / paddingVertical for the start / end gutter on outer-row and outer-column cards. - row: drop gap; resultCellWrapper.paddingHorizontal handles spacing between adjacent cards in the same row. - resultCellWrapper: 28dp vertical, 14dp horizontal — a hair more generous than the original CARD_GAP since glow needs the halo headroom on top of card-to-card spacing. - CARD_GAP constant removed (no longer used). Edge cards (top-row / bottom-row / leftmost-column / rightmost-column) now have breathing room from the FlatList edge, eliminating the corner-clip on the focus glow.
ResultCard had a fixed width: scale(280) + image: width: scale(280),
so 4 cards-per-row never filled the right pane. The leftover space
piled up on the right edge, asymmetric to the left gutter.
Switch to a flex-based sizing chain:
- resultCellWrapper now sets width: \`\${100/NUM_COLUMNS}%\` so each
cell occupies exactly 1/4 of the row width regardless of how many
cards land in the row. A partial last row's lone card stays at
1/4 width rather than stretching to fill the row.
- ResultCard.card switches from fixed width to flex: 1 so it expands
to its cell wrapper's content width (cell width minus the
scale(14) horizontal padding the wrapper carries for focus halo).
- ResultCard.image switches to width: "100%" so the thumbnail
stretches to the card's full width. Height stays at scale(158)
so cards remain uniform vertically across panel widths.
- CARD_WIDTH constant removed.
Net: 4 cards per row fill the panel edge-to-edge with equal left and
right gutters (the listContent.paddingHorizontal: scale(16) outer
gutter on each side). Cards are also visibly larger than before.
ResultCard: - Title/snippet area is now a vertical LinearGradient from surfaceContainerHighest (#383432, lightest container tone) at the image seam down to surface (#161311, the screen background) at the bottom of the card. The fade-into-shadow look reads as cinematic poster rather than two flat panels, and — critically — the title plate is no longer the same color as the right pane's solid surfaceContainer background, so cards visibly separate from the panel even when not focused. - Card outer bg switches from surfaceContainer to surfaceContainerHighest so any pixel showing through during the focus-scale animation reads as "card" (not "panel"). The image covers the top, gradient covers the rest, so the bg only shows behind the borderRadius corners during the brief animation interpolations. SearchResultsGrid: - resultCellWrapper.paddingVertical: scale(28) → scale(14). Halves the inter-row visual gap (was 56dp top + bottom of adjacent rows → now 28dp). Glow halo (~21dp) is trimmed by ~7dp at top/bottom corners of focused cards in the worst case; per-side trim is more forgiving than the previous full-clip-at-edge problem and the denser grid was the explicit ask.
…responsive Cross-cutting cleanup driven by /impeccable audit on the TV search surface (P1 + P2s, no P0 blockers). - FocusableCard: accept accessibilityHint and apply accessibilityRole="button" for every consumer. VoiceOver / TalkBack now announces these as actionable controls instead of plain text. - SearchResultsGrid: dynamic numColumns (4 below ~2880dp window width, 6 above) so 4K-class displays get a denser grid; FlatList key bumps on column change so RN does not warn on numColumns switches mid-flight. Replace em-dash in the degraded banner with a colon (impeccable copy law). Drop the redundant "Please try again." line in the error state — the button text already conveys the action. Add accessibilityRole + accessibilityLabel + accessibilityHint to the two retry Pressables (Pressable doesn't inherit FocusableCard's defaults). - SearchBrowse: replace hard-coded "#FFFFFF" / "#000000" in category-card styling with COLORS.text and COLORS.surface (the Crimson Gallery rule explicitly bans pure black/white). Add accessibilityHint to category cards, recent chips, the Clear chip, and popular-experience cards. - ResultCard: bump title fontSize from scale(16) to scale(18) for a 18/12 = 1.5 hierarchy ratio (above the impeccable ≥1.25 law). Tighten snippet lineHeight to scale(15) so it reads as supporting metadata, not a competing headline. Add accessibilityHint. - HomeHeader: add accessibilityHint to the Search chip. - SearchKeyboard: frequency-row keys now sit on surfaceContainerHigh (one tier brighter than the alphabetical grid below) so the quick-pick row is visually discoverable without adding chrome — closes the plan's deferred "frequency top-row visual treatment" question.
P0: - Reset isSubmittingRef + clear safety timer when debounce useEffect's empty-query branch bumps requestIdRef. Was wedging the hook permanently on backspace-to-empty mid-flight. P1: - useSearchHistory hydration: merge prev + loaded via reduceRight instead of pick-one. Fast-typing on cold mount no longer clobbers on-disk history. P2: - addRecent reads lastSubmittedQuery (the query that drove state="ready") instead of the live keyboard state. - Gated the two ungated console.warn/error calls in search.ts with __DEV__. No raw user queries in production logs. - accessibilityRole="header" on SearchBrowse rail titles. - skipNextDebounceRef stops chip-click duplicate-fire 600ms after each chip press. - Removed unused isSubmitting React state from useSemanticSearch. - Dropped redundant Apollo response casts; let gql.tada infer. - Exhaustiveness assert on SearchState in SearchResultsGrid. - First-cell focus-claim keyed on first-result identity so debounced refresh doesn't yank focus from the user's cell. P3: - sanitizeQuery JSDoc no longer claims "trim"; documented caller empty-query-gating responsibility. - Counter-based StrictMode guard on useFocusEffect so dev focus matches prod first-render behavior. - Explicit fetchPolicy: "cache-first" on SearchBrowse useQuery. Deferred to follow-up PRs (see .context/compound-engineering/ce-code-review/20260428-160414-62316cd5/deferred.md): - Push flex:1 out of FocusableCard inner (architectural) - SearchBrowse error UI (needs designed UX) - AccessibilityInfo announcements for search state transitions - Test suites for useSemanticSearch, useSearchHistory, safeStorage Verified: pnpm tsc --noEmit clean, pnpm test 50/50 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
🚅 Deployed to the forge-pr-847 environment in forge 4 services not affected by this PR
|
CI's strict tsc rejected `style={({ focused }) => [...]}` on the
two retry Pressables — `focused` is exposed at runtime by
react-native-tvos but not in the upstream PressableStateCallbackType,
so the callback form fails type-check (only `pressed`/`hovered`
are typed).
Extracted RetryButton matching the onFocus/onBlur + useState
pattern already used by the home screen's retry button at
app/index.tsx:243-256. Same visual behavior; type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ur-imazing
added a commit
that referenced
this pull request
May 6, 2026
* chore(roadmap): mark feat-076 and feat-106 complete Both TV tickets owned by urim have shipped to main: - feat-076 (TV App — Video Playback + Polish): shipped via #742, #749, #773, #803, #830. - feat-106 (TV App — Search UI): shipped via #847. Bump README counts (Complete 63→65, In progress 7→5) and add the missing feat-106 row to the Topic Experiences table. * docs(solutions): capture roadmap status drift audit recipe Knowledge-track learning compounded from this PR's audit. Documents the grep + git-log + verification cross-check workflow that surfaced feat-076 and feat-106 as drifted, plus the secondary drift pattern (file on disk without README table registration). Cross-references three related workflow / drift pattern docs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Summary
Implements feat-106 (TV App Search UI). 18 commits delivering a complete D-pad-navigable search surface for Apple TV + Android TV against the existing semantic-search GraphQL resolver.
VideoViewwas nailed down. Final layout keeps the chip out of the AVPlayerLayer region.useSemanticSearchhook with 600ms debounce, in-flight guard, stale-response guard, 12s safety timeout, retry/runQuery/submit/lastSubmittedQuery surface. State machine:idle | loading | ready | empty | error | degraded(thekeyword-onlysearchMode triggers the distinct degraded UX).LIST_EXPERIENCES).useSearchHistoryhook over asafeStoragewrapper that pre-flights the AsyncStorage native module viaNativeModules+TurboModuleRegistrybefore callingrequire()(so a stale dev client doesn't red-box).Code review
Ran
/compound-engineering:ce-code-review(13 reviewer personas). The final commit (0f6b6e4) lands the LFG bundle: 14 fixes addressing 1 P0 (hook wedge after backspace-to-empty), 1 P1 (history hydration race), 8 P2 (PII logging, a11y headers, chip-click dup-fire, focus-restealing, type tightening, exhaustiveness), and 4 P3 (JSDoc / StrictMode / cache hint).Stage 5b validators dropped 3 findings: in-flight guard is intentional (rapid-Enter), schema disallows nulls in
[SearchResult!]!, React 18 doesn't coalesce sequential gesture handlers.6 deferred to follow-up PRs (recorded in
.context/compound-engineering/ce-code-review/20260428-160414-62316cd5/deferred.md):flex: 1decision out ofFocusableCard's inner (architectural)SearchBrowseuseQueryerror UI (needs designed UX)AccessibilityInfo.announceForAccessibilityfor search state transitionsuseSemanticSearch,useSearchHistory,safeStorageVerification
pnpm tsc --noEmitcleanpnpm test50/50 passing (sanitizeQuery: 16, searchHistoryMerge: 6, validateUrl + easterDates unchanged)Test plan
🤖 Generated with Claude Code