← back to CelebritySignatures
wear: add typeahead search across the top of the signature picker
a70566c1bc92c19e1994a0aa62d9125a7364118f · 2026-09-09 10:20:46 -0700 · Steve
Full-width search bar above the grid that (a) live-filters the 2,955-signature
grid as you type (name + category) with the count + empty-state updating, and
(b) drops an autocomplete list of the top ranked matches (word-start > substring,
match highlighted, thumbnail + category) that you can click or arrow-key +Enter
to jump straight to the product. Escape/blur closes it; × clears. Pure client-side
addition to wear.html, no server change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oMdD46CVyPsuM6JUoiea7
Files touched
M public/wear.htmlA verification/search-typeahead.png
Diff
commit a70566c1bc92c19e1994a0aa62d9125a7364118f
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Sep 9 10:20:46 2026 -0700
wear: add typeahead search across the top of the signature picker
Full-width search bar above the grid that (a) live-filters the 2,955-signature
grid as you type (name + category) with the count + empty-state updating, and
(b) drops an autocomplete list of the top ranked matches (word-start > substring,
match highlighted, thumbnail + category) that you can click or arrow-key +Enter
to jump straight to the product. Escape/blur closes it; × clears. Pure client-side
addition to wear.html, no server change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oMdD46CVyPsuM6JUoiea7
---
public/wear.html | 92 +++++++++++++++++++++++++++++++++++++-
verification/search-typeahead.png | Bin 0 -> 79778 bytes
2 files changed, 91 insertions(+), 1 deletion(-)
diff --git a/public/wear.html b/public/wear.html
index 0333f65..b5539c4 100644
--- a/public/wear.html
+++ b/public/wear.html
@@ -53,6 +53,21 @@
.card .nm { font-size:13px; margin-top:8px; font-weight:500; }
.card .cat { font-size:11px; color:#9a978f; text-transform:uppercase; letter-spacing:.05em; }
.empty { background:#fff; border:1px dashed var(--line); border-radius:12px; padding:44px 24px; text-align:center; color:#6b6b6b; }
+ /* signature search + typeahead */
+ .searchbar { position:relative; margin:18px 0 4px; }
+ .searchbar .ico { position:absolute; left:14px; top:50%; transform:translateY(-50%); color:#9a978f; font-size:16px; pointer-events:none; }
+ #sigSearch { width:100%; font:inherit; font-size:16px; padding:13px 42px 13px 40px; border:1px solid var(--line); border-radius:12px; background:#fff; color:var(--ink); }
+ #sigSearch:focus { outline:none; border-color:var(--ink); box-shadow:0 4px 18px rgba(0,0,0,.06); }
+ .searchbar .clear { position:absolute; right:10px; top:50%; transform:translateY(-50%); border:0; background:none; color:#9a978f; font-size:20px; cursor:pointer; padding:2px 8px; line-height:1; display:none; }
+ .searchbar .clear:hover { color:var(--ink); }
+ .ac { position:absolute; z-index:20; left:0; right:0; top:calc(100% + 6px); background:#fff; border:1px solid var(--line); border-radius:12px; box-shadow:0 12px 30px rgba(0,0,0,.12); overflow:hidden; display:none; max-height:340px; overflow-y:auto; }
+ .ac-item { display:flex; align-items:center; gap:12px; padding:9px 14px; cursor:pointer; border-bottom:1px solid var(--paper); }
+ .ac-item:last-child { border-bottom:0; }
+ .ac-item.active, .ac-item:hover { background:var(--paper); }
+ .ac-item img { width:40px; height:34px; object-fit:contain; mix-blend-mode:multiply; flex:0 0 40px; }
+ .ac-item .ac-nm { font-size:14px; font-weight:500; }
+ .ac-item .ac-nm b { color:var(--green); font-weight:600; }
+ .ac-item .ac-cat { font-size:11px; color:#9a978f; text-transform:uppercase; letter-spacing:.05em; margin-left:auto; }
/* product overlay */
.scrim { position:fixed; inset:0; background:rgba(20,18,14,.5); display:none; align-items:center; justify-content:center; padding:20px; z-index:40; }
.scrim.on { display:flex; }
@@ -107,6 +122,15 @@
<div id="disclosure" class="disclosure">Loading trademark screen…</div>
<p id="selectionNotice" class="disclosure warn" role="status" hidden></p>
+ <div class="searchbar">
+ <span class="ico">🔍</span>
+ <input id="sigSearch" type="text" autocomplete="off" spellcheck="false"
+ placeholder="Search a name — Lincoln, Einstein, Marilyn Monroe…"
+ role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="ac">
+ <button class="clear" id="sigClear" aria-label="Clear search">×</button>
+ <div class="ac" id="ac" role="listbox"></div>
+ </div>
+
<div class="controls">
<label>Sort
<select id="sort">
@@ -256,6 +280,8 @@ async function boot(){
function render(){
const grid = document.getElementById('grid'), empty = document.getElementById('empty');
let sigs = (state.data.signatures||[]).slice();
+ const q = (state.q||'').trim().toLowerCase();
+ if (q) sigs = sigs.filter(s => (s.full_name||'').toLowerCase().includes(q) || (s.category||'').toLowerCase().includes(q));
const sort = document.getElementById('sort').value;
if (sort==='name') sigs.sort((a,b)=>a.full_name.localeCompare(b.full_name));
else if (sort==='cat') sigs.sort((a,b)=>(a.category||'').localeCompare(b.category||'')||a.full_name.localeCompare(b.full_name));
@@ -263,7 +289,9 @@ function render(){
document.getElementById('count').textContent = sigs.length ? `${sigs.length.toLocaleString()} available` : '';
if (!sigs.length){
grid.style.display='none'; empty.style.display='block';
- empty.innerHTML = 'No signatures matched the eligibility gate.';
+ empty.innerHTML = (state.q||'').trim()
+ ? `No signatures match “${(state.q).replace(/</g,'<')}”.`
+ : 'No signatures matched the eligibility gate.';
return;
}
grid.style.display='grid'; empty.style.display='none';
@@ -589,6 +617,68 @@ function draw(){
})();
document.getElementById('sort').onchange = e => { localStorage.setItem('wear_sort', e.target.value); render(); };
+
+// ---- search + typeahead over the signature names ----
+(function initSearch(){
+ const input = document.getElementById('sigSearch');
+ const clear = document.getElementById('sigClear');
+ const ac = document.getElementById('ac');
+ let items = [], active = -1;
+
+ const esc = t => String(t).replace(/[&<>]/g, c=>({'&':'&','<':'<','>':'>'}[c]));
+ const hl = (name, q) => { // bold the matched substring
+ const i = name.toLowerCase().indexOf(q); if (i<0) return esc(name);
+ return esc(name.slice(0,i))+'<b>'+esc(name.slice(i,i+q.length))+'</b>'+esc(name.slice(i+q.length));
+ };
+ function closeAc(){ ac.style.display='none'; input.setAttribute('aria-expanded','false'); active=-1; items=[]; }
+ function suggest(q){
+ const all = state.data && state.data.signatures || [];
+ const scored = [];
+ for (const s of all){
+ const n = (s.full_name||'').toLowerCase(); const i = n.indexOf(q);
+ if (i<0) continue;
+ // prefix / word-start matches rank first, then earliest position
+ const wordStart = i===0 || n[i-1]===' ';
+ scored.push({ s, rank: (i===0?0:wordStart?1:2), pos:i });
+ if (scored.length>400) break; // cap the scan work
+ }
+ scored.sort((a,b)=> a.rank-b.rank || a.pos-b.pos || a.s.full_name.localeCompare(b.s.full_name));
+ return scored.slice(0,8).map(x=>x.s);
+ }
+ function openAc(q){
+ items = suggest(q);
+ if (!items.length){ closeAc(); return; }
+ ac.innerHTML = items.map((s,i)=>`<div class="ac-item${i===active?' active':''}" role="option" data-qid="${s.qid}">
+ <img src="${s.signature_image_url}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">
+ <span class="ac-nm">${hl(s.full_name||'', q)}</span><span class="ac-cat">${esc(s.category||'')}</span></div>`).join('');
+ ac.style.display='block'; input.setAttribute('aria-expanded','true');
+ ac.querySelectorAll('.ac-item').forEach(el=>{
+ el.onmousedown = ev => { ev.preventDefault(); pick(el.dataset.qid); };
+ });
+ }
+ function pick(qid){ closeAc(); input.blur(); openProduct(qid); }
+ function onType(){
+ const raw = input.value; state.q = raw; clear.style.display = raw ? 'block':'none';
+ const q = raw.trim().toLowerCase();
+ render(); // live-filter the grid
+ if (q) openAc(q); else closeAc();
+ }
+ input.addEventListener('input', onType);
+ input.addEventListener('focus', ()=>{ const q=input.value.trim().toLowerCase(); if(q) openAc(q); });
+ input.addEventListener('blur', ()=> setTimeout(closeAc,120));
+ input.addEventListener('keydown', e=>{
+ if (ac.style.display!=='block') return;
+ if (e.key==='ArrowDown'){ e.preventDefault(); active=Math.min(active+1,items.length-1); paintActive(); }
+ else if (e.key==='ArrowUp'){ e.preventDefault(); active=Math.max(active-1,0); paintActive(); }
+ else if (e.key==='Enter'){ e.preventDefault(); const s=items[active>=0?active:0]; if(s) pick(s.qid); }
+ else if (e.key==='Escape'){ closeAc(); }
+ });
+ function paintActive(){
+ ac.querySelectorAll('.ac-item').forEach((el,i)=>el.classList.toggle('active',i===active));
+ const el = ac.querySelector('.ac-item.active'); if(el) el.scrollIntoView({block:'nearest'});
+ }
+ clear.onclick = ()=>{ input.value=''; state.q=''; clear.style.display='none'; closeAc(); render(); input.focus(); };
+})();
document.getElementById('density').oninput = e => { document.documentElement.style.setProperty('--cols', e.target.value); localStorage.setItem('wear_cols', e.target.value); };
document.getElementById('close').onclick = ()=>document.getElementById('scrim').classList.remove('on');
document.getElementById('scrim').onclick = e => { if(e.target.id==='scrim') document.getElementById('scrim').classList.remove('on'); };
diff --git a/verification/search-typeahead.png b/verification/search-typeahead.png
new file mode 100644
index 0000000..45e7916
Binary files /dev/null and b/verification/search-typeahead.png differ
← 6ccdfb9 Clear the bucket hat eyelets and update verified preview han
·
back to CelebritySignatures
·
wear: add all-over-print signature shirts (camp button-up + 3693ec3 →