diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e24177e..266e461 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -33,6 +33,7 @@ jobs: --exclude='.github' \ --exclude='.gitignore' \ --exclude='*.md' \ + --exclude='tools' \ ./ /tmp/site-build/ # Check out gh-pages branch diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 5d9074b..0c8558a 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -40,6 +40,7 @@ jobs: --exclude='.github' \ --exclude='.gitignore' \ --exclude='*.md' \ + --exclude='tools' \ --exclude='CNAME' \ ./ /tmp/pr-build/ diff --git a/.gitignore b/.gitignore index 423281d..2589b60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ **/.claude/settings.local.json **/.playwright-mcp/* + +# Fonts and scratch files fetched by tools/og/build.mjs +tools/og/.fonts/ +tools/og/.build/ diff --git a/index.html b/index.html index 2b53e75..8d3ca05 100644 --- a/index.html +++ b/index.html @@ -13,6 +13,25 @@ Clockdown — Countdown Timers That Don't Suck + + + + + + + + + + + + + + + + + @@ -1432,10 +1451,17 @@

startConfetti(); } + // Link unfurlers never receive the #hash, so the preview card that renders in + // iMessage/Slack/etc is always the generic one and can't name this counter. + // Passing the name as share `text` puts it in the message body itself, just + // above the card -- the one place the name does come through. function shareCountdown() { const url = window.location.href; + const emoji = ($('#cd-emoji').textContent || '⏰').trim(); + const name = $('#cd-name').textContent.trim(); + const text = name ? `${emoji} ${name} \u2014 counting down on Clockdown` : 'Counting down on Clockdown'; if (navigator.share) { - navigator.share({ title: document.title, url }).catch(() => {}); + navigator.share({ title: document.title, text, url }).catch(() => {}); } else { navigator.clipboard.writeText(url).then(() => showToast('Link copied!')).catch(() => {}); } diff --git a/motivation/index.html b/motivation/index.html index 1192999..0004da6 100644 --- a/motivation/index.html +++ b/motivation/index.html @@ -13,6 +13,25 @@ Why Clockdown? — The Motivation + + + + + + + + + + + + + + + + + diff --git a/og/clockdown.png b/og/clockdown.png new file mode 100644 index 0000000..f5113d2 Binary files /dev/null and b/og/clockdown.png differ diff --git a/og/stopwatch.png b/og/stopwatch.png new file mode 100644 index 0000000..867ff3e Binary files /dev/null and b/og/stopwatch.png differ diff --git a/og/timer.png b/og/timer.png new file mode 100644 index 0000000..64ce304 Binary files /dev/null and b/og/timer.png differ diff --git a/stopwatch/index.html b/stopwatch/index.html index c8b423e..49fcb4e 100644 --- a/stopwatch/index.html +++ b/stopwatch/index.html @@ -13,6 +13,25 @@ Clockdown Stopwatch — Count Up With Laps + + + + + + + + + + + + + + + + + @@ -830,10 +849,17 @@

if (rafId) { cancelAnimationFrame(rafId); rafId = null; } } + // Link unfurlers never receive the #hash, so the preview card that renders in + // iMessage/Slack/etc is always the generic one and can't name this counter. + // Passing the name as share `text` puts it in the message body itself, just + // above the card -- the one place the name does come through. function shareStopwatch() { const url = window.location.href; + const emoji = ($('#sw-emoji').textContent || '⏱️').trim(); + const name = $('#sw-name').textContent.trim(); + const text = name ? `${emoji} ${name} \u2014 stopwatch running on Clockdown` : 'Stopwatch running on Clockdown'; if (navigator.share) { - navigator.share({ title: document.title, url }).catch(() => {}); + navigator.share({ title: document.title, text, url }).catch(() => {}); } else { navigator.clipboard.writeText(url).then(() => showToast('Link copied!')).catch(() => {}); } diff --git a/timer/index.html b/timer/index.html index 05defb7..48e08a1 100644 --- a/timer/index.html +++ b/timer/index.html @@ -13,6 +13,25 @@ Clockdown Timer — Countdown From Any Duration + + + + + + + + + + + + + + + + + @@ -1031,10 +1050,17 @@

