diff --git a/src/api/__tests__/live-search.test.ts b/src/api/__tests__/live-search.test.ts
new file mode 100644
index 0000000..de7613b
--- /dev/null
+++ b/src/api/__tests__/live-search.test.ts
@@ -0,0 +1,103 @@
+/**
+ * @jest-environment node
+ */
+import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth';
+import { createCommentIssuesIssueIdCommentsPost } from '@/api/generated/endpoints/comments/comments';
+import {
+ createIssueTeamsTeamIdIssuesPost,
+ deleteIssueIssuesIssueIdDelete,
+} from '@/api/generated/endpoints/issues/issues';
+import { searchSearchGet } from '@/api/generated/endpoints/search/search';
+import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams';
+import type { TeamRead } from '@/api/generated/models';
+import { setInstanceUrl } from '@/api/instance';
+import { persistToken } from '@/auth/session';
+
+/**
+ * Search against a real instance.
+ *
+ * The acceptance criterion for #11 is the same result set as web for the same
+ * query and scope, so what matters is that matching, ranking and scoping all
+ * stay on the server -- the client only debounces and pages.
+ */
+const LIVE_URL = process.env.SOFTTRACK_LIVE_URL;
+const describeLive = LIVE_URL ? describe : describe.skip;
+
+const RUN = Date.now().toString(36).slice(-5);
+const NEEDLE = `zqx${RUN}`;
+
+describeLive('search against a live instance', () => {
+ let team: TeamRead;
+ const created: number[] = [];
+
+ beforeAll(async () => {
+ await setInstanceUrl(LIVE_URL!);
+ const token = await loginAuthLoginPost({
+ username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev',
+ password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123',
+ });
+ await persistToken(token.access_token);
+ team = (await listMyTeamsTeamsGet())[0];
+
+ const inTitle = await createIssueTeamsTeamIdIssuesPost(team.id, {
+ title: `Title match ${NEEDLE}`,
+ });
+ const inDescription = await createIssueTeamsTeamIdIssuesPost(team.id, {
+ title: `Description holder ${RUN}`,
+ description: `body mentions ${NEEDLE} here`,
+ });
+ const inComment = await createIssueTeamsTeamIdIssuesPost(team.id, {
+ title: `Comment holder ${RUN}`,
+ });
+ await createCommentIssuesIssueIdCommentsPost(inComment.id, {
+ body: `a comment saying ${NEEDLE}`,
+ });
+ created.push(inTitle.id, inDescription.id, inComment.id);
+ });
+
+ afterAll(async () => {
+ for (const id of created) {
+ await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined);
+ }
+ });
+
+ it('searches titles, descriptions and comments alike', async () => {
+ const page = await searchSearchGet({ q: NEEDLE, limit: 25 });
+ const ids = page.items.map((hit) => hit.id);
+
+ for (const id of created) expect(ids).toContain(id);
+
+ // `matched_in` is what the result row labels itself with.
+ const kinds = new Set(page.items.map((hit) => hit.matched_in));
+ expect(kinds.size).toBeGreaterThan(1);
+ });
+
+ it('carries a snippet, which is why the result is worth a line', async () => {
+ const page = await searchSearchGet({ q: NEEDLE, limit: 25 });
+ for (const hit of page.items) {
+ expect(typeof hit.snippet).toBe('string');
+ expect(hit.identifier).toContain(hit.team_key);
+ }
+ });
+
+ it('scopes to one team when asked', async () => {
+ const scoped = await searchSearchGet({ q: NEEDLE, team_id: team.id, limit: 25 });
+ for (const hit of scoped.items) expect(hit.team_id).toBe(team.id);
+ expect(scoped.items.length).toBeGreaterThan(0);
+ });
+
+ it('pages, which is what the "show more" control walks through', async () => {
+ const first = await searchSearchGet({ q: NEEDLE, limit: 1, offset: 0 });
+ expect(first.items).toHaveLength(1);
+ expect(first.total).toBeGreaterThanOrEqual(3);
+
+ const wider = await searchSearchGet({ q: NEEDLE, limit: 25 });
+ expect(wider.items.length).toBe(wider.total);
+ });
+
+ it('finds nothing for a query that matches nothing', async () => {
+ const page = await searchSearchGet({ q: `${NEEDLE}-absent`, limit: 25 });
+ expect(page.items).toEqual([]);
+ expect(page.total).toBe(0);
+ });
+});
diff --git a/src/app/(app)/search.tsx b/src/app/(app)/search.tsx
index a7d84ea..4c1673c 100644
--- a/src/app/(app)/search.tsx
+++ b/src/app/(app)/search.tsx
@@ -1,5 +1 @@
-import { Placeholder } from '@/ui/placeholder';
-
-export default function SearchRoute() {
- return ;
-}
+export { SearchScreen as default } from '@/search/search-screen';
diff --git a/src/search/search-screen.tsx b/src/search/search-screen.tsx
new file mode 100644
index 0000000..5cc8aa2
--- /dev/null
+++ b/src/search/search-screen.tsx
@@ -0,0 +1,219 @@
+import { useState } from 'react';
+import { FlatList, Pressable, StyleSheet, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { router } from 'expo-router';
+
+import { useSearchSearchGet } from '@/api/generated/endpoints/search/search';
+import type { SearchHit } from '@/api/generated/models';
+import { useTeams } from '@/team/team-context';
+import { href } from '@/ui/href';
+import { useIsMultiPane } from '@/ui/layout';
+import { AppText, Card, Field, Loading, TeamBadge } from '@/ui/primitives';
+import { Sheet } from '@/ui/sheet';
+import { useDebouncedValue } from '@/ui/use-debounced-value';
+import { useTokens } from '@/ui/theme';
+
+/**
+ * Search, per `docs/design/mobile/148-search.svg`.
+ *
+ * The query settles before it is sent, so typing a word does not cost a request
+ * per keystroke. Scope is all your teams or one of them, which is exactly the
+ * `team_id` parameter the endpoint takes -- ranking and matching stay on the
+ * server, so the result set is the web's.
+ */
+const DEBOUNCE_MS = 300;
+const PAGE = 25;
+
+/** Where the match was found, as something to read rather than a column name. */
+function matchedInLabel(hit: SearchHit): string {
+ switch (hit.matched_in) {
+ case 'title':
+ return 'Title';
+ case 'description':
+ return 'Description';
+ case 'comment':
+ return 'Comment';
+ default:
+ return hit.matched_in;
+ }
+}
+
+export function SearchScreen() {
+ const t = useTokens();
+ const multiPane = useIsMultiPane();
+ const { teams } = useTeams();
+
+ const [query, setQuery] = useState('');
+ const [scopeTeamId, setScopeTeamId] = useState(null);
+ const [scoping, setScoping] = useState(false);
+ const [limit, setLimit] = useState(PAGE);
+
+ const settled = useDebouncedValue(query.trim(), DEBOUNCE_MS);
+
+ const results = useSearchSearchGet(
+ { q: settled, team_id: scopeTeamId ?? undefined, limit, offset: 0 },
+ // A single character matches most of an instance and is never what anyone
+ // meant, so the request waits for a second one.
+ { query: { enabled: settled.length >= 2 } },
+ );
+
+ const hits = results.data?.items ?? [];
+ const total = results.data?.total ?? 0;
+ const scopeName = scopeTeamId
+ ? (teams.find((team) => team.id === scopeTeamId)?.name ?? 'One team')
+ : 'All teams';
+
+ return (
+
+
+ Search
+
+ setScoping(true)}
+ style={{
+ alignSelf: 'flex-start',
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ borderRadius: t.radius.pill,
+ backgroundColor: scopeTeamId ? t.line.navActive : t.line.ghost,
+ }}
+ >
+
+ {scopeName}
+
+
+
+
+ {settled.length < 2 ? (
+
+
+ Type at least two characters to search.
+
+
+ ) : results.isPending ? (
+
+ ) : (
+ String(hit.id)}
+ contentContainerStyle={{ padding: 16, gap: 10 }}
+ keyboardShouldPersistTaps="handled"
+ ListHeaderComponent={
+
+ {total} RESULT{total === 1 ? '' : 'S'}
+
+ }
+ ListEmptyComponent={
+
+ Nothing matches “{settled}”.
+
+ }
+ ListFooterComponent={
+ total > hits.length ? (
+ setLimit((current) => current + PAGE)}
+ style={{
+ paddingVertical: 12,
+ alignItems: 'center',
+ borderRadius: t.radius.control,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: t.line.hairline,
+ }}
+ >
+
+ Show more
+
+
+ ) : null
+ }
+ renderItem={({ item }) => (
+ router.push(href(`/issue/${item.id}`))}
+ >
+
+
+
+ {item.identifier}
+
+
+ {matchedInLabel(item)}
+
+
+
+ {item.title}
+
+
+ {/* The snippet is why this result is here, so it is worth the
+ line even when it repeats part of the title. */}
+ {item.snippet ? (
+
+ {item.snippet}
+
+ ) : null}
+
+
+ )}
+ />
+ )}
+
+ setScoping(false)} title="Search in">
+
+ {[{ id: null, name: 'All teams' }, ...teams].map((option) => {
+ const id = 'id' in option ? (option.id as number | null) : null;
+ const active = scopeTeamId === id;
+ return (
+ {
+ setScopeTeamId(id);
+ setScoping(false);
+ }}
+ style={{
+ paddingVertical: 12,
+ paddingHorizontal: 10,
+ borderRadius: t.radius.control,
+ backgroundColor: active ? t.line.navActive : 'transparent',
+ }}
+ >
+ {option.name}
+
+ );
+ })}
+
+
+
+ );
+}