feat: capture states, data portability, and a generic first-run - #10
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Two modal/card refinements.
`.field-group-label` was emitted by the modal template but had no CSS rule at
all, so a group name ("meta", "design") rendered as loose body text above its
grid. Groups now render as tabs reusing the existing .modal-tabs vocabulary —
one group in view at a time, so a long design analysis stops pushing the rest
of the modal off-screen. A single group still renders as a plain grid, since a
lone tab is a heading pretending to be a control.
The in-flight skeleton was three pills, which read as a progress bar rather
than a card. It now ghosts the real geometry it stands in for: a title bar, a
tier-badge block, two lines at the .card-steal cadence (12px/1.4, two-line
clamp), and a wrapping row of pills at .tag's height and radius.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oard
A fresh install seeded `reflection.apply_to_naruki` / "Apply to Naruki" — a
field named after the author's own product, which is dead weight to every
other person who installs this. The enrichment prompt had been half
generalized already ("a specific marketing site"), leaving the key and label
behind.
The field is now `reflection.apply_to_your_work` / "Apply to your work", and
the prompt asks how the reader could use the pattern on their own project.
A test asserts the seeded descriptor names no personal project, so this
cannot quietly come back.
Existing installs are unaffected: a board's descriptor is stored per-board at
seed time, so this changes what NEW installs get, not what current ones hold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Export emitted a document nothing could read back. `importRecords` only had mappers for `inspiration` and `library`, so exporting a composed board and re-importing it threw "No importer mapping registered"; no code path recreated a board from a document at all; and because ExportAsset carries paths rather than bytes, a restore on another machine left every image 404ing. An export was not a backup. Four pieces: - A generic descriptor-driven mapper, the exact inverse of export's toRecord. The two hand-written mappers stay for the legacy flat files whose field selection must not change; every other board now falls through to it. - `import-document`, consuming a whole ExportDocument. Non-destructive at both levels: an existing board keeps its name, view and descriptor, and items dedupe on the preserved id, so re-importing your own backup is a no-op. - `wipe-items` for "start from a fresh state". Items and image files go; boards and descriptors survive, since those encode the shape you built. Requires a literal confirm:'delete' so a stray POST cannot empty a collection. - A streamed ustar tar (`GET`/`POST /api/backup`) carrying manifest.json plus the screenshot bytes. Base64-in-JSON was the obvious alternative and is unusable at real size: ~110MB of images inflates past 140MB, which a browser must build as one string and parse back. Verified against system tar(1), which lists and extracts it, so a backup is inspectable without this app. The raw-body parser and 4GB limit are scoped to the restore route alone. Extraction resolves entries by basename and verifies containment, so a hostile archive cannot write outside the screenshots directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/add.ts shipped a hardcoded brief for the author's own product — every install analyzed sites against a stranger's positioning, and the schema field was `apply_to_naruki`. The default is now generic (and keeps the untrusted-content guard), the schema field matches the seeded descriptor, and the prompt is no longer fixed at all. A `setting` key/value table holds runtime config. It lives in the same SQLite file as the collection rather than a sidecar JSON, so "copy one file and walk away" stays true and settings ride along in a backup for free. `systemPromptFor` resolves the lens at call time, so an override saved in the UI applies to the next capture with no restart. A blank override falls back to the default rather than sending an empty system prompt to the model. GET/PATCH /api/settings expose it. The store is generic; the route is not — only `*.system_prompt` keys are writable, so an open PATCH cannot scribble arbitrary keys into the file the collection lives in. GET also serves the built-in default, so the UI can show it as placeholder text and offer a restore instead of making "empty" and "default" look identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The welcome guide was reachable only from an empty board — so once you had items, which is exactly when a backup starts to matter, there was no way back to it and no UI for import/export at all. A small fixed button now opens it from anywhere, and the guide gains two tabs beside the existing one, reusing the item modal's tab vocabulary rather than inventing a second pattern. Your data: download the whole collection as one .tar, or restore one. Export is a plain navigation, not a fetch, so the browser streams ~100MB to disk instead of holding it in memory. Restoring says what actually happened — created, and how many were already there and left alone. "Start from a fresh state" is armed in two steps: the checkbox reveals the consequence in plain words, and the operation stays disabled until `delete` is typed AND a file is chosen, so a wipe can never happen without something to restore from. A backup downloads before anything is deleted; it is the only undo this action has. System: the analysis lens, in an editor sized for a ten-line prompt. The built-in default renders as placeholder text so "empty" and "default" stay distinguishable, and clearing the box restores the default rather than sending an empty prompt. The per-board enrichment prompt editor grows from two rows to eight for the same reason. The ai-nudge moves up to share the corner with the new button. Verified against the real collection: a 99MB / 126-entry backup streams out in 0.14s, and restoring it reports 0 created, 157 skipped, 4 boards untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
One conflict, in applyFilters. Both sides changed the same branch point: main added a descriptor-driven predicate for composed boards, this branch added the in-flight bypass that keeps a just-added item visible. Resolved by keeping both, and extending the bypass into main's new branch: a composed board's descriptor facets are precisely the fields enrichment has not filled yet, so without it an in-flight item matches nothing and vanishes from the board it was just added to — the same bug the bypass exists to fix, on the boards most likely to hit it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Nine commits covering capture feedback, a data-portability surface, and two personal-project traces removed from a fresh install.
Capture states
An item looked identical whether it was fully enriched, mid-capture, or dead from a timeout: the frontend rendered neither
statusnorerror_reason, both of whichhydratealready shipped. A pasted URL produced an empty slot you had to refresh to resolve, and a failed item silently pretended to be fine.Cards now ghost their real geometry while in flight and fill in the order data arrives — capture publishes a
capturedevent carrying title and screenshot, so the page appears while the AI read is still running. Failures state the reason (allowlisted, 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.The timeout was a real bug, not just a display gap. Capture and the LLM read share one job under a fixed 60s budget. Two measured Claude-CLI enrichments took 90s and 99s, so every AI-enabled capture was a coin flip. The budget is now 180s and configurable via
CAPTURE_TIMEOUT_MS.Backup and restore
Export emitted a document nothing could read back:
importRecordsonly had mappers forinspirationandlibrary, so exporting a composed board and re-importing it threwNo importer mapping registered. Nothing consumed a wholeExportDocument, and becauseExportAssetcarries paths rather than bytes, restoring on another machine left every image 404ing.toRecordimport-document, non-destructive at both levels (existing boards keep their descriptor; items dedupe on id)wipe-items, requiring a literalconfirm: 'delete'GET/POST /api/backup) carrying the manifest plus image bytesBase64-in-JSON was the obvious alternative and is unusable at real size: ~110MB of screenshots inflates past 140MB, which a browser must build as one string and parse back. The tar is verified against system
tar(1), so a backup is inspectable without this app. Extraction resolves by basename and verifies containment, so a hostile archive cannot write outside the screenshots directory.Editable analysis prompt
src/add.tsshipped a hardcoded brief for one specific product. The default is now generic (keeping the untrusted-content guard) and overridable at runtime from a newsettingtable, which lives in the same SQLite file so "copy one file and walk away" stays true. Only*.system_promptkeys are writable.UI
A fixed button opens the guide from anywhere — it was previously reachable only from an empty board, which is exactly when a backup does not yet matter. The guide gains Data and System tabs reusing the item modal's tab vocabulary. "Start from a fresh state" arms in two steps and stays disabled until
deleteis typed and a file is chosen, so a wipe cannot happen without something to restore from; a backup downloads first.Also: modal field groups became tabs (
.field-group-labelhad no CSS rule at all and rendered as body text), and the file picker got the app's button vocabulary.Seeded boards
A fresh install seeded
reflection.apply_to_naruki/ "Apply to Naruki" — the author's own product, dead weight to everyone else. Nowapply_to_your_work, with a test asserting the seeded descriptor names no personal project.Behaviour change worth review: Story 3.3's "everything imports as pending" is superseded. An import that already carries an AI read stores as
done, becausependingis the signal the new skeleton keys off, and 150 fully-enriched legacy items sat atpendingforever. Records without enrichment still import as pending.Verification
572 tests passing, typecheck clean. Verified against a real 157-item collection: a 99MB / 126-entry backup streams out in 0.14s, and restoring it reports 0 created, 157 skipped, 4 boards untouched. Both previously-broken items recovered through the new retry path.
🤖 Generated with Claude Code
https://claude.ai/code/session_01C83mrW9X8zBLY1sSZRgdCa