Files
relicario/docs/superpowers/plans/2026-04-24-relicario-extension-1c-gamma1.md
adlee-was-taken 71c182af9a fix(ext/shared): correct AttachmentCaps field names to match Rust core
The previous commit (f963ae3) used per_item_max_bytes and per_vault_*_max_bytes
which don't match the Rust core's struct (per_item_max_count and
per_vault_*_cap_bytes). Also fixes the per-item semantics: it's a COUNT of
attachments per item, not a byte sum.

Spec and plan docs updated in-place so future Task 7 cap-enforcement
implementation uses the correct names + semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:42:51 -04:00

2119 lines
83 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 199204) 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) <noreply@anthropic.com>
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<Uint8Array>;
/// Create or update a file in the repo with a commit message.
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
/// Delete a file from the repo with a commit message.
deleteFile(path: string, message: string): Promise<void>;
/// List file names in a directory (non-recursive).
listDir(path: string): Promise<string[]>;
/// 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<string>;
/// 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<Uint8Array>;
/// Delete a blob from the repo. Currently identical to deleteFile;
/// kept distinct for symmetry with putBlob.
deleteBlob(path: string, message: string): Promise<void>;
}
```
- [ ] **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<string> {
throw new Error(`GitHubHost.putBlob not implemented — Task 3`);
}
async getBlob(path: string): Promise<Uint8Array> {
return this.readFile(path);
}
async deleteBlob(path: string, message: string): Promise<void> {
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<string> {
throw new Error(`GiteaHost.putBlob not implemented — Task 4`);
}
async getBlob(path: string): Promise<Uint8Array> {
return this.readFile(path);
}
async deleteBlob(path: string, message: string): Promise<void> {
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) <noreply@anthropic.com>
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<typeof vi.fn> {
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<string, string>;
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<string> {
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<Uint8Array> {
return this.readFile(path);
}
async deleteBlob(path: string, message: string): Promise<void> {
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) <noreply@anthropic.com>
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<string, string>;
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<string> {
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<Uint8Array> {
return this.readFile(path);
}
async deleteBlob(path: string, message: string): Promise<void> {
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) <noreply@anthropic.com>
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<void> {
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<AttachmentRef[]> {
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<void> {
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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<string, string>(); // 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<string | null> {
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)
? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">📄</span>`
: `<span class="attachment-row__icon">📄</span>`;
return `
<div class="attachment-row" data-att-id="${escapeHtml(a.id)}">
${iconHtml}
<span class="attachment-row__name">${escapeHtml(a.filename)}</span>
<span class="attachment-row__meta">${formatBytes(a.size)}</span>
<span class="${actionClass}" data-att-id="${escapeHtml(a.id)}">${action}</span>
</div>
`;
}).join('');
const addBtn = opts.mode === 'edit'
? `<button class="attachment-add-btn" type="button">+ attach file</button>`
: '';
return `
<details class="attachments-disclosure" ${expanded ? 'open' : ''}>
<summary>${expanded ? '▾' : '▸'} ${headerLabel}</summary>
<div class="attachments-disclosure__body">
${rowsHtml}
${addBtn}
</div>
<input type="file" class="attachments-disclosure__file-input" hidden />
</details>
`;
}
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<void> => {
const thumbs = disc.querySelectorAll<HTMLElement>('.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 = `<img src="${url}" alt="" />`;
}
}
};
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<HTMLElement>('.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<HTMLElement>('.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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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) <noreply@anthropic.com>
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 `<div class="form-actions">`.
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 130180 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 ? '<span class="entry-row__attach-indicator" title="has attachments">📎</span>' : ''}
```
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) <noreply@anthropic.com>
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 `
<div class="document-primary-row document-primary-row--empty" id="primary-picker">
+ attach primary file
<input type="file" id="primary-file-input" hidden />
</div>
`;
}
return `
<div class="document-primary-row" id="primary-picker">
<span class="document-primary-row__thumb">📄</span>
<span class="document-primary-row__name">${escapeHtml(primaryRef.filename)}</span>
<span class="document-primary-row__meta">${formatBytes(primaryRef.size)}</span>
<span class="document-primary-row__action" id="primary-change">↑ change</span>
<input type="file" id="primary-file-input" hidden />
</div>
`;
};
const html = `
<div class="form pad">
<div class="form-title">${isEdit ? 'edit document' : 'new document'}</div>
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
<input id="f-title" type="text" value="${escapeHtml(existing?.title ?? '')}" placeholder="Passport, Lease, etc."></div>
<div class="form-group"><label class="label">primary attachment <span class="req">*</span></label>
${renderPrimary()}
</div>
<div class="form-group"><label class="label" for="f-notes">notes</label>
<textarea id="f-notes" rows="3" placeholder="optional context...">${escapeHtml(existing?.core?.kind === 'document' ? '' : '')}</textarea>
</div>
<div class="form-group"><label class="label" for="f-tags">tags</label>
<input id="f-tags" type="text" value="${escapeHtml((existing?.tags ?? []).join(', '))}" placeholder="legal, official"></div>
<div class="form-group"><label class="label">expires</label>
<div class="row" style="display:flex;gap:6px;">
<input id="f-exp-month" type="text" maxlength="2" placeholder="MM" style="max-width:50px;" />
<input id="f-exp-year" type="text" maxlength="4" placeholder="YYYY" style="max-width:60px;" />
</div>
</div>
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">${getState().loading ? '<span class="spinner"></span>' : 'save'}</button>
</div>
</div>
`;
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 = `<div class="pad"><p>document missing primary attachment</p></div>`;
return;
}
const expiresHtml = item.expires
? `<div class="form-group"><label class="label">expires</label><code>${String(item.expires.month).padStart(2,'0')}/${item.expires.year}</code></div>`
: '';
const tagsHtml = item.tags && item.tags.length > 0
? `<div class="form-group"><label class="label">tags</label><span>${item.tags.map((t) => escapeHtml(t)).join(', ')}</span></div>`
: '';
app.innerHTML = `
<div class="pad">
<div class="detail-title">${escapeHtml(item.title)}</div>
<div class="document-signature-block" id="doc-sigblock">
<div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">📄</div>
<div class="document-signature-block__info">
<div class="document-signature-block__name">${escapeHtml(primaryRef.filename)}</div>
<div class="document-signature-block__meta">${formatBytes(primaryRef.size)} · ${new Date(primaryRef.created * 1000).toISOString().slice(0,10)}</div>
<div class="document-signature-block__actions">
<span id="doc-download" style="cursor:pointer;color:#d2ab43;">↓ download</span>
${primaryRef.mime_type.startsWith('image/') ? '<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">🔍 preview</span>' : ''}
</div>
</div>
</div>
${item.notes ? `<div class="form-group"><label class="label">notes</label><p>${escapeHtml(item.notes)}</p></div>` : ''}
${tagsHtml}
${expiresHtml}
${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments.filter((a) => a.id !== c.primary_attachment), mode: 'view' })}
<div class="form-actions">
<button class="btn" id="back-btn">← back</button>
<button class="btn" id="edit-btn">edit</button>
</div>
</div>
`;
// 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 = `<img src="${url}" alt="" />`;
});
}
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 = `<img src="${url}" alt="" />`;
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<typeof import('../../../popup')>('../../../popup');
return {
...actual,
sendMessage: vi.fn(),
getState: () => ({ loading: false }),
setState: vi.fn(),
navigate: vi.fn(),
escapeHtml: (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = '<div id="app"></div>';
});
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 γ₁.