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
8 changes: 4 additions & 4 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
name: Deploy to GitHub Pages

# Builds the static site and publishes ./dist to GitHub Pages on every push to
# master. One-time setup: repo Settings → Pages → Build and deployment → Source
# = "GitHub Actions".
# Builds the static site and publishes ./dist to GitHub Pages when a CalVer tag
# (e.g. 2026.8.0) is pushed; a push to master alone does not deploy. One-time
# setup: repo Settings → Pages → Build and deployment → Source = "GitHub Actions".

on:
push:
Expand Down Expand Up @@ -32,7 +32,7 @@ jobs:
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
# Gate the deploy on the same checks CI runs, so master never ships red.
# Gate the deploy on the same checks CI runs, so a tag never ships red.
- run: bun run lint
- run: bun run typecheck
- run: bun test
Expand Down
27 changes: 14 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

A **fully static** world clock for digital signage: a board of cities, each
showing its own local time, date, GMT offset and a day/night accent. Everything
on screen is driven by **URL query parameters** — there is no server and no
on screen is driven by **URL query parameters**: there is no server and no
backend. The site builds to `./dist` and is published to **GitHub Pages**.

It is modelled loosely on the sibling [`../clock-app`](../clock-app) (the
"Observatory" aesthetic — Fraunces serif over a night sky), but where clock-app
"Observatory" aesthetic: Fraunces serif over a night sky), but where clock-app
is a single edge-rendered Cloudflare Worker clock, this is a self-contained,
multi-zone, client-only static site. All client code is **TypeScript**.

Expand All @@ -32,21 +32,22 @@ bun run sync-fonts # vendor webfonts from @fontsource only
```

CI (`.github/workflows/ci.yml`) runs lint → typecheck → test → build on every PR.
Pushing to `master` runs `deploy-pages.yml`, which re-runs those checks and
publishes `./dist` to GitHub Pages.
Deploys are tag-driven: pushing a CalVer tag (e.g. `2026.8.0`) runs
`deploy-pages.yml`, which re-runs those checks and publishes `./dist` to GitHub
Pages. Pushing to `master` on its own does not deploy.

## Architecture

### Pure logic vs. DOM (the split to preserve)

- **`src/clocks.ts`** — pure, side-effect-free helpers: URL parsing
- **`src/clocks.ts`** holds the pure, side-effect-free helpers: URL parsing
(`parseClocks`), `Intl` formatter construction (`buildFormatters`), and the
formatting/banding helpers. Every function takes an explicit `Date`, so tests
are independent of the machine clock and timezone. This is the **only** file
with unit tests (`src/clocks.test.ts`). Keep testable logic here.
- **`src/main.ts`** — the DOM driver: reads `location.search`, builds one card
- **`src/main.ts`** is the DOM driver: reads `location.search`, builds one card
per clock, and repaints them on a shared timer. No `Intl`/parsing logic of its
own — it imports it from `clocks.ts`. Loaded as a `type="module"` script.
own; it imports it from `clocks.ts`. Loaded as a `type="module"` script.

This mirrors clock-app's `locale.js` / `main.js` split, but here `main.ts` is a
normal ES module (the static page uses `<script type="module">`), so there is no
Expand All @@ -67,7 +68,7 @@ project subpath (`https://<org>.github.io/world-clock/`) unchanged.

`sync-fonts.ts` vendors the Bun-managed `@fontsource` woff2 files into
`assets/fonts/` (run automatically by `build.ts`). **Only Latin fonts are
vendored** — non-Latin locales (CJK, Arabic, …) fall back to the device's own
vendored**; non-Latin locales (CJK, Arabic, …) fall back to the device's own
fonts. The time digits are always Latin, so the hero always renders in Fraunces.

## URL parameters (the configuration surface)
Expand All @@ -81,7 +82,7 @@ fonts. The time digits are always Latin, so the hero always renders in Fraunces.
| `seconds` | no | Present (or `=1`) → tick every second; otherwise tick by the minute |
| `title` | no | Replaces the "World Clock" masthead label |

