Goal: make quick search work and feel good for every QQQ app (kof22 admin first). Search-as-you-type in the header box, the / dialog and the ⌘K palette, plus a full results page you can share.
Surfaces
Header box and / dialog share one combobox model (useQuickSearch) and one listbox (QuickSearchListbox). The / dialog is also the mobile entry, near full-screen below sm.
⌘K palette keeps its actions and navigation, and adds record groups (CommandRecordResults) plus "Search everything for …".
/app/search is a page whose state lives in the URL: q, tables=a,b, view=pages, sort=recent.
Interaction
Debounced at 200 ms. Superseded requests are aborted (TanStack Query signal passed to axios). The previous results stay on screen, dimmed, while the next ones load, so the list doesn't flash empty.
Groups, in order:
Top hits: exact or prefix label matches across pages and records, at most 3.
Pages: apps, tables, processes and reports.
One group per table, with its icon, a count (totalCount, else n+ when hasMore), and a "See all N matches in Table" entry.
Recently viewed records.
Search everything.
Before you type, the dropdown shows recent searches (localStorage, guarded) and recently viewed records.
Keyboard: ↑/↓ move, and the first result is preselected while typing, so Enter opens the top hit. Ctrl/⌘+Enter opens a new tab and keeps the search open. Esc closes, a second Esc clears.
Options are real links, so middle-click and Ctrl-click work. The combobox uses aria-activedescendant, groups are aria-labelledby their heading, and status is announced through a polite live region.
Each row shows:
the record label with the matched text highlighted;
a snippet of the matched field (Email: alice@…) or secondary values;
when the record was last updated (relative time);
its table, inside Top hits.
Honest states
Failure: the failure class is shown: server error (HTTP n), took too long, unreachable, permission denied, not available on this server, rejected (HTTP 4xx) or unexpected response. The backend's own message and a Retry (when retrying can help) come with it. Page and recently-viewed matches stay visible.
Provider fallback: when provider.status = FAILED, a "Search index unavailable — showing basic matches instead" notice shows failureClass: message above the fallback results.
No results: says what found nothing, suggests different words, and from a filtered view offers "Search everything instead".
Older backends: when the server sends no summaries, the one-table view says "Showing the first 25 matches" instead of pretending to page.
Results page
Filter chips (All, Pages, one per table, each with its count) and a Sort control (Best match, Recently updated).
The All view shows per-table sections with "Show all". The one-table view pages 25 at a time with Show more while the backend reports hasMore, and drops duplicates when offsets drift.
Result cards show highlighted snippets (up to 3 matched fields), details and the update date. Quick actions: open (the whole card), open in a new tab, copy link (with a toast).
Readable from 360 px to ultra-wide: max-w-5xl, filters scroll inside their own row, and the page has no second scrollbar (it doesn't touch the #54 scroll container).
No virtualization: lists are capped at 8 per table in the overview and grow 25 at a time. The cost of virtualization would outweigh its benefit here.
API contract (additive, backward compatible)
Request: optional offset and sort: "RECENT", sent only when they aren't the defaults. The body is unchanged for older servers.
Per result: optional score, updatedAt (ISO-8601), highlights[] {fieldName, fieldLabel, text, matches[{start,end}]} and details[] {fieldName, fieldLabel, displayValue}. Highlights are plain text plus match ranges, never HTML. They are rendered as text nodes and <mark>, and invalid ranges are dropped.
Every extension field is optional. A malformed extension field is dropped (Zod catch), and a malformed result is skipped with a warning instead of failing the search. Labels may be missing, null or empty, which is the #57 root cause: they fall back to {table} {id}.
Upstream: QRun-IO/qqq#1072 (core + v1 spec) and the qbit-quick-search provider PR implement the server side. This UI works against both old and new servers.
Bundle
The results panel, the palette record groups and the page live in on-demand chunks. The always-loaded shell gains only the inputs, search-url and searchableTables.
Measured with pnpm build:export + pnpm perf:budget, origin/main vs this branch:
Route
main
this PR
cap
/app/_
335.5 KB
336.6 KB
350
/app/_/_
342.3 KB
343.3 KB
350
/app/_/_/dev
355.0 KB
356.1 KB
360
/app/_/dev
351.0 KB
352.0 KB
360
/app/search
334.7 KB
345.3 KB
350
total JS
983.7 KB
1010.4 KB
1050
No cap raised. A first cut that bundled the panel statically measured +7.8 KB per route and put /app/_/_/dev 0.2 KB over its cap, which is why the panel is split out.
Screenshots
Mocked API, production build.
Header dropdown 1440
Header dropdown 1024
Header dropdown 1920
/ dialog 390 (mobile)
Search page 390
Search page 1024
Search page 1440
Search page 1920
Failure (HTTP 500)
Provider fallback
Owner follow-up: preview, completions, relevance
Record preview without leaving the results.
At ≥1280 px a sticky pane sits beside the list and follows hover, focus and ↑/↓. The arrow keys move between result cards, and the preview follows.
Below 1280 px, a Preview button (or a long press on touch screens) opens a side sheet.
The preview shows:
the record label and table;
quick actions: Open, New tab, Copy link, Edit (Edit only when canEditRecords);
Matched fields with highlights;
the record's first-tier fields, as on the record view, through FieldValue. FILE_PREVIEW thumbnails come through that path, and heavy fields and the primary key are left out.
It is an on-demand chunk (RecordPreview), so it adds nothing to first load.
Relevance meter on cards when the provider sends scores, scaled to the best result in the table: Strong, Good or Partial match.
Autocomplete completions in the header box and the / dialog: a "Suggestions" group completes the last typed word from matching page and record labels, most frequent first. Completions are never preselected, so Enter still opens the top hit. Shift+Enter opens the results page. Ctrl/⌘+Enter still opens the selected result in a new tab.
Bundle, measured after merging main (incl. #62/#63):
/app/search 347.4 / 350 KB (the Radix dialog for the sheet)
/app/_/_/dev 355.8 / 360 KB
/app/_/_ 343.1 / 350 KB
total 1043.9 / 1050 KB
No cap raised.
Preview 1440
Preview 1920
Preview 3200
Preview sheet 360
Preview sheet 390
Suggestions 1440
New tests
Unit:
termCompletions.
GlobalSearch: suggestions and Shift+Enter.
SearchPage: the split preview (T1 fields, no heavy fields, Edit link, relevance, ↑/↓, hover) and the narrow-screen sheet.
Review follow-ups: no stale or hidden pick on Enter, the moved-flag reset, and the late URL echo.
Playwright: the split preview with arrow keys and no horizontal overflow; the phone sheet. Full mocked suite locally: 124 passed.
Real-backend acceptance specs updated for the new UI, all 4 browsers green on 2f745d5.
Tests
Unit (Vitest):
record-search API: body compatibility, abort signal, the extended response, invalid ranges, the unlabeled-result regression from kof22, malformed results.
Search library: highlighting, every failure class, provider fallback, recent searches.
search-model: match quality, ordering, grouping, counts, URL state, quick-search groups.
GlobalSearch: top hits, highlights, Ctrl+Enter, recents, failure and retry, fallback.
CommandMenu record groups, SearchDialog focus trap, SearchPage: URL state, paging, the older-backend note, sort, failure and permission, fallback, empty state, copy link.
The existing global-search spec is updated for preselection. The full mocked suite passes locally against pnpm build: 122 passed.
Pre-existing failures on Windows, unrelated:tests/standalone/acceptance-stage and standalone-variant fail on untouched main too.
Coordination
Builds on the record-search fix in #57: the same per-result tolerance, reimplemented in the new lib/api/record-search.ts. When #57 merges I'll merge main and resolve in favor of this module.
Detail chips: result cards show the backend's details (up to 3 search fields outside the label, from QQQ core #1072) as label/value chips, not a run of text. Screen readers still hear "Label: value".
Phones: below sm a card keeps only its Preview button. New tab and Copy link are in the preview sheet, so the snippet gets the width and no longer breaks mid-word at 390 px.
Gates, local:
typecheck and lint pass;
unit tests: 2344 passed. The 2 Windows-only tests/standalone failures also fail on main;
mocked e2e (chromium, production build): 124 passed;
search and global-search specs on WebKit: 20 passed;
WebKit, pre-existing:keyboard-shortcuts "'/' or '?' does not open … when an input is focused" fails in local WebKit on main (89add47) as well, because the table quick filter re-renders. Not touched here.
Light
Dark
Results + preview 1440
Autocomplete 1440
Results 390
Preview sheet 390
/ dialog 390
Fix: "nothing selected" survives late records (5b4ef76)
Bug: ArrowUp above the first quick-search option clears the selection, so Enter opens the results page. When record results arrived after that keypress (the options changed and the stale flag flipped), the first option was preselected again, so Enter opened a hit instead.
Fix: an explicit "nothing selected" now holds until the term changes, like any other choice.
Found by: NAV-025 in WebKit acceptance, where the records answer after the keypress.
Test: a new GlobalSearch unit test fails without the fix and passes with it.
The merged tree first measured 1067.0 KB total JS, over the 1050 KB cap.
Changes
/app/search loads SearchPage through import(), so the page is in no route's first load.
The record preview renders values itself: thumbnails, chips, Yes/No, dates, links and text. FieldValue's widget imports had pulled +17.1 KB of re-bundled chunks into the preview. Secrets (REVEAL), widgets, code, HTML and binary fields are not previewed.
The record-search schemas moved from the shared schemas.ts to record-search.ts.
perf-budget.json budgets the four on-demand search chunks under lazyChunks, at measured size with caps about 8% above:
the preview with useRecord/useTableMetaData: +12.4 KB;
file thumbnails and chips: +9.3 KB.
Turbopack re-bundles those shared modules into more route-screen chunks.
Fix: the preview runs its own two queries, with the same query keys so the caches are shared, and renders values as text, Yes/No and links. Thumbnails and chips stay on the record view.
Result: total JS 1042.7 KB (cap 1050, equal to main); CSS 23.0 KB (cap 23); no cap raised.
Lazy chunks: search page 13.6, quick search panel 9.7, palette record groups 7.8, preview 3.7 KB gzip.
Merge conflicts: the command palette keeps #60's Ask Assistant entry (last) and Cmd+J with the search groups.
## Design
**Goal:** make quick search work and feel good for every QQQ app (kof22 admin first). Search-as-you-type in the header box, the `/` dialog and the ⌘K palette, plus a full results page you can share.
**Surfaces**
- **Header box and `/` dialog** share one combobox model (`useQuickSearch`) and one listbox (`QuickSearchListbox`). The `/` dialog is also the mobile entry, near full-screen below `sm`.
- **⌘K palette** keeps its actions and navigation, and adds record groups (`CommandRecordResults`) plus "Search everything for …".
- **`/app/search`** is a page whose state lives in the URL: `q`, `tables=a,b`, `view=pages`, `sort=recent`.
**Interaction**
- Debounced at 200 ms. Superseded requests are **aborted** (TanStack Query `signal` passed to axios). The previous results stay on screen, dimmed, while the next ones load, so the list doesn't flash empty.
- Groups, in order:
- **Top hits**: exact or prefix label matches across pages and records, at most 3.
- **Pages**: apps, tables, processes and reports.
- **One group per table**, with its icon, a count (`totalCount`, else `n+` when `hasMore`), and a "See all N matches in Table" entry.
- **Recently viewed** records.
- **Search everything**.
- Before you type, the dropdown shows **recent searches** (localStorage, guarded) and **recently viewed** records.
- Keyboard: ↑/↓ move, and the first result is preselected while typing, so Enter opens the top hit. **Ctrl/⌘+Enter opens a new tab** and keeps the search open. Esc closes, a second Esc clears.
- Options are real links, so middle-click and Ctrl-click work. The combobox uses `aria-activedescendant`, groups are `aria-labelledby` their heading, and status is announced through a polite live region.
- Each row shows:
- the record label with the matched text highlighted;
- a snippet of the matched field (`Email: alice@…`) or secondary values;
- when the record was last updated (relative time);
- its table, inside Top hits.
**Honest states**
- **Failure:** the failure class is shown: server error (HTTP n), took too long, unreachable, permission denied, not available on this server, rejected (HTTP 4xx) or unexpected response. The backend's own message and a Retry (when retrying can help) come with it. Page and recently-viewed matches stay visible.
- **Provider fallback:** when `provider.status = FAILED`, a "Search index unavailable — showing basic matches instead" notice shows `failureClass: message` above the fallback results.
- **No results:** says what found nothing, suggests different words, and from a filtered view offers "Search everything instead".
- **Older backends:** when the server sends no summaries, the one-table view says "Showing the first 25 matches" instead of pretending to page.
**Results page**
- Filter chips (All, Pages, one per table, each with its count) and a Sort control (Best match, Recently updated).
- The All view shows per-table sections with "Show all". The one-table view pages 25 at a time with **Show more** while the backend reports `hasMore`, and drops duplicates when offsets drift.
- Result cards show highlighted snippets (up to 3 matched fields), details and the update date. Quick actions: open (the whole card), open in a new tab, copy link (with a toast).
- Readable from 360 px to ultra-wide: `max-w-5xl`, filters scroll inside their own row, and the page has no second scrollbar (it doesn't touch the #54 scroll container).
- No virtualization: lists are capped at 8 per table in the overview and grow 25 at a time. The cost of virtualization would outweigh its benefit here.
**API contract (additive, backward compatible)**
- **Request:** optional `offset` and `sort: "RECENT"`, sent only when they aren't the defaults. The body is unchanged for older servers.
- **Per result:** optional `score`, `updatedAt` (ISO-8601), `highlights[] {fieldName, fieldLabel, text, matches[{start,end}]}` and `details[] {fieldName, fieldLabel, displayValue}`. Highlights are **plain text plus match ranges, never HTML**. They are rendered as text nodes and `<mark>`, and invalid ranges are dropped.
- **Response:** optional `tables[] {tableName, tableLabel, resultCount, hasMore, totalCount, source: PROVIDER|DEFAULT|FALLBACK}` and `provider {status: OK|FAILED|NONE, failureClass, message}`.
- Every extension field is optional. A malformed extension field is dropped (Zod `catch`), and a malformed result is skipped with a warning instead of failing the search. Labels may be missing, null or empty, which is the #57 root cause: they fall back to `{table} {id}`.
- Upstream: QRun-IO/qqq#1072 (core + v1 spec) and the qbit-quick-search provider PR implement the server side. This UI works against both old and new servers.
**Bundle**
- The results panel, the palette record groups and the page live in **on-demand chunks**. The always-loaded shell gains only the inputs, `search-url` and `searchableTables`.
- Measured with `pnpm build:export` + `pnpm perf:budget`, origin/main vs this branch:
| Route | main | this PR | cap |
|---|---|---|---|
| `/app/_` | 335.5 KB | 336.6 KB | 350 |
| `/app/_/_` | 342.3 KB | 343.3 KB | 350 |
| `/app/_/_/dev` | 355.0 KB | 356.1 KB | 360 |
| `/app/_/dev` | 351.0 KB | 352.0 KB | 360 |
| `/app/search` | 334.7 KB | 345.3 KB | 350 |
| total JS | 983.7 KB | 1010.4 KB | 1050 |
No cap raised. A first cut that bundled the panel statically measured +7.8 KB per route and put `/app/_/_/dev` 0.2 KB over its cap, which is why the panel is split out.
## Screenshots
Mocked API, production build.
| | |
|---|---|
| Header dropdown 1440 |  |
| Header dropdown 1024 |  |
| Header dropdown 1920 |  |
| `/` dialog 390 (mobile) |  |
| Search page 390 |  |
| Search page 1024 |  |
| Search page 1440 |  |
| Search page 1920 |  |
| Failure (HTTP 500) |  |
| Provider fallback |  |
## Owner follow-up: preview, completions, relevance
- **Record preview without leaving the results.**
- At ≥1280 px a sticky pane sits beside the list and follows hover, focus and **↑/↓**. The arrow keys move between result cards, and the preview follows.
- Below 1280 px, a **Preview** button (or a long press on touch screens) opens a side sheet.
- The preview shows:
- the record label and table;
- quick actions: **Open, New tab, Copy link, Edit** (Edit only when `canEditRecords`);
- **Matched** fields with highlights;
- the record's **first-tier fields**, as on the record view, through `FieldValue`. FILE_PREVIEW thumbnails come through that path, and heavy fields and the primary key are left out.
- It is an on-demand chunk (`RecordPreview`), so it adds nothing to first load.
- **Relevance meter** on cards when the provider sends scores, scaled to the best result in the table: Strong, Good or Partial match.
- **Autocomplete completions** in the header box and the `/` dialog: a "Suggestions" group completes the last typed word from matching page and record labels, most frequent first. Completions are never preselected, so Enter still opens the top hit. **Shift+Enter** opens the results page. Ctrl/⌘+Enter still opens the selected result in a new tab.
- **Bundle, measured after merging main (incl. #62/#63):**
- `/app/search` 347.4 / 350 KB (the Radix dialog for the sheet)
- `/app/_/_/dev` 355.8 / 360 KB
- `/app/_/_` 343.1 / 350 KB
- total 1043.9 / 1050 KB
- No cap raised.
| | |
|---|---|
| Preview 1440 |  |
| Preview 1920 |  |
| Preview 3200 |  |
| Preview sheet 360 |  |
| Preview sheet 390 |  |
| Suggestions 1440 |  |
**New tests**
- **Unit:**
- `termCompletions`.
- GlobalSearch: suggestions and Shift+Enter.
- SearchPage: the split preview (T1 fields, no heavy fields, Edit link, relevance, ↑/↓, hover) and the narrow-screen sheet.
- Review follow-ups: no stale or hidden pick on Enter, the moved-flag reset, and the late URL echo.
- **Playwright:** the split preview with arrow keys and no horizontal overflow; the phone sheet. Full mocked suite locally: 124 passed.
- **Real-backend acceptance specs** updated for the new UI, all 4 browsers green on 2f745d5.
## Tests
- **Unit (Vitest):**
- `record-search` API: body compatibility, abort signal, the extended response, invalid ranges, the unlabeled-result regression from kof22, malformed results.
- Search library: highlighting, every failure class, provider fallback, recent searches.
- `search-model`: match quality, ordering, grouping, counts, URL state, quick-search groups.
- GlobalSearch: top hits, highlights, Ctrl+Enter, recents, failure and retry, fallback.
- CommandMenu record groups, SearchDialog focus trap, SearchPage: URL state, paging, the older-backend note, sort, failure and permission, fallback, empty state, copy link.
- **Playwright (mocked API):** `tests/e2e/record-search.spec.ts`
- highlights, counts and the top hit on Enter;
- arrows and `aria-activedescendant`;
- Ctrl+Enter opens a new tab;
- HTTP 500 failure class;
- provider fallback;
- superseded requests;
- axe on the dropdown and the page;
- `/` dialog keyboard;
- page filters, Show more and sort;
- no horizontal overflow at 360 px.
The existing global-search spec is updated for preselection. The full mocked suite passes locally against `pnpm build`: 122 passed.
- **Pre-existing failures on Windows, unrelated:** `tests/standalone/acceptance-stage` and `standalone-variant` fail on untouched main too.
## Coordination
- Builds on the record-search fix in #57: the same per-result tolerance, reimplemented in the new `lib/api/record-search.ts`. When #57 merges I'll merge main and resolve in favor of this module.
## Detail chips and phone cards (6c1e3a6)
- **Detail chips:** result cards show the backend's `details` (up to 3 search fields outside the label, from QQQ core #1072) as label/value chips, not a run of text. Screen readers still hear "Label: value".
- **Phones:** below `sm` a card keeps only its **Preview** button. New tab and Copy link are in the preview sheet, so the snippet gets the width and no longer breaks mid-word at 390 px.
- **Gates, local:**
- typecheck and lint pass;
- unit tests: 2344 passed. The 2 Windows-only `tests/standalone` failures also fail on main;
- mocked e2e (chromium, production build): 124 passed;
- search and global-search specs on WebKit: 20 passed;
- Storybook builds;
- perf budget OK: `/app/search` 347.4 / 350 KB, total 1043.9 / 1050 KB, unchanged.
- **WebKit, pre-existing:** `keyboard-shortcuts` "'/' or '?' does not open … when an input is focused" fails in local WebKit on main (89add47) as well, because the table quick filter re-renders. Not touched here.
| | Light | Dark |
|---|---|---|
| Results + preview 1440 |  |  |
| Autocomplete 1440 |  |  |
| Results 390 |  |  |
| Preview sheet 390 |  |  |
| `/` dialog 390 |  |  |
## Fix: "nothing selected" survives late records (5b4ef76)
- **Bug:** ArrowUp above the first quick-search option clears the selection, so Enter opens the results page. When record results arrived after that keypress (the options changed and the stale flag flipped), the first option was preselected again, so Enter opened a hit instead.
- **Fix:** an explicit "nothing selected" now holds until the term changes, like any other choice.
- **Found by:** NAV-025 in WebKit acceptance, where the records answer after the keypress.
- **Test:** a new GlobalSearch unit test fails without the fix and passes with it.
## Bundle after merging main a724fc1 (ddc66c6)
The merged tree first measured 1067.0 KB total JS, over the 1050 KB cap.
**Changes**
- `/app/search` loads `SearchPage` through `import()`, so the page is in no route's first load.
- The record preview renders values itself: thumbnails, chips, Yes/No, dates, links and text. `FieldValue`'s widget imports had pulled +17.1 KB of re-bundled chunks into the preview. Secrets (REVEAL), widgets, code, HTML and binary fields are not previewed.
- The record-search schemas moved from the shared `schemas.ts` to `record-search.ts`.
- `perf-budget.json` budgets the four on-demand search chunks under `lazyChunks`, at measured size with caps about 8% above:
- search page 13.6 KB;
- quick search panel 9.7 KB;
- palette record groups 7.8 KB;
- record preview 7.3 KB.
**Measured** (static export, `perf-budget.mjs`):
| | main a724fc1 | #59 ddc66c6 |
|---|---|---|
| Total JS | 1005.4 KB | 1012.7 KB (+7.3) |
| CSS | 21.5 KB | 21.9 KB (+0.4) |
| `/app/search` first load | 335.2 KB | 334.5 KB |
| `/app/_/_/dev` first load | 356.2 KB | 356.7 KB |
- Every other route is within 0.5 KB of main, and no cap is raised.
- The remaining +7 KB is Turbopack re-partitioning on-demand route-screen chunks. The first-load chunks match main.
## Bundle after merging main b32a29d (#60, #61, #78), 3508c50
- **Main b32a29d:** total JS 1042.7 KB, CSS 22.6 KB.
- **Merged tree, first measurement:** 1065.5 KB.
- **Bisect, total JS:**
- without the record preview: 1042.5 KB;
- the preview with `useRecord`/`useTableMetaData`: +12.4 KB;
- file thumbnails and chips: +9.3 KB.
- Turbopack re-bundles those shared modules into more route-screen chunks.
- **Fix:** the preview runs its own two queries, with the same query keys so the caches are shared, and renders values as text, Yes/No and links. Thumbnails and chips stay on the record view.
- **Result:** total JS 1042.7 KB (cap 1050, equal to main); CSS 23.0 KB (cap 23); no cap raised.
- **Lazy chunks:** search page 13.6, quick search panel 9.7, palette record groups 7.8, preview 3.7 KB gzip.
- **Merge conflicts:** the command palette keeps #60's Ask Assistant entry (last) and Cmd+J with the search groups.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Design
Goal: make quick search work and feel good for every QQQ app (kof22 admin first). Search-as-you-type in the header box, the
/dialog and the ⌘K palette, plus a full results page you can share.Surfaces
/dialog share one combobox model (useQuickSearch) and one listbox (QuickSearchListbox). The/dialog is also the mobile entry, near full-screen belowsm.CommandRecordResults) plus "Search everything for …"./app/searchis a page whose state lives in the URL:q,tables=a,b,view=pages,sort=recent.Interaction
signalpassed to axios). The previous results stay on screen, dimmed, while the next ones load, so the list doesn't flash empty.totalCount, elsen+whenhasMore), and a "See all N matches in Table" entry.aria-activedescendant, groups arearia-labelledbytheir heading, and status is announced through a polite live region.Email: alice@…) or secondary values;Honest states
provider.status = FAILED, a "Search index unavailable — showing basic matches instead" notice showsfailureClass: messageabove the fallback results.Results page
hasMore, and drops duplicates when offsets drift.max-w-5xl, filters scroll inside their own row, and the page has no second scrollbar (it doesn't touch the #54 scroll container).API contract (additive, backward compatible)
offsetandsort: "RECENT", sent only when they aren't the defaults. The body is unchanged for older servers.score,updatedAt(ISO-8601),highlights[] {fieldName, fieldLabel, text, matches[{start,end}]}anddetails[] {fieldName, fieldLabel, displayValue}. Highlights are plain text plus match ranges, never HTML. They are rendered as text nodes and<mark>, and invalid ranges are dropped.tables[] {tableName, tableLabel, resultCount, hasMore, totalCount, source: PROVIDER|DEFAULT|FALLBACK}andprovider {status: OK|FAILED|NONE, failureClass, message}.catch), and a malformed result is skipped with a warning instead of failing the search. Labels may be missing, null or empty, which is the #57 root cause: they fall back to{table} {id}.Bundle
search-urlandsearchableTables.pnpm build:export+pnpm perf:budget, origin/main vs this branch:/app/_/app/_/_/app/_/_/dev/app/_/dev/app/searchNo cap raised. A first cut that bundled the panel statically measured +7.8 KB per route and put
/app/_/_/dev0.2 KB over its cap, which is why the panel is split out.Screenshots
Mocked API, production build.
/dialog 390 (mobile)Owner follow-up: preview, completions, relevance
canEditRecords);FieldValue. FILE_PREVIEW thumbnails come through that path, and heavy fields and the primary key are left out.RecordPreview), so it adds nothing to first load./dialog: a "Suggestions" group completes the last typed word from matching page and record labels, most frequent first. Completions are never preselected, so Enter still opens the top hit. Shift+Enter opens the results page. Ctrl/⌘+Enter still opens the selected result in a new tab./app/search347.4 / 350 KB (the Radix dialog for the sheet)/app/_/_/dev355.8 / 360 KB/app/_/_343.1 / 350 KBNew tests
termCompletions.2f745d5.Tests
Unit (Vitest):
record-searchAPI: body compatibility, abort signal, the extended response, invalid ranges, the unlabeled-result regression from kof22, malformed results.search-model: match quality, ordering, grouping, counts, URL state, quick-search groups.Playwright (mocked API):
tests/e2e/record-search.spec.tsaria-activedescendant;/dialog keyboard;The existing global-search spec is updated for preselection. The full mocked suite passes locally against
pnpm build: 122 passed.Pre-existing failures on Windows, unrelated:
tests/standalone/acceptance-stageandstandalone-variantfail on untouched main too.Coordination
lib/api/record-search.ts. When #57 merges I'll merge main and resolve in favor of this module.Detail chips and phone cards (
6c1e3a6)details(up to 3 search fields outside the label, from QQQ core #1072) as label/value chips, not a run of text. Screen readers still hear "Label: value".sma card keeps only its Preview button. New tab and Copy link are in the preview sheet, so the snippet gets the width and no longer breaks mid-word at 390 px.tests/standalonefailures also fail on main;/app/search347.4 / 350 KB, total 1043.9 / 1050 KB, unchanged.keyboard-shortcuts"'/' or '?' does not open … when an input is focused" fails in local WebKit on main (89add47) as well, because the table quick filter re-renders. Not touched here./dialog 390Fix: "nothing selected" survives late records (
5b4ef76)Bundle after merging main
a724fc1(ddc66c6)The merged tree first measured 1067.0 KB total JS, over the 1050 KB cap.
Changes
/app/searchloadsSearchPagethroughimport(), so the page is in no route's first load.FieldValue's widget imports had pulled +17.1 KB of re-bundled chunks into the preview. Secrets (REVEAL), widgets, code, HTML and binary fields are not previewed.schemas.tstorecord-search.ts.perf-budget.jsonbudgets the four on-demand search chunks underlazyChunks, at measured size with caps about 8% above:Measured (static export,
perf-budget.mjs):a724fc1ddc66c6/app/searchfirst load/app/_/_/devfirst loadBundle after merging main
b32a29d(#60, #61, #78),3508c50b32a29d: total JS 1042.7 KB, CSS 22.6 KB.useRecord/useTableMetaData: +12.4 KB;🤖 Generated with Claude Code