Game Watchlist (proto-watchlist)

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 failurewindow: 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.