Work gamingworld
service

Gaming World

An indie-only game platform — reviews by people who played the game, AI-drafted previews, a release calendar, hosting, emulator and handheld guides, a daily news desk. No ads, no AAA noise.

Gaming World UK is an ad-free, community-run platform that promotes independent game developers — reviews written by people who played the game (scored out of 100, now leading the homepage), AI-drafted previews for games nobody on staff has played yet, a release calendar with a Steam Next Fest countdown, hosting on `*.gamingworld.uk` subdomains, emulator and handheld guides with hardware reviews, and a small in-house studio shipping original projects. Submissions arrive by mail and an AI inbox scanner classifies them for staff triage; a roster of AI writer personas drafts previews from trailers, store pages, and hunted screenshots — openly speculative, labelled as AI-written, never claiming hands-on time. A daily news desk covers the beat the big sites skip. Volunteer writers claim games from a shared desk, publish their own work, and are paid in site credits rather than money — an append-only ledger, ranks, a leaderboard — with comments, votes, public profiles and a staff page behind every byline. Built as a TypeScript SPA on top of a Go (Fiber) API + PostgreSQL, with a Python toolchain feeding the emulator database.

Last updateOct 30, 2026 PrimaryGo
  • TypeScript
  • React
  • Vite
  • Go
  • Fiber
  • PostgreSQL
  • WebAuthn
  • WebSockets
  • OpenRouter
  • Postmark
  • Tiptap
  • Python
  • Tailwind CSS
  • nginx
  • systemd
Gaming World — An indie-only game platform — reviews by people who played the game, AI-drafted previews, a release calendar, hosting, emulator and handheld guides, a daily news desk. No ads, no AAA noise.
Gaming World media
Gaming World media
Gaming World media
Gaming World media

Gaming World is built on a single, opinionated thesis: independent games deserve a platform that only covers independent games. No AAA bleed-over, no franchise news, no engagement-bait listicles — just a curated feed of what actually-small studios are shipping right now, the people behind them, and a free runway to host their work.

What the platform does

  • Honest indie reviews — written by people who played the game, scored out of 100, and now the first thing on the homepage after the news, with Reviews in the top bar. A writer publishes their own piece from the desk, and the studio is mailed once when a review of their game goes live, with an invitation to report factual errors. Every game that ships on the platform gets a real write-up, not a syndicated press release. Submissions arrive by mail, and an AI inbox scanner sweeps the queue every fifteen minutes — classifying each message, pulling cover art from the Steam API where it can, and landing the result in a two-pane staff mail client where an editor claims it over a live WebSocket. The machine does the sorting; the human does the words. A game nobody pitched can be added to the queue by hand and is labelled a find, so it is never passed off as a pitch a studio sent in.
  • AI-drafted previews — for games nobody on staff has played, a roster of nine AI writer personas (each with their own beat, voice, and byline) drafts a preview from the trailer, store page, and hunted screenshots alone. The voice guide is explicit that nobody has played the game, and a lint pass catches drafts that drift into hands-on language. Every byline says "AI writer" and every piece carries a link to a standing note on how previews are made. More than 180 are published, with artwork interleaved through the prose instead of dumped in a grid at the end, and where a developer has answered Steam's or itch.io's "did this use generative AI" question, the answer is shown word for word, linked to its source, never graded. A studio that writes back can correct the facts, and the correction is held on the queue item rather than patched into the page, so the next regeneration keeps it instead of writing the error back.
  • Subdomain hosting on *.gamingworld.uk for a submitted game's marketing site — templated vhosts, auto-issued certificates, and the dev keeps full ownership of the content. Most of the subdomains in use today are in-house rather than submitted work, including projects that are not games at all.
  • A release calendar at /calendar — read from each studio's own release line rather than a store feed: what launches this week and by month, demos you can play now, what just came out, and a countdown to Steam Next Fest with the games the desk has already previewed. A "Coming up" strip on the homepage carries the same dates.
  • Emulator & handheld guides — a curated database of emulators and the handhelds that run them, fed by a Python toolchain and vendor spreadsheet syncs, because "how do I actually play this" is half of indie preservation. Writers now review the hardware too, and a handheld's page lists the desk's own reviews of it.
  • An in-house studio shipping original work (Tulpa, Revamp, Mall World and friends) under the same brand, to keep the platform honest about what "indie" actually means. Mall World has its own section — a hub and six pages on its systems — marked as ours rather than sitting among previews of other people's games.
  • A news desk — one multi-source article a day on emulation, homebrew and indie development, the beat the big sites skip, with reader tips arriving through a public funnel, a publish gate for hand-written pieces, an RSS feed, and designed fallback art for stories that ship without an image. The gatherer reads a studio's own Steam announcements in full, checks each source's publish date, and refuses to write up a fortnight-old repost as news.
  • Volunteer writers and a credit economy — recruiting at /join, then a writers' desk where a person claims a game before the AI desk gets to it (the machine waits out a head start), drafts a human-written preview in the same editor, and sends it to an editor. The pay is Gaming World credits, not money: an append-only ledger pays per published piece, for being first to cover a game, for an editor's pick and for keeping pace, with lifetime ranks on every byline and a public leaderboard. Spends — claiming a game out of the queue, making the desk chase a studio for a key, a week on the front page — are filed as requests the desk honours by hand.
  • A community layer — comments and upvotes on every published piece (rate-limited, injection-scanned, fanned out live over the WebSocket), public profiles behind every avatar, a person card on hover over any byline, a /staff page listing the writers, the people who run it and the AI desk with everything each has published, and a presence stack showing who else is reading the same page. Share panels and bot prerendering with JSON-LD keep each piece legible to crawlers and link previews, and a plain-language privacy page says what is recorded and for how long.

How it ships

The frontend is a React + Vite + Tailwind SPA — fast, static-deployable, and small enough that the entire bundle warms in a few hundred kilobytes. Behind it is a Go (Fiber) HTTP API backed by PostgreSQL (with a small library of PL/pgSQL functions for the heavier queue queries), passkey login via WebAuthn, and role-based staff permissions. A Python sidecar handles emulator ingestion, image normalization, and OG-tag scraping. Everything runs under systemd on a single VPS behind nginx (HTTP/2, gzip, CSP); deploys stage the new binary, poll its health, and roll back automatically if it doesn't come up — with nightly database dumps shipped to cloud storage and mail in both directions (submissions in, studio notes and writer digests out) through Postmark.

Why we built it

The big games-coverage sites stopped covering independent work seriously a long time ago — small studios get one slot a quarter if they're lucky, buried under whatever publisher-funded preview is trending. Gaming World takes the opposite vow: AAA studios are explicitly out of scope. If a team of more than ten people is making it, it doesn't belong here. The platform is the editorial line.

Straight from the source

The project's own README.

Rendered in place — every link, image, and code block carried over from the repo. The page below is what a contributor would see opening the project for the first time.

Changelog

All notable changes to Gaming World are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[0.1.158] - 2026-10-05

Added
  • Calendar art (plan 26): gw-genassets -group calendar, 17 transparent cut-outs on openai/gpt-image-2 matted with birefnet ($1.17 including rerolls). Twelve wide month scenes (scene() jobs, 1536x1024, new sceneStyle vignette brief), Next Fest festival, page hero, and icons for This week / Just out / Demos. Handhelds are briefed as "unbranded": the first rolls drew Nintendo Switches. SITE_ART.calendar + monthArt(); scenes break out of month headings, the Next Fest banner and the homepage fest link. No Valve branding used.

[0.1.157] - 2026-10-05

Added
  • Release calendar at /calendar (plan 26 A). ReleaseEvents() reads each clause of a published preview's release line into dated events (release, demo, Early Access, Next Fest; day or month precision), calibrated on all 187 live lines. GET /previews/calendar returns games with a date to come, a date in the last 14 days, a demo, or a place in Steam Next Fest (19 to 26 Oct 2026), with the slug of any published review of the same game. Sections: Next Fest with countdown, this week, by month, just out, demos to play now.
  • Homepage "Coming up" strip (sideways scroll with snap on phones) and Calendar in the top bar. Bot prerender + sitemap entry for /calendar.

[0.1.156] - 2026-10-04

Changed
  • Preview lint: the tic forms of plain "doing the work" are now caught under stock personification ("letting the wait do the work", "What's doing the work here", "sparks doing the work an effect normally needs", "a filter doing the work." ending a sentence). Literal uses still pass ("your ears doing the work instead", "No clock does the work for you"). Found in CELESTAY's first draft (S20b); corpus had 3 more live, all hand-reviewed -repair (Subject 404, King of Words, Mythchiefs). Corpus 0 of 212.

[0.1.155] - 2026-10-03

Added
  • gw-huntimages -id X -set-title "Name": rename a game on its queue item and preview (slug and URL stay). The Blitz: Tiny Arcade rename Jack approved on 30 Sep had no route but raw SQL, which the desk cannot run.
  • gw-huntimages -id X -split "Second game": file the second game of a two-game pitch as its own queue item, copying studio, contact address, platforms and price, so the studio is still noted when it publishes. First use: Coachable's Spades, split from Solitaire Quest.
Changed
  • Solitaire Quest draft rebuilt from the six gameplay screenshots Coachable sent: the studio-site "press kit" link (CRM screenshots, promo renders, another game) was filling all nine hunt slots ahead of the captures, so it was removed and the gallery cut to the game's own screens; fact sheet narrowed to Solitaire Quest; regenerated from a spec, three hand repairs.

[0.1.154] - 2026-10-03

Changed
  • Homepage: Reviews now sit directly under the news carousel, above Previews, and lead with the newest review as a wide featured card (big splash, score out of 100, synopsis, "Read the review"), then up to four more in a grid. Skeletons hold the space while it loads, so Previews no longer jump. New ReviewFeatured and shared ReviewSummary type.
  • Top bar: Reviews moved out of More into the main bar, before Previews (Home, News, Reviews, Previews).
Fixed
  • Bot prerender description for /reviews said "a score out of ten"; scores are out of 100.

[0.1.153] - 2026-10-03

Changed
  • Preview lint: the stock-personification rule also catches "a lot of the talking" (and "a great deal of / plenty of the" talking, selling, explaining, storytelling). Found as a section heading in Shattered Realms's first draft; 8 live pages had it, one as a headline (The Carcass). All 9 hand-repaired, corpus 0. Desk review before publishing: Wizzo PopOut's hero was the studio's other game (Cosmic Arcade Tycoon) and 6 gallery images came off the studio site, all dropped; its "bathtub" is the Tuna map, fixed. For Good Measure's hero was a bare brick texture, now a level. Shattered Realms's off-screen markers were described as tags on enemies, with distances not on screen; fixed from the screenshot (plan 14 S20b).

[0.1.152] - 2026-10-02

Changed
  • Preview lint: "is doing a lot of work" now also catches one word in between ("a lot of quiet / narrative / unstated work", "a lot of the work"). Found in two Coachable first drafts; 8 live pages had it, one as a section heading. 11 repairs read and stored (1 heading by hand), corpus 0. PokEmerald Online draft: "fourteen" menu entries listed sixteen, now "more than a dozen" (plan 14 S20b).

[0.1.151] - 2026-10-02

Changed
  • Preview lint: the stock-personification rule also catches "is doing a lot of talking" (or a great deal of / all the talking, selling, explaining). Found in Pixel Metropolis's first draft; 8 more pages had it, two as section headings ("The HUD is doing a lot of talking") and one as a headline (2048 Trader). All 9 repaired after reading each pair (2 by hand), corpus 0 (plan 14 S20b).

[0.1.150] - 2026-10-02

Changed
  • Preview lint: the "I keep coming back to" rule also catches "I keep assuming / wondering / imagining" (TRENCHWORMS first draft: "a thin white line that I keep assuming is a rope"; the only hit in 204 previews). The same draft's headline and closing were hand-repaired: the screenshots being against bots was framed as a doubt about the game rather than a question for launch (plan 14 S20b).

[0.1.149] - 2026-10-02

Added
  • gw-abpreviews -lift-key MAIL -id GAME -appstore lifts Mac/iOS App Store promo codes (twelve capitals and digits on a line of their own, mixed letters and digits) onto a game's review keys, the way Steam keys already were. Tamadochi's code (ByteRove) was the first; until now an Apple code needed a hand database write. Apple codes die 28 days after they are made.

[0.1.148] - 2026-10-01

Changed
  • Preview lint: new "stock personification" rule for the wider family of the "doing a lot of work" tic: "X is doing the talking", "doing its job", "doing the job of a cold open", "doing the actual selling", "not doing that work yet", "the one doing the (most) work". Found in two first drafts of 1 Oct (An Unreasonable Number of Zombies, World Leaders Fight) and Genuine Article's that evening; 38 hits over every preview, all but one the tic. 37 live/draft pages repaired (35 by -repair after reading every pair, 9 of those hand-edited; 4 headings and lines by hand), corpus 0 failing. "Your ears doing the work instead" still passes. Wizzo PopOut draft: release past (29 Sep), now "Out now" and "Grab it" (plan 14 S20b).

[0.1.147] - 2026-09-30

Changed
  • Preview lint: the "doing a lot of work" stock-phrase rule also catches its successor, "is doing most/all/much of the " ("atmosphere is doing most of the selling"). Checked against every preview: 2 hits, both the tic, both hand-repaired (Deadly Prompt live, ZORELO draft). "doing most of the steering" without "is" still passes. TapToFlip draft: invented "lily pads" and a sign-off field that never matched its linked body line repaired (plan 14 S20b).

[0.1.146] - 2026-09-30

Fixed
  • News desk corroboration no longer adds Reddit or Hacker News threads found by title-word overlap. The D7VK 2.3 piece (29 Sep) listed "Star Wars Dark Forces N64" and an iTunes API question as sources because the story's one item had a 40-word title. Threads still ride along when triage groups them with the story (plan 14 S46).

[0.1.145] - 2026-09-30

Fixed
  • GET /user/credits now returns the chosen frame, so the account page's frame picker no longer falls back to "None" on refresh (the choice was saved; only the summary left it out). Reported by a writer.

[0.1.144] - 2026-09-29

Added
  • Plan 25 chunk D: admin Staff tab (people listing and titles, interview question bank, interviews board), staff.manage gated.
  • Migration 065 seeds the 37-question interview bank.
  • gw-interview CLI: invite, pick and mail questions, ingest replies, draft skeleton and staff news draft, send for approval, record approval, decline, withdraw (docs/features/interviews.md).
  • /desk/guide#interview (content policy) and a /privacy section on interview data and withdrawal.
Changed
  • A staff news article publishes only once its interview is approved (gate in NewsRepository.Publish, every publish path).
  • The publish lint reads a staff article's <blockquote> answers for privacy only, not house voice.
  • The AI inbox scanner skips mail from anyone with an interview under way.

[0.1.143] - 2026-09-27

Fixed
  • A review's category scores were dropped by its first save (reported by a writer): the create handler never stored scores, only update did, and the editor then reloaded the saved review without them. The overall rating was kept, which is what made it look like a partial save. just uitest-journey sets a category before the first save and reads it back twice.

[0.1.142] - 2026-09-27

Fixed
  • The "Publish this review?" box on the writers' desk was drawn inside the sticky action bar, about 50px tall with its text and buttons cut off (reported by a writer). The bar's backdrop-filter made it the containing block for the dialog's position: fixed. Modal now portals to <body>, which fixes every dialog opened from the bar (publish, take down, release). just uitest-journey measures the box.

[0.1.141] - 2026-09-27

Added
  • Plan 25 chunk C: the person card. Hovering or focusing an avatar or a byline name (previews, reviews, news, comments, the Staff page) shows who it is: face, name, title or role, what they have published, "View profile". It reads the public profile endpoint, once per person per page load, so it exposes nothing new. Byline names now link to the profile.
  • On a phone a tap opens the profile as a bottom sheet (Modal sheetOnPhone).
  • just uitest-staff checks the hover card, that it closes, and the phone sheet.

[0.1.140] - 2026-09-27

