A personal site that behaves like a CAD viewport: near-black ground, one acid-green
accent, mono chrome, object snapping, a command line, a WebGL globe of real flights —
set from Markdown the way a newspaper is set from copy. Built with
Eleventy, compiled to a single dark index.html plus a
standalone page per dispatch.
reference/field-broadsheet.html is the original hand-built prototype and the
visual/behavioral source of truth for this whole rebuild — if something looks
wrong, open it side by side and diff.
This document is the one-stop reference for running, editing, and shipping the site. Everything you'd need to change — copy, colors, flights, panels, routing — is described here with the exact file and key to touch.
- Quickstart
- Dependencies
- Project structure
- Editing guide — "I want to…"
field.config.jsreference- Content types reference
- Markdown formatting
- Design tokens & theming
- Publishing, routing & URLs
- JS module architecture
- Command line reference
- Spotify Now Playing
- Testing
- Deploy
- Caveats
npx create-field-broadsheet my-site # scaffold a new copy of this template
cd my-site
npm install
npm run dev # eleventy --serve — live reload at http://localhost:3000
npm run build # → dist/index.html (single file, assets inlined) + dist/posts/*/
npm test # jsdom behavioral suite (test/suite.js)
npm run clean # rm -rf distAlready inside this repo (not scaffolding a new one)? Skip the npx/cd lines
and just run npm install once, then the same npm run dev/build/test.
| Package | Used for |
|---|---|
@11ty/eleventy (^3.0.0) |
The static site generator — collections, templating, the whole build |
gray-matter (^4.0.3) |
Parses frontmatter out of content Markdown so eleventy.config.js can pull the raw body text for markdown rendering |
js-yaml (^4.1.0) |
Parses content/photos.yml |
markdown-it (^14.0.0) |
The Markdown → HTML engine — one shared instance (markdown.config.js) renders both the in-page reader's body data and the standalone /posts/…/ pages |
markdown-it-mark (^4.0.0) |
==highlight== → <mark> |
markdown-it-footnote (^4.0.0) |
[^1] reference footnotes |
markdown-it-task-lists (^2.1.1) |
- [ ] / - [x] checkboxes |
markdown-it-anchor (^9.2.1) |
Stamps an id on every h2/h3, which the table-of-contents feature reads |
| Package | Used for |
|---|---|
jsdom (^29.1.1) |
Headless DOM for npm test — loads the built page, runs its scripts, asserts on the result |
| Script | Purpose | Failure mode |
|---|---|---|
unpkg.com/three@0.160.0 |
3D engine backing the globe | — |
unpkg.com/globe.gl@2.34.5 |
The WebGL globe itself | If either fails to load, initGlobe()'s own try/catch shows #globe-offline instead |
fonts.googleapis.com (Space Grotesk + JetBrains Mono) |
Display + mono typefaces | Falls back to system fonts |
| Service | Feature | On failure |
|---|---|---|
raw.githubusercontent.com (vasturiano/globe.gl geojson) |
Hex-dot continents on the globe | Globe still renders as a plain sphere |
api.github.com |
Personnel File → Open-Source Activity (7-day bars, streak, heatmap) | Shows "GITHUB UNREACHABLE"; cached 1h in localStorage |
api.open-meteo.com |
Topbar temperature | Shows —°C; cached 1h |
aviationweather.gov |
The metar command |
Command prints an error |
dummyjson.com/quotes |
The live WIRE quote card | Falls back to a 5-quote baked archive |
api.spotify.com (via /api/nowplaying, our own proxy) |
Now Playing panel — currently playing, or last played if nothing's active | Falls back to config.nowPlaying, then "SILENCE". See Spotify Now Playing |
picsum.photos |
Photo placeholders when photos.yml has no real src |
— |
Nothing above is required for npm run dev/build/test to work — the site
degrades honestly offline, exactly as designed.
field.config.js masthead & standing info — see full reference below
eleventy.config.js Eleventy config: collections, filters, shortcodes
markdown.config.js the shared markdown-it instance — see Markdown formatting
cli.js the `npx create-field-broadsheet` scaffolder
package.json scripts + dependencies
vercel.json tells Vercel to build with `npm run build`, serve `dist/`
api/
nowplaying.js Spotify proxy — see Spotify Now Playing
.env.example the three SPOTIFY_* env vars the proxy needs (copy to .env.local)
.cache/
bookmarks.json fetched Open Graph data for bookmark-card URLs — committed, not gitignored
src/
index.njk the field — assembles collections into DOM + client-side data
_includes/
head-seo.njk shared <head> SEO block (meta/OG/Twitter/JSON-LD)
layouts/post.njk standalone-post page shell (used by every dispatch)
_data/
config.js exposes field.config.js as `config` in templates
photos.js loads + resolves content/photos.yml (real src or picsum fallback)
homeCode.js derives the home airport's IATA code from field.config.js
sameAs.js non-empty social links, for the Person JSON-LD
css/
field.css the stylesheet — ported verbatim from the prototype
js/ client-side modules, concatenated into ONE inline <script>
by the `fieldJS` shortcode, in this exact load order:
10-render.js lead/flow (computed order + Load More)/pinned-panels/ticker/
travel-feed/photo-grid/experiments/CV/morgue render + status clock/temp
20-cursor.js CAD cursor, osnap engine, crosshair, construction lines
40-globe.js globe.gl init, flight math, city dossiers, legend chips
50-activity.js GitHub activity fetch (cached)
60-cmdline.js the command line — all 19 commands
70-reader.js reader overlay, lightbox, deep links, pushState routing
80-fx.js reveal-on-scroll, panel tilt, nav, boot gate, scrollspy, WIRE quote
90-panels.js now-playing / reading / streak panel fills
content/ ← you write here (see Content types reference)
posts/*.md dispatches (23 sample entries + _template.md)
briefs/*.md short-form briefs (8 + _template.md)
quotes/*.md pull-quotes (3 + _template.md)
morgue/*.md killed drafts (3 + _template.md)
experiments/*.md the log rows in §04 (7 + _template.md)
photos.yml the contact-strip / photography manifest
posts.json, briefs.json, … directory data files (permalinks, layouts — see below)
sitemap.njk → /sitemap.xml
robots.njk → /robots.txt
test/
suite.js jsdom behavioral suite — `npm test`
reference/
field-broadsheet.html the original prototype — ground truth for behavior/visuals
dist/ build output (gitignored) — index.html + posts/*/ + sitemap/robots
| I want to… | Do this |
|---|---|
| Write a new dispatch | cp src/content/posts/_template.md src/content/posts/YYYY-MM-DD-my-title.md, fill in the frontmatter, write the body. It appears in the dispatch column automatically — see Dispatch ordering & Load More |
| Use bold/tables/callouts/footnotes/wikilinks in a post | Just write it — see Markdown formatting for the full syntax reference and the tone/type mapping for callouts |
| Make a post the lead (top of the page) | Give it num: "001" — whichever post has the lowest num leads. Renumber the others if needed |
| Add a travel dispatch | Set tag: travel and a city: (an IATA code from field.config.js → airports) on a post or brief — it routes to the Travel feed and that city's dossier automatically, and is excluded from the main dispatch flow |
| Add a photo | Add an entry to src/content/photos.yml; set city: to link it into that city's dossier; set src: to a real image path, or omit it for a picsum placeholder |
| Add a flight | One line in field.config.js → flights. The globe, plane animation, legend chips, flight stats, logbook command, and every city dossier all derive from this array — nothing else to touch |
| Add a new destination airport | Add it to field.config.js → airports (needs name, lat, lon, icao) before referencing it in flights |
| Move your home base | Change field.config.js → based (lat/lon) and move home: true to the matching entry in airports — weather, the globe's default view, the topbar city code, and the map-note copy all re-anchor from this one change |
| Change the accent color / palette | Edit field.config.js → tokens (never edit hex values in field.css directly — it only contains rules derived from these tokens) |
| Update your CV / experience / skills | field.config.js → cv (blurb, experience, education, specializations, skills, testimonial) |
| Update a panel (Now Playing, Reading, Challenge, Bucket List, Toys, Projects) | The matching key in field.config.js (nowPlaying, reading, challenge, bucket, toys, projects) |
| Change the About/Now pinned panels | field.config.js → about / now (HTML strings — <span class="fg">…</span> for the brighter inline color) |
| Change how the dispatch column is ordered/paginated | Edit the constants at the top of the flow section in src/js/10-render.js (FLOW_PANEL_KEYS, FLOW_PAGE_SIZE) — see Dispatch ordering & Load More |
| Retire a post without deleting it | Move it into src/content/morgue/ with a stamp of UNPUBLISHED/ABANDONED/UNFINISHED |
| Log a new experiment | Add a file to src/content/experiments/ |
| Change the site's SEO title/description/domain | field.config.js → site (title, tagline, description, url — url feeds canonical links, the sitemap, and JSON-LD, so set it before deploying) |
| Add social links | field.config.js → social (empty strings are omitted automatically from the Person JSON-LD's sameAs) |
| Change the OG image / Twitter card type | field.config.js → seo |
| Enable live Now Playing | Set up the Spotify proxy — see Spotify Now Playing |
Every key the site reads, in one file, grouped as they appear:
| Key | Shape | Drives |
|---|---|---|
identity |
{name, title, tagline, email, base, github, stats:{years,projects,countries}} |
Masthead byline, CV header, mailto links, GitHub activity username, CV stat line |
birthday |
ISO date string | Reserved for age-driven copy (not currently rendered) |
lastUpdated |
'YYYY.MM.DD' |
The "Now" panel's month badge (truncated to YYYY.MM) |
based |
{label, lat, lon} |
Weather fetch origin, globe default view anchor, visitor-distance-style features |
cv |
{blurb, experience[], education[], specializations[], skills[], testimonial} |
The entire Personnel File section |
airports |
{IATA: {name, lat, lon, icao, home?}} |
Globe labels, dossiers, city/fly/metar commands — exactly one entry needs home: true |
flights |
[{fl, from, to, date}] |
Globe arcs + planes, legend chips, flight stats, logbook command, dossiers |
projects |
[{st, name}] |
The "Projects · Tracked" panel (st is active/shipped/paused/shelved) |
about / now |
HTML strings | The two pinned panels above the dispatch flow |
hobbies |
HTML string | The Hobbies panel |
nowPlaying |
{title, artist, genre} |
Now Playing panel fallback (used when the Spotify proxy isn't configured or its fetch fails) |
reading |
{title, author, page, total} |
Currently Reading panel + progress bar |
challenge |
{name, day, total, startDate, active} |
Challenge/streak panel + progress bar |
bucket |
[{text, done}] |
Bucket List panel |
toys |
[{st, name, note}] |
Currently Using panel |
site |
{title, tagline, description, url, lang, vol, rev, est} |
<title>, meta description, canonical URLs, sitemap, JSON-LD, masthead volume/rev/est line |
social |
{email, github, linkedin, instagram} |
Contact panel, CV mailto, Person JSON-LD sameAs (blanks are dropped) |
seo |
{ogImage, twitterCard} |
Open Graph / Twitter card image and type |
tokens |
{bg, bg2, fg, ink, accRgb, acc, card, panelBg, overlayBg} |
The entire color palette — the fieldTokens Eleventy shortcode emits these into a <style>:root> block; field.css only ever contains rules derived from them |
All content lives under src/content/. Every type has a _template.md to copy.
Post, morgue, and travel bodies run through a full Markdown pipeline (see
Markdown formatting below) and render identically in
the in-page reader and the standalone /posts/…/ pages. Brief, quote, and
experiment bodies support the same inline formatting (bold, italic,
highlight, links, wikilinks) on their single line, but not block-level things
like lists, tables, or callouts.
| Type | Frontmatter | Notes |
|---|---|---|
posts (posts/*.md) |
id, num, tag, city?, date, size, title, excerpt |
num is fixed forever (deep-link stability). tag: travel + a city routes to the Travel feed instead of the main flow. size is lg/md/sm (title scale in the flow). Lowest num = lead post. date drives the dispatch column's reverse-chronological order (see below). Also builds a standalone page at /posts/<slug>/ |
briefs (briefs/*.md) |
tag, city?, order |
order sets the cycle position briefs are drawn from when the flow interleaves them (see below). Label ("CODE · BRIEF") is derived from tag, not authored |
quotes (quotes/*.md) |
attr, tag? |
Drawn into the flow by file order (1-based) as part of the same interleave cycle |
morgue (morgue/*.md) |
num, stamp, title |
stamp is UNPUBLISHED/ABANDONED/UNFINISHED. Morgue entries never get standalone pages and are excluded from the sitemap |
experiments (experiments/*.md) |
id, name, st |
st is active/parked/shipped/live. Body is the one-line description |
photos (photos.yml) |
s, src?, city?, cap |
s is a picsum seed used as a fallback when src is omitted; cap format is F-### · PLACE · LAT LON |
There is no authored position list — every non-travel, non-lead post appears
in the dispatch column (§01) automatically, computed in src/js/10-render.js:
- Order. Posts are filtered (lead post +
tag: travelposts excluded — they render in#lead-slotand the Travel feed instead) and sorted bydate, newest first. - Interleave. Every third post, one "extra" is woven in — a brief, a
quote, or a panel, cycling through those three kinds in turn — pulling
round-robin from the non-travel briefs (by
order), the quotes (by file order), and a fixed list of panel keys. This keeps the column's newspaper variety without anyone hand-placing each block; add more briefs/quotes any time and the cycle picks them up automatically. - Paginate. The computed list (
FLOW_ITEMS) is revealed a page at a time (FLOW_PAGE_SIZE, currently 9 items) — the rest of the archive is there, it just isn't dumped on first paint. Clicking Load More appends the next page in place (no navigation, no reload); the button hides itself once every item has been shown.
To change the cadence, edit FLOW_PANEL_KEYS / FLOW_PAGE_SIZE at the top of
the flow section in 10-render.js — nothing else needs to change.
src/content/posts/2026-08-03-a-note-on-markdown.md is a live example of
every feature below, rendered at /posts/a-note-on-markdown/ and reachable
from the in-page reader by number (open 017 in the command line, or #open=017).
Like every non-travel, non-lead post, it also appears in the main dispatch
column in its correct chronological position — see
Dispatch ordering & Load More.
One markdown-it instance (configured in markdown.config.js) renders every
post/morgue body — used both for the in-page reader's data and (via
eleventyConfig.setLibrary('md', ...)) the standalone pages, so a post looks
identical in both places.
| Feature | Syntax | Notes |
|---|---|---|
| Bold / italic / both | **text**, *text*, ***text*** |
Standard |
| Strikethrough | ~~text~~ |
Standard (markdown-it core) |
| Highlight | ==text== |
markdown-it-mark → <mark> |
| Headings | ##, ###, … |
Rendered at content-bucket sizes, never competing with real page headings |
| Lists (nested, ordered/unordered) | -, 1. |
Standard |
| Task lists | - [ ] / - [x] |
Display-only checkboxes (not interactive — this is published content) |
| Blockquote | > text |
Plain quote styling — see Callouts for the [!type] variant |
| Inline code / fenced code | `code`, ```lang |
Fenced blocks get a language-xxx class on the <code> for optional syntax-highlighting hookup |
| Tables | GFM pipe tables | Standard (markdown-it core) |
| Links | [text](url) |
Standard |
| Images | ,  |
Responsive by default. The optional third argument renders as a <figcaption> under the image |
| Horizontal rule | --- |
Standard |
| Footnotes | text[^1] … [^1]: definition |
markdown-it-footnote. Don't put a footnote reference inside a callout's title line — see below |
| Wikilinks | [[Post Title]], [[Post Title|Display Text]] |
Custom inline rule (markdown.config.js), matched against every post's title/id/slug. Resolves to /posts/<slug>/; no match renders as plain dashed-underline text, mirroring Obsidian's own "unresolved link" look |
| Callouts | > [!note], > [!tip], > [!warning], > [!danger], > [!note]- (collapsed), > [!note]+ (foldable, open) |
Custom source-level extraction (markdown.config.js), not a token-level plugin. Type maps to one of four tones reusing the site's existing status colors — see table below. Titles support inline formatting (bold/italic/wikilinks) but not footnotes (see above) |
| Comments | %% text %% |
Stripped entirely before rendering — never appears in either the reader or the standalone page |
| Table of contents | (automatic) | Any post with 3+ h2/h3 headings gets one automatically — a sticky right rail on the standalone page, a "Contents" toggle drawer in the in-page reader. See below |
| Bookmark cards | A bare URL alone on its own line | A rich preview card (title/description/image), Ghost-editor style. See below |
Callout tone mapping (CALLOUT_TYPES in markdown.config.js — add more
aliases there if you want them):
| Tone | Color | Obsidian types |
|---|---|---|
info |
blue #57b0ff |
note, info |
warn |
amber #e8a33d |
warning, caution, attention, todo |
danger |
red #ff5c57 |
danger, error, failure, fail, missing, bug |
acc (default) |
acid green | tip, hint, important, success, check, done, question, help, faq, example, and anything unrecognized |
dim |
neutral/italic | abstract, summary, tldr, quote, cite |
Long-form posts (3+ h2/h3 headings — markdown-it-anchor stamps an id on
every one) get a table of contents automatically, no frontmatter flag needed:
- Standalone page (
layouts/post.njk) — a sticky rail to the right of the article, scrollspy-highlighted as you scroll (rootMargin:'-20% 0px -70% 0px'). Hidden below 1100px — there's no room for a third column once the viewport narrows. - In-page reader (
70-reader.js) — a "Contents" button appears in the reader's top bar; clicking it slides in a drawer fixed to the right edge of the viewport (independent of the reader panel's own centered width), listing the same headings. Clicking one scrolls the reader to that section and closes the drawer.
Both read from the same toc: [{level, id, text}] array, computed once per
post in eleventy.config.js alongside its body.
Paste a URL alone on its own line (blank line before and after — the same gesture that turns a link into a bookmark card in Ghost's editor) and it becomes a card with the target page's title, description, and image:
Some paragraph of text.
https://ghost.org/
Another paragraph.
A URL inside a sentence, or written as [text](url), is never touched — only
a lone URL on its own line qualifies.
How it's fetched: eleventy.config.js runs an async prefetch pass before
any rendering starts — it scans every post and morgue file for bookmark-style
URLs, fetches each new one's Open Graph tags (og:title/og:description/
og:image, falling back to <title>/meta description), and caches the
result in .cache/bookmarks.json. That file is committed to the repo
(not gitignored) — once a URL has been fetched once, the build never needs
the network for it again, and a fresh clone builds identically offline. A
failed fetch (offline, 404, timeout) just leaves the line as a plain
auto-linked URL — nothing breaks the build.
To force a re-fetch (the target page changed), delete its entry from
.cache/bookmarks.json — or the whole file — and rebuild.
Deliberately not implemented — both are genuine Obsidian features, left out for reasons specific to a public website rather than oversight:
- Obsidian URIs (
obsidian://...) — only resolve if the visitor has Obsidian installed with this vault open locally; meaningless for a website. - Inline footnotes (
^[text]) — no established markdown-it plugin for this Obsidian-only variant. Use standard reference footnotes ([^1]/[^1]: …) instead; they cover the same need.
If you add a new markdown-it plugin, register it in createMarkdownIt() in
markdown.config.js — that one instance feeds both render paths, so nothing
else needs to change.
The palette lives only in field.config.js → tokens. The fieldTokens
shortcode in eleventy.config.js writes these into an inline <style>:root>
block at build time; field.css never contains a hardcoded hex value for the
core palette (a handful of semantic status colors — amber/blue/green for
paused/shipped/active states, plus the white osnap marker — are literal by
design, since they're not part of the token indirection).
Type follows a deliberate two-bucket system — don't flatten it:
- Content (anything a visitor reads — reader prose, CV blurb, excerpts,
titles) stays legible:
.7rem–1.6rem, never shrunk for density. - Chrome (labels, tags, kickers, coordinates, nav, the command line) stays
small and mono-uppercase-tracked on purpose:
.44rem–.6rem. It reads as an instrument readout, not text — don't "fix" it to a uniform accessible size.
There is no light mode — it was built and deliberately removed. Don't
reintroduce a body.dark/theme toggle.
The site ships two kinds of pages from one npm run build:
- The field (
/,dist/index.html) — the whole single-page experience. Every dispatch, brief, quote, and panel is rendered client-side from a data block Eleventy generates at build time (window.DATA,POSTS,BRIEFS,QUOTES,MORGUE,EXPS,PHOTOS,FLOW) plus the concatenatedsrc/js/*.jsmodules — see JS module architecture. - Standalone post pages (
/posts/<slug>/) — one real, crawlable, fully SEO'd page per dispatch, built viasrc/_includes/layouts/post.njk. The slug is the filename with any leadingYYYY-MM-DD-date stripped (Eleventy's defaultfileSlugbehavior). Its topbar matches the field's masthead exactly (same typography, same live clock/date/temperature — sharing the samelocalStorageweather cache), and its footer mirrors the field's status bar with a live clock and a reading-progress bar — but it intentionally skips the nav filters, ticker, and the cursor/command-line/ globe JS bundle. It's a reading page, not a second field.
They're linked together:
- Every post opened in the reader shows a "Full Page ↗" link next to Contents/Close, for anyone who'd rather read it as its own page (or share that exact link) instead of the overlay.
- Opening a post from the field (click, ticker, search, deep link) calls
history.pushStateto the canonical/posts/<slug>/URL, so the address bar is always shareable. Closing the reader restores/. Back/forward (popstate) re-syncs the overlay instead of reloading. #open=004(hash) on load jumps straight to that post number — a fallback deep-link format still handled bysrc/js/70-reader.js.#goto=travel/dispatches/photography/experiments/personnel/morgue/topscrolls to that section on load./sitemap.xmllists/plus every post (lead = priority 0.9,size: lg= 0.8, everything else 0.7) — morgue is excluded on purpose./robots.txtallows everything except/assets/and points at the sitemap.
Everything in src/js/ is concatenated by the fieldJS Eleventy shortcode
into one inline <script>, in ascending filename order — so it's all one
execution context (a function declared in 10-render.js is callable from
90-panels.js), but keep new code in the file matching its concern:
| File | Responsibility |
|---|---|
10-render.js |
Builds nearly everything from the data block: lead post, the flow column, pinned panels, ticker marquee, paged travel feed, photo grid, experiments list, CV (incl. the GitHub-activity DOM scaffold), morgue list, plus the status-bar clock/date/temperature |
20-cursor.js |
The CAD cursor: dot/ring/label, coordinate readout, End/Mid/Near osnap engine, crosshair, construction lines. Entirely gated on FINE_PTR && !REDUCED (desktop, motion-enabled only) |
40-globe.js |
Haversine/slerp flight math, flight-stat totals, globe.gl init (wrapped in try/catch → offline notice), city dossiers, legend chips with live ref counts |
50-activity.js |
GitHub activity fetch, cached 1h in localStorage |
60-cmdline.js |
The command line — all 19 commands (see below), history, tab-complete |
70-reader.js |
Reader overlay, lightbox, deep-link resolution, reading-depth tracking, the pushState/popstate routing described above |
80-fx.js |
Reveal-on-scroll, panel tilt, nav + filter, the once-ever boot gate, scrollspy, the live WIRE quote |
90-panels.js |
Fills in Now Playing / Currently Reading / Challenge — the dynamic bits inside panels 10-render.js already built the shell for |
Press / anywhere to focus it (Esc to blur, ↑/↓ for history, Tab to complete).
This set is deliberately trimmed — don't re-bloat it:
| Command | Does |
|---|---|
help |
List commands |
open <n> |
Open dispatch number n in the reader |
search <term> / find <term> |
Full-text search across posts + morgue, clickable results |
city <IATA> |
Print that city's dossier to the console output |
fly <IATA> |
Highlight that route on the globe for 7s + print distance |
logbook |
Print the full flight history |
metar <IATA|ICAO> |
Live METAR from aviationweather.gov, raw + decoded |
cv |
Scroll to the Personnel File |
quote |
Pull a fresh WIRE quote |
plot |
window.print() — the CAD title-block print stylesheet |
email |
Opens a mailto: to your configured email |
morgue |
Scroll to the Morgue |
boot |
Replay the boot sequence (clears the once-ever flag + reloads) |
clear |
Reset filters, selection, and command history |
rhino / sudo / hello / coffee |
Easter eggs |
The Now Playing panel can show your real currently-playing (or last-played)
Spotify track. Unlike Last.fm, Spotify has no endpoint you can call directly
from the browser with just an API key — "what's playing" requires OAuth, and
the token refresh needs a Client Secret that must never reach client-side JS.
So this is a small serverless proxy (api/nowplaying.js, written for Vercel)
that holds your credentials server-side and returns just
{isPlaying, title, artist, album, image} to the page. src/js/90-panels.js
fetches /api/nowplaying first and only falls back to field.config.js →
nowPlaying (then "SILENCE") if that proxy isn't deployed or configured — so
skipping this section entirely is fine, nothing breaks.
1. Register a Spotify app. Go to the Spotify Developer Dashboard, log in, click Create app. Any name/description is fine. Under Redirect URIs, add:
http://127.0.0.1:8888/callback
Save, then open Settings on the new app to copy the Client ID and Client Secret.
2. Authorize it as yourself (one-time, in a browser). Paste your Client ID into this URL and visit it:
https://accounts.spotify.com/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://127.0.0.1:8888/callback&scope=user-read-currently-playing%20user-read-recently-played
Log in and click Agree. The browser will try to load
http://127.0.0.1:8888/callback?code=... and fail to connect (nothing's
running there) — that's fine, the code you need is right there in the address
bar. Copy everything after code=.
3. Exchange that code for a refresh token (one-time, in a terminal — do this quickly, the code expires in ~60 seconds):
curl -X POST https://accounts.spotify.com/api/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=authorization_code \
-d code=THE_CODE_FROM_STEP_2 \
-d redirect_uri=http://127.0.0.1:8888/callback \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRETThe JSON response includes a refresh_token — save it, this is the long-lived
credential the proxy uses from now on (the access_token in that same
response expires in an hour and isn't needed again; the proxy fetches its own
fresh one per request).
4. Set three environment variables in your Vercel project (Settings →
Environment Variables), or in .env.local for local testing with vercel dev
(copy .env.example — it's gitignored):
SPOTIFY_CLIENT_ID=...
SPOTIFY_CLIENT_SECRET=...
SPOTIFY_REFRESH_TOKEN=...
That's it — redeploy, and the panel starts showing real data. npm run dev
(plain eleventy --serve) has no serverless runtime, so /api/nowplaying will
404 locally unless you run vercel dev instead; either way the panel falls
back gracefully, so this never blocks local development.
Using Cloudflare Pages instead? The same logic ports to a Pages Function
(functions/nowplaying.js, onRequest(context) instead of (req, res),
context.env instead of process.env) — ask if you want that version built
out instead of or alongside the Vercel one.
npm testRuns test/suite.js: loads the built dist/index.html into jsdom (stubbing
matchMedia, fetch, IntersectionObserver, scrollIntoView, PointerEvent,
canvas contexts, etc.), executes every script, and asserts:
- Zero window errors under both
FINE_PTR/REDUCEDpermutations. - Render counts (flow items, pinned panels = 2, travel-feed pager, photo strip = 10, CV = 2 columns, GitHub activity block present).
- Dossier ref-counts for every city, plus open/empty/home dossier states.
- The command registry is exactly the 19 above, and every removed command name is confirmed absent.
- Search → reader flow, the full Esc chain, a duplicate-ID scan, a
stale-reference scan for removed features (theme toggle, telemetry,
particle field, CSS-animated marquee), and heading structure (exactly one
<h1>, the masthead).
Run npm run build first if you've changed anything — the suite tests the
compiled output, not the source.
| Host | Build command | Output dir | Node | Notes |
|---|---|---|---|---|
| Vercel | npm run build |
dist (vercel.json sets this) |
20 | Also deploys api/nowplaying.js as a serverless function — needed for live Spotify (see above) |
| Cloudflare Pages | npm run build |
dist |
20 | Simplest for a pure static deploy; the Spotify proxy would need porting to a Pages Function first (see note above) |
Before going live:
- Set
field.config.js→site.urlto your real domain — it feeds canonical URLs,sitemap.xml, and the JSON-LD. - Generate
/assets/og-default.png(1200×630) and a favicon (see Caveats). - If you want GitHub activity beyond the public API's rate limit, add a
GITHUB_TOKEN-backed proxy in front of theapi.github.comcalls insrc/js/50-activity.js. - If you want live Now Playing, set up the three
SPOTIFY_*env vars — see Spotify Now Playing.
- Spotify Now Playing needs a one-time OAuth setup (see
Spotify Now Playing) and a host that runs
api/nowplaying.jsas a serverless function (Vercel, out of the box). Until configured, the panel falls back tofield.config.js→nowPlaying, then "SILENCE" — nothing breaks if you skip it. - Photos render from
photos.yml'ssrcif you set one; otherwise they fall back to a picsum.photos placeholder seeded froms. - GitHub activity (Personnel File) reads the public Events API, which only spans ~90 days — the 12-week heatmap is honest about that; a longer history needs an authenticated proxy.
/assets/og-default.png(1200×630) and a favicon aren't generated by the build — add real ones before deploying, or social shares will 404 the image.- The sample content (Prajwal's dispatches, CV, flights) is a working example to build and preview against — replace it with your own before shipping.