Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
--exclude='.github' \
--exclude='.gitignore' \
--exclude='*.md' \
--exclude='tools' \
./ /tmp/site-build/

# Check out gh-pages branch
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
--exclude='.github' \
--exclude='.gitignore' \
--exclude='*.md' \
--exclude='tools' \
--exclude='CNAME' \
./ /tmp/pr-build/

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
28 changes: 27 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@
</script>
<title>Clockdown — Countdown Timers That Don't Suck</title>
<meta name="description" content="Create beautiful, shareable countdown timers in seconds. No signup, no ads, no bloat.">

<!-- Rich link previews (iMessage, Slack, X, Facebook, WhatsApp). These are
static: link unfurlers never run JS and never receive the #hash, so the
individual counter's name cannot appear here. See tools/og/README.md. -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="Clockdown">
<meta property="og:url" content="https://clockdown.us/">
<meta property="og:title" content="Clockdown — Countdown Timers That Don't Suck">
<meta property="og:description" content="Create beautiful, shareable countdown timers in seconds. No signup, no ads, no bloat.">
<meta property="og:image" content="https://clockdown.us/og/clockdown.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="The Clockdown wordmark above a row of countdown digits reading 12 days, 4 hours, 37 minutes, 52 seconds.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Clockdown — Countdown Timers That Don't Suck">
<meta name="twitter:description" content="Create beautiful, shareable countdown timers in seconds. No signup, no ads, no bloat.">
<meta name="twitter:image" content="https://clockdown.us/og/clockdown.png">
<meta name="twitter:image:alt" content="The Clockdown wordmark above a row of countdown digits reading 12 days, 4 hours, 37 minutes, 52 seconds.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
Expand Down Expand Up @@ -1432,10 +1451,17 @@ <h1 class="event-name" id="cd-name"></h1>
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(() => {});
}
Expand Down
19 changes: 19 additions & 0 deletions motivation/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@
</script>
<title>Why Clockdown? — The Motivation</title>
<meta name="description" content="Why I built clockdown — a simple, social-first countdown timer that doesn't suck.">

<!-- Rich link previews (iMessage, Slack, X, Facebook, WhatsApp). These are
static: link unfurlers never run JS and never receive the #hash, so the
individual counter's name cannot appear here. See tools/og/README.md. -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="Clockdown">
<meta property="og:url" content="https://clockdown.us/motivation/">
<meta property="og:title" content="Why Clockdown? — The Motivation">
<meta property="og:description" content="Why I built clockdown — a simple, social-first countdown timer that doesn't suck.">
<meta property="og:image" content="https://clockdown.us/og/clockdown.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="The Clockdown wordmark above a row of countdown digits.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Why Clockdown? — The Motivation">
<meta name="twitter:description" content="Why I built clockdown — a simple, social-first countdown timer that doesn't suck.">
<meta name="twitter:image" content="https://clockdown.us/og/clockdown.png">
<meta name="twitter:image:alt" content="The Clockdown wordmark above a row of countdown digits.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
Expand Down
Binary file added og/clockdown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added og/stopwatch.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added og/timer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 27 additions & 1 deletion stopwatch/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@
</script>
<title>Clockdown Stopwatch — Count Up With Laps</title>
<meta name="description" content="A beautiful, shareable stopwatch with pause, resume, and lap splits. Name it, pick a vibe, share the link. No signup, no ads, no bloat.">

<!-- Rich link previews (iMessage, Slack, X, Facebook, WhatsApp). These are
static: link unfurlers never run JS and never receive the #hash, so the
individual counter's name cannot appear here. See tools/og/README.md. -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="Clockdown">
<meta property="og:url" content="https://clockdown.us/stopwatch/">
<meta property="og:title" content="Clockdown Stopwatch — Count Up With Laps">
<meta property="og:description" content="A beautiful, shareable stopwatch with pause, resume, and lap splits. Name it, pick a vibe, share the link. No signup, no ads, no bloat.">
<meta property="og:image" content="https://clockdown.us/og/stopwatch.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="The Clockdown Stopwatch wordmark above digits reading 0 hours, 12 minutes, 47 seconds.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Clockdown Stopwatch — Count Up With Laps">
<meta name="twitter:description" content="A beautiful, shareable stopwatch with pause, resume, and lap splits. Name it, pick a vibe, share the link. No signup, no ads, no bloat.">
<meta name="twitter:image" content="https://clockdown.us/og/stopwatch.png">
<meta name="twitter:image:alt" content="The Clockdown Stopwatch wordmark above digits reading 0 hours, 12 minutes, 47 seconds.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
Expand Down Expand Up @@ -830,10 +849,17 @@ <h1 class="event-name" id="sw-name"></h1>
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(() => {});
}
Expand Down
28 changes: 27 additions & 1 deletion timer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@
</script>
<title>Clockdown Timer — Countdown From Any Duration</title>
<meta name="description" content="A beautiful, shareable countdown timer with pause, resume, and reset. Set a duration, name it, pick a vibe. No signup, no ads, no bloat.">

<!-- Rich link previews (iMessage, Slack, X, Facebook, WhatsApp). These are
static: link unfurlers never run JS and never receive the #hash, so the
individual counter's name cannot appear here. See tools/og/README.md. -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="Clockdown">
<meta property="og:url" content="https://clockdown.us/timer/">
<meta property="og:title" content="Clockdown Timer — Countdown From Any Duration">
<meta property="og:description" content="A beautiful, shareable countdown timer with pause, resume, and reset. Set a duration, name it, pick a vibe. No signup, no ads, no bloat.">
<meta property="og:image" content="https://clockdown.us/og/timer.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="The Clockdown Timer wordmark above digits reading 0 hours, 25 minutes, 0 seconds.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Clockdown Timer — Countdown From Any Duration">
<meta name="twitter:description" content="A beautiful, shareable countdown timer with pause, resume, and reset. Set a duration, name it, pick a vibe. No signup, no ads, no bloat.">
<meta name="twitter:image" content="https://clockdown.us/og/timer.png">
<meta name="twitter:image:alt" content="The Clockdown Timer wordmark above digits reading 0 hours, 25 minutes, 0 seconds.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
Expand Down Expand Up @@ -1031,10 +1050,17 @@ <h1 class="event-name" id="tm-name"></h1>
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(() => {});
}
Expand Down
78 changes: 78 additions & 0 deletions tools/og/README.md
Original file line number Diff line number Diff line change
@@ -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.
130 changes: 130 additions & 0 deletions tools/og/build.mjs
Original file line number Diff line number Diff line change
@@ -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, '<html><body><b id="v"></b><script>v.textContent=innerHeight</script></body></html>');
const dom = chrome([`--window-size=${WIDTH},${HEIGHT}`, '--dump-dom', `file://${probe}`]);
const inner = Number(dom.match(/<b id="v">(\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) =>
`<div class="timer-unit"><div class="timer-digits"><span class="digit-value">${value}</span></div><span class="digit-label">${label}</span></div>`;

const grid = (units) =>
units.map(([v, l]) => unit(v, l)).join('<span class="timer-colon">:</span>');

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"></span>', `data-slot="sub">${card.sub}</span>`)
.replace('data-slot="grid"></div>', `data-slot="grid">${card.grid}</div>`)
.replace('data-slot="headline"></div>', `data-slot="headline">${card.headline}</div>`)
.replace('data-slot="footnote"></div>', `data-slot="footnote">${card.footnote}</div>`);

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 });
Loading
Loading