# 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. /// Field names mirror the Rust core's `AttachmentCaps` struct. /// γ₁ enforces; γ₂ ships the configuration UI. export interface AttachmentCaps { per_attachment_max_bytes?: number; per_item_max_count?: number; // count of attachments per item, NOT bytes per_vault_soft_cap_bytes?: number; // soft cap — warn user but allow per_vault_hard_cap_bytes?: number; // hard cap — reject } 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_count?: 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; } if (caps.per_item_max_count && opts.attachments.length + 1 > caps.per_item_max_count) { alert(`item attachment count would exceed per-item cap (${opts.attachments.length + 1} / ${caps.per_item_max_count})`); 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 γ₁.