From 092300de6094b47342c480d8efbb5e216be422aa Mon Sep 17 00:00:00 2001 From: Seanathon Date: Wed, 19 Aug 2026 00:22:48 -0700 Subject: [PATCH 1/9] fix(upload): route manual image upload through the SQLite asset path The screenshot route used the legacy JSON handler, which resolved a collection from `cid` and only knew the three seeded boards. Uploading to a composed board 400'd with "Unknown collection", so a manual re-upload after a failed og:image fetch was impossible. handleScreenshot now takes a DbHandle and delegates to uploadAssetForItem, so it works for every board. The "visual collections only" guard is dropped deliberately: a readable/list item can also carry an uploaded image. Co-Authored-By: Claude Opus 5 --- src/server.test.ts | 109 ++++++++++++++++++++++++--------------------- src/server.ts | 63 +++++++++----------------- 2 files changed, 79 insertions(+), 93 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index d5aa928..8064448 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -6,10 +6,9 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { buildServer, getListenOptions, warnIfExposed } from "./server.js"; import { loadConfig } from "./config.js"; -import { BOOKMARKS_FILE, getCollection, loadCollection, saveCollection } from "./storage.js"; +import { BOOKMARKS_FILE, saveCollection } from "./storage.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const LIBRARY_FILE = path.join(__dirname, "..", getCollection("library").dataFile); // library.json / bookmarks.json are gitignored personal-capture files (absent in // CI). snapshotFile tolerates a missing file (returns null); restoreFile puts the @@ -27,21 +26,6 @@ function restoreFile(file: string, snap: string | null): void { else fs.writeFileSync(file, snap); } -const LIBRARY_ITEM = { - id: "test-lib-001", - url: "https://example.com/article", - added: "2025-01-01", - title: "Test Article", - summary: "A test summary.", - topics: ["testing", "server"], - author: "Tester", - type: "article", - key_points: ["Point one", "Point two"], - notes: "", - analysis_agent: "claude", - analysis_model: null, -}; - // --- GET /api/collections --- test("GET /api/collections returns all collections including library (SQLite)", async () => { @@ -226,59 +210,80 @@ test("PATCH /api/collections/:cid/items/:id returns 404 for an unknown item (SQL } }); -// --- Screenshot guard --- +// --- Manual image upload (SQLite, board-mode-agnostic) --- -test("POST /api/collections/library/items/:id/screenshot returns 400 for non-visual collection", async () => { - const libSnapshot = snapshotFile(LIBRARY_FILE); +test("POST /api/collections/:cid/items/:id/screenshot uploads to a composed (SQLite) board", async () => { + // Regression: the upload route used the legacy JSON handler, which only knew the + // three seeded boards and 400'd ("Unknown collection") on a composed board id — so a + // manual re-upload after a failed og:image fetch was impossible. It now routes through + // the SQLite upload-asset path and works for any board. + const { initDb } = await import("./db/index.js"); + const { seed } = await import("./db/seed.js"); + const { writeItem } = await import("./db/queue.js"); + const { boards } = await import("./db/schema.js"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "board-oss-cut-")); + const shotDir = fs.mkdtempSync(path.join(os.tmpdir(), "board-oss-shot-")); + const handle = initDb(path.join(dir, "c.db")); + seed(handle.db); try { - saveCollection("library", [LIBRARY_ITEM]); - const app = await buildServer(); + // A composed grid board with one image-less item (auto-capture "failed"). + handle.db.insert(boards).values({ id: "wishlist", name: "Wish List", view: "grid", descriptor: { name: "Wish List", fields: [] } as any }).run(); + await writeItem(handle, { id: "wl-1", boardId: "wishlist", source: "https://x", title: "T" }); + const app = await buildServer({ db: handle, screenshotsDir: shotDir }); + const res = await app.inject({ method: "POST", - url: `/api/collections/library/items/${LIBRARY_ITEM.id}/screenshot`, + url: "/api/collections/wishlist/items/wl-1/screenshot", headers: { "content-type": "application/json" }, - body: JSON.stringify({ dataUrl: "data:image/png;base64,abc" }), + body: JSON.stringify({ dataUrl: "data:image/png;base64,iVBORw0KGgo=" }), }); - assert.equal(res.statusCode, 400); - const body = JSON.parse(res.body) as any; - assert.ok(body.error.includes("screenshot"), "error message should mention screenshot"); + assert.equal(res.statusCode, 200, "composed-board upload must not 400"); + const updated = JSON.parse(res.body) as any; + assert.ok(updated.screenshot, "the hydrated item should now carry a screenshot path"); + + // The file landed under the injected screenshotsDir and is served (assets don't 404). + assert.ok(fs.existsSync(path.join(shotDir, "wl-1.png")), "image must be written under screenshotsDir"); + const served = await app.inject({ method: "GET", url: `/${updated.screenshot}` }); + assert.equal(served.statusCode, 200, "uploaded image should be served"); + assert.ok(served.rawPayload.length > 0); } finally { - restoreFile(LIBRARY_FILE, libSnapshot); + handle.sqlite.close(); + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(shotDir, { recursive: true, force: true }); } }); -test("POST /api/collections/inspiration/items/:id/screenshot passes visual guard", async () => { - const bmSnapshot = snapshotFile(BOOKMARKS_FILE); - // Story 2.2 (AC 5): inject a temp screenshotsDir so the write never pollutes the - // real DATA_DIR / app tree. - const shotDir = fs.mkdtempSync(path.join(os.tmpdir(), "board-oss-shot-")); +test("POST .../screenshot returns 404 for an unknown item", async () => { + const { app, handle, dir } = await seededSqliteApp(); try { - const testItem = { id: "bm-shot-test", url: "https://example.com", added: "2025-01-01", screenshot: null, title: "T", meta: {}, design: {}, reflection: {}, analysis_agent: "claude", analysis_model: null }; - saveCollection("inspiration", [testItem]); - const app = await buildServer({ screenshotsDir: shotDir }); const res = await app.inject({ method: "POST", - url: `/api/collections/inspiration/items/${testItem.id}/screenshot`, + url: "/api/collections/inspiration/items/ghost/screenshot", headers: { "content-type": "application/json" }, body: JSON.stringify({ dataUrl: "data:image/png;base64,iVBORw0KGgo=" }), }); - // Should not be 400 (screenshot guard allows visual collection) - assert.ok(res.statusCode !== 400 || (res.statusCode === 400 && !JSON.parse(res.body).error.includes("not supported")), - "inspiration screenshot should not be blocked by visual guard"); - - // AC 5 — the file landed under the temp screenshotsDir, NOT the app tree. - const writtenPath = path.join(shotDir, "bm-shot-test.png"); - assert.ok(fs.existsSync(writtenPath), "screenshot must be written under the injected screenshotsDir"); - assert.ok(!fs.existsSync(path.join(__dirname, "screenshots", "bm-shot-test.png")), "must not write into the app tree"); + assert.equal(res.statusCode, 404); + } finally { + handle.sqlite.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); - // AC 4 — the screenshot is served at /screenshots/ (assets don't 404). - const served = await app.inject({ method: "GET", url: "/screenshots/bm-shot-test.png" }); - assert.equal(served.statusCode, 200, "served screenshot should be 200"); - assert.equal(served.headers["content-type"], "image/png"); - assert.ok(served.rawPayload.length > 0, "served screenshot should have bytes"); +test("POST .../screenshot returns 400 for a non-image data URL", async () => { + const { writeItem } = await import("./db/queue.js"); + const { app, handle, dir } = await seededSqliteApp(); + try { + await writeItem(handle, { id: "bad-shot", boardId: "inspiration", source: "https://x", title: "T" }); + const res = await app.inject({ + method: "POST", + url: "/api/collections/inspiration/items/bad-shot/screenshot", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ dataUrl: "not-a-data-url" }), + }); + assert.equal(res.statusCode, 400); } finally { - restoreFile(BOOKMARKS_FILE, bmSnapshot); - fs.rmSync(shotDir, { recursive: true, force: true }); + handle.sqlite.close(); + fs.rmSync(dir, { recursive: true, force: true }); } }); diff --git a/src/server.ts b/src/server.ts index 1cf6342..c153564 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,7 @@ import { enqueueWrite, enqueueTransaction, reconcileInterruptedItems } from "./d import { eq } from "drizzle-orm"; import { validateDescriptorProposal } from "./descriptor/guardrails.js"; import { patchItemFields, deleteItemWithAssets } from "./db/item-actions.js"; +import { uploadAssetForItem } from "./capture/manual-upload.js"; import { listBoardItemsForUi, getItemForUi } from "./db/hydrate.js"; import { renameBoard, deleteBoardCascade } from "./db/board-actions.js"; import { boards as boardsTable } from "./db/schema.js"; @@ -256,51 +257,32 @@ async function handleRefetchItem( return spawnAddItem({ cid, url: item.url as string, updateId: itemId, instructions: body.instructions, analysisAgent }, reply); } -// Shared handler: upload screenshot (visual collections only) -function handleScreenshot( - cid: string, +// Shared handler: manual image upload (the graceful escape hatch when auto-capture +// fails — e.g. an og:image fetch came back empty). SQLite-backed via the upload-asset +// path (capture/manual-upload), so it works for EVERY board, including composed ones +// (the legacy JSON handler only knew the three seeded boards and 400'd on a composed +// board id). Board-mode-agnostic by design: a readable/list item can also receive an +// uploaded image, so there is no "visual collections only" guard here. +async function handleScreenshot( + handle: DbHandle, itemId: string, body: { dataUrl?: string }, reply: FastifyReply, screenshotsDir: string -): Record | { error: string } | null { - const col = resolveCollection(cid, reply); - if (!col) return { error: `Unknown collection: "${cid}"` }; +): Promise | { error: string } | null> { + const dataUrl = body?.dataUrl; + if (!dataUrl) { reply.status(400); return { error: "dataUrl is required" }; } + if (!getItemForUi(handle, itemId)) { reply.status(404); return { error: "Not found" }; } - if (col.view !== "grid") { + try { + await uploadAssetForItem(handle, { itemId, dataUrl, screenshotsDir }); + } catch (err) { + // Bad/oversized data URL → client error. (Unknown item is already 404'd above.) reply.status(400); - return { error: "screenshots not supported for this collection" }; + return { error: (err as Error).message }; } - const { dataUrl } = body; - if (!dataUrl) { reply.status(400); return { error: "dataUrl is required" }; } - - const m = /^data:image\/[^;]+;base64,(.+)$/.exec(dataUrl); - if (!m) { reply.status(400); return { error: "Invalid dataUrl" }; } - const buf = Buffer.from(m[1], "base64"); - - const updated = mutateCollection, Record | undefined>( - col.id, - (items) => { - const idx = items.findIndex((b) => b.id === itemId); - if (idx === -1) return undefined; - - const relPath = (items[idx].screenshot as string | null) ?? `screenshots/${itemId}.png`; - // Story 2.2: write under DATA_DIR/screenshots (by basename), not the app tree. - const absPath = path.join(screenshotsDir, path.basename(relPath)); - fs.mkdirSync(path.dirname(absPath), { recursive: true }); - fs.writeFileSync(absPath, buf); - - if (!items[idx].screenshot) { - items[idx] = { ...items[idx], screenshot: relPath }; - } - - return items[idx]; - } - ); - - if (!updated) { reply.status(404); return { error: "Not found" }; } - return updated; + return getItemForUi(handle, itemId) ?? null; } // --- Server factory --- @@ -737,11 +719,10 @@ t.addEventListener('input',upd);upd(); } ); - // Manual screenshot upload stays on the legacy handler for now (the upload-asset - // skill is the SQLite path; wiring the UI's replace-screenshot to it is a follow-up). + // Manual image upload → SQLite asset (works for composed boards too). app.post<{ Params: { cid: string; id: string }; Body: { dataUrl?: string } }>( "/api/collections/:cid/items/:id/screenshot", - async (req, reply) => handleScreenshot(req.params.cid, req.params.id, req.body, reply, screenshotsDir) + async (req, reply) => handleScreenshot(opts.db ?? getDb(), req.params.id, req.body, reply, screenshotsDir) ); // --- Legacy aliases (delegate to collection handlers with cid="inspiration") --- @@ -770,7 +751,7 @@ t.addEventListener('input',upd);upd(); app.post<{ Params: { id: string }; Body: { dataUrl?: string } }>( "/api/bookmarks/:id/screenshot", - async (req, reply) => handleScreenshot("inspiration", req.params.id, req.body, reply, screenshotsDir) + async (req, reply) => handleScreenshot(opts.db ?? getDb(), req.params.id, req.body, reply, screenshotsDir) ); // --- Story 3.2: the ONE generic skill-invocation route (AD11/FR-19) --- From 0a826045fba494fa6ab8d5c711a10b24d5e42537 Mon Sep 17 00:00:00 2001 From: Seanathon Date: Wed, 19 Aug 2026 00:45:25 -0700 Subject: [PATCH 2/9] feat(ui): show capture progress, and say when an item failed An item looked identical whether it was fully enriched, mid-capture, or dead from a timeout: the frontend rendered neither `status` nor `error_reason`, both of which hydrate already shipped. So a pasted URL produced an empty slot you had to refresh to resolve, and a failed item silently pretended to be fine. A card now ghosts its own real geometry while in flight and fills in the order data arrives: capture publishes a `captured` event carrying title + screenshot, so the page appears while the AI read is still running. Failures state the reason (allowlisted via safeErrorReason, never raw) and offer a retry through the existing refetch route. SSE events merge into the card in place instead of reloading the board, and in-flight items bypass filters so a new item can never be invisible. Two data-honesty fixes this depends on: - Imports that already carry an AI read now store as `done`. The blanket "everything imports as pending" of Story 3.3 left 150 fully-enriched items at `pending`, which is exactly the signal the skeleton keys off. Records without enrichment still import as pending. - The capture job budget (capture AND the LLM read share one job) moves from a fixed 60s to a configurable 180s. A measured Claude-CLI enrichment took 90s, so the old cap made every AI-enabled capture a coin flip. Co-Authored-By: Claude Opus 5 --- .env.example | 4 + public/index.html | 300 ++++++++++++++++++++++++---- src/capture/adapter.test.ts | 46 +++++ src/capture/adapter.ts | 14 ++ src/collections-ui.js | 54 +++++ src/collections-ui.test.ts | 72 +++++++ src/config.test.ts | 18 ++ src/config.ts | 20 ++ src/db/importer.test.ts | 58 ++++++ src/db/importer.ts | 25 ++- src/enrichment/pipeline.ts | 7 +- src/skills/import-bookmarks.test.ts | 12 +- src/sse.ts | 7 + 13 files changed, 597 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index eff084f..aeb76a3 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,10 @@ DATA_DIR=./data # Path to a system Chromium/Chrome binary. Optional — Story 2.3 autodetects on # Linux when unset. # CHROME_PATH=/usr/bin/chromium +# Budget in milliseconds for ONE capture job. Capture and the LLM read share a job, so +# this covers both. The default suits a CLI provider; a slow local model may need more. +# Too low and an item fails as "timed out" with its page already captured. +# CAPTURE_TIMEOUT_MS=180000 # --- LLM provider (optional; unset = no-AI, enrichment disabled) --- # CLI agent id for the subprocess provider (claude / codex / cursor-agent). diff --git a/public/index.html b/public/index.html index b4f1c7e..bc88b20 100644 --- a/public/index.html +++ b/public/index.html @@ -942,6 +942,80 @@ font-size: 12px; } + /* --- Capture lifecycle: in-flight and failed cards --- + The skeleton ghosts THIS card's real geometry (image box, title line, badge, + steal line, tag row) at true size, so nothing shifts when real content lands and + the card visibly assembles in the order data arrives. A generic shimmer + rectangle would say only "something is loading"; this says what is still + missing. Motion is a slow staggered breathe, not a sweep. */ + .ghost-bar { + background: var(--surface-2); + border-radius: 3px; + height: 9px; + animation: ghost-breathe 1.9s ease-in-out infinite; + } + .ghost-image { + width: 100%; + height: 200px; + background: var(--surface-2); + display: flex; + align-items: flex-end; + padding: 12px 14px; + box-sizing: border-box; + animation: ghost-breathe 1.9s ease-in-out infinite; + } + .ghost-tags { display: flex; gap: 6px; } + .ghost-tags .ghost-bar { height: 16px; border-radius: 999px; } + @keyframes ghost-breathe { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.42; } + } + + /* The state word. Never color alone: the dot is an accompaniment to the label, + never the signal itself. */ + .card-state { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + letter-spacing: 0.02em; + color: var(--text-2); + } + .card-state-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent); + flex: none; + animation: ghost-breathe 1.9s ease-in-out infinite; + } + .card-state.is-failed { color: var(--text-2); } + .card-state.is-failed .card-state-dot { background: #f87171; animation: none; } + + .card-retry { + font-size: 11px; + color: var(--accent); + background: none; + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + } + .card-retry:disabled { color: var(--text-3); cursor: default; text-decoration: none; } + + /* An in-flight card is not yet judgeable, so it stays quiet: no hover lift, no + cursor glow competing with the fill. */ + .grid-card.is-inflight, .list-card.is-inflight, .lib-card.is-inflight { cursor: default; } + .grid-card.is-inflight:hover { transform: none; box-shadow: none; } + .grid-card.is-inflight::after, .list-card.is-inflight::after, .lib-card.is-inflight::after { display: none; } + + /* Reduced motion: a real static path, not a disabled animation. The ghosts hold a + fixed dimmed opacity and content swaps in without transition. */ + @media (prefers-reduced-motion: reduce) { + .ghost-bar, .ghost-image, .card-state-dot { animation: none; opacity: 0.6; } + } + /* Library/text-board grid tile: no screenshot, scales to text fields. */ .lib-grid-card { position: relative; } .lib-grid-card .more-btn-list { position: absolute; top: 10px; right: 10px; } @@ -1785,6 +1859,45 @@ let statusSource = null; let subscribedCid = null; let statusReloadPending = false; + + /** + * Fold one SSE transition into the in-memory item and re-render. + * @returns {boolean} true when the item was known and handled locally. + */ + function applyStatusEvent(event) { + if (!event || !event.itemId) return false; + const item = bookmarks.find(b => b.id === event.itemId); + if (!item) return false; + + if (event.status === 'captured') { + // Not a lifecycle transition: the row still reads `processing`. Only the newly + // captured page is applied, so the card keeps its skeleton for the AI fields. + if (event.title) item.title = event.title; + if (event.screenshot) { item.screenshot = event.screenshot; item._cacheBust = Date.now(); } + } else { + item.status = event.status; + if (event.error_reason !== undefined) item.error_reason = event.error_reason; + else delete item.error_reason; + if (event.fields && typeof event.fields === 'object') { + // `fields` arrives flat and dotted ("meta.tier"); the renderers read it nested. + for (const [key, value] of Object.entries(event.fields)) { + const dot = key.indexOf('.'); + if (dot > 0) { + const group = key.slice(0, dot); + item[group] = { ...(item[group] || {}), [key.slice(dot + 1)]: value }; + } else { + item[key] = value; + } + } + } + } + // A finished item can change the facet vocabulary, so refresh the clouds too. + if (event.status === 'done') { + if (activeCollection === 'inspiration') buildTagCloud(); else renderLibraryTopicCloud(); + } + applyFilters(); + return true; + } function subscribeToStatus() { if (typeof EventSource === 'undefined') return; if (statusSource && subscribedCid === activeCollection) return; @@ -1792,7 +1905,16 @@ if (statusSource) statusSource.close(); subscribedCid = activeCollection; statusSource = new EventSource(window.collectionHelpers.eventsUrl(activeCollection)); - statusSource.addEventListener('status', () => { + statusSource.addEventListener('status', (e) => { + // Merge the transition into the card in place. A full load() would discard + // scroll position and any open popover on every hop of a capture, and the + // `captured` event carries exactly what the card needs to fill its image and + // title while the AI read is still running. + let event; + try { event = JSON.parse(e.data); } catch { return; } + if (applyStatusEvent(event)) return; + // An event for an item this client has never seen (added from the extension, + // the share target, or another tab) — fall back to a debounced board refetch. if (statusReloadPending) return; statusReloadPending = true; setTimeout(() => { statusReloadPending = false; load(); }, 200); @@ -2229,12 +2351,18 @@ const anyActive = !!(q || audience || form || domain || activeTiers.size || showFavoritesOnly || activeTag || libraryTopicFilter || libraryTypeFilter); document.getElementById('clear-filters-btn').classList.toggle('visible', anyActive); + // An item still being captured has no facets to match on yet, so every active + // filter would drop it and the card the user just created would never appear. + // It stays visible until it has been read and can be judged on its merits. + const inFlight = (b) => window.collectionHelpers.isInFlight(b); + if (activeCollection !== 'inspiration') { filtered = bookmarks.filter(b => - window.collectionHelpers.matchesLibraryFilters(b, { q, topic: libraryTopicFilter, type: libraryTypeFilter }) + inFlight(b) || window.collectionHelpers.matchesLibraryFilters(b, { q, topic: libraryTopicFilter, type: libraryTypeFilter }) ); } else { filtered = bookmarks.filter(b => { + if (inFlight(b)) return true; if (showFavoritesOnly && !b.favorite) return false; if (audience && b.meta?.audience !== audience) return false; if (form && b.meta?.form !== form) return false; @@ -2285,28 +2413,107 @@ const HEART_SVG = ``; + // --- Capture lifecycle rendering --- + // State comes from one tested helper (collections-ui.itemRenderState) so the card, + // the list row and the modal can never disagree about what an item is doing. + + const STATE_LABEL = { capturing: 'Capturing the page', reading: 'Reading it' }; + + function stateOf(b) { return window.collectionHelpers.itemRenderState(b); } + + /** A ghost line at a given width; the stagger makes the card breathe as a unit. */ + function ghostBar(width, delay = 0) { + return `
`; + } + + /** The status line shown where the AI takeaway will eventually sit. */ + function stateNote(b, state) { + if (state === 'failed') { + // Never the raw reason: safeErrorReason allowlists the known user-safe set so a + // stack or a secret-bearing string can't reach a card. + const reason = window.collectionHelpers.safeErrorReason(b); + return `
` + + `${esc(reason.charAt(0).toUpperCase() + reason.slice(1))}.` + + `
`; + } + return `
${STATE_LABEL[state]}
`; + } + + /** Ghosted stand-ins for the fields enrichment has not filled yet. */ + function ghostBody() { + return `
` + + ghostBar('54px', 0) + ghostBar('38px', 90) + ghostBar('46px', 180) + + `
`; + } + + /** Wire the retry buttons a failed card renders. */ + function bindRetryButtons(root) { + root.querySelectorAll('.card-retry').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + retryItem(btn.dataset.retryId, btn); + }); + }); + } + + /** + * Re-run capture + enrichment for a failed item via the existing refetch route. + * The card returns to its in-flight state immediately; SSE drives it from there. + */ + async function retryItem(id, btn) { + if (btn) { btn.disabled = true; btn.textContent = 'Retrying…'; } + const item = bookmarks.find(b => b.id === id); + if (item) { item.status = 'processing'; delete item.error_reason; applyFilters(); } + try { + const res = await fetch(window.collectionHelpers.refetchUrl(activeCollection, id), { method: 'POST' }); + if (!res.ok) throw new Error('refetch failed'); + } catch { + if (item) { item.status = 'error'; item.error_reason = 'could not start a retry'; applyFilters(); } + } + } + + function renderGrid() { const el = document.getElementById('grid-view'); if (!filtered.length) { el.innerHTML = emptyState(bookmarks.length > 0); return; } - el.innerHTML = filtered.map(b => ` -
- ${b.screenshot - ? `${esc(b.title)}` - : `
No image
`} + el.innerHTML = filtered.map(b => { + const state = stateOf(b); + const inFlight = state === 'capturing' || state === 'reading'; + // The image and title are whatever capture has already produced: at `reading` + // they are real and only the AI read is still ghosted, so the card fills in the + // order the data actually arrives instead of flipping from blank to complete. + const image = b.screenshot + ? `${esc(b.title)}` + : inFlight + ? `
` + : `
No image
`; + const title = b.title + ? `
${esc(b.title)}
` + : `
${ghostBar('72%')}
`; + const badge = state === 'ready' + ? `${TIER_LABELS[b.meta?.tier] || b.meta?.tier || ''}` + : ''; + const body = state === 'ready' + ? `
${esc(b.design?.steal_this || '')}
+
+ ${(b.meta?.tags || []).map(t => `${esc(t)}`).join('')} +
` + : `${stateNote(b, state)}${inFlight ? ghostBody() : ''}`; + return ` +
+ ${image}
-
${esc(b.title)}
- ${TIER_LABELS[b.meta?.tier] || b.meta?.tier || ''} -
-
${esc(b.design?.steal_this || '')}
-
- ${(b.meta?.tags || []).map(t => `${esc(t)}`).join('')} + ${title} + ${badge}
+ ${body}
- `).join(''); + `;}).join(''); + bindRetryButtons(el); el.querySelectorAll('.grid-card').forEach(card => { card.addEventListener('click', (e) => { if (!e.target.closest('.fav-btn')) openModal(card.dataset.id); @@ -2323,8 +2530,11 @@ function renderList() { const el = document.getElementById('list-view'); if (!filtered.length) { el.innerHTML = emptyState(bookmarks.length > 0); return; } - el.innerHTML = filtered.map(b => ` -
+ el.innerHTML = filtered.map(b => { + const state = stateOf(b); + const inFlight = state === 'capturing' || state === 'reading'; + return ` +
${b.favorite ? `
` : '
'} ${b.screenshot @@ -2333,11 +2543,13 @@
-
${esc(b.title)}
+
${b.title ? esc(b.title) : ghostBar('160px')}
${esc(hostname(b.url))}
${esc([b.meta?.audience, b.meta?.form, b.meta?.domain].filter(Boolean).join(' · '))} · ${b.meta?.tone?.join(', ') || ''}
-
${esc(b.design?.steal_this || '')}
+ ${state === 'ready' + ? `
${esc(b.design?.steal_this || '')}
` + : `
${stateNote(b, state)}
`}
${TIER_LABELS[b.meta?.tier] || ''}
@@ -2349,7 +2561,8 @@
- `).join(''); + `;}).join(''); + bindRetryButtons(el); el.querySelectorAll('.list-card').forEach(card => { card.addEventListener('click', (e) => { if (!e.target.closest('.more-btn-list')) openModal(card.dataset.id); @@ -2366,8 +2579,11 @@ gridEl.style.display = 'none'; listEl.style.display = 'block'; if (!filtered.length) { listEl.innerHTML = emptyState(bookmarks.length > 0); return; } - listEl.innerHTML = filtered.map(b => ` -
+ listEl.innerHTML = filtered.map(b => { + const state = stateOf(b); + const inFlight = state === 'capturing' || state === 'reading'; + return ` +
- ${b.summary ? `

${esc(b.summary)}

` : ''} - ${b.topics?.length ? `
${b.topics.map(t => `${esc(t)}`).join('')}
` : ''} + ${state === 'ready' + ? `${b.summary ? `

${esc(b.summary)}

` : ''} + ${b.topics?.length ? `
${b.topics.map(t => `${esc(t)}`).join('')}
` : ''}` + : `${stateNote(b, state)}${inFlight ? `
${ghostBar('100%')}${ghostBar('88%', 90)}${ghostBar('42%', 180)}
` : ''}`} - `).join(''); + `;}).join(''); + bindRetryButtons(listEl); listEl.querySelectorAll('.lib-card').forEach(card => { card.addEventListener('click', (e) => { if (!e.target.closest('.more-btn-list')) openLibraryModal(card.dataset.id); @@ -2400,8 +2619,11 @@ listEl.style.display = 'none'; gridEl.style.display = ''; if (!filtered.length) { gridEl.innerHTML = emptyState(bookmarks.length > 0); return; } - gridEl.innerHTML = filtered.map(b => ` -
+ gridEl.innerHTML = filtered.map(b => { + const state = stateOf(b); + const inFlight = state === 'capturing' || state === 'reading'; + return ` +
@@ -2409,11 +2631,14 @@ ${b.type ? `${esc(b.type)}` : ''}
${esc(hostname(b.url))}${b.author ? ` · ${esc(b.author)}` : ''}
- ${b.summary ? `

${esc(b.summary)}

` : ''} - ${b.topics?.length ? `
${b.topics.slice(0, 6).map(t => `${esc(t)}`).join('')}
` : ''} + ${state === 'ready' + ? `${b.summary ? `

${esc(b.summary)}

` : ''} + ${b.topics?.length ? `
${b.topics.slice(0, 6).map(t => `${esc(t)}`).join('')}
` : ''}` + : `${stateNote(b, state)}${inFlight ? `
${ghostBar('100%')}${ghostBar('80%', 90)}${ghostBar('50%', 180)}
` : ''}`}
- `).join(''); + `;}).join(''); + bindRetryButtons(gridEl); gridEl.querySelectorAll('.lib-grid-card').forEach(card => { card.addEventListener('click', (e) => { if (!e.target.closest('.more-btn-list')) openLibraryModal(card.dataset.id); @@ -2522,6 +2747,10 @@ const favStar = ``; const urlSafe = rh.isSafeUrl && rh.isSafeUrl(item.url); + // An item that is still being read, or that failed, says so here too — otherwise + // the modal is a wall of blank fields with no explanation (the reported bug). + const modalState = stateOf(item); + const content = document.getElementById('modal-content'); content.innerHTML = ` ${item.screenshot ? `${esc(item.title || '')}` : ''} @@ -2532,6 +2761,7 @@
${item.url ? `` : ''} ${tagsHtml} + ${modalState !== 'ready' ? `
${stateNote(item, modalState)}
` : ''} ${lead ? `
${esc(lead.field.label)}
${esc(String(lead.value))}
` : ''} ${displayHtml} ${editSection} @@ -2544,6 +2774,7 @@ document.getElementById('modal-cancel').onclick = closeModal; document.getElementById('modal-save').onclick = () => saveItemEdits(item.id); document.getElementById('modal-fav').onclick = () => toggleItemFavorite(item.id); + bindRetryButtons(content); document.getElementById('modal-overlay').classList.add('open'); } @@ -2946,7 +3177,7 @@ agentBtn.disabled = true; closeAgentMenu(); status.className = 'add-status'; - status.textContent = '📸 Capturing…'; + status.textContent = ''; try { const res = await fetch(window.collectionHelpers.addUrl(activeCollection), { @@ -2962,8 +3193,9 @@ } bookmarks.unshift(data); input.value = ''; - status.textContent = `✓ Added ${data.title || data.url}`; - setTimeout(() => { status.textContent = ''; }, 3000); + // The card itself now shows capture progress, so the header stops narrating it; + // duplicating the state in two places just makes the chrome louder. + status.textContent = ''; if (activeCollection === 'inspiration') buildTagCloud(); else renderLibraryTopicCloud(); applyFilters(); @@ -3406,8 +3638,8 @@

Worth knowing

// load() is called by the module script below after collectionHelpers are initialized From 2407a6f7dc715eef26d663dd9a338e30b8031f9d Mon Sep 17 00:00:00 2001 From: Seanathon Date: Wed, 19 Aug 2026 01:48:54 -0700 Subject: [PATCH 8/9] fix(ui): scope the welcome footer to the Getting started tab "Read the docs on GitHub" and "Maybe later" are next steps for someone still deciding whether to use Board. Beside a prompt editor or a delete confirmation they read as noise, and "Maybe later" is meaningless there. The footer moves inside the Getting started panel. The other tabs rely on the modal's own close button, which they already had. Co-Authored-By: Claude Opus 5 --- public/index.html | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/public/index.html b/public/index.html index 2ec3c3e..eb56324 100644 --- a/public/index.html +++ b/public/index.html @@ -3928,6 +3928,15 @@

Worth knowing

I found it genuinely useful. I hope you do too.

Sean, the original creator of Board.

+ + + - `); wireWelcomeTabs(); From 81ad36b7bc3ff9ed2735a12d2387943bc4b05a6a Mon Sep 17 00:00:00 2001 From: Seanathon Date: Wed, 19 Aug 2026 01:51:18 -0700 Subject: [PATCH 9/9] fix(ui): give the file picker the app's button vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file input renders as a browser-native control that no stylesheet can touch, so it sat in the data panel looking like nothing else in the app. The real input is now visually hidden and a label carries the button styling. Hidden by clipping rather than display:none — the latter drops the control out of the tab order and makes it unreachable by keyboard — with the focus ring forwarded from the input to the label. The native control displayed the chosen filename itself, so the label now says whether a file is picked and the note below carries the name and size. Restore becomes the primary button in that block, since choosing a file is a precondition and restoring is the action. Two ghost buttons side by side gave no hierarchy at all. Co-Authored-By: Claude Opus 5 --- public/index.html | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/public/index.html b/public/index.html index eb56324..b882d53 100644 --- a/public/index.html +++ b/public/index.html @@ -883,6 +883,25 @@ .data-note { font-size: 12px; color: var(--text-3); line-height: 1.5; } .data-file { font-size: 12px; color: var(--text-2); } + /* A file input cannot be styled, so the real control is visually hidden and a + label carries the button vocabulary. Hidden by clipping, NOT display:none — + the latter drops it out of the tab order and makes the control unreachable by + keyboard. The focus ring is forwarded from the input to the label. */ + .file-pick input[type="file"] { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } + .file-pick label { display: inline-block; } + .file-pick input[type="file"]:focus-visible + label { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + /* The one destructive path in the app. It stays visually quiet until armed, then states the consequence in plain words and demands a typed confirmation — a checkbox alone is too easy to tick past. */ @@ -3780,9 +3799,12 @@ setTimeout(() => { exportNote.textContent = 'Saved to your downloads.'; }, 1500); }); + const importLabel = document.getElementById('data-import-label'); fileInput.addEventListener('change', () => { const f = fileInput.files && fileInput.files[0]; - importBtn.disabled = !f; + importBtn.disabled = !f || fresh.checked; + // The native control showed the filename itself; the styled label has to say it. + importLabel.textContent = f ? 'Choose a different file' : 'Choose a file'; importNote.textContent = f ? `${f.name} · ${(f.size / 1048576).toFixed(1)} MB` : ''; updateWipeArm(); }); @@ -3953,8 +3975,11 @@

Take it with you

Bring it back

Restore a backup, on this machine or a new one. Anything already here is left untouched: boards keep their own fields, and items you already have are skipped rather than rewritten.

- - + + + + +