Added
  • Plan 25 chunk B: the /staff page (writers, admins and moderators who opted in, the AI desk; All / Writers / Admins / AI desk switch; count chips drawn from the API's registry, so a new kind of work needs no web change). "Staff" in the More menu, linked from /about and /join.
  • Profiles get About (own links, interview) and Work (everything live, filter by kind, paged via /users/:handle/activity) tabs; ?tab=work is shareable. The card's fixed Reviews/Previews boxes became registry chips, and a listed admin wears their title.
  • Settings: "Links and the Staff page", up to six links and "Show me on the Staff page".
  • Bot prerender for /staff and in the sitemap.
Fixed
  • Handles with a space: the profile prerender, nginx /u/ location, sitemap and preview JSON-LD author URL now accept and escape them (Simon Rogers, Syauqi Putra got the bare SPA shell).

[0.1.139] - 2026-09-27

Added
  • Plan 25 chunk A, the Staff area's API. Migration 064: users.staff_listed / staff_title / public_links / hide_from_staff, news_articles.subject_user_id, interview_questions, interviews, permission staff.manage (admin + super_admin). Jack listed as Founder.
  • Contributions registry (repository/contributions.go): reviews, hardware reviews, previews, videos, news, comments, each one SELECT of public rows only (tested). GET /staff (sections: writers, admins and moderators who opted in, the AI desk; cached 5 min) and profile counts both iterate it, so a new kind of work is one entry.
  • GET /users/:handle/activity?type=&page=; profile gains sources, links, staff_title, interview_slug. GET /user/profile/staff, PUT /user/profile/links (http(s) only, max 6), PUT /user/profile/staff-visibility, POST /admin/users/:id/staff (staff.manage).
Fixed
  • /users/Simon%20Rogers and /users/Syauqi%20Putra were 404: Fiber leaves path params escaped, so two of three writers' profile pages never loaded. HandleParam decodes.

[0.1.138] - 2026-09-27

Added
  • Lint: the stock phrase's comparative, "is doing more (design) work than", joins the rule. It was on 19 live pages and reappeared in Goblin Maze's regen within the hour of v0.1.136; all 19 hand-repaired (bitiverse's "any image I've been sent" too). Goblin Maze (plan 14 S6b) published from our own captures.

[0.1.137] - 2026-09-27

Added
  • Lint: "I keep trying to read / work out / make out" joins the desk's-tic rule (Zone//Zero 09-24, BOOOM44 09-27; corpus 0 after repairs).
  • scripts/capture-browser-game.js: Playwright capture for a canvas game that needs a click to start. BOOOM44 (plan 14 S28) published from our own captures.

[0.1.136] - 2026-09-27

Added
  • Lint: "X is doing a lot of work" (with "doing real work", "does a lot of work", "the heavy lifting") is the desk's current stock phrase (plan 14 S20b). It was in all three first drafts of 2026-09-27 and on 34 live pages; all 35 sentences hand-repaired for free (no model), each replacement varied so no new stock phrase forms. Plain "doing the work" is allowed and is the sibling to watch. Repairs in /srv/gw-drafts/2026-09-27-s20b/.
Fixed
  • -repair apply on a headline or tagline whose text was not there printed STORED and changed nothing; it now refuses, as the body already did.

[0.1.135] - 2026-09-27

Added
  • Desk captures: gw-huntimages -id X -add-image a.png,b.png copies the desk's own screenshots into /srv/uploads/captures/<id>/, a new capture source every hunt reads (outside previews/, so a hunt never deletes it). For a free browser game whose studio sent no art (plan 14 S7): ClueRoll captured in headless Chromium, drafted with a desk note that the screenshots are ours and not to solve the live puzzle, published and noted.
  • gw-abpreviews -sync-facts <preview> copies the queue item's release and price lines onto a preview without a regen, so a date fixed with gw-huntimages -set-release after hand repairs does not cost a paid regen that throws the repairs away. First use: OMG: Oh My Gravity, whose studio mail gave BOB LOGISTICS's 2 Nov date; Steam and the studio site say Q1 2027. It now also copies genres, platforms and the Steam app id.
  • gw-huntimages -id X -set-genres "Action,Strategy" -set-platforms Windows ("-" clears). The scanner filled both once and Steam enrichment only fills empty lists, so Cat's Wars stayed an "Action, Shooter" game with no platforms until SQL. Reaches the preview with -sync-facts.
Changed
  • gw-abpreviews -set-signoff swaps from any of the three sign-offs, not only "Wishlist it if": a game that launches after its preview moves "Keep an eye on it if" to "Grab it if".
Fixed
  • gw-newsdesk -days N fills N days (each up to -per-day), not N slots, and the monthly Top 5 no longer counts as a desk article (desk and just replies). Closes the plan 14 S36 leftovers.
Docs
  • Plan 22 (writers ready) finished and deleted: the third applicant, Syauqi Putra, linked 2026-09-25, so all three /join applicants are writers.

[0.1.134] - 2026-09-27

Changed
  • Postmark's weekly digests and newsletter archive themselves (plan 14 S11), like DMARC reports: "Weekly Digest for …" from @postmarkapp.com and anything from @fyi.postmarkapp.com, with no inbound alert. Account mail from the same support@ address ("We're reviewing your Postmark account", "Action Required") still arrives unread. Matched exactly the 38 digests and 5 newsletters in the mail log; the 37 left unarchived were archived by hand.

[0.1.133] - 2026-09-27

Fixed
  • Steam data was silently missing since about 2026-09-24. Steam's appdetails reply now files the answer under an unrelated number (asked for 4529640, keyed
    1. while data.steam_appid is still the app asked for, so FetchSteamAppDetails found nothing and the scanner, image hunt, preview writer, -top5 and gw-pricefill all carried on without Steam (50 of the corpus's app ids failed in one dry run). It now takes the entry whose steam_appid matches.
  • gw-abpreviews -dedupe-gallery -near took a contrast ratio while its help said "6" (RMSE units), which would have emptied 166 galleries. Recalibrated by eye on the corpus and capped at 0.1; 43 duplicate images dropped from 13 live previews (plan 14 S22).
Added
  • gw-pricefill reads the App Store page (plan 14 S24): its serialized-server-data block states isFree and hasInAppPurchases per app, which itunes.apple.com/lookup does not. NiteLore and PAPERJAM were listed as plain free but have in-app purchases; Blitz Arcade, Space Academy Idle and Moleculox had no tier at all. Source appstore.

[0.1.132] - 2026-09-26

Changed
  • Submissions stay true (plan 14 S8). Mail to reviews@ is no longer copied into review_submissions when it is a reply or forward (Re:, Fwd:, AW:, SV:, Antw:) or comes from @gamingworld.uk; S8 found writers' replies and our own mail sitting "pending". Having an account is deliberately not a test (PlayHumanIntuition's developer had one). A pending submission becomes accepted when the scanner files it into the games queue (declined if the blocklist dismisses it) and completed when its preview publishes; a staff decision is never overwritten.
Added
  • gw-newsdesk -drop-gallery <slug> -image /uploads/... removes one gallery picture. Gather took two sidebar thumbnails (STEINS;GATE, Prince of Persia) from a GamingOnLinux page for the 26 Sep Proton story.

[0.1.131] - 2026-09-26

Added
  • Hardware reviews (plan 24 D2, Jack 2026-09-26: no claims for hardware). Migration 063 adds reviews.handheld_id (a review names a queue game or a handheld, never both; FK RESTRICT). The writers' desk has a Hardware section (GET /desk/hardware?q=): search a device, Review it, or Open your draft; one open draft per writer and device, no claim, two owners may review the same device. The editor labels the subject "Device" and fills it from the database. A handheld's page shows Our reviews (GET /handhelds/:id/reviews, published only), and the sheet sync never deletes a reviewed device. Guide section "Hardware reviews". just uitest-hardware walks it as a throwaway writer and never publishes.

[0.1.130] - 2026-09-26

Fixed
  • The news desk no longer writes up a repost as news. Triage picked a GamesIndustry.biz piece from 10 August because it was shared on r/gamedev this week, and the writer was shown the Reddit post's date. Gather now reads each page's own date (article:published_time / JSON-LD datePublished), shows it to the writer, and refuses a story whose dated first-hand sources are all more than 14 days old (an undated source still counts as fresh).

[0.1.129] - 2026-09-26

Changed
  • Handhelds keep their id across syncs (plan 24 D1). The daily sheet sync deleted every handheld and inserted the sheet again, so every id changed daily and nothing could point at a device for long. Migration 062 adds handhelds.natural_key (lower(oem)|lower(name)|year, #2 for the two devices the sheet lists twice) and the sync now upserts on it; only devices the sheet drops are deleted, and an empty sheet is refused. Checked on a copy of prod: 586 of 587 kept their ids across two syncs, the dropped one went.

[0.1.128] - 2026-09-26

Fixed
  • The news desk reads a studio's own Steam announcement (plan 14 S36). A store.steampowered.com/news/app/N/view/G page is a JS shell, so the Cat's Wars draft of 2026-09-25 said the studio "gave no details". Gather now reads the post through ISteamNews/GetNewsForApp (full text and pictures, news_steam.go), marks the developer's own posts OFFICIAL, and the writer brief attributes an OFFICIAL source to the studio instead of doubting it shipped.
  • Hand-filed items no longer reach the writer labelled "via Reader tips" (draft 1 called a press release "a reader tip"), and the brief forbids guessing where a person or studio is based.

[0.1.127] - 2026-09-25

Added
  • The studio hears when a review goes live (Jack 2026-09-25). Publishing a review (editor or the writer themselves) mails the game's queue contact once, from previews@ and signed by the desk: the writer's name, the link, a thank-you for the key when that writer was issued one, and an invitation to report factual errors. Migration 061 adds reviews.studio_noted_at / studio_noted_mail_id; the note is taken before the send so republishing never mails twice, and given back if the send fails. Replies are recognised as our thread by the scanner. gw-devnotes -reviews [-dry] backfills and retries; a hand-sent mail carrying the review link is recorded instead of repeated.

[0.1.126] - 2026-09-25

Changed
  • "Ask an editor first" is an outline button in the review editor too, as in the preview editor, so Publish is the one filled action for a writer.

[0.1.125] - 2026-09-25

Added
  • Writers publish their own work (Jack 2026-09-25). Migration 060 adds reviews.publish.own and previews.publish.own (writer, staff, admin, super_admin). A writer publishes their own draft, pending or sent-back piece, edits it once live, and takes it down to draft; deleting stays draft-only. Previews must pass the house rules to publish, reviews need a title and a body. Routes: POST /admin/reviews/:id/publish|unpublish now decide own vs any in the handler (reviewAccess actions publish, unpublish, edit_live, delete); PUT /desk/previews/:id/publish|unpublish. Every writer publish emails the owner ("✅ Review published by @handle"). "Send to an editor" stays as Ask an editor first. Permissions reach existing sessions on the next token refresh (15 minutes).
  • One editor action bar (EditorActionBar) for reviews and previews: a sticky bar with a status chip (Draft, With an editor, Live, Sent back), the autosave state, and buttons in four theme-aware styles (a green gradient Publish, accent, outline, red outline), all from CSS variables so light, dark and custom accents follow. Publish and Take it down ask first.
  • Writers' guide, Roles page and editor hints say writers publish.

[0.1.124] - 2026-09-25

Fixed
  • Buttons take the size they ask for, site-wide. The base .btn-primary and .btn-secondary rules were unlayered CSS, which in Tailwind 4 beats every utility class, so all 133 buttons that set their own size (text-xs px-2.5 py-1 and the like) rendered at 16px with 12x24 padding. The base rules now sit in @layer components; hover and active stay as they were. Most visible on the writers' desk, where three claim buttons per card were stacked at full size.
Added
  • just uitest-journey [1] (plan 24 C): a writer's whole journey through the real UI in Chromium as a throwaway uitest_writer: claim preview, review and video on one open game, house-rules box on a thin draft, Write it reopens the same draft, review needs a title, back arrow to the desk, video link validation, release through the confirm dialog, no page errors. With 1 it also sends both pieces to an editor, withdraws them and hands in a video, which emails the owner three alerts. Cleanup deletes the pieces, returns the game to new and deletes the account. 20/20 on 2026-09-25.

[0.1.123] - 2026-09-25

Fixed (plan 24 A: writers' desk dead ends)
  • "Write it" on a preview claim the writer already started opens their draft instead of refusing ("this game already has a preview"); the claim's button reads "Open your draft" for previews and reviews alike (/desk/mine now carries game_queue_id).
  • A second unpublished review of the same game by the same writer is refused (409); pressing Write it twice used to leave two drafts.
  • A preview an editor sent back (rejected) opens for editing again; the first edit returns it to draft. It used to be locked for good.
  • Published pieces show no edit link in Your work (saving them was refused).
  • The preview editor is text only: no image/video buttons or drops, whose output the server stripped; its own placeholder; headline and tagline limits match their labels (120, 160).
  • The review editor's back arrow goes to the desk for writers; saving with no title says so instead of doing nothing; status reads Draft / With an editor / Live like the desk, in theme colours; "Submit for review" is now "Send to an editor" like previews.
Added
  • Hand in a video: a writer on a video claim pastes the video's link (POST /desk/claims/:id/hand-in, own video claims only, http(s)); the claim closes as delivered with the link and the owner is emailed.
  • Writers' guide explains Open your draft, Save draft vs autosave, sent-back pieces and handing in a video.

[0.1.122] - 2026-09-25

Added
  • A writer's preview reaching "Waiting for an editor" now emails the owner (ALERT_EMAIL), as a submitted review already did: game, writer handle, a link to read it and a link to the Previews tab. House voices never trigger it.

[0.1.121] - 2026-09-24

Added
  • Monthly Top 5 Free Games (plan 15 phase 4): gw-newsdesk -top5 lists the candidates (published, free or free with purchases, never featured; studios that asked first, then gallery size, then newest), -games a,b,c,d,e -dry prints the writer's brief for free, and without -dry it saves a GWUK draft and records the five in news_featured_previews (migration 059, one feature per game ever; deleting the draft frees them). The writer returns two plain paragraphs per game; the intro, the series announcement and the note to developers, the store links, prices, pictures (up to four gallery shots the preview did not use as hero) and preview links are fixed code. The desk's screenshot notes and the developers' replies are left out of its brief: the first draft turned one game into a settings-menu tour and quoted a studio hoping for its first review. The draft is checked against the publish gate before saving; store titles with a dash become a colon.

[0.1.120] - 2026-09-24

Added
  • Board cards show a "Key waiting" tag when the game holds review keys (keys_available, already in the board DTO).
  • A review or video claim with no key says so and points the writer to reviews@ to ask for one; the guide's Keys section explains the unreleased case (studio may have no build yet; release the claim to play something else). The first linked writer claimed two unreleased, keyless games and got no hint what to do.

[0.1.119] - 2026-09-24

Added
  • /privacy: what the site records, who handles it (Hetzner, Google Cloud Storage, Postmark, OpenRouter, YouTube), how long each thing is kept and how to get it deleted. Linked from the More menu and the About page; in the sitemap, with a bot prerender (seo_static.go + nginx location = /privacy).
Changed
  • Retention now matches what the page says. The daily analytics timer also blanks the sender IP on submissions, news tips, writer applications and mailing-list rows older than 30 days (repository/privacy_repository.go), and deletes expired login sessions (DeleteExpiredSessions existed but nothing called it; 19 of 27 session rows had expired).
  • gw-backup.sh deletes off-site dumps and uploads tarballs older than 90 days. No listing: each upload is recorded in /var/lib/gw-backup/offsite.list (seeded once by hand with the 73 objects already there, oldest 22 Jul).

[0.1.118] - 2026-09-24

Changed
  • Admin → Analytics → Page views, whole site now counts under the plan 23 rules: bots, local builds, staff and refreshes within 30 minutes are left out, and a footer says how many of each. Over the last 30 days that is 892 of 2,411 raw views (1,155 were bots). All figures are the last 30 days; "Unique visitors" was all time and is now "Visitors, 30 days". SQL moved from the handler to repository/traffic_site_repository.go.
Fixed
  • Android phones counted as Linux and iPhones as macOS (their user agents say both); Opera and Samsung Internet counted as Chrome.
  • The panel's daily chart drew every bar at its 2px minimum (percent heights in a column with no height). It is the shared Sparkline now, fed all 30 days with the empty ones filled in by the API.

[0.1.117] - 2026-09-24

Added
  • Plan 23 chunk D, reads on screen. components/common/Sparkline.tsx: daily columns in the accent colour, 2px gaps, today drawn lighter because it is still filling, a hover readout and a screen-reader summary.
  • A Reads block under the share panel on every preview, review and news page (ReadsBlock). Renders nothing unless the API says the caller may see it; under the floor the author and staff get a note that only they see it.
  • Writers' My numbers: a 30-day column per piece; opening a row shows the chart, 7-day / 30-day / all-time reads and where readers came from. "Page views" is now "Reads".
  • Admin → Analytics → Content reads (lazy chunk, admin.analytics.view): 7d / 30d / 90d / 1y, filter by kind and writer, a site series, rollups by writer, kind and source, and a sortable per-piece table with the change on the period before. Source buckets read in plain words everywhere (SOURCE_NAMES); an opened desk row stays readable at phone width.

[0.1.116] - 2026-09-24

Added
  • Plan 23 chunk C, the traffic API (no UI yet; that is chunk D). Every series is rolled-up days plus today's raw rows under the same counting rules.
    • GET /api/v1/{previews|reviews|news}/:slug/traffic: the last 30 days per day and a total. Public once a piece has 25 reads in 30 days (TrafficPublicFloor, decision D1); below that only the author and staff (a role above user or writer) get the numbers, everyone else gets visible: false and no count. Optional bearer token; cached per piece for an hour.
    • GET /api/v1/desk/traffic: the caller's own published pieces with 7-day, 30-day and all-time reads, a 30-day series and top sources.
    • GET /api/v1/admin/traffic?kind=&author=&from=&to=&sort= (admin.analytics.view): every published piece in range with reads, visitors (daily uniques summed; the visitor hash changes daily), the previous period and trend, top source; per-writer and per-kind rollups, a site series and a sources breakdown. Sorts whitelisted, range up to 366 days.

[0.1.115] - 2026-09-24

Fixed
  • Access tokens carried roles but an empty permission list: generateTokensForUser read role.Permissions, which GetUserRoles never loads. Only super_admin (short-circuited client-side) saw permission-gated UI, so a newly linked writer was turned away from /desk and the House style tab although the API (live RequirePermission) would have let them in. Tokens now take permissions from GetUserPermissions. Reported by the first linked writer.
  • PermissionService re-reads its cache when the access token changes, so a refreshed token's permissions show without a full reload.

[0.1.114] - 2026-09-24

Changed
  • Plan 23 chunk B: the traffic rollup counts reads, not requests (rules version 2, TrafficRules). Left out: crawlers that run JavaScript (TrafficBotPattern, calibrated on all 90 days: Bytespider, meta-externalagent and GoogleOther were two thirds of piece views), a piece's author and staff (/track reads an optional bearer token into page_views.user_id), and a visitor's views of a piece within 30 minutes of their previous one. Every complete day still in page_views was rolled again: 1,571 piece views became 460.
  • page_views stores a daily-salted hash (visitor) instead of the IP (Jack, D2). The salt lives in Redis and expires an hour after its UTC day ends (VisitorHasher). Migration 058 hashed every row already held, with a per-day salt that was never stored, and dropped the addresses; the nightly job hashes any stragglers an older build writes during a deploy.
  • The writer desk counts today's views under the same rules, old preview slugs included.
  • previews.view_count is frozen. It counted API fetches of a preview (1,693 against 1,064 page views) and nothing else uses it.
Fixed
  • A stored referrer that is not a host name reads as source unknown, not as a domain.

[0.1.113] - 2026-09-24

Added
  • Plan 23 chunk A: content_views_daily, content_referrers_daily and traffic_rollup_days (migration 057). TrafficService.RollupPending rolls every finished UTC day up per piece (views, distinct visitors, bucketed source) before the 90-day page_views purge, and the purge now waits if the rollup fails. Previews count on their old slugs too; rows from local builds (localhost referers) are left out. All 90 days still on hand are backfilled.
  • The page tracker sends document.referrer on the first page of a visit and internal after; /track stores the host only. The Referer header it used was always our own origin, so older rows have source unknown.
Fixed
  • Writer desk view totals read the rollup plus today's raw rows, so they stop shrinking as raw rows age out.

[0.1.112] - 2026-09-24

Added
  • Third preview sign-off, "Keep an eye on it if", for a game with no release and nowhere to wishlist it. Prompt rule 7, house rules and the editor labels now describe all three.
  • WishlistTargetProblems: a "Wishlist it" sign-off fails unless a Steam app page or Google Play listing is linked (plan 14 S19). Runs in the writer and, through the new LintPreviewLinks, in -repair, -fix, the writers' desk and gw-relint.
  • gw-abpreviews -set-signoff grab|watch -slugs a,b [-dry]: swap the sign-off on pages a person has checked; refuses a body with more or less than one "Wishlist it if" and a line that would pass 200 characters.
Fixed
  • 14 live previews asked readers to wishlist a game with nowhere to wishlist it. Eight playable now (browser, iOS, itch demos, betas) became "Grab it", five with no store page became "Keep an eye on it", and Dice After Dark got its Steam link (app 5131760, due 24 March 2027). Survidle no longer says itch has a wishlist.

[0.1.111] - 2026-09-24

Fixed
  • The emulators list had been frozen since 21 March. scripts/fetch-emulators.py was never scheduled, and upstream (awesome-emulators) had since switched to short system names ("N64", "PS2", "DS") and emoji the parser did not know, so a rerun would have renamed half the systems, emptied 465 logo paths and put "N" and "A" in the platform filter. The parser now maps short names back (SYSTEM_ALIASES, derived from the 700+ rows in both versions), treats a markdown link as one token, drops "List of supported systems" pointers, knows BSD and DOS, carries logo paths over, and has --dry. 885 -> 893 emulators (v86, Flashpoint Archive, ZSNES, oxGBC, PockEmul, GoGBA, shadPS4Plus, ConsoleMini, wine-assembly and a renamed Larper64), each with a description; 9 new logos. Bleem! linked to wikiless.org, a dead Wikipedia front end whose domain now serves a gambling site's favicon; it links to Wikipedia now, and that icon plus three GitHub owners' personal avatars are blocked (IMAGE_BLOCKLIST). A miss is retried monthly, not every run.
  • Handheld pictures for devices added since 1 September never appeared (Retroid Pocket Duo, Duo Lite, RG DS Plus, RG35, GPD Win Max 3). v0.1.36 moved the API's name-to-image map to data/handhelds/ but the weekly image cron kept writing /tmp. It writes the API's path now (untracked, and it keeps the old map if an export comes back under 80%), and the sync hashes the map with the sheet so a new picture alone triggers a resync.
  • gw-devnotes -ingest could not see a studio's reply to a hand-sent ask that went out before any preview note (PlayHumanIntuition's five screenshots). It now counts any desk mail to that address about that game as "we wrote first".
Changed
  • Both catalogue jobs live in a tracked /etc/cron.d/gw-catalogues (infra/cron/), Sundays 04:00/04:30, log /var/log/gw-catalogues.log (rotated). The emulator job refreshes data only; logos stay a hand step with a look at every file.

[0.1.110] - 2026-09-24

Added
  • Steam's "AI Generated Content Disclosure" on previews (plan 18 phase 2, S27b). The image hunt reads the store page in English past the age gate (through httpx.SafeClient) and stores the developer's statement word for word with source steam, linked to the store page. A developer's mail beats Steam and Steam beats itch.io; a page that was not read (age gate, error) leaves what is stored alone. Calibrated on all 89 Steam games in the queue: 47 have one, 29 to 1,282 characters, parsed with no odd shapes. The Get it panel clamps a long statement to three lines with Show all; a studio note names a long one instead of quoting it. gw-huntimages -ai-disclosure [-dry] now reads Steam first, then itch.
  • gw-devnotes -invite <application id> -body F: sends a /join applicant their onboarding letter from reviews@, reads To:/Subject: lines off the draft, never prints the address, and sets the application to reading.
Fixed
  • A Steam game whose date reads "Coming soon" got the release line "Coming Coming soon" (SG Racing); "To be announced" would have read "Coming To be announced".

[0.1.109] - 2026-09-24

Added
  • The developer's own AI disclosure on previews (plan 18). itch.io prints a developer's answer to "did this use generative AI" on the listing; the image hunt now reads that row from the page it already fetches and stores it on the queue item exactly as printed (migration 056: ai_disclosure, _source, _url, _at). No row means unknown, never "no AI". The preview reads it by join, so a re-hunt that finds it changed updates the live page. Shown as one plain line in the Get it panel ("AI disclosure (itch.io): AI Assisted, Code"), linked to the listing, with no badge or colour. The writer gets it on the fact sheet as the developer's statement, to repeat in their words or leave out; a new lint rule refuses grading it ("slop", "AI-generated game", "made by AI"), calibrated at zero hits on the corpus. The long studio notes say the line is there and where it came from. gw-huntimages -ai-disclosure [-dry] backfills from every linked itch page; -id X -set-ai-disclosure "..." records one a studio sends by mail, which a hunt never overwrites.

[0.1.108] - 2026-09-24

Added
  • Factual repair (plan 17 step 3): gw-abpreviews -fix <preview id> [-body file|- [-dev]]. The model sees the live piece as numbered blocks and one correction (the newest desk note by default; -dev fences a developer's email as untrusted and runs the injection scan) and returns only the blocks the correction contradicts. Always a dry run: it prints a diff, what it could not do by editing (UNRESOLVED), and REPAIR lines that -repair apply stores after review. Numbers may change; links, tags, the voice rules and the lint may not, and more than five blocks means regen. First run: a date change on a 608-word piece patched two phrases, left the rest byte-identical, invented no price, about $0.02 against $0.04 plus a full re-vet for a regen.

[0.1.107] - 2026-09-24

Added
  • Writer area (plan 22 chunk D). A "Writers" group leads the admin menu for anyone with claims.own: My desk, My numbers (views, comments, votes and credits per own live piece, drafts and pending, rank), Published lately (last 30 reviews, previews and news, public fields only), House style (generated from the rules the human-preview lint and review submit enforce, with examples), Writers' guide and Leaderboard. Routes GET /desk/stats, /desk/published, /desk/house-rules, each built from its own DTO; a test holds every DTO free of private fields.
  • /desk/guide: a public writers' guide (getting started in order, claims, the 48-hour head start, keys, writing and editing, house style and honesty rules, credits, the writer menu). In the sitemap, bot prerender and nginx; linked from /desk and /join. A test holds its claim numbers to claim_rules.go, and the house-rule table is tested against the real lint.

[0.1.106] - 2026-09-24

Added
  • Video claims (plan 22 chunk B): a third claim kind, open to anyone on the desk with the same 7-day timer, extensions and 3-claim cap. Cards say who holds each kind. Publishing a review with a video delivers the author's video claim; POST /admin/claims/:id/deliver (optional link) and an "All open claims" panel on /desk for claims.manage.
  • Claims taken by claims.manage holders have no expiry and no cap (migration 055 clears the timer on their open claims).
  • Keys on claim (plan 22 chunk C): "Get a key" on an open review or video claim moves one key out of the queue into review_key_issues in one locked transaction; "My keys" on the desk; issued keys listed in the games modal (games.manage). gw-abpreviews -lift-key <mail> -id <game> [-dry-run] lifts Steam-format keys out of a studio mail, printing counts and masked keys only.
  • Applications: "Link to account" by handle (PUT /admin/applications/:id/link, needs applications.manage + users.roles.assign) sets the user, accepted and the writer role.
Security
  • Review keys reach only games.manage callers; everyone else, and every games-queue WebSocket event, gets a count. gw-sql cannot read issued keys.

[0.1.105] - 2026-09-24

Security
  • Writer route audit (plan 22 chunk A). Creating a review from a submission needs submissions.manage (was reviews.create, so any writer holding a submission id could take it over).
  • Mutating games-queue routes (status, reply, alerts) need games.manage; admin claim/unclaim need claims.manage; mail read/archive need mail.send. Moderators keep games.view as read-only.
  • Desk claim and view refuse games that are not on the board. The board no longer shows the slug of an unpublished preview.
  • Writers get a daily upload allowance (200 images, 2 GB video, Redis-backed with a memory fallback); review save routes carry a 2 MB body cap and 60/min per-user limit. Review game_url and splash path accept http(s) or /uploads/ only.
Changed
  • Reviews link to a game (reviews.game_queue_id, migration 054). A writer can only start a review on a game where they hold the open review claim; publishing delivers the claim, so review claims no longer lapse after the piece is live.
  • A writer can edit a review or human preview only while it is a draft. "Withdraw to edit" moves a pending piece back to draft (POST /admin/reviews/:id/withdraw); the SQL guards the race with publish. Editors still edit anything.
  • Releasing or lapsing a preview claim frees the holder's abandoned human draft from the game, so the game returns to the board and the AI desk; the draft stays with its writer. Setting a game back to new also releases its open claims.

[0.1.104] - 2026-09-23

Security
  • Security pass over the community and writers' work (plan 21, chunk C9, the last; plan doc deleted). Four audits over C2 to C8 (ownership and IDOR, data exposure, limiters and injection coverage, classic diff review) found no cross-user read or write; everything below is what they did find, fixed
  • Writers' board and the writer alert mail now hand external hires the same scrubbed fact sheet the AI desk gets: public store links only, emails, phone numbers, key-shaped tokens and any sentence about keys, embargoes or press kits removed from the summary and studio lines (the scanner is told to note perks such as keys in the summary)
  • Public profiles wear only writer, staff and moderator badges; admin roles stay off the page, and GET /users/:handle is rate limited (it answers for any active account)
  • Desk submit is a draft-to-pending move only: an editor with previews.create could "submit" a live preview and take it offline without previews.publish
  • Claim cap is counted and inserted under one advisory lock per user, so parallel claims cannot exceed it; claim, release, extend, view and draft saves carry a per-user limiter (30 a minute); comment delete carries the comment limiter and deleting twice is a no-op with no broadcast
  • Comments: replies only under a visible parent; an author cannot delete a comment a moderator hid (it would rewrite the moderation record); votes on a comment need the piece under it to be live; hidden and deleted comments lose their byline for ordinary readers; a thread is capped at 1000 rows
  • WebSocket: inbound frames capped at 4 KB (there was no read limit), presence reports capped at 30 a minute per socket, and presence fan-out drops on a full queue instead of closing the receiver, so one client walking paths cannot knock every admin socket off
  • Request bodies capped per route (64 KB for comments, profiles and credit spends, 512 KB for a preview draft) instead of the 100 MB app-wide limit; avatar upload shares the upload limiter
  • SEO check accepts exact hosts over https with no port or userinfo (gamingworld.uk:22 used to pass); admin credit routes hide database driver messages
  • Writer prompt: title, release and price lines are injection-scanned and printed on one line (they sit outside the fence); feed titles in the news writer are one line; the sentiment prompt fences the review and the scraped pages; the admin inline edit re-lints like the desk edit does; upload MIME and extension checks must both agree
  • nginx: every apex proxy to the API sets X-Real-IP (health, RSS, sitemap and the prerender locations passed a client-supplied header through); a gw-429 fail2ban jail (tracked under infra/fail2ban/) bans an address for 15 minutes after forty HTTP 429 answers inside a minute, covering the nginx belt and the Go limiters alike
  • gw-relint -injection runs the injection scan over every queue item's writer-facing fields as stored (1 of 181 flagged, on the developer-reply scan; the new fields flag nothing)
  • Admin credits: the hand-grant note field says the person sees it

[0.1.103] - 2026-09-23

Added
  • Share panels and SEO completion (plan 21, chunk C8). SharePanel (native share where the browser has it, copy link, X, Bluesky, Reddit, Facebook, Mastodon with a remembered instance, WhatsApp, Telegram) on previews, reviews, news articles, public profiles and the leaderboard; works for guests, every URL built by pure functions under test
  • Bot prerender for the front page, /previews, /reviews, /about, /join, /emulators, /handhelds, /leaderboard, /credits, /roles (GET /seo/page/:slug, JSON-LD WebSite / CollectionPage / AboutPage / WebPage, the previews and reviews indexes list the latest 20 pieces) and for public profiles (GET /seo/u/:handle, ProfilePage with a Person for people and the desk Organization for house voices; unknown handle answers 404). nginx bot map now also matches Bluesky (Cardyb), Mastodon, redditbot, DuckDuckBot, Embedly, Iframely, Skype and Teams previews
  • Every prerendered page and every client-side page carries og:locale, og:image:alt and the full twitter set; usePageMeta restores the SPA defaults on unmount instead of leaving stale tags, and ReviewPage uses the hook rather than its own copy. Home, About, Emulators, Handhelds and the Reviews index set meta for the first time
  • Sitemap adds /leaderboard, /credits and /u/<handle> for people with published work; the review list in it is no longer capped at 20 by the public page-size rule
  • Admin SEO check tab (admin.analytics.view): fetch any gamingworld.uk URL as Googlebot through the SSRF-safe client (other hosts refused before any fetch, redirects must stay on the site) and see the tags, each JSON-LD block parsed, and 19 pass/fail checks including whether og:image actually loads. GET /admin/seo/check?url=
  • Admin AI Usage tab: 7/30/90-day breakdown by tool, by model and by day with calls, cost and tokens in/out (GET /admin/usage/breakdown?days=)
Fixed
  • The built API binary apps/api/api was tracked in git; it is ignored now

[0.1.102] - 2026-09-23

Added
  • Comments and votes (plan 21, chunk C7). Every published review, preview and news article has a thread: plain-text comments (2000 characters, tags and control characters stripped, URLs drawn as links and nothing else), one level of replies, and one upvote per person on the piece and on each comment. Log in to post or vote; guests get the login modal. The author can delete their own (a tombstone keeps replies in place); content.moderate holders hide or restore from the page or from the new admin Comments tab (newest 100, filter by status). Every body goes through the prompt-injection scan on the way in and the reasons are stored in injection_flags, so nothing AI-facing ever reads one unmarked
  • Credits: a comment pays 3 (ten a day), an upvote you give 1 (twenty a day, once per subject), an upvote on your piece or comment 2 to a human author (once per voter). The /credits page no longer marks these "soon"
  • Live: a new comment, a delete or a hide reaches everyone on that page over the WebSocket (comment event, Manager.SendToPath)
  • GET /threads/:type/:id (public, marks your own with a token), POST /user/comments, DELETE /user/comments/:id, PUT /user/votes/:type/:id, GET|PUT /admin/comments. Comments and votes are rate limited per user (5 and 60 a minute), not per address
  • Migration 053 (comments, votes); "Comments coming soon!" is gone from review pages

[0.1.101] - 2026-09-23

Added
  • Credits ledger (plan 21, chunk C6; plan 10 shipped). credit_ledger is append only and every award is idempotent on (user, reason, subject), so a review unpublished and published again pays once. Pays on review publish (100), a person's preview publish (60), first to cover a game (50, no other live review or preview names it), an editor's pick (40, an award attached to a live review or a live human preview pinned) and pace (25, two or more live pieces in a calendar month). Comment and upvote reasons (3, 2, 1 with daily caps) are defined for C7. House voices never earn. Amounts live in services/credits_rules.go and creditsData.ts, and a Go test fails when they drift
  • Ranks on lifetime credits (Rookie, Contributor 250, Critic 750, Senior Critic 2000, Editor 5000) on public profiles, review bylines and human preview author cards; Avatar draws the badge on the corner. /leaderboard (all time or last 30 days, GET /credits/leaderboard) and /credits, the how-to-earn page with the gem art and your own standing when logged in. Both in the More menu
  • Spends. POST /user/credits/spend takes the cost now and files a request (credit_grants); the admin Credits tab (credits.view to see, credits.manage to act) marks it done or refunds it, and has a by-hand grant. The account page shows balance, lifetime, the road to the next rank, the four spends and the ledger
  • Review score boards. review_scores (up to eight categories, each out of 5, 10 or 100) ride on the review JSON as scores and save through the existing update route. ScoreBoard draws the overall as the big SVG gauge with the categories beside it, counting up once when it scrolls in; ScoreEditor in the review editor with presets
  • Cosmetics. cosmetics + user_cosmetics + users.avatar_frame; two frames seeded and granted by rank (Critic ring, Editor ring), switched from the account page. No shop
  • Migration 052; permissions credits.view (staff up) and credits.manage (admin up)

[0.1.100] - 2026-09-23

Added
  • Presence (plan 21, chunk C5). The site keeps one WebSocket open while you are logged in (before this only the admin games tab opened it). The client tells the hub which page it is on at every route change ({type:"presence", data:{path}}, pathname only); the hub keeps one path per connection and answers everyone on that path with who else is there: handle, display name and avatar, nothing else. Admins hear about every path; a connection on /admin hears about the whole admin area so the games queue can say "Kai is looking at this" while someone has that game's detail open. PresenceStack (up to five faces and a "+n") sits on preview, review and news pages, the desk, the admin games rows and the game detail modal. Nothing is stored; a restart forgets it
  • Public profiles. GET /users/:handle returns handle, display name, bio, avatar, whether it is a house voice, when they joined, role names, published counts and the latest published pieces. Never an email, an id or a status; the repository has its own column list. /u/:handle is the page; a ProfileModal opens from every avatar on the site (bylines, author cards, board viewers, claim holders, presence stacks). PUT /user/profile sets display name (60 characters) and bio (500), plain text only: tags, control characters and zero-width marks are stripped server-side. The account page gains the form and a link to the public page. Credits and rank fields are reserved on the DTO for the ledger (C6)
Fixed
  • Three hub bugs from the survey: CleanupStaleConnections was never called (the hub's event loop now sweeps every two minutes); reconnection stayed off for good after a logout (connect() switches it back on and resets the attempt count); one rate limiter was shared by seven routes, so ten page-view pings in a minute cost the same person their WebSocket ticket (the ticket, the upgrade, /track, the public forms and the profile edit now each have their own)
  • Connection.Close() could consume a queued message while checking for a closed channel, and a send after close could panic; it now uses a flag under the lock
  • Logging out closes the socket instead of leaving it open under the old identity

[0.1.99] - 2026-09-23

Added
  • Edit here (plan 21, chunk C4, second half). An "Edit here" button on /reviews/:slug and /previews/:slug for whoever may change the piece: its author while it is not live, or a super_admin at any time. Editors keep the admin editor. Pressing it swaps the headline, tagline (or title and synopsis), closing line and the body for one shared InlineEditor (TipTap in place, autosave two seconds after the last change, a status pill, the house rules the text breaks listed above it, Done to leave). Saves go through the routes that already exist: PUT /desk/previews/:id for previews (same access rule and sanitiser as the writer's editor) and PUT /admin/reviews/:id for reviews (same ownership rule as the editor page). The public pages only ever hold published pieces, so in practice this is a super_admin's tool; a writer's own live piece stays read-only, as on the server
  • An AI preview edited in place says so under the editor: the next regeneration rewrites it from its brief, so a fact fix also needs a desk note
Fixed
  • A save through the desk route on an AI preview re-linted it with the human rule set, dropping the hands-on and motion rules; the lint now follows author_kind

[0.1.98] - 2026-09-23

Added
  • Human previews (plan 21, chunk C4, first half; plan 20 steps 4-7). Migration 051: previews.author_kind (ai | human) and a pending status. A writer who holds the preview claim on a game starts a draft from the desk (POST /desk/previews): title, studio, facts, links, price and the studio's images come from the queue item the same way the AI path takes them, and the queue row is linked so the AI desk leaves the game alone. /desk/previews/:id is the writer's editor: headline, tagline, closing line and the body in TipTap, autosave two seconds after the last keystroke with a status pill, the house-rule problems listed above the text, "Send to an editor" (draft to pending, house rules must pass) and "Take it back". Own-or-any access follows reviewAccess(): an editor with previews.create may open any piece, a writer only their own and never once it is live. The body goes through the same sanitiser as the admin editor
  • Honest labels the other way round. Bylines, the author card, the bot prerender byline and the JSON-LD author switch on author_kind: a person's piece says "Writer, Previews desk", "Written by a person", and is a Person in the structured data; the AI label and disclosure stay on every house-voice piece
  • Lint keeps house rules for people and lifts the AI-only ones. PreviewDraft.Human skips the hands-on, time-spent, sent-a-key, human-experience and motion-claim rules; dashes, forbidden phrases, headline case, word count, links and the rest still apply. -repair, -regen, -clean-only and the admin regenerate refuse a human preview
  • Studio note says a person wrote it. DevNoteDraftsFor picks a template without the AI disclosure when the preview's author is human; the admin note dialog and gw-devnotes both use it. Publishing a human preview delivers the writer's claim. The admin previews tab gains a "pending" filter, a "written by a person" tag and reject on pending

[0.1.97] - 2026-09-23

Added
  • Writers' desk (plan 21, chunk C3). /desk for anyone with claims.own: your open claims with a countdown, extend (+3 days, three times) and release with a confirm, the cross-kind warning naming the other holder; your reviews and previews with status and a way back into the editor; the open board in three tabs (nothing yet / preview, no review / review, no preview) with title search, claim buttons, who holds what, when the AI desk may step in, and on expand the last five people who looked, marked looked or claimed; the alert switch for new-games mail; the short version of the rules. GET /desk/mine lists the caller's own pieces as a small DTO (MyPiece). A "Writers' desk" link in the account menu for anyone with the permission
  • /roles, public: every role and what it can and cannot do, generated from the live permission table through GET /roles/matrix (names, display names, descriptions and colours only; no ids, no user counts; cached ten minutes). Linked from /join, the desk and the sitemap
  • docs/features/claims.md: the claim process, the AI head start, routes and pages

[0.1.96] - 2026-09-23

Added
  • Claims engine (plan 21, chunk C2). Migration 050: claims (one open claim per game per kind, review or preview), claim_views (who looked, newest five shown), user_alert_prefs (a writer's own new-games mail switch); permissions claims.own and previews.write.own (writer and every staff tier), claims.manage (admin). services/claim_service.go holds the rules Jack set on 2026-09-23: three open claims per user, a 7-day lapse, three 3-day extensions, a review claim and a preview claim on the same game coexist with a warning naming the holder. An hourly ticker releases lapsed claims and mails the holder from previews@. game_queue.claimed_by/claimed_at/status now mirror the newest open claim, so the admin games tab keeps working; PUT /admin/games/:id/claim takes {"kind"} and answers {item, claim, warning?}
  • The AI desk waits 48 hours. One function, PreviewEligibility, answers the ticker, the batch, POST /admin/games/:id/preview and gw-abpreviews: no draft inside 48 hours of a game arriving, and never while a writer holds its preview claim. The head start is on only while an active user holds the writer role, so nothing waits for a person who has not been hired; {"now":true} in admin and -now on the CLI skip the wait for a launch day, never a claim. A review claim no longer hides a game from the desk (it used to: status = 'claimed' was the filter), and the image hunt now covers claimed games
  • GET /desk/board (behind claims.own, never games.view): open games as a BoardGame DTO built from its own column list (title, studio, summary, genres, platforms, links, release, price, art, when the AI may step in, who holds what), plus the caller's claims, alert prefs and the rules. POST /desk/claims, DELETE /desk/claims/:id, POST /desk/claims/:id/extend, POST /desk/games/:id/view, GET|PUT /desk/alerts; GET /admin/claims and DELETE /admin/claims/:id for editors. New queue items mail opted-in writers a writer-safe digest linking to /desk
  • just replies prints open claims with their ages and whether the head start is on. Tests for the expiry, extension and eligibility maths

[0.1.95] - 2026-09-23

Added
  • Site art and a parallax background (plan 21, chunk 1). gw-run gw-genassets generates the site's own art through ImageGenerationService (every image in usage_history): four quiet full-page backgrounds (two per theme, bytedance/seedream-4, $0.03 each), six section banners (previews, news, search, per theme), a transparent credits gem set, five rank badges and ten UI icons (openai/gpt-image-2 drawn on a flat ground, then birefnet/general strips it: ImageRouter ignores background=transparent, tested). Output lands in /srv/uploads/site/<group>/, a new allowed upload dir, and the uploads mirror now carries site to GCS. The whole run cost under $1
  • SiteBackground: a fixed, theme-aware art layer behind every page with a small scroll parallax, off under prefers-reduced-motion; SectionBanner on the previews, news and reviews headers; CreditGem and RankBadge components; config/siteArt.ts is the registry
  • scripts/animate-gem.py builds credits.webp and credits.gif (a bobbing gem with a glint sweep) from the transparent gem. The old animated credits gif is not in this repo, its history, or anywhere on the box, so this replaces it. /join shows the animated gem and the rank badges
  • docs/plans/21-community-and-writers.md: Jack's 2026-09-23 brief in nine session-sized chunks (claims engine, writer desk, human previews + inline editing, presence + profiles, credits ledger + ranks + scores, comments + votes, share + SEO, security pass), the decisions that supersede plans 10/19/20, and a 30-day cost analysis (about $0.42 per published preview)
Fixed
  • Model sync stored $0 for ImageRouter models that publish a price range (gpt-image-2, seedream); it now takes the range average, so their generations are costed. Rows logged before the next sync stay at $0
  • GenerateImageRequest.Quality (low/medium/high) reaches ImageRouter for gpt-image models; it sets the price tier

[0.1.94] - 2026-09-22

Added
  • Bot prerender for reviews (plan 19, step 5b). /seo/reviews/:slug and the matching nginx rule: a crawler hitting /reviews/<slug> now gets the title, synopsis, splash image and Review + VideoGame JSON-LD with the writer as a Person, where previews name the organisation on purpose. Built before the first review is published, because news and previews had this and reviews had only the SPA shell
  • gw-devnotes -owner -subject S -body F: mails a plain-text report to the site owner. The recipient is fixed to ALERT_EMAIL, so it cannot be pointed at anyone else. No CLI could send Jack a report before
  • docs/plans/20-human-previews.md: writers may take previews, and a new game waits 24 hours before the AI desk may draft it
Fixed
  • gw-abpreviews -repair apply on a wishlist_line change now also swaps the sign-off paragraph inside the body when the body stores it with escaped apostrophes (&#39;). Before, the wishlist line changed and the body did not, the lint said "wishlist line missing from body", and an out-now fix could only be bought with a regeneration

[0.1.93] - 2026-09-21

Added
  • A writer role, and a review path a writer can actually use (plan 19, steps 1 to 5). Jack wants to trial people who applied through /join; an audit found the review path had never run in production (0 rows in reviews) and could not have. Migration 049 adds role writer with reviews.create, reviews.edit.own, reviews.delete.own, content.create and nothing else: no publish, no games.* (which would show every review key we hold), no previews, no mail
  • PUT and DELETE /admin/reviews/:id now accept reviews.edit.any/reviews.delete.any, or the .own permission on the caller's own unpublished review. Before, PUT demanded edit.any, so a writer could create a draft and never save it again, and the two .own permissions guarded no route at all. Another person's review answers 404. The rule is one pure function (reviewAccess) with a table test
  • pending state: POST /admin/reviews/:id/submit moves the caller's draft to pending, stamps submitted_at and mails the alert address. reviews.status gains a CHECK (draft, pending, published). Editors see pending reviews first, with a count, and can publish or return to draft; writers see "Submit for review" where editors see "Publish"
  • Without reviews.edit.any the admin list and GET /admin/reviews/:id return only the caller's own reviews
  • Reviews carry an author (handle, display name, avatar, never the email) and the public page says "Reviewed by" them instead of a hardcoded "Gaming World"
Fixed
  • Anyone who could edit a review could publish it. Update wrote status from the request body, skipping reviews.publish; Create accepted it too. Status now moves only through submit, publish and unpublish. The same line meant every save from the editor, which never sends a status, would have blanked the column
  • A published review keeps its slug when retitled, so a live URL cannot move
  • Genres chosen before the first save were dropped (genre_ids was only read on update)
  • Creating, editing and deleting genres needed only reviews.create; now reviews.edit.any, so a new writer cannot delete a genre. The sentiment and awards controls are hidden from people without those permissions instead of failing on click

[0.1.92] - 2026-09-21

Fixed
  • The news desk published a 2021 forum thread as today's news. GBAtemp's RSS dates a thread by its last reply, so a bumped save-editor thread (id 591780 on a day the forum was issuing 684,000s) arrived stamped 2026-09-20; the desk read its first post and wrote "a modder has released". Unpublished within the hour. Forum threads whose id sits more than 1,500 below the newest in the same batch (about four weeks on GBAtemp) are now dropped before triage: on the real feed that is 29 of 51 items on 2026-09-20, every one read, all old (help threads, 2019 cheat threads), which also takes them out of the triage prompt. A fresh forum thread with no article, release or repository beside it now carries a story only at triage fit 8 or above; the bumped one was a 6

[0.1.91] - 2026-09-21

Added
  • gw-abpreviews -repair <preview id|all> [-dry]: fix the sentence, not the piece. All 39 live previews failing the linter failed on one stock phrase each ("I keep coming back to", "I spent longer than I'd admit", "a trailer I have not opened"). A regen to change that phrase re-sends the brief and every still (about 9,400 tokens in), rewrites all 500 words and, as 2026-09-20 showed, invents one to three new errors that then need a vetter. The repair sends only the offending block (about 450 tokens in, 250 out, no images) and refuses any answer that still trips a voice rule, moves a link or a number, changes the tag, guts the length, uses a dash, or swaps the tic for a stock opener ("stands out", "I notice", "the detail that matters": the first dry run over the corpus wrote those 14 times). A dry run prints REPAIR {json} lines; -repair apply on stdin stores exactly the lines a person read, under the same checks, because a second run is a fresh roll of the dice. Result: 41 blocks on 39 published pages and 2 drafts, four edited by hand at review (one rewrite had changed a quoted IPA symbol), gw-relint: 0 failing. About $0.40 a pass; the whole sitting including three dry runs cost $1.25, against roughly $15 to $20 plus Opus vetting for 39 regens. S18 is closed
  • gw-abpreviews -publish <preview id> publishes one lint-clean draft. -publish-clean takes every clean draft, and the repair had just made Goblin Maze clean while it is still held for screenshots
  • gw-huntimages -id X -clear-studio removes a placeholder developer credit; ARB: Alien Races Battle was credited to "Solo developer"
  • docs/plans/17-cheaper-writing.md: where the AI money goes. 94% is the preview writer, 78% of that was spent on pages already live, a published piece has cost 8.1 writer calls on average, first drafts are 4% of spend. The model is not the problem, the number of whole-piece rewrites is
  • gw-abpreviews -rescan-submission <id>: a second scan for a form submission the scanner declined. The model answered is_game_pitch: false for Akelarre and Fame & Fear under reasons that called them genuine pitches, and a declined scan is never retried; both sat unqueued for two weeks. just replies now lists declined form entries from the last 30 days that carried a link or media
Content
  • Eight previews published and their studios written to (Akelarre, Fame & Fear: Dungeon Architect, UNWELL, Klaus Veen's Bastards, JANK Word Game, Tadado, Bullet Paint, Blade Radiant), each vetted image by image by an Opus agent first: every draft was lint-clean and every one carried factual errors (a wrong price in words, "Wishlist it" on released games, title cards as heroes, a placeholder studio credit)
  • S18 batch 5: six live previews regenerated and verified, 39 still failing today's linter. Lying Platforms was publicly linking the developer's Drive review build of a paid game; removed
  • Halloway's free-room structure corrected at the developer's request; Penguin Weapons gained its gameplay video
  • Crooky The Game: desk notes 18 to 21 had never reached the writer (the note window kept only whole notes after the last replacement spec, and later notes pushed it out); folded into one replacement spec, regenerated and verified. The sign-off no longer says party members forget each other, and the release line no longer trips the stale-date check while the full game is still "Coming soon"
  • The Retroid Duo Lite+ news hero was the maker's sales card with prices and a coupon on it; cropped to the device
Fixed
  • The scanner leaves a placeholder studio credit ("Solo Indie Developer", "n/a") empty so the store listing's developer fills it; Fame & Fear reached draft credited to "Solo Indie Developer" while Steam said Wasdlab. Calibrated on all 161 queue studios: one hit, a real placeholder
  • News desk illustrations may not draw game characters or mascots a company owns (a generated hero drew Sonic; replaced). Takes effect in the API on the next deploy, in the CLI on the next just install-clis

[0.1.90] - 2026-09-18

Changed
  • The header has two layouts and nothing in between (Jack: v0.1.89's icon-only step still looked bad). Below 1024px every width gets the phone header: logo, theme, avatar (logged in), menu button; the menu lists every page in one or two columns, then Profile, Account & security, Admin (admins) and Log out. From 1024px a short bar: Home, News, Previews, More (Reviews, Games, Handhelds, Emulators, Mall World, Send a news tip, Submit a game, Write for us, About, Changelog), theme, an Admin shield for admins, and an avatar menu with Profile, Account & security, Admin and Log out (or Login / Sign up). Checked in headless Chromium logged out and as an admin at 390, 700, 900, 1023, 1024, 1180 and 1440px: no sideways scroll at any. Navigation.tsx split from 470 lines into six files under 80

[0.1.89] - 2026-09-18

Fixed
  • The homepage scrolled sideways between 768 and 1023px (1073px of page in a 900px window) because the desktop menu did not fit. The bar now fits at every width: icons only below 1024px, labels from 1024px, Submit Game and Write for Us hidden below 1024px, icons at 1024px and labelled from 1280px, About always an icon (with a tooltip), a smaller logo and tighter spacing at the narrow end. Mall World leaves the top bar; it, Submit a game and Write for us are in the Content menu, so nothing is out of reach. The phone menu no longer lists Previews twice

[0.1.88] - 2026-09-18

Changed
  • The homepage ALL NEWS badge is much smaller (44/52px wide) and sits on a soft shadow in the top-right corner of the carousel's main image, with no plate behind it
  • From 640px up the carousel's text keeps a side gutter wider than the arrows, so the side chevrons no longer cover the headline or synopsis; the arrows are slightly smaller and frosted

[0.1.87] - 2026-09-18

Added
  • Write now on the admin News desk tab (from the second session, 32152452): file 1-3 desk articles for today or any missed day in the last 7, run in the background inside the API by NewsDeskRunner (one job at a time, panic-safe) with the tab's models and per-run budgets and the site-wide daily ceiling. POST /admin/newsdesk/write {day, count} returns 202; GET /admin/newsdesk/run reports progress; /admin/newsdesk/output gains the last 8 UTC days with counts. Writing today re-reads the RSS and HN feeds first. There is no lock against the CLI, so do not press it around the 09:40 UTC cron
  • just replies ends with the desk's last 7 days (MISSED days flagged) and today's desk spend; the session protocol now offers Jack a daily news run
Changed
  • The homepage ALL NEWS badge moves to the top-right corner of the carousel's main image and is much smaller (64/80px wide); the ALL NEWS button under the carousel is gone, so the badge is the one link; on phones the carousel arrows move to the top-left

[0.1.86] - 2026-09-18

Fixed
  • Mail-merge click trackers on public pages (plan 14, S14; from the second session, f3fffef6). Links wrapped by YAMM, Mailchimp list-manage and similar trackers are resolved to their target before they are stored (target taken from the query when present, else one GET through httpx.SafeClient stopping at the first hop off the tracker), and dropped if they do not resolve. Applies to the inbox scanner, POST /admin/games (scout) and gw-huntimages -add-link. PublicLinks and AllowedHosts drop tracker hosts with no network call as a backstop, so a page never shows one even if an old one is stored. Studio utm_ tags are untouched. Checked over all 817 stored URLs: 17 matched, all YAMM, no false positives. terra-7's 8 tracker links and Weather the Swarm's press-kit link swapped for their targets; terra-7 regenerated ($0.07)

[0.1.85] - 2026-09-18

Fixed
  • Soft 404s in the bot prerender (plan 14, S12). A bot asking for a preview or news article that does not exist, or is no longer public (withdrawn previews included), got the site shell with a 200, which search engines report as a soft 404 and may keep indexing. It now gets a 404 with noindex; a lookup that fails for another reason is a 503 with Retry-After so an outage does not read as the page being gone. Both sitemaps already listed published rows only. NewsRepository.GetBySlug returns repository.ErrNewsNotFound (same message as before)

[0.1.84] - 2026-09-18

Added
  • Admin News desk tab (?tab=newsdesk, new permission news.desk, migration 048; from the second session, commit 1eec0ea): the feeds table (enable/disable, weight, add, fetch one now for rss/hn, last fetch and last error), the writer and triage model pickers (a saved model must exist in the OpenRouter registry), per-run writer and art budgets (ceilings $5 and $1), and a cost/output panel. gw-newsdesk reads the models and budgets from site_settings unless a flag is passed, so a bare manual run now defaults to $0.25 writer / $0.05 art; pass -budget for fill runs. The cron no longer passes budget flags
  • Plan 12: a publish gate for hand-written news. POST /admin/news/:id/publish lints the article as it stands and refuses with 422 and the list of problems; no report is stored, so an edit can never leave a stale one. House voice and privacy rules are shared with the desk's LintNews (newsVoiceChecks). Dashes are allowed only after a bold list label (optionally followed by a link), between a name and a capitalised tagline in a headline or heading, and in number ranges (a digit on both sides; an em dash is never a range). Calibrated on all 51 live articles: 2 would be refused, both sibling-API posts with real prose dashes; the external News API path is not gated. The desk's Title Case headline rule checked on 56 headlines: 4 flagged, all real Title Case, none missed
Changed
  • The homepage ALL NEWS badge sits flush in the carousel's top-left corner on a frosted, blurred plate
Fixed
  • Saving a published article in the news editor would have set its status to an empty string and taken it off the site: NewsRepository.Update wrote status from a request that never sends one. Status is now owned by Publish/Unpublish, and Create always starts a draft. No article had been hit (51 of 51 published)
  • Reader tip images (/uploads/news-tips/, v0.1.83) were not in the GCS mirror's MANAGED_ROOTS, so they had no offsite copy; storage_mirror_test.go caught it

[0.1.83] - 2026-09-18

Added
  • Readers can send the news desk a tip at /submit-news (POST /news-tips): a headline, 1-5 links, what happened, up to 8 images, optional credit. Honeypot and the submissions rate limit; images are sniffed and stored as webp under news-tips/ (no video, no PDFs); every text field and link is run through ScanTextForInjection on arrival and the flags are stored and shown to staff. Nothing reaches a model until a person accepts the tip; accepting files it as a news_items row under the new Reader tips feed (migration 047), where the writer sees it fenced like any other source. Rejection paths tested (news_tip_test.go)
  • GET /news/stats and charts under /news (stories per day, topic share, topics by month, with a table view); /news views: magazine, grid, list, timeline (?view=)
  • ALL NEWS buttons on the homepage: a badge over the carousel and a centred button under it, generated as transparent art in a dark and a light variant (gw-newsdesk -raw-art)
  • News SEO: bot prerender now carries the full article, its sources, credits and the AI label, with NewsArticle JSON-LD citing every source; /news and /news?category= have their own prerender with an ItemList; /news/rss.xml; news images in the sitemap
  • gw-newsdesk -per-day N (fill busy days with a second and third story), -set-title
Fixed
  • An "iPod Classic" headline went up as "IPod Classic"; corrected (the slug is unchanged)

[0.1.82] - 2026-09-18

Added
  • The news desk (plan 16). One article a day about the beat mainstream games news skips: emulation and preservation, decomps and PC recompiles, console hacking and homebrew, ROM hacks and mods, independent developers, app store trouble, game-related programming. gw-newsdesk reads 41 feeds (subreddits via RSS, Hacker News via Algolia, emulator blogs, GitHub release feeds, GBAtemp, Wololo, Time Extension, Game Developer, MobileGamer.biz and others), has a cheap model (gpt-5.6-luna) group and rank a day's items, finds other coverage of the chosen story in the pool and through a Bing News search, reads every source page, keeps up to six real pictures (validated, deduplicated, webp, credited to the site they came from), and has Sonnet 5 write it under the preview desk's house rules with a new LintNews gate. A Reddit or Hacker News thread is never the only source: a story needs at least one article, release page or repository behind it, enforced in code. Articles are filed under the day the story happened, labelled AI-drafted, and list their sources. Generated art only when a story has no usable picture. Daily cron at 09:40 UTC (just newsdesk-install). See docs/features/news-desk.md
  • Migrations 044-046: news_articles gains category, tags, sources, gallery, splash_credit(_url), ai_drafted, cost_usd; new news_feeds and news_items
  • API: GET /news?category=, GET /news/categories, related on GET /news/:slug, and every news payload now carries the desk fields plus reading_minutes
  • News views rebuilt (from a second session, commit 3048842): the homepage carousel runs the full content width with an autoplay progress bar, a slow zoom on the active slide, category chips and a desktop thumbnail rail; /news is a magazine layout with a lead story, category chips, search and paging; article pages show the hero credit, tags, reading time, an AI-drafted label, a gallery with lightbox, a Sources list and related articles
Fixed
  • The sitemap listed only the newest 20 news articles. seo.go asked NewsService.ListPublished for 500 and the service silently clamped anything over 50 to 20. The sitemap now has its own unclamped query
  • The Playing Card Tarot open beta post led with a 200px thumbnail hotlinked from another site, stretched across the whole carousel slide. It now uses the full-size art, composed to 16:9 and served from our own uploads. The two posts that had no image at all now have illustrations

[0.1.81] - 2026-09-18

Security
  • The API ran as www-data, and so does the PHP-FPM pool serving tri-coma.com on this same box. A process can read /proc/<pid>/environ of any process with its own uid, so a bug in that unrelated PHP app would have handed over DATABASE_URL, JWT_SECRET, ENCRYPTION_KEY, POSTMARK_TOKEN and every other secret — the 600-root EnvironmentFile protects the file on disk, not the environment of the running process. Confirmed readable before the change and denied after. gw-api now runs as its own gwapi user; /srv/uploads is gwapi:www-data with setgid directories so files the API writes still land in the group nginx reads as (all 4255 re-owned, 0 unreadable by www-data afterwards). deploy.sh reproduces it, gw-run drops to gwapi. The postgres role is still called www-data — that is a password-authenticated database user, unrelated to the unix account, and it stays
  • SSH accepted passwords. 12,448 failed password attempts in seven days, 1,803 addresses banned by fail2ban all-time, and every successful login on record was publickey — no account on this box has ever logged in by password. PasswordAuthentication no and PermitRootLogin prohibit-password, tracked in infra/ssh/. The drop-in is numbered 01, not 99: sshd takes the first value it sees for a keyword and sshd_config.d/*.conf expands alphabetically, so a 99- file loses to cloud-init's 50-cloud-init.conf — which is exactly what happened on the first attempt, silently
  • The CSP was Report-Only with no report-uri, so nothing collected reports and nothing was blocked; it had been decorative since plan 06 B2. It also could not have been enforced as written — with no frame-src, YouTube embeds fell back to default-src 'self'. Nothing would have broken today: 0 of 157 published previews currently set video_url and there are still no published reviews, so the blast radius was zero and the gap was purely latent. It would have surfaced the first time a video review went live. Now enforced, with frame-src for both origins sanitizeReviewHtml.ts permits and form-action 'self' added. Checked first that the built index.html loads exactly one script from /assets, that there are no external fonts, stylesheets or fetch() calls, and that the sibling gamingworld.uk sites appear only as links and images — then confirmed with headless Chromium against the live home, previews, a preview page and /changelog: no violations, every page fully rendered
  • nginx offered TLS 1.0 and 1.1 in its base config. Not exploitable — every TLS vhost includes certbot's options-ssl-nginx.conf, which sets 1.2/1.3, and the server was already refusing 1.0 and 1.1 in practice. Tightened anyway so a vhost added without that include cannot inherit them
  • Go toolchain 1.24.7 -> 1.27.1, and the x/ libraries with it. govulncheck reported 30 vulnerabilities the API actually reaches, almost all in the standard library the 1.24.7 toolchain builds in: crypto/x509 quadratic name-constraint checking on the WebAuthn and JWT verification paths, crypto/tls ALPN error text carrying attacker-controlled bytes, net/url mis-parsing bracketed IPv6 hosts, encoding/pem and encoding/asn1 blowing up on crafted input in the GCS key loader. golang.org/x/net v0.43.0 -> v0.59.0, x/image v0.30.0 -> v0.46.0, x/crypto v0.42.0 -> v0.57.0. Reachable count is now 0
  • 15 npm advisories closed, 8 of them high. Lockfile-only, no package.json change and no breaking upgrade. The two that were not dev-tooling: @tiptap/core (a __proto__ key in mergeAttributes() reaching the prototype chain — TipTap is the editor behind every review and news article) and react-router (a CSRF bypass in RSC mode, which this SPA does not use). The rest sat under eslint, vitest, jsdom and postcss and never shipped to a browser
Fixed
  • just build-api wrote straight to /srv/api/api, the path systemd executes. A plain compile check replaced the live binary in place. Nothing broke on 2026-09-18 only because the running process kept its old inode and a deploy followed, but the health watchdog restarts gw-api on a failed check every two minutes — one of those after a bare build-api would have started whatever was in the working tree, uncommitted. It now builds to apps/api/bin/, which is gitignored. deploy.sh remains the only thing that writes the live path, and it still stages through api.new and keeps api.prev
Changed
  • CLAUDE.md: the live nginx vhost is /etc/nginx/sites-enabled/gw, a real file, not a symlink into sites-available/. Both existed and had drifted three months apart: sites-available/gw was missing all nine deny-vcs.conf includes and the /free-games and /mall-world bot prerenders. The repo matched the file nginx actually loads, so nothing was wrong in production — but anyone following the convention in the doc would have edited the copy nginx ignores, or reloaded the stale one over the top. sites-available/gw is now a copy of the live file

[0.1.80] - 2026-09-18

Fixed
  • Most of a game's desk notes were never reaching the writer. The fact sheet trimmed desk_note to its last 6000 bytes, cut wherever that fell. Measured over all 55 items that carry notes: 13 were being cut mid-note, so the writer was handed the back half of a sentence about what not to say, with the "not" on the far side of the cut, and every earlier correction silently dropped. The Manager Online carries 48,851 bytes of notes, eight windows' worth; Starvoxel Defender five. This is the mechanical cause of the six-regeneration fight on 2026-09-18, where each note fixed the sentence it named and broke something a previous note had asked for: those previous notes were not being sent. Notes are now kept whole, newest first, and a note that says it replaces every earlier one does — the convention the desk invented by hand now holds in code. The Manager Online goes from 11 notes read as a truncated tail to 1 complete spec
  • -note now says what the writer will read. It prints how many of the pile fit and, when some do not, what to do about it. Previously the only way to discover a correction had not been sent was a draft that came back wrong
Added
  • gw-huntimages -set-summary / -set-pitch. The fact sheet was write-once: the scanner read a studio's mail at ingest, wrote summary and pitch, and nothing could ever correct them. Both go to the writer on every regeneration, so a figure that was wrong on day one came back in every draft forever and the only lever was a desk note telling the writer to disbelieve its own brief. Starvoxel Defender took six passes on 2026-09-18 partly for this: its sheet still said "14 families" (Play was revised to 15 on 2026-09-13) and "no pay-to-win", a phrase published nowhere, and the writer reached past the notes for both. Each flag prints the value it is about to overwrite, because the sheet is the only record of what the studio originally said

[0.1.79] - 2026-09-17

Fixed
  • Two kinds of invented afternoon the linter waved through. The human-experience rules only fired on the word "longer", so a second visit worded as a count or a duration read as ordinary voice: "which I stared at for a while" (The Manager Online), "I went back to the site twice while writing this" (Choizzle), "I had to read that line twice" (Insudoku), "I have been staring at that number for longer than is probably healthy" (Guardian Cats). Calibrated over all 159 previews: 18 sentences in 18 published pieces, every one read, no false positives. "went/gone/came" need a particle ("went back", "gone over"), because a bare "it went twice as fast" is a claim about the game
  • The trailer the writer tells you it has not opened. The existing rule caught only a promise to watch it later; an admission of never having opened it passed, and every batch of rewrites produced one ("a trailer I have not opened", "though I haven't watched it and won't pretend otherwise"). Honest, and still the writer stepping into a sentence that belongs to the reader: the trailer is linked, and that is the whole fact. 5 published pieces. Watching and seeing need a media word in the same sentence, because "numbers I have not seen yet" is about the game; "opened" needs no anchor, because nobody opens a game
  • The prompt was teaching the tic it banned. The voice guide opened with "You have not watched the trailer and you have not seen anything move", and the writer put that sentence on the page in the first person. It now states the limit without handing over the words, and says to leave the reader's trailer line alone
Changed
  • "I read the store page twice" is no longer covered by the looking-not-playing exemption. Reading the listing is the job; reading it a second time is time nobody spent

[0.1.78] - 2026-09-17

Added
  • Prompt caching on the preview writer. Every writer call shipped the voice guide, the fact sheet and up to six screenshots at full price, and a lint retry sent all of it again. cache_control breakpoints now sit at the end of the voice guide and after the last still, so a repeat reads the prefix at a tenth of the price; the failed draft and its correction are appended after the marks and never invalidate what precedes them. Measured on the first run: in=24169 cached=0 then in=25905 cached=24167, 93% of the prompt served from cache on the retry
  • A hard daily AI spend ceiling, AI_DAILY_BUDGET_USD (default $10). Checked before every writer call, across the API, the tickers and every gw-* CLI; a call that would cross it is refused with the day's running total, and the window resets at 00:00 UTC. An operator can raise it for one run with AI_DAILY_BUDGET_USD=25 gw-run .... This exists because 2026-09-17 cost $22.88 with nothing wrong: no bug and no leak, just a retry-heavy rewrite campaign nobody was totting up as it ran
  • gw-setkey installs a provider key into the database, where the API and the CLIs actually read it from. Dropping a new key into /var/keys rotates nothing; that path is only the vault copy backup-keys.sh ships to GCS. The key is never printed, only its 4-and-4 hint. gw-run gw-setkey -list shows what is stored and when it was set
Fixed
  • A doomed call is no longer retried three times. 401, 402 and 403 now return ErrProviderRefused and stop the try loop immediately; an out-of-credit regen used to print the identical 402 three times over. A spent daily budget stops it the same way

  • A regen that is not converging gives up. Each -tries attempt was a fresh conversation, so the writer never learned what the previous try got wrong and re-rolled blind. When two tries come back with an identical lint report, the run stops rather than paying for a third identical roll

  • Stills are sent at 784px, not 1568px. A provider charges by pixels, not file size: a 1722x1536 screenshot cost 2,922 tokens sent whole and 730 at half the edge. Measured across all 159 previews, stills were 70.5% of every prompt. Verified on a live call: Helvetreis went from 24,169 input tokens to 9,494, a 61% cut, and the draft came back lint-clean. UI text stays legible at 784px, which matters because a writer that cannot read a label is a writer that invents one

  • gw-abpreviews -prompt-cost estimates what a writer call would send, per component, without sending anything. With -id, the breakdown for one preview; without, every preview ranked worst-first with the images share of each

Changed
  • Writer calls log in=, cached= and out= token counts, so a zero cache-read column is visible rather than silent

[0.1.77] - 2026-09-17

Fixed
  • A link a studio sends us reaches their live page without a rewrite. gw-huntimages -add-link/-remove-link only wrote to game_queue, so the preview kept its old links until someone paid for a regen. With OpenRouter out of credits that meant Xavier at Helvetreis sent two trailers and neither could be published at all. Links are data, not prose, so the CLI now copies the filtered public links onto the preview row as -set-studio and -set-price already do (PreviewRepository.SetPublicLinksForQueue); the regen line still stands for the words
  • A preview with two trailers showed only one. PreviewPage picked the first trailer link to embed and filtered every trailer out of the "Get it" row, so a second one a developer sent was silently dropped. Extras now render as links beside the store buttons, and the section shows for a game whose only links are trailers
Content
  • Helvetreis carries both of Xavier's trailers at his request

[0.1.76] - 2026-09-17

Added
  • The image hunt reads App Store listings. An apps.apple.com link went through the generic page scraper, which only finds srcset thumbnails below the width floor, so an iOS-only game got its app icon and nothing else. The hunt now asks Apple's public lookup API for every iPhone and iPad screenshot and requests each at 2000px (preview_images_appstore.go); square results are rejected as store furniture, as on Play. Deadly Prompt went from four copies of its icon to nine real screens
  • gw-abpreviews -dedupe-gallery [-dry] takes byte-identical copies, and copies of the hero, off every preview's gallery. Hunts on 28-31 August wrote some images twice under two names (before v0.1.38 rejected duplicates); run once on 2026-09-17, it dropped 178 copies from 53 live pages. Files stay on disk
Content
  • Session item S18, batch 1: eight published previews regenerated against their screenshots and stores (snake-gridbreaker, bitiverse, thatracinggame, deepward, work-rush, white-creek, nova-swarm, mineracer). Release lines corrected on six from Steam, itch.io and Google Play; four sign-offs now "Grab it"; about 30 wrong-game, duplicate or re-encoded images dropped, including a photo of a real dog under a game banner
  • Session item S18, batch 2: eight more (desktop-pet-idle-cats, all-our-broken-parts, king-me, the-legend-of-joke, deadly-prompt, country-draw, feed-it, the-manager-online). Feed It's three links were email tracking redirects and are now its Steam page; King Me is name-your-own-price on itch.io; The Manager Online is credited to Snilld; The Legend Of Joke lost a doubled "Coming Coming soon". 42 previews still fail lint

[0.1.75] - 2026-09-17

Fixed
  • Two more forms of the desk's lingering tic now fail lint. NeoRunners' regen wrote "My eye kept sticking on" and Video Game Menu's wrote "I have stared at [it] longer than the screenshot probably deserves"; the rules only knew "going back" and "spent longer". The first form matches 0 stored previews; the second matches 11 published ones (country-draw, feed-it, starvoxel-defender, crooky-the-game, all-our-broken-parts, dice-after-dark, deadly-prompt, bitiverse, nova-swarm, wish-impossible and one more), now part of session item S18
Content
  • Three previews published after image checks and three to six desk-note passes each: NeoRunners (one composited image dropped), Tap Monster Duel (out today on Switch; nine Linktree and other-GaniTani images dropped, Linktree link removed), Video Game Menu: The Game (duplicate images and a press kit cover carrying a personal address dropped). Studios notified

[0.1.74] - 2026-09-17

Fixed
  • A Steam link added by hand now sets the game's Steam app id. gw-huntimages -add-link with a store.steampowered.com/app/N URL only appended the link, so the image hunt and the price fill never saw Steam. Clearing Blade's hunt found 0 Steam images with the link on the item and 8 once the id was set. GameQueueRepository.AddLink now fills steam_app_id from the URL when the item has none. Three published previews (Deep Blue Sushi, Weather the Swarm, Spacetime Chronicles) still carry a Steam link with no id
  • A sign-off that would be cut off, or appears twice, now fails lint. Gaotai Survival's regen passed with a 212-character sign-off that the save truncated to 200 mid-sentence, plus a bare "Wishlist it if" paragraph above the real one. Both rules flag 0 of 156 stored previews
Content
  • Nine previews published after image checks and two to four desk-note passes each: Ball Rampage, Foxwood Games, Crow Circuit Chess (four gallery images of other ArcadeReboot games removed), Gaotai Survival, LIFE LIKE A DJ, BoxParty!, Clearing Blade (eleven publisher and other-game images replaced by Steam screenshots; Early Access, £7.49), Damn, Zombies: The Corporate Undead, Forklift Logistics Simulator (full release March 2027). Studios notified
  • Goblin Maze held: its only image is the Goblin Arcade portal banner; the studio was asked for screenshots. ClueRoll (no images) sent the standard note

[0.1.73] - 2026-09-16

Fixed
  • A release line that says "Out now" now wins over the text written before launch. MONOWAVE went on sale today; its release line was corrected to "Out now on Steam, released September 16, 2026" and the piece regenerated with -clean-only, and the new draft still said "a free demo is already up ahead of the full release on 16 September" and signed off "Wishlist it". It passed lint, because nothing in that sentence says the game is out, and the store text and the pitch, both written before launch, outvoted one short line. services.ReleaseLineSaysOut recognises the forms the desk writes ("Out now", "In Early Access", "Released", "Launched") and refuses any line that mentions a demo, playtest, beta or plan; run over all 124 release lines in the queue it matches 27, all of them games on sale. When it matches, the fact sheet says the older text below is out of date on that point, and the writer loop rejects a draft that signs off "Wishlist it" or describes the launch as still to come (ReleasedFactProblems). Over the 144 published previews it flags exactly one page, MONOWAVE
  • MONOWAVE's preview now says it is out on Steam at £7.39 (the standing price; launch week is 10% off) and signs off "Grab it"; the queue row is tier paid, not demo. Three desk notes went in after its screenshots were checked, one of them correcting the desk's own first note, which had missed stage 1-1 because the check left out the hero image

[0.1.72] - 2026-09-16

Added
  • Free games have somewhere to live (plan 15 phase 3). 44 published previews are free or free with purchases, and until now a reader could only find them by opening each one. /previews gains a green "Free" chip and a sort control (Newest, Price: low to high, Price: high to low), and search, genre, free and sort are all in the URL now, so a filtered list can be linked and survives a reload. The filter bar moved to components/previews/PreviewFilters.tsx
  • /free-games, the same list with Free fixed on and its own title and description; in the sitemap, and served to bots by GET /seo/free-games, which lists every free game as a link so a crawler that runs no JavaScript still finds them (infra/nginx/gw routes it). The Mall World prerender template is now a shared staticMetaPage
  • A "Free to play" row on the homepage under the previews showcase, six games, linking to /free-games. It skips anything the showcase already shows: the newest free games are usually among the newest six, and the first live build put Lumen Vault at the end of one row and the start of the next
  • Price badges on preview cards: "Free", "Free, with purchases", or the list price in its own currency for a paid game. A demo shows nothing on the public card, and so does a paid game with no amount on file: the badge only states what the data holds (utils/price.ts, tested)
  • Admin games queue: the tier as a badge on cards and list rows (demo included), a "Tier" line in the detail modal, and a "Free only" toggle that combines with the status filters (GET /admin/games?free=1)
Notes
  • Price sort orders by tier first, then amount; GBP and USD amounts are compared as plain numbers (20 GBP and 3 USD prices today). Good enough for a sort, not for a comparison

[0.1.71] - 2026-09-16

Added
  • gw-relint -stale: the live pages the calendar has overtaken. A preview is written once and then left alone, but its proudest sentence is often a date in the future. Kickslaught spent two days telling readers to wishlist a game that had gone on sale at 95p, and it was found by accident while chasing a different game's release. The check reads the last day a queue item's release line commits to and lists every published preview that is past it and still signing off "Wishlist it"; just replies runs it. A quarter or a bare year is not a date on purpose ("planned for Q4 2026" is not wrong on 1 October), and the day itself counts, because a game dated today is on sale by the evening. Six flagged on the first run, four of them released weeks earlier
  • gw-devnotes -mark-notified <queue id>. A studio that wrote in and got a real answer still printed as owed a note forever, because -reply records nothing on the queue row: Aventuras had been sat there since 15 September with the one thing the template would have said already in the developer's inbox. Migration 043_dev_notified_by_hand.sql adds game_queue.dev_notified_by_hand, so the row can say the note was covered by a person in their own words rather than claiming a template went out. It refuses an item that is not owed, records the kind that was owed (which keeps the "asked for artwork, then previewed" rule working), and points at the message we actually sent; -force covers an answer that went out some other way
Fixed
  • The released signal now reads contractions. "It's on browser now" is the same claim as "It is on browser now", and \b(?:is|are)\b matches neither the apostrophe nor the "'re" form, so Country Draw, free and playable in a tab, signed off "Wishlist it". Run over all 153 previews the widened rule finds exactly one new page, which is that one. The bare "out" alternative keeps the spelled-out verb: Go's regexp has no negative lookahead and "it's out of ideas" is not a release
  • Five previews were corrected against their stores: Kickslaught (in Early Access at 95p since 14 September, and its market prices are in-game gold, not pounds - the piece printed a pound sign in front of 1,040 for a shot speed boost), 1 MIN AIM: Neo Seoul, The Carcass, Refund Me TD and Deep Blue Sushi, all four out for between six days and six weeks while their pages asked for a wishlist. Country Draw's sign-off was fixed in place
  • Deep Blue Sushi's two mail-merge tracker links were resolved and replaced with the Steam page and the press kit they point at, so a reader clicking through no longer reports it to the sender's tracker (queue item S14, one of four pages)

[0.1.70] - 2026-09-16

Added
  • The desk can correct the writer. The writer is shown the screenshots and reads them itself, so a misread label became a stated fact on the site and only a studio writing in could undo it. Migration 042_desk_notes.sql adds game_queue.desk_note, appended oldest first like dev_reply and the one input that reaches the model unfenced, because we wrote it. gw-abpreviews -note <preview|queue id> -body - records one; the next -regen writes it as plain fact and never mentions the correction
  • Corrections went in for six published previews after every claim in them was checked against the images: Crooked (Read Mind costs thirty dollars to buy and adrenaline to use, not adrenaline instead of cash), Duane's OS (the settings panels are navy, not beige; the arcade screenshot is a menu, not a game of Space Invaders), Daily Botanica (14/237 counts species, not XP; the XP line is 60/196), Penguin Weapons (the tag reads TIER II, the damage numbers are positive, the kayak and the spiral effects are in different screenshots), Long Story Short (the grey tile sits between a blue one and an orange one) and Lumen Vault (the hint count does not track difficulty)
Fixed
  • "Is in Early Access" now counts as released for the sign-off check. Crooked signed off "Wishlist it" while its own last line said it was in Early Access on Steam for £1.79. The phrase alone is still not an out-signal; the verb is. Of the eight published previews that mention Early Access, "is in" matches only the one that is on sale, and "heading into Early Access on September 14" stays a wishlist. Widening the older "is on ... now" rule instead would have started reading "is in development on Steam right now" as a release

[0.1.69] - 2026-09-15

Fixed
  • The retro writer no longer rules on pixel art that is not there. Rhodri is assigned games by genre beat (roguelite, arcade, dungeon), and his voice told him to say whether a game's pixel art earned its nostalgia. Handed Crooked, which is painted, he said so anyway and invented a reason: a pixel art genre tag on the Steam page that does not exist. The voice now asks for that verdict only when the art in front of him is pixel art, and tells him to write about what he can see otherwise
  • Writer rule 4 names the two inventions that reached the site: how big a studio is, and what a store page lists. The corpus sweep behind this found 19 previews claiming a solo developer, 16 of them sourced from the studio's own pitch; the two that were not (Crooked, LIFELINE) were regenerated

[0.1.68] - 2026-09-14

Added
  • Plan 15 phase 2 step 7: the submission form asks about price. "Is the game free?" (optional: free, free with purchases, free demo of a paid game, paid, or not sure) and, only for the two free answers, "Consider it for Top 5 Free Games". Migration 041_submission_price.sql: review_submissions.price_tier (same CHECK as 039, default unknown) and wants_free_feature; game_queue.wants_free_feature_at, a time rather than a flag because the monthly Top 5 is first come, first served and the ask can arrive after the pitch
  • SubmissionPriceAnswer validates both (a bad tier is a 400; a tick sent with a paid answer is dropped). The scanner puts the studio's answer on the queue item as price_source = submission, fills an empty price line from it ("Free, with in-app purchases") so the writer sees it, and carries the Top 5 request with the submission's time
  • The scanner prompt asks for wants_free_feature (the sender asks in words to be in a free games list); a duplicate pitch that asks records the request on the existing entry, keeping the earliest time
  • Admin submissions rows show the price answer and "Top 5 request"
  • gw-sql: read-only psql as the new gw_ro role (SELECT only, passphrase_hash, refresh_token_hash, api_keys.encrypted_key and game_queue.review_keys withheld, 30s timeout), set up by scripts/gw-sql-role.sh and refreshed by migrate.sh. gw-devnotes -archive MAIL
Changed
  • Price precedence (Jack, 2026-09-14: store evidence wins): Steam, then the studio's form answer, then the classifier over the price line, then the scanner model. Steam's plain free does not replace a studio's "free, with purchases". gw-pricefill keeps a submission tier unless Steam states one, and prints a CHECK row when the studio's own price line disagrees with their answer
  • SubmitGamePage split: components/submit/PriceQuestion, MediaDropzone, SubmissionReceived (241 lines to 196)

[0.1.67] - 2026-09-14

Added
  • Duplicate games are caught at every door. Little Bettas was pitched twice under two titles from two addresses and both pitches were drafted and published, because the only check compared whole titles. services/game_identity.go now calls two items the same game (SURE) when they share a store page (Steam app id, Google Play id=, App Store id, itch.io page, Kickstarter project), or the same title minus subtitle and punctuation from the same studio (same address, or one studio name inside the other), or the identical title with no studio given. The same title from two unmatched studios is MAYBE, for a person. Over all 149 queue items it found 5 pairs, all real
  • The scanner no longer queues a SURE duplicate: its links are added to the existing item and the scan log names it. POST /admin/games answers 409 with the existing id. Generate refuses to draft a SURE duplicate of an item that already has a live preview
  • gw-abpreviews -dupes [-all], -merge DUP -into KEEP [-dry] (moves links, review keys and any studio reply, redirects the old slug, rejects the retired preview, dismisses its item with a DUPLICATE note, pings IndexNow), -distinct QA,QB. just replies prints -dupes every session
  • Migration 040_game_duplicates.sql: game_queue_duplicates (verdict duplicate or distinct) and preview_redirects. A separate table, not a game_queue column, so the running binary survives the migrate
  • An old preview slug answers with the kept preview: GET /previews/:slug falls back to preview_redirects and PreviewPage replaces the URL; the bot prerender sends a 301. A redirected slug is never handed to a new preview. Studio replies from a merged item's address are recorded on the kept item
Fixed
  • The redirect fallback runs when the slug is not published, not only when it is missing: the retired row keeps its slug, so the first deploy of this version still 404ed the old Little Bettas URL (caught reading it back, fixed before the version was pushed)
Removed
  • GameQueueRepository.ExistsByTitle, replaced by the identity check

[0.1.66] - 2026-09-14

Added
  • Plan 15 phase 2, admin and scanner. The scanner prompt asks for price_tier. ApplyScannerPriceTier uses it only as the last fallback (Steam and the classifier both unknown) and only when the item states a price line, so a model guessing "free" from silence puts nothing in the Free section; recorded as price_source = scanner
  • PreviewEdit takes price_info and price_tier, written to the queue item as well (source hand) so a rewrite keeps them; a paid tier reads its amount from the line; an unknown tier is a 400. The preview edit modal has a price line and tier select and sends them only when they changed, so an ordinary save does not freeze the tier against gw-pricefill
  • AddGameModal has a tier select next to the price line (PriceTierSelect, shared)
Changed
  • Price tiers backfilled on production: gw-pricefill -write (88 rows), then the 22 CHECK rows by hand against Google Play, the App Store lookup and itch.io. Arcane Heroes 2, Jet Fighter Game, King of Words and Starvoxel carry Play's in-app purchases label (free with purchases); Foxwood Games is US$1.29; VYS Infinite is Early Access from US$2 with a free demo; 2048 Trader is free on the App Store ("premium" was the pitch's adjective); Wizard Tournament is £7.99 now it is out. The last three published pages were rewritten (-regen -clean-only) and read back

[0.1.65] - 2026-09-14

Added
  • Plan 15 phase 2, backend: price as data. Migration 039_price_tier.sql adds price_tier (free, free_with_purchases, demo, paid, unknown), price_amount and price_currency to game_queue and previews, plus price_source (steam, text, hand) on the queue. price_info stays the line printed on the page
  • services/price_tier.go: ClassifyPrice over the price line (the release line when that is empty), PriceFromSteam, ResolvePrice (Steam wins, except a studio's "free with optional purchases" over Steam's is_free). Test table holds every distinct price line on the 134 published previews plus the near misses ("free demo planned", "One-time purchase (no in-app purchases)")
  • Steam appdetails now decodes currency, initial, final, discount_percent and demos; price_amount is the list price, so goo-deer's stale £8.44 launch discount becomes £12.79. Scanner enrichment and the scout handler (price_tier in the body) fill the tier on new items
  • GET /previews takes free=1 and sort=newest|price_asc|price_desc (whitelisted; the default order is unchanged, so the homepage showcase does not move). Public previews carry price_tier, price_amount, price_currency
  • gw-pricefill: dry-run table of every preview and live queue item (old tier, new tier, amount, source, CHECK notes for text-classified games with a store page elsewhere and Steam/text disagreements); -write, -only, -only-steam, -no-steam. Hand-set tiers are kept on the queue item and copied to the preview; no-op rows are not written, so updated_at (the sitemap lastmod) does not move. Dry run on production 2026-09-14: 145 rows, free 35, free with purchases 8, demo 22, paid 22, unknown 58, 21 to check by hand
  • gw-huntimages -set-price takes free|fwp|demo|paid|paid:£4.99|unknown as well as a price line, recorded as set by hand and copied onto the preview at once
Fixed
  • A regen copies the queue item's tier onto the preview, but leaves the preview's tier alone when the queue item's is unknown
Deploy note
  • game_queue is read with SELECT gq.*, so between migrate.sh and the restart the old binary cannot read the queue (admin queue and the scanner tick, about a minute; public previews use explicit columns). An auto-rollback leaves the old binary on the new schema: the queue stays broken until a forward deploy. The CLIs built from this commit write the new columns, so run just install-clis after the deploy, not before

[0.1.64] - 2026-09-14

Changed
  • Plan 15 phase 1: the homepage leads with news. NewsCarousel (GET /news?limit=6) replaces the single-article NewsBanner at the top of the page: full-width slides at 16:9 (21:9 from sm), splash image with a gradient and the title as alt text, or a newspaper mark when the post has no image. Arrows (top corner on phones, sides from sm), dots, pointer swipe and the arrow keys; auto-advance every 7 s, paused on hover and focus, off under prefers-reduced-motion; aria-live is off while it cycles and polite when paused. Timer and gesture logic in hooks/useCarousel.ts. Checked at 400 px and 1280 px in headless Chromium: next, dot, ArrowLeft, auto-advance after blur, and no advance under reduced motion
  • MallWorldFeature moves below PreviewShowcase; ReviewSplash and the rest keep their places
  • NewsBanner.tsx deleted (nothing else used it); the article shape both it and NewsPage declared now lives once in components/news/newsTypes.ts
Gotcha
  • index.css gives every button position: relative, which outranks Tailwind's absolute (utilities sit in a cascade layer). Absolute placement of a button has to go on a wrapper element

[0.1.63] - 2026-09-14

Changed
  • Plan 13 phase C, batch 1: the first 15 "I keep coming back to" pieces rewritten (bloodjack, zeroeco, dark-roll-2, fantasy-online-2, weather-the-swarm, tormentum-ii, toy-shop-tidy-up, knife-slice-3d, halloway, 38-0-j-league-challenge, hoop-dynasty-rise-of-a-legend, hn-gunslinger, goo-deer, nitelore, cartoon-crime). Every regenerated page read in full; five needed a second or third roll. Facts corrected on the queue items first so the rewrite used them: Tormentum II, Toy Shop Tidy Up and Nitelore said "launches" about games that are out, Tormentum II and Toy Shop still advertised launch discounts that ended weeks ago, Weather the Swarm's release line printed "announcement under embargo until July 23", and Hn Gunslinger credited "Mishref Studio (inferred from email)"
Added
  • Lint: the tic with the pronoun or verb moved. Batch 1's rewrites wrote "My eye keeps going back to", and two of the fifteen "I keep looking at", so the rule now covers my eye/attention keeps going back, keeps pulling me back, find myself going back, and I keep looking at / staring at / squinting at / picturing / turning over. Calibrated on all 144 previews: every hit read, no false positives; 14 more published pieces fail as stored and join phase C
  • Lint: "aside", the prompt's name for the personal paragraph, printed on the page ("My aside for this one is", "the closest thing to an aside I can offer", 4 published pieces). The label-colon rule also missed a bold label (<p><strong>The aside:</strong>), which is how the zeroeco rewrite leaked it
Added (tooling)
  • gw-abpreviews -regen X -clean-only [-tries 3] saves only a lint-clean draft and otherwise leaves the page alone. The writer keeps its last attempt whatever the lint says, and on a live page a re-roll made toy-shop-tidy-up worse (the tic back, "Wishlist it" on a released game). PreviewService.RegenerateIfClean
  • gw-huntimages -id X -set-price "<text>" (- clears), beside -set-release: a launch discount copied from the announcement mail had no way to expire short of SQL
Fixed
  • Weather the Swarm's trailer, Steam and Discord links went through a mail-merge click tracker (yamm-track), so every reader click reported to the sender. Replaced with the targets. Three more published pieces have tracker links (plan 14 S14)
  • Hn Gunslinger showed the same poster twice (hero and gallery were byte-identical files)

[0.1.62] - 2026-09-14

Changed
  • Plan 13 phase B: the writer prompt stops teaching human experience. Seven of the nine voice blocks told the model its aside was "a single moment in the trailer you keep rewinding", "something you saw or heard in the footage", or what "the footage suggests", while rule 5b banned exactly that. Every aside now points at a screenshot, a palette, a level layout or a store page. The template says "one of the AI house voices" instead of "staff writer", the aside rule says an AI voice has no time or habit to report, and rule 5a no longer quotes the time confessions it bans
Added
  • Lint humanExperienceRes: time claims ("I spent longer than I'd admit", 18 published pieces, every one about a screenshot, which is why the looking exemption let them through), "I keep coming back to" and its variants as a style failure (35), and "before I do/can" about seeing the game move (2). Calibrated on all 144 previews: every hit read, no false positives. 53 of 134 published pieces now fail as stored; plan 13 phase C lists them for regeneration
Fixed
  • "I've watched the trailer twice" (sectmind) and "I've played" passed lint: the watched and hands-on rules wanted whitespace between the pronoun and the verb, and a contraction has none

[0.1.61] - 2026-09-14

Fixed
  • A studio that uploads only a video with the submission form gets a preview. The image hunt passed uploads to the image decoder, which called an mp4 "not an image", so the item ended at "no usable images" and the studio would have been told we had nothing to work from (Duane's OS: a 113-second 1080p walkthrough, the only media sent). preview_images_video.go pulls six stills from the middle of the clip with ffmpeg (12% to 82%, clear of logo and end cards), crops baked-in letterbox bars (cropdetect; his capture had 90px top and bottom), and hands them to the same checks as every other candidate, so fades, lookalikes and small frames are still dropped there. Source label video in the hunt note. 11 submissions carry an mp4 and only this one had no other art
Added (tooling)
  • gw-huntimages -id X -fresh forgets what earlier hunts saved (the gallery, and the card image when it is one of the hunt's own files) before hunting. Every hunt reads its own previous gallery back in and image_path never changes once set, so a bad picture survived every re-hunt: the first Duane run's five letterboxed frames came back beside their cropped copies. Refuses on an item with a preview
Fixed (tooling)
  • The image hunt's note now counts "corrupt image" and "blank image" rejections. They were left out of its reason list, so ten screenshots a studio sent with cut-off PNG data disappeared from gw-huntimages output without a reason
Added (tooling)
  • gw-abpreviews -unblock <queue id> undoes -block for one queue item; the withdrawn preview stays down. Used for High Hand Poker after its developer asked for a real review
  • just mail <id> prints one mail with its whole thread and the sender's queue item ids

[0.1.60] - 2026-09-13

Fixed
  • Every preview printed its closing line twice. The writer ends the body with the "Grab it if..." paragraph and also returns it as wishlist_line, and PreviewPage showed both, one under the other. dropClosingLine takes a last paragraph that matches the line off the prose before the figures are placed, and the quote box keeps it. Reported by a studio. Run over all 134 published previews: 132 match, the other two are below
  • A closing line over 200 characters was cut off in the quote box. previews.wishlist_line is varchar(200), so a long line was stored truncated. A last paragraph that starts with the stored line also matches, and the box shows the paragraph's full sentence (cartoon-crime)
  • One preview (little-bettas) had <h2>...</p>, which turned the rest of the article into a heading; regenerated

[0.1.59] - 2026-09-13

Fixed
  • A studio writing back in one of our threads is never read as a new pitch. The scanner now skips any inbound mail whose subject belongs to a studio note thread ("Re: X is up on Gaming World", "X: we need a few screenshots..."), before the model sees it; those reach a person through just replies. A developer's personal letter to Jack in one of those threads, with private photos attached, scored 90%, was queued as a new game and drafted by the ticker at 00:09. The draft was withdrawn before anything published it and the attachments were moved out of /srv/uploads to a root-only quarantine. The subject tails are shared constants in dev_notes.go (IsDevNoteThread), so the rule follows the notes if their wording changes. Checked against every inbound mail: 38 are thread replies, the only 2 that were ever queued are that letter and the one before it, and none of the 89 mail-sourced queue items would be skipped
  • Why the block had been catching it: ExistsByTitle ignores dismissed items, so after -unblock nothing stood between a withdrawn game's thread and a fresh queue item

[0.1.58] - 2026-09-12

Changed
  • Previews say they are written with AI, everywhere a reader or a studio meets them (plan 13 phase A). The byline reads "AI writer, Previews desk"; the card at the end of a piece says "Drafted by , one of Gaming World's AI preview voices", shows even when there is no bio, and carries the disclosure line with a link to a standing note at the top of /previews (PreviewDeskNote, #how-previews-are-made). The list page meta description says the same
  • Search engines are no longer told a byline is a person. The prerender's JSON-LD author is the Organization "Gaming World Previews desk", meta author and article:author read "Gaming World (AI-drafted)", and the bot body says "By , AI writer" plus the disclosure
  • Bios rewritten (migration 038, same text in preview_personas.go): what each voice reads, never what it watched, owns or keeps on a desk. The persona test fails a bio that claims a person's experience or does not start "AI preview voice", and a new test fails if the registry and the migration drift
  • Notes to studios carry no byline name. devNoteSignOff is the desk only, the writer plumbing through DevNoteDrafts, the notify handler, ListOwedDevNote and GetReplyTarget is gone, every previewed and no-media note says the preview is written with AI, and the notes say "we" rather than "I". They also stop promising to "fold footage into the piece" and ask for screenshots as attachments, because a Drive folder never reaches the image hunt
  • The disclosure does not say a person checks each piece, which plan 13 had proposed: drafts go live through -publish-clean, and saying a human read them would be a new false claim. It says automated tools check them
  • Admin inbox labels call a byline address "a previews AI voice: answer as the desk". Past public changelog entries that introduced the voices as writers with faces are reworded

[0.1.57] - 2026-09-12

Added
  • Studios we do not cover. coverage_blocklist (migration 037) holds contact addresses, studio names and studio website domains. Every queue item is checked on the way in, whatever its source, and a match is stored already dismissed with the reason on it, so the scanner does not retry the mail and a person can still see they wrote. Shared mail hosts and stores (proton.me, Gmail, Steam, Google Play) are never blocked by domain. gw-abpreviews -withdraw <id> -block "<reason>" adds the studio; entries are data and never go in a migration, because the values are people's addresses. Checked against all 141 queue items: only the withdrawn one matches

[0.1.56] - 2026-09-12

Fixed
  • A studio with two games had every reply recorded on both. gw-devnotes -ingest matched replies on the sender alone, so RedenelGO's Cozy Liora press kit was about to land on KICKSLAUGHT as a correction too. Replies now go to the game named in the subject whenever an address has more than one, and a subject naming neither is left for a person. -reply threads on the right game's conversation for the same reason
  • Links on a line of their own were deleted from replies. The cleanup for Gmail's emoji reactions removed every line that was only a URL, so Goo Deer's "Official trailer:", "Media kit:" and "Steam page:" reached the writer with nothing under them. Only Gmail's own link goes now
  • Steam keys went into the text the writer reads. A reply carrying keys is now split: the keys go to review_keys, the text keeps a marker, and the dry run prints a count rather than the key
  • Turkish Gmail and German Outlook quote headers are recognised, so our own note no longer rides along under those replies
  • A rewrite put the old studio name back. A regeneration now copies the studio from the queue item, the same way it already copied the release line
  • just replies crashed on stale CLI binaries after the v0.1.54 migration. It rebuilds them when the Go source is newer
Changed
  • just replies is a to-do list: one row per unhandled studio message with its mail id, the game it is about (matched by thread, which also finds a developer writing from a second address), and whether it is recorded and answered
  • Replies are signed by the desk, not by a byline. The bylines are AI writers. A developer answering "Hi Priya" was writing to somebody who does not exist, and a reply signed Priya told them she did. Replies now say so where it matters
Added (tooling)
  • gw-abpreviews -withdraw <preview id> for a studio that asks us to take a piece down: rejects it, dismisses the queue item with the reason written on it, and pings IndexNow so the 404 is crawled. Used today for High Hand Poker
  • gw-devnotes -ack <mail ids> closes a thank-you without recording it; gw-huntimages -set-studio and -remove-link; fact edits on an item with a live preview print the -regen to run instead of refusing
  • The studio-reply playbook in docs/features/previews.md: what each kind of message needs, in order

[0.1.55] - 2026-09-12

Fixed
  • Every genre chip and every search on /previews answered 500 rather than filtering, from the day the page shipped in plan 09 until a smoke test of the new video field went looking for one preview and could not find it. The list query numbered its filter placeholders after limit and offset, then handed the same WHERE clause to a count query with neither of them bound, so the clause referred to a $3 that was not there. Filters are numbered first now and the one clause is valid in both statements

[0.1.54] - 2026-09-12

Added
  • Video reviews attach to a preview. A preview is written from screenshots and is careful to say nobody here has played the game. A video is the opposite claim, so it gets its own fields rather than being smuggled in as another entry in public_links where it would render as the developer's own trailer and say nothing about who played it. URL, title, who played it and a standfirst, set from the preview edit modal in the admin panel
  • The block sits above the prose on the preview page, embeds through youtube-nocookie for a YouTube link and falls back to a plain "Watch the review" button for anything else. Preview cards carry a Video badge, because somebody here having played it is the reason a reader picks that card out of the grid
  • Rewriting a preview leaves the video alone. ReplaceGenerated names the columns it overwrites and the video columns are not among them: a rewrite of the prose does not un-play the game
Fixed
  • A studio written to twice could not be heard in between. The reply window was anchored on dev_notified_at, which every new note overwrites, so a developer who answered "we need screenshots" with screenshots and then got the "it is up" note had everything they said fall outside the window. gw-devnotes -ingest could never see it and the correction never reached the writer. The window now starts at the first note, recorded once in dev_first_notified_at and backfilled from the mail log. Two replies were stuck behind this and both are recorded now: Nonograms.games sending the gameplay footage we had asked for, and Lying Platforms sending the itch.io page with their screenshots on it
Added (tooling)
  • gw-devnotes -reply -id <queue uuid> -body <file> answers one studio in their own thread, signed by the writer whose name is on the piece, and marks their message read and replied. The two templates cover the two things the desk says unprompted; everything after that is a conversation, and a conversation needs a person writing the words. It was the admin composer by hand until now

[0.1.53] - 2026-09-12

Added
  • A Mall World section at /mall-world, which is the first thing on the site that is ours rather than somebody else's. A hub plus six pages: the dream system, the ZFPS movement and combat model, the nightmare cycle, the world builder, a tour of the admin panel, and a strategy guide
  • Every number quoted in it was read off the live USWest build rather than written from memory: the public catalog and dimensions endpoints, and the help text the admin panel shows beside each knob. The cube-law summoning cost, the 8 MB / 50k-triangle import limit, the 45 second Night Terror omen and the twenty second abandonment grace are all the shipped defaults
  • The front page leads with it, labelled "our game" rather than slipped in among previews of other people's work. Previews follow underneath
  • Bot prerender for /mall-world and its sections (/seo/mall-world/:section plus two nginx locations). The copy lives in the JS bundle, so without it every link Jack shares would have unfurled as the site front page. All seven URLs are in the sitemap
Notes
  • The art is Mall World's own brand gallery, nine images, each one looked at before it went in. There are no in-game screenshots yet: this box has no browser, and headless WebGL capture on the production web server is a poor trade for the result

[0.1.52] - 2026-09-12

Added
  • Studios can correct us and have it stick. game_queue.dev_reply holds what a developer said after reading their preview, and the writer reads it as its own block that outranks the store page and the original pitch. Editing the HTML by hand was never a fix: previews are written from the queue item, so the next regeneration writes the error back over the correction and the developer we promised a fix to gets to watch it return
  • gw-devnotes -ingest [-dry] folds replies from studios we have written to into their queue items, stripping the copy of our own note quoted underneath. That quote matters: the writer is told this block is authoritative, so leaving it in means "a review is written by a person and I cannot promise one" reads as something the studio said about their game
  • The reply's mail row is recorded too, so the image hunt can finally see attachments a developer sends back. It read source_mail_id alone, which is the mail that created the item, so a studio answering "we need four screenshots" with four screenshots was putting them in a folder nothing opened. Aevryn Studios sent three
  • A "the studio wrote back" panel in the games queue detail modal: what they said, and a box to paste the next one into
  • gw-huntimages -id <uuid> -set-release "<text>" corrects the release line. -add-link was already there for the same errand and the date next to it needed SQL
Fixed
  • 36 of 135 published previews described a trailer nobody here has watched, and the linter passed every one. \brewind never matches "rewound", which is how ten of them put it. "pause" was not in the verb list, so seven said "I paused the trailer". The source-verb pattern needed the verb next to the noun, so "the trailer cuts" failed and "the trailer keeps letting the camera drift sideways" sailed through. Two previews described sound, which the desk cannot hear at all
  • The prompt was teaching it. Rule 5b bans describing footage and the SOURCES block hands the writer a link labelled "Trailer", while rule 5d tells it to name the source a reader could go and look at. The link now arrives with the sentence saying which of those two things it is for. Same lesson as "the fact sheet" in 0.1.47
  • Two false positives found by running the widened rule over the whole corpus before trusting it: "that absence of footage is its own kind of statement" is the honest sentence, and "I want to see a stretch of footage where the ship clips past debris" is a wish about video that does not exist yet. Both now exempt
  • Spacetime Chronicles is not out on 5 November 2026. It is a 2027 Steam release with publisher Beverlor, console ports still in negotiation, and the developer told us so the morning after we emailed him
  • Orbit Switch is not German-only. The piece read a German screenshot as a game caught mid-localisation and built a section on it. It is fully in English, interface and content
  • oorio Mines answered the two questions its preview left open (pinch-zoom and panning on the dense late boards; the seven planets add mechanics rather than skins) and told us the rank list in the screenshots is not the whole progression. Fantasy Online 2 launched on Android on 29 July 2026 and remains paid Early Access on Steam with shared progress
  • gw-huntimages -id now refuses to hunt behind a published preview. A hunt deletes the files it did not keep this time, and the preview row holds its own copy of those paths, so the trap was a documented line that still caught somebody: seven gallery images on two live pages. gw-abpreviews -refresh-images does both halves
  • Images found on a developer's reply were counted but not named in the hunt note, so a hunt that used them reported a smaller total than it found

[0.1.51] - 2026-09-12

Added
  • The whole notify backlog went out: 136 studios, one every 20 seconds, zero failures and zero bounces. Every recipient had contacted us first (83 by email, 51 through the form, none scouted), so these were replies rather than cold outreach. Six developers had written back within the hour
  • gw-huntimages -id <uuid> -add-link <url> adds a link to an existing queue item and re-hunts. Every note we send asks the studio for a trailer or fresh screenshots and there was nowhere for the answer to go: links were only ever written when the item was created. The kind is guessed from the host
  • Nonograms.games is previewed, which is the flow working end to end: no usable art, a note saying so and asking for footage, a reply with a link to their own gameplay video within the hour, one frame of it as the hero, published
Fixed
  • 24 previews told the reader to wishlist a game the same piece had just said was out now. Rule 7 offers both sign-offs and nothing checked the writer picked the one matching the article. It is a small wrongness in the most visible place on the page, the last line. Now a lint rule, and the 24 are corrected by swapping four words rather than rewriting 24 good articles
  • Three things have to agree before that rule fires, and each was earned by a false positive in the corpus: an out-signal, a platform or shop in the same sentence, and nothing nearby saying the live thing is a demo, a playtest, a Kickstarter or the store page. Without the second, Meltdown Crew's "whoever is out on the floor" (a worker in a reactor) counted as a release
  • A studio we asked for artwork was locked out of ever hearing their preview was live. The notify record tracked whether we had written, not what we had said, so the "we need images" note marked them done. It now tracks the kind, and an unblocked game is owed the other note. Nonograms.games hit this within an hour of the rule existing

[0.1.50] - 2026-09-12

Added
  • gw-devnotes works through the backlog of studios owed a note: -list to see who, -dry to read exactly what would go out, -limit N to send that many, -delay to pace them. Every send is recorded on the queue row, so a second run never writes to the same studio twice
  • just replies shows what developers have written back, what has been sent to a previews byline, and who is still owed a note. It is the first thing a session should run now that we email people: somebody who sent the artwork we asked for is waiting on a person, and the ticker does not read email
  • gw-abpreviews -reject <preview id> takes a preview off the site, for duplicates and mistakes
Fixed
  • Work Rush was published twice. The developer emailed us and filled in the submission form on the same day, and the title dedupe compares titles exactly, so "Work Rush" and "WorkRush" became two queue items, two previews and two bylines for one Google Play id. work-rush stays, with the browser-playable link the other one lacked; workrush is rejected and its queue item dismissed. It is the only such pair in the corpus
  • Two hands-on claims and one disclosure the linter could not see. Work Rush's preview said "I loaded the browser preview", Stickman Skirmish's said "I clicked through it", and neither verb was on the hands-on list, which is built from words like played and booted. Loading a browser build is playing it. WorkRush's told readers "the developer has specifically asked for an honest read", which rule 5 has banned from the start while receivedRe only ever caught "we were sent". Both are rules now, both sentence-aware, and all three pieces are rewritten
  • The linter did not recognise the phrase its own prompt recommends. Rule 5d tells the writer to name the source a reader can check and offers "the Steam listing"; the looking-and-reading exemption only matched "listing page", so "I opened the Steam listing and read it twice" scored as playing the game. listing, blurb, description and read are anchors now
  • gw-relint skips rejected previews. One that is off the site reporting as failing forever is how a report becomes something you scroll past
Changed
  • The note wording moved from the composer into services/dev_notes.go, so the admin button and gw-devnotes send the same words. Two copies of a promise we make to developers is one copy too many
Notes
  • "The pitch" is still not a tell, and the contact rule had to be narrowed to keep it. An early cut flagged three previews for "buried in the pitch" and "mentioned in the pitch", which mean how a studio sells its game in public. Same lesson as 0.1.47

[0.1.49] - 2026-09-12

Added
  • Developers hear back now. A studio that sent us a game found out it had been previewed by accident or not at all, and a studio we could not preview heard nothing at all, which is backwards: they are the only people who can fix the thing that blocked it. The Games queue marks who is owed a note and opens a pre-filled composer for it, in two flavours. "Your preview is live", with the link, an offer to fold in a trailer or new screenshots, and a plain statement that a review is written by a person and carries no date and no promise. And "we need artwork", which says what we tried, why it failed, and exactly what to send
  • POST /admin/games/:id/notify sends and records in one call, so a studio can never be emailed without the record or recorded without the email. Where the send works and the recording does not, it says so rather than reporting a failure, because a reported failure gets sent again. game_queue.dev_notified_at, _kind, _by and _mail_id are the record; the message is a mail_log row like any other
  • Mail addressed to one of the desk's writers now says so. Nine of the addresses on this domain are bylines on published previews, and none of those accounts can sign in, so a developer replying to Wren Ashby is writing to somebody who cannot answer. Postmark's catch-all already delivered it; nothing said what the address was. Inbound mail now carries the name, the inbox shows it, and the alert email says "a previews byline: answer as the desk". preview_personas.go stays the single source of truth, so a hire is labelled the moment its entry exists
Notes
  • Notes to studios go out from [email protected] and never from a writer's own address, signed off with the writer's name in the body. Replies then land in one inbox any staffer can answer from, instead of arriving addressed to a person somebody has to impersonate
  • The templates carry two lines that should survive any edit: a preview is written from public material and nobody here has played the game, and a review is a separate thing with no promised date. The reviewers are volunteers paid in credits and the desk cannot commit their time

[0.1.48] - 2026-09-12

Added
  • The AI-tell sweep is a lint rule now, not a script somebody remembers to run. scripts/gw-aiisms.py knew about sixty patterns and preview_lint.go knew about fourteen phrases, so a draft could pass every rule the writer is held to and still publish as "a vibrant, meticulously crafted hidden gem". That is how 54 pieces needed rewriting by hand in 0.1.47. The sweep's patterns are now lint rules the writer retries against and -publish-clean refuses to publish past, in four groups: stock AI vocabulary, essay scaffolding, player-facing filler and stacked hedging, plus review-blurb cliches judged a sentence at a time. Prompt rules 2a, 2b and 2c name them, because a rule the writer cannot see is a rule it fails blind
  • The image hunt can tell a logo from a screenshot. An image more than 30% fully transparent is cut-out art: a wordmark, an app badge, a character sprite with its background removed. It stays in the gallery and can never be the hero unless it is the only thing the hunt found. A game running on a screen has no transparent pixels, so the gap is wide rather than fine. Measured over all 1478 stored images: 23 clear the threshold and all 23 are logos, badges, empty card frames, a Discord invite and a PEGI shield. Four of them had won a hero slot, one of those a studio's logo sitting on top of somebody else's game
  • gw-abpreviews -refresh-images <preview id> re-hunts artwork and writes the result back onto the preview, hero included. A hunt deletes the files it did not keep this time, so gw-huntimages behind a live preview leaves the page pointing at images that are gone; this is the path that does both halves
  • gw-abpreviews -set-hero <preview id> -image N promotes a gallery image to the hero, and -image 0 lists them. The hunt ranks by size and shape, which is right often enough to run unattended and wrong in the two cases a person spots instantly: a game whose screenshots are all portrait, where the one landscape picture is marketing, and a piece written about one particular screen
Fixed
  • gw-aiisms.py was blind to every pattern containing an apostrophe. It stripped tags but never decoded entities, and stored body_html comes back from the sanitiser with apostrophes as &#39;, so "here's the thing", "it's worth noting", "if you're a fan of" and "in today's" could not match. The corpus sweep had been reporting those as zero. Decoding first turned up one real tell in a published preview, Arcane Heroes 2, which the Go linter found the moment the same rules ran through it. The corpus is 9 of 142 documents, up from a reported 8, and the extra one is now fixed
  • Trainimals and oorio Mines were about to publish with a logo as the hero: both are mobile games whose screenshots are all portrait, and the hero sort rewards landscape and wide, which is exactly the shape of a wordmark
Changed
  • Eight previews published, taking the desk to 135: Sort The Capybaras, Refund Me TD, Trainimals, Guardian Cats, a wonderful cat life, oorio Mines, MONOWAVE and Meltdown Crew. Every hero was looked at; two were swapped. Meltdown Crew was rewritten to fit the word count and Arcane Heroes 2 to drop the essay scaffolding, and all 135 previews now pass the stricter linter
Changed
  • The prose em dashes are out of the news back catalogue. 22 of them across 5 published articles, rewritten by hand as full stops, commas, colons or parentheses. The 29 that remain are label dashes in feature and glossary lists on sibling-project announcements ("Live Thumbnails - See your Resolume clips", "Palmistry - the lines, mounts, and shapes of the palm"), which is press-release formatting on copy we did not write, and the two number ranges in the SSHore walkthrough. The split is the point: counting dashes cannot separate a list label from a proper noun before an aside, so somebody had to read all 51
Fixed
  • scripts/gw-aiisms.py was counting number ranges as em-dash tells. The dash pattern added in 0.1.47 matched 02-04 and 12-13 in the SSHore piece, so the news dash count was reported as 53 when the real figure is 51. A dash is only excluded when there is a digit on both sides, which keeps genuine prose like "Windows 11 - a garnet-dark utility" and "cut it - 14 pieces of art remain". Two passes at the pattern leaked: the first flagged unspaced ranges only, the second let 12 - 13 through because the trailing \s* backtracked to empty and the lookahead landed on the space rather than the digit. gw-aiisms.py --selftest now pins all of it. No deploy: the scanner is an offline script and nothing on the site changed

[0.1.47] - 2026-09-09

Fixed
  • The desk was calling the source material "the fact sheet" in front of readers. The phrase reached 51 of the 127 published previews, 78 times. It is an internal name for the block of notes the writer is handed, and a reader has no idea what one is or where to find it. Hard rule 5d now bans naming any of our machinery in a piece and tells the writer to name the source a reader could actually look at instead: "the Steam listing", "the developer's own description". All 54 affected previews have been rewritten
  • The prompt was teaching the phrase it banned. Adding rule 5d was not enough: the first two regenerations under the new rule produced "the fact sheet" anyway, one of them after three attempts against the linter, because the prompt itself said the words six times. The block is now headed SOURCES and the surrounding prose names the real source, so there is nothing left to echo. The preview that had just failed three attempts passed on the first one afterwards
  • The "not just X, it is Y" construction is now caught in every form. forbiddenPhrases had a single entry for "it's not just", which missed "is not just", "are not just" and "more than just"; five previews and one news article were using those. The news piece was edited by hand
  • scripts/gw-aiisms.py had no pattern for em and en dashes, the most-cited tell in the voice guide, so the news back catalogue scanned clean while carrying 44 of them. Added. The previews were already clean, held there by lint rule 1
Notes
  • "The pitch" is deliberately not banned. It appears in 51 previews and every one of them means how the studio is selling the game, which is ordinary English, not our jargon. Same for "realm" (a field in a game's own stat block), "leverage" (the noun), "love letter to" (quoting a developer) and "the store page says" (naming a real source, which is the behaviour the fix is trying to produce)

[0.1.46] - 2026-09-09

Added
  • Untrusted input is now treated as untrusted. Every preview is built from text written outside this building: a pitch emailed to an address anyone can write to, the scanner's summary of it, and a store description on a page the developer controls. All of it goes to models that take instructions, and nothing was reading it for anything shaped like one. Untrusted blocks in the fact sheet, and the whole inbound item in the scanner prompt, are now fenced in explicit markers, with a hard rule in both prompts saying that what is inside is information about a game and never a job. Fence() strips the markers out of the text first, so a pitch cannot close the quote early and carry on as if it were the prompt
  • A heuristic scan of the pitch, summary, studio and store text for chat control tokens, forged role labels, "ignore all previous instructions", bidi overrides, runs of zero-width characters, text impersonating our own fact sheet, and sentences that dictate what the piece should say. It flags and never rewrites: a pitch is a person's own words about their own game, and deleting sentences out of it to satisfy a regex is worse than telling a human it looks odd. Flags land in the draft's lint report, which the publish gate already refuses to skip and the admin edit modal already lists
  • An output check for a preview that mentions the writing machinery ("system prompt", "as an AI model", "I was instructed to"). A piece reporting on the machinery instead of the game is the far end of a successful injection
Fixed
  • Steam descriptions bypassed the scrubber entirely. BuildPublicFacts sent StripHTML(steamAbout, 2500) straight to the writer, which made 2500 characters of developer-controlled copy the widest and least filtered channel we had. It now gets the same ScrubPitch as a pitch

[0.1.45] - 2026-09-09

Added
  • The linter now catches a preview describing motion. v0.1.44 gave the writer the screenshots and told it stills are all it gets, but nothing checked whether it listened. Three sentence-aware patterns do now: rewinding, watching a trailer, and describing how footage is cut all fail, as does a moment measured in seconds or frames when the sentence has a camera anywhere near it. A 60 second aim trainer is a fact about the game, so the count on its own is left alone
  • The honest sentence still passes. "There is no trailer to rewind here" was already being written by four previews, and a sentence saying the footage does not exist is exempt from every one of the three checks
  • Hard rule 5b in the writer prompt names the banned phrases outright, the way rule 5a already did for hands-on claims. The stance paragraph explained why the desk cannot describe motion; 5b lists what that rules out
  • gw-abpreviews now labels its per-preview output attempts-to-date and cost-to-date. Both columns are accumulated on every rewrite, so the old labels read as though a first-try draft on a much-regenerated row had argued with the linter six times. Per-run spend lives in usage_history, one row per call
Fixed
  • Fifteen published previews rewritten, all with the screenshots attached and keeping their bylines and their art. Twelve were describing footage nobody at the desk has seen. Goo Deer had gone out at 762 words with a lint report recording only that the model had returned broken JSON. FLIP was missing its own sign-off from the body, and Cartoon Crime had a paragraph opening "One aside:". Every one of the 127 published previews now lints clean, which has never been true before
  • Time spent on a trailer no longer counts as an honest aside. The time-spent check used to accept "I spent ten minutes on the trailer" because trailers were on the list of things you can look at without playing. They were on that list from when the prompt pretended the desk watched them, and it does not

[0.1.44] - 2026-09-08

Fixed
  • The previews desk was told it had watched footage nobody ever showed it. The writer prompt opens "everything you know comes from the trailer, the screenshots and the store page", and requires a personal aside about watching. The fact sheet it actually receives is text only: BuildPublicFacts never passed a single image. With a rich store page the model had enough real material to stay honest; with a thin one it invented the footage. A first draft for Chef Fiasco, whose whole public record is one trailer and a blocked Kickstarter page, described a brawler with windup, recovery and an implied combo system, including "a moment I keep rewinding... three frames, maybe four". The game is a catch-the-falling-ingredients arcade game and the writer had seen nothing at all
  • The writer is now sent the artwork the image hunt kept, up to six stills, as image blocks alongside the fact sheet, and the stance was rewritten to match what it can genuinely see: stills, never motion, never a specific moment, never a frame count. It is also told that how many pictures we managed to fetch is our problem and not a fact about the game
  • game_queue.summary was never on the fact sheet either. The scanner's summary of a pitch, and the only description a hand-added game has, went to the writer's eyes zero times. It is now included, scrubbed the same way the pitch is. Both bugs hit the same draft: the model was asked to write 400 words about a game it had been told nothing about beyond a title, three genre tags and the word NES
Added
  • Chef Fiasco, an NES homebrew game found on r/homebrew, is the first game in the queue that nobody sent us

[0.1.43] - 2026-09-08

Added
  • Games can be added to the queue by hand. Until now a game could only reach the queue two ways: the scanner finding a pitch in inbound mail, or a developer using the submission form. Anything spotted in the wild - a Reddit thread, a forum post, a dev's own site - had no door in, because game_queue.source was constrained to mail and submission. There is now a third source, scout, a POST /admin/games endpoint behind games.manage, and an "Add a find" form in the Games tab. Source is set server-side, so a hand-entered game can never be passed off as a pitch the studio sent us
  • Scouted items are labelled as such everywhere the queue shows where an item came from, and their detail view says "Added by hand from public pages" in place of the scanner's confidence score, which means nothing for a game a person typed in

[0.1.42] - 2026-09-08

Added
  • Three new writers on the Previews desk, taking it from six to nine. Marisol Vega covers multiplayer, PvP and co-op; Yara Haddad covers browser and web-native games, quizzes and incremental; Hana Mori covers puzzle, logic and memory. Each has a face, a bio, a beat and a voice block of their own, and the hires were chosen from what the queue actually holds: 11 items tagged multiplayer, 40 browser or web-native, and 22 puzzles, the largest single genre. VR was deliberately not hired for, because exactly one item in the queue has ever mentioned it
  • Thirteen previews published: FLIP, MerjUp, Lying Platforms, Escape: Immersion, Age of Failon, Gravity Reversed, Tactful Tactics, Rice Purity Test, Crooky The Game, Little Bettas, Be A Chameleon, SectMind and Moleculox. That is 126 published previews, with Hana, Marisol and Yara taking their first four bylines between them
  • gw-abpreviews -reassign -status draft -moved-only, because hiring a writer re-scores every game and a bare -reassign wanted to rewrite all 126 rows for $3.15, moving bylines under published work. Scoped, the same hire cost 3 rewrites and $0.07
Fixed
  • Three games had been sitting in the queue unpreviewable for weeks for want of art, and none of them actually lacked any. FLIP pitched a Google Drive press kit that is not an image, and has a live Steam page: 11 screenshots once the app id was set. Lying Platforms pitched a bare Drive .exe and has an itch.io page: 4. MerjUp's own site 403s every request from this box, and its Play listing is public: 10
  • The image hunter will harvest a press article's sidebar if you let it. Adding an EDM Sauce write-up to LIFE LIKE A DJ returned Grand Theft Auto VI key art and a deadmau5 EP banner as its two best "screenshots", both the right size and both past every store-furniture check, because the padded-card test needs a pale border and these were dark. Reverted, and written down in docs/features/previews.md: coverage is a public link, never an image source
  • Tactful Tactics was tagged tactical RPG alone, dropping the 1-6 player online co-op the pitch leads with
Notes
  • LIFE LIKE A DJ is still the one game in the queue with no preview. It renders to canvas, ships no stills, and its own pages carry no images. No images means no preview

[0.1.41] - 2026-09-06

Added
  • Twelve previews published: Iron Dice, Space Academy Idle, PAPERJAM: Endless Office Run, High Hand Poker, Mythchiefs: Prologue, CaseZero, NexusRPG, LIFELINE, KICKSLAUGHT, Solo Chudding, KRUSHPIT and Anagram Search. That is 113 published previews
  • A news post rounding up the twelve, which also says plainly why three games in the queue have nothing written about them and what a developer can do about it
Fixed
  • A published news article 404'd on its own page while appearing in the feed. content_json is nullable and has no default, and the model scanned it into a json.RawMessage, which cannot take a NULL: database/sql has no conversion case for a named []byte holding one, so the scan failed and the repository reported the error as "not found". The list and latest queries do not select the column, so the article looked perfectly healthy everywhere except the one page that mattered. Nullable jsonb columns now use models.JSONDoc, which scans NULL to an empty document, writes an empty document back as NULL, and marshals either as null
  • The same trap was waiting on three more columns. reviews.ai_sentiment_data is NULL until the sentiment pass runs, so the first review we publish would have broken its own page in the gap between publishing and being scored, and reviews.content_json and previews.lint_report had it too. All four are converted
  • nullableJSON in the preview repository is gone, because the type now does that on the way out as well as the way in

[0.1.40] - 2026-09-03

Fixed
  • Admin → Previews crashed on the Published filter. A clean lint report is stored with problems: null (a nil Go slice marshals to null), and the row component read its length without checking. Every one of the 101 published previews carried that shape, so the whole list fell over into the error boundary. The client now treats a missing list as empty, and the linter returns an empty slice instead of nil. Existing rows keep their null, which the client now tolerates

[0.1.39] - 2026-09-03

Added
  • We are hiring humans to review games. /join is a public page that says what the job is, what it pays and how to apply. It leads with the honest part, that there is no salary, because burying that under the word opportunity wastes everybody's afternoon
  • Gaming World credits. A reviewer earns credits for published work (100 a review, plus bonuses for covering a game first, being picked for the front page, and keeping a pace) and spends them on things nobody can buy with money here: claiming a game out of the queue, making us chase a studio for a key, a week on the homepage, an author page. Ranks run on lifetime credits rather than the balance, so spending never costs standing. The numbers live in components/join/creditsData.ts, which the page renders, so the economy has one home
  • Applications land twice. POST /applications stores the row and mails the staff inbox, so an application can be answered from the mail client without opening the admin panel. The applicant gets a note back saying a person reads these and it takes days rather than minutes. Mail is best effort in a goroutine: a Postmark outage must not fail somebody's submit
  • Admin → Applications, gated on new applications.view and applications.manage permissions, with a five-step workflow (new, reading, trial review, on the team, declined) and notes per applicant
  • /join in the sitemap, and Write for Us in the main navigation
Notes
  • The credit ledger itself is not built. Until docs/plans/10-reviewer-credits.md ships, credits are tracked and honoured by hand, which is fine at nought writers and not at ten

[0.1.38] - 2026-09-03

Fixed
  • Preview galleries showed the same screenshot more than once. The image hunt deduplicated on the bytes it downloaded, so one shot arriving as Steam's PNG and the studio's JPEG, or at two resolutions, was kept as two pictures: Wish Impossible had three such pairs and Fish Them All! carried its own hero again further down the gallery. A download is now compared three ways: the bytes fetched, the bytes stored after webp conversion (two different downloads can encode to an identical file, which is how a hero was repeated), and an average hash of the picture itself, which survives re-encoding and rescaling. The two byte keys are namespaced, because a source that is already webp passes through unconverted and would otherwise be read as a copy of itself
  • A 260x95 site logo was the hero image of a preview. The size floor only checked width, so a wordmark cleared it. Nothing under 150px tall is game art. LIFE LIKE A DJ, whose own site and press kit both 404 and whose only usable image was a publisher's logo, is now correctly held back as having no artwork instead of being published behind it
  • App icons padded onto white were beating real screenshots to the hero slot. A store's social card is 1200x630 of flat field around an icon, so it wins on width and then renders as a logo floating in a white box. An image whose whole border ring is one pale colour and whose content covers less than 60% of the frame is now rejected. The check is deliberately narrow: judging on content area alone flagged one image in ten, because a dark screenshot has dark corners, and it would have thrown away terminal roguelikes and horror scenes to tidy up a hero
  • Rating badges, app icons and advert banners were reaching galleries. A PEGI 3 badge and an app icon sat in Zomdie's, because the Play collector skips squares by the dimensions Google declares in page markup, and asking for =w1920 returns a small source at its native size; square images from Play are now judged on the pixels actually received. Wide short strips go too: a Steam wordmark, a publisher sale advert, a Discord invite. Wide alone was not safe, since King Me's own hero art is 2000x500, so the rule needs both, the adverts all being under 220px tall and the artwork over 500
Changed
  • Repaired the back catalogue. Padded icon cards removed from 2048-trader, chaos-core, deadly-prompt, nitelore, parcel-game and snakebit-exe, three of which had one as their hero, and advert banners from feed-it and bitiverse. Every one of the 1141 pictures the previews point at now decodes, exists on disk and passes the new checks
Added
  • Four previews published: Arcane Heroes 2: Divided Fate, Wish Impossible, Fish Them All! and Zomdie

[0.1.37] - 2026-09-01

Changed
  • HandheldsPage.tsx was 1553 lines against a 200-line house rule. It is now a 189-line page over 18 modules in components/handhelds/: the data types, the sheet constants, the grade and tier maths, the formatting helpers, and one file each for the card, the detail modal, its specification tables, its pros and cons, its shop and review links, the filter bar, the badges, the device images and the spec rows. Behaviour is unchanged; the split is mechanical, and the brand list is the one thing that moved rather than being copied
  • The handhelds page reads its brand storefronts from GET /handhelds/oems instead of a 34-entry map hardcoded in the page, so the list has one home. handheld_brands.go seeds it, and the sync writes it
Fixed
  • An unchanged sheet no longer preserves an empty table. The sync skipped all its work on a hash match, which meant the OEM table, empty since the day it was created, could never be filled by code added after the sheet last changed. The skip now also requires the tables the sheet feeds to actually have rows

[0.1.36] - 2026-09-01

Fixed
  • A pitch that arrived just after the hour waited the best part of an hour for a preview that was already due. The scan that queues a game ran on a 15-minute ticker while the image hunt and the auto-draft batch ran on their own hourly one, so on 31 August the hourly pass reported 0/0 drafted five seconds before the scan queued Dominion Tap, and nothing looked at it again until the next hour. The three stages feed each other in one direction, so they now run in order on a single 15-minute ticker: scan, then hunt, then draft. An idle queue costs nothing per pass, since the hunt revisits an item at most weekly and the batch only drafts items that have artwork and no preview yet
  • Three corrupt screenshots and nine blank white banners were live in published preview galleries. The image hunt validated a download with image.DecodeConfig, which reads the header and stops. Mancalero's gallery held three 1920x1080 PNGs whose second half was garbage: the header parsed, the bytes were stored, cwebp then failed on the corrupt tail and the fallback kept the unreadable .png. Separately, a 744x171 image of pure white had been hunted into nine different galleries. Hunted images are now decoded all the way to pixels, and one that decodes to a single flat colour is rejected as blank
  • The games queue applies the same check: a header can be right and the pixels still unusable, so the scanner decodes a download in full before keeping it
  • All 481 handheld pictures were on a 30-day fuse. The handheld sync read its name-to-image map from /tmp/handheld_name_to_image.json, which systemd clears at 30 days, and the sync replaces every row wholesale. The day that file aged out, every handheld image would have disappeared from the site behind a clean, successful sync. The map is tracked repo data now, and a handheld with no entry keeps the picture it already had
  • OpenXBOX's logo in the emulator grid was a 256x256 square of pure black. The emulator sync now ignores feed art that is missing from disk or decodes to one flat colour, so the card falls back to its generated placeholder
Added
  • /handhelds/oems served an empty list from the day the table was created, because nothing ever wrote to it. The sync fills it from the manufacturer named on every sheet row: 121 makers, each with its device count and, for the 34 we know, its own storefront
Changed
  • Repaired the affected galleries: the three unreadable PNGs and nine blank banners were removed from mancalero, 1-min-aim-neo-seoul, arcade-architect, deep-blue-sushi, feed-it, hotel-paraiso, lawless-streets, terra-7, the-world-s-shortest-horror-game and weather-the-swarm, and the files deleted

[0.1.35] - 2026-09-01

Fixed
  • Three games in the queue showed a broken image and no console error. Apple's App Store serves the og:image for a freshly submitted listing as HTTP 200 with Content-Type: image/jpeg and an empty body. downloadGameImage trusted that header and skipped the byte sniff, io.Copy treated an empty source as a successful write, and cwebp then failed on the empty file so the .jpg path was the one stored. The result was a 0-byte file that nginx serves as a 200, which is why nothing 404'd. The download now rejects a non-2xx status, a body under 512 bytes, and anything that sniffs as text however the header describes it
  • saveToDisk refuses to store a 0-byte file at all, so no upload path can leave one behind: the scanner, the preview image hunt, generated art and manual uploads all go through it
  • Repaired the three affected queue items, Nitelore, Snakebit.exe and Chaos Core, by re-fetching each og:image (Apple has since milled all three) and deleting the empty files

[0.1.34] - 2026-09-01

Fixed
  • Steam prices came back in euros, in continental notation. store.steampowered.com/api/appdetails geolocates a request with no country code, and the box is in Germany, so every price we pulled off Steam was EUR formatted the German way. For a round amount Steam writes the cents as a dash pair, which is why the games queue showed Slaymeji at 2,--€. The call now pins cc=gb&l=en, so prices arrive as GBP in the form a UK reader expects (£1.76). Both other callers of FetchSteamAppDetails, the preview fact sheet and the image hunt, get the same treatment
  • Prices lifted out of a pitch email by the scanner go through NormalisePriceInfo, which rewrites continental euro amounts into €19.99 form and expands the ,-- round-amount idiom. Anything it does not recognise is left alone, so $9.99, Free and US$19.99, 15% launch discount pass through untouched
  • Backfilled the eight queue items and eight previews that had stored a euro price, re-fetching each from Steam in GBP

[0.1.33] - 2026-08-31

Fixed
  • Portrait hero art was shown as a band from the middle of the image. The preview hero rendered every image in a hard aspect-[16/9] with object-cover, which is right for the 88 previews whose art is landscape and wrong for the seven whose art is phone screenshots: a 1440x2560 Android capture was cropped to a strip somewhere around its centre, usually missing both the title screen at the top and the UI at the bottom. Nothing stores image dimensions, so the hero now measures the image on load and, when it is taller than it is wide, gives it a taller box, contains the whole image inside it and fills the space either side with a blurred copy of the same file. Landscape art takes exactly the path it took before. Affected today: dominion-tap, snappals, bounce-trix, color-collapse, protocol-73-the-zenith, sprin-world, orbit-switch
Changed
  • The hero moved out of PreviewPage.tsx into components/previews/PreviewHero.tsx, which is where the orientation state belongs and keeps the page inside the file-length convention
Added
  • Component tests for the hero, standing in for the naturalWidth/naturalHeight that jsdom never reports: landscape keeps 16/9 and object-cover with a single img on the page, portrait switches to the taller box and object-contain and mounts an aria-hidden blurred fill, square counts as landscape

[0.1.32] - 2026-08-31

Fixed
  • Publishing from the command line never reached the search engines. gw-abpreviews -publish-clean goes through the same SetStatus as the admin panel, so it queued an IndexNow ping for every URL it published — and then the process exited, killing the goroutine before it had sent anything. Publishing is deliberately fire-and-forget so a search engine having a bad afternoon cannot fail a publish, which is free for an API that outlives its own goroutines and silently wrong for a CLI that does not. IndexNowService now tracks in-flight submissions and exposes Wait(); the CLI wires the service in, flushes before it exits, and reports what it sent instead of printing a reminder to go and run gw-seosubmit by hand. -dry disables submission outright. Every batch published since the CLI existed had to be pushed manually, and any batch where that was forgotten was invisible to Bing, Yandex, Seznam, Naver and Yep until the next backfill
Added
  • just install-clis builds and installs the five gw-* Go tools into /usr/local/bin. deploy.sh never touched them, so a fix in a package the CLIs share with the API shipped to the API on the next deploy and to the tools not at all — gw-abpreviews on the box was three days older than the code that had supposedly fixed it. Run it after any change under internal/ that a CLI path reads
  • A test for the delivery guarantee itself: five SubmitAsync calls against an httptest server, then Wait, then assert all five landed. indexNowEndpoint became a var so it can be pointed somewhere harmless. A ping that is queued and then dropped on exit is indistinguishable from a delivered one at the call site, which is why the bug survived
Changed
  • The sudo -u www-data env $(grep ... | xargs) invocation documented at the top of abpreviews and huntimages has been broken since a value with a space entered api.env — it fails with env: 'World': No such file or directory. Both now document sourcing the file as root and passing --preserve-env for the three vars the tools actually need, which is also the only form that works given the file is 0600 root:root

[0.1.31] - 2026-08-31

Fixed
  • Accented letters in a title collapsed into hyphens in its slug. generateSlug lowercased and then replaced every run of non-[a-z0-9] with a hyphen, so "Hotel Paraíso" became hotel-para-so — a permanent, unreadable URL, and one that no longer contains the word anyone would search for. Titles are now folded to ASCII first ("í" to "i", "ß" to "ss", "æ" to "ae"), and apostrophes are dropped rather than hyphenated, so "Shoot 'em up" stays shoot-em-up and "don't" no longer becomes don-t. The helper moved out of award_template_service.go into its own slug.go with tests, since previews, news, reviews and awards all slug through it. Stored slugs are untouched; this only affects titles slugged from here on

[0.1.30] - 2026-08-30

Fixed
  • Eleven published preview headlines were Title Case where the house style is sentence case, which read like a press release next to the other seventy-nine. Normalised in place: capital on the first word and proper nouns only. The writer prompt gains a hard rule (9) and the linter gains a check, so the next draft that drifts is caught before an editor has to notice it. The check exempts words from the game title, because studios do name things "FEED IT" and "The Legend Of Joke", and the short function words Title Case leaves lowercase anyway; four or more capitalised ordinary words left over is the giveaway. Across all ninety previews it separates the two styles with nothing in between
  • Two published previews carried lint flags for problems they did not have. "That's the part I want to get my hands on" was read as a hands-on claim, when it says the opposite; the future-tense idiom is now blanked before the hands-on patterns see it, and "got my hands on" still fails. "The pitch for Desktop Pet: Idle Cats is almost insultingly simple:" was read as list-style label padding, when the colon belongs to the game's name; a title containing a colon is now removed before the label check runs
Added
  • gw-relint re-runs the linter over previews as they are stored and reports where the stored lint_report no longer matches the content. Reports are written once, at generation time, so editing a published preview leaves its report describing text that is no longer there, and the next publish is blocked for a problem the piece does not have. It lints only what the row stores: the hook, the SEO fields and the Steam blurb are not persisted, so checks that read them are dropped rather than guessed at

[0.1.29] - 2026-08-28

Fixed
  • The artwork behind every published preview existed on one disk. previews and generated-images were held out of the uploads mirror (localOnlyDirs) because nginx read the bucket before the disk, and a stale object could shadow a live local file. That read path was removed in 0.1.26, so all the exclusion still bought was 1,189 files and 150MB with no offsite copy — including the art on all 87 previews that were published and submitted to the search engines the same day. localOnlyDirs is gone: ManagedUploadDirs() is now every directory in allowedDirs, and MANAGED_ROOTS in the mirror script matches. One-time cost to seed, about 1,189 objects.insert calls, is under a penny; storage adds roughly $0.004/month
  • Comments across storage.go, the mirror script, gcssync, infra/nginx/gw and docs/gcs-uploads.md still explained themselves in terms of nginx checking the bucket first, which stopped being true in 0.1.26. Delete reconciliation in particular is not about shadowing a deleted image any more; it is about the backup not becoming a paid archive of everything the site has ever deleted
Added
  • The mirror takes a non-blocking flock for its apply phase, so a run with hundreds of files still to upload is skipped by the next five-minute tick rather than doubled by it. -n does not take the lock
  • infra/cron/gw-uploads-mirror is tracked and installed by just uploads-mirror-install, which previously installed the script and left the cron entry to be written by hand

[0.1.28] - 2026-08-28

Added
  • The site tells the search engines when something goes up, instead of waiting to be found. services/indexnow.go implements IndexNow, which Bing, Yandex, Seznam, Naver and Yep all accept: publishing a preview, a news article or a review submits its URL immediately, in the background, and a failure there can never fail the publish. The key is public by design and served from apps/web/public/<key>.txt, so every build ships it. Google is deliberately not wired up — it dropped sitemap ping in 2023 and only accepts Search Console, which needs a human to verify the property once
  • gw-seosubmit (cmd/seosubmit) submits the whole live sitemap in one go: the backfill for everything published before IndexNow existed, and the thing to run after a bulk rewrite. -dry prints the list, -only previews narrows it
  • Published previews now carry an <image:image> entry in the sitemap with the hero and the game's name. The artwork is half of what a preview is, and Google Images is a real way into a page like that
Fixed
  • Every SPA route was telling crawlers it was a duplicate of the homepage. index.html ships a hardcoded <link rel="canonical"> pointing at the apex, and nothing moved it on navigation, so any crawler that renders JS rather than hitting the bot prerender saw 80-odd previews all claiming to be the front page. usePageMeta now owns the canonical, sets og:site_name alongside it, and restores the default on unmount
  • robots.txt no longer invites crawlers into /admin
  • Publishing a preview never worked. PreviewRepository.SetStatus used $2 twice — assigned to a varchar column and compared against a bare literal — so Postgres deduced two different types for one parameter and refused the statement outright. Every publish, unpublish and reject has failed since plan 09 phase C; the previews that were live had been published some other way. Both uses are cast explicitly now
  • Filtering previews by status never worked either. PreviewRepository.List built its count query from the same where string as the list query, so a status filter sent WHERE p.status = $3 with one argument. The two queries number their own placeholders now
  • gw-abpreviews -publish-clean publishes every draft the lint has no problem with and leaves the rest for a human, which is the point of the gate. -regen <preview id> re-runs the writer for one preview that came out badly

[0.1.27] - 2026-08-28

Added
  • The Previews desk is six writers instead of one. Wren Ashby wrote all 82 previews, which is why the back catalogue read like one long shrug. services/preview_personas.go is the registry: Priya Raman (strategy, sims, anything with a spreadsheet under the art), Kai Okonkwo (action, platformers, animation), Nell Brannigan (horror, atmosphere, art direction), Rhodri Vaughan (retro, pixel art, arcade) and Sunny Patel (cosy, mobile, twenty minutes on a sofa) join Wren on the desk. Each has a user row (migration 029, staff role, no passphrase so none can sign in), a generated portrait and a voice block that replaces the generic one in the writer prompt. The stance and every hard rule are shared, so nobody claims to have played anything
  • Assignment is by beat, with a stable per-game hash breaking ties and spreading the games nobody has a claim on. Deterministic on the queue item id, so the byline never shuffles under a published piece
  • The whole back catalogue was rewritten and re-bylined (gw-abpreviews -reassign [-with-images]), keeping each preview's slug, links and status
  • An "About the writer" card under each preview, and author.bio on the public preview DTO
  • scripts/gcs-bucket-access.sh shows or strips public IAM on a bucket, using the existing backup-agent key
Fixed
  • Five games that could not be previewed now can. The image hunt was throwing away artwork: presskit links were skipped entirely (a press kit is the one page a studio builds specifically to hand out screenshots), and only og:image was read, so any site that ships its screenshots as plain <img> tags read as "no images". The hunt now reads inline images, srcset and CSS backgrounds, follows Google Drive file and folder links through the thumbnail endpoint, and accepts base64 images inlined into the page. Dice After Dark went from nothing to seven images with a 3840px hero
  • Small artwork is used rather than discarded when it is all there is. huntMinWidth (600) is now the preferred size, not the floor: images down to 256px wide are kept, but only surface when a game has fewer than three full-size images, so no well-supplied game gets a small hero. Three games were blocked on this alone
  • The scanner's own saved image is now a last-resort candidate on every hunt instead of only when nothing else was collected
  • @tailwindcss/typography was never installed, so the prose* classes in the preview, review, news and editor views styled nothing. Installed and bound to the theme variables in index.css, so articles pick up real typography in every theme
  • gamingworld-uploads is no longer world-readable. It was made public when nginx proxied /uploads/ to it; nothing has served from the bucket since 0.1.26, so allUsers was exposure with no upside. This also closes the submissions/ listability noted on 2026-08-25. The mirror and backup jobs authenticate as the service account and are unaffected
Changed
  • Previews may no longer open by announcing that nobody has played the game. It is honest and it was in one draft in three, which across a whole desk is a tic rather than a disclosure; preview_lint.go flags it in the first paragraph only, so saying it later in passing is still fine

[0.1.26] - 2026-08-28

Fixed
  • gamingworld.uk was down for thirteen hours on 2026-08-28. certbot.timer stopped nginx at 06:35; the restart ran nginx -t, which had to resolve storage.googleapis.com because /uploads/ was a proxy_pass to the bucket; the lookup failed for a moment ([emerg] host not found in upstream); and nginx.service has no Restart=, so the failed start was final and nothing retried. /uploads/ is now plain root /srv;. Bucket serving was never worth a hard startup dependency: the bytes still transited this box, so it added a paid GCS egress leg in front of a local file, and Cloudflare fronts the site either way. The five-minute mirror is unchanged; the bucket copy is now purely an offsite backup
  • Previews no longer claim to have played the game. The voice guide asked for a personal aside and offered "I lost twenty minutes to the fishing minigame" as the model, so drafts drifted into hands-on language for games nobody here has touched. The guide now states the stance up front (everything comes from the trailer, the screenshots and the store page) and the aside must be about watching or reading. preview_lint.go enforces it, so the writer's retry loop corrects it rather than trusting the prompt. Two published previews (Copero, Bird Tone) that had slipped were corrected in place
Added
  • Health watchdog (scripts/gw-watchdog.sh, root cron every two minutes, just watchdog-*): checks nginx, gw-api, the site over TLS on the loopback and from the public name, the API health endpoint, disk usage and certificate expiry. Restarts what is down and reports it; the one exception is nginx with a config that does not parse, where it mails the nginx -t error rather than thrashing. Alerts [email protected] (override with ALERT_EMAIL) through Postmark directly, never through the API, because "the API is down" is one of the things it must be able to report. Alerts on the second consecutive failure so a deploy never mails, reminds every six hours, and sends one recovery mail only if it actually alerted
  • Restart=on-failure drop-in for nginx (infra/systemd/nginx-restart.conf), five attempts over five minutes
  • docs/monitoring.md
Changed
  • Previews carry twice as many pictures. huntMaxGallery 8 to 16 (45 of the 90 queued games were pinned at exactly 8, so the cap was discarding art Steam had already provided) and huntMaxCands 40 to 60
  • Screenshots are interleaved through the prose rather than stacked in a grid below it (utils/interleaveFigures.ts): a preview is a "does this look interesting" page, so making the reader finish the text before seeing the game was backwards. The hook and the sign-off keep their breathing room, and whatever does not fit still fills the grid at the bottom
  • Wren Ashby's bio no longer claims to play every demo (migration 028)
  • preview_lint.go distinguishes watching from playing rather than banning both. The first draft of the rule flagged 15 of the 81 published previews, of which 13 were the correct voice ("I spent ten minutes on the store page watching the clip loop"), so the time-spent rule is now sentence-aware and "a hands-on version of the sport" is allowed as a description of a game

[0.1.25] - 2026-08-25

Added
  • Uploads mirror (scripts/gw-uploads-mirror.sh, root cron every five minutes): mirrors the managed upload directories into gamingworld-uploads using the existing root-only /var/keys/gcs-key, the same shape as gw-backup.sh and backup-keys.sh. /var/lib/gw-uploads-mirror/state (md5 size mtime object) is the index instead of the bucket, so an idle run makes zero GCS calls and never lists. Uploads stream with curl -T and are verified against the returned ETag; deletes are reconciled, because nginx checks the bucket first and a leftover object would keep serving an image the site had deleted. Guards: a missing managed directory suspends delete reconciliation entirely, and more than 50 deletions in one run refuses without FORCE=1
  • infra/logrotate/gw-logs rotates all three gw cron logs, none of which rotated before; just uploads-mirror-check dry-runs the mirror, just uploads-mirror-install installs it
  • storage_mirror_test.go fails if MANAGED_ROOTS in the script drifts from services.ManagedUploadDirs(), including if a local-only tree ever appears in it
Removed
  • The in-process GCS storage backend (services/storage_gcs.go, STORAGE_BACKEND, GCS_BUCKET, GCS_CREDENTIALS_FILE) and the one-implementation backend interface behind it. The API runs as www-data and the only GCS key on this box also reaches the DB backups and the secrets vault, so the backend was unreachable without minting a second service account for the web process to hold. The five-minute mirror gets the same bucket contents with no credential in reach of the API. Serving is unchanged: nginx still goes bucket first, disk second
  • /health no longer reports a storage field, and deploy.sh no longer carries the GCS credential pre-flight
Changed
  • ConvertLocalFileToWebP and DeleteLocalUpload are gone, folded back into ConvertToWebP and DeleteFile now that there is only one place a file can live. The split existed only to stop bucket-backed code from looking in the wrong place
  • docs/gcs-uploads.md, docs/plans/README.md and the nginx /uploads/ comments describe the mirror rather than a cutover that is not going to happen

[0.1.24] - 2026-08-25

Added
  • Google Cloud Storage backend for uploads (plan 04): services.SaveFile / ConvertToWebP / DeleteFile / OpenUpload now dispatch to a storage backend chosen by STORAGE_BACKEND (disk default, or gcs). Objects are named exactly like the public path minus /uploads/, stored with their content type and a 30-day immutable Cache-Control, so nothing downstream (DB rows, TipTap JSON, the frontend) changes
  • nginx serves /uploads/ from the bucket with a disk fallback: managed media is proxied to storage.googleapis.com, anything the bucket answers 403/404 for falls back to /srv/uploads, and the trees that bypass the storage backend (mail attachments, emoji assets, preview hunter art, generated images, the offline emulator/handheld art) are served from disk directly. Rollback is STORAGE_BACKEND=disk with no nginx change
  • gcssync operator CLI (apps/api/cmd/gcssync, just gcs-check / gcs-sync / gcs-verify): credential round-trip probe, public-read IAM grant, idempotent upload of local files, and a per-directory local-vs-bucket verify. There is no gsutil on the box, so this replaces the gsutil iam ch / rsync steps the plan assumed
  • internal/gcs: a ~350-line GCS JSON API client (signed-JWT auth, multipart upload, download, delete, list, IAM) instead of cloud.google.com/go/storage, which would have pulled gRPC and ~40 modules into a ten-dependency go.mod
  • /health reports the live storage backend; deploy.sh refuses to deploy with STORAGE_BACKEND=gcs if www-data cannot read the credentials file
  • docs/gcs-uploads.md covers the request path, the local-only directories, the rollout and the rollback
Fixed
  • Deleting an image under the GCS backend also removes any leftover local copy. Without that, nginx's disk fallback would keep serving a file the user had just deleted
  • Code that writes straight to /srv/uploads (the preview image hunter) now uses the explicitly-local ConvertLocalFileToWebP / DeleteLocalUpload helpers instead of the dispatching ones, which would have gone looking in the bucket for a file it had just written to disk

[0.1.23] - 2026-08-25

Added
  • Staff review tooling (plan 03): draft preview (/admin/reviews/:id/preview, renders the public layout with a draft banner), video in reviews (POST /admin/uploads/review-video, mp4/webm up to 100MB, TipTap video block + YouTube embeds via youtube-nocookie; the public sanitizer now keeps <video> and YouTube-only iframes), submission to review conversion (POST /admin/submissions/:id/create-review seeds a draft with title, links, synopsis and the submitter's media copied into review storage; review_submissions.review_id links them), reply-by-email from the Submissions tab with Accepted / Declined / Need more info templates, all five submission statuses in the UI, mail_log.sent_by records which staffer sent an admin reply
  • Email-sourced submissions: mail to [email protected] is filed as a review_submission (source=email, attachments as media) by the Postmark inbound webhook
  • Image hunter: Google Play listings now yield full-size screenshots (page cap raised to 3MB, the og:image tag sat just past the old 1MB cap), and trailer links contribute YouTube maxresdefault / Vimeo 1920x1080 posters. 5 of the 10 art-less queue items got artwork on the first run
Fixed
  • Review genre cap (3) was enforced in the service but the handler discarded the error; a 4-genre save now returns 400. Genres can also be cleared to none
  • API body limit and nginx client_max_body_size raised to 100MB for video uploads

[0.1.22] - 2026-08-25

Fixed
  • SEO prerender crawl loop: the bot prerender for news, previews and the fallback page carried a <meta http-equiv="refresh"> back to the same URL. Googlebot treats that as a redirect, so the Rich Results test looped six times and reported "crawl failed". The refresh is gone (bots already get a plain link to the page)
  • Prerender body excerpt no longer double-escapes entities (&amp;#39;), keeps a space between paragraphs, and truncates on rune boundaries

[0.1.21] - 2026-08-25

Added
  • Previews (/previews, /previews/:slug): promotional mini-previews of indie games from the pitch queue, written by staff persona Wren Ashby with AI assistance (Claude Sonnet 5 via OpenRouter, ~$0.02 per piece, cost-logged to AI Usage). Each preview has a hero image, screenshot gallery, trailer embed, store links and a "Pitch your game" CTA. Previews lead the homepage showcase and sit in the top-level nav
  • Image hunter: Steam storefront screenshots/movie thumbs, itch.io art, og/twitter images, submission media and mail attachments are collected per queue item (min 600px, webp, gallery cap 8), with an hourly re-hunt ticker and a gw-huntimages CLI
  • Content lint + scrubber: generated copy is checked for em dashes, AI-tell phrases, review keys, emails, phones and private links before it can be published; public API only ever exposes a PublicPreview DTO
  • Admin Previews tab (/admin?tab=previews): draft batches with a cost estimate and model picker, edit modal with lint panel and hero/gallery picker, rewrite, publish/unpublish/reject/pin/delete, hourly auto-draft toggle. Games tab gained "Make preview", a Previewed filter and a "needs artwork" badge
  • SEO: bot prerender for previews with the site's first JSON-LD (Article + VideoGame), sitemap now lists previews and every published review

[0.1.20] - 2026-08-09

Added
  • Games queue display modes (/admin?tab=games): a Comfy / Compact / List switcher next to the Alerts button, remembered per browser. Compact shows small artwork cards in up to 6 columns (12–18 per laptop screen); List shows dense single-line rows with inline Claim/Release/Done/Dismiss actions (24+ per screen) — ideal for demoing the whole inventory
  • Games queue pages now load 24 at a time (was 12) so the dense views fill the screen on first fetch

[0.1.16] - 2026-08-04

Added
  • "Games" admin area (/admin?tab=games): a games-to-review queue between Analytics and Mail for staff/moderator/admin roles. Card grid with artwork, genres/platforms, AI summary, store/trailer/press-kit links, one-click copy for included review keys, and a detail view with screenshots, the original pitch, and a deep link back to the source email/submission
  • AI inbox scanner: every 15 minutes (plus a "Scan now" button) an LLM (DeepSeek v4 Flash via OpenRouter, ~$0.0002/item, cost-logged to AI Usage) classifies unprocessed inbound mail + review submissions as game pitch vs. noise, extracts title/studio/links/keys/release/price, and enriches with artwork from the Steam storefront API or the site's og:image — DMARC/Postmark noise is filtered for free before the LLM ever runs
  • Claim workflow with live presence: staff claim a game to put their avatar + hold on it; claims, releases, and status changes (new → in-review → reviewed/dismissed) broadcast over WebSocket to every staff viewer in real time
  • Email alerts: staff-tier users can self-subscribe (or be added by managers) to a branded digest email whenever the scanner queues new games, deep-linking each game
  • New games.view / games.manage permissions granted to staff, moderator, admin, super_admin
Fixed
  • WebSocket connections now carry the user's real roles (previously hardcoded to user, so role-targeted messages reached nobody)

[0.1.15] - 2026-07-22

Added
  • Branded maintenance page: nginx now serves a styled auto-refreshing "back in a moment" page on 502/503/504 (both apex and API vhosts) instead of the stock white nginx error — visible only during deploys/restarts
  • Route-level error boundary + chunk-load retry: a failed page render no longer takes down the whole app shell; dynamic chunks that 404 after a deploy retry once, then auto-reload the tab to pick up fresh hashes (loop-guarded)
  • Shared ErrorState + skeleton loaders: public pages now show an honest "couldn't load, retry" block when the API is unreachable instead of fake empty states, and skeleton placeholders instead of bare spinners
  • nginx configs are now tracked in the repo under infra/nginx/
Changed
  • Deploys are atomic-ish: deploy.sh builds frontend + API and runs migrations before anything goes live; the API binary is staged and swapped with automatic rollback to the previous binary if the health check fails; the frontend swap keeps the 2 newest generations of hashed chunks so already-open tabs survive a deploy (npm run build no longer touches production at all)
  • Delivery is much lighter: gzip enabled for JS/CSS/JSON/SVG (assets were shipping uncompressed), HTTP/2 on all vhosts, build precompresses .gz served via gzip_static, react/react-dom/react-router split into a stable long-cached vendor chunk, admin panel tabs are per-tab lazy chunks
  • Caching is correct: hashed /assets/ get Cache-Control: immutable (1y), index.html is no-store — the stale-tab-after-deploy class of bugs is closed
  • Tulpa and Revamp card splashes converted from 2.9MB/2.3MB PNGs to sized WebP (274KB/84KB, ~4.8MB saved on first homepage load); card images lazy-load with explicit dimensions; homepage LCP splash is preloaded with fetchpriority=high
  • https://gamingworld.uk/health now proxies to the API health endpoint (previously 404)
Security
  • nginx outer-belt rate limit on the API vhost (20r/s per IP, burst 40 → 429), in front of the app's per-endpoint limiters
  • Content-Security-Policy (report-only) on the apex to iterate toward enforcement; uploads now serve non-media types with Content-Disposition: attachment
  • systemd sandbox tightened: PrivateDevices, ProtectKernel*, RestrictNamespaces, RestrictAddressFamilies, empty capability bounding set, and more
  • Frontend dependency audit: 17 vulnerabilities fixed (incl. react-router XSS advisory → 7.18.1); WebSocket proxying moved to a dedicated location with sane timeouts (30s API / 1h WS)
UI
  • Fixed Tailwind v4 opacity utilities sitewide (modal backdrops rendered solid black, nav blur was opaque)
  • Removed the universal button-lift hover and global 0.3s transition on every element; theme transition stays on body only
  • Semantic --success/--warning/--danger tokens replace scattered status hexes; dark: classes now follow the site's [data-theme] switcher
  • Loading and error states polished across Home, Games, News, Reviews, Emulators, Handhelds

[0.1.14] - 2026-07-22

Added
  • systemd service (gw-api.service): the API is managed by systemd — auto-restart on crash (3s), starts on boot, secrets in a root-only /etc/gamingworld/api.env (generated from .env.production by deploy.sh), runs as www-data instead of root, with ProtectSystem/NoNewPrivileges hardening. Replaces the nohup process that nothing restarted and a stale, broken unit that pointed at a September binary with a plaintext DB password
  • Nightly database backups (scripts/gw-backup.sh, cron 03:30): pg_dump -Fc with pg_restore --list verification, 14-day local retention in /srv/backups/pg/, off-box copy to gs://gamingworld-uploads/backups/, plus a weekly tar of /srv/uploads on Sundays
  • Tracked migrations (scripts/migrate.sh): schema_migrations table, files applied exactly once in sorted order — no more "already exists" spam from re-running every file; 015_sync_log.sql renamed to 015b_sync_log.sql to remove ordering ambiguity
  • Daily background purge of page-analytics data older than 90 days (previously only ran when an admin opened the dashboard)
Security
  • Inbound email attachments are now content-sniffed against an allowlist (images, pdf, zip, gz, txt) with a 15MB cap and the extension forced from the sniffed type — previously attacker-controlled bytes/filenames were written verbatim into the publicly-served /uploads/mail/ (stored-XSS vector). Blocked attachments show a "blocked" note in the mail UI
  • Config fails closed: the API refuses to start when JWT_SECRET or DATABASE_URL is missing (previously fell back to a hardcoded public JWT secret)
  • Rotated the DB password leaked by the old world-readable systemd unit; .env.production and the generated env file are now mode 600
  • Full-text search endpoints (emulators, handhelds, game cards, reviews) sanitize tsquery input — & | ! ( ) : characters no longer cause 500s; ILIKE filters escape %/_ wildcards
  • Mail handlers log detailed errors server-side and return generic messages to clients; /track uses the proxy-resolved client IP instead of trusting spoofable headers
Fixed
  • Background sync goroutines (Postmark, models, handhelds, emulators, analytics) recover from panics instead of killing the API
  • All outbound HTTP calls carry timeouts (Postmark 15s; image downloads 30s via the SSRF-filtered client); Fiber got read/write/idle timeouts
  • All list endpoints clamp limit (max 200) and negative offset — ?limit=99999999 no longer works as a free DoS

[0.1.13] - 2026-07-22

Added
  • Two-pane mail client: the admin Mail tab is now a proper mail client — message list on the left, reading pane on the right, each scrolling independently; collapses to list→detail navigation on mobile
  • Read/unread tracking (migration 021): unread inbound mail shows bold with a dot, opening a message marks it read (DB-backed), and the Inbox toggle shows a live unread count badge
  • Server-side mail search (q=) across sender, recipient, subject, and body, plus an All/Inbound/Outbound direction filter and an unread-only toggle in the UI
  • Archive: archive/restore messages (archived_at); the inbox excludes archived mail by default and an Archived filter shows it
  • Real pagination: the inbox API returns total and the UI has a Load More button with "x of total" (previously hardcoded to the latest 50)
  • Compose-new mail (previously reply-only) with a From dropdown of known @gamingworld.uk addresses (+ custom), and replies quote the original message behind a "Show quoted text" toggle
  • Replied indicator: sending with reply_to_id stamps the original inbound message's metadata, showing a ↩ marker in the list
  • New endpoints (all mail.view): PUT /admin/mail/inbox/:id/read, PUT /admin/mail/inbox/:id/archive, GET /admin/mail/unread-count
Changed
  • MailTab split from one 491-line file into focused components under components/admin/mail/ (all < 200 LOC)
  • Relative timestamps in the message list (full date on hover), sender avatars with deterministic colors, skeleton loading states, and proper empty states
  • Subscribers panel: filter inputs are debounced (no more refetch per keystroke), result count shown, sticky table header, striped rows

[0.1.12] - 2026-07-22

Added
  • Staff role: new staff system role (migration 020) with exactly the permissions needed to work reviews, submissions, and mail — no role/user management, settings, news admin, or API key access
  • Granular mail permissions: mail.view / mail.send replace the borrowed admin.analytics.view / admin.settings.edit gates on /admin/mail/*; granted to staff, admin, and super_admin
  • Working user management: GET /admin/users (search, role/status filters, pagination) and PUT /admin/users/:id/status implemented for real (previously mock/disabled); Users tab in the admin panel now shows the live user table with activate/deactivate and a Manage Roles modal
  • Permission-based admin access: the admin panel gate and sidebar now check permissions instead of role names — staff (or any custom role) see exactly the tabs their permissions unlock; landing on a forbidden tab redirects to the first permitted one
Fixed
  • Self-deactivation is refused (an admin can no longer lock themselves out from the UI)
  • Backfilled missing gamecards.* permissions on the admin role

[0.1.11] - 2026-07-22

Security
  • HIGH: Fix JWT expiry env vars being silently ignored — JWT_ACCESS_EXPIRY="15m" / JWT_REFRESH_EXPIRY="7d" failed integer parsing and fell back to 8-hour access / 90-day refresh defaults. Config now accepts both bare integers and duration strings (15m, 8h, 7d), defaults tightened to 15m/7d, and the resolved expiries are logged at startup
  • MEDIUM: Enforce is_active on every authenticated request and on token refresh — deactivating a user now locks them out immediately instead of at token expiry
  • MEDIUM: WebSocket auth switched from ?token=<JWT> (leaked into nginx access logs) to a single-use 30-second ticket from POST /api/v1/ws/ticket; raw JWTs in the query string are no longer accepted. /ws and the ticket endpoint are rate-limited (10 req/min/IP)
  • LOW: Rate-limit /auth/refresh (5 req/min/IP, same as login)
  • LOW: Bind API to 127.0.0.1:8080 so nginx is the only ingress (blocks direct-connect X-Real-IP spoofing that bypassed per-IP rate limits)
  • LOW: Submission uploads now require the sniffed content type to match the file extension's category (image/video/pdf); application/octet-stream is no longer accepted
  • LOW: 5xx responses return a generic message instead of raw internal error strings; /health no longer echoes DB error details (both are logged server-side)
  • LOW: Sanitize changelog HTML with DOMPurify for consistency with all other rich-text sinks

[0.1.10] - 2026-05-20

Security
  • CRITICAL: Fix unauthenticated WebAuthn account takeover — /auth/webauthn/register/begin now refuses any handle or email already in the database, forcing existing users through the authenticated /webauthn/add/* flow to attach new passkeys
  • HIGH: Synchronize in-memory WebAuthn ceremony sessions with a mutex and add 5-minute TTL + background sweeper (concurrent map writes were crashing the API; unbounded growth could exhaust memory)
  • MEDIUM: Rate-limit /auth/login/passphrase, /auth/webauthn/login/begin, and /auth/webauthn/register/begin (5 req/min/IP) to deter brute force and enumeration
  • LOW: Equalize bcrypt timing for unknown handles in passphrase login (handle-enumeration oracle)
  • LOW: SSRF defense in /news/external splash-image fetch — refuse to dial loopback / link-local / RFC1918 / cloud-metadata addresses and require http(s) scheme
  • LOW: Fix refresh-token rotation — switch DB-stored hash from bcrypt to HMAC-SHA256(jwt_secret, token) so GetSessionByRefreshToken actually finds rows. Existing sessions are invalidated; users re-login once
  • LOW: Fail closed when POSTMARK_WEBHOOK_SECRET / mail / news-external API keys are empty (previous subtle.ConstantTimeCompare("","") would have returned 1)
Added
  • Admin reply composer: One-click "Reply" button on any inbound email at /admin?tab=mail, sends via authenticated /admin/mail/send endpoint
  • Inbound mail notifications: Branded Gaming World email alert fires to ALERT_EMAIL (default [email protected]) whenever an inbound message arrives, with who/what/where/when + admin-panel deep link

[0.1.9] - 2026-04-02

Added
  • External News API: POST /api/v1/news/external endpoint for external projects to publish news articles via API key auth (X-API-Key header)
  • SEO prerender: Server-side rendered meta tags (Open Graph, Twitter Card) for news articles, served to bots via nginx user-agent detection
  • Dynamic sitemap.xml: Auto-generated from published news articles with 15-minute cache
  • Client-side SEO: document.title and OG meta tag updates in NewsArticlePage for in-app navigation
  • source_project tracking: News articles record which external project posted them
Infrastructure
  • NEWS_API_KEY environment variable for external news API authentication
  • Migration 019: source_project column on news_articles table
  • Nginx map directive for bot detection with prerender proxy routing

[0.1.8] - 2026-03-21

Added
  • Emulator logo sync: 465 emulators now show real logos synced from disk into the database
  • AI fallback images: Category-specific AI-generated fallback images (via FLUX) for 420 emulators with no logo
  • Polymorf game card: New project card for the AI-powered dual-camera photo booth activation
Improved
  • Ghost watermarks on all emulator cards including fallback images
  • Emulator detail modals show fallback images instead of emoji placeholders
  • New justfile recipes: gen-fallback-images and sync-emulator-images
Fixed
  • CORS blocking all requests via www: Added nginx redirect from www.gamingworld.uk to apex gamingworld.uk — previously both were served without redirect, causing CORS origin mismatch with the API

[0.1.7] - 2026-03-21

Added
  • Emulator system images: Real hardware photos for 40+ gaming systems across category, landing, and system browse cards
  • Process monitor: Admin analytics showing top 20 processes by memory usage
  • Image preview on submit: Thumbnail previews for uploaded images in the submission form
Fixed
  • HTML entities in submissions tab rendering as literal &middot; text
  • SNES system image mismatch between API and frontend map
Improved
  • Category browse cards show real hardware images instead of emoji placeholders
  • Landing page browse cards use category/system images with ghost watermarks
  • System image map expanded from 32 to 90+ entries

[0.1.6] - 2026-03-20

Added
  • Emulators 3-tier browsing: Landing page with 3 browse options (By Category, By System, All Emulators)
  • Category browse view: Grid of 8 category cards (Home Console, Handheld, Computer, Arcade, Multi-system, Mobile, Calculator, Other) with AI-generated watermark images and emulator counts
  • System browse view: Grid of 60+ emulated system cards (NES, SNES, N64, PlayStation, etc.) with console images, search filter, and manufacturer-based colors
  • System filter API: New GET /emulators/systems endpoint returning distinct emulated systems with counts; system query param added to GET /emulators for JSONB array filtering
  • Category images: 8 generated images at /uploads/emulators/categories/
  • System images: 28 console/system images at /uploads/emulators/systems/
  • All emulator views use URL search params (?view=category&category=Home+Console) for proper back/forward/bookmark support
  • Breadcrumb navigation across all emulator views
  • Shared emulator constants/types extracted to emulatorConstants.ts
Fixed
  • Analytics page views always 0 (sequence permissions + empty IP inet cast)
  • Dark mode email body unreadable (hardcoded bg-white)
  • just migrate now auto-grants sequence permissions

[0.1.5] - 2026-03-20

Fixed
  • Postmark inbound emails broken: deploy.sh was not passing POSTMARK_TOKEN, POSTMARK_WEBHOOK_SECRET, MAIL_API_KEY, SUBSCRIBE_API_KEY, or ALERT_EMAIL env vars to the API process — webhook auth always failed and sync was silently disabled
  • Added missing Postmark env vars to .env.production and .env.example
  • Analytics page views always 0: page_views INSERT failed silently because www-data user lacked USAGE permission on page_views_id_seq; also fixed empty IP causing ::inet cast error
  • Dark mode email body unreadable: HTML email preview used hardcoded bg-white, now uses theme-aware bg-secondary with proper text color
  • just migrate now auto-grants table and sequence permissions to www-data
Added
  • Inbound email attachments saved to disk: Webhook decodes base64 attachment content and saves to /srv/uploads/mail/{id}/, downloadable via nginx
  • Attachment display in admin mail inbox: Inbound emails with attachments show paperclip icon in list; expanded view has clickable download links
  • Postmark sync fetches attachment metadata: Background sync calls Postmark message details API to capture attachment info for each new inbound message
  • Admin panel URL routing: Active tab is now reflected in the URL as ?tab=mail, ?tab=news, etc. — browser back/forward buttons work correctly, pages are bookmarkable and shareable
Removed
  • Admin mail tab 30-second auto-refresh: Removed wasteful polling interval; manual Refresh button remains

[0.1.4] - 2026-03-18

Added
  • News system: Full CRUD for news articles with TipTap rich text editor, inline image drag-drop with live WebP conversion, splash images, publish/unpublish workflow
  • News banner on homepage: Most recent published news article displayed as a prominent banner at the top of the home page
  • News public pages: Browse all news at /news with search, individual article pages at /news/:slug
  • Review submission funnel: Comprehensive public form at /submit for indie devs to submit games for review — includes multi-file upload (screenshots, trailers, press kits) with 50MB cap, 10 file max
  • Submissions admin inbox: Unified admin tab showing all submissions (form + email) with expandable detail cards, media preview, notes, status filters (pending/reviewing/accepted/declined/completed)
  • GWUK system user: handle=GWUK, [email protected] — system/AI robot user with admin role for automated and editorial news posts
  • News full-text search with weighted tsvector (title A, synopsis B, content C)
  • ReviewEditor component now accepts uploadEndpoint prop for reuse across news and reviews
  • Navigation updated with "News" in Content dropdown and "Submit Game" link
  • Permissions: news.create, news.edit.any, news.delete.any, news.publish, submissions.view, submissions.manage
  • Migration 018: news_articles table, review_submissions table, permissions, GWUK user
  • Upload directories: news/, news/inline/, submissions/
Changed
  • ReviewRequestModal now redirects to /submit page instead of using mailto
  • Storage service extended to support video (mp4, webm) and PDF uploads for submissions

[0.1.3] - 2026-03-16

Added
  • Console system icons displayed throughout handhelds UI — filter pills, grade badges, and performance bar all use official console logos instead of text labels
  • 18 supplemental console hardware photos (Wikimedia Commons CC) stored in /uploads/handhelds/systems/supplemental/ — survive sheet re-syncs
  • Grade badges show console hardware image as subtle background with hover reveal
  • System icons auto-rotated from vertical spreadsheet orientation to horizontal and converted to WebP
  • Colored side tabs on handheld cards showing top emulation systems (blue=A, green=B, yellow=C)
  • 3D corner ribbons for Upcoming (purple), All A (gold), Budget (green), Premium (blue)
  • "Coming Soon" disclaimer in detail modal for upcoming/unreleased devices with expected release date
  • Performance rating parsed into clean badge (e.g. 🔥 4½/5 instead of raw emoji strings)
  • Accent-secondary color spread across hover effects, toggles, input focus, card shadows project-wide
Changed
  • Sync script now auto-rotates extracted system icons 90° and converts to WebP
  • System names only appear in hidden title/alt attributes for accessibility
  • Filter pill selection uses grayscale-to-color + checkmark instead of invert (fixes NES/GBA/Genesis readability)
  • Clear/Clear-all filter buttons are now compact X icons with badge counter
  • Max emulation box uses solid background instead of transparent accent tint

[0.1.2] - 2026-03-15

Fixed
  • Handhelds section showing 0 results: Google Sheets sync was using wrong tab gid (1→0), headers had multi-line names that didn't match column lookups
  • Avatar upload showing broken image: URL was pointing to api.gamingworld.uk but uploads are served from gamingworld.uk
  • Game cards not respecting admin sort order: frontend was grouping all hero/large/normal cards separately, now groups consecutive same-size cards to preserve sort_order
  • Handheld images broken: image URLs were using API domain instead of frontend domain
  • Price overflow causing horizontal scrollbar: prices now show first line only with full details in tooltip
  • Name overflow on long handheld names: dynamic font sizing based on name length
Added
  • 565 retro gaming handhelds syncing from Retro Handhelds community spreadsheet
  • 473 device images and 18 system console icons extracted from XLSX
  • 35 new handheld data columns: CPU/GPU details, screen specs, controls, audio, materials, performance data
  • Performance grades for 18 emulation systems (GB/GBC through PS3) with letter grades (A-F)
  • Performance-tier card borders: gold (S-tier), blue (A-tier), green (B-tier)
  • Ghost image watermark behind card content at 6% opacity
  • Corner ribbons: Upcoming (purple), All A (gold star), Budget ($), Premium (diamond)
  • Form factor emoji badges on each card
  • "Supports" multi-select filter: filter by system emulation capability
  • Enhanced performance heatmap bar with hover tooltips
  • Sync log capture for model and handheld sync services, displayed in admin Models tab
  • Weekly cron job for handheld image extraction from XLSX
  • Migration 015 (sync_log), Migration 016 (handhelds overhaul with 35 new columns)

[0.1.1] - 2026-03-13

Fixed
  • Review editing 404 error: SELECT * on reviews table included search_vector generated column, causing sqlx scan failure
  • TipTap duplicate extension warning: StarterKit v3 includes Link and Underline by default, now disabled in StarterKit config
  • Logo in navigation now links to home page
  • Empty reviews page now shows "Reviews coming soon!" instead of generic message
Added
  • Review unpublish support: admin can unpublish reviews from both editor and admin panel
  • Splash image removal button in review editor
  • Publish/unpublish toggle in admin reviews tab
  • Game cards "Import All" button always visible (not just when empty)
  • Hardcoded cards comparison view on Games page for verifying database card imports
  • Orphaned image cleanup script (scripts/cleanup-orphaned-images.sh)
Removed
  • Test review deleted from database

[0.1.0] - 2026-03-12

Added
  • Full game reviews system with TipTap rich text editor
  • Award templates with CSS-rendered shapes and AI image generation (ImageRouter)
  • AI sentiment analysis via OpenRouter + Steam/Itch.io scraping
  • Encrypted API key management (AES-256-GCM) with admin GUI
  • File upload infrastructure with nginx static serving (/srv/uploads/)
  • Review editor with drag & drop images, genre tags (max 3), rating slider, splash images
  • Public review pages with full-text search, genre filters, 3D card hover effects
  • Homepage "Latest Reviews" carousel
  • Award system with templates, AI-generated images, review attachment
  • Sentiment analysis with circular gauge visualization
  • 4 new admin tabs: API Keys, Award Templates, Awards, Reviews
  • Reviews CRUD, publish, search, genres, sentiment analysis API endpoints
  • Awards & templates CRUD, AI image generation API endpoints
  • Encrypted API key management with test endpoints
  • File upload endpoints with rate limiting
  • 4 database migrations (006-009): api_keys, award_templates, awards, reviews/genres
  • 10 new backend services, 5 handlers, 4 repositories, 4 models
  • Nginx configured to serve uploads with 30-day cache headers

[0.0.2] - 2025-09-23

Changed
  • 86% LOC reduction in main App.tsx through component extraction
  • Complete frontend refactoring with SOLID principles
Added
  • ESLint integration with TypeScript support
  • Glassy UI enhancements for light mode teaser images
  • Professional build pipeline with quality gates
  • Component-based architecture (7 focused components extracted)
  • Reusable UI components (Modal, Navigation, etc.)
  • Automated linting in build process

[0.0.1] - 2025-09-22

Added
  • React + TypeScript + Tailwind CSS SPA
  • WebAuthn/Passkey authentication system with passphrase fallback
  • User account management (add/remove passkeys)
  • Theme system (dark/light mode with customizable accent colors)
  • Settings persistence
  • Home and About pages with content
  • Go API backend with Fiber framework and JWT tokens
  • PostgreSQL database integration
  • Responsive design and mobile navigation
  • Hetzner server deployment with SSL certificates (Let's Encrypt)
  • Nginx reverse proxy and production build process

Roadmap

[0.2.0] - Community & Polish (Target: Q2 2026)
  • Comments system on reviews
  • Image WebP conversion and EXIF stripping
  • Backend HTML sanitization (bluemonday)
  • Review image gallery and auto-save drafts
[1.0.0] - Production Ready (Target: Q2 2026)
  • Comprehensive testing
  • Performance optimization
  • API documentation and stability

Build something like this

Want a tool like this for your shop?

We've shipped this kind of thing before. Twenty-minute intro call, no slides.