diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts
index f0e72be..0460c7c 100644
--- a/extension/src/popup/components/item-form.ts
+++ b/extension/src/popup/components/item-form.ts
@@ -41,7 +41,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
const type: ItemType = existing?.type ?? state.newType ?? 'login';
switch (type) {
- case 'login': return login.renderForm(app, mode, existing, { surface: isInTab() ? 'fullscreen' : 'popup' });
+ case 'login': return login.renderForm(app, mode, existing, { surface: isInTab() ? 'fullscreen' : 'popup', externalActions: isInTab() });
case 'secure_note': return secureNote.renderForm(app, mode, existing);
case 'identity': return identity.renderForm(app, mode, existing);
case 'card': return card.renderForm(app, mode, existing);
diff --git a/extension/src/popup/components/types/login.ts b/extension/src/popup/components/types/login.ts
index f923bb6..f21bef7 100644
--- a/extension/src/popup/components/types/login.ts
+++ b/extension/src/popup/components/types/login.ts
@@ -358,12 +358,10 @@ export function renderForm(
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
- ${externalActions ? '' : `
-
`;
diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css
index 299d3c1..a507dbd 100644
--- a/extension/src/vault/vault.css
+++ b/extension/src/vault/vault.css
@@ -1605,3 +1605,39 @@ textarea {
padding-bottom: 6px;
margin-bottom: 12px;
}
+
+/* Phase 2B: sticky save bar + scrollable form pane */
+.form-pane {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+.form-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: 20px 24px;
+}
+.sticky-save-bar {
+ position: sticky;
+ bottom: 0;
+ background: rgba(17, 22, 30, 0.7);
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+ border-top: 1px solid var(--border-mid);
+ padding: 12px 24px;
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ z-index: 10;
+}
+.sticky-save-bar::before {
+ content: '';
+ position: absolute;
+ top: -24px;
+ left: 0;
+ right: 0;
+ height: 24px;
+ background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent);
+ pointer-events: none;
+}
diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts
index f90449e..4f0bb92 100644
--- a/extension/src/vault/vault.ts
+++ b/extension/src/vault/vault.ts
@@ -415,6 +415,71 @@ async function selectItem(id: ItemId): Promise {
}
}
+// ---------------------------------------------------------------------------
+// Platform-aware save hint
+// ---------------------------------------------------------------------------
+
+const isMac = navigator.platform.toLowerCase().includes('mac');
+const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save';
+
+// ---------------------------------------------------------------------------
+// Fullscreen form wrapper — sticky save bar + scrollable content + header
+// ---------------------------------------------------------------------------
+
+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 wrapper = document.createElement('div');
+ wrapper.className = 'form-pane';
+ wrapper.innerHTML = `
+
+
+
+
+
+
+ `;
+ // Remove pane padding so form-pane can fill height cleanly
+ app.style.padding = '0';
+ app.style.overflow = 'hidden';
+ app.replaceChildren(wrapper);
+
+ const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement;
+ renderItemForm(scrollEl, mode);
+
+ const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement;
+ let isDirty = false;
+ const markDirty = () => {
+ if (isDirty) return;
+ isDirty = true;
+ subEl.textContent = 'unsaved · esc to cancel';
+ };
+ const markClean = () => {
+ isDirty = false;
+ subEl.textContent = 'no changes';
+ };
+ scrollEl.addEventListener('input', markDirty, true);
+ scrollEl.addEventListener('change', markDirty, true);
+
+ wrapper.querySelector('#form-cancel')?.addEventListener('click', () => {
+ markClean();
+ (scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click();
+ });
+ wrapper.querySelector('#form-save')?.addEventListener('click', () => {
+ markClean();
+ (scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click();
+ });
+}
+
+export const __test__ = { renderFormWrapped };
+
// ---------------------------------------------------------------------------
// Pane rendering — delegates to shared popup components
// ---------------------------------------------------------------------------
@@ -453,10 +518,16 @@ function renderPane(): void {
// set by the type-selection click handler (which calls setState →
// renderPane before the URL hash has been updated to include the type).
state.newType = (route.type as ItemType) ?? state.newType ?? null;
- renderItemForm(pane, 'add');
+ // Use the form wrapper (sticky bar + header) when a type is already chosen.
+ // Without a type the type-selection screen renders — no sticky bar needed.
+ if (state.newType) {
+ renderFormWrapped(pane, 'add');
+ } else {
+ renderItemForm(pane, 'add');
+ }
break;
case 'edit':
- renderItemForm(pane, 'edit');
+ renderFormWrapped(pane, 'edit');
break;
case 'trash':
renderTrash(pane);