diff --git a/extension/src/shared/__tests__/glyphs.test.ts b/extension/src/shared/__tests__/glyphs.test.ts index 312251c..847cfe4 100644 --- a/extension/src/shared/__tests__/glyphs.test.ts +++ b/extension/src/shared/__tests__/glyphs.test.ts @@ -52,7 +52,7 @@ describe('Stream A glyphs (vault tab + type icons)', () => { 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_IDENTITY).toBe('◍'); expect(glyphs.GLYPH_TYPE_KEY).toBe('⊹'); expect(glyphs.GLYPH_TYPE_DOCUMENT).toBe('≡'); }); diff --git a/extension/src/shared/glyphs.ts b/extension/src/shared/glyphs.ts index 71a23a7..66f09a1 100644 --- a/extension/src/shared/glyphs.ts +++ b/extension/src/shared/glyphs.ts @@ -27,7 +27,7 @@ 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_IDENTITY = '◍'; // identity (distinct from GLYPH_DEVICES ⌬) export const GLYPH_TYPE_KEY = '⊹'; // SSH / API key export const GLYPH_TYPE_DOCUMENT = '≡'; // document diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 8d9f994..19fa844 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -438,6 +438,14 @@ textarea { margin-top: 16px; } +/* When the sticky save bar (fullscreen) provides external actions, + the form's inner action row sets the [hidden] attribute. The default + user-agent rule for [hidden] is display:none, but our .form-actions + display:flex above wins specificity. Re-assert hidden takes priority. */ +.form-actions[hidden] { + display: none !important; +} + .inline-row { display: flex; gap: 8px; @@ -626,16 +634,18 @@ textarea { .gen-trigger { background: #7c5719; color: #fff3cf; - border: none; - border-radius: 4px; - padding: 0 12px; - font-size: 16px; + border: 1px solid #7c5719; + border-radius: 3px; + padding: 0 8px; + font-size: 14px; cursor: pointer; line-height: 1; - min-width: 38px; + min-width: 28px; + height: 28px; display: inline-flex; align-items: center; justify-content: center; + vertical-align: middle; } .gen-trigger:hover { background: #aa812a; } .gen-trigger[aria-expanded="true"] { background: #aa812a; } @@ -1291,8 +1301,8 @@ textarea { gap: 8px; } .vault-sidebar__header .brand-logo { - width: 20px; - height: 20px; + width: 36px; + height: 36px; margin: 0; display: block; flex-shrink: 0; @@ -1402,6 +1412,7 @@ textarea { height: 100vh; overflow: hidden; background: var(--bg-page, #0d1117); + position: relative; } .vault-sidebar { @@ -1410,9 +1421,11 @@ textarea { display: flex; flex-direction: column; border-right: 1px solid var(--border, #30363d); - background: var(--bg-sidebar, #0d1117); + background: var(--bg-page); overflow-y: auto; flex-shrink: 0; + position: relative; + z-index: 5; } .vault-list-pane { @@ -1423,6 +1436,29 @@ textarea { min-width: 0; } +/* Pane occupies the same flex slot as list-pane for non-list views */ +.vault-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + min-width: 0; + padding: 24px 32px; +} +.vault-pane--empty { + align-items: center; + justify-content: center; + color: var(--text-dim); + font-size: 14px; +} + +/* View-specific visibility: only one of list-pane / vault-pane is visible. + Default to list view if neither class is set. */ +.vault-shell .vault-pane { display: none; } +.vault-shell--pane .vault-list-pane { display: none; } +.vault-shell--pane .vault-pane { display: flex; } +.vault-shell--pane .vault-drawer { display: none; } + .vault-drawer { width: 440px; min-width: 440px; @@ -1439,6 +1475,44 @@ textarea { transform: translateX(0); } +/* Empty state — centered, gold-accented icon, polished */ +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 48px 24px; + text-align: center; + min-height: 240px; +} +.empty-state__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--gold-soft); + border: 1px solid var(--gold-ring); + color: var(--gold-text); + font-size: 30px; + line-height: 1; + margin-bottom: 4px; +} +.empty-state__title { + font-size: 14px; + font-weight: 600; + color: var(--text); + letter-spacing: 0.02em; +} +.empty-state__hint { + font-size: 12px; + color: var(--text-muted); + max-width: 320px; +} + .vault-list-row { display: flex; align-items: center; @@ -1496,79 +1570,126 @@ textarea { flex-shrink: 0; } -/* Bottom sheet */ -.vault-bottom-sheet-scrim { +/* Type picker — secondary panel that slides out from behind the left sidebar */ +.vault-type-panel-scrim { position: absolute; inset: 0 0 0 200px; - background: rgba(0,0,0,0.5); + background: rgba(0, 0, 0, 0.45); opacity: 0; - transition: opacity 0.2s; pointer-events: none; - z-index: 100; + transition: opacity 0.2s ease; + z-index: 3; } - -.vault-bottom-sheet-scrim--visible { +.vault-type-panel-scrim--visible { opacity: 1; pointer-events: auto; } -.vault-bottom-sheet { +.vault-type-panel { position: absolute; + top: 0; 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; + width: 280px; + background: var(--bg-elevated); + border-right: 1px solid var(--border-mid); + transform: translateX(-100%); + transition: transform 0.22s ease; + z-index: 4; overflow-y: auto; + padding: 14px 12px; + box-shadow: 8px 0 24px rgba(0, 0, 0, 0.45); } +.vault-type-panel--open { transform: translateX(0); } -.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-type-panel__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 4px 10px; + margin-bottom: 8px; + border-bottom: 1px solid var(--border-soft); } - -.vault-bottom-sheet__title { - font-size: 14px; +.vault-type-panel__title { + font-size: 12px; font-weight: 600; - margin-bottom: 16px; - text-align: center; + color: var(--gold-text); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.vault-type-panel__close { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + padding: 2px 6px; + border-radius: 3px; +} +.vault-type-panel__close:hover { color: var(--text); background: var(--bg-pane); } +.vault-type-panel__hint { + font-size: 11px; + color: var(--text-dim); + padding: 0 4px 8px; } -.vault-type-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); - gap: 10px; -} - -.vault-type-card { +.vault-type-list { 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; + gap: 2px; } -.vault-type-card:hover { border-color: var(--gold, #b8860b); } +.vault-type-item { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 10px; + background: transparent; + border: 1px solid transparent; + border-radius: 5px; + cursor: pointer; + text-align: left; + color: var(--text); + font: inherit; + font-size: 13px; + transition: background 0.1s, border-color 0.1s; +} +.vault-type-item:hover { + background: var(--bg-pane); + border-color: var(--border-mid); +} +.vault-type-item:focus-visible { + outline: none; + border-color: var(--gold-base); + background: var(--gold-soft); +} +.vault-type-item__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex-shrink: 0; + background: var(--gold-soft); + border: 1px solid var(--gold-ring); + border-radius: 5px; + font-size: 15px; + color: var(--gold-text); +} +.vault-type-item__name { + flex: 1; + color: var(--text); +} -.vault-type-card__icon { font-size: 28px; } -.vault-type-card__name { font-size: 11px; color: var(--text-muted, #8b949e); } +/* Sidebar nav button — primary new-item variant */ +.vault-sidebar__nav-item--primary { + color: var(--gold-text); + font-weight: 600; +} +.vault-sidebar__nav-item--primary:hover { + background: var(--gold-soft); + color: var(--gold-hi-end); +} /* Drawer header and body */ .vault-drawer__header { @@ -1902,7 +2023,7 @@ textarea { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; - max-width: 960px; + max-width: 1100px; margin: 0 auto; } @media (max-width: 720px) { @@ -1912,7 +2033,7 @@ textarea { /* P3: lower form sections constrained to the same envelope as .form-grid. Gated on surface === 'fullscreen' in login.ts; popup unaffected. */ .form-lower { - max-width: 960px; + max-width: 1100px; margin: 0 auto; } .form-lower > .form-group, @@ -1965,6 +2086,9 @@ textarea { flex-direction: column; height: 100%; overflow: hidden; + width: 100%; + max-width: 1280px; + margin: 0 auto; } .form-scroll { flex: 1; diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 69feb7c..2f8f0a0 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -27,10 +27,10 @@ import { renderImportPanel, teardown as teardownImport } from './components/impo import { applyColorScheme } from '../shared/color-scheme'; // --------------------------------------------------------------------------- -// Bottom sheet type picker +// Type picker (right side panel) // --------------------------------------------------------------------------- -const BOTTOM_SHEET_TYPES: Array<{ type: ItemType; label: string }> = [ +const PICKER_TYPES: Array<{ type: ItemType; label: string }> = [ { type: 'login', label: 'Login' }, { type: 'secure_note', label: 'Secure Note' }, { type: 'totp', label: 'TOTP' }, @@ -47,6 +47,27 @@ const BOTTOM_SHEET_TYPES: Array<{ type: ItemType; label: string }> = [ function sendMessage(request: Request): Promise { return new Promise((resolve) => { chrome.runtime.sendMessage(request, (response: Response) => { + // MV3 service workers are evicted after ~30s idle, which wipes the + // in-memory session/manifest/gitHost. The fullscreen tab stays open + // and has no signal that the SW restarted — the next RPC just comes + // back `vault_locked`. Treat that as "session lost" and force the + // lock screen so the user can re-enter their passphrase. Skip for + // is_unlocked / unlock themselves to avoid loops on cold start. + if ( + response && + !response.ok && + response.error === 'vault_locked' && + request.type !== 'is_unlocked' && + request.type !== 'unlock' && + state.unlocked + ) { + state.unlocked = false; + state.selectedId = null; + state.selectedItem = null; + state.entries = []; + state.error = 'Session expired — please unlock again.'; + render(); + } resolve(response); }); }); @@ -166,7 +187,7 @@ interface VaultState { searchQuery: string; activeGroup: string | null; drawerOpen: boolean; - bottomSheetOpen: boolean; + typePanelOpen: boolean; vaultSettings: VaultSettings | null; generatorDefaults: GeneratorRequest | null; error: string | null; @@ -187,7 +208,7 @@ const state: VaultState = { searchQuery: '', activeGroup: null, drawerOpen: false, - bottomSheetOpen: false, + typePanelOpen: false, vaultSettings: null, generatorDefaults: null, error: null, @@ -211,8 +232,9 @@ registerHost({ navigate: (view: string, extras?: any) => { Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); + applyShellViewClass(); renderSidebarCategories(); - renderListPane(); + if (state.view === 'list') renderListPane(); renderPane(); }, sendMessage, @@ -290,7 +312,7 @@ function renderShell(app: HTMLElement): void {
- + Relicario
- + @@ -306,69 +328,102 @@ function renderShell(app: HTMLElement): void {
+
-
-
+
+
`; wireSidebar(); - wireBottomSheet(); + wireTypePanel(); } + applyShellViewClass(); renderSidebarCategories(); - renderListPane(); - if (state.drawerOpen && state.selectedItem) { - renderDrawer(state.selectedItem); + if (state.view === 'list') { + renderListPane(); + if (state.drawerOpen && state.selectedItem) { + renderDrawer(state.selectedItem); + } + } else { + renderPane(); } } +// Toggle which middle column is visible based on the current view. +// list view → list-pane (+ optional drawer); other views → vault-pane. +function applyShellViewClass(): void { + const shell = document.querySelector('.vault-shell'); + if (!shell) return; + shell.classList.toggle('vault-shell--list', state.view === 'list'); + shell.classList.toggle('vault-shell--pane', state.view !== 'list'); +} + // --------------------------------------------------------------------------- -// Bottom sheet (wired in Task 11) +// Right-side type picker panel // --------------------------------------------------------------------------- -function wireBottomSheet(): void { - document.getElementById('vault-sheet-scrim')?.addEventListener('click', closeBottomSheet); +function wireTypePanel(): void { + document.getElementById('vault-type-scrim')?.addEventListener('click', closeTypePanel); document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && state.bottomSheetOpen) closeBottomSheet(); + if (e.key === 'Escape' && state.typePanelOpen) closeTypePanel(); }); } -function openBottomSheet(): void { - const sheet = document.getElementById('vault-bottom-sheet'); - const scrim = document.getElementById('vault-sheet-scrim'); - if (!sheet || !scrim) return; +function openTypePanel(): void { + const panel = document.getElementById('vault-type-panel'); + const scrim = document.getElementById('vault-type-scrim'); + if (!panel || !scrim) return; - sheet.innerHTML = ` -
-
New item — choose type
-
- ${BOTTOM_SHEET_TYPES.map((t) => ` - +
+
Choose a type
+ `; - sheet.classList.add('vault-bottom-sheet--open'); - scrim.classList.add('vault-bottom-sheet-scrim--visible'); - state.bottomSheetOpen = true; + panel.classList.add('vault-type-panel--open'); + scrim.classList.add('vault-type-panel-scrim--visible'); + state.typePanelOpen = true; - sheet.querySelectorAll('[data-type]').forEach((btn) => { + panel.querySelector('#vault-type-close')?.addEventListener('click', closeTypePanel); + + panel.querySelectorAll('[data-type]').forEach((btn) => { btn.addEventListener('click', () => { const type = btn.dataset.type as ItemType; - closeBottomSheet(); + closeTypePanel(); + // Use the host's navigate hook so view + hash + visibility all update + // together. This was the bug: bare setHash + renderPane left the + // shell stuck in list view with #vault-pane hidden. + state.newType = type; + state.selectedId = null; + state.selectedItem = null; + state.drawerOpen = false; + state.view = 'add'; setHash('add', type); + applyShellViewClass(); + renderSidebarCategories(); renderPane(); }); }); + + // Focus first item for keyboard users + (panel.querySelector('.vault-type-item') as HTMLElement | null)?.focus(); } -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; +function closeTypePanel(): void { + document.getElementById('vault-type-panel')?.classList.remove('vault-type-panel--open'); + document.getElementById('vault-type-scrim')?.classList.remove('vault-type-panel-scrim--visible'); + state.typePanelOpen = false; } // --------------------------------------------------------------------------- @@ -527,14 +582,19 @@ function wireSidebar(): void { state.selectedId = null; state.selectedItem = null; state.newType = null; - openBottomSheet(); + state.drawerOpen = false; + closeDrawer(); + openTypePanel(); return; } if (nav === 'trash' || nav === 'devices' || nav === 'settings') { state.selectedId = null; state.selectedItem = null; state.newType = null; + state.drawerOpen = false; + state.view = nav; setHash(nav); + applyShellViewClass(); renderPane(); return; } @@ -605,7 +665,8 @@ function renderSidebarCategories(): void { for (const t of typeOrder) { const count = filtered.filter(([, e]) => e.type === t).length; - if (count === 0 && allCount > 0) continue; + // Always show Login (staple type); hide other types when empty. + if (count === 0 && t !== 'login') continue; const isActive = state.activeGroup === t; html += `