diff --git a/docs/superpowers/plans/2026-04-24-relicario-extension-1c-gamma1.md b/docs/superpowers/plans/2026-04-24-relicario-extension-1c-gamma1.md new file mode 100644 index 0000000..2f1c74a --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-relicario-extension-1c-gamma1.md @@ -0,0 +1,2118 @@ +# Plan 1C-γ₁ Implementation Plan — Attachments + Document type + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire the Rust attachment-encrypt surface into the extension, add `GitHost.putBlob` with Git Data API fallback for >900 KB blobs, ship the Document item type, and surface attachments in the compact disclosure pattern across all type forms. + +**Architecture:** Layered bottom-up so each task ships a working increment. Types tighten first (1), then transport (2-4), then service-worker logic (5-6), then popup UI (7-10). The disclosure component is shared between all type forms; Document type adds the signature-block treatment for its required `primary_attachment`. + +**Tech Stack:** TypeScript, vitest + happy-dom (popup tests), webpack, Rust core via WASM (`attachment_encrypt` / `attachment_decrypt` already exposed). No Rust changes — the core is complete. + +**Spec:** `docs/superpowers/specs/2026-04-24-relicario-extension-1c-gamma1-design.md` (commit `6f5ef43`). + +--- + +## File overview + +**Shared types & messages:** +- `extension/src/shared/types.ts` — tighten `attachment_caps: unknown` → typed `AttachmentCaps` (Task 1) +- `extension/src/shared/messages.ts` — add `upload_attachment`, `download_attachment` message types (Task 6) + +**Service worker:** +- `extension/src/service-worker/git-host.ts` — add `putBlob`, `getBlob`, `deleteBlob` to interface; export `BLOB_THRESHOLD_BYTES` constant (Task 2) +- `extension/src/service-worker/github.ts` — implement the three new methods (Task 3) +- `extension/src/service-worker/gitea.ts` — implement the three new methods (Task 4) +- `extension/src/service-worker/vault.ts` — add `addAttachmentToItem`, `removeAttachmentsFromItem` helpers (Task 5) +- `extension/src/service-worker/router/popup-only.ts` — add 2 message handlers (Task 6) + +**Popup:** +- `extension/src/popup/components/attachments-disclosure.ts` — NEW shared component (Task 7) +- `extension/src/popup/components/types/document.ts` — NEW Document type form + detail (Task 9) +- `extension/src/popup/components/item-form.ts` — route Document case (Task 10) +- `extension/src/popup/components/item-list.ts` — add 📎 indicator (Task 8) +- `extension/src/popup/components/types/{login,secure-note,identity,card,key,totp}.ts` — wire disclosure (Task 8) +- `extension/src/popup/styles.css` — attachment-row + signature-block rules (Tasks 7, 9) + +**Tests:** +- `extension/src/service-worker/__tests__/git-host.test.ts` — NEW (putBlob threshold + Git Data API sequence — Task 3 introduces, Task 4 extends) +- `extension/src/popup/components/__tests__/attachments-disclosure.test.ts` — NEW (Task 7) +- `extension/src/popup/components/types/__tests__/document.save.test.ts` — NEW (Task 9) +- `extension/src/service-worker/router/__tests__/router.test.ts` — extended with 4 router cases (Task 6) + +Working dir: `/home/alee/Sources/relicario`. Branch: main. Direct-to-main per project convention. Do NOT push. + +--- + +## Task 1: Tighten `attachment_caps` type + +**Files:** +- Modify: `extension/src/shared/types.ts` + +- [ ] **Step 1: Edit `extension/src/shared/types.ts`** — find the `VaultSettings` interface (around line 199–204) and add an `AttachmentCaps` type definition above it. Then change the field's type. + +Find: +```ts +export interface VaultSettings { + trash_retention: TrashRetention; + field_history_retention: HistoryRetention; + generator_defaults: GeneratorRequest; + attachment_caps: unknown; // opaque — γ tightens + autofill_origin_acks: { [hostname: string]: number }; +} +``` + +Replace the comment line and add the type definition just above the interface: + +```ts +/// Optional per-level caps on attachment plaintext sizes. +/// All fields optional; if undefined that level is uncapped. +/// γ₁ enforces; γ₂ ships the configuration UI. +export interface AttachmentCaps { + per_attachment_max_bytes?: number; + per_item_max_bytes?: number; + per_vault_soft_max_bytes?: number; + per_vault_hard_max_bytes?: number; +} + +export interface VaultSettings { + trash_retention: TrashRetention; + field_history_retention: HistoryRetention; + generator_defaults: GeneratorRequest; + attachment_caps: AttachmentCaps; + autofill_origin_acks: { [hostname: string]: number }; +} +``` + +- [ ] **Step 2: Verify type-check** — `cd extension && bunx tsc --noEmit 2>&1 | tail -3`. Expected: zero errors. (Existing code that initialized `attachment_caps: {}` will still satisfy `AttachmentCaps` because all fields are optional.) + +- [ ] **Step 3: Run vitest** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 128 passed. + +- [ ] **Step 4: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/shared/types.ts +git commit -m "$(cat <<'EOF' +feat(ext/shared): tighten VaultSettings.attachment_caps to AttachmentCaps + +All four cap fields optional; undefined means uncapped. γ₁ enforces; +γ₂ adds the configuration UI. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Extend `GitHost` interface + +**Files:** +- Modify: `extension/src/service-worker/git-host.ts` + +- [ ] **Step 1: Edit `extension/src/service-worker/git-host.ts`** — find the `GitHost` interface (around line 7) and add three methods after `listDir`: + +```ts +export interface GitHost { + /// Read a single file from the repo, returning its raw bytes. + readFile(path: string): Promise; + + /// Create or update a file in the repo with a commit message. + writeFile(path: string, content: Uint8Array, message: string): Promise; + + /// Delete a file from the repo with a commit message. + deleteFile(path: string, message: string): Promise; + + /// List file names in a directory (non-recursive). + listDir(path: string): Promise; + + /// Write an opaque binary blob to the repo. Optimized for large + /// attachments — implementations switch from Contents API to Git + /// Data API when content exceeds BLOB_THRESHOLD_BYTES. + /// Returns the path that was written (same as input, for chaining). + putBlob(path: string, content: Uint8Array, message: string): Promise; + + /// Read an opaque binary blob from the repo. Same semantics as + /// readFile for current sizes. Distinct method so we can later add + /// streaming/chunked reads for very large blobs. + getBlob(path: string): Promise; + + /// Delete a blob from the repo. Currently identical to deleteFile; + /// kept distinct for symmetry with putBlob. + deleteBlob(path: string, message: string): Promise; +} +``` + +- [ ] **Step 2: Add the threshold constant** — append after the interface: + +```ts +/// Pre-base64 byte size at which putBlob switches from Contents API to +/// Git Data API. 900 KB leaves headroom for base64 inflation (1.33×) and +/// JSON wrapping under both GitHub's and Gitea's Contents API soft-limits. +export const BLOB_THRESHOLD_BYTES = 900 * 1024; +``` + +- [ ] **Step 3: Verify type-check** — `cd extension && bunx tsc --noEmit 2>&1 | tail -10`. **Expected: TypeScript will REPORT ERRORS** because `GitHubHost` and `GiteaHost` no longer satisfy the extended interface (they don't implement the three new methods yet). This is expected — Tasks 3 and 4 add the implementations. + +To allow the project to keep compiling between Task 2 and Task 3, add a temporary stub to each impl. Edit `extension/src/service-worker/github.ts` — append these stubs at the END of the `GitHubHost` class body, just before the closing `}`: + +```ts + async putBlob(path: string, _content: Uint8Array, _message: string): Promise { + throw new Error(`GitHubHost.putBlob not implemented — Task 3`); + } + + async getBlob(path: string): Promise { + return this.readFile(path); + } + + async deleteBlob(path: string, message: string): Promise { + return this.deleteFile(path, message); + } +``` + +Same stub for `extension/src/service-worker/gitea.ts` — append before the closing `}` of the `GiteaHost` class: + +```ts + async putBlob(path: string, _content: Uint8Array, _message: string): Promise { + throw new Error(`GiteaHost.putBlob not implemented — Task 4`); + } + + async getBlob(path: string): Promise { + return this.readFile(path); + } + + async deleteBlob(path: string, message: string): Promise { + return this.deleteFile(path, message); + } +``` + +- [ ] **Step 4: Re-verify** — `cd extension && bunx tsc --noEmit 2>&1 | tail -3`. Expected: zero errors. + +- [ ] **Step 5: Run vitest** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 128 passed. + +- [ ] **Step 6: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/service-worker/git-host.ts \ + extension/src/service-worker/github.ts \ + extension/src/service-worker/gitea.ts +git commit -m "$(cat <<'EOF' +feat(ext/sw): extend GitHost interface with putBlob/getBlob/deleteBlob + +Adds the three blob ops to the interface and a BLOB_THRESHOLD_BYTES +constant. Both GitHubHost and GiteaHost ship temporary stubs so the +build stays green until tasks 3-4 fill in real implementations. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: GitHubHost — putBlob with Git Data API fallback (+ tests) + +**Files:** +- Modify: `extension/src/service-worker/github.ts` — replace stubs with real implementations +- Create: `extension/src/service-worker/__tests__/git-host.test.ts` — NEW test file + +- [ ] **Step 1: Create the test file** at `extension/src/service-worker/__tests__/git-host.test.ts` with this content: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GitHubHost } from '../github'; +import { BLOB_THRESHOLD_BYTES } from '../git-host'; + +const REPO = 'alee/test-vault'; +const TOKEN = 'ghp_TEST'; + +function setupFetch(): ReturnType { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('GitHubHost.putBlob', () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it('uses Contents API when content is at threshold', async () => { + const fetchMock = setupFetch(); + // First fetch: GET existing file → 404 (create path) + fetchMock.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' } as Response); + // Second fetch: PUT contents → 201 created + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({}) } as Response); + + const host = new GitHubHost(REPO, TOKEN); + const small = new Uint8Array(BLOB_THRESHOLD_BYTES); // exactly threshold + await host.putBlob('attachments/abc.bin', small, 'add abc'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const lastCall = fetchMock.mock.calls[1]; + expect(lastCall[0]).toContain('/contents/'); // Contents API + expect(lastCall[1]?.method).toBe('PUT'); + }); + + it('uses Git Data API fallback when content exceeds threshold', async () => { + const fetchMock = setupFetch(); + // 1. POST /git/blobs → blob SHA + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'blobsha111' }) } as Response); + // 2. GET /git/refs/heads/main → ref { object: { sha: 'commitsha' } } + fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ object: { sha: 'commitsha111' } }) } as Response); + // 3. GET /git/commits/commitsha111 → commit { tree: { sha: 'treesha' } } + fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ tree: { sha: 'treesha111' } }) } as Response); + // 4. POST /git/trees → new tree { sha: 'newtreesha' } + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newtreesha111' }) } as Response); + // 5. POST /git/commits → new commit { sha: 'newcommitsha' } + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newcommitsha111' }) } as Response); + // 6. PATCH /git/refs/heads/main → updated + fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) } as Response); + + const host = new GitHubHost(REPO, TOKEN); + const big = new Uint8Array(BLOB_THRESHOLD_BYTES + 1); // one byte over + await host.putBlob('attachments/big.bin', big, 'add big'); + + expect(fetchMock).toHaveBeenCalledTimes(6); + expect(fetchMock.mock.calls[0][0]).toContain('/git/blobs'); + expect(fetchMock.mock.calls[1][0]).toContain('/git/refs/heads/'); + expect(fetchMock.mock.calls[3][0]).toContain('/git/trees'); + expect(fetchMock.mock.calls[4][0]).toContain('/git/commits'); + expect(fetchMock.mock.calls[5][1]?.method).toBe('PATCH'); + }); + + it('throws when blob creation fails in fallback path', async () => { + const fetchMock = setupFetch(); + fetchMock.mockResolvedValueOnce({ ok: false, status: 422, statusText: 'Unprocessable', text: async () => 'too big' } as Response); + + const host = new GitHubHost(REPO, TOKEN); + const big = new Uint8Array(BLOB_THRESHOLD_BYTES + 1); + await expect(host.putBlob('attachments/big.bin', big, 'add big')).rejects.toThrow(/422|too big/); + }); +}); +``` + +- [ ] **Step 2: Run the new tests — they should FAIL** (because the stub throws): + +```bash +cd extension && bun run test src/service-worker/__tests__/git-host.test.ts 2>&1 | tail -10 +``` + +Expected: 3 tests FAIL with error matching the stub message. + +- [ ] **Step 3: Implement real `putBlob`, `getBlob`, `deleteBlob` in `extension/src/service-worker/github.ts`**. + +Replace the three stub methods (added in Task 2) with these implementations. Add a `private repoPath` and `private apiBase` member if not already present (they may need adjusting from the constructor — see existing constructor). The existing constructor sets `baseUrl = api.github.com/repos/{repoPath}/contents`; we need to also keep the bare `repoPath` for Git Data API URLs. + +Modify the constructor to store `repoPath` as a class member: + +```ts +export class GitHubHost implements GitHost { + private baseUrl: string; // ...repos/{repoPath}/contents + private gitApiBase: string; // ...repos/{repoPath}/git + private branch: string = 'main'; + private headers: Record; + + constructor(repoPath: string, apiToken: string) { + this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`; + this.gitApiBase = `https://api.github.com/repos/${repoPath}/git`; + this.headers = { + 'Authorization': `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + 'Accept': 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + } +``` + +Then replace the three stub methods with: + +```ts + async putBlob(path: string, content: Uint8Array, message: string): Promise { + if (content.length <= BLOB_THRESHOLD_BYTES) { + // Contents API path — same as writeFile + await this.writeFile(path, content, message); + return path; + } + + // Git Data API fallback for large blobs. + // 1. Create the blob. + const blobResp = await fetch(`${this.gitApiBase}/blobs`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + content: uint8ArrayToBase64(content), + encoding: 'base64', + }), + }); + if (!blobResp.ok) { + const text = await blobResp.text(); + throw new Error(`GitHub putBlob create-blob ${path}: ${blobResp.status} ${text}`); + } + const { sha: blobSha } = await blobResp.json() as { sha: string }; + + // 2. Get current ref (branch tip commit SHA). + const refResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, { headers: this.headers }); + if (!refResp.ok) { + throw new Error(`GitHub putBlob get-ref ${this.branch}: ${refResp.status} ${refResp.statusText}`); + } + const { object: { sha: commitSha } } = await refResp.json() as { object: { sha: string } }; + + // 3. Get current commit's tree SHA. + const commitResp = await fetch(`${this.gitApiBase}/commits/${commitSha}`, { headers: this.headers }); + if (!commitResp.ok) { + throw new Error(`GitHub putBlob get-commit ${commitSha}: ${commitResp.status} ${commitResp.statusText}`); + } + const { tree: { sha: baseTreeSha } } = await commitResp.json() as { tree: { sha: string } }; + + // 4. Create new tree adding the blob at `path` on top of base tree. + const treeResp = await fetch(`${this.gitApiBase}/trees`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + base_tree: baseTreeSha, + tree: [{ path, mode: '100644', type: 'blob', sha: blobSha }], + }), + }); + if (!treeResp.ok) { + const text = await treeResp.text(); + throw new Error(`GitHub putBlob create-tree: ${treeResp.status} ${text}`); + } + const { sha: newTreeSha } = await treeResp.json() as { sha: string }; + + // 5. Create new commit pointing at the new tree. + const newCommitResp = await fetch(`${this.gitApiBase}/commits`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + message, + tree: newTreeSha, + parents: [commitSha], + }), + }); + if (!newCommitResp.ok) { + const text = await newCommitResp.text(); + throw new Error(`GitHub putBlob create-commit: ${newCommitResp.status} ${text}`); + } + const { sha: newCommitSha } = await newCommitResp.json() as { sha: string }; + + // 6. Fast-forward branch to the new commit. + const updateRefResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, { + method: 'PATCH', + headers: this.headers, + body: JSON.stringify({ sha: newCommitSha, force: false }), + }); + if (!updateRefResp.ok) { + const text = await updateRefResp.text(); + throw new Error(`GitHub putBlob update-ref: ${updateRefResp.status} ${text}`); + } + + return path; + } + + async getBlob(path: string): Promise { + return this.readFile(path); + } + + async deleteBlob(path: string, message: string): Promise { + return this.deleteFile(path, message); + } +``` + +Also: at the top of the file, ensure `import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host';` (add `BLOB_THRESHOLD_BYTES`). + +- [ ] **Step 4: Re-run the new tests** — `cd extension && bun run test src/service-worker/__tests__/git-host.test.ts 2>&1 | tail -10`. Expected: 3 passed. + +- [ ] **Step 5: Run full vitest** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 131 passed (was 128 + 3 new). + +- [ ] **Step 6: Type-check** — `cd extension && bunx tsc --noEmit 2>&1 | tail -3`. Expected: zero errors. + +- [ ] **Step 7: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/service-worker/github.ts \ + extension/src/service-worker/__tests__/git-host.test.ts +git commit -m "$(cat <<'EOF' +feat(ext/sw): GitHubHost.putBlob with Git Data API fallback + +Blobs ≤ BLOB_THRESHOLD_BYTES (900 KB) take the Contents API path +(same as writeFile). Larger blobs use the Git Data API: POST blob, +GET ref + commit, POST tree (with base_tree), POST commit, PATCH ref. +Tests cover both paths plus error propagation. + +getBlob/deleteBlob are thin wrappers over readFile/deleteFile. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: GiteaHost — putBlob with Git Data API fallback + +**Files:** +- Modify: `extension/src/service-worker/gitea.ts` +- Modify: `extension/src/service-worker/__tests__/git-host.test.ts` — add Gitea test cases + +- [ ] **Step 1: Add Gitea test cases** at the end of `extension/src/service-worker/__tests__/git-host.test.ts`. Append: + +```ts +import { GiteaHost } from '../gitea'; + +describe('GiteaHost.putBlob', () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it('uses Contents API when content is at threshold', async () => { + const fetchMock = setupFetch(); + fetchMock.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' } as Response); + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({}) } as Response); + + const host = new GiteaHost('https://git.example.com', REPO, TOKEN); + const small = new Uint8Array(BLOB_THRESHOLD_BYTES); + await host.putBlob('attachments/abc.bin', small, 'add abc'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1][0]).toContain('/contents/'); + expect(fetchMock.mock.calls[1][1]?.method).toBe('PUT'); + }); + + it('uses Git Data API fallback when content exceeds threshold', async () => { + const fetchMock = setupFetch(); + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'blobsha111' }) } as Response); + fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ object: { sha: 'commitsha111' } }) } as Response); + fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ tree: { sha: 'treesha111' } }) } as Response); + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newtreesha111' }) } as Response); + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newcommitsha111' }) } as Response); + fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) } as Response); + + const host = new GiteaHost('https://git.example.com', REPO, TOKEN); + const big = new Uint8Array(BLOB_THRESHOLD_BYTES + 1); + await host.putBlob('attachments/big.bin', big, 'add big'); + + expect(fetchMock).toHaveBeenCalledTimes(6); + // Gitea uses /api/v1/ prefix + expect(fetchMock.mock.calls[0][0]).toContain('/api/v1/repos/'); + expect(fetchMock.mock.calls[0][0]).toContain('/git/blobs'); + expect(fetchMock.mock.calls[5][1]?.method).toBe('PATCH'); + }); +}); +``` + +Note: the GiteaHost constructor signature in the existing file is `constructor(hostUrl: string, repoPath: string, apiToken: string)`. The test creates with `new GiteaHost('https://git.example.com', REPO, TOKEN)` — verify against actual constructor signature when implementing. + +- [ ] **Step 2: Run the new Gitea tests — they should FAIL** (stub still throws): + +```bash +cd extension && bun run test src/service-worker/__tests__/git-host.test.ts 2>&1 | tail -10 +``` + +Expected: 2 new tests fail (3 GitHub tests still pass). + +- [ ] **Step 3: Implement real methods in `extension/src/service-worker/gitea.ts`**. + +The GiteaHost constructor sets `baseUrl = {hostUrl}/api/v1/repos/{repoPath}/contents`. We need a `gitApiBase = {hostUrl}/api/v1/repos/{repoPath}/git`. Modify the constructor (similar pattern to Task 3): + +```ts +export class GiteaHost implements GitHost { + private baseUrl: string; + private gitApiBase: string; + private branch: string = 'main'; + private headers: Record; + + constructor(hostUrl: string, repoPath: string, apiToken: string) { + this.baseUrl = `${hostUrl}/api/v1/repos/${repoPath}/contents`; + this.gitApiBase = `${hostUrl}/api/v1/repos/${repoPath}/git`; + this.headers = { + 'Authorization': `token ${apiToken}`, + 'Content-Type': 'application/json', + }; + } +``` + +(Verify auth header — GitHub uses `Bearer`, Gitea uses `token`. Existing GiteaHost code already handles this; preserve the existing format.) + +Replace the three stub methods with implementations parallel to GitHub's. The body of `putBlob` is structurally identical to GitHub's — just uses `this.gitApiBase` (which already has the `/api/v1/` prefix). Copy the GitHub implementation verbatim, swapping `gitApiBase` references appropriately. + +```ts + async putBlob(path: string, content: Uint8Array, message: string): Promise { + if (content.length <= BLOB_THRESHOLD_BYTES) { + await this.writeFile(path, content, message); + return path; + } + + // Git Data API fallback (Gitea v1; same shape as GitHub). + const blobResp = await fetch(`${this.gitApiBase}/blobs`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + content: uint8ArrayToBase64(content), + encoding: 'base64', + }), + }); + if (!blobResp.ok) { + const text = await blobResp.text(); + throw new Error(`Gitea putBlob create-blob ${path}: ${blobResp.status} ${text}`); + } + const { sha: blobSha } = await blobResp.json() as { sha: string }; + + const refResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, { headers: this.headers }); + if (!refResp.ok) { + throw new Error(`Gitea putBlob get-ref: ${refResp.status} ${refResp.statusText}`); + } + const { object: { sha: commitSha } } = await refResp.json() as { object: { sha: string } }; + + const commitResp = await fetch(`${this.gitApiBase}/commits/${commitSha}`, { headers: this.headers }); + if (!commitResp.ok) { + throw new Error(`Gitea putBlob get-commit: ${commitResp.status} ${commitResp.statusText}`); + } + const { tree: { sha: baseTreeSha } } = await commitResp.json() as { tree: { sha: string } }; + + const treeResp = await fetch(`${this.gitApiBase}/trees`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + base_tree: baseTreeSha, + tree: [{ path, mode: '100644', type: 'blob', sha: blobSha }], + }), + }); + if (!treeResp.ok) { + const text = await treeResp.text(); + throw new Error(`Gitea putBlob create-tree: ${treeResp.status} ${text}`); + } + const { sha: newTreeSha } = await treeResp.json() as { sha: string }; + + const newCommitResp = await fetch(`${this.gitApiBase}/commits`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + message, + tree: newTreeSha, + parents: [commitSha], + }), + }); + if (!newCommitResp.ok) { + const text = await newCommitResp.text(); + throw new Error(`Gitea putBlob create-commit: ${newCommitResp.status} ${text}`); + } + const { sha: newCommitSha } = await newCommitResp.json() as { sha: string }; + + const updateRefResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, { + method: 'PATCH', + headers: this.headers, + body: JSON.stringify({ sha: newCommitSha, force: false }), + }); + if (!updateRefResp.ok) { + const text = await updateRefResp.text(); + throw new Error(`Gitea putBlob update-ref: ${updateRefResp.status} ${text}`); + } + + return path; + } + + async getBlob(path: string): Promise { + return this.readFile(path); + } + + async deleteBlob(path: string, message: string): Promise { + return this.deleteFile(path, message); + } +``` + +Add `BLOB_THRESHOLD_BYTES` to the imports at the top: `import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host';`. + +- [ ] **Step 4: Re-run tests** — `cd extension && bun run test src/service-worker/__tests__/git-host.test.ts 2>&1 | tail -10`. Expected: 5 passed (3 GitHub + 2 Gitea). + +- [ ] **Step 5: Full vitest + tsc** — `cd extension && bun run test && bunx tsc --noEmit`. Expected: 133 passed; zero TS errors. + +- [ ] **Step 6: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/service-worker/gitea.ts \ + extension/src/service-worker/__tests__/git-host.test.ts +git commit -m "$(cat <<'EOF' +feat(ext/sw): GiteaHost.putBlob with Git Data API fallback + +Same shape as GitHubHost — Gitea v1 /api/v1/ prefix; otherwise the +endpoint shapes are identical. 2 new tests; total 5 git-host tests. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Service-worker vault helpers — addAttachmentToItem + removeAttachmentsFromItem + +**Files:** +- Modify: `extension/src/service-worker/vault.ts` — add 2 helper functions + +- [ ] **Step 1: Read existing `vault.ts`** to understand the encrypt/persist patterns it already uses for items + manifest. The new helpers will reuse those primitives. + +- [ ] **Step 2: Append at the end of `extension/src/service-worker/vault.ts`** (after existing exports): + +```ts +import type { AttachmentRef, Item } from '../shared/types'; + +/// Add an AttachmentRef to an existing item. +/// Reads + decrypts the item, appends to attachments[], re-encrypts and +/// writes both item.json and the manifest entry's attachment_summaries. +/// Throws if the item is not found. +export async function addAttachmentToItem( + itemId: string, + ref: AttachmentRef, +): Promise { + const item = await loadItem(itemId); + if (!item) { + throw new Error(`addAttachmentToItem: item ${itemId} not found`); + } + item.attachments = [...item.attachments, ref]; + item.modified = Math.floor(Date.now() / 1000); + await saveItem(item); + await syncManifestEntry(itemId); +} + +/// Remove attachments matching the given IDs from the item. +/// Returns the removed AttachmentRefs (for the caller to delete underlying blobs). +/// If `idsToRemove` contains IDs not present, those are silently ignored. +export async function removeAttachmentsFromItem( + itemId: string, + idsToRemove: string[], +): Promise { + const item = await loadItem(itemId); + if (!item) { + throw new Error(`removeAttachmentsFromItem: item ${itemId} not found`); + } + const removeSet = new Set(idsToRemove); + const removed: AttachmentRef[] = []; + const kept: AttachmentRef[] = []; + for (const att of item.attachments) { + if (removeSet.has(att.id)) { + removed.push(att); + } else { + kept.push(att); + } + } + item.attachments = kept; + item.modified = Math.floor(Date.now() / 1000); + await saveItem(item); + await syncManifestEntry(itemId); + return removed; +} +``` + +The functions reference `loadItem`, `saveItem`, `syncManifestEntry` — these are existing helpers in `vault.ts`. If a helper is named slightly differently in the actual file, adapt the calls to match. If `syncManifestEntry` doesn't exist, the implementer should create a small inline helper that re-encrypts the manifest entry's `attachment_summaries` from the item's current attachments and writes the manifest. Pattern: + +```ts +async function syncManifestEntry(itemId: string): Promise { + const manifest = await loadManifest(); + const item = await loadItem(itemId); + if (!item || !manifest) return; + const entry = manifest.entries[itemId]; + if (!entry) return; + entry.attachment_summaries = item.attachments.map((a) => ({ + id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size, + })); + await saveManifest(manifest); +} +``` + +If the existing `vault.ts` already auto-syncs the manifest on item save, drop the explicit `syncManifestEntry` calls — verify by reading the existing `saveItem` implementation. + +- [ ] **Step 3: Verify type-check + tests** — `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`. Expected: zero TS errors; 133 passed (no new tests yet — these helpers are exercised via Task 6's router tests). + +- [ ] **Step 4: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/service-worker/vault.ts +git commit -m "$(cat <<'EOF' +feat(ext/sw): vault helpers for attachment add/remove + +addAttachmentToItem appends an AttachmentRef + re-syncs the manifest +entry's attachment_summaries. removeAttachmentsFromItem returns the +removed refs so the caller can deleteBlob() the underlying bytes. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Router handlers — upload_attachment + download_attachment + +**Files:** +- Modify: `extension/src/shared/messages.ts` — add 2 new message types +- Modify: `extension/src/service-worker/router/popup-only.ts` — add 2 handlers +- Modify: `extension/src/service-worker/router/__tests__/router.test.ts` — add 4 cases (2 accept + 2 reject) + +- [ ] **Step 1: Add message types** in `extension/src/shared/messages.ts`. Find the `PopupMessage` union (or wherever `update_vault_settings` is defined) and add: + +```ts + | { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer } + | { type: 'download_attachment'; itemId: string; attachmentId: string } +``` + +Then find `POPUP_ONLY_TYPES` (the Set) and add `'upload_attachment'` and `'download_attachment'`. + +Add response shapes (find the existing response-type union): + +```ts + | { type: 'upload_attachment'; data: { attachment: AttachmentRef } } + | { type: 'download_attachment'; data: { bytes: ArrayBuffer; filename: string; mimeType: string } } +``` + +Make sure `AttachmentRef` is imported at the top of messages.ts: `import type { ..., AttachmentRef } from './types';`. + +- [ ] **Step 2: Write failing router tests** in `extension/src/service-worker/router/__tests__/router.test.ts`. Append 4 test cases at the end of the relevant describe block. Pattern (model after the existing `update_vault_settings` cases): + +```ts + describe('upload_attachment / download_attachment', () => { + it('upload_attachment accepted from popup', async () => { + const result = await routeMessage( + { type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) }, + popupSender, + ); + expect(result.ok).toBeDefined(); // either ok:true or ok:false but reached the handler + }); + + it('upload_attachment rejected from content script', async () => { + const result = await routeMessage( + { type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) }, + contentSender, + ); + expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('download_attachment accepted from popup', async () => { + const result = await routeMessage( + { type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' }, + popupSender, + ); + expect(result.ok).toBeDefined(); + }); + + it('download_attachment rejected from content script', async () => { + const result = await routeMessage( + { type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' }, + contentSender, + ); + expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + }); +``` + +(Adapt `popupSender` / `contentSender` / `routeMessage` to match the names used in the existing test file. The point is: assert each new message type lives in `POPUP_ONLY_TYPES` and the router rejects content-script senders.) + +- [ ] **Step 3: Run tests — they should FAIL** because the handler cases don't exist yet: + +```bash +cd extension && bun run test 2>&1 | tail -5 +``` + +Expected: 4 new tests fail. + +- [ ] **Step 4: Implement handlers** in `extension/src/service-worker/router/popup-only.ts`. Add to the switch statement (model after the existing `update_vault_settings` case): + +```ts + case 'upload_attachment': { + try { + const session = getSessionHandle(); + if (!session) return { ok: false, error: 'locked' }; + + // Encrypt the bytes via WASM. + const plaintext = new Uint8Array(msg.bytes); + const encrypted = wasm.attachment_encrypt(session, plaintext); + // encrypted is { id: string, bytes: Uint8Array } + + // Upload via GitHost.putBlob. + const config = await getVaultConfig(); + const host = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); + const blobPath = `${config.vaultRoot}/attachments/${encrypted.id}.bin`; + await host.putBlob(blobPath, encrypted.bytes, `attach: ${msg.filename}`); + + // Build AttachmentRef and add to item. + const ref: AttachmentRef = { + id: encrypted.id, + filename: msg.filename, + mime_type: msg.mimeType, + size: plaintext.length, + created: Math.floor(Date.now() / 1000), + }; + await addAttachmentToItem(msg.itemId, ref); + + return { ok: true, data: { attachment: ref } }; + } catch (e) { + return { ok: false, error: 'upload_failed', detail: String(e) }; + } + } + + case 'download_attachment': { + try { + const session = getSessionHandle(); + if (!session) return { ok: false, error: 'locked' }; + + // Find the AttachmentRef on the item to recover filename + mime. + const item = await loadItem(msg.itemId); + if (!item) return { ok: false, error: 'item_not_found' }; + const ref = item.attachments.find((a) => a.id === msg.attachmentId); + if (!ref) return { ok: false, error: 'attachment_not_found' }; + + const config = await getVaultConfig(); + const host = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); + const blobPath = `${config.vaultRoot}/attachments/${msg.attachmentId}.bin`; + const encrypted = await host.getBlob(blobPath); + const decrypted = wasm.attachment_decrypt(session, encrypted); + + return { + ok: true, + data: { + bytes: decrypted.buffer.slice(decrypted.byteOffset, decrypted.byteOffset + decrypted.byteLength), + filename: ref.filename, + mimeType: ref.mime_type, + }, + }; + } catch (e) { + return { ok: false, error: 'download_failed', detail: String(e) }; + } + } +``` + +Add the necessary imports at the top: +```ts +import { addAttachmentToItem, removeAttachmentsFromItem, loadItem } from '../vault'; +import type { AttachmentRef } from '../../shared/types'; +``` + +(`wasm`, `getSessionHandle`, `getVaultConfig`, `createGitHost` should already be imported in this file — adapt to existing names.) + +- [ ] **Step 5: Run tests** — `cd extension && bun run test 2>&1 | tail -5`. Expected: 137 passed (was 133 + 4 new). + +- [ ] **Step 6: Type-check** — `cd extension && bunx tsc --noEmit 2>&1 | tail -5`. Expected: zero errors. + +- [ ] **Step 7: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/shared/messages.ts \ + extension/src/service-worker/router/popup-only.ts \ + extension/src/service-worker/router/__tests__/router.test.ts +git commit -m "$(cat <<'EOF' +feat(ext/sw): upload_attachment + download_attachment router handlers + +Both popup-only. upload_attachment encrypts via WASM, putBlobs via +GitHost (Git Data API fallback for >900 KB), persists the AttachmentRef +on the item + manifest summaries. download_attachment reads + decrypts +and returns plaintext bytes for the popup to wrap in a Blob. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Popup attachments-disclosure component (+ CSS) + +**Files:** +- Create: `extension/src/popup/components/attachments-disclosure.ts` +- Create: `extension/src/popup/components/__tests__/attachments-disclosure.test.ts` +- Modify: `extension/src/popup/styles.css` — add `.attachments-disclosure` rules + +- [ ] **Step 1: Create the component** at `extension/src/popup/components/attachments-disclosure.ts`: + +```ts +/// Compact disclosure pattern for attachments. +/// Renders inside any item form (edit) or detail (view) and manages +/// add (upload via SW) + remove (defer until form save) + thumb-icons +/// (lazy decrypt + object-URL lifecycle for image-mime rows). + +import { sendMessage, escapeHtml } from '../popup'; +import type { AttachmentRef } from '../../shared/types'; + +export type DisclosureMode = 'edit' | 'view'; + +export interface AttachmentsDisclosureOpts { + itemId: string; // item being edited/viewed (for upload + download messages) + attachments: AttachmentRef[]; // current attachments (read-only — caller maintains state) + mode: DisclosureMode; + onChange?: (next: AttachmentRef[]) => void; // edit mode only — caller updates draft +} + +const formatBytes = (n: number): string => { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +}; + +const isImage = (mime: string): boolean => mime.startsWith('image/'); + +const objectUrlRegistry = new Map(); // attachmentId → object URL + +function teardownObjectUrls(): void { + for (const url of objectUrlRegistry.values()) { + URL.revokeObjectURL(url); + } + objectUrlRegistry.clear(); +} + +async function fetchThumbUrl(itemId: string, attachmentId: string, mime: string): Promise { + if (objectUrlRegistry.has(attachmentId)) return objectUrlRegistry.get(attachmentId)!; + const resp = await sendMessage({ type: 'download_attachment', itemId, attachmentId }); + if (!resp.ok) return null; + const data = resp.data as { bytes: ArrayBuffer }; + const blob = new Blob([data.bytes], { type: mime }); + const url = URL.createObjectURL(blob); + objectUrlRegistry.set(attachmentId, url); + return url; +} + +export function renderAttachmentsDisclosure(opts: AttachmentsDisclosureOpts): string { + const count = opts.attachments.length; + const headerLabel = count === 0 ? 'attachments' : `attachments (${count})`; + const expanded = count > 0; + const rowsHtml = opts.attachments.map((a) => { + const action = opts.mode === 'edit' ? '×' : '↓'; + const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download'; + const iconHtml = isImage(a.mime_type) + ? `📄` + : `📄`; + return ` +
+ ${iconHtml} + ${escapeHtml(a.filename)} + ${formatBytes(a.size)} + ${action} +
+ `; + }).join(''); + const addBtn = opts.mode === 'edit' + ? `` + : ''; + return ` +
+ ${expanded ? '▾' : '▸'} ${headerLabel} +
+ ${rowsHtml} + ${addBtn} +
+ +
+ `; +} + +export function wireAttachmentsDisclosure( + root: HTMLElement, + opts: AttachmentsDisclosureOpts, +): void { + const disc = root.querySelector('.attachments-disclosure') as HTMLDetailsElement | null; + if (!disc) return; + + // Lazy-load image thumbs whenever disclosure opens. + const loadThumbs = async (): Promise => { + const thumbs = disc.querySelectorAll('.attachment-row__thumb'); + for (const thumb of thumbs) { + const attId = thumb.dataset.attId; + const mime = thumb.dataset.mime; + if (!attId || !mime) continue; + const url = await fetchThumbUrl(opts.itemId, attId, mime); + if (url) { + thumb.innerHTML = ``; + } + } + }; + if (disc.open) loadThumbs(); + disc.addEventListener('toggle', () => { + if (disc.open) loadThumbs(); + else teardownObjectUrls(); + }); + + // Edit mode: + attach file + if (opts.mode === 'edit') { + const fileInput = disc.querySelector('.attachments-disclosure__file-input') as HTMLInputElement; + const addBtn = disc.querySelector('.attachment-add-btn') as HTMLButtonElement | null; + addBtn?.addEventListener('click', () => fileInput?.click()); + + fileInput?.addEventListener('change', async () => { + const file = fileInput.files?.[0]; + if (!file) return; + // TODO: cap-checks (per_attachment_max_bytes etc.) + const bytes = await file.arrayBuffer(); + const resp = await sendMessage({ + type: 'upload_attachment', + itemId: opts.itemId, + filename: file.name, + mimeType: file.type || 'application/octet-stream', + bytes, + }); + if (resp.ok) { + const data = resp.data as { attachment: AttachmentRef }; + opts.onChange?.([...opts.attachments, data.attachment]); + } + fileInput.value = ''; // allow re-pick of same file later + }); + + // Remove (×) buttons — defer the actual blob delete until form save + disc.querySelectorAll('.attachment-row__remove').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const attId = btn.dataset.attId; + if (!attId) return; + opts.onChange?.(opts.attachments.filter((a) => a.id !== attId)); + }); + }); + } + + // View mode: ↓ download + if (opts.mode === 'view') { + disc.querySelectorAll('.attachment-row__download').forEach((btn) => { + btn.addEventListener('click', async (e) => { + e.preventDefault(); + const attId = btn.dataset.attId; + if (!attId) return; + const att = opts.attachments.find((a) => a.id === attId); + if (!att) return; + const resp = await sendMessage({ type: 'download_attachment', itemId: opts.itemId, attachmentId: attId }); + if (!resp.ok) return; + const data = resp.data as { bytes: ArrayBuffer; filename: string; mimeType: string }; + const blob = new Blob([data.bytes], { type: data.mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = data.filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 5000); + }); + }); + } +} + +/// Call from the parent component's teardown to release any image thumbs. +export function teardownAttachmentsDisclosure(): void { + teardownObjectUrls(); +} +``` + +Note: the cap-check TODO is left as a comment. Implementer should add the actual cap check using `getState().vaultSettings?.attachment_caps` — fetch via `get_vault_settings` if not in state. + +Replace the `// TODO: cap-checks` with: + +```ts +const settings = (await sendMessage({ type: 'get_vault_settings' }))?.data as { settings: { attachment_caps: { per_attachment_max_bytes?: number; per_item_max_bytes?: number } } } | undefined; +const caps = settings?.settings.attachment_caps ?? {}; +if (caps.per_attachment_max_bytes && file.size > caps.per_attachment_max_bytes) { + alert(`file too large (${formatBytes(file.size)} / cap ${formatBytes(caps.per_attachment_max_bytes)})`); + return; +} +const currentItemBytes = opts.attachments.reduce((s, a) => s + a.size, 0); +if (caps.per_item_max_bytes && currentItemBytes + file.size > caps.per_item_max_bytes) { + alert(`item attachments would exceed per-item cap (${formatBytes(currentItemBytes + file.size)} / ${formatBytes(caps.per_item_max_bytes)})`); + return; +} +``` + +(Use `alert()` for now; γ₂ can wire a proper toast.) + +- [ ] **Step 2: Write tests** at `extension/src/popup/components/__tests__/attachments-disclosure.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../popup', async () => { + const sendMessage = vi.fn(); + const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!)); + return { sendMessage, escapeHtml }; +}); + +import { renderAttachmentsDisclosure, wireAttachmentsDisclosure } from '../attachments-disclosure'; +import { sendMessage } from '../../popup'; +import type { AttachmentRef } from '../../../shared/types'; + +const REF1: AttachmentRef = { id: 'a1', filename: 'doc.pdf', mime_type: 'application/pdf', size: 12345, created: 1700000000 }; +const REF2: AttachmentRef = { id: 'a2', filename: 'photo.png', mime_type: 'image/png', size: 240000, created: 1700000001 }; + +describe('attachments-disclosure render', () => { + it('renders empty state with no rows in edit mode', () => { + const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() }); + expect(html).toContain('attachments'); + expect(html).toContain('+ attach file'); + expect(html).not.toContain('attachment-row'); + }); + + it('renders rows + remove buttons in edit mode', () => { + const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange: vi.fn() }); + expect(html).toContain('doc.pdf'); + expect(html).toContain('photo.png'); + expect(html).toContain('×'); + expect(html).toContain('attachment-row__thumb'); // image-mime row gets thumb hook + }); + + it('renders rows + download buttons in view mode (no add btn)', () => { + const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1], mode: 'view' }); + expect(html).toContain('↓'); + expect(html).not.toContain('+ attach file'); + }); +}); + +describe('attachments-disclosure wiring', () => { + beforeEach(() => { + vi.mocked(sendMessage).mockReset(); + }); + + it('clicking + attach triggers file input click', () => { + document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() }); + const fileInput = document.querySelector('.attachments-disclosure__file-input') as HTMLInputElement; + const clickSpy = vi.spyOn(fileInput, 'click'); + wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() }); + (document.querySelector('.attachment-add-btn') as HTMLButtonElement).click(); + expect(clickSpy).toHaveBeenCalled(); + }); + + it('clicking × calls onChange with the attachment removed', () => { + const onChange = vi.fn(); + document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange }); + wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange }); + (document.querySelectorAll('.attachment-row__remove')[0] as HTMLElement).click(); + expect(onChange).toHaveBeenCalledWith([REF2]); + }); + + it('clicking ↓ in view mode sends download_attachment', async () => { + vi.mocked(sendMessage).mockResolvedValueOnce({ ok: true, data: { bytes: new ArrayBuffer(10), filename: 'doc.pdf', mimeType: 'application/pdf' } }); + document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1], mode: 'view' }); + wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [REF1], mode: 'view' }); + (document.querySelector('.attachment-row__download') as HTMLElement).click(); + await new Promise((r) => setTimeout(r, 50)); + expect(vi.mocked(sendMessage)).toHaveBeenCalledWith(expect.objectContaining({ + type: 'download_attachment', + itemId: 'i1', + attachmentId: 'a1', + })); + }); +}); +``` + +- [ ] **Step 3: Run tests** — `cd extension && bun run test 2>&1 | tail -5`. Expected: 143 passed (137 + 6 new). + +- [ ] **Step 4: Add CSS** in `extension/src/popup/styles.css` (append at the end): + +```css +/* --- attachments disclosure (γ₁) --- */ + +.attachments-disclosure { + margin: 8px 0; + border: 1px solid #30363d; + border-radius: 4px; + padding: 6px 8px; + font-size: 11px; + color: #8b949e; +} +.attachments-disclosure[open] { + border-color: #aa812a; +} +.attachments-disclosure summary { + cursor: pointer; + list-style: none; + outline: none; + user-select: none; + padding: 2px 0; +} +.attachments-disclosure summary::-webkit-details-marker { display: none; } +.attachments-disclosure summary:hover { color: #c9d1d9; } +.attachments-disclosure__body { + margin-top: 6px; +} +.attachment-row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + font-size: 10px; + border-bottom: 1px solid #21262d; +} +.attachment-row:last-of-type { + border-bottom: 0; +} +.attachment-row__icon, +.attachment-row__thumb { + width: 16px; + height: 16px; + background: #21262d; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + flex-shrink: 0; + overflow: hidden; +} +.attachment-row__thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} +.attachment-row__name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #c9d1d9; +} +.attachment-row__meta { + color: #6e7681; + font-size: 9px; + font-family: ui-monospace, monospace; + flex-shrink: 0; +} +.attachment-row__remove, +.attachment-row__download { + color: #d2ab43; + cursor: pointer; + padding: 0 6px; + flex-shrink: 0; +} +.attachment-row__remove { color: #ab2b20; } +.attachment-add-btn { + background: transparent; + border: 1px dashed #30363d; + color: #8b949e; + padding: 5px 8px; + font-size: 10px; + cursor: pointer; + border-radius: 3px; + width: 100%; + margin-top: 6px; + text-align: center; +} +.attachment-add-btn:hover { + border-color: #aa812a; + color: #c9d1d9; +} +``` + +- [ ] **Step 5: Type-check + tests** — `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`. Expected: zero TS errors; 143 passed. + +- [ ] **Step 6: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/popup/components/attachments-disclosure.ts \ + extension/src/popup/components/__tests__/attachments-disclosure.test.ts \ + extension/src/popup/styles.css +git commit -m "$(cat <<'EOF' +feat(ext/popup): attachments-disclosure shared component + +Compact disclosure rendering attachment rows with an action column +(× in edit, ↓ in view). Image-mime rows lazily decrypt + show a 16×16 +thumb via object URLs; teardown revokes them on disclosure close. Edit +mode adds a "+ attach file" button wired to a hidden file input that +sends upload_attachment to the SW. 6 new tests; total ~143. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Wire disclosure into 6 type forms + item-list 📎 indicator + +**Files:** +- Modify: 6 type files (`login.ts`, `secure-note.ts`, `identity.ts`, `card.ts`, `key.ts`, `totp.ts`) +- Modify: `extension/src/popup/components/item-list.ts` — add 📎 indicator + +For each of the 6 type files, the disclosure is rendered+wired in two places: `renderForm` (edit mode) and `renderDetail` (view mode). + +- [ ] **Step 1: For each type file, add the disclosure to renderForm**. + +Pattern — search for the existing custom-fields disclosure call (e.g., `renderSectionsEditor(...)`) in `renderForm`. The attachments disclosure goes AFTER it, BEFORE the `
`. + +Add at the top of each file: +```ts +import { renderAttachmentsDisclosure, wireAttachmentsDisclosure, teardownAttachmentsDisclosure } from '../attachments-disclosure'; +``` + +In the form HTML template, after the sections-editor disclosure HTML, add: +```ts +${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })} +``` + +Above the template, declare draft state: +```ts +let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? []; +``` + +After `wireSectionsEditor(...)`, add a local `wireDisclosure` helper that handles re-render + re-wire (so the onChange callback can recurse into itself cleanly): + +```ts +const wireDisclosure = (): void => { + wireAttachmentsDisclosure(app, { + itemId: existing?.id ?? '', + attachments: attachmentsDraft, + mode: 'edit', + onChange: (next) => { + attachmentsDraft = next; + const disc = app.querySelector('.attachments-disclosure'); + if (disc) { + disc.outerHTML = renderAttachmentsDisclosure({ + itemId: existing?.id ?? '', + attachments: attachmentsDraft, + mode: 'edit', + }); + wireDisclosure(); // re-attach handlers to the freshly rendered DOM + } + }, + }); +}; +wireDisclosure(); +``` + +In the save handler, replace `attachments: existing?.attachments ?? []` with `attachments: attachmentsDraft`. + +In the form's `teardown()` function, add `teardownAttachmentsDisclosure()` to release any object URLs. + +- [ ] **Step 2: For each type file, add the disclosure to renderDetail (view mode)**. + +In `renderDetail`, after the typed fields and sections render, before the closing form tag, add: +```ts +${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })} +``` + +After the detail is mounted (in the function that wires up the detail — usually right after `app.innerHTML = ...`): +```ts +wireAttachmentsDisclosure(app, { itemId: item.id, attachments: item.attachments, mode: 'view' }); +``` + +Same teardown call in the detail's `teardown()`. + +- [ ] **Step 3: Wire the 📎 indicator in `extension/src/popup/components/item-list.ts`**. + +Find the row template (around line 130–180 area). Locate the part that renders the title + favorite star. Add a `📎` span if `entry.attachment_summaries.length > 0`: + +```ts +${entry.attachment_summaries.length > 0 ? '📎' : ''} +``` + +Add corresponding CSS in `styles.css`: +```css +.entry-row__attach-indicator { + font-size: 9px; + opacity: 0.6; + margin-right: 4px; +} +``` + +- [ ] **Step 4: Run tests** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 143 passed (no new tests; existing tests should still pass since we didn't break behavior). + +- [ ] **Step 5: Type-check** — `cd extension && bunx tsc --noEmit 2>&1 | tail -3`. Expected: zero errors. + +- [ ] **Step 6: Build** — `cd extension && bun run build:all 2>&1 | tail -10`. Expected: 2 WASM warnings only. + +- [ ] **Step 7: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/popup/components/types/login.ts \ + extension/src/popup/components/types/secure-note.ts \ + extension/src/popup/components/types/identity.ts \ + extension/src/popup/components/types/card.ts \ + extension/src/popup/components/types/key.ts \ + extension/src/popup/components/types/totp.ts \ + extension/src/popup/components/item-list.ts \ + extension/src/popup/styles.css +git commit -m "$(cat <<'EOF' +feat(ext/popup): wire attachments disclosure into 6 type forms + 📎 list indicator + +Each existing type form (Login, SecureNote, Identity, Card, Key, TOTP) +renders + wires the attachments-disclosure in both edit and view modes. +Form save reads from attachmentsDraft; teardown revokes any image +object URLs. Item-list rows show a 📎 glyph for items with at least +one attachment. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: Document type — form + detail + signature-block CSS + +**Files:** +- Create: `extension/src/popup/components/types/document.ts` +- Create: `extension/src/popup/components/types/__tests__/document.save.test.ts` +- Modify: `extension/src/popup/styles.css` — add `.document-signature-block` rules + +- [ ] **Step 1: Create `extension/src/popup/components/types/document.ts`** modeled on the structure of `secure-note.ts` (one of the simpler types). Skeleton: + +```ts +/// Document item type — title + REQUIRED primary attachment + optional +/// notes/tags/expires + optional supplementary attachments. + +import { sendMessage, escapeHtml, getState, setState, navigate } from '../../popup'; +import { renderSectionsEditor, wireSectionsEditor } from '../sections-editor'; +import { renderAttachmentsDisclosure, wireAttachmentsDisclosure, teardownAttachmentsDisclosure } from '../attachments-disclosure'; +import type { Item, AttachmentRef, MonthYear, Section } from '../../../shared/types'; + +let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; + +export function teardown(): void { + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } + teardownAttachmentsDisclosure(); +} + +export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void { + teardown(); + + const isEdit = mode === 'edit'; + const c = existing?.core?.kind === 'document' ? existing.core : null; + + let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? []; + let primaryId = c?.primary_attachment ?? ''; + let sectionsDraft: Section[] = existing?.sections ?? []; + let sectionsExpanded = false; + + const renderPrimary = (): string => { + const primaryRef = attachmentsDraft.find((a) => a.id === primaryId); + if (!primaryRef) { + return ` +
+ + attach primary file + +
+ `; + } + return ` +
+ 📄 + ${escapeHtml(primaryRef.filename)} + ${formatBytes(primaryRef.size)} + ↑ change + +
+ `; + }; + + const html = ` +
+
${isEdit ? 'edit document' : 'new document'}
+ +
+
+ +
+ ${renderPrimary()} +
+ +
+ +
+ +
+
+ +
+
+ + +
+
+ + ${renderSectionsEditor(sectionsDraft, sectionsExpanded)} + ${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })} + +
+ + +
+
+ `; + + app.innerHTML = html; + + // Wire primary picker + const primaryFileInput = document.getElementById('primary-file-input') as HTMLInputElement; + const primaryPicker = document.getElementById('primary-picker'); + primaryPicker?.addEventListener('click', (e) => { + if ((e.target as HTMLElement).id === 'primary-change' || primaryPicker.classList.contains('document-primary-row--empty')) { + primaryFileInput.click(); + } + }); + primaryFileInput?.addEventListener('change', async () => { + const file = primaryFileInput.files?.[0]; + if (!file) return; + const bytes = await file.arrayBuffer(); + const resp = await sendMessage({ + type: 'upload_attachment', + itemId: existing?.id ?? '', + filename: file.name, + mimeType: file.type || 'application/octet-stream', + bytes, + }); + if (resp.ok) { + const data = resp.data as { attachment: AttachmentRef }; + attachmentsDraft = [...attachmentsDraft, data.attachment]; + primaryId = data.attachment.id; + // Re-render primary section + const primaryGroup = document.querySelector('label[for=""]')?.closest('.form-group'); + // Simpler: just call render again + renderForm(app, mode, existing); + } + }); + + // Wire supplementary attachments disclosure + wireAttachmentsDisclosure(app, { + itemId: existing?.id ?? '', + attachments: attachmentsDraft, + mode: 'edit', + onChange: (next) => { + attachmentsDraft = next; + // If the primary was removed, clear primaryId so save validation will block + if (!attachmentsDraft.find((a) => a.id === primaryId)) primaryId = ''; + }, + }); + + // Wire sections editor + const rerender = (): void => { + const disclosure = app.querySelector('.disclosure'); + if (!disclosure) return; + sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true'; + disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded); + wireSectionsEditor(app, sectionsDraft, rerender); + }; + wireSectionsEditor(app, sectionsDraft, rerender); + + // Cancel + save + document.getElementById('cancel-btn')?.addEventListener('click', () => navigate(isEdit ? 'detail' : 'list')); + + document.getElementById('save-btn')?.addEventListener('click', async () => { + const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); + if (!title) { alert('title required'); return; } + if (!primaryId || !attachmentsDraft.find((a) => a.id === primaryId)) { + alert('primary attachment required'); + return; + } + const primaryRef = attachmentsDraft.find((a) => a.id === primaryId)!; + + const monthRaw = (document.getElementById('f-exp-month') as HTMLInputElement).value.trim(); + const yearRaw = (document.getElementById('f-exp-year') as HTMLInputElement).value.trim(); + const expires: MonthYear | undefined = + monthRaw && yearRaw ? { month: Number(monthRaw), year: Number(yearRaw) } : undefined; + + const tags = (document.getElementById('f-tags') as HTMLInputElement).value + .split(',').map((t) => t.trim()).filter(Boolean); + + const now = Math.floor(Date.now() / 1000); + const item: Item = { + schema_version: 2, + id: existing?.id ?? '', + type: 'document', + core: { + kind: 'document', + filename: primaryRef.filename, + mime_type: primaryRef.mime_type, + primary_attachment: primaryId, + }, + title, + url: undefined, + notes: (document.getElementById('f-notes') as HTMLInputElement).value.trim() || undefined, + tags, + expires, + favorite: existing?.favorite ?? false, + created: existing?.created ?? now, + modified: now, + trashed_at: undefined, + sections: sectionsDraft, + attachments: attachmentsDraft, + field_history: existing?.field_history ?? {}, + }; + + const resp = await sendMessage({ type: isEdit ? 'update_item' : 'create_item', item }); + if (resp.ok) navigate('list'); + else alert('save failed: ' + (resp.error ?? 'unknown')); + }); +} + +export function renderDetail(app: HTMLElement, item: Item): void { + teardown(); + if (item.core.kind !== 'document') return; + const c = item.core; + const primaryRef = item.attachments.find((a) => a.id === c.primary_attachment); + if (!primaryRef) { + app.innerHTML = `

document missing primary attachment

`; + return; + } + + const expiresHtml = item.expires + ? `
${String(item.expires.month).padStart(2,'0')}/${item.expires.year}
` + : ''; + const tagsHtml = item.tags && item.tags.length > 0 + ? `
${item.tags.map((t) => escapeHtml(t)).join(', ')}
` + : ''; + + app.innerHTML = ` +
+
${escapeHtml(item.title)}
+ +
+
📄
+
+
${escapeHtml(primaryRef.filename)}
+
${formatBytes(primaryRef.size)} · ${new Date(primaryRef.created * 1000).toISOString().slice(0,10)}
+
+ ↓ download + ${primaryRef.mime_type.startsWith('image/') ? '🔍 preview' : ''} +
+
+
+ + ${item.notes ? `

${escapeHtml(item.notes)}

` : ''} + ${tagsHtml} + ${expiresHtml} + + ${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments.filter((a) => a.id !== c.primary_attachment), mode: 'view' })} + +
+ + +
+
+ `; + + // Wire signature-block thumb (if image, lazy-load) + if (primaryRef.mime_type.startsWith('image/')) { + const thumb = document.querySelector('.document-signature-block__thumb') as HTMLElement; + sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id }).then((resp) => { + if (!resp.ok) return; + const data = resp.data as { bytes: ArrayBuffer }; + const blob = new Blob([data.bytes], { type: primaryRef.mime_type }); + const url = URL.createObjectURL(blob); + thumb.innerHTML = ``; + }); + } + + document.getElementById('doc-download')?.addEventListener('click', async () => { + const resp = await sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id }); + if (!resp.ok) return; + const data = resp.data as { bytes: ArrayBuffer }; + const blob = new Blob([data.bytes], { type: primaryRef.mime_type }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = primaryRef.filename; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 5000); + }); + + document.getElementById('doc-preview')?.addEventListener('click', async () => { + const sigblock = document.getElementById('doc-sigblock')!; + const existingPreview = sigblock.querySelector('.document-signature-block__preview'); + if (existingPreview) { + existingPreview.remove(); + return; + } + const resp = await sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id }); + if (!resp.ok) return; + const data = resp.data as { bytes: ArrayBuffer }; + const blob = new Blob([data.bytes], { type: primaryRef.mime_type }); + const url = URL.createObjectURL(blob); + const preview = document.createElement('div'); + preview.className = 'document-signature-block__preview'; + preview.innerHTML = ``; + sigblock.appendChild(preview); + }); + + // Wire supplementary attachments disclosure + wireAttachmentsDisclosure(app, { + itemId: item.id, + attachments: item.attachments.filter((a) => a.id !== c.primary_attachment), + mode: 'view', + }); + + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + document.getElementById('edit-btn')?.addEventListener('click', () => { + setState({ selectedItem: item }); + navigate('edit'); + }); +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} +``` + +(Implementer: tighten any deviations from existing type-component patterns to match codebase conventions. Some helper imports may be slightly differently named.) + +- [ ] **Step 2: Add CSS for the signature block** in `styles.css` (append): + +```css +/* --- Document signature block (γ₁) --- */ + +.document-signature-block { + border-left: 3px solid #aa812a; + background: #161b22; + padding: 10px; + margin: 8px 0; + border-radius: 0 4px 4px 0; + display: flex; + align-items: center; + gap: 10px; +} +.document-signature-block__thumb { + width: 48px; + height: 60px; + border-radius: 2px; + background: linear-gradient(135deg, #b88a30, #7c5719); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; + overflow: hidden; + color: #fff3cf; +} +.document-signature-block__thumb img { + width: 100%; height: 100%; object-fit: contain; +} +.document-signature-block__info { flex: 1; min-width: 0; } +.document-signature-block__name { + font-size: 11px; + color: #f1cf6e; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.document-signature-block__meta { + font-size: 9px; + color: #8b949e; + font-family: ui-monospace, monospace; + margin-top: 2px; +} +.document-signature-block__actions { + font-size: 9px; + margin-top: 4px; +} +.document-signature-block__preview { + margin-top: 8px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 3px; + padding: 6px; + text-align: center; +} +.document-signature-block__preview img { + max-width: 100%; + max-height: 200px; + border-radius: 2px; +} + +/* Document primary picker (form mode) */ +.document-primary-row { + background: #161b22; + border: 1px solid #30363d; + border-radius: 4px; + padding: 6px 8px; + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + cursor: pointer; +} +.document-primary-row--empty { + border-style: dashed; + border-color: #aa812a; + color: #8b949e; + justify-content: center; + padding: 10px 8px; +} +.document-primary-row__thumb { + width: 18px; height: 18px; + border-radius: 2px; + background: linear-gradient(135deg, #b88a30, #7c5719); + display: flex; align-items: center; justify-content: center; + font-size: 10px; flex-shrink: 0; +} +.document-primary-row__name { + flex: 1; + color: #c9d1d9; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.document-primary-row__meta { + color: #6e7681; + font-size: 9px; + font-family: ui-monospace, monospace; +} +.document-primary-row__action { + color: #d2ab43; + font-size: 10px; + padding: 0 6px; + cursor: pointer; +} +``` + +- [ ] **Step 3: Write Document save tests** at `extension/src/popup/components/types/__tests__/document.save.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../popup', async () => { + const actual = await vi.importActual('../../../popup'); + return { + ...actual, + sendMessage: vi.fn(), + getState: () => ({ loading: false }), + setState: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!)), + }; +}); + +import { renderForm } from '../document'; +import { sendMessage } from '../../../popup'; +import type { Item, AttachmentRef } from '../../../../shared/types'; + +const PRIMARY: AttachmentRef = { id: 'primaryid', filename: 'passport.pdf', mime_type: 'application/pdf', size: 240000, created: 1700000000 }; + +describe('Document form save', () => { + beforeEach(() => { + vi.mocked(sendMessage).mockReset(); + document.body.innerHTML = '
'; + }); + + it('rejects save when primary_attachment is missing', () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + (document.getElementById('f-title') as HTMLInputElement).value = 'Passport'; + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + (document.getElementById('save-btn') as HTMLButtonElement).click(); + expect(alertSpy).toHaveBeenCalledWith('primary attachment required'); + expect(vi.mocked(sendMessage)).not.toHaveBeenCalled(); + }); + + it('sends create_item with correct wire shape when valid', async () => { + vi.mocked(sendMessage).mockResolvedValue({ ok: true }); + const existingDraft: Item = { + schema_version: 2, id: '', type: 'document', + core: { kind: 'document', filename: PRIMARY.filename, mime_type: PRIMARY.mime_type, primary_attachment: PRIMARY.id }, + title: 'Passport', url: undefined, notes: undefined, tags: [], + expires: undefined, favorite: false, created: 0, modified: 0, + trashed_at: undefined, sections: [], attachments: [PRIMARY], field_history: {}, + }; + const app = document.getElementById('app')!; + renderForm(app, 'edit', existingDraft); + (document.getElementById('f-title') as HTMLInputElement).value = 'Passport'; + (document.getElementById('save-btn') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 50)); + const lastCall = vi.mocked(sendMessage).mock.calls[vi.mocked(sendMessage).mock.calls.length - 1]![0] as any; + expect(lastCall.type).toBe('update_item'); + expect(lastCall.item.core.primary_attachment).toBe(PRIMARY.id); + expect(lastCall.item.attachments).toEqual([PRIMARY]); + }); +}); +``` + +- [ ] **Step 4: Run tests** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 145 passed (143 + 2 new). + +- [ ] **Step 5: Type-check + build** — `cd extension && bunx tsc --noEmit && bun run build:all 2>&1 | tail -10`. Expected: zero TS errors; 2 WASM warnings only. + +- [ ] **Step 6: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/popup/components/types/document.ts \ + extension/src/popup/components/types/__tests__/document.save.test.ts \ + extension/src/popup/styles.css +git commit -m "$(cat <<'EOF' +feat(ext/popup): Document item type — form + signature-block detail + +Form requires title + primary_attachment; the primary-row picker is +compact in edit mode (dashed-border when empty, filename row when +filled). Detail view promotes the primary to a gold signature block +(48×60 thumb + filename + meta + ↓ download · 🔍 preview). For image- +mime primaries, the thumb lazy-loads via decrypt + object-URL; the +preview button toggles an inline expanded view. + +Supplementary attachments use the standard compact disclosure (Task 7). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Wire Document into the form dispatcher + +**Files:** +- Modify: `extension/src/popup/components/item-form.ts` — replace the "coming soon" Document case +- Modify: `extension/src/popup/components/item-list.ts` — drop the `disabled: true` flag on Document in the type chooser + +- [ ] **Step 1: Update `extension/src/popup/components/item-form.ts`** dispatcher. + +Find: +```ts +import * as totp from './types/totp'; +``` + +Add: +```ts +import * as document from './types/document'; +``` + +Find the switch statement (around line 24): +```ts +case 'document': return renderComingSoon(app, type); +``` + +Replace with: +```ts +case 'document': return document.renderForm(app, mode, existing); +``` + +Also call `document.teardown()` in the teardown sequence at the top of `renderItemForm`: +```ts +totp.teardown(); +document.teardown(); +``` + +- [ ] **Step 2: Update `extension/src/popup/components/item-list.ts`** — find the type chooser (mentioned `'coming in γ — needs attachment upload'`): + +```ts +{ type: 'document', icon: '📄', label: 'document', disabled: true, tooltip: 'coming in γ — needs attachment upload' }, +``` + +Replace with: +```ts +{ type: 'document', icon: '📄', label: 'document' }, +``` + +- [ ] **Step 3: Find renderDetail dispatcher** (probably in `item-detail.ts`). Same pattern — add Document case routing to `document.renderDetail(app, item)`. + +```ts +case 'document': return document.renderDetail(app, item); +``` + +- [ ] **Step 4: Run tests** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 145 passed. + +- [ ] **Step 5: Type-check + build** — `cd extension && bunx tsc --noEmit && bun run build:all 2>&1 | tail -10`. Expected: zero TS errors; 2 WASM warnings only. + +- [ ] **Step 6: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/popup/components/item-form.ts \ + extension/src/popup/components/item-list.ts \ + extension/src/popup/components/item-detail.ts +git commit -m "$(cat <<'EOF' +feat(ext/popup): wire Document type into form + detail dispatchers + +Document is no longer 'coming soon' — the type chooser unlocks it, +form dispatcher routes to document.renderForm, detail dispatcher +routes to document.renderDetail. teardown chains include document. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 11: Build, full verification, manual smoke + +Working dir: `/home/alee/Sources/relicario`. Branch: main. + +- [ ] **Step 1: Full test sweep** + +```bash +cd /home/alee/Sources/relicario && cargo test --workspace 2>&1 | grep -E "test result|running [0-9]+ tests" | tail -20 +cd /home/alee/Sources/relicario/extension && bun run test 2>&1 | tail -5 +cd /home/alee/Sources/relicario/extension && bunx tsc --noEmit 2>&1 | tail -5 +``` + +Expected: 155 Rust + 145 Vitest, all green; tsc zero errors. + +- [ ] **Step 2: Build both bundles** + +```bash +cd /home/alee/Sources/relicario/extension && bun run build:all 2>&1 | tail -10 +``` + +Expected: 2 WASM warnings only for both Chrome and Firefox. + +- [ ] **Step 3: Manual smoke test (relay to user)** + +Reload the extension and walk through: + + - **Login form attachment:** New Login → fill basics → expand `▾ attachments` → click "+ attach file" → pick small PDF (<900 KB) → row appears with size → save item → reopen → attachment row visible with ↓ → click ↓ → file downloads correctly. Decrypt works. + - **Image attachment thumb:** New Login → attach a small PNG → save → reopen → expand attachments disclosure → image row should show a tiny thumb instead of the generic 📄 icon. Close disclosure → re-open → thumb still works (object URL revoked + re-created). + - **Large file (Git Data API path):** Attach a >1 MB file. Should still upload successfully (slower). Confirm in the git host that a new commit landed. + - **Document type:** New → Document → fill title → click "+ attach primary file" → pick a PDF → primary row populates → save → detail view shows the gold signature block + filename + meta + ↓ download. Click ↓ → downloads. For image primaries, 🔍 preview shows inline. + - **Item-list 📎 indicator:** Items with at least one attachment show a small 📎 in the row. + - **Removal lifecycle:** Edit a Login that has 2 attachments → click × on one → save → reopen → only one attachment remains. Verify in git host the underlying blob was deleted. + +- [ ] **Step 4: Final lint sweep — confirm no TODOs leaked** + +```bash +cd /home/alee/Sources/relicario && grep -rn 'TODO.*cap-checks\|coming-soon.*document\|coming in γ' extension/src/ +``` + +Expected: no output. + +- [ ] **Step 5: Verify the slice's commit chain** + +```bash +cd /home/alee/Sources/relicario && git log --oneline 6f5ef43..HEAD +``` + +Expected: ~10 commits — Tasks 1-10. (Task 11 doesn't commit unless something broke.) + +- [ ] **Step 6: Working tree clean** + +```bash +cd /home/alee/Sources/relicario && git status --short +``` + +Expected: only `.claude/` may be untracked. + +--- + +## Verification summary + +```bash +cd /home/alee/Sources/relicario/extension && bun run build:all +cd /home/alee/Sources/relicario && cargo test --workspace +cd /home/alee/Sources/relicario/extension && bun run test +cd /home/alee/Sources/relicario/extension && bunx tsc --noEmit +``` + +All four commands must succeed for the slice to be considered complete. + +--- + +## Notes for the implementer + +- **No worktree** — direct commits to main per project's single-maintainer flow (the user has explicitly approved this for prior slices). +- **Order matters strictly** — Tasks 2-4 ship interface + impls; Task 5 uses them; Task 6 uses Task 5; Task 7 introduces the shared component; Task 8 wires it; Task 9 adds Document; Task 10 unblocks Document; Task 11 verifies. +- **Atomic commits per task** — gen-UX redesign Task 2 was incorrectly split into 2 commits leaving a broken intermediate. For each task here, stage all changes first, then commit ONCE. The build must be green at every commit. +- **Existing patterns to follow:** + - The router test pattern (Task 6) — model on existing `update_vault_settings` cases + - The type-component shape (Task 9) — model on `secure-note.ts` (simplest existing form) + - The disclosure pattern (Task 7) — visually mirrors the custom-fields disclosure from β₂ (`extension/src/popup/components/sections-editor.ts`) +- **Don't re-export old popover types from generator-panel** — that module was renamed in the gen-UX redesign; ignore. +- **The `popup.ts`-side `humanizeError` helper** — can be used in error toasts if it exists in the codebase; otherwise `alert()` is fine for v1. +- **Attachment decryption renders a Blob in popup memory** — fine for typical file sizes; very large files (>50 MB) may hit memory pressure. Out of scope for γ₁.