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

+ +
+
+ + + + + +