From aaa991bda76289e40c0a492e1a7f8f8d3e1a2168 Mon Sep 17 00:00:00 2001 From: Seanathon Date: Wed, 19 Aug 2026 03:22:55 -0700 Subject: [PATCH 1/2] fix: make a composed board a first-class board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four bugs that all shared one cause — the app knew the two seeded boards and treated everything the composer made as an approximation of them. Live status never reached a board you switched to. The status stream is board-scoped (/events?boardId=…), but only load() subscribed, and load() runs at boot. Switching boards left the client attached to the boot board's stream, so the server's boardId filter dropped every event: cards on any other board sat in their skeleton until a refresh. setActiveCollection now re-subscribes. Composed tiles rendered a title and nothing else. /api/collections derives type "inspiration" for any grid board, so composed boards fell into renderGrid, which reads meta.tags/design.steal_this — keys their descriptors don't have. Adds a descriptor-driven card (cardSummary picks a category badge, a lead line and chips out of whatever fields the board declared) plus grid and list renderers on it. The seeded boards keep their bespoke renderers untouched. Compose gave no feedback for the longest wait in the app. Its only signal was a .save-status span, which is opacity:0 until something adds .visible — nothing did, so a 90s round trip read as a dead modal. Adds a waiting state with a live clock and an honest phase line, and rebuilds the proposal so the lens (the stance) outranks the field inventory: the fields are a two-column definition list, not 13 bordered boxes with ragged rows and an orphan. Headless claude ran on whatever the operator's interactive default was, usually Opus, for a fixed-schema extraction. Both headless callers now ask for Sonnet unless a model is configured. Verified in a browser against a real board: a new /events opens per switch, and an item added to a composed board fills its badge, lead and tags live with no refresh. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C83mrW9X8zBLY1sSZRgdCa --- .env.example | 6 +- README.md | 2 +- public/index.html | 474 ++++++++++++++++++++++++++++++-- src/add.test.ts | 7 +- src/add.ts | 10 +- src/collections-ui.js | 69 +++++ src/collections-ui.test.ts | 120 ++++++++ src/llm/select-provider.test.ts | 42 ++- src/llm/select-provider.ts | 27 +- 9 files changed, 721 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index aeb76a3..a2b6921 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,11 @@ DATA_DIR=./data # --- LLM provider (optional; unset = no-AI, enrichment disabled) --- # CLI agent id for the subprocess provider (claude / codex / cursor-agent). # LLM_AGENT=claude -# Model name (HttpProvider or CLI). +# Model name (HttpProvider or CLI). Unset with LLM_AGENT=claude means Sonnet: the +# CLI would otherwise inherit whatever your INTERACTIVE default is (often Opus), +# which is slow and expensive for what is a fixed-schema extraction. Set this to +# override — e.g. `opus` for a richer read, `haiku` for a cheaper one. codex is +# left unpinned and uses its own default. # LLM_MODEL= # OpenAI-compatible base URL (HttpProvider). # LLM_BASE_URL=https://api.openai.com/v1 diff --git a/README.md b/README.md index 7719161..0ce466d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ the full annotated list). Empty/whitespace values are treated as unset. | `HOST` | `127.0.0.1` | Bind address. **Localhost-only by default** (see Security). | | `DATA_DIR` | `./data` | Persistent data root (SQLite DB + screenshots). | | `CHROME_PATH` | autodetect | System Chromium/Chrome binary; autodetected on Linux when unset. | -| `LLM_AGENT` / `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` | unset | LLM provider. **Unset = no-AI** (enrichment disabled). | +| `LLM_AGENT` / `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` | unset | LLM provider. **Unset = no-AI** (enrichment disabled). With `LLM_AGENT=claude` and no `LLM_MODEL`, Board asks the CLI for **Sonnet** rather than inheriting your interactive default. | | `CAPTURE_TIMEOUT_MS` | `180000` | Budget for one capture job — page capture **and** the AI read share it. A CLI agent routinely takes 90–100s; raise it for slow local models. | | `BOARD_API_TOKEN` | unset | Bearer token for the `/api/v1` capture API. **Unset = the v1 API is off** (fail-closed). See [Integrations](docs/integrations.md). | diff --git a/public/index.html b/public/index.html index 84e7d15..da233cd 100644 --- a/public/index.html +++ b/public/index.html @@ -1143,6 +1143,35 @@ .tier-badge.polish { background: rgba(5,150,105,0.2); color: var(--polish); } .tier-badge.reference { background: rgba(14,165,233,0.2); color: var(--reference); } + /* Composed-board tile: the same geometry as the Inspiration card, but the badge is + a neutral chip (a composed board's enum has no tier palette to key off) and the + lead line is upright — the italic in .card-steal is Inspiration's "steal this" + voice, not something every board's lead field should borrow. */ + .card-badge { + flex-shrink: 0; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 6px; + border-radius: 4px; + background: var(--surface-2); + color: var(--text-2); + border: 1px solid var(--border); + white-space: nowrap; + } + + .card-lead { + font-size: 12px; + color: var(--text-2); + margin-bottom: 10px; + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + .card-steal { font-size: 12px; color: var(--text-2); @@ -1603,6 +1632,123 @@ .btn-primary:hover { background: #fbbf24; } .btn-primary:disabled { opacity: 0.5; cursor: default; } + /* ── New-board composer ────────────────────────────────────────────────────── + Composition is a 30-120s LLM round trip — the longest wait in the app. The old + feedback was one `.save-status` span, which is opacity:0 until something adds + .visible, so the wait read as a dead modal. This is the honest version: a live + elapsed clock, a phase line that says what is actually happening, and a ghost of + the board being drafted so the shape of the answer is legible before it lands. + No fake percentage — nothing here knows how far along the model is. */ + .compose-working { + margin-top: 16px; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + } + .compose-working-head { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text); + } + .compose-working-head .card-state-dot { flex-shrink: 0; } + .compose-elapsed { + margin-left: auto; + font-variant-numeric: tabular-nums; + font-size: 11px; + color: var(--text-3); + } + .compose-working-note { font-size: 11px; color: var(--text-3); margin-top: 6px; } + .compose-ghost { + display: flex; + flex-direction: column; + gap: 7px; + margin-top: 14px; + } + /* .ghost-bar's default fill IS --surface-2, which is this panel's own background — + the bars would breathe invisibly. Step them up one level so they read. */ + .compose-ghost .ghost-bar { background: var(--border-hover); } + + /* The proposal: a board's identity (name, how it captures, what it reads for) above + the fields it would keep, split by WHO fills each one — the one distinction that + decides whether this board will work for you. + + Deliberately NOT a card grid. A field definition is a name and a type: two short + tokens. Boxing each one gave 13 borders of chrome around ~2 words apiece, made + every row as tall as the tallest enum in it, and stranded the odd field alone on + its own row. A two-column definition list carries the same information with no + chrome, no ragged rows, and room for the field NAME (the part you actually read) + to be full-length. The lens above it reuses .steal-this — the app's existing + "here is the opinionated bit" component — so the stance outranks the inventory. */ + .proposal { margin-top: 18px; } + .proposal .field-group-label { margin: 20px 0 10px; } + .proposal .steal-this { margin: 14px 0 4px; } + .proposal-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 4px; + } + .proposal-chip { + font-size: 10px; + padding: 3px 8px; + border-radius: 20px; + background: var(--surface-2); + color: var(--text-2); + border: 1px solid var(--border); + } + .proposal-chip strong { color: var(--text); font-weight: 600; } + .proposal-count { text-transform: none; letter-spacing: 0; font-weight: 400; color: var(--text-2); } + + /* Multi-column, not a 2-track grid: a grid row is as tall as its tallest cell, so + one enum's value line pushed a gap under the plain field beside it. Columns flow + each field at its own height, so the vertical rhythm stays even down both. */ + .proposal-fields { + columns: 2; + column-gap: 28px; + } + @media (max-width: 560px) { + .proposal-fields { columns: 1; } + } + .proposal-field { + break-inside: avoid; + -webkit-column-break-inside: avoid; + margin-bottom: 9px; + } + .proposal-field-name { font-size: 13px; color: var(--text); line-height: 1.35; } + /* The type trails the name instead of leading it: leading, it pushed every name + into a narrow remainder and truncated the longest ones. Monospace because that is + already how this app renders schema (see .bf-key-input in the board editor). */ + .proposal-type { + margin-left: 7px; + padding: 1px 5px; + border-radius: 3px; + background: var(--surface-2); + color: var(--text-2); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 9.5px; + white-space: nowrap; + } + .proposal-values { + margin-top: 2px; + font-size: 11px; + line-height: 1.45; + color: var(--text-2); + } + + .proposal-draft-note { + font-size: 12px; + line-height: 1.5; + color: var(--text-2); + background: var(--accent-dim); + border: 1px solid rgba(245,158,11,0.2); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin-top: 14px; + } + .save-status { font-size: 12px; color: var(--polish); @@ -2078,13 +2224,44 @@ document.getElementById('modal-overlay').classList.add('open'); } - function fieldsPreviewHtml(descriptor) { + const FIELD_TYPE_ORDER = ['url', 'image', 'text', 'tags', 'enum', 'number', 'date']; + + /** One field: its name, its type, and (for an enum) the vocabulary it would accept. */ + function proposalFieldHtml(f) { + const values = f.type === 'enum' && (f.values || []).length + ? `
${esc(f.values.join(' · '))}
` + : ''; + return `
` + + `
${esc(f.label || f.key)}` + + `${esc(f.type)}
${values}
`; + } + + /** + * The fields, split by WHO fills them. That is the one distinction that decides + * whether a proposed board will actually work for you. Ordered by type so like sits + * with like. A
: a field name described by its type is exactly that, and it + * keeps reading order identical to visual order. + */ + function proposalFieldsHtml(descriptor) { const fields = (descriptor && descriptor.fields) || []; - return fields.map(f => - `
${esc(f.type)} ${esc(f.label)} ${esc(f.key)}${f.enrichable === false ? ' (yours)' : ''}
` - ).join('') || '
No fields
'; + if (!fields.length) return '
No fields yet. Add them after you create the board.
'; + const bySlot = (a, b) => FIELD_TYPE_ORDER.indexOf(a.type) - FIELD_TYPE_ORDER.indexOf(b.type); + const ai = fields.filter(f => f.enrichable !== false).sort(bySlot); + const mine = fields.filter(f => f.enrichable === false).sort(bySlot); + const group = (head, note, list) => list.length + ? `
${head} ${list.length} · ${note}
` + + `
${list.map(proposalFieldHtml).join('')}
` + : ''; + return group('Board fills these', 'from each capture', ai) + + group('You fill these', 'your own read', mine); } + const INGEST_LABEL = { + 'url-screenshot': 'screenshots the page', + 'url-readable': 'reads the article text', + 'manual-upload': 'takes files you upload', + }; + function openComposeModal() { showModalContent(` `); document.getElementById('modal-cancel').onclick = closeModal; document.getElementById('compose-go').onclick = runCompose; + composedProposal = null; // a fresh modal must not offer to create the LAST proposal + } + + /** + * Set the composer's status line. `.save-status` is opacity:0 until `.visible` is + * added — without it every message here is invisible, which is exactly how a 90s + * compose came to look like a dead modal. + */ + function setComposeStatus(text, tone) { + const el = document.getElementById('compose-status'); + if (!el) return; + el.textContent = text || ''; + el.classList.toggle('visible', !!text); + el.style.color = tone === 'error' ? 'var(--danger, #ef4444)' : ''; + } + + // Honest phase copy: what the composer is actually doing, in order. Nothing here + // knows how far along the model is, so there is no progress bar and no percentage — + // the elapsed clock is the only real number on screen. + const COMPOSE_PHASES = [ + 'Reading your description', + 'Choosing the fields worth keeping', + 'Picking how this board captures and reads', + 'Checking the proposal holds together', + ]; + const COMPOSE_PHASE_MS = 12_000; + let composeTicker = null; + + /** The waiting state: a live clock, the current phase, and a ghost of the board. */ + function startComposeWorking() { + const host = document.getElementById('compose-preview'); + host.innerHTML = ` +
+
+ + ${esc(COMPOSE_PHASES[0])} + 0s +
+
Usually under a minute. Nothing is saved until you accept.
+
+ ${ghostBar('42%', 0)}${ghostBar('88%', 90)}${ghostBar('66%', 180)}${ghostBar('78%', 270)}${ghostBar('50%', 360)} +
+
`; + const started = Date.now(); + stopComposeWorking(false); + composeTicker = setInterval(() => { + const secs = Math.floor((Date.now() - started) / 1000); + const elapsed = document.getElementById('compose-elapsed'); + const phase = document.getElementById('compose-phase'); + if (!elapsed || !phase) return stopComposeWorking(false); + elapsed.textContent = secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s`; + // Hold on the last phase rather than looping — a spinner that starts over reads + // as "stuck", and by then the only honest statement is "still working". + const i = Math.min(Math.floor((Date.now() - started) / COMPOSE_PHASE_MS), COMPOSE_PHASES.length - 1); + phase.textContent = COMPOSE_PHASES[i]; + }, 1000); + } + + function stopComposeWorking(clear = true) { + if (composeTicker) { clearInterval(composeTicker); composeTicker = null; } + if (clear) { + const host = document.getElementById('compose-preview'); + if (host) host.innerHTML = ''; + } } let composedProposal = null; async function runCompose() { const desc = document.getElementById('compose-desc').value.trim(); - const status = document.getElementById('compose-status'); const goBtn = document.getElementById('compose-go'); - if (!desc) { status.textContent = 'Describe the board first'; return; } - goBtn.disabled = true; status.textContent = 'Composing…'; + if (!desc) { setComposeStatus('Describe the board first', 'error'); return; } + goBtn.disabled = true; + goBtn.textContent = 'Composing…'; + setComposeStatus(''); + startComposeWorking(); try { const res = await fetch('/skills/compose-board', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: desc }), }); const data = await res.json(); - if (!res.ok) { status.textContent = data.error || 'Compose failed'; return; } + stopComposeWorking(); + if (!res.ok) { setComposeStatus(data.error || 'Compose failed', 'error'); return; } composedProposal = data; // { status, name, descriptor } - status.textContent = data.status === 'draft' ? 'Draft (review/edit below)' : 'Proposed'; - document.getElementById('compose-preview').innerHTML = ` -
-
Board name
- -
Fields (${(data.descriptor && data.descriptor.fields || []).length}) · ${esc(data.descriptor && data.descriptor.view || '')} view
- ${fieldsPreviewHtml(data.descriptor)} + renderProposal(data); + } catch { + stopComposeWorking(); + setComposeStatus('Network error', 'error'); + } finally { + goBtn.disabled = false; + goBtn.textContent = composedProposal ? 'Compose again' : 'Compose'; + goBtn.className = composedProposal ? 'btn btn-ghost' : 'btn btn-primary'; + } + } + + /** The proposal, as a board you can read at a glance rather than a list of rows. */ + function renderProposal(data) { + const d = data.descriptor || {}; + const fields = d.fields || []; + const draftNote = data.status === 'draft' + ? `
This one needs your hand. ${esc((data.errors || [])[0]?.message || 'The proposal did not validate.')} Create it anyway and shape the fields in the board editor.
` + : ''; + document.getElementById('compose-preview').innerHTML = ` +
+
Board name
+ +
+ ${esc(d.view || 'grid')} view + ${esc(INGEST_LABEL[d.ingest_mode] || d.ingest_mode || 'captures pages')}
- `; - document.getElementById('compose-create').onclick = createComposedBoard; - } catch { status.textContent = 'Network error'; } - finally { goBtn.disabled = false; } + ${d.enrichment_prompt ? `
The lens
${esc(d.enrichment_prompt)}
` : ''} + ${proposalFieldsHtml(d)} + ${draftNote} +
+ `; + document.getElementById('compose-create').onclick = createComposedBoard; } async function createComposedBoard() { if (!composedProposal) return; const name = (document.getElementById('compose-name').value || composedProposal.name || '').trim(); - const status = document.getElementById('compose-status'); const btn = document.getElementById('compose-create'); - if (!name) { status.textContent = 'Name is required'; return; } - btn.disabled = true; status.textContent = 'Creating…'; + if (!name) { setComposeStatus('Name is required', 'error'); return; } + btn.disabled = true; + btn.textContent = 'Creating…'; + setComposeStatus('Creating the board…'); try { const id = slugifyBoardId(name); const res = await fetch('/skills/create-board', { @@ -2145,11 +2410,21 @@ body: JSON.stringify({ id, name, descriptor: composedProposal.descriptor }), }); const data = await res.json(); - if (!res.ok) { status.textContent = data.error || 'Create failed'; btn.disabled = false; return; } + if (!res.ok) { + setComposeStatus(data.error || 'Create failed', 'error'); + btn.disabled = false; btn.textContent = 'Create board'; + return; + } closeModal(); + // Point the app at the new board BEFORE load(), so load() resolves it as active + // and doesn't subscribe to the outgoing board's status stream on the way past. + localStorage.setItem('board.activeCollection', id); await load(); await setActiveCollection(id); - } catch { status.textContent = 'Network error'; btn.disabled = false; } + } catch { + setComposeStatus('Network error', 'error'); + btn.disabled = false; btn.textContent = 'Create board'; + } } const BOARD_FIELD_TYPES = ['text', 'number', 'date', 'url', 'enum', 'tags', 'image']; @@ -2343,6 +2618,11 @@ async function setActiveCollection(cid) { activeCollection = cid; localStorage.setItem('board.activeCollection', cid); + // The status stream is BOARD-SCOPED on the server (/events?boardId=…), so a switch + // must re-subscribe or the new board receives nothing and its cards stay stuck in + // their skeleton until a manual refresh. Before the items fetch, so a transition + // that fires while they load isn't missed; subscribeToStatus is idempotent per board. + subscribeToStatus(); const helpers = window.collectionHelpers; const activeCol = collections.find(c => c.id === cid); @@ -2539,7 +2819,7 @@ const inFlight = (b) => window.collectionHelpers.isInFlight(b); const activeColObj = collections.find(c => c.id === activeCollection); - const isCustomBoard = !!activeColObj && !['inspiration', 'library', 'inbox'].includes(activeColObj.id); + const isCustomBoard = isComposedBoard(activeColObj); if (isCustomBoard) { // Composed board: filter via the descriptor-driven predicate (Story 8.2's @@ -2608,6 +2888,16 @@ function render() { const activeCol = collections.find(c => c.id === activeCollection); + // A COMPOSED board renders from its own descriptor. It can't use either seeded + // renderer: /api/collections derives type "inspiration" for any grid board, which + // sent composed boards into renderGrid() — hardcoded to meta.tags/design.steal_this + // — so their tiles came out as a title and nothing else. The seeded boards keep + // their bespoke renderers untouched (NFR-BC). + if (activeCol && isComposedBoard(activeCol)) { + if (activeView === 'grid') renderDescriptorGrid(activeCol); + else renderDescriptorList(activeCol); + return; + } if (activeCol && activeCol.type !== 'inspiration') { // Non-visual boards (e.g. Library) honor the grid/list toggle too — the grid is // just screenshot-less (the board's descriptor stores no images). @@ -2619,6 +2909,15 @@ else renderList(); } + // The seeded boards (bespoke renderers + fixed chrome) vs everything the composer + // made. Used by BOTH applyFilters (descriptor-driven predicate) and render + // (descriptor-driven card) so filtering and drawing can never disagree about which + // kind of board this is. + const SEEDED_BOARD_IDS = ['inspiration', 'library', 'inbox']; + function isComposedBoard(col) { + return !!col && !SEEDED_BOARD_IDS.includes(col.id); + } + const HEART_SVG = ``; // --- Capture lifecycle rendering --- @@ -2865,6 +3164,126 @@ }); } + // --- Composed-board rendering (descriptor-driven) --------------------------------- + // ONE pair of renderers for EVERY board the composer makes, with no per-board code: + // collectionHelpers.cardSummary picks the three card slots (category badge, lead + // line, chips) out of whatever fields that board declared. The tile chrome, the + // skeleton and the failure state are the SAME helpers the seeded boards use, so a + // composed board never looks like a second-class citizen of the app. + + /** Does this board carry images? url-readable boards are text-first (og:image only). */ + function boardShowsImages(col) { + return (col?.descriptor?.ingest_mode ?? 'url-screenshot') !== 'url-readable'; + } + + function summaryOf(b, col) { + const ch = window.collectionHelpers; + return ch?.cardSummary ? ch.cardSummary(b, col?.descriptor) : { badge: null, lead: null, tags: [] }; + } + + /** + * The filled (state === 'ready') body of a composed card: lead line + chip row. + * Both class names are passed in because the two shells lay their body out + * differently — .lib-card is a NAMED-AREA grid, so its chips must carry + * `lib-topics` or they auto-place into an implicit cell and land in the wrong slot. + */ + function descriptorCardBody(b, col, leadClass, tagsClass) { + const s = summaryOf(b, col); + const lead = s.lead ? `
${esc(s.lead.value)}
` : ''; + const tags = s.tags.length + ? `
${s.tags.map(t => `${esc(t)}`).join('')}
` + : ''; + return lead + tags; + } + + function renderDescriptorGrid(col) { + const gridEl = document.getElementById('grid-view'); + const listEl = document.getElementById('list-view'); + listEl.style.display = 'none'; + gridEl.style.display = ''; + if (!filtered.length) { gridEl.innerHTML = emptyState(bookmarks.length > 0); return; } + const withImages = boardShowsImages(col); + gridEl.innerHTML = filtered.map(b => { + const state = stateOf(b); + const inFlight = state === 'capturing' || state === 'reading'; + const badge = state === 'ready' + ? (summaryOf(b, col).badge ? `${esc(summaryOf(b, col).badge.value)}` : '') + : inFlight ? `` : ''; + // Same progressive reveal as Inspiration: the screenshot and title are whatever + // capture already produced, and only the AI fields stay ghosted. + const image = b.screenshot + ? `${esc(b.title || '')}` + : inFlight && withImages + ? `
` + : withImages ? `
No image
` : ''; + const title = b.title + ? `
${esc(b.title)}
` + : `
${ghostBar('72%')}
`; + const body = state === 'ready' + ? descriptorCardBody(b, col, 'card-lead', 'card-tags') + : `${stateNote(b, state)}${inFlight ? ghostBody() : ''}`; + return ` +
+ ${image} + + +
+
${title}${badge}
+ ${body} +
+
`; + }).join(''); + wireDescriptorCards(gridEl, '.grid-card'); + } + + function renderDescriptorList(col) { + const gridEl = document.getElementById('grid-view'); + const listEl = document.getElementById('list-view'); + gridEl.style.display = 'none'; + listEl.style.display = 'block'; + if (!filtered.length) { listEl.innerHTML = emptyState(bookmarks.length > 0); return; } + listEl.innerHTML = filtered.map(b => { + const state = stateOf(b); + const inFlight = state === 'capturing' || state === 'reading'; + const badge = state === 'ready' && summaryOf(b, col).badge + ? `${esc(summaryOf(b, col).badge.value)}` : ''; + const body = state === 'ready' + ? descriptorCardBody(b, col, 'lib-summary', 'lib-topics') + : `${stateNote(b, state)}${inFlight ? `
${ghostBar('100%')}${ghostBar('88%', 90)}${ghostBar('42%', 180)}
` : ''}`; + return ` + `; + }).join(''); + wireDescriptorCards(listEl, '.lib-card'); + } + + /** Shared wiring for both composed renderers: open the generic modal, fav, ⋯ menu. */ + function wireDescriptorCards(root, cardSel) { + bindRetryButtons(root); + root.querySelectorAll(cardSel).forEach(card => { + card.addEventListener('click', (e) => { + if (!e.target.closest('.fav-btn') && !e.target.closest('.more-btn') && !e.target.closest('.more-btn-list')) { + openModal(card.dataset.id); + } + }); + }); + root.querySelectorAll('.fav-btn').forEach(btn => { + btn.addEventListener('click', (e) => { e.stopPropagation(); openFavPopover(btn.dataset.id, btn); }); + }); + root.querySelectorAll('.more-btn, .more-btn-list').forEach(btn => { + btn.addEventListener('click', (e) => { e.stopPropagation(); openCtxMenu(btn.dataset.id, btn); }); + }); + } + function renderLibraryTopicCloud() { const cloud = document.getElementById('library-topic-cloud'); if (!cloud || !window.collectionHelpers?.topicCounts) return; @@ -3368,6 +3787,7 @@ } function closeModal() { + stopComposeWorking(false); // the composer's elapsed clock must not tick on forever document.getElementById('modal-overlay').classList.remove('open'); document.getElementById('modal').classList.remove('modal--welcome'); currentBookmark = null; @@ -4125,8 +4545,8 @@

The analysis lens

// load() is called by the module script below after collectionHelpers are initialized diff --git a/src/collections-ui.js b/src/collections-ui.js index c8348ee..071660f 100644 --- a/src/collections-ui.js +++ b/src/collections-ui.js @@ -480,3 +480,27 @@ export function cardSummary(item, descriptor, opts = {}) { const maxTags = Number.isFinite(opts.maxTags) ? opts.maxTags : DEFAULT_MAX_TAGS; return { badge: slots.badge, lead: slots.lead, tags: slots.tags.slice(0, maxTags) }; } + +// --- Sort --------------------------------------------------------------------------- +/** + * Order a board's items by recency. `added` carries only a DATE, so everything captured + * on the same day compares equal and the TIE-BREAK decides the order of most of a + * board. Incoming order is authoritative for that tie-break: the items API returns + * `created_at DESC`, so index 0 is the newest item. + * + * The previous inline comparator broke the tie the wrong way round (it returned + * `bi - ai` for "newest"), which put the oldest same-day item first — so on a board + * where everything was added today, "Recently added" listed it backwards. + * + * Returns a NEW array; the caller's list order is the recency reference and must survive. + */ +export function sortItems(items, sort) { + const oldestFirst = sort === "oldest"; + const index = new Map(items.map((item, i) => [item, i])); + return [...items].sort((a, b) => { + const byDate = String(a.added ?? "").localeCompare(String(b.added ?? "")); + if (byDate !== 0) return oldestFirst ? byDate : -byDate; + const ai = index.get(a), bi = index.get(b); + return oldestFirst ? bi - ai : ai - bi; + }); +} diff --git a/src/collections-ui.test.ts b/src/collections-ui.test.ts index fe0db93..3477ecf 100644 --- a/src/collections-ui.test.ts +++ b/src/collections-ui.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { resolveActiveCollection, cardSummary, + sortItems, itemsUrl, itemUrl, addUrl, @@ -643,3 +644,40 @@ test("cardSummary reads the nested prototype shape too (meta.tags / design.steal assert.equal(s.lead?.value, "Lead with proof."); assert.deepEqual(s.tags, ["editorial"]); }); + +// --- Sort order --------------------------------------------------------------------- +// `added` is a DATE ("2026-08-19"), so every item captured on the same day ties. The +// tie-break therefore decides the order of most of a board, not some rare edge. + +test("sortItems puts the newest first for 'newest', including same-day ties", () => { + // The API returns newest-first (created_at DESC), so index order IS recency. + const items = [ + { id: "c", added: "2026-08-19" }, + { id: "b", added: "2026-08-19" }, + { id: "a", added: "2026-08-18" }, + ]; + assert.deepEqual(sortItems(items, "newest").map(i => i.id), ["c", "b", "a"]); +}); + +test("sortItems reverses to oldest-first for 'oldest', ties included", () => { + const items = [ + { id: "c", added: "2026-08-19" }, + { id: "b", added: "2026-08-19" }, + { id: "a", added: "2026-08-18" }, + ]; + assert.deepEqual(sortItems(items, "oldest").map(i => i.id), ["a", "b", "c"]); +}); + +test("sortItems is exactly reversible when every item shares a date", () => { + const items = [{ id: "c" }, { id: "b" }, { id: "a" }].map(i => ({ ...i, added: "2026-08-19" })); + assert.deepEqual(sortItems(items, "newest").map(i => i.id), ["c", "b", "a"]); + assert.deepEqual(sortItems(items, "oldest").map(i => i.id), ["a", "b", "c"]); +}); + +test("sortItems does not mutate its input and tolerates a missing date", () => { + const items = [{ id: "b", added: "2026-08-19" }, { id: "a" }]; + const before = items.map(i => i.id); + sortItems(items, "newest"); + assert.deepEqual(items.map(i => i.id), before, "sorts a copy"); + assert.equal(sortItems(items, "newest").length, 2); +}); diff --git a/src/llm/select-provider.test.ts b/src/llm/select-provider.test.ts index b3dc941..1c6d97a 100644 --- a/src/llm/select-provider.test.ts +++ b/src/llm/select-provider.test.ts @@ -59,7 +59,10 @@ describe('describeProvider', () => { assert.equal(describeProvider(loadConfig({})), null); }); it('labels a claude CLI agent', () => { - assert.deepEqual(describeProvider(loadConfig({ LLM_AGENT: 'claude' })), { kind: 'cli', agent: 'claude', label: 'Claude Code' }); + // "Claude", not "Claude Code": this label is interpolated into the add button + // ("Add with Claude") and the provider menu ("Using Claude"), where the extra word + // buys nothing and costs header width the board switcher needs. + assert.deepEqual(describeProvider(loadConfig({ LLM_AGENT: 'claude' })), { kind: 'cli', agent: 'claude', label: 'Claude' }); }); it('labels a codex CLI agent', () => { assert.deepEqual(describeProvider(loadConfig({ LLM_AGENT: 'codex' })), { kind: 'cli', agent: 'codex', label: 'Codex' }); diff --git a/src/llm/select-provider.ts b/src/llm/select-provider.ts index b3ccce6..99ba649 100644 --- a/src/llm/select-provider.ts +++ b/src/llm/select-provider.ts @@ -58,8 +58,11 @@ export interface ProviderInfo { label: string; } +// The user-facing provider name, interpolated into "Add with {label}" and +// "Using {label}". Kept to the product name alone — the extra "Code" bought nothing +// in either sentence and cost header width the board switcher needs. const CLI_AGENT_LABELS: Record<'claude' | 'codex', string> = { - claude: 'Claude Code', + claude: 'Claude', codex: 'Codex', };