diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index f8cfa1e..4c0c075 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -47,6 +47,17 @@ interface ConfirmSlot { value: string; } +/** Line icons for the "More sign-in options" menu: stroke-based so they pick + * up the muted foreground in either theme, sized by .option-icon. */ +const OPTION_ICONS = { + extension: ``, + remote: ``, + importKey: ``, + recover: `` +} as const; + +const CHEVRON_DOWN = ``; + export class BitLoginAuthElement extends HTMLElement { private root: ShadowRoot; private worker: WorkerClient; @@ -57,6 +68,9 @@ export class BitLoginAuthElement extends HTMLElement { private screen: Screen = "welcome"; private busy = false; private errorMessage: string | undefined; + /** Welcome-screen disclosure for non-primary sign-in paths; survives + * re-renders so an open menu doesn't snap shut on an error render. */ + private moreOptionsOpen = false; private loginName = ""; private generatedCredential = ""; @@ -752,6 +766,10 @@ export class BitLoginAuthElement extends HTMLElement { this.bunkerAuthUrl = ""; this.goto("welcome"); return; + case "toggle-more": + this.moreOptionsOpen = !this.moreOptionsOpen; + this.render(); + return; case "extension-signin": return this.handleExtensionSignIn(); case "extension-continue": @@ -1381,12 +1399,30 @@ export class BitLoginAuthElement extends HTMLElement { private renderScreen(): string { switch (this.screen) { case "welcome": { - // Username/password is the primary method; a detected extension appears as a - // secondary affordance only (§LM9.1, resolved: password-first). - const extensionOption = detectForeignNip07Provider() - ? `` + // Username/password is the primary method (§LM9.1, resolved: + // password-first); every alternative path lives behind the collapsed + // "More sign-in options" disclosure below the two primary buttons. + const optionRow = (action: string, icon: string, title: string, sub: string, disabled = false) => ` + `; + const extensionRow = detectForeignNip07Provider() + ? optionRow( + "extension-signin", + OPTION_ICONS.extension, + this.busy ? "Asking your extension…" : "Nostr extension", + "Use the signer extension in this browser", + this.busy + ) + : ""; + const optionsMenu = this.moreOptionsOpen + ? `
` : ""; return ` ${this.renderBrandLockup()} @@ -1394,10 +1430,10 @@ export class BitLoginAuthElement extends HTMLElement { ${this.renderError()} - - - ${extensionOption} - + + ${optionsMenu} `; } diff --git a/packages/widget/src/securityPolicy.test.ts b/packages/widget/src/securityPolicy.test.ts index 06725bb..c36e1fc 100644 --- a/packages/widget/src/securityPolicy.test.ts +++ b/packages/widget/src/securityPolicy.test.ts @@ -34,20 +34,37 @@ describe("widget security policy", () => { // not displace the password path. The welcome screen's primary sign-in button has // to appear before the extension option in the rendered template. const passwordSignIn = widgetSource.indexOf('data-action="goto-login">Sign in'); - const extensionSlot = widgetSource.indexOf("${extensionOption}"); + const optionsMenuSlot = widgetSource.indexOf("${optionsMenu}"); expect(passwordSignIn).toBeGreaterThan(-1); - expect(extensionSlot).toBeGreaterThan(-1); - expect(passwordSignIn).toBeLessThan(extensionSlot); + expect(optionsMenuSlot).toBeGreaterThan(-1); + expect(passwordSignIn).toBeLessThan(optionsMenuSlot); + // The extension row renders inside the collapsed menu, not free-standing. + expect(widgetSource).toContain('"extension-signin",'); }); - it("keeps alternative methods below the password path on the welcome screen", () => { - // Same owner decision as the extension pin above, applied to NIP-46: the - // remote-signer option renders after the primary sign-in button. - const passwordSignIn = widgetSource.indexOf('data-action="goto-login">Sign in'); - const bunkerOption = widgetSource.indexOf('data-action="goto-bunker">Use a remote signer'); - expect(passwordSignIn).toBeGreaterThan(-1); - expect(bunkerOption).toBeGreaterThan(-1); - expect(passwordSignIn).toBeLessThan(bunkerOption); + it("keeps every alternative method inside the collapsed options menu", () => { + // Same owner decision as the pin above, applied to all of NIP-46, key + // import, and recovery: each renders as a row of the option-menu that the + // "${optionsMenu}" slot (already pinned after the primary sign-in button) + // interpolates -- never as a free-standing button beside the primaries. + const menuStart = widgetSource.indexOf('``", menuStart); + expect(menuStart).toBeGreaterThan(-1); + expect(menuEnd).toBeGreaterThan(menuStart); + const menu = widgetSource.slice(menuStart, menuEnd); + // Expected occurrences: the onClick case + the menu row, plus sanctioned + // contextual renderings AWAY from the welcome screen -- the login screen's + // "Forgot password?" link (goto-recover) and the bunker screen's own + // "Generate a new code" retry (goto-bunker). + for (const [action, expected] of [ + ["goto-bunker", 3], + ["goto-import", 2], + ["goto-recover", 3], + ] as const) { + expect(menu).toContain(`optionRow("${action}"`); + const occurrences = widgetSource.split(`"${action}"`).length - 1; + expect(occurrences).toBe(expected); + } }); it("treats the bunker URI as a secret and keeps the element storage-free", () => { diff --git a/packages/widget/src/styles.ts b/packages/widget/src/styles.ts index 0af14c9..6c603a8 100644 --- a/packages/widget/src/styles.ts +++ b/packages/widget/src/styles.ts @@ -204,6 +204,60 @@ button.link { font-weight: 500; font-size: 13px; } +button:focus-visible { outline: 2px solid var(--bl-accent); outline-offset: 2px; } +/* Collapsed disclosure for every non-primary sign-in path (§LM9.1: the + password flow stays the visual headline; alternatives are one tap away). + Rows are full-bleed buttons with generous minimum height so the menu works + as well under a thumb as a pointer. */ +.options-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + background: none; + border: none; + color: var(--bl-muted); + font-size: 13px; + font-weight: 500; + padding: 10px 0 8px; + margin-top: 8px; +} +.options-toggle:hover { color: var(--bl-fg); } +.options-toggle svg { width: 14px; height: 14px; transition: transform 0.18s ease; } +.options-toggle.open svg { transform: rotate(180deg); } +.option-menu { + border: 1px solid var(--bl-border); + border-radius: 12px; + margin-top: 4px; + overflow: hidden; +} +.option-row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + margin: 0; + padding: 12px 14px; + min-height: 56px; + background: none; + border: none; + border-radius: 0; + text-align: left; + font-size: 14px; + font-weight: 500; + color: var(--bl-fg); +} +.option-row + .option-row { border-top: 1px solid var(--bl-border); } +.option-row:hover { background: var(--bl-input-bg); } +.option-row:disabled { opacity: 0.55; cursor: default; } +.option-icon { flex: none; width: 20px; height: 20px; color: var(--bl-muted); } +.option-icon svg { display: block; width: 100%; height: 100%; } +.option-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.option-sub { font-size: 12px; font-weight: 400; color: var(--bl-muted); } +@media (prefers-reduced-motion: reduce) { + .options-toggle svg { transition: none; } +} .row { display: flex; gap: 8px; } .row > * { flex: 1; } .notice { diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts index e527ff9..c3f19a0 100644 --- a/packages/widget/src/worker/cryptoWorker.ts +++ b/packages/widget/src/worker/cryptoWorker.ts @@ -289,7 +289,11 @@ async function handle(action: string, payload: unknown): Promise