From 30816c2fe3f3b0a9f6b6f6136bd8c677c67e32ca Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 24 May 2026 13:01:01 -0400 Subject: [PATCH] docs: implementation plan for vault-tab management surfaces revamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 tasks covering settings/devices/trash/history pane revamps, plus groundwork (glyph constants, relative-time util, ssh-fingerprint util, shared CSS classes) and routing/nav wiring. Tasks are TDD where the work is testable (utils) and bite-sized manual-smoke where it's UI. Spec corrections folded in: - Devices revoke is upgrade (text+confirm → glyph+inline), not greenfield - Fingerprint via webcrypto in extension, no SW shape change, no WASM - Routing keeps 'field-history' as internal dispatch key; only user-facing hash normalizes #field-history → #history for backward compat Co-Authored-By: Claude Opus 4.7 --- ...24-vault-tab-management-surfaces-revamp.md | 1735 +++++++++++++++++ ...t-tab-management-surfaces-revamp-design.md | 18 +- 2 files changed, 1744 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-24-vault-tab-management-surfaces-revamp.md diff --git a/docs/superpowers/plans/2026-05-24-vault-tab-management-surfaces-revamp.md b/docs/superpowers/plans/2026-05-24-vault-tab-management-surfaces-revamp.md new file mode 100644 index 0000000..5bb8af1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-vault-tab-management-surfaces-revamp.md @@ -0,0 +1,1735 @@ +# Vault-tab Management Surfaces Revamp Implementation Plan + +> **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:** Apply the fullscreen visual language to all four extension management panes (Settings, Devices, Trash, History), close functional gaps (per-device session-timeout UI, glyph-button revoke + inline two-step, SHA256 fingerprint + added-by display, per-item purge countdown), and add a new "items with history" index pane. + +**Architecture:** Pure extension change — no core, no WASM, no schema. Shared components in `extension/src/popup/components/` render in both popup (~360px) and vault tab (full). One new pane file (`item-history-index.ts`), one new util (`shared/ssh-fingerprint.ts`), one util extracted from 5 duplicate inline copies (`shared/relative-time.ts`), three new glyph constants. Hash route `#field-history/` normalized to `#history/` with one release of backward-compat. New sidebar bottom-nav slot `◷ history`. + +**Tech Stack:** TypeScript, vitest + happy-dom for unit tests, webpack for build. Existing patterns: template-literal HTML, BEM-ish CSS, BFS message protocol via `shared/state.ts` `sendMessage` helper. + +**Spec:** `docs/superpowers/specs/2026-05-23-vault-tab-management-surfaces-revamp-design.md` + +--- + +## Task 1: Add glyph constants + +**Files:** +- Modify: `extension/src/shared/glyphs.ts` +- Test: `extension/src/shared/__tests__/glyphs.test.ts` + +Three new constants needed: `GLYPH_HISTORY ◷`, `GLYPH_REVOKE ⊘` (same char as existing `GLYPH_HIDE` but semantically distinct — kept as separate constant so future re-skinning can decouple), `GLYPH_RESTORE ⤺`. `GLYPH_TRASH`, `GLYPH_DEVICES`, `GLYPH_SETTINGS`, `GLYPH_LOCK`, `GLYPH_COPY` (`⎘`), `GLYPH_REVEAL` (`⊙`) already exist. + +- [ ] **Step 1: Read the existing glyphs test** to learn the test pattern + +Run: `cat extension/src/shared/__tests__/glyphs.test.ts` +Expected output: see the existing test shape so new assertions match style. + +- [ ] **Step 2: Add failing test for new constants** + +Append to `extension/src/shared/__tests__/glyphs.test.ts` (before the closing brace of the existing `describe` block, or in a new `describe`): + +```ts +import { GLYPH_HISTORY, GLYPH_REVOKE, GLYPH_RESTORE } from '../glyphs'; + +describe('management-surface glyphs', () => { + it('exposes a history glyph', () => { + expect(GLYPH_HISTORY).toBe('◷'); + }); + + it('exposes a revoke glyph distinct from reveal/hide semantics', () => { + expect(GLYPH_REVOKE).toBe('⊘'); + }); + + it('exposes a restore glyph for trash actions', () => { + expect(GLYPH_RESTORE).toBe('⤺'); + }); +}); +``` + +- [ ] **Step 3: Run the test to confirm it fails** + +Run: `cd extension && npx vitest run src/shared/__tests__/glyphs.test.ts` +Expected: FAIL — `GLYPH_HISTORY`/`GLYPH_REVOKE`/`GLYPH_RESTORE` are not exported. + +- [ ] **Step 4: Add the constants** + +In `extension/src/shared/glyphs.ts`, add after the existing `GLYPH_VAULT_TAB` line: + +```ts +export const GLYPH_HISTORY = '◷'; // sidebar history nav (clock-quadrant — distinct from clock emoji) +export const GLYPH_REVOKE = '⊘'; // revoke device / autofill-origin ack (same shape as HIDE; kept distinct for semantic clarity) +export const GLYPH_RESTORE = '⤺'; // restore from trash +``` + +- [ ] **Step 5: Run the test to confirm it passes** + +Run: `cd extension && npx vitest run src/shared/__tests__/glyphs.test.ts` +Expected: PASS — all three new assertions green. + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/shared/glyphs.ts extension/src/shared/__tests__/glyphs.test.ts +git commit -m "feat(extension): add history/revoke/restore glyph constants" +``` + +--- + +## Task 2: Create shared relative-time util + +**Files:** +- Create: `extension/src/shared/relative-time.ts` +- Test: `extension/src/shared/__tests__/relative-time.test.ts` + +Five inline copies exist today (with subtle differences in upper bounds): `settings-vault.ts:70`, `devices.ts:13` (has w/mo bands), `trash.ts:20`, `field-history.ts:8` (has w/mo bands), `vault/vault.ts:125`. Canonical version uses devices.ts's full bands (m/h/d/w/mo) since that's the most complete and the new history index needs the full range. + +- [ ] **Step 1: Write the failing test** + +Create `extension/src/shared/__tests__/relative-time.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { relativeTime, daysUntilPurge } from '../relative-time'; + +const NOW_UNIX = 1779552000; // fixed reference instant + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW_UNIX * 1000)); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('relativeTime', () => { + it('returns "just now" under 60s', () => { + expect(relativeTime(NOW_UNIX - 30)).toBe('just now'); + }); + it('returns minutes under an hour', () => { + expect(relativeTime(NOW_UNIX - 600)).toBe('10m ago'); + }); + it('returns hours under a day', () => { + expect(relativeTime(NOW_UNIX - 7200)).toBe('2h ago'); + }); + it('returns days under a week', () => { + expect(relativeTime(NOW_UNIX - 3 * 86400)).toBe('3d ago'); + }); + it('returns weeks under a month', () => { + expect(relativeTime(NOW_UNIX - 14 * 86400)).toBe('2w ago'); + }); + it('returns months above 30 days', () => { + expect(relativeTime(NOW_UNIX - 90 * 86400)).toBe('3mo ago'); + }); +}); + +describe('daysUntilPurge', () => { + it('returns null for forever retention', () => { + expect(daysUntilPurge(NOW_UNIX - 5 * 86400, { kind: 'forever' })).toBeNull(); + }); + it('returns remaining days for a recent trash', () => { + expect(daysUntilPurge(NOW_UNIX - 8 * 86400, { kind: 'days', value: 30 })).toBe(22); + }); + it('clamps to zero when retention already elapsed', () => { + expect(daysUntilPurge(NOW_UNIX - 60 * 86400, { kind: 'days', value: 30 })).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `cd extension && npx vitest run src/shared/__tests__/relative-time.test.ts` +Expected: FAIL — module `../relative-time` does not exist. + +- [ ] **Step 3: Implement the util** + +Create `extension/src/shared/relative-time.ts`: + +```ts +/// Single source of truth for relative-time formatting and trash-retention math. +/// Pulled out of five near-duplicate inline copies (settings-vault, devices, +/// trash, field-history, vault/vault). + +import type { TrashRetention } from './types'; + +/// Format a past unix timestamp (seconds) as "Nm ago" / "Nh ago" / "Nd ago" / +/// "Nw ago" / "Nmo ago" relative to now. Returns "just now" under 60 seconds. +export function relativeTime(unixSec: number): string { + const now = Math.floor(Date.now() / 1000); + const diff = now - unixSec; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; + if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`; + return `${Math.floor(diff / 2592000)}mo ago`; +} + +/// Days remaining until an item trashed at `trashedAt` (unix seconds) will be +/// auto-purged given the vault's retention policy. Returns null for forever +/// retention; clamps to 0 when the retention window has already elapsed. +export function daysUntilPurge(trashedAt: number, retention: TrashRetention): number | null { + if (retention.kind === 'forever') return null; + const trashedDaysAgo = Math.floor((Date.now() / 1000 - trashedAt) / 86400); + return Math.max(0, retention.value - trashedDaysAgo); +} +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: `cd extension && npx vitest run src/shared/__tests__/relative-time.test.ts` +Expected: PASS — all 9 assertions green. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/shared/relative-time.ts extension/src/shared/__tests__/relative-time.test.ts +git commit -m "feat(extension): add shared relative-time util with tests" +``` + +--- + +## Task 3: Migrate the five duplicate relativeTime copies + +**Files:** +- Modify: `extension/src/popup/components/settings-vault.ts` (lines 68-77) +- Modify: `extension/src/popup/components/devices.ts` (lines 13-21) +- Modify: `extension/src/popup/components/trash.ts` (lines 20-33; also `daysUntilPurge` lines 29-33) +- Modify: `extension/src/popup/components/field-history.ts` (lines 8-17) +- Modify: `extension/src/vault/vault.ts` (lines 125-131) + +Each file currently has a local `relativeTime` and (in trash.ts) `daysUntilPurge`. Replace with imports from `shared/relative-time.ts`. **Behavioral parity matters** — the canonical impl has w/mo bands that some local copies lack; that's an intentional upgrade (months read more naturally than "365d ago"). Smoke after migration. + +- [ ] **Step 1: Replace in settings-vault.ts** + +In `extension/src/popup/components/settings-vault.ts`, delete lines 68-77 (the `// --- Time formatting ---` block and `relativeTime` function). Add to the top imports block: + +```ts +import { relativeTime } from '../../shared/relative-time'; +``` + +- [ ] **Step 2: Replace in devices.ts** + +In `extension/src/popup/components/devices.ts`, delete lines 13-21 (the `relativeTime` function). Add to the top imports: + +```ts +import { relativeTime } from '../../shared/relative-time'; +``` + +- [ ] **Step 3: Replace in trash.ts** + +In `extension/src/popup/components/trash.ts`, delete lines 20-33 (both `relativeTime` and `daysUntilPurge`). Add to the top imports: + +```ts +import { relativeTime, daysUntilPurge } from '../../shared/relative-time'; +``` + +- [ ] **Step 4: Replace in field-history.ts** + +In `extension/src/popup/components/field-history.ts`, delete lines 8-17 (the `relativeTime` function). Add to the top imports: + +```ts +import { relativeTime } from '../../shared/relative-time'; +``` + +- [ ] **Step 5: Replace in vault/vault.ts** + +In `extension/src/vault/vault.ts`, delete lines 125-131 (the `relativeTime` function). Add to the existing `from '../shared/glyphs'` import block area: + +```ts +import { relativeTime } from '../shared/relative-time'; +``` + +- [ ] **Step 6: Build the extension to catch any leftover references** + +Run: `cd extension && npm run build` +Expected: SUCCESS — webpack compiles without "undefined relativeTime" or "duplicate identifier" errors. + +- [ ] **Step 7: Run all extension tests** + +Run: `cd extension && npm test` +Expected: PASS — all existing tests still green; the relative-time tests from Task 2 still green. + +- [ ] **Step 8: Commit** + +```bash +git add extension/src/popup/components/settings-vault.ts \ + extension/src/popup/components/devices.ts \ + extension/src/popup/components/trash.ts \ + extension/src/popup/components/field-history.ts \ + extension/src/vault/vault.ts +git commit -m "refactor(extension): consolidate 5 relativeTime copies into shared util" +``` + +--- + +## Task 4: Add shared CSS utility classes + +**Files:** +- Modify: `extension/src/vault/vault.css` +- Modify: `extension/src/popup/popup.css` (or wherever popup CSS lives — verify before editing) + +Add the four utility classes spec §1 calls out: `.section-header`, `.glyph-btn`, `.kv-row`, `.fingerprint`. These must exist in both stylesheets because the shared components render in both contexts. + +- [ ] **Step 1: Locate the popup CSS file** + +Run: `find extension/src/popup -name "*.css" -maxdepth 2` +Expected: identifies the popup stylesheet path (likely `extension/src/popup/popup.css` or `extension/src/popup/styles.css`). Use the path returned below as ``. + +- [ ] **Step 2: Append utility classes to vault.css** + +Append to `extension/src/vault/vault.css`: + +```css +/* --- Shared utility classes for management surfaces (settings/devices/trash/history) --- */ + +.section-header { + text-transform: uppercase; + font-weight: 500; + letter-spacing: 1px; + color: var(--text-muted); + border-bottom: 1px solid var(--border-subtle); + padding-bottom: 4px; + margin: 16px 0 10px 0; + font-size: 11px; +} + +.glyph-btn { + min-width: 28px; + font-family: ui-monospace, monospace; + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border-subtle); + border-radius: 3px; + padding: 2px 6px; + cursor: pointer; +} +.glyph-btn:hover { color: var(--text); background: var(--bg-input); } +.glyph-btn:focus-visible { box-shadow: var(--focus-ring); outline: none; } +.glyph-btn[data-danger]:hover { color: var(--danger); border-color: var(--danger); } + +.kv-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 4px 0; +} +.kv-row > .k { color: var(--text-muted); } +.kv-row > .v { color: var(--text); font-variant-numeric: tabular-nums; } + +.fingerprint { + font-family: ui-monospace, monospace; + color: var(--text-muted); + font-size: 11px; + word-break: break-all; /* wraps to two lines in popup (~360px) */ + line-height: 1.4; +} +``` + +- [ ] **Step 3: Append the same block to the popup stylesheet** + +Append the identical block to `` (path from Step 1). + +- [ ] **Step 4: Build to confirm no CSS syntax errors** + +Run: `cd extension && npm run build` +Expected: SUCCESS — no PostCSS / webpack errors. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/vault/vault.css +git commit -m "feat(extension): add shared section-header/glyph-btn/kv-row/fingerprint CSS" +``` + +--- + +## Task 5: Add SSH fingerprint util + +**Files:** +- Create: `extension/src/shared/ssh-fingerprint.ts` +- Test: `extension/src/shared/__tests__/ssh-fingerprint.test.ts` + +Computes the standard SSH `SHA256:` fingerprint from a public-key string of the form `ssh-ed25519 [comment]`. Uses webcrypto `subtle.digest`. Returns the formatted string or null if input is malformed (devices.ts will fall back to "(unknown)" in render). + +- [ ] **Step 1: Write the failing test** + +Create `extension/src/shared/__tests__/ssh-fingerprint.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { sshFingerprint } from '../ssh-fingerprint'; + +describe('sshFingerprint', () => { + it('formats a known ed25519 key to SHA256:', async () => { + // Public key for the seed below — same format `relicario device list` prints. + // Pre-computed: SHA256 of the base64-decoded key blob, base64-no-pad encoded. + const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8wRgr7y2BwnIaUMfqCcW8GZTYCmGoiCQ0c3VwYTtVZ alice@example'; + const fp = await sshFingerprint(key); + expect(fp).toMatch(/^SHA256:[A-Za-z0-9+/]+$/); + expect(fp?.includes('=')).toBe(false); + }); + + it('returns null for malformed input', async () => { + expect(await sshFingerprint('')).toBeNull(); + expect(await sshFingerprint('not a key')).toBeNull(); + expect(await sshFingerprint('ssh-ed25519')).toBeNull(); // missing blob + }); + + it('returns null for invalid base64', async () => { + expect(await sshFingerprint('ssh-ed25519 !!!notbase64!!!')).toBeNull(); + }); + + it('is deterministic for the same key', async () => { + const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8wRgr7y2BwnIaUMfqCcW8GZTYCmGoiCQ0c3VwYTtVZ'; + const a = await sshFingerprint(key); + const b = await sshFingerprint(key); + expect(a).toBe(b); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `cd extension && npx vitest run src/shared/__tests__/ssh-fingerprint.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the util** + +Create `extension/src/shared/ssh-fingerprint.ts`: + +```ts +/// SSH-style SHA256 fingerprint of an ed25519 public key, computed in the +/// extension so devices.ts can display verifiable IDs without a SW round-trip. +/// Output format matches `ssh-keygen -lf` and `relicario device list`: +/// SHA256: + +function base64Decode(b64: string): Uint8Array | null { + try { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } catch { + return null; + } +} + +function base64Encode(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s); +} + +export async function sshFingerprint(publicKey: string): Promise { + if (!publicKey) return null; + const parts = publicKey.trim().split(/\s+/); + if (parts.length < 2) return null; // need " " + const blob = base64Decode(parts[1]); + if (!blob || blob.length === 0) return null; + const hash = await crypto.subtle.digest('SHA-256', blob); + const b64 = base64Encode(new Uint8Array(hash)).replace(/=+$/, ''); + return `SHA256:${b64}`; +} +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: `cd extension && npx vitest run src/shared/__tests__/ssh-fingerprint.test.ts` +Expected: PASS — all 4 assertions green. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/shared/ssh-fingerprint.ts extension/src/shared/__tests__/ssh-fingerprint.test.ts +git commit -m "feat(extension): add SSH SHA256 fingerprint util (webcrypto)" +``` + +--- + +## Task 6: Settings pane revamp + +**Files:** +- Modify: `extension/src/popup/components/settings-vault.ts` (full rewrite of `rerender` body — lines 90-181 — and add session row + handlers) + +Apply the spec §A layout: three top-level groups (`VAULT SETTINGS · synced` / `THIS DEVICE · local` / `ACTIONS`), two-column for small-field sections (Retention ↔ Generator; Attachments standalone), new SESSION row wired to `get_session_config` / `update_session_config`, form-header subtitle ("unsaved · ⌘+S to save" / "no changes"). Existing handlers for retention/history/attachments/generator/origin-revoke/save stay; SESSION handlers are new. + +- [ ] **Step 1: Add SessionTimeoutConfig import and load** + +In `extension/src/popup/components/settings-vault.ts`, add to the imports at the top: + +```ts +import type { SessionTimeoutConfig } from '../../shared/messages'; +``` + +Add a module-level cache below `pendingSettings`: + +```ts +let pendingSession: SessionTimeoutConfig | null = null; +let baseSession: SessionTimeoutConfig | null = null; +``` + +- [ ] **Step 2: Fetch session config before first render** + +Update `renderVaultSettings` to fetch the session config alongside the vault settings. After the `if (!base) { ... return; }` guard and before `pendingSettings = ...`, insert: + +```ts +// Fetch device-local session config; cached for diff-checking on save. +sendMessage({ type: 'get_session_config' }).then((resp) => { + if (resp.ok) { + baseSession = (resp.data as { config: SessionTimeoutConfig }).config; + pendingSession = JSON.parse(JSON.stringify(baseSession)) as SessionTimeoutConfig; + rerender(); + } +}); +``` + +(The initial `rerender()` at the bottom of the function will still fire; once the session config arrives the second `rerender()` paints the SESSION row with values.) + +- [ ] **Step 3: Rewrite the rerender body to match the new layout** + +In `extension/src/popup/components/settings-vault.ts`, replace the entire `function rerender()` body (currently lines 90-193) with: + +```ts + function rerender(): void { + if (!pendingSettings) return; + const acksEntries = Object.entries(pendingSettings.autofill_origin_acks) + .sort(([, a], [, b]) => b - a); + + const dirty = JSON.stringify(pendingSettings) !== JSON.stringify(base) + || (baseSession && JSON.stringify(pendingSession) !== JSON.stringify(baseSession)); + const subtitle = dirty ? 'unsaved · esc to cancel' : 'no changes'; + + const sessionMode = pendingSession?.mode ?? 'inactivity'; + const sessionMinutes = pendingSession && pendingSession.mode === 'inactivity' + ? pendingSession.minutes : 15; + + app.innerHTML = ` +
+
+ +

