A zoomable, inspectable vertical timeline running from the Stone Age (c. 3,300,000 BCE) to the present day — where "present" is recomputed on every page load.
Client-side only. No server, no API, no build-time data fetching. https://devjonny.github.io/Timeline/
| Concern | Choice |
|---|---|
| UI | Svelte 5 (runes) |
| Build | Vite 8 |
| Language | TypeScript |
| Axis maths | d3-scale, d3-zoom |
| Data validation | zod (devDependency only — never reaches the bundle) |
| Offline | vite-plugin-pwa (Workbox) |
| Tests | Vitest |
| Hosting | GitHub Pages via GitHub Actions |
There is deliberately no SvelteKit: this is a single client-rendered page
with no routing or SSR, so a router and adapter-static would be configuration
overhead solving nothing.
npm install
npm run dev # http://localhost:5173/Timeline/
npm run dev -- --host # expose on the LAN to test on a real phone
npm test # unit tests
npm run validate # data integrity
npm run check # svelte-check, warnings are errors
npm run build && npm run preview
npm run icons # regenerate PWA icons (output is committed)Because the site is served from a subpath, every runtime fetch of a file in
public/ must go through import.meta.env.BASE_URL. A wrong base path will
not show up in npm run dev — only in preview and production.
The service worker is disabled in dev. To exercise offline behaviour, use
npm run build && npm run preview.
Content is data, not code. Add an entry to public/data/entries.json:
{
"id": "battle-of-hastings",
"title": "Battle of Hastings",
"type": "battle",
"start": { "year": 1066, "month": 10, "day": 14 },
"importance": 2,
"keywords": ["england", "medieval", "norman-conquest"]
}and a matching public/data/details/battle-of-hastings.json:
{
"id": "battle-of-hastings",
"short": "One sentence, shown at the top of the detail sheet.",
"full": "Longer prose. Blank lines separate paragraphs."
}Then run npm run validate. CI runs it too, so a missing or misnamed detail
file fails the build rather than 404-ing for a reader.
| Field | Notes |
|---|---|
id |
Lowercase hyphenated slug. Must match the detail filename. |
type |
age, empire, ruler, person, war, battle, event |
start / end |
year is signed: -3300 is 3300 BCE. There is no year 0. month/day optional; circa: true renders "c. 3300 BCE". |
end |
Omit for an instant. Use the string "present" for something ongoing. |
importance |
1 (era-defining) to 5 (minor). Drives what appears at which zoom. |
keywords |
Lowercase hyphenated slugs. Drive filtering and grouping. |
Importance is the main authoring lever. It decides the zoom at which an
entry appears, so a timeline full of 1s is as unreadable as one full of 5s.
Four decisions are load-bearing; changing them will break things in non-obvious ways.
Data is authored historically (-3300 is 3300 BCE, no year zero); the axis
needs a continuous astronomical coordinate (1 BCE is 0). Everything entering
the axis goes through toDecimalYear, everything shown to a human comes back
through a format* function. Never render a raw axis coordinate — and note
that ticks land on round historical years, or they read "3501 BCE".
src/lib/scale.ts never CSS-transforms content. At the zoom levels this axis
reaches (k up to 1e7) a translate offset would exceed 1e10 pixels and browsers
lose sub-pixel fidelity. Positions are recomputed each frame from a rescaled
domain instead.
transformForDomain is the inverse of that rescale, and is the single function
behind every navigational affordance: zoom-to-extent, the saved default view,
era-rail jumps, deep links, and preserving the visible range across rotation.
Timeline markers of different types sit adjacent arbitrarily, so the palette must clear the all-pairs colourblind test, not the easier adjacent-pairs one. Every subset of the reference palette larger than four fails it. So four validated hues carry five families, and a glyph distinguishes types within a family.
Two consequences are binding, not stylistic:
- dark mode carries a CVD warning between the conflict and age hues, legal only alongside a secondary channel — every marker renders a glyph;
- light mode puts two families under 3:1 contrast, triggering the relief rule — every marker renders a visible text label.
Do not add a fifth hue without re-running a palette validator.
The viewport is a role="application" canvas that a screen reader cannot
meaningfully traverse. The search panel is a real, keyboard-navigable list of
every entry, and selecting from it does exactly what tapping a marker does. It
is not a convenience feature — nothing must ever be reachable only by pointing
at the canvas.
Phones and tablets are the primary target, not a responsive afterthought:
touch-action: noneon the viewport, or the browser claims the pinch before d3-zoom sees it. iOS Safari additionally needs its non-standardgesture*events cancelled.user-scalable=nois not used — it is an accessibility anti-pattern and iOS ignores it anyway.100dvh, never100vh;env(safe-area-inset-*)throughout.- Resize and rotation preserve the visible year range, not the pixel offset.
- No hover exists: detail prefetch happens on selection and focus.
- Level-of-detail culling is a performance requirement, not decoration — it is what bounds the number of DOM nodes.
Pushing to main runs validate, typecheck, tests, build, and deploys via
.github/workflows/deploy.yml. Pages is set to the GitHub Actions source.