Name the search query in the links empty state - #55
Conversation
Issue #52 reported a search with no matches rendering "No links yet. Use the + New Link button above to get started." on a catalog holding 200 links. That half is already gone: 4e5c81f gave the service a three-value emptyReason, so a search that matches nothing now resolves to no-matches rather than falling through to the first-run copy. What survived is the other half of the report. no-matches carries two different claims. A filter chip selected none of the catalog, or a search matched none of it, and both rendered "No links match the current filter." Searching zzz blamed a filter the user never touched and never said which query came back empty. The service cannot tell the two apart in copy, since it hands back one reason and the page owns the query. So the split lands in the page: emptyStateCopy takes the reason and the query, and picks links.noSearchMatches with the query interpolated whenever a search is what emptied the window. Pulling the choice out of the JSX also stops a fourth arm from growing the ternary another level. links.noSearchMatches goes into en.ts, id.ts and sv.ts. Translations is typed off en, so the other two locales cannot lag behind it. The query reaches the copy as a t() parameter and hono/jsx escapes string children, so markup in the search box renders as text. A test pins that rather than trusting it.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
shrtnr | 0ae8233 | Aug 27 2026, 12:17 AM |
Review of #55 turned up an empty-state case the first commit left standing. A search of nothing but spaces reaches the repository, which treats a query that trims to empty as matching nothing rather than letting a bare LIKE "%%" match every row. The window comes back empty while the catalog is full, and the reason is then picked from the filter alone: under the default Active chip that renders "All links are disabled. Pick the Disabled filter to see them." over a catalog where nothing is disabled, and under filter=all it renders the blame-the-filter wording this branch set out to remove. The route now trims the query before anything reads it, so a run of spaces is no search at all and the listing renders its rows. Trimming at the route also collapses the two places that tested search.trim(): the service still decides all-disabled vs no-matches, but it can no longer see a query the page would call empty. emptyStateCopy grows an exhaustive switch. The old negative test (emptyReason !== "no-matches") mapped anything unrecognised onto the first-run copy, so a fourth reason would have shipped "No links yet" over a full catalog with no build error. A never-typed default fails the build instead; adding a member to the union was checked to confirm it does. The query is user input pasted into a centred one-paragraph block, so it clips at 60 characters. Escaping already made it safe, this keeps it readable. emptyStateCopy is exported and unit tested alongside pageWindow and paginationItems, which is where the whitespace and long-query cases are cheap to cover. The page tests keep the rendered-HTML cases and now assert the whole sentence rather than the query substring, and they use the seed() helper the enclosing describe already provides. Placeholder parity gets a guard. Translations is typed off en, so a missing key fails the build, but nothing typed the {query} inside a value: a locale could keep the key and drop the interpolation the key exists for. Dropping it from id.ts was checked to confirm the new test fails.
|
Self-review pass over this branch. Ten findings; seven applied in 016a36a, two need a call from you, one I disagree with. AppliedWhitespace-only search rendered a false claim. Non-exhaustive reason test. No placeholder parity guard. The PR body claimed Unbounded query length. The query now clips at 60 characters with an ellipsis. Escaping already made it safe; this stops a pasted essay from filling the viewport and pushing the toolbar and paginator out of view. Helper not exported. Weak headline assertion. Duplicated seeding. The three page tests use the Needs your call1. Search plus a narrowing filter blames only the query. 2. The split arguably belongs in the service. DisagreeFold the filter-regression test into the existing one. Adding the VerificationThe |
Two decisions from the review of #55. The service now owns the split. LinksEmptyReason gains no-search-matches, and listLinksPage raises it wherever the trimmed query is what emptied the window. The page had been re-deriving "did a search happen" from the raw query, one layer above the trim that already answered it, so the two could disagree. The page is now handed the answer. A live search outranks the status filter when picking the reason. The query is the specific thing the copy can name back, and it is the more likely culprit. Everything else is unchanged: an empty catalog is still no-links, an active filter over an expired catalog still all-disabled, and a status filter that selected nothing still no-matches. This renames the assertion in "reports no-matches when a search finds nothing in a populated catalog", which is an existing test. CLAUDE.md forbids editing one to fit a code change, so the conflict went to Dennis first and the rename is deliberate: the value it named no longer describes that case. The copy names both narrowing dimensions. Searching one under the Disabled chip rendered `No links match "one".` while a link matching one sat a chip away, so the branch had swapped blaming the filter for blaming the query. Under active or disabled the copy now names the chip too, and under all, where nothing but the search is hiding rows, it stays as it was. The chip table moves to module scope. The empty state and the chips read their labels from one place, so the filter a user clicks and the filter the copy names cannot drift.
|
Both open decisions are settled and pushed in c8fbe92. Copy names both narrowing dimensions
Under
Split moved into the service
This renamed the assertion in VerificationThe exhaustiveness guard was re-checked after the union grew: adding a
|
…el arm Two findings from a fresh read of this branch. The clip cut UTF-16 units. A query long enough to trim, with its emoji sitting on odd offsets, lost half a surrogate pair at the boundary and the response carried an unpaired surrogate that a browser draws as U+FFFD. Spreading the string first counts and cuts code points instead. The first attempt at the test picked an all-emoji query, where every pair straddles the 60th unit evenly and a naive slice gets away with it, so the test passed against the bug. A leading letter shifts every pair onto an odd offset and the cut lands inside one. The test was checked against the old slice to confirm it fails there. Naming the filter went through FILTER_CHIPS.find(), which returns undefined for a key that is not in the list. LinksFilter has three members and the list covers all three, so the not-found arm could never run and existed only to satisfy the type. A Record keyed by LinksFilter is total: no fallback, and a filter added to the union fails the build here rather than falling back to its raw key. The chips now read their labels from the same table.
|
Fresh read of the whole branch, treating it as someone else's work. Two findings, both fixed in 0ae8233. Two more checked and dismissed. FixedThe clip cut UTF-16 units, not characters. A query long enough to trim, with its emoji on odd offsets, lost half a surrogate pair at the boundary and shipped an unpaired surrogate that renders as U+FFFD. Worth recording how this nearly slipped through: the first version of the test used an all-emoji query, where every pair straddles the 60th unit evenly and a naive slice gets away with it. It passed against the bug. A leading letter shifts every pair onto an odd offset and the cut lands inside one: The corrected test was run against the old slice to confirm it fails there before the fix went in. The filter label had an unreachable arm. Checked and dismissedAdding a member to A search over a fully expired catalog now reports the search rather than Verification |
Closes #52.
What was already fixed
The report says a search with no matches renders
No links yet. Use the + New Link button above to get started.on a catalog holding 200 links. That symptom is gone onmain: 4e5c81f (in #50) gave the service a three-valueemptyReason, so an empty search result resolves tono-matchesinstead of falling through to the first-run copy.What was left
no-matchescarries two different claims. Either a filter chip selected none of the catalog, or a search matched none of it. Both rendered the same string:Searching
zzzblamed a filter the user never touched, and never named the query that came back empty. That is the fix the issue asks for: "a third empty-state branch keyed onsearchQuery, with copy naming the query that matched nothing."The change
The service hands back one reason and the page owns the query, so the split lands in the page.
emptyStateCopy(src/pages/links.tsx:124) takes the reason and the query and pickslinks.noSearchMatcheswhenever a search is what emptied the window. Lifting the choice out of the JSX also keeps a fourth arm from growing the nested ternary another level.New key in all three locales, per the CLAUDE.md i18n rule:
links.noSearchMatchesNo links match "{query}".Tidak ada tautan yang cocok dengan "{query}".Inga länkar matchar "{query}".Translationsis typed offen, soidandsvcannot lag behind the key.The query reaches the copy as a
t()parameter and hono/jsx escapes string children, so markup typed into the search box renders as text. A test pins that rather than trusting it.Tests
Three added to
src/__tests__/page/links-page.test.ts, all watched failing first:names the query in the empty state when a search matched nothingasserts the query appears and neithercurrent filternorNo links yetdoes.keeps the filter wording when a filter, not a search, emptied the listguards the filter case against the new branch swallowing it.escapes markup in a search query before naming it in the empty stateasserts<b>zzz</b>and no raw tag.No existing test was modified or removed.
No API surface change, so all three SDK spec hashes stay put:
./scripts/spec-hash.shstill returnsd4cf1923…deb956, matching what the manifests record.🤖 Generated with Claude Code