Skip to content

Dev/mjolley/v10 - #20

Merged
michaeljolley merged 118 commits into
mainfrom
dev/mjolley/v2
Aug 7, 2026
Merged

Dev/mjolley/v10#20
michaeljolley merged 118 commits into
mainfrom
dev/mjolley/v2

Conversation

@michaeljolley

Copy link
Copy Markdown
Collaborator

No description provided.

michaeljolley and others added 30 commits July 31, 2026 12:11
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
michaeljolley and others added 28 commits August 3, 2026 19:58
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
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
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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>
@michaeljolley
michaeljolley merged commit a609da7 into main Aug 7, 2026
9 checks passed
@michaeljolley
michaeljolley deleted the dev/mjolley/v2 branch August 7, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant