Skip to content

feat: capture states, data portability, and a generic first-run - #10

Merged
Seanathon merged 10 commits into
mainfrom
feat/capture-states-and-data-portability
Aug 19, 2026
Merged

feat: capture states, data portability, and a generic first-run#10
Seanathon merged 10 commits into
mainfrom
feat/capture-states-and-data-portability

Conversation

@Seanathon

Copy link
Copy Markdown
Owner

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 status nor error_reason, both of which hydrate already 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 captured event 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: importRecords only had mappers for inspiration and library, so exporting a composed board and re-importing it threw No importer mapping registered. Nothing consumed a whole ExportDocument, and because ExportAsset carries paths rather than bytes, restoring on another machine left every image 404ing.

  • A generic descriptor-driven mapper, the exact inverse of export's toRecord
  • import-document, non-destructive at both levels (existing boards keep their descriptor; items dedupe on id)
  • wipe-items, requiring a literal confirm: 'delete'
  • A streamed ustar tar (GET/POST /api/backup) carrying the manifest plus image bytes

Base64-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.ts shipped a hardcoded brief for one specific product. The default is now generic (keeping the untrusted-content guard) and overridable at runtime from a new setting table, which lives in the same SQLite file so "copy one file and walk away" stays true. Only *.system_prompt keys 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 delete is 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-label had 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. Now apply_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, because pending is the signal the new skeleton keys off, and 150 fully-enriched legacy items sat at pending forever. 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

Seanathon and others added 10 commits August 19, 2026 00:22
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>
@Seanathon
Seanathon merged commit 07c8f10 into main Aug 19, 2026
2 checks passed
@Seanathon
Seanathon deleted the feat/capture-states-and-data-portability branch August 19, 2026 09:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant