mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
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:
1 parent
49682dd75c
commit
2e7b1e19e3
4 files changed
+133
-22
No files matched your search
@@ -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}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user