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

83 KiB
Raw Blame History

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:

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-checkcd 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 vitestcd 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 the GitHost interface (around line 7) and add three methods after listDir:

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-checkcd 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 }:

  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-verifycd extension && bunx tsc --noEmit 2>&1 | tail -3. Expected: zero errors.

  • Step 5: Run vitestcd 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.ts with 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, 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:

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 testscd extension && bun run test src/service-worker/__tests__/git-host.test.ts 2>&1 | tail -10. Expected: 3 passed.

  • Step 5: Run full vitestcd extension && bun run test 2>&1 | tail -3. Expected: 131 passed (was 128 + 3 new).

  • Step 6: Type-checkcd 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 testscd 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 + tsccd 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.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):

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 + testscd 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 the PopupMessage union (or wherever update_vault_settings is 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 existing update_vault_settings cases):
  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 existing update_vault_settings case):
    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 testscd extension && bun run test 2>&1 | tail -5. Expected: 137 passed (was 133 + 4 new).

  • Step 6: Type-checkcd 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-disclosure rules

  • 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) => ({ '&': '&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 testscd 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 + testscd 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 130180 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 testscd 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-checkcd extension && bunx tsc --noEmit 2>&1 | tail -3. Expected: zero errors.

  • Step 6: Buildcd 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-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:

/// 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) => ({ '&': '&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 testscd extension && bun run test 2>&1 | tail -3. Expected: 145 passed (143 + 2 new).

  • Step 5: Type-check + buildcd 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 the disabled: true flag on Document in the type chooser

  • Step 1: Update extension/src/popup/components/item-form.ts dispatcher.

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 to document.renderDetail(app, item).
case 'document':    return document.renderDetail(app, item);
  • Step 4: Run testscd extension && bun run test 2>&1 | tail -3. Expected: 145 passed.

  • Step 5: Type-check + buildcd 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_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 γ₁.