From b120df215f631edb751d2b243538ddb06e9289c0 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 17 Aug 2026 11:21:57 -0400 Subject: [PATCH] feat(welcome): open a pin-the-icon page on first install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome hides a new extension's icon behind the puzzle piece, and the badge — the passive half of what this extension does — is invisible until somebody pins it. No manifest field or API can ask for a pin, only chrome.action.getUserSettings() reads whether it happened, so the worker opens a bundled welcome.html on onInstalled (install only, never an update), dressed as substandard.blog, with the extensions menu drawn and that read polled so the line under the picture answers by itself. The picture is a drawing rather than a capture because browser UI never renders into a page screenshot, and every visible word on the page is a placeholder: this repo does not write the brand's prose. deploy-ext.sh refuses to package a build that still carries the SNICKERSNEE token, since nothing else about the page would fail with it in. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/deploy-ext.sh | 11 +- scripts/deploy-ext.test.mjs | 10 +- scripts/verify-dist.mjs | 1 + src/background.ts | 19 ++ src/lib/welcome.test.ts | 47 ++++ src/lib/welcome.ts | 36 ++++ src/vite-env.d.ts | 7 + src/welcome/welcome.css | 412 ++++++++++++++++++++++++++++++++++++ src/welcome/welcome.ts | 54 +++++ vite.config.ts | 5 +- welcome.html | 191 +++++++++++++++++ 11 files changed, 789 insertions(+), 4 deletions(-) create mode 100644 src/lib/welcome.test.ts create mode 100644 src/lib/welcome.ts create mode 100644 src/vite-env.d.ts create mode 100644 src/welcome/welcome.css create mode 100644 src/welcome/welcome.ts create mode 100644 welcome.html diff --git a/scripts/deploy-ext.sh b/scripts/deploy-ext.sh index e3083ee..edf07d2 100755 --- a/scripts/deploy-ext.sh +++ b/scripts/deploy-ext.sh @@ -76,6 +76,15 @@ rm -rf dist export SUBSTANDARD_CHANNEL=release npm run build npm run verify:dist +# The welcome page (welcome.html) carries placeholder copy until a human +# writes the words it says — this repo does not write the brand's prose (see +# CLAUDE.md). Nothing else would stop that token reaching store users, since +# every build, test and load of the page works perfectly well with it in. +placeholders="$(grep -rl SNICKERSNEE dist || true)" +if [[ -n "$placeholders" ]]; then + echo "$placeholders" >&2 + die "dist/ still carries placeholder copy — write the welcome page's words before releasing" +fi # The artifact itself, not the sources it should have come from: a rebuild # from a stale tree passes every check above and still ships the wrong # redirect URIs (this is what happened to v1.2.1). @@ -138,7 +147,7 @@ PY # slowly, and this extension's all-hosts grant already earns an in-depth # review. Losing them silently would give that back, so require the ones a # reviewer needs — every entry point we wrote. -for js in background.js popup.js content.js offscreen.js; do +for js in background.js popup.js content.js offscreen.js welcome.js; do grep -qx "$js.map" <<<"$entries" || die "$zipfile has no $js.map — reviewers would be reading minified output" done diff --git a/scripts/deploy-ext.test.mjs b/scripts/deploy-ext.test.mjs index 8a1972f..5dcbc5d 100644 --- a/scripts/deploy-ext.test.mjs +++ b/scripts/deploy-ext.test.mjs @@ -62,7 +62,7 @@ describe('deploy-ext.sh preflight', () => { // silently would give that back. it('requires the sourcemaps a reviewer reads, rather than banning them', () => { expect(source).not.toMatch(/contains sourcemaps/) - for (const js of ['background.js', 'popup.js', 'content.js', 'offscreen.js']) { + for (const js of ['background.js', 'popup.js', 'content.js', 'offscreen.js', 'welcome.js']) { expect(source).toContain(js) } expect(source).toMatch(/grep -qx "\$js\.map"/) @@ -71,6 +71,14 @@ describe('deploy-ext.sh preflight', () => { it('builds after the install', () => { expect(at('\nnpm ci')).toBeLessThan(at('\nnpm run build')) }) + + // The welcome page's copy is placeholder until a human writes it, and every + // other check in this script passes with the token still in — so this is + // the only thing standing between SNICKERSNEE and the store listing. + it('refuses a build whose welcome copy is still the placeholder', () => { + expect(source).toMatch(/grep -rl SNICKERSNEE dist/) + expect(at('grep -rl SNICKERSNEE dist')).toBeLessThan(at('zipfile=')) + }) }) describe('deploy-ext.sh link()', () => { diff --git a/scripts/verify-dist.mjs b/scripts/verify-dist.mjs index a2505e9..459d56e 100644 --- a/scripts/verify-dist.mjs +++ b/scripts/verify-dist.mjs @@ -58,6 +58,7 @@ for (const war of manifest.web_accessible_resources ?? []) { // Pages created at runtime rather than referenced from the manifest. requireFile('offscreen.html', 'created by the worker via chrome.offscreen') +requireFile('welcome.html', 'opened by the worker on install (src/lib/welcome.ts)') // Vendored assets referenced from built JS as extension-root "/vendored/..." // string literals (reader icons in src/lib/readers.ts). diff --git a/src/background.ts b/src/background.ts index e751939..0d8bdd6 100644 --- a/src/background.ts +++ b/src/background.ts @@ -9,6 +9,7 @@ import { cached } from './lib/cache' import { detectPage } from './lib/detection' import { type IconState, badgeFor, iconStateFor, titleFor } from './lib/icon' import { FOLLOWS_CAP, type FollowList, followingDids } from './lib/subscribers' +import { welcomeUrl } from './lib/welcome' import { startSignIn } from './signin' import type { FollowSet, Msg, PageState, SessionInfo } from './lib/types' @@ -290,6 +291,24 @@ async function handle(msg: Msg, sender: chrome.runtime.MessageSender): Promise { + if (reason !== chrome.runtime.OnInstalledReason.INSTALL) return + console.debug('[substandard] first install; opening the welcome page') + void chrome.tabs.create({ url: welcomeUrl() }) +}) + chrome.tabs.onRemoved.addListener((tabId) => { void chrome.storage.session.remove(`tab:${tabId}`) lastBadge.delete(tabId) diff --git a/src/lib/welcome.test.ts b/src/lib/welcome.test.ts new file mode 100644 index 0000000..838978b --- /dev/null +++ b/src/lib/welcome.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WELCOME_PAGE, pinState, welcomeUrl } from './welcome' + +const getUserSettings = vi.fn() + +beforeEach(() => { + vi.stubGlobal('chrome', { + action: { getUserSettings }, + runtime: { getURL: (path: string) => `chrome-extension://testid/${path}` }, + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('welcomeUrl', () => { + it('resolves the bundled page against the extension origin', () => { + expect(welcomeUrl()).toBe(`chrome-extension://testid/${WELCOME_PAGE}`) + }) +}) + +describe('pinState', () => { + it('reports a pinned toolbar icon', async () => { + getUserSettings.mockResolvedValue({ isOnToolbar: true }) + await expect(pinState()).resolves.toBe('pinned') + }) + + it('reports an unpinned one', async () => { + getUserSettings.mockResolvedValue({ isOnToolbar: false }) + await expect(pinState()).resolves.toBe('unpinned') + }) + + // Both failure shapes answer 'unknown', which the page renders as no claim + // either way — never as "you have not pinned it". + it('is unknown when the read fails', async () => { + vi.spyOn(console, 'debug').mockImplementation(() => {}) + getUserSettings.mockRejectedValue(new Error('nope')) + await expect(pinState()).resolves.toBe('unknown') + }) + + it('is unknown when the browser has no such API', async () => { + vi.stubGlobal('chrome', { action: {} }) + await expect(pinState()).resolves.toBe('unknown') + }) +}) diff --git a/src/lib/welcome.ts b/src/lib/welcome.ts new file mode 100644 index 0000000..4d221a8 --- /dev/null +++ b/src/lib/welcome.ts @@ -0,0 +1,36 @@ +// The welcome page's address and the one question it exists to ask. +// +// Chrome offers no manifest field and no API to request a pin — only +// `chrome.action.getUserSettings()` to read whether the icon is on the +// toolbar, which needs no extra permission. So the page asks the user in +// words, and polls the read to notice when they have done it. + +/** The bundled page, opened on first install and from the dev debug menu. */ +export const WELCOME_PAGE = 'welcome.html' + +export function welcomeUrl(): string { + return chrome.runtime.getURL(WELCOME_PAGE) +} + +/** How often the open welcome page re-asks whether the icon is pinned. */ +export const PIN_POLL_MS = 1_000 + +/** + * `unknown` is a real answer, not an error case: the read is advisory, and a + * page that cannot get one says nothing rather than telling somebody who has + * already pinned the icon to pin it. + */ +export type PinState = 'pinned' | 'unpinned' | 'unknown' + +export async function pinState(): Promise { + // Chrome 91+, so every browser above this extension's floor of 110 has it — + // but a missing API here should cost the page nothing. + if (typeof chrome.action?.getUserSettings !== 'function') return 'unknown' + try { + const settings = await chrome.action.getUserSettings() + return settings.isOnToolbar ? 'pinned' : 'unpinned' + } catch (err) { + console.debug('[substandard] could not read the toolbar pin state', err) + return 'unknown' + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..06e9921 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,7 @@ +// Vite's `?raw` imports, typed here rather than by adding `vite/client` to the +// tsconfig's `types`: the extension needs exactly one of them — the generated +// wordmark, inlined into the welcome page so the theme can retint it. +declare module '*.svg?raw' { + const contents: string + export default contents +} diff --git a/src/welcome/welcome.css b/src/welcome/welcome.css new file mode 100644 index 0000000..37c5548 --- /dev/null +++ b/src/welcome/welcome.css @@ -0,0 +1,412 @@ +/* The first-run page, dressed as substandard.blog: paper, ink, and a red pen + that disagrees. The tokens and the tilted-card treatment are lifted from + web/src/styles/global.css — the two cannot share a file (one is bundled + into the extension, the other into an Astro site), so this is a copy, and + a change to the brand's colors belongs in both. */ + +:root { + color-scheme: light dark; + --bg: #f4f3ee; + --card: #fdfcf9; + --fg: #26282b; + --muted: #6d6a62; + --line: #dedcd3; + --red: #d9482b; + --gold: #e8b52a; + --gold-hl: rgba(232, 181, 42, 0.35); + --shadow: #dedcd3; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1c1d20; + --card: #26272b; + --fg: #e9e7e0; + --muted: #96938a; + --line: #3a3b40; + --red: #e2593a; + --gold: #d9a826; + --gold-hl: rgba(217, 168, 38, 0.28); + --shadow: #131417; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + line-height: 1.6; + background: var(--bg); + color: var(--fg); +} + +::selection { + background: var(--gold-hl); +} + +a { + color: var(--fg); + text-decoration: underline; + text-decoration-style: wavy; + text-decoration-color: var(--red); + text-decoration-thickness: 1.5px; + text-underline-offset: 3px; +} + +/* --- hero: the site's lockup, Subby pre-tilted beside the wordmark --------- */ + +.hero { + max-width: 44rem; + margin: 0 auto; + padding: 3.5rem 1.5rem 2.5rem; + text-align: center; +} + +.hero-mark { + display: flex; + align-items: center; + justify-content: center; + gap: 1.25rem; +} + +.hero-icon { + width: 5.5rem; + height: 5.5rem; + transform: rotate(2deg); + transition: transform 0.25s ease; +} + +.hero-icon:hover { + transform: rotate(0deg); +} + +.hero-wordmark { + flex: 0 1 17rem; + min-width: 0; +} + +.hero-wordmark svg { + display: block; + width: 100%; + height: auto; +} + +/* The generated wordmark bakes in its ink; the theme takes it back. */ +.hero-wordmark text { + fill: var(--fg); +} + +.hero-wordmark path { + stroke: var(--red); +} + +/* Looser than the site's own h1 (1.15): this headline is a full sentence and + wraps, and at that line height the wavy underline of the second line lands + on the descenders of the first. */ +.hero h1 { + margin: 1.25rem 0 0.75rem; + font-size: 2.5rem; + line-height: 1.35; + letter-spacing: -0.01em; + transform: rotate(-1deg); + text-decoration: underline; + text-decoration-style: wavy; + text-decoration-color: var(--red); + text-decoration-thickness: 3px; + text-underline-offset: 10px; +} + +.hero-sub { + margin: 0.75rem 0 0; + color: var(--muted); +} + +/* --- cards ---------------------------------------------------------------- */ + +main { + max-width: 44rem; + margin: 0 auto; + padding: 0 1.5rem 2rem; +} + +/* Laid out by eye: each card tilts its own way and none of the shadows agree + on where the light is. */ +main section { + background: var(--card); + border: 2px solid var(--line); + border-radius: 0.75rem; + padding: 1.5rem; + margin-bottom: 1.75rem; +} + +main section:nth-of-type(odd) { + transform: rotate(-0.6deg); + box-shadow: 5px 4px 0 var(--shadow); +} + +main section:nth-of-type(even) { + transform: rotate(0.45deg); + box-shadow: -4px 5px 0 var(--shadow); +} + +main h2 { + display: inline-block; + margin: 0 0 0.5rem; + font-size: 1.5rem; + line-height: 1.3; + padding: 0 0.35rem; + background: linear-gradient( + 100deg, + transparent 0.5%, + var(--gold-hl) 3%, + var(--gold-hl) 98%, + transparent 99.5% + ); + transform: rotate(-0.4deg); +} + +main p { + margin: 0.5rem 0 0; + color: var(--muted); +} + +main ul { + margin: 0.5rem 0 0; + padding-left: 1.5rem; + color: var(--muted); +} + +main li::marker { + color: var(--red); +} + +/* The two clicks, numbered to match the red circles in the picture. */ +.steps { + margin: 1rem 0 0; + padding-left: 1.6rem; + color: var(--muted); +} + +.steps li { + margin-top: 0.5rem; +} + +.steps li::marker { + color: var(--red); + font-weight: 700; +} + +/* --- the picture ---------------------------------------------------------- */ + +/* Framed the way the site frames its screenshots: hard border, solid misprint + shadow, a lean that straightens when you look closer. */ +.pin-figure { + margin: 1.5rem 0 0; +} + +.pin-art { + display: block; + width: 100%; + height: auto; + border: 2px solid var(--fg); + border-radius: 0.4rem; + background: var(--card); + box-shadow: 5px 5px 0 var(--shadow); + transform: rotate(-0.8deg); + transition: transform 0.25s ease; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +.pin-art:hover { + transform: rotate(0deg); +} + +.pin-figure figcaption { + margin: 0.9rem 0 0; + color: var(--muted); + font-size: 0.85rem; + transform: rotate(-0.3deg); +} + +/* The drawing's own palette, so it follows the theme rather than baking one + in — same arrangement as the site's architecture diagram. */ +.w-page { + fill: var(--card); + stroke: var(--line); + stroke-width: 2; +} + +.w-bar { + fill: var(--bg); +} + +.w-rule { + fill: none; + stroke: var(--line); + stroke-width: 2; +} + +.w-glyph { + fill: none; + stroke: var(--muted); + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.w-omni { + fill: var(--card); + stroke: var(--line); + stroke-width: 1.5; +} + +.w-chip { + fill: var(--muted); + opacity: 0.35; +} + +.w-chip-faint { + fill: var(--muted); + opacity: 0.18; +} + +.w-dot { + fill: var(--muted); +} + +/* The puzzle piece is drawn as one filled shape plus a bite taken out of it, + so the bite has to be painted in whatever is behind the toolbar. */ +.w-ink { + color: var(--fg); +} + +.w-notch { + fill: var(--bg); +} + +.w-panel { + fill: var(--card); + stroke: var(--line); + stroke-width: 2; +} + +.w-panel-shadow { + fill: var(--shadow); +} + +.w-title { + fill: var(--fg); + font-size: 14px; + font-weight: 650; +} + +.w-name { + fill: var(--fg); + font-size: 13.5px; +} + +/* The pin the reader is being sent to click, and the ones they are not. */ +.w-pin { + color: var(--fg); +} + +.w-pin-off { + color: var(--muted); + opacity: 0.55; +} + +.w-hi { + fill: var(--gold-hl); +} + +.pen { + fill: none; + stroke: var(--red); + stroke-width: 2.5; + stroke-linecap: round; +} + +.pen-dash { + fill: none; + stroke: var(--red); + stroke-width: 2; + stroke-dasharray: 5 5; + stroke-linecap: round; +} + +.pen-badge { + fill: var(--red); +} + +.pen-num { + fill: var(--card); + font-size: 13px; + font-weight: 700; + text-anchor: middle; +} + +/* --- the live answer ------------------------------------------------------ */ + +/* Unpinned is a note, still asking; pinned is the gold sticker, slapped on + almost straight. Hidden until the first read lands, and absent entirely + when the browser will not answer. */ +.pin-state { + margin: 1.25rem 0 0; + padding: 0.75rem 1rem; + border: 2px dashed var(--line); + border-radius: 0.75rem; + color: var(--muted); + transform: rotate(0.5deg); +} + +.pin-state.is-pinned { + border: 3px solid var(--fg); + border-style: solid; + background: var(--gold); + color: #26282b; + font-weight: 650; + transform: rotate(-0.8deg); + box-shadow: 4px 5px 0 var(--shadow); +} + +/* --- footer: the mark's own squiggle, then the small print ---------------- */ + +footer { + max-width: 44rem; + margin: 0 auto; + padding: 1.5rem 1.5rem 2.5rem; + color: var(--muted); + font-size: 0.9rem; + text-align: center; +} + +footer p { + margin: 0.35rem 0 0; +} + +footer::before { + content: ""; + display: block; + width: 108px; + height: 34px; + margin: 0 auto 1.25rem; + background-color: var(--red); + mask: url('data:image/svg+xml;utf8,') + center / contain no-repeat; +} + +/* Filled from the manifest in welcome.ts; empty on a page opened outside the + extension, where it should take no space. */ +.version:empty { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + .hero-icon, + .pin-art { + transition: none; + } +} diff --git a/src/welcome/welcome.ts b/src/welcome/welcome.ts new file mode 100644 index 0000000..60e17f3 --- /dev/null +++ b/src/welcome/welcome.ts @@ -0,0 +1,54 @@ +// The page Chrome opens once, on install (src/background.ts), and that the dev +// build's debug menu can open again on demand. Its whole job is the toolbar +// pin: the badge is the passive half of what this extension does, and Chrome +// keeps a new extension's icon behind the puzzle piece until somebody pins it. + +// The generated wordmark, inlined so the theme takes back its baked-in ink — +// the same trick the site's hero plays (web/src/pages/index.astro). +import wordmark from '../../brand/wordmark.svg?raw' +import { PIN_POLL_MS, type PinState, pinState } from '../lib/welcome' + +/** Elements are declared in welcome.html; a missing id is a packaging bug. */ +const $ = (id: string) => document.getElementById(id) as T + +// Our own build output, not remote markup: the file is bundled from brand/. +$('wordmark').innerHTML = wordmark + +// Read from the manifest rather than baked in at build time, so it is the +// version Chrome actually installed — same as the popup header, down to +// preferring the dev channel's version_name ("1.4.0+abc1234") when there is +// one. +const manifest = chrome.runtime.getManifest() +$('version').textContent = `v${manifest.version_name ?? manifest.version}` + +/** Placeholder copy, like the rest of the page (grep SNICKERSNEE). */ +const PIN_LINES: Record, string> = { + unpinned: 'SNICKERSNEE — not pinned yet: this line notices by itself when you do it.', + pinned: 'SNICKERSNEE — pinned. Say something nice here.', +} + +async function renderPinState(): Promise { + const state = await pinState() + const line = $('pin-state') + // A browser that will not answer gets no line at all: better to say nothing + // than to tell somebody who has already pinned the icon to go and pin it. + if (state === 'unknown') { + line.hidden = true + return + } + line.textContent = PIN_LINES[state] + line.classList.toggle('is-pinned', state === 'pinned') + line.hidden = false +} + +// Chrome fires no event when an icon is pinned, so asking again is the only +// way this line can change under the reader while they follow the steps. The +// poll is skipped while the tab is in the background, where nobody is looking +// — and re-run the moment it comes back, so a tab returned to is never stale. +void renderPinState() +setInterval(() => { + if (document.visibilityState === 'visible') void renderPinState() +}, PIN_POLL_MS) +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') void renderPinState() +}) diff --git a/vite.config.ts b/vite.config.ts index 91d435b..5093719 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -73,8 +73,8 @@ function ownCodeSourcemapsOnly(): Plugin { // One build, two environments (Vite 6 builder API — `vite build` builds both // in order): // -// - `client`: the popup/offscreen pages and the background service worker, as -// ES modules. Empties dist/ and copies public/ (manifest, icons). +// - `client`: the popup/offscreen/welcome pages and the background service +// worker, as ES modules. Empties dist/ and copies public/ (manifest, icons). // - `content`: the content script as a single IIFE file, because MV3 content // scripts cannot be ES modules. Builds second, into the same dist/. // @@ -97,6 +97,7 @@ export default defineConfig({ input: { popup: resolve(__dirname, 'popup.html'), offscreen: resolve(__dirname, 'offscreen.html'), + welcome: resolve(__dirname, 'welcome.html'), background: resolve(__dirname, 'src/background.ts'), }, output: { diff --git a/welcome.html b/welcome.html new file mode 100644 index 0000000..f903d6e --- /dev/null +++ b/welcome.html @@ -0,0 +1,191 @@ + + + + + substandard + + + + +
+
+ + + +
+

SNICKERSNEE — headline: thanks for installing

+

+ SNICKERSNEE — one line: what the extension does, and why the next ten + seconds are worth spending. +

+
+ +
+
+

SNICKERSNEE — heading: pin the toolbar icon

+

+ SNICKERSNEE — a sentence: the badge does its work in the toolbar, and + Chrome hides new extensions behind the puzzle piece until you pin + them. +

+
    +
  1. + SNICKERSNEE — step one: click the puzzle piece at the right of the + Chrome toolbar. +
  2. +
  3. + SNICKERSNEE — step two: click the pin beside substandard, so the + icon stays out where you can see it. +
  4. +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Extensions + + + + substandard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 2 + +
SNICKERSNEE — caption: the two clicks, in the toolbar.
+
+ + + +
+ +
+

SNICKERSNEE — heading: what happens next

+

+ SNICKERSNEE — a sentence or two: visit a blog, watch the badge, sign + in when you want to subscribe. +

+ +
+
+ + + + + + -- 2.51.2