From f7f7bd9d2c7f4b9da147715ac347f19801f9a15b Mon Sep 17 00:00:00 2001 From: Seanathon Date: Sat, 27 Jun 2026 00:36:48 -0700 Subject: [PATCH] fix(ui): composed boards get descriptor-driven filters, not Inspiration's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A composed grid board inherits type 'inspiration' for its card layout, so the navbar showed Inspiration's fixed Audience/Form/Domain facets (populated from Inspiration's taxonomy) and its design tag cloud — both irrelevant/empty for, say, a 'Wish List'. The board's tags never populated. Wire up the descriptor-driven filter helpers that already existed but were never used by the UI (Story 8.2: buildFilters + matchesFilters — 'a composed board filters with no code'): - collectionChrome: the FIXED Inspiration controls are now keyed to the SEEDED Inspiration board by id, not any type==='inspiration' board. Library controls likewise gate on the seeded ids. - applyCollectionChrome renders, for composed boards, a dropdown per enum field and a tag cloud from the tags field (via buildFilters). - applyFilters filters composed boards through matchesFilters (enum equality + tags includes, shape-bridged) plus a generic text search. - board switch + Clear reset the new selections. Verified live on a 'Wish List': Want level / Verdict dropdowns + a category tag cloud, and clicking a tag filters correctly. tsc clean; 534 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- public/index.html | 104 +++++++++++++++++++++++++++++++++++-- src/collections-ui.js | 6 ++- src/collections-ui.test.ts | 34 ++++++++++++ 3 files changed, 139 insertions(+), 5 deletions(-) diff --git a/public/index.html b/public/index.html index b4f1c7e..b57a66c 100644 --- a/public/index.html +++ b/public/index.html @@ -1476,6 +1476,8 @@ + +
@@ -1573,6 +1575,10 @@ let activeTiers = new Set(); let activeTag = null; let showFavoritesOnly = false; + // Composed-board filters (descriptor-driven): enum-field selections + which tags + // field feeds the tag cloud. Reset on board switch. + let descriptorFacetState = {}; + let activeTagField = null; let currentBookmark = null; let analysisAgent = 'claude'; // null = unknown until /api/meta resolves; then boolean. Drives the add button label @@ -2120,6 +2126,7 @@ libraryTypeFilter = ''; const libTypeEl = document.getElementById('library-type-filter'); if (libTypeEl) libTypeEl.value = ''; + descriptorFacetState = {}; // composed-board enum selections don't carry across boards const itemsRes = await fetch(helpers.itemsUrl(cid)); bookmarks = await itemsRes.json(); @@ -2150,14 +2157,18 @@ const tagCloud = document.getElementById('tag-cloud'); if (tagCloud) tagCloud.style.display = chrome.tagCloud ? '' : 'none'; - // Library-specific controls - const isLibrary = col.type === 'library'; + // Library-specific controls — only the SEEDED list boards (a composed board can + // inherit type 'library' but gets descriptor-driven facets instead, below). + const isLibrary = col.id === 'library' || col.id === 'inbox'; const libTypeFilter = document.getElementById('library-type-filter'); if (libTypeFilter) libTypeFilter.style.display = isLibrary ? '' : 'none'; const libTopicCloud = document.getElementById('library-topic-cloud'); if (libTopicCloud) libTopicCloud.style.display = isLibrary ? '' : 'none'; if (isLibrary) renderLibraryTopicCloud(); + // Composed boards: render enum-field dropdowns + a tag cloud from the descriptor. + renderDescriptorFacets(col); + // Apply collection's default view const newView = col.view || 'grid'; if (newView !== activeView) { @@ -2212,6 +2223,65 @@ else cloud.style.display = ''; } + // Composed boards: render enum-field dropdowns from the descriptor into the filter bar, + // and (when the board has a tags field) feed the tag cloud from it. Seeded boards skip + // this — chrome.descriptorSelects/TagFields are empty for them. + function renderDescriptorFacets(col) { + const container = document.getElementById('descriptor-facets'); + const helpers = window.collectionHelpers; + // Composed boards (not the seeded ones) derive their filters from the descriptor via + // buildFilters (Story 8.2): enum fields → dropdowns, the tags field → the tag cloud. + const isCustom = !!col && !['inspiration', 'library', 'inbox'].includes(col.id); + const filters = isCustom ? helpers.buildFilters(col.descriptor) : []; + const selects = filters.filter(f => f.type === 'enum'); + const tagFilter = filters.find(f => f.type === 'tags'); + + // Drop any stale selections whose field isn't on this board. + const validKeys = new Set(selects.map(f => f.key)); + for (const k of Object.keys(descriptorFacetState)) if (!validKeys.has(k)) delete descriptorFacetState[k]; + + if (!selects.length) { + container.style.display = 'none'; + container.innerHTML = ''; + } else { + container.style.display = 'flex'; + container.innerHTML = selects.map(f => + `` + ).join(''); + container.querySelectorAll('select[data-facet-key]').forEach(sel => { + sel.classList.toggle('active', !!sel.value); + sel.addEventListener('change', () => { + const key = sel.dataset.facetKey; + if (sel.value) descriptorFacetState[key] = sel.value; else delete descriptorFacetState[key]; + applyFilters(); + }); + }); + } + + activeTagField = tagFilter ? tagFilter.key : null; // one tag cloud; the tags field drives it + if (activeTagField) renderDescriptorTagCloud(); + } + + function renderDescriptorTagCloud() { + const cloud = document.getElementById('tag-cloud'); + if (!cloud || !activeTagField) return; + const counts = {}; + bookmarks.forEach(b => { const t = window.collectionHelpers.getFieldValue(b, activeTagField); if (Array.isArray(t)) t.forEach(x => { counts[x] = (counts[x] || 0) + 1; }); }); + const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 30); + cloud.innerHTML = ''; + sorted.forEach(([tag]) => { + const chip = document.createElement('button'); + chip.className = 'tag-chip' + (activeTag === tag ? ' active' : ''); + chip.textContent = tag; + chip.onclick = () => { activeTag = activeTag === tag ? null : tag; renderDescriptorTagCloud(); applyFilters(); }; + cloud.appendChild(chip); + }); + cloud.style.display = sorted.length ? '' : 'none'; + } + function applyFilters() { const searchEl = document.getElementById('search'); const audienceEl = document.getElementById('audience-filter'); @@ -2226,10 +2296,33 @@ formEl.classList.toggle('active', !!form); domainEl.classList.toggle('active', !!domain); - const anyActive = !!(q || audience || form || domain || activeTiers.size || showFavoritesOnly || activeTag || libraryTopicFilter || libraryTypeFilter); + const facetActive = Object.values(descriptorFacetState).some(Boolean); + const anyActive = !!(q || audience || form || domain || activeTiers.size || showFavoritesOnly || activeTag || libraryTopicFilter || libraryTypeFilter || facetActive); document.getElementById('clear-filters-btn').classList.toggle('visible', anyActive); - if (activeCollection !== 'inspiration') { + const activeColObj = collections.find(c => c.id === activeCollection); + const isCustomBoard = !!activeColObj && !['inspiration', 'library', 'inbox'].includes(activeColObj.id); + + if (isCustomBoard) { + // Composed board: filter via the descriptor-driven predicate (Story 8.2's + // matchesFilters — enum equality, tags includes, shape-bridged via getFieldValue), + // fed the active enum selections + the active tag. Plus a generic text search. + const activeFilters = { ...descriptorFacetState }; + if (activeTag && activeTagField) activeFilters[activeTagField] = activeTag; + const descriptor = activeColObj.descriptor; + filtered = bookmarks.filter(b => { + if (!window.collectionHelpers.matchesFilters(b, activeFilters, descriptor)) return false; + if (q) { + const parts = [b.title, b.url]; + for (const v of Object.values(b)) { + if (typeof v === 'string') parts.push(v); + else if (Array.isArray(v)) parts.push(...v.filter(x => typeof x === 'string')); + } + if (!parts.filter(Boolean).join(' ').toLowerCase().includes(q)) return false; + } + return true; + }); + } else if (activeCollection !== 'inspiration') { filtered = bookmarks.filter(b => window.collectionHelpers.matchesLibraryFilters(b, { q, topic: libraryTopicFilter, type: libraryTypeFilter }) ); @@ -3257,7 +3350,10 @@ libraryTypeFilter = ''; const libTypeEl = document.getElementById('library-type-filter'); if (libTypeEl) libTypeEl.value = ''; + descriptorFacetState = {}; + document.querySelectorAll('#descriptor-facets select[data-facet-key]').forEach(s => { s.value = ''; s.classList.remove('active'); }); if (activeCollection === 'inspiration') buildTagCloud(); + else if (activeTagField) renderDescriptorTagCloud(); else renderLibraryTopicCloud(); applyFilters(); }); diff --git a/src/collections-ui.js b/src/collections-ui.js index e1ab5a4..23cb53f 100644 --- a/src/collections-ui.js +++ b/src/collections-ui.js @@ -313,7 +313,11 @@ export function shouldShowEnableAiNudge(opts = {}) { } export function collectionChrome(collection) { - const isInspiration = collection.type === "inspiration"; + // The FIXED Inspiration controls (Audience/Form/Domain + tiers + design tags) belong + // to the SEEDED Inspiration board only — a composed grid board inherits type + // "inspiration" for card layout but must NOT show them (they'd be empty/irrelevant). + // Match by id; composed boards get descriptor-driven filters (buildFilters) instead. + const isInspiration = collection.id === "inspiration"; const isGrid = collection.view === "grid"; return { facets: isInspiration, diff --git a/src/collections-ui.test.ts b/src/collections-ui.test.ts index 72a1c32..61c3b7c 100644 --- a/src/collections-ui.test.ts +++ b/src/collections-ui.test.ts @@ -117,6 +117,40 @@ test("collectionChrome keeps viewToggle true for any collection", () => { assert.equal(collectionChrome(COLLECTIONS[1]).viewToggle, true); }); +// --- composed boards get descriptor-driven filters, not Inspiration's fixed chrome --- + +const WISH_LIST = { + id: "wish-list-sblv", name: "Wish List", type: "inspiration", view: "grid", + descriptor: { + fields: [ + { key: "brand", label: "Brand", type: "text" }, + { key: "category", label: "Category", type: "tags" }, + { key: "want_level", label: "Want level", type: "enum", values: ["Nice to have", "Must have"] }, + { key: "verdict", label: "Verdict", type: "enum", values: ["Watching", "Bought"] }, + ], + }, +}; + +test("collectionChrome: a composed grid board does NOT inherit Inspiration's fixed facets", () => { + // It carries type 'inspiration' for card layout, but the fixed Audience/Form/Domain + + // tier + design tag cloud belong to the SEEDED Inspiration board (matched by id). The + // composed board's filters come from buildFilters(descriptor) instead (the UI wires it). + const chrome = collectionChrome(WISH_LIST); + assert.equal(chrome.facets, false, "no fixed Audience/Form/Domain"); + assert.equal(chrome.tiers, false, "no design tiers"); + assert.equal(chrome.tagCloud, false, "no fixed design tag cloud"); + assert.equal(chrome.screenshot, true, "still a grid board → cards show images"); +}); + +test("buildFilters drives the composed board's filters (enum dropdowns + tags cloud)", () => { + const filters = buildFilters(WISH_LIST.descriptor); + assert.deepEqual( + filters.map((f: { key: string; type: string }) => `${f.key}:${f.type}`), + ["category:tags", "want_level:enum", "verdict:enum"], + "enum + tags fields become filters; text fields don't", + ); +}); + // --- Library view helpers --- const LIBRARY_ITEM = {