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
↓ peach-browser-adc8a19b.tar.gzatlas at this save
files changed4 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 −0src/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>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 (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 }),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 {