Game Watchlist (proto-watchlist)
- » info: proto-watchlist on Vercel
- » info: proto-watchlist on GitHub Pages
A personal PS5 watchlist, rebuilt as a data-driven page from two earlier hand-written
drafts (one by Perplexity, one by Grok). data/watchlist.yaml is the single source of
truth; build.py validates it and then generates both outputs — the HTML page and a
plain-text Markdown mirror. Repo eswat2/proto-watchlist (private), but both published
sites are public, so the YAML and the build script are publicly readable at the root
alongside the page.
Python is the only renderer. The page ships finished and does no rendering of its own —
no JSON payload in the HTML, no client-side templating. template.html is a complete,
valid HTML document with __TOKEN__ holes in it, so it can be opened directly to check
the chrome and CSS without running a build. Both outputs are rendered to strings before
either is written, so a failure in one renderer can never leave the two disagreeing on
disk. The build refuses to write at all if the data is bad: duplicate ids, unknown
commitment/ps5 values, fit scores outside 1–5, lanes that do not exist, backwards date
windows, missing window_label/trigger, an evidence: id that resolves to nothing.
Two checks go further than the data. check_hours() reconciles the playtime rows against
the totals their source files state, and check_nav() runs on the assembled page — it
compares the section nav against the sections themselves, by id and by link text, and
fails the build if the two have drifted apart.
There used to be a third output, artifact.html, a body-only variant for publishing the
page as a Claude Artifact. It cost a hand-assembled <head> and a split of the rendered
body at the first </style>, and in 34 commits it never once changed independently of
index.html — a 92 KB duplicate doubling every data diff. Removing it took out a whole
render path.
Two hosts serve the same commit, neither running a build step — both just serve
index.html and the self-hosted fonts from the root of main. A push updates both. The
page fetches nothing from anyone: the fonts are local, @font-face is inline rather than
behind a render-blocking stylesheet, and every remaining external URL is an <a href> a
reader clicks.
Three axes, deliberately independent
The thing the earlier drafts got wrong was conflating separate questions, which forced Grok's board to invent a code for "high interest, platform unconfirmed". Each is its own field:
| Axis | Means | Values |
|---|---|---|
commitment |
what has been decided | buy · preordered · owned · sample · watch |
blocked_on |
what is stopping the decision | a list; [] means nothing is |
stage |
what the row is | announced (default) · library |
"High interest but the platform is unconfirmed" is commitment: watch + fit: 4 +
blocked_on: [PS5 version], not a new status code. The first two axes each get their own
control on the page and they intersect, so "what am I committed to that is still blocked?"
is two selections. Every option is labelled with the count it would return, and options
that would return nothing are disabled rather than left as dead ends.
stage is the newest and the one most easily folded into commitment by mistake. Every
Library row was bought under some commitment, so what a game is and what was decided
about it have to stay apart for the same reason commitment and blocked_on do. The
consequence is a rule that runs through the whole build: every count reads incoming(),
never the full games list — the masthead total, the stat tiles, the filter counts, the
lanes, the footer, the recheck worklist. Buying a game must not make the number of things
you are waiting for go up.
Titles are also grouped into five taste lanes — Mass Effect-shaped sci-fi, cyberpunk and future cities, solo ARPG and dark fantasy, big worlds and systems, focused stylish action. Incoming filters and sorts three ways but never by lane, so the By purpose section is the only place the grouping is visible.
The date window is the content
Release dates are stored as a window: [start, end] span rather than a date, and the span
is the claim: the page draws each window as a bar on a shared axis, so a confirmed day is a
tick and a bare "2027" is a wide ghost bar. Scanning that column shows how much of the list
is guesswork. A window wider than 45 days is treated as soft — it gets "~12-month window"
instead of a countdown. window: null means genuinely unscheduled. Four bar forms are the
whole vocabulary: a tick, a plain bar, a faded fuzzy bar, and a dashed full-width bar.
The axis is strictly linear and labelled by quarter, with January carrying the year. Year-only ticks left everything between today and 1 Jan 2027 unlabelled, which is exactly where most of the confirmed dates sit — the rows that mattered most were the ones you could not place. The scale was never the problem; the labelling was.
The lead stat tile is "Next decision", not the next arrival: the soonest firm window among rows still undecided. Naming the soonest arrival made the page's most prominent card repeat its own first row — the default sort is release order, so they were the same title — and pointed it at things that ask nothing of you, like a preordered game or a free remaster of one already owned.
A window in the past means two different things, and the split is the point. A firm date
passed is _released: it is out, a real event. A soft window passed is _elapsed:
only a guess expired, nobody announced anything, and the page must not claim a release.
_released narrows again — preordered and owned already made the call, so they read
"out now" while buy/watch/sample read "released, decision due". Billing a preordered
game as owing a decision forever was the bug that prompted the split; on the data at the
time it would have mislabelled five rows out of six.
Provenance as a first-class field
Every window carries a verified: date and a source: naming what it was checked against
— the publisher's own announcement where one exists, Wikipedia's infobox otherwise, and
owner for first-hand knowledge. That shows up per row, in the header stats, and as a
filter. The original drafts cited nothing; the first pass against real sources found two
dates wrong and one title whose announced date had been fabricated outright.
Three rules came out of that pass. Aggregators overstate — when a secondary source is
more specific than the publisher's own words, the publisher wins. Never draw a window
narrower than its source: "Spring 2027" is Mar–Jun, not Q2, and the bar width is the
claim. "No date announced" is a finding, not a failure — window: null +
window_label: TBA is a correct outcome and still earns a verified: stamp.
The same discipline applies to two optional fields that are easy to guess wrong. studio:
is the developer, not the publisher — six of the twenty-two rows that existed at the
time came back wrong when guessed from the name on the trailer. home: is the best page to send a reader to, in the
order official site → Steam → anything else, and searching <game name> official website
rather than putting the studio in the query is what surfaces it; two rows were first filed
under their studios' pages because a dedicated site existed and the studio did not link to
it.
Dates decay, so rechecking is a standing chore rather than a one-off: build.py --stale
prints what is due — soft windows, anything landing inside 60 days, anything last checked
over a week ago — and --stale --check exits 2 when a check is overdue. That is
deliberately a narrower question than being on the worklist, most of which is structural
and would fail every run forever. The procedure is written down in RECHECK.md to be
handed straight to an agent.
What happens after you buy it
stage: library moves a row out of Incoming and into its own section, reusing the manifest
row with the track and countdown removed — both answered "when does it arrive", which is
settled, and the bar would be actively misleading, since bars clamp at the axis start and
every older purchase would draw against the left edge as though it had all landed the same
week.
played: is what has happened since — one evolving line, Library-only, rendered as So
far directly under Bought because. It is overwritten rather than appended: the
question is the current standing, not the history of getting there. The pair is the
Library's whole answer to did this buy hold up, and it is meant to say what is still
unanswered as readily as what is settled.
Evidence, Reference, and the hours record
reference: is a frozen snapshot of the library that predates the list, and evidence: is
a list of its ids naming the comparison points that justify a row's fit. It renders as a
line in the detail panel linking down to the table, and the table carries the inverse
("Explains") computed by explained_by() — authored one way, read both, so neither
direction is maintained by hand. Only links the prose already supports get added; an empty
evidence: is honest and an invented one is not. Evidence resolves against the Library as
well as Reference, because a game bought off this list is the best evidence there is for
the next row.
hours: is a different thing again, and the two must not be merged: played: is a
sentence about a game currently being played, hours: is a number for every game ever
played. It is transcribed from two CSVs in _arkive/ — a PlayStation export and a partial
Xbox record recovered later — and build.py does not read them. Each file states its
own total instead, and check_hours() reconciles the sum of the rows against it, so a
mistranscribed number fails the build rather than publishing a chart that disagrees with
its own source.
It is a wider set than Reference on purpose: Reference is the subset that earns a
comparison note, this is everything that was played. Leaving a 534-hour title out of a
chart of hours because it never earned a note would misstate the record. cut: is the
threshold below which a title is counted but not drawn — 50 hours, keeping 24 of the 43
titles and 99% of the time — and build.py generates the sentence summarising what fell
below, so the summary cannot disagree with the bars. unrecorded: names a platform whose
time no file covers and draws a fixed-width hatched tail, fixed because the quantity is
genuinely unknown; a bar whose width claimed a value would be the same mistake as drawing a
release window narrower than its source. Every total on the chart is a floor and the hatch
is how the page says so.
Like Reference, it is a snapshot, not a series — there is no clean way to re-pull the counts, so it is a dated reading refreshed when someone sits down and reads them off again.
The interactivity is CSS
The only JavaScript is the theme toggle, which needs localStorage to remember a choice
and needs to read it before first paint to stop the wrong theme flashing. Everything else
is CSS over static markup: native <details> for row expansion, radio inputs plus :has()
for filtering, order: var(--r-…) over ranks precomputed in Python for sorting, and a CSS
counter for "N of M shown" — a display: none row increments nothing. Sorting moves boxes,
not DOM nodes, so screen-reader and tab order stay in release order.
Two consequences are worth stating because both have been asked for and declined. There
is no text search — everything is in the DOM, so the browser's own find covers it, and a
search box means adding JavaScript back. That is also why the sections are not collapsible:
find-in-page only opens a closed <details> in recent browsers and does it unevenly, so
collapsing would stake the page's whole search story on that. And filter counts are
per-axis totals computed at build time; they do not react to the other filter, because
nothing on the page can recount.
The section nav is a drawer on :target, and its mechanism is a deliberate departure from
the one it borrows its look from. The styling is ported from the hamburger on this repository's own /ten-sudokus page
(see projects://sudoku-comparison), which uses a
checkbox-hack toggle — that works there because its links go to other pages, so a click
destroys the drawer by navigating away. These links go to sections of the same page, and a
checkbox stays checked across an in-page jump, which would land the reader behind an open
panel. :target has no such problem. Its own cost is that closing is also a navigation, so
a 0×0 fixed-position #nav-shut absorbs it and scrolls nowhere. The panel is
visibility: hidden when shut rather than merely translated off-screen, because six links
in the tab order behind an off-screen panel is a keyboard trap.
Written August 2026 and substantially revised 6 September 2026, after 44 commits added the
stage axis and the Library, the Hours played record, the Reference/evidence pair, and the
section nav, and removed the artifact.html output. Figures — 29 rows across 28 Incoming
and 1 Library, 5 lanes, 27 Reference entries, 43 playtime titles with 24 drawn — are read
from data/watchlist.yaml on that date and will drift; the repo is the source.