startConfetti(); } + // Link unfurlers never receive the #hash, so the preview card that renders in + // iMessage/Slack/etc is always the generic one and can't name this counter. + // Passing the name as share `text` puts it in the message body itself, just + // above the card -- the one place the name does come through. function shareTimer() { const url = window.location.href; + const emoji = ($('#tm-emoji').textContent || '⏲️').trim(); + const name = $('#tm-name').textContent.trim(); + const text = name ? `${emoji} ${name} \u2014 timer running on Clockdown` : 'Timer running on Clockdown'; if (navigator.share) { - navigator.share({ title: document.title, url }).catch(() => {}); + navigator.share({ title: document.title, text, url }).catch(() => {}); } else { navigator.clipboard.writeText(url).then(() => showToast('Link copied!')).catch(() => {}); } diff --git a/tools/og/README.md b/tools/og/README.md new file mode 100644 index 0000000..29677a3 --- /dev/null +++ b/tools/og/README.md @@ -0,0 +1,78 @@ +# Share cards (`og/*.png`) + +Generates the Open Graph images that iMessage, Slack, X, WhatsApp and Facebook +show when a clockdown link is pasted. + +```sh +node tools/og/build.mjs +``` + +Outputs `og/clockdown.png`, `og/timer.png` and `og/stopwatch.png` at 1200×630. +Commit the regenerated PNGs — the site is static, so they ship as files. + +This directory is excluded from the deploy (see `.github/workflows/deploy.yml`); +only `og/` is published. + +## Requirements + +- Node 18+ (uses the built-in `fetch`; no npm dependencies) +- Chromium. Set `CHROME_BIN` if it isn't at `/opt/pw-browsers/chromium`: + ```sh + CHROME_BIN=/usr/bin/chromium node tools/og/build.mjs + ``` + +Space Grotesk is downloaded from Google Fonts on first run and cached in +`.fonts/` (gitignored), so no font binaries live in the repo. + +## Editing a card + +`card.html` is the shared layout; the per-card text and digits live in the +`CARDS` array in `build.mjs`. The layout deliberately mirrors the live +countdown — same palette, same glass digit tiles, same gradient-clipped +numerals — so the preview looks like the page it links to. + +## Why the render is fiddly + +Two headless Chromium quirks are worked around in `build.mjs` and `crop.mjs`: + +1. **`--window-size` includes window chrome**, so the viewport comes out ~87px + shorter than requested. Headless captures the full window height but paints + only inside the viewport, which leaves a dead band across the bottom of the + card. The script measures that inset, renders oversized so the viewport lands + at exactly 630px, then crops the extra rows back off. + +2. **PNG filtering.** Chromium dithers the card's background gradient, and that + per-pixel noise dominates the file size. The PNG spec's minimum-sum filter + heuristic picks badly for it. Measured on these cards: + + | filter | size | + | --- | --- | + | None | 459 KB | + | **Sub (used)** | **442 KB** | + | Up | 613 KB | + | Average | 556 KB | + | Paeth | 530 KB | + | spec heuristic | 541 KB | + + Dropping the fully-opaque alpha channel saves another 25% of raw bytes. + Re-measure if the card design changes materially. The glow is what costs the + space — a flat background compresses to 45 KB — but it is also what makes the + card look like the product, so it stays. Browsers never fetch these; only + link unfurlers do, once, and they cache. + +## What these cards cannot do + +The counter's name is **not** in them, and can't be. Every counter's data lives +in the URL's `#hash` fragment, which browsers never send to a server, and link +unfurlers don't run JavaScript. On static hosting there is no point at which a +per-counter title could be rendered. + +The name does reach the recipient one other way: the in-app **Share** button +passes it as `navigator.share({ text })`, so it appears in the message body +above the preview card. See `shareCountdown()` in `index.html`. + +Putting the name *in the card itself* would mean moving the data out of the +hash and into the path or query string, and serving the HTML from something +that can render per-request meta tags (a Vercel/Netlify/Cloudflare edge +function). That also means counter names would start appearing in server logs, +which they never do today. diff --git a/tools/og/build.mjs b/tools/og/build.mjs new file mode 100644 index 0000000..57d9f54 --- /dev/null +++ b/tools/og/build.mjs @@ -0,0 +1,130 @@ +// Renders the Open Graph share cards in og/ from card.html using headless Chromium. +// Run: node tools/og/build.mjs (see tools/og/README.md for setup) +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'; +import { cropTop } from './crop.mjs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, '../..'); +const CHROME = process.env.CHROME_BIN || '/opt/pw-browsers/chromium'; +const WIDTH = 1200; +const HEIGHT = 630; + +const chrome = (args) => + execFileSync(CHROME, ['--headless', '--no-sandbox', '--disable-gpu', ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + }).toString(); + +// --window-size includes window chrome, so the viewport comes out shorter than +// the window. Headless captures the full window height but only paints inside +// the viewport, leaving a dead band across the bottom of the card. So render +// with the window oversized by the inset (viewport lands at exactly HEIGHT, +// everything paints) and crop the leftover rows back off. The inset varies by +// Chromium version, so measure it rather than hard-coding it. +function viewportInset(tmpDir) { + const probe = resolve(tmpDir, 'probe.html'); + writeFileSync(probe, ''); + const dom = chrome([`--window-size=${WIDTH},${HEIGHT}`, '--dump-dom', `file://${probe}`]); + const inner = Number(dom.match(/(\d+)<\/b>/)?.[1]); + if (!inner) throw new Error('could not measure headless viewport height'); + return HEIGHT - inner; +} + +// Space Grotesk is fetched on demand rather than vendored, so the repo carries +// no font binaries (and no OFL redistribution obligations). Cached in .fonts/. +const FONT_CSS = 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700'; +const FONT_WEIGHTS = [500, 700]; + +async function ensureFonts(cacheDir) { + mkdirSync(cacheDir, { recursive: true }); + const missing = FONT_WEIGHTS.filter((w) => !existsSync(resolve(cacheDir, `sg-${w}.ttf`))); + if (!missing.length) return; + + console.log(`fetching Space Grotesk (${missing.join(', ')})...`); + const css = await fetch(FONT_CSS).then((r) => { + if (!r.ok) throw new Error(`font CSS request failed: ${r.status}`); + return r.text(); + }); + + for (const weight of missing) { + const block = css.split('@font-face').find((b) => b.includes(`font-weight: ${weight};`)); + const url = block?.match(/https:\/\/[^)]+\.ttf/)?.[0]; + if (!url) throw new Error(`no TTF for Space Grotesk ${weight} in the Google Fonts CSS`); + const ttf = await fetch(url).then((r) => { + if (!r.ok) throw new Error(`font download failed: ${r.status}`); + return r.arrayBuffer(); + }); + writeFileSync(resolve(cacheDir, `sg-${weight}.ttf`), Buffer.from(ttf)); + } +} + +const unit = (value, label) => + `
${value}
${label}
`; + +const grid = (units) => + units.map(([v, l]) => unit(v, l)).join(':'); + +const CARDS = [ + { + out: 'og/clockdown.png', + sub: '', + grid: grid([['12', 'Days'], ['04', 'Hours'], ['37', 'Minutes'], ['52', 'Seconds']]), + headline: "Countdown Timers That Don't Suck", + footnote: 'clockdown.us — no signup, no ads, no bloat', + }, + { + out: 'og/timer.png', + sub: ' / timer', + grid: grid([['00', 'Hours'], ['25', 'Minutes'], ['00', 'Seconds']]), + headline: 'Countdown From Any Duration', + footnote: 'clockdown.us/timer — pause, resume, reset', + }, + { + out: 'og/stopwatch.png', + sub: ' / stopwatch', + grid: grid([['00', 'Hours'], ['12', 'Minutes'], ['47', 'Seconds']]), + headline: 'Count Up With Laps', + footnote: 'clockdown.us/stopwatch — start, split, share', + }, +]; + +const template = readFileSync(resolve(here, 'card.html'), 'utf8'); +const fonts = resolve(here, '.fonts'); +await ensureFonts(fonts); + +const tmp = resolve(here, '.build'); +mkdirSync(tmp, { recursive: true }); +// card.html loads the fonts by relative path, so the render dir needs copies. +for (const weight of FONT_WEIGHTS) { + writeFileSync(resolve(tmp, `sg-${weight}.ttf`), readFileSync(resolve(fonts, `sg-${weight}.ttf`))); +} + +const inset = viewportInset(tmp); + +for (const card of CARDS) { + const html = template + .replace('data-slot="sub">', `data-slot="sub">${card.sub}`) + .replace('data-slot="grid">', `data-slot="grid">${card.grid}`) + .replace('data-slot="headline">', `data-slot="headline">${card.headline}`) + .replace('data-slot="footnote">', `data-slot="footnote">${card.footnote}`); + + const page = resolve(tmp, 'page.html'); + writeFileSync(page, html); + const png = resolve(repo, card.out); + mkdirSync(dirname(png), { recursive: true }); + + chrome([ + '--hide-scrollbars', + '--force-device-scale-factor=1', + `--window-size=${WIDTH},${HEIGHT + inset}`, + `--screenshot=${png}`, + `file://${page}`, + ]); + writeFileSync(png, cropTop(readFileSync(png), HEIGHT)); + + console.log(`rendered ${card.out} (${WIDTH}x${HEIGHT})`); +} + +rmSync(tmp, { recursive: true, force: true }); diff --git a/tools/og/card.html b/tools/og/card.html new file mode 100644 index 0000000..0f2fb67 --- /dev/null +++ b/tools/og/card.html @@ -0,0 +1,143 @@ + + + + + + + +
+
+
clockdown
+
+
+
+
+ + diff --git a/tools/og/crop.mjs b/tools/og/crop.mjs new file mode 100644 index 0000000..8f66341 --- /dev/null +++ b/tools/og/crop.mjs @@ -0,0 +1,132 @@ +// Minimal PNG top-crop, so the card generator needs no image dependencies. +// Handles the 8-bit RGB/RGBA output Chromium's --screenshot produces. +import { deflateSync, inflateSync } from 'node:zlib'; + +const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(buf) { + let c = 0xffffffff; + for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'latin1'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +const paeth = (a, b, c) => { + const p = a + b - c; + const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); + return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; +}; + +// Chromium dithers the card's smooth background gradient, and that per-pixel +// noise dominates the file size. The PNG spec's minimum-sum heuristic picks +// badly for it (541KB); measured against these cards, Sub beats every other +// filter and the heuristic itself (442KB vs 459KB for None). Re-measure with +// tools/og/README.md's bench notes if the card design changes materially. +const FILTER_SUB = 1; + +function filterSub(line, channels) { + const out = Buffer.alloc(line.length); + for (let x = 0; x < line.length; x++) { + out[x] = (line[x] - (x >= channels ? line[x - channels] : 0)) & 0xff; + } + return out; +} + +/** Returns a PNG buffer containing the top `keepRows` rows of `input`. */ +export function cropTop(input, keepRows) { + let pos = 8; // skip signature + let ihdr = null; + const idat = []; + + while (pos < input.length) { + const len = input.readUInt32BE(pos); + const type = input.toString('latin1', pos + 4, pos + 8); + const data = input.subarray(pos + 8, pos + 8 + len); + if (type === 'IHDR') ihdr = data; + else if (type === 'IDAT') idat.push(data); + else if (type === 'IEND') break; + pos += 12 + len; + } + if (!ihdr) throw new Error('not a PNG: missing IHDR'); + + const width = ihdr.readUInt32BE(0); + const height = ihdr.readUInt32BE(4); + const depth = ihdr[8]; + const colorType = ihdr[9]; + const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colorType]; + if (depth !== 8 || !channels) { + throw new Error(`unsupported PNG: depth ${depth}, color type ${colorType}`); + } + if (keepRows > height) throw new Error(`cannot keep ${keepRows} of ${height} rows`); + if (keepRows === height) return input; + + // Undo per-row filtering. Each row's filter may reference the row above, so + // rows must be decoded in order even though only the first `keepRows` are kept. + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * channels; + const rows = []; + let prev = Buffer.alloc(stride); + let read = 0; + + for (let y = 0; y < keepRows; y++) { + const filter = raw[read++]; + const line = Buffer.from(raw.subarray(read, read + stride)); + read += stride; + for (let x = 0; x < stride; x++) { + const a = x >= channels ? line[x - channels] : 0; + const b = prev[x]; + const c = x >= channels ? prev[x - channels] : 0; + if (filter === 1) line[x] = (line[x] + a) & 0xff; + else if (filter === 2) line[x] = (line[x] + b) & 0xff; + else if (filter === 3) line[x] = (line[x] + ((a + b) >> 1)) & 0xff; + else if (filter === 4) line[x] = (line[x] + paeth(a, b, c)) & 0xff; + else if (filter !== 0) throw new Error(`unknown PNG filter ${filter}`); + } + rows.push(line); + prev = line; + } + + // Share cards are fully opaque, so the alpha channel is pure overhead. + const opaque = channels === 4 && rows.every((line) => { + for (let x = 3; x < stride; x += 4) if (line[x] !== 255) return false; + return true; + }); + const outChannels = opaque ? 3 : channels; + const outStride = width * outChannels; + const pixels = opaque + ? rows.map((line) => { + const rgb = Buffer.alloc(outStride); + for (let x = 0, o = 0; x < stride; x += 4, o += 3) line.copy(rgb, o, x, x + 3); + return rgb; + }) + : rows; + + const body = Buffer.alloc(keepRows * (outStride + 1)); + for (let y = 0; y < keepRows; y++) { + body[y * (outStride + 1)] = FILTER_SUB; + filterSub(pixels[y], outChannels).copy(body, y * (outStride + 1) + 1); + } + + const header = Buffer.from(ihdr); + header.writeUInt32BE(keepRows, 4); + if (opaque) header[9] = 2; // RGBA -> RGB + + return Buffer.concat([ + input.subarray(0, 8), + chunk('IHDR', header), + chunk('IDAT', deflateSync(body, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ]); +}