Dev/mjolley/v10 - #20
Merged
Merged
Conversation
Phase 0 and Phase 1 of the v2 rebuild. Baseline - pnpm is now the one package manager. package-lock.json removed, packageManager and engines pinned. - netlify.toml moved from public/ to the repo root, where Netlify actually reads it. It was previously copied into dist/ and ignored, so the bbb.dev host rule and the /posts/ and /brain-dump/ catch alls have not been running. The catch alls now live at the end of public/_redirects so the specific rules win. - Netlify adapter and Preact added. The Preact integration is pinned to the 4.x line because 5.x depends on Vite 7 and uses Vite 7 plugin filter syntax, which Astro 5.17 (Vite 6) silently ignores, leaving astro:preact:opts unresolved. - twitch.ts guarded. An empty or errored Twitch response took the whole site build down rather than hiding one panel. Theme engine - scripts/gen-themes.mjs is a real build step wired to prebuild. It reads real VS Code themes through shiki, resolves each to the token contract, runs the contrast guard against all three surfaces and the hue guard that keeps Warning from collapsing into Error, and exits non zero if any theme fails. - It emits three artifacts that agree with each other: themes.css, the picker list, and Expressive Code theme definitions built from the resolved tokens rather than the originals. Without the third, chrome would pass AA while code blocks quietly failed it. - The audit now also covers all six syntax tokens, which the design phase script did not check. All sixteen themes still pass at 4.5:1 or better. - Expressive Code config moved to ec.config.mjs because themeCssSelector is a function and the Code component needs serializable options. Its own syntax contrast pass is disabled so the generator stays the single guard. - Verified in a browser across all sixteen themes: every code block background equals bg-inset exactly, and every rendered syntax color is one of the generated tokens. Design system - app.css ported as one global stylesheet. The single literal color in it, a hardcoded black drop shadow, now derives from the page background so it works under light themes. - Base layout, masthead, footer and wordmark built from the mockups. The theme picker is a Preact island hydrating on idle, reading the generated list so it cannot drift. It hides itself without script rather than showing a dead control. - src/config/site.ts holds topics, reserved slugs, severities and the Start here list, because the content submodule is read only from here. - tests/contrast.test.mjs reparses themes.css with its own WCAG implementation, deliberately not the generator's, so it is a check rather than a restatement. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Content now lives at /[topic]/[slug]/ rather than under /blog/ and /videos/. Where each item lives is decided by scripts/gen-taxonomy.mjs and recorded in src/config/taxonomy.json, which is generated and committed so slugs freeze. Classification runs in three layers, highest wins: a human review flag, an AI review pass held in src/config/taxonomy.overrides.json, then a keyword scorer. The generator refuses to finish if an override matches no content, names a topic that does not exist, or produces a slug that shadows a reserved path. scripts/gen-redirects.mjs turns that map into 148 explicit rules plus two catch alls. Old URLs are mapped to content ids rather than to other URLs, so a redirect can never chain. Measuring production first found two live faults the new map fixes: one alias pointed at a slug that no longer exists and returned 404, and /posts/* landed on an unslashed URL that then redirected again. Two test files guard it. One checks the map, the other checks real dist output, and the second is what caught five future dated posts whose redirects pointed at pages the build never produced. Those now build with a draft flag and noindex so the URL is live the moment the date passes. Expressive Code drops from sixteen themes to one. Sixteen made it inline sixteen hex values onto every syntax span, which took one real article to 926 KB of HTML. The single theme sets every token color to var(--tok-*), so the existing [data-theme] cascade does the switching. The same article is 55 KB, the generated stylesheet halves, and code colors now reference the chrome tokens instead of copying them, so they cannot drift. Two mockup rules did not survive real content. Feed summaries had no clamp and real ones run past 400 characters, and .embed had no iframe rule because the mockup drew a placeholder rather than a player. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Phase 3 of the v2 build. Every page in the mockups that is not a profile now renders from real content, and the v1 site is gone rather than left to rot beside it. Pages: home, topic index with filters, article, video, dev disaster archive and detail, about, submit, conduct, report, search, 404, uses. RSS and the sitemap follow the topic first URLs. Gates, both new and both green: * pnpm a11y runs axe over 15 archetypes at two viewports under three themes, 90 audits in all. It found 137 real violations on the first run. Two are worth naming. The design system shipped a contrast bug, .chip .n set opacity .6 over an already dim token and failed 4.5:1 in all 16 themes, which the theme generator could never catch because opacity is not a token. And the nav landmark added a flex item app.css did not expect, overflowing the masthead at 390px on every page. * pnpm perf runs Lighthouse over 5 archetypes with a hard 30 KB script budget. It scores 98 to 99 with a clean best practices and SEO 100. Getting there took three changes worth knowing about: * The theme picker is no longer a Preact island. It sat in the masthead of every page and cost 19 KB to toggle one attribute. It is now server rendered with about a kilobyte of inline script, so content pages ship zero JavaScript. Preact stays configured for the comment and like islands. * YouTube is behind a click to load facade, which removes 874 KB of third party script from every video page and stops YouTube seeing readers who never press play. * Fonts are self hosted from Fontsource by scripts/gen-fonts.mjs instead of loaded from Google. That deletes the last render blocking request and the last third party connection. Fraunces needs the full axis build because the design drives both SOFT and WONK. Two upstream bugs worked around. Pagefind's filter dropdown puts role=combobox on its trigger and then names it from its own content, which a combobox does not do, so the control has no accessible name at all. We use the filter pane instead. Expressive Code's runtime promotes scrollable code blocks to unnamed regions, so two on a page are two identical landmarks. An EC plugin now bakes the label in at build time. Also fixed a Pagefind indexing bug of our own. Two filters in one data-pagefind-filter attribute do not split, they produce one filter with a garbage value. They are on separate elements now. CI did not exist in this repo. It does now: generated artifacts in sync, tests, build, axe, Lighthouse. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Everything the social layer needs to exist, with nothing yet hydrating on a content page. The interactive parts land in phase five. Schema. Seven migrations, all applied. A baseline that captures the schema that was already in production and had no home in any repo, the v2 tables, row level security on all of them, seed data for reserved handles and badges, badge families and tiers so a shelf can draw an earned III next to a locked IV, and two fixes that came out of actually reading what had been built. Two of those fixes were real bugs rather than tidying. The first: streams watched was counting stream events, so one talkative evening read as two thousand streams instead of one. That number now comes from a function that counts distinct stream days, because PostgREST cannot express a distinct count. The second is worse. comments.author_id cascaded from profiles and comments.parent_id cascaded from comments, so one person deleting their account would hard delete every comment they had written and every reply anybody else had written underneath. A person leaving should not be able to erase a conversation other people are still in. author_id is now nullable and set null, parent_id is restrict, and a departed author leaves a tombstone the thread can still be read around. Auth. GitHub is the only way in. Twitch is a link only identity attached to an account that already exists, which is the narrowest reading that satisfies both decision 4 and decision 13. The link captures the Twitch user id and not just the login, because logins change and the badge backfill would otherwise silently skip anybody who ever renamed. Accounts. /account/ is a plain form that POSTs to itself. No island, no fetch, no optimistic state. Settings is the one place a person needs to be certain the thing they clicked took effect, and a full page POST with a redirect is certain by construction. It also works with JavaScript off. Deleting an account deletes it: stories stay published with the name taken off, comments become tombstones, everything else goes. Profiles. /builders/[handle]/ renders on demand and is noindex, every link somebody adds is nofollowed, and a private profile returns 404 rather than saying it is private, because admitting somebody exists is the thing going private is meant to prevent. Two things the mockups promise that the data cannot keep. The profile draws "1 made into a video" and nothing anywhere links a disaster to a video, so that stat is gone. And the badge shelf copy said the Twitch history goes back to 2019 when the earliest row in streamEvents is June 2025, so the copy no longer names a year. That also caps what the badge backfill can reward, which needs a look before thresholds get fixed. Also here: the publish clock came back. v1 published at 8am Central and the port had quietly replaced that with midnight UTC, so it is restored, moved into src/lib, rewritten to read DST from Intl rather than a hand maintained table, and covered by tests including both changeover days. The two orphaned v1 scripts are gone. Severity class and label lookups were copy pasted into three pages and are now one export, which matters because the class is sev-warn while the id is warning. astro check runs in CI, and Supabase types are generated by a script and committed so no build ever needs a login. Gates: 32 tests, 0 type errors, axe clean across 90 audits, Lighthouse 98 to 99 on all five archetypes. Content pages still ship zero bytes of JavaScript. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Decision 11 wants a like that needs no sign in, deduped by browser token plus hashed address, attributed when somebody happens to be signed in. Reader identity lives in src/lib/reader.ts, on its own, because none of it talks to a database. It mints a browser token, validates a target, and turns an address into an HMAC with a rotatable secret. With no secret set it collapses to a constant rather than hashing with an empty key and pretending the protection is there. src/lib/likes.ts is the queries. The unique key is the pair of token and hashed address, not the address alone, so one person behind a household router cannot silence everybody else behind it. Counts are read once per build and grouped by kind, not once per page. There are over a hundred and fifty pages and a page at a time would be a hundred and fifty round trips. The placeholder counts are gone from the disaster seed, so the wall and a story page can no longer disagree about how many people liked it. The island renders its count on the server, because the count is content, and marks itself unpressable until it wakes up. A button that looks live and does nothing is worse than one that says so. Two things found while wiring this up: The Lighthouse script had one script budget for every page, so an island landing on the homepage would have passed. Listing pages now get a budget small enough that anything hydrating there fails, and detail pages get room for the one island they carry. The accessibility gate was flaky. target-size measures rendered boxes, and the nav links cleared 24px by about half a pixel, so whichever font had loaded when axe ran decided whether the site passed. Fixed at both ends: the gate waits for document.fonts.ready, and the links have a floor. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Post moderated comments per decision 16. Markdown with fenced code blocks, one level of threading, a fifteen minute edit window, soft delete tombstones, a hold on brand new GitHub accounts, and rate limits of ten an hour and thirty a day. Comment bodies are rendered to HTML on write rather than on read, so a thread does not put a syntax highlighter and its cold start in front of every reader. The renderer is an allow list walking marked's token stream, not a sanitiser, and it added no new dependency. Images are dropped, headings become bold text so a comment cannot join the page outline, and links are nofollow ugc noopener. Comment likes needed a schema change. The shared target_kind enum could not grow a comment value without also permitting a comment whose target is a comment, so likes got their own wider like_target type. Retyping the column meant dropping and recreating like_counts, which is why that view appears twice in one migration. Every Report control is a link to /report/ with the target filled in, never a modal. The form now posts to a real endpoint that records the report, and the auto hide trigger that was already in the schema finally has something to fire on. Reports carry a hashed address so one person cannot hide a comment by filing the same report three times. The accessibility gate had a hole worth naming. It audits dist, and the report page is rendered on demand, so the one page people reach on a bad day was the one page nothing checked. It now runs a dev server alongside the static one and audits the on demand pages too, including the prefilled and post submit states. That took the gate from 90 audits to 114. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Decision 25: nobody titles their own disaster. A submission now arrives with a title, a line, a slug and a severity already drafted, so a story lands in Studio readable rather than as an untitled wall of text I have to open to identify. The drafter never throws and never rejects. Somebody sat down and typed out the worst day of their career, so losing it because a model had a bad minute is not an acceptable failure. No key, a timeout, a non JSON answer or a nonsense severity each fall back field by field, and the story is saved either way. The moderation note says which happened. The voice rule is enforced rather than asked for. A model told not to use an em dash will use one eventually, and it will land on a public page under somebody else's story, so the parser strips them. Submitting needs a sign in, which meant /submit/ had to stop being prerendered: a page built once and served from a CDN cannot know who is reading it. It is back in the sitemap by hand, because it is a page people should be able to find and on demand pages are invisible to the sitemap. The report page stays out on purpose, since it is noindex. Submissions are capped at three a day. That is my reading limit rather than the submitter's, and pre moderation means every one of them costs a read. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
… you are The badge definitions have been sitting in the schema since Phase 4 and nothing has ever granted one. This is the part that counts. First, the numbers were wrong. They were written before anybody looked at the data, and the data says the top tier was unreachable. There are 113 recorded streams spanning June 2025 to May 2026, and the most anybody attended is 68, so Front Row IV at a hundred streams was a plaque nobody could ever light up. Rescaled against the real distribution: 5, 15, 30 and 50, which is roughly 125 people, 38, 19 and 6. Talker got the same treatment and became a family of three rather than one flat badge. Cheering was in the history with nothing recognising it, so it has a badge now. Day One stopped being a guess and became the first ninety days of recorded history, derived from the earliest stream rather than hard coded, so it survives an import of older events. Second, the engine. It lives in SQL rather than in a script because the same numbers have to be right in three places at once: a trigger grants on the way in, a nightly job catches anything the trigger missed, and the profile page draws a progress bar. If each did its own counting they would disagree, and the version people would notice is the progress bar that reads 4 of 5 next to a badge they already have. So there is exactly one counter, badge_counts, and everything reads it. Grants are additive and never revoked. A badge that vanishes because somebody deleted a comment would punish them for tidying up, and the whole point of a shelf is that it accumulates. Linking Twitch now grants on the spot rather than overnight. The original plan deferred it on the grounds that scanning twenty four thousand rows is not something to make somebody wait for, which was true until the index went in and it became a few milliseconds. Telling somebody who just linked their account to come back tomorrow and see if it worked is a worse trade. The shelf now shows progress, and only where progress means something: the lowest unearned tier in a family, never on a badge one step away, never on a threshold of one. "0 of 1000" under a badge three steps out is discouragement dressed as information. That shaping logic moved into src/lib/shelf.ts, with no database import, so plain node can test it. Fourteen tests, because that is where the rules live and rules are where the mistakes will be. One accessibility fix fell out of it. A locked plaque was dimmed with opacity .45, which is fine until something inside it has to be read. Opacity is applied after a contrast ratio is computed, so no automated check can see the failure. The dimming is done with real colours now, which looks the same and keeps the number legible. And scripts/twitch-ids.mjs, which is conflict 10 finally answered. streamEvents and streamUsers key on a Twitch login, logins change, and matching a linked account by login silently drops anybody who ever renamed. No error, no log line, nothing to notice: the person just gets an empty shelf and assumes the feature is broken, which for them it is. The script fills stable ids in for the 1,850 logins already in the history. It needs Twitch credentials and explains itself and exits when they are absent, because a half finished id backfill is worse than none. Verified against the live history with a throwaway profile linked to a real chatter: nine badges granted, progress correct on the rest, then removed. All four gates green. astro check 0 errors across 80 files, 105 tests, 152 pages built, axe clean across 126 audits, Lighthouse clean across 6 pages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Data migration is out of scope. Michael loads it himself, so the import script
is gone and what replaces it is a written spec, because nobody can load data
into a shape nobody has described.
docs/backfill.md covers every table that expects backfilled rows: columns with
types and nullability, keys, constraints, one valid example row each, the load
order, and the verification queries. Two hazards are called out at the top
because both produce a load that looks correct and is not.
The first is that filling streamUsers.twitch_user_id disables the login
fallback for those rows. The matcher pairs a profile to history by id, or by
login only when the history row carries no id, which is what stops somebody
taking over an abandoned Twitch name from inheriting the previous owner's
badges. So a half filled load silently empties the shelves of everybody who
linked before ids were captured. Fill both sides or neither.
The second is that the Day One window runs min("streamDate"::date) across the
whole table with no filter. One malformed date anywhere raises for every
profile on the site, not just that row.
The empty shelf is now the state that ships on day one, so it had to look
deliberate. It shows one plaque per badge family rather than seventeen dark
ones, with a line saying what fills them. Screenshotting it in Hot Dog Stand
found a real bug: the progress track was drawn in --line, which in that theme
is the same saturated red as --accent, so an empty bar rendered as a solid
full width one next to the text "0 of 5". A bar that says finished when it
means nothing. The track is now a surface token, which the contrast guard
guarantees can never collide with an accent, and a bar at zero is not drawn at
all because it was decoration.
Chapters are in. Forty two long form videos, twenty carry chapters, nothing
failed. Transcripts still need the YouTube Data API as the channel owner.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
The ruling was a brand new database. Fourteen v2 migrations went into the
project serving baldbeardedbuilder.com and bbb.dev instead. Two of them are not
dormant: a trigger on auth.users that fires on real sign ins, and a cron job
sweeping 24,574 rows nightly.
supabase/reversal/ removes all of it and is committed unrun. Michael owns that
database and decides when it executes. It unschedules the cron job before
dropping the function it calls, and drops the auth.users trigger before its
function, because the reverse order fails and a half applied reversal on
auth.users is a broken sign in on the live site. Measured before writing it:
every v2 table is empty and streamUsers.twitch_user_id is null on all 1,854
rows, so as of now it reverses cleanly rather than losing anything.
It lives outside supabase/migrations on purpose. In there it would be picked up
by db push against the new project and delete everything the other fourteen had
just created.
The baseline is trimmed from 34 KB to 6 KB, down to streamEvents and
streamUsers. An empty table in the new project is worse than no table, and
shorturls is the clearest case: bbb.dev fronts it through an edge function for
1,627 links, so an empty copy means the day anybody repoints that function every
link 404s and nothing raises, because the table exists and simply returns no
rows. A missing table fails loudly.
Two corrections to what was asked for, both from grep rather than reading.
The streams table is not queried by badge_counts or by anything else in v2, so
it is gone too, taking a per row insert trigger that would have fired 24,574
times during a load. And drips is not read from Supabase at all: every
reference in src is getCollection('drips') against the content submodule.
Since the baseline no longer carries the rest of the legacy schema, a migration
still leaning on something that went would only fail during a push against a
fresh project. pnpm check:migrations proves the chain is self contained and runs
in CI.
The tooling was still aimed at the old ref in four places. pnpm types would have
regenerated from it and, once the reversal runs, produced a types file with
every v2 table missing, which reads as somebody having deleted the schema. The
ref now comes from the environment with no default and an explicit refusal of
the old one. The Supabase README said to run migration repair on the baseline,
which for the new project would mark it applied without creating either table
and land every badge migration on top of nothing.
netlify.toml keeps pointing at the old project for bbb.dev, which is correct.
Its header comment claimed the file was never read while it lived in public/.
That was measured wrong: all three rules were live in production. Fixed, because
the next person to read it would prune on that basis.
docs/backfill.md gains the caveat that the badge tiers were fitted to the
measured distribution, so a partial load makes every tier wrong in the one
direction nothing can flag: the badges still grant and the bars still fill, just
against a scale built for a history that is not there.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Screenshotting the undrawn comment states in all three themes turned up a contradiction on the offline path: the rail reads "0 REPLIES" while the body says the replies did not load. The number is the build time snapshot and it is honest on its own, but sat next to a failure message it reads as a live count of nothing, which is the site telling somebody the conversation is empty when what happened is that it could not look. The count stays, because it is real information and it is the only thing left when the API is down. The message now says where it came from. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Both pages state only what the code actually does, and where the code does something less flattering than a policy would like to say, the page says the less flattering thing. The one that matters is video thumbnails. The player is a facade, so no Google script loads and nothing tracks you into a video, but the still itself is served from i.ytimg.com, so a browser on any page with a video on it does make a request to Google. A privacy page claiming no third party contact would have been wrong, and wrong in the direction that reads as a lie when somebody opens the network tab. The like dedupe gets the same treatment. Saying "hashed" and stopping implies the address was never there. It is there, in memory, for the length of one request, the same as it is for every request to every website. What is different is that it is never written down and rotating the key forgets it. That is worth a paragraph rather than a word. Terms follow the two consent checkboxes on the submit page, because those are the actual agreement and this only spells them out. If they ever disagree the checkbox wins, since it is the thing somebody ticked. Both pages carry a comment saying which file to check when a claim stops being true. Neither has been near a solicitor. The terms page says so on the page rather than only in a comment, because a reader who is a lawyer is more useful than one who assumes it was reviewed. The a11y gate earned its place twice. It caught the rail links falling under the 24px target size WCAG 2.2 asks for, which had been true since the rail was built and only surfaced here because terms is the first page with two links in one rail. Fixed in app.css for every rail rather than on this page. And the first draft of the privacy table put a single child into .crow, which is a two column grid, so every card rendered squeezed into the 5.5rem label column, one word per line. Now it uses the column it was given, with a quiet mono eyebrow in it. axe clean across 138 audits and 46 page loads, which is the two new pages in three themes at two widths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
…for years The two redirect tests prove the map is consistent and that its destinations were built. Neither talks to a server, so neither can catch a rule Netlify parses differently from the way this repository reads it, a rule shadowed by an earlier one, or a destination that is itself a redirect. scripts/verify-deploy asks the running site instead. The check that matters is one hop. A chain is invisible to anything that only reads the first response, because /posts/x returning 301 looks right until you follow it and land on another 301. Pointed at production to prove the harness works, it immediately found that /posts/code-of-conduct-and-contributions-in-public-repositories 301s to a blog slug that was renamed and now 404s. That rule has been dead in the hand maintained file for as long as the rename is old. Checked the other twelve legacy rules the same way and they all still resolve, so it is one rotted line rather than a pattern. The generated map already fixes it, by accident of resolving through the content id rather than through the old rule's target, and the build test means it cannot rot again: a destination that stops existing now fails a test instead of a visitor. Said so in the generated header, because the next person comparing the two files will notice the disagreement and needs to know which one is right. On the short links, and this contradicts the plan of record. A branch deploy cannot prove the bbb.dev rule. That rule matches on hostname, bbb.dev resolves to the production deploy, so a branch deploy never receives a request for it and the rule is never exercised. A Host header does not help, because that is exactly what Netlify routes on, so the request lands on production. What can be done is what the script does: verify them against production before the merge and again after, and roll back rather than debug forwards if the second run differs. Baseline captured today, all three resolving to the redirect function. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
…ssing Decision 15's three types: a story published, a story featured, a reply to your comment. Opt out per type, one click unsubscribe. Featuring did not exist. The preferences table has had a story_featured column since the schema landed, and there was nothing anywhere that could set it off. The front page lead was simply the newest published row, so nobody was ever chosen and the email describing that choice could never fire. So this adds disasters.featured_at, points the front page at the most recently featured story with newest as the fallback, and hangs both the email and the Featured badge off the same timestamp. Setting one column in Studio is the whole mechanism, which is the right amount of admin tooling for a version with no admin UI. A queue rather than a direct send. All three events are somebody changing a row or posting a comment, and none of them should fail because a mail provider is having a bad afternoon. The trigger writes a row, the drain empties it, retries with a widening gap and gives up loudly. No email address in the queue. It is read from auth.users at send time, so a row that outlives the account it belongs to cannot be sent. Copying the address into the queue would break decision 17's deletion promise by accident, which is the way that promise is most likely to break. Preferences are checked twice, once at enqueue and once at send, because a queue drained on a timer holds rows across the moment somebody changes their mind and the later answer is the right one. The drain is an endpoint on the site rather than a database function. The reply email has to link to the comment, working that out means resolving a topic first URL, and rebuilding that in SQL would guarantee that an email and a page eventually disagree about where something is. Unsubscribe takes GET from a person and POST from a mail client honouring RFC 8058, and the GET does the work rather than showing a confirm button. That is a real trade with a real downside: a link scanner can turn somebody's email off without them asking. The other way costs more, because somebody who wanted out, clicked, and closed the tab keeps getting email, and that is how a sender ends up marked as spam. Wrongly silent is undoable from the account page in ten seconds. The page says nothing about whether the token was real, or it becomes an oracle for guessing them. It does distinguish a missing token from a supplied one, because otherwise a passer by who lands on the URL is told their email has been switched off, which is a lie and a worrying one. With no API key the queue still fills and the drain logs what it would have sent. The reply that triggers an email is on the same request as the comment that caused it, so a missing key must never turn somebody's comment into a 500. The templates are split into their own module with no database import so they can be tested, which matters more for email than for anything else here: the first time you see the real thing is after it has arrived in somebody's inbox. Eleven tests, including one that an excerpt somebody typed cannot inject markup into the HTML version, and one that the text and HTML versions say the same things. supabase/pending.types.ts is temporary and says so. database.types.ts is generated from a live project and there is no project to generate from yet, so the new shapes live in a file that is obviously a stopgap rather than as a hand edit inside a file that says GENERATED at the top. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
The site ships reading only the content submodule. No transcripts, no authored video fields, no stream history, no badge grants, no comments and no likes, because nobody has arrived yet. So empty is the state built and tested, not an edge case handled afterwards. Counts that were claims rather than facts The disaster seed file carried reply counts. Sixteen rows asserting that conversations had happened, on a site where the comments table is empty. Those are gone and the count now comes from Supabase like every other one, which means it reads zero until somebody writes something. Same treatment for the homepage lead stats, the wall totals and the "Here are N of them" line: a zero renders as absent rather than as a zero. The Drip fine print printed an issue count derived from the submodule, which holds only the recent run. Any number derived from it understates the real one, so there is no number. Video pages are gated on there being something to read A video earns a page when there is a video_pages row for it. There are none, so there are no video detail pages, which is the correct outcome rather than a gap. A page whose entire content is an apology and a link to YouTube is worse than the link on its own. Every video row still goes somewhere real, it just goes to YouTube until Michael writes an intro, and pages appear one at a time as rows land. That left the catalogue with nowhere to be listed, so /videos/ is a finished thing in its own right: long form and shorts, grouped by year, every row linking out. Two redirect tests hold the rule now. Nothing may redirect to a video page, and nothing built may link to a video page that was not built. Empty states drawn rather than left to happen The homepage hero has no lead disaster to bake when the wall is empty, so it draws an invitation instead of inventing one. The wall says nobody has told me theirs yet. The comment rail no longer announces "0 replies" above an invitation to write the first one, and the load failure line no longer defends a count that does not exist. The Start here lead was a void A video has no summary until one is authored, so the widest card on the homepage was a title in the corner of a card sized by its taller neighbour. It now carries the real thumbnail, which is data that exists today, and card summaries clamp at four lines so one long summary cannot set the height of everything beside it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Michael has to create the new Supabase project by hand, and everything that follows from it is a value he has to copy from one dashboard into another. Doing that from a scattered set of notes takes three round trips. docs/new-project.md is the single pass version: what to name it, which region and the one reason it matters, the two OAuth apps, the redirect allowlist, which Netlify deploy contexts each variable is scoped to, and the order to do it in. The parts that are easy to get wrong, written down because they are GitHub OAuth Apps have a single callback URL field, so a new ref means a new app rather than an edit to the existing one. Editing it would repoint the current site's sign in at the new project. Twitch allows several redirect URLs on one application, so that one can be added to. Twitch has to go in at the same time as GitHub even though nothing signs in with it and there is no stream history loaded. Linking is what captures profiles.twitch_user_id, logins change and ids do not, and anybody who links before the capture exists is unmatchable later. It fails as an empty badge shelf, not as an error. The redirect allowlist is the step that gets skipped. The sign in route builds redirectTo from the request origin so it works on any host, which means Supabase has to be told every host that is allowed. A missing entry redirects to the site URL instead of failing, so it reads as "signing in on the branch deploy sends me to production" rather than as configuration. The same list is now in config.toml with both callback paths, including the Twitch one that was missing. And the bbb.dev line in netlify.toml still points at the old project on purpose. shorturls lives there and is not being copied. That line looks like a leftover and repointing it 404s 1,627 short links with nothing raised anywhere. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Four findings from the thread screenshots, all reproduced against the real island with the API intercepted rather than the markup hand built. 1. A removed comment named its author. `reported-enough` kept a linked handle and `RE` initials above "This comment was removed.", so moderation left a permanent public marker saying that specific person had something taken down. The row below it, an account deletion, was already anonymised. That asymmetry was backwards: deleting your own account is a choice you made, having a comment removed is done to you. A row with no body now carries no handle, no initials and a neutral `?` avatar. The timestamp stays so the thread keeps its shape and any reply still sits visibly under something. 2. The rail count was off by one for the author of a held comment. The server counts visible rows and cannot know that one particular reader has something in the queue, so signed in as the held author the rail said 5 above 6 bodies. The rail now counts what is actually on screen for this reader. 3. Both of those come from one predicate now, `hasBody`, and it lives in `src/lib/thread` where a test can reach it rather than in the island. Two matching conditions written out separately is how they drifted in the first place. The island also stops carrying its own copy of `CommentView` and `initials`. 4. Reporting yourself did nothing useful and ended on the conduct page. Report is gone when the comment is yours. Delete is the control you wanted. On the divider tokens. Measured every bordered element inside `.comments` in both themes rather than reading it off a screenshot. There is one token. Every rule between comments is `--line`, in all sixteen themes, and the only coloured borders in a thread are the host avatar and a primary button. What reads as a second rule in Hot Dog Stand is the inset above a nested reply, which is a length difference rather than a colour one, and it is invisible in a calm palette only because the line itself is. Kept, and written down in `app.css` as deliberate along with what it is doing, so nobody straightens it later. Gates: check 0 errors across 95 files, 126 tests, build clean, migration chain self contained across 17 files and 38 objects, axe clean across 150 audits and 50 page loads. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
The file move activated build configuration that has never run. The version on main was three [[redirects]] blocks. The root version also carries [build] and [build.environment], and those four keys cannot be honoured from the publish directory the way redirects can, because the build has finished by the time anything reads dist. So they were inert and are now live, and netlify.toml wins over the Netlify UI for every key it declares. I could not read the UI to say what changes. The Netlify MCP server returns 401 and the CLI session has expired, so that needs somebody signed in. docs/deploy.md carries the four values, what each one will impose, and why a difference matters, in the shape of a table somebody can fill in and paste into the pull request. What I could do is stop the two files drifting apart later, which is the version of this problem that bites in six months rather than next week. Three tests now assert that the Node major in netlify.toml satisfies engines.node, that PNPM_VERSION agrees with the major in packageManager, and that the build command names a script that exists. The bbb.dev risk was defined too broadly. It is two facts and only one is changing. Whether the root netlify.toml is read is new, and has nothing to do with hostnames, so a branch deploy proves it. Whether the host rule is valid and bbb.dev routes here is unchanged, and the same rule text is serving production right now. Proving the first proves the pair. So netlify.toml now carries a permanent probe rule on a path that exists nowhere else. verify-deploy.mjs asks for it on every run, and a 301 means the file was read and its redirect table parsed, which means the bbb.dev rule is loaded. Three tests keep it honest by asserting the path stays in netlify.toml, stays out of _redirects, and never becomes a real page, because a probe that cannot fail is worse than no probe: it would report the short links as fine on a deploy that is ignoring the file entirely. It stays after merge. It costs one line and one request, and it makes the assumption that has already been wrong once answerable forever. Measured while writing this. The probe currently 404s on production, which is correct, production is serving main and has no such rule, and it demonstrates the check can fail. The short link baseline is captured: gh, yt and twitch all 301 to the redirect function. On the question about the landing page: verify-deploy.mjs already asserts 200, at line 140, right after the chain check. A rule that hops cleanly into a 404 fails today. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Michael's ruling: no sender, no address, no email of any kind. That is a clean scope cut but not a clean deletion, because the promise was already written into the product in five places, one of which sits at the moment somebody is deciding whether to hand over the worst thing that ever happened to them at work. Copy first. Submit now says the address is asked for so the story attaches to an account and the badge has somewhere to land, that nothing arrives in your inbox, and that you come back and look. Terms says the same about a story after you send it. Privacy says none rather than hedging: no notification, no digest, no reply alert, and the address is here because GitHub hands one over at sign in. The summary rail gained an "Email sent: None" line next to the analytics one. Then the affordances. The three switches are gone from the settings page, and saveAccount no longer writes notification_prefs at all. That last part is the trap: a form with no switches posts no fields, and a missing checkbox and an unticked one are the same absence in form data, so reading them anyway would have turned every ordinary save into three falses nobody chose. The row keeps its designed defaults instead. The unsubscribe page stops being a route. It moves under src/pages/_unwired/, which Astro excludes from routing, and its two entries come out of the axe list because auditing a 404 passes and proves nothing. A page that says "that is switched off" for a thing that never sends is a control that appears to work. The drain endpoint stays where it is, because nobody can browse into it and be misled, and it refuses everything with no secret set. The queue migration moves to supabase/deferred/, alongside the reversal precedent, so db push cannot reach it. One thing did not go with it. featured_at was the first half of that file, and it is what leadDisaster reads for the front page, so holding it back would have quietly taken the lead story out. It is now its own migration in the chain. docs/notifications.md is the instruction sheet: what is off, why, the five steps to turn it on, and the table of every file whose copy has to change back in the same commit. Each unwired file carries a short header pointing at it. Dead code that looks live is how somebody later concludes a feature is broken rather than absent. tests/no-email.test.mjs stops half of it coming back. Eight assertions, each one half of a pair that has to move together. The copy check is narrow on purpose: a bare word match fails on privacy's own denial, which is the one sentence that most needs to stay. Verified against every line this commit removed. Gates: astro check 0 errors across 94 files, 140 tests, build clean, check:migrations clean across 17 files and 33 objects, axe clean across 138 audits and 46 page loads.
Two things, and the first is a gap in my own split of the notifications migration a commit ago. THE FEATURED BADGE WAS INSIDE THE EMAIL TRIGGER Splitting featured_at back out of the notifications migration saved the column, which is what the front page lead reads. It did not save the badge grant, which sat in the same branch of notify_disaster_change as the enqueue call. Parking the email parked the grant with it, which would have left a seeded badge on the shelf that no code path could ever award. grant_featured_badge and its trigger now live in the featured migration, firing only on the null to not null transition so a Studio edit to some other column on an already featured row grants nothing. Two tests: one asserts the grant is in the applied chain, one asserts the deferred file does not grant it a second time if it ever comes back. The general shape of this is worth naming. A column added to serve one feature, then depended on by two others, still lives in the first feature's migration, and the file name stops describing its contents without anything changing. YOUR OWN STORIES, ON YOUR ACCOUNT PAGE With no email, somebody hands over the worst thing that ever happened to them at work and never learns what became of it. Email was carrying that loop and nothing else was. It closes by letting a person look. /account/ rather than the public profile, because a public page that draws extra rows for one particular reader is the exact bug that had to be fixed twice on this branch. /account/ is signed in only by construction. Three states, and a fourth that only shows up with real data: published with no slug yet refuses to link rather than linking to a 404. TWO DEFECTS FOUND BY DRAWING IT The block is its own component so it could be rendered with fixture data in all sixteen themes, which account.astro cannot be because it needs a session and a database. First, Published and Not running were both outlined and only differed by colour. Measured, accent and sev-warn are deltaE 6.9 apart in bbb-light and 10.0 in vitesse-dark, so in the site's own light theme they were the same marker. Published is now filled rather than outlined, which no palette can collapse. The two that remain outlined never drop below deltaE 35, and contrast.test.mjs now gates both halves of that. Contrast ratio is the wrong instrument here and says so in the test. Three of these pairs score 1.00 and are perfectly fine, because ratio measures luminance and cannot see hue. Second, a linked title and an unlinked title were drawn identically. I had copied the strip the link chrome pattern from the wall card and the comment handle, and it does not transfer: both of those make the whole row the click target or have no ambiguous sibling. This list has a published story you can open sitting one row above a published story you cannot, so the link looks like a link. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Parking the unsubscribe route was meant to be the small half of this. Checking the sitemap and the Pagefind index rather than only the route file, as asked, turned up something much larger. scripts/check-dist.mjs is the new gate. It asserts a parked route produces no built page, appears in no sitemap, and is in no Pagefind fragment. Mutation testing it, by restoring the route and rebuilding, is what found the rest: only the sitemap check fired, because an on demand page emits no HTML to index but is still listed. The comment in astro.config.mjs asserted the exact opposite, that on demand pages are invisible to the sitemap, and had a hand kept exclusion built on that false premise. 42 of 120 sitemap urls were pages whose own markup says noindex. Four static routes, and 38 dynamic ones the hand kept list could never have covered, including five unpublished drafts being submitted to Google. Every noindex in the codebase was right. The sitemap was wrong. Two derived mechanisms replace the list: builtNoindex reads the robots meta each page actually emitted, which is the only thing that can see a dynamic route, and noindexRoutes scans source for the on demand pages that write no file. The config fails open on a missing file so a build never loses urls by accident, and the gate fails closed so nothing slips through unseen. Sitemap is 78 urls. Second thing, same shape. Four tests in redirects.build.test.mjs skip when dist is absent, and CI ran the tests before the build, so they skipped on every run and reported green while asserting nothing. The exhaustive redirect check the plan calls for had never once executed in the pipeline. It runs now, after the build, with REQUIRE_DIST turning the skip into a failure. All four pass, so the map was sound and only the gate was broken. Third, smaller. pending.types.ts declared unsubscribe_by_token while its migration sits in supabase/deferred/, so a typed rpc call would have compiled and failed at runtime with nothing in between. Split into DeferredDatabase, which only _unwired/ may import, with tests/types.test.mjs holding the line. Gates: astro check 0 errors, 155 tests 0 skipped, check:migrations clean, check:dist clean, axe clean across 138 audits and 46 page loads. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
.gitignore named .env and .env.production, which were the files present the day it was written. Any other variant, .env.local from a debugging session or a .env.audit-bak left over from a check, was untracked AND unignored. That is the one state where git add -A stages a file nobody asked it to, and the file it would have staged holds a service role key. Nothing was actually leaked. .env.audit-bak was never committed on any ref, does not exist on disk in either checkout, and both tracked samples carry empty keys only. The exposure was potential, not realised. It is closed by inverting the rule: ignore everything env shaped, allow the samples by name. So an unknown variant is hidden by default and only a named file is tracked. That is still a list somebody maintains, and a list is what failed the first time, so tests/secrets.test.mjs asks git instead of naming filenames. It checks that nothing env shaped is untracked and unignored right now, that every tracked env file is on the allow list, that the samples declare keys and never values, and that no tracked file anywhere holds something shaped like a live credential. It reports where and what, never the value, because a test failure gets pasted into a lot of places. Mutation testing it found a gap the widened rule still had: env.local and env.backup have no leading dot, so .env* missed them. Fixed with a root anchored /env* that cannot reach src/env.d.ts. Separately, .env.example described notifications as a feature to configure. It told a reader that with no key the queue still fills and the drain logs what it would have sent. Neither is true now. The enqueue trigger and email_outbox are both held in supabase/deferred/, so nothing queues and there is nothing to drain. Three variables Michael cannot get values for and does not need, sitting in the file he copies, is a round trip on a handover written to take one pass. They are marked parked, with the same claim corrected in supabase/README.md and src/lib/mail.ts. The drain route needs no change. It refuses every request before touching the database when the secret is unset, and its header already explains why it stays in the route table when the unsubscribe page did not. Gates: astro check 0 errors, 159 tests 0 skipped, check:migrations clean, check:dist clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
The baseline header warned that several unrelated things are called some variation of video, and it carried a count. That count had been reported as three, then five, then four. A warning whose entire function is a number cannot itself be miscounted, because the next person to notice the disagreement resolves it by ignoring the warning. It is a list now. A list cannot drift. Writing the list out proved the point immediately. The count said four and a fifth was already in the repo when it was written: src/lib/videos.ts reads video_pages and has never touched the videos collection. So the file named after the catalogue was actually the page gate, and the catalogue was in content.ts. Anybody hunting for how the video list is built opened the wrong file and had to go looking again. That one is renamed to video-pages.ts rather than documented, because it is the only one of the seven this repo controls. A table in another project and a collection in a submodule can only be written down. A local module can be fixed. Seven things named in the header now, with which project each lives in and which of them is the real catalogue. If another one appears it goes on the list. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
The Start here lead was a title and a foot line with a hole between them. Three separate causes, and the interesting part is that none of them was the one that had been blamed. FIRST CAUSE, A CLASS NAME COLLISION. The site footer and the metadata line at the bottom of a card were both called .foot. The footer rule sets a padding shorthand, and .card .foot only ever overrode padding-top, so 4rem of footer padding-bottom was sitting inside every card on the site. That is 64 pixels of nothing under the foot line of all five front page cards, and it also meant margin-top auto on the card foot resolved to zero, because the padding had already eaten the free space it existed to absorb. Rows were 307px where the content needed 243px. The footer rules are element qualified now, so they can only match the real footer, and the card foot declares the border it had been getting by accident. The collision is in the mockup too, so this shipped as a faithful port of a defect rather than as a porting mistake. SECOND CAUSE, A MISSING SUMMARY. The mockup draws the wide card with a title, a summary and a foot line, and the summary is what gives it its height. Articles carry their own description. A video cannot: the videos collection schema has no description field at all, and it lives in a submodule this repo treats as read only. So START_HERE now carries an optional editorial blurb, which is the right home for it anyway, because the line wanted is about why this is where somebody should start rather than what the video contains. THIRD CAUSE, AND IT IS THE ONE I GOT WRONG TWICE. align-content on .card.wide is stretch. It was briefly reverted to start on the reasoning that stretch cannot move the thumbnail, which is true and is not the point. The thumb has a definite width from its column and a specified aspect-ratio, which gives it a definite height, so stretch never applies to it. That is exactly why stretch is safe here rather than why it is useless. What it stretches is .body, which has no definite height, and that is the half that matters, because the foot uses margin-top auto and needs free space to push into. Measured both ways: start leaves 26.1px of dead space and puts the lead's foot 24.3px above where every neighbouring card puts its own. Stretch leaves 1.8px and lands it on the shared baseline. Crop loss is 0 percent either way, thumb unchanged at 238x134. The other candidate, dropping the aspect-ratio so the picture can genuinely fill the card, costs 47.9 percent of the image at 1200px and stays rejected. THE GATE. scripts/check-layout.mjs measures what a browser draws rather than what the markup says, which is the gap every other check in this repo leaves open. It checks thumbnails against their source ratio, forces the neighbouring card to 2000px to prove the thumb cannot be stretched by a future change, and measures dead space in the lead card. Dead space is measured against ink, not boxes. The first version compared element rectangles and scored the card 24px clean while 64 pixels of hole sat inside it, because every box was flush against its neighbour and the emptiness was padding within one of them. Walking the text nodes and taking the lowest one catches all three causes: 90px for the footer bleed, 60px for the missing blurb, 26px for align-content going back to start, each reported with its own diagnosis. The limit is 12px rather than 48px for the same reason. 48 passed the 26px regression happily, which made it a gate that could not fail for the bug it was written after. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
CI has never run on this branch. Not once. Every push since the workflow landed failed in twelve to fifteen seconds inside actions/checkout, and reported it as an ordinary red cross that looked like a test failure. The cause: src/content is a git submodule pointing at michaeljolley/content, which is private, and the default GITHUB_TOKEN is scoped to this repository alone and cannot clone another one. So checkout aborted with "Repository not found" nested three levels inside git submodule output, before a single gate ran. Which means the gates this repo is proudest of have only ever run on my machine. The exhaustive redirect test, the migration chain check, the shipped output check, axe, the layout geometry gate, all of them green locally and none of them ever executed by CI. A workflow file is not a gate. A workflow file that runs is a gate. The fix has two halves and only one of them is code. The code half: stop asking actions/checkout to fetch the submodule, and do it in a step that can explain itself. A missing token now prints what is wrong, why it matters and the two steps that fix it, rather than an error about a repository that does exist. The half that is not code: somebody has to create a fine grained personal access token with Contents read on michaeljolley/content and save it here as the secret CONTENT_TOKEN. Until that exists this job still fails, deliberately, because building with an empty collection set would make every gate below pass for the wrong reason. That is the failure mode worth being loud about. The accessibility and performance jobs are untouched. They consume the dist artifact and never needed the submodule. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Two things that were false in the repo. THE README WAS THE ASTRO STARTER TEMPLATE. Untouched. It said "Astro Starter Kit: Minimal", it told you to delete it, and every command in it was npm while this project pins pnpm in two places and Netlify builds with pnpm. Somebody following it would run npm install against a pnpm lockfile as their first act in the repo. Replaced with what the site is, how to run it, the submodule and why an empty one is dangerous, which files are generated and must never be hand edited, what each check does, and pointers to the deploy and new project docs. THE PRIVACY PAGE SAID SOMETHING UNTRUE ABOUT EMAIL. Under the Email heading it read "The Drip is a separate thing on a separate site with its own sign up. Nothing you do here puts you on it." The .NET Drip signup form is on this site. It is a Netlify Form posting to net-drip, it asks for an address, and it renders on seventeen pages including the front page. Typing your address into it is a thing you do here, and it does put you on the list. The sentence was probably true when the plan was to link out to dotnetdrip.com, and nobody revisited it when the form was carried across. This matters more than an ordinary copy error because it is on the page whose entire job is telling somebody the truth about their own data, and it is the sentence that would matter to the person who most wanted it to be true. The rest of that section was accurate and is unchanged: the site sends no email, the address from GitHub sits in the account table and does nothing. What is added is that the Drip is the one exception, that it is opt in, where the box is, where the address goes, and that having an account or signing in does not put you on it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Michael is loading the real data himself and does not want a fabricated dataset sitting in the repo until then. src/config/disasters.seed.json is deleted and allDisasters() now reads published rows out of Supabase. Deleting the file was the small half. The large half is what the file was hiding. THERE WAS NO PATH FROM A SUBMISSION TO THE WALL. /api/disasters inserted into disasters. account.ts and profiles.ts read it back, so a submitter saw their own story on their own profile. allDisasters() read the JSON. So a person could send a story, have it published, be shown it on their profile, and never once see it on the site. Nothing would have errored. Every page would have rendered. The story simply would not have been there. A READ FAILURE IS NOT AN EMPTY WALL. An error throws rather than returning an empty list, because drawing "Nobody has told me theirs yet" over a database full of stories is the same failure wearing the site's own empty state as a disguise. No keys at all still returns empty, which is what keeps a fresh clone working. AN UNKNOWN SEVERITY DROPS ONE ROW INSTEAD OF THE BUILD. Against the old seed file throwing was right: a bad value meant somebody had mistyped it in this repo, so the build that failed belonged to the person holding the fix. Against a database the row is somebody else's and the check constraint already rejects anything the schema does not know, so what is left is drift between that constraint and SEVERITIES. Taking every article and video on the site down over one row the site cannot draw is out of proportion. It is skipped, and the reason is printed with its id. It is not redrawn under a substituted severity, because that would invent a classification for somebody else's story. THREE THINGS THE SEED FILE WAS HIDING, ALL LIVE THE MOMENT IT WENT. RESERVED_DISASTER_SLUGS was exported from site.ts, documented as being checked by the submit API, and imported by nothing at all. The archive serves its severity and sort views from the same path segment a story slug occupies, so a story slugged "newest" sits on top of the "all, newest" view. The only thing enforcing that was a test reading the seed file, and it was about to be deleted along with its subject. The list now goes into the set uniqueSlug checks against, so a story that drafts as "newest" becomes "newest-2" and nothing has to fail. The empty wall read "Nothing filed under yet" on the default view, because scopeLabel is deliberately blank on the all scope and the sentence needed a noun. That branch was unreachable while sixteen committed rows covered all four severities. It is now the first sentence a visitor reads in the section. The accessibility gate pointed at a hardcoded seed slug, so it failed on a 404 and reported it as two axe problems. It now discovers a story page from dist and skips the archetype when there are none, the same way the video page archetype already works. The archetype comes back on its own the day a story is published. tests/disasters.test.mjs used to check sixteen rows of committed data. It now checks the wiring that replaced them, including the reserved slug wiring that did not exist. Mutation tested: removing that wiring fails the test by name. Verified at zero rows. Fifteen archive views still build as real pages with every severity count reading zero, zero detail pages build with no warning, the front page leads on "Somebody has to go first" and the panel reads 0 errors, 0 warnings, 0 other. 160 tests, axe clean across 132 audits, layout gate clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
The wide lead card's align-content: stretch against an aspect-ratio thumb has been questioned repeatedly, so both proposed alternatives are now measured on the built page and the numbers live next to the rule. Dropping aspect-ratio on .card.wide .thumb cuts 30.8 percent of the image at 1200px, 21.0 at 860 and 6.7 at 390, and lets the thumb grow from 240x195 to 240x1952 beside a 2000px neighbour. The layout gate fails on four counts. Setting align-content to start and pinning the card with align-self: start passes the layout gate and is still wrong. The card shrinks to 219px beside a 243px sibling, and .grid draws its 1px gaps as background: var(--line), so the missing 24px returns as a band of divider colour across the full width of the lead. The void does not go away. It changes colour and moves outside the card. That last part is a gate blind spot rather than just a rejected option. check-layout.mjs measures ink inside the card and crop on the thumb, and never asks whether the card fills its own grid cell, so it reported clean on a layout with a visible 24px bar in it. Recorded in the comment. Not yet built. No behaviour change. The rule is unchanged and still measures 0 percent crop and 2px of dead space below the lead's last text. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
The lead card change was applied, measured in both cases asked for, photographed and reverted. The numbers are now next to the rule so the same question does not have to be reopened. With aspect-ratio: auto on .card.wide .thumb the thumbnail is cut 30.8 percent against a four line neighbour and 21.0 percent with the wide card alone in its row, so both cases crop and the second one shows the neighbour was never the cause. Every line of the title on the thumbnail loses its first character: class reads lass, struct reads truct. The gap it was meant to close does not move. The distance from the summary to the meta line is 34px with the aspect ratio and 34px without it, because that gap is the foot's own padding plus the body's row gap. The space the change does remove is 59px below the thumbnail in the left column, a different gap in a different column from the one named. No behaviour change. The rule is unchanged and still measures 0.6 percent crop, which is one pixel of border on a 136 pixel box. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
The widest card on the front page holds a thumbnail, a two line title and a meta line. With no summary between them the card is mostly empty, and no layout change fixes that because the missing thing is words. Dropping aspect-ratio on the thumb was measured as an answer in 52a121e and it costs 30.8 percent of the picture while leaving the summary gap at exactly the size it already was. So the rule is about content and it lives in config. src/lib/starters.ts resolves the rail: an authored blurb wins over whatever the collection supplied, and the lead is the first pick that has a summary rather than simply the first pick. A bare video moves down the rail instead of putting a hole in the widest slot on the site. If no pick has a summary there is no honest lead to draw, so the build fails and the failure belongs to whoever just edited START_HERE. The blurb field already existed and the current first pick already carries one, so nothing on the page moves today. What is new is that it cannot quietly stop being true. tests/starters.build.test.mjs reads the built page rather than the source for the two assertions that matter, because what counts is the paragraph a visitor sees. It follows the REQUIRE_DIST pattern so the skip cannot go unnoticed on CI. Both directions were exercised rather than assumed. Removing the blurb from the first pick moves the lead to the first article, keeps the bare video in the rail and leaves every test passing, which is the fallback working. Removing the blurb and disabling the lead rule together fails with "the lead card drew no paragraph at all, so it is a title over a meta line", which is the probe proving it can fail. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3070fc6-4aa5-412a-9dfe-0d0037c9684a
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8df59069-5e99-4ebd-9380-fa8de477c3ba # Conflicts: # astro.config.mjs
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7151090-3c32-450a-b405-2f4129e77b82
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 862e6899-40cf-4c00-bf0f-0a0e0690f48e
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 799dd654-5d28-46a2-bbbd-432b1d4e1d51
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fc3c048d-9557-4959-a0c6-60ba30e1732a
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 853eb6a5-76e1-4275-aec9-b003eed508de
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f3c6060-8821-42fb-ad41-388bd15a5341
Route the homepage All and Everything I've made links to a new archive containing every published article and video. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c847d414-c7b8-4148-a903-cf008ded8c9b
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c847d414-c7b8-4148-a903-cf008ded8c9b
Replace the constrained Pagefind component layout with a responsive, accessible search console and contained result ledger. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 58dc1e1e-7a0d-415a-aa30-86fc5e7bd81e
…/redesign-search-results
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f112f741-f895-4e61-b4b9-5e4ec8d4922f
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f112f741-f895-4e61-b4b9-5e4ec8d4922f
Recover from an empty generated taxonomy file and replace generated JSON atomically while keeping hand-edited overrides strict. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ff9a49da-ea14-4622-a9c8-d923298b2ebf
…my-json Fix taxonomy JSON recovery
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…r/website into dev/mjolley/v2
Submitting a dev disaster answered 303 to /submit/?sent=signin and saved nothing, for a reader who was signed in. 20260805000000_base_table_grants.sql revokes every table privilege in public from anon and authenticated, and it argues for that on the grounds that no serverClient call has a .from on it. Middleware was the one that did. So the profiles select came back permission denied, only data was destructured, and locals.profile stayed null. Every route that gates on it, /api/disasters included, then behaved as though nobody was signed in. The grant is right and the claim about the source tree was what had gone stale, so the read moves to serviceClient, which is how the OAuth callback already touches this same row. The id comes from getUser, not the request. Also stops the submit endpoint dropping three Supabase errors on the floor. All three are gates and all three failed open: an unreachable database would have waved a banned person through, reset the daily count to zero, and handed uniqueSlug an empty set of taken slugs, silently. tests/auth.test.mjs now fails if a serverClient ever reads public again. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ee6adae5-acdc-408e-8f7f-588977953740
* feat: sign in with GitHub, Discord, or Twitch /auth/signin/ hardcoded provider: 'github' and was a redirect rather than a page, so there was nowhere to choose. Adds a chooser at /signin/ and turns the redirect into an allowlisted handoff. Revises decision 4 (GitHub, the only way in) and decision 13 (Twitch links but never signs in). The people who read this site do not all live on GitHub. - new /signin/ page, three plain links, works with no JavaScript - /auth/signin/ takes ?provider= checked against an allowlist, with per-provider scopes, and bounces back to the chooser rather than defaulting to GitHub - profiles gains discord_id and discord_login; handle_new_user branches on the provider so a Discord snowflake never lands in the unique github_id column - callback refreshes only the columns of the provider just used, so a second provider does not blank the first one - signing in with Twitch fills twitch_user_id, so the badge backfill sees it without a second trip through /auth/link/twitch/ - the decision 16 hold falls back to profiles.created_at, because only GitHub reports an account age and holding on a null is a ban nobody decided to issue Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ee6adae5-acdc-408e-8f7f-588977953740 * feat: connect and disconnect all three accounts from /account/ The Connected accounts panel hardcoded GitHub as display only and Twitch as the one thing you could link. Now that all three can sign you in, somebody who started with Discord saw a GitHub row that said "Not connected" and gave them no way to fix it. All three now read the same: a Connect button when it is not attached, a Disconnect button when it is. Connected is read from the auth identities rather than the profile columns, because the identity is what can actually open the door and the column is only the cached name beside it. Disconnect refuses the last identity, since removing it leaves an account with no way back into it. Supabase refuses too, we just check first so the answer is a sentence instead of a generic failure. Replaces the hardcoded /auth/link/twitch/ pair with /auth/link/[provider]/. The old URLs still resolve through the dynamic route, so nothing bookmarked breaks. Twitch keeps its immediate grant_badges call. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ee6adae5-acdc-408e-8f7f-588977953740 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ee6adae5-acdc-408e-8f7f-588977953740
Three things about auth that all came from the same place: the multi provider change added Discord as a way in, and the parts of the site that talk about accounts were never brought along with it. Sign out. The route was written correctly, as a POST, and then nothing on the site ever called it. The only way to end a session was to clear cookies by hand or wait for the token to expire. /account/ has the control now, as a real form so it works with JavaScript off. It signs out of this browser rather than every device, and it clears the sb- cookies outside the try, because signOut is a network call and a browser still holding a token after pressing sign out is the one outcome the button exists to prevent. Connected accounts. The list named GitHub and Twitch and quietly left out Discord. It renders from the provider list now, so a fourth door means editing providers.ts and nothing else. Each row says what that provider is actually for rather than "Connected account" three times. Link it. Every one of those links pointed at /auth/link/<provider>/ and no such route had ever been written, so every press was a 404. The config and the docs both described it, which is how it went unnoticed. It is linkIdentity rather than signInWithOAuth: the latter would replace the session, so pressing Link it would sign somebody in as a second empty account instead of attaching an identity to the one they were looking at. The link comes back through the existing callback carrying a `linked` parameter. It needs one, because app_metadata.provider keeps naming whoever somebody signed up as, so a Twitch link on a GitHub account would otherwise refresh github_login and leave twitch_user_id null. A link also leaves the display name and avatar alone, and stops falling back to user_metadata, which on a link still describes the other provider. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The three a day cap on /api/disasters exists because decision 7 makes every submission cost me a read in Studio. It was never protecting the queue from the person emptying it, so it now skips the count entirely for the host. The exemption keys off a uuid in SITE rather than hostHandle. The schema says handles are mutable and nothing else keys off them, and this one is a gate: a rename should not hand the exemption to whoever claims the handle next. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…er submission limit Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.