503503 .studio-count .on {min-width : 18px ;width : auto;padding : 0 6px ;margin-left : 8px ;opacity : 1 }
504504 .schip {font : inherit;font-size : 12px ;padding : 3px 11px ;border : 1px solid var (--line );border-radius : 20px ;background : var (--panel );color : var (--ink );cursor : pointer;text-transform : capitalize}
505505 .schip : hover {border-color : var (--brand-2 )}
506+ /* one filter across all 300+ chips, plus perceptual family blocks so notes are scannable */
507+ .chip-filter {position : relative;margin : 0 0 12px }
508+ .chip-filter input {width : 100% ;font : inherit;font-size : 13px ;padding : 9px 13px ;border-radius : 9px ;
509+ border : 1px solid var (--line );background : var (--panel );color : var (--ink )}
510+ .chip-filter input : focus {outline : none;border-color : var (--brand-2 )}
511+ .cf-none {position : absolute;right : 12px ;top : 50% ;transform : translateY (-50% );font-family : var (--mono );
512+ font-size : 11px ;color : var (--muted )}
513+ .fam {margin : 0 0 10px }
514+ .fam-label {font-family : var (--mono );font-size : 10.5px ;letter-spacing : .07em ;text-transform : uppercase;
515+ color : var (--muted );margin : 0 0 5px ;display : flex;align-items : baseline;gap : 7px }
516+ .fam-n {font-size : 10px ;opacity : .6 }
506517 .schip-note {border-style : dashed}
507518 /* mouthfeel chips: a distinct dotted edge + amber cast so the sensation modality reads apart
508519 from odour notes at a glance (cooling/pungent legitimately appear in both rows) */
@@ -1210,6 +1221,37 @@ <h4>Software & type</h4>
12101221 alcoholic :'#9FB6C4' , musky :'#8B7BA5' , putrid :'#6E7A4A' , soapy :'#B9C6D0' , vegetable :'#6FA36B' , bready :'#C7A06A' , odorless :'#9AA4AC' ,
12111222 onion :'#B7A64B' , neroli :'#F0A5A0' , aldehydic :'#E8C070' , animalic :'#8A6B5A' , amber :'#C08A4E' , leathery :'#6E5544' , powdery :'#D9C6D0' , resinous :'#7A8B5A' , terpenic :'#6B9E5A' , ozonic :'#6FC2C4' , hyacinth :'#8FC46A' , cooling :'#6FD0C0' , metallic :'#9BB0BE' , lactonic :'#E3C99A' , popcorn :'#D9B87A' , celery :'#A8C46A' , maple :'#B5773A' , chamomile :'#E8D888' , tobacco :'#8A6B4A' , carnation :'#D9607A' , mimosa :'#E8D77A' , magnolia :'#E6E29A' , narcissus :'#E0C878' , saffron :'#E0700E' , tuberose :'#EBA9C0' , gardenia :'#E8C9D6' , ylang :'#D9A6C4' , lilac :'#C9A6D6' , osmanthus :'#E8B48A' , geranium :'#D77A8E' , eucalyptus :'#5FA98A' , corky :'#8A8A6E' , violetleaf :'#7DB36A' , fir :'#3E7A5A' , oakmoss :'#6E7A4A' , vetiver :'#7A6B4A' , patchouli :'#8A5A3A' , sandalwood :'#C9A87A' , cedarwood :'#9C6B4A' , cognac :'#A85A2E' , grapefruit :'#E8846A' , bergamot :'#C8D45A' , mandarin :'#EE9A2E' , lime :'#8FC44A' , yuzu :'#D4D46A' , honeysuckle :'#E8C86A' , freesia :'#E0D0A0' , cyclamen :'#D98AB0' , linden :'#C8D488' , coriander :'#9DBE6A' , cumin :'#B5883A' , passionfruit :'#E0C84A' , rosemary :'#5A8A6A' , blackpepper :'#6E5A4A' , nutmeg :'#A8703A' , sage :'#8AA07A' , thyme :'#7A9A5A' , oregano :'#8A9A4A' , mustard :'#D9C84A' , pineapple :'#EBD24A' , strawberry :'#E0506A' , raspberry :'#C0396B' , myrrh :'#8A6B4E' , frankincense :'#B0A07A' , musty :'#8AA07A' , hazelnut :'#C6B07A' , blueberry :'#C08A6A' , guava :'#9AA6B0' , juniper :'#C6B07A' , allspice :'#B0A07A' , dill :'#8AA07A' , tomato :'#C08A6A' , fig :'#C08A6A' , mango :'#B0A07A' , champaca :'#C6B07A' , styrax :'#8AA07A' , davana :'#B0A07A' , truffle :'#8AA07A' , clarysage :'#C08A6A' , opoponax :'#8AA07A' , turmeric :'#9AA6B0' , costus :'#C6B07A' , elemi :'#9AA6B0' , labdanum :'#C6B07A' , } ;
12121223const aromaColor = n => AROMA_COLORS [ n ] || 'var(--aroma)' ;
1224+ // Perceptual families for the 160+ odour notes. A flat alphabetical wall of chips is unusable —
1225+ // a flavorist thinks in families, so the picker groups by them. Anything not listed lands in
1226+ // "Other", so a newly-trained head is never silently dropped from the UI.
1227+ const AROMA_FAMILIES = [
1228+ [ 'Citrus' , [ 'citrus' , 'orange' , 'lime' , 'grapefruit' , 'bergamot' , 'mandarin' , 'yuzu' , 'neroli' ] ] ,
1229+ [ 'Fruity' , [ 'fruity' , 'apple' , 'pear' , 'quince' , 'peach' , 'apricot' , 'plum' , 'cherry' , 'berry' , 'strawberry' ,
1230+ 'raspberry' , 'blueberry' , 'blackberry' , 'cassis' , 'grape' , 'banana' , 'melon' , 'watermelon' , 'lychee' , 'fig' ,
1231+ 'mango' , 'pineapple' , 'tropical' , 'passionfruit' , 'guava' ] ] ,
1232+ [ 'Floral' , [ 'floral' , 'rose' , 'jasmine' , 'violet' , 'lilac' , 'lavender' , 'muguet' , 'ylang' , 'gardenia' , 'tuberose' ,
1233+ 'honeysuckle' , 'magnolia' , 'mimosa' , 'narcissus' , 'hyacinth' , 'freesia' , 'cyclamen' , 'osmanthus' , 'champaca' ,
1234+ 'linden' , 'carnation' , 'geranium' , 'elderflower' , 'boronia' , 'chamomile' , 'immortelle' , 'tagetes' ] ] ,
1235+ [ 'Green & vegetal' , [ 'green' , 'grassy' , 'hay' , 'herbal' , 'vegetable' , 'tomato' , 'celery' , 'cucumber' ,
1236+ 'violetleaf' , 'coriander' , 'angelica' , 'wormwood' , 'tea' ] ] ,
1237+ [ 'Spice' , [ 'spicy' , 'cinnamon' , 'clove' , 'nutmeg' , 'allspice' , 'blackpepper' , 'cumin' , 'cardamom' , 'ginger' ,
1238+ 'anise' , 'fennel' , 'dill' , 'saffron' , 'turmeric' , 'mustard' , 'thyme' , 'oregano' , 'sage' , 'rosemary' , 'juniper' ,
1239+ 'clarysage' ] ] ,
1240+ [ 'Wood, resin & amber' , [ 'woody' , 'cedarwood' , 'sandalwood' , 'vetiver' , 'patchouli' , 'pine' , 'fir' , 'terpenic' ,
1241+ 'resinous' , 'amber' , 'labdanum' , 'frankincense' , 'myrrh' , 'opoponax' , 'elemi' , 'styrax' , 'oakmoss' , 'costus' ,
1242+ 'davana' , 'balsamic' ] ] ,
1243+ [ 'Sweet & bakery' , [ 'vanilla' , 'caramel' , 'maple' , 'honey' , 'tonka' , 'bready' , 'popcorn' , 'malty' , 'nutty' ,
1244+ 'hazelnut' , 'almond' , 'cocoa' , 'coffee' , 'coconut' , 'lactonic' ] ] ,
1245+ [ 'Dairy & fatty' , [ 'buttery' , 'creamy' , 'cheesy' , 'fatty' , 'waxy' , 'rancid' ] ] ,
1246+ [ 'Savoury & sulfur' , [ 'meaty' , 'truffle' , 'garlic' , 'onion' , 'sulfurous' , 'mushroom' , 'fishy' ] ] ,
1247+ [ 'Earthy, tobacco & leather' , [ 'earthy' , 'musty' , 'corky' , 'tobacco' , 'leathery' ] ] ,
1248+ [ 'Fresh, cool & marine' , [ 'fresh' , 'marine' , 'ozonic' , 'aldehydic' , 'minty' , 'cooling' , 'camphor' ,
1249+ 'eucalyptus' , 'wintergreen' ] ] ,
1250+ [ 'Animalic & musk' , [ 'animalic' , 'musky' , 'powdery' ] ] ,
1251+ [ 'Sharp & chemical' , [ 'acidic' , 'alcoholic' , 'ammoniacal' , 'ethereal' , 'petroleum' , 'phenolic' , 'medicinal' ,
1252+ 'smoky' , 'tarry' , 'burnt' , 'putrid' , 'metallic' , 'soapy' , 'pungent' , 'winey' , 'cognac' ] ] ,
1253+ [ 'Base taste' , [ 'sweet' , 'bitter' ] ] ,
1254+ ] ;
12131255
12141256const $ = id => document . getElementById ( id ) ;
12151257const go = $ ( 'go' ) , q = $ ( 'q' ) ;
@@ -2302,16 +2344,31 @@ <h4>Software & type</h4>
23022344 other :'other' , misc :'other' } ;
23032345 const svgIcon = c => `<svg class="ic"><use href="#ic-${ CAT_ICON [ String ( c ) . toLowerCase ( ) ] || 'other' } "/></svg>` ;
23042346 // Flavors — named flavors grouped by category, each with its glyph
2305- let html = `<div class="studio-section"><div class="ss-head"><svg class="ic"><use href="#ic-flavors"/></svg> Flavors <span class="ss-sub">a flavor you know → the molecule that makes it</span></div><div class="ss-body">` ;
2347+ // one filter across every chip — with 300+ of them, scanning is not a search strategy
2348+ let html = `<div class="chip-filter"><input id="studioFilter" type="search" autocomplete="off"
2349+ placeholder="Filter ${ cats . reduce ( ( a , c ) => a + ( c . flavors || [ ] ) . length , 0 ) + notes . length + mouth . length } flavors, notes & sensations — e.g. citrus, vanilla, cool">
2350+ <span id="studioFilterNone" class="cf-none" hidden>no match</span></div>` ;
2351+ html += `<div class="studio-section"><div class="ss-head"><svg class="ic"><use href="#ic-flavors"/></svg> Flavors <span class="ss-sub">a flavor you know → the molecule that makes it</span></div><div class="ss-body">` ;
23062352 html += cats . map ( c => `<div class="studio-group"><span class="sg-label" title="${ esc ( c . category ) } "><span class="sg-ico">${ svgIcon ( c . category ) } </span>${ c . category } </span>` +
23072353 c . flavors . map ( f => `<button type="button" class="schip schip-flavor" data-term="${ esc ( f ) } ">${ f } </button>` ) . join ( '' ) + `</div>` ) . join ( '' ) ;
23082354 html += `</div></div>` ;
2309- // Notes — model-predicted aroma/taste qualities
2355+ // Notes — grouped into perceptual families, because 170+ chips in one alphabetical row is
2356+ // unreadable. Unlisted heads fall into "Other" so nothing is silently dropped.
23102357 if ( notes . length ) {
2311- html += `<div class="studio-section"><div class="ss-head"><svg class="ic"><use href="#ic-notes"/></svg> Notes <span class="ss-sub">an aroma/taste quality → molecules that carry it</span></div><div class="ss-body">` +
2312- `<div class="studio-group studio-group-notes">` +
2313- notes . map ( n => `<button type="button" class="schip schip-note" data-term="${ esc ( n ) } ">${ n } </button>` ) . join ( '' ) +
2314- `</div></div></div>` ;
2358+ const left = new Set ( notes ) , groups = [ ] ;
2359+ for ( const [ fam , members ] of AROMA_FAMILIES ) {
2360+ const have = members . filter ( m => left . has ( m ) ) ;
2361+ have . forEach ( m => left . delete ( m ) ) ;
2362+ if ( have . length ) groups . push ( [ fam , have ] ) ;
2363+ }
2364+ if ( left . size ) groups . push ( [ 'Other' , [ ...left ] . sort ( ) ] ) ;
2365+ html += `<div class="studio-section"><div class="ss-head"><svg class="ic"><use href="#ic-notes"/></svg> Notes <span class="ss-sub">an aroma quality → molecules that carry it</span></div><div class="ss-body">` +
2366+ groups . map ( ( [ fam , ms ] ) =>
2367+ `<div class="fam"><div class="fam-label">${ fam } <span class="fam-n">${ ms . length } </span></div>` +
2368+ `<div class="studio-group studio-group-notes">` +
2369+ ms . map ( n => `<button type="button" class="schip schip-note" data-term="${ esc ( n ) } " title="${ esc ( AROMA_HELP [ n ] || n ) } ">${ n } </button>` ) . join ( '' ) +
2370+ `</div></div>` ) . join ( '' ) +
2371+ `</div></div>` ;
23152372 }
23162373 // Mouthfeel — trained trigeminal sensations (a different modality from taste and aroma)
23172374 if ( mouth . length ) {
@@ -2326,8 +2383,36 @@ <h4>Software & type</h4>
23262383 const t = b . dataset . term ; if ( _studioSel . has ( t ) ) { _studioSel . delete ( t ) ; b . classList . remove ( 'on' ) ; } else { _studioSel . add ( t ) ; b . classList . add ( 'on' ) ; }
23272384 syncStudioCount ( ) ;
23282385 } ) ) ;
2386+ wireStudioFilter ( ) ;
23292387 } catch ( _ ) { setTimeout ( initStudio , 4000 ) ; }
23302388}
2389+ // Live filter over every chip. Hides non-matching chips, then collapses any family / category /
2390+ // section left empty, so filtering never leaves a trail of orphan headings. A selected chip always
2391+ // stays visible — otherwise your own picks vanish as you type and the count looks wrong.
2392+ function wireStudioFilter ( ) {
2393+ const inp = $ ( 'studioFilter' ) ; if ( ! inp ) return ;
2394+ const apply = ( ) => {
2395+ const q = inp . value . trim ( ) . toLowerCase ( ) ;
2396+ let shown = 0 ;
2397+ $ ( 'studioChips' ) . querySelectorAll ( '.schip' ) . forEach ( b => {
2398+ const hit = ! q || b . textContent . toLowerCase ( ) . includes ( q ) || b . classList . contains ( 'on' ) ;
2399+ b . hidden = ! hit ; if ( hit ) shown ++ ;
2400+ } ) ;
2401+ // roll empties up: chip rows, then family blocks / flavor categories, then whole sections
2402+ $ ( 'studioChips' ) . querySelectorAll ( '.studio-group' ) . forEach ( g => {
2403+ g . hidden = ! g . querySelector ( '.schip:not([hidden])' ) ;
2404+ } ) ;
2405+ $ ( 'studioChips' ) . querySelectorAll ( '.fam' ) . forEach ( f => {
2406+ f . hidden = ! f . querySelector ( '.schip:not([hidden])' ) ;
2407+ } ) ;
2408+ $ ( 'studioChips' ) . querySelectorAll ( '.studio-section' ) . forEach ( s => {
2409+ s . hidden = ! s . querySelector ( '.schip:not([hidden])' ) ;
2410+ } ) ;
2411+ const none = $ ( 'studioFilterNone' ) ; if ( none ) none . hidden = shown > 0 ;
2412+ } ;
2413+ inp . addEventListener ( 'input' , apply ) ;
2414+ inp . addEventListener ( 'search' , apply ) ; // clicking the native clear (×)
2415+ }
23312416function syncStudioCount ( ) {
23322417 const n = _studioSel . size , el = $ ( 'studioGoCount' ) ; if ( ! el ) return ;
23332418 el . textContent = n ? String ( n ) : '' ;
0 commit comments