AddItemsDialog.runSearch was reachable with an empty query two ways — a blank form submit, and a kind-chip click which called it immediately — and getLibraryBrowseItems omits a falsy query. So each path degraded into an unfiltered browse of the whole media-library type (first 50 rows, per kind) presented as the answer, with nothing surfacing the truncation. merged.slice(0, 50) separately dropped up to 100 of 150 fetched rows even for a real query.
All ten ADDABLE_TYPE_LIST entries are spa-conventions.md §3b Class B media-library types.
Shape of the fix
The dialog is multi-select (checkbox rows, many items added at once, fanned out over several kinds), so §3b's SearchPicker — single-select — does not fit. It takes §3b's constraints instead, and they live in a shared helper rather than at the call site:
New searchLibraryBrowseItems (web/src/api/libraryBrowse.ts), a sibling of searchLibraryPickerOptions, owns the LIBRARY_PICKER_MIN_QUERY gate, the LIBRARY_PICKER_RESULTS clamp and the titleContainsQuery compile. It returns full LibraryBrowseItem rows plus totalCount (the {id, name} shape loses the mediaType that toAddItemsRequest needs).
Raw text is no longer forwarded as Lucene — a second latent §3b violation on this screen, since the index's default field does not match bare title words.
merged.slice(0, 50) is removed rather than hinted at; the per-kind cap that can still truncate is surfaced as Showing N of M matches.
Results carry a Results for "<query>" label keyed to the settled query, so rows are never shown without saying which search produced them.
Both ingresses into the searched kinds (MediaKindFilter, DEFAULT_SEARCH_KINDS) are derived from ADDABLE_TYPE_LIST, making the hint's pre-filter/post-filter arithmetic a compile-time property.
Two corrections to the issue body
It says ADDABLE_TYPES includes collection-family types needing a truncation hint. It does not — all ten entries are Class B media types. Done-when box 4 has no subject on this screen.
It prescribes rendering SearchPicker, which is single-select and cannot serve this dialog.
Review
Four independent adversarial review rounds (cold brief, isolated worktree). Round 2 returned BLOCKED: the helper's bound was dead code to the suite — deleting its clamp left 1119/1119 green while the registry note claimed callers "cannot skip the bound" — and a duplicated gate in the screen masked the helper's, so the boundary test pinned nothing. Both fixed and re-verified by mutation. Rounds 4 and 5 confirmed no new defects.
Every gate and guard here has at least one test that reddens when that gate alone is removed; each was verified by mutating the single clause.
Verification
npm run typecheck clean · npm run lint clean · 1199/1199 tests green, on current origin/main.
No live-E2E: pure-SPA change, no API or server-state effect.
Docs
docs/spa-conventions.md §3b — records that multi-select pickers satisfy §3b without SearchPicker, corrects a parent paragraph that contradicted the new hint, and carries the lesson that a gate's home is the shared helper however single-sink the screen's own function looks.
Follow-up filed: #740 (pre-existing missing seqRef/useIsMountedRef guards on this screen, explicitly not introduced here).
fixes #685
`AddItemsDialog.runSearch` was reachable with an **empty query** two ways — a blank form submit, and a kind-chip click which called it immediately — and `getLibraryBrowseItems` omits a falsy `query`. So each path degraded into an unfiltered browse of the whole media-library type (first 50 rows, per kind) presented as the answer, with nothing surfacing the truncation. `merged.slice(0, 50)` separately dropped up to 100 of 150 fetched rows even for a real query.
All ten `ADDABLE_TYPE_LIST` entries are `spa-conventions.md` §3b **Class B** media-library types.
## Shape of the fix
The dialog is **multi-select** (checkbox rows, many items added at once, fanned out over several kinds), so §3b's `SearchPicker` — single-select — does not fit. It takes §3b's *constraints* instead, and they live in a shared helper rather than at the call site:
- New `searchLibraryBrowseItems` (`web/src/api/libraryBrowse.ts`), a sibling of `searchLibraryPickerOptions`, owns the `LIBRARY_PICKER_MIN_QUERY` gate, the `LIBRARY_PICKER_RESULTS` clamp and the `titleContainsQuery` compile. It returns full `LibraryBrowseItem` rows plus `totalCount` (the `{id, name}` shape loses the `mediaType` that `toAddItemsRequest` needs).
- Raw text is no longer forwarded as Lucene — a second latent §3b violation on this screen, since the index's default field does not match bare title words.
- `merged.slice(0, 50)` is removed rather than hinted at; the per-kind cap that *can* still truncate is surfaced as `Showing N of M matches`.
- Results carry a `Results for "<query>"` label keyed to the settled query, so rows are never shown without saying which search produced them.
- Both ingresses into the searched kinds (`MediaKindFilter`, `DEFAULT_SEARCH_KINDS`) are derived from `ADDABLE_TYPE_LIST`, making the hint's pre-filter/post-filter arithmetic a compile-time property.
## Two corrections to the issue body
1. It says `ADDABLE_TYPES` includes collection-family types needing a truncation hint. It does not — all ten entries are Class B media types. **Done-when box 4 has no subject on this screen.**
2. It prescribes rendering `SearchPicker`, which is single-select and cannot serve this dialog.
## Review
Four independent adversarial review rounds (cold brief, isolated worktree). Round 2 returned **BLOCKED**: the helper's bound was dead code to the suite — deleting its clamp left 1119/1119 green while the registry note claimed callers "cannot skip the bound" — and a duplicated gate in the screen masked the helper's, so the boundary test pinned nothing. Both fixed and re-verified by mutation. Rounds 4 and 5 confirmed no new defects.
Every gate and guard here has at least one test that reddens when that gate **alone** is removed; each was verified by mutating the single clause.
## Verification
`npm run typecheck` clean · `npm run lint` clean · **1199/1199** tests green, on current `origin/main`.
No live-E2E: pure-SPA change, no API or server-state effect.
## Docs
`docs/spa-conventions.md` §3b — records that multi-select pickers satisfy §3b without `SearchPicker`, corrects a parent paragraph that contradicted the new hint, and carries the lesson that a gate's home is the shared helper however single-sink the screen's own function looks.
Follow-up filed: #740 (pre-existing missing `seqRef`/`useIsMountedRef` guards on this screen, explicitly not introduced here).
AddItemsDialog.runSearch was reachable with an empty query two ways — a blank
form submit, and a kind-chip click, which called it immediately — and
getLibraryBrowseItems omits a falsy `query`, so each path degraded into an
unfiltered browse of the whole media-library type (first 50 rows, per kind)
presented as the answer with nothing surfacing the truncation. All ten
ADDABLE_TYPE_LIST entries are spa-conventions §3b Class B media-library types.
The dialog is multi-select, so §3b's SearchPicker (single-select) does not fit;
it takes §3b's constraints instead:
- no request below LIBRARY_PICKER_MIN_QUERY, enforced in runSearch — the single
sink both entry paths route through, not duplicated per caller
- typed text compiled with titleContainsQuery rather than forwarded raw (a
second latent §3b violation here: the search index's default field does not
match bare title words)
- each kind bounded to LIBRARY_PICKER_RESULTS
- merged.slice(0, 50) removed — it silently dropped up to 100 of 150 fetched
rows even for a real query
Tests assert zero requests below the gate on both paths, exactly one bounded
request per kind above it (20k-row fixture), the compiled+escaped query, and
that no fetched row is dropped. Each was verified to fail with its mechanism
removed.
The pageSize registry entry moves from `deviation` to `search-bounded`. That
leaves zero deviation entries, so the anti-vacuity assertion guarding that list
is deleted deliberately, per its own instruction.
Done-when box 4 (collection-family truncation hint) has no subject: this screen
offers no collection-family type.
fixes#685
Independent review round 2. Verdict was MERGEABLE with no blockers; this takes
the two recommended fixes plus the structural one it listed as a follow-up.
- The bound was caller discipline, not code: getLibraryBrowseItems does not
clamp pageSize, so the bound was only the constant this one call site chose
to pass, and §3b is explicit that a bound a caller can exceed is not a bound.
New searchLibraryBrowseItems in libraryBrowse.ts owns the min-query gate, the
pageSize clamp and the titleContainsQuery compile, returning full
LibraryBrowseItem rows plus totalCount (searchLibraryPickerOptions' {id,name}
shape loses the mediaType that toAddItemsRequest needs). runSearch keeps one
early return, for the spinner only, and no longer re-implements the gate.
- The min-query guidance was keyed to the LIVE input, so backspacing below the
gate after a search wiped the rendered rows and their checkmarks while
`selected` and the Add button still counted them. Keyed to results.length too.
- "Nothing left to hint at" was false: each kind is still capped at
LIBRARY_PICKER_RESULTS and totalCount was never read. This is a bulk
multi-select add, so the cap is surfaced — per-kind totalCounts are summed and
rendered as "Showing N of M matches" once it exceeds the rendered rows. The
registry note and the §3b bullet are corrected to stop claiming otherwise.
- Gate boundary tested at 1 character (§3b: inclusive endpoints, or a > for >=
slip passes the whole suite).
- The guard test's deviation loop iterates an empty list now, so it gains one
bidirectional assertion that is non-vacuous: the set carrying an `issue` field
must equal the set classified 'deviation'.
- The §3b bullet no longer reads as a conformance certificate: AddItemsDialog
still lacks the seqRef and useIsMountedRef guards §3b mandates. That defect is
PRE-EXISTING, not introduced here, and is tracked in #740.
refs #740
Independent review round 2 returned BLOCKED on two findings, both correct.
- The helper's bound was dead code to the suite. searchLibraryBrowseItems had
zero tests, so deleting its clamp OR its gate left the whole suite green —
while the registry note claimed a caller "cannot skip the bound". That is the
previous round's finding relocated, not removed. It now has the three tests
its sibling searchLibraryPickerOptions already had (clamp, gate, compile),
plus one pinning the full-row return that is its reason to exist.
- The screen kept a second copy of the min-query check, and the two masked each
other: the 1-character boundary test passed with EITHER gate alone, so it
pinned nothing. The screen's copy is deleted; the helper is the sole gate.
Measured before/after: with the duplicate present, removing the helper's gate
left that test green; with it gone, the same removal reddens it.
- §3b contradicted itself two lines apart — the parent still said "there is no
truncation, so there is no truncation hint" above a sub-bullet mandating one.
Reworded so a hint is permitted, required only where bulk selection makes the
count actionable. Same correction to the 'search-bounded' definition.
- A failed search left results/totalMatches stale, rendering a confident
"Showing 75 of 60000 matches" beside the error banner. The catch clears them.
- Results now carry a `Results for "<query>"` heading and the guidance is keyed
to the settled query, not the live input, so rows are never shown without
saying which search produced them. `selected` persists across queries (correct
for a multi-select picker); the Add button's count keeps it discoverable.
- The hint sums pre-filter totalCount against post-filter rows, which is only
correct because every filterable kind is addable. MediaKindFilter is now
derived from ADDABLE_TYPE_LIST, making that a compile error rather than prose.
- aria-live on the hint; the #740 doc caveat no longer overstates the typeahead
rule as a mandate this screen violates.
Declined: the NaN pageSize edge (copied faithfully from the sibling helper) and
the registry's same-identity substitution gap (already disclosed in that file).
refs #740
Verification round returned MERGEABLE with all three blocking findings resolved
by measurement. These are its four remaining items.
- DEFAULT_SEARCH_KINDS was the SECOND ingress into the searched kinds and was
not derived, so the previous commit's "enforced by the type system" claim held
for one of two paths. A non-addable kind there typechecked clean and would
have overstated the hint with every one of its rows dropped — the exact defect
the derivation exists to prevent. Now derived; verified by mutation that
adding 'Collection' to it is a compile error.
- The error path fell into the min-query guidance branch, so a valid 2-character
query that got a 500 told the user to type at least 2 characters. That branch
conflated "nothing searched yet" with "the last search failed". Newly
introduced by the previous commit's error-path reset; now gated on !error and
pinned by a test.
- The aria-live region was mounted conditionally, creating the region and its
text in one commit — which most screen readers do not announce. It is now
mounted unconditionally with the condition inside.
- The §3b lesson mis-stated where the duplicate gate lived: it was inside
runSearch, the genuine single sink, NOT at one of the callers — so the rule as
written ("put the gate in the single sink, not at each caller") described the
revision that was rejected. Reworded to the actual lesson: the gate's home is
the shared helper, and "it's the single sink" is not evidence it is the only
guard. That misreading is why #685 got this wrong twice.
Declined again, with reasons: the NaN pageSize edge (faithful to the sibling
helper), the clamp test's unpinned lower bound (same), and the registry's
disclosed same-identity substitution gap.
refs #740
Round-4 verification returned MERGEABLE with no new defects and no
BLOCKER/HIGH/MEDIUM. These are its remaining LOW and nits.
- "No results — try a search above." asserts a search that COMPLETED and found
nothing, so it was false beside a failed request. Both empty-state messages
are now suppressed on error and the role="alert" banner is the whole message,
as that test's comment already claimed. Pinned positively and negatively so a
refactor cannot satisfy the assertion by rendering nothing at all.
- The (typeof ADDABLE_TYPE_LIST)[number] derivation is now the named
AddableKind, spelled once instead of twice: a third ingress into the searched
kinds is most likely to be written by copying one of the existing two, and the
"one list, all ingresses" property should be visible at a glance rather than
reassembled.
- The §3b lesson cited two measured test counts, which go stale against the very
suite they describe — a count taken before the helper had unit tests no longer
holds now that it does. Scoped the observation to the sha it was measured on
and replaced the counts with the invariant they were evidence for: every gate
needs at least one test that reddens when that gate ALONE is removed.
- Documented why the error banner stays conditionally mounted while the hint's
live region does not: role="alert" is the one live-region role screen readers
reliably announce on insertion, so the two are correct for opposite reasons.
The review flagged the divergence as unexplained, not as wrong.
refs #740
Four independent adversarial rounds (cold brief, isolated worktree, Opus). Round 2 BLOCKED: helper bound was dead code to the suite (deleting the clamp left 1119/1119 green) and a duplicated screen gate masked the helper's, so the boundary test pinned nothing. Both fixed, re-verified by independent mutation. Rounds 4 and 5 confirmed no new defects; all severity bands empty. typecheck+lint clean, 1199/1199.
Review-verdict: MERGEABLE @ 7be4265
Four independent adversarial rounds (cold brief, isolated worktree, Opus). Round 2 BLOCKED: helper bound was dead code to the suite (deleting the clamp left 1119/1119 green) and a duplicated screen gate masked the helper's, so the boundary test pinned nothing. Both fixed, re-verified by independent mutation. Rounds 4 and 5 confirmed no new defects; all severity bands empty. typecheck+lint clean, 1199/1199.
timothy
merged commit d7647b6104 into main2026-08-05 20:02:00 +02:00
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.
fixes #685
AddItemsDialog.runSearchwas reachable with an empty query two ways — a blank form submit, and a kind-chip click which called it immediately — andgetLibraryBrowseItemsomits a falsyquery. So each path degraded into an unfiltered browse of the whole media-library type (first 50 rows, per kind) presented as the answer, with nothing surfacing the truncation.merged.slice(0, 50)separately dropped up to 100 of 150 fetched rows even for a real query.All ten
ADDABLE_TYPE_LISTentries arespa-conventions.md§3b Class B media-library types.Shape of the fix
The dialog is multi-select (checkbox rows, many items added at once, fanned out over several kinds), so §3b's
SearchPicker— single-select — does not fit. It takes §3b's constraints instead, and they live in a shared helper rather than at the call site:searchLibraryBrowseItems(web/src/api/libraryBrowse.ts), a sibling ofsearchLibraryPickerOptions, owns theLIBRARY_PICKER_MIN_QUERYgate, theLIBRARY_PICKER_RESULTSclamp and thetitleContainsQuerycompile. It returns fullLibraryBrowseItemrows plustotalCount(the{id, name}shape loses themediaTypethattoAddItemsRequestneeds).merged.slice(0, 50)is removed rather than hinted at; the per-kind cap that can still truncate is surfaced asShowing N of M matches.Results for "<query>"label keyed to the settled query, so rows are never shown without saying which search produced them.MediaKindFilter,DEFAULT_SEARCH_KINDS) are derived fromADDABLE_TYPE_LIST, making the hint's pre-filter/post-filter arithmetic a compile-time property.Two corrections to the issue body
ADDABLE_TYPESincludes collection-family types needing a truncation hint. It does not — all ten entries are Class B media types. Done-when box 4 has no subject on this screen.SearchPicker, which is single-select and cannot serve this dialog.Review
Four independent adversarial review rounds (cold brief, isolated worktree). Round 2 returned BLOCKED: the helper's bound was dead code to the suite — deleting its clamp left 1119/1119 green while the registry note claimed callers "cannot skip the bound" — and a duplicated gate in the screen masked the helper's, so the boundary test pinned nothing. Both fixed and re-verified by mutation. Rounds 4 and 5 confirmed no new defects.
Every gate and guard here has at least one test that reddens when that gate alone is removed; each was verified by mutating the single clause.
Verification
npm run typecheckclean ·npm run lintclean · 1199/1199 tests green, on currentorigin/main.No live-E2E: pure-SPA change, no API or server-state effect.
Docs
docs/spa-conventions.md§3b — records that multi-select pickers satisfy §3b withoutSearchPicker, corrects a parent paragraph that contradicted the new hint, and carries the lesson that a gate's home is the shared helper however single-sink the screen's own function looks.Follow-up filed: #740 (pre-existing missing
seqRef/useIsMountedRefguards on this screen, explicitly not introduced here).Independent review round 2. Verdict was MERGEABLE with no blockers; this takes the two recommended fixes plus the structural one it listed as a follow-up. - The bound was caller discipline, not code: getLibraryBrowseItems does not clamp pageSize, so the bound was only the constant this one call site chose to pass, and §3b is explicit that a bound a caller can exceed is not a bound. New searchLibraryBrowseItems in libraryBrowse.ts owns the min-query gate, the pageSize clamp and the titleContainsQuery compile, returning full LibraryBrowseItem rows plus totalCount (searchLibraryPickerOptions' {id,name} shape loses the mediaType that toAddItemsRequest needs). runSearch keeps one early return, for the spinner only, and no longer re-implements the gate. - The min-query guidance was keyed to the LIVE input, so backspacing below the gate after a search wiped the rendered rows and their checkmarks while `selected` and the Add button still counted them. Keyed to results.length too. - "Nothing left to hint at" was false: each kind is still capped at LIBRARY_PICKER_RESULTS and totalCount was never read. This is a bulk multi-select add, so the cap is surfaced — per-kind totalCounts are summed and rendered as "Showing N of M matches" once it exceeds the rendered rows. The registry note and the §3b bullet are corrected to stop claiming otherwise. - Gate boundary tested at 1 character (§3b: inclusive endpoints, or a > for >= slip passes the whole suite). - The guard test's deviation loop iterates an empty list now, so it gains one bidirectional assertion that is non-vacuous: the set carrying an `issue` field must equal the set classified 'deviation'. - The §3b bullet no longer reads as a conformance certificate: AddItemsDialog still lacks the seqRef and useIsMountedRef guards §3b mandates. That defect is PRE-EXISTING, not introduced here, and is tracked in #740. refs #740Verification round returned MERGEABLE with all three blocking findings resolved by measurement. These are its four remaining items. - DEFAULT_SEARCH_KINDS was the SECOND ingress into the searched kinds and was not derived, so the previous commit's "enforced by the type system" claim held for one of two paths. A non-addable kind there typechecked clean and would have overstated the hint with every one of its rows dropped — the exact defect the derivation exists to prevent. Now derived; verified by mutation that adding 'Collection' to it is a compile error. - The error path fell into the min-query guidance branch, so a valid 2-character query that got a 500 told the user to type at least 2 characters. That branch conflated "nothing searched yet" with "the last search failed". Newly introduced by the previous commit's error-path reset; now gated on !error and pinned by a test. - The aria-live region was mounted conditionally, creating the region and its text in one commit — which most screen readers do not announce. It is now mounted unconditionally with the condition inside. - The §3b lesson mis-stated where the duplicate gate lived: it was inside runSearch, the genuine single sink, NOT at one of the callers — so the rule as written ("put the gate in the single sink, not at each caller") described the revision that was rejected. Reworded to the actual lesson: the gate's home is the shared helper, and "it's the single sink" is not evidence it is the only guard. That misreading is why #685 got this wrong twice. Declined again, with reasons: the NaN pageSize edge (faithful to the sibling helper), the clamp test's unpinned lower bound (same), and the registry's disclosed same-identity substitution gap. refs #740Review-verdict: MERGEABLE @
7be4265Four independent adversarial rounds (cold brief, isolated worktree, Opus). Round 2 BLOCKED: helper bound was dead code to the suite (deleting the clamp left 1119/1119 green) and a duplicated screen gate masked the helper's, so the boundary test pinned nothing. Both fixed, re-verified by independent mutation. Rounds 4 and 5 confirmed no new defects; all severity bands empty. typecheck+lint clean, 1199/1199.