settings

+ ${escapeHtml(subtitle)} +
+ +
VAULT SETTINGS · synced
+ +
+
+
RETENTION
+
+ trash + +
+
+ history + +
+
+ +
+
GENERATOR
+

${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}

+ +
+
+ +
+
ATTACHMENTS
+
+ max size + +
+
+ +
+
AUTOFILL ORIGINS
+ ${acksEntries.length === 0 + ? `

No origins acknowledged yet.

` + : acksEntries.map(([host, ts]) => ` +
+ ${escapeHtml(host)} + ${escapeHtml(relativeTime(ts))} + +
+ `).join('')} +
+ +
THIS DEVICE · local
+ +
+
SESSION
+
+ +
+
+ + +
+
+ +
ACTIONS
+ +
+
+ + +
+
+ + +
+ `; + + // Set current select values + (document.getElementById('trash-retention') as HTMLSelectElement).value = + trashRetentionToValue(pendingSettings.trash_retention); + (document.getElementById('history-retention') as HTMLSelectElement).value = + historyRetentionToValue(pendingSettings.field_history_retention); + const capValue = pendingSettings.attachment_caps?.per_attachment_max_bytes ?? 10485760; + (document.getElementById('attachment-cap') as HTMLSelectElement).value = String(capValue); + (document.getElementById('session-minutes') as HTMLSelectElement).value = String(sessionMinutes); + + wireHandlers(); + } +``` + +- [ ] **Step 4: Add session handlers to wireHandlers** + +In the same file, find the `wireHandlers()` function (currently lines 202-272) and append these handlers before the closing brace: + +```ts + document.querySelectorAll('input[name="session-mode"]').forEach((el) => { + el.addEventListener('change', () => { + const mode = (document.querySelector('input[name="session-mode"]:checked')?.value ?? 'inactivity') as 'every_time' | 'inactivity'; + if (mode === 'every_time') { + pendingSession = { mode: 'every_time' }; + } else { + const mins = Number((document.getElementById('session-minutes') as HTMLSelectElement).value); + pendingSession = { mode: 'inactivity', minutes: mins }; + } + rerender(); + }); + }); + + document.getElementById('session-minutes')?.addEventListener('change', (e) => { + const mins = Number((e.target as HTMLSelectElement).value); + if (pendingSession?.mode === 'inactivity') { + pendingSession = { mode: 'inactivity', minutes: mins }; + rerender(); + } + }); +``` + +- [ ] **Step 5: Extend the save handler to commit session config** + +In the same file, find the save handler (`document.getElementById('save-btn')?.addEventListener('click', async () => { ... })`). Modify it to also persist session config when dirty: + +```ts + document.getElementById('save-btn')?.addEventListener('click', async () => { + if (!pendingSettings) return; + const resp = await sendMessage({ type: 'update_vault_settings', settings: pendingSettings }); + if (!resp.ok) { + setState({ error: resp.error }); + return; + } + if (pendingSession && JSON.stringify(pendingSession) !== JSON.stringify(baseSession)) { + const sessResp = await sendMessage({ type: 'update_session_config', config: pendingSession }); + if (!sessResp.ok) { + setState({ error: sessResp.error }); + return; + } + baseSession = JSON.parse(JSON.stringify(pendingSession)) as SessionTimeoutConfig; + } + // Refresh cached state and navigate back. + const refreshed = await sendMessage({ type: 'get_vault_settings' }); + if (refreshed.ok && refreshed.data) { + const vs = (refreshed.data as { settings: VaultSettings }).settings; + if (vs) { + setState({ vaultSettings: vs, generatorDefaults: vs.generator_defaults }); + } + } + navigate('list'); + }); +``` + +- [ ] **Step 6: Clear session cache on teardown** + +Update the `teardown` function (line 15) to reset session state: + +```ts +export function teardown(): void { + closeGeneratorPanel(); + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } + pendingSettings = null; + pendingSession = null; + baseSession = null; +} +``` + +- [ ] **Step 7: Add minimal CSS for the settings grid** + +Append to both stylesheets (`vault.css` and the popup CSS file from Task 4): + +```css +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} +@media (max-width: 720px) { + .settings-grid { grid-template-columns: 1fr; } +} + +.settings-header__sub { + margin-left: auto; + font-size: 11px; +} +``` + +- [ ] **Step 8: Build the extension** + +Run: `cd extension && npm run build` +Expected: SUCCESS — TypeScript compiles, no missing-import errors. + +- [ ] **Step 9: Manual smoke (load extension, open settings)** + +Load the unpacked extension at `extension/dist/chrome` in `chrome://extensions` (or re-load if already loaded). Open the vault tab, navigate to settings. Verify: +- Three groups visible: VAULT SETTINGS · synced / THIS DEVICE · local / ACTIONS +- Retention + Generator render side-by-side at full width; stack at narrow widths +- SESSION row shows current config (default `inactivity` 15 min) +- Toggling radio between `every_time` and `inactivity` disables/enables the minutes select +- Subtitle reads "no changes" initially, switches to "unsaved · esc to cancel" after any edit +- Save persists session config (re-open settings; new value sticks) +- "esc" still navigates back to list + +- [ ] **Step 10: Commit** + +```bash +git add extension/src/popup/components/settings-vault.ts extension/src/vault/vault.css +git commit -m "feat(extension): settings pane revamp — synced/local split + session timeout UI" +``` + +--- + +## Task 7: Devices pane revamp + +**Files:** +- Modify: `extension/src/popup/components/devices.ts` (rewrite render body + handlers — lines 41-179) + +Switch to three-line per-entry rhythm with full fingerprint + added-by. Replace text "revoke" button with `⊘` glyph button. Replace browser `confirm()` with inline two-step (clicking `⊘` swaps the row's right edge for a confirmation panel; cancel restores). Flesh out the unregistered banner copy. Fingerprint computed via the Task 5 util. + +- [ ] **Step 1: Add imports** + +In `extension/src/popup/components/devices.ts`, replace the existing top imports with: + +```ts +import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; +import type { Device } from '../../shared/types'; +import { relativeTime } from '../../shared/relative-time'; +import { sshFingerprint } from '../../shared/ssh-fingerprint'; +import { GLYPH_REVOKE, GLYPH_DEVICES } from '../../shared/glyphs'; +``` + +(`relativeTime` import may already be there from Task 3 — that's fine, dedupe.) + +- [ ] **Step 2: Precompute fingerprints in renderDevices** + +Inside `renderDevices`, after fetching `devicesResp` and before building `activeDevicesHtml`, insert: + +```ts + // Compute fingerprints for active devices in parallel; failures fall back to "(unknown)". + const fingerprints = new Map(); + await Promise.all(devices.map(async (d) => { + const fp = await sshFingerprint(d.public_key); + fingerprints.set(d.name, fp ?? '(unknown)'); + })); +``` + +- [ ] **Step 3: Rewrite activeDevicesHtml for three-line rhythm** + +Replace the existing `activeDevicesHtml` builder (currently lines 64-77) with: + +```ts + const activeDevicesHtml = devices.length === 0 + ? `

