adc8a19b4e39

extend globe-detection to entry view domain row — DomainFavicon component, shared titleInitials/titleBackground, fix contracts mock

time
2026-05-31 01:50 UTC · 4 months ago
machine
koh2
agent
claude-sonnet-4-6
session
placeholder favicon initials
parent
b0f705a676ed
4 files src/popup/views/Entry.tsx+45 −17src/popup/views/Vault.tsx+1 −31src/popup/views/contracts.test.tsx+4 −3src/shared/favicon.ts+29 −0
src/popup/views/Entry.tsx+45 −17
@@ -3,7 +3,7 @@33 import { isPasskeyOnly } from "../../shared/types";44 import { detectBrowser } from "../../shared/browser";55 import { getDisplayDomain } from "../../shared/domain";6-import { faviconUrl } from "../../shared/favicon";6+import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } from "../../shared/favicon";77 import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";88 import { sendMessage, triggerPipPulse, triggerRing } from "../App";99 import { popupCopy } from "../copy";@@ -34,6 +34,49 @@3434   onProGate?: (feature: ProFeature) => void;3535 }3636 37+// Inline 16×16 favicon for the domain row — shows coloured initials until a38+// real favicon is confirmed (same globe-detection logic as the vault list).39+function DomainFavicon(props: { domain: string; title: string }) {40+  const src = props.domain ? faviconUrl(props.domain) : null;41+  const [isReal, setIsReal] = createSignal(false);42+  const label = () => titleInitials(props.title || props.domain);43+  const bg = () => titleBackground(props.title || props.domain);44+  const chipStyle = () =>45+    `display:inline-flex;align-items:center;justify-content:center;` +46+    `width:16px;height:16px;border-radius:3px;background:${bg()};` +47+    `font-family:"IBM Plex Mono",monospace;font-size:0.4375rem;font-weight:600;` +48+    `letter-spacing:0.04em;color:rgba(240,232,216,0.92);flex-shrink:0;margin-right:5px;`;49+  return (50+    <Show51+      when={isReal()}52+      fallback={53+        <>54+          <span style={chipStyle()} aria-hidden="true">{label()}</span>55+          {src && (56+            <img57+              src={src}58+              style="display:none"59+              onError={() => {}}60+              onLoad={async (e) => {61+                const img = e.currentTarget as HTMLImageElement;62+                if (img.naturalWidth <= 16) return;63+                const sig = faviconImgSig(img);64+                if (sig !== null) {65+                  const gSig = await globeFaviconSig();66+                  if (gSig !== null && sig === gSig) return;67+                }68+                setIsReal(true);69+              }}70+            />71+          )}72+        </>73+      }74+    >75+      <img src={src!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />76+    </Show>77+  );78+}79+3780 export function EntryView(props: EntryProps) {3881   const [editingField, setEditingField] = createSignal<EditableField>(null);3982   const [draft, setDraft] = createSignal("");@@ -52,7 +95,6 @@5295   const [totpTick, setTotpTick] = createSignal(0);5396   const [confirmDelete, setConfirmDelete] = createSignal(false);5497   const [totpHot, setTotpHot] = createSignal(false);55-  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);5698   const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);5799   // Auto-open advanced when entry already has passkey or canary configured58100   const [showAdvanced, setShowAdvanced] = createSignal(false);@@ -132,11 +174,6 @@132174     props.entry?.url ? getDisplayDomain(props.entry.url) : ""133175   );134176 135-  createEffect(() => {136-    const domain = displayDomain();137-    setFaviconSrc(domain ? faviconUrl(domain) : null);138-  });139-140177   // Reset transient prompts (trash confirmation, inline edits, advanced panel)141178   // when the selected entry changes so they don't carry over between entries.142179   // Track the last id so a same-entry refresh (e.g. after saving a field)@@ -637,16 +674,7 @@637674                         title="Click to edit URL"638675                         onClick={() => startEdit("url", entry().url ?? "")}639676                       >640-                        <Show when={faviconSrc()}>641-                          <img642-                            src={faviconSrc()!}643-                            width="16"644-                            height="16"645-                            alt=""646-                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"647-                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}648-                          />649-                        </Show>677+                        <DomainFavicon domain={displayDomain()} title={entry().title ?? ""} />650678                         <span class="peach-domain-value">651679                           {displayDomain() || (entry().url ? entry().url : "add a website url")}652680                         </span>

