@@ -93,6 +93,11 @@ function sourceLabel(source?: string): string {
9393 return source ?? 'Unknown' ;
9494}
9595
96+ /** Source filter chip values. `all` is the no-filter default; the
97+ * rest mirror the source-tone slugs used on the cards so the chip
98+ * semantics stay 1:1 with the badges. */
99+ type SourceFilter = 'all' | 'builtin' | 'disk' | 'compiled' ;
100+
96101export function ScenarioCatalogGrid ( props : ScenarioCatalogGridProps ) : JSX . Element | null {
97102 const { disabled = false , onRunScenario } = props ;
98103 const scenario = useScenarioContext ( ) ;
@@ -101,6 +106,17 @@ export function ScenarioCatalogGrid(props: ScenarioCatalogGridProps): JSX.Elemen
101106 const [ actorCount , setActorCount ] = useState < number > ( 2 ) ;
102107 const [ loading , setLoading ] = useState < boolean > ( true ) ;
103108 const [ error , setError ] = useState < string | null > ( null ) ;
109+ // Search query is debounced via a delayed setter below so a fast
110+ // typist doesn't re-filter the grid on every keystroke. The raw
111+ // input value drives the controlled <input>; the debounced value
112+ // drives the actual filter. Trims + lowercases at compare time.
113+ const [ queryInput , setQueryInput ] = useState < string > ( '' ) ;
114+ const [ debouncedQuery , setDebouncedQuery ] = useState < string > ( '' ) ;
115+ useEffect ( ( ) => {
116+ const handle = setTimeout ( ( ) => setDebouncedQuery ( queryInput ) , 120 ) ;
117+ return ( ) => clearTimeout ( handle ) ;
118+ } , [ queryInput ] ) ;
119+ const [ sourceFilter , setSourceFilter ] = useState < SourceFilter > ( 'all' ) ;
104120
105121 // Catalog refresh on mount + a soft 30s poll so a freshly-compiled
106122 // scenario from another browser tab (or a friend on the same hosted
@@ -155,11 +171,30 @@ export function ScenarioCatalogGrid(props: ScenarioCatalogGridProps): JSX.Elemen
155171 if ( scenarios . length < 2 ) return null ;
156172
157173 const sliderId = 'scenario-catalog-actor-count' ;
174+ const searchInputId = 'scenario-catalog-search' ;
175+ const filtersActive = debouncedQuery . trim ( ) . length > 0 || sourceFilter !== 'all' ;
176+
177+ // Filter pipeline: source-chip → free-text → sort. The active
178+ // scenario stays pinned to position 0 regardless of filter so users
179+ // never lose track of it; filtering it OUT of the list mid-search
180+ // would be a reasonable behavior too, but pinning matches the
181+ // LoadedScenarioCTA's "this one is loaded" framing above the grid.
182+ const queryNeedle = debouncedQuery . trim ( ) . toLowerCase ( ) ;
183+ const filtered = scenarios . filter ( ( s ) => {
184+ if ( s . id === activeId ) return true ; // always show active
185+ if ( sourceFilter !== 'all' && s . source !== sourceFilter ) return false ;
186+ if ( queryNeedle . length === 0 ) return true ;
187+ const haystack = [
188+ s . name ,
189+ s . id ,
190+ s . seedText ?? '' ,
191+ s . description ?? '' ,
192+ ] . join ( ' ' ) . toLowerCase ( ) ;
193+ return haystack . includes ( queryNeedle ) ;
194+ } ) ;
195+
158196 // Sort: active first, then most-run, then newest, then alphabetical.
159- // Anchors the user's currently-loaded scenario as a visual reference
160- // before the rest of the catalog so they can compare it to others
161- // without scrolling.
162- const sorted = [ ...scenarios ] . sort ( ( a , b ) => {
197+ const sorted = [ ...filtered ] . sort ( ( a , b ) => {
163198 if ( a . id === activeId ) return - 1 ;
164199 if ( b . id === activeId ) return 1 ;
165200 const runDiff = ( b . runCount ?? 0 ) - ( a . runCount ?? 0 ) ;
@@ -174,11 +209,31 @@ export function ScenarioCatalogGrid(props: ScenarioCatalogGridProps): JSX.Elemen
174209 return a . name . localeCompare ( b . name ) ;
175210 } ) ;
176211
212+ // Per-chip count for the filter row. Surfacing them as suffixes
213+ // (`Built-in 2`) tells the user how many entries they'll see before
214+ // they click — reduces the "filter to nothing then back out" cycle
215+ // when e.g. there are no Saved scenarios on a fresh install.
216+ const sourceCounts = scenarios . reduce < Record < string , number > > ( ( acc , s ) => {
217+ const key = s . source ?? 'other' ;
218+ acc [ key ] = ( acc [ key ] ?? 0 ) + 1 ;
219+ return acc ;
220+ } , { } ) ;
221+
222+ const chipDefs : Array < { key : SourceFilter ; label : string } > = [
223+ { key : 'all' , label : 'All' } ,
224+ { key : 'builtin' , label : 'Built-in' } ,
225+ { key : 'disk' , label : 'Saved' } ,
226+ { key : 'compiled' , label : 'Custom' } ,
227+ ] ;
228+
177229 return (
178230 < section className = { styles . section } aria-labelledby = "scenario-catalog-heading" >
179231 < header className = { styles . header } >
180232 < h3 className = { styles . heading } id = "scenario-catalog-heading" >
181- All scenarios < span className = { styles . count } > · { scenarios . length } </ span >
233+ All scenarios{ ' ' }
234+ < span className = { styles . count } >
235+ · { filtersActive ? `${ sorted . length } of ${ scenarios . length } ` : scenarios . length }
236+ </ span >
182237 </ h3 >
183238 < div className = { styles . actorRow } >
184239 < label className = { styles . actorLabel } htmlFor = { sliderId } > Actors</ label >
@@ -196,6 +251,54 @@ export function ScenarioCatalogGrid(props: ScenarioCatalogGridProps): JSX.Elemen
196251 < span className = { styles . actorValue } > { actorCount } </ span >
197252 </ div >
198253 </ header >
254+ < div className = { styles . filterRow } >
255+ < label className = { styles . searchLabel } htmlFor = { searchInputId } >
256+ < span className = "sr-only" > Search scenarios</ span >
257+ < input
258+ id = { searchInputId }
259+ type = "search"
260+ value = { queryInput }
261+ onChange = { ( e ) => setQueryInput ( e . target . value ) }
262+ placeholder = "Search by name or seed text…"
263+ className = { styles . searchInput }
264+ aria-label = "Filter catalog by name or seed text"
265+ />
266+ </ label >
267+ < div className = { styles . chipGroup } role = "group" aria-label = "Filter by scenario source" >
268+ { chipDefs . map ( ( c ) => {
269+ const total = c . key === 'all'
270+ ? scenarios . length
271+ : ( sourceCounts [ c . key ] ?? 0 ) ;
272+ const active = sourceFilter === c . key ;
273+ // Hide chips with no entries so a fresh-install single-
274+ // builtin user doesn't see four empty filter buttons.
275+ if ( c . key !== 'all' && total === 0 ) return null ;
276+ return (
277+ < button
278+ key = { c . key }
279+ type = "button"
280+ onClick = { ( ) => setSourceFilter ( c . key ) }
281+ className = { `${ styles . chip } ${ active ? styles . chipActive : '' } ` }
282+ aria-pressed = { active }
283+ >
284+ { c . label } < span className = { styles . chipCount } > { total } </ span >
285+ </ button >
286+ ) ;
287+ } ) }
288+ </ div >
289+ </ div >
290+ { sorted . length === 0 ? (
291+ < div className = { styles . emptyMatches } role = "status" aria-live = "polite" >
292+ No scenarios match your filter.{ ' ' }
293+ < button
294+ type = "button"
295+ className = { styles . clearFiltersBtn }
296+ onClick = { ( ) => { setQueryInput ( '' ) ; setSourceFilter ( 'all' ) ; } }
297+ >
298+ Clear filters
299+ </ button >
300+ </ div >
301+ ) : (
199302 < ul className = { styles . grid } role = "list" >
200303 { sorted . map ( ( s ) => {
201304 const tone = sourceTone ( s . source ) ;
@@ -247,6 +350,7 @@ export function ScenarioCatalogGrid(props: ScenarioCatalogGridProps): JSX.Elemen
247350 ) ;
248351 } ) }
249352 </ ul >
353+ ) }
250354 </ section >
251355 ) ;
252356}
0 commit comments