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) =>
+ `