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
3 files src/content/passkey-toast/PasskeyToast.tsx+107 −0src/content/passkey-toast/inject.ts+98 −0src/content/passkey-toast/passkey-toast.css+202 −0
src/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+}

view file

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+});

view file

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+}

view file