diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index c9956ae..f0e72be 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); + case 'login': return login.renderForm(app, mode, existing, { surface: isInTab() ? 'fullscreen' : 'popup' }); 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/__tests__/login.test.ts b/extension/src/popup/components/types/__tests__/login.test.ts index 50400bb..7077847 100644 --- a/extension/src/popup/components/types/__tests__/login.test.ts +++ b/extension/src/popup/components/types/__tests__/login.test.ts @@ -63,6 +63,40 @@ describe('login form smart inputs', () => { }); }); +describe('renderForm surface flag', () => { + let app: HTMLElement; + beforeEach(() => { + document.body.innerHTML = '
'; + app = document.getElementById('app')!; + (globalThis as any).chrome = { + storage: { + local: { + get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})), + set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()), + }, + }, + runtime: { + sendMessage: vi.fn(), + }, + }; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { groups: [] } }); + }); + + it('renders single-column when surface is "popup" (default)', () => { + renderForm(app, 'add', null); + expect(app.querySelector('.form-grid')).toBeNull(); + }); + + it('renders two-column .form-grid wrapper when surface is "fullscreen"', () => { + renderForm(app, 'add', null, { surface: 'fullscreen' }); + const grid = app.querySelector('.form-grid'); + expect(grid).toBeTruthy(); + expect(grid!.querySelector('[data-form-section="identity"]')).toBeTruthy(); + expect(grid!.querySelector('[data-form-section="credentials"]')).toBeTruthy(); + }); +}); + describe('Login save shape', () => { beforeEach(() => { document.body.innerHTML = '
'; diff --git a/extension/src/popup/components/types/login.ts b/extension/src/popup/components/types/login.ts index aff7dff..f923bb6 100644 --- a/extension/src/popup/components/types/login.ts +++ b/extension/src/popup/components/types/login.ts @@ -235,7 +235,20 @@ function startTotpTicker(id: ItemId): void { // Form (add / edit) // ---------------------------------------------------------------------- -export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void { +export interface RenderFormOptions { + surface?: 'popup' | 'fullscreen'; + /** When true, renderForm skips its own save/cancel buttons (caller provides them in a sticky bar). */ + externalActions?: boolean; +} + +export function renderForm( + app: HTMLElement, + mode: 'add' | 'edit', + existing: Item | null, + opts: RenderFormOptions = {} +): void { + const surface = opts.surface ?? 'popup'; + const externalActions = opts.externalActions ?? false; const state = getState(); const existingCore = (existing?.core.type === 'login') ? (existing.core as LoginCore & { type: 'login' }) @@ -254,58 +267,86 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite : []; let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? []; + const titleFieldHtml = ` +
+
`; + + const urlFieldHtml = ` +
+ +
+ + +
+ +
`; + + const groupFieldHtml = ` +
+
`; + + const usernameFieldHtml = ` +
+
`; + + const passwordFieldHtml = ` +
+ +
+ + + +
+ +
`; + + const totpFieldHtml = ` +
+ +
+ + +
+ + +
`; + + const identityHtml = ` +
+ ${surface === 'fullscreen' ? '
Identity
' : ''} + ${titleFieldHtml} + ${urlFieldHtml} + ${groupFieldHtml} +
`; + + const credentialsHtml = ` +
+ ${surface === 'fullscreen' ? '
Credentials
' : ''} + ${usernameFieldHtml} + ${passwordFieldHtml} + ${totpFieldHtml} +
`; + + const sectionsHtml = surface === 'fullscreen' + ? `
${identityHtml}${credentialsHtml}
` + : `${identityHtml}${credentialsHtml}`; + app.innerHTML = `
- ${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })} + ${surface === 'popup' ? renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' }) : ''} ${state.error ? `
${escapeHtml(state.error)}
` : ''} -
-
- -
- -
- - -
- -
- -
-
- -
- -
- - - -
- -
- -
- -
- - -
- - -
- -
-
+ ${sectionsHtml}
@@ -317,10 +358,12 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite ${renderSectionsEditor(sectionsDraft, sectionsExpanded)} ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''} + ${externalActions ? '' : `
+ `}
`; @@ -433,7 +476,7 @@ function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; e } } -async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise { +export async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise { const state = getState(); const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value; diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 08de923..299d3c1 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -1580,3 +1580,28 @@ textarea { outline: none; box-shadow: var(--focus-ring); } + +/* Phase 2B: two-column form grid for fullscreen login */ +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + max-width: 960px; + margin: 0 auto; +} +@media (max-width: 720px) { + .form-grid { grid-template-columns: 1fr; } +} +.form-col { + padding: 14px 16px; +} +.col-header { + text-transform: uppercase; + letter-spacing: 1.2px; + font-weight: 500; + color: var(--text-muted); + font-size: 10px; + border-bottom: 1px solid var(--border-mid); + padding-bottom: 6px; + margin-bottom: 12px; +}