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>
83 KiB
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— tightenattachment_caps: unknown→ typedAttachmentCaps(Task 1)extension/src/shared/messages.ts— addupload_attachment,download_attachmentmessage types (Task 6)
Service worker:
extension/src/service-worker/git-host.ts— addputBlob,getBlob,deleteBlobto interface; exportBLOB_THRESHOLD_BYTESconstant (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— addaddAttachmentToItem,removeAttachmentsFromItemhelpers (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 theVaultSettingsinterface (around line 199–204) and add anAttachmentCapstype definition above it. Then change the field's type.
Find:
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:
/// 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 initializedattachment_caps: {}will still satisfyAttachmentCapsbecause all fields are optional.) -
Step 3: Run vitest —
cd extension && bun run test 2>&1 | tail -3. Expected: 128 passed. -
Step 4: Commit
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 theGitHostinterface (around line 7) and add three methods afterlistDir:
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:
/// 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 becauseGitHubHostandGiteaHostno 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 }:
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:
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
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.tswith this content:
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):
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,deleteBlobinextension/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:
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:
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
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:
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):
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):
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.
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
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.tsto 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):
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:
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
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 thePopupMessageunion (or whereverupdate_vault_settingsis defined) and add:
| { 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):
| { 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 existingupdate_vault_settingscases):
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:
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 existingupdate_vault_settingscase):
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:
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
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-disclosurerules -
Step 1: Create the component at
extension/src/popup/components/attachments-disclosure.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:
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:
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):
/* --- 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
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:
import { renderAttachmentsDisclosure, wireAttachmentsDisclosure, teardownAttachmentsDisclosure } from '../attachments-disclosure';
In the form HTML template, after the sections-editor disclosure HTML, add:
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
Above the template, declare draft state:
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):
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:
${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 = ...):
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:
${entry.attachment_summaries.length > 0 ? '<span class="entry-row__attach-indicator" title="has attachments">📎</span>' : ''}
Add corresponding CSS in styles.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
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-blockrules -
Step 1: Create
extension/src/popup/components/types/document.tsmodeled on the structure ofsecure-note.ts(one of the simpler types). Skeleton:
/// 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):
/* --- 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:
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
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 thedisabled: trueflag on Document in the type chooser -
Step 1: Update
extension/src/popup/components/item-form.tsdispatcher.
Find:
import * as totp from './types/totp';
Add:
import * as document from './types/document';
Find the switch statement (around line 24):
case 'document': return renderComingSoon(app, type);
Replace with:
case 'document': return document.renderForm(app, mode, existing);
Also call document.teardown() in the teardown sequence at the top of renderItemForm:
totp.teardown();
document.teardown();
- Step 2: Update
extension/src/popup/components/item-list.ts— find the type chooser (mentioned'coming in γ — needs attachment upload'):
{ type: 'document', icon: '📄', label: 'document', disabled: true, tooltip: 'coming in γ — needs attachment upload' },
Replace with:
{ type: 'document', icon: '📄', label: 'document' },
- Step 3: Find renderDetail dispatcher (probably in
item-detail.ts). Same pattern — add Document case routing todocument.renderDetail(app, item).
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
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
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
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
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
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
cd /home/alee/Sources/relicario && git status --short
Expected: only .claude/ may be untracked.
Verification summary
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_settingscases - 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)
- The router test pattern (Task 6) — model on existing
- Don't re-export old popover types from generator-panel — that module was renamed in the gen-UX redesign; ignore.
- The
popup.ts-sidehumanizeErrorhelper — can be used in error toasts if it exists in the codebase; otherwisealert()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 γ₁.