Stamp genuine successes with the brand's brass ring + checkmark

Signing in, creating an account, recovering one, and rotating a
password all used to just silently swap to the dashboard screen --
nothing marked the moment as a success versus any other navigation.
These are exactly the security-relevant "you're safely in/done"
moments, so mark them with the same brass ring + checkmark mark used
in the logo: a brief overlay stamps the destination screen with the
mark and a short label ("Signed in", "Account created", "Account
recovered", "Password updated"), then fades on its own.

Deliberately narrow: this only fires on those four real completions,
not on every validation step or plain navigation (e.g. the "Back to
dashboard" button), so it stays a rare, meaningful beat instead of
becoming visual noise. It's also inert by design -- pointer-events:
none means it can never block interacting with the screen underneath,
a JS timer (not just the CSS animation) guarantees it's removed even
if the animation can't run, and prefers-reduced-motion drops the
motion but keeps the same brief hold-then-clear timing.
This commit is contained in:
thePR0M3TH3AN committed 2026-07-21 18:44:58 -04:00
1 parent f725e2cd83
commit ed8d8eb1be
2 files changed
+101 -5

No files matched your search

+52 -5
View File
@@ -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<typeof setTimeout> | 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<void> {
@@ -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 = `<style>${WIDGET_STYLES}</style><div class="card">${this.renderScreen()}</div>`;
const successOverlay = this.pendingSuccessLabel ? this.renderSuccessOverlay(this.pendingSuccessLabel) : "";
this.root.innerHTML = `<style>${WIDGET_STYLES}</style><div class="card">${this.renderScreen()}${successOverlay}</div>`;
}
private renderSuccessOverlay(label: string): string {
return `
<div class="success-overlay" data-success-overlay>
<span class="success-stamp">
<svg viewBox="0 0 64 64" aria-hidden="true">
<defs>
<linearGradient id="bl-success-brass" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#e6c481" />
<stop offset="1" stop-color="#b3924a" />
</linearGradient>
</defs>
<circle cx="32" cy="32" r="21" fill="none" stroke="url(#bl-success-brass)" stroke-width="2.5" />
<path d="M27 32.5l3.6 3.6L38 28.2" fill="none" stroke="url(#bl-success-brass)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<p class="success-label">${escapeHtml(label)}</p>
</div>
`;
}
/**
* 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 {
+49
View File
@@ -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;