← back to Wallco Ai
wallco.ai · /api/designs/search now emits did_you_mean[] on 0-hit · Levenshtein over the motif+category vocab (cap=2, early-exit on row min) — only kicks in when count===0, returns top-5 candidates sorted by distance · autocomplete dropdown wires it through window._didYouMean and renders a 'Did you mean' chip row beneath the no-matches message · verified: roze→rose(d=1), damaks→damask(d=2), valid 'rose' (53 hits) → did_you_mean:[]
4739bcb4c002011bac1c995ddff18e0284afed46 · 2026-05-12 08:32:23 -0700 · SteveStudio2
Files touched
Diff
commit 4739bcb4c002011bac1c995ddff18e0284afed46
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 08:32:23 2026 -0700
wallco.ai · /api/designs/search now emits did_you_mean[] on 0-hit · Levenshtein over the motif+category vocab (cap=2, early-exit on row min) — only kicks in when count===0, returns top-5 candidates sorted by distance · autocomplete dropdown wires it through window._didYouMean and renders a 'Did you mean' chip row beneath the no-matches message · verified: roze→rose(d=1), damaks→damask(d=2), valid 'rose' (53 hits) → did_you_mean:[]
---
server.js | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 63 insertions(+), 2 deletions(-)
diff --git a/server.js b/server.js
index e642501..07294bb 100644
--- a/server.js
+++ b/server.js
@@ -216,10 +216,34 @@ function maybe304(req, res, lastModifiedDate) {
return false;
}
+// ── Levenshtein distance (early-exit at cap). Used for did-you-mean suggestions.
+function _lev(a, b, cap) {
+ if (a === b) return 0;
+ if (!a.length) return b.length;
+ if (!b.length) return a.length;
+ if (Math.abs(a.length - b.length) > cap) return cap + 1;
+ let prev = new Array(b.length + 1);
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
+ for (let i = 1; i <= a.length; i++) {
+ let cur = [i];
+ let rowMin = i;
+ for (let j = 1; j <= b.length; j++) {
+ const cost = a[i-1] === b[j-1] ? 0 : 1;
+ const v = Math.min(prev[j] + 1, cur[j-1] + 1, prev[j-1] + cost);
+ cur.push(v);
+ if (v < rowMin) rowMin = v;
+ }
+ if (rowMin > cap) return cap + 1;
+ prev = cur;
+ }
+ return prev[b.length];
+}
+
// ── Lightweight in-memory search across the catalog.
// /api/designs/search?q=<term>&limit=20
// Scores each design on motif (3), title (2), category/handle (1) substring match.
// Returns top-N by score then by saturation. Pure read-only, no DB.
+// On 0 hits, suggests near-matches via Levenshtein over motif + category vocab.
app.get('/api/designs/search', (req, res) => {
const qRaw = String(req.query.q || '').toLowerCase().trim();
const limit = Math.max(1, Math.min(50, parseInt(req.query.limit || '20', 10)));
@@ -260,6 +284,33 @@ app.get('/api/designs/search', (req, res) => {
});
}
}
+ // Did-you-mean: if no hits, search the vocabulary for near-matches to each term.
+ // Cap=2 edit distance; cheapest 5 unique candidates.
+ let didYouMean = [];
+ if (out.length === 0) {
+ const vocab = new Set();
+ for (const d of DESIGNS) {
+ (d.motifs || []).forEach(m => { const v = String(m).toLowerCase().trim(); if (v.length >= 3) vocab.add(v); });
+ if (d.category) vocab.add(String(d.category).toLowerCase().trim());
+ }
+ const candidates = new Map();
+ const cap = 2;
+ for (const t of terms) {
+ if (t.length < 3) continue; // too short — Levenshtein noise
+ for (const w of vocab) {
+ const d = _lev(t, w, cap);
+ if (d <= cap && w !== t) {
+ const prev = candidates.get(w);
+ if (prev === undefined || d < prev) candidates.set(w, d);
+ }
+ }
+ }
+ didYouMean = Array.from(candidates.entries())
+ .sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]))
+ .slice(0, 5)
+ .map(([word, distance]) => ({ word, distance }));
+ }
+
out.sort((a, b) => b.score - a.score || b.saturation - a.saturation);
// Facet counts — rolled up from the matched designs.
// Lets a UI show "Refine: rose (12) · peony (8) · scrollwork (5)" alongside results.
@@ -291,6 +342,7 @@ app.get('/api/designs/search', (req, res) => {
motifs: topMotifs,
categories: topCategories,
},
+ did_you_mean: didYouMean,
});
});
@@ -1498,7 +1550,16 @@ ${FOOTER}
var hide = function(){ sBox.style.display='none'; sIn.setAttribute('aria-expanded','false'); sIdx=-1; };
var render = function(results, qStr){
if (!results || !results.length) {
- sBox.innerHTML = '<div style="padding:14px 16px;font:13px var(--sans,system-ui);color:var(--ink-faint,#888)">No matches for “'+esc(qStr)+'”</div>';
+ var dym = (window._didYouMean || []);
+ var dymRow = '';
+ if (dym.length) {
+ dymRow = '<div style="padding:10px 14px 12px;border-top:1px solid var(--line,#f0eee9);background:#fafaf7"><div style="font:10px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint,#888);margin-bottom:6px">Did you mean</div><div style="display:flex;flex-wrap:wrap;gap:5px">'
+ + dym.slice(0,5).map(function(s){
+ return '<a href="/designs?q='+encodeURIComponent(s.word)+'" style="display:inline-flex;align-items:center;padding:4px 10px;font:11px var(--sans,system-ui);background:var(--bg,#fff);color:var(--ink,#111);border:1px solid var(--line,#e6e2d8);border-radius:999px;text-decoration:none">'+esc(s.word)+'</a>';
+ }).join('')
+ + '</div></div>';
+ }
+ sBox.innerHTML = '<div style="padding:14px 16px;font:13px var(--sans,system-ui);color:var(--ink-faint,#888)">No matches for “'+esc(qStr)+'”</div>' + dymRow;
sBox.style.display='block'; sIn.setAttribute('aria-expanded','true');
sItems = []; sIdx = -1;
return;
@@ -1534,7 +1595,7 @@ ${FOOTER}
sCtl = new AbortController();
fetch('/api/designs/search?q='+encodeURIComponent(qStr)+'&limit=24', { signal: sCtl.signal })
.then(function(r){ return r.json(); })
- .then(function(j){ window._facets = j.facets || null; render(j.results||[], qStr); })
+ .then(function(j){ window._facets = j.facets || null; window._didYouMean = j.did_you_mean || []; render(j.results||[], qStr); })
.catch(function(){ /* aborted or network — ignore */ });
};
sIn.addEventListener('input', function(){
← 0b4eba7 studio: show rendered image + 3 room settings (bedroom/livin
·
back to Wallco Ai
·
wallco.ai · /murals search box parity with /designs — server 4295177 →