No devices registered

` + : devices.map((d) => { + const isCurrentDevice = d.name === currentDeviceName; + const fp = fingerprints.get(d.name) ?? '(unknown)'; + const addedBy = d.added_by && d.added_by !== 'unknown' ? ` · by ${escapeHtml(d.added_by)}` : ''; + return ` +
+
+ ${escapeHtml(d.name)} + ${isCurrentDevice + ? '← you' + : ``} +
+
${escapeHtml(fp)}
+
added ${escapeHtml(relativeTime(d.added_at))}${addedBy}
+ +
+ `; + }).join(''); +``` + +- [ ] **Step 4: Flesh out the unregistered banner** + +In the same file, replace the existing banner template (currently lines 108-113) with: + +```ts + ${!isRegistered ? ` +
+
This device isn't registered.
+

Registering generates an ed25519 keypair and adds the public key to .relicario/devices.json on the remote.

+ +
+ ` : ''} +``` + +- [ ] **Step 5: Replace browser confirm with inline two-step revoke** + +In the same file, replace the existing revoke click handler (currently lines 162-178) with: + +```ts + document.querySelectorAll('[data-revoke]').forEach((btn) => { + btn.addEventListener('click', () => { + const name = btn.dataset.revoke; + if (!name) return; + const panel = document.querySelector(`[data-confirm-for="${CSS.escape(name)}"]`); + if (!panel) return; + panel.hidden = false; + panel.innerHTML = ` +

