From 2aeb6ed2fc2ab9591f91455ed60023bd7291a5cf Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Sun, 10 May 2026 22:47:57 -0500 Subject: [PATCH 01/88] Trim treaty dashboard to message-first share surface --- .../site/TreatyTaskDashboardClient.tsx | 30 ++----------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/packages/web/src/components/site/TreatyTaskDashboardClient.tsx b/packages/web/src/components/site/TreatyTaskDashboardClient.tsx index 18252a423..72a96ab5d 100644 --- a/packages/web/src/components/site/TreatyTaskDashboardClient.tsx +++ b/packages/web/src/components/site/TreatyTaskDashboardClient.tsx @@ -1,12 +1,9 @@ "use client"; import { LogOut } from "lucide-react"; -import { signOut, useSession } from "next-auth/react"; +import { signOut } from "next-auth/react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; import { DashboardShareCard } from "@/components/dashboard/DashboardShareCard"; -import { ReferralLinkBanner } from "@/components/dashboard/ReferralLinkBanner"; import { Button } from "@/components/retroui/Button"; import { ROUTES } from "@/lib/routes"; import { useRequestSiteOrigin } from "@/lib/request-site-origin"; @@ -37,27 +34,15 @@ const OTHER_ACTIONS: Array<{ href: string; label: string; body: string }> = [ }, ]; -// Treaty-paper themed wrapper for the handle/referral-link card. Replaces the -// default brutal-yellow Card so it sits naturally inside the treaty layout. -const TREATY_BANNER_CLASSNAME = - "relative border border-[var(--treaty-ink)]/40 bg-[var(--treaty-paper)] p-6 sm:p-8 shadow-none mb-8"; - export function TreatyTaskDashboardClient({ user: initialUser, signerTasks, }: TreatyTaskDashboardClientProps) { - const router = useRouter(); - const { update: updateSession } = useSession(); - const [user, setUser] = useState(initialUser); + const user = initialUser; const requestOrigin = useRequestSiteOrigin(); const referralLink = buildUserReferralUrl(user, requestOrigin); const overdueSignerCount = signerTasks.length; - const refreshPage = () => { - void updateSession(); - router.refresh(); - }; - return (
@@ -77,17 +62,6 @@ export function TreatyTaskDashboardClient({
- -
From 4f99eb69f6d53478ee57a97fb30eebb3f6321d1f Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Sun, 10 May 2026 23:41:50 -0500 Subject: [PATCH 02/88] Post-vote forward email + first-conversion referrer email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new triggered emails fire after a YES vote on the 1% Treaty referendum (`TREATY_REFERENDUM_SLUG`): 1. `post-vote-share-email` to the voter. Body is the canonical "I love you and don't want you to suffer and die of horrible diseases" share message verbatim, with the voter's referral URL inline + a single "End war and disease" button. Designed to be forwarded as-is: the user hits Forward, pastes two addresses, sends. The email IS the share kit. Deduped on `voteId` so re-votes don't double-send. 2. `referral-first-conversion-email` to the referrer (when `vote.referredByUserId` is set). Sent EXACTLY ONCE per referrer — the moment of their first confirmed conversion. Per-vote pings get spammy fast and don't add signal after the first confirmation. Subsequent conversions live on /dashboard as ambient stats. Deduped on `referrerUserId`. Both emails lead with the chain math (`32 doubling rounds × 2 referrals each = 4,300,000,000 humans`) so the user internalises the multiplier and communicates it correctly to the people they share with. A user who understands the math is a better evangelist than one who doesn't. Both use `scope: "onboarding"` so users can opt out via the existing unsubscribe rails. Failure logging is best-effort (`log.error`) — email send failure doesn't break the vote write. TODO.md notes the deferred work: - Monthly chain-stats digest (cron + recursive CTE; only send when N > 0 to skip depressing zero-count months). - Email-template screenshots in the visual review pipeline so reviewers can see email copy without setting up Resend locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- TODO.md | 17 ++ .../app/api/referendums/[slug]/vote/route.ts | 49 ++++++ .../__tests__/post-vote-share-email.test.ts | 50 ++++++ .../referral-first-conversion-email.test.ts | 47 ++++++ .../src/lib/email/post-vote-share-email.ts | 127 +++++++++++++++ .../email/referral-first-conversion-email.ts | 146 ++++++++++++++++++ 6 files changed, 436 insertions(+) create mode 100644 packages/web/src/lib/email/__tests__/post-vote-share-email.test.ts create mode 100644 packages/web/src/lib/email/__tests__/referral-first-conversion-email.test.ts create mode 100644 packages/web/src/lib/email/post-vote-share-email.ts create mode 100644 packages/web/src/lib/email/referral-first-conversion-email.ts diff --git a/TODO.md b/TODO.md index 6c1952e56..c8a22a78b 100644 --- a/TODO.md +++ b/TODO.md @@ -266,6 +266,23 @@ cross-check so they do not disappear into chat history. **Public copy, messaging, and emails** +- Post-vote forward email + first-conversion email shipped (PR3). Voter receives + a forward-friendly share kit on YES treaty vote. Referrer receives a single + "Your link worked. Round 1 of 32" email on their first conversion only — + never on subsequent conversions. Both deduped via `EmailLog.dedupeKey`. +- Monthly chain-stats digest (not yet built). Once a month, send each user a + digest only when the count of new voters reaching them via their link in + the past month is > 0. Subject: `{N} more voters joined through your link + in {month}`. Body shows full chain size + which doubling round they're on + + how many more rounds reach 4B (the math the user has to grasp to + evangelize correctly). Skip zero-count months entirely — silent is better + than depressing. Requires cron + transitive-chain query (recursive CTE). +- Email-template screenshots in the visual review (not yet built). Render + every email template — magic-link, task-assignment, task-comment-notification, + inbound-monitor-forward, post-vote-share, referral-first-conversion — with + representative tokens, screenshot at email-client widths, embed alongside + page screenshots in `latest.html`. Reviewers currently can't see email + copy without setting up Resend locally. - Move remaining dashboard/page copy into the messaging/copy-review system where practical, especially Treaty Dashboard text and major CTAs. - Continue internationalization groundwork by centralizing public copy in JSON or diff --git a/packages/web/src/app/api/referendums/[slug]/vote/route.ts b/packages/web/src/app/api/referendums/[slug]/vote/route.ts index 225696be2..ff7f39334 100644 --- a/packages/web/src/app/api/referendums/[slug]/vote/route.ts +++ b/packages/web/src/app/api/referendums/[slug]/vote/route.ts @@ -23,6 +23,10 @@ import { ensureHumanityVGovernmentPlaintiffParty } from "@/lib/humanity-v-govern import { ensureUserTreatyTask } from "@/lib/tasks/user-treaty-task.server"; import { TREATY_REFERENDUM_SLUG } from "@/lib/treaty"; import { verifyOrgContextToken } from "@/lib/organization-context-token.server"; +import { sendPostVoteShareEmail } from "@/lib/email/post-vote-share-email"; +import { sendReferralFirstConversionEmail } from "@/lib/email/referral-first-conversion-email"; +import { buildUserReferralUrl, getBaseUrl } from "@/lib/url"; +import { ROUTES } from "@/lib/routes"; const log = createLogger("referendum-vote"); @@ -245,6 +249,51 @@ export async function POST( } catch (plaintiffError) { log.error("Plaintiff registration error", plaintiffError); } + + // Forward-friendly post-vote share email + first-conversion email to + // the referrer (if any). Both are deduped by emailLog dedupeKey so + // re-votes and subsequent referral conversions don't fire again. + try { + const voter = await prisma.user.findUnique({ + where: { id: userId }, + select: { + email: true, + referralCode: true, + person: { select: { displayName: true, handle: true } }, + }, + }); + if (voter?.email) { + const referralUrl = buildUserReferralUrl({ + handle: voter.person?.handle ?? null, + referralCode: voter.referralCode, + }); + await sendPostVoteShareEmail({ + voteId: vote.id, + userId, + toAddress: voter.email, + referralUrl, + }); + } + + if (vote.referredByUserId) { + const referrer = await prisma.user.findUnique({ + where: { id: vote.referredByUserId }, + select: { email: true }, + }); + if (referrer?.email) { + const voterDisplayName = + voter?.person?.displayName ?? "A new voter"; + await sendReferralFirstConversionEmail({ + referrerUserId: vote.referredByUserId, + referrerEmail: referrer.email, + voterDisplayName, + dashboardUrl: `${getBaseUrl()}${ROUTES.dashboard}`, + }); + } + } + } catch (shareEmailError) { + log.error("Post-vote share email error", shareEmailError); + } } } diff --git a/packages/web/src/lib/email/__tests__/post-vote-share-email.test.ts b/packages/web/src/lib/email/__tests__/post-vote-share-email.test.ts new file mode 100644 index 000000000..ff34c8946 --- /dev/null +++ b/packages/web/src/lib/email/__tests__/post-vote-share-email.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + POST_VOTE_SHARE_SUBJECT, + POST_VOTE_SHARE_TEMPLATE_ID, + buildPostVoteShareHtml, + buildPostVoteShareMessageText, + buildPostVoteShareText, +} from "../post-vote-share-email"; + +const SAMPLE_URL = "https://warondisease.org/r/ABCD1234"; + +describe("post-vote share email builders", () => { + it("embeds the referral URL verbatim in the share message", () => { + const message = buildPostVoteShareMessageText(SAMPLE_URL); + expect(message).toContain(SAMPLE_URL); + }); + + it("uses the canonical love + threat + 30 seconds frame", () => { + const message = buildPostVoteShareMessageText(SAMPLE_URL); + expect(message).toContain("I love you"); + expect(message).toContain("suffer and die of horrible diseases"); + expect(message).toContain("30 seconds"); + }); + + it("renders forward-friendly HTML with the message body and a button", () => { + const html = buildPostVoteShareHtml(SAMPLE_URL); + expect(html).toContain(SAMPLE_URL); + expect(html).toContain("End war and disease"); + expect(html).toContain("forward this to two humans"); + }); + + it("escapes the URL so a hostile referral code cannot inject HTML", () => { + const hostile = "https://warondisease.org/r/"; + const html = buildPostVoteShareHtml(hostile); + expect(html).not.toContain("", + }); + expect(html).not.toContain(" `; From 7d3e304c46e2bbe4286dd4016162a9dff2de30ce Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 00:58:43 -0500 Subject: [PATCH 12/88] Address CodeRabbit feedback on PR 74 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Decouple voter share + referrer first-conversion sends** (vote/route.ts). Previously both lived inside a single try/catch, so a failure in voter fetch/send suppressed the referrer's email. Independent try blocks; each side logs its own error. - **Monthly digest month-label mismatch** (monthly-chain-digest.server.ts). The 30-day window covers the prior calendar month when cron fires on day 1, but the label used monthLabel(now) — so an April-data email said "May 2026". Now labels from windowStart; dedupe bucket still keyed on now for the one-per-calendar-month guarantee. - **Monthly digest EmailLog stuck in QUEUED on non-sent SendResult**. Switched the local send wrapper to the shared sendDedupedEmail helper, which marks disabled/suppressed results as FAILED with send_aborted:. Also rolls non-sent statuses into the publisher's failed counter so the cron summary surfaces them. - **Extract getRecipientReferralUrl** to lib/referral-url-helpers.server.ts. The function lived verbatim in both task-assignment-notifications and task-comment-notifications; CLAUDE.md "Delete on sight: copy-paste". Both callers import the shared helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/api/referendums/[slug]/vote/route.ts | 37 ++++++++-- .../lib/email/monthly-chain-digest.server.ts | 74 ++++++------------- .../src/lib/referral-url-helpers.server.ts | 31 ++++++++ .../task-assignment-notifications.server.ts | 18 +---- .../task-comment-notifications.server.ts | 18 +---- 5 files changed, 87 insertions(+), 91 deletions(-) create mode 100644 packages/web/src/lib/referral-url-helpers.server.ts diff --git a/packages/web/src/app/api/referendums/[slug]/vote/route.ts b/packages/web/src/app/api/referendums/[slug]/vote/route.ts index 7be039aab..d2ee049c1 100644 --- a/packages/web/src/app/api/referendums/[slug]/vote/route.ts +++ b/packages/web/src/app/api/referendums/[slug]/vote/route.ts @@ -253,9 +253,26 @@ export async function POST( // Forward-friendly post-vote share email + first-conversion email to // the referrer (if any). Both are deduped by emailLog dedupeKey so - // re-votes and subsequent referral conversions don't fire again. + // re-votes and subsequent referral conversions don't fire again. The + // two sends are independent: a voter share failure must not suppress + // the referrer's first-conversion email (or vice-versa). + type VoterRecord = { + id: string; + email: string; + referralCode: string | null; + person: + | { + id: string; + handle: string | null; + displayName: string | null; + image: string | null; + isPublic: boolean | null; + } + | null; + }; + let voter: VoterRecord | null = null; try { - const voter = await prisma.user.findUnique({ + voter = (await prisma.user.findUnique({ where: { id: userId }, select: { ...userDisplaySelect, @@ -273,7 +290,8 @@ export async function POST( }, }, }, - }); + })) as VoterRecord | null; + if (voter?.email) { const referralUrl = buildUserReferralUrl({ handle: voter.person?.handle ?? null, @@ -286,8 +304,12 @@ export async function POST( referralUrl, }); } + } catch (postVoteShareError) { + log.error("Post-vote share email error", postVoteShareError); + } - if (vote.referredByUserId) { + if (vote.referredByUserId) { + try { const referrer = await prisma.user.findUnique({ where: { id: vote.referredByUserId }, select: { @@ -315,9 +337,12 @@ export async function POST( referrerReferralUrl, }); } + } catch (firstConversionError) { + log.error( + "Referral first-conversion email error", + firstConversionError, + ); } - } catch (shareEmailError) { - log.error("Post-vote share email error", shareEmailError); } } } diff --git a/packages/web/src/lib/email/monthly-chain-digest.server.ts b/packages/web/src/lib/email/monthly-chain-digest.server.ts index d7634b2d0..ce90f2a9c 100644 --- a/packages/web/src/lib/email/monthly-chain-digest.server.ts +++ b/packages/web/src/lib/email/monthly-chain-digest.server.ts @@ -10,15 +10,11 @@ * Driven by `app/api/cron/monthly-chain-digest/route.ts`. */ -import { nanoid } from "nanoid"; import { ReferendumStatus, VotePosition } from "@optimitron/db"; import { createLogger } from "@/lib/logger"; import { prisma } from "@/lib/prisma"; -import { - claimEmailLog, - markEmailLogStatus, -} from "@/lib/email/email-log.server"; -import { sendResendEmail, type SendResult } from "@/lib/email/resend"; +import type { SendResult } from "@/lib/email/resend"; +import { sendDedupedEmail } from "@/lib/email/send-deduped-email.server"; import { MONTHLY_CHAIN_DIGEST_TEMPLATE_ID, buildMonthlyChainDigestHtml, @@ -54,8 +50,8 @@ function monthBucket(now: Date): string { return `${year}-${month}`; } -function monthLabel(now: Date): string { - return now.toLocaleDateString("en-US", { +function monthLabel(date: Date): string { + return date.toLocaleDateString("en-US", { month: "long", year: "numeric", timeZone: "UTC", @@ -67,8 +63,12 @@ export async function publishMonthlyChainDigest(input?: { }): Promise { const now = input?.now ?? new Date(); const bucket = monthBucket(now); - const label = monthLabel(now); const windowStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + // Label the month the conversion data actually covers (the prior calendar + // month when cron fires on day 1), not the month we send. The dedupe + // bucket still uses `now` so we ship exactly one digest per recipient per + // calendar month. + const label = monthLabel(windowStart); const referendum = await prisma.referendum.findUnique({ where: { slug: TREATY_REFERENDUM_SLUG }, @@ -202,6 +202,12 @@ export async function publishMonthlyChainDigest(input?: { result.duplicate += 1; } else if (sendResult.status === "sent") { result.sent += 1; + } else { + // disabled / suppressed — terminal, not retryable, but visible. + result.failed += 1; + result.errors.push( + `${recipient.userId}: send_aborted:${sendResult.status}`, + ); } } catch (error) { result.failed += 1; @@ -223,50 +229,16 @@ async function sendMonthlyChainDigestEmail(input: { monthBucket: string; digestInput: MonthlyChainDigestInput; }): Promise { - const emailLogId = nanoid(); - const dedupeKey = `${MONTHLY_CHAIN_DIGEST_TEMPLATE_ID}:${input.userId}:${input.monthBucket}`; const subject = buildMonthlyChainDigestSubject(input.digestInput); - - const claimed = await claimEmailLog({ - dedupeKey, - id: emailLogId, - now: new Date(), - subject, + return sendDedupedEmail({ + dedupeKey: `${MONTHLY_CHAIN_DIGEST_TEMPLATE_ID}:${input.userId}:${input.monthBucket}`, templateId: MONTHLY_CHAIN_DIGEST_TEMPLATE_ID, - toAddress: input.toAddress, + subject, + html: buildMonthlyChainDigestHtml(input.digestInput), + text: buildMonthlyChainDigestText(input.digestInput), userId: input.userId, + toAddress: input.toAddress, + scope: "onboarding", + skipWishoniaSignature: true, }); - - if (claimed.duplicate || !claimed.emailLogId) { - return { status: "duplicate" }; - } - - try { - const result = await sendResendEmail({ - emailLogId: claimed.emailLogId, - html: buildMonthlyChainDigestHtml(input.digestInput), - scope: "onboarding", - skipWishoniaSignature: true, - subject, - text: buildMonthlyChainDigestText(input.digestInput), - to: input.toAddress, - userId: input.userId, - }); - - if (result.status === "sent") { - await markEmailLogStatus({ - emailLogId: claimed.emailLogId, - providerMessageId: result.id, - status: "SENT", - }); - } - return result; - } catch (error) { - await markEmailLogStatus({ - emailLogId: claimed.emailLogId, - errorMessage: error instanceof Error ? error.message : String(error), - status: "FAILED", - }); - throw error; - } } diff --git a/packages/web/src/lib/referral-url-helpers.server.ts b/packages/web/src/lib/referral-url-helpers.server.ts new file mode 100644 index 000000000..315d81531 --- /dev/null +++ b/packages/web/src/lib/referral-url-helpers.server.ts @@ -0,0 +1,31 @@ +import { prisma } from "@/lib/prisma"; +import { buildUserReferralUrl } from "@/lib/url"; + +/** + * Look up a user's personal referral URL by id. Returns null when the user + * is missing or the caller passed null. Used by transactional notification + * builders (task assignment, task comment, etc.) to embed a "recruit two + * more humans" share footer at the bottom of every engaged-user email. + * + * Lives in its own module because it's referenced by two notification + * pipelines (`task-assignment-notifications.server.ts`, + * `task-comment-notifications.server.ts`) and copy-pasting it twice + * invited divergence. + */ +export async function getRecipientReferralUrl( + userId: string | null, +): Promise { + if (!userId) return null; + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + referralCode: true, + person: { select: { handle: true } }, + }, + }); + if (!user) return null; + return buildUserReferralUrl({ + handle: user.person?.handle ?? null, + referralCode: user.referralCode, + }); +} diff --git a/packages/web/src/lib/tasks/task-assignment-notifications.server.ts b/packages/web/src/lib/tasks/task-assignment-notifications.server.ts index d21ac4262..2a64b0471 100644 --- a/packages/web/src/lib/tasks/task-assignment-notifications.server.ts +++ b/packages/web/src/lib/tasks/task-assignment-notifications.server.ts @@ -14,23 +14,7 @@ import { sendDraftTaskNotification, } from "@/lib/tasks/task-notifications.server"; import { getUserDisplayName, userDisplaySelect } from "@/lib/user-display"; -import { buildUserReferralUrl } from "@/lib/url"; - -async function getRecipientReferralUrl(userId: string | null): Promise { - if (!userId) return null; - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - referralCode: true, - person: { select: { handle: true } }, - }, - }); - if (!user) return null; - return buildUserReferralUrl({ - handle: user.person?.handle ?? null, - referralCode: user.referralCode, - }); -} +import { getRecipientReferralUrl } from "@/lib/referral-url-helpers.server"; const log = createLogger("task-assignment-notifications"); diff --git a/packages/web/src/lib/tasks/task-comment-notifications.server.ts b/packages/web/src/lib/tasks/task-comment-notifications.server.ts index be3d2285e..cc87ee4de 100644 --- a/packages/web/src/lib/tasks/task-comment-notifications.server.ts +++ b/packages/web/src/lib/tasks/task-comment-notifications.server.ts @@ -22,23 +22,7 @@ import { } from "@/lib/tasks/task-notifications.server"; import { recipientWithinRateLimits } from "@/lib/tasks/task-recipient-rate-limit.server"; import { resolveTaskRecipients } from "@/lib/tasks/task-recipients.server"; -import { buildUserReferralUrl } from "@/lib/url"; - -async function getRecipientReferralUrl(userId: string | null): Promise { - if (!userId) return null; - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - referralCode: true, - person: { select: { handle: true } }, - }, - }); - if (!user) return null; - return buildUserReferralUrl({ - handle: user.person?.handle ?? null, - referralCode: user.referralCode, - }); -} +import { getRecipientReferralUrl } from "@/lib/referral-url-helpers.server"; const log = createLogger("task-comment-notifications"); From 28b1ba5591ef759cc4c59ba5744a70871febbcb7 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 01:24:45 -0500 Subject: [PATCH 13/88] Mask LiveCounter in visual review screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiveCounter rendered a real ticking value during the visual-regression spec, so every screenshot capture saw a different millisecond of the counter and Argos surfaced false-positive diffs on every page that embeds it (/employees, /presidents, signer rows, dashboards). The spec already supports `data-visual-mask="dynamic"` + the placeholder attribute — death-counter and money-counter both use it correctly. This component had a non-standard `data-volatile="..."` attribute that the spec doesn't recognize, so the mask never applied. Match the established pattern so screenshots capture a stable placeholder (`123,456` / `$123,456,789,012`) instead of live values. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/components/tasks/live-counter.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/tasks/live-counter.tsx b/packages/web/src/components/tasks/live-counter.tsx index 970c5471c..362b9a3f5 100644 --- a/packages/web/src/components/tasks/live-counter.tsx +++ b/packages/web/src/components/tasks/live-counter.tsx @@ -15,6 +15,17 @@ interface LiveCounterProps { /** Tick every 250ms — 4 updates/sec, smooth enough to feel live, cheap on CPU. */ const TICK_INTERVAL_MS = 250; +/** + * Visual-review placeholders. The e2e visual-regression spec swaps any node + * with `data-visual-mask="dynamic"` to render its `data-visual-placeholder` + * text instead of the live value, so screenshots stay byte-identical across + * runs. Without this, every CI run captured a different tick of the counter + * and produced false-positive diffs on every page that embeds a LiveCounter + * (notably /employees, /presidents, and signer rows). + */ +const VISUAL_REVIEW_INTEGER_PLACEHOLDER = "123,456"; +const VISUAL_REVIEW_CURRENCY_PLACEHOLDER = "$123,456,789,012"; + const intFormatter = new Intl.NumberFormat("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0, @@ -59,7 +70,12 @@ export function LiveCounter({ return ( {displayValue ?? "…"} From c99fa5c6c6132f146d02f7eeae39ff61cf769678 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 01:24:57 -0500 Subject: [PATCH 14/88] Persist per-PR visual review across deploys (gh-pages branch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous setup used `actions/upload-pages-artifact` + `actions/deploy-pages`, which atomically replaces the entire Pages site each deployment. Combined with the workflow's `rm -rf "$pages_root"`, every PR's CI run wiped every other PR's visual review URL — clicking the "Visual review" check on an older PR returned 404 the moment any other PR's CI finished. Switch to `peaceiris/actions-gh-pages@v4` with `keep_files: true`, writing to a long-lived `gh-pages` branch. Each PR's content lands at `pr-//` (the URL the commit status posts) AND at `pr-/latest/` (a stable per-PR link). Other PRs' directories on the branch are preserved. The job's `contents` permission moves from `read` to `write` so the action can push to gh-pages. `pages: write` / `id-token: write` are removed — they were only needed by the old deploy-pages flow. One-time repo setup (manual): Settings → Pages → Source must be set to "Deploy from a branch" with branch `gh-pages`. The action creates the branch on its first successful run. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 71 +++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5376c79e5..9a1bfb771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,11 +82,11 @@ jobs: timeout-minutes: 30 permissions: actions: read - contents: read + # peaceiris/actions-gh-pages needs write to push the visual review to + # the long-lived `gh-pages` branch. + contents: write deployments: read - id-token: write issues: write - pages: write pull-requests: write statuses: write env: @@ -314,22 +314,39 @@ jobs: packages/web/test-results if-no-files-found: ignore - - name: Prepare visual review Pages site + # Publish the per-PR visual review to the long-lived `gh-pages` branch + # so reviews from different PRs don't clobber each other. The previous + # setup used `actions/deploy-pages`, which atomically REPLACES the + # entire Pages site each deploy — so whenever any PR's CI finished, + # every other PR's review URL went 404 until that PR's CI ran again. + # + # `peaceiris/actions-gh-pages` with `keep_files: true` writes only the + # current PR's directory and preserves everything else on `gh-pages`. + # + # One-time repo setup: Settings → Pages → Source must be set to + # "Deploy from a branch" with branch `gh-pages` (root). The action + # creates the branch on its first push. + - name: Prepare per-PR visual review directory if: always() && github.event_name == 'pull_request' + id: prepare_pages shell: bash run: | - pages_root="packages/web/output/playwright/pages" - pr_dir="$pages_root/pr-${{ github.event.pull_request.number }}" head_sha="${{ github.event.pull_request.head.sha }}" short_sha="${head_sha:0:12}" - commit_dir="$pr_dir/$short_sha" - rm -rf "$pages_root" - mkdir -p "$pr_dir" "$commit_dir" - - cp -R packages/web/output/playwright/review/. "$pr_dir/" + pr_root="packages/web/output/playwright/pages-per-pr/pr-${{ github.event.pull_request.number }}" + commit_dir="$pr_root/$short_sha" + latest_dir="$pr_root/latest" + rm -rf "$pr_root" + mkdir -p "$commit_dir" "$latest_dir" cp -R packages/web/output/playwright/review/. "$commit_dir/" - - cat > "$pages_root/index.html" <<'HTML' + cp -R packages/web/output/playwright/review/. "$latest_dir/" + # Seed `.nojekyll` and a root `index.html` at the publish root so the + # gh-pages site doesn't try to render through Jekyll and so the + # bare https://.github.io// URL renders something + # meaningful. `keep_files: true` preserves these across deploys. + publish_root="packages/web/output/playwright/pages-per-pr" + touch "$publish_root/.nojekyll" + cat > "$publish_root/index.html" <<'HTML' @@ -339,26 +356,26 @@ jobs:

Optimitron Visual Reviews

-

Open the pull request comment for the latest visual review link.

+

Open the "Visual review" check on a pull request for its review page.

HTML - touch "$pages_root/.nojekyll" - - - name: Configure GitHub Pages - if: always() && github.event_name == 'pull_request' - uses: actions/configure-pages@v5 + echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT" + echo "publish_dir=packages/web/output/playwright/pages-per-pr" >> "$GITHUB_OUTPUT" - - name: Upload GitHub Pages artifact - if: always() && github.event_name == 'pull_request' - uses: actions/upload-pages-artifact@v3 - with: - path: packages/web/output/playwright/pages - - - name: Deploy visual review to GitHub Pages + - name: Publish visual review to gh-pages if: always() && github.event_name == 'pull_request' id: visual_review_pages - uses: actions/deploy-pages@v4 + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ${{ steps.prepare_pages.outputs.publish_dir }} + publish_branch: gh-pages + keep_files: true + enable_jekyll: false + commit_message: "Visual review for pr-${{ github.event.pull_request.number }}@${{ steps.prepare_pages.outputs.short_sha }}" + user_name: github-actions[bot] + user_email: github-actions[bot]@users.noreply.github.com - name: Post Visual review commit status if: always() && github.event_name == 'pull_request' From 06e9298d20ebdf2aff57ffbe454df2d8ab9b9b49 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 01:24:45 -0500 Subject: [PATCH 15/88] Mask LiveCounter in visual review screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiveCounter rendered a real ticking value during the visual-regression spec, so every screenshot capture saw a different millisecond of the counter and Argos surfaced false-positive diffs on every page that embeds it (/employees, /presidents, signer rows, dashboards). The spec already supports `data-visual-mask="dynamic"` + the placeholder attribute — death-counter and money-counter both use it correctly. This component had a non-standard `data-volatile="..."` attribute that the spec doesn't recognize, so the mask never applied. Match the established pattern so screenshots capture a stable placeholder (`123,456` / `$123,456,789,012`) instead of live values. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/components/tasks/live-counter.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/tasks/live-counter.tsx b/packages/web/src/components/tasks/live-counter.tsx index 970c5471c..362b9a3f5 100644 --- a/packages/web/src/components/tasks/live-counter.tsx +++ b/packages/web/src/components/tasks/live-counter.tsx @@ -15,6 +15,17 @@ interface LiveCounterProps { /** Tick every 250ms — 4 updates/sec, smooth enough to feel live, cheap on CPU. */ const TICK_INTERVAL_MS = 250; +/** + * Visual-review placeholders. The e2e visual-regression spec swaps any node + * with `data-visual-mask="dynamic"` to render its `data-visual-placeholder` + * text instead of the live value, so screenshots stay byte-identical across + * runs. Without this, every CI run captured a different tick of the counter + * and produced false-positive diffs on every page that embeds a LiveCounter + * (notably /employees, /presidents, and signer rows). + */ +const VISUAL_REVIEW_INTEGER_PLACEHOLDER = "123,456"; +const VISUAL_REVIEW_CURRENCY_PLACEHOLDER = "$123,456,789,012"; + const intFormatter = new Intl.NumberFormat("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0, @@ -59,7 +70,12 @@ export function LiveCounter({ return ( {displayValue ?? "…"} From 35764b79ec4c88fc799a7f5d4fc77e7357b56c3a Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 01:24:57 -0500 Subject: [PATCH 16/88] Persist per-PR visual review across deploys (gh-pages branch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous setup used `actions/upload-pages-artifact` + `actions/deploy-pages`, which atomically replaces the entire Pages site each deployment. Combined with the workflow's `rm -rf "$pages_root"`, every PR's CI run wiped every other PR's visual review URL — clicking the "Visual review" check on an older PR returned 404 the moment any other PR's CI finished. Switch to `peaceiris/actions-gh-pages@v4` with `keep_files: true`, writing to a long-lived `gh-pages` branch. Each PR's content lands at `pr-//` (the URL the commit status posts) AND at `pr-/latest/` (a stable per-PR link). Other PRs' directories on the branch are preserved. The job's `contents` permission moves from `read` to `write` so the action can push to gh-pages. `pages: write` / `id-token: write` are removed — they were only needed by the old deploy-pages flow. One-time repo setup (manual): Settings → Pages → Source must be set to "Deploy from a branch" with branch `gh-pages`. The action creates the branch on its first successful run. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 71 +++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5376c79e5..9a1bfb771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,11 +82,11 @@ jobs: timeout-minutes: 30 permissions: actions: read - contents: read + # peaceiris/actions-gh-pages needs write to push the visual review to + # the long-lived `gh-pages` branch. + contents: write deployments: read - id-token: write issues: write - pages: write pull-requests: write statuses: write env: @@ -314,22 +314,39 @@ jobs: packages/web/test-results if-no-files-found: ignore - - name: Prepare visual review Pages site + # Publish the per-PR visual review to the long-lived `gh-pages` branch + # so reviews from different PRs don't clobber each other. The previous + # setup used `actions/deploy-pages`, which atomically REPLACES the + # entire Pages site each deploy — so whenever any PR's CI finished, + # every other PR's review URL went 404 until that PR's CI ran again. + # + # `peaceiris/actions-gh-pages` with `keep_files: true` writes only the + # current PR's directory and preserves everything else on `gh-pages`. + # + # One-time repo setup: Settings → Pages → Source must be set to + # "Deploy from a branch" with branch `gh-pages` (root). The action + # creates the branch on its first push. + - name: Prepare per-PR visual review directory if: always() && github.event_name == 'pull_request' + id: prepare_pages shell: bash run: | - pages_root="packages/web/output/playwright/pages" - pr_dir="$pages_root/pr-${{ github.event.pull_request.number }}" head_sha="${{ github.event.pull_request.head.sha }}" short_sha="${head_sha:0:12}" - commit_dir="$pr_dir/$short_sha" - rm -rf "$pages_root" - mkdir -p "$pr_dir" "$commit_dir" - - cp -R packages/web/output/playwright/review/. "$pr_dir/" + pr_root="packages/web/output/playwright/pages-per-pr/pr-${{ github.event.pull_request.number }}" + commit_dir="$pr_root/$short_sha" + latest_dir="$pr_root/latest" + rm -rf "$pr_root" + mkdir -p "$commit_dir" "$latest_dir" cp -R packages/web/output/playwright/review/. "$commit_dir/" - - cat > "$pages_root/index.html" <<'HTML' + cp -R packages/web/output/playwright/review/. "$latest_dir/" + # Seed `.nojekyll` and a root `index.html` at the publish root so the + # gh-pages site doesn't try to render through Jekyll and so the + # bare https://.github.io// URL renders something + # meaningful. `keep_files: true` preserves these across deploys. + publish_root="packages/web/output/playwright/pages-per-pr" + touch "$publish_root/.nojekyll" + cat > "$publish_root/index.html" <<'HTML' @@ -339,26 +356,26 @@ jobs:

Optimitron Visual Reviews

-

Open the pull request comment for the latest visual review link.

+

Open the "Visual review" check on a pull request for its review page.

HTML - touch "$pages_root/.nojekyll" - - - name: Configure GitHub Pages - if: always() && github.event_name == 'pull_request' - uses: actions/configure-pages@v5 + echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT" + echo "publish_dir=packages/web/output/playwright/pages-per-pr" >> "$GITHUB_OUTPUT" - - name: Upload GitHub Pages artifact - if: always() && github.event_name == 'pull_request' - uses: actions/upload-pages-artifact@v3 - with: - path: packages/web/output/playwright/pages - - - name: Deploy visual review to GitHub Pages + - name: Publish visual review to gh-pages if: always() && github.event_name == 'pull_request' id: visual_review_pages - uses: actions/deploy-pages@v4 + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ${{ steps.prepare_pages.outputs.publish_dir }} + publish_branch: gh-pages + keep_files: true + enable_jekyll: false + commit_message: "Visual review for pr-${{ github.event.pull_request.number }}@${{ steps.prepare_pages.outputs.short_sha }}" + user_name: github-actions[bot] + user_email: github-actions[bot]@users.noreply.github.com - name: Post Visual review commit status if: always() && github.event_name == 'pull_request' From adcf0b04cb75c05c9e1f652685d65dd3ee8200ab Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 01:56:48 -0500 Subject: [PATCH 17/88] Drop unused updatedAt from court-of-humanity ShareableSnippet The auto-generated `shareableSnippets` map in `parameters-calculations-citations.ts` dropped `updatedAt` from `ShareableSnippet` and its entries earlier in this PR. The hand-edited court-of-humanity referendum body still carried `updatedAt: "2026-05-03"` plus a module comment claiming the canonical shape included it. No code reads `.updatedAt` from any snippet, so drop the field from this module and update the migration note to match the new shape (`{ markdown, sourceFile, originalName }`). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/data/src/referendums/court-of-humanity.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/data/src/referendums/court-of-humanity.ts b/packages/data/src/referendums/court-of-humanity.ts index 748a5282f..095883c0a 100644 --- a/packages/data/src/referendums/court-of-humanity.ts +++ b/packages/data/src/referendums/court-of-humanity.ts @@ -11,8 +11,7 @@ * starts appearing in the auto-generated file, this module can be deleted * and the import in `packages/web/src/config/referendums.ts` switched to * `shareableSnippets.courtOfHumanityText`. Keep the export shape compatible - * (`{ markdown, sourceFile, updatedAt, originalName }`) to make that - * migration trivial. + * (`{ markdown, sourceFile, originalName }`) to make that migration trivial. * * Treaty body (`onePercentTreatyText`) intentionally stays in the * auto-generated file because the QMD pipeline already produces it. @@ -72,6 +71,5 @@ NOW, THEREFORE, the undersigned humans, having noticed, hereby join the Court of IN WITNESS WHEREOF, the undersigned humans, being of sound mind (debatable) and tired of watching their governments kill their families with no consequences, hereby join the Court of Humanity. `, sourceFile: "knowledge/solution/court-of-humanity.qmd", - updatedAt: "2026-05-03", originalName: "court-of-humanity-text", } as const; From f4a9392d4ad078474f43ee609cff5eda5fb96452 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 01:55:29 -0500 Subject: [PATCH 18/88] Address Copilot feedback on PR 76 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues, all valid: - **LiveCounter dropped `data-volatile`**, breaking `scripts/render-pages-to-markdown.ts` which uses that attribute to substitute deterministic placeholders into markdown previews (`pnpm copy:preview`). Restored — the component now emits BOTH attributes simultaneously, like death-counter and money-counter. - **LiveCounter kept ticking during visual review**, producing layout jitter as the span width grew from "1" → "10" → "100" even though the CSS mask hid the text. Added the same `__OPTIMITRON_VISUAL_REVIEW__` freeze death-counter / money-counter already use: when the flag is set, the component renders the placeholder text directly and skips the setInterval entirely. - **`pr-N/latest/` bare URL returned 404** because the directory only contained `latest.html`, not `index.html`. Workflow now copies `latest.html` to `index.html` in both the per-commit and per-PR latest directories so directory URLs resolve to the review page. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 10 ++++ .../web/src/components/tasks/live-counter.tsx | 47 ++++++++++++++----- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a1bfb771..0a0d324bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,6 +340,16 @@ jobs: mkdir -p "$commit_dir" "$latest_dir" cp -R packages/web/output/playwright/review/. "$commit_dir/" cp -R packages/web/output/playwright/review/. "$latest_dir/" + # `latest.html` is the entry point the build script writes. Also + # publish it as `index.html` so bare `pr-N/latest/` and + # `pr-N//` directory URLs (no filename) resolve to the review + # page instead of returning 404. + if [ -f "$commit_dir/latest.html" ]; then + cp "$commit_dir/latest.html" "$commit_dir/index.html" + fi + if [ -f "$latest_dir/latest.html" ]; then + cp "$latest_dir/latest.html" "$latest_dir/index.html" + fi # Seed `.nojekyll` and a root `index.html` at the publish root so the # gh-pages site doesn't try to render through Jekyll and so the # bare https://.github.io// URL renders something diff --git a/packages/web/src/components/tasks/live-counter.tsx b/packages/web/src/components/tasks/live-counter.tsx index 362b9a3f5..c86fc9194 100644 --- a/packages/web/src/components/tasks/live-counter.tsx +++ b/packages/web/src/components/tasks/live-counter.tsx @@ -16,16 +16,33 @@ interface LiveCounterProps { const TICK_INTERVAL_MS = 250; /** - * Visual-review placeholders. The e2e visual-regression spec swaps any node - * with `data-visual-mask="dynamic"` to render its `data-visual-placeholder` - * text instead of the live value, so screenshots stay byte-identical across - * runs. Without this, every CI run captured a different tick of the counter - * and produced false-positive diffs on every page that embeds a LiveCounter - * (notably /employees, /presidents, and signer rows). + * Visual-review placeholders. The e2e visual-regression spec uses both + * mechanisms in tandem: + * - CSS swap on `data-visual-mask="dynamic"` to render the + * `data-visual-placeholder` attribute as the displayed text. + * - `window.__OPTIMITRON_VISUAL_REVIEW__` runtime flag so the component + * itself stops ticking, avoiding layout jitter from the underlying span + * width changing as values grow. + * + * `data-volatile` is also kept because `scripts/render-pages-to-markdown.ts` + * relies on that attribute to substitute deterministic placeholders into the + * generated markdown previews (`pnpm copy:preview`). + * + * death-counter / money-counter follow the same triple-pattern. */ const VISUAL_REVIEW_INTEGER_PLACEHOLDER = "123,456"; const VISUAL_REVIEW_CURRENCY_PLACEHOLDER = "$123,456,789,012"; +function isVisualReviewMode(): boolean { + return ( + typeof window !== "undefined" && + Boolean( + (window as Window & { __OPTIMITRON_VISUAL_REVIEW__?: boolean }) + .__OPTIMITRON_VISUAL_REVIEW__, + ) + ); +} + const intFormatter = new Intl.NumberFormat("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0, @@ -54,9 +71,16 @@ export function LiveCounter({ mode, className, }: LiveCounterProps) { + const visualReviewMode = isVisualReviewMode(); + const placeholder = + mode === "currency" + ? VISUAL_REVIEW_CURRENCY_PLACEHOLDER + : VISUAL_REVIEW_INTEGER_PLACEHOLDER; const [displayValue, setDisplayValue] = useState(null); useEffect(() => { + if (visualReviewMode) return; + const tick = () => { const elapsedSec = Math.max(0, (Date.now() - startMs) / 1000); const value = elapsedSec * ratePerSecond; @@ -65,20 +89,17 @@ export function LiveCounter({ tick(); const interval = window.setInterval(tick, TICK_INTERVAL_MS); return () => window.clearInterval(interval); - }, [ratePerSecond, startMs, mode]); + }, [ratePerSecond, startMs, mode, visualReviewMode]); return ( - {displayValue ?? "…"} + {visualReviewMode ? placeholder : (displayValue ?? "…")} ); } From 91c739819a956efc3e31cc2a93308d793b31b7cb Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 01:55:29 -0500 Subject: [PATCH 19/88] Address Copilot feedback on PR 76 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues, all valid: - **LiveCounter dropped `data-volatile`**, breaking `scripts/render-pages-to-markdown.ts` which uses that attribute to substitute deterministic placeholders into markdown previews (`pnpm copy:preview`). Restored — the component now emits BOTH attributes simultaneously, like death-counter and money-counter. - **LiveCounter kept ticking during visual review**, producing layout jitter as the span width grew from "1" → "10" → "100" even though the CSS mask hid the text. Added the same `__OPTIMITRON_VISUAL_REVIEW__` freeze death-counter / money-counter already use: when the flag is set, the component renders the placeholder text directly and skips the setInterval entirely. - **`pr-N/latest/` bare URL returned 404** because the directory only contained `latest.html`, not `index.html`. Workflow now copies `latest.html` to `index.html` in both the per-commit and per-PR latest directories so directory URLs resolve to the review page. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 10 ++++ .../web/src/components/tasks/live-counter.tsx | 47 ++++++++++++++----- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a1bfb771..0a0d324bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,6 +340,16 @@ jobs: mkdir -p "$commit_dir" "$latest_dir" cp -R packages/web/output/playwright/review/. "$commit_dir/" cp -R packages/web/output/playwright/review/. "$latest_dir/" + # `latest.html` is the entry point the build script writes. Also + # publish it as `index.html` so bare `pr-N/latest/` and + # `pr-N//` directory URLs (no filename) resolve to the review + # page instead of returning 404. + if [ -f "$commit_dir/latest.html" ]; then + cp "$commit_dir/latest.html" "$commit_dir/index.html" + fi + if [ -f "$latest_dir/latest.html" ]; then + cp "$latest_dir/latest.html" "$latest_dir/index.html" + fi # Seed `.nojekyll` and a root `index.html` at the publish root so the # gh-pages site doesn't try to render through Jekyll and so the # bare https://.github.io// URL renders something diff --git a/packages/web/src/components/tasks/live-counter.tsx b/packages/web/src/components/tasks/live-counter.tsx index 362b9a3f5..c86fc9194 100644 --- a/packages/web/src/components/tasks/live-counter.tsx +++ b/packages/web/src/components/tasks/live-counter.tsx @@ -16,16 +16,33 @@ interface LiveCounterProps { const TICK_INTERVAL_MS = 250; /** - * Visual-review placeholders. The e2e visual-regression spec swaps any node - * with `data-visual-mask="dynamic"` to render its `data-visual-placeholder` - * text instead of the live value, so screenshots stay byte-identical across - * runs. Without this, every CI run captured a different tick of the counter - * and produced false-positive diffs on every page that embeds a LiveCounter - * (notably /employees, /presidents, and signer rows). + * Visual-review placeholders. The e2e visual-regression spec uses both + * mechanisms in tandem: + * - CSS swap on `data-visual-mask="dynamic"` to render the + * `data-visual-placeholder` attribute as the displayed text. + * - `window.__OPTIMITRON_VISUAL_REVIEW__` runtime flag so the component + * itself stops ticking, avoiding layout jitter from the underlying span + * width changing as values grow. + * + * `data-volatile` is also kept because `scripts/render-pages-to-markdown.ts` + * relies on that attribute to substitute deterministic placeholders into the + * generated markdown previews (`pnpm copy:preview`). + * + * death-counter / money-counter follow the same triple-pattern. */ const VISUAL_REVIEW_INTEGER_PLACEHOLDER = "123,456"; const VISUAL_REVIEW_CURRENCY_PLACEHOLDER = "$123,456,789,012"; +function isVisualReviewMode(): boolean { + return ( + typeof window !== "undefined" && + Boolean( + (window as Window & { __OPTIMITRON_VISUAL_REVIEW__?: boolean }) + .__OPTIMITRON_VISUAL_REVIEW__, + ) + ); +} + const intFormatter = new Intl.NumberFormat("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0, @@ -54,9 +71,16 @@ export function LiveCounter({ mode, className, }: LiveCounterProps) { + const visualReviewMode = isVisualReviewMode(); + const placeholder = + mode === "currency" + ? VISUAL_REVIEW_CURRENCY_PLACEHOLDER + : VISUAL_REVIEW_INTEGER_PLACEHOLDER; const [displayValue, setDisplayValue] = useState(null); useEffect(() => { + if (visualReviewMode) return; + const tick = () => { const elapsedSec = Math.max(0, (Date.now() - startMs) / 1000); const value = elapsedSec * ratePerSecond; @@ -65,20 +89,17 @@ export function LiveCounter({ tick(); const interval = window.setInterval(tick, TICK_INTERVAL_MS); return () => window.clearInterval(interval); - }, [ratePerSecond, startMs, mode]); + }, [ratePerSecond, startMs, mode, visualReviewMode]); return ( - {displayValue ?? "…"} + {visualReviewMode ? placeholder : (displayValue ?? "…")} ); } From b50469063e39883ab12059b81daaba0774461fb6 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 02:38:07 -0500 Subject: [PATCH 20/88] Speed up CI: path-filter web-validate + parallel seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Path filter** — `web-validate` is a required check, so it has to run on every PR. The expensive steps inside it (Next.js build, Playwright smoke + visual regression, baseline resolution, gh-pages publish) now gate on a `changes.web` output produced by `dorny/paths-filter@v3`. PRs that don't touch `packages/web/**`, `packages/db/**`, `packages/data/**`, the lockfile, root package.json, or the workflow itself short-circuit the job to a trivial pass in ~30 seconds instead of ~10 minutes. Push to main always runs the full pipeline (regression baseline). **Parallel seeds** — `seed:demo`, `seed:tasks`, and `db:sync:managed-data --apply` were sequential after `seed:bootstrap`. Bootstrap lays down the schema; the rest are independent population steps that can fan out. Backgrounded with `&` and joined with `wait`. Saves ~30-60s wall time when the full pipeline runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 107 ++++++++++++++++++++++++++++++++------- 1 file changed, 89 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a0d324bf..20f9e03d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,33 @@ concurrency: cancel-in-progress: true jobs: + # Detect which areas of the repo changed so downstream jobs can skip + # expensive work (Next.js build, Playwright) when no web/db/data files + # were touched. Branch protection still requires `web-validate` to pass, + # so the job runs either way — but it short-circuits to a trivial pass + # when nothing relevant changed. + changes: + name: changes + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + web: ${{ steps.filter.outputs.web }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Detect changed paths + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + web: + - 'packages/web/**' + - 'packages/db/**' + - 'packages/data/**' + - 'pnpm-lock.yaml' + - 'package.json' + - '.github/workflows/ci.yml' + core-validate: name: core-validate if: github.event_name != 'push' @@ -78,8 +105,15 @@ jobs: web-validate: name: web-validate if: github.event_name != 'push' || github.ref == 'refs/heads/main' + needs: changes runs-on: ubuntu-latest timeout-minutes: 30 + env: + # Push to main always runs the full pipeline (regression baseline). + # PRs run the full pipeline only when web-relevant paths changed. + # When false, the job still completes successfully — branch protection + # is satisfied — but skips all the expensive build + Playwright work. + WEB_VALIDATE_FULL: ${{ github.event_name == 'push' || needs.changes.outputs.web == 'true' }} permissions: actions: read # peaceiris/actions-gh-pages needs write to push the visual review to @@ -112,26 +146,40 @@ jobs: --health-retries=5 steps: + - name: Report path-filter outcome + shell: bash + run: | + if [ "$WEB_VALIDATE_FULL" = "true" ]; then + echo "Web-relevant paths changed (or push to main) — running full pipeline." + else + echo "No web-relevant paths changed. Skipping build + Playwright; job will pass trivially." + fi + - name: Checkout + if: env.WEB_VALIDATE_FULL == 'true' uses: actions/checkout@v6 with: submodules: recursive - name: Enable Corepack + if: env.WEB_VALIDATE_FULL == 'true' run: | corepack enable corepack prepare pnpm@8.14.0 --activate - name: Setup Node.js + if: env.WEB_VALIDATE_FULL == 'true' uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - name: Install dependencies + if: env.WEB_VALIDATE_FULL == 'true' run: pnpm install --frozen-lockfile - name: Cache Next.js build + if: env.WEB_VALIDATE_FULL == 'true' uses: actions/cache@v4 with: path: packages/web/.next/cache @@ -141,23 +189,29 @@ jobs: nextjs-${{ runner.os }}- - name: Apply database migrations + if: env.WEB_VALIDATE_FULL == 'true' run: pnpm db:deploy - name: Build web workspace dependencies + if: env.WEB_VALIDATE_FULL == 'true' run: pnpm --filter @optimitron/web run build:workspace-deps - name: Typecheck web app + if: env.WEB_VALIDATE_FULL == 'true' run: pnpm --filter @optimitron/web run typecheck:fast - name: Run web unit tests + if: env.WEB_VALIDATE_FULL == 'true' run: pnpm --filter @optimitron/web run test - name: Build web app + if: env.WEB_VALIDATE_FULL == 'true' env: NODE_OPTIONS: --max-old-space-size=6144 run: pnpm --filter @optimitron/web run build:fast - name: Cache Playwright browsers + if: env.WEB_VALIDATE_FULL == 'true' id: playwright-cache uses: actions/cache@v4 with: @@ -165,31 +219,48 @@ jobs: key: playwright-${{ runner.os }}-${{ hashFiles('packages/web/package.json') }} - name: Install Playwright browser - if: steps.playwright-cache.outputs.cache-hit != 'true' + if: env.WEB_VALIDATE_FULL == 'true' && steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm --filter @optimitron/web exec playwright install --with-deps chromium - name: Install Playwright system deps - if: steps.playwright-cache.outputs.cache-hit == 'true' + if: env.WEB_VALIDATE_FULL == 'true' && steps.playwright-cache.outputs.cache-hit == 'true' run: pnpm --filter @optimitron/web exec playwright install-deps chromium - - name: Run Playwright smoke validation - run: pnpm --filter @optimitron/web run e2e -- smoke - + # Seeds were sequential — bootstrap → demo → tasks → managed-data sync. + # `demo` and `tasks` don't depend on each other and both need bootstrap + # to land first; managed-data sync needs the schema (bootstrap) only. + # Run demo + tasks + managed-data in parallel after bootstrap and + # `wait` on the group. Saves ~30-60s of serial wall time. - name: Seed visual review data + if: env.WEB_VALIDATE_FULL == 'true' + shell: bash run: | pnpm --filter @optimitron/db run seed:bootstrap - pnpm --filter @optimitron/db run seed:demo - pnpm --filter @optimitron/db run seed:tasks - pnpm db:sync:managed-data -- --apply + pnpm --filter @optimitron/db run seed:demo & + demo_pid=$! + pnpm --filter @optimitron/db run seed:tasks & + tasks_pid=$! + pnpm db:sync:managed-data -- --apply & + sync_pid=$! + fail=0 + wait $demo_pid || fail=1 + wait $tasks_pid || fail=1 + wait $sync_pid || fail=1 + exit $fail + + - name: Run Playwright smoke validation + if: env.WEB_VALIDATE_FULL == 'true' + run: pnpm --filter @optimitron/web run e2e -- smoke - name: Run Playwright visual review + if: env.WEB_VALIDATE_FULL == 'true' env: ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} ARGOS_IGNORE_UPLOAD_FAILURES: ${{ secrets.ARGOS_TOKEN == '' && '1' || '0' }} run: pnpm --filter @optimitron/web run e2e -- visual - name: Preserve PR visual screenshots - if: always() && github.event_name == 'pull_request' + if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' shell: bash run: | rm -rf packages/web/output/playwright/pr-screenshots @@ -199,7 +270,7 @@ jobs: fi - name: Resolve main visual baseline - if: always() && github.event_name == 'pull_request' + if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' env: GH_TOKEN: ${{ github.token }} shell: bash @@ -248,7 +319,7 @@ jobs: fi - name: Resolve PR preview URL - if: always() && github.event_name == 'pull_request' + if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' id: pr_preview_url uses: actions/github-script@v8 with: @@ -283,14 +354,14 @@ jobs: return ''; - name: Build visual review index - if: always() + if: always() && env.WEB_VALIDATE_FULL == 'true' env: VISUAL_REVIEW_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} VISUAL_REVIEW_BASE_URL: ${{ steps.pr_preview_url.outputs.result }} run: pnpm --filter @optimitron/web run visual:review - name: Summarize visual review - if: always() + if: always() && env.WEB_VALIDATE_FULL == 'true' shell: bash run: | { @@ -303,7 +374,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload Playwright visual review - if: always() + if: always() && env.WEB_VALIDATE_FULL == 'true' uses: actions/upload-artifact@v6 with: name: web-visual-review @@ -327,7 +398,7 @@ jobs: # "Deploy from a branch" with branch `gh-pages` (root). The action # creates the branch on its first push. - name: Prepare per-PR visual review directory - if: always() && github.event_name == 'pull_request' + if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' id: prepare_pages shell: bash run: | @@ -374,7 +445,7 @@ jobs: echo "publish_dir=packages/web/output/playwright/pages-per-pr" >> "$GITHUB_OUTPUT" - name: Publish visual review to gh-pages - if: always() && github.event_name == 'pull_request' + if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' id: visual_review_pages uses: peaceiris/actions-gh-pages@v4 with: @@ -388,7 +459,7 @@ jobs: user_email: github-actions[bot]@users.noreply.github.com - name: Post Visual review commit status - if: always() && github.event_name == 'pull_request' + if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' uses: actions/github-script@v8 with: script: | @@ -408,7 +479,7 @@ jobs: }); - name: Upload Playwright artifacts - if: failure() + if: failure() && env.WEB_VALIDATE_FULL == 'true' uses: actions/upload-artifact@v6 with: name: web-playwright-artifacts From 35399f78ab8880ccc6415ceebbd6fb308b455319 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 02:49:51 -0500 Subject: [PATCH 21/88] Revert "Speed up CI: path-filter web-validate + parallel seeds" This reverts commit b50469063e39883ab12059b81daaba0774461fb6. --- .github/workflows/ci.yml | 107 +++++++-------------------------------- 1 file changed, 18 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20f9e03d7..0a0d324bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,33 +15,6 @@ concurrency: cancel-in-progress: true jobs: - # Detect which areas of the repo changed so downstream jobs can skip - # expensive work (Next.js build, Playwright) when no web/db/data files - # were touched. Branch protection still requires `web-validate` to pass, - # so the job runs either way — but it short-circuits to a trivial pass - # when nothing relevant changed. - changes: - name: changes - runs-on: ubuntu-latest - timeout-minutes: 2 - outputs: - web: ${{ steps.filter.outputs.web }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Detect changed paths - id: filter - uses: dorny/paths-filter@v3 - with: - filters: | - web: - - 'packages/web/**' - - 'packages/db/**' - - 'packages/data/**' - - 'pnpm-lock.yaml' - - 'package.json' - - '.github/workflows/ci.yml' - core-validate: name: core-validate if: github.event_name != 'push' @@ -105,15 +78,8 @@ jobs: web-validate: name: web-validate if: github.event_name != 'push' || github.ref == 'refs/heads/main' - needs: changes runs-on: ubuntu-latest timeout-minutes: 30 - env: - # Push to main always runs the full pipeline (regression baseline). - # PRs run the full pipeline only when web-relevant paths changed. - # When false, the job still completes successfully — branch protection - # is satisfied — but skips all the expensive build + Playwright work. - WEB_VALIDATE_FULL: ${{ github.event_name == 'push' || needs.changes.outputs.web == 'true' }} permissions: actions: read # peaceiris/actions-gh-pages needs write to push the visual review to @@ -146,40 +112,26 @@ jobs: --health-retries=5 steps: - - name: Report path-filter outcome - shell: bash - run: | - if [ "$WEB_VALIDATE_FULL" = "true" ]; then - echo "Web-relevant paths changed (or push to main) — running full pipeline." - else - echo "No web-relevant paths changed. Skipping build + Playwright; job will pass trivially." - fi - - name: Checkout - if: env.WEB_VALIDATE_FULL == 'true' uses: actions/checkout@v6 with: submodules: recursive - name: Enable Corepack - if: env.WEB_VALIDATE_FULL == 'true' run: | corepack enable corepack prepare pnpm@8.14.0 --activate - name: Setup Node.js - if: env.WEB_VALIDATE_FULL == 'true' uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - name: Install dependencies - if: env.WEB_VALIDATE_FULL == 'true' run: pnpm install --frozen-lockfile - name: Cache Next.js build - if: env.WEB_VALIDATE_FULL == 'true' uses: actions/cache@v4 with: path: packages/web/.next/cache @@ -189,29 +141,23 @@ jobs: nextjs-${{ runner.os }}- - name: Apply database migrations - if: env.WEB_VALIDATE_FULL == 'true' run: pnpm db:deploy - name: Build web workspace dependencies - if: env.WEB_VALIDATE_FULL == 'true' run: pnpm --filter @optimitron/web run build:workspace-deps - name: Typecheck web app - if: env.WEB_VALIDATE_FULL == 'true' run: pnpm --filter @optimitron/web run typecheck:fast - name: Run web unit tests - if: env.WEB_VALIDATE_FULL == 'true' run: pnpm --filter @optimitron/web run test - name: Build web app - if: env.WEB_VALIDATE_FULL == 'true' env: NODE_OPTIONS: --max-old-space-size=6144 run: pnpm --filter @optimitron/web run build:fast - name: Cache Playwright browsers - if: env.WEB_VALIDATE_FULL == 'true' id: playwright-cache uses: actions/cache@v4 with: @@ -219,48 +165,31 @@ jobs: key: playwright-${{ runner.os }}-${{ hashFiles('packages/web/package.json') }} - name: Install Playwright browser - if: env.WEB_VALIDATE_FULL == 'true' && steps.playwright-cache.outputs.cache-hit != 'true' + if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm --filter @optimitron/web exec playwright install --with-deps chromium - name: Install Playwright system deps - if: env.WEB_VALIDATE_FULL == 'true' && steps.playwright-cache.outputs.cache-hit == 'true' + if: steps.playwright-cache.outputs.cache-hit == 'true' run: pnpm --filter @optimitron/web exec playwright install-deps chromium - # Seeds were sequential — bootstrap → demo → tasks → managed-data sync. - # `demo` and `tasks` don't depend on each other and both need bootstrap - # to land first; managed-data sync needs the schema (bootstrap) only. - # Run demo + tasks + managed-data in parallel after bootstrap and - # `wait` on the group. Saves ~30-60s of serial wall time. + - name: Run Playwright smoke validation + run: pnpm --filter @optimitron/web run e2e -- smoke + - name: Seed visual review data - if: env.WEB_VALIDATE_FULL == 'true' - shell: bash run: | pnpm --filter @optimitron/db run seed:bootstrap - pnpm --filter @optimitron/db run seed:demo & - demo_pid=$! - pnpm --filter @optimitron/db run seed:tasks & - tasks_pid=$! - pnpm db:sync:managed-data -- --apply & - sync_pid=$! - fail=0 - wait $demo_pid || fail=1 - wait $tasks_pid || fail=1 - wait $sync_pid || fail=1 - exit $fail - - - name: Run Playwright smoke validation - if: env.WEB_VALIDATE_FULL == 'true' - run: pnpm --filter @optimitron/web run e2e -- smoke + pnpm --filter @optimitron/db run seed:demo + pnpm --filter @optimitron/db run seed:tasks + pnpm db:sync:managed-data -- --apply - name: Run Playwright visual review - if: env.WEB_VALIDATE_FULL == 'true' env: ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} ARGOS_IGNORE_UPLOAD_FAILURES: ${{ secrets.ARGOS_TOKEN == '' && '1' || '0' }} run: pnpm --filter @optimitron/web run e2e -- visual - name: Preserve PR visual screenshots - if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' + if: always() && github.event_name == 'pull_request' shell: bash run: | rm -rf packages/web/output/playwright/pr-screenshots @@ -270,7 +199,7 @@ jobs: fi - name: Resolve main visual baseline - if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' + if: always() && github.event_name == 'pull_request' env: GH_TOKEN: ${{ github.token }} shell: bash @@ -319,7 +248,7 @@ jobs: fi - name: Resolve PR preview URL - if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' + if: always() && github.event_name == 'pull_request' id: pr_preview_url uses: actions/github-script@v8 with: @@ -354,14 +283,14 @@ jobs: return ''; - name: Build visual review index - if: always() && env.WEB_VALIDATE_FULL == 'true' + if: always() env: VISUAL_REVIEW_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} VISUAL_REVIEW_BASE_URL: ${{ steps.pr_preview_url.outputs.result }} run: pnpm --filter @optimitron/web run visual:review - name: Summarize visual review - if: always() && env.WEB_VALIDATE_FULL == 'true' + if: always() shell: bash run: | { @@ -374,7 +303,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload Playwright visual review - if: always() && env.WEB_VALIDATE_FULL == 'true' + if: always() uses: actions/upload-artifact@v6 with: name: web-visual-review @@ -398,7 +327,7 @@ jobs: # "Deploy from a branch" with branch `gh-pages` (root). The action # creates the branch on its first push. - name: Prepare per-PR visual review directory - if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' + if: always() && github.event_name == 'pull_request' id: prepare_pages shell: bash run: | @@ -445,7 +374,7 @@ jobs: echo "publish_dir=packages/web/output/playwright/pages-per-pr" >> "$GITHUB_OUTPUT" - name: Publish visual review to gh-pages - if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' + if: always() && github.event_name == 'pull_request' id: visual_review_pages uses: peaceiris/actions-gh-pages@v4 with: @@ -459,7 +388,7 @@ jobs: user_email: github-actions[bot]@users.noreply.github.com - name: Post Visual review commit status - if: always() && env.WEB_VALIDATE_FULL == 'true' && github.event_name == 'pull_request' + if: always() && github.event_name == 'pull_request' uses: actions/github-script@v8 with: script: | @@ -479,7 +408,7 @@ jobs: }); - name: Upload Playwright artifacts - if: failure() && env.WEB_VALIDATE_FULL == 'true' + if: failure() uses: actions/upload-artifact@v6 with: name: web-playwright-artifacts From b636966808b0d33f85ee3e9371f078a1f5416321 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 11:18:47 -0500 Subject: [PATCH 22/88] Shrink LiveCounter visual-review placeholders to fit task rows The previous placeholders (`123,456` and `$123,456,789,012`) were sized to a trillion-dollar worst case. LiveCounter renders inside `w-40`/`w-44` columns with `break-all` in task-row.tsx; the 16-char currency placeholder overflowed and got chopped into vertical fragments in visual review screenshots (visible on the signer leaderboard at /tasks/1-pct-treaty). Real per-task delay magnitudes: - deaths: tens to low thousands (`1,234`) - USD wasted: thousands to single-millions (`$1,234,567`) Sizing the placeholders to typical reality keeps the visual review faithful and removes the spurious layout breakage. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/src/components/tasks/live-counter.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/web/src/components/tasks/live-counter.tsx b/packages/web/src/components/tasks/live-counter.tsx index c86fc9194..4ef77107b 100644 --- a/packages/web/src/components/tasks/live-counter.tsx +++ b/packages/web/src/components/tasks/live-counter.tsx @@ -29,9 +29,16 @@ const TICK_INTERVAL_MS = 250; * generated markdown previews (`pnpm copy:preview`). * * death-counter / money-counter follow the same triple-pattern. + * + * Placeholder widths are sized to typical PER-TASK real values, not to + * absolute worst case. LiveCounter is used in task-row.tsx inside narrow + * columns (`w-40 break-all`) where overflow chunks numbers into vertical + * fragments. Real per-task wasted-by-delay amounts are in the thousands to + * single-millions; real per-task deaths-from-delay counts are in the tens + * to thousands. Keep these placeholders representative of that range. */ -const VISUAL_REVIEW_INTEGER_PLACEHOLDER = "123,456"; -const VISUAL_REVIEW_CURRENCY_PLACEHOLDER = "$123,456,789,012"; +const VISUAL_REVIEW_INTEGER_PLACEHOLDER = "1,234"; +const VISUAL_REVIEW_CURRENCY_PLACEHOLDER = "$1,234,567"; function isVisualReviewMode(): boolean { return ( From 8c095261766f3b1a731cba3bda220fbea6f0c5cb Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 11:19:45 -0500 Subject: [PATCH 23/88] Revert "Shrink LiveCounter visual-review placeholders to fit task rows" This reverts commit b636966808b0d33f85ee3e9371f078a1f5416321. --- packages/web/src/components/tasks/live-counter.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/web/src/components/tasks/live-counter.tsx b/packages/web/src/components/tasks/live-counter.tsx index 4ef77107b..c86fc9194 100644 --- a/packages/web/src/components/tasks/live-counter.tsx +++ b/packages/web/src/components/tasks/live-counter.tsx @@ -29,16 +29,9 @@ const TICK_INTERVAL_MS = 250; * generated markdown previews (`pnpm copy:preview`). * * death-counter / money-counter follow the same triple-pattern. - * - * Placeholder widths are sized to typical PER-TASK real values, not to - * absolute worst case. LiveCounter is used in task-row.tsx inside narrow - * columns (`w-40 break-all`) where overflow chunks numbers into vertical - * fragments. Real per-task wasted-by-delay amounts are in the thousands to - * single-millions; real per-task deaths-from-delay counts are in the tens - * to thousands. Keep these placeholders representative of that range. */ -const VISUAL_REVIEW_INTEGER_PLACEHOLDER = "1,234"; -const VISUAL_REVIEW_CURRENCY_PLACEHOLDER = "$1,234,567"; +const VISUAL_REVIEW_INTEGER_PLACEHOLDER = "123,456"; +const VISUAL_REVIEW_CURRENCY_PLACEHOLDER = "$123,456,789,012"; function isVisualReviewMode(): boolean { return ( From d884b3d0731c7ecaa5ed36e8c1e81e7bbf73b16b Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 11:32:16 -0500 Subject: [PATCH 24/88] Restore /treaty to skim-and-sign layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page had grown a multi-step stepper (apology → grandma → apocalypse → slides → vote) that buried the signature box behind a forced narrative. Users who land on /treaty want to read the treaty and sign. Reuse `` (already used elsewhere on the campaign site) which produces exactly that: 1. Short call-to-action at the top — "Please take 30 seconds to end war and disease." 2. Treaty body markdown rendered in reader mode. 3. Court-of-Humanity CTA + the `` signature box at the bottom. No stepper, no audio, no slide-by-slide navigation. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/src/app/treaty/page.tsx | 37 ++++++++++++---------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/web/src/app/treaty/page.tsx b/packages/web/src/app/treaty/page.tsx index fb8b6d1bb..84a9a89e8 100644 --- a/packages/web/src/app/treaty/page.tsx +++ b/packages/web/src/app/treaty/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { headers } from "next/headers"; import { getOptionalReferendumSiteContent } from "@/content/referendum-sites"; -import { ReferendumStepperPage } from "@/components/referendum/ReferendumStepperPage"; +import { TreatyContent } from "@/components/treaty/TreatyContent"; import { getRouteMetadata, getSiteMetadata } from "@/lib/metadata"; import { getReferendumPageContent } from "@/lib/referendum-content.server"; import { ROUTES, treatyLink } from "@/lib/routes"; @@ -22,29 +22,24 @@ export async function generateMetadata(): Promise { return getRouteMetadata(treatyLink); } -interface TreatyPageProps { - searchParams: Promise>; -} - -export default async function TreatyPage({ searchParams }: TreatyPageProps) { - const params = await searchParams; - const hdrs = await headers(); - const site = getSiteFromHeaders(hdrs); - const referralCode = typeof params.ref === "string" ? params.ref : null; - const treatyDashboardUrl = - site.primaryReferendumSlug ? "/dashboard?welcome=1" : undefined; - const referendumContent = await getReferendumPageContent(TREATY_REFERENDUM_SLUG); +/** + * `/treaty` — the lightweight "skim and sign" surface. Previously rendered + * the full multi-step stepper (prelude → grandma → apocalypse → slides → + * vote) which buried the signature box behind a forced narrative. Reverted + * to the document-then-sign layout: one short call-to-action on top, the + * treaty body in the middle, the signature box at the bottom. + */ +export default async function TreatyPage() { + const referendumContent = await getReferendumPageContent( + TREATY_REFERENDUM_SLUG, + ); return ( -
- + -
+ ); } From 1aeebfe2fdcafac2c752d8458e854bdfa4ae903a Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 11:53:54 -0500 Subject: [PATCH 25/88] Add CI retries to Playwright to absorb CSRF flakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tasks-index-auth` has hit `apiRequestContext.get: read ECONNRESET` on `/api/auth/csrf` three times in one day — once per session and always on the first auth-after-sign-in fetch. The visual review job fails the whole PR over the one flaky test even though the other 61 pass. `retries: 2` in CI lets Playwright re-run a failed test up to 2 times. Each retry uploads its own trace + screenshot on success, so a real failure (e.g. layout regression) still surfaces — it just has to fail all 3 attempts. Local dev stays at 0 retries so flakes aren't masked during iteration. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/playwright.config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/web/playwright.config.ts b/packages/web/playwright.config.ts index 33369b45b..7ca110e8b 100644 --- a/packages/web/playwright.config.ts +++ b/packages/web/playwright.config.ts @@ -27,7 +27,12 @@ const reporter: "html" | ReporterDescription[] = enableArgosReporter export default defineConfig({ testDir: "./e2e", fullyParallel: true, - retries: 0, + // 2 retries in CI: the visual-regression suite has hit transient + // `ECONNRESET` on `/api/auth/csrf` three times in one day (always on + // `tasks-index-auth` — the first request after sign-in occasionally + // drops the connection). Retries clear the flake without papering over + // real failures — each retry uploads its own trace + screenshot. + retries: isCI ? 2 : 0, workers: isCI ? 4 : 4, reporter, timeout: 120_000, From 8454192e0eeb3c06cd70dd0b112581fd800eed81 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 11 May 2026 11:56:12 -0500 Subject: [PATCH 26/88] =?UTF-8?q?Rewrite=20DashboardShareCard=20intro=20?= =?UTF-8?q?=E2=80=94=20Humanity=20Manager=20+=20apocalypse=20math?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous intro was three lines of slogan-y blather: > Send to two humans you love > Each voter who recruits two more is the campaign. > 2 → 4 → 8 → 16 → 32 doubling rounds → 4,300,000,000 humans reached. The middle sentence is the offender — reads like a campaign-strategy deck talking to itself ("the campaign is X" is a marketer sentence, not a Wishonia sentence). The doubling-rounds line tries to motivate but arrives before the user has any stake in the math. Replace with three short paragraphs that follow the Wishonia voice: 1. Eyebrow + role assignment — "You have been promoted to Humanity Manager at Earth Optimization Services LLC. Responsible for 8B humans. First task: get them to ratify the 1% Treaty." Gives the user a frame for why they're holding this share box. 2. The trade — "Earth owns 12,200 warheads. 100 ends civilization. That is 122 apocalypses on the shelf. Spend one apocalypse on 12.3× more clinical trials. Disease eradication: 443 years → 36." Data-first. Numbers. The actual ask. 3. Method — "Send the message below to two humans you love. They send to two. 32 rounds reaches every adult on Earth." The doubling math now justifies *why two humans*, not as a standalone slogan. Share-message textarea + copy button unchanged — the textbox still holds the forward-friendly love+threat+30-seconds message that the recipient reads. The intro persuades the SENDER; the textbox persuades the recipient. Different audiences. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashboard/DashboardShareCard.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/web/src/components/dashboard/DashboardShareCard.tsx b/packages/web/src/components/dashboard/DashboardShareCard.tsx index 1fd94f278..54c6b6ba4 100644 --- a/packages/web/src/components/dashboard/DashboardShareCard.tsx +++ b/packages/web/src/components/dashboard/DashboardShareCard.tsx @@ -53,14 +53,28 @@ export function DashboardShareCard({ referralUrl }: DashboardShareCardProps) { return (

- Send to two humans you love + Humanity Manager · Assignment 1

- Each voter who recruits two more is the campaign. + Trade one apocalypse for 12.3× more clinical trials.

-

- 2 → 4 → 8 → 16 → 32 doubling rounds → 4,300,000,000 humans reached. -

+
+

+ You have been promoted to Humanity Manager at Earth + Optimization Services LLC. Responsible for 8,000,000,000 humans. + First task: get them to ratify the 1% Treaty. +

+

+ Earth owns 12,200 nuclear warheads. 100 of them ends + civilization. That is 122 apocalypses on the shelf. Spend one + apocalypse on 12.3× more clinical trials and the disease- + eradication timeline collapses from 443 years to 36. +

+

+ To get there: send the message below to two humans you love. + They send it to two. 32 rounds reaches every adult on Earth. +

+