view file

src/popup/views/Vault.tsx+1 −31
@@ -1,7 +1,7 @@11 import { createEffect, createMemo, createSignal, For, Show } from "solid-js";22 import type { VaultEntry } from "../../shared/types";33 import { extractDomain, getDisplayDomain } from "../../shared/domain";4-import { faviconUrl, faviconImgSig, globeFaviconSig } from "../../shared/favicon";4+import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } from "../../shared/favicon";55 import { popupCopy } from "../copy";66 77 function vibrate(ms: number) {@@ -56,36 +56,6 @@5656   );5757 }5858 59-/**60- * Title → "PM"-style initials. First letters of the first two words,61- * falling back to the first two characters of a single-word title. Empty62- * titles get a small dot. Deterministic so the same entry always renders63- * the same chip.64- */65-function titleInitials(source: string): string {66-  const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();67-  if (!cleaned) return "·";68-  const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);69-  if (words.length >= 2 && words[0]![0] && words[1]![0]) {70-    return (words[0]![0]! + words[1]![0]!).toUpperCase();71-  }72-  const single = words[0] ?? cleaned;73-  return single.slice(0, 2).toUpperCase();74-}75-76-/**77- * Hash a string to an HSL background. Fixed saturation + lightness so the78- * palette stays muted and dark enough that white initials read clearly on79- * top, while still giving each entry a recognisable colour signature.80- */81-function titleBackground(source: string): string {82-  let hash = 0;83-  for (let i = 0; i < source.length; i++) {84-    hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;85-  }86-  const hue = ((hash % 360) + 360) % 360;87-  return `hsl(${hue}, 32%, 26%)`;88-}8959 9060 function InitialsChip(props: { title: string }) {9161   return (

view file

src/popup/views/contracts.test.tsx+4 −3
@@ -30,9 +30,10 @@3030   parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),3131 }));3232 33-vi.mock("../../shared/favicon", () => ({34-  faviconUrl: () => null,35-}));33+vi.mock("../../shared/favicon", async (importOriginal) => {34+  const actual = await importOriginal<typeof import("../../shared/favicon")>();35+  return { ...actual, faviconUrl: () => null };36+});3637 3738 vi.mock("../../crypto/totp", () => ({3839   generateTOTP: () => ({ code: "123 456", remaining: 24 }),

view file

src/shared/favicon.ts+29 −0
@@ -22,6 +22,35 @@2222   }2323 }2424 25+/**26+ * Derives 2-letter initials from a domain or title string. Used as the27+ * favicon fallback label when no real favicon is available.28+ */29+export function titleInitials(source: string): string {30+  const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();31+  if (!cleaned) return "·";32+  const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);33+  if (words.length >= 2 && words[0]![0] && words[1]![0]) {34+    return (words[0]![0]! + words[1]![0]!).toUpperCase();35+  }36+  const single = words[0] ?? cleaned;37+  return single.slice(0, 2).toUpperCase();38+}39+40+/**41+ * Hashes a string to a deterministic HSL background colour for an initials42+ * chip. Fixed saturation + lightness keeps the palette muted and dark so43+ * light initials read clearly on top.44+ */45+export function titleBackground(source: string): string {46+  let hash = 0;47+  for (let i = 0; i < source.length; i++) {48+    hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;49+  }50+  const hue = ((hash % 360) + 360) % 360;51+  return `hsl(${hue}, 32%, 26%)`;52+}53+2554 // Renders an img element to an 8×8 canvas and returns its RGB pixels as a2655 // compact string. Used to fingerprint what Chrome returns for "no favicon".2756 function imgSig(img: HTMLImageElement): string {

view file