diff --git a/extension/src/popup/components/attachments-disclosure.ts b/extension/src/popup/components/attachments-disclosure.ts index 422c37a..5206085 100644 --- a/extension/src/popup/components/attachments-disclosure.ts +++ b/extension/src/popup/components/attachments-disclosure.ts @@ -7,6 +7,7 @@ import { sendMessage, escapeHtml } from '../../shared/state'; import type { AttachmentRef, VaultSettings } from '../../shared/types'; +import { GLYPH_TYPE_DOCUMENT } from '../../shared/glyphs'; export type DisclosureMode = 'edit' | 'view'; @@ -53,8 +54,8 @@ export function renderAttachmentsDisclosure(opts: AttachmentsDisclosureOpts): st const action = opts.mode === 'edit' ? 'Γ—' : '↓'; const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download'; const iconHtml = isImage(a.mime_type) - ? `πŸ“„` - : `πŸ“„`; + ? `${GLYPH_TYPE_DOCUMENT}` + : `${GLYPH_TYPE_DOCUMENT}`; return `
${iconHtml} diff --git a/extension/src/popup/components/field-history.ts b/extension/src/popup/components/field-history.ts index 27de410..1edf1f2 100644 --- a/extension/src/popup/components/field-history.ts +++ b/extension/src/popup/components/field-history.ts @@ -3,6 +3,7 @@ import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; import { colorizePassword } from '../../shared/password-coloring'; import type { FieldHistoryView } from '../../shared/types'; +import { GLYPH_COPY } from '../../shared/glyphs'; function relativeTime(unixSec: number): string { const now = Math.floor(Date.now() / 1000); @@ -75,7 +76,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise { ${isCurrent ? 'current' : ''} ${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}
- + `; } @@ -140,7 +141,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise { const value = valueStore.get(key) ?? ''; await navigator.clipboard.writeText(value); btn.textContent = 'βœ“'; - setTimeout(() => { btn.textContent = 'πŸ“‹'; }, 1500); + setTimeout(() => { btn.textContent = GLYPH_COPY; }, 1500); }); }); } diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index 0460c7c..a2019e7 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -3,15 +3,19 @@ import { navigate, getState, setState, escapeHtml, popOutToTab, isInTab } from '../../shared/state'; import type { Item, ItemType } from '../../shared/types'; +import { + 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'; -const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string }> = [ - { type: 'login', icon: 'πŸ”‘', label: 'login' }, - { type: 'secure_note', icon: 'πŸ“', label: 'secure note' }, - { type: 'identity', icon: 'πŸ‘€', label: 'identity' }, - { type: 'card', icon: 'πŸ’³', label: 'card' }, - { type: 'key', icon: 'πŸ”', label: 'key' }, - { type: 'document', icon: 'πŸ“„', label: 'document' }, - { type: 'totp', icon: '⏱️', label: 'totp' }, +const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; description: string }> = [ + { type: 'login', icon: GLYPH_TYPE_LOGIN, label: 'Login', description: 'Username + password' }, + { type: 'secure_note', icon: GLYPH_TYPE_SECURE_NOTE, label: 'Secure Note', description: 'Encrypted text note' }, + { type: 'identity', icon: GLYPH_TYPE_IDENTITY, label: 'Identity', description: 'Personal details' }, + { type: 'card', icon: GLYPH_TYPE_CARD, label: 'Card', description: 'Credit / debit card' }, + { type: 'key', icon: GLYPH_TYPE_KEY, label: 'SSH / API Key', description: 'Keys and tokens' }, + { type: 'document', icon: GLYPH_TYPE_DOCUMENT, label: 'Document', description: 'File attachment' }, + { type: 'totp', icon: GLYPH_TYPE_TOTP, label: 'TOTP', description: '2FA authenticator' }, ]; import * as login from './types/login'; import * as secureNote from './types/secure-note'; @@ -54,36 +58,36 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { function renderTypeSelection(app: HTMLElement): void { app.innerHTML = `
-
- -

new item

+
+ + New item - ${isInTab() ? '' : ''} + ${isInTab() ? '' : ''}
- ${isInTab() ? '
esc to cancel
' : '
'} -
+
${TYPE_OPTIONS.map((opt) => ` - `).join('')}
+
Esc back
`; document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); document.getElementById('popout-btn')?.addEventListener('click', popOutToTab); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') navigate('list'); + }, { once: true }); document.querySelectorAll('[data-type]').forEach((btn) => { btn.addEventListener('click', () => { const type = btn.dataset.type as ItemType; setState({ newType: type }); - if (type === 'login' || type === 'secure_note') { - renderItemForm(app, 'add'); - } else { - popOutToTab(); - } + renderItemForm(app, 'add'); }); }); } diff --git a/extension/src/popup/components/item-list.ts b/extension/src/popup/components/item-list.ts index b7058fe..ae15f9d 100644 --- a/extension/src/popup/components/item-list.ts +++ b/extension/src/popup/components/item-list.ts @@ -3,6 +3,13 @@ /// to the detail view. import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state'; +import { showToast } from '../../shared/toast'; +import { + GLYPH_VAULT_TAB, + GLYPH_DEVICES, GLYPH_LOCK, + 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'; import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types'; /// Extract the display hostname from an icon_hint or fallback to the first tag. @@ -12,30 +19,46 @@ function metaLine(e: ManifestEntry): string { return ''; } -/// Emoji icon per item type. Placeholder until we ship real SVG icons. +/// Glyph icon per item type. function typeIcon(t: ItemType): string { switch (t) { - case 'login': return 'πŸ”‘'; - case 'secure_note': return 'πŸ“'; - case 'identity': return 'πŸͺͺ'; - case 'card': return 'πŸ’³'; - case 'key': return 'πŸ—'; - case 'document': return 'πŸ“„'; - case 'totp': return '⏱'; + case 'login': return GLYPH_TYPE_LOGIN; + case 'secure_note': return GLYPH_TYPE_SECURE_NOTE; + case 'identity': return GLYPH_TYPE_IDENTITY; + case 'card': return GLYPH_TYPE_CARD; + case 'key': return GLYPH_TYPE_KEY; + case 'document': return GLYPH_TYPE_DOCUMENT; + case 'totp': return GLYPH_TYPE_TOTP; } } function buildRowsHtml(): string { const state = getState(); const filtered = getFilteredEntries(); - return filtered.length > 0 - ? filtered.map(([id, e], i) => ` + if (filtered.length > 0) { + return filtered.map(([id, e], i) => `
- ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' πŸ“Ž' : ''} + ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' βŠ•' : ''}
- `).join('') - : '
no items
'; + `).join(''); + } + if (state.searchQuery) { + return ` +
+ +
No results for "${escapeHtml(state.searchQuery)}"
+
Try a shorter search term.
+
+ `; + } + return ` +
+ +
No items yet
+
Press + to add your first item.
+
+ `; } function updateItemList(): void { @@ -66,7 +89,7 @@ export function renderItemList(app: HTMLElement): void { - +
@@ -108,11 +131,14 @@ export function renderItemList(app: HTMLElement): void { if (listResp.ok) { const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; setState({ entries: data.items, loading: false }); + showToast('Synced', 'success'); return; } setState({ loading: false, error: listResp.error }); + showToast(listResp.error ?? 'Sync failed', 'error'); } else { setState({ loading: false, error: resp.error }); + showToast(resp.error ?? 'Sync failed', 'error'); } }); @@ -253,8 +279,8 @@ function handleListKeydown(e: KeyboardEvent): void { // ---------------------------------------------------------------------- const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [ - { view: 'settings', icon: 'πŸ–₯', label: 'device settings' }, - { view: 'settings-vault', icon: 'πŸ”', label: 'vault settings' }, + { view: 'settings', icon: GLYPH_DEVICES, label: 'device settings' }, + { view: 'settings-vault', icon: GLYPH_LOCK, label: 'vault settings' }, ]; function showSettingsPicker(anchor: HTMLElement): void { diff --git a/extension/src/popup/components/settings.ts b/extension/src/popup/components/settings.ts index 2cea82d..ab4b60c 100644 --- a/extension/src/popup/components/settings.ts +++ b/extension/src/popup/components/settings.ts @@ -2,7 +2,7 @@ import { sendMessage, navigate, escapeHtml } from '../../shared/state'; import type { DeviceSettings } from '../../shared/types'; -import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs'; +import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SYNC } from '../../shared/glyphs'; import { loadColorScheme, saveColorScheme, resetColorScheme, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR, @@ -63,7 +63,7 @@ export async function renderSettings(app: HTMLElement): Promise {
- +
@@ -189,3 +189,8 @@ async function renderDisplaySection(): Promise { updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR); }); } + +// DEV-B interface contract stub β€” will be replaced with real teardown logic at merge time +export function teardownSettings(): void { + // no-op stub +} diff --git a/extension/src/popup/components/trash.ts b/extension/src/popup/components/trash.ts index 63b5b2d..27c32a5 100644 --- a/extension/src/popup/components/trash.ts +++ b/extension/src/popup/components/trash.ts @@ -2,10 +2,19 @@ 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, +} from '../../shared/glyphs'; const TYPE_ICONS: Record = { - login: 'πŸ”‘', secure_note: 'πŸ“', identity: 'πŸ‘€', card: 'πŸ’³', - key: 'πŸ”', document: 'πŸ“„', totp: '⏱️', + 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, }; function relativeTime(unixSec: number): string { @@ -64,7 +73,7 @@ export async function renderTrash(app: HTMLElement): Promise { ? `

Trash is empty

` : items.map(([id, entry]) => `
- ${TYPE_ICONS[entry.type] ?? 'πŸ“¦'} + ${TYPE_ICONS[entry.type] ?? 'β—»'}
${escapeHtml(entry.title)} trashed ${relativeTime(entry.trashed_at ?? 0)} diff --git a/extension/src/popup/components/types/document.ts b/extension/src/popup/components/types/document.ts index ba4fdab..a60cf5e 100644 --- a/extension/src/popup/components/types/document.ts +++ b/extension/src/popup/components/types/document.ts @@ -4,7 +4,7 @@ import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; import { renderFormHeader } from '../form-header'; -import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; +import { REQUIRED_PILL_HTML, GLYPH_TYPE_DOCUMENT, GLYPH_PREVIEW } from '../../../shared/glyphs'; import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types'; import { renderSectionsEditor, wireSectionsEditor, @@ -76,7 +76,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite } return `
- πŸ“„ + ${GLYPH_TYPE_DOCUMENT} ${escapeHtml(primaryRef.filename)} ${formatBytes(primaryRef.size)} ↑ change @@ -283,13 +283,13 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise
${escapeHtml(item.title)}
-
πŸ“„
+
${GLYPH_TYPE_DOCUMENT}
${escapeHtml(primaryRef.filename)}
${formatBytes(primaryRef.size)} Β· ${new Date(primaryRef.created * 1000).toISOString().slice(0, 10)}
↓ download - ${isImageMime ? 'πŸ” preview' : ''} + ${isImageMime ? `${GLYPH_PREVIEW} preview` : ''}
diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 09c3509..ee28b08 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -1608,3 +1608,94 @@ textarea { margin-top: 8px; background: var(--bg-input); } + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; +} + +.empty-state__icon { + font-size: 28px; + color: var(--text-muted, #8b949e); + margin-bottom: 12px; + display: block; +} + +.empty-state__title { + font-size: 13px; + font-weight: 600; + margin-bottom: 4px; +} + +.empty-state__hint { + font-size: 11px; + color: var(--text-muted, #8b949e); +} + +.type-card-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 12px; +} + +.type-card { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 10px 12px; + background: var(--bg-elevated, #161b22); + border: 1px solid var(--border-mid, #30363d); + border-radius: 6px; + cursor: pointer; + text-align: left; + transition: border-color 0.15s; +} + +.type-card:hover { border-color: var(--gold-base, #a88a4a); } + +.type-card__icon { font-size: 20px; margin-bottom: 4px; } +.type-card__label { font-size: 12px; font-weight: 600; } + +/* Toast notifications */ +.relicario-toast-container { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: none; + z-index: 9999; +} + +.vault-shell .relicario-toast-container { + left: auto; + right: 24px; + transform: none; +} + +.relicario-toast { + padding: 8px 16px; + border-radius: 6px; + font-size: 12px; + opacity: 0; + transform: translateY(8px); + transition: opacity 0.2s, transform 0.2s; + pointer-events: none; +} + +.relicario-toast--visible { + opacity: 1; + transform: translateY(0); +} + +.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; } +.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; } +.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; } +.type-card__desc { font-size: 10px; color: var(--text-muted, #8b949e); margin-top: 2px; } diff --git a/extension/src/shared/__tests__/glyphs.test.ts b/extension/src/shared/__tests__/glyphs.test.ts index 957c28c..312251c 100644 --- a/extension/src/shared/__tests__/glyphs.test.ts +++ b/extension/src/shared/__tests__/glyphs.test.ts @@ -41,3 +41,30 @@ describe('glyph constants', () => { expect(GLYPH_NEXT).toBe('β–Έ'); }); }); + +describe('Stream A glyphs (vault tab + type icons)', () => { + it('exports GLYPH_VAULT_TAB as U+29C9', () => { + expect(glyphs.GLYPH_VAULT_TAB).toBe('⧉'); + }); + + it('exports per-type glyph constants', () => { + expect(glyphs.GLYPH_TYPE_LOGIN).toBe('β—‰'); + expect(glyphs.GLYPH_TYPE_SECURE_NOTE).toBe('β—«'); + expect(glyphs.GLYPH_TYPE_TOTP).toBe('⊑'); + expect(glyphs.GLYPH_TYPE_CARD).toBe('β–­'); + expect(glyphs.GLYPH_TYPE_IDENTITY).toBe('⌬'); + expect(glyphs.GLYPH_TYPE_KEY).toBe('⊹'); + expect(glyphs.GLYPH_TYPE_DOCUMENT).toBe('≑'); + }); + + it('per-type glyphs are single codepoints (no emoji)', () => { + const typeGlyphs = [ + glyphs.GLYPH_TYPE_LOGIN, glyphs.GLYPH_TYPE_SECURE_NOTE, glyphs.GLYPH_TYPE_TOTP, + glyphs.GLYPH_TYPE_CARD, glyphs.GLYPH_TYPE_IDENTITY, glyphs.GLYPH_TYPE_KEY, + glyphs.GLYPH_TYPE_DOCUMENT, + ]; + for (const g of typeGlyphs) { + expect([...g].length).toBe(1); + } + }); +}); diff --git a/extension/src/shared/glyphs.ts b/extension/src/shared/glyphs.ts index a69e3fb..71a23a7 100644 --- a/extension/src/shared/glyphs.ts +++ b/extension/src/shared/glyphs.ts @@ -17,6 +17,19 @@ export const GLYPH_DEVICES = '⌬'; // sidebar devices nav export const GLYPH_SETTINGS = 'βš™'; // sidebar settings nav export const GLYPH_LOCK = '⏻'; // sidebar lock nav export const GLYPH_NEXT = 'β–Έ'; // forward / next button (matches β–Ύ/β–Έ disclosure family) +export const GLYPH_COPY = '⎘'; // copy to clipboard +export const GLYPH_SYNC = 'β‡…'; // sync / upload +export const GLYPH_PREVIEW = 'βŠ•'; // preview / expand + +export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab + +export const GLYPH_TYPE_LOGIN = 'β—‰'; // login +export const GLYPH_TYPE_SECURE_NOTE = 'β—«'; // secure note +export const GLYPH_TYPE_TOTP = '⊑'; // totp / 2FA +export const GLYPH_TYPE_CARD = 'β–­'; // card +export const GLYPH_TYPE_IDENTITY = '⌬'; // identity +export const GLYPH_TYPE_KEY = '⊹'; // SSH / API key +export const GLYPH_TYPE_DOCUMENT = '≑'; // document /// Inline HTML snippet for the required-field pill. Use after a label's text: /// `` diff --git a/extension/src/shared/toast.ts b/extension/src/shared/toast.ts new file mode 100644 index 0000000..943ea09 --- /dev/null +++ b/extension/src/shared/toast.ts @@ -0,0 +1,26 @@ +export function showToast( + message: string, + type: 'success' | 'error' | 'info' = 'info', + durationMs = 2500, +): void { + let container = document.querySelector('.relicario-toast-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'relicario-toast-container'; + document.body.appendChild(container); + } + + const toast = document.createElement('div'); + toast.className = `relicario-toast relicario-toast--${type}`; + toast.textContent = message; + container.appendChild(toast); + + requestAnimationFrame(() => { + requestAnimationFrame(() => toast.classList.add('relicario-toast--visible')); + }); + + setTimeout(() => { + toast.classList.remove('relicario-toast--visible'); + toast.addEventListener('transitionend', () => toast.remove(), { once: true }); + }, durationMs); +} diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index c1d1639..8d9f994 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -1396,6 +1396,281 @@ textarea { color: #484f58; } +/* === 3-column shell === */ +.vault-shell { + display: flex; + height: 100vh; + overflow: hidden; + background: var(--bg-page, #0d1117); +} + +.vault-sidebar { + width: 200px; + min-width: 200px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border, #30363d); + background: var(--bg-sidebar, #0d1117); + overflow-y: auto; + flex-shrink: 0; +} + +.vault-list-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + min-width: 0; +} + +.vault-drawer { + width: 440px; + min-width: 440px; + max-width: 440px; + border-left: 1px solid var(--border, #30363d); + background: var(--bg-elevated, #161b22); + overflow-y: auto; + transform: translateX(100%); + transition: transform 0.2s ease; + flex-shrink: 0; +} + +.vault-drawer--open { + transform: translateX(0); +} + +.vault-list-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border-subtle, #21262d); + transition: background 0.1s; +} + +.vault-list-row:hover { background: var(--bg-hover, #161b22); } + +.vault-list-row--selected { + background: var(--bg-selected, #1c2d41); + border-left: 2px solid var(--gold, #b8860b); +} + +.vault-list-row__icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-elevated, #161b22); + border-radius: 6px; + border: 1px solid var(--border, #30363d); + font-size: 14px; + flex-shrink: 0; +} + +.vault-list-row--selected .vault-list-row__icon { border-color: var(--gold, #b8860b); } + +.vault-list-row__text { flex: 1; min-width: 0; } + +.vault-list-row__title { + font-size: 13px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vault-list-row__subtitle { + font-size: 11px; + color: var(--text-muted, #8b949e); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 1px; +} + +.vault-list-row__age { + font-size: 10px; + color: var(--text-dim, #6e7681); + flex-shrink: 0; +} + +/* Bottom sheet */ +.vault-bottom-sheet-scrim { + position: absolute; + inset: 0 0 0 200px; + background: rgba(0,0,0,0.5); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + z-index: 100; +} + +.vault-bottom-sheet-scrim--visible { + opacity: 1; + pointer-events: auto; +} + +.vault-bottom-sheet { + position: absolute; + bottom: 0; + left: 200px; + right: 0; + background: var(--bg-elevated, #161b22); + border-top: 1px solid var(--border, #30363d); + border-radius: 12px 12px 0 0; + padding: 16px 24px 24px; + transform: translateY(100%); + transition: transform 0.25s ease; + z-index: 101; + max-height: 60vh; + overflow-y: auto; +} + +.vault-bottom-sheet--open { transform: translateY(0); } + +.vault-bottom-sheet__handle { + width: 40px; + height: 4px; + background: var(--border, #30363d); + border-radius: 2px; + margin: 0 auto 16px; +} + +.vault-bottom-sheet__title { + font-size: 14px; + font-weight: 600; + margin-bottom: 16px; + text-align: center; +} + +.vault-type-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 10px; +} + +.vault-type-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 12px 8px; + background: var(--bg-page, #0d1117); + border: 1px solid var(--border, #30363d); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s; + gap: 6px; +} + +.vault-type-card:hover { border-color: var(--gold, #b8860b); } + +.vault-type-card__icon { font-size: 28px; } +.vault-type-card__name { font-size: 11px; color: var(--text-muted, #8b949e); } + +/* Drawer header and body */ +.vault-drawer__header { + display: flex; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border, #30363d); + gap: 8px; +} + +.vault-drawer__type-pill { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 8px; + background: var(--bg-page, #0d1117); + border: 1px solid var(--border, #30363d); + border-radius: 4px; + color: var(--text-muted, #8b949e); +} + +.vault-drawer__actions { display: flex; gap: 6px; margin-left: auto; } + +.vault-drawer__close { + background: transparent; + border: none; + cursor: pointer; + font-size: 16px; + color: var(--text-muted, #8b949e); + padding: 4px 6px; +} + +.vault-drawer__body { padding: 20px 20px 16px; } + +.vault-drawer__title { font-size: 18px; font-weight: 700; margin-bottom: 4px; } +.vault-drawer__subtitle { font-size: 12px; color: var(--text-muted, #8b949e); margin-bottom: 16px; } + +.vault-drawer__field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.vault-drawer__field-grid > .vault-drawer__field--full { grid-column: 1 / -1; } + +.vault-drawer__field-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted, #8b949e); + margin-bottom: 2px; +} + +.vault-drawer__field-value { + font-size: 13px; + word-break: break-all; +} + +/* Category nav */ +.vault-category-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 12px; + background: transparent; + border: none; + cursor: pointer; + color: inherit; + font-size: 13px; + text-align: left; +} + +.vault-category-row:hover { background: var(--bg-hover, #161b22); } +.vault-category-row--active { background: var(--bg-selected, #1c2d41); } +.vault-category-row__icon { font-size: 14px; flex-shrink: 0; } +.vault-category-row__label { flex: 1; } +.vault-category-row__count { font-size: 11px; color: var(--text-muted, #8b949e); } + +/* === Responsive === */ +@media (max-width: 960px) { + .vault-drawer { + position: absolute; + right: 0; + top: 0; + height: 100%; + } +} + +@media (max-width: 720px) { + .vault-sidebar { + width: 48px; + min-width: 48px; + } + .vault-sidebar__category-label, + .vault-sidebar__category-count, + .vault-sidebar__nav-label { + display: none; + } + .vault-sidebar__nav-item { justify-content: center; padding: 10px 0; } +} + /* --- Lock screen (vault tab) --- */ .vault-lock-screen { @@ -1719,3 +1994,41 @@ textarea { background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent); pointer-events: none; } + +/* Toast notifications */ +.relicario-toast-container { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: none; + z-index: 9999; +} + +.vault-shell .relicario-toast-container { + left: auto; + right: 24px; + transform: none; +} + +.relicario-toast { + padding: 8px 16px; + border-radius: 6px; + font-size: 12px; + opacity: 0; + transform: translateY(8px); + transition: opacity 0.2s, transform 0.2s; + pointer-events: none; +} + +.relicario-toast--visible { + opacity: 1; + transform: translateY(0); +} + +.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; } +.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; } +.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; } diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index ba91bc6..69feb7c 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -10,18 +10,36 @@ import type { } from '../shared/types'; import { registerHost } from '../shared/state'; import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy'; -import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs'; +import { + GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK, + 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'; import { renderItemDetail } from '../popup/components/item-detail'; import { renderItemForm } from '../popup/components/item-form'; import { renderTrash, teardown as teardownTrash } from '../popup/components/trash'; import { renderDevices, teardown as teardownDevices } from '../popup/components/devices'; -import { renderSettings } from '../popup/components/settings'; +import { renderSettings, teardownSettings } from '../popup/components/settings'; import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault'; import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history'; import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel'; import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; import { applyColorScheme } from '../shared/color-scheme'; +// --------------------------------------------------------------------------- +// Bottom sheet type picker +// --------------------------------------------------------------------------- + +const BOTTOM_SHEET_TYPES: Array<{ type: ItemType; label: string }> = [ + { type: 'login', label: 'Login' }, + { type: 'secure_note', label: 'Secure Note' }, + { type: 'totp', label: 'TOTP' }, + { type: 'card', label: 'Card' }, + { type: 'identity', label: 'Identity' }, + { type: 'key', label: 'SSH / API Key' }, + { type: 'document', label: 'Document' }, +]; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -60,26 +78,35 @@ function renderErrorBlock(code: string | null | undefined): string { function typeIcon(t: ItemType): string { switch (t) { - case 'login': return '\u{1F511}'; // key - case 'secure_note': return '\u{1F4DD}'; // memo - case 'identity': return '\u{1FAAA}'; // id card - case 'card': return '\u{1F4B3}'; // credit card - case 'key': return '\u{1F5DD}'; // old key - case 'document': return '\u{1F4C4}'; // page facing up - case 'totp': return '⏱'; // stopwatch + case 'login': return GLYPH_TYPE_LOGIN; + case 'secure_note': return GLYPH_TYPE_SECURE_NOTE; + case 'identity': return GLYPH_TYPE_IDENTITY; + case 'card': return GLYPH_TYPE_CARD; + case 'key': return GLYPH_TYPE_KEY; + case 'document': return GLYPH_TYPE_DOCUMENT; + case 'totp': return GLYPH_TYPE_TOTP; } } function typeLabel(t: ItemType): string { - switch (t) { - case 'login': return 'Logins'; - case 'secure_note': return 'Secure Notes'; - case 'identity': return 'Identities'; - case 'card': return 'Cards'; - case 'key': return 'Keys'; - case 'document': return 'Documents'; - case 'totp': return 'TOTP'; - } + const labels: Record = { + login: 'Login', + secure_note: 'Secure Note', + identity: 'Identity', + card: 'Card', + key: 'SSH / API Key', + document: 'Document', + totp: 'TOTP', + }; + return labels[t]; +} + +function relativeTime(unixSec: number): string { + const diffS = Math.floor(Date.now() / 1000) - unixSec; + if (diffS < 60) return 'just now'; + if (diffS < 3600) return `${Math.floor(diffS / 60)}m ago`; + if (diffS < 86400) return `${Math.floor(diffS / 3600)}h ago`; + return `${Math.floor(diffS / 86400)}d ago`; } // --------------------------------------------------------------------------- @@ -138,6 +165,8 @@ interface VaultState { selectedIndex: number; searchQuery: string; activeGroup: string | null; + drawerOpen: boolean; + bottomSheetOpen: boolean; vaultSettings: VaultSettings | null; generatorDefaults: GeneratorRequest | null; error: string | null; @@ -157,6 +186,8 @@ const state: VaultState = { selectedIndex: 0, searchQuery: '', activeGroup: null, + drawerOpen: false, + bottomSheetOpen: false, vaultSettings: null, generatorDefaults: null, error: null, @@ -180,7 +211,8 @@ registerHost({ navigate: (view: string, extras?: any) => { Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); - renderSidebarList(); + renderSidebarCategories(); + renderListPane(); renderPane(); }, sendMessage, @@ -249,39 +281,220 @@ function renderLockScreen(app: HTMLElement): void { } // --------------------------------------------------------------------------- -// Shell (sidebar + pane) +// Shell (3-column: sidebar + list pane + drawer) // --------------------------------------------------------------------------- function renderShell(app: HTMLElement): void { - // Only create the shell structure if it's not present yet - if (!app.querySelector('.vault-sidebar')) { + if (!app.querySelector('.vault-shell')) { app.innerHTML = ` -
-
- - Relicario +
+
+
+ + Relicario +
+ + +
+ + + + + +
- -
-
- - - - - -
-
-
- select an item +
+
+
+
`; wireSidebar(); + wireBottomSheet(); } - renderSidebarList(); - renderPane(); + renderSidebarCategories(); + renderListPane(); + if (state.drawerOpen && state.selectedItem) { + renderDrawer(state.selectedItem); + } +} + +// --------------------------------------------------------------------------- +// Bottom sheet (wired in Task 11) +// --------------------------------------------------------------------------- + +function wireBottomSheet(): void { + document.getElementById('vault-sheet-scrim')?.addEventListener('click', closeBottomSheet); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && state.bottomSheetOpen) closeBottomSheet(); + }); +} + +function openBottomSheet(): void { + const sheet = document.getElementById('vault-bottom-sheet'); + const scrim = document.getElementById('vault-sheet-scrim'); + if (!sheet || !scrim) return; + + sheet.innerHTML = ` +
+
New item β€” choose type
+
+ ${BOTTOM_SHEET_TYPES.map((t) => ` + + `).join('')} +
+ `; + + sheet.classList.add('vault-bottom-sheet--open'); + scrim.classList.add('vault-bottom-sheet-scrim--visible'); + state.bottomSheetOpen = true; + + sheet.querySelectorAll('[data-type]').forEach((btn) => { + btn.addEventListener('click', () => { + const type = btn.dataset.type as ItemType; + closeBottomSheet(); + setHash('add', type); + renderPane(); + }); + }); +} + +function closeBottomSheet(): void { + document.getElementById('vault-bottom-sheet')?.classList.remove('vault-bottom-sheet--open'); + document.getElementById('vault-sheet-scrim')?.classList.remove('vault-bottom-sheet-scrim--visible'); + state.bottomSheetOpen = false; +} + +// --------------------------------------------------------------------------- +// Drawer (implemented in Task 10) +// --------------------------------------------------------------------------- + +function openDrawer(): void { + document.getElementById('vault-drawer')?.classList.add('vault-drawer--open'); +} + +function closeDrawer(): void { + state.drawerOpen = false; + state.selectedId = null; + state.selectedItem = null; + document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open'); +} + +function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> { + const core = item.core as unknown as Record; + if (!core) return []; + const fields: Array<[string, string, boolean]> = []; + + switch (item.type) { + case 'login': + if ('username' in core) fields.push(['username', String(core.username ?? ''), false]); + if ('password' in core) fields.push(['password', 'β€’β€’β€’β€’β€’β€’β€’β€’', false]); + if ('url' in core) fields.push(['url', String(core.url ?? ''), true]); + break; + case 'card': { + if ('number' in core) fields.push(['number', String(core.number ?? ''), false]); + if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]); + if ('expiry' in core && core.expiry) { + const exp = core.expiry as { month: number; year: number }; + fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]); + } + if ('cvv' in core) fields.push(['cvv', 'β€’β€’β€’', false]); + if ('pin' in core) fields.push(['pin', 'β€’β€’β€’β€’', false]); + break; + } + case 'identity': + if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]); + if ('email' in core) fields.push(['email', String(core.email ?? ''), true]); + if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]); + if ('address' in core) fields.push(['address', String(core.address ?? ''), true]); + if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]); + break; + case 'key': + if ('label' in core) fields.push(['label', String(core.label ?? ''), true]); + if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]); + if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]); + break; + case 'secure_note': + if ('body' in core) fields.push(['body', String(core.body ?? ''), true]); + break; + case 'totp': + if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]); + if ('label' in core) fields.push(['label', String(core.label ?? ''), false]); + break; + case 'document': + if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]); + if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]); + break; + } + + if (item.notes) fields.push(['notes', item.notes, true]); + return fields; +} + +function renderDrawer(item: Item): void { + const drawer = document.getElementById('vault-drawer'); + if (!drawer) return; + + const coreFields = getDrawerCoreFields(item); + + drawer.innerHTML = ` +
+ ${item.type.replace('_', ' ').toUpperCase()} +
+ + +
+
+
+
${escapeHtml(item.title)}
+ ${item.type === 'login' && (item.core as { url?: string }).url + ? `
${escapeHtml((item.core as { url?: string }).url ?? '')}
` + : ''} +
+ ${coreFields.map(([label, value, full]) => ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
+ `).join('')} +
+
+ `; + + document.getElementById('drawer-close-btn')?.addEventListener('click', () => { + closeDrawer(); + renderListPane(); + }); + + document.getElementById('drawer-edit-btn')?.addEventListener('click', () => { + if (state.selectedId) { + setHash('edit', state.selectedId); + renderPane(); + } + }); +} + +// --------------------------------------------------------------------------- +// Item selection (implemented in Task 10) +// --------------------------------------------------------------------------- + +async function selectItemForDrawer(id: string): Promise { + const resp = await sendMessage({ type: 'get_item', id }); + if (!resp.ok) return; + const data = resp.data as { item: Item }; + state.selectedId = id; + state.selectedItem = data.item; + state.drawerOpen = true; + renderSidebarCategories(); + renderListPane(); + renderDrawer(data.item); + openDrawer(); } // --------------------------------------------------------------------------- @@ -293,7 +506,8 @@ function wireSidebar(): void { const searchInput = document.getElementById('vault-search') as HTMLInputElement | null; searchInput?.addEventListener('input', () => { state.searchQuery = searchInput.value; - renderSidebarList(); + renderSidebarCategories(); + renderListPane(); }); // Nav buttons @@ -313,8 +527,7 @@ function wireSidebar(): void { state.selectedId = null; state.selectedItem = null; state.newType = null; - setHash('add'); - renderPane(); + openBottomSheet(); return; } if (nav === 'trash' || nav === 'devices' || nav === 'settings') { @@ -328,11 +541,16 @@ function wireSidebar(): void { }); }); - // Global "/" shortcut to focus search + // Global "/" shortcut to focus search; Esc to close drawer document.addEventListener('keydown', (e) => { if (e.key === '/' && !isEditableTarget(e.target)) { e.preventDefault(); searchInput?.focus(); + return; + } + if (e.key === 'Escape' && state.drawerOpen) { + closeDrawer(); + renderListPane(); } }); } @@ -346,7 +564,7 @@ function isEditableTarget(target: EventTarget | null): boolean { } // --------------------------------------------------------------------------- -// Sidebar list +// Sidebar category nav // --------------------------------------------------------------------------- function getFilteredEntries(): Array<[ItemId, ManifestEntry]> { @@ -367,70 +585,96 @@ function getFilteredEntries(): Array<[ItemId, ManifestEntry]> { return filtered; } -function renderSidebarList(): void { - const container = document.getElementById('vault-sidebar-list'); +function renderSidebarCategories(): void { + const container = document.getElementById('vault-categories'); if (!container) return; const filtered = getFilteredEntries(); - - // Group by type - const groups = new Map>(); - for (const entry of filtered) { - const t = entry[1].type; - if (!groups.has(t)) groups.set(t, []); - groups.get(t)!.push(entry); - } - - if (filtered.length === 0) { - container.innerHTML = '
no items
'; - return; - } - - let html = ''; - // Stable type ordering const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp']; + + const allCount = filtered.length; + const isAllActive = !state.activeGroup && state.view === 'list'; + + let html = ` + + `; + for (const t of typeOrder) { - const items = groups.get(t); - if (!items || items.length === 0) continue; - html += `
${typeIcon(t)} ${escapeHtml(typeLabel(t))}
`; - for (const [id, e] of items) { - const sel = id === state.selectedId ? ' selected' : ''; - const meta = e.icon_hint ? escapeHtml(e.icon_hint) : ''; - html += ` -
- ${escapeHtml(e.title)} - ${meta ? `` : ''} -
- `; - } + const count = filtered.filter(([, e]) => e.type === t).length; + if (count === 0 && allCount > 0) continue; + const isActive = state.activeGroup === t; + html += ` + + `; } container.innerHTML = html; - // Wire clicks - container.querySelectorAll('.vault-entry').forEach((el) => { - el.addEventListener('click', async () => { - const id = (el as HTMLElement).dataset.id!; - await selectItem(id); + container.querySelectorAll('.vault-category-row').forEach((btn) => { + btn.addEventListener('click', () => { + state.activeGroup = btn.dataset.group || null; + state.drawerOpen = false; + state.selectedId = null; + state.selectedItem = null; + renderSidebarCategories(); + renderListPane(); + closeDrawer(); }); }); } -async function selectItem(id: ItemId): Promise { - state.loading = true; - const resp = await sendMessage({ type: 'get_item', id }); - if (resp.ok) { - const data = resp.data as { item: Item }; - state.selectedId = id; - state.selectedItem = data.item; - state.loading = false; - setHash('detail', id); - renderSidebarList(); - renderPane(); - } else { - state.loading = false; - state.error = (resp as { error: string }).error; +// --------------------------------------------------------------------------- +// List pane +// --------------------------------------------------------------------------- + +function renderListPane(): void { + const pane = document.getElementById('vault-list-pane'); + if (!pane) return; + + const group = state.activeGroup as ItemType | null; + let items = getFilteredEntries(); + if (group) items = items.filter(([, e]) => e.type === group); + + if (items.length === 0) { + pane.innerHTML = ` +
+ +
${state.searchQuery ? `No results for "${escapeHtml(state.searchQuery)}"` : 'No items yet'}
+
${state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}
+
+ `; + return; } + + pane.innerHTML = items.map(([id, e]) => { + const sel = id === state.selectedId ? ' vault-list-row--selected' : ''; + const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : ''); + const modifiedAgo = e.modified ? relativeTime(e.modified) : ''; + return ` +
+ +
+
${escapeHtml(e.title)}
+ ${subtitle ? `
${escapeHtml(subtitle)}
` : ''} +
+ ${modifiedAgo ? `
${escapeHtml(modifiedAgo)}
` : ''} +
+ `; + }).join(''); + + pane.querySelectorAll('.vault-list-row').forEach((row) => { + row.addEventListener('click', async () => { + await selectItemForDrawer(row.dataset.id!); + }); + }); } // --------------------------------------------------------------------------- @@ -446,8 +690,8 @@ const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save'; function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void { const itemType = state.selectedItem?.type ?? state.newType ?? 'login'; - const typeLabel = itemType.replace('_', ' '); - const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`; + const typeLabelText = itemType.replace('_', ' '); + const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`; const wrapper = document.createElement('div'); wrapper.className = 'form-pane'; wrapper.innerHTML = ` @@ -505,6 +749,7 @@ export const __test__ = { renderFormWrapped }; function teardownPaneComponents(): void { teardownTrash(); teardownDevices(); + teardownSettings(); teardownFieldHistory(); teardownBackup(); teardownImport(); @@ -554,7 +799,7 @@ function renderPane(): void { renderDevices(pane); break; case 'settings': - renderSettings(pane); + void renderSettings(pane); break; case 'settings-vault': renderVaultSettingsView(pane); @@ -674,7 +919,8 @@ document.addEventListener('DOMContentLoaded', async () => { if ((route.view === 'detail' || route.view === 'edit') && route.id) { if (state.selectedId === route.id && state.selectedItem) { renderPane(); - renderSidebarList(); + renderSidebarCategories(); + renderListPane(); return; } // Need to fetch the item @@ -685,7 +931,30 @@ document.addEventListener('DOMContentLoaded', async () => { // For non-item views, just re-render the pane state.selectedId = null; state.selectedItem = null; - renderSidebarList(); + renderSidebarCategories(); + renderListPane(); renderPane(); }); }); + +// --------------------------------------------------------------------------- +// Legacy selectItem β€” used by hash-change deep linking +// --------------------------------------------------------------------------- + +async function selectItem(id: ItemId): Promise { + state.loading = true; + const resp = await sendMessage({ type: 'get_item', id }); + if (resp.ok) { + const data = resp.data as { item: Item }; + state.selectedId = id; + state.selectedItem = data.item; + state.loading = false; + setHash('detail', id); + renderSidebarCategories(); + renderListPane(); + renderPane(); + } else { + state.loading = false; + state.error = (resp as { error: string }).error; + } +}