+ Revoke this device? It won't be able to sign commits or push changes after revocation. +

+
+ + +
+ `; + btn.disabled = true; + + panel.querySelector('[data-revoke-cancel]')?.addEventListener('click', () => { + panel.hidden = true; + panel.innerHTML = ''; + btn.disabled = false; + }); + + panel.querySelector('[data-revoke-confirm]')?.addEventListener('click', async () => { + const confirmBtn = panel.querySelector('[data-revoke-confirm]')!; + confirmBtn.disabled = true; + confirmBtn.textContent = '...'; + const result = await sendMessage({ type: 'revoke_device', name }); + if (result.ok) { + await sendMessage({ type: 'sync' }); + renderDevices(app); + } else { + setState({ error: result.error }); + } + }); + }); + }); +``` + +- [ ] **Step 6: Update the revoked section to also use relative-time and added-by** + +In the same file, replace the existing `revokedSectionHtml` builder (lines 79-100) with: + +```ts + const revokedSectionHtml = revokedDevices.length === 0 ? '' : ` +
+ ▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''} +
+ ${revokedDevices.map((r) => ` +
+
+ + ${escapeHtml(r.name)} + +
+
+ revoked ${escapeHtml(relativeTime(r.revoked_at))}${r.revoked_by !== 'unknown' ? ` · by ${escapeHtml(r.revoked_by)}` : ''} +
+
+ `).join('')} +
+
+ `; +``` + +- [ ] **Step 7: Update the outer template to add ACTIVE section header** + +In the same file, replace the existing `app.innerHTML = ...` block (lines 102-117) with: + +```ts + app.innerHTML = ` +
+
+ +