**Locale is global, not per-clock** — a world clock shows every city in one
**Locale is global, not per-clock**: a world clock shows every city in one
language; only the timezone differs per card. Invalid IANA zones are dropped
(not thrown), and an unsupported `locale` falls back to `en-GB`. With no clock
params, `DEFAULT_CLOCKS` (a spread of major cities) is shown. See the
Expand All @@ -92,15 +93,15 @@ table-driven tests in `src/clocks.test.ts` for the exact parsing contract.
This is a [Screenly Playground](https://github.com/Screenly/Playground) app, so a
quiet Screenly lockup sits in the footer (`assets/images/screenly-logo.svg`,
copied to `dist/images/` by `build.ts`) and the masthead pip carries the brand
gradient. Card accents are reserved for the data-driven day/night bands — keep
gradient. Card accents are reserved for the data-driven day/night bands, so keep
the brand colour in the chrome, not on the clocks.

## Conventions

- Biome: single quotes, no semicolons, no trailing commas, 2-space indent, 100
cols. Run `bun run lint:fix` before committing.
- TypeScript is `strict` with `noUncheckedIndexedAccess` and
`verbatimModuleSyntax` — use `import type` for type-only imports and keep
`verbatimModuleSyntax`: use `import type` for type-only imports and keep
array access guarded.

### Signature design element (don't flatten it)
Expand All @@ -109,8 +110,8 @@ Each card's accent (the pip, the tinted top "sky band", and the glow) is set fro
**that city's own local hour** via `data-period` (`dawn`/`day`/`dusk`/`night`),
so a wall of cities reads at a glance as who is awake. The grid uses
`grid-auto-rows: 1fr` to give every card a definite size, and `.card-time` is
sized with `min(24cqw, 40cqh)` so the time fits both the card's width and height
— this is what keeps any number of cities from overflowing on any resolution
sized with `min(24cqw, 40cqh)` so the time fits both the card's width and height,
which is what keeps any number of cities from overflowing on any resolution
(480×800 portrait through 4K). Test layout changes across the resolutions in
[`Playground/docs/resolutions.md`](../../Playground/docs/resolutions.md).

Expand Down
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,20 @@ Screenly lockup in the footer.

## Settings (URL parameters)

Everything on screen is driven by the query string — no build step or config
Everything on screen is driven by the query string: no build step or config
file needed to change the board. All settings are optional; with **no parameters
at all**, a default board of major world cities is shown in 24-hour `en-GB`.

| Setting | Values / syntax | Default | Repeatable | What it does |
| --------- | ------------------------------------------------- | -------------- | ---------- | ------------ |
| `tz` | `Area/City` or `Area/City\|Label` | — | yes | Adds one clock. Repeat the parameter for more cities. |
| `clocks` | comma-separated list of the `tz` syntax | — | yes | Adds several clocks in a single parameter. |
| `tz` | `Area/City` or `Area/City\|Label` | none | yes | Adds one clock. Repeat the parameter for more cities. |
| `clocks` | comma-separated list of the `tz` syntax | none | yes | Adds several clocks in a single parameter. |
| `locale` | a [BCP-47 tag](https://en.wikipedia.org/wiki/IETF_language_tag) (`en-US`, `de-DE`, `fr-FR`, `ja-JP`) | `en-GB` | no | **Global** display language for every card's date and time, and the default 12/24h cycle. |
| `format` | `12` or `24` | the locale's default | no | Forces the hour cycle on every card, overriding the locale. |
| `seconds` | present, or `=1` / `=0` | off | no | Shows seconds and ticks every second (otherwise ticks each minute). |
| `title` | any text | `World Clock` | no | Replaces the masthead label. |

### `tz` and `clocks` — choosing the cities
### `tz` and `clocks`: choosing the cities

A clock is an [IANA timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
(`Europe/London`, `America/New_York`, `Asia/Kolkata`) with an optional label
Expand All @@ -38,7 +38,7 @@ after a `|`:
- `tz=Asia/Tokyo` → labelled "Tokyo" (derived from the zone)
- `tz=Asia/Tokyo|HQ Tokyo` → labelled "HQ Tokyo"

Add as many as you like, either by repeating `tz` or with one `clocks` list — the
Add as many as you like, either by repeating `tz` or with one `clocks` list; the
two can be combined and the order is preserved. **Unknown timezones are skipped**
rather than shown broken, so a typo drops one card instead of breaking the board.

Expand All @@ -49,13 +49,13 @@ rather than shown broken, so a typo drops one card instead of breaking the board

### `locale` is global, not per-city

A world clock shows every city in **one** language and time format — the
A world clock shows every city in **one** language and time format: the
viewer's, set once with `locale`. The timezone is the only thing that differs
per card. (Localizing each city into its own local language would make a board of
clocks unreadable, so it is deliberately not supported.)

> Non-Latin locales (CJK, Arabic, …) render their text using the **device's own
> fonts** — only Latin webfonts are vendored to keep the site small and CDN-free.
> fonts**: only Latin webfonts are vendored to keep the site small and CDN-free.
> The time digits are always Latin, so they render in Fraunces everywhere.

### Examples
Expand All @@ -74,7 +74,7 @@ clocks unreadable, so it is deliberately not supported.)
## Resolutions

Responsive from a Raspberry Pi touch display (480×800) up to 4K (4096×2160), in
both orientations — see [`Playground/docs/resolutions.md`](../../Playground/docs/resolutions.md).
both orientations, see [`Playground/docs/resolutions.md`](../../Playground/docs/resolutions.md).
The grid divides the screen into equal cells, so any number of cities fills the
board without scrolling or clipping.

Expand All @@ -100,7 +100,7 @@ and copies the HTML shell + vendored fonts into `./dist`.

## Deployment (GitHub Pages)

Deploys are **tag-driven**. Pushing a CalVer tag (`YYYY.N`, e.g. `2026.8.0`)
Deploys are **tag-driven**. Pushing a CalVer tag (`YYYY.M.PATCH`, e.g. `2026.8.0`)
runs [`.github/workflows/deploy-pages.yml`](.github/workflows/deploy-pages.yml),
which lints, typechecks, tests, builds `./dist`, and publishes it to Pages; it
also accepts `workflow_dispatch`. Pushing to `master` on its own does **not**
Expand Down
39 changes: 20 additions & 19 deletions assets/styles/main.css
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/* =========================================================================
World Clock — "Observatory" theme
World Clock: "Observatory" theme
Editorial almanac aesthetic borrowed from the Screenly clock-app: a Fraunces
display serif over a deep night sky, atmospheric grain + vignette, and a
time-of-day reactive accent. Here the accent is PER CARD — every city glows
with its own dawn/day/dusk/night band — so a wall of zones reads at a glance.
time-of-day reactive accent. Here the accent is PER CARD: every city glows
with its own dawn/day/dusk/night band, so a wall of zones reads at a glance.
Tuned for unattended digital signage: legible at distance, no interaction,
responsive from a 480x800 Pi touch display up to 4096x2160.
========================================================================= */
Expand Down Expand Up @@ -48,7 +48,7 @@
--ink-dim: rgb(247 243 236 / 0.74);
--hairline: rgb(247 243 236 / 0.16);

/* Single fluid root size — symmetric across orientation (vw + vh) so the
/* Single fluid root size, symmetric across orientation (vw + vh) so the
whole layout scales smoothly from a Pi touch display up to 4K. */
--root: clamp(15px, 0.82vw + 0.82vh, 54px);
}
Expand Down Expand Up @@ -78,7 +78,7 @@ body {
font-family: var(--font-meta);
line-height: 1.3;
/* A deep, neutral observatory sky. Unlike the single-zone clock-app, the page
can't belong to one time of day — many zones share it — so the backdrop
can't belong to one time of day (many zones share it), so the backdrop
stays a constant night and each card carries its own period glow. */
background-color: #06070f;
background-image:
Expand All @@ -95,7 +95,7 @@ body {
}

/* =========================================================================
Board shell — masthead / grid, plus vignette, grain and ambient glow
Board shell: masthead / grid, plus vignette, grain and ambient glow
========================================================================= */
.board {
position: relative;
Expand Down Expand Up @@ -157,7 +157,7 @@ body {
}

/* =========================================================================
Masthead — title eyebrow + live UTC reference
Masthead: title eyebrow + live UTC reference
========================================================================= */
.masthead {
display: flex;
Expand Down Expand Up @@ -206,8 +206,8 @@ body {
/* =========================================================================
Grid of city cards
The column count (--cols) is computed in main.ts from the city count AND the
board's aspect ratio, so the board is a balanced block — 4 cities → 2x2,
7 → 4+3 — instead of whatever happens to fit the width. A partial last row is
board's aspect ratio, so the board is a balanced block (4 cities → 2x2,
7 → 4+3) instead of whatever happens to fit the width. A partial last row is
centred, not left-aligned: the grid is laid out in TWICE as many tracks as
there are columns (each card spans 2), so main.ts can offset the last row by
a half-column and centre an odd remainder exactly. align-content stretches
Expand All @@ -233,14 +233,14 @@ body {
}

/* =========================================================================
City card — the hero unit. Each is its own container so the time can size
City card: the hero unit. Each is its own container so the time can size
to the CARD width (container queries), staying balanced no matter how many
cities share the board.
========================================================================= */
.card {
--accent: #9aa6f2;
/* container-type: size (not just inline-size) so the time can query the card
HEIGHT as well as its width — the grid gives every card a definite size. */
HEIGHT as well as its width; the grid gives every card a definite size. */
container-type: size;
position: relative;
overflow: hidden;
Expand All @@ -262,7 +262,7 @@ body {
transition: --accent 1.1s ease;
}

/* Period-tinted glow bleeding in from the top — the card's "sky". */
/* Period-tinted glow bleeding in from the top, the card's "sky". */
.card-glow {
position: absolute;
inset: 0;
Expand Down Expand Up @@ -365,7 +365,7 @@ body {
margin-inline-start: 0;
}

/* ko / zh-Hant print the period before the time — reorder the marker. */
/* ko / zh-Hant print the period before the time, so reorder the marker. */
.card-clock.period-first .card-period {
order: -1;
margin-inline-start: 0;
Expand Down Expand Up @@ -403,7 +403,7 @@ body {
}

/* =========================================================================
Entrance choreography — one staggered reveal on load
Entrance choreography: one staggered reveal on load
========================================================================= */
.anim {
opacity: 0;
Expand Down Expand Up @@ -456,14 +456,15 @@ body {
}

/* =========================================================================
Degraded mode — container-query fallback for older browsers.
Degraded mode: container-query fallback for older browsers.
The card sizes its text AND spacing with cqw/cqh units (container queries:
Chrome 105 / Safari 16). Where they're unsupported those declarations drop, so
the text collapses to an inherited size and the card loses its gap/padding
(leaving the time jammed against the edge, unstyled and misaligned). Re-derive
everything off the viewport (vmin) instead — grid-count-agnostic, so it stays
legible and laid-out whether the board shows one city or twelve. This block
must cover EVERY cqw/cqh declaration in the card, not just the font sizes.
everything off the viewport (vmin) instead, which is grid-count-agnostic, so
it stays legible and laid-out whether the board shows one city or twelve. This
block must cover EVERY cqw/cqh declaration in the card, not just the font
sizes.
========================================================================= */
@supports not (container-type: size) {
.card {
Expand Down Expand Up @@ -497,7 +498,7 @@ body {
}

/* =========================================================================
Degraded mode — app-specific resting state (the generic html.legacy kill-switch
Degraded mode: app-specific resting state (the generic html.legacy kill-switch
is prepended at build by @screenly-labs/signage-kit). .anim has a base
opacity:0 and only appears via the entrance animation, so reveal it here
(the kill-switch cancels the animation itself), mirroring reduced-motion.
Expand Down
2 changes: 1 addition & 1 deletion build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,4 @@ console.log(`✓ Manifest: ${DIST}/.well-known/signage-app.json`)
await Bun.write(`${DIST}/.nojekyll`, '')
console.log(`✓ ${DIST}/.nojekyll`)

console.log('Build complete — static site written to ./dist')
console.log('Build complete: static site written to ./dist')
6 changes: 3 additions & 3 deletions dev-server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bun
// Local dev server for testing the static build on the LAN. It builds the site
// once, serves ./dist on 0.0.0.0 (so other devices on the network can reach it),
// and rebuilds whenever a source file changes. This is a dev convenience only —
// and rebuilds whenever a source file changes. This is a dev convenience only,
// production is the static ./dist published to GitHub Pages, never this server.

import { watch } from 'node:fs'
Expand All @@ -27,7 +27,7 @@ let pending: ReturnType<typeof setTimeout> | undefined
const scheduleBuild = (): void => {
if (pending) clearTimeout(pending)
pending = setTimeout(() => {
console.log('↻ change detected — rebuilding…')
console.log('↻ change detected, rebuilding…')
void build()
}, 120)
}
Expand Down Expand Up @@ -68,5 +68,5 @@ console.log(`\nWorld Clock dev server`)
console.log(` local: http://localhost:${server.port}/`)
console.log(` network: http://0.0.0.0:${server.port}/ (reachable on your LAN IP)`)
console.log(
`Watching ${[...WATCH_DIRS, ...WATCH_FILES].join(', ')} — edits rebuild automatically.\n`
`Watching ${[...WATCH_DIRS, ...WATCH_FILES].join(', ')}, edits rebuild automatically.\n`
)
Loading