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 <noreply@anthropic.com>
1736 lines
65 KiB
Markdown
1736 lines
65 KiB
Markdown
# 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/<id>` normalized to `#history/<id>` 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 `<POPUP_CSS>`.
|
|
|
|
- [ ] **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 `<POPUP_CSS>` (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 <POPUP_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:<base64-no-pad>` fingerprint from a public-key string of the form `ssh-ed25519 <base64-blob> [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:<b64>', 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:<base64-no-pad of SHA256(decoded-key-blob)>
|
|
|
|
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<string | null> {
|
|
if (!publicKey) return null;
|
|
const parts = publicKey.trim().split(/\s+/);
|
|
if (parts.length < 2) return null; // need "<algo> <blob>"
|
|
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 = `
|
|
<div class="pad">
|
|
<div class="settings-header">
|
|
<button class="btn" id="back-btn">← back</button>
|
|
<h3 style="margin:0;">settings</h3>
|
|
<span class="muted settings-header__sub">${escapeHtml(subtitle)}</span>
|
|
</div>
|
|
|
|
<div class="section-header">VAULT SETTINGS · synced</div>
|
|
|
|
<div class="settings-grid">
|
|
<div class="settings-section">
|
|
<div class="settings-section__title">RETENTION</div>
|
|
<div class="settings-row">
|
|
<span class="settings-row__label">trash</span>
|
|
<select id="trash-retention">
|
|
<option value="forever">Forever</option>
|
|
<option value="days:7">7 days</option>
|
|
<option value="days:30">30 days</option>
|
|
<option value="days:60">60 days</option>
|
|
<option value="days:90">90 days</option>
|
|
<option value="days:180">180 days</option>
|
|
<option value="days:365">365 days</option>
|
|
</select>
|
|
</div>
|
|
<div class="settings-row">
|
|
<span class="settings-row__label">history</span>
|
|
<select id="history-retention">
|
|
<option value="forever">Forever</option>
|
|
<option value="last_n:3">Last 3</option>
|
|
<option value="last_n:5">Last 5</option>
|
|
<option value="last_n:10">Last 10</option>
|
|
<option value="days:30">30 days</option>
|
|
<option value="days:90">90 days</option>
|
|
<option value="days:365">365 days</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<div class="settings-section__title">GENERATOR</div>
|
|
<p class="gen-preview-line">${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}</p>
|
|
<button class="gen-trigger" id="configure-gen" type="button" title="configure generator defaults" aria-expanded="false">✨ configure</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<div class="settings-section__title">ATTACHMENTS</div>
|
|
<div class="settings-row">
|
|
<span class="settings-row__label">max size</span>
|
|
<select id="attachment-cap">
|
|
<option value="5242880">5 MB</option>
|
|
<option value="10485760">10 MB</option>
|
|
<option value="26214400">25 MB</option>
|
|
<option value="52428800">50 MB</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<div class="settings-section__title">AUTOFILL ORIGINS</div>
|
|
${acksEntries.length === 0
|
|
? `<p class="muted">No origins acknowledged yet.</p>`
|
|
: acksEntries.map(([host, ts]) => `
|
|
<div class="ack-row">
|
|
<span class="ack-row__host">${escapeHtml(host)}</span>
|
|
<span class="ack-row__meta">${escapeHtml(relativeTime(ts))}</span>
|
|
<button class="glyph-btn" data-danger title="revoke" data-revoke="${escapeHtml(host)}">⊘</button>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<div class="section-header">THIS DEVICE · local</div>
|
|
|
|
<div class="settings-section">
|
|
<div class="settings-section__title">SESSION</div>
|
|
<div class="settings-row">
|
|
<label><input type="radio" name="session-mode" value="every_time" ${sessionMode === 'every_time' ? 'checked' : ''}> lock every time</label>
|
|
</div>
|
|
<div class="settings-row">
|
|
<label><input type="radio" name="session-mode" value="inactivity" ${sessionMode === 'inactivity' ? 'checked' : ''}> after inactivity</label>
|
|
<select id="session-minutes" ${sessionMode !== 'inactivity' ? 'disabled' : ''}>
|
|
<option value="5">5 min</option>
|
|
<option value="15">15 min</option>
|
|
<option value="30">30 min</option>
|
|
<option value="60">60 min</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-header">ACTIONS</div>
|
|
|
|
<div class="settings-section">
|
|
<div class="settings-row">
|
|
<button class="btn" id="open-backup">Backup & restore ${GLYPH_NEXT}</button>
|
|
<button class="btn" id="open-import">Import from… ${GLYPH_NEXT}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-footer">
|
|
<button class="btn" id="discard-btn">discard</button>
|
|
<button class="btn btn-primary" id="save-btn" ${dirty ? '' : 'disabled'}>save changes</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 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<HTMLInputElement>('input[name="session-mode"]').forEach((el) => {
|
|
el.addEventListener('change', () => {
|
|
const mode = (document.querySelector<HTMLInputElement>('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 <POPUP_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<string, string>();
|
|
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
|
|
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
|
: 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 `
|
|
<div class="device-row" data-device="${escapeHtml(d.name)}">
|
|
<div class="device-row__head">
|
|
<span class="device-row__name">${escapeHtml(d.name)}</span>
|
|
${isCurrentDevice
|
|
? '<span class="device-row__you">← you</span>'
|
|
: `<button class="glyph-btn" data-danger data-revoke="${escapeHtml(d.name)}" title="revoke">${GLYPH_REVOKE}</button>`}
|
|
</div>
|
|
<div class="fingerprint">${escapeHtml(fp)}</div>
|
|
<div class="device-row__meta">added ${escapeHtml(relativeTime(d.added_at))}${addedBy}</div>
|
|
<div class="device-row__confirm" data-confirm-for="${escapeHtml(d.name)}" hidden></div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
```
|
|
|
|
- [ ] **Step 4: Flesh out the unregistered banner**
|
|
|
|
In the same file, replace the existing banner template (currently lines 108-113) with:
|
|
|
|
```ts
|
|
${!isRegistered ? `
|
|
<div class="device-banner">
|
|
<div class="device-banner__title">This device isn't registered.</div>
|
|
<p class="device-banner__body muted">Registering generates an ed25519 keypair and adds the public key to <code>.relicario/devices.json</code> on the remote.</p>
|
|
<button class="btn btn-primary" id="register-btn">Register this device</button>
|
|
</div>
|
|
` : ''}
|
|
```
|
|
|
|
- [ ] **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<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const name = btn.dataset.revoke;
|
|
if (!name) return;
|
|
const panel = document.querySelector<HTMLElement>(`[data-confirm-for="${CSS.escape(name)}"]`);
|
|
if (!panel) return;
|
|
panel.hidden = false;
|
|
panel.innerHTML = `
|
|
<p class="device-row__confirm-text">
|
|
Revoke this device? It won't be able to sign commits or push changes after revocation.
|
|
</p>
|
|
<div class="device-row__confirm-actions">
|
|
<button class="btn" data-revoke-cancel="${escapeHtml(name)}">cancel</button>
|
|
<button class="btn btn-danger" data-revoke-confirm="${escapeHtml(name)}">revoke</button>
|
|
</div>
|
|
`;
|
|
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<HTMLButtonElement>('[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 ? '' : `
|
|
<details class="revoked-section">
|
|
<summary class="muted">▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}</summary>
|
|
<div class="revoked-section__body">
|
|
${revokedDevices.map((r) => `
|
|
<div class="device-row device-row--revoked">
|
|
<div class="device-row__head">
|
|
<span class="device-row__name" style="text-decoration:line-through;opacity:0.6;">
|
|
${escapeHtml(r.name)}
|
|
</span>
|
|
</div>
|
|
<div class="device-row__meta">
|
|
revoked ${escapeHtml(relativeTime(r.revoked_at))}${r.revoked_by !== 'unknown' ? ` · by ${escapeHtml(r.revoked_by)}` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</details>
|
|
`;
|
|
```
|
|
|
|
- [ ] **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 = `
|
|
<div class="pad">
|
|
<div class="devices-header">
|
|
<button class="btn" id="back-btn">← back</button>
|
|
<h3 style="margin:0;">devices</h3>
|
|
</div>
|
|
${!isRegistered ? `
|
|
<div class="device-banner">
|
|
<div class="device-banner__title">This device isn't registered.</div>
|
|
<p class="device-banner__body muted">Registering generates an ed25519 keypair and adds the public key to <code>.relicario/devices.json</code> on the remote.</p>
|
|
<button class="btn btn-primary" id="register-btn">Register this device</button>
|
|
</div>
|
|
` : ''}
|
|
${devices.length > 0 ? `<div class="section-header">ACTIVE · ${devices.length}</div>` : ''}
|
|
${activeDevicesHtml}
|
|
${revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : ''}
|
|
${revokedSectionHtml}
|
|
</div>
|
|
`;
|
|
```
|
|
|
|
- [ ] **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 <POPUP_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<void> {
|
|
const state = getState();
|
|
|
|
const resp = await sendMessage({ type: 'list_trashed' });
|
|
if (!resp.ok) {
|
|
app.innerHTML = `<div class="pad"><p class="error">Failed to load trash</p></div>`;
|
|
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 = `
|
|
<div class="pad">
|
|
<div class="trash-header">
|
|
<button class="btn" id="back-btn">← back</button>
|
|
<h3 style="margin:0;">trash</h3>
|
|
</div>
|
|
${headerInfo ? `<p class="muted" style="margin:8px 0;">${escapeHtml(headerInfo)}</p>` : ''}
|
|
${items.length === 0
|
|
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
|
|
: `<div class="section-header"> </div>
|
|
${items.map(([id, entry]) => {
|
|
const purgeIn = daysUntilPurge(entry.trashed_at ?? 0, retention);
|
|
const purgeStr = purgeIn === null ? 'retained forever' : `purges in ${purgeIn} days`;
|
|
return `
|
|
<div class="trash-row" data-id="${escapeHtml(id)}">
|
|
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '◻'}</span>
|
|
<div class="trash-row__info">
|
|
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
|
|
<span class="trash-row__meta muted">trashed ${escapeHtml(relativeTime(entry.trashed_at ?? 0))} · ${escapeHtml(purgeStr)}</span>
|
|
</div>
|
|
<button class="glyph-btn" data-restore="${escapeHtml(id)}" title="restore">${GLYPH_RESTORE}</button>
|
|
</div>
|
|
`;
|
|
}).join('')}`
|
|
}
|
|
${items.length > 0 ? `
|
|
<div class="trash-footer">
|
|
<button class="btn btn-danger" id="empty-trash-btn">empty trash</button>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
|
|
|
document.querySelectorAll<HTMLButtonElement>('[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 <POPUP_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 `
|
|
<div class="history-entry" data-entry="${escapeHtml(entryKey)}">
|
|
<div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>
|
|
<div class="history-entry__meta muted">
|
|
${isCurrent ? '<span class="history-entry__current">current · </span>' : ''}
|
|
${isCurrent ? 'set' : 'changed'} ${escapeHtml(relativeTime(timestamp))}
|
|
</div>
|
|
<div class="history-entry__actions">
|
|
<button class="glyph-btn" data-entry-reveal="${escapeHtml(entryKey)}" title="${isRevealed ? 'hide' : 'reveal'}">${revealGlyph}</button>
|
|
<button class="glyph-btn" data-entry-copy="${escapeHtml(entryKey)}" title="copy">${GLYPH_COPY}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
```
|
|
|
|
- [ ] **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 += `<div class="section-header">${escapeHtml(field.field_name.toUpperCase())} · ${entryCount} ${entryCount === 1 ? 'entry' : 'entries'}</div>`;
|
|
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<HTMLButtonElement>('[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 <POPUP_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/<itemId>` (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<string, string> = {
|
|
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<void> {
|
|
const state = getState();
|
|
const manifest = state.entries;
|
|
|
|
app.innerHTML = `
|
|
<div class="pad">
|
|
<div class="history-header">
|
|
<button class="btn" id="back-btn">← back</button>
|
|
<h3 style="margin:0;">history</h3>
|
|
</div>
|
|
<p class="muted" style="margin:8px 0;">Scanning items…</p>
|
|
</div>
|
|
`;
|
|
app.querySelector<HTMLButtonElement>('#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 = `
|
|
<div class="pad">
|
|
<div class="history-header">
|
|
<button class="btn" id="back-btn">← back</button>
|
|
<h3 style="margin:0;">history</h3>
|
|
</div>
|
|
<p class="muted" style="text-align:center;margin-top:32px;">
|
|
No field history yet.<br>
|
|
Edits to passwords, TOTP secrets, and concealed fields will appear here.
|
|
</p>
|
|
</div>
|
|
`;
|
|
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('list'));
|
|
return;
|
|
}
|
|
|
|
app.innerHTML = `
|
|
<div class="pad">
|
|
<div class="history-header">
|
|
<button class="btn" id="back-btn">← back</button>
|
|
<h3 style="margin:0;">history</h3>
|
|
</div>
|
|
<p class="muted" style="margin:8px 0;">${entries.length} item${entries.length === 1 ? '' : 's'} have field history</p>
|
|
<div class="section-header"> </div>
|
|
${entries.map((e) => `
|
|
<div class="history-index-row" data-id="${escapeHtml(e.id)}">
|
|
<span class="history-index-row__icon">${TYPE_ICONS[e.type] ?? '◻'}</span>
|
|
<div class="history-index-row__info">
|
|
<span class="history-index-row__title">${escapeHtml(e.title)}</span>
|
|
<span class="history-index-row__meta muted">${e.changeCount} change${e.changeCount === 1 ? '' : 's'} · last ${escapeHtml(relativeTime(e.lastChangedAt))}</span>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('list'));
|
|
app.querySelectorAll<HTMLElement>('.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 <POPUP_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/<itemId>` → per-item view. Normalize legacy `#field-history/<id>` 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/<id>`**
|
|
|
|
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/<id> → #history/<id>
|
|
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/<id> → 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
|
|
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
|
|
<button class="vault-sidebar__nav-item" data-nav="devices" title="Devices">${GLYPH_DEVICES} <span class="vault-sidebar__nav-label">devices</span></button>
|
|
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
|
|
<button class="vault-sidebar__nav-item" data-nav="history" title="History">${GLYPH_HISTORY} <span class="vault-sidebar__nav-label">history</span></button>
|
|
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
|
|
```
|
|
|
|
- [ ] **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/<some-id>` in the address bar → URL gets rewritten to `#history/<some-id>`; 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/<id> 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/<id>` with `#field-history/<id>` 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/<id>` (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/<id>` redirects to `#history/<id>`
|
|
- 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 <files>
|
|
git commit -m "fix(extension): <specific issue> 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
|