devices

+
+ ${!isRegistered ? ` +
+
This device isn't registered.
+

Registering generates an ed25519 keypair and adds the public key to .relicario/devices.json on the remote.

+ +
+ ` : ''} + ${devices.length > 0 ? `
ACTIVE · ${devices.length}
` : ''} + ${activeDevicesHtml} + ${revokedDevices.length > 0 ? `
REVOKED · ${revokedDevices.length}
` : ''} + ${revokedSectionHtml} +
+ `; +``` + +- [ ] **Step 8: Add device-row CSS for the new layout** + +Append to both stylesheets: + +```css +.device-row { + padding: 10px 0; + border-bottom: 1px solid var(--border-subtle); +} +.device-row__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 2px; +} +.device-row__name { color: var(--text); } +.device-row__you { + font-size: 11px; + color: var(--text-muted); + margin-left: 8px; +} +.device-row__meta { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} +.device-row__confirm { + margin-top: 8px; + padding: 10px; + border: 1px solid var(--border-subtle); + border-radius: 3px; + background: var(--bg-input); +} +.device-row__confirm-text { margin: 0 0 8px 0; color: var(--text); } +.device-row__confirm-actions { display: flex; gap: 8px; justify-content: flex-end; } + +.device-banner { + padding: 12px; + border: 1px solid var(--border-subtle); + border-radius: 3px; + background: var(--bg-pane); + margin-bottom: 16px; +} +.device-banner__title { margin-bottom: 4px; } +.device-banner__body { font-size: 12px; margin: 0 0 10px 0; } + +.btn-danger { + background: var(--danger); + color: white; + border-color: var(--danger); +} +``` + +- [ ] **Step 9: Build the extension** + +Run: `cd extension && npm run build` +Expected: SUCCESS — TS + webpack compile clean. + +- [ ] **Step 10: Manual smoke** + +Re-load extension, navigate to devices. Verify: +- Each device shows three lines: name + (← you / `⊘` button), full fingerprint, "added X ago · by Y" +- Fingerprint matches `relicario device list` output (run it in a terminal to compare) +- Click `⊘` on a non-current device → inline confirm panel slides in, button disables +- Cancel → panel disappears, button re-enables +- Confirm revoke → sync → re-renders with device gone (move to revoked section) +- Unregistered state shows full banner copy with code snippet for `.relicario/devices.json` +- Popup context (~360px width) wraps fingerprint to two lines correctly + +- [ ] **Step 11: Commit** + +```bash +git add extension/src/popup/components/devices.ts extension/src/vault/vault.css +git commit -m "feat(extension): devices pane revamp — fingerprint + added-by + inline two-step revoke" +``` + +--- + +## Task 8: Trash pane revamp + +**Files:** +- Modify: `extension/src/popup/components/trash.ts` (rewrite render body + glyph imports — lines 1-127) + +Apply visual language: section header rule, glyph restore button (`⤺`), per-item purge countdown on the muted meta line, destructive "empty trash" button anchored bottom-right. + +- [ ] **Step 1: Update imports** + +In `extension/src/popup/components/trash.ts`, replace the existing top imports with: + +```ts +import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; +import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types'; +import { + GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD, + GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP, + GLYPH_RESTORE, +} from '../../shared/glyphs'; +import { relativeTime, daysUntilPurge } from '../../shared/relative-time'; +``` + +(Remove the local `relativeTime` / `daysUntilPurge` if Task 3 didn't fully strip them.) + +- [ ] **Step 2: Replace the renderTrash body** + +In the same file, replace the entire `renderTrash` function body (currently lines 39-126) with: + +```ts +export async function renderTrash(app: HTMLElement): Promise { + const state = getState(); + + const resp = await sendMessage({ type: 'list_trashed' }); + if (!resp.ok) { + app.innerHTML = `

Failed to load trash

`; + return; + } + + const items = (resp.data as { items: Array<[ItemId, ManifestEntry]> }).items; + const retention = state.vaultSettings?.trash_retention ?? { kind: 'days', value: 30 }; + + let oldestPurgeDays: number | null = null; + if (items.length > 0 && retention.kind === 'days') { + const oldest = items[items.length - 1][1]; + oldestPurgeDays = daysUntilPurge(oldest.trashed_at ?? 0, retention); + } + + const headerInfo = items.length === 0 + ? '' + : oldestPurgeDays !== null + ? `${items.length} item${items.length === 1 ? '' : 's'} · oldest purges in ${oldestPurgeDays} days` + : `${items.length} item${items.length === 1 ? '' : 's'} · retained forever`; + + app.innerHTML = ` +
+
+ +

trash

