Welcome-screen polish: alternative methods behind a collapsed options menu

Import, recovery, extension, and remote signer no longer stack as four
links under the primary buttons -- they live behind a 'More sign-in
options' disclosure that expands into an icon+description option menu.
Rows keep a 56px minimum touch target, the toggle carries aria-expanded,
chevron rotation respects prefers-reduced-motion, and buttons get a
focus-visible outline. The disclosure state survives re-renders so an
error doesn't snap the menu shut. Contextual renderings stay put: the
login screen keeps 'Forgot password?' and the bunker screen its retry.

Also cap the nostrconnect URI at two relays: five relay params pushed
the QR several versions denser, and tiny modules hurt both the dot
aesthetic and camera lock-on. Verified in the browser: collapsed and
expanded welcome, and the sparser QR.

Policy pins updated to the new structure: every alternative action must
render inside the option-menu template, with exact occurrence counts.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-04 10:45:17 -04:00
1 parent 49682dd75c
commit 2e7b1e19e3
4 files changed
+133 -22

No files matched your search

+46 -10
View File
@@ -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: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/><path d="M6.5 6.7h.01"/><path d="M9.3 6.7h.01"/></svg>`,
remote: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 14h3v3"/><path d="M21 14v0.01"/><path d="M14 21h0.01"/><path d="M17.5 17.5L21 21"/></svg>`,
importKey: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="12" r="3.5"/><path d="M11.5 12H21"/><path d="M18 12v3.2"/><path d="M14.8 12v2.2"/></svg>`,
recover: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 5v5h5"/><path d="M4.2 14a8 8 0 1 0 1.9-8.3L2.5 10"/></svg>`
} as const;
const CHEVRON_DOWN = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>`;
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()
? `<button class="link" data-action="extension-signin" ${this.busy ? "disabled" : ""}>${
this.busy ? '<span class="spinner"></span>Asking your extension…' : "Use your Nostr extension instead"
}</button>`
// 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) => `
<button class="option-row" type="button" data-action="${action}" ${disabled ? "disabled" : ""}>
<span class="option-icon">${icon}</span>
<span class="option-text"><span>${title}</span><span class="option-sub">${sub}</span></span>
</button>`;
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
? `<div class="option-menu">
${extensionRow}
${optionRow("goto-bunker", OPTION_ICONS.remote, "Remote signer", "Scan a code with Amber or another signer app")}
${optionRow("goto-import", OPTION_ICONS.importKey, "Import a Nostr key", "Wrap an existing identity in a login name and password")}
${optionRow("goto-recover", OPTION_ICONS.recover, "Recover account", "Sign back in with your 12-word recovery phrase")}
</div>`
: "";
return `
${this.renderBrandLockup()}
@@ -1394,10 +1430,10 @@ export class BitLoginAuthElement extends HTMLElement {
${this.renderError()}
<button class="primary" data-action="goto-login">Sign in</button>
<button class="secondary" data-action="goto-create">Create account</button>
<button class="link" data-action="goto-import">Import an existing Nostr key</button>
<button class="link" data-action="goto-recover">Forgot password? Recover with phrase</button>
${extensionOption}
<button class="link" data-action="goto-bunker">Use a remote signer (bunker)</button>
<button class="options-toggle ${this.moreOptionsOpen ? "open" : ""}" type="button" data-action="toggle-more" aria-expanded="${this.moreOptionsOpen}">
More sign-in options ${CHEVRON_DOWN}
</button>
${optionsMenu}
`;
}
+28 -11
View File
@@ -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</button>');
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</button>');
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('`<div class="option-menu">');
const menuEnd = widgetSource.indexOf("</div>`", 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", () => {
+54
View File
@@ -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 {
+5 -1
View File
@@ -289,7 +289,11 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
case "nip46NostrconnectStart": {
const p = payload as Nip46NostrconnectStartPayload;
const relayUrls = vaultRelayUrls;
// Two relays, not the whole list: every relay URL inflates the QR by a
// version step or two, and a dense code with tiny modules is measurably
// harder for a phone camera to lock onto. NIP-46 needs one shared
// relay; the second is the redundancy.
const relayUrls = vaultRelayUrls.slice(0, 2);
const clientSecretKey = generatePrivateKey();
// The secret is the proof-of-scan: whoever echoes it back becomes this
// session's signer, so it gets real entropy, not a friendly string.