b8e642f2990b
add PasskeyToast component, styles, and shadow-DOM content script injection
- time
- 2026-05-13 15:04 UTC · 5 months ago
- machine
- koh2
- parent
- 2199d6be0193
↓ peach-browser-b8e642f2.tar.gzatlas at this save
files changed3 files
src/content/passkey-toast/PasskeyToast.tsx+107 −0src/content/passkey-toast/inject.ts+98 −0src/content/passkey-toast/passkey-toast.css+202 −0src/content/passkey-toast/PasskeyToast.tsx+107 −0
@@ -0,0 +1,107 @@1+import { createSignal, onMount, onCleanup } from 'solid-js';2+3+interface PasskeyToastProps {4+ site: string;5+ displayName: string;6+ username: string;7+ requestId: string;8+ onSignIn: () => void;9+ onDeny: () => void;10+}11+12+const TIMEOUT_MS = 30_000;13+14+export function PasskeyToast(props: PasskeyToastProps) {15+ const [visible, setVisible] = createSignal(false);16+ const [exiting, setExiting] = createSignal(false);17+ let timeoutId: number;18+19+ onMount(() => {20+ requestAnimationFrame(() => setVisible(true));21+22+ timeoutId = window.setTimeout(() => {23+ handleDeny();24+ }, TIMEOUT_MS);25+ });26+27+ onCleanup(() => {28+ clearTimeout(timeoutId);29+ });30+31+ function handleSignIn() {32+ clearTimeout(timeoutId);33+ exit(props.onSignIn);34+ }35+36+ function handleDeny() {37+ clearTimeout(timeoutId);38+ exit(props.onDeny);39+ }40+41+ function exit(callback: () => void) {42+ setExiting(true);43+ setTimeout(callback, 220);44+ }45+46+ return (47+ <div48+ class={[49+ 'peach-passkey-toast',50+ visible() ? 'is-visible' : '',51+ exiting() ? 'is-exiting' : '',52+ ].join(' ')}53+ >54+ <div class="peach-passkey-toast-site">55+ <div class="peach-passkey-toast-icon">56+ <KeyIcon />57+ </div>58+ <span class="peach-passkey-toast-domain">{props.site}</span>59+ </div>60+61+ <p class="peach-passkey-toast-headline">62+ sign in with passkey?63+ </p>64+65+ <div class="peach-passkey-toast-account">66+ <span class="peach-passkey-toast-display-name">67+ {props.displayName}68+ </span>69+ <span class="peach-passkey-toast-username">70+ {props.username}71+ </span>72+ </div>73+74+ <div class="peach-passkey-toast-countdown">75+ <div76+ class="peach-passkey-toast-countdown-bar"77+ style={{ 'animation-duration': `${TIMEOUT_MS}ms` }}78+ />79+ </div>80+81+ <div class="peach-passkey-toast-actions">82+ <button83+ class="peach-passkey-toast-btn peach-passkey-toast-btn--deny"84+ onClick={handleDeny}85+ >86+ deny87+ </button>88+ <button89+ class="peach-passkey-toast-btn peach-passkey-toast-btn--signin"90+ onClick={handleSignIn}91+ >92+ sign in →93+ </button>94+ </div>95+ </div>96+ );97+}98+99+function KeyIcon() {100+ return (101+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">102+ <circle cx="5.5" cy="7.5" r="3" stroke="currentColor" stroke-width="1.5"/>103+ <path d="M8 7.5h5.5M11 7.5v2" stroke="currentColor"104+ stroke-width="1.5" stroke-linecap="round"/>105+ </svg>106+ );107+}src/content/passkey-toast/inject.ts+98 −0
@@ -0,0 +1,98 @@1+import { render } from 'solid-js/web';2+import { PasskeyToast } from './PasskeyToast';3+// ?inline imports the CSS as a string so we can inject it into the shadow root4+// rather than letting Vite/crx inject it into the page document head.5+import styles from './passkey-toast.css?inline';6+7+let currentDispose: (() => void) | null = null;8+let currentRequestId: string | null = null;9+let toastContainer: HTMLElement | null = null;10+let shadowRoot: ShadowRoot | null = null;11+12+function ensureContainer(): ShadowRoot {13+ if (!toastContainer) {14+ toastContainer = document.createElement('div');15+ toastContainer.id = 'peach-passkey-toast-root';16+ shadowRoot = toastContainer.attachShadow({ mode: 'closed' });17+ document.body.appendChild(toastContainer);18+19+ const style = document.createElement('style');20+ style.textContent = styles;21+ shadowRoot.appendChild(style);22+ }23+ return shadowRoot!;24+}25+26+export function showPasskeyToast(payload: {27+ site: string;28+ displayName: string;29+ username: string;30+ requestId: string;31+}) {32+ // Dismiss any existing toast, actively denying the request it holds.33+ if (currentDispose) {34+ dismissPasskeyToast(currentRequestId ?? undefined);35+ }36+37+ const root = ensureContainer();38+ const wrapper = document.createElement('div');39+ root.appendChild(wrapper);40+41+ let dispose: (() => void) | null = null;42+43+ dispose = render(() => (44+ <PasskeyToast45+ {...payload}46+ onSignIn={() => {47+ chrome.runtime.sendMessage({48+ type: 'PASSKEY_AUTH_RESPONSE',49+ payload: { requestId: payload.requestId, approved: true },50+ });51+ cleanup();52+ }}53+ onDeny={() => {54+ chrome.runtime.sendMessage({55+ type: 'PASSKEY_AUTH_RESPONSE',56+ payload: { requestId: payload.requestId, approved: false },57+ });58+ cleanup();59+ }}60+ />61+ ), wrapper);62+63+ currentDispose = dispose;64+ currentRequestId = payload.requestId;65+66+ function cleanup() {67+ if (dispose) {68+ dispose();69+ dispose = null;70+ }71+ wrapper.remove();72+ currentDispose = null;73+ currentRequestId = null;74+ }75+}76+77+export function dismissPasskeyToast(denyRequestId?: string) {78+ if (denyRequestId) {79+ chrome.runtime.sendMessage({80+ type: 'PASSKEY_AUTH_RESPONSE',81+ payload: { requestId: denyRequestId, approved: false },82+ });83+ }84+ if (currentDispose) {85+ currentDispose();86+ currentDispose = null;87+ currentRequestId = null;88+ }89+}90+91+chrome.runtime.onMessage.addListener((msg) => {92+ if (msg.type === 'SHOW_PASSKEY_TOAST') {93+ showPasskeyToast(msg.payload);94+ }95+ if (msg.type === 'DISMISS_PASSKEY_TOAST') {96+ dismissPasskeyToast();97+ }98+});src/content/passkey-toast/passkey-toast.css+202 −0
@@ -0,0 +1,202 @@1+.peach-passkey-toast {2+ /* Position */3+ position: fixed;4+ top: 16px;5+ right: 16px;6+ z-index: 2147483647;7+8+ /* Size */9+ width: 320px;10+11+ /* Appearance */12+ background: #141210;13+ border: 0.5px solid rgba(255, 255, 255, 0.1);14+ border-radius: 14px;15+ box-shadow:16+ 0 4px 6px rgba(0, 0, 0, 0.3),17+ 0 12px 32px rgba(0, 0, 0, 0.4),18+ 0 0 0 0.5px rgba(0, 0, 0, 0.5);19+20+ /* Layout */21+ display: flex;22+ flex-direction: column;23+ gap: 0;24+ overflow: hidden;25+26+ /* Initial state — hidden before animation */27+ opacity: 0;28+ transform: translateY(-12px) scale(0.96);29+ pointer-events: none;30+31+ /* Font reset — page styles must not bleed in */32+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;33+ font-size: 14px;34+ line-height: 1.4;35+ -webkit-font-smoothing: antialiased;36+ box-sizing: border-box;37+}38+39+.peach-passkey-toast * {40+ box-sizing: border-box;41+ margin: 0;42+ padding: 0;43+}44+45+.peach-passkey-toast.is-visible {46+ animation: peach-passkey-in 280ms cubic-bezier(0, 0, 0, 1) forwards;47+ pointer-events: auto;48+}49+50+.peach-passkey-toast.is-exiting {51+ animation: peach-passkey-out 200ms cubic-bezier(0.3, 0, 1, 1) forwards;52+ pointer-events: none;53+}54+55+@keyframes peach-passkey-in {56+ from {57+ opacity: 0;58+ transform: translateY(-12px) scale(0.96);59+ }60+ to {61+ opacity: 1;62+ transform: translateY(0) scale(1);63+ }64+}65+66+@keyframes peach-passkey-out {67+ from {68+ opacity: 1;69+ transform: translateY(0) scale(1);70+ }71+ to {72+ opacity: 0;73+ transform: translateY(-12px) scale(0.96);74+ }75+}76+77+/* Site row */78+.peach-passkey-toast-site {79+ display: flex;80+ align-items: center;81+ gap: 8px;82+ padding: 16px 16px 12px;83+}84+85+.peach-passkey-toast-icon {86+ width: 28px;87+ height: 28px;88+ background: rgba(201, 137, 58, 0.15);89+ border-radius: 7px;90+ display: flex;91+ align-items: center;92+ justify-content: center;93+ color: #C9893A;94+ flex-shrink: 0;95+}96+97+.peach-passkey-toast-domain {98+ font-size: 13px;99+ color: rgba(255, 255, 255, 0.5);100+ font-family: 'IBM Plex Mono', 'Fira Code', 'Courier New', monospace;101+ letter-spacing: -0.01em;102+}103+104+/* Headline */105+.peach-passkey-toast-headline {106+ font-family: 'Cormorant Garamond', 'Georgia', 'Times New Roman', serif;107+ font-style: italic;108+ font-size: 20px;109+ font-weight: 600;110+ color: rgba(255, 255, 255, 0.92);111+ padding: 0 16px 10px;112+ line-height: 1.2;113+}114+115+/* Account */116+.peach-passkey-toast-account {117+ display: flex;118+ flex-direction: column;119+ gap: 1px;120+ padding: 0 16px 16px;121+}122+123+.peach-passkey-toast-display-name {124+ font-size: 13px;125+ font-weight: 500;126+ color: rgba(255, 255, 255, 0.85);127+}128+129+.peach-passkey-toast-username {130+ font-family: 'IBM Plex Mono', 'Fira Code', 'Courier New', monospace;131+ font-size: 12px;132+ color: rgba(255, 255, 255, 0.4);133+}134+135+/* Countdown bar */136+.peach-passkey-toast-countdown {137+ height: 2px;138+ background: rgba(255, 255, 255, 0.06);139+ margin: 0 16px 14px;140+ border-radius: 1px;141+ overflow: hidden;142+}143+144+.peach-passkey-toast-countdown-bar {145+ height: 100%;146+ background: #C9893A;147+ border-radius: 1px;148+ transform-origin: left;149+ animation: peach-passkey-countdown linear forwards;150+ /* animation-duration set inline to TIMEOUT_MS */151+}152+153+@keyframes peach-passkey-countdown {154+ from { width: 100%; }155+ to { width: 0%; }156+}157+158+/* Actions */159+.peach-passkey-toast-actions {160+ display: flex;161+ gap: 8px;162+ padding: 0 16px 16px;163+}164+165+.peach-passkey-toast-btn {166+ flex: 1;167+ height: 36px;168+ border-radius: 8px;169+ border: none;170+ font-size: 13px;171+ font-weight: 500;172+ cursor: pointer;173+ transition: opacity 120ms ease, transform 120ms ease;174+ font-family: inherit;175+ letter-spacing: -0.01em;176+}177+178+.peach-passkey-toast-btn:active {179+ opacity: 0.75;180+ transform: scale(0.97);181+}182+183+.peach-passkey-toast-btn--deny {184+ background: rgba(255, 255, 255, 0.07);185+ color: rgba(255, 255, 255, 0.6);186+ border: 0.5px solid rgba(255, 255, 255, 0.1);187+}188+189+.peach-passkey-toast-btn--deny:hover {190+ background: rgba(255, 255, 255, 0.1);191+ color: rgba(255, 255, 255, 0.8);192+}193+194+.peach-passkey-toast-btn--signin {195+ background: #C9893A;196+ color: #0C0A06;197+ font-weight: 600;198+}199+200+.peach-passkey-toast-btn--signin:hover {201+ background: #D99A4A;202+}