+
+ ${headerInfo ? `

${escapeHtml(headerInfo)}

` : ''} + ${items.length === 0 + ? `

Trash is empty

` + : `
 
+ ${items.map(([id, entry]) => { + const purgeIn = daysUntilPurge(entry.trashed_at ?? 0, retention); + const purgeStr = purgeIn === null ? 'retained forever' : `purges in ${purgeIn} days`; + return ` +
+ ${TYPE_ICONS[entry.type] ?? '◻'} +
+ ${escapeHtml(entry.title)} + trashed ${escapeHtml(relativeTime(entry.trashed_at ?? 0))} · ${escapeHtml(purgeStr)} +
+ +
+ `; + }).join('')}` + } + ${items.length > 0 ? ` + + ` : ''} +
+ `; + + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + + document.querySelectorAll('[data-restore]').forEach((btn) => { + btn.addEventListener('click', async () => { + const id = btn.dataset.restore; + if (!id) return; + btn.disabled = true; + btn.textContent = '...'; + const result = await sendMessage({ type: 'restore_item', id }); + if (result.ok) { + await sendMessage({ type: 'sync' }); + renderTrash(app); + } else { + setState({ error: result.error }); + } + }); + }); + + document.getElementById('empty-trash-btn')?.addEventListener('click', async () => { + if (!confirm(`Permanently delete ${items.length} item${items.length === 1 ? '' : 's'}? This cannot be undone.`)) return; + const btn = document.getElementById('empty-trash-btn') as HTMLButtonElement; + btn.disabled = true; + btn.textContent = 'deleting...'; + const result = await sendMessage({ type: 'purge_all_trash' }); + if (result.ok) { + await sendMessage({ type: 'sync' }); + renderTrash(app); + } else { + setState({ error: result.error }); + } + }); +} +``` + +- [ ] **Step 3: Add trash-row CSS** + +Append to both stylesheets: + +```css +.trash-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border-subtle); +} +.trash-row__icon { font-size: 14px; } +.trash-row__info { flex: 1; display: flex; flex-direction: column; } +.trash-row__title { color: var(--text); } +.trash-row__meta { font-size: 11px; color: var(--text-muted); } +.trash-footer { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} +``` + +- [ ] **Step 4: Build** + +Run: `cd extension && npm run build` +Expected: SUCCESS. + +- [ ] **Step 5: Manual smoke** + +Trash a few items (delete from item detail). Open trash pane. Verify: +- Header shows "N items · oldest purges in M days" +- Each row: type icon, title, muted line "trashed X ago · purges in Y days", `⤺` restore glyph at right +- Click `⤺` → restores, row disappears +- "empty trash" button anchored bottom-right, danger-colored +- Click "empty trash" → browser confirm → purges all + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/popup/components/trash.ts extension/src/vault/vault.css +git commit -m "feat(extension): trash pane revamp — per-item purge countdown + glyph restore" +``` + +--- + +## Task 9: History per-item view polish + +**Files:** +- Modify: `extension/src/popup/components/field-history.ts` (visual polish — keep filename, keep structure) + +Apply visual language: section-header rule per field, glyph buttons for copy and reveal toggle. **No structural changes** — same content layout, same reveal/copy behavior, same security pattern (valueStore). + +- [ ] **Step 1: Update imports** + +In `extension/src/popup/components/field-history.ts`, replace the existing top imports with: + +```ts +import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; +import { colorizePassword } from '../../shared/password-coloring'; +import type { FieldHistoryView } from '../../shared/types'; +import { GLYPH_COPY, GLYPH_REVEAL, GLYPH_HIDE } from '../../shared/glyphs'; +import { relativeTime } from '../../shared/relative-time'; +``` + +- [ ] **Step 2: Update renderEntry to use glyph buttons and section-header per field** + +In the same file, replace the existing `renderEntry` helper (currently lines 66-82) with: + +```ts + function renderEntry(fieldId: string, value: string, timestamp: number, isCurrent: boolean): string { + const entryKey = `${fieldId}-${timestamp}`; + const isRevealed = revealedSet.has(entryKey); + const displayValue = isRevealed ? escapeHtml(value) : '••••••••••••'; + valueStore.set(entryKey, value); + const revealGlyph = isRevealed ? GLYPH_HIDE : GLYPH_REVEAL; + + return ` +
+
${displayValue}
+ +
+ + +
+
+ `; + } +``` + +- [ ] **Step 3: Apply section-header rule to per-field labels** + +In the same file, replace the content-building loop (currently lines 84-95) with: + +```ts + let content = ''; + for (const field of history) { + const entryCount = field.entries.length + 1; // +1 for current + content += `
${escapeHtml(field.field_name.toUpperCase())} · ${entryCount} ${entryCount === 1 ? 'entry' : 'entries'}
`; + content += renderEntry(field.field_id, field.current_value, item.modified, true); + for (const entry of field.entries) { + content += renderEntry(field.field_id, entry.value, entry.changed_at, false); + } + } +``` + +- [ ] **Step 4: Decouple reveal toggle from row click** + +In the same file, replace the existing row click handler (currently lines 122-134) with an explicit reveal-button handler (and remove the row-click handler since clicks now flow through the explicit glyph button): + +```ts + app.querySelectorAll('[data-entry-reveal]').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const key = btn.dataset.entryReveal; + if (!key) return; + if (revealedSet.has(key)) revealedSet.delete(key); + else revealedSet.add(key); + renderFieldHistory(app); + }); + }); +``` + +- [ ] **Step 5: Add history-entry CSS** + +Append to both stylesheets: + +```css +.history-entry { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border-subtle); +} +.history-entry__value { + font-family: ui-monospace, monospace; + word-break: break-all; +} +.history-entry__value.masked { letter-spacing: 1px; } +.history-entry__meta { + grid-column: 1 / 2; + font-size: 11px; +} +.history-entry__actions { + grid-row: 1 / 3; + grid-column: 2 / 3; + display: flex; + gap: 4px; +} +``` + +- [ ] **Step 6: Build** + +Run: `cd extension && npm run build` +Expected: SUCCESS. + +- [ ] **Step 7: Manual smoke** + +Open an item with field history (an item where the password has been changed). Click "view history" from item detail. Verify: +- Section header per field: `PASSWORD · 3 entries` uppercase with 1px rule +- Each entry: masked value, muted meta line "current · set X ago" / "changed X ago", reveal `⊙`/`⊘` glyph + copy `⎘` glyph at right +- Click reveal → value shows with colorize; glyph swaps to `⊘`; click again hides +- Click copy → clipboard gets value; glyph briefly shows `✓` + +- [ ] **Step 8: Commit** + +```bash +git add extension/src/popup/components/field-history.ts extension/src/vault/vault.css +git commit -m "feat(extension): field-history pane visual polish — section headers + glyph buttons" +``` + +--- + +## Task 10: New history-index pane + +**Files:** +- Create: `extension/src/popup/components/item-history-index.ts` + +New pane that lists items having any field history. Reachable from the new `◷ history` sidebar slot (wired in Task 11). Each row clicks through to `#history/` (per-item view from Task 9). Empty state when no items have history. + +- [ ] **Step 1: Implement the component** + +Create `extension/src/popup/components/item-history-index.ts`: + +```ts +/// History index — lists items that have any field history, sorted by most-recent +/// change. Clicking a row drills into the per-item view (field-history.ts). +/// +/// Implementation: iterate manifest, fetch each item via get_item, check the +/// field_history map; emit entries with ≥1 non-empty history-tracked field. + +import { getState, sendMessage, navigate, setState, escapeHtml } from '../../shared/state'; +import type { Item, ItemId, ManifestEntry } from '../../shared/types'; +import { relativeTime } from '../../shared/relative-time'; +import { + GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD, + GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP, +} from '../../shared/glyphs'; + +const TYPE_ICONS: Record = { + login: GLYPH_TYPE_LOGIN, + secure_note: GLYPH_TYPE_SECURE_NOTE, + identity: GLYPH_TYPE_IDENTITY, + card: GLYPH_TYPE_CARD, + key: GLYPH_TYPE_KEY, + document: GLYPH_TYPE_DOCUMENT, + totp: GLYPH_TYPE_TOTP, +}; + +interface HistoryIndexEntry { + id: ItemId; + type: string; + title: string; + changeCount: number; + lastChangedAt: number; +} + +export function teardown(): void { + // No persistent state. +} + +export async function renderItemHistoryIndex(app: HTMLElement): Promise { + const state = getState(); + const manifest = state.entries; + + app.innerHTML = ` +
+
+ +

history

+
+

Scanning items…

+
+ `; + app.querySelector('#back-btn')?.addEventListener('click', () => navigate('list')); + + // Fetch field_history for each item in parallel (skips items where the SW + // returns an error — those just don't appear in the index). + const entries: HistoryIndexEntry[] = []; + await Promise.all(manifest.map(async ([id, manifestEntry]: [ItemId, ManifestEntry]) => { + if (manifestEntry.trashed_at !== undefined && manifestEntry.trashed_at !== null) return; + const resp = await sendMessage({ type: 'get_field_history', id }); + if (!resp.ok) return; + const history = (resp.data as { history: Array<{ entries: Array<{ changed_at: number }> }> }).history; + let totalCount = 0; + let mostRecent = 0; + for (const field of history) { + totalCount += field.entries.length; + for (const e of field.entries) { + if (e.changed_at > mostRecent) mostRecent = e.changed_at; + } + } + if (totalCount > 0) { + entries.push({ + id, + type: manifestEntry.type, + title: manifestEntry.title, + changeCount: totalCount, + lastChangedAt: mostRecent, + }); + } + })); + + entries.sort((a, b) => b.lastChangedAt - a.lastChangedAt); + + if (entries.length === 0) { + app.innerHTML = ` +
+
+ +

history

+
+

+ No field history yet.
+ Edits to passwords, TOTP secrets, and concealed fields will appear here. +

+
+ `; + app.querySelector('#back-btn')?.addEventListener('click', () => navigate('list')); + return; + } + + app.innerHTML = ` +
+
+ +

history

+
+

${entries.length} item${entries.length === 1 ? '' : 's'} have field history

+
 
+ ${entries.map((e) => ` +
+ ${TYPE_ICONS[e.type] ?? '◻'} +
+ ${escapeHtml(e.title)} + ${e.changeCount} change${e.changeCount === 1 ? '' : 's'} · last ${escapeHtml(relativeTime(e.lastChangedAt))} +
+
+ `).join('')} +
+ `; + + app.querySelector('#back-btn')?.addEventListener('click', () => navigate('list')); + app.querySelectorAll('.history-index-row').forEach((row) => { + row.addEventListener('click', async () => { + const id = row.dataset.id as ItemId; + // Load the item into state so field-history.ts can render it + const itemResp = await sendMessage({ type: 'get_item', id }); + if (!itemResp.ok) { + setState({ error: 'Failed to load item' }); + return; + } + const item = (itemResp.data as { item: Item }).item; + setState({ selectedId: id, selectedItem: item, historyItemId: id }); + navigate('field-history'); + }); + }); +} +``` + +- [ ] **Step 2: Add history-index CSS** + +Append to both stylesheets: + +```css +.history-index-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; +} +.history-index-row:hover { background: var(--bg-input); } +.history-index-row__icon { font-size: 14px; } +.history-index-row__info { flex: 1; display: flex; flex-direction: column; } +.history-index-row__title { color: var(--text); } +.history-index-row__meta { font-size: 11px; } +``` + +- [ ] **Step 3: Build** + +Run: `cd extension && npm run build` +Expected: SUCCESS. + +(No standalone smoke yet — pane is unreachable until Task 11 wires the route.) + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/components/item-history-index.ts extension/src/vault/vault.css +git commit -m "feat(extension): add item-history-index pane (lists items with field history)" +``` + +--- + +## Task 11: Wire history route + sidebar nav slot + +**Files:** +- Modify: `extension/src/vault/vault.ts` (`VaultView` union line 137, `parseHash` lines 145-169, sidebar nav lines 322-330, `renderPane` lines 823-887, `wireSidebar` lines 591-602) +- Modify: `extension/src/shared/state.ts` (`navigate` view union if narrowly typed) +- Verify popup nav, if any, is updated too + +Add the `◷ history` sidebar slot before lock; route `#history` → index pane, `#history/` → per-item view. Normalize legacy `#field-history/` URLs. + +- [ ] **Step 1: Import the new component and history glyph** + +In `extension/src/vault/vault.ts`, update the existing glyph import line to include `GLYPH_HISTORY`: + +```ts +import { + GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK, GLYPH_HISTORY, + GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP, + GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, +} from '../shared/glyphs'; +``` + +And add an import for the new component: + +```ts +import { renderItemHistoryIndex, teardown as teardownHistoryIndex } from '../popup/components/item-history-index'; +``` + +- [ ] **Step 2: Extend the VaultView union** + +In `extension/src/vault/vault.ts`, replace the `VaultView` type line (currently line 137) with: + +```ts +type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'history' | 'backup' | 'import'; +``` + +(`'field-history'` is kept in the union since the per-item view still uses it internally; only the user-facing hash changes.) + +- [ ] **Step 3: Extend parseHash to handle `history` and normalize `field-history/`** + +In `extension/src/vault/vault.ts`, replace the `parseHash` function (lines 145-169) with: + +```ts +function parseHash(): HashRoute { + let raw = window.location.hash.replace(/^#\/?/, ''); + if (!raw) return { view: 'list' }; + + // Normalize legacy bookmarks: #field-history/ → #history/ + if (raw.startsWith('field-history/')) { + raw = 'history/' + raw.slice('field-history/'.length); + window.location.hash = raw; // rewrite for the address bar + } + + const parts = raw.split('/'); + const view = parts[0] as VaultView; + + switch (view) { + case 'detail': + case 'edit': + return { view, id: parts[1] }; + case 'add': + return { view, type: parts[1] }; + case 'history': + // #history → index (no id); #history/ → per-item view (internal 'field-history') + return parts[1] + ? { view: 'field-history', id: parts[1] } + : { view: 'history' }; + case 'trash': + case 'devices': + case 'settings': + case 'settings-vault': + case 'field-history': + case 'backup': + case 'import': + return { view }; + default: + return { view: 'list' }; + } +} +``` + +- [ ] **Step 4: Add the history nav button to the sidebar** + +In the same file, find the `vault-sidebar__nav` block (lines 322-330) inside `renderShell`. Insert a history button between settings and lock: + +```ts + + + + + +``` + +- [ ] **Step 5: Handle the history nav click** + +In the same file, find `wireSidebar` (lines 560-617). Extend the nav button click condition to include `'history'`: + +```ts + if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') { + state.selectedId = null; + state.selectedItem = null; + state.newType = null; + state.drawerOpen = false; + state.view = nav; + setHash(nav); + applyShellViewClass(); + renderPane(); + return; + } +``` + +- [ ] **Step 6: Add the `history` case to renderPane** + +In the same file, find the `renderPane` switch (lines 836-886). Add a case for `'history'` and update teardown to include the new component: + +```ts +function teardownPaneComponents(): void { + teardownTrash(); + teardownDevices(); + teardownSettings(); + teardownFieldHistory(); + teardownHistoryIndex(); + teardownBackup(); + teardownImport(); +} +``` + +And inside the switch, add (alongside the existing `case 'field-history':`): + +```ts + case 'history': + renderItemHistoryIndex(pane); + break; +``` + +- [ ] **Step 7: Check popup.ts (if it has its own nav) and mirror the change** + +Run: `grep -n "data-nav=\"settings\"" extension/src/popup/popup.ts` +Expected: if matches found, the popup has its own sidebar — add a history entry there too with the same pattern from Step 4. If no matches, the popup uses a different nav approach (likely no nav slot for these surfaces — it relies on `openVaultTab` to redirect). Skip this step if no matches. + +If matches were found, apply the same `data-nav="history"` button + click handler addition to `extension/src/popup/popup.ts`. + +- [ ] **Step 8: Build** + +Run: `cd extension && npm run build` +Expected: SUCCESS — TS compiles, no missing-case-in-switch warnings. + +- [ ] **Step 9: Manual smoke** + +Re-load extension, open vault tab. Verify: +- Sidebar bottom-nav has 5 items: `▦ trash · ⌬ devices · ⚙ settings · ◷ history · ⏻ lock` (plus the `+ new item` primary above) +- Click `◷ history` → renders the index pane; loading state, then list of items with history (or empty state) +- Click a row → drills into the per-item field-history view +- Manually navigate to `#field-history/` in the address bar → URL gets rewritten to `#history/`; per-item view renders +- Direct `#history` URL also works on cold load +- Sidebar bottom-nav fits comfortably at the narrowest viewport + +- [ ] **Step 10: Commit** + +```bash +git add extension/src/vault/vault.ts extension/src/popup/popup.ts +git commit -m "feat(extension): wire history sidebar slot + #history/ route normalization" +``` + +(Drop `popup.ts` from the `git add` if Step 7 found no popup nav to update.) + +--- + +## Task 12: Docs update + +**Files:** +- Modify: `STATUS.md` +- Modify: `extension/ARCHITECTURE.md` + +Move work to "Recent landings" in STATUS, note the new history route + sidebar slot in extension architecture. + +- [ ] **Step 1: Update STATUS.md** + +In `STATUS.md`, under "Recent work (post-v0.5.0, landed on main)", append a new bullet: + +```markdown +**Vault-tab management surfaces revamp (2026-05-24+):** +- Fullscreen visual language applied to Settings, Devices, Trash, and History panes +- Settings: synced/local section grouping + per-device session-timeout UI (radio + minutes) +- Devices: SHA256 fingerprint + added-by display; glyph revoke button + inline two-step confirm +- Trash: per-item purge countdown +- History: new "items with history" index pane reachable from sidebar `◷ history` slot +- Shared `relative-time.ts` consolidates 5 duplicate inline copies; `ssh-fingerprint.ts` (webcrypto) added +- New hash route `#history/` with `#field-history/` legacy normalization +``` + +Remove the matching entry from "In progress" / "Up next" if any duplicate framing exists there. + +- [ ] **Step 2: Update extension/ARCHITECTURE.md** + +Open `extension/ARCHITECTURE.md`. Find the sidebar nav documentation (likely under a "Vault tab" or "Routing" section) and update it to reflect: +- Bottom-nav now has 5 slots: `+ new item · ▦ trash · ⌬ devices · ⚙ settings · ◷ history · ⏻ lock` (verify exact framing matches the doc's existing style) +- New route `#history` (index) and `#history/` (per-item, internal view value remains `'field-history'`) +- New shared utils: `shared/relative-time.ts`, `shared/ssh-fingerprint.ts` +- New component: `popup/components/item-history-index.ts` + +If the doc doesn't have a routing/nav section, add a brief one under whatever section documents the vault shell. + +- [ ] **Step 3: Commit** + +```bash +git add STATUS.md extension/ARCHITECTURE.md +git commit -m "docs: STATUS + extension ARCHITECTURE update for management-surfaces revamp" +``` + +--- + +## Final verification + +- [ ] **Step 1: Run all extension tests** + +Run: `cd extension && npm test` +Expected: PASS — all new and existing tests green. + +- [ ] **Step 2: Build production bundle** + +Run: `cd extension && npm run build` +Expected: SUCCESS — no warnings about unused imports or type errors. + +- [ ] **Step 3: Full smoke checklist (manual)** + +Load the production build, then verify end-to-end: +- Settings: three groups visible; two-column at full width; SESSION row persists across reload; "esc" navigates back +- Devices: fingerprint matches `relicario device list`; inline revoke flow works; unregistered banner copy is fleshed-out; popup width wraps fingerprint +- Trash: each row shows purge countdown; restore glyph works; empty-trash button bottom-right +- History index: reachable from `◷ history` sidebar; lists items by recency; empty state when none +- History per-item: section headers per field; reveal/copy glyphs work; reveals colorize on demand +- Legacy URL `#field-history/` redirects to `#history/` +- All four panes render in BOTH popup (~360px) and vault tab (full) + +- [ ] **Step 4: Final review commit (only if minor fixes from smoke)** + +If smoke found small issues, fix and commit: + +```bash +git add +git commit -m "fix(extension): from management-surfaces smoke" +``` + +--- + +## Out of scope (per spec) + +This plan deliberately does NOT include: +- Phase 3 shell rearchitecture (three-pane layout, command palette) +- Item-level snapshot history (option B/C from brainstorm) +- Settings-as-hub with sub-tabs +- Trash multi-select / bulk-restore / hover preview +- Devices rotate-key flow / "last seen" detail +- History diff view between adjacent values +- Any core or WASM changes diff --git a/docs/superpowers/specs/2026-05-23-vault-tab-management-surfaces-revamp-design.md b/docs/superpowers/specs/2026-05-23-vault-tab-management-surfaces-revamp-design.md index b2790f6..6186cbe 100644 --- a/docs/superpowers/specs/2026-05-23-vault-tab-management-surfaces-revamp-design.md +++ b/docs/superpowers/specs/2026-05-23-vault-tab-management-surfaces-revamp-design.md @@ -9,7 +9,7 @@ Four "management" surfaces in the extension — **Settings**, **Devices**, **Trash**, and **field history** — all shipped in the 1C-β₂ / device-auth waves but in the *pre-fullscreen-redesign* visual language. They read as popup-derived forms stretched across the vault tab, with inconsistent typography, no glyph buttons, no focus rings, and no visual section grouping. Several functional gaps remain alongside the visual debt: - **Settings**: per-device session-timeout config UI was specced in the vault-tab design (2026-04-27) but never built; the only way to change session behavior today is to edit `chrome.storage.local` directly. -- **Devices**: the `revoke_device` SW message handler exists but no UI surfaces it — revocation is CLI-only. Device entries don't expose the SHA256 fingerprint (used for verifying against the server-side `devices.json`) or the `added_by` field that's already in `DeviceEntry`. +- **Devices**: revocation works via a plain text "revoke" button + browser `confirm()` dialog — functional but inconsistent with the rest of the extension's UX. Device entries don't expose the SHA256 fingerprint (used for verifying against the server-side `devices.json`) or the `added_by` field that's already in `DeviceEntry`. - **Trash**: per-item purge countdown isn't shown — users see "trashed N days ago" but have to mentally add the retention window to figure out when it'll be gone. - **History**: the per-item field-history viewer (`field-history.ts`) is only reachable from an item detail page; there's no entry point to discover *which* items have history. @@ -37,7 +37,7 @@ This spec applies the fullscreen visual-language tokens to all four panes and cl | Surface | Files touched | New? | |---|---|---| | Settings | `popup/components/settings-vault.ts`, `vault.css`/`popup.css` | modify | -| Devices | `popup/components/devices.ts`, SW response shape | modify | +| Devices | `popup/components/devices.ts`, new `shared/ssh-fingerprint.ts` | modify + 1 new util | | Trash | `popup/components/trash.ts` | modify | | History — index | `popup/components/item-history-index.ts` | **NEW** | | History — per-item | `popup/components/field-history.ts` | polish only (no rename) | @@ -85,7 +85,7 @@ extension/src/ | List trashed, restore, purge | `list_trashed` / `restore_item` / `purge_item` / `purge_all_trash` | exists | | Per-item field history | `get_field_history` | exists (reused for index + per-item) | -**Single shape change:** extend `ListDevicesResponse` to include `fingerprint: string` per entry — SHA256 of the device's ed25519 public key, computed via the existing `core::device::fingerprint()` function. No new message round-trip. +**No SW shape changes.** Fingerprint is computed client-side in `devices.ts` via `crypto.subtle.digest('SHA-256', …)` against the base64-decoded ed25519 key blob from `DeviceEntry.public_key`. Result is formatted as `SHA256:` to match the SSH convention (and what `relicario device list` prints from `core::device::fingerprint()`). Pure extension change — no message round-trip, no WASM export, no Rust change. ### Shared CSS utility classes @@ -393,16 +393,16 @@ TOTP_SECRET · 1 entry type VaultView = | 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' + | 'field-history' // existing — per-item view (internal dispatch key kept) + | 'history' // NEW — index pane only | 'backup' | 'import' - | 'history' // NEW — covers both index and per-item, payload distinguishes ``` -The existing `'field-history'` view value is **removed** from the union — the hash-parse layer normalizes any `#field-history/` URL to `#history/` before view resolution, so consumers only ever see the new value. The per-item component (`field-history.ts`) is unchanged in identity; only its dispatch key changes. +The user-facing hash changes (`#history` is the new entry point, `#history/` is the per-item view), but the internal dispatch keeps `'field-history'` for the per-item view to minimize the diff to working code. Normalization happens in `parseHash`: -Hash routes: -- `#history` → index pane (`item-history-index.ts`) -- `#history/` → per-item view (`field-history.ts`) -- `#field-history/` → 301-style redirect to `#history/` (one release of backward compat for any bookmarked URLs) +- `#history` → `{ view: 'history' }` → index pane (`item-history-index.ts`) +- `#history/` → `{ view: 'field-history', id: }` → per-item view (`field-history.ts`) +- `#field-history/` → rewritten to `#history/` in the address bar, then resolved as above (one release of backward compat for any bookmarked URLs) ### Sidebar bottom-nav