diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index 6bf2d53..3105a56 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -81,6 +81,11 @@ export class BitLoginAuthElement extends HTMLElement { private exportedNsec = ""; private changePasswordNewCredential = ""; + // Brief animated brass-seal stamp (matching the widget's own brand mark) shown once over + // the destination screen right after a real security-relevant success -- see flashSuccess(). + private pendingSuccessLabel: string | null = null; + private successDismissTimer: ReturnType | null = null; + // Rollback confirmation (ยง16.2 step 6). A RollbackDetectedError from either login or // password-change means this device has already seen a newer credential generation than the // one just read -- most likely a rotated-away password being replayed from a relay that never @@ -124,6 +129,7 @@ export class BitLoginAuthElement extends HTMLElement { } disconnectedCallback(): void { + if (this.successDismissTimer !== null) clearTimeout(this.successDismissTimer); this.releaseSigner(); this.worker.terminate(); } @@ -629,7 +635,7 @@ export class BitLoginAuthElement extends HTMLElement { this.sessionWarnings = []; this.noteSignerClaim(this.claimSigner()); this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } })); - this.goto("dashboard"); + this.flashSuccess("dashboard", "Account created"); } private async handleLoginSubmit(): Promise { @@ -653,7 +659,7 @@ export class BitLoginAuthElement extends HTMLElement { this.busy = false; this.noteSignerClaim(this.claimSigner()); this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } })); - this.goto("dashboard"); + this.flashSuccess("dashboard", "Signed in"); void this.offerToSaveCredential(loginName, password); } catch (err) { if (err instanceof Error && err.name === "RollbackDetectedError") { @@ -725,7 +731,7 @@ export class BitLoginAuthElement extends HTMLElement { this.sessionWarnings = []; this.noteSignerClaim(this.claimSigner()); this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } })); - this.goto("dashboard"); + this.flashSuccess("dashboard", "Account recovered"); void this.offerToSaveCredential(newLoginName, newPassword); } @@ -757,7 +763,7 @@ export class BitLoginAuthElement extends HTMLElement { this.busy = false; this.sessionWarnings = []; this.noteSignerClaim(this.claimSigner()); - this.goto("dashboard"); + this.flashSuccess("dashboard", "Password updated"); void this.offerToSaveCredential(this.loginName, newPassword); } catch (err) { if (err instanceof Error && err.name === "RollbackDetectedError") { @@ -795,7 +801,48 @@ export class BitLoginAuthElement extends HTMLElement { } private render(): void { - this.root.innerHTML = `
${this.renderScreen()}
`; + const successOverlay = this.pendingSuccessLabel ? this.renderSuccessOverlay(this.pendingSuccessLabel) : ""; + this.root.innerHTML = `
${this.renderScreen()}${successOverlay}
`; + } + + private renderSuccessOverlay(label: string): string { + return ` +
+ + + +

${escapeHtml(label)}

+
+ `; + } + + /** + * Navigates to `screen` and briefly stamps it with the same brass-ring-and-checkmark mark + * used in the logo, for the handful of moments that are a genuine security-relevant success + * -- signing in, creating an account, recovering one, or rotating a password -- rather than + * every validation step or navigation. Purely decorative: `pointer-events: none` (see + * styles.ts) means it never blocks interacting with the screen underneath, and a JS timer + * (not just the CSS animation) guarantees it's removed even if the animation can't run. + */ + private flashSuccess(screen: Screen, label: string): void { + this.goto(screen); + if (this.successDismissTimer !== null) clearTimeout(this.successDismissTimer); + this.pendingSuccessLabel = label; + this.render(); + this.successDismissTimer = setTimeout(() => { + this.pendingSuccessLabel = null; + this.successDismissTimer = null; + this.root.querySelector("[data-success-overlay]")?.remove(); + }, 1100); } private renderError(): string { diff --git a/packages/widget/src/styles.ts b/packages/widget/src/styles.ts index e4a1d6f..937710d 100644 --- a/packages/widget/src/styles.ts +++ b/packages/widget/src/styles.ts @@ -54,6 +54,7 @@ export const WIDGET_STYLES = /* css */ ` } * { box-sizing: border-box; } .card { + position: relative; width: 100%; background: var(--bl-bg); color: var(--bl-fg); @@ -79,6 +80,54 @@ h2 { margin: 0 0 6px; color: var(--bl-fg); } +/* Brief confirmation stamp shown over the destination screen right after a real + security-relevant success (see flashSuccess() in element.ts) -- the same brass ring + and checkmark as the brand mark, so a successful sign-in/create/recover/rotate reads + as "sealed," not just a silent screen change. Never blocks interaction, and fades out + on its own; the JS timer that removes it entirely doesn't depend on the animation. */ +.success-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: var(--bl-bg); + border-radius: var(--bl-radius); + pointer-events: none; + animation: bl-overlay-fade-out 0.4s ease 0.7s both; +} +.success-stamp svg { + width: 56px; + height: 56px; + animation: bl-stamp-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; +} +.success-label { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--bl-fg); + opacity: 0; + animation: bl-label-in 0.3s ease 0.35s both; +} +@keyframes bl-stamp-in { + 0% { transform: scale(0.5); opacity: 0; } + 60% { transform: scale(1.15); opacity: 1; } + 100% { transform: scale(1); opacity: 1; } +} +@keyframes bl-label-in { + to { opacity: 1; } +} +@keyframes bl-overlay-fade-out { + 0% { opacity: 1; } + 100% { opacity: 0; } +} +@media (prefers-reduced-motion: reduce) { + .success-overlay { animation: none; } + .success-stamp svg { animation: none; } + .success-label { animation: none; opacity: 1; } +} p.sub { color: var(--bl-muted); margin: 0 0 18px;