Spaces:
Running
Running
| /* HFBA v2 — client-side smart search. | |
| No backend, no dependencies. Weighted multi-field scoring with synonym | |
| expansion, prefix + typo-tolerant fuzzy matching, AND-semantics across terms, | |
| and relevance ranking. Operates by relocating existing card nodes into a ranked | |
| results grid (keeps the working download buttons), restoring them on clear. */ | |
| (function () { | |
| "use strict"; | |
| var indexEl = document.getElementById("search-index"); | |
| if (!indexEl) return; | |
| var INDEX = JSON.parse(indexEl.textContent); | |
| // ---------- Synonym clusters ---------- | |
| // Each cluster groups related terms; every member expands to all the others | |
| // (bidirectional). Expansion targets that aren't asset-name words simply match | |
| // nothing, and because metadata is name-only, expansion can't flood results — | |
| // it only surfaces assets whose NAME contains a related word. So "headphones" | |
| // reaches "dj" and vice-versa, "music" reaches the whole audio family, etc. | |
| var CLUSTERS = [ | |
| // music / audio | |
| ["music", "audio", "sound", "headphones", "headphone", "dj", "earphones", "beats"], | |
| ["music", "song", "sing", "singing", "karaoke", "whistle", "violin", "violinist", "baguette", "melody", "tune"], | |
| ["music", "dance", "dancing", "vibing", "vibe", "party", "groove", "disco"], | |
| // tech / ai / ml | |
| ["code", "coding", "developer", "programming", "dev", "software", "engineer", "transformer", "agent", "robot", "terminal"], | |
| ["ai", "ml", "model", "intelligence", "neural", "transformer", "agent", "assistant", "lora", "growing", "training"], | |
| ["gpu", "chip", "hardware", "compute", "graphics", "processor", "optimum", "card"], | |
| ["data", "dataset", "discover", "discovery", "scan", "database", "analytics"], | |
| ["robot", "bot", "android", "optimum", "machine", "cyborg"], | |
| ["text", "generation", "llm", "transformer", "prompt", "writing"], | |
| // roles / jobs | |
| ["doctor", "medic", "nurse", "medical", "health", "hospital", "clinic"], | |
| ["cook", "cooking", "kitchen", "chef", "baguette", "meal", "recipe"], | |
| ["law", "lawyer", "judge", "legal", "court", "justice", "attorney"], | |
| ["manage", "manager", "management", "boss", "lead"], | |
| ["assistant", "agent", "helper", "support", "butler"], | |
| ["guide", "diffusor", "tutorial", "howto"], | |
| // food | |
| ["food", "baguette", "wine", "yolk", "egg", "bread", "drink", "meal", "snack"], | |
| // characters / costumes / animals | |
| ["magic", "magical", "wizard", "spell", "sorcerer", "secret", "mystery", "hidden"], | |
| ["royal", "king", "crown", "judge", "viking", "cowboy", "warrior", "hero"], | |
| ["animal", "snake", "horse", "dragon", "creature", "beast", "reptile", "mascot"], | |
| // activities | |
| ["game", "gaming", "gamejam", "jam", "play", "controller", "console", "video", "esports", "arcade"], | |
| ["sport", "sports", "athlete", "running", "run", "rushing", "rush", "jog", "fitness", "exercise", "gym", "race"], | |
| ["fish", "fishing", "catch", "catching", "rod"], | |
| ["space", "rocket", "launch", "moon", "global", "world", "earth", "planet", "orbit"], | |
| ["grow", "growing", "sprout", "sprouting", "plant", "seed", "leaf", "bloom"], | |
| // study / science | |
| ["study", "learn", "learning", "education", "school", "academic", "acedemic", "student", "paper", "research", "measure", "guide", "knowledge"], | |
| ["science", "lab", "laboratory", "research", "experiment", "vision", "scan", "ray", "xray", "microscope"], | |
| ["vision", "sight", "eye", "computer", "see", "scan", "view", "look"], | |
| // greetings / reactions / emotions -> expressive named assets | |
| ["greeting", "greet", "hi", "hello", "hey", "wave", "waving", "sunny", "welcome"], | |
| ["ok", "okay", "approve", "approved", "thumbs", "like", "yes", "respect", "nod", "double", "props", "good"], | |
| ["happy", "joy", "smile", "smiling", "cheerful", "sunny", "excited", "super", "vibing", "cool", "pop", "glad", "okay"], | |
| ["excited", "exciting", "thrilled", "amazed", "starry", "super", "learning", "wow"], | |
| ["sad", "unhappy", "down", "nervous", "frustrated", "upset", "worried", "cry"], | |
| ["angry", "mad", "frustrated", "rage", "chad", "yell", "furious"], | |
| ["cool", "chill", "smug", "chad", "swag", "confident", "calm"], | |
| ["sneaky", "sneak", "peek", "peeking", "tiptoe", "secret", "hidden", "quiet"], | |
| ["curious", "wonder", "peeking", "question", "interested", "inquisitive"], | |
| ["love", "loving", "heart", "hug", "hugging", "like", "adore", "family"], | |
| ["family", "together", "group", "parents", "home", "loving"], | |
| // brand / logos / style | |
| ["logo", "brand", "mark", "icon", "wordmark", "hf", "hugging", "emblem", "identity"], | |
| ["rainbow", "colorful", "gradient", "pride", "color"], | |
| ["outline", "outlined", "lineart", "line", "sketch", "doodle", "drawing"], | |
| ["doodle", "sketch", "draw", "drawing", "scribble", "comic"], | |
| ["comic", "cartoon", "manga", "strip"], | |
| ["paper", "document", "report", "article", "read", "research"], | |
| ["wine", "drink", "glass", "celebrate", "cheers"], | |
| ["lean", "leaning", "tilt", "tilting", "side"], | |
| ]; | |
| // Build a bidirectional synonym map from the clusters (deduped). | |
| var SYN = (function () { | |
| var acc = {}; | |
| CLUSTERS.forEach(function (cl) { | |
| cl.forEach(function (w) { | |
| var set = acc[w] || (acc[w] = {}); | |
| cl.forEach(function (o) { if (o !== w) set[o] = true; }); | |
| }); | |
| }); | |
| var out = {}; | |
| Object.keys(acc).forEach(function (k) { out[k] = Object.keys(acc[k]); }); | |
| return out; | |
| })(); | |
| // ---------- helpers ---------- | |
| function tokenize(s) { | |
| if (!s) return []; | |
| return String(s) | |
| .toLowerCase() | |
| .split(/[^a-z0-9]+/) | |
| .filter(function (t) { return t.length > 0; }); | |
| } | |
| function lev(a, b) { | |
| var m = a.length, n = b.length; | |
| if (Math.abs(m - n) > 2) return 3; | |
| var prev = new Array(n + 1), cur = new Array(n + 1), i, j; | |
| for (j = 0; j <= n; j++) prev[j] = j; | |
| for (i = 1; i <= m; i++) { | |
| cur[0] = i; | |
| for (j = 1; j <= n; j++) { | |
| var cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1; | |
| cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost); | |
| } | |
| var t = prev; prev = cur; cur = t; | |
| } | |
| return prev[n]; | |
| } | |
| // Field weights | |
| var W = { name: 10, tags: 7, syn: 6, section: 4, attr: 3, desc: 2, file: 1 }; | |
| // Precompute per-record weighted fields | |
| var RECS = INDEX.map(function (e) { | |
| function field(w, str, extraTokens) { | |
| var toks = tokenize(str); | |
| if (extraTokens) toks = toks.concat(extraTokens); | |
| return { w: w, set: new Set(toks), text: " " + toks.join(" ") + " " }; | |
| } | |
| var attrStr = (e.attr || []).join(" "); | |
| return { | |
| id: e.id, | |
| nameLower: (e.name || "").toLowerCase(), | |
| fields: [ | |
| field(W.name, e.name), | |
| field(W.tags, (e.tags || []).join(" ")), | |
| field(W.syn, (e.syn || []).join(" ")), | |
| field(W.section, e.section), | |
| field(W.attr, attrStr), | |
| field(W.desc, e.desc), | |
| field(W.file, (e.file || "").replace(/\.[^.]+$/, "")), | |
| ], | |
| }; | |
| }); | |
| function expand(tok) { | |
| // The typed token gets full matching (prefix/substring/fuzzy for typo tolerance). | |
| // Synonym expansions are matched EXACTLY — they're controlled vocab that maps to | |
| // real name words, so fuzzy/prefix on them would create cross-noise. | |
| var out = [{ t: tok, f: 1, syn: false }]; | |
| var syns = SYN[tok]; | |
| if (syns) for (var i = 0; i < syns.length; i++) out.push({ t: syns[i], f: 0.8, syn: true }); | |
| return out; | |
| } | |
| function fieldScore(t, f) { | |
| if (f.set.has(t)) return f.w * 3; | |
| if (t.length >= 2) { | |
| for (var ft of f.set) { if (ft.length > t.length && ft.indexOf(t) === 0) return f.w * 2; } | |
| } | |
| if (t.length >= 3 && f.text.indexOf(t) !== -1) return f.w * 1.5; | |
| var maxD = t.length >= 7 ? 2 : t.length >= 4 ? 1 : 0; | |
| if (maxD > 0) { | |
| for (var ft2 of f.set) { | |
| if (Math.abs(ft2.length - t.length) <= maxD && lev(ft2, t) <= maxD) return f.w * 1; | |
| } | |
| } | |
| return 0; | |
| } | |
| // Synonym expansions only count on an exact token hit (no prefix/substring/fuzzy). | |
| function fieldScoreExact(t, f) { return f.set.has(t) ? f.w * 3 : 0; } | |
| function scoreRecord(rec, qtokens, rawQuery) { | |
| var total = 0; | |
| for (var i = 0; i < qtokens.length; i++) { | |
| var variants = expand(qtokens[i]); | |
| var best = 0; | |
| for (var v = 0; v < variants.length; v++) { | |
| var vt = variants[v]; | |
| for (var fi = 0; fi < rec.fields.length; fi++) { | |
| var s = vt.syn | |
| ? vt.f * fieldScoreExact(vt.t, rec.fields[fi]) | |
| : vt.f * fieldScore(vt.t, rec.fields[fi]); | |
| if (s > best) best = s; | |
| } | |
| } | |
| if (best <= 0) return 0; // AND: every term must match somewhere | |
| total += best; | |
| } | |
| if (rawQuery.length >= 2 && rec.nameLower.indexOf(rawQuery) !== -1) total *= 1.2; // phrase boost | |
| return total; | |
| } | |
| function search(query) { | |
| var raw = query.trim().toLowerCase(); | |
| var qtokens = tokenize(raw); | |
| if (!qtokens.length) return null; | |
| var hits = []; | |
| for (var i = 0; i < RECS.length; i++) { | |
| var sc = scoreRecord(RECS[i], qtokens, raw); | |
| if (sc > 0) hits.push({ id: RECS[i].id, score: sc, name: RECS[i].nameLower }); | |
| } | |
| hits.sort(function (a, b) { return b.score - a.score || a.name.localeCompare(b.name); }); | |
| // Relevance floor: drop the long tail that scores far below the best match. | |
| // Keeps precise, ranked results and trims weak fuzzy/synonym noise. | |
| if (hits.length) { | |
| var floor = hits[0].score * 0.28; | |
| hits = hits.filter(function (h) { return h.score >= floor; }); | |
| } | |
| return { tokens: qtokens, hits: hits }; | |
| } | |
| // ---------- DOM wiring ---------- | |
| var byId = {}; | |
| document.querySelectorAll("[data-id]").forEach(function (el) { byId[el.dataset.id] = el; }); | |
| // remember original layout to restore on clear | |
| var grids = [].slice.call(document.querySelectorAll(".card-grid")); | |
| var originalOrder = grids.map(function (g) { | |
| return { grid: g, ids: [].slice.call(g.children).filter(function (c) { return c.dataset.id; }).map(function (c) { return c.dataset.id; }) }; | |
| }); | |
| var allIdsInOrder = originalOrder.reduce(function (acc, o) { return acc.concat(o.ids); }, []); | |
| // store original heading text per card title (for un-highlighting) | |
| var titleEls = {}; | |
| Object.keys(byId).forEach(function (id) { | |
| var h5 = byId[id].querySelector("h5"); | |
| if (h5) titleEls[id] = { el: h5, text: h5.textContent }; | |
| }); | |
| var input = document.getElementById("q"); | |
| var clearBtn = document.getElementById("q-clear"); | |
| var browse = document.getElementById("browse"); | |
| var results = document.getElementById("searchResults"); | |
| var bar = document.getElementById("results-bar"); | |
| var countEl = document.getElementById("results-count"); | |
| var noRes = document.getElementById("no-results"); | |
| var resultsClear = document.getElementById("results-clear"); | |
| var moreSection = document.getElementById("more-section"); | |
| var more = document.getElementById("moreResults"); | |
| var moreHeading = document.getElementById("more-heading"); | |
| function highlight(id, tokens) { | |
| var t = titleEls[id]; | |
| if (!t) return; | |
| var text = t.text; | |
| var html = ""; | |
| var lower = text.toLowerCase(); | |
| // greedily mark earliest token occurrences | |
| var marks = []; | |
| tokens.forEach(function (tok) { | |
| if (tok.length < 2) return; | |
| var from = 0, idx; | |
| while ((idx = lower.indexOf(tok, from)) !== -1) { marks.push([idx, idx + tok.length]); from = idx + tok.length; } | |
| }); | |
| if (!marks.length) { t.el.textContent = text; return; } | |
| marks.sort(function (a, b) { return a[0] - b[0]; }); | |
| var merged = [marks[0]]; | |
| for (var i = 1; i < marks.length; i++) { | |
| var last = merged[merged.length - 1]; | |
| if (marks[i][0] <= last[1]) last[1] = Math.max(last[1], marks[i][1]); | |
| else merged.push(marks[i]); | |
| } | |
| var pos = 0; | |
| merged.forEach(function (m) { | |
| html += escapeHtml(text.slice(pos, m[0])) + "<mark>" + escapeHtml(text.slice(m[0], m[1])) + "</mark>"; | |
| pos = m[1]; | |
| }); | |
| html += escapeHtml(text.slice(pos)); | |
| t.el.innerHTML = html; | |
| } | |
| function escapeHtml(s) { return s.replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); } | |
| function clearHighlights() { Object.keys(titleEls).forEach(function (id) { titleEls[id].el.textContent = titleEls[id].text; }); } | |
| function enterBrowse() { | |
| results.classList.remove("show"); | |
| bar.classList.remove("show"); | |
| noRes.classList.remove("show"); | |
| moreSection.classList.remove("show"); | |
| browse.style.display = ""; | |
| // move every card back to its home grid in original order | |
| originalOrder.forEach(function (o) { | |
| o.ids.forEach(function (id) { if (byId[id]) o.grid.appendChild(byId[id]); }); | |
| }); | |
| clearHighlights(); | |
| } | |
| function runSearch(query) { | |
| var res = search(query); | |
| if (!res) { enterBrowse(); return; } | |
| browse.style.display = "none"; | |
| clearHighlights(); | |
| results.innerHTML = ""; | |
| more.innerHTML = ""; | |
| // ranked matches into the results grid (highlighted) | |
| var matched = {}; | |
| res.hits.forEach(function (h) { | |
| matched[h.id] = true; | |
| var el = byId[h.id]; | |
| if (el) { results.appendChild(el); highlight(h.id, res.tokens); } | |
| }); | |
| // every non-matching asset below, in original browse order — seamless browsing | |
| allIdsInOrder.forEach(function (id) { | |
| if (!matched[id]) { var el = byId[id]; if (el) more.appendChild(el); } | |
| }); | |
| var n = res.hits.length; | |
| if (n > 0) { | |
| countEl.innerHTML = "<b>" + n + "</b> " + (n === 1 ? "result" : "results") + " for “" + escapeHtml(query.trim()) + "”"; | |
| } else { | |
| countEl.innerHTML = "No matches for “" + escapeHtml(query.trim()) + "” — browse everything below"; | |
| } | |
| bar.classList.add("show"); | |
| results.classList.toggle("show", n > 0); | |
| noRes.classList.remove("show"); // soft no-results: the bar message + full browse list below | |
| moreHeading.textContent = n > 0 ? "More to explore" : "All Huggies"; | |
| moreSection.classList.toggle("show", more.children.length > 0); | |
| } | |
| var debounce; | |
| function onInput() { | |
| var v = input.value; | |
| clearBtn.classList.toggle("show", v.length > 0); | |
| clearTimeout(debounce); | |
| debounce = setTimeout(function () { | |
| if (v.trim()) runSearch(v); else enterBrowse(); | |
| }, 90); | |
| } | |
| input.addEventListener("input", onInput); | |
| function clearSearch(focus) { | |
| input.value = ""; | |
| clearBtn.classList.remove("show"); | |
| enterBrowse(); | |
| if (focus) input.focus(); | |
| } | |
| clearBtn.addEventListener("click", function () { clearSearch(true); }); | |
| resultsClear.addEventListener("click", function () { clearSearch(true); }); | |
| // quick-filter chips | |
| document.querySelectorAll(".chip").forEach(function (chip) { | |
| chip.addEventListener("click", function () { | |
| input.value = chip.dataset.q; | |
| clearBtn.classList.add("show"); | |
| runSearch(chip.dataset.q); | |
| input.focus(); | |
| }); | |
| }); | |
| // collapse the quick-filter chips once scrolled away from the top; | |
| // they reappear when back near the top. Keeps the search field always visible. | |
| // Hysteresis: collapse once scrolled past COLLAPSE_AT, re-expand only below | |
| // EXPAND_AT. The dead zone between them stops jitter near a single threshold | |
| // (trackpad/momentum) from rapidly toggling the class and flickering the chips. | |
| var header = document.getElementById("search-header"); | |
| var COLLAPSE_AT = 56, EXPAND_AT = 6; | |
| var condensed = false; | |
| function syncCondensed() { | |
| var y = window.pageYOffset || document.documentElement.scrollTop || 0; | |
| if (!condensed && y > COLLAPSE_AT) { | |
| condensed = true; | |
| header.classList.add("condensed"); | |
| } else if (condensed && y < EXPAND_AT) { | |
| condensed = false; | |
| header.classList.remove("condensed"); | |
| } | |
| } | |
| window.addEventListener("scroll", syncCondensed, { passive: true }); | |
| syncCondensed(); | |
| // keyboard: "/" or Cmd/Ctrl+K focuses; Esc clears | |
| document.addEventListener("keydown", function (e) { | |
| if ((e.key === "/" || ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k")) && | |
| document.activeElement !== input) { | |
| e.preventDefault(); | |
| input.focus(); | |
| input.select(); | |
| } else if (e.key === "Escape" && document.activeElement === input) { | |
| clearSearch(false); | |
| input.blur(); | |
| } | |
| }); | |
| })(); | |