From 2ee8ec5aed3c0acb38890177d5a703ff0e24c28f Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 8 Aug 2026 15:41:39 -0400 Subject: [PATCH] feat!: present per-tab state via the native action badge The composited status dot crowded the 16px icon canvas; Chrome's badge layer draws on top of a full-size icon and may overflow the tile corner, like other extensions' count badges. The icon is now always the logo (still drawn with 15% overscan, set once globally per worker start) and state maps to setBadgeText/BackgroundColor/TextColor per tab. Badge mapping: grey dot (checking), color-only green (detected), green ? (signed out / subscription unknown), green check (subscribed), amber ! (warning), red x (none); no badge on non-http pages. The dot-compositing machinery and its per-tab render race guard are gone. Co-Authored-By: Claude Fable 5 --- src/background.ts | 81 ++++++++++------------ src/lib/icon.ts | 171 ++++++++++++---------------------------------- 2 files changed, 79 insertions(+), 173 deletions(-) diff --git a/src/background.ts b/src/background.ts index 336be7e..0eb48d7 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,4 +1,4 @@ -// MV3 service worker: detection state per tab, toolbar icon states, and +// MV3 service worker: detection state per tab, toolbar badge states, and // public reads (publication records, the user's subscription list). OAuth and // PDS writes happen in extension pages (see src/lib/oauth.ts) because the // OAuth client cannot run in a worker; the pages mirror {did, handle} into @@ -6,7 +6,7 @@ import { listRecords, parseAtUri, resolveDid } from './lib/atproto' import { detectPage } from './lib/detection' -import { ICON_SIZES, type IconState, iconStateFor, renderIcon } from './lib/icon' +import { ICON_SIZES, type IconState, badgeFor, iconStateFor, renderBaseIcon } from './lib/icon' import type { Msg, PageState, SessionInfo } from './lib/types' const SUB_COLLECTION = 'site.standard.graph.subscription' @@ -23,57 +23,51 @@ async function setTabState(tabId: number, state: PageState | undefined): Promise const key = `tab:${tabId}` if (state) await chrome.storage.session.set({ [key]: state }) else await chrome.storage.session.remove(key) - await setIcon(tabId, iconStateFor(state)) + await setBadge(tabId, iconStateFor(state)) } -// --- toolbar icon ------------------------------------------------------------- - -/** Composited logo+overlay pixels per state, cached for the worker's lifetime. */ -const iconImages = new Map>>() +// --- toolbar badge ----------------------------------------------------------- +// +// The icon is always the bare logo; per-tab state is shown with Chrome's +// native badge layer, which the toolbar draws on top of the icon at full +// size instead of us cramming a dot into the 16px canvas. /** Last state set per tab; only for transition debug logs. */ -const lastIcon = new Map() - -/** Per-tab render sequence: compositing is async, so a slow earlier render - * must not overwrite a newer state's icon. */ -const iconSeq = new Map() - -const DEFAULT_ICON_PATHS = { 16: 'icons/icon16.png', 32: 'icons/icon32.png' } - -function renderAll(state: IconState): Promise> { - let images = iconImages.get(state) - if (!images) { - images = (async () => { - const rendered: Record = {} - for (const size of ICON_SIZES) rendered[size] = await renderIcon(state, size) - return rendered - })() - images.catch(() => iconImages.delete(state)) // e.g. asset fetch failed; retry later - iconImages.set(state, images) - } - return images -} +const lastBadge = new Map() -async function setIcon(tabId: number, state: IconState): Promise { - const prev = lastIcon.get(tabId) +async function setBadge(tabId: number, state: IconState): Promise { + const prev = lastBadge.get(tabId) if (prev !== state) { - console.debug('[substandard] icon', tabId, `${prev ?? '(unset)'} -> ${state}`) - lastIcon.set(tabId, state) + console.debug('[substandard] badge', tabId, `${prev ?? '(unset)'} -> ${state}`) + lastBadge.set(tabId, state) } - const seq = (iconSeq.get(tabId) ?? 0) + 1 - iconSeq.set(tabId, seq) try { - // 'idle' renders as the bare overscanned logo (no overlay), so the tile - // keeps one size across tabs; the packaged PNGs are the failure fallback. - const images = await renderAll(state).catch(() => undefined) - if (iconSeq.get(tabId) !== seq) return // a newer state won the race - if (images) await chrome.action.setIcon({ tabId, imageData: images }) - else await chrome.action.setIcon({ tabId, path: DEFAULT_ICON_PATHS }) + const badge = badgeFor(state) + if (!badge) { + await chrome.action.setBadgeText({ tabId, text: '' }) + return + } + await chrome.action.setBadgeBackgroundColor({ tabId, color: badge.background }) + await chrome.action.setBadgeTextColor({ tabId, color: badge.color }) + await chrome.action.setBadgeText({ tabId, text: badge.text }) } catch { // tab may be gone } } +// The toolbar icon: the logo with slight overscan so the mark reads larger. +// Set once globally per worker start; the manifest PNGs are the fallback. +async function applyBaseIcon(): Promise { + try { + const imageData: Record = {} + for (const size of ICON_SIZES) imageData[size] = await renderBaseIcon(size) + await chrome.action.setIcon({ imageData }) + } catch (err) { + console.debug('[substandard] base icon render failed; keeping manifest icon', err) + } +} +void applyBaseIcon() + // --- subscriptions ----------------------------------------------------------- interface SubsCache { @@ -117,7 +111,7 @@ async function computeState( hints: { pubHint?: string; docHint?: string }, refreshSubs: boolean, ): Promise { - await setIcon(tabId, 'checking') + await setBadge(tabId, 'checking') let state: PageState try { const { pub, doc } = await detectPage(url, hints.pubHint, hints.docHint) @@ -183,15 +177,14 @@ async function handle(msg: Msg, sender: chrome.runtime.MessageSender): Promise { void chrome.storage.session.remove(`tab:${tabId}`) - lastIcon.delete(tabId) - iconSeq.delete(tabId) + lastBadge.delete(tabId) }) // Clear stale state on plain navigations; the content script re-reports. chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { if (changeInfo.status === 'loading' && changeInfo.url) { void chrome.storage.session.remove(`tab:${tabId}`) - void setIcon(tabId, isHttpUrl(changeInfo.url) ? 'checking' : 'idle') + void setBadge(tabId, isHttpUrl(changeInfo.url) ? 'checking' : 'idle') } }) diff --git a/src/lib/icon.ts b/src/lib/icon.ts index c115962..0e6709b 100644 --- a/src/lib/icon.ts +++ b/src/lib/icon.ts @@ -1,8 +1,9 @@ -// Toolbar icon states. The pure mapping from a tab's PageState to an -// IconState lives here so it can be unit-tested; the actual pixels are -// composited on demand with OffscreenCanvas in the service worker (MV3 has -// no DOM): the substandard logo stays the base icon, and the state is a -// small status dot overlaid in the bottom-right corner. +// Toolbar state presentation. The pure mappings — a tab's PageState to an +// IconState, and an IconState to a native action-badge spec — live here so +// they can be unit-tested. The toolbar icon itself is always the bare +// substandard logo (drawn once with slight overscan so the mark reads +// larger); state is shown via Chrome's badge layer, which the toolbar +// draws on top of the icon and lets overflow the tile corner. import type { PageState } from './types' @@ -35,53 +36,58 @@ export function iconStateFor(state: PageState | undefined): IconState { return state.subscriptionRkey === null ? 'detected' : 'signedout' } -// --- drawing ----------------------------------------------------------------- +// --- badge mapping ----------------------------------------------------------- -const GREEN = '#1a7f37' // matches the old badge green +const GREEN = '#1a7f37' const GREY = '#8b949e' const AMBER = '#d4a72c' const RED = '#cf222e' const INK = '#24292f' const WHITE = '#ffffff' -export const ICON_SIZES = [16, 32] as const - -/** The packaged logo, decoded once per size for the worker's lifetime. */ -const baseBitmaps = new Map>() +export interface BadgeSpec { + text: string + background: string + color: string +} -function baseBitmap(size: number): Promise { - let p = baseBitmaps.get(size) - if (!p) { - p = fetch(chrome.runtime.getURL(`icons/icon${size}.png`)) - .then((res) => res.blob()) - .then((blob) => createImageBitmap(blob)) - p.catch(() => baseBitmaps.delete(size)) // retry on next render - baseBitmaps.set(size, p) +/** + * Native badge for a state; null clears the badge (idle/non-http pages). + * Glyphs are plain chars that stay crisp at badge size — "✗" renders + * smudged there, so `none` uses a lowercase "x"; `detected` is a + * color-only green badge (a bare space renders as a compact dot). + */ +export function badgeFor(state: IconState): BadgeSpec | null { + switch (state) { + case 'idle': + return null + case 'checking': + return { text: '•', background: GREY, color: WHITE } + case 'none': + return { text: 'x', background: RED, color: WHITE } + case 'warning': + return { text: '!', background: AMBER, color: INK } + case 'detected': + return { text: ' ', background: GREEN, color: WHITE } + case 'signedout': + return { text: '?', background: GREEN, color: WHITE } + case 'subscribed': + return { text: '✓', background: GREEN, color: WHITE } } - return p } -// Overlay geometry in 16px space (scaled up for larger sizes): a status dot -// hugging the bottom-right corner, ~57% of the canvas, with a white backing -// ring so it separates from the dark logo tile. The backing ring may crop -// slightly at the canvas edge (conventional badge placement); the symbol -// inside stays fully visible. -const CX = 12.1 -const CY = 12.1 -const BACKING_R = 5.0 -const DOT_R = 4.1 +// --- base icon --------------------------------------------------------------- + +export const ICON_SIZES = [16, 32] as const // The logo tile is drawn with slight overscan so the mark reads larger in // the toolbar; only the rounded-corner curvature is cropped. const BASE_OVERSCAN = 1.15 -/** - * Composite one icon state at one size: the overscanned logo, plus the - * state's status dot bottom-right. 'idle' is the bare overscanned logo, so - * the tile keeps one size across all tabs. - */ -export async function renderIcon(state: IconState, size: number): Promise { - const base = await baseBitmap(size) +/** Render the overscanned logo at one size (the toolbar icon for every tab). */ +export async function renderBaseIcon(size: number): Promise { + const res = await fetch(chrome.runtime.getURL(`icons/icon${size}.png`)) + const base = await createImageBitmap(await res.blob()) const canvas = new OffscreenCanvas(size, size) const ctx = canvas.getContext('2d') if (!ctx) throw new Error('no 2d context') @@ -89,98 +95,5 @@ export async function renderIcon(state: IconState, size: number): Promise { - ctx.fillStyle = color - ctx.beginPath() - ctx.arc(CX, CY, DOT_R, 0, Math.PI * 2) - ctx.fill() - } - - switch (state) { - case 'checking': { - // Grey dot with an open refresh arc (static; MV3 workers cannot - // cheaply animate a spinner). - dot(GREY) - ctx.strokeStyle = WHITE - ctx.lineWidth = 1.2 - ctx.beginPath() - ctx.arc(CX, CY, 2.2, -Math.PI * 0.35, Math.PI * 1.15) - ctx.stroke() - ctx.fillStyle = WHITE - const ax = CX + 2.2 * Math.cos(-Math.PI * 0.35) - const ay = CY + 2.2 * Math.sin(-Math.PI * 0.35) - ctx.beginPath() - ctx.moveTo(ax - 1.3, ay - 0.8) - ctx.lineTo(ax + 1.2, ay - 0.3) - ctx.lineTo(ax - 0.5, ay + 1.4) - ctx.closePath() - ctx.fill() - break - } - case 'none': { - dot(RED) - ctx.strokeStyle = WHITE - ctx.lineWidth = 1.4 - ctx.beginPath() - ctx.moveTo(CX - 1.7, CY - 1.7) - ctx.lineTo(CX + 1.7, CY + 1.7) - ctx.moveTo(CX + 1.7, CY - 1.7) - ctx.lineTo(CX - 1.7, CY + 1.7) - ctx.stroke() - break - } - case 'warning': { - dot(AMBER) - ctx.strokeStyle = INK - ctx.lineWidth = 1.3 - ctx.beginPath() - ctx.moveTo(CX, CY - 2.2) - ctx.lineTo(CX, CY + 0.5) - ctx.stroke() - ctx.fillStyle = INK - ctx.beginPath() - ctx.arc(CX, CY + 2.3, 0.7, 0, Math.PI * 2) - ctx.fill() - break - } - case 'detected': { - dot(GREEN) - break - } - case 'signedout': { - // Hollow green ring on the white backing: on standard.site, but - // subscription state unknown. - ctx.strokeStyle = GREEN - ctx.lineWidth = 1.8 - ctx.beginPath() - ctx.arc(CX, CY, 2.9, 0, Math.PI * 2) - ctx.stroke() - break - } - case 'subscribed': { - dot(GREEN) - ctx.strokeStyle = WHITE - ctx.lineWidth = 1.4 - ctx.beginPath() - ctx.moveTo(CX - 2.0, CY + 0.2) - ctx.lineTo(CX - 0.6, CY + 1.7) - ctx.lineTo(CX + 2.1, CY - 1.6) - ctx.stroke() - break - } - } return ctx.getImageData(0, 0, size, size) } -- 2.51.2