Skip to content

feat(tv): search UI — chip, /search route, semantic search, results grid, browse surface (feat-106)#847

Merged
Ur-imazing merged 20 commits into
mainfrom
feat/tv-search-ui
Apr 28, 2026
Merged

feat(tv): search UI — chip, /search route, semantic search, results grid, browse surface (feat-106)#847
Ur-imazing merged 20 commits into
mainfrom
feat/tv-search-ui

Conversation

@Ur-imazing
Copy link
Copy Markdown
Contributor

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.

  • Home Search chip — Netflix-style centered sticky pill above the video hero. Survived several positioning iterations (above → overlay-on-hero → reverted) once the tvOS focus engine's behavior with playing VideoView was nailed down. Final layout keeps the chip out of the AVPlayerLayer region.
  • /search route — two-pane layout: on-screen alphabetical keyboard (with ⌕ frequency row + caps-lock toggle) on the left, results grid or browse surface on the right.
  • Semantic search wiringuseSemanticSearch hook 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 (the keyword-only searchMode triggers the distinct degraded UX).
  • Results grid — adaptive 4-column / 6-column FlatList based on viewport width, focus-halo headroom in per-cell wrappers, retry-button error state, banner-plus-results degraded state.
  • Browse surface — Recent chips (AsyncStorage-backed, 5-cap, case-insensitive dedupe) + 6 hardcoded category cards + Popular experiences rail (reuses home's LIST_EXPERIENCES).
  • Recent historyuseSearchHistory hook over a safeStorage wrapper that pre-flights the AsyncStorage native module via NativeModules + TurboModuleRegistry before calling require() (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):

  • Push flex: 1 decision out of FocusableCard's inner (architectural)
  • SearchBrowse useQuery error UI (needs designed UX)
  • AccessibilityInfo.announceForAccessibility for search state transitions
  • Test suites for useSemanticSearch, useSearchHistory, safeStorage

Verification

  • pnpm tsc --noEmit clean
  • pnpm test 50/50 passing (sanitizeQuery: 16, searchHistoryMerge: 6, validateUrl + easterDates unchanged)
  • Manual sim verification on 1080p tvOS — typing, debounced fire, chip click, recent re-run, focus traversal, sticky pill behavior

Test plan

  • /search loads with focus on the rail's first card; D-pad-up reaches the Search chip
  • Type "Christmas" via on-screen keyboard; results land within ~1s of the last keystroke
  • Press ⏎ during typing; in-flight result is replaced cleanly when the explicit submit lands
  • Backspace to empty mid-flight; subsequent searches still fire (the P0 fix)
  • Click a Recent chip; results render without a 600ms duplicate spinner-flicker (the chip-click duplicate-fire fix)
  • Force keyword-only mode (kill OpenRouter creds locally); degraded banner renders with retry
  • Cold-launch with prior recent history; type fast; prior history is preserved (hydration race fix)
  • D-pad navigate to result chore(tooling): add missing repo config best practices #5; debounced refresh doesn't yank focus back to result chore(tooling): enforce GitHub agent workflow #1 (focus-restealing fix)

🤖 Generated with Claude Code

Ur-imazing and others added 18 commits April 24, 2026 16:29
…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>
@railway-app
Copy link
Copy Markdown

railway-app Bot commented Apr 28, 2026

🚅 Deployed to the forge-pr-847 environment in forge

4 services not affected by this PR
  • @forge/cms/db
  • @forge/cms
  • @forge/manager
  • @forge/web

Ur-imazing and others added 2 commits April 28, 2026 16:46
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 Ur-imazing merged commit c9b120c into main Apr 28, 2026
30 checks passed
@Ur-imazing Ur-imazing deleted the feat/tv-search-ui branch April 28, 2026 04:53
@railway-app railway-app Bot temporarily deployed to forge / forge-pr-847 April 28, 2026 04:53 Destroyed
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>
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