Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 100 additions & 4 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,8 @@
<option value="repo">Repo</option>
<option value="video">Video</option>
</select>
<!-- Composed boards: enum-field dropdowns rendered here from the descriptor. -->
<div id="descriptor-facets" style="display:none;gap:10px"></div>
<button class="clear-filters-btn" id="clear-filters-btn">× Clear</button>
</div>
<div class="header-right">
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 =>
`<select class="filter-select" data-facet-key="${esc(f.key)}">` +
`<option value="">${esc(f.label)}</option>` +
(f.values || []).map(v => `<option value="${esc(v)}"${descriptorFacetState[f.key] === v ? ' selected' : ''}>${esc(v)}</option>`).join('') +
`</select>`
).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');
Expand All @@ -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 })
);
Expand Down Expand Up @@ -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();
});
Expand Down
6 changes: 5 additions & 1 deletion src/collections-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions src/collections-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading