@reddoorla/maintenance 0.88.2 → 0.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{announce-GKD5YJNB.js → announce-OJFAS5JT.js} +6 -6
- package/dist/{blux-DN4MI532.js → blux-EATHOHKK.js} +5 -5
- package/dist/{chunk-7UAYMAKR.js → chunk-3VQWSE3F.js} +4 -4
- package/dist/{chunk-A2UNJIKH.js → chunk-5PWB3JHJ.js} +6 -4
- package/dist/chunk-5PWB3JHJ.js.map +1 -0
- package/dist/{chunk-K6SLF6GI.js → chunk-62NYED6E.js} +6 -6
- package/dist/{chunk-B23FFWU4.js → chunk-AEXJDCJN.js} +96 -19
- package/dist/{chunk-B23FFWU4.js.map → chunk-AEXJDCJN.js.map} +1 -1
- package/dist/{chunk-7FFUBF4I.js → chunk-AX22DH6S.js} +2 -2
- package/dist/{chunk-7TMWBXX7.js → chunk-DXKF552B.js} +269 -7
- package/dist/chunk-DXKF552B.js.map +1 -0
- package/dist/{chunk-6I6L3CMX.js → chunk-KGUKL3CV.js} +2 -2
- package/dist/{chunk-ULHPALI5.js → chunk-NDCOOI3V.js} +2 -2
- package/dist/{chunk-GQDDWYU2.js → chunk-QBAAZY7W.js} +1 -1
- package/dist/{chunk-GQDDWYU2.js.map → chunk-QBAAZY7W.js.map} +1 -1
- package/dist/{chunk-KMCZA7CH.js → chunk-QLIUDTY6.js} +2 -2
- package/dist/{chunk-XP5WM2LV.js → chunk-SKI23ZZH.js} +4 -4
- package/dist/chunk-SVAAONYB.js +21 -0
- package/dist/chunk-SVAAONYB.js.map +1 -0
- package/dist/{chunk-DGZWFI2G.js → chunk-VBEKL445.js} +17 -8
- package/dist/chunk-VBEKL445.js.map +1 -0
- package/dist/{chunk-ANJNTAMM.js → chunk-ZOCJBJQV.js} +4 -2
- package/dist/{chunk-J3WEZGIN.js → chunk-ZRI5VRPG.js} +4 -4
- package/dist/cli/bin.js +27 -18
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +5 -5
- package/dist/{db-V6UIS433.js → db-DAJX7NNW.js} +51 -10
- package/dist/db-DAJX7NNW.js.map +1 -0
- package/dist/{digest-2JEAEJK6.js → digest-T6TAZNLF.js} +73 -16
- package/dist/digest-T6TAZNLF.js.map +1 -0
- package/dist/digest-collectors-NI2M3UTC.js +50 -0
- package/dist/digest-state-3KHZF7GD.js +50 -0
- package/dist/digest-state-3KHZF7GD.js.map +1 -0
- package/dist/{dump-EPL7AZFR.js → dump-QK7LNOGC.js} +14 -1
- package/dist/{dump-EPL7AZFR.js.map → dump-QK7LNOGC.js.map} +1 -1
- package/dist/{email-SL7464YI.js → email-LJQT5J7F.js} +6 -7
- package/dist/{email-SL7464YI.js.map → email-LJQT5J7F.js.map} +1 -1
- package/dist/{ensure-site-FU6527OU.js → ensure-site-NSNY2QHN.js} +2 -2
- package/dist/{fleet-state-R5LU7IC3.js → fleet-state-3LQUQHYD.js} +2 -2
- package/dist/{forms-notify-target-SGDGXXK5.js → forms-notify-target-LYSKME5L.js} +2 -2
- package/dist/freeze-A6MUCZU4.js +9 -0
- package/dist/{github-signals-GWGCKEE2.js → github-signals-4YML6NY5.js} +4 -4
- package/dist/{health-mirror-GCDN7BJL.js → health-mirror-7LKC7Q5Z.js} +5 -5
- package/dist/index.d.ts +19 -4
- package/dist/index.js +29 -28
- package/dist/index.js.map +1 -1
- package/dist/{init-FGTYNGUI.js → init-NHKADHB6.js} +7 -7
- package/dist/{launch-5H7LPEGP.js → launch-VQJ4Z35C.js} +6 -6
- package/dist/{orchestrate-JNKCWU5K.js → orchestrate-6HROYTM6.js} +4 -4
- package/dist/{parity-NBYZUBFU.js → parity-623WNFZG.js} +3 -3
- package/dist/{pipeline-WYTXSLHX.js → pipeline-MQJD4CKN.js} +6 -2
- package/dist/{preflight-IMTYDDD4.js → preflight-LIN5NCYU.js} +5 -5
- package/dist/{prismic-ci-MY2U6NN7.js → prismic-ci-PZ5LCPWT.js} +4 -4
- package/dist/{prismic-models-NJN5JORF.js → prismic-models-6OYYZUR5.js} +3 -3
- package/dist/prospect/types.d.ts +27 -0
- package/dist/{prospect-audit-JMBRVXSG.js → prospect-audit-DBBXL7EG.js} +8 -6
- package/dist/prospect-audit-DBBXL7EG.js.map +1 -0
- package/dist/recipes/sync-configs.js +2 -2
- package/dist/{render-VX72AESY.js → render-P3SBUFVB.js} +3 -3
- package/dist/{render-from-row-DOYJDO2L.js → render-from-row-P5CWM3MQ.js} +3 -3
- package/dist/render-from-row-P5CWM3MQ.js.map +1 -0
- package/dist/{report-TALY5P3L.js → report-2N4PXO3L.js} +33 -17
- package/dist/report-2N4PXO3L.js.map +1 -0
- package/dist/{report-mirror-AAK3MXHY.js → report-mirror-3ENU6OOK.js} +5 -5
- package/dist/{selftest-NZTR4SM2.js → selftest-CUX2FIQD.js} +7 -7
- package/dist/{site-mirror-Q763PLGG.js → site-mirror-RQTEAID5.js} +5 -5
- package/dist/{sync-GBFV45WX.js → sync-ESP5ZQUU.js} +3 -3
- package/dist/{sync-configs-34JIA6KZ.js → sync-configs-LVBITPLU.js} +3 -3
- package/dist/usage-UHKU76VK.js +147 -0
- package/dist/usage-UHKU76VK.js.map +1 -0
- package/dist/{write-audits-to-airtable-OBHGR7DQ.js → write-audits-to-airtable-K352LZQY.js} +3 -3
- package/dist/write-audits-to-airtable-K352LZQY.js.map +1 -0
- package/package.json +1 -1
- package/dist/chunk-7TMWBXX7.js.map +0 -1
- package/dist/chunk-A2UNJIKH.js.map +0 -1
- package/dist/chunk-DGZWFI2G.js.map +0 -1
- package/dist/chunk-RUPPWAD6.js +0 -7
- package/dist/chunk-RUPPWAD6.js.map +0 -1
- package/dist/db-V6UIS433.js.map +0 -1
- package/dist/digest-2JEAEJK6.js.map +0 -1
- package/dist/digest-state-CRZRNGDI.js +0 -25
- package/dist/digest-state-CRZRNGDI.js.map +0 -1
- package/dist/prospect-audit-JMBRVXSG.js.map +0 -1
- package/dist/report-TALY5P3L.js.map +0 -1
- /package/dist/{announce-GKD5YJNB.js.map → announce-OJFAS5JT.js.map} +0 -0
- /package/dist/{blux-DN4MI532.js.map → blux-EATHOHKK.js.map} +0 -0
- /package/dist/{chunk-7UAYMAKR.js.map → chunk-3VQWSE3F.js.map} +0 -0
- /package/dist/{chunk-K6SLF6GI.js.map → chunk-62NYED6E.js.map} +0 -0
- /package/dist/{chunk-7FFUBF4I.js.map → chunk-AX22DH6S.js.map} +0 -0
- /package/dist/{chunk-6I6L3CMX.js.map → chunk-KGUKL3CV.js.map} +0 -0
- /package/dist/{chunk-ULHPALI5.js.map → chunk-NDCOOI3V.js.map} +0 -0
- /package/dist/{chunk-KMCZA7CH.js.map → chunk-QLIUDTY6.js.map} +0 -0
- /package/dist/{chunk-XP5WM2LV.js.map → chunk-SKI23ZZH.js.map} +0 -0
- /package/dist/{chunk-ANJNTAMM.js.map → chunk-ZOCJBJQV.js.map} +0 -0
- /package/dist/{chunk-J3WEZGIN.js.map → chunk-ZRI5VRPG.js.map} +0 -0
- /package/dist/{fleet-state-R5LU7IC3.js.map → digest-collectors-NI2M3UTC.js.map} +0 -0
- /package/dist/{ensure-site-FU6527OU.js.map → ensure-site-NSNY2QHN.js.map} +0 -0
- /package/dist/{orchestrate-JNKCWU5K.js.map → fleet-state-3LQUQHYD.js.map} +0 -0
- /package/dist/{forms-notify-target-SGDGXXK5.js.map → forms-notify-target-LYSKME5L.js.map} +0 -0
- /package/dist/{parity-NBYZUBFU.js.map → freeze-A6MUCZU4.js.map} +0 -0
- /package/dist/{github-signals-GWGCKEE2.js.map → github-signals-4YML6NY5.js.map} +0 -0
- /package/dist/{health-mirror-GCDN7BJL.js.map → health-mirror-7LKC7Q5Z.js.map} +0 -0
- /package/dist/{init-FGTYNGUI.js.map → init-NHKADHB6.js.map} +0 -0
- /package/dist/{launch-5H7LPEGP.js.map → launch-VQJ4Z35C.js.map} +0 -0
- /package/dist/{pipeline-WYTXSLHX.js.map → orchestrate-6HROYTM6.js.map} +0 -0
- /package/dist/{render-VX72AESY.js.map → parity-623WNFZG.js.map} +0 -0
- /package/dist/{render-from-row-DOYJDO2L.js.map → pipeline-MQJD4CKN.js.map} +0 -0
- /package/dist/{preflight-IMTYDDD4.js.map → preflight-LIN5NCYU.js.map} +0 -0
- /package/dist/{prismic-ci-MY2U6NN7.js.map → prismic-ci-PZ5LCPWT.js.map} +0 -0
- /package/dist/{prismic-models-NJN5JORF.js.map → prismic-models-6OYYZUR5.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-OBHGR7DQ.js.map → render-P3SBUFVB.js.map} +0 -0
- /package/dist/{report-mirror-AAK3MXHY.js.map → report-mirror-3ENU6OOK.js.map} +0 -0
- /package/dist/{selftest-NZTR4SM2.js.map → selftest-CUX2FIQD.js.map} +0 -0
- /package/dist/{site-mirror-Q763PLGG.js.map → site-mirror-RQTEAID5.js.map} +0 -0
- /package/dist/{sync-GBFV45WX.js.map → sync-ESP5ZQUU.js.map} +0 -0
- /package/dist/{sync-configs-34JIA6KZ.js.map → sync-configs-LVBITPLU.js.map} +0 -0
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/recipes/index.ts","../src/dashboard/favicon.ts","../src/dashboard/auth/render.ts","../src/dashboard/relative-time.ts","../src/dashboard/onboarding.ts","../src/dashboard/submission-view.ts","../src/dashboard/site-details.ts","../src/dashboard/render.ts","../src/dashboard/fleet-cockpit.ts","../src/dashboard/fleet-browse-render.ts","../src/dashboard/fleet-render.ts","../src/dashboard/basic-auth.ts","../src/dashboard/auth/signing.ts","../src/dashboard/auth/session.ts","../src/dashboard/prospect-audit-trigger.ts"],"sourcesContent":["import type { RecipeName } from \"../types.js\";\nimport { syncConfigs, type SyncConfigsOptions } from \"./sync-configs.js\";\nimport { bumpDeps, type BumpDepsOptions } from \"./bump-deps.js\";\nimport { upgradeSvelte4to5, type UpgradeSvelte4to5Options } from \"./svelte-5/index.js\";\nimport { svelteCodemods } from \"./svelte-codemods.js\";\nimport { convertToPnpm, type ConvertToPnpmOptions } from \"./convert-to-pnpm.js\";\nimport { onboard, type OnboardOptions, type OnboardAudit } from \"./onboard.js\";\nimport { a11yFixturesPage } from \"./a11y-fixtures-page/index.js\";\nimport { healthEndpoint } from \"./health-endpoint/index.js\";\nimport { smokeSuite } from \"./smoke-suite/index.js\";\nimport {\n init,\n DEFAULT_INIT_STEPS,\n type InitOptions,\n type InitResult,\n type InitStep,\n type InitStepResult,\n} from \"./init.js\";\n\nexport {\n syncConfigs,\n bumpDeps,\n upgradeSvelte4to5,\n svelteCodemods,\n convertToPnpm,\n onboard,\n a11yFixturesPage,\n healthEndpoint,\n smokeSuite,\n init,\n DEFAULT_INIT_STEPS,\n};\nexport type {\n SyncConfigsOptions,\n BumpDepsOptions,\n UpgradeSvelte4to5Options,\n ConvertToPnpmOptions,\n OnboardOptions,\n OnboardAudit,\n InitOptions,\n InitResult,\n InitStep,\n InitStepResult,\n};\n\nexport const ALL_RECIPE_NAMES: RecipeName[] = [\n \"sync-configs\",\n \"bump-deps\",\n \"svelte-4-to-5\",\n \"svelte-codemods\",\n \"convert-to-pnpm\",\n \"onboard\",\n \"a11y-fixtures-page\",\n \"health-endpoint\",\n \"smoke-suite\",\n \"self-updating\",\n \"prismic-ci\",\n \"init\",\n];\n\nexport function isRecipeName(value: string): value is RecipeName {\n return (ALL_RECIPE_NAMES as string[]).includes(value);\n}\n","// The reddoor mark (32×32 PNG, ~554 B) inlined as a data-URI favicon. The\n// dashboard pages are rendered by Netlify functions with no static-asset\n// pipeline, so embedding the icon in the <head> brands every page without a\n// second request or a hosted file. Source: reddoor-website/static/favicon.png.\nconst REDDOOR_FAVICON_PNG_BASE64 =\n \"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAACXBIWXMAAAAnAAAAJwEqCZFPAAAAk1BMVEVHcEzkGTfjGDbjGTfjGTfjGTfjGTfjGDbjGDbjGTfjGTfjGTfkGTfjGTfjGDbjGDbkGTfjGDbjGDbjGTfjGTfjGDbjGTfjGDbjGTfjGDbjGTfjGTfjGDbjGDbjGDbjGDbjGTfjGTbjGDbjGDbjGDbjGDbjGTfjGTfjGTfjGDbjGDbjGTfjGDbjGDbjGTfjGTbkGTfxxbwzAAAALXRSTlMA4DABMOD8cOBwDltwi+bFLMlk54/BDVPk/I61/u2cFaBw9nqO1izBIQE+Becdo6eEAAABBElEQVQ4y91SCVYCMQwt4kxbcEHFfUFFcMGf9P6nM0kXEPEAmtdJs+cnU+f+He2fIIEAYuVgkmvDfTG9Rh+6LoRBUBqE7gi09l9eAeeTrZJoFd5uQWfj4VbPvRqwmj9zfzP6CYpyi48F6HW5A3WuMHsEPsfvO8cSkMPTe+pfRr/MTckdg+4enqL3PkYf/YFIqiiP8VAw6EZkH6QEO0xJLUhmdbY5+TjHkCVohprlcpzlnJw9GlNkrZC16uCmWIaAZItNrT4xV0T2yxrIWoNKy4rdVXOq0MycWp4r43FOMQWciibMYaOHCXKSPhapqTupGFAnwHoVuUUZq7jaSkqDb0/uD9MXqvJMDtU7lL0AAAAASUVORK5CYII=\";\n\n/** A ready-to-interpolate `<link rel=\"icon\">` carrying the reddoor mark. */\nexport const FAVICON_LINK = `<link rel=\"icon\" type=\"image/png\" href=\"data:image/png;base64,${REDDOOR_FAVICON_PNG_BASE64}\" />`;\n","import { escapeHtml } from \"../../util/html.js\";\nimport { FAVICON_LINK } from \"../favicon.js\";\n\n/**\n * The sign-in page, and the \"signed in as …\" fragment the dashboard pages\n * carry in their headers.\n *\n * A page rather than a bare redirect to Google, on purpose: a stale bookmark\n * should not fling someone at an account chooser with no explanation, and\n * \"that account is not authorised\" needs somewhere to be said.\n */\n\n/**\n * Failure codes the login page knows how to explain.\n *\n * Query text is never echoed into the page — an unknown code falls back to the\n * generic message. Same discipline as `STAGE_FAILED_MESSAGE` in the prospect\n * report: a fixed lookup, so a crafted `?error=` cannot put attacker-chosen\n * prose (or markup) in front of an operator.\n */\nexport const LOGIN_ERROR_MESSAGE: Record<string, string> = {\n state: \"That sign-in link expired or was already used. Try again.\",\n exchange: \"Google could not complete the sign-in. Try again.\",\n userinfo: \"Google signed you in but would not share a verified address.\",\n unverified: \"That Google account's email address is not verified.\",\n config: \"Sign-in is not configured on this deploy.\",\n};\n\nconst GENERIC_ERROR = \"Sign-in did not complete. Try again.\";\n\n/** Resolve a code to a message, never trusting the code itself as text. */\nexport function loginErrorMessage(code: string | null | undefined): string | null {\n if (!code) return null;\n return LOGIN_ERROR_MESSAGE[code] ?? GENERIC_ERROR;\n}\n\nconst STYLES = `\n:root { color-scheme: light dark; }\nbody { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 26rem; margin: 4rem auto; padding: 0 1rem; color: #1a1a1a; }\n@media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } .card { border-color: #333; } }\nh1 { margin: 0 0 0.25rem; font-size: 1.5rem; }\n.meta { color: #666; margin: 0 0 1.5rem; }\n.card { border: 1px solid #ddd; border-radius: 10px; padding: 1.5rem; }\n.google { display: block; text-align: center; font: inherit; font-weight: 600; padding: 0.7rem 1rem; border-radius: 8px; border: 1px solid #4285f4; background: #4285f4; color: #fff; text-decoration: none; }\n.google:hover { background: #3367d6; border-color: #3367d6; }\n.notice { padding: 0.7rem 0.9rem; border-radius: 8px; margin-bottom: 1.25rem; font-size: 0.92rem; }\n.notice-denied { border: 1px solid #b00; color: #b00; background: #bb000010; }\n.notice-error { border: 1px solid #c80; color: #a60; background: #cc880010; }\n.alt { margin: 1.25rem 0 0; font-size: 0.85rem; color: #666; text-align: center; }\n`;\n\nexport type LoginPageInput = {\n /** Where to send the operator once signed in. Already sanitised by\n * `safeReturnTo` — this renderer escapes but does not validate. */\n returnTo: string;\n /** The account authenticated but is not on the allowlist. */\n denied?: boolean;\n /** A failure code from {@link LOGIN_ERROR_MESSAGE}. */\n errorCode?: string | null;\n /** Whether `DASHBOARD_PASSWORD` is set on this deploy. Controls only whether\n * the shared-password link is offered — it gates nothing. */\n basicFallbackAvailable: boolean;\n};\n\nexport function renderLoginPageHtml(input: LoginPageInput): string {\n const returnTo = encodeURIComponent(input.returnTo);\n const error = loginErrorMessage(input.errorCode);\n\n // \"Try a different account\" restarts with prompt=select_account. Without it,\n // someone whose browser has cached the wrong Google account is stuck being\n // silently re-authenticated as the account that just got refused.\n const notice = input.denied\n ? `<div class=\"notice notice-denied\">That Google account is not on the cockpit's list. Ask Tucker to add it, or <a href=\"/auth/login?returnTo=${returnTo}&switch=1\">try a different account</a>.</div>`\n : error\n ? `<div class=\"notice notice-error\">${escapeHtml(error)}</div>`\n : \"\";\n\n const alt = input.basicFallbackAvailable\n ? `<p class=\"alt\"><a href=\"/auth/basic?returnTo=${returnTo}\">Use the shared password instead</a><br />Deploy previews only — Google sign-in needs the production URL.</p>`\n : \"\";\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"robots\" content=\"noindex, nofollow\" />\n ${FAVICON_LINK}\n <title>Sign in — Reddoor cockpit</title>\n <style>${STYLES}</style>\n</head>\n<body>\n <h1>Reddoor cockpit</h1>\n <p class=\"meta\">Operator access only.</p>\n <div class=\"card\">\n ${notice}\n <a class=\"google\" href=\"/auth/login?returnTo=${returnTo}&start=1\">Sign in with Google</a>\n ${alt}\n </div>\n</body>\n</html>`;\n}\n\n/**\n * The \"signed in as … · sign out\" fragment for a dashboard page header.\n *\n * There is no shared page shell — five renderers each emit their own\n * `<!doctype html>` — so this is a fragment each one drops in rather than a\n * layout. Converting the dashboard to a framework with a real layout is\n * tracked separately (#582); until then, five one-line insertions is the\n * honest cost.\n *\n * Returns empty for a session with no identity (the shared-password fallback):\n * showing \"signed in as cockpit\" would dress an anonymous shared credential up\n * as a person.\n */\nexport function renderAuthChrome(email: string | null | undefined): string {\n if (!email) return \"\";\n // Styled inline rather than by class: the four dashboard pages each carry\n // their own <style> block, and a fragment that depends on a rule existing in\n // all four is a fragment that silently loses its styling in whichever one\n // gets missed.\n return `<div style=\"float:right;font-size:0.8rem;color:#666\">${escapeHtml(email)} · <a href=\"/auth/logout\">sign out</a></div>`;\n}\n","/** Render an absolute timestamp as a coarse \"Xd ago\" relative string for the\n * fleet card. Takes an explicit `now` for testability; defaults to wall clock\n * for callers (the Netlify function). Returns \"—\" for null / unparseable. */\nexport function relativeTimeFromNow(iso: string | null, now: Date = new Date()): string {\n if (!iso) return \"—\";\n const t = Date.parse(iso);\n if (Number.isNaN(t)) return \"—\";\n\n const seconds = Math.max(0, Math.floor((now.getTime() - t) / 1000));\n if (seconds < 60) return \"just now\";\n\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes}m ago`;\n\n const hours = Math.floor(minutes / 60);\n if (hours < 24) return `${hours}h ago`;\n\n const days = Math.floor(hours / 24);\n if (days < 7) return `${days}d ago`;\n\n const weeks = Math.floor(days / 7);\n if (weeks < 4) return `${weeks}w ago`;\n\n const months = Math.floor(days / 30);\n return `${months}mo ago`;\n}\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\n\nexport type OnboardingStatus = {\n score: number;\n total: 4;\n checks: {\n firstAudit: boolean;\n recipients: boolean;\n schedule: boolean;\n poc: boolean;\n };\n};\n\nfunction isNonEmpty(s: string | null | undefined): boolean {\n return typeof s === \"string\" && s.trim().length > 0;\n}\n\n/** Four-point onboarding signal for the fleet card. A site is \"fully onboarded\"\n * when it has been audited at least once, has a To-recipient for monthly\n * reports, has a maintenance schedule that isn't \"None\", and has a named POC. */\nexport function onboardingStatus(row: WebsiteRow): OnboardingStatus {\n const checks = {\n firstAudit: isNonEmpty(row.lastLighthouseAuditAt),\n recipients: isNonEmpty(row.reportRecipientsTo),\n schedule: row.maintenanceFreq !== \"None\",\n poc: isNonEmpty(row.pointOfContact),\n };\n const score = Object.values(checks).filter(Boolean).length;\n return { score, total: 4, checks };\n}\n\n/** Human label for each onboarding check, in canonical check order. Used by the\n * dashboards to spell out which signals a partially-onboarded site is missing\n * (cockpit setup-chip tooltip + per-site setup line). */\nexport const ONBOARDING_LABELS: Record<keyof OnboardingStatus[\"checks\"], string> = {\n firstAudit: \"First audit\",\n recipients: \"Report recipients\",\n schedule: \"Maintenance schedule\",\n poc: \"Point of contact\",\n};\n\n/** The labels of the onboarding checks this site has NOT satisfied, in check\n * order. Empty array → fully onboarded. */\nexport function missingOnboarding(row: WebsiteRow): string[] {\n const { checks } = onboardingStatus(row);\n return (Object.keys(ONBOARDING_LABELS) as Array<keyof typeof ONBOARDING_LABELS>)\n .filter((key) => !checks[key])\n .map((key) => ONBOARDING_LABELS[key]);\n}\n","import type { SubmissionRow } from \"../reports/submission-row.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\n\n/** Render a submission's `extraFields` JSON as a key/value list; on parse failure\n * show the raw string (escaped) rather than dropping it. Returns \"\" when blank. */\nfunction extraFieldsList(raw: string | null): string {\n if (!raw || raw.trim() === \"\") return \"\";\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return `<div class=\"subm-kv\"><span class=\"k\">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n return `<div class=\"subm-kv\"><span class=\"k\">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;\n }\n const rows = Object.entries(parsed as Record<string, unknown>)\n .map(\n ([k, v]) =>\n `<div class=\"subm-kv\"><span class=\"k\">${escapeHtml(k)}</span> ${escapeHtml(String(v))}</div>`,\n )\n .join(\"\");\n return rows;\n}\n\n/** A submission belongs in the per-site strip unless it was auto-filtered as spam\n * (status \"spam_auto\"): auto-spam is reviewed only on /submissions, so it never\n * crowds the real-lead window. PURE. */\nexport function isVisibleInStrip(s: SubmissionRow): boolean {\n return s.status !== \"spam_auto\";\n}\n\n/** Split a comma-joined spam_reason string into its reason tokens. */\nfunction spamReasonTokens(reason: string | null | undefined): string[] {\n return (reason ?? \"\")\n .split(\",\")\n .map((t) => t.trim())\n .filter((t) => t !== \"\");\n}\n\n/** The fan-out tokens that did NOT succeed (see ingest.ts for the grammar: one\n * `<destination>:<outcome>` token per attempt, `ok` being the only good outcome).\n * A failure here means the signup never reached that destination — the row itself\n * still looks perfectly healthy, which is exactly why it needs surfacing. PURE. */\nexport function fanoutFailures(fanoutStatus: string | null | undefined): string[] {\n return (fanoutStatus ?? \"\")\n .split(\",\")\n .map((t) => t.trim())\n .filter((t) => t !== \"\" && !t.endsWith(\":ok\"));\n}\n\n/** The inline chip shows at most this many reason tokens; a long classifier trail\n * must not blow up the summary line. The full list stays in the badge tooltip and\n * the expanded Spam row. */\nconst REASON_CHIP_MAX_TOKENS = 3;\n\nfunction reasonChipText(tokens: string[]): string {\n const shown = tokens.slice(0, REASON_CHIP_MAX_TOKENS).join(\" · \");\n const more = tokens.length - REASON_CHIP_MAX_TOKENS;\n return more > 0 ? `${shown} +${more} more` : shown;\n}\n\n/** A submission row's content WITHOUT the <li> wrapper — for callers that need\n * to compose their own <li> (the /submissions page wraps rows with a site link;\n * ul may only contain li, so the wrapper must own the li). */\nexport function renderSubmissionRowInner(s: SubmissionRow): string {\n const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : \"—\";\n const type = escapeHtml(s.formType);\n const who = escapeHtml(s.name || \"(no name)\");\n const email = escapeHtml(s.email || \"\");\n const status = escapeHtml(s.status);\n const id = escapeHtml(s.id);\n const url = `/api/submissions/${encodeURIComponent(s.id)}/status`;\n const btn = (label: string, action: string) =>\n `<button class=\"subm-status\" data-id=\"${id}\" data-status=\"${action}\" data-url=\"${url}\">${label}</button>`;\n // The reasons must be VISIBLE text, not only the badge tooltip — tooltips never\n // render on iPad/phone, and the requireTurnstile canary review hinges on telling\n // \"turnstile-required-absent\" apart from content-classifier reasons (2026-07-15).\n const reasonTokens = spamReasonTokens(s.spamReason);\n const reasonChip =\n reasonTokens.length > 0\n ? ` <span class=\"subm-reasons\">${escapeHtml(reasonChipText(reasonTokens))}</span>`\n : \"\";\n const provenance =\n s.status === \"spam_auto\" && s.spamScore !== null && s.spamScore !== undefined\n ? ` <span class=\"subm-provenance\" title=\"${escapeHtml(s.spamReason ?? \"\")}\">auto-spam · ${escapeHtml(String(s.spamScore))}</span>${reasonChip}`\n : \"\";\n const recover = s.status === \"spam_auto\" ? btn(\"Not spam → new\", \"new\") : \"\";\n // Bounce marker (2026-07-16): visible summary-line text, not just the Notify\n // detail row — a bounced notification means the LEAD never reached the client\n // even though the row looks healthy (same visible-not-tooltip rule as the\n // reason chip above). The resend-webhook flips notifyStatus to \"bounced\".\n const bounceChip =\n s.notifyStatus === \"bounced\"\n ? ` <span class=\"subm-bounce\" title=\"The lead notification email bounced — the point-of-contact address may be dead\">notify bounced</span>`\n : \"\";\n // Fan-out marker (2026-07-31): a newsletter signup that never reached the site\n // webhook or the Mailchimp audience is otherwise INDISTINGUISHABLE from one that\n // did — same status pill, same notify=sent. Visible on the collapsed line for the\n // same reason as the bounce chip; only the failing destinations are named, since\n // the successes are what the expanded Fan-out row is for.\n const fanoutBad = fanoutFailures(s.fanoutStatus);\n const fanoutChip =\n fanoutBad.length > 0\n ? ` <span class=\"subm-fanout-fail\" title=\"This signup did not reach every configured destination\">fan-out: ${escapeHtml(fanoutBad.join(\" · \"))}</span>`\n : \"\";\n\n // One detail row per present field; absent fields are omitted (no blank rows).\n const kv = (label: string, value: string | number | null) =>\n value === null || value === \"\"\n ? \"\"\n : `<div class=\"subm-kv\"><span class=\"k\">${label}</span> ${escapeHtml(String(value))}</div>`;\n const sourceLink = s.sourceUrl\n ? `<div class=\"subm-kv\"><span class=\"k\">Source</span> <a href=\"${escapeHtml(safeUrl(s.sourceUrl))}\" rel=\"noopener noreferrer\">${escapeHtml(s.sourceUrl)}</a></div>`\n : \"\";\n const messageBlock = s.message\n ? `<div class=\"subm-kv\"><span class=\"k\">Message</span></div><div class=\"subm-msg\">${escapeHtml(s.message)}</div>`\n : \"\";\n // Full spam provenance in the detail block for ANY scored row (not just\n // spam_auto): a scored-but-delivered row is exactly what a near-threshold\n // classifier review needs to see.\n const hasScore = s.spamScore !== null && s.spamScore !== undefined;\n const spamDetail = hasScore\n ? reasonTokens.length > 0\n ? `score ${s.spamScore} — ${reasonTokens.join(\", \")}`\n : `score ${s.spamScore}`\n : reasonTokens.join(\", \");\n const details = [\n kv(\"Phone\", s.phone),\n messageBlock,\n sourceLink,\n kv(\"UTM\", s.utm),\n extraFieldsList(s.extraFields),\n kv(\"Spam\", spamDetail),\n kv(\"Notify\", s.notifyStatus),\n kv(\"Fan-out\", s.fanoutStatus ?? null),\n kv(\"Resend ID\", s.resendMessageId),\n kv(\"Submission #\", s.submissionId),\n ].join(\"\");\n\n return `<details>\n <summary class=\"subm-head\"><strong>${type}</strong> · ${who} <span class=\"muted\">${email}</span> <span class=\"pill subm-${status}\">${status}</span>${provenance}${bounceChip}${fanoutChip} <span class=\"muted\">${when}</span></summary>\n <div class=\"subm-detail\">${details}</div>\n </details>\n <div class=\"subm-actions\">${recover}${btn(\"Read\", \"read\")}${btn(\"Archive\", \"archived\")}${btn(\"Spam\", \"spam\")}</div>`;\n}\n\nexport function renderSubmissionRow(s: SubmissionRow): string {\n return `<li class=\"subm-item\">${renderSubmissionRowInner(s)}</li>`;\n}\n\n/** CSS rules for the submission list UI. Append to the page's <style> block. */\nexport const SUBMISSION_STYLES = `.subm-list { list-style: none; padding: 0; margin: 0; }\n.subm-item { padding: 0.6rem 0; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { .subm-item { border-color: #2a2a2a; } }\n.subm-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }\n.subm-msg { margin: 0.35rem 0; white-space: pre-wrap; }\n.subm-detail { padding: 0.35rem 0 0.2rem; }\n.subm-kv { font-size: 0.9rem; margin: 0.15rem 0; }\n.subm-kv .k { color: #888; margin-right: 0.4rem; }\nsummary.subm-head { cursor: pointer; }\n.subm-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }\nbutton.subm-status { font: inherit; padding: 0.25rem 0.7rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }\nbutton.subm-status:disabled { opacity: 0.6; cursor: default; }\n@media (pointer: coarse) { button.subm-status { padding: 0.55rem 0.9rem; } } /* >=40px tap target on touch devices */\n.spam-screen .spam-kv { font-size: 0.95rem; margin: 0.2rem 0; }\n.spam-screen .spam-kv .k { color: #888; display: inline-block; min-width: 11rem; }\n.pill.subm-new { background: #e8f0fe; color: #1a56db; }\n.pill.subm-read { background: #f0f0f0; color: #555; }\n.pill.subm-archived { background: #eee; color: #888; }\n.pill.subm-spam { background: #fdecea; color: #b00; }\n.pill.subm-spam_auto { background: #fff4e5; color: #a65a00; }\n.subm-provenance { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; background: #fff4e5; color: #a65a00; }\n.subm-bounce { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; background: #fdecea; color: #b00; font-weight: 700; }\n.subm-fanout-fail { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; background: #fdecea; color: #b00; font-weight: 700; }\n.subm-reasons { font-size: 0.72rem; color: #999; }\n.subm-viewall { font-size: 0.8rem; font-weight: normal; margin-left: 0.4rem; white-space: nowrap; }`;\n\n/** Client-side JS for the submission status triage buttons. Insert bare (no <script> wrapper). */\nexport const SUBMISSION_STATUS_SCRIPT = `document.querySelectorAll(\"button.subm-status\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n try {\n const res = await fetch(b.dataset.url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ status: b.dataset.status }),\n });\n b.textContent = res.ok ? \"✓\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });`;\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { parseNotifyRouting } from \"../reports/airtable/websites.js\";\nimport { CANONICAL_STATUSES, toAirtableStatus } from \"../reports/airtable/site-status.js\";\nimport type { AirtableCellValue } from \"../reports/airtable/websites.js\";\nimport { isHttpUrl } from \"../util/url.js\";\n\n/**\n * Status options the editor offers, expressed as the values Airtable actually\n * STORES — this dropdown writes straight into the \"Status\" single-select, so its\n * options must be options that column accepts.\n *\n * Since stage 3 (the retired option names deleted from the field, the alias map\n * deleted from the code) these ARE the canonical names, and `toAirtableStatus`\n * is the identity — so this list is exactly the six options the column holds.\n *\n * `render.ts` still preselects against `site.statusRaw` rather than\n * `site.status`. Those two now coincide for every value the single-select can\n * hold, so the distinction is dormant rather than load-bearing; it is kept\n * because the placeholder behaviour it produces — an unrecognized cell shows the\n * disabled \"— select —\" instead of being silently re-labelled — is what should\n * happen if a stale value ever reappears.\n */\nexport const SITE_STATUS_OPTIONS: readonly string[] = CANONICAL_STATUSES.map(toAirtableStatus);\nexport const FREQ_OPTIONS = [\"None\", \"Monthly\", \"Quarterly\", \"Yearly\"] as const;\n\n/**\n * The options the live `Accepted Watch Conditions` multi-select carries, read\n * off the base schema on 2026-08-25.\n *\n * Spelled out rather than derived, because the API CANNOT add an option to a\n * select — a PATCH with new choices returns 422, proven during the status\n * migration — so offering a value this field does not have would produce a write\n * Airtable rejects. The records API would create one as a `typecast` side\n * effect; that is the silent-option-creation hazard this codebase refuses\n * everywhere, and it is why an unknown condition is rejected rather than sent.\n *\n * KNOWN GAP, operator-owned: `fleet-cockpit.ts` also supports a\n * `turnstile-unverified` accept key, and this field has no option for it — so\n * that one condition cannot be accepted from the console. Adding the option is a\n * UI action in Airtable; nothing here can do it.\n */\nexport const WATCH_CONDITION_OPTIONS: readonly string[] = [\n \"Performance\",\n \"Accessibility\",\n \"Best Practices\",\n \"SEO\",\n \"stale repo\",\n \"no custom domain\",\n] as const;\n\ntype FieldKind =\n | \"text\"\n | \"email\"\n | \"emails\"\n | \"enum\"\n | \"gitrepo\"\n | \"url\"\n | \"date\"\n | \"notifyRouting\"\n | \"bool\"\n | \"multiselect\"\n | \"secret\";\nexport type EditableField = {\n column: string;\n kind: FieldKind;\n options?: readonly string[];\n maxLen?: number;\n};\n\n/**\n * The ONLY columns the dashboard editor may write. `column` is the EXACT Airtable\n * field name (note the lowercase / em-dash / misspelled ones), kept in lockstep\n * with `mapRow` in src/reports/airtable/websites.ts.\n */\nexport const EDITABLE_SITE_FIELDS: Record<string, EditableField> = {\n pointOfContact: { column: \"point of contact\", kind: \"email\" },\n reportRecipientsTo: { column: \"Report recipients (To)\", kind: \"emails\" },\n reportRecipientsCc: { column: \"Report recipients (CC)\", kind: \"emails\" },\n copyIntro: { column: \"Copy — Intro\", kind: \"text\", maxLen: 2000 },\n copyContact: { column: \"Copy — Contact\", kind: \"text\", maxLen: 2000 },\n copyFooter: { column: \"Copy — Footer\", kind: \"text\", maxLen: 2000 },\n searchQuery: { column: \"Search query\", kind: \"text\", maxLen: 500 },\n ga4PropertyId: { column: \"GA4 property ID\", kind: \"text\", maxLen: 500 },\n gitRepo: { column: \"Git repo\", kind: \"gitrepo\" },\n status: { column: \"Status\", kind: \"enum\", options: SITE_STATUS_OPTIONS },\n maintenanceFreq: { column: \"maintenence freq\", kind: \"enum\", options: FREQ_OPTIONS },\n testingFreq: { column: \"testing freq\", kind: \"enum\", options: FREQ_OPTIONS },\n // #539 Phase 4 — the fields the design lists as \"the eight nothing renders\n // today\". Kinds follow the LIVE Airtable column types, read off the base\n // schema rather than inferred from the reader: `maintenance day`/`testing day`\n // are `date`, `Notify Routing` is `multilineText` holding JSON, the rest are\n // `singleLineText`. All seven are string-valued, which is why they need no\n // change to `updateSiteField`. `Require Turnstile` (checkbox) and `Accepted\n // Watch Conditions` (multipleSelects) are NOT here — they cannot be written as\n // strings and need a typed writer first.\n //\n // `Mailchimp API Key` is deliberately absent too: it is a live credential, and\n // every field in this map is rendered back into the page carrying its stored\n // value (see `inputRow` in render.ts). It needs a write-only kind first.\n netlifyId: { column: \"Netlify ID\", kind: \"text\", maxLen: 200 },\n searchConsoleProperty: { column: \"Search Console property\", kind: \"text\", maxLen: 500 },\n mailchimpAudienceId: { column: \"Mailchimp Audience ID\", kind: \"text\", maxLen: 200 },\n newsletterWebhook: { column: \"Newsletter Webhook\", kind: \"url\" },\n maintenanceDay: { column: \"maintenance day\", kind: \"date\" },\n testingDay: { column: \"testing day\", kind: \"date\" },\n notifyRouting: { column: \"Notify Routing\", kind: \"notifyRouting\" },\n // The two non-text columns. They write a boolean and a string[] respectively,\n // which is why `updateSiteField` and `mirrorSiteField` take AirtableCellValue.\n requireTurnstile: { column: \"Require Turnstile\", kind: \"bool\" },\n acceptedWatchConditions: {\n column: \"Accepted Watch Conditions\",\n kind: \"multiselect\",\n options: WATCH_CONDITION_OPTIONS,\n },\n // A live credential. `secret` is what makes it safe to list here at all: the\n // renderer emits no value for it, and an empty submission leaves the stored\n // key alone instead of clearing it.\n mailchimpApiKey: { column: \"Mailchimp API Key\", kind: \"secret\", maxLen: 200 },\n};\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst ISO_DATE_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/**\n * A real calendar day in `YYYY-MM-DD`, not merely a parseable one.\n *\n * `new Date(\"2026-02-31\")` does not throw — it rolls over to 2 March. These\n * columns feed the next-due schedule, so accepting a rolled-over day would\n * silently reschedule a site to a date the operator never chose. The round-trip\n * comparison below is what rejects it: a day that rolled is not the day that\n * went in.\n */\nfunction isCalendarDate(v: string): boolean {\n if (!ISO_DATE_RE.test(v)) return false;\n const [y, m, d] = v.split(\"-\").map(Number) as [number, number, number];\n const dt = new Date(Date.UTC(y, m - 1, d));\n return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;\n}\n/** `owner/repo` shape. Exported for other dashboard surfaces that consume the\n * legacy free-text `Git repo` cell (e.g. trigger-renovate). */\nexport const REPO_RE = /^[\\w.-]+\\/[\\w.-]+$/;\n\n/**\n * Validate/normalize a raw value for a field kind. Returns the string to write, or\n * `null` when invalid. Empty (after trim) is allowed — it clears the cell — for\n * every kind EXCEPT `enum`, which must be one of its options.\n */\nexport function normalizeFieldValue(f: EditableField, raw: string): AirtableCellValue | null {\n const v = raw.trim();\n // Hard upper bound across every kind (text additionally enforces its own\n // tighter maxLen below) — a single absurdly long value can't reach Airtable.\n if (v.length > 2000) return null;\n switch (f.kind) {\n case \"enum\":\n return f.options!.includes(v) ? v : null;\n case \"email\":\n return v === \"\" ? \"\" : EMAIL_RE.test(v) ? v : null;\n case \"emails\": {\n if (v === \"\") return \"\";\n const parts = v\n .split(/[,\\n]/)\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n return parts.every((p) => EMAIL_RE.test(p)) ? parts.join(\", \") : null;\n }\n case \"gitrepo\":\n return v === \"\" ? \"\" : REPO_RE.test(v) ? v : null;\n case \"url\":\n // The SAME scheme allowlist the deployed-audit target uses. This value is\n // fetched server-side by the newsletter forwarder, so a `file://` or\n // `javascript:` cell is a local-file read or an injection sink, not a typo.\n return v === \"\" ? \"\" : isHttpUrl(v) ? v : null;\n case \"date\":\n return v === \"\" ? \"\" : isCalendarDate(v) ? v : null;\n case \"notifyRouting\":\n // Validated by the PRODUCTION reader, not by a second parser written to\n // agree with it. `parseNotifyRouting` returns null for anything it would\n // drop, so the editor cannot store a value that reads back as \"no\n // routing\" — which would look like a saved change while every form\n // notification kept going to the previous target.\n return v === \"\" ? \"\" : parseNotifyRouting(v) !== null ? v : null;\n case \"bool\":\n // A checkbox has no empty state, so \"\" is a malformed request rather than\n // a clear. Exactly two accepted spellings — anything else is refused\n // instead of guessed at, since guessing wrong here silently turns a site's\n // spam protection off.\n return v === \"true\" ? true : v === \"false\" ? false : null;\n case \"multiselect\": {\n if (v === \"\") return [];\n const parts = v\n .split(/[,\\n]/)\n .map((x) => x.trim())\n .filter(Boolean);\n return parts.every((p) => f.options!.includes(p)) ? parts : null;\n }\n case \"secret\":\n // NOTE the caller contract: `\"\"` here means \"leave unchanged\", and\n // `setSiteDetail` turns it into a no-op rather than a write. Every other\n // kind clears on empty; this one must not, because the field renders blank\n // on every page load (its value is never sent to the browser), so\n // clear-on-empty would let any unrelated save destroy a working key.\n return v.length <= (f.maxLen ?? 500) ? v : null;\n case \"text\":\n return v.length <= (f.maxLen ?? 500) ? v : null;\n }\n}\n\n/** Injected IO — the `.mts` binds these to a live Airtable base; tests bind fakes. */\nexport type SiteDetailDeps = {\n getSite: (slug: string) => Promise<WebsiteRow | null>;\n updateField: (recordId: string, column: string, value: AirtableCellValue) => Promise<void>;\n};\n\n/** Typed into a `secret` field to ERASE it. A sentinel rather than a new\n * control, because the secret input already fires its save listener on any\n * keystroke — so clearing needs no change to the inline dashboard script, the\n * one part of this page no test has ever executed (#591 shipped broken there).\n *\n * Underscore-wrapped and lowercase so it cannot collide with a real key: every\n * credential this field holds is a provider-issued token. */\nexport const CLEAR_SECRET = \"__clear__\";\n\nexport type SiteDetailResult =\n | { status: \"updated\"; slug: string; field: string }\n /** A `secret` field explicitly erased via the CLEAR_SECRET sentinel. Distinct\n * from `updated` so the console can say \"cleared\" rather than implying a new\n * value was stored. */\n | { status: \"cleared\"; slug: string; field: string }\n /** A `secret` field submitted empty: nothing was written, deliberately. */\n | { status: \"unchanged\"; slug: string; field: string }\n | { status: \"bad-field\"; slug: string; field: string }\n | { status: \"invalid\"; slug: string; field: string }\n | { status: \"not-found\"; slug: string };\n\n/**\n * Write one allowlisted site-detail field from the dashboard editor.\n *\n * SAFETY: an unknown `field` is rejected BEFORE any read (a hand-crafted authed\n * POST can never write an arbitrary Airtable column), and the value is\n * validated/normalized per kind before the write — invalid input never reaches\n * Airtable.\n */\nexport async function setSiteDetail(\n deps: SiteDetailDeps,\n slug: string,\n field: string,\n rawValue: string,\n): Promise<SiteDetailResult> {\n const f = EDITABLE_SITE_FIELDS[field];\n if (!f) return { status: \"bad-field\", slug, field };\n const value = normalizeFieldValue(f, rawValue);\n if (value === null) return { status: \"invalid\", slug, field };\n // A blank secret is \"I did not change this\", not \"erase it\" — the input is\n // rendered without a value, so it is blank on every load. Returning before the\n // read means an accidental save cannot even touch the record.\n if (f.kind === \"secret\" && value === \"\") return { status: \"unchanged\", slug, field };\n // ...which left NO way to clear one from the console. Airtable was the escape\n // hatch, and the freeze removes it (#612), so clearing needs its own explicit\n // gesture. A typed sentinel rather than a new control: the secret input is the\n // one field whose blur listener already fires on any keystroke (it renders\n // with no value, so anything typed differs from its default), so this needs no\n // change to the inline dashboard script — which no test executes.\n if (f.kind === \"secret\" && value === CLEAR_SECRET) {\n const target = await deps.getSite(slug);\n if (!target) return { status: \"not-found\", slug };\n await deps.updateField(target.id, f.column, \"\");\n return { status: \"cleared\", slug, field };\n }\n const site = await deps.getSite(slug);\n if (!site) return { status: \"not-found\", slug };\n await deps.updateField(site.id, f.column, value);\n return { status: \"updated\", slug, field };\n}\n","import { renderAuthChrome } from \"./auth/render.js\";\nimport type { WebsiteRow, SecurityAdvisory } from \"../reports/airtable/websites.js\";\nimport { SEVERITY_RANK, siteSlug } from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { isPendingApproval } from \"../reports/airtable/reports.js\";\nimport type { EvidenceResult } from \"../reports/auto-tick.js\";\nimport type { SubmissionRow } from \"../reports/submission-row.js\";\nimport type { ScreenOutTotals } from \"../db/screenouts.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { FAVICON_LINK } from \"./favicon.js\";\nimport { onboardingStatus, missingOnboarding } from \"./onboarding.js\";\nimport { checklistFor, gatingFields, isHealthGateClear } from \"../reports/checklist.js\";\nimport { rendersCommentary } from \"../reports/render.js\";\nimport { approveBlockers, type PreflightFinding } from \"../reports/preflight.js\";\nimport { parseAddresses, withGlobalCc } from \"../reports/send/orchestrate.js\";\nimport {\n renderSubmissionRow,\n SUBMISSION_STYLES,\n SUBMISSION_STATUS_SCRIPT,\n isVisibleInStrip,\n} from \"./submission-view.js\";\nimport {\n SITE_STATUS_OPTIONS,\n FREQ_OPTIONS,\n WATCH_CONDITION_OPTIONS,\n CLEAR_SECRET,\n} from \"./site-details.js\";\nimport type { SiteAlarmContext } from \"./fleet-cockpit.js\";\n\nconst DASH = \"—\";\n\nfunction scoreTile(label: string, value: number | null): string {\n const display = value === null ? \"—\" : String(value);\n return `<div class=\"tile\"><div class=\"tile-value\">${escapeHtml(display)}</div><div class=\"tile-label\">${escapeHtml(label)}</div></div>`;\n}\n\nfunction healthTile(label: string, value: number | null, sub: string | null): string {\n const display = value === null ? \"—\" : String(value);\n const subLine = sub ? `<div class=\"tile-sub\">${escapeHtml(sub)}</div>` : \"\";\n return `<div class=\"tile\"><div class=\"tile-value\">${escapeHtml(display)}</div><div class=\"tile-label\">${escapeHtml(label)}</div>${subLine}</div>`;\n}\n\nfunction depsSub(majorBehind: number | null): string | null {\n if (majorBehind === null || majorBehind === 0) return null;\n return `${majorBehind} major behind`;\n}\n\nfunction securityTotal(site: WebsiteRow): number | null {\n const parts = [\n site.securityVulnsCritical,\n site.securityVulnsHigh,\n site.securityVulnsModerate,\n site.securityVulnsLow,\n ];\n if (parts.every((p) => p === null)) return null;\n return parts.reduce<number>((sum, p) => sum + (p ?? 0), 0);\n}\n\nfunction securitySub(site: WebsiteRow): string | null {\n const total = securityTotal(site);\n if (total === null || total === 0) return null;\n const c = site.securityVulnsCritical ?? 0;\n const h = site.securityVulnsHigh ?? 0;\n const m = site.securityVulnsModerate ?? 0;\n const l = site.securityVulnsLow ?? 0;\n return `${c}C / ${h}H / ${m}M / ${l}L`;\n}\n\n/** One advisory line: a severity pill, the vulnerable module, the advisory title, any CVEs,\n * and a link to the advisory when present. All Airtable-sourced text is escaped. */\nfunction advisoryRow(a: SecurityAdvisory): string {\n const sev = escapeHtml(a.severity);\n const module = escapeHtml(a.module);\n // Build-time-only (\"development\") deps have a lower live-exploit surface for a static site;\n // flag them so a dev-scoped critical reads differently than a runtime one. Fixed literal — safe.\n const scope = a.scope === \"development\" ? ` <span class=\"muted\">(dev)</span>` : \"\";\n const title = a.title ? ` — ${escapeHtml(a.title)}` : \"\";\n const cves =\n a.cves.length > 0 ? ` <span class=\"muted\">(${escapeHtml(a.cves.join(\", \"))})</span>` : \"\";\n const link = a.url\n ? ` <a href=\"${escapeHtml(safeUrl(a.url))}\" rel=\"noopener noreferrer\">advisory ▸</a>`\n : \"\";\n return `<li class=\"vuln-item\">\n <span class=\"pill sev-${sev}\">${sev}</span>\n <strong>${module}</strong>${scope}${title}${cves}${link}\n </li>`;\n}\n\n/** The per-site vulnerability list — which packages are vulnerable, severity-sorted, not just the\n * totals tile. Omitted entirely when the site was never audited (`null`) or is clean (empty). */\nfunction securitySection(site: WebsiteRow): string {\n const advisories = site.securityAdvisories;\n if (!advisories || advisories.length === 0) return \"\";\n const sorted = [...advisories].sort(\n (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity],\n );\n return `<div class=\"section vulns\">\n <h2>Vulnerabilities (${sorted.length})</h2>\n <ul class=\"vuln-list\">${sorted.map(advisoryRow).join(\"\")}</ul>\n </div>`;\n}\n\n/** Map an evidence result onto the existing site-Tier bands (green/amber/red) — pass→healthy,\n * fail→attention, unknown/absent→watch; `n/a` is a muted, non-blocking annotation. No 4th scale. */\nfunction healthPresentation(status: EvidenceResult): { cls: string; word: string } {\n switch (status) {\n case \"pass\":\n return { cls: \"healthy\", word: \"clear\" };\n case \"fail\":\n return { cls: \"attention\", word: \"blocks\" };\n case \"n/a\":\n return { cls: \"na\", word: \"n/a\" };\n default:\n return { cls: \"watch\", word: \"needs you\" }; // unknown / absent\n }\n}\n\n/** The per-report health panel: one Tier-colored pill per `checklistFor(reportType)` item, driven\n * by `report.autoEvidence` (absent → unknown/amber). Advisory items (in the checklist but not\n * `gatingFields`) render their color but are annotated \"advisory — never blocks\". Launch/\n * Announcement (empty checklist) render NOTHING — never gated. No manual checkboxes: ticking is\n * retired from the gating path. */\nfunction checklistBlock(r: ReportRow): string {\n const items = checklistFor(r.reportType);\n if (items.length === 0) return \"\";\n const gating = new Set(gatingFields(r.reportType));\n const rid = escapeHtml(r.id);\n const rows = items\n .map((item) => {\n const ev = r.autoEvidence?.[item.field];\n const status: EvidenceResult = ev?.result ?? \"unknown\";\n const { cls, word } = healthPresentation(status);\n const advisory = gating.has(item.field)\n ? \"\"\n : ` <span class=\"advisory\">advisory — never blocks</span>`;\n const note = ev?.note ? ` <span class=\"check-note\">${escapeHtml(ev.note)}</span>` : \"\";\n return `<li class=\"check-item\"><span class=\"pill ${cls}\" title=\"${escapeHtml(ev?.note ?? \"not yet measured\")}\">${word}</span> ${escapeHtml(item.label)}${advisory}${note}</li>`;\n })\n .join(\"\");\n return `<ul class=\"checklist\" data-checklist-for=\"${rid}\">${rows}</ul>`;\n}\n\n/** The Approve button for a pending report. Server-renders `disabled` when the\n * report's health gate is not clear OR the report has send blockers (the\n * convenience gate — approve.ts + orchestrate.ts are the hard backstops).\n * `data-send-blocked` marks the reason for tests/tooling; there is no client-side\n * re-gate to guard against (checklist ticking no longer drives this button). */\nfunction approveButton(r: ReportRow, blocked: boolean): string {\n const gateClear = isHealthGateClear({\n reportType: r.reportType,\n autoEvidence: r.autoEvidence ?? {},\n });\n const disabled = gateClear && !blocked ? \"\" : \" disabled\";\n const blockedAttr = blocked ? ` data-send-blocked=\"1\"` : \"\";\n return `<button class=\"approve\" data-report-id=\"${escapeHtml(r.id)}\" data-approve-url=\"${escapeHtml(`/api/reports/${encodeURIComponent(r.id)}/approve`)}\"${blockedAttr}${disabled}>Approve</button>`;\n}\n\n/** The \"Send anyway…\" override affordance for a health-red pending report: a required-reason\n * text input plus a submit button that POSTs to the approve endpoint with `?override=1`.\n * Rendered ONLY when `!isHealthGateClear(r)` — a healthy report has nothing to override, and a\n * gate that's already clear hides/omits the control entirely (R4.1). This is NOT a bypass of the\n * real send blockers (missing recipients / header image / report scores): the endpoint still\n * evaluates those against the synthetic overridden report and can refuse the override too. */\nfunction overrideControl(r: ReportRow): string {\n const gateClear = isHealthGateClear({\n reportType: r.reportType,\n autoEvidence: r.autoEvidence ?? {},\n });\n if (gateClear) return \"\";\n const rid = escapeHtml(r.id);\n const overrideUrl = escapeHtml(`/api/reports/${encodeURIComponent(r.id)}/approve?override=1`);\n return `<div class=\"override\" data-override-for=\"${rid}\">\n <button type=\"button\" class=\"override-toggle\" data-report-id=\"${rid}\">Send anyway…</button>\n <div class=\"override-form\" hidden>\n <input type=\"text\" class=\"override-reason\" placeholder=\"Reason for overriding the health gate (required)\" />\n <button type=\"button\" class=\"override-submit\" data-report-id=\"${rid}\" data-override-url=\"${overrideUrl}\">Confirm override</button>\n <span class=\"override-status\"></span>\n </div>\n </div>`;\n}\n\n/** The preflight chip for one pending report: red = send blockers (approve\n * disabled), amber = wrong-inbox warns, green = clear. Reasons ride the title\n * tooltip. Same approveBlockers() the approve endpoint gates on, computed from\n * data the page already fetched — the chip can't drift from the gate. */\nfunction preflightChip(findings: PreflightFinding[]): string {\n const fails = findings.filter((f) => f.level === \"fail\");\n const warns = findings.filter((f) => f.level === \"warn\");\n if (fails.length > 0) {\n const title = escapeHtml(fails.map((f) => `${f.check}: ${f.message}`).join(\"\\n\"));\n return `<span class=\"preflight preflight-fail\" title=\"${title}\">preflight ✗ ${fails.length}</span>`;\n }\n if (warns.length > 0) {\n const title = escapeHtml(warns.map((f) => `${f.check}: ${f.message}`).join(\"\\n\"));\n return `<span class=\"preflight preflight-warn\" title=\"${title}\">preflight ⚠ ${warns.length}</span>`;\n }\n return `<span class=\"preflight preflight-ok\" title=\"recipients, header image and report scores all present\">preflight ✓</span>`;\n}\n\n/** The next 09:23 UTC daily-run strictly after `now` — when an approved report\n * actually sends (daily-reports.yml cron). Approve is not send; the row says so. */\nfunction nextDailyRun(now: Date): Date {\n const run = new Date(now);\n run.setUTCHours(9, 23, 0, 0);\n if (run.getTime() <= now.getTime()) run.setUTCDate(run.getUTCDate() + 1);\n return run;\n}\n\n/** The send-timing line for a pending row. Two honesty rules: (1) between 09:23\n * and ~10:30 UTC TODAY's run may still be mid-flight (cron start delay + install\n * + build + the draft step) and would sweep a fresh approval within minutes —\n * claiming \"~24h\" there would be a lie in the harmful direction, so say the\n * window out loud instead; (2) the countdown floors (minutes under an hour) so\n * it never claims more remaining time than exists. */\nfunction sendTimingLine(now: Date): string {\n const minuteOfDay = now.getUTCHours() * 60 + now.getUTCMinutes();\n const RUN_START = 9 * 60 + 23;\n const GRACE_END = 10 * 60 + 30;\n if (minuteOfDay >= RUN_START && minuteOfDay < GRACE_END) {\n return `<span class=\"muted\">today's 09:23 UTC run may still be in flight — approving now can send within minutes</span>`;\n }\n const run = nextDailyRun(now);\n const leadMin = Math.floor((run.getTime() - now.getTime()) / 60_000);\n const lead =\n leadMin >= 60\n ? `~${Math.floor(leadMin / 60)}h`\n : leadMin >= 1\n ? `~${leadMin} min`\n : \"under 1 min\";\n return `<span class=\"muted\">approve ≠ send: goes out at the next daily run, 09:23 UTC (${lead})</span>`;\n}\n\n/** Exactly what a send of this report would address, resolved the way the send\n * path resolves it (To override → point of contact; forced ops CC appended) —\n * computed from the same inputs so the row can't drift from orchestrate.ts. */\nfunction recipientsLine(site: WebsiteRow): string {\n const to = parseAddresses(site.reportRecipientsTo) ?? parseAddresses(site.pointOfContact) ?? [];\n if (to.length === 0) {\n return `<span class=\"recipients recipients-missing\">recipients: none resolve — set point of contact in Airtable</span>`;\n }\n const cc = withGlobalCc(parseAddresses(site.reportRecipientsCc), to);\n const ccPart = cc.length > 0 ? ` · CC ${cc.map(escapeHtml).join(\", \")}` : \"\";\n return `<span class=\"recipients\">To ${to.map(escapeHtml).join(\", \")}${ccPart}</span>`;\n}\n\n/** \"Refresh preview\": dispatch a re-render so the stored body picks up commentary\n * edited after drafting. Offered only on an UNSENT report — the endpoint and the\n * workflow both refuse a sent one, and a button whose only outcome is a refusal\n * is worse than no button. */\nfunction rerenderButton(r: ReportRow): string {\n if (r.sentAt !== null) return \"\";\n const url = `/api/reports/${encodeURIComponent(r.id)}/rerender`;\n return `<button class=\"rerender\" data-rerender-url=\"${escapeHtml(url)}\" title=\"Re-render this report from its current row (runs in Actions; takes a minute or two)\">refresh preview</button>`;\n}\n\n/** The dashboard's own preview route for a report body (served from Turso).\n * Never the Airtable attachment URL — that one is signed and expires. */\nfunction reportPreviewUrl(reportId: string): string {\n return `/api/reports/${encodeURIComponent(reportId)}/preview`;\n}\n\n/** Inline commentary editor for a report still awaiting send (#539 Phase 4).\n * Rendered only where the server would accept the write — `setReportCommentary`\n * refuses once `sentAt` is set, and offering a box that can only be rejected is\n * worse than offering none. */\nfunction commentaryEditor(r: ReportRow): string {\n // Only where the template will actually use it. `buildAnnouncementMjml` and\n // `buildLaunchMjml` never reference `commentary`, so offering a box on those\n // types means the operator writes text, sees it save, previews it, and finds\n // nothing — with no explanation. Same principle as not offering it on a report\n // that has already been sent.\n if (!rendersCommentary(r.reportType)) return \"\";\n const url = `/api/reports/${encodeURIComponent(r.id)}/commentary`;\n return `<div class=\"commentary\"><label for=\"commentary-${escapeHtml(r.id)}\">Commentary <span class=\"muted\">(re-rendered into the email at send)</span></label><textarea id=\"commentary-${escapeHtml(r.id)}\" data-commentary-for=\"${escapeHtml(r.id)}\" data-commentary-url=\"${escapeHtml(url)}\" rows=\"3\">${escapeHtml(r.commentary ?? \"\")}</textarea><span class=\"commentary-saved\" data-for=\"${escapeHtml(r.id)}\"></span></div>`;\n}\n\nfunction pendingRow(r: ReportRow, site: WebsiteRow, now: Date): string {\n const type = escapeHtml(r.reportType);\n const period = r.period ? escapeHtml(r.period) : \"—\";\n const findings = approveBlockers(site, r);\n const blocked = findings.some((f) => f.level === \"fail\");\n // Draft-time render: sendOne re-renders at send with current Commentary /\n // subject override, so this is the DRAFT preview, labeled as such.\n // The dashboard's OWN route, not the Airtable attachment URL. Those are signed\n // and expire, so a tab left open 404s — the reason `/api/reports/:id/preview`\n // was built in Phase 2 to serve the body from Turso. It was never linked to,\n // so the expiring URL stayed in front of the operator. `renderedHtmlAttachment`\n // still gates the link: it is how we know a body was ever rendered at all.\n const preview = r.renderedHtmlAttachment\n ? `<a href=\"${escapeHtml(reportPreviewUrl(r.id))}\" rel=\"noopener noreferrer\" title=\"rendered at draft time — Commentary/subject edits after drafting are not reflected\">draft preview ▸</a>`\n : `<span class=\"muted\">no preview yet</span>`;\n const sendLine = sendTimingLine(now);\n return `<li><div class=\"pending-head\"><strong>${type}</strong> <span class=\"muted\">${period}</span> ${preflightChip(findings)} ${preview} ${rerenderButton(r)} ${approveButton(r, blocked)}</div><div class=\"pending-info\">${recipientsLine(site)} ${sendLine}</div>${checklistBlock(r)}${commentaryEditor(r)}${overrideControl(r)}</li>`;\n}\n\nfunction pendingSection(reports: ReportRow[], site: WebsiteRow, now: Date): string {\n const pending = reports.filter(isPendingApproval);\n if (pending.length === 0) return \"\";\n return `<div class=\"section pending\">\n <h2>Pending your yes (${pending.length})</h2>\n <ul class=\"pending-list\">${pending.map((r) => pendingRow(r, site, now)).join(\"\")}</ul>\n </div>`;\n}\n\n/** The GA \"Users\" cell for a report row: current count plus the signed delta vs\n * the previous period when both are known. Renders \"—\" when there's no current\n * count (GA not configured / fetch failed → blank in Airtable). */\nfunction gaUsersCell(r: ReportRow): string {\n if (r.gaUsersCurrent === null) return DASH;\n const current = String(r.gaUsersCurrent);\n if (r.gaUsersPrevious === null) return escapeHtml(current);\n const delta = r.gaUsersCurrent - r.gaUsersPrevious;\n const sign = delta > 0 ? \"+\" : \"\"; // negatives carry their own \"-\"; zero shows \"0\"\n return `${escapeHtml(current)} <span class=\"muted\">(${escapeHtml(`${sign}${delta}`)})</span>`;\n}\n\n/** The search-presence cell: the page-1 position when the site was found on\n * page 1, otherwise \"—\" (not-found OR the check didn't run). */\nfunction searchCell(r: ReportRow): string {\n if (r.searchFoundPage1 && r.searchPosition !== null) {\n return escapeHtml(`#${r.searchPosition}`);\n }\n return DASH;\n}\n\nfunction reportRow(r: ReportRow, site: WebsiteRow): string {\n const date = r.completedOn ? escapeHtml(r.completedOn) : DASH;\n const type = escapeHtml(r.reportType);\n const id = escapeHtml(r.reportId);\n const ga = gaUsersCell(r);\n const search = searchCell(r);\n const link = r.renderedHtmlAttachment\n ? `<a href=\"${escapeHtml(reportPreviewUrl(r.id))}\">view</a>`\n : `<span class=\"muted\">no attachment</span>`;\n // Same gate as the pending section: an approve action in the history table\n // must not be a side door around the send-blocker gate.\n const action = isPendingApproval(r)\n ? approveButton(\n r,\n approveBlockers(site, r).some((f) => f.level === \"fail\"),\n )\n : \"\";\n // Commentary stays editable for the WHOLE unsent window, not just while a\n // report is awaiting approval: approving schedules the send for the next 09:23\n // UTC run, so there is a window of up to ~24h in which a typo is still\n // fixable, and the server's lock is `sentAt`. A pending report already carries\n // its editor in the pending list, so this covers the rest — approved-awaiting-\n // send, and drafts not yet marked ready.\n const commentary =\n r.sentAt === null && !isPendingApproval(r)\n ? `<tr class=\"commentary-row\"><td colspan=\"7\">${commentaryEditor(r)}</td></tr>`\n : \"\";\n return `<tr><td>${date}</td><td>${type}</td><td><code>${id}</code></td><td>${ga}</td><td>${search}</td><td>${link}</td><td>${action}</td></tr>${commentary}`;\n}\n\nconst SUBMISSIONS_PER_SITE_CAP = 25;\n\nfunction submissionsSection(submissions: SubmissionRow[], site: WebsiteRow): string {\n const visible = submissions.filter(isVisibleInStrip);\n if (visible.length === 0) return \"\";\n const recent = [...visible]\n .sort((a, b) => (b.submittedAt ?? \"\").localeCompare(a.submittedAt ?? \"\"))\n .slice(0, SUBMISSIONS_PER_SITE_CAP);\n // The heading shows the true total; when we only list a slice, say so rather\n // than implying every one of the N is on the page.\n const note =\n visible.length > recent.length\n ? `<span class=\"muted\"> — showing ${recent.length} of ${visible.length}</span>`\n : \"\";\n const viewAll = `<a class=\"subm-viewall\" href=\"/submissions?site=${escapeHtml(siteSlug(site.name))}\">View all for this site →</a>`;\n return `<div class=\"section submissions\">\n <h2>Form submissions (${visible.length})${note} ${viewAll}</h2>\n <ul class=\"subm-list\">${recent.map(renderSubmissionRow).join(\"\")}</ul>\n </div>`;\n}\n\nconst SPAM_WINDOW_DAYS = 30;\n\n/** The per-site spam panel: caught (honeypot/too-fast) + marked-spam from the screen-out\n * buckets, plus auto-filtered and delivered counted from the submissions loaded for this\n * page within the window. Omitted when there's nothing to show. The in-window counts\n * undercount only if the site exceeds the 200-row submissions fetch within the window\n * (rare at fleet scale). */\nfunction spamScreenSection(\n totals: ScreenOutTotals | null,\n submissions: SubmissionRow[],\n now: Date,\n): string {\n const sinceMs = now.getTime() - SPAM_WINDOW_DAYS * 24 * 60 * 60 * 1000;\n const inWindow = submissions.filter(\n (s) => s.submittedAt !== null && Date.parse(s.submittedAt) >= sinceMs,\n );\n // \"Delivered\" excludes spam_auto/spam rows: their notification was skipped, and on\n // the requireTurnstile canary site they are the very rows under watch — counting\n // them inflated the one metric the canary is judged on (2026-07-15).\n const delivered = inWindow.filter((s) => s.status !== \"spam_auto\" && s.status !== \"spam\").length;\n const autoFiltered = inWindow.filter((s) => s.status === \"spam_auto\").length;\n const t = totals ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };\n if (\n delivered === 0 &&\n autoFiltered === 0 &&\n t.honeypot === 0 &&\n t.tooFast === 0 &&\n t.markedSpam === 0\n )\n return \"\";\n const row = (label: string, n: number) =>\n `<div class=\"spam-kv\"><span class=\"k\">${label}</span> ${escapeHtml(String(n))}</div>`;\n return `<div class=\"section spam-screen\">\n <h2>Spam screen (30d)</h2>\n ${row(\"Caught — honeypot\", t.honeypot)}\n ${row(\"Caught — too-fast\", t.tooFast)}\n ${row(\"Auto-filtered\", autoFiltered)}\n ${row(\"Delivered\", delivered)}\n ${row(\"Marked spam\", t.markedSpam)}\n </div>`;\n}\n\n/** Setup (N/4) status near the page header. Lists the missing onboarding items\n * visibly (the cockpit chip only hovers them) so the operator sees what's left\n * to wire up without leaving the page. */\nfunction setupSection(site: WebsiteRow): string {\n const { score, total } = onboardingStatus(site);\n const missing = missingOnboarding(site);\n const detail =\n missing.length === 0\n ? `<span class=\"setup-ok\">complete</span>`\n : `<span class=\"setup-missing\">Missing: ${escapeHtml(missing.join(\", \"))}</span>`;\n return `<div class=\"setup-line\">Setup ${score}/${total} — ${detail}</div>`;\n}\n\n/** The site's cockpit alarm verdict, rendered as a chip strip under the header.\n * Mirrors fleet-browse-render's chips() presentation (attention items → red/critical\n * chips incl. the auto-fix-stuck border; watch reasons → chips with the accept-key\n * hint; accepted → muted ✓ chips). Renders \"\" when null or when there is nothing\n * to show (healthy/pre-launch with no accepted conditions stays calm). */\nfunction alarmSection(alarm: SiteAlarmContext | null): string {\n if (!alarm) return \"\";\n const chips: string[] = [];\n for (const it of alarm.items) {\n const cls = it.autoFixExhausted\n ? \"chip critical stuck\"\n : it.severity === \"critical\"\n ? \"chip critical\"\n : \"chip\";\n chips.push(`<span class=\"${cls}\">${escapeHtml(it.title)}</span>`);\n }\n alarm.watchReasons.forEach((reason, i) => {\n const key = alarm.watchAcceptKeys[i];\n const hint = key ? ` · accept: \"${escapeHtml(key)}\"` : \"\";\n chips.push(\n `<span class=\"chip\" title=\"Add this exact text to the site's Accepted Watch Conditions to mute this watch\">${escapeHtml(reason)}${hint}</span>`,\n );\n });\n for (const reason of alarm.acceptedReasons)\n chips.push(`<span class=\"chip accepted\">✓ accepted: ${escapeHtml(reason)}</span>`);\n if (chips.length === 0) return \"\";\n const label =\n alarm.tier === \"attention\" ? \"Needs attention\" : alarm.tier === \"watch\" ? \"Watch\" : \"Status\";\n return `<div class=\"section alarm\"><h2>${label}</h2><div class=\"chips\">${chips.join(\"\")}</div></div>`;\n}\n\n/** One read-only \"Site details\" row: a label and a value that degrades to \"—\". */\nfunction detailRow(label: string, value: string | null | undefined): string {\n const display =\n typeof value === \"string\" && value.trim().length > 0 ? escapeHtml(value.trim()) : DASH;\n return `<div class=\"detail\"><dt>${escapeHtml(label)}</dt><dd>${display}</dd></div>`;\n}\n\n/** A per-field \"saved\" indicator the page script flips to ✓ / ✗ after a POST. */\nfunction savedSpan(field: string): string {\n return `<span class=\"detail-saved\" data-for=\"${field}\"></span>`;\n}\n\n/** Editable `<select>` row for an enum field (Status / cadence). */\nfunction selectRow(\n label: string,\n field: string,\n options: readonly string[],\n current: string | null,\n url: string,\n): string {\n const inList = current !== null && options.includes(current);\n const opts = options\n .map(\n (o) =>\n `<option value=\"${escapeHtml(o)}\"${o === current ? \" selected\" : \"\"}>${escapeHtml(o)}</option>`,\n )\n .join(\"\");\n // When the stored value isn't one of the offered options (e.g. a null cadence,\n // or an Airtable-only \"legacy\" status), show a disabled placeholder selected\n // first so the operator must actively pick — never silently overwrites.\n const placeholder = inList ? \"\" : `<option value=\"\" disabled selected hidden>— select —</option>`;\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><select id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\">${placeholder}${opts}</select>${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable single-line `<input>` row for a text/email/repo field. */\nfunction inputRow(label: string, field: string, value: string | null, url: string): string {\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><input type=\"text\" id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\" value=\"${escapeHtml(value ?? \"\")}\" />${savedSpan(field)}</dd></div>`;\n}\n\n/**\n * How the site-details editor turns a control into the string it POSTs.\n *\n * Exported as SOURCE so tests can execute the exact text the page serves — the\n * inline script is a template string and `vitest.config.ts` runs without a DOM,\n * so nothing else in the suite can run it.\n *\n * The two special cases are DOM traps, not preferences:\n * - a checkbox's `.value` is its `value` content attribute (\"on\" by default),\n * never the checked state, so `el.value` posts \"on\" whether ticked or not;\n * - a `multiple` select's `.value` is the FIRST selected option only, so\n * `el.value` silently drops every other selection.\n * Both shipped broken in #591 for exactly this reason.\n */\nexport const DETAIL_VALUE_FN = `function detailValue(el) {\n if (el.type === \"checkbox\") return el.checked ? \"true\" : \"false\";\n if (el.multiple) return Array.from(el.selectedOptions).map((o) => o.value).join(\",\");\n return el.value;\n }`;\n\n/**\n * The site-details save, exported as SOURCE for the same reason as\n * DETAIL_VALUE_FN: it is served inside a template literal, so executing the real\n * text is the only way a test can see it work. `root` is passed in rather than\n * closed over so the test can hand it a stub instead of a document.\n *\n * **The resync on line `el.defaultValue = el.value` is load-bearing.** The blur\n * listener fires on `value !== defaultValue`, and this function used not to touch\n * `defaultValue` at all — so after one successful edit the two stayed different\n * forever and every later focus+blur of that field posted another Airtable write,\n * until the page was reloaded. The commentary handler 40 lines below always did\n * this correctly, which is what marks the omission as an oversight rather than a\n * decision.\n *\n * The worst case is the `secret` kind, which deliberately emits no `value`\n * attribute so an existing credential is never echoed into the HTML: its\n * `defaultValue` is permanently `\"\"`, so every blur after typing re-POSTed the\n * credential. And one Airtable quota exhaustion has already reddened six\n * workflows fleet-wide (2026-08-17), so a tab-through of this form was a cheap\n * way to burn the fleet's write budget.\n *\n * Only on `r.ok`: a failed save must stay dirty so the next blur retries it.\n * Guarded by an `in` check because `select` elements have no `defaultValue`.\n */\nexport const SAVE_DETAIL_FN = `function saveDetail(el, root) {\n const span = root.querySelector('.detail-saved[data-for=\"' + el.dataset.detailField + '\"]');\n return fetch(el.dataset.detailsUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ field: el.dataset.detailField, value: detailValue(el) }),\n })\n .then((r) => {\n if (span) span.textContent = r.ok ? \" \\\\u2713\" : \" \\\\u2717\";\n if (r.ok && \"defaultValue\" in el) el.defaultValue = el.value;\n })\n .catch(() => {\n if (span) span.textContent = \" \\\\u2717\";\n });\n }`;\n\n/**\n * `<input type=\"date\">` accepts ONLY `YYYY-MM-DD`. Handed anything else — an ISO\n * datetime such as `2026-08-01T00:00:00.000Z` — the browser sanitizes `.value` to\n * `\"\"` while `.defaultValue` keeps the raw string. That difference is what the\n * blur guard used to read as \"the operator edited this field\", so an untouched\n * tab-through would post an empty value and the server would accept it as a\n * deliberate clear. `maintenance day` / `testing day` drive the code-owned\n * next-due schedule, so the site would silently reschedule.\n *\n * Dormant while those Airtable columns stay date-only; it goes live the moment\n * anyone ticks \"include time\" on the field. Truncating to the date part here\n * removes the mismatch at the source (the script's gesture guard is the second\n * line of defence).\n */\nfunction toDateInputValue(value: string | null): string {\n return (value ?? \"\").slice(0, 10);\n}\n\n/** Editable `<input type=\"date\">` row. The browser enforces YYYY-MM-DD before\n * the request is made; `normalizeFieldValue`'s `date` kind still validates it\n * server-side, because a hand-crafted POST never touches this widget. */\nfunction dateRow(label: string, field: string, value: string | null, url: string): string {\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><input type=\"date\" id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\" value=\"${escapeHtml(toDateInputValue(value))}\" />${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable checkbox row. Posts the literal \"true\"/\"false\" the `bool` kind\n * accepts — a checkbox has no empty state, so there is nothing to clear. */\nfunction checkboxRow(label: string, field: string, checked: boolean, url: string): string {\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><input type=\"checkbox\" id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\"${checked ? \" checked\" : \"\"} />${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable multi-select row. Options come from WATCH_CONDITION_OPTIONS — the\n * live Airtable choices — because the API cannot create a missing one, so an\n * option offered here that the field lacks would produce a rejected write. */\nfunction multiSelectRow(\n label: string,\n field: string,\n options: readonly string[],\n selected: readonly string[],\n url: string,\n): string {\n const sel = new Set(selected);\n const opts = options\n .map(\n (o) =>\n `<option value=\"${escapeHtml(o)}\"${sel.has(o) ? \" selected\" : \"\"}>${escapeHtml(o)}</option>`,\n )\n .join(\"\");\n return `<div class=\"detail wide\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><select multiple id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\">${opts}</select>${savedSpan(field)}</dd></div>`;\n}\n\n/** Write-only row for a credential. Emits NO `value`, so the stored secret never\n * reaches the browser; the placeholder reports only whether one is set. Saving\n * it blank is a no-op server-side (`setSiteDetail` returns \"unchanged\"), which\n * is what makes an always-blank input safe to render beside fields that clear\n * on empty. */\nfunction secretRow(label: string, field: string, isSet: boolean, url: string): string {\n // The placeholder is the ONLY place the clear gesture is discoverable, so it\n // names the sentinel verbatim. Shown only when a key is actually set —\n // offering \"clear\" on an empty field is noise.\n const ph = isSet ? `•••••••• (type to replace, or ${CLEAR_SECRET} to erase)` : \"not set\";\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><input type=\"password\" autocomplete=\"off\" id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\" placeholder=\"${escapeHtml(ph)}\" />${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable multi-line `<textarea>` row for the copy override fields. */\nfunction textareaRow(label: string, field: string, value: string | null, url: string): string {\n return `<div class=\"detail wide\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><textarea id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\">${escapeHtml(value ?? \"\")}</textarea>${savedSpan(field)}</dd></div>`;\n}\n\n/** \"Site details\" section — inline-editable for the safe-text + operational fields\n * (writes via the authed /api/sites/:slug/details endpoint). `Last commit` stays\n * read-only (machine-derived). The Trigger Renovate button rides the heading. */\nfunction siteDetailsSection(site: WebsiteRow): string {\n const url = `/api/sites/${escapeHtml(siteSlug(site.name))}/details`;\n const lastCommit = site.lastCommitAt ? `${relativeTimeFromNow(site.lastCommitAt)}` : null;\n const rows = [\n // `statusRaw`, not `status`: this select's options ARE the Airtable cell\n // values (see SITE_STATUS_OPTIONS), so it must preselect against the raw cell.\n // Comparing the canonical value would mis-preselect every site, and would show\n // a \"legacy\" site as \"deprecated\" instead of the placeholder.\n selectRow(\"Status\", \"status\", SITE_STATUS_OPTIONS, site.statusRaw, url),\n selectRow(\"Maintenance cadence\", \"maintenanceFreq\", FREQ_OPTIONS, site.maintenanceFreq, url),\n selectRow(\"Testing cadence\", \"testingFreq\", FREQ_OPTIONS, site.testingFreq, url),\n inputRow(\"Report recipients (To)\", \"reportRecipientsTo\", site.reportRecipientsTo, url),\n inputRow(\"Report recipients (CC)\", \"reportRecipientsCc\", site.reportRecipientsCc, url),\n inputRow(\"Point of contact\", \"pointOfContact\", site.pointOfContact, url),\n inputRow(\"GA4 property\", \"ga4PropertyId\", site.ga4PropertyId, url),\n inputRow(\"Search query\", \"searchQuery\", site.searchQuery, url),\n inputRow(\"Git repo\", \"gitRepo\", site.gitRepo, url),\n // #539 Phase 4 — the fields nothing rendered before. `Mailchimp API Key` is\n // the one field in that group still absent, and deliberately: it is a live\n // credential and every row here emits its stored value into the page.\n inputRow(\"Netlify ID\", \"netlifyId\", site.netlifyId, url),\n inputRow(\"Search Console property\", \"searchConsoleProperty\", site.searchConsoleProperty, url),\n inputRow(\"Newsletter webhook\", \"newsletterWebhook\", site.newsletterWebhook, url),\n inputRow(\"Mailchimp audience ID\", \"mailchimpAudienceId\", site.mailchimpAudienceId, url),\n dateRow(\"Last maintenance day\", \"maintenanceDay\", site.maintenanceDay, url),\n dateRow(\"Last testing day\", \"testingDay\", site.testingDay, url),\n // Raw JSON, and shown as the raw cell rather than the parsed object: what the\n // operator edits has to be the thing `parseNotifyRouting` will read back.\n textareaRow(\"Notify routing (JSON)\", \"notifyRouting\", site.notifyRoutingRaw, url),\n checkboxRow(\"Require Turnstile\", \"requireTurnstile\", site.requireTurnstile, url),\n secretRow(\"Mailchimp API key\", \"mailchimpApiKey\", site.mailchimpApiKey !== null, url),\n multiSelectRow(\n \"Accepted watch conditions\",\n \"acceptedWatchConditions\",\n WATCH_CONDITION_OPTIONS,\n site.acceptedWatchConditions,\n url,\n ),\n textareaRow(\"Copy — Intro\", \"copyIntro\", site.copyIntro, url),\n textareaRow(\"Copy — Contact\", \"copyContact\", site.copyContact, url),\n textareaRow(\"Copy — Footer\", \"copyFooter\", site.copyFooter, url),\n detailRow(\"Last commit\", lastCommit),\n ].join(\"\");\n const triggerBtn = site.gitRepo?.trim()\n ? `<button class=\"trigger-renovate\" data-trigger-url=\"/api/sites/${escapeHtml(siteSlug(site.name))}/trigger-renovate\">Trigger Renovate</button>`\n : \"\";\n return `<div class=\"section site-details\">\n <h2>Site details ${triggerBtn}</h2>\n <dl class=\"details\">${rows}</dl>\n </div>`;\n}\n\nconst STYLES = `\n:root { color-scheme: light dark; }\nbody { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }\n@media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } }\nh1 { margin: 0 0 0.25rem; font-size: 1.75rem; }\n.meta { color: #666; margin-bottom: 2rem; }\n.meta a { color: inherit; }\n.audited { color: #999; font-size: 0.85rem; margin-bottom: 1.5rem; }\n.section { margin: 2rem 0; }\n.section h2 { font-size: 1.1rem; margin: 0 0 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #666; }\n.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; }\n.tile { padding: 1rem; border: 1px solid #ddd; border-radius: 6px; text-align: center; }\n@media (prefers-color-scheme: dark) { .tile { border-color: #333; } }\n.tile-value { font-size: 2rem; font-weight: 600; }\n.tile-label { font-size: 0.85rem; color: #666; margin-top: 0.25rem; }\n.tile-sub { font-size: 0.75rem; color: #999; margin-top: 0.15rem; }\ntable { width: 100%; border-collapse: collapse; }\nth, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { th, td { border-color: #2a2a2a; } }\n.muted { color: #999; }\n.empty { color: #999; padding: 1rem; border: 1px dashed #ccc; border-radius: 6px; text-align: center; }\nbutton.approve { font: inherit; padding: 0.35rem 0.85rem; border: 1px solid #2c7; border-radius: 6px; background: #2c7; color: #fff; cursor: pointer; position: relative; transition: background-color 0.12s ease, border-color 0.12s ease; }\nbutton.approve:hover:not(:disabled) { background: #25b36a; border-color: #25b36a; }\nbutton.approve:active:not(:disabled) { background: #1e9a5b; border-color: #1e9a5b; }\nbutton.approve:focus-visible { outline: 2px solid #2c7; outline-offset: 2px; }\nbutton.approve:disabled { opacity: 0.6; cursor: default; }\n/* In-flight: the label goes transparent rather than being removed, so the button keeps\n its exact width and the row never reflows mid-request. The spinner is a pseudo-element,\n NOT injected markup — this handler is deliberately textContent/title-only so server\n strings can never become HTML, and a ::after keeps that property intact. */\nbutton.approve.is-loading { color: transparent; cursor: progress; }\nbutton.approve.is-loading::after { content: \"\"; position: absolute; top: 50%; left: 50%; width: 0.85em; height: 0.85em; margin: -0.45em 0 0 -0.45em; border: 2px solid #ffffff80; border-top-color: transparent; border-radius: 50%; animation: approve-spin 0.8s linear infinite; }\n@keyframes approve-spin { to { transform: rotate(360deg); } }\n/* Never remove the spin outright — a frozen ring reads as \"hung\". Slow it instead. */\n@media (prefers-reduced-motion: reduce) { button.approve.is-loading::after { animation-duration: 2.4s; } }\n/* Terminal success. Wins over :disabled's dimming (the button stays disabled after a\n successful approve, and a 0.6-opacity \"Approved\" reads as not-quite-done). */\nbutton.approve.is-approved, button.approve.is-approved:disabled { background: #14663c; border-color: #14663c; color: #fff; opacity: 1; }\n.pending-info { display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; font-size: 0.82rem; margin: 0.3rem 0 0.15rem; }\n.recipients-missing { color: #e57373; }\n.preflight { font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; white-space: nowrap; }\n.preflight-ok { background: #1b5e2033; color: #7bc67e; }\n.preflight-warn { background: #f9a82533; color: #d79921; }\n.preflight-fail { background: #b71c1c33; color: #e57373; }\n.pending-list { list-style: none; padding: 0; margin: 0; }\n.pending-list li { padding: 0.5rem; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { .pending-list li { border-color: #2a2a2a; } }\n.pending-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; row-gap: 0.25rem; }\n.checklist {\n list-style: none;\n padding: 0;\n margin: 0.5rem 0 0.25rem 0.25rem;\n display: flex;\n flex-direction: column;\n gap: 0.3rem;\n}\n.check-item {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n font-size: 0.9rem;\n}\n.pill.healthy {\n background: #e8f5e9;\n color: #1b7a2f;\n}\n.pill.watch {\n background: #fff4e5;\n color: #a65a00;\n}\n.pill.attention {\n background: #fdecea;\n color: #b00;\n}\n.pill.na {\n background: #f0f0f0;\n color: #666;\n}\n.advisory {\n font-size: 0.72rem;\n color: #999;\n font-style: italic;\n}\n.check-note {\n font-size: 0.75rem;\n color: #999;\n}\n@media (prefers-color-scheme: dark) {\n .pill.healthy {\n background: #10240f;\n color: #7fce85;\n }\n .pill.watch {\n background: #2a2410;\n color: #ffd454;\n }\n .pill.attention {\n background: #2a0f0d;\n color: #ff8a80;\n }\n .pill.na {\n background: #222;\n color: #999;\n }\n}\n.pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }\n.override { margin: 0.4rem 0 0.15rem 0.25rem; }\nbutton.override-toggle { font: inherit; font-size: 0.78rem; padding: 0.15rem 0.6rem; border: 1px solid #b00; border-radius: 6px; background: transparent; color: #b00; cursor: pointer; transition: background-color 0.12s ease; }\nbutton.override-toggle:hover { background: #bb00001a; }\nbutton.override-toggle:focus-visible { outline: 2px solid #b00; outline-offset: 2px; }\n@media (prefers-color-scheme: dark) { button.override-toggle { border-color: #ff8a80; color: #ff8a80; } button.override-toggle:hover { background: #ff8a801f; } button.override-toggle:focus-visible { outline-color: #ff8a80; } }\n.override-form { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }\n.override-reason { font: inherit; font-size: 0.85rem; padding: 0.25rem 0.45rem; border: 1px solid #ccc; border-radius: 4px; background: transparent; color: inherit; min-width: 220px; flex: 1 1 220px; }\n@media (prefers-color-scheme: dark) { .override-reason { border-color: #444; } }\nbutton.override-submit { font: inherit; font-size: 0.8rem; padding: 0.25rem 0.7rem; border: 1px solid #b00; border-radius: 6px; background: #b00; color: #fff; cursor: pointer; transition: background-color 0.12s ease, border-color 0.12s ease; }\nbutton.override-submit:hover:not(:disabled) { background: #900; border-color: #900; }\nbutton.override-submit:active:not(:disabled) { background: #7a0000; border-color: #7a0000; }\nbutton.override-submit:focus-visible { outline: 2px solid #b00; outline-offset: 2px; }\nbutton.override-submit:disabled { opacity: 0.6; cursor: default; }\n.override-status { font-size: 0.78rem; color: #999; }\n.vuln-list { list-style: none; padding: 0; margin: 0; }\n.vuln-item { padding: 0.45rem 0; border-bottom: 1px solid #eee; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline; }\n@media (prefers-color-scheme: dark) { .vuln-item { border-color: #2a2a2a; } }\n.pill.sev-critical { background: #fdecea; color: #b00; }\n.pill.sev-high { background: #fff0e6; color: #c4500a; }\n.pill.sev-moderate { background: #fff8e1; color: #8a6d00; }\n.pill.sev-low { background: #f0f0f0; color: #555; }\n.home { display: inline-block; font-size: 0.9rem; margin-bottom: 0.75rem; text-decoration: none; }\n.setup-line { font-size: 0.9rem; color: #666; margin-bottom: 1rem; }\n.setup-ok { color: #1b7a2f; font-weight: 600; }\n.setup-missing { color: #a65a00; }\n.details { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.5rem 1.5rem; margin: 0; }\n.detail { display: flex; flex-direction: column; }\n.detail.wide { grid-column: 1 / -1; }\n.detail dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #999; }\n.detail dd { margin: 0; }\n.detail dd input, .detail dd select, .detail dd textarea { width: 100%; box-sizing: border-box; font: inherit; padding: 0.25rem 0.4rem; border: 1px solid #ccc; border-radius: 4px; background: transparent; color: inherit; }\n.detail dd textarea { min-height: 3.5rem; resize: vertical; }\n.detail-saved { font-size: 0.8rem; color: #2a8; }\nbutton.trigger-renovate { font: inherit; font-size: 0.8rem; padding: 0.15rem 0.6rem; margin-left: 0.5rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; transition: background-color 0.12s ease, border-color 0.12s ease; }\nbutton.trigger-renovate:hover:not(:disabled) { background: #88888822; border-color: #666; }\nbutton.trigger-renovate:focus-visible { outline: 2px solid #888; outline-offset: 2px; }\nbutton.trigger-renovate:disabled { opacity: 0.6; cursor: default; }\n/* Alarm chip strip — mirrors fleet-render.ts chip styles (NOT the page's .pill classes). */\n.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }\n.chip { font-size: 0.8rem; padding: 0.1rem 0.5rem; border-radius: 6px; background: #f0f0f0; }\n@media (prefers-color-scheme: dark) { .chip { background: #222; } }\n.chip.critical { background: #fdecea; color: #b00; }\n.chip.stuck { border: 1px solid #b00; font-weight: 600; }\n.chip.accepted { background: transparent; border: 1px dashed #bbb; color: #888; }\n@media (prefers-color-scheme: dark) { .chip.accepted { border-color: #444; } .chip.critical { background: #2a0f0d; color: #ff8a80; } }\n`;\n\n/**\n * Render the per-site dashboard as a single HTML document. Pure function:\n * no Airtable access, no env reads, no I/O. The Netlify function handler\n * fetches data, then hands it here. Easier to unit-test, easier to render\n * a static preview from CLI later.\n */\nexport function renderSiteDashboardHtml(\n site: WebsiteRow,\n reports: ReportRow[],\n submissions: SubmissionRow[] = [],\n spamTotals: ScreenOutTotals | null = null,\n now: Date = new Date(),\n alarm: SiteAlarmContext | null = null,\n operatorEmail: string | null = null,\n): string {\n const name = escapeHtml(site.name);\n const urlSafe = safeUrl(site.url);\n const allScoresNull =\n site.pScore === null && site.rScore === null && site.bpScore === null && site.seoScore === null;\n\n const scoresSection = allScoresNull\n ? `<div class=\"empty\">No lighthouse data yet — run <code>reddoor-maint audit --write-airtable</code> from the site checkout.</div>`\n : `<div class=\"tiles\">\n ${scoreTile(\"Performance\", site.pScore)}\n ${scoreTile(\"Accessibility\", site.rScore)}\n ${scoreTile(\"Best Practices\", site.bpScore)}\n ${scoreTile(\"SEO\", site.seoScore)}\n </div>`;\n\n const secTotal = securityTotal(site);\n const allHealthNull =\n site.a11yViolations === null && site.depsDrifted === null && secTotal === null;\n const healthSection = allHealthNull\n ? `<div class=\"empty\">No health data yet — run <code>reddoor-maint audit --write-airtable</code> from the site checkout.</div>`\n : `<div class=\"tiles\">\n ${healthTile(\"Accessibility issues\", site.a11yViolations, null)}\n ${healthTile(\"Dependency updates\", site.depsDrifted, depsSub(site.depsMajorBehind))}\n ${healthTile(\"Security alerts\", secTotal, securitySub(site))}\n </div>`;\n\n const auditedLine = site.lastLighthouseAuditAt\n ? `<div class=\"audited\">Last audited ${escapeHtml(relativeTimeFromNow(site.lastLighthouseAuditAt))}</div>`\n : \"\";\n\n // The report-history TABLE is the only place the \"recent 6\" slice belongs:\n // long enough to show a quarter of monthly reports plus the latest testing\n // report, short enough to keep the page a single scroll. The pending list +\n // approve buttons above intentionally see the FULL `reports` set — an OLD\n // pending report that falls outside this slice must still be approvable\n // (and must not disagree with the fleet banner, which counts ALL reports).\n const recentReports = [...reports]\n .sort((a, b) => (b.completedOn ?? \"\").localeCompare(a.completedOn ?? \"\"))\n .slice(0, 6);\n const reportsSection =\n recentReports.length === 0\n ? `<div class=\"empty\">No reports yet.</div>`\n : `<table>\n <thead><tr><th>Completed</th><th>Type</th><th>ID</th><th>GA users</th><th>Search</th><th>Report</th><th></th></tr></thead>\n <tbody>${recentReports.map((r) => reportRow(r, site)).join(\"\")}</tbody>\n </table>`;\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n ${FAVICON_LINK}\n <title>${name} — Reddoor maintenance</title>\n <style>${STYLES}${SUBMISSION_STYLES}</style>\n</head>\n<body>\n ${renderAuthChrome(operatorEmail)}\n <a class=\"home\" href=\"/\">← Fleet home</a>\n <h1>${name}</h1>\n <div class=\"meta\"><a href=\"${escapeHtml(urlSafe)}\">${escapeHtml(site.url)}</a></div>\n ${auditedLine}\n ${setupSection(site)}\n ${alarmSection(alarm)}\n ${pendingSection(reports, site, now)}\n\n <div class=\"section\">\n <h2>Lighthouse</h2>\n ${scoresSection}\n </div>\n\n <div class=\"section\">\n <h2>Site Health</h2>\n ${healthSection}\n </div>\n\n ${securitySection(site)}\n\n <div class=\"section\">\n <h2>Reports</h2>\n ${reportsSection}\n </div>\n\n ${siteDetailsSection(site)}\n ${spamScreenSection(spamTotals, submissions, now)}\n ${submissionsSection(submissions, site)}\n <script>\n // The SAME pending report is rendered twice — once in the pending list, once\n // in the reports history — so one report id owns two Approve buttons. Every\n // state change has to reach both, or the two places you look disagree: one\n // says \"Approved\", the other still says \"Approve\" and (with the gate clear)\n // is still enabled.\n function approveButtonsFor(id) {\n return Array.from(document.querySelectorAll('button.approve[data-report-id=\"' + id + '\"]'));\n }\n document.querySelectorAll(\"button.approve\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n const twins = approveButtonsFor(b.dataset.reportId);\n twins.forEach((t) => { t.disabled = true; });\n // is-loading swaps the label for a spinner; aria-busy is the same news for a\n // screen reader, which cannot see it. Both are cleared in the finally below so\n // no exit path can strand the button spinning forever.\n b.classList.add(\"is-loading\");\n b.setAttribute(\"aria-busy\", \"true\");\n try {\n const res = await fetch(b.dataset.approveUrl, { method: \"POST\" });\n if (res.ok) {\n // Terminal success: darker green, and they stay disabled (approving twice\n // is a no-op server-side, but the button should not invite the second\n // click). Applied to every twin, not just the one that was clicked.\n twins.forEach((t) => {\n t.textContent = \"Approved\";\n t.classList.add(\"is-approved\");\n });\n } else {\n // A 409 carries { reason, blockers } — surface WHY instead of a bare\n // \"Failed\" next to a possibly-stale green chip. textContent/title\n // assignment only (no innerHTML), so server strings stay inert.\n const data = await res.json().catch(() => null);\n const label = data && data.reason === \"send-blocked\" ? \"Blocked\" : \"Failed\";\n // Double-backslash, NOT single: this whole block lives inside the renderer's\n // template literal, so a single-backslash escape is consumed HERE at build\n // time and emits a real newline into the served HTML — an unterminated string\n // literal that kills the parse of the entire script element, taking every\n // handler on the page with it (exactly how approve/override/renovate went\n // dead). Note this comment must also avoid backticks for the same reason.\n const title = data && Array.isArray(data.blockers) ? data.blockers.join(\"\\\\n\") : null;\n // Re-enable EVERY twin, not just the clicked one — leaving the other\n // disabled would strand it reading \"Approve\" and unusable.\n twins.forEach((t) => {\n t.textContent = label;\n if (title !== null) t.title = title;\n t.disabled = false;\n });\n }\n } catch {\n // Network rejection (offline, DNS, abort): mirror the !res.ok path so\n // the buttons don't sit permanently disabled reading \"Approve\".\n twins.forEach((t) => {\n t.textContent = \"Failed\";\n t.disabled = false;\n });\n } finally {\n b.classList.remove(\"is-loading\");\n b.removeAttribute(\"aria-busy\");\n }\n });\n });\n // Health-gate override affordance: the toggle reveals a required-reason field; the\n // submit POSTs the server-provided override URL with { reason } as the body — a\n // distinct, deliberate action, never a silent default (an empty reason is\n // refused client-side AND server-side).\n document.querySelectorAll(\"button.override-toggle\").forEach((b) => {\n b.addEventListener(\"click\", () => {\n // Scoped lookup, not nextElementSibling: adjacency is a markup accident,\n // and anything inserted between the toggle and its form would kill \"Send\n // anyway…\" silently. The submit handler six lines down already uses\n // closest(); this is the same contract from the other end.\n const wrap = b.closest(\".override\");\n const form = wrap ? wrap.querySelector(\".override-form\") : null;\n if (form) form.hidden = !form.hidden;\n });\n });\n document.querySelectorAll(\"button.override-submit\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n const wrap = b.closest(\".override-form\");\n const input = wrap ? wrap.querySelector(\".override-reason\") : null;\n const status = wrap ? wrap.querySelector(\".override-status\") : null;\n const reason = input ? input.value.trim() : \"\";\n if (!reason) {\n if (status) status.textContent = \"Reason required\";\n return;\n }\n b.disabled = true;\n if (status) status.textContent = \"Sending…\";\n try {\n const res = await fetch(b.dataset.overrideUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ reason }),\n });\n if (res.ok) {\n if (status) status.textContent = \"Overridden ✓\";\n if (input) input.disabled = true;\n // Reflect the send-anyway state on the row's own Approve buttons too —\n // they stay server-gated disabled, but the label should stop implying\n // \"waiting on you\" once an override has gone through. ALL of them: the\n // same report is rendered in both the pending list and the history, so\n // a singular querySelector updated the first and left the second lying.\n approveButtonsFor(b.dataset.reportId).forEach((t) => {\n t.textContent = \"Overridden\";\n });\n } else {\n // A 409 carries { reason, blockers } — surface WHY (textContent only,\n // never innerHTML, so server strings stay inert).\n const data = await res.json().catch(() => null);\n if (status) {\n status.textContent =\n data && Array.isArray(data.blockers) && data.blockers.length > 0\n ? data.blockers.join(\"; \")\n : data && data.reason === \"override-reason-required\"\n ? \"Reason required\"\n : \"Failed\";\n }\n b.disabled = false;\n }\n } catch {\n if (status) status.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });\n // Trigger-renovate button: async on-demand dispatch (mirrors the cockpit).\n document.querySelectorAll(\"button.trigger-renovate\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n b.textContent = \"Dispatching…\";\n try {\n const res = await fetch(b.dataset.triggerUrl, { method: \"POST\" });\n b.textContent = res.ok ? \"Dispatched ✓\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });\n // Site-details editor: save on change (selects) / blur (inputs+textareas, only\n // when the value actually changed). The per-field span shows ✓ / ✗.\n ${DETAIL_VALUE_FN}\n ${SAVE_DETAIL_FN}\n document.querySelectorAll(\"select[data-detail-field]\").forEach((s) => {\n s.addEventListener(\"change\", () => saveDetail(s, document));\n });\n // Checkboxes save on CHANGE. They cannot use the blur path below: that path\n // only fires when \\`value !== defaultValue\\`, and for a checkbox both are the\n // \"on\" content attribute, so it would never save at all.\n document.querySelectorAll(\"input[type=checkbox][data-detail-field]\").forEach((c) => {\n c.addEventListener(\"change\", () => saveDetail(c, document));\n });\n document.querySelectorAll(\"input:not([type=checkbox])[data-detail-field], textarea[data-detail-field]\").forEach((i) => {\n // A save needs BOTH a real edit gesture and a changed value. \\`value !==\n // defaultValue\\` alone is not evidence of an edit: the browser rewrites\n // \\`.value\\` on its own when a control is handed a value it cannot represent\n // (an ISO datetime in a date input sanitizes to \"\"), and the blur that\n // follows an untouched tab-through then posts that empty string as if the\n // operator had cleared the field. Requiring an \\`input\\` event first means\n // only a human keystroke or picker choice can arm the save.\n i.addEventListener(\"input\", () => { i.dataset.edited = \"1\"; });\n i.addEventListener(\"blur\", () => {\n if (i.dataset.edited === \"1\" && i.value !== i.defaultValue) saveDetail(i, document);\n });\n });\n // Refresh preview: dispatch the re-render workflow, then tell the operator to\n // reload once it lands. No polling — the run takes a minute or two and a\n // spinner that long reads as a hang.\n document.querySelectorAll(\"button.rerender\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n b.textContent = \"Refreshing\\u2026\";\n try {\n const res = await fetch(b.dataset.rerenderUrl, { method: \"POST\" });\n b.textContent = res.ok ? \"Queued \\u2713 reload in ~2 min\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });\n // Report commentary: save on blur, only when it actually changed. Same\n // shape as the site-details editor, but keyed on the REPORT id rather than a\n // field name, so it posts { text } to the report's own endpoint.\n document.querySelectorAll(\"textarea[data-commentary-for]\").forEach((t) => {\n t.addEventListener(\"blur\", () => {\n if (t.value === t.defaultValue) return;\n const span = document.querySelector('.commentary-saved[data-for=\"' + t.dataset.commentaryFor + '\"]');\n fetch(t.dataset.commentaryUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ text: t.value }),\n })\n .then((r) => {\n if (span) span.textContent = r.ok ? \" \\u2713\" : \" \\u2717\";\n if (r.ok) t.defaultValue = t.value;\n })\n .catch(() => {\n if (span) span.textContent = \" \\u2717\";\n });\n });\n });\n ${SUBMISSION_STATUS_SCRIPT}\n </script>\n</body>\n</html>`;\n}\n","// src/dashboard/fleet-cockpit.ts\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport {\n siteSlug,\n isDashboardVisible,\n isArchivedStatus,\n isUnrecognizedStatus,\n} from \"../reports/airtable/websites.js\";\nimport type { AttentionItem } from \"../alerts/attention.js\";\nimport { isPendingApproval } from \"../reports/airtable/reports.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport type { ReportType } from \"../reports/types.js\";\nimport type { SubmissionRow, FormType } from \"../reports/submission-row.js\";\nimport { isLeadFormType } from \"../forms/types.js\";\nimport type { FleetEvent, FleetEventType } from \"../db/fleet-events.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectPreflightBlocked,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\n collectTurnstileGuardrailAlerts,\n collectNotifyBounceAlerts,\n collectPrismicDriftAlerts,\n prismicAckIsLive,\n} from \"../alerts/digest-collectors.js\";\nimport { diffAttention, type DigestSnapshot } from \"../alerts/digest-state.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { isNetlifyAppUrl } from \"../util/url.js\";\n\nexport type Tier = \"attention\" | \"watch\" | \"healthy\" | \"pre-launch\";\n\n/** Watch-tier thresholds (the soft band beneath the M5 alert floor). */\nconst LIGHTHOUSE_FLOOR = 75; // mirrors collectLighthouseAlerts — at/above is not an attention item\nconst LIGHTHOUSE_WATCH_HIGH = 85; // [75,85) = \"near the floor\" → watch\nconst STALE_DAYS = 30;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** Netlify deploy states the dashboard treats as a successful production build. */\nconst READY_DEPLOY_STATES: ReadonlySet<string> = new Set([\"ready\"]);\n/** Netlify deploy states that mean the build actually failed (→ attention tier + red badge). */\nconst FAILED_DEPLOY_STATES: ReadonlySet<string> = new Set([\"error\", \"failed\", \"rejected\"]);\n\n/** True when the persisted deploy status is a successful production build. PURE. */\nexport function isReadyDeployStatus(status: string | null): boolean {\n return status !== null && READY_DEPLOY_STATES.has(status.toLowerCase());\n}\n/** True when the persisted deploy status is a failed build. PURE. */\nexport function isFailedDeployStatus(status: string | null): boolean {\n return status !== null && FAILED_DEPLOY_STATES.has(status.toLowerCase());\n}\n\nconst WATCH_CATEGORIES: ReadonlyArray<{\n field: \"pScore\" | \"rScore\" | \"bpScore\" | \"seoScore\";\n label: string;\n}> = [\n { field: \"pScore\", label: \"Performance\" },\n { field: \"rScore\", label: \"Accessibility\" },\n { field: \"bpScore\", label: \"Best Practices\" },\n { field: \"seoScore\", label: \"SEO\" },\n];\n\n/**\n * Tier a single site from its attention items + soft watch rules. PURE; `now` is\n * injected for testability. Any attention item → 🔴 attention (items already encode\n * the M5 thresholds, so a sub-75 Lighthouse score arrives here as an item and never\n * needs the watch band). A FAILED latest production deploy (`deployStatus === \"failed\"`/\n * \"error\") is the same severity → 🔴 attention. Otherwise 🟡 watch when a Lighthouse\n * category sits in [75,85), the last commit to `main` is older than 30 days, or a\n * maintenance site is still on `*.netlify.app`. Else 🟢 healthy.\n *\n * Each active watch condition is a structured candidate with a set of accept keys\n * (aliases — e.g. the Netlify/no-custom-domain condition accepts \"no custom domain\",\n * \"netlify\", \"netlify.app\", …); a condition the operator has marked accepted (any of\n * its keys, case-insensitive, in `site.acceptedWatchConditions`) is routed to\n * `acceptedReasons` instead of `watchReasons` — it leaves the watch band (an\n * all-accepted site becomes healthy) but stays visible as a muted chip. Acceptance is\n * keyed on the STABLE signal token, not the volatile reason text, so accepting\n * \"performance\" tolerates a score of 82→78 (both watch). Acceptance is watch-only: a\n * sub-floor Lighthouse score arrives as an AttentionItem above and still alarms broken,\n * so accepting \"78\" never hides a drop to \"72\".\n *\n * `watchReasons` are the human labels for the card; `watchAcceptKeys` is the primary/\n * canonical accept token per un-accepted reason (index-aligned — surfaced on the card so\n * the operator can see the exact string that would mute it); `watchSignals` are the\n * STRUCTURED filter tags (\"lighthouse\" / \"stale\") the client filter keys off.\n */\n/** One detected watch condition, before acceptance is applied. `signal` is the\n * client-filter tag; `acceptKeys` is every string the operator can type to mute it,\n * PRIMARY FIRST — that primary is the token surfaced on the card for discoverability;\n * `reason` is the human label. The tuple type guarantees a primary exists. */\ntype WatchCandidate = { signal: string; acceptKeys: [string, ...string[]]; reason: string };\n\n/** Which attention items PIERCE the pre-launch mute. A \"launching\" site mutes\n * expected pre-launch conditions (early/absent Lighthouse, errored deploy,\n * Renovate/analytics warnings) — but a genuine alarm must not hide behind the\n * mute: 2026-07, a human content push left hedloc's default-branch CI red for\n * two days and the cockpit hid it; the daily digest (which runs these same\n * collectors over ALL sites with no pre-launch mute) was the only alarm surface.\n * The cockpit now surfaces the genuine subset of what the digest already shows —\n * not a third behavior. Pierces: any CRITICAL-severity item (turnstile guardrail,\n * notify-bounce, spam complaint, approved-report preflight) plus default-branch\n * CI red (`kind === \"ci\"` — warning severity, but a broken repo is never expected\n * pre-launch noise). A vuln pierces ONLY once Renovate's auto-fix is exhausted —\n * before that the fleet is still self-patching it and the operator has asked not\n * to hear about it (a day-zero critical CVE alone must not re-alarm a pre-launch\n * site while auto-fix is running). PURE. */\nexport function piercesPreLaunchMute(item: AttentionItem): boolean {\n if (item.kind === \"vuln\") return item.autoFixExhausted === true;\n return item.severity === \"critical\" || item.kind === \"ci\";\n}\n\nexport function assignTier(\n site: WebsiteRow,\n items: AttentionItem[],\n now: Date,\n): {\n tier: Tier;\n watchReasons: string[];\n /** Primary accept token per un-accepted watch reason, index-aligned with\n * `watchReasons` — the exact string that would mute it (discoverability). */\n watchAcceptKeys: string[];\n watchSignals: string[];\n acceptedReasons: string[];\n} {\n // Lifecycle short-circuit (FIRST, before any alarm rule): a \"launching\" site\n // is PRE-LIVE prep, not a live site (Status flips to \"maintained\" at go-live).\n // Its expected pre-launch conditions — no GA4 property, early/absent Lighthouse,\n // an errored/absent Netlify deploy, Renovate warnings — would otherwise force it\n // to 🔴 attention and read as \"broken\". Mute those as a calm \"pre-launch\" tier\n // that never alarms and is excluded from the \"needs you\" feed — but a GENUINE\n // alarm (piercesPreLaunchMute: any critical item, or CI red) re-tiers the site\n // to 🔴 attention through the normal machinery. (Only \"launching\" reaches\n // the cockpit — isDashboardVisible = {maintained, launching}.)\n if (site.status === \"launching\") {\n // Genuine alarms pierce the mute; this attention return sits ABOVE the\n // accepted-watch loop, so an operator ack can never silence a pierced alarm —\n // the same invariant the live-site items short-circuit keeps. Everything else\n // (incl. a failed deploy, checked further below only for live sites) stays\n // muted as expected pre-launch conditions.\n if (items.some(piercesPreLaunchMute))\n return {\n tier: \"attention\",\n watchReasons: [],\n watchAcceptKeys: [],\n watchSignals: [],\n acceptedReasons: [],\n };\n return {\n tier: \"pre-launch\",\n watchReasons: [],\n watchAcceptKeys: [],\n watchSignals: [],\n acceptedReasons: [],\n };\n }\n if (items.length > 0)\n return {\n tier: \"attention\",\n watchReasons: [],\n watchAcceptKeys: [],\n watchSignals: [],\n acceptedReasons: [],\n };\n // A failed latest production deploy is an active break — tier it 🔴 attention, the\n // same severity a sub-floor Lighthouse score gets (which arrives as an item above).\n if (isFailedDeployStatus(site.deployStatus))\n return {\n tier: \"attention\",\n watchReasons: [],\n watchAcceptKeys: [],\n watchSignals: [],\n acceptedReasons: [],\n };\n\n // Conditions the operator has reviewed and accepted (case-insensitive). An accepted\n // watch reason is routed to acceptedReasons instead of raising the watch band.\n const accepted = new Set(site.acceptedWatchConditions.map((c) => c.trim().toLowerCase()));\n\n // Collect every active watch condition as a structured candidate; a single generic\n // matcher below routes each to acceptedReasons (muted) or watchReasons, keyed on the\n // stable signal/category token (never the volatile reason text, which carries a\n // score/age that changes daily). Adding a new watch candidate here makes it acceptable\n // AND discoverable with no new accept branch.\n const candidates: WatchCandidate[] = [];\n for (const cat of WATCH_CATEGORIES) {\n const score = site[cat.field];\n if (score !== null && score >= LIGHTHOUSE_FLOOR && score < LIGHTHOUSE_WATCH_HIGH) {\n candidates.push({\n signal: \"lighthouse\",\n acceptKeys: [cat.label.toLowerCase()],\n reason: `${cat.label} ${score}`,\n });\n }\n }\n if (site.lastCommitAt !== null) {\n const ageMs = now.getTime() - Date.parse(site.lastCommitAt);\n if (Number.isFinite(ageMs) && ageMs > STALE_DAYS * MS_PER_DAY) {\n candidates.push({\n signal: \"stale\",\n acceptKeys: [\"stale repo\", \"stale\"],\n reason: `last commit ${relativeTimeFromNow(site.lastCommitAt, now)}`,\n });\n }\n }\n // A live (maintenance) site still served from *.netlify.app never got a custom\n // domain — a launch-completeness gap. Only for maintenance: a launch-period site on\n // netlify.app is expected (not launched yet).\n if (site.status === \"maintained\" && isNetlifyAppUrl(site.url)) {\n candidates.push({\n signal: \"no-domain\",\n acceptKeys: [\"no custom domain\", \"no-domain\", \"netlify\", \"netlify.app\", \"on netlify\"],\n reason: \"on *.netlify.app (no custom domain)\",\n });\n }\n // Require-Turnstile guardrail, watch half: the flag hard-buckets token-less\n // submissions, so a gated site whose widget state ISN'T positively confirmed\n // deserves a nag. A fresh confirmed \"fail\" never reaches here — that is a CRITICAL\n // AttentionItem (collectTurnstileGuardrailAlerts) caught by the items short-circuit\n // ABOVE the accept loop, so an accept key can mute this \"can't verify\" watch but\n // can never silence the confirmed-missing alarm. `!== \"pass\"` covers both null\n // (older package /health without a forms block, or the sweep never ran) and a\n // stale \"fail\" the collector downgraded.\n if (site.requireTurnstile && site.turnstileWidget !== \"pass\") {\n candidates.push({\n signal: \"turnstile-unverified\",\n acceptKeys: [\"turnstile-unverified\", \"turnstile unverified\", \"turnstile\"],\n reason:\n site.turnstileWidget === \"fail\"\n ? \"Require Turnstile on; widget missing per a stale health sweep\"\n : \"Require Turnstile on; widget not verifiable from /health\",\n });\n }\n\n const watchReasons: string[] = [];\n const watchAcceptKeys: string[] = [];\n const acceptedReasons: string[] = [];\n const signals = new Set<string>();\n for (const cand of candidates) {\n if (cand.acceptKeys.some((k) => accepted.has(k))) {\n acceptedReasons.push(cand.reason);\n } else {\n watchReasons.push(cand.reason);\n watchAcceptKeys.push(cand.acceptKeys[0]); // primary/canonical token — surfaced on the card\n signals.add(cand.signal);\n }\n }\n // A Prismic divergence the operator accepted stays VISIBLE, as a muted chip. The\n // item itself was suppressed upstream (`collectPrismicDriftAlerts`), so without\n // this the site would read as plainly healthy and the acceptance would be\n // invisible — an accepted finding is still a finding, and the expiry date is the\n // part the operator most needs on the card. Not a WatchCandidate: this is already\n // acked by its own column, and routing it through `accepted` would let a stray\n // \"prismic\" string in Accepted Watch Conditions mute it with no expiry.\n if (site.prismicModels === \"fail\" && prismicAckIsLive(site.prismicAckUntil, now)) {\n const until = new Date(Date.parse(site.prismicAckUntil!)).toISOString().slice(0, 10);\n acceptedReasons.push(`Prismic drift accepted until ${until}`);\n }\n\n return watchReasons.length > 0\n ? { tier: \"watch\", watchReasons, watchAcceptKeys, watchSignals: [...signals], acceptedReasons }\n : {\n tier: \"healthy\",\n watchReasons: [],\n watchAcceptKeys: [],\n watchSignals: [],\n acceptedReasons,\n };\n}\n\nexport type SiteCard = {\n site: WebsiteRow;\n tier: Tier;\n /** This site's tagged attention items (status already set), critical-first. */\n items: AttentionItem[];\n /** Why the site is on Watch — human labels (empty unless tier === \"watch\"). */\n watchReasons: string[];\n /** Primary accept token per watch reason, index-aligned with `watchReasons` — the\n * exact string the operator can add to Accepted Watch Conditions to mute it. Optional\n * for back-compat with hand-built card fixtures; the renderer falls back to no hint. */\n watchAcceptKeys?: string[];\n /** Structured watch tags (\"lighthouse\" / \"stale\") for the client filter. */\n watchSignals: string[];\n /** Watch reasons the operator has accepted: suppressed from the band, shown as a\n * muted chip. Populated whenever the underlying condition is currently active. */\n acceptedReasons: string[];\n /** Count of NEW submissions for this site (optional; populated by buildCockpitModel). */\n newSubmissions?: number;\n /** Split of newSubmissions: actionable leads vs newsletter/rsvp signups (2026-07-16).\n * Optional for back-compat with hand-built card fixtures. */\n newLeads?: number;\n newSignups?: number;\n};\n\nexport type PendingEntry = {\n reportId: string;\n siteName: string;\n slug: string;\n reportType: ReportType;\n period: string;\n};\n\nexport type SubmissionEntry = {\n submissionId: string;\n siteName: string;\n slug: string;\n formType: FormType;\n name: string;\n email: string;\n submittedAt: string | null;\n};\n\nexport type CockpitSummary = {\n attention: number;\n watch: number;\n healthy: number;\n /** Count of pre-live \"launching\" sites still muted as expected pre-launch\n * conditions. A piercing alarm (piercesPreLaunchMute) re-tiers such a site to\n * attention, so it counts there instead — muted never means \"hidden if broken\". */\n preLaunch: number;\n criticalHighVulns: number;\n lighthouseBelowFloor: number;\n deliveryFailures: number;\n renovateFailing: number;\n ciRed: number;\n /** Count of sites whose vuln has exhausted the Renovate auto-fix (manual fix needed). */\n autoFixStuck: number;\n pending: number;\n /** Count of NEW submissions across the fleet (optional for back-compat). */\n newSubmissions?: number;\n /** Split of newSubmissions: actionable leads vs newsletter/rsvp signups\n * (optional for back-compat, 2026-07-16). */\n newLeads?: number;\n newSignups?: number;\n};\n\n/** A render-ready row for the cockpit \"Recently\" lane. `url` is an external link\n * (PR) when present; `slug` links to `/s/<slug>` when the event names a site. */\nexport type RecentEntry = {\n type: FleetEventType;\n summary: string;\n siteName: string | null;\n slug: string | null;\n url: string | null;\n ts: string;\n};\n\n/** A Websites row surfaced OUTSIDE the fleet cards: archived (legacy/deprecated)\n * or holding an unrecognized Status cell.\n *\n * `status` is the RAW Airtable cell, not the canonical status (#539 Phase 4).\n * These two lanes exist to mirror what Airtable holds so a row can never\n * silently vanish, and both of them survive on the distinction the canonical\n * vocabulary erases: `legacy` and `deprecated` both canonicalize to `archived`,\n * so labelling the lane canonically would render 12 live rows identically and\n * disagree with the cell the operator is looking at. Everything else about\n * these rows — which lane they land in — is decided canonically. */\nexport type OffFleetSiteEntry = { name: string; slug: string; status: string };\n\nexport type CockpitModel = {\n summary: CockpitSummary;\n /** All visible sites, ordered: attention (worst-first) → watch (A-Z) → healthy (A-Z). */\n cards: SiteCard[];\n pending: PendingEntry[];\n /** NEW submissions across the fleet, newest-first (optional for back-compat). */\n submissions?: SubmissionEntry[];\n /** Fleet spam totals over the window (optional; populated by buildCockpitModel). */\n spam?: { caught: number; through: number } | null;\n /** Recent fleet-activity events for the \"Recently\" lane (optional for back-compat). */\n recent?: RecentEntry[];\n /** Fleet-wide count of submissions auto-filtered as spam in the affordance window\n * (optional for back-compat). Drives the cockpit \"N auto-filtered this week\" line. */\n autoFiltered?: number;\n /** Archived (legacy/deprecated) rows — excluded from every fleet op, listed on a\n * neutral collapsed lane so a row can never silently vanish (optional for back-compat). */\n archived?: OffFleetSiteEntry[];\n /** Rows whose Status cell is outside the code's union (typo / renamed option) —\n * invisible to every fleet op, so the Needs-you feed surfaces them as amber watch\n * rows (optional for back-compat). */\n unrecognizedStatus?: OffFleetSiteEntry[];\n};\n\nexport type NeedsYouGroup = \"broken\" | \"watch\" | \"approval\";\n\n/** One row of the per-site \"Needs you\" feed: every reason a single site needs the\n * operator, combined. The feed is navigation-only — the row links once to the page. */\nexport type NeedsYouItem = {\n /** The site's worst category present — drives the dot, the group sub-label, and order. */\n group: NeedsYouGroup;\n /** Any of the site's broken items is `severity: \"critical\"` (within-broken ordering). */\n hasCritical: boolean;\n slug: string;\n siteName: string;\n reasons: string[];\n /** Always `/s/${slug}`. */\n url: string;\n};\n\nconst NEEDS_YOU_GROUP_RANK: Record<NeedsYouGroup, number> = { broken: 0, watch: 1, approval: 2 };\n\n/**\n * Collapse the cockpit model into a per-site \"Needs you\" feed — ONE row per site,\n * with every reason combined. PURE. A non-exhausted vuln is amber `watch` (the fleet\n * is auto-patching it); an exhausted vuln (`item.autoFixExhausted`) is a hard `broken`\n * break, as is any non-vuln attention item. The whole watch tier folds into `watch`.\n * Order: broken → watch → approval; within broken, critical-first; then site name.\n */\nexport function buildNeedsYouFeed(model: CockpitModel): NeedsYouItem[] {\n type Acc = {\n slug: string;\n siteName: string;\n reasons: string[];\n hasCritical: boolean;\n broken: boolean;\n watch: boolean;\n approval: boolean;\n };\n const bySlug = new Map<string, Acc>();\n const get = (siteName: string): Acc => {\n const slug = siteSlug(siteName);\n let a = bySlug.get(slug);\n if (!a) {\n a = {\n slug,\n siteName,\n reasons: [],\n hasCritical: false,\n broken: false,\n watch: false,\n approval: false,\n };\n bySlug.set(slug, a);\n }\n return a;\n };\n\n for (const card of model.cards) {\n // Only \"attention\" (broken) and \"watch\" cards enter the feed; a \"pre-launch\"\n // card is pre-live prep and intentionally never surfaces here as needing you.\n // (A launch-period site with a PIERCING alarm arrives as tier \"attention\" —\n // assignTier re-tiers it — so genuine pre-launch breaks do reach the feed.)\n if (card.tier === \"attention\") {\n // A self-patching vuln (present but not yet exhausted) is amber WATCH — the fleet\n // is auto-patching it. Every other item, INCLUDING an exhausted vuln, is a hard\n // break. A site with any hard break is broken and its self-patching vulns are not\n // separately listed (it is already red). Single-source the predicate so the two\n // partitions can never drift out of lockstep.\n const isSelfPatchingVuln = (it: AttentionItem): boolean =>\n it.kind === \"vuln\" && it.autoFixExhausted !== true;\n const hardBroken = card.items.filter((it) => !isSelfPatchingVuln(it));\n const selfPatchingVulns = card.items.filter(isSelfPatchingVuln);\n if (hardBroken.length > 0) {\n const a = get(card.site.name);\n for (const it of hardBroken) {\n a.reasons.push(it.title);\n if (it.severity === \"critical\") a.hasCritical = true;\n }\n a.broken = true;\n } else if (selfPatchingVulns.length > 0) {\n const a = get(card.site.name);\n for (const it of selfPatchingVulns) a.reasons.push(it.title);\n a.watch = true;\n }\n } else if (card.tier === \"watch\" && card.watchReasons.length > 0) {\n const a = get(card.site.name);\n for (const r of card.watchReasons) a.reasons.push(r);\n a.watch = true;\n }\n }\n\n for (const p of model.pending) {\n const a = get(p.siteName);\n a.reasons.push(`${p.reportType} ${p.period} ready`);\n a.approval = true;\n }\n\n // A Websites row whose Status is outside the union is invisible to every fleet\n // op — surface it amber so a typo can never silently vanish a site.\n for (const u of model.unrecognizedStatus ?? []) {\n const a = get(u.name);\n a.reasons.push(\n `Status \"${u.status}\" is not a recognized value — fix it in Airtable (site is invisible to fleet ops)`,\n );\n a.watch = true;\n }\n\n const items: NeedsYouItem[] = [];\n for (const a of bySlug.values()) {\n if (a.reasons.length === 0) continue;\n const group: NeedsYouGroup = a.broken ? \"broken\" : a.watch ? \"watch\" : \"approval\";\n items.push({\n group,\n hasCritical: a.hasCritical,\n slug: a.slug,\n siteName: a.siteName,\n reasons: a.reasons,\n url: `/s/${a.slug}`,\n });\n }\n\n items.sort((x, y) => {\n if (NEEDS_YOU_GROUP_RANK[x.group] !== NEEDS_YOU_GROUP_RANK[y.group])\n return NEEDS_YOU_GROUP_RANK[x.group] - NEEDS_YOU_GROUP_RANK[y.group];\n if (x.group === \"broken\" && x.hasCritical !== y.hasCritical) return x.hasCritical ? -1 : 1;\n return x.siteName.toLowerCase().localeCompare(y.siteName.toLowerCase());\n });\n\n return items;\n}\n\nconst SEVERITY_RANK: Record<AttentionItem[\"severity\"], number> = { critical: 0, warning: 1 };\nconst TIER_RANK: Record<Tier, number> = { attention: 0, watch: 1, healthy: 2, \"pre-launch\": 3 };\n\n/** The fleet attention-item collector run — the SINGLE source of truth for which\n * signals the cockpit surfaces. buildCockpitModel (over all visible sites) and\n * buildSiteAlarmContext (over one site) both call this, so the two can never\n * drift: a signal added here shows up in both the fleet cockpit and every\n * /s/<slug> page at once. Returns items UNSORTED — callers apply their own\n * ordering (cockpit groups by site; the site page severity-sorts). PURE. */\nfunction collectFleetAttentionItems(\n sites: WebsiteRow[],\n sitesById: Map<string, WebsiteRow>,\n reports: ReportRow[],\n baseUrl: string,\n now: Date,\n notifyBounces: ReadonlyMap<string, number>,\n): AttentionItem[] {\n return [\n ...collectVulnAlerts(sites, baseUrl),\n ...collectLighthouseAlerts(sites, baseUrl),\n ...collectDeliveryFailures(reports, sitesById, baseUrl),\n ...collectPreflightBlocked(reports, sitesById, baseUrl),\n ...collectRenovateAlerts(sites, baseUrl, now),\n ...collectCiAlerts(sites, baseUrl, now),\n ...collectAnalyticsFailures(sites, baseUrl, now),\n ...collectTurnstileGuardrailAlerts(sites, baseUrl, now),\n ...collectNotifyBounceAlerts(sites, notifyBounces, baseUrl),\n // Reads the nightly-persisted `Prismic Models` verdict columns — no live\n // Prismic/GitHub call, so this stays safe on the request path (the cockpit is\n // request-path; a collector that shelled out would 502 in Lambda).\n ...collectPrismicDriftAlerts(sites, baseUrl, now),\n ];\n}\n\n/** The cockpit's alarm verdict for ONE site, for the /s/<slug> page header. */\nexport type SiteAlarmContext = {\n tier: Tier;\n /** This site's attention items, severity-sorted, status UNTAGGED (no diffAttention). */\n items: AttentionItem[];\n watchReasons: string[];\n watchAcceptKeys: string[];\n acceptedReasons: string[];\n};\n\n/**\n * The cockpit's alarm verdict for ONE site, for the /s/<slug> page header. Runs\n * the SAME collector list buildCockpitModel runs — both call the shared\n * collectFleetAttentionItems (the single source of truth), so the two structurally\n * cannot drift — and the same assignTier. A parity test (site-alarm-context.test.ts)\n * additionally asserts this function's item keys match buildCockpitModel's for the\n * same site. No diffAttention: the site page never writes digest state and shows no\n * NEW/WORSE badges (the digest alone writes the snapshot; the cockpit reads it\n * read-only).\n *\n * `reports` is expected to be site-scoped (listReportsForSite) — the delivery/\n * preflight collectors resolve report.siteId against a one-entry sitesById, so an\n * orphan report in the array would surface as an \"(unlinked site)\" preflight row.\n * Acceptable: the real caller only ever passes this site's reports.\n */\nexport function buildSiteAlarmContext(\n site: WebsiteRow,\n reports: ReportRow[],\n baseUrl: string,\n now: Date,\n notifyBounces: ReadonlyMap<string, number> = new Map(),\n): SiteAlarmContext {\n const sites = [site];\n const sitesById = new Map([[site.id, site]]);\n const items = collectFleetAttentionItems(\n sites,\n sitesById,\n reports,\n baseUrl,\n now,\n notifyBounces,\n ).sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);\n const {\n tier,\n watchReasons,\n watchAcceptKeys,\n watchSignals: _ws,\n acceptedReasons,\n } = assignTier(site, items, now);\n return { tier, items, watchReasons, watchAcceptKeys, acceptedReasons };\n}\n\n/**\n * Assemble the render-ready cockpit model from already-fetched Airtable rows. PURE\n * (`now` injected). Filters to dashboard-visible sites (maintenance or launch period),\n * runs the M5 collectors\n * over them, tags NEW/WORSE via diffAttention against the prior digest snapshot\n * (READ-ONLY — the returned `next` is discarded; only the daily digest writes state),\n * tiers each site, computes the summary, and resolves the pending-approval list.\n */\nexport function buildCockpitModel(\n websites: WebsiteRow[],\n reports: ReportRow[],\n priorSnapshot: DigestSnapshot,\n baseUrl: string,\n now: Date,\n newSubmissions: SubmissionRow[] = [],\n spamTotals: { honeypot: number; tooFast: number; markedSpam: number } | null = null,\n recentEvents: FleetEvent[] = [],\n autoFilteredCount = 0,\n // Per-site bounced-lead-notification counts (countNotifyBouncedBySite, last\n // NOTIFY_BOUNCE_WINDOW_DAYS). Optional like the other libSQL inputs: a Turso\n // blip drops the signal, never the cockpit (2026-07-16).\n notifyBounces: ReadonlyMap<string, number> = new Map(),\n): CockpitModel {\n const visible = websites.filter(isDashboardVisible);\n const sitesById = new Map<string, WebsiteRow>(visible.map((w) => [w.id, w]));\n\n // Off-fleet visibility: archived rows get a neutral roster lane; a typo'd Status\n // (outside the union) gets an amber watch row in the Needs-you feed. Both are\n // read-only surfacing — neither joins `visible`, so no fleet op gains a site.\n //\n // Membership is decided on the CANONICAL status; the label is the RAW cell.\n // These lanes are the fleet's mirror of Airtable, and `legacy`/`deprecated` are\n // the one pair the canonical vocabulary merges — labelling them `archived`\n // would collapse 12 distinguishable live rows into one indistinguishable\n // label and disagree with the cell it mirrors.\n //\n // The typo lane reads `statusRaw` for the same REASON, though not for the same\n // effect: canonicalization passes an unrecognized value through verbatim, so a\n // row in that lane always has `statusRaw === status`. Writing it this way is\n // consistency, not a fix — and it stays correct if that ever stops being true.\n const archived: OffFleetSiteEntry[] = websites\n .filter((w) => isArchivedStatus(w.status))\n .map((w) => ({\n name: w.name,\n slug: siteSlug(w.name),\n status: w.statusRaw ?? (w.status as string),\n }))\n .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));\n const unrecognizedStatus: OffFleetSiteEntry[] = websites\n .filter((w) => isUnrecognizedStatus(w.status))\n .map((w) => ({\n name: w.name,\n slug: siteSlug(w.name),\n status: w.statusRaw ?? (w.status as string),\n }));\n\n // Per-site NEW-submission counts, keyed by Websites record id, split into\n // actionable leads vs newsletter/rsvp signups (2026-07-16). Used for the\n // per-card badge below; the strip resolves entries against ALL sites.\n const subCountBySite = new Map<string, { leads: number; signups: number }>();\n for (const sub of newSubmissions) {\n let c = subCountBySite.get(sub.siteId);\n if (!c) {\n c = { leads: 0, signups: 0 };\n subCountBySite.set(sub.siteId, c);\n }\n if (isLeadFormType(sub.formType)) c.leads++;\n else c.signups++;\n }\n\n const rawItems = collectFleetAttentionItems(\n visible,\n sitesById,\n reports,\n baseUrl,\n now,\n notifyBounces,\n );\n // Read-only diff: tag NEW/WORSE exactly as the email does; discard `next`.\n const { tagged } = diffAttention(rawItems, priorSnapshot, now.toISOString().slice(0, 10));\n\n // Group by siteName (the collectors set siteName from the row). This relies on the\n // fleet-wide name→slug uniqueness invariant the /s/<slug> lookup already assumes; if\n // two visible sites ever shared a name they'd share a card. Acceptable for slice 1.\n const bySite = new Map<string, AttentionItem[]>();\n for (const it of tagged) {\n const bucket = bySite.get(it.siteName);\n if (bucket) bucket.push(it);\n else bySite.set(it.siteName, [it]);\n }\n\n const cards: SiteCard[] = visible.map((site) => {\n const collected = (bySite.get(site.name) ?? []).sort(\n (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity],\n );\n // A launch-period card carries only the piercing alarms: muted pre-launch noise\n // must not ride into the chips or the needs-you feed when a genuine alarm flips\n // the tier (and a still-muted card shows no alarm chips at all).\n const items = site.status === \"launching\" ? collected.filter(piercesPreLaunchMute) : collected;\n const { tier, watchReasons, watchAcceptKeys, watchSignals, acceptedReasons } = assignTier(\n site,\n items,\n now,\n );\n const c = subCountBySite.get(site.id) ?? { leads: 0, signups: 0 };\n return {\n site,\n tier,\n items,\n watchReasons,\n watchAcceptKeys,\n watchSignals,\n acceptedReasons,\n newSubmissions: c.leads + c.signups,\n newLeads: c.leads,\n newSignups: c.signups,\n };\n });\n\n cards.sort((a, b) => {\n if (TIER_RANK[a.tier] !== TIER_RANK[b.tier]) return TIER_RANK[a.tier] - TIER_RANK[b.tier];\n if (a.tier === \"attention\") {\n const sevA = a.items.some((i) => i.severity === \"critical\") ? 0 : 1;\n const sevB = b.items.some((i) => i.severity === \"critical\") ? 0 : 1;\n if (sevA !== sevB) return sevA - sevB;\n const metA = a.items.reduce((s, i) => s + i.metric, 0);\n const metB = b.items.reduce((s, i) => s + i.metric, 0);\n if (metA !== metB) return metB - metA;\n }\n return a.site.name.toLowerCase().localeCompare(b.site.name.toLowerCase());\n });\n\n const pending: PendingEntry[] = [];\n // Mirror listPendingApproval's predicate. Resolve against ALL websites (a pending\n // approval is never dropped just because the site is hidden from the fleet view).\n const allById = new Map<string, WebsiteRow>(websites.map((w) => [w.id, w]));\n for (const r of reports) {\n if (!isPendingApproval(r)) continue;\n const s = allById.get(r.siteId);\n if (!s) continue; // orphan → skip rather than render a broken link\n pending.push({\n reportId: r.id,\n siteName: s.name,\n slug: siteSlug(s.name),\n reportType: r.reportType,\n period: r.period ?? \"—\",\n });\n }\n\n const submissions: SubmissionEntry[] = [];\n for (const sub of newSubmissions) {\n const s = allById.get(sub.siteId);\n if (!s) continue; // orphan submission → skip rather than render a broken link\n submissions.push({\n submissionId: sub.id,\n siteName: s.name,\n slug: siteSlug(s.name),\n formType: sub.formType,\n name: sub.name,\n email: sub.email,\n submittedAt: sub.submittedAt,\n });\n }\n\n const summary: CockpitSummary = {\n attention: cards.filter((c) => c.tier === \"attention\").length,\n watch: cards.filter((c) => c.tier === \"watch\").length,\n healthy: cards.filter((c) => c.tier === \"healthy\").length,\n preLaunch: cards.filter((c) => c.tier === \"pre-launch\").length,\n criticalHighVulns: tagged.filter((i) => i.kind === \"vuln\").reduce((s, i) => s + i.metric, 0),\n lighthouseBelowFloor: tagged.filter((i) => i.kind === \"lighthouse\").length,\n deliveryFailures: tagged.filter((i) => i.kind === \"delivery\").length,\n renovateFailing: tagged.filter((i) => i.kind === \"renovate\").reduce((s, i) => s + i.metric, 0),\n ciRed: tagged.filter((i) => i.kind === \"ci\").length,\n autoFixStuck: tagged.filter((i) => i.autoFixExhausted).length,\n pending: pending.length,\n newSubmissions: submissions.length,\n // Same population as newSubmissions (resolved entries — orphans excluded).\n newLeads: submissions.filter((s) => isLeadFormType(s.formType)).length,\n newSignups: submissions.filter((s) => !isLeadFormType(s.formType)).length,\n };\n\n const recent: RecentEntry[] = recentEvents.map((e) => {\n const url =\n e.type === \"pr_automerged\" &&\n e.data !== null &&\n typeof e.data === \"object\" &&\n \"url\" in e.data &&\n typeof (e.data as { url: unknown }).url === \"string\"\n ? (e.data as { url: string }).url\n : null;\n return {\n type: e.type,\n summary: e.summary,\n siteName: e.siteName,\n slug: e.siteName ? siteSlug(e.siteName) : null,\n url,\n ts: e.ts,\n };\n });\n\n return {\n summary,\n cards,\n pending,\n submissions,\n spam: spamTotals\n ? { caught: spamTotals.honeypot + spamTotals.tooFast, through: spamTotals.markedSpam }\n : null,\n recent,\n autoFiltered: autoFilteredCount,\n archived,\n unrecognizedStatus,\n };\n}\n\n/** Most recent `lastLighthouseAuditAt` across the cards, or null if none recorded.\n * Drives the cockpit verdict's \"fleet last audited Xh ago\" line. PURE. */\nexport function fleetLastAuditedAt(cards: SiteCard[]): string | null {\n let latestIso: string | null = null;\n let latestMs = -Infinity;\n for (const c of cards) {\n const iso = c.site.lastLighthouseAuditAt;\n if (!iso) continue;\n const ms = Date.parse(iso);\n if (!Number.isFinite(ms)) continue;\n if (ms > latestMs) {\n latestMs = ms;\n latestIso = iso;\n }\n }\n return latestIso;\n}\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { siteSlug } from \"../reports/airtable/websites.js\";\nimport type { CockpitModel, SiteCard, Tier } from \"./fleet-cockpit.js\";\nimport { isReadyDeployStatus, isFailedDeployStatus } from \"./fleet-cockpit.js\";\nimport { onboardingStatus, missingOnboarding } from \"./onboarding.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\n\nconst DASH = \"—\";\n\n// ---- MOVED VERBATIM from fleet-render.ts (do not change their bodies) ----\n\nfunction scoreSpan(category: \"perf\" | \"a11y-lh\" | \"bp\" | \"seo\", value: number | null): string {\n const display = value === null ? DASH : String(value);\n return `<span class=\"score ${category}\">${escapeHtml(display)}</span>`;\n}\n\nfunction a11ySpan(value: number | null): string {\n const display = value === null ? DASH : String(value);\n return `<span class=\"metric a11y\">${escapeHtml(display)}</span>`;\n}\n\nfunction depsSpan(\n drifted: number | null,\n majorBehind: number | null,\n outdated: number | null,\n majorOutdated: number | null,\n): string {\n if (drifted === null || majorBehind === null) {\n return `<span class=\"metric deps\">${DASH}</span>`;\n }\n // Declared-range drift vs baseline, plus the real outdated-install count when\n // it was determined (null = not checked this run → omit, don't imply clean).\n const driftPart = drifted === 0 ? \"0\" : `${drifted} drifted (${majorBehind} major)`;\n // The outdated part carries its own \"(N major)\" — majors behind npm latest,\n // distinct from the baseline \"(major)\" on driftPart. Shown only when known.\n const outdatedPart =\n outdated === null\n ? \"\"\n : ` · ${outdated} outdated${majorOutdated === null ? \"\" : ` (${majorOutdated} major)`}`;\n return `<span class=\"metric deps\">${escapeHtml(driftPart + outdatedPart)}</span>`;\n}\n\nfunction securitySpan(\n critical: number | null,\n high: number | null,\n moderate: number | null,\n low: number | null,\n): string {\n if (critical === null || high === null || moderate === null || low === null) {\n return `<span class=\"metric sec\">${DASH}</span>`;\n }\n const total = critical + high + moderate + low;\n const display = total === 0 ? \"0\" : `${critical}C/${high}H/${moderate}M/${low}L`;\n return `<span class=\"metric sec\">${escapeHtml(display)}</span>`;\n}\n\nfunction card(site: WebsiteRow): string {\n const name = escapeHtml(site.name);\n // The per-site dashboard at /s/<slug> is operator-only, gated by the shared\n // dashboard password (no per-site token). Cockpit visibility is Status-based;\n // the caller filters the fleet view.\n const href = `/s/${escapeHtml(siteSlug(site.name))}`;\n const onboarding = onboardingStatus(site);\n const missing = missingOnboarding(site);\n const setupTitle = escapeHtml(\n missing.length === 0 ? \"Setup complete\" : `Missing: ${missing.join(\", \")}`,\n );\n const audited = relativeTimeFromNow(site.lastLighthouseAuditAt);\n const safeSiteUrl = escapeHtml(safeUrl(site.url));\n const visibleUrl = escapeHtml(site.url);\n\n return `<article class=\"card\">\n <header class=\"card-head\">\n <a class=\"site\" href=\"${href}\">${name}</a>\n <a class=\"url\" href=\"${safeSiteUrl}\" target=\"_blank\" rel=\"noopener\">${visibleUrl}</a>\n <span class=\"setup\" title=\"${setupTitle}\">Setup: <strong>${onboarding.score}/${onboarding.total}</strong></span>\n <span class=\"audited\">Audited: <strong>${escapeHtml(audited)}</strong></span>\n </header>\n <div class=\"card-metrics\">\n <span class=\"cluster lighthouse\">\n <span class=\"metric-label\">Perf</span> ${scoreSpan(\"perf\", site.pScore)}\n <span class=\"metric-label\">Access</span> ${scoreSpan(\"a11y-lh\", site.rScore)}\n <span class=\"metric-label\">BP</span> ${scoreSpan(\"bp\", site.bpScore)}\n <span class=\"metric-label\">SEO</span> ${scoreSpan(\"seo\", site.seoScore)}\n </span>\n <span class=\"cluster health\">\n <span class=\"metric-label\">a11y</span> ${a11ySpan(site.a11yViolations)}\n <span class=\"metric-label\">deps</span> ${depsSpan(site.depsDrifted, site.depsMajorBehind, site.depsOutdated, site.depsMajorOutdated)}\n <span class=\"metric-label\">sec</span> ${securitySpan(\n site.securityVulnsCritical,\n site.securityVulnsHigh,\n site.securityVulnsModerate,\n site.securityVulnsLow,\n )}\n <span class=\"metric-label\">deploy</span> ${deployBadge(site)}\n </span>\n </div>\n </article>`;\n}\n\n/** Small colored deploy badge: green = ready/success, red = failed, grey =\n * unknown/none. Shows the deploy state + a relative \"Xd ago\" timestamp, and links\n * to the deploy log when one is known. PURE. A null state renders a grey \"—\" (the\n * site has no Netlify id wired, or no deploy was read this run). */\nfunction deployBadge(site: WebsiteRow): string {\n const state = site.deployStatus;\n if (state === null) {\n return `<span class=\"metric deploy unknown\" title=\"No deploy data\">${DASH}</span>`;\n }\n const cls = isReadyDeployStatus(state)\n ? \"deploy ready\"\n : isFailedDeployStatus(state)\n ? \"deploy failed\"\n : \"deploy building\";\n const when = relativeTimeFromNow(site.lastDeployAt);\n const label = escapeHtml(`${state}${when !== DASH ? ` · ${when}` : \"\"}`);\n // Only link when we have a real http(s) URL — scheme-allowlist it via safeUrl (which\n // returns \"#\" for anything non-http), since deployLogUrl is data that flowed in from\n // the Netlify API via Airtable. A \"#\" fallback would be a dead link, so drop it.\n const url = site.deployLogUrl ? safeUrl(site.deployLogUrl) : \"#\";\n if (url !== \"#\") {\n return `<a class=\"metric ${cls}\" href=\"${escapeHtml(url)}\" target=\"_blank\" rel=\"noopener\">${label}</a>`;\n }\n return `<span class=\"metric ${cls}\">${label}</span>`;\n}\n\nfunction submBadge(c: SiteCard): string {\n // Lead/signup split (2026-07-16). Legacy cards without the split fields fall back\n // to labelling the whole count \"new\" — same rendering as before the split.\n const leads = c.newLeads ?? c.newSubmissions ?? 0;\n const signups = c.newSignups ?? 0;\n if (leads === 0 && signups === 0) return \"\";\n const label =\n leads > 0 && signups > 0\n ? `📥 ${leads} new · +${signups} newsletter`\n : leads > 0\n ? `📥 ${leads} new`\n : `📥 +${signups} newsletter`;\n return `<span class=\"chip\">${label}</span>`;\n}\n\nconst PILL_LABEL: Record<Tier, string> = {\n attention: \"failing\",\n watch: \"watch\",\n healthy: \"ok\",\n \"pre-launch\": \"pre-launch\",\n};\n\nfunction attentionBadge(status?: string): string {\n if (status === \"new\") return `<span class=\"badge\">NEW</span>`;\n if (status === \"worse\") return `<span class=\"badge\">WORSE</span>`;\n return \"\";\n}\n\nfunction chips(c: SiteCard): string {\n const items = c.items.map((it) => {\n const cls = it.autoFixExhausted\n ? \"chip critical stuck\"\n : it.severity === \"critical\"\n ? \"chip critical\"\n : \"chip\";\n return `<span class=\"${cls}\">${attentionBadge(it.status)}${escapeHtml(it.title)}</span>`;\n });\n // Surface the exact accept token beside each watch chip so the operator can discover\n // what string mutes it — e.g. `on *.netlify.app (no custom domain) · accept: \"no custom\n // domain\"`. The literal quotes stay outside escapeHtml (the key itself is escaped).\n const acceptKeys = c.watchAcceptKeys ?? [];\n c.watchReasons.forEach((reason, i) => {\n const key = acceptKeys[i];\n const hint = key ? ` · accept: \"${escapeHtml(key)}\"` : \"\";\n items.push(\n `<span class=\"chip\" title=\"Add this exact text to the site's Accepted Watch Conditions to mute this watch\">${escapeHtml(reason)}${hint}</span>`,\n );\n });\n for (const reason of c.acceptedReasons)\n items.push(`<span class=\"chip accepted\">✓ accepted: ${escapeHtml(reason)}</span>`);\n return items.length ? `<div class=\"chips\">${items.join(\"\")}</div>` : \"\";\n}\n\n/** Space-separated signal tags for the client filter. Attention-item kinds\n * (\"vulns\"/\"lighthouse\"/\"delivery\"/\"prs\" from renovate/\"ci\") plus the structured\n * watch signals (\"lighthouse\" for a sub-floor-band score, \"stale\" for an old\n * commit) — so a watch-band Lighthouse card still matches the \"lighthouse\" filter. */\nfunction signalsAttr(c: SiteCard): string {\n const kinds = new Set<string>();\n for (const it of c.items) {\n kinds.add(it.kind === \"vuln\" ? \"vulns\" : it.kind === \"renovate\" ? \"prs\" : it.kind);\n }\n if (c.items.some((it) => it.autoFixExhausted)) kinds.add(\"auto-fix-failed\");\n for (const sig of c.watchSignals) kinds.add(sig);\n return [...kinds].join(\" \");\n}\n\n/** On-demand Renovate trigger button — only for repo-backed sites (nothing to\n * dispatch otherwise). Posts to the authed /api/sites/:slug/trigger-renovate. */\nfunction triggerRenovateBtn(c: SiteCard): string {\n if (!c.site.gitRepo?.trim()) return \"\";\n const url = `/api/sites/${escapeHtml(siteSlug(c.site.name))}/trigger-renovate`;\n return `<button class=\"trigger-renovate\" data-trigger-url=\"${url}\">Trigger Renovate</button>`;\n}\n\nfunction cockpitCard(c: SiteCard): string {\n const base = card(c.site); // existing header + metrics markup\n const pill = `<span class=\"pill ${c.tier}\">${PILL_LABEL[c.tier]}</span>`;\n const extra = `${pill}${chips(c)}${submBadge(c)}${triggerRenovateBtn(c)}`;\n const opening = `<article class=\"card\" data-signals=\"${signalsAttr(c)}\">`;\n // Inject the pill + chips before the article's closing tag, and add the filter\n // hook. Function replacers so a `$` in escaped chip text can't be read as a\n // String.replace special ($&, $1, …).\n return base\n .replace('<article class=\"card\">', () => opening)\n .replace(\"</article>\", () => `${extra}</article>`);\n}\n\n/** Signal filters for the Fleet panel — card-derived tags only. `pending` lives in the\n * Needs-you feed and `submissions` in the Inbox lane, so they are not card filters. */\nconst FLEET_FILTERS = [\n \"all\",\n \"vulns\",\n \"lighthouse\",\n \"delivery\",\n \"prs\",\n \"ci\",\n \"auto-fix-failed\",\n \"stale\",\n \"no-domain\",\n] as const;\n\n/** The fleet browser: one collapsed <details> holding the filter chips and a single flat\n * card grid (cards already ordered attention→watch→healthy by buildCockpitModel).\n * Flattening — no nested per-tier <details> — is what makes the filters actually work:\n * a filtered card can never hide inside a collapsed tier. */\nexport function renderFleetBrowsePanel(model: CockpitModel): string {\n const total = model.cards.length;\n const chips = FLEET_FILTERS.map(\n (f) =>\n `<button type=\"button\" data-filter=\"${f}\" aria-pressed=\"${f === \"all\" ? \"true\" : \"false\"}\">${f}</button>`,\n ).join(\"\");\n const body =\n total === 0\n ? `<div class=\"empty\">No sites on the fleet view yet.</div>`\n : `<div class=\"cards\">${model.cards.map(cockpitCard).join(\"\")}</div>`;\n return `<details class=\"fleet-browse\">\n <summary>Fleet (${total})</summary>\n <div class=\"filters\">${chips}</div>\n ${body}\n </details>`;\n}\n\n/** Client behavior scoped to the Fleet panel: tag-based card filtering + the per-card\n * Trigger Renovate dispatch. String-concat only (lives inside a TS template literal). */\nexport const FLEET_BROWSE_SCRIPT = `<script>\n(function(){\n var btns = document.querySelectorAll('.fleet-browse .filters button');\n var cards = document.querySelectorAll('.fleet-browse .cards .card');\n btns.forEach(function(b){\n b.addEventListener('click', function(){\n var f = b.getAttribute('data-filter');\n btns.forEach(function(x){ x.setAttribute('aria-pressed', x===b ? 'true':'false'); });\n cards.forEach(function(c){\n var sig = (c.getAttribute('data-signals')||'').split(' ');\n c.style.display = (f==='all' || sig.indexOf(f)!==-1) ? '' : 'none';\n });\n });\n });\n document.querySelectorAll('.fleet-browse button.trigger-renovate').forEach(function(b){\n b.addEventListener('click', async function(){\n b.disabled = true; b.textContent = 'Dispatching…';\n try { var res = await fetch(b.dataset.triggerUrl, { method: 'POST' });\n b.textContent = res.ok ? 'Dispatched ✓' : 'Failed';\n if (!res.ok) b.disabled = false; }\n catch(e){ b.textContent = 'Failed'; b.disabled = false; }\n });\n });\n})();\n</script>`;\n","import { renderAuthChrome } from \"./auth/render.js\";\nimport type {\n CockpitModel,\n SubmissionEntry,\n NeedsYouItem,\n NeedsYouGroup,\n RecentEntry,\n} from \"./fleet-cockpit.js\";\nimport { fleetLastAuditedAt, buildNeedsYouFeed } from \"./fleet-cockpit.js\";\nimport { isLeadFormType } from \"../forms/types.js\";\nimport type { FleetEventType } from \"../db/fleet-events.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml } from \"../util/html.js\";\nimport { FAVICON_LINK } from \"./favicon.js\";\nimport { renderFleetBrowsePanel, FLEET_BROWSE_SCRIPT } from \"./fleet-browse-render.js\";\n\nconst STYLES = `\n:root { color-scheme: light dark; }\nbody { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }\n@media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } }\nh1 { margin: 0 0 0.25rem; font-size: 1.75rem; }\n.meta { color: #666; margin-bottom: 1.5rem; }\n.empty { color: #999; padding: 2rem; text-align: center; border: 1px dashed #ccc; border-radius: 6px; }\n.cards { display: flex; flex-direction: column; gap: 0.75rem; }\n.card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 0.9rem 1.1rem; }\n@media (prefers-color-scheme: dark) { .card { border-color: #2a2a2a; background: #181818; } }\n.card-head { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; align-items: baseline; }\n.card-head .site { font-weight: 600; font-size: 1.05rem; }\n.card-head .url { color: #666; font-size: 0.85rem; }\n.card-head .setup, .card-head .audited { color: #666; font-size: 0.85rem; }\n.card-head .setup { margin-left: auto; }\n.card-metrics { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; margin-top: 0.5rem; font-variant-numeric: tabular-nums; }\n.cluster { display: inline-flex; gap: 0.5rem; align-items: baseline; }\n.cluster.lighthouse .score { display: inline-block; min-width: 2.25rem; text-align: right; }\n.metric-label { color: #999; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }\n.metric { font-feature-settings: \"tnum\"; }\n.metric.deploy { font-size:0.78rem; padding:0.05rem 0.4rem; border-radius:6px; text-decoration:none; }\n.metric.deploy.ready { background:#e6f4ea; color:#1a7f37; }\n.metric.deploy.failed { background:#fdecea; color:#b00; font-weight:600; }\n.metric.deploy.building { background:#fff4e5; color:#9a6700; }\n.metric.deploy.unknown { color:#999; }\n@media (prefers-color-scheme: dark) {\n .metric.deploy.ready { background:#0f2417; color:#4ac26b; }\n .metric.deploy.failed { background:#3a1412; color:#ff6b6b; }\n .metric.deploy.building { background:#2e2410; color:#e3b341; }\n}\n.spam-rollup { font-size:0.9rem; margin-bottom:1rem; }\n.muted { color:#999; }\n.filters { display:flex; flex-wrap:wrap; gap:0.4rem; margin-bottom:1.25rem; }\n.filters button { font:inherit; font-size:0.85rem; padding:0.25rem 0.7rem; border:1px solid #ccc; border-radius:999px; background:transparent; color:inherit; cursor:pointer; }\n.filters button[aria-pressed=\"true\"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }\n@media (prefers-color-scheme: dark) { .filters button[aria-pressed=\"true\"] { background:#e8e8e8; color:#111; } }\n.fleet-actions { margin-bottom:1.25rem; }\n.refresh-fleet { font:inherit; font-size:0.85rem; padding:0.3rem 0.8rem; border:1px solid #1a1a1a; border-radius:999px; background:#1a1a1a; color:#fff; cursor:pointer; }\n.refresh-fleet:disabled { opacity:0.6; cursor:default; }\n@media (prefers-color-scheme: dark) { .refresh-fleet { background:#e8e8e8; color:#111; border-color:#e8e8e8; } }\n.rf-status { margin-top:0.6rem; font-size:0.85rem; }\n.rf-row { padding:0.1rem 0; }\n.rf-row a { margin-left:0.3rem; }\n.rf-sub { color:#999; font-size:0.8rem; margin-left:1.1rem; }\n.rf-spin { display:inline-block; width:0.8em; height:0.8em; border:2px solid #999; border-top-color:transparent; border-radius:50%; animation:rf-spin 0.8s linear infinite; vertical-align:-0.1em; }\n@keyframes rf-spin { to { transform:rotate(360deg); } }\ndetails.fleet-browse, details.inbox { margin:0.75rem 0; }\ndetails.fleet-browse > summary, details.inbox > summary { cursor:pointer; font-weight:700; font-size:1.05rem; padding:0.35rem 0; list-style:none; }\n.approve-row { display:flex; flex-wrap:wrap; gap:0.5rem 1rem; align-items:center; padding:0.25rem 0; }\n.pill { font-size:0.75rem; padding:0.1rem 0.5rem; border-radius:999px; font-weight:700; }\n.pill.attention { background:#fdecea; color:#b00; }\n.pill.watch { background:#fff4e5; color:#a65a00; }\n.pill.healthy { background:#e8f5e9; color:#1b7a2f; }\n.pill.pre-launch { background:#eef1f5; color:#5b6570; }\n@media (prefers-color-scheme: dark) { .pill.pre-launch { background:#1c2530; color:#9aa7b4; } }\n.chips { display:flex; flex-wrap:wrap; gap:0.4rem; margin-top:0.5rem; }\n.chip { font-size:0.8rem; padding:0.1rem 0.5rem; border-radius:6px; background:#f0f0f0; }\n@media (prefers-color-scheme: dark) { .chip { background:#222; } }\n.chip.critical { background:#fdecea; color:#b00; }\n.chip.stuck { border:1px solid #b00; font-weight:600; }\n.chip.accepted { background:transparent; border:1px dashed #bbb; color:#888; }\n@media (prefers-color-scheme: dark) { .chip.accepted { border-color:#444; color:#888; } }\n.badge { font-weight:700; color:#C00; font-size:0.72rem; margin-right:0.25rem; }\n.verdict { border-radius:8px; padding:0.9rem 1.1rem; margin-bottom:1.25rem; }\n.verdict .verdict-line { font-weight:800; font-size:1.4rem; }\n.verdict .verdict-meta { color:#666; font-size:0.9rem; margin-top:0.2rem; }\n.verdict.ok { background:#e8f5e9; color:#1b7a2f; }\n.verdict.ok .verdict-meta { color:#2e7d32; }\n.verdict.warn { background:#fdecea; color:#b00; }\n.verdict.warn .verdict-meta { color:#b00; opacity:0.85; }\n.verdict.watch { background:#fff4e5; color:#a65a00; }\n.verdict.watch .verdict-meta { color:#a65a00; opacity:0.85; }\n.verdict.soft { background:#e7f1ff; color:#1c5d99; }\n.verdict.soft .verdict-meta { color:#1c5d99; opacity:0.85; }\n@media (prefers-color-scheme: dark) { .verdict.ok { background:#10240f; color:#7fce85; } .verdict.warn { background:#2a0f0d; color:#ff8a80; } .verdict.watch { background:#2a2410; color:#ffd454; } .verdict.soft { background:#0f1d2a; color:#7fb6e8; } }\n.verdict .fleet-actions { margin:0.6rem 0 0; }\n.needs-you { border:1px solid #e5e5e5; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.25rem; }\n@media (prefers-color-scheme: dark) { .needs-you { border-color:#2a2a2a; background:#181818; } }\n.needs-you h2 { font-size:1.05rem; margin:0 0 0.5rem; }\n.feed-group-label { text-transform:uppercase; letter-spacing:0.04em; font-size:0.72rem; color:#999; margin:0.6rem 0 0.2rem; }\n.feed-row { display:flex; gap:0.5rem; align-items:center; padding:0.3rem 0; border-bottom:1px dashed #eee; }\n.feed-row:last-child { border-bottom:0; }\n@media (prefers-color-scheme: dark) { .feed-row { border-bottom-color:#262626; } }\n.feed-what { flex:1; }\n.feed-open { white-space:nowrap; }\n.dot { width:0.55rem; height:0.55rem; border-radius:50%; display:inline-block; flex:0 0 auto; }\n.dot.broken { background:#dc2626; }\n.dot.approval { background:#2563eb; }\n.dot.watch { background:#f59e0b; }\n`;\n\n/** Per-band site counts for the verdict, derived from the Needs-you feed. PURE. */\nfunction needsYouCounts(feed: NeedsYouItem[]): {\n broken: number;\n watch: number;\n approval: number;\n} {\n let broken = 0;\n let watch = 0;\n let approval = 0;\n for (const i of feed) {\n if (i.group === \"broken\") broken++;\n else if (i.group === \"watch\") watch++;\n else approval++;\n }\n return { broken, watch, approval };\n}\n\n/** The glance verdict — worst band wins. Green \"✓ All clear\" on an empty feed; else\n * red (any broken), amber (watch, nothing broken), or blue (only approvals). Every\n * lower band's count + the healthy count ride in the meta line (zero terms omitted),\n * followed by the audit-recency suffix. Houses the ↻ Audit button + live panel.\n * CSS-class ↔ band map: ok=empty, warn=broken, watch=watch, soft=approval — `warn`\n * and `soft` are inherited from the prior binary verdict, so they differ from the\n * feed's `broken`/`approval` group names. */\nfunction verdictBar(model: CockpitModel, feed: NeedsYouItem[]): string {\n const auditedIso = fleetLastAuditedAt(model.cards);\n const auditedTerm = auditedIso\n ? `fleet last audited ${escapeHtml(relativeTimeFromNow(auditedIso))}`\n : null;\n const total = model.cards.length;\n const { broken, watch, approval } = needsYouCounts(feed);\n // Pre-launch (pre-live) cards never enter the feed and must not be counted as\n // \"healthy\" nor as \"broken\" — surface them as their own neutral meta term.\n const preLaunch = model.summary.preLaunch;\n const preLaunchTerm = preLaunch > 0 ? `${preLaunch} pre-launch` : null;\n // Archived (legacy/deprecated) rows are not in model.cards, so they never join\n // the healthy math below — a neutral count term only, mirroring pre-launch.\n const archivedCount = model.archived?.length ?? 0;\n const archivedTerm = archivedCount > 0 ? `${archivedCount} archived` : null;\n // Clamp: feed counts include pending-report sites, which (rarely) may not be among\n // the visible cards, so total − feed could otherwise underflow to a negative \"healthy\".\n const healthy = Math.max(0, total - (broken + watch + approval + preLaunch));\n const actions = `<div class=\"fleet-actions\">\n <button type=\"button\" class=\"refresh-fleet\" data-refresh-url=\"/api/fleet/refresh\">↻ Audit fleet</button>\n <div id=\"rf-status\" class=\"rf-status\" aria-live=\"polite\"></div>\n </div>`;\n const render = (cls: string, line: string, terms: Array<string | null>): string =>\n `<div class=\"verdict ${cls}\">\n <div class=\"verdict-line\">${line}</div>\n <div class=\"verdict-meta\">${terms.filter(Boolean).join(\" · \")}</div>\n ${actions}\n </div>`;\n\n if (broken === 0 && watch === 0 && approval === 0) {\n const healthyWord = healthy > 0 ? `${healthy} site${healthy === 1 ? \"\" : \"s\"} healthy` : null;\n return render(\"ok\", \"✓ All clear\", [healthyWord, preLaunchTerm, archivedTerm, auditedTerm]);\n }\n\n // Count terms are integer-derived static English — no escaping needed (unlike auditedTerm).\n const watchTerm = watch > 0 ? `${watch} watching` : null;\n const approvalTerm = approval > 0 ? `${approval} waiting on you` : null;\n const healthyTerm = healthy > 0 ? `${healthy} healthy` : null;\n\n if (broken > 0) {\n return render(\"warn\", `⚠ ${broken} site${broken === 1 ? \"\" : \"s\"} broken`, [\n watchTerm,\n approvalTerm,\n healthyTerm,\n preLaunchTerm,\n archivedTerm,\n auditedTerm,\n ]);\n }\n if (watch > 0) {\n return render(\"watch\", `${watch} site${watch === 1 ? \"\" : \"s\"} to watch`, [\n approvalTerm,\n healthyTerm,\n preLaunchTerm,\n archivedTerm,\n auditedTerm,\n ]);\n }\n return render(\"soft\", `${approval} waiting on you`, [\n healthyTerm,\n preLaunchTerm,\n archivedTerm,\n auditedTerm,\n ]);\n}\n\nconst NEEDS_YOU_GROUP_LABEL: Record<NeedsYouGroup, string> = {\n broken: \"Broken\",\n watch: \"Watch\",\n approval: \"Waiting on your yes\",\n};\n\n/** The single per-site triage feed. Every row is navigation-only: one Open ▸ to the\n * site page (where approve / Trigger Renovate / checklist already live). */\nfunction renderNeedsYouFeed(feed: NeedsYouItem[]): string {\n if (feed.length === 0) return \"\";\n const groups: NeedsYouGroup[] = [\"broken\", \"watch\", \"approval\"];\n const blocks = groups\n .map((g) => {\n const rows = feed.filter((i) => i.group === g);\n if (rows.length === 0) return \"\";\n const lis = rows\n .map(\n (i) => `<div class=\"feed-row\" data-group=\"${g}\">\n <span class=\"dot ${g}\"></span>\n <span class=\"feed-what\"><strong>${escapeHtml(i.siteName)}</strong> — ${escapeHtml(i.reasons.join(\" · \"))}</span>\n <a class=\"feed-open\" href=\"${escapeHtml(i.url)}\">Open ▸</a>\n </div>`,\n )\n .join(\"\");\n return `<div class=\"feed-group\"><div class=\"feed-group-label\">${NEEDS_YOU_GROUP_LABEL[g]}</div>${lis}</div>`;\n })\n .join(\"\");\n return `<section class=\"needs-you\"><h2>Needs you (${feed.length})</h2>${blocks}</section>`;\n}\n\n/** Most submissions to render in the cockpit inbox lane. The summary still shows the\n * true fleet total; overflow is triaged on each site's page (which lists 25). */\nconst SUBMISSIONS_STRIP_CAP = 10;\n\nconst RECENT_ICON: Record<FleetEventType, string> = {\n pr_automerged: \"🔧\",\n vuln_cleared: \"🛡\",\n ci_recovered: \"✅\",\n site_launched: \"🚀\",\n cert_renewed: \"🔒\",\n fleet_swept: \"🔄\",\n report_sent_with_override: \"✳️\",\n};\n\n/** The calm \"Recently\" lane: what the fleet did for you, collapsed by default\n * (reassurance, not an alarm). One row per event: icon · site + summary · when ·\n * optional link (PR url, else /s/<slug>). Returns \"\" when there is nothing. */\nfunction renderRecentlyLane(model: CockpitModel): string {\n const events: RecentEntry[] = model.recent ?? [];\n if (events.length === 0) return \"\";\n const rows = events\n .map((e) => {\n const icon = RECENT_ICON[e.type] ?? \"•\";\n const when = escapeHtml(relativeTimeFromNow(e.ts));\n const site = e.siteName ? `<strong>${escapeHtml(e.siteName)}</strong> — ` : \"\";\n const link = e.url\n ? `<a href=\"${escapeHtml(e.url)}\">view ▸</a>`\n : e.slug\n ? `<a href=\"/s/${escapeHtml(e.slug)}\">open ▸</a>`\n : \"\";\n return `<div class=\"recent-row\" data-type=\"${e.type}\">\n <span class=\"recent-icon\">${icon}</span>\n <span class=\"recent-what\">${site}${escapeHtml(e.summary)}</span>\n <span class=\"muted\">${when}</span>\n ${link}\n </div>`;\n })\n .join(\"\");\n return `<details class=\"recently\">\n <summary>🔧 Recently (${events.length})</summary>\n ${rows}\n </details>`;\n}\n\n/** Neutral roster of archived (legacy/deprecated) rows — excluded from every\n * fleet op, listed so rows can never silently vanish from the cockpit. Reuses\n * the approve-row/muted styling; /s/<slug> works for archived rows because\n * getWebsiteBySlug does not status-filter. Returns \"\" when there are none. */\nfunction renderArchivedLane(model: CockpitModel): string {\n const rows = model.archived ?? [];\n if (rows.length === 0) return \"\";\n const lis = rows\n .map(\n (s) =>\n `<div class=\"approve-row\"><strong>${escapeHtml(s.name)}</strong> <span class=\"muted\">${escapeHtml(s.status)}</span> <a href=\"/s/${escapeHtml(s.slug)}\">open ▸</a></div>`,\n )\n .join(\"\");\n return `<details class=\"archived\"><summary>🗄 Archived (${rows.length})</summary>${lis}</details>`;\n}\n\n/** The quiet inbox lane: newest submissions + the 30-day spam roll-up, in one collapsed\n * <details>. Submissions are a separate work stream — they never raise the verdict. */\nfunction renderInboxLane(model: CockpitModel): string {\n const subs: SubmissionEntry[] = model.submissions ?? [];\n const spam = model.spam;\n const hasSpam = !!spam && (spam.caught > 0 || spam.through > 0);\n const autoFiltered = model.autoFiltered ?? 0;\n const hasAutoFiltered = autoFiltered > 0;\n if (subs.length === 0 && !hasSpam && !hasAutoFiltered) return \"\";\n\n const shown = [...subs]\n .sort((a, b) => (b.submittedAt ?? \"\").localeCompare(a.submittedAt ?? \"\"))\n .slice(0, SUBMISSIONS_STRIP_CAP);\n const rows = shown\n .map((sub) => {\n const href = `/s/${escapeHtml(sub.slug)}`;\n const when = sub.submittedAt ? escapeHtml(relativeTimeFromNow(sub.submittedAt)) : \"\";\n const who = escapeHtml(sub.name || sub.email);\n return `<div class=\"approve-row\" data-signal=\"submissions\">\n <strong>${escapeHtml(sub.siteName)}</strong>\n <span class=\"muted\">${escapeHtml(sub.formType)} — ${who}</span>\n <span class=\"muted\">${when}</span>\n <a href=\"${href}\">open ▸</a>\n </div>`;\n })\n .join(\"\");\n const overflow = subs.length - shown.length;\n const more =\n overflow > 0\n ? `<div class=\"approve-row subm-more muted\"><a href=\"/submissions\">+${overflow} more — view all submissions</a></div>`\n : `<div class=\"approve-row subm-more muted\"><a href=\"/submissions\">View all submissions →</a></div>`;\n const spamLine = hasSpam\n ? `<div class=\"spam-rollup muted\">🛡 Spam (30d) — caught ${spam!.caught} · through ${spam!.through}</div>`\n : \"\";\n const spamInSummary = hasSpam ? \" · 🛡 Spam (30d)\" : \"\";\n const autoFilteredLine = hasAutoFiltered\n ? `<div class=\"spam-rollup muted\">🚫 ${autoFiltered} auto-filtered this week — <a href=\"/submissions?status=spam_auto\">review</a></div>`\n : \"\";\n // Actionable leads vs newsletter/rsvp signups (2026-07-16): a signup backlog must\n // not read as N leads waiting. The signup term only appears when nonzero.\n const leads = subs.filter((s) => isLeadFormType(s.formType)).length;\n const signups = subs.length - leads;\n const summaryCount =\n signups === 0 ? `${leads} new` : `${leads} new · +${signups} newsletter/rsvp`;\n return `<details class=\"inbox\">\n <summary>📥 Submissions (${summaryCount})${spamInSummary}</summary>\n ${rows}${subs.length > 0 ? more : \"\"}\n ${spamLine}\n ${autoFilteredLine}\n </details>`;\n}\n\nconst AUDIT_SCRIPT = `<script>\n(function(){\n // fleet-refresh live status: dispatch, then poll the actual runs and follow them.\n // Vanilla JS, string-concat only (no template literals) — this lives inside a TS\n // template string, so backticks or interpolation syntax would break the server render.\n var RF_KEY = 'reddoor:fleet-refresh';\n var RF_POLL_MS = 10000;\n var RF_MAX_MS = 90 * 60 * 1000; // safety ceiling; a full fleet Lighthouse run was ~48 min (2026-06-24)\n function rfPanel(){ return document.getElementById('rf-status'); }\n function rfStop(){ try { localStorage.removeItem(RF_KEY); } catch(e){} }\n // Per-workflow ETA hint (the lighthouse fleet sweep runs ~48 min; security ~1-2 min).\n function rfEta(label){ return label === 'lighthouse' ? '~48m' : label === 'security' ? '~2m' : ''; }\n // Escape before interpolating. The values here are server-side enums and a\n // GitHub html_url today, but this builds HTML from a REMOTE API response, and\n // the house rule everywhere else on these pages is that no server string ever\n // reaches the browser as markup. Cheaper to escape than to keep re-deciding.\n function rfEsc(v){\n return String(v == null ? '' : v)\n .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')\n .replace(/\"/g,'"').replace(/'/g,''');\n }\n // A run link must be a GitHub run URL. Anything else is dropped rather than\n // rendered, so a compromised or unexpected API response cannot place an\n // arbitrary href (javascript:, data:, an attacker's host) in the panel.\n function rfRunUrl(u){\n return typeof u === 'string' && u.indexOf('https://github.com/') === 0 ? rfEsc(u) : '';\n }\n function rfRender(status, startedAt){\n var failed = function(s){ return s === 'failure' || s === 'cancelled' || s === 'timed_out'; };\n var mins = Math.floor((Date.now() - startedAt) / 60000);\n var elapsed = mins < 1 ? '<1m' : mins + 'm';\n return status.perWorkflow.map(function(w){\n var label = String(w.workflow || '').replace('.yml','').replace('fleet-','');\n var done = w.state === 'success';\n var isFailed = failed(w.state);\n var icon = done ? '✓' : isFailed ? '✗' : '<span class=\"rf-spin\"></span>';\n var line = icon + ' ' + rfEsc(label) + ' — ' + rfEsc(String(w.state || '').replace('_',' '));\n var url = rfRunUrl(w.url);\n if (!done && !isFailed){\n var eta = rfEta(label);\n var link = url ? ' · <a href=\"'+url+'\" target=\"_blank\" rel=\"noopener\">view run ↗</a>' : '';\n line += '<div class=\"rf-sub\">' + elapsed + (eta ? ' / ' + eta : '') + link + '</div>';\n } else if (isFailed && url){\n line += ' <a href=\"'+url+'\" target=\"_blank\" rel=\"noopener\">run</a>';\n }\n return '<div class=\"rf-row\">' + line + '</div>';\n }).join('');\n }\n function rfPoll(since, startedAt){\n fetch('/api/fleet/refresh/status?since=' + encodeURIComponent(since)).then(function(res){\n if (res.status === 401) return { authFail: true };\n return res.ok ? res.json() : null;\n }).then(function(data){\n var p = rfPanel();\n if (data && data.authFail){\n if (p) p.innerHTML += '<div class=\"rf-row\">Session expired — reload to sign in.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Audit fleet'; }\n rfStop(); return;\n }\n if (data && data.status){\n if (p) p.innerHTML = rfRender(data.status, startedAt);\n if (data.status.allDone){\n if (!data.status.anyFailure){\n if (p) p.innerHTML += '<div class=\"rf-row\">✓ Done — reloading…</div>';\n rfStop(); setTimeout(function(){ location.reload(); }, 2000); return;\n }\n if (p) p.innerHTML += '<div class=\"rf-row\"><button type=\"button\" onclick=\"location.reload()\">Reload</button></div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Audit fleet'; }\n rfStop(); return;\n }\n }\n if (Date.now() - startedAt > RF_MAX_MS){\n if (p) p.innerHTML += '<div class=\"rf-row\">Still running — reload later.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Audit fleet'; }\n rfStop(); return;\n }\n setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);\n }).catch(function(){\n var p = rfPanel();\n if (Date.now() - startedAt > RF_MAX_MS){\n if (p) p.innerHTML += '<div class=\"rf-row\">Still running — reload later.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Audit fleet'; }\n rfStop(); return;\n }\n setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);\n });\n }\n function rfBegin(since, startedAt){\n try { localStorage.setItem(RF_KEY, JSON.stringify({ since: since, startedAt: startedAt })); } catch(e){}\n var p = rfPanel(); if (p) p.innerHTML = '<div class=\"rf-row\"><span class=\"rf-spin\"></span> starting…</div>';\n rfPoll(since, startedAt);\n }\n var rf = document.querySelector('button.refresh-fleet');\n if (rf) rf.addEventListener('click', async function(){\n if (!confirm('Kick off the security + Lighthouse sweeps for the whole fleet? They take a few minutes.')) return;\n rf.disabled = true; rf.textContent = 'Auditing…';\n try {\n var res = await fetch(rf.dataset.refreshUrl, { method: 'POST' });\n if (res.ok){\n var data = await res.json();\n rf.textContent = '↻ Audit running…';\n if (data && data.since) rfBegin(data.since, Date.now());\n } else { rf.textContent = 'Failed to start'; rf.disabled = false; }\n } catch(e){ rf.textContent = 'Failed to start'; rf.disabled = false; }\n });\n // Resume-on-reload: if a refresh is in flight (<90 min old), keep following it.\n try {\n var rfSaved = JSON.parse(localStorage.getItem(RF_KEY) || 'null');\n if (rfSaved && rfSaved.since && rfSaved.startedAt && (Date.now() - rfSaved.startedAt) < RF_MAX_MS){\n if (rf){ rf.disabled = true; rf.textContent = '↻ Audit running…'; }\n rfBegin(rfSaved.since, rfSaved.startedAt);\n } else if (rfSaved) { rfStop(); }\n } catch(e){}\n})();\n</script>`;\n\n/**\n * Render the fleet cockpit as a single HTML document. Pure function: no Airtable\n * access, no env reads, no I/O. The Netlify function handler builds the\n * CockpitModel (visible-site filter, tiering, NEW/WORSE badging, pending list)\n * and hands it here. Renders the doc shell + verdict bar + the per-site Needs-you\n * feed + the collapsed Fleet browse panel (one flat, filterable card grid).\n */\nexport function renderCockpitHtml(\n model: CockpitModel,\n operatorEmail: string | null = null,\n): string {\n const total = model.cards.length;\n const feed = buildNeedsYouFeed(model);\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n ${FAVICON_LINK}\n <title>Reddoor maintenance — fleet cockpit</title>\n <style>${STYLES}</style>\n</head>\n<body>\n ${renderAuthChrome(operatorEmail)}\n <h1>Reddoor fleet cockpit</h1>\n <div class=\"meta\">${total} site${total === 1 ? \"\" : \"s\"} on the Reddoor stack. <a href=\"/fleet\">Fleet table →</a></div>\n ${verdictBar(model, feed)}\n ${renderNeedsYouFeed(feed)}\n ${renderFleetBrowsePanel(model)}\n ${renderRecentlyLane(model)}\n ${renderArchivedLane(model)}\n ${renderInboxLane(model)}\n ${AUDIT_SCRIPT}\n ${FLEET_BROWSE_SCRIPT}\n</body>\n</html>`;\n}\n","import { timingSafeEqual } from \"node:crypto\";\n\n/**\n * Verify an `Authorization: Basic <base64>` header against the configured\n * dashboard password. Username is intentionally ignored — operators may\n * type anything when the browser prompts; only the password gates entry.\n *\n * Returns false for any of:\n * - missing/empty Authorization header\n * - non-Basic auth scheme\n * - malformed base64 or payload (no colon to split user:password)\n * - wrong password\n * - expected password missing (DASHBOARD_PASSWORD not configured)\n *\n * Wrong-password compare is constant-time; BYTE lengths are checked first\n * (timingSafeEqual throws a RangeError on a buffer-length mismatch, and the\n * length itself doesn't leak — operator's password length is fixed per deploy).\n * Comparing JS-string lengths instead of byte lengths could let an equal-char\n * but unequal-byte password (a multibyte char) reach timingSafeEqual and throw,\n * turning a wrong password into an uncaught 500.\n */\nexport function verifyBasicAuth(\n authHeader: string | null | undefined,\n expectedPassword: string | null,\n): boolean {\n if (!authHeader || !expectedPassword) return false;\n // RFC 7235: scheme is case-insensitive.\n const match = /^basic\\s+(.+)$/i.exec(authHeader.trim());\n if (!match) return false;\n let decoded: string;\n try {\n decoded = Buffer.from(match[1]!, \"base64\").toString(\"utf-8\");\n } catch {\n return false;\n }\n // Base64-decoding never throws in Node, but a payload of garbage may\n // produce a string with no colon. user:password form is required.\n const colonIdx = decoded.indexOf(\":\");\n if (colonIdx === -1) return false;\n const provided = decoded.slice(colonIdx + 1);\n // Compare BYTE lengths, not JS-string lengths: timingSafeEqual compares the\n // underlying buffers and throws a RangeError if they differ in byte length.\n // Two strings can share a JS length but differ in UTF-8 byte length (e.g. a\n // multibyte char), so a JS-length guard would let mismatched buffers through\n // and crash the handler with a 500.\n const a = Buffer.from(provided, \"utf-8\");\n const b = Buffer.from(expectedPassword, \"utf-8\");\n if (a.length !== b.length) return false;\n return timingSafeEqual(a, b);\n}\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Versioned HMAC-SHA256 over a string payload — the one security primitive the\n * cookie-session scheme rests on. Both the session cookie (session.ts) and the\n * OAuth state cookie (oauth-state.ts) are built from it, which is why it lives\n * on its own rather than inside either.\n *\n * Format: `v1.<base64url(payload)>.<base64url(mac)>`, where the MAC covers the\n * *encoded* payload segment. Signing the encoded form rather than the raw\n * string means verification never has to decode attacker-controlled bytes\n * before deciding whether to trust them.\n *\n * The version prefix exists so the format can change without silently\n * accepting old tokens: bump it and every previously-issued token stops\n * verifying.\n */\n\nconst VERSION = \"v1\";\n\nfunction mac(encodedPayload: string, secret: string): Buffer {\n return createHmac(\"sha256\", secret).update(encodedPayload).digest();\n}\n\n/** Sign a payload string. Throws on an empty secret — a deploy that signs with\n * \"\" would hand out forgeable tokens, so this fails loudly rather than\n * quietly issuing them. Callers check their env before getting here. */\nexport function sign(payload: string, secret: string): string {\n if (!secret) throw new Error(\"sign: refusing to sign with an empty secret\");\n const encoded = Buffer.from(payload, \"utf-8\").toString(\"base64url\");\n return `${VERSION}.${encoded}.${mac(encoded, secret).toString(\"base64url\")}`;\n}\n\n/**\n * Verify and unwrap a token, returning the payload string, or null for any\n * failure: wrong shape, unknown version, bad signature, wrong secret.\n *\n * Never throws. Every input here is attacker-controlled — it arrives in a\n * cookie — so malformed input has to be an ordinary rejection rather than a\n * 500. In particular the MAC's byte length is checked before `timingSafeEqual`,\n * which throws `RangeError` on a length mismatch; this is the same lesson\n * already recorded in basic-auth.ts, where a JS-length guard would have turned\n * a wrong password into an uncaught error.\n */\nexport function verify(token: string | null | undefined, secret: string): string | null {\n if (!token || !secret) return null;\n const parts = token.split(\".\");\n if (parts.length !== 3) return null;\n const [version, encoded, signature] = parts as [string, string, string];\n if (version !== VERSION || !encoded || !signature) return null;\n\n const expected = mac(encoded, secret);\n const provided = Buffer.from(signature, \"base64url\");\n if (provided.length !== expected.length) return null;\n if (!timingSafeEqual(provided, expected)) return null;\n\n return Buffer.from(encoded, \"base64url\").toString(\"utf-8\");\n}\n","import { sign, verify } from \"./signing.js\";\nimport { serializeCookie, clearCookie } from \"./cookies.js\";\n\n/**\n * The operator session: a signed cookie carrying a verified Google address and\n * an expiry. No server-side session record exists, deliberately.\n *\n * Revocation does not come from this module. `requireOperator` re-reads the\n * allowlist on every request, so removing an address ends that operator's\n * access on their next click even though their cookie is still cryptographically\n * valid. Rotating DASHBOARD_SESSION_SECRET invalidates every token at once.\n *\n * The consequence, recorded so it is not rediscovered as a surprise: an\n * individual live cookie (a stolen laptop) cannot be revoked on its own without\n * rotating the secret and signing everyone out. That is an accepted trade for a\n * three-operator tool, and the reason to revisit it is the operator list\n * growing, not the calendar.\n */\n\nexport const SESSION_COOKIE = \"rd_session\";\n\n/** 30 days. Long is safe here precisely because the allowlist is re-checked per\n * request — access can be withdrawn at any moment regardless of cookie age —\n * and re-authenticating is one click for someone already signed in to Google. */\nexport const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;\n\nexport type SessionPayload = {\n /** Lowercased, Google-verified address. */\n email: string;\n /** Issued-at, epoch seconds. Informational — not enforced. */\n iat: number;\n /** Expiry, epoch seconds. Enforced. */\n exp: number;\n};\n\n/**\n * Mint a session token for a verified address.\n *\n * The email is lowercased here so the allowlist comparison downstream never has\n * to care about the casing Google happened to return.\n */\nexport function mintSession(\n email: string,\n secret: string,\n now: Date,\n ttlSeconds: number = SESSION_TTL_SECONDS,\n): string {\n const iat = Math.floor(now.getTime() / 1000);\n const payload: SessionPayload = {\n email: email.trim().toLowerCase(),\n iat,\n exp: iat + ttlSeconds,\n };\n return sign(JSON.stringify(payload), secret);\n}\n\n/**\n * Verify a session token and return its payload, or null if it is missing,\n * malformed, forged, signed with a different secret, or expired.\n *\n * Only `exp` is enforced. `iat` is carried for debugging but deliberately not\n * validated: rejecting a future `iat` would turn ordinary clock skew between\n * deploys into a mysterious sign-out, and buys nothing — the token is signed by\n * us either way.\n */\nexport function verifySession(\n token: string | null | undefined,\n secret: string,\n now: Date,\n): SessionPayload | null {\n const payload = verify(token, secret);\n if (payload === null) return null;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(payload);\n } catch {\n return null;\n }\n if (typeof parsed !== \"object\" || parsed === null) return null;\n\n const { email, iat, exp } = parsed as Record<string, unknown>;\n if (typeof email !== \"string\" || email.length === 0) return null;\n if (typeof iat !== \"number\" || !Number.isFinite(iat)) return null;\n if (typeof exp !== \"number\" || !Number.isFinite(exp)) return null;\n if (exp * 1000 <= now.getTime()) return null;\n\n return { email, iat, exp };\n}\n\n/** `Set-Cookie` for a freshly minted session. */\nexport function sessionSetCookie(token: string): string {\n return serializeCookie(SESSION_COOKIE, token, {\n maxAgeSeconds: SESSION_TTL_SECONDS,\n path: \"/\",\n sameSite: \"Lax\",\n });\n}\n\n/**\n * `Set-Cookie` that ends the session.\n *\n * SameSite is Lax rather than Strict throughout, and that is a requirement\n * rather than a preference: the OAuth callback is a top-level navigation from\n * accounts.google.com, and Strict withholds cookies on cross-site navigations —\n * so a Strict state cookie would never arrive and every sign-in would fail\n * state validation. Lax sends on top-level GET and withholds on cross-site\n * POST, which is exactly the property this design wants.\n */\nexport function sessionClearCookie(): string {\n return clearCookie(SESSION_COOKIE, \"/\");\n}\n","import { assertUrlSegment } from \"../github/gh.js\";\nimport { makeGitHubRest } from \"../github/gh-rest.js\";\nimport { isHttpUrl, isPrivateOrLoopbackHost, hostnameOf } from \"../util/url.js\";\nimport type { ProspectAuditListItem } from \"../db/prospect-audits.js\";\n\n/**\n * Trigger logic for `POST /api/prospect-audit/run` (netlify/functions/prospect-audit-run.mts).\n * Kept here, deps-injected and framework-free, so it's unit-testable without\n * booting the Netlify handler — same split as trigger-renovate.ts.\n *\n * The audit itself runs as a `workflow_dispatch` in a PRIVATE repo (never this\n * one — this repo's Actions logs are world-readable, and a prospect URL there\n * would publish the sales pipeline), so every dispatch target is read from env\n * at the call site and threaded through as `{ repo, workflowFile }` — nothing\n * here hardcodes a repo.\n */\n\n/** A repeat submission of the SAME url within this window is refused rather\n * than spending a second audit — the double-press guard. A workflow retry\n * (or an impatient second click before the first request's 202 lands) must\n * not double-run and double-email. 10 minutes comfortably exceeds any\n * plausible client retry/backoff window while staying far shorter than the\n * audit's own several-minute runtime, so a genuine re-audit of the same\n * prospect a bit later is never blocked. */\nexport const PROSPECT_AUDIT_DUPLICATE_WINDOW_MS = 10 * 60 * 1000;\n\n/** How many of the most-recent audits (across every url) `triggerProspectAudit`\n * pulls to look for a same-url duplicate inside the window. Usage is\n * low-volume and human-triggered (one operator, one button), so this comfortably\n * covers the window without scanning the whole table; well under\n * MAX_RECENT_PROSPECT_AUDITS (src/db/prospect-audits.ts). */\nexport const DUPLICATE_CHECK_LOOKBACK = 25;\n\n/** Most audits that may be dispatched in any rolling 24 hours (#612 review).\n *\n * The duplicate window stops the SAME url being re-run; nothing stopped\n * DISTINCT urls. One authenticated session could dispatch ~30/minute against\n * 30 hostnames indefinitely, and one audit is structurally an Opus call plus\n * up to 28 Sonnet calls with up to 112 billed web searches, a 20-page double\n * crawl, a 3-pass Lighthouse and a PDF render, inside a billed Actions job.\n *\n * 25/day is far above real use (this is one operator clicking a button) and far\n * below a number that could quietly cost hundreds. A cap that never binds in\n * normal operation is the point: it is a runaway brake, not a quota. */\nexport const PROSPECT_AUDIT_DAILY_CAP = 25;\n\n/** Lookback for the daily cap. Must exceed the cap so the count can actually\n * reach it — a lookback at or below the cap would make the limit unreachable\n * and the brake permanently disengaged, which is exactly the kind of guard\n * that reads as working while doing nothing. */\nexport const DAILY_CAP_LOOKBACK = PROSPECT_AUDIT_DAILY_CAP * 2;\n\n/** The workflow file dispatched when `PROSPECT_AUDIT_WORKFLOW_FILE` is unset. */\nexport const DEFAULT_PROSPECT_AUDIT_WORKFLOW_FILE = \"prospect-audit.yml\";\n\n/** Shown in the success message when `PROSPECT_AUDIT_RECIPIENTS` is not mirrored\n * into this cockpit's env. The authoritative list lives in the PRIVATE dispatch\n * repo's workflow secrets, which this endpoint cannot read — so name no names.\n * A message that confidently lists the wrong people is worse than a vague one,\n * and it would drift silently the day someone is added. */\nexport const DEFAULT_PROSPECT_AUDIT_RECIPIENTS_LABEL = \"the configured recipients\";\n\n/** Workflow inputs sent with the dispatch. All-string, matching GitHub Actions'\n * `workflow_dispatch` input typing. */\nexport type ProspectAuditDispatchInputs = {\n url: string;\n business: string;\n requested_by: string;\n};\n\n/** Everything a dispatcher needs to fire one run — deliberately narrow (no\n * GitHub-specific types leak past this boundary) so a future executor (a\n * different CI, a queue) can implement this same shape without touching the\n * endpoint or `triggerProspectAudit`. */\nexport type ProspectAuditDispatchTarget = {\n repo: string; // \"owner/repo\"\n workflowFile: string;\n inputs: ProspectAuditDispatchInputs;\n};\n\nexport type ProspectAuditDispatchResult = { ok: true } | { ok: false; error: string };\n\n/** The injectable seam: real dispatch is GitHub Actions today\n * ({@link makeWorkflowDispatchDispatcher}); tests inject a fake. Never throws —\n * a failure is reported as `{ ok: false, error }` so callers don't need a\n * try/catch around every dispatch. */\nexport type ProspectAuditDispatcher = (\n target: ProspectAuditDispatchTarget,\n) => Promise<ProspectAuditDispatchResult>;\n\nconst GITHUB_API = \"https://api.github.com\";\n\n/**\n * The actual dispatch call: a GitHub Actions `workflow_dispatch` against\n * `target.repo`'s default branch, carrying `target.inputs`. Built on\n * `makeGitHubRest` for `defaultBranch` (repo lookup), but hand-rolls the\n * dispatch POST itself — `GitHubRest.dispatchWorkflow` (src/github/gh-rest.ts)\n * only sends `{ ref }`, with no `inputs` support, and this endpoint's whole\n * point is passing `url`/`business`/`requested_by` through to the runner.\n *\n * This is the one piece of the module that is \"GitHub Actions\" — everything\n * else depends only on the narrow {@link ProspectAuditDispatcher} shape, so\n * swapping the executor later means writing a new function of that type, not\n * touching the trigger logic or the endpoint.\n */\nexport function makeWorkflowDispatchDispatcher(deps: {\n token: string;\n fetch?: typeof fetch;\n}): ProspectAuditDispatcher {\n const doFetch = deps.fetch ?? fetch;\n const gh = makeGitHubRest({ token: deps.token, fetch: doFetch });\n return async ({ repo, workflowFile, inputs }) => {\n try {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n return { ok: false, error: `expected \"owner/repo\", got ${JSON.stringify(repo)}` };\n }\n const ref = await gh.defaultBranch(repo);\n // Defense in depth, mirroring gh-rest.ts's own dispatchWorkflow — every\n // segment below interpolates into the API path.\n assertUrlSegment(\"path\", owner);\n assertUrlSegment(\"path\", name);\n assertUrlSegment(\"path\", workflowFile);\n assertUrlSegment(\"branch\", ref);\n const res = await doFetch(\n `${GITHUB_API}/repos/${owner}/${name}/actions/workflows/${workflowFile}/dispatches`,\n {\n method: \"POST\",\n headers: {\n authorization: `Bearer ${deps.token}`,\n accept: \"application/vnd.github+json\",\n \"x-github-api-version\": \"2022-11-28\",\n \"user-agent\": \"reddoor-maintenance-dashboard\",\n \"content-type\": \"application/json\",\n },\n body: JSON.stringify({ ref, inputs }),\n },\n );\n if (!res.ok) {\n const body = await res.text().catch(() => \"<no body>\");\n return {\n ok: false,\n error: `GitHub workflow_dispatch ${owner}/${name}/${workflowFile} failed (${res.status}): ${body.slice(0, 300)}`,\n };\n }\n return { ok: true };\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n };\n}\n\n/** Everything `triggerProspectAudit` needs from the outside world, injected so\n * it never touches Turso or the network directly. */\nexport type ProspectAuditTriggerDeps = {\n /** Newest-first recent audits, e.g. `(limit) => listRecentProspectAudits(db, limit)`. */\n listRecent: (limit: number) => Promise<ProspectAuditListItem[]>;\n dispatch: ProspectAuditDispatcher;\n /** Injectable clock for the duplicate-window check; defaults to `Date.now`. */\n now?: () => Date;\n};\n\nexport type ProspectAuditTriggerInput = {\n url: string;\n business: string | null;\n requestedBy: string;\n};\n\nexport type ProspectAuditTriggerResult =\n | { status: \"invalid-url\" }\n | { status: \"private-host\" }\n | { status: \"duplicate\"; existing: ProspectAuditListItem }\n /** The 24h runaway brake tripped (#612 review). Carries both numbers so the\n * operator sees a real limit rather than a bare refusal. */\n | { status: \"daily-cap\"; count: number; cap: number }\n | { status: \"dispatch-failed\"; error: string }\n | { status: \"dispatched\" };\n\n/**\n * Validate, de-duplicate, then dispatch. Order matches the design's threat\n * model: cheap shape checks first (no DB read, no dispatch) — malformed and\n * private-host input never reach the database or the dispatcher — THEN the\n * duplicate check (one DB read, still no dispatch on a hit) — THEN the 24h\n * runaway brake — THEN, only for a genuinely new, safe, public URL under the\n * cap, the actual dispatch that spends an audit.\n */\nexport async function triggerProspectAudit(\n deps: ProspectAuditTriggerDeps,\n target: { repo: string; workflowFile: string },\n input: ProspectAuditTriggerInput,\n): Promise<ProspectAuditTriggerResult> {\n const url = input.url.trim();\n if (!isHttpUrl(url)) return { status: \"invalid-url\" };\n\n // isHttpUrl already proved this parses; re-parsing here is cheap and keeps\n // the hostname extraction local to this one call site.\n const hostname = new URL(url).hostname;\n if (isPrivateOrLoopbackHost(hostname)) return { status: \"private-host\" };\n\n const now = (deps.now ?? (() => new Date()))();\n const cutoff = now.getTime() - PROSPECT_AUDIT_DUPLICATE_WINDOW_MS;\n const recent = await deps.listRecent(Math.max(DUPLICATE_CHECK_LOOKBACK, DAILY_CAP_LOOKBACK));\n const existing = recent.find((r) => r.url === url && Date.parse(r.created_at) >= cutoff);\n if (existing) return { status: \"duplicate\", existing };\n\n // Runaway brake. Checked AFTER the duplicate check on purpose: a repeated\n // click on one url should read as \"duplicate\", which is the truthful and more\n // useful answer, and should not consume the day's budget.\n const dayAgo = now.getTime() - 24 * 60 * 60 * 1000;\n const today = recent.filter((r) => Date.parse(r.created_at) >= dayAgo).length;\n if (today >= PROSPECT_AUDIT_DAILY_CAP) {\n return { status: \"daily-cap\", count: today, cap: PROSPECT_AUDIT_DAILY_CAP };\n }\n\n const business = input.business?.trim() || null;\n const result = await deps.dispatch({\n repo: target.repo,\n workflowFile: target.workflowFile,\n inputs: { url, business: business ?? \"\", requested_by: input.requestedBy },\n });\n if (!result.ok) return { status: \"dispatch-failed\", error: result.error };\n return { status: \"dispatched\" };\n}\n\n/**\n * The `requested_by` workflow input: the operator's Google-verified address, or\n * `\"cockpit\"` when there is no identity to report.\n *\n * This takes `requireOperator`'s verified email rather than reading a name off\n * the request. The predecessor pulled the username out of the `Authorization`\n * header — which `verifyBasicAuth` documents itself as deliberately ignoring,\n * so any operator could type any name and the audit log recorded it verbatim.\n * That made `requested_by` unverified free text, which is precisely the problem\n * Google sign-in was introduced to fix.\n *\n * `null` (the shared-password fallback, used on deploy previews) still yields\n * `\"cockpit\"`: an anonymous shared credential genuinely has no person behind\n * it, and inventing one would put the old lie back.\n */\nexport function resolveRequestedBy(operatorEmail: string | null | undefined): string {\n return operatorEmail?.trim() || \"cockpit\";\n}\n\n/** Human label for who gets the audit email, from the optional\n * `PROSPECT_AUDIT_RECIPIENTS` value (comma-separated names/emails) mirrored\n * into this cockpit's env, falling back to the design doc's stated audience.\n * The actual send + its recipient list live in the private dispatch repo's\n * workflow — this is a best-effort label for the success message only. */\nexport function prospectAuditRecipientsLabel(raw: string | undefined): string {\n const trimmed = raw?.trim();\n if (!trimmed) return DEFAULT_PROSPECT_AUDIT_RECIPIENTS_LABEL;\n const parts = trimmed\n .split(\",\")\n .map((s) => s.trim())\n .filter(Boolean);\n return parts.length > 0 ? parts.join(\", \") : DEFAULT_PROSPECT_AUDIT_RECIPIENTS_LABEL;\n}\n\n/** JSON status + body for one `ProspectAuditTriggerResult`. Kept pure and\n * separate from the .mts handler so each message is unit-testable directly. */\nexport function respondToProspectAuditTrigger(\n result: ProspectAuditTriggerResult,\n opts: { recipientsLabel: string },\n): { status: number; body: Record<string, unknown> } {\n switch (result.status) {\n case \"invalid-url\":\n return {\n status: 400,\n body: { ok: false, error: \"invalid-url\", message: \"Enter a valid http(s) URL to audit.\" },\n };\n case \"private-host\":\n return {\n status: 400,\n body: {\n ok: false,\n error: \"private-host\",\n message: \"That host looks internal or private — audits only run against public sites.\",\n },\n };\n case \"duplicate\":\n return {\n status: 409,\n body: {\n ok: false,\n error: \"duplicate\",\n message: `${hostnameOf(result.existing.url)} was already audited in the last 10 minutes — see the existing report instead of running another.`,\n reportUrl: `/r/${result.existing.token}`,\n },\n };\n case \"daily-cap\":\n return {\n // 429, not 400: the request is well-formed and the caller is\n // authorised — it is the rate that is refused, and it will succeed\n // again later without any change on their part.\n status: 429,\n body: {\n ok: false,\n error: \"daily-cap\",\n message: `${result.count} audits have run in the last 24 hours (cap ${result.cap}). This is a runaway brake — if the run is genuinely needed, raise PROSPECT_AUDIT_DAILY_CAP.`,\n },\n };\n case \"dispatch-failed\":\n return {\n status: 502,\n body: {\n ok: false,\n error: \"dispatch-failed\",\n message: `Could not start the audit: ${result.error}`,\n },\n };\n case \"dispatched\":\n return {\n status: 202,\n body: {\n ok: true,\n message: `Audit started. ${opts.recipientsLabel} will get an email with the report in a few minutes.`,\n },\n };\n }\n}\n"],"mappings":"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","names":["sign","chips","card","DASH","chips","STYLES","timingSafeEqual"]}
|
|
1
|
+
{"version":3,"sources":["../src/recipes/index.ts","../src/dashboard/favicon.ts","../src/dashboard/auth/render.ts","../src/dashboard/relative-time.ts","../src/dashboard/onboarding.ts","../src/dashboard/submission-view.ts","../src/dashboard/site-details.ts","../src/dashboard/render.ts","../src/dashboard/fleet-cockpit.ts","../src/dashboard/fleet-browse-render.ts","../src/dashboard/fleet-render.ts","../src/dashboard/basic-auth.ts","../src/dashboard/auth/signing.ts","../src/dashboard/auth/session.ts","../src/dashboard/prospect-audit-trigger.ts"],"sourcesContent":["import type { RecipeName } from \"../types.js\";\nimport { syncConfigs, type SyncConfigsOptions } from \"./sync-configs.js\";\nimport { bumpDeps, type BumpDepsOptions } from \"./bump-deps.js\";\nimport { upgradeSvelte4to5, type UpgradeSvelte4to5Options } from \"./svelte-5/index.js\";\nimport { svelteCodemods } from \"./svelte-codemods.js\";\nimport { convertToPnpm, type ConvertToPnpmOptions } from \"./convert-to-pnpm.js\";\nimport { onboard, type OnboardOptions, type OnboardAudit } from \"./onboard.js\";\nimport { a11yFixturesPage } from \"./a11y-fixtures-page/index.js\";\nimport { healthEndpoint } from \"./health-endpoint/index.js\";\nimport { smokeSuite } from \"./smoke-suite/index.js\";\nimport {\n init,\n DEFAULT_INIT_STEPS,\n type InitOptions,\n type InitResult,\n type InitStep,\n type InitStepResult,\n} from \"./init.js\";\n\nexport {\n syncConfigs,\n bumpDeps,\n upgradeSvelte4to5,\n svelteCodemods,\n convertToPnpm,\n onboard,\n a11yFixturesPage,\n healthEndpoint,\n smokeSuite,\n init,\n DEFAULT_INIT_STEPS,\n};\nexport type {\n SyncConfigsOptions,\n BumpDepsOptions,\n UpgradeSvelte4to5Options,\n ConvertToPnpmOptions,\n OnboardOptions,\n OnboardAudit,\n InitOptions,\n InitResult,\n InitStep,\n InitStepResult,\n};\n\nexport const ALL_RECIPE_NAMES: RecipeName[] = [\n \"sync-configs\",\n \"bump-deps\",\n \"svelte-4-to-5\",\n \"svelte-codemods\",\n \"convert-to-pnpm\",\n \"onboard\",\n \"a11y-fixtures-page\",\n \"health-endpoint\",\n \"smoke-suite\",\n \"self-updating\",\n \"prismic-ci\",\n \"init\",\n];\n\nexport function isRecipeName(value: string): value is RecipeName {\n return (ALL_RECIPE_NAMES as string[]).includes(value);\n}\n","// The reddoor mark (32×32 PNG, ~554 B) inlined as a data-URI favicon. The\n// dashboard pages are rendered by Netlify functions with no static-asset\n// pipeline, so embedding the icon in the <head> brands every page without a\n// second request or a hosted file. Source: reddoor-website/static/favicon.png.\nconst REDDOOR_FAVICON_PNG_BASE64 =\n \"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAACXBIWXMAAAAnAAAAJwEqCZFPAAAAk1BMVEVHcEzkGTfjGDbjGTfjGTfjGTfjGTfjGDbjGDbjGTfjGTfjGTfkGTfjGTfjGDbjGDbkGTfjGDbjGDbjGTfjGTfjGDbjGTfjGDbjGTfjGDbjGTfjGTfjGDbjGDbjGDbjGDbjGTfjGTbjGDbjGDbjGDbjGDbjGTfjGTfjGTfjGDbjGDbjGTfjGDbjGDbjGTfjGTbkGTfxxbwzAAAALXRSTlMA4DABMOD8cOBwDltwi+bFLMlk54/BDVPk/I61/u2cFaBw9nqO1izBIQE+Becdo6eEAAABBElEQVQ4y91SCVYCMQwt4kxbcEHFfUFFcMGf9P6nM0kXEPEAmtdJs+cnU+f+He2fIIEAYuVgkmvDfTG9Rh+6LoRBUBqE7gi09l9eAeeTrZJoFd5uQWfj4VbPvRqwmj9zfzP6CYpyi48F6HW5A3WuMHsEPsfvO8cSkMPTe+pfRr/MTckdg+4enqL3PkYf/YFIqiiP8VAw6EZkH6QEO0xJLUhmdbY5+TjHkCVohprlcpzlnJw9GlNkrZC16uCmWIaAZItNrT4xV0T2yxrIWoNKy4rdVXOq0MycWp4r43FOMQWciibMYaOHCXKSPhapqTupGFAnwHoVuUUZq7jaSkqDb0/uD9MXqvJMDtU7lL0AAAAASUVORK5CYII=\";\n\n/** A ready-to-interpolate `<link rel=\"icon\">` carrying the reddoor mark. */\nexport const FAVICON_LINK = `<link rel=\"icon\" type=\"image/png\" href=\"data:image/png;base64,${REDDOOR_FAVICON_PNG_BASE64}\" />`;\n","import { escapeHtml } from \"../../util/html.js\";\nimport { FAVICON_LINK } from \"../favicon.js\";\n\n/**\n * The sign-in page, and the \"signed in as …\" fragment the dashboard pages\n * carry in their headers.\n *\n * A page rather than a bare redirect to Google, on purpose: a stale bookmark\n * should not fling someone at an account chooser with no explanation, and\n * \"that account is not authorised\" needs somewhere to be said.\n */\n\n/**\n * Failure codes the login page knows how to explain.\n *\n * Query text is never echoed into the page — an unknown code falls back to the\n * generic message. Same discipline as `STAGE_FAILED_MESSAGE` in the prospect\n * report: a fixed lookup, so a crafted `?error=` cannot put attacker-chosen\n * prose (or markup) in front of an operator.\n */\nexport const LOGIN_ERROR_MESSAGE: Record<string, string> = {\n state: \"That sign-in link expired or was already used. Try again.\",\n exchange: \"Google could not complete the sign-in. Try again.\",\n userinfo: \"Google signed you in but would not share a verified address.\",\n unverified: \"That Google account's email address is not verified.\",\n config: \"Sign-in is not configured on this deploy.\",\n};\n\nconst GENERIC_ERROR = \"Sign-in did not complete. Try again.\";\n\n/** Resolve a code to a message, never trusting the code itself as text. */\nexport function loginErrorMessage(code: string | null | undefined): string | null {\n if (!code) return null;\n return LOGIN_ERROR_MESSAGE[code] ?? GENERIC_ERROR;\n}\n\nconst STYLES = `\n:root { color-scheme: light dark; }\nbody { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 26rem; margin: 4rem auto; padding: 0 1rem; color: #1a1a1a; }\n@media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } .card { border-color: #333; } }\nh1 { margin: 0 0 0.25rem; font-size: 1.5rem; }\n.meta { color: #666; margin: 0 0 1.5rem; }\n.card { border: 1px solid #ddd; border-radius: 10px; padding: 1.5rem; }\n.google { display: block; text-align: center; font: inherit; font-weight: 600; padding: 0.7rem 1rem; border-radius: 8px; border: 1px solid #4285f4; background: #4285f4; color: #fff; text-decoration: none; }\n.google:hover { background: #3367d6; border-color: #3367d6; }\n.notice { padding: 0.7rem 0.9rem; border-radius: 8px; margin-bottom: 1.25rem; font-size: 0.92rem; }\n.notice-denied { border: 1px solid #b00; color: #b00; background: #bb000010; }\n.notice-error { border: 1px solid #c80; color: #a60; background: #cc880010; }\n.alt { margin: 1.25rem 0 0; font-size: 0.85rem; color: #666; text-align: center; }\n`;\n\nexport type LoginPageInput = {\n /** Where to send the operator once signed in. Already sanitised by\n * `safeReturnTo` — this renderer escapes but does not validate. */\n returnTo: string;\n /** The account authenticated but is not on the allowlist. */\n denied?: boolean;\n /** A failure code from {@link LOGIN_ERROR_MESSAGE}. */\n errorCode?: string | null;\n /** Whether `DASHBOARD_PASSWORD` is set on this deploy. Controls only whether\n * the shared-password link is offered — it gates nothing. */\n basicFallbackAvailable: boolean;\n};\n\nexport function renderLoginPageHtml(input: LoginPageInput): string {\n const returnTo = encodeURIComponent(input.returnTo);\n const error = loginErrorMessage(input.errorCode);\n\n // \"Try a different account\" restarts with prompt=select_account. Without it,\n // someone whose browser has cached the wrong Google account is stuck being\n // silently re-authenticated as the account that just got refused.\n const notice = input.denied\n ? `<div class=\"notice notice-denied\">That Google account is not on the cockpit's list. Ask Tucker to add it, or <a href=\"/auth/login?returnTo=${returnTo}&switch=1\">try a different account</a>.</div>`\n : error\n ? `<div class=\"notice notice-error\">${escapeHtml(error)}</div>`\n : \"\";\n\n const alt = input.basicFallbackAvailable\n ? `<p class=\"alt\"><a href=\"/auth/basic?returnTo=${returnTo}\">Use the shared password instead</a><br />Deploy previews only — Google sign-in needs the production URL.</p>`\n : \"\";\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"robots\" content=\"noindex, nofollow\" />\n ${FAVICON_LINK}\n <title>Sign in — Reddoor cockpit</title>\n <style>${STYLES}</style>\n</head>\n<body>\n <h1>Reddoor cockpit</h1>\n <p class=\"meta\">Operator access only.</p>\n <div class=\"card\">\n ${notice}\n <a class=\"google\" href=\"/auth/login?returnTo=${returnTo}&start=1\">Sign in with Google</a>\n ${alt}\n </div>\n</body>\n</html>`;\n}\n\n/**\n * The \"signed in as … · sign out\" fragment for a dashboard page header.\n *\n * There is no shared page shell — five renderers each emit their own\n * `<!doctype html>` — so this is a fragment each one drops in rather than a\n * layout. Converting the dashboard to a framework with a real layout is\n * tracked separately (#582); until then, five one-line insertions is the\n * honest cost.\n *\n * Returns empty for a session with no identity (the shared-password fallback):\n * showing \"signed in as cockpit\" would dress an anonymous shared credential up\n * as a person.\n */\nexport function renderAuthChrome(email: string | null | undefined): string {\n if (!email) return \"\";\n // Styled inline rather than by class: the four dashboard pages each carry\n // their own <style> block, and a fragment that depends on a rule existing in\n // all four is a fragment that silently loses its styling in whichever one\n // gets missed.\n return `<div style=\"float:right;font-size:0.8rem;color:#666\">${escapeHtml(email)} · <a href=\"/auth/logout\">sign out</a></div>`;\n}\n","/** Render an absolute timestamp as a coarse \"Xd ago\" relative string for the\n * fleet card. Takes an explicit `now` for testability; defaults to wall clock\n * for callers (the Netlify function). Returns \"—\" for null / unparseable. */\nexport function relativeTimeFromNow(iso: string | null, now: Date = new Date()): string {\n if (!iso) return \"—\";\n const t = Date.parse(iso);\n if (Number.isNaN(t)) return \"—\";\n\n const seconds = Math.max(0, Math.floor((now.getTime() - t) / 1000));\n if (seconds < 60) return \"just now\";\n\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes}m ago`;\n\n const hours = Math.floor(minutes / 60);\n if (hours < 24) return `${hours}h ago`;\n\n const days = Math.floor(hours / 24);\n if (days < 7) return `${days}d ago`;\n\n const weeks = Math.floor(days / 7);\n if (weeks < 4) return `${weeks}w ago`;\n\n const months = Math.floor(days / 30);\n return `${months}mo ago`;\n}\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\n\nexport type OnboardingStatus = {\n score: number;\n total: 4;\n checks: {\n firstAudit: boolean;\n recipients: boolean;\n schedule: boolean;\n poc: boolean;\n };\n};\n\nfunction isNonEmpty(s: string | null | undefined): boolean {\n return typeof s === \"string\" && s.trim().length > 0;\n}\n\n/** Four-point onboarding signal for the fleet card. A site is \"fully onboarded\"\n * when it has been audited at least once, has a To-recipient for monthly\n * reports, has a maintenance schedule that isn't \"None\", and has a named POC. */\nexport function onboardingStatus(row: WebsiteRow): OnboardingStatus {\n const checks = {\n firstAudit: isNonEmpty(row.lastLighthouseAuditAt),\n recipients: isNonEmpty(row.reportRecipientsTo),\n schedule: row.maintenanceFreq !== \"None\",\n poc: isNonEmpty(row.pointOfContact),\n };\n const score = Object.values(checks).filter(Boolean).length;\n return { score, total: 4, checks };\n}\n\n/** Human label for each onboarding check, in canonical check order. Used by the\n * dashboards to spell out which signals a partially-onboarded site is missing\n * (cockpit setup-chip tooltip + per-site setup line). */\nexport const ONBOARDING_LABELS: Record<keyof OnboardingStatus[\"checks\"], string> = {\n firstAudit: \"First audit\",\n recipients: \"Report recipients\",\n schedule: \"Maintenance schedule\",\n poc: \"Point of contact\",\n};\n\n/** The labels of the onboarding checks this site has NOT satisfied, in check\n * order. Empty array → fully onboarded. */\nexport function missingOnboarding(row: WebsiteRow): string[] {\n const { checks } = onboardingStatus(row);\n return (Object.keys(ONBOARDING_LABELS) as Array<keyof typeof ONBOARDING_LABELS>)\n .filter((key) => !checks[key])\n .map((key) => ONBOARDING_LABELS[key]);\n}\n","import type { SubmissionRow } from \"../reports/submission-row.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\n\n/** Render a submission's `extraFields` JSON as a key/value list; on parse failure\n * show the raw string (escaped) rather than dropping it. Returns \"\" when blank. */\nfunction extraFieldsList(raw: string | null): string {\n if (!raw || raw.trim() === \"\") return \"\";\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return `<div class=\"subm-kv\"><span class=\"k\">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n return `<div class=\"subm-kv\"><span class=\"k\">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;\n }\n const rows = Object.entries(parsed as Record<string, unknown>)\n .map(\n ([k, v]) =>\n `<div class=\"subm-kv\"><span class=\"k\">${escapeHtml(k)}</span> ${escapeHtml(String(v))}</div>`,\n )\n .join(\"\");\n return rows;\n}\n\n/** A submission belongs in the per-site strip unless it was auto-filtered as spam\n * (status \"spam_auto\"): auto-spam is reviewed only on /submissions, so it never\n * crowds the real-lead window. PURE. */\nexport function isVisibleInStrip(s: SubmissionRow): boolean {\n return s.status !== \"spam_auto\";\n}\n\n/** Split a comma-joined spam_reason string into its reason tokens. */\nfunction spamReasonTokens(reason: string | null | undefined): string[] {\n return (reason ?? \"\")\n .split(\",\")\n .map((t) => t.trim())\n .filter((t) => t !== \"\");\n}\n\n/** The fan-out tokens that did NOT succeed (see ingest.ts for the grammar: one\n * `<destination>:<outcome>` token per attempt, `ok` being the only good outcome).\n * A failure here means the signup never reached that destination — the row itself\n * still looks perfectly healthy, which is exactly why it needs surfacing. PURE. */\nexport function fanoutFailures(fanoutStatus: string | null | undefined): string[] {\n return (fanoutStatus ?? \"\")\n .split(\",\")\n .map((t) => t.trim())\n .filter((t) => t !== \"\" && !t.endsWith(\":ok\"));\n}\n\n/** The inline chip shows at most this many reason tokens; a long classifier trail\n * must not blow up the summary line. The full list stays in the badge tooltip and\n * the expanded Spam row. */\nconst REASON_CHIP_MAX_TOKENS = 3;\n\nfunction reasonChipText(tokens: string[]): string {\n const shown = tokens.slice(0, REASON_CHIP_MAX_TOKENS).join(\" · \");\n const more = tokens.length - REASON_CHIP_MAX_TOKENS;\n return more > 0 ? `${shown} +${more} more` : shown;\n}\n\n/** A submission row's content WITHOUT the <li> wrapper — for callers that need\n * to compose their own <li> (the /submissions page wraps rows with a site link;\n * ul may only contain li, so the wrapper must own the li). */\nexport function renderSubmissionRowInner(s: SubmissionRow): string {\n const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : \"—\";\n const type = escapeHtml(s.formType);\n const who = escapeHtml(s.name || \"(no name)\");\n const email = escapeHtml(s.email || \"\");\n const status = escapeHtml(s.status);\n const id = escapeHtml(s.id);\n const url = `/api/submissions/${encodeURIComponent(s.id)}/status`;\n const btn = (label: string, action: string) =>\n `<button class=\"subm-status\" data-id=\"${id}\" data-status=\"${action}\" data-url=\"${url}\">${label}</button>`;\n // The reasons must be VISIBLE text, not only the badge tooltip — tooltips never\n // render on iPad/phone, and the requireTurnstile canary review hinges on telling\n // \"turnstile-required-absent\" apart from content-classifier reasons (2026-07-15).\n const reasonTokens = spamReasonTokens(s.spamReason);\n const reasonChip =\n reasonTokens.length > 0\n ? ` <span class=\"subm-reasons\">${escapeHtml(reasonChipText(reasonTokens))}</span>`\n : \"\";\n const provenance =\n s.status === \"spam_auto\" && s.spamScore !== null && s.spamScore !== undefined\n ? ` <span class=\"subm-provenance\" title=\"${escapeHtml(s.spamReason ?? \"\")}\">auto-spam · ${escapeHtml(String(s.spamScore))}</span>${reasonChip}`\n : \"\";\n const recover = s.status === \"spam_auto\" ? btn(\"Not spam → new\", \"new\") : \"\";\n // Bounce marker (2026-07-16): visible summary-line text, not just the Notify\n // detail row — a bounced notification means the LEAD never reached the client\n // even though the row looks healthy (same visible-not-tooltip rule as the\n // reason chip above). The resend-webhook flips notifyStatus to \"bounced\".\n const bounceChip =\n s.notifyStatus === \"bounced\"\n ? ` <span class=\"subm-bounce\" title=\"The lead notification email bounced — the point-of-contact address may be dead\">notify bounced</span>`\n : \"\";\n // Fan-out marker (2026-07-31): a newsletter signup that never reached the site\n // webhook or the Mailchimp audience is otherwise INDISTINGUISHABLE from one that\n // did — same status pill, same notify=sent. Visible on the collapsed line for the\n // same reason as the bounce chip; only the failing destinations are named, since\n // the successes are what the expanded Fan-out row is for.\n const fanoutBad = fanoutFailures(s.fanoutStatus);\n const fanoutChip =\n fanoutBad.length > 0\n ? ` <span class=\"subm-fanout-fail\" title=\"This signup did not reach every configured destination\">fan-out: ${escapeHtml(fanoutBad.join(\" · \"))}</span>`\n : \"\";\n\n // One detail row per present field; absent fields are omitted (no blank rows).\n const kv = (label: string, value: string | number | null) =>\n value === null || value === \"\"\n ? \"\"\n : `<div class=\"subm-kv\"><span class=\"k\">${label}</span> ${escapeHtml(String(value))}</div>`;\n const sourceLink = s.sourceUrl\n ? `<div class=\"subm-kv\"><span class=\"k\">Source</span> <a href=\"${escapeHtml(safeUrl(s.sourceUrl))}\" rel=\"noopener noreferrer\">${escapeHtml(s.sourceUrl)}</a></div>`\n : \"\";\n const messageBlock = s.message\n ? `<div class=\"subm-kv\"><span class=\"k\">Message</span></div><div class=\"subm-msg\">${escapeHtml(s.message)}</div>`\n : \"\";\n // Full spam provenance in the detail block for ANY scored row (not just\n // spam_auto): a scored-but-delivered row is exactly what a near-threshold\n // classifier review needs to see.\n const hasScore = s.spamScore !== null && s.spamScore !== undefined;\n const spamDetail = hasScore\n ? reasonTokens.length > 0\n ? `score ${s.spamScore} — ${reasonTokens.join(\", \")}`\n : `score ${s.spamScore}`\n : reasonTokens.join(\", \");\n const details = [\n kv(\"Phone\", s.phone),\n messageBlock,\n sourceLink,\n kv(\"UTM\", s.utm),\n extraFieldsList(s.extraFields),\n kv(\"Spam\", spamDetail),\n kv(\"Notify\", s.notifyStatus),\n kv(\"Fan-out\", s.fanoutStatus ?? null),\n kv(\"Resend ID\", s.resendMessageId),\n kv(\"Submission #\", s.submissionId),\n ].join(\"\");\n\n return `<details>\n <summary class=\"subm-head\"><strong>${type}</strong> · ${who} <span class=\"muted\">${email}</span> <span class=\"pill subm-${status}\">${status}</span>${provenance}${bounceChip}${fanoutChip} <span class=\"muted\">${when}</span></summary>\n <div class=\"subm-detail\">${details}</div>\n </details>\n <div class=\"subm-actions\">${recover}${btn(\"Read\", \"read\")}${btn(\"Archive\", \"archived\")}${btn(\"Spam\", \"spam\")}</div>`;\n}\n\nexport function renderSubmissionRow(s: SubmissionRow): string {\n return `<li class=\"subm-item\">${renderSubmissionRowInner(s)}</li>`;\n}\n\n/** CSS rules for the submission list UI. Append to the page's <style> block. */\nexport const SUBMISSION_STYLES = `.subm-list { list-style: none; padding: 0; margin: 0; }\n.subm-item { padding: 0.6rem 0; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { .subm-item { border-color: #2a2a2a; } }\n.subm-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }\n.subm-msg { margin: 0.35rem 0; white-space: pre-wrap; }\n.subm-detail { padding: 0.35rem 0 0.2rem; }\n.subm-kv { font-size: 0.9rem; margin: 0.15rem 0; }\n.subm-kv .k { color: #888; margin-right: 0.4rem; }\nsummary.subm-head { cursor: pointer; }\n.subm-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }\nbutton.subm-status { font: inherit; padding: 0.25rem 0.7rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }\nbutton.subm-status:disabled { opacity: 0.6; cursor: default; }\n@media (pointer: coarse) { button.subm-status { padding: 0.55rem 0.9rem; } } /* >=40px tap target on touch devices */\n.spam-screen .spam-kv { font-size: 0.95rem; margin: 0.2rem 0; }\n.spam-screen .spam-kv .k { color: #888; display: inline-block; min-width: 11rem; }\n.pill.subm-new { background: #e8f0fe; color: #1a56db; }\n.pill.subm-read { background: #f0f0f0; color: #555; }\n.pill.subm-archived { background: #eee; color: #888; }\n.pill.subm-spam { background: #fdecea; color: #b00; }\n.pill.subm-spam_auto { background: #fff4e5; color: #a65a00; }\n.subm-provenance { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; background: #fff4e5; color: #a65a00; }\n.subm-bounce { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; background: #fdecea; color: #b00; font-weight: 700; }\n.subm-fanout-fail { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; background: #fdecea; color: #b00; font-weight: 700; }\n.subm-reasons { font-size: 0.72rem; color: #999; }\n.subm-viewall { font-size: 0.8rem; font-weight: normal; margin-left: 0.4rem; white-space: nowrap; }`;\n\n/** Client-side JS for the submission status triage buttons. Insert bare (no <script> wrapper). */\nexport const SUBMISSION_STATUS_SCRIPT = `document.querySelectorAll(\"button.subm-status\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n try {\n const res = await fetch(b.dataset.url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ status: b.dataset.status }),\n });\n b.textContent = res.ok ? \"✓\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });`;\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { parseNotifyRouting } from \"../reports/airtable/websites.js\";\nimport { CANONICAL_STATUSES, toAirtableStatus } from \"../reports/airtable/site-status.js\";\nimport type { AirtableCellValue } from \"../reports/airtable/websites.js\";\nimport { isHttpUrl } from \"../util/url.js\";\n\n/**\n * Status options the editor offers, expressed as the values Airtable actually\n * STORES — this dropdown writes straight into the \"Status\" single-select, so its\n * options must be options that column accepts.\n *\n * Since stage 3 (the retired option names deleted from the field, the alias map\n * deleted from the code) these ARE the canonical names, and `toAirtableStatus`\n * is the identity — so this list is exactly the six options the column holds.\n *\n * `render.ts` still preselects against `site.statusRaw` rather than\n * `site.status`. Those two now coincide for every value the single-select can\n * hold, so the distinction is dormant rather than load-bearing; it is kept\n * because the placeholder behaviour it produces — an unrecognized cell shows the\n * disabled \"— select —\" instead of being silently re-labelled — is what should\n * happen if a stale value ever reappears.\n */\nexport const SITE_STATUS_OPTIONS: readonly string[] = CANONICAL_STATUSES.map(toAirtableStatus);\nexport const FREQ_OPTIONS = [\"None\", \"Monthly\", \"Quarterly\", \"Yearly\"] as const;\n\n/**\n * The options the live `Accepted Watch Conditions` multi-select carries, read\n * off the base schema on 2026-08-25.\n *\n * Spelled out rather than derived, because the API CANNOT add an option to a\n * select — a PATCH with new choices returns 422, proven during the status\n * migration — so offering a value this field does not have would produce a write\n * Airtable rejects. The records API would create one as a `typecast` side\n * effect; that is the silent-option-creation hazard this codebase refuses\n * everywhere, and it is why an unknown condition is rejected rather than sent.\n *\n * KNOWN GAP, operator-owned: `fleet-cockpit.ts` also supports a\n * `turnstile-unverified` accept key, and this field has no option for it — so\n * that one condition cannot be accepted from the console. Adding the option is a\n * UI action in Airtable; nothing here can do it.\n */\nexport const WATCH_CONDITION_OPTIONS: readonly string[] = [\n \"Performance\",\n \"Accessibility\",\n \"Best Practices\",\n \"SEO\",\n \"stale repo\",\n \"no custom domain\",\n] as const;\n\ntype FieldKind =\n | \"text\"\n | \"email\"\n | \"emails\"\n | \"enum\"\n | \"gitrepo\"\n | \"url\"\n | \"date\"\n | \"notifyRouting\"\n | \"bool\"\n | \"multiselect\"\n | \"secret\";\nexport type EditableField = {\n column: string;\n kind: FieldKind;\n options?: readonly string[];\n maxLen?: number;\n};\n\n/**\n * The ONLY columns the dashboard editor may write. `column` is the EXACT Airtable\n * field name (note the lowercase / em-dash / misspelled ones), kept in lockstep\n * with `mapRow` in src/reports/airtable/websites.ts.\n */\nexport const EDITABLE_SITE_FIELDS: Record<string, EditableField> = {\n pointOfContact: { column: \"point of contact\", kind: \"email\" },\n reportRecipientsTo: { column: \"Report recipients (To)\", kind: \"emails\" },\n reportRecipientsCc: { column: \"Report recipients (CC)\", kind: \"emails\" },\n copyIntro: { column: \"Copy — Intro\", kind: \"text\", maxLen: 2000 },\n copyContact: { column: \"Copy — Contact\", kind: \"text\", maxLen: 2000 },\n copyFooter: { column: \"Copy — Footer\", kind: \"text\", maxLen: 2000 },\n searchQuery: { column: \"Search query\", kind: \"text\", maxLen: 500 },\n ga4PropertyId: { column: \"GA4 property ID\", kind: \"text\", maxLen: 500 },\n gitRepo: { column: \"Git repo\", kind: \"gitrepo\" },\n status: { column: \"Status\", kind: \"enum\", options: SITE_STATUS_OPTIONS },\n maintenanceFreq: { column: \"maintenence freq\", kind: \"enum\", options: FREQ_OPTIONS },\n testingFreq: { column: \"testing freq\", kind: \"enum\", options: FREQ_OPTIONS },\n // #539 Phase 4 — the fields the design lists as \"the eight nothing renders\n // today\". Kinds follow the LIVE Airtable column types, read off the base\n // schema rather than inferred from the reader: `maintenance day`/`testing day`\n // are `date`, `Notify Routing` is `multilineText` holding JSON, the rest are\n // `singleLineText`. All seven are string-valued, which is why they need no\n // change to `updateSiteField`. `Require Turnstile` (checkbox) and `Accepted\n // Watch Conditions` (multipleSelects) are NOT here — they cannot be written as\n // strings and need a typed writer first.\n //\n // `Mailchimp API Key` is deliberately absent too: it is a live credential, and\n // every field in this map is rendered back into the page carrying its stored\n // value (see `inputRow` in render.ts). It needs a write-only kind first.\n netlifyId: { column: \"Netlify ID\", kind: \"text\", maxLen: 200 },\n searchConsoleProperty: { column: \"Search Console property\", kind: \"text\", maxLen: 500 },\n mailchimpAudienceId: { column: \"Mailchimp Audience ID\", kind: \"text\", maxLen: 200 },\n newsletterWebhook: { column: \"Newsletter Webhook\", kind: \"url\" },\n maintenanceDay: { column: \"maintenance day\", kind: \"date\" },\n testingDay: { column: \"testing day\", kind: \"date\" },\n notifyRouting: { column: \"Notify Routing\", kind: \"notifyRouting\" },\n // The two non-text columns. They write a boolean and a string[] respectively,\n // which is why `updateSiteField` and `mirrorSiteField` take AirtableCellValue.\n requireTurnstile: { column: \"Require Turnstile\", kind: \"bool\" },\n acceptedWatchConditions: {\n column: \"Accepted Watch Conditions\",\n kind: \"multiselect\",\n options: WATCH_CONDITION_OPTIONS,\n },\n // A live credential. `secret` is what makes it safe to list here at all: the\n // renderer emits no value for it, and an empty submission leaves the stored\n // key alone instead of clearing it.\n mailchimpApiKey: { column: \"Mailchimp API Key\", kind: \"secret\", maxLen: 200 },\n};\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst ISO_DATE_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/**\n * A real calendar day in `YYYY-MM-DD`, not merely a parseable one.\n *\n * `new Date(\"2026-02-31\")` does not throw — it rolls over to 2 March. These\n * columns feed the next-due schedule, so accepting a rolled-over day would\n * silently reschedule a site to a date the operator never chose. The round-trip\n * comparison below is what rejects it: a day that rolled is not the day that\n * went in.\n */\nfunction isCalendarDate(v: string): boolean {\n if (!ISO_DATE_RE.test(v)) return false;\n const [y, m, d] = v.split(\"-\").map(Number) as [number, number, number];\n const dt = new Date(Date.UTC(y, m - 1, d));\n return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;\n}\n/** `owner/repo` shape. Exported for other dashboard surfaces that consume the\n * legacy free-text `Git repo` cell (e.g. trigger-renovate). */\nexport const REPO_RE = /^[\\w.-]+\\/[\\w.-]+$/;\n\n/**\n * Validate/normalize a raw value for a field kind. Returns the string to write, or\n * `null` when invalid. Empty (after trim) is allowed — it clears the cell — for\n * every kind EXCEPT `enum`, which must be one of its options.\n */\nexport function normalizeFieldValue(f: EditableField, raw: string): AirtableCellValue | null {\n const v = raw.trim();\n // Hard upper bound across every kind (text additionally enforces its own\n // tighter maxLen below) — a single absurdly long value can't reach Airtable.\n if (v.length > 2000) return null;\n switch (f.kind) {\n case \"enum\":\n return f.options!.includes(v) ? v : null;\n case \"email\":\n return v === \"\" ? \"\" : EMAIL_RE.test(v) ? v : null;\n case \"emails\": {\n if (v === \"\") return \"\";\n const parts = v\n .split(/[,\\n]/)\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n return parts.every((p) => EMAIL_RE.test(p)) ? parts.join(\", \") : null;\n }\n case \"gitrepo\":\n return v === \"\" ? \"\" : REPO_RE.test(v) ? v : null;\n case \"url\":\n // The SAME scheme allowlist the deployed-audit target uses. This value is\n // fetched server-side by the newsletter forwarder, so a `file://` or\n // `javascript:` cell is a local-file read or an injection sink, not a typo.\n return v === \"\" ? \"\" : isHttpUrl(v) ? v : null;\n case \"date\":\n return v === \"\" ? \"\" : isCalendarDate(v) ? v : null;\n case \"notifyRouting\":\n // Validated by the PRODUCTION reader, not by a second parser written to\n // agree with it. `parseNotifyRouting` returns null for anything it would\n // drop, so the editor cannot store a value that reads back as \"no\n // routing\" — which would look like a saved change while every form\n // notification kept going to the previous target.\n return v === \"\" ? \"\" : parseNotifyRouting(v) !== null ? v : null;\n case \"bool\":\n // A checkbox has no empty state, so \"\" is a malformed request rather than\n // a clear. Exactly two accepted spellings — anything else is refused\n // instead of guessed at, since guessing wrong here silently turns a site's\n // spam protection off.\n return v === \"true\" ? true : v === \"false\" ? false : null;\n case \"multiselect\": {\n if (v === \"\") return [];\n const parts = v\n .split(/[,\\n]/)\n .map((x) => x.trim())\n .filter(Boolean);\n return parts.every((p) => f.options!.includes(p)) ? parts : null;\n }\n case \"secret\":\n // NOTE the caller contract: `\"\"` here means \"leave unchanged\", and\n // `setSiteDetail` turns it into a no-op rather than a write. Every other\n // kind clears on empty; this one must not, because the field renders blank\n // on every page load (its value is never sent to the browser), so\n // clear-on-empty would let any unrelated save destroy a working key.\n return v.length <= (f.maxLen ?? 500) ? v : null;\n case \"text\":\n return v.length <= (f.maxLen ?? 500) ? v : null;\n }\n}\n\n/** Injected IO — the `.mts` binds these to a live Airtable base; tests bind fakes. */\nexport type SiteDetailDeps = {\n getSite: (slug: string) => Promise<WebsiteRow | null>;\n updateField: (recordId: string, column: string, value: AirtableCellValue) => Promise<void>;\n};\n\n/** Typed into a `secret` field to ERASE it. A sentinel rather than a new\n * control, because the secret input already fires its save listener on any\n * keystroke — so clearing needs no change to the inline dashboard script, the\n * one part of this page no test has ever executed (#591 shipped broken there).\n *\n * Underscore-wrapped and lowercase so it cannot collide with a real key: every\n * credential this field holds is a provider-issued token. */\nexport const CLEAR_SECRET = \"__clear__\";\n\nexport type SiteDetailResult =\n | { status: \"updated\"; slug: string; field: string }\n /** A `secret` field explicitly erased via the CLEAR_SECRET sentinel. Distinct\n * from `updated` so the console can say \"cleared\" rather than implying a new\n * value was stored. */\n | { status: \"cleared\"; slug: string; field: string }\n /** A `secret` field submitted empty: nothing was written, deliberately. */\n | { status: \"unchanged\"; slug: string; field: string }\n | { status: \"bad-field\"; slug: string; field: string }\n | { status: \"invalid\"; slug: string; field: string }\n | { status: \"not-found\"; slug: string };\n\n/**\n * Write one allowlisted site-detail field from the dashboard editor.\n *\n * SAFETY: an unknown `field` is rejected BEFORE any read (a hand-crafted authed\n * POST can never write an arbitrary Airtable column), and the value is\n * validated/normalized per kind before the write — invalid input never reaches\n * Airtable.\n */\nexport async function setSiteDetail(\n deps: SiteDetailDeps,\n slug: string,\n field: string,\n rawValue: string,\n): Promise<SiteDetailResult> {\n const f = EDITABLE_SITE_FIELDS[field];\n if (!f) return { status: \"bad-field\", slug, field };\n const value = normalizeFieldValue(f, rawValue);\n if (value === null) return { status: \"invalid\", slug, field };\n // A blank secret is \"I did not change this\", not \"erase it\" — the input is\n // rendered without a value, so it is blank on every load. Returning before the\n // read means an accidental save cannot even touch the record.\n if (f.kind === \"secret\" && value === \"\") return { status: \"unchanged\", slug, field };\n // ...which left NO way to clear one from the console. Airtable was the escape\n // hatch, and the freeze removes it (#612), so clearing needs its own explicit\n // gesture. A typed sentinel rather than a new control: the secret input is the\n // one field whose blur listener already fires on any keystroke (it renders\n // with no value, so anything typed differs from its default), so this needs no\n // change to the inline dashboard script — which no test executes.\n if (f.kind === \"secret\" && value === CLEAR_SECRET) {\n const target = await deps.getSite(slug);\n if (!target) return { status: \"not-found\", slug };\n await deps.updateField(target.id, f.column, \"\");\n return { status: \"cleared\", slug, field };\n }\n const site = await deps.getSite(slug);\n if (!site) return { status: \"not-found\", slug };\n await deps.updateField(site.id, f.column, value);\n return { status: \"updated\", slug, field };\n}\n","import { renderAuthChrome } from \"./auth/render.js\";\nimport type { WebsiteRow, SecurityAdvisory } from \"../reports/airtable/websites.js\";\nimport { SEVERITY_RANK, siteSlug } from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { isPendingApproval } from \"../reports/airtable/reports.js\";\nimport type { EvidenceResult } from \"../reports/auto-tick.js\";\nimport type { SubmissionRow } from \"../reports/submission-row.js\";\nimport type { ScreenOutTotals } from \"../db/screenouts.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { FAVICON_LINK } from \"./favicon.js\";\nimport { onboardingStatus, missingOnboarding } from \"./onboarding.js\";\nimport { checklistFor, gatingFields, isHealthGateClear } from \"../reports/checklist.js\";\nimport { rendersCommentary } from \"../reports/render.js\";\nimport { approveBlockers, type PreflightFinding } from \"../reports/preflight.js\";\nimport { parseAddresses, withGlobalCc } from \"../reports/send/orchestrate.js\";\nimport {\n renderSubmissionRow,\n SUBMISSION_STYLES,\n SUBMISSION_STATUS_SCRIPT,\n isVisibleInStrip,\n} from \"./submission-view.js\";\nimport {\n SITE_STATUS_OPTIONS,\n FREQ_OPTIONS,\n WATCH_CONDITION_OPTIONS,\n CLEAR_SECRET,\n} from \"./site-details.js\";\nimport type { SiteAlarmContext } from \"./fleet-cockpit.js\";\n\nconst DASH = \"—\";\n\nfunction scoreTile(label: string, value: number | null): string {\n const display = value === null ? \"—\" : String(value);\n return `<div class=\"tile\"><div class=\"tile-value\">${escapeHtml(display)}</div><div class=\"tile-label\">${escapeHtml(label)}</div></div>`;\n}\n\nfunction healthTile(label: string, value: number | null, sub: string | null): string {\n const display = value === null ? \"—\" : String(value);\n const subLine = sub ? `<div class=\"tile-sub\">${escapeHtml(sub)}</div>` : \"\";\n return `<div class=\"tile\"><div class=\"tile-value\">${escapeHtml(display)}</div><div class=\"tile-label\">${escapeHtml(label)}</div>${subLine}</div>`;\n}\n\nfunction depsSub(majorBehind: number | null): string | null {\n if (majorBehind === null || majorBehind === 0) return null;\n return `${majorBehind} major behind`;\n}\n\nfunction securityTotal(site: WebsiteRow): number | null {\n const parts = [\n site.securityVulnsCritical,\n site.securityVulnsHigh,\n site.securityVulnsModerate,\n site.securityVulnsLow,\n ];\n if (parts.every((p) => p === null)) return null;\n return parts.reduce<number>((sum, p) => sum + (p ?? 0), 0);\n}\n\nfunction securitySub(site: WebsiteRow): string | null {\n const total = securityTotal(site);\n if (total === null || total === 0) return null;\n const c = site.securityVulnsCritical ?? 0;\n const h = site.securityVulnsHigh ?? 0;\n const m = site.securityVulnsModerate ?? 0;\n const l = site.securityVulnsLow ?? 0;\n return `${c}C / ${h}H / ${m}M / ${l}L`;\n}\n\n/** One advisory line: a severity pill, the vulnerable module, the advisory title, any CVEs,\n * and a link to the advisory when present. All Airtable-sourced text is escaped. */\nfunction advisoryRow(a: SecurityAdvisory): string {\n const sev = escapeHtml(a.severity);\n const module = escapeHtml(a.module);\n // Build-time-only (\"development\") deps have a lower live-exploit surface for a static site;\n // flag them so a dev-scoped critical reads differently than a runtime one. Fixed literal — safe.\n const scope = a.scope === \"development\" ? ` <span class=\"muted\">(dev)</span>` : \"\";\n const title = a.title ? ` — ${escapeHtml(a.title)}` : \"\";\n const cves =\n a.cves.length > 0 ? ` <span class=\"muted\">(${escapeHtml(a.cves.join(\", \"))})</span>` : \"\";\n const link = a.url\n ? ` <a href=\"${escapeHtml(safeUrl(a.url))}\" rel=\"noopener noreferrer\">advisory ▸</a>`\n : \"\";\n return `<li class=\"vuln-item\">\n <span class=\"pill sev-${sev}\">${sev}</span>\n <strong>${module}</strong>${scope}${title}${cves}${link}\n </li>`;\n}\n\n/** The per-site vulnerability list — which packages are vulnerable, severity-sorted, not just the\n * totals tile. Omitted entirely when the site was never audited (`null`) or is clean (empty). */\nfunction securitySection(site: WebsiteRow): string {\n const advisories = site.securityAdvisories;\n if (!advisories || advisories.length === 0) return \"\";\n const sorted = [...advisories].sort(\n (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity],\n );\n return `<div class=\"section vulns\">\n <h2>Vulnerabilities (${sorted.length})</h2>\n <ul class=\"vuln-list\">${sorted.map(advisoryRow).join(\"\")}</ul>\n </div>`;\n}\n\n/** Map an evidence result onto the existing site-Tier bands (green/amber/red) — pass→healthy,\n * fail→attention, unknown/absent→watch; `n/a` is a muted, non-blocking annotation. No 4th scale. */\nfunction healthPresentation(status: EvidenceResult): { cls: string; word: string } {\n switch (status) {\n case \"pass\":\n return { cls: \"healthy\", word: \"clear\" };\n case \"fail\":\n return { cls: \"attention\", word: \"blocks\" };\n case \"n/a\":\n return { cls: \"na\", word: \"n/a\" };\n default:\n return { cls: \"watch\", word: \"needs you\" }; // unknown / absent\n }\n}\n\n/** The per-report health panel: one Tier-colored pill per `checklistFor(reportType)` item, driven\n * by `report.autoEvidence` (absent → unknown/amber). Advisory items (in the checklist but not\n * `gatingFields`) render their color but are annotated \"advisory — never blocks\". Launch/\n * Announcement (empty checklist) render NOTHING — never gated. No manual checkboxes: ticking is\n * retired from the gating path. */\nfunction checklistBlock(r: ReportRow): string {\n const items = checklistFor(r.reportType);\n if (items.length === 0) return \"\";\n const gating = new Set(gatingFields(r.reportType));\n const rid = escapeHtml(r.id);\n const rows = items\n .map((item) => {\n const ev = r.autoEvidence?.[item.field];\n const status: EvidenceResult = ev?.result ?? \"unknown\";\n const { cls, word } = healthPresentation(status);\n const advisory = gating.has(item.field)\n ? \"\"\n : ` <span class=\"advisory\">advisory — never blocks</span>`;\n const note = ev?.note ? ` <span class=\"check-note\">${escapeHtml(ev.note)}</span>` : \"\";\n return `<li class=\"check-item\"><span class=\"pill ${cls}\" title=\"${escapeHtml(ev?.note ?? \"not yet measured\")}\">${word}</span> ${escapeHtml(item.label)}${advisory}${note}</li>`;\n })\n .join(\"\");\n return `<ul class=\"checklist\" data-checklist-for=\"${rid}\">${rows}</ul>`;\n}\n\n/** The Approve button for a pending report. Server-renders `disabled` when the\n * report's health gate is not clear OR the report has send blockers (the\n * convenience gate — approve.ts + orchestrate.ts are the hard backstops).\n * `data-send-blocked` marks the reason for tests/tooling; there is no client-side\n * re-gate to guard against (checklist ticking no longer drives this button). */\nfunction approveButton(r: ReportRow, blocked: boolean): string {\n const gateClear = isHealthGateClear({\n reportType: r.reportType,\n autoEvidence: r.autoEvidence ?? {},\n });\n const disabled = gateClear && !blocked ? \"\" : \" disabled\";\n const blockedAttr = blocked ? ` data-send-blocked=\"1\"` : \"\";\n return `<button class=\"approve\" data-report-id=\"${escapeHtml(r.id)}\" data-approve-url=\"${escapeHtml(`/api/reports/${encodeURIComponent(r.id)}/approve`)}\"${blockedAttr}${disabled}>Approve</button>`;\n}\n\n/** The \"Send anyway…\" override affordance for a health-red pending report: a required-reason\n * text input plus a submit button that POSTs to the approve endpoint with `?override=1`.\n * Rendered ONLY when `!isHealthGateClear(r)` — a healthy report has nothing to override, and a\n * gate that's already clear hides/omits the control entirely (R4.1). This is NOT a bypass of the\n * real send blockers (missing recipients / header image / report scores): the endpoint still\n * evaluates those against the synthetic overridden report and can refuse the override too. */\nfunction overrideControl(r: ReportRow): string {\n const gateClear = isHealthGateClear({\n reportType: r.reportType,\n autoEvidence: r.autoEvidence ?? {},\n });\n if (gateClear) return \"\";\n const rid = escapeHtml(r.id);\n const overrideUrl = escapeHtml(`/api/reports/${encodeURIComponent(r.id)}/approve?override=1`);\n return `<div class=\"override\" data-override-for=\"${rid}\">\n <button type=\"button\" class=\"override-toggle\" data-report-id=\"${rid}\">Send anyway…</button>\n <div class=\"override-form\" hidden>\n <input type=\"text\" class=\"override-reason\" placeholder=\"Reason for overriding the health gate (required)\" />\n <button type=\"button\" class=\"override-submit\" data-report-id=\"${rid}\" data-override-url=\"${overrideUrl}\">Confirm override</button>\n <span class=\"override-status\"></span>\n </div>\n </div>`;\n}\n\n/** The preflight chip for one pending report: red = send blockers (approve\n * disabled), amber = wrong-inbox warns, green = clear. Reasons ride the title\n * tooltip. Same approveBlockers() the approve endpoint gates on, computed from\n * data the page already fetched — the chip can't drift from the gate. */\nfunction preflightChip(findings: PreflightFinding[]): string {\n const fails = findings.filter((f) => f.level === \"fail\");\n const warns = findings.filter((f) => f.level === \"warn\");\n if (fails.length > 0) {\n const title = escapeHtml(fails.map((f) => `${f.check}: ${f.message}`).join(\"\\n\"));\n return `<span class=\"preflight preflight-fail\" title=\"${title}\">preflight ✗ ${fails.length}</span>`;\n }\n if (warns.length > 0) {\n const title = escapeHtml(warns.map((f) => `${f.check}: ${f.message}`).join(\"\\n\"));\n return `<span class=\"preflight preflight-warn\" title=\"${title}\">preflight ⚠ ${warns.length}</span>`;\n }\n return `<span class=\"preflight preflight-ok\" title=\"recipients, header image and report scores all present\">preflight ✓</span>`;\n}\n\n/** The next 09:23 UTC daily-run strictly after `now` — when an approved report\n * actually sends (daily-reports.yml cron). Approve is not send; the row says so. */\nfunction nextDailyRun(now: Date): Date {\n const run = new Date(now);\n run.setUTCHours(9, 23, 0, 0);\n if (run.getTime() <= now.getTime()) run.setUTCDate(run.getUTCDate() + 1);\n return run;\n}\n\n/** The send-timing line for a pending row. Two honesty rules: (1) between 09:23\n * and ~10:30 UTC TODAY's run may still be mid-flight (cron start delay + install\n * + build + the draft step) and would sweep a fresh approval within minutes —\n * claiming \"~24h\" there would be a lie in the harmful direction, so say the\n * window out loud instead; (2) the countdown floors (minutes under an hour) so\n * it never claims more remaining time than exists. */\nfunction sendTimingLine(now: Date): string {\n const minuteOfDay = now.getUTCHours() * 60 + now.getUTCMinutes();\n const RUN_START = 9 * 60 + 23;\n const GRACE_END = 10 * 60 + 30;\n if (minuteOfDay >= RUN_START && minuteOfDay < GRACE_END) {\n return `<span class=\"muted\">today's 09:23 UTC run may still be in flight — approving now can send within minutes</span>`;\n }\n const run = nextDailyRun(now);\n const leadMin = Math.floor((run.getTime() - now.getTime()) / 60_000);\n const lead =\n leadMin >= 60\n ? `~${Math.floor(leadMin / 60)}h`\n : leadMin >= 1\n ? `~${leadMin} min`\n : \"under 1 min\";\n return `<span class=\"muted\">approve ≠ send: goes out at the next daily run, 09:23 UTC (${lead})</span>`;\n}\n\n/** Exactly what a send of this report would address, resolved the way the send\n * path resolves it (To override → point of contact; forced ops CC appended) —\n * computed from the same inputs so the row can't drift from orchestrate.ts. */\nfunction recipientsLine(site: WebsiteRow): string {\n const to = parseAddresses(site.reportRecipientsTo) ?? parseAddresses(site.pointOfContact) ?? [];\n if (to.length === 0) {\n return `<span class=\"recipients recipients-missing\">recipients: none resolve — set point of contact in Airtable</span>`;\n }\n const cc = withGlobalCc(parseAddresses(site.reportRecipientsCc), to);\n const ccPart = cc.length > 0 ? ` · CC ${cc.map(escapeHtml).join(\", \")}` : \"\";\n return `<span class=\"recipients\">To ${to.map(escapeHtml).join(\", \")}${ccPart}</span>`;\n}\n\n/** \"Refresh preview\": dispatch a re-render so the stored body picks up commentary\n * edited after drafting. Offered only on an UNSENT report — the endpoint and the\n * workflow both refuse a sent one, and a button whose only outcome is a refusal\n * is worse than no button. */\nfunction rerenderButton(r: ReportRow): string {\n if (r.sentAt !== null) return \"\";\n const url = `/api/reports/${encodeURIComponent(r.id)}/rerender`;\n return `<button class=\"rerender\" data-rerender-url=\"${escapeHtml(url)}\" title=\"Re-render this report from its current row (runs in Actions; takes a minute or two)\">refresh preview</button>`;\n}\n\n/** The dashboard's own preview route for a report body (served from Turso).\n * Never the Airtable attachment URL — that one is signed and expires. */\nfunction reportPreviewUrl(reportId: string): string {\n return `/api/reports/${encodeURIComponent(reportId)}/preview`;\n}\n\n/** Inline commentary editor for a report still awaiting send (#539 Phase 4).\n * Rendered only where the server would accept the write — `setReportCommentary`\n * refuses once `sentAt` is set, and offering a box that can only be rejected is\n * worse than offering none. */\nfunction commentaryEditor(r: ReportRow): string {\n // Only where the template will actually use it. `buildAnnouncementMjml` and\n // `buildLaunchMjml` never reference `commentary`, so offering a box on those\n // types means the operator writes text, sees it save, previews it, and finds\n // nothing — with no explanation. Same principle as not offering it on a report\n // that has already been sent.\n if (!rendersCommentary(r.reportType)) return \"\";\n const url = `/api/reports/${encodeURIComponent(r.id)}/commentary`;\n return `<div class=\"commentary\"><label for=\"commentary-${escapeHtml(r.id)}\">Commentary <span class=\"muted\">(re-rendered into the email at send)</span></label><textarea id=\"commentary-${escapeHtml(r.id)}\" data-commentary-for=\"${escapeHtml(r.id)}\" data-commentary-url=\"${escapeHtml(url)}\" rows=\"3\">${escapeHtml(r.commentary ?? \"\")}</textarea><span class=\"commentary-saved\" data-for=\"${escapeHtml(r.id)}\"></span></div>`;\n}\n\nfunction pendingRow(r: ReportRow, site: WebsiteRow, now: Date): string {\n const type = escapeHtml(r.reportType);\n const period = r.period ? escapeHtml(r.period) : \"—\";\n const findings = approveBlockers(site, r);\n const blocked = findings.some((f) => f.level === \"fail\");\n // Draft-time render: sendOne re-renders at send with current Commentary /\n // subject override, so this is the DRAFT preview, labeled as such.\n // The dashboard's OWN route, not the Airtable attachment URL. Those are signed\n // and expire, so a tab left open 404s — the reason `/api/reports/:id/preview`\n // was built in Phase 2 to serve the body from Turso. It was never linked to,\n // so the expiring URL stayed in front of the operator. `renderedHtmlAttachment`\n // still gates the link: it is how we know a body was ever rendered at all.\n const preview = r.renderedHtmlAttachment\n ? `<a href=\"${escapeHtml(reportPreviewUrl(r.id))}\" rel=\"noopener noreferrer\" title=\"rendered at draft time — Commentary/subject edits after drafting are not reflected\">draft preview ▸</a>`\n : `<span class=\"muted\">no preview yet</span>`;\n const sendLine = sendTimingLine(now);\n return `<li><div class=\"pending-head\"><strong>${type}</strong> <span class=\"muted\">${period}</span> ${preflightChip(findings)} ${preview} ${rerenderButton(r)} ${approveButton(r, blocked)}</div><div class=\"pending-info\">${recipientsLine(site)} ${sendLine}</div>${checklistBlock(r)}${commentaryEditor(r)}${overrideControl(r)}</li>`;\n}\n\nfunction pendingSection(reports: ReportRow[], site: WebsiteRow, now: Date): string {\n const pending = reports.filter(isPendingApproval);\n if (pending.length === 0) return \"\";\n return `<div class=\"section pending\">\n <h2>Pending your yes (${pending.length})</h2>\n <ul class=\"pending-list\">${pending.map((r) => pendingRow(r, site, now)).join(\"\")}</ul>\n </div>`;\n}\n\n/** The GA \"Users\" cell for a report row: current count plus the signed delta vs\n * the previous period when both are known. Renders \"—\" when there's no current\n * count (GA not configured / fetch failed → blank in Airtable). */\nfunction gaUsersCell(r: ReportRow): string {\n if (r.gaUsersCurrent === null) return DASH;\n const current = String(r.gaUsersCurrent);\n if (r.gaUsersPrevious === null) return escapeHtml(current);\n const delta = r.gaUsersCurrent - r.gaUsersPrevious;\n const sign = delta > 0 ? \"+\" : \"\"; // negatives carry their own \"-\"; zero shows \"0\"\n return `${escapeHtml(current)} <span class=\"muted\">(${escapeHtml(`${sign}${delta}`)})</span>`;\n}\n\n/** The search-presence cell: the page-1 position when the site was found on\n * page 1, otherwise \"—\" (not-found OR the check didn't run). */\nfunction searchCell(r: ReportRow): string {\n if (r.searchFoundPage1 && r.searchPosition !== null) {\n return escapeHtml(`#${r.searchPosition}`);\n }\n return DASH;\n}\n\nfunction reportRow(r: ReportRow, site: WebsiteRow): string {\n const date = r.completedOn ? escapeHtml(r.completedOn) : DASH;\n const type = escapeHtml(r.reportType);\n const id = escapeHtml(r.reportId);\n const ga = gaUsersCell(r);\n const search = searchCell(r);\n const link = r.renderedHtmlAttachment\n ? `<a href=\"${escapeHtml(reportPreviewUrl(r.id))}\">view</a>`\n : `<span class=\"muted\">no attachment</span>`;\n // Same gate as the pending section: an approve action in the history table\n // must not be a side door around the send-blocker gate.\n const action = isPendingApproval(r)\n ? approveButton(\n r,\n approveBlockers(site, r).some((f) => f.level === \"fail\"),\n )\n : \"\";\n // Commentary stays editable for the WHOLE unsent window, not just while a\n // report is awaiting approval: approving schedules the send for the next 09:23\n // UTC run, so there is a window of up to ~24h in which a typo is still\n // fixable, and the server's lock is `sentAt`. A pending report already carries\n // its editor in the pending list, so this covers the rest — approved-awaiting-\n // send, and drafts not yet marked ready.\n const commentary =\n r.sentAt === null && !isPendingApproval(r)\n ? `<tr class=\"commentary-row\"><td colspan=\"7\">${commentaryEditor(r)}</td></tr>`\n : \"\";\n return `<tr><td>${date}</td><td>${type}</td><td><code>${id}</code></td><td>${ga}</td><td>${search}</td><td>${link}</td><td>${action}</td></tr>${commentary}`;\n}\n\nconst SUBMISSIONS_PER_SITE_CAP = 25;\n\nfunction submissionsSection(submissions: SubmissionRow[], site: WebsiteRow): string {\n const visible = submissions.filter(isVisibleInStrip);\n if (visible.length === 0) return \"\";\n const recent = [...visible]\n .sort((a, b) => (b.submittedAt ?? \"\").localeCompare(a.submittedAt ?? \"\"))\n .slice(0, SUBMISSIONS_PER_SITE_CAP);\n // The heading shows the true total; when we only list a slice, say so rather\n // than implying every one of the N is on the page.\n const note =\n visible.length > recent.length\n ? `<span class=\"muted\"> — showing ${recent.length} of ${visible.length}</span>`\n : \"\";\n const viewAll = `<a class=\"subm-viewall\" href=\"/submissions?site=${escapeHtml(siteSlug(site.name))}\">View all for this site →</a>`;\n return `<div class=\"section submissions\">\n <h2>Form submissions (${visible.length})${note} ${viewAll}</h2>\n <ul class=\"subm-list\">${recent.map(renderSubmissionRow).join(\"\")}</ul>\n </div>`;\n}\n\nconst SPAM_WINDOW_DAYS = 30;\n\n/** The per-site spam panel: caught (honeypot/too-fast) + marked-spam from the screen-out\n * buckets, plus auto-filtered and delivered counted from the submissions loaded for this\n * page within the window. Omitted when there's nothing to show. The in-window counts\n * undercount only if the site exceeds the 200-row submissions fetch within the window\n * (rare at fleet scale). */\nfunction spamScreenSection(\n totals: ScreenOutTotals | null,\n submissions: SubmissionRow[],\n now: Date,\n): string {\n const sinceMs = now.getTime() - SPAM_WINDOW_DAYS * 24 * 60 * 60 * 1000;\n const inWindow = submissions.filter(\n (s) => s.submittedAt !== null && Date.parse(s.submittedAt) >= sinceMs,\n );\n // \"Delivered\" excludes spam_auto/spam rows: their notification was skipped, and on\n // the requireTurnstile canary site they are the very rows under watch — counting\n // them inflated the one metric the canary is judged on (2026-07-15).\n const delivered = inWindow.filter((s) => s.status !== \"spam_auto\" && s.status !== \"spam\").length;\n const autoFiltered = inWindow.filter((s) => s.status === \"spam_auto\").length;\n const t = totals ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };\n if (\n delivered === 0 &&\n autoFiltered === 0 &&\n t.honeypot === 0 &&\n t.tooFast === 0 &&\n t.markedSpam === 0\n )\n return \"\";\n const row = (label: string, n: number) =>\n `<div class=\"spam-kv\"><span class=\"k\">${label}</span> ${escapeHtml(String(n))}</div>`;\n return `<div class=\"section spam-screen\">\n <h2>Spam screen (30d)</h2>\n ${row(\"Caught — honeypot\", t.honeypot)}\n ${row(\"Caught — too-fast\", t.tooFast)}\n ${row(\"Auto-filtered\", autoFiltered)}\n ${row(\"Delivered\", delivered)}\n ${row(\"Marked spam\", t.markedSpam)}\n </div>`;\n}\n\n/** Setup (N/4) status near the page header. Lists the missing onboarding items\n * visibly (the cockpit chip only hovers them) so the operator sees what's left\n * to wire up without leaving the page. */\nfunction setupSection(site: WebsiteRow): string {\n const { score, total } = onboardingStatus(site);\n const missing = missingOnboarding(site);\n const detail =\n missing.length === 0\n ? `<span class=\"setup-ok\">complete</span>`\n : `<span class=\"setup-missing\">Missing: ${escapeHtml(missing.join(\", \"))}</span>`;\n return `<div class=\"setup-line\">Setup ${score}/${total} — ${detail}</div>`;\n}\n\n/** The site's cockpit alarm verdict, rendered as a chip strip under the header.\n * Mirrors fleet-browse-render's chips() presentation (attention items → red/critical\n * chips incl. the auto-fix-stuck border; watch reasons → chips with the accept-key\n * hint; accepted → muted ✓ chips). Renders \"\" when null or when there is nothing\n * to show (healthy/pre-launch with no accepted conditions stays calm). */\nfunction alarmSection(alarm: SiteAlarmContext | null): string {\n if (!alarm) return \"\";\n const chips: string[] = [];\n for (const it of alarm.items) {\n const cls = it.autoFixExhausted\n ? \"chip critical stuck\"\n : it.severity === \"critical\"\n ? \"chip critical\"\n : \"chip\";\n chips.push(`<span class=\"${cls}\">${escapeHtml(it.title)}</span>`);\n }\n alarm.watchReasons.forEach((reason, i) => {\n const key = alarm.watchAcceptKeys[i];\n const hint = key ? ` · accept: \"${escapeHtml(key)}\"` : \"\";\n chips.push(\n `<span class=\"chip\" title=\"Add this exact text to the site's Accepted Watch Conditions to mute this watch\">${escapeHtml(reason)}${hint}</span>`,\n );\n });\n for (const reason of alarm.acceptedReasons)\n chips.push(`<span class=\"chip accepted\">✓ accepted: ${escapeHtml(reason)}</span>`);\n if (chips.length === 0) return \"\";\n const label =\n alarm.tier === \"attention\" ? \"Needs attention\" : alarm.tier === \"watch\" ? \"Watch\" : \"Status\";\n return `<div class=\"section alarm\"><h2>${label}</h2><div class=\"chips\">${chips.join(\"\")}</div></div>`;\n}\n\n/** One read-only \"Site details\" row: a label and a value that degrades to \"—\". */\nfunction detailRow(label: string, value: string | null | undefined): string {\n const display =\n typeof value === \"string\" && value.trim().length > 0 ? escapeHtml(value.trim()) : DASH;\n return `<div class=\"detail\"><dt>${escapeHtml(label)}</dt><dd>${display}</dd></div>`;\n}\n\n/** A per-field \"saved\" indicator the page script flips to ✓ / ✗ after a POST. */\nfunction savedSpan(field: string): string {\n return `<span class=\"detail-saved\" data-for=\"${field}\"></span>`;\n}\n\n/** Editable `<select>` row for an enum field (Status / cadence). */\nfunction selectRow(\n label: string,\n field: string,\n options: readonly string[],\n current: string | null,\n url: string,\n): string {\n const inList = current !== null && options.includes(current);\n const opts = options\n .map(\n (o) =>\n `<option value=\"${escapeHtml(o)}\"${o === current ? \" selected\" : \"\"}>${escapeHtml(o)}</option>`,\n )\n .join(\"\");\n // When the stored value isn't one of the offered options (e.g. a null cadence,\n // or an Airtable-only \"legacy\" status), show a disabled placeholder selected\n // first so the operator must actively pick — never silently overwrites.\n const placeholder = inList ? \"\" : `<option value=\"\" disabled selected hidden>— select —</option>`;\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><select id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\">${placeholder}${opts}</select>${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable single-line `<input>` row for a text/email/repo field. */\nfunction inputRow(label: string, field: string, value: string | null, url: string): string {\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><input type=\"text\" id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\" value=\"${escapeHtml(value ?? \"\")}\" />${savedSpan(field)}</dd></div>`;\n}\n\n/**\n * How the site-details editor turns a control into the string it POSTs.\n *\n * Exported as SOURCE so tests can execute the exact text the page serves — the\n * inline script is a template string and `vitest.config.ts` runs without a DOM,\n * so nothing else in the suite can run it.\n *\n * The two special cases are DOM traps, not preferences:\n * - a checkbox's `.value` is its `value` content attribute (\"on\" by default),\n * never the checked state, so `el.value` posts \"on\" whether ticked or not;\n * - a `multiple` select's `.value` is the FIRST selected option only, so\n * `el.value` silently drops every other selection.\n * Both shipped broken in #591 for exactly this reason.\n */\nexport const DETAIL_VALUE_FN = `function detailValue(el) {\n if (el.type === \"checkbox\") return el.checked ? \"true\" : \"false\";\n if (el.multiple) return Array.from(el.selectedOptions).map((o) => o.value).join(\",\");\n return el.value;\n }`;\n\n/**\n * The site-details save, exported as SOURCE for the same reason as\n * DETAIL_VALUE_FN: it is served inside a template literal, so executing the real\n * text is the only way a test can see it work. `root` is passed in rather than\n * closed over so the test can hand it a stub instead of a document.\n *\n * **The resync on line `el.defaultValue = el.value` is load-bearing.** The blur\n * listener fires on `value !== defaultValue`, and this function used not to touch\n * `defaultValue` at all — so after one successful edit the two stayed different\n * forever and every later focus+blur of that field posted another Airtable write,\n * until the page was reloaded. The commentary handler 40 lines below always did\n * this correctly, which is what marks the omission as an oversight rather than a\n * decision.\n *\n * The worst case is the `secret` kind, which deliberately emits no `value`\n * attribute so an existing credential is never echoed into the HTML: its\n * `defaultValue` is permanently `\"\"`, so every blur after typing re-POSTed the\n * credential. And one Airtable quota exhaustion has already reddened six\n * workflows fleet-wide (2026-08-17), so a tab-through of this form was a cheap\n * way to burn the fleet's write budget.\n *\n * Only on `r.ok`: a failed save must stay dirty so the next blur retries it.\n * Guarded by an `in` check because `select` elements have no `defaultValue`.\n */\nexport const SAVE_DETAIL_FN = `function saveDetail(el, root) {\n const span = root.querySelector('.detail-saved[data-for=\"' + el.dataset.detailField + '\"]');\n return fetch(el.dataset.detailsUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ field: el.dataset.detailField, value: detailValue(el) }),\n })\n .then((r) => {\n if (span) span.textContent = r.ok ? \" \\\\u2713\" : \" \\\\u2717\";\n if (r.ok && \"defaultValue\" in el) el.defaultValue = el.value;\n })\n .catch(() => {\n if (span) span.textContent = \" \\\\u2717\";\n });\n }`;\n\n/**\n * `<input type=\"date\">` accepts ONLY `YYYY-MM-DD`. Handed anything else — an ISO\n * datetime such as `2026-08-01T00:00:00.000Z` — the browser sanitizes `.value` to\n * `\"\"` while `.defaultValue` keeps the raw string. That difference is what the\n * blur guard used to read as \"the operator edited this field\", so an untouched\n * tab-through would post an empty value and the server would accept it as a\n * deliberate clear. `maintenance day` / `testing day` drive the code-owned\n * next-due schedule, so the site would silently reschedule.\n *\n * Dormant while those Airtable columns stay date-only; it goes live the moment\n * anyone ticks \"include time\" on the field. Truncating to the date part here\n * removes the mismatch at the source (the script's gesture guard is the second\n * line of defence).\n */\nfunction toDateInputValue(value: string | null): string {\n return (value ?? \"\").slice(0, 10);\n}\n\n/** Editable `<input type=\"date\">` row. The browser enforces YYYY-MM-DD before\n * the request is made; `normalizeFieldValue`'s `date` kind still validates it\n * server-side, because a hand-crafted POST never touches this widget. */\nfunction dateRow(label: string, field: string, value: string | null, url: string): string {\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><input type=\"date\" id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\" value=\"${escapeHtml(toDateInputValue(value))}\" />${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable checkbox row. Posts the literal \"true\"/\"false\" the `bool` kind\n * accepts — a checkbox has no empty state, so there is nothing to clear. */\nfunction checkboxRow(label: string, field: string, checked: boolean, url: string): string {\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><input type=\"checkbox\" id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\"${checked ? \" checked\" : \"\"} />${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable multi-select row. Options come from WATCH_CONDITION_OPTIONS — the\n * live Airtable choices — because the API cannot create a missing one, so an\n * option offered here that the field lacks would produce a rejected write. */\nfunction multiSelectRow(\n label: string,\n field: string,\n options: readonly string[],\n selected: readonly string[],\n url: string,\n): string {\n const sel = new Set(selected);\n const opts = options\n .map(\n (o) =>\n `<option value=\"${escapeHtml(o)}\"${sel.has(o) ? \" selected\" : \"\"}>${escapeHtml(o)}</option>`,\n )\n .join(\"\");\n return `<div class=\"detail wide\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><select multiple id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\">${opts}</select>${savedSpan(field)}</dd></div>`;\n}\n\n/** Write-only row for a credential. Emits NO `value`, so the stored secret never\n * reaches the browser; the placeholder reports only whether one is set. Saving\n * it blank is a no-op server-side (`setSiteDetail` returns \"unchanged\"), which\n * is what makes an always-blank input safe to render beside fields that clear\n * on empty. */\nfunction secretRow(label: string, field: string, isSet: boolean, url: string): string {\n // The placeholder is the ONLY place the clear gesture is discoverable, so it\n // names the sentinel verbatim. Shown only when a key is actually set —\n // offering \"clear\" on an empty field is noise.\n const ph = isSet ? `•••••••• (type to replace, or ${CLEAR_SECRET} to erase)` : \"not set\";\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><input type=\"password\" autocomplete=\"off\" id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\" placeholder=\"${escapeHtml(ph)}\" />${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable multi-line `<textarea>` row for the copy override fields. */\nfunction textareaRow(label: string, field: string, value: string | null, url: string): string {\n return `<div class=\"detail wide\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><textarea id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\">${escapeHtml(value ?? \"\")}</textarea>${savedSpan(field)}</dd></div>`;\n}\n\n/** \"Site details\" section — inline-editable for the safe-text + operational fields\n * (writes via the authed /api/sites/:slug/details endpoint). `Last commit` stays\n * read-only (machine-derived). The Trigger Renovate button rides the heading. */\nfunction siteDetailsSection(site: WebsiteRow): string {\n const url = `/api/sites/${escapeHtml(siteSlug(site.name))}/details`;\n const lastCommit = site.lastCommitAt ? `${relativeTimeFromNow(site.lastCommitAt)}` : null;\n const rows = [\n // `statusRaw`, not `status`: this select's options ARE the Airtable cell\n // values (see SITE_STATUS_OPTIONS), so it must preselect against the raw cell.\n // Comparing the canonical value would mis-preselect every site, and would show\n // a \"legacy\" site as \"deprecated\" instead of the placeholder.\n selectRow(\"Status\", \"status\", SITE_STATUS_OPTIONS, site.statusRaw, url),\n selectRow(\"Maintenance cadence\", \"maintenanceFreq\", FREQ_OPTIONS, site.maintenanceFreq, url),\n selectRow(\"Testing cadence\", \"testingFreq\", FREQ_OPTIONS, site.testingFreq, url),\n inputRow(\"Report recipients (To)\", \"reportRecipientsTo\", site.reportRecipientsTo, url),\n inputRow(\"Report recipients (CC)\", \"reportRecipientsCc\", site.reportRecipientsCc, url),\n inputRow(\"Point of contact\", \"pointOfContact\", site.pointOfContact, url),\n inputRow(\"GA4 property\", \"ga4PropertyId\", site.ga4PropertyId, url),\n inputRow(\"Search query\", \"searchQuery\", site.searchQuery, url),\n inputRow(\"Git repo\", \"gitRepo\", site.gitRepo, url),\n // #539 Phase 4 — the fields nothing rendered before. `Mailchimp API Key` is\n // the one field in that group still absent, and deliberately: it is a live\n // credential and every row here emits its stored value into the page.\n inputRow(\"Netlify ID\", \"netlifyId\", site.netlifyId, url),\n inputRow(\"Search Console property\", \"searchConsoleProperty\", site.searchConsoleProperty, url),\n inputRow(\"Newsletter webhook\", \"newsletterWebhook\", site.newsletterWebhook, url),\n inputRow(\"Mailchimp audience ID\", \"mailchimpAudienceId\", site.mailchimpAudienceId, url),\n dateRow(\"Last maintenance day\", \"maintenanceDay\", site.maintenanceDay, url),\n dateRow(\"Last testing day\", \"testingDay\", site.testingDay, url),\n // Raw JSON, and shown as the raw cell rather than the parsed object: what the\n // operator edits has to be the thing `parseNotifyRouting` will read back.\n textareaRow(\"Notify routing (JSON)\", \"notifyRouting\", site.notifyRoutingRaw, url),\n checkboxRow(\"Require Turnstile\", \"requireTurnstile\", site.requireTurnstile, url),\n secretRow(\"Mailchimp API key\", \"mailchimpApiKey\", site.mailchimpApiKey !== null, url),\n multiSelectRow(\n \"Accepted watch conditions\",\n \"acceptedWatchConditions\",\n WATCH_CONDITION_OPTIONS,\n site.acceptedWatchConditions,\n url,\n ),\n textareaRow(\"Copy — Intro\", \"copyIntro\", site.copyIntro, url),\n textareaRow(\"Copy — Contact\", \"copyContact\", site.copyContact, url),\n textareaRow(\"Copy — Footer\", \"copyFooter\", site.copyFooter, url),\n detailRow(\"Last commit\", lastCommit),\n ].join(\"\");\n const triggerBtn = site.gitRepo?.trim()\n ? `<button class=\"trigger-renovate\" data-trigger-url=\"/api/sites/${escapeHtml(siteSlug(site.name))}/trigger-renovate\">Trigger Renovate</button>`\n : \"\";\n return `<div class=\"section site-details\">\n <h2>Site details ${triggerBtn}</h2>\n <dl class=\"details\">${rows}</dl>\n </div>`;\n}\n\nconst STYLES = `\n:root { color-scheme: light dark; }\nbody { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }\n@media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } }\nh1 { margin: 0 0 0.25rem; font-size: 1.75rem; }\n.meta { color: #666; margin-bottom: 2rem; }\n.meta a { color: inherit; }\n.audited { color: #999; font-size: 0.85rem; margin-bottom: 1.5rem; }\n.section { margin: 2rem 0; }\n.section h2 { font-size: 1.1rem; margin: 0 0 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #666; }\n.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; }\n.tile { padding: 1rem; border: 1px solid #ddd; border-radius: 6px; text-align: center; }\n@media (prefers-color-scheme: dark) { .tile { border-color: #333; } }\n.tile-value { font-size: 2rem; font-weight: 600; }\n.tile-label { font-size: 0.85rem; color: #666; margin-top: 0.25rem; }\n.tile-sub { font-size: 0.75rem; color: #999; margin-top: 0.15rem; }\ntable { width: 100%; border-collapse: collapse; }\nth, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { th, td { border-color: #2a2a2a; } }\n.muted { color: #999; }\n.empty { color: #999; padding: 1rem; border: 1px dashed #ccc; border-radius: 6px; text-align: center; }\nbutton.approve { font: inherit; padding: 0.35rem 0.85rem; border: 1px solid #2c7; border-radius: 6px; background: #2c7; color: #fff; cursor: pointer; position: relative; transition: background-color 0.12s ease, border-color 0.12s ease; }\nbutton.approve:hover:not(:disabled) { background: #25b36a; border-color: #25b36a; }\nbutton.approve:active:not(:disabled) { background: #1e9a5b; border-color: #1e9a5b; }\nbutton.approve:focus-visible { outline: 2px solid #2c7; outline-offset: 2px; }\nbutton.approve:disabled { opacity: 0.6; cursor: default; }\n/* In-flight: the label goes transparent rather than being removed, so the button keeps\n its exact width and the row never reflows mid-request. The spinner is a pseudo-element,\n NOT injected markup — this handler is deliberately textContent/title-only so server\n strings can never become HTML, and a ::after keeps that property intact. */\nbutton.approve.is-loading { color: transparent; cursor: progress; }\nbutton.approve.is-loading::after { content: \"\"; position: absolute; top: 50%; left: 50%; width: 0.85em; height: 0.85em; margin: -0.45em 0 0 -0.45em; border: 2px solid #ffffff80; border-top-color: transparent; border-radius: 50%; animation: approve-spin 0.8s linear infinite; }\n@keyframes approve-spin { to { transform: rotate(360deg); } }\n/* Never remove the spin outright — a frozen ring reads as \"hung\". Slow it instead. */\n@media (prefers-reduced-motion: reduce) { button.approve.is-loading::after { animation-duration: 2.4s; } }\n/* Terminal success. Wins over :disabled's dimming (the button stays disabled after a\n successful approve, and a 0.6-opacity \"Approved\" reads as not-quite-done). */\nbutton.approve.is-approved, button.approve.is-approved:disabled { background: #14663c; border-color: #14663c; color: #fff; opacity: 1; }\n.pending-info { display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; font-size: 0.82rem; margin: 0.3rem 0 0.15rem; }\n.recipients-missing { color: #e57373; }\n.preflight { font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; white-space: nowrap; }\n.preflight-ok { background: #1b5e2033; color: #7bc67e; }\n.preflight-warn { background: #f9a82533; color: #d79921; }\n.preflight-fail { background: #b71c1c33; color: #e57373; }\n.pending-list { list-style: none; padding: 0; margin: 0; }\n.pending-list li { padding: 0.5rem; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { .pending-list li { border-color: #2a2a2a; } }\n.pending-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; row-gap: 0.25rem; }\n.checklist {\n list-style: none;\n padding: 0;\n margin: 0.5rem 0 0.25rem 0.25rem;\n display: flex;\n flex-direction: column;\n gap: 0.3rem;\n}\n.check-item {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n font-size: 0.9rem;\n}\n.pill.healthy {\n background: #e8f5e9;\n color: #1b7a2f;\n}\n.pill.watch {\n background: #fff4e5;\n color: #a65a00;\n}\n.pill.attention {\n background: #fdecea;\n color: #b00;\n}\n.pill.na {\n background: #f0f0f0;\n color: #666;\n}\n.advisory {\n font-size: 0.72rem;\n color: #999;\n font-style: italic;\n}\n.check-note {\n font-size: 0.75rem;\n color: #999;\n}\n@media (prefers-color-scheme: dark) {\n .pill.healthy {\n background: #10240f;\n color: #7fce85;\n }\n .pill.watch {\n background: #2a2410;\n color: #ffd454;\n }\n .pill.attention {\n background: #2a0f0d;\n color: #ff8a80;\n }\n .pill.na {\n background: #222;\n color: #999;\n }\n}\n.pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }\n.override { margin: 0.4rem 0 0.15rem 0.25rem; }\nbutton.override-toggle { font: inherit; font-size: 0.78rem; padding: 0.15rem 0.6rem; border: 1px solid #b00; border-radius: 6px; background: transparent; color: #b00; cursor: pointer; transition: background-color 0.12s ease; }\nbutton.override-toggle:hover { background: #bb00001a; }\nbutton.override-toggle:focus-visible { outline: 2px solid #b00; outline-offset: 2px; }\n@media (prefers-color-scheme: dark) { button.override-toggle { border-color: #ff8a80; color: #ff8a80; } button.override-toggle:hover { background: #ff8a801f; } button.override-toggle:focus-visible { outline-color: #ff8a80; } }\n.override-form { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }\n.override-reason { font: inherit; font-size: 0.85rem; padding: 0.25rem 0.45rem; border: 1px solid #ccc; border-radius: 4px; background: transparent; color: inherit; min-width: 220px; flex: 1 1 220px; }\n@media (prefers-color-scheme: dark) { .override-reason { border-color: #444; } }\nbutton.override-submit { font: inherit; font-size: 0.8rem; padding: 0.25rem 0.7rem; border: 1px solid #b00; border-radius: 6px; background: #b00; color: #fff; cursor: pointer; transition: background-color 0.12s ease, border-color 0.12s ease; }\nbutton.override-submit:hover:not(:disabled) { background: #900; border-color: #900; }\nbutton.override-submit:active:not(:disabled) { background: #7a0000; border-color: #7a0000; }\nbutton.override-submit:focus-visible { outline: 2px solid #b00; outline-offset: 2px; }\nbutton.override-submit:disabled { opacity: 0.6; cursor: default; }\n.override-status { font-size: 0.78rem; color: #999; }\n.vuln-list { list-style: none; padding: 0; margin: 0; }\n.vuln-item { padding: 0.45rem 0; border-bottom: 1px solid #eee; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline; }\n@media (prefers-color-scheme: dark) { .vuln-item { border-color: #2a2a2a; } }\n.pill.sev-critical { background: #fdecea; color: #b00; }\n.pill.sev-high { background: #fff0e6; color: #c4500a; }\n.pill.sev-moderate { background: #fff8e1; color: #8a6d00; }\n.pill.sev-low { background: #f0f0f0; color: #555; }\n.home { display: inline-block; font-size: 0.9rem; margin-bottom: 0.75rem; text-decoration: none; }\n.setup-line { font-size: 0.9rem; color: #666; margin-bottom: 1rem; }\n.setup-ok { color: #1b7a2f; font-weight: 600; }\n.setup-missing { color: #a65a00; }\n.details { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.5rem 1.5rem; margin: 0; }\n.detail { display: flex; flex-direction: column; }\n.detail.wide { grid-column: 1 / -1; }\n.detail dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #999; }\n.detail dd { margin: 0; }\n.detail dd input, .detail dd select, .detail dd textarea { width: 100%; box-sizing: border-box; font: inherit; padding: 0.25rem 0.4rem; border: 1px solid #ccc; border-radius: 4px; background: transparent; color: inherit; }\n.detail dd textarea { min-height: 3.5rem; resize: vertical; }\n.detail-saved { font-size: 0.8rem; color: #2a8; }\nbutton.trigger-renovate { font: inherit; font-size: 0.8rem; padding: 0.15rem 0.6rem; margin-left: 0.5rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; transition: background-color 0.12s ease, border-color 0.12s ease; }\nbutton.trigger-renovate:hover:not(:disabled) { background: #88888822; border-color: #666; }\nbutton.trigger-renovate:focus-visible { outline: 2px solid #888; outline-offset: 2px; }\nbutton.trigger-renovate:disabled { opacity: 0.6; cursor: default; }\n/* Alarm chip strip — mirrors fleet-render.ts chip styles (NOT the page's .pill classes). */\n.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }\n.chip { font-size: 0.8rem; padding: 0.1rem 0.5rem; border-radius: 6px; background: #f0f0f0; }\n@media (prefers-color-scheme: dark) { .chip { background: #222; } }\n.chip.critical { background: #fdecea; color: #b00; }\n.chip.stuck { border: 1px solid #b00; font-weight: 600; }\n.chip.accepted { background: transparent; border: 1px dashed #bbb; color: #888; }\n@media (prefers-color-scheme: dark) { .chip.accepted { border-color: #444; } .chip.critical { background: #2a0f0d; color: #ff8a80; } }\n`;\n\n/**\n * Render the per-site dashboard as a single HTML document. Pure function:\n * no Airtable access, no env reads, no I/O. The Netlify function handler\n * fetches data, then hands it here. Easier to unit-test, easier to render\n * a static preview from CLI later.\n */\nexport function renderSiteDashboardHtml(\n site: WebsiteRow,\n reports: ReportRow[],\n submissions: SubmissionRow[] = [],\n spamTotals: ScreenOutTotals | null = null,\n now: Date = new Date(),\n alarm: SiteAlarmContext | null = null,\n operatorEmail: string | null = null,\n): string {\n const name = escapeHtml(site.name);\n const urlSafe = safeUrl(site.url);\n const allScoresNull =\n site.pScore === null && site.rScore === null && site.bpScore === null && site.seoScore === null;\n\n const scoresSection = allScoresNull\n ? `<div class=\"empty\">No lighthouse data yet — run <code>reddoor-maint audit --write-airtable</code> from the site checkout.</div>`\n : `<div class=\"tiles\">\n ${scoreTile(\"Performance\", site.pScore)}\n ${scoreTile(\"Accessibility\", site.rScore)}\n ${scoreTile(\"Best Practices\", site.bpScore)}\n ${scoreTile(\"SEO\", site.seoScore)}\n </div>`;\n\n const secTotal = securityTotal(site);\n const allHealthNull =\n site.a11yViolations === null && site.depsDrifted === null && secTotal === null;\n const healthSection = allHealthNull\n ? `<div class=\"empty\">No health data yet — run <code>reddoor-maint audit --write-airtable</code> from the site checkout.</div>`\n : `<div class=\"tiles\">\n ${healthTile(\"Accessibility issues\", site.a11yViolations, null)}\n ${healthTile(\"Dependency updates\", site.depsDrifted, depsSub(site.depsMajorBehind))}\n ${healthTile(\"Security alerts\", secTotal, securitySub(site))}\n </div>`;\n\n const auditedLine = site.lastLighthouseAuditAt\n ? `<div class=\"audited\">Last audited ${escapeHtml(relativeTimeFromNow(site.lastLighthouseAuditAt))}</div>`\n : \"\";\n\n // The report-history TABLE is the only place the \"recent 6\" slice belongs:\n // long enough to show a quarter of monthly reports plus the latest testing\n // report, short enough to keep the page a single scroll. The pending list +\n // approve buttons above intentionally see the FULL `reports` set — an OLD\n // pending report that falls outside this slice must still be approvable\n // (and must not disagree with the fleet banner, which counts ALL reports).\n const recentReports = [...reports]\n .sort((a, b) => (b.completedOn ?? \"\").localeCompare(a.completedOn ?? \"\"))\n .slice(0, 6);\n const reportsSection =\n recentReports.length === 0\n ? `<div class=\"empty\">No reports yet.</div>`\n : `<table>\n <thead><tr><th>Completed</th><th>Type</th><th>ID</th><th>GA users</th><th>Search</th><th>Report</th><th></th></tr></thead>\n <tbody>${recentReports.map((r) => reportRow(r, site)).join(\"\")}</tbody>\n </table>`;\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n ${FAVICON_LINK}\n <title>${name} — Reddoor maintenance</title>\n <style>${STYLES}${SUBMISSION_STYLES}</style>\n</head>\n<body>\n ${renderAuthChrome(operatorEmail)}\n <a class=\"home\" href=\"/\">← Fleet home</a>\n <h1>${name}</h1>\n <div class=\"meta\"><a href=\"${escapeHtml(urlSafe)}\">${escapeHtml(site.url)}</a></div>\n ${auditedLine}\n ${setupSection(site)}\n ${alarmSection(alarm)}\n ${pendingSection(reports, site, now)}\n\n <div class=\"section\">\n <h2>Lighthouse</h2>\n ${scoresSection}\n </div>\n\n <div class=\"section\">\n <h2>Site Health</h2>\n ${healthSection}\n </div>\n\n ${securitySection(site)}\n\n <div class=\"section\">\n <h2>Reports</h2>\n ${reportsSection}\n </div>\n\n ${siteDetailsSection(site)}\n ${spamScreenSection(spamTotals, submissions, now)}\n ${submissionsSection(submissions, site)}\n <script>\n // The SAME pending report is rendered twice — once in the pending list, once\n // in the reports history — so one report id owns two Approve buttons. Every\n // state change has to reach both, or the two places you look disagree: one\n // says \"Approved\", the other still says \"Approve\" and (with the gate clear)\n // is still enabled.\n function approveButtonsFor(id) {\n return Array.from(document.querySelectorAll('button.approve[data-report-id=\"' + id + '\"]'));\n }\n document.querySelectorAll(\"button.approve\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n const twins = approveButtonsFor(b.dataset.reportId);\n twins.forEach((t) => { t.disabled = true; });\n // is-loading swaps the label for a spinner; aria-busy is the same news for a\n // screen reader, which cannot see it. Both are cleared in the finally below so\n // no exit path can strand the button spinning forever.\n b.classList.add(\"is-loading\");\n b.setAttribute(\"aria-busy\", \"true\");\n try {\n const res = await fetch(b.dataset.approveUrl, { method: \"POST\" });\n if (res.ok) {\n // Terminal success: darker green, and they stay disabled (approving twice\n // is a no-op server-side, but the button should not invite the second\n // click). Applied to every twin, not just the one that was clicked.\n twins.forEach((t) => {\n t.textContent = \"Approved\";\n t.classList.add(\"is-approved\");\n });\n } else {\n // A 409 carries { reason, blockers } — surface WHY instead of a bare\n // \"Failed\" next to a possibly-stale green chip. textContent/title\n // assignment only (no innerHTML), so server strings stay inert.\n const data = await res.json().catch(() => null);\n const label = data && data.reason === \"send-blocked\" ? \"Blocked\" : \"Failed\";\n // Double-backslash, NOT single: this whole block lives inside the renderer's\n // template literal, so a single-backslash escape is consumed HERE at build\n // time and emits a real newline into the served HTML — an unterminated string\n // literal that kills the parse of the entire script element, taking every\n // handler on the page with it (exactly how approve/override/renovate went\n // dead). Note this comment must also avoid backticks for the same reason.\n const title = data && Array.isArray(data.blockers) ? data.blockers.join(\"\\\\n\") : null;\n // Re-enable EVERY twin, not just the clicked one — leaving the other\n // disabled would strand it reading \"Approve\" and unusable.\n twins.forEach((t) => {\n t.textContent = label;\n if (title !== null) t.title = title;\n t.disabled = false;\n });\n }\n } catch {\n // Network rejection (offline, DNS, abort): mirror the !res.ok path so\n // the buttons don't sit permanently disabled reading \"Approve\".\n twins.forEach((t) => {\n t.textContent = \"Failed\";\n t.disabled = false;\n });\n } finally {\n b.classList.remove(\"is-loading\");\n b.removeAttribute(\"aria-busy\");\n }\n });\n });\n // Health-gate override affordance: the toggle reveals a required-reason field; the\n // submit POSTs the server-provided override URL with { reason } as the body — a\n // distinct, deliberate action, never a silent default (an empty reason is\n // refused client-side AND server-side).\n document.querySelectorAll(\"button.override-toggle\").forEach((b) => {\n b.addEventListener(\"click\", () => {\n // Scoped lookup, not nextElementSibling: adjacency is a markup accident,\n // and anything inserted between the toggle and its form would kill \"Send\n // anyway…\" silently. The submit handler six lines down already uses\n // closest(); this is the same contract from the other end.\n const wrap = b.closest(\".override\");\n const form = wrap ? wrap.querySelector(\".override-form\") : null;\n if (form) form.hidden = !form.hidden;\n });\n });\n document.querySelectorAll(\"button.override-submit\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n const wrap = b.closest(\".override-form\");\n const input = wrap ? wrap.querySelector(\".override-reason\") : null;\n const status = wrap ? wrap.querySelector(\".override-status\") : null;\n const reason = input ? input.value.trim() : \"\";\n if (!reason) {\n if (status) status.textContent = \"Reason required\";\n return;\n }\n b.disabled = true;\n if (status) status.textContent = \"Sending…\";\n try {\n const res = await fetch(b.dataset.overrideUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ reason }),\n });\n if (res.ok) {\n if (status) status.textContent = \"Overridden ✓\";\n if (input) input.disabled = true;\n // Reflect the send-anyway state on the row's own Approve buttons too —\n // they stay server-gated disabled, but the label should stop implying\n // \"waiting on you\" once an override has gone through. ALL of them: the\n // same report is rendered in both the pending list and the history, so\n // a singular querySelector updated the first and left the second lying.\n approveButtonsFor(b.dataset.reportId).forEach((t) => {\n t.textContent = \"Overridden\";\n });\n } else {\n // A 409 carries { reason, blockers } — surface WHY (textContent only,\n // never innerHTML, so server strings stay inert).\n const data = await res.json().catch(() => null);\n if (status) {\n status.textContent =\n data && Array.isArray(data.blockers) && data.blockers.length > 0\n ? data.blockers.join(\"; \")\n : data && data.reason === \"override-reason-required\"\n ? \"Reason required\"\n : \"Failed\";\n }\n b.disabled = false;\n }\n } catch {\n if (status) status.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });\n // Trigger-renovate button: async on-demand dispatch (mirrors the cockpit).\n document.querySelectorAll(\"button.trigger-renovate\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n b.textContent = \"Dispatching…\";\n try {\n const res = await fetch(b.dataset.triggerUrl, { method: \"POST\" });\n b.textContent = res.ok ? \"Dispatched ✓\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });\n // Site-details editor: save on change (selects) / blur (inputs+textareas, only\n // when the value actually changed). The per-field span shows ✓ / ✗.\n ${DETAIL_VALUE_FN}\n ${SAVE_DETAIL_FN}\n document.querySelectorAll(\"select[data-detail-field]\").forEach((s) => {\n s.addEventListener(\"change\", () => saveDetail(s, document));\n });\n // Checkboxes save on CHANGE. They cannot use the blur path below: that path\n // only fires when \\`value !== defaultValue\\`, and for a checkbox both are the\n // \"on\" content attribute, so it would never save at all.\n document.querySelectorAll(\"input[type=checkbox][data-detail-field]\").forEach((c) => {\n c.addEventListener(\"change\", () => saveDetail(c, document));\n });\n document.querySelectorAll(\"input:not([type=checkbox])[data-detail-field], textarea[data-detail-field]\").forEach((i) => {\n // A save needs BOTH a real edit gesture and a changed value. \\`value !==\n // defaultValue\\` alone is not evidence of an edit: the browser rewrites\n // \\`.value\\` on its own when a control is handed a value it cannot represent\n // (an ISO datetime in a date input sanitizes to \"\"), and the blur that\n // follows an untouched tab-through then posts that empty string as if the\n // operator had cleared the field. Requiring an \\`input\\` event first means\n // only a human keystroke or picker choice can arm the save.\n i.addEventListener(\"input\", () => { i.dataset.edited = \"1\"; });\n i.addEventListener(\"blur\", () => {\n if (i.dataset.edited === \"1\" && i.value !== i.defaultValue) saveDetail(i, document);\n });\n });\n // Refresh preview: dispatch the re-render workflow, then tell the operator to\n // reload once it lands. No polling — the run takes a minute or two and a\n // spinner that long reads as a hang.\n document.querySelectorAll(\"button.rerender\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n b.textContent = \"Refreshing\\u2026\";\n try {\n const res = await fetch(b.dataset.rerenderUrl, { method: \"POST\" });\n b.textContent = res.ok ? \"Queued \\u2713 reload in ~2 min\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });\n // Report commentary: save on blur, only when it actually changed. Same\n // shape as the site-details editor, but keyed on the REPORT id rather than a\n // field name, so it posts { text } to the report's own endpoint.\n document.querySelectorAll(\"textarea[data-commentary-for]\").forEach((t) => {\n t.addEventListener(\"blur\", () => {\n if (t.value === t.defaultValue) return;\n const span = document.querySelector('.commentary-saved[data-for=\"' + t.dataset.commentaryFor + '\"]');\n fetch(t.dataset.commentaryUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ text: t.value }),\n })\n .then((r) => {\n if (span) span.textContent = r.ok ? \" \\u2713\" : \" \\u2717\";\n if (r.ok) t.defaultValue = t.value;\n })\n .catch(() => {\n if (span) span.textContent = \" \\u2717\";\n });\n });\n });\n ${SUBMISSION_STATUS_SCRIPT}\n </script>\n</body>\n</html>`;\n}\n","// src/dashboard/fleet-cockpit.ts\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport {\n siteSlug,\n isDashboardVisible,\n isArchivedStatus,\n isUnrecognizedStatus,\n} from \"../reports/airtable/websites.js\";\nimport type { AttentionItem } from \"../alerts/attention.js\";\nimport { isPendingApproval } from \"../reports/airtable/reports.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport type { ReportType } from \"../reports/types.js\";\nimport type { SubmissionRow, FormType } from \"../reports/submission-row.js\";\nimport { isLeadFormType } from \"../forms/types.js\";\nimport type { FleetEvent, FleetEventType } from \"../db/fleet-events.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectPreflightBlocked,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\n collectTurnstileGuardrailAlerts,\n collectNotifyBounceAlerts,\n collectPrismicDriftAlerts,\n prismicAckIsLive,\n} from \"../alerts/digest-collectors.js\";\nimport { diffAttention, type DigestSnapshot } from \"../alerts/digest-state.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { isNetlifyAppUrl } from \"../util/url.js\";\n\nexport type Tier = \"attention\" | \"watch\" | \"healthy\" | \"pre-launch\";\n\n/** Watch-tier thresholds (the soft band beneath the M5 alert floor). */\nconst LIGHTHOUSE_FLOOR = 75; // mirrors collectLighthouseAlerts — at/above is not an attention item\nconst LIGHTHOUSE_WATCH_HIGH = 85; // [75,85) = \"near the floor\" → watch\nconst STALE_DAYS = 30;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** Netlify deploy states the dashboard treats as a successful production build. */\nconst READY_DEPLOY_STATES: ReadonlySet<string> = new Set([\"ready\"]);\n/** Netlify deploy states that mean the build actually failed (→ attention tier + red badge). */\nconst FAILED_DEPLOY_STATES: ReadonlySet<string> = new Set([\"error\", \"failed\", \"rejected\"]);\n\n/** True when the persisted deploy status is a successful production build. PURE. */\nexport function isReadyDeployStatus(status: string | null): boolean {\n return status !== null && READY_DEPLOY_STATES.has(status.toLowerCase());\n}\n/** True when the persisted deploy status is a failed build. PURE. */\nexport function isFailedDeployStatus(status: string | null): boolean {\n return status !== null && FAILED_DEPLOY_STATES.has(status.toLowerCase());\n}\n\nconst WATCH_CATEGORIES: ReadonlyArray<{\n field: \"pScore\" | \"rScore\" | \"bpScore\" | \"seoScore\";\n label: string;\n}> = [\n { field: \"pScore\", label: \"Performance\" },\n { field: \"rScore\", label: \"Accessibility\" },\n { field: \"bpScore\", label: \"Best Practices\" },\n { field: \"seoScore\", label: \"SEO\" },\n];\n\n/**\n * Tier a single site from its attention items + soft watch rules. PURE; `now` is\n * injected for testability. Any attention item → 🔴 attention (items already encode\n * the M5 thresholds, so a sub-75 Lighthouse score arrives here as an item and never\n * needs the watch band). A FAILED latest production deploy (`deployStatus === \"failed\"`/\n * \"error\") is the same severity → 🔴 attention. Otherwise 🟡 watch when a Lighthouse\n * category sits in [75,85), the last commit to `main` is older than 30 days, or a\n * maintenance site is still on `*.netlify.app`. Else 🟢 healthy.\n *\n * Each active watch condition is a structured candidate with a set of accept keys\n * (aliases — e.g. the Netlify/no-custom-domain condition accepts \"no custom domain\",\n * \"netlify\", \"netlify.app\", …); a condition the operator has marked accepted (any of\n * its keys, case-insensitive, in `site.acceptedWatchConditions`) is routed to\n * `acceptedReasons` instead of `watchReasons` — it leaves the watch band (an\n * all-accepted site becomes healthy) but stays visible as a muted chip. Acceptance is\n * keyed on the STABLE signal token, not the volatile reason text, so accepting\n * \"performance\" tolerates a score of 82→78 (both watch). Acceptance is watch-only: a\n * sub-floor Lighthouse score arrives as an AttentionItem above and still alarms broken,\n * so accepting \"78\" never hides a drop to \"72\".\n *\n * `watchReasons` are the human labels for the card; `watchAcceptKeys` is the primary/\n * canonical accept token per un-accepted reason (index-aligned — surfaced on the card so\n * the operator can see the exact string that would mute it); `watchSignals` are the\n * STRUCTURED filter tags (\"lighthouse\" / \"stale\") the client filter keys off.\n */\n/** One detected watch condition, before acceptance is applied. `signal` is the\n * client-filter tag; `acceptKeys` is every string the operator can type to mute it,\n * PRIMARY FIRST — that primary is the token surfaced on the card for discoverability;\n * `reason` is the human label. The tuple type guarantees a primary exists. */\ntype WatchCandidate = { signal: string; acceptKeys: [string, ...string[]]; reason: string };\n\n/** Which attention items PIERCE the pre-launch mute. A \"launching\" site mutes\n * expected pre-launch conditions (early/absent Lighthouse, errored deploy,\n * Renovate/analytics warnings) — but a genuine alarm must not hide behind the\n * mute: 2026-07, a human content push left hedloc's default-branch CI red for\n * two days and the cockpit hid it; the daily digest (which runs these same\n * collectors over ALL sites with no pre-launch mute) was the only alarm surface.\n * The cockpit now surfaces the genuine subset of what the digest already shows —\n * not a third behavior. Pierces: any CRITICAL-severity item (turnstile guardrail,\n * notify-bounce, spam complaint, approved-report preflight) plus default-branch\n * CI red (`kind === \"ci\"` — warning severity, but a broken repo is never expected\n * pre-launch noise). A vuln pierces ONLY once Renovate's auto-fix is exhausted —\n * before that the fleet is still self-patching it and the operator has asked not\n * to hear about it (a day-zero critical CVE alone must not re-alarm a pre-launch\n * site while auto-fix is running). PURE. */\nexport function piercesPreLaunchMute(item: AttentionItem): boolean {\n if (item.kind === \"vuln\") return item.autoFixExhausted === true;\n return item.severity === \"critical\" || item.kind === \"ci\";\n}\n\nexport function assignTier(\n site: WebsiteRow,\n items: AttentionItem[],\n now: Date,\n): {\n tier: Tier;\n watchReasons: string[];\n /** Primary accept token per un-accepted watch reason, index-aligned with\n * `watchReasons` — the exact string that would mute it (discoverability). */\n watchAcceptKeys: string[];\n watchSignals: string[];\n acceptedReasons: string[];\n} {\n // Lifecycle short-circuit (FIRST, before any alarm rule): a \"launching\" site\n // is PRE-LIVE prep, not a live site (Status flips to \"maintained\" at go-live).\n // Its expected pre-launch conditions — no GA4 property, early/absent Lighthouse,\n // an errored/absent Netlify deploy, Renovate warnings — would otherwise force it\n // to 🔴 attention and read as \"broken\". Mute those as a calm \"pre-launch\" tier\n // that never alarms and is excluded from the \"needs you\" feed — but a GENUINE\n // alarm (piercesPreLaunchMute: any critical item, or CI red) re-tiers the site\n // to 🔴 attention through the normal machinery. (Only \"launching\" reaches\n // the cockpit — isDashboardVisible = {maintained, launching}.)\n if (site.status === \"launching\") {\n // Genuine alarms pierce the mute; this attention return sits ABOVE the\n // accepted-watch loop, so an operator ack can never silence a pierced alarm —\n // the same invariant the live-site items short-circuit keeps. Everything else\n // (incl. a failed deploy, checked further below only for live sites) stays\n // muted as expected pre-launch conditions.\n if (items.some(piercesPreLaunchMute))\n return {\n tier: \"attention\",\n watchReasons: [],\n watchAcceptKeys: [],\n watchSignals: [],\n acceptedReasons: [],\n };\n return {\n tier: \"pre-launch\",\n watchReasons: [],\n watchAcceptKeys: [],\n watchSignals: [],\n acceptedReasons: [],\n };\n }\n if (items.length > 0)\n return {\n tier: \"attention\",\n watchReasons: [],\n watchAcceptKeys: [],\n watchSignals: [],\n acceptedReasons: [],\n };\n // A failed latest production deploy is an active break — tier it 🔴 attention, the\n // same severity a sub-floor Lighthouse score gets (which arrives as an item above).\n if (isFailedDeployStatus(site.deployStatus))\n return {\n tier: \"attention\",\n watchReasons: [],\n watchAcceptKeys: [],\n watchSignals: [],\n acceptedReasons: [],\n };\n\n // Conditions the operator has reviewed and accepted (case-insensitive). An accepted\n // watch reason is routed to acceptedReasons instead of raising the watch band.\n const accepted = new Set(site.acceptedWatchConditions.map((c) => c.trim().toLowerCase()));\n\n // Collect every active watch condition as a structured candidate; a single generic\n // matcher below routes each to acceptedReasons (muted) or watchReasons, keyed on the\n // stable signal/category token (never the volatile reason text, which carries a\n // score/age that changes daily). Adding a new watch candidate here makes it acceptable\n // AND discoverable with no new accept branch.\n const candidates: WatchCandidate[] = [];\n for (const cat of WATCH_CATEGORIES) {\n const score = site[cat.field];\n if (score !== null && score >= LIGHTHOUSE_FLOOR && score < LIGHTHOUSE_WATCH_HIGH) {\n candidates.push({\n signal: \"lighthouse\",\n acceptKeys: [cat.label.toLowerCase()],\n reason: `${cat.label} ${score}`,\n });\n }\n }\n if (site.lastCommitAt !== null) {\n const ageMs = now.getTime() - Date.parse(site.lastCommitAt);\n if (Number.isFinite(ageMs) && ageMs > STALE_DAYS * MS_PER_DAY) {\n candidates.push({\n signal: \"stale\",\n acceptKeys: [\"stale repo\", \"stale\"],\n reason: `last commit ${relativeTimeFromNow(site.lastCommitAt, now)}`,\n });\n }\n }\n // A live (maintenance) site still served from *.netlify.app never got a custom\n // domain — a launch-completeness gap. Only for maintenance: a launch-period site on\n // netlify.app is expected (not launched yet).\n if (site.status === \"maintained\" && isNetlifyAppUrl(site.url)) {\n candidates.push({\n signal: \"no-domain\",\n acceptKeys: [\"no custom domain\", \"no-domain\", \"netlify\", \"netlify.app\", \"on netlify\"],\n reason: \"on *.netlify.app (no custom domain)\",\n });\n }\n // Require-Turnstile guardrail, watch half: the flag hard-buckets token-less\n // submissions, so a gated site whose widget state ISN'T positively confirmed\n // deserves a nag. A fresh confirmed \"fail\" never reaches here — that is a CRITICAL\n // AttentionItem (collectTurnstileGuardrailAlerts) caught by the items short-circuit\n // ABOVE the accept loop, so an accept key can mute this \"can't verify\" watch but\n // can never silence the confirmed-missing alarm. `!== \"pass\"` covers both null\n // (older package /health without a forms block, or the sweep never ran) and a\n // stale \"fail\" the collector downgraded.\n if (site.requireTurnstile && site.turnstileWidget !== \"pass\") {\n candidates.push({\n signal: \"turnstile-unverified\",\n acceptKeys: [\"turnstile-unverified\", \"turnstile unverified\", \"turnstile\"],\n reason:\n site.turnstileWidget === \"fail\"\n ? \"Require Turnstile on; widget missing per a stale health sweep\"\n : \"Require Turnstile on; widget not verifiable from /health\",\n });\n }\n\n const watchReasons: string[] = [];\n const watchAcceptKeys: string[] = [];\n const acceptedReasons: string[] = [];\n const signals = new Set<string>();\n for (const cand of candidates) {\n if (cand.acceptKeys.some((k) => accepted.has(k))) {\n acceptedReasons.push(cand.reason);\n } else {\n watchReasons.push(cand.reason);\n watchAcceptKeys.push(cand.acceptKeys[0]); // primary/canonical token — surfaced on the card\n signals.add(cand.signal);\n }\n }\n // A Prismic divergence the operator accepted stays VISIBLE, as a muted chip. The\n // item itself was suppressed upstream (`collectPrismicDriftAlerts`), so without\n // this the site would read as plainly healthy and the acceptance would be\n // invisible — an accepted finding is still a finding, and the expiry date is the\n // part the operator most needs on the card. Not a WatchCandidate: this is already\n // acked by its own column, and routing it through `accepted` would let a stray\n // \"prismic\" string in Accepted Watch Conditions mute it with no expiry.\n if (site.prismicModels === \"fail\" && prismicAckIsLive(site.prismicAckUntil, now)) {\n const until = new Date(Date.parse(site.prismicAckUntil!)).toISOString().slice(0, 10);\n acceptedReasons.push(`Prismic drift accepted until ${until}`);\n }\n\n return watchReasons.length > 0\n ? { tier: \"watch\", watchReasons, watchAcceptKeys, watchSignals: [...signals], acceptedReasons }\n : {\n tier: \"healthy\",\n watchReasons: [],\n watchAcceptKeys: [],\n watchSignals: [],\n acceptedReasons,\n };\n}\n\nexport type SiteCard = {\n site: WebsiteRow;\n tier: Tier;\n /** This site's tagged attention items (status already set), critical-first. */\n items: AttentionItem[];\n /** Why the site is on Watch — human labels (empty unless tier === \"watch\"). */\n watchReasons: string[];\n /** Primary accept token per watch reason, index-aligned with `watchReasons` — the\n * exact string the operator can add to Accepted Watch Conditions to mute it. Optional\n * for back-compat with hand-built card fixtures; the renderer falls back to no hint. */\n watchAcceptKeys?: string[];\n /** Structured watch tags (\"lighthouse\" / \"stale\") for the client filter. */\n watchSignals: string[];\n /** Watch reasons the operator has accepted: suppressed from the band, shown as a\n * muted chip. Populated whenever the underlying condition is currently active. */\n acceptedReasons: string[];\n /** Count of NEW submissions for this site (optional; populated by buildCockpitModel). */\n newSubmissions?: number;\n /** Split of newSubmissions: actionable leads vs newsletter/rsvp signups (2026-07-16).\n * Optional for back-compat with hand-built card fixtures. */\n newLeads?: number;\n newSignups?: number;\n};\n\nexport type PendingEntry = {\n reportId: string;\n siteName: string;\n slug: string;\n reportType: ReportType;\n period: string;\n};\n\nexport type SubmissionEntry = {\n submissionId: string;\n siteName: string;\n slug: string;\n formType: FormType;\n name: string;\n email: string;\n submittedAt: string | null;\n};\n\nexport type CockpitSummary = {\n attention: number;\n watch: number;\n healthy: number;\n /** Count of pre-live \"launching\" sites still muted as expected pre-launch\n * conditions. A piercing alarm (piercesPreLaunchMute) re-tiers such a site to\n * attention, so it counts there instead — muted never means \"hidden if broken\". */\n preLaunch: number;\n criticalHighVulns: number;\n lighthouseBelowFloor: number;\n deliveryFailures: number;\n renovateFailing: number;\n ciRed: number;\n /** Count of sites whose vuln has exhausted the Renovate auto-fix (manual fix needed). */\n autoFixStuck: number;\n pending: number;\n /** Count of NEW submissions across the fleet (optional for back-compat). */\n newSubmissions?: number;\n /** Split of newSubmissions: actionable leads vs newsletter/rsvp signups\n * (optional for back-compat, 2026-07-16). */\n newLeads?: number;\n newSignups?: number;\n};\n\n/** A render-ready row for the cockpit \"Recently\" lane. `url` is an external link\n * (PR) when present; `slug` links to `/s/<slug>` when the event names a site. */\nexport type RecentEntry = {\n type: FleetEventType;\n summary: string;\n siteName: string | null;\n slug: string | null;\n url: string | null;\n ts: string;\n};\n\n/** A Websites row surfaced OUTSIDE the fleet cards: archived (legacy/deprecated)\n * or holding an unrecognized Status cell.\n *\n * `status` is the RAW Airtable cell, not the canonical status (#539 Phase 4).\n * These two lanes exist to mirror what Airtable holds so a row can never\n * silently vanish, and both of them survive on the distinction the canonical\n * vocabulary erases: `legacy` and `deprecated` both canonicalize to `archived`,\n * so labelling the lane canonically would render 12 live rows identically and\n * disagree with the cell the operator is looking at. Everything else about\n * these rows — which lane they land in — is decided canonically. */\nexport type OffFleetSiteEntry = { name: string; slug: string; status: string };\n\nexport type CockpitModel = {\n summary: CockpitSummary;\n /** All visible sites, ordered: attention (worst-first) → watch (A-Z) → healthy (A-Z). */\n cards: SiteCard[];\n pending: PendingEntry[];\n /** NEW submissions across the fleet, newest-first (optional for back-compat). */\n submissions?: SubmissionEntry[];\n /** Fleet spam totals over the window (optional; populated by buildCockpitModel). */\n /** `computedAt` present ⇒ these came from the nightly roll-up and are up to\n * 24h old; the strip labels them so a stale number is never read as live. */\n spam?: { caught: number; through: number; computedAt?: string } | null;\n /** Recent fleet-activity events for the \"Recently\" lane (optional for back-compat). */\n recent?: RecentEntry[];\n /** Fleet-wide count of submissions auto-filtered as spam in the affordance window\n * (optional for back-compat). Drives the cockpit \"N auto-filtered this week\" line. */\n autoFiltered?: number;\n /** Archived (legacy/deprecated) rows — excluded from every fleet op, listed on a\n * neutral collapsed lane so a row can never silently vanish (optional for back-compat). */\n archived?: OffFleetSiteEntry[];\n /** Rows whose Status cell is outside the code's union (typo / renamed option) —\n * invisible to every fleet op, so the Needs-you feed surfaces them as amber watch\n * rows (optional for back-compat). */\n unrecognizedStatus?: OffFleetSiteEntry[];\n};\n\nexport type NeedsYouGroup = \"broken\" | \"watch\" | \"approval\";\n\n/** One row of the per-site \"Needs you\" feed: every reason a single site needs the\n * operator, combined. The feed is navigation-only — the row links once to the page. */\nexport type NeedsYouItem = {\n /** The site's worst category present — drives the dot, the group sub-label, and order. */\n group: NeedsYouGroup;\n /** Any of the site's broken items is `severity: \"critical\"` (within-broken ordering). */\n hasCritical: boolean;\n slug: string;\n siteName: string;\n reasons: string[];\n /** Always `/s/${slug}`. */\n url: string;\n};\n\nconst NEEDS_YOU_GROUP_RANK: Record<NeedsYouGroup, number> = { broken: 0, watch: 1, approval: 2 };\n\n/**\n * Collapse the cockpit model into a per-site \"Needs you\" feed — ONE row per site,\n * with every reason combined. PURE. A non-exhausted vuln is amber `watch` (the fleet\n * is auto-patching it); an exhausted vuln (`item.autoFixExhausted`) is a hard `broken`\n * break, as is any non-vuln attention item. The whole watch tier folds into `watch`.\n * Order: broken → watch → approval; within broken, critical-first; then site name.\n */\nexport function buildNeedsYouFeed(model: CockpitModel): NeedsYouItem[] {\n type Acc = {\n slug: string;\n siteName: string;\n reasons: string[];\n hasCritical: boolean;\n broken: boolean;\n watch: boolean;\n approval: boolean;\n };\n const bySlug = new Map<string, Acc>();\n const get = (siteName: string): Acc => {\n const slug = siteSlug(siteName);\n let a = bySlug.get(slug);\n if (!a) {\n a = {\n slug,\n siteName,\n reasons: [],\n hasCritical: false,\n broken: false,\n watch: false,\n approval: false,\n };\n bySlug.set(slug, a);\n }\n return a;\n };\n\n for (const card of model.cards) {\n // Only \"attention\" (broken) and \"watch\" cards enter the feed; a \"pre-launch\"\n // card is pre-live prep and intentionally never surfaces here as needing you.\n // (A launch-period site with a PIERCING alarm arrives as tier \"attention\" —\n // assignTier re-tiers it — so genuine pre-launch breaks do reach the feed.)\n if (card.tier === \"attention\") {\n // A self-patching vuln (present but not yet exhausted) is amber WATCH — the fleet\n // is auto-patching it. Every other item, INCLUDING an exhausted vuln, is a hard\n // break. A site with any hard break is broken and its self-patching vulns are not\n // separately listed (it is already red). Single-source the predicate so the two\n // partitions can never drift out of lockstep.\n const isSelfPatchingVuln = (it: AttentionItem): boolean =>\n it.kind === \"vuln\" && it.autoFixExhausted !== true;\n const hardBroken = card.items.filter((it) => !isSelfPatchingVuln(it));\n const selfPatchingVulns = card.items.filter(isSelfPatchingVuln);\n if (hardBroken.length > 0) {\n const a = get(card.site.name);\n for (const it of hardBroken) {\n a.reasons.push(it.title);\n if (it.severity === \"critical\") a.hasCritical = true;\n }\n a.broken = true;\n } else if (selfPatchingVulns.length > 0) {\n const a = get(card.site.name);\n for (const it of selfPatchingVulns) a.reasons.push(it.title);\n a.watch = true;\n }\n } else if (card.tier === \"watch\" && card.watchReasons.length > 0) {\n const a = get(card.site.name);\n for (const r of card.watchReasons) a.reasons.push(r);\n a.watch = true;\n }\n }\n\n for (const p of model.pending) {\n const a = get(p.siteName);\n a.reasons.push(`${p.reportType} ${p.period} ready`);\n a.approval = true;\n }\n\n // A Websites row whose Status is outside the union is invisible to every fleet\n // op — surface it amber so a typo can never silently vanish a site.\n for (const u of model.unrecognizedStatus ?? []) {\n const a = get(u.name);\n a.reasons.push(\n `Status \"${u.status}\" is not a recognized value — fix it in Airtable (site is invisible to fleet ops)`,\n );\n a.watch = true;\n }\n\n const items: NeedsYouItem[] = [];\n for (const a of bySlug.values()) {\n if (a.reasons.length === 0) continue;\n const group: NeedsYouGroup = a.broken ? \"broken\" : a.watch ? \"watch\" : \"approval\";\n items.push({\n group,\n hasCritical: a.hasCritical,\n slug: a.slug,\n siteName: a.siteName,\n reasons: a.reasons,\n url: `/s/${a.slug}`,\n });\n }\n\n items.sort((x, y) => {\n if (NEEDS_YOU_GROUP_RANK[x.group] !== NEEDS_YOU_GROUP_RANK[y.group])\n return NEEDS_YOU_GROUP_RANK[x.group] - NEEDS_YOU_GROUP_RANK[y.group];\n if (x.group === \"broken\" && x.hasCritical !== y.hasCritical) return x.hasCritical ? -1 : 1;\n return x.siteName.toLowerCase().localeCompare(y.siteName.toLowerCase());\n });\n\n return items;\n}\n\nconst SEVERITY_RANK: Record<AttentionItem[\"severity\"], number> = { critical: 0, warning: 1 };\nconst TIER_RANK: Record<Tier, number> = { attention: 0, watch: 1, healthy: 2, \"pre-launch\": 3 };\n\n/** The fleet attention-item collector run — the SINGLE source of truth for which\n * signals the cockpit surfaces. buildCockpitModel (over all visible sites) and\n * buildSiteAlarmContext (over one site) both call this, so the two can never\n * drift: a signal added here shows up in both the fleet cockpit and every\n * /s/<slug> page at once. Returns items UNSORTED — callers apply their own\n * ordering (cockpit groups by site; the site page severity-sorts). PURE. */\nfunction collectFleetAttentionItems(\n sites: WebsiteRow[],\n sitesById: Map<string, WebsiteRow>,\n reports: ReportRow[],\n baseUrl: string,\n now: Date,\n notifyBounces: ReadonlyMap<string, number>,\n): AttentionItem[] {\n return [\n ...collectVulnAlerts(sites, baseUrl),\n ...collectLighthouseAlerts(sites, baseUrl),\n ...collectDeliveryFailures(reports, sitesById, baseUrl),\n ...collectPreflightBlocked(reports, sitesById, baseUrl),\n ...collectRenovateAlerts(sites, baseUrl, now),\n ...collectCiAlerts(sites, baseUrl, now),\n ...collectAnalyticsFailures(sites, baseUrl, now),\n ...collectTurnstileGuardrailAlerts(sites, baseUrl, now),\n ...collectNotifyBounceAlerts(sites, notifyBounces, baseUrl),\n // Reads the nightly-persisted `Prismic Models` verdict columns — no live\n // Prismic/GitHub call, so this stays safe on the request path (the cockpit is\n // request-path; a collector that shelled out would 502 in Lambda).\n ...collectPrismicDriftAlerts(sites, baseUrl, now),\n ];\n}\n\n/** The cockpit's alarm verdict for ONE site, for the /s/<slug> page header. */\nexport type SiteAlarmContext = {\n tier: Tier;\n /** This site's attention items, severity-sorted, status UNTAGGED (no diffAttention). */\n items: AttentionItem[];\n watchReasons: string[];\n watchAcceptKeys: string[];\n acceptedReasons: string[];\n};\n\n/**\n * The cockpit's alarm verdict for ONE site, for the /s/<slug> page header. Runs\n * the SAME collector list buildCockpitModel runs — both call the shared\n * collectFleetAttentionItems (the single source of truth), so the two structurally\n * cannot drift — and the same assignTier. A parity test (site-alarm-context.test.ts)\n * additionally asserts this function's item keys match buildCockpitModel's for the\n * same site. No diffAttention: the site page never writes digest state and shows no\n * NEW/WORSE badges (the digest alone writes the snapshot; the cockpit reads it\n * read-only).\n *\n * `reports` is expected to be site-scoped (listReportsForSite) — the delivery/\n * preflight collectors resolve report.siteId against a one-entry sitesById, so an\n * orphan report in the array would surface as an \"(unlinked site)\" preflight row.\n * Acceptable: the real caller only ever passes this site's reports.\n */\nexport function buildSiteAlarmContext(\n site: WebsiteRow,\n reports: ReportRow[],\n baseUrl: string,\n now: Date,\n notifyBounces: ReadonlyMap<string, number> = new Map(),\n): SiteAlarmContext {\n const sites = [site];\n const sitesById = new Map([[site.id, site]]);\n const items = collectFleetAttentionItems(\n sites,\n sitesById,\n reports,\n baseUrl,\n now,\n notifyBounces,\n ).sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);\n const {\n tier,\n watchReasons,\n watchAcceptKeys,\n watchSignals: _ws,\n acceptedReasons,\n } = assignTier(site, items, now);\n return { tier, items, watchReasons, watchAcceptKeys, acceptedReasons };\n}\n\n/**\n * Assemble the render-ready cockpit model from already-fetched Airtable rows. PURE\n * (`now` injected). Filters to dashboard-visible sites (maintenance or launch period),\n * runs the M5 collectors\n * over them, tags NEW/WORSE via diffAttention against the prior digest snapshot\n * (READ-ONLY — the returned `next` is discarded; only the daily digest writes state),\n * tiers each site, computes the summary, and resolves the pending-approval list.\n */\nexport function buildCockpitModel(\n websites: WebsiteRow[],\n reports: ReportRow[],\n priorSnapshot: DigestSnapshot,\n baseUrl: string,\n now: Date,\n newSubmissions: SubmissionRow[] = [],\n spamTotals: {\n honeypot: number;\n tooFast: number;\n markedSpam: number;\n /** When the nightly digest computed these. The figures are up to 24h\n * old by construction, so the strip says so rather than reading live. */\n computedAt?: string;\n } | null = null,\n recentEvents: FleetEvent[] = [],\n autoFilteredCount = 0,\n // Per-site bounced-lead-notification counts (countNotifyBouncedBySite, last\n // NOTIFY_BOUNCE_WINDOW_DAYS). Optional like the other libSQL inputs: a Turso\n // blip drops the signal, never the cockpit (2026-07-16).\n notifyBounces: ReadonlyMap<string, number> = new Map(),\n): CockpitModel {\n const visible = websites.filter(isDashboardVisible);\n const sitesById = new Map<string, WebsiteRow>(visible.map((w) => [w.id, w]));\n\n // Off-fleet visibility: archived rows get a neutral roster lane; a typo'd Status\n // (outside the union) gets an amber watch row in the Needs-you feed. Both are\n // read-only surfacing — neither joins `visible`, so no fleet op gains a site.\n //\n // Membership is decided on the CANONICAL status; the label is the RAW cell.\n // These lanes are the fleet's mirror of Airtable, and `legacy`/`deprecated` are\n // the one pair the canonical vocabulary merges — labelling them `archived`\n // would collapse 12 distinguishable live rows into one indistinguishable\n // label and disagree with the cell it mirrors.\n //\n // The typo lane reads `statusRaw` for the same REASON, though not for the same\n // effect: canonicalization passes an unrecognized value through verbatim, so a\n // row in that lane always has `statusRaw === status`. Writing it this way is\n // consistency, not a fix — and it stays correct if that ever stops being true.\n const archived: OffFleetSiteEntry[] = websites\n .filter((w) => isArchivedStatus(w.status))\n .map((w) => ({\n name: w.name,\n slug: siteSlug(w.name),\n status: w.statusRaw ?? (w.status as string),\n }))\n .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));\n const unrecognizedStatus: OffFleetSiteEntry[] = websites\n .filter((w) => isUnrecognizedStatus(w.status))\n .map((w) => ({\n name: w.name,\n slug: siteSlug(w.name),\n status: w.statusRaw ?? (w.status as string),\n }));\n\n // Per-site NEW-submission counts, keyed by Websites record id, split into\n // actionable leads vs newsletter/rsvp signups (2026-07-16). Used for the\n // per-card badge below; the strip resolves entries against ALL sites.\n const subCountBySite = new Map<string, { leads: number; signups: number }>();\n for (const sub of newSubmissions) {\n let c = subCountBySite.get(sub.siteId);\n if (!c) {\n c = { leads: 0, signups: 0 };\n subCountBySite.set(sub.siteId, c);\n }\n if (isLeadFormType(sub.formType)) c.leads++;\n else c.signups++;\n }\n\n const rawItems = collectFleetAttentionItems(\n visible,\n sitesById,\n reports,\n baseUrl,\n now,\n notifyBounces,\n );\n // Read-only diff: tag NEW/WORSE exactly as the email does; discard `next`.\n const { tagged } = diffAttention(rawItems, priorSnapshot, now.toISOString().slice(0, 10));\n\n // Group by siteName (the collectors set siteName from the row). This relies on the\n // fleet-wide name→slug uniqueness invariant the /s/<slug> lookup already assumes; if\n // two visible sites ever shared a name they'd share a card. Acceptable for slice 1.\n const bySite = new Map<string, AttentionItem[]>();\n for (const it of tagged) {\n const bucket = bySite.get(it.siteName);\n if (bucket) bucket.push(it);\n else bySite.set(it.siteName, [it]);\n }\n\n const cards: SiteCard[] = visible.map((site) => {\n const collected = (bySite.get(site.name) ?? []).sort(\n (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity],\n );\n // A launch-period card carries only the piercing alarms: muted pre-launch noise\n // must not ride into the chips or the needs-you feed when a genuine alarm flips\n // the tier (and a still-muted card shows no alarm chips at all).\n const items = site.status === \"launching\" ? collected.filter(piercesPreLaunchMute) : collected;\n const { tier, watchReasons, watchAcceptKeys, watchSignals, acceptedReasons } = assignTier(\n site,\n items,\n now,\n );\n const c = subCountBySite.get(site.id) ?? { leads: 0, signups: 0 };\n return {\n site,\n tier,\n items,\n watchReasons,\n watchAcceptKeys,\n watchSignals,\n acceptedReasons,\n newSubmissions: c.leads + c.signups,\n newLeads: c.leads,\n newSignups: c.signups,\n };\n });\n\n cards.sort((a, b) => {\n if (TIER_RANK[a.tier] !== TIER_RANK[b.tier]) return TIER_RANK[a.tier] - TIER_RANK[b.tier];\n if (a.tier === \"attention\") {\n const sevA = a.items.some((i) => i.severity === \"critical\") ? 0 : 1;\n const sevB = b.items.some((i) => i.severity === \"critical\") ? 0 : 1;\n if (sevA !== sevB) return sevA - sevB;\n const metA = a.items.reduce((s, i) => s + i.metric, 0);\n const metB = b.items.reduce((s, i) => s + i.metric, 0);\n if (metA !== metB) return metB - metA;\n }\n return a.site.name.toLowerCase().localeCompare(b.site.name.toLowerCase());\n });\n\n const pending: PendingEntry[] = [];\n // Mirror listPendingApproval's predicate. Resolve against ALL websites (a pending\n // approval is never dropped just because the site is hidden from the fleet view).\n const allById = new Map<string, WebsiteRow>(websites.map((w) => [w.id, w]));\n for (const r of reports) {\n if (!isPendingApproval(r)) continue;\n const s = allById.get(r.siteId);\n if (!s) continue; // orphan → skip rather than render a broken link\n pending.push({\n reportId: r.id,\n siteName: s.name,\n slug: siteSlug(s.name),\n reportType: r.reportType,\n period: r.period ?? \"—\",\n });\n }\n\n const submissions: SubmissionEntry[] = [];\n for (const sub of newSubmissions) {\n const s = allById.get(sub.siteId);\n if (!s) continue; // orphan submission → skip rather than render a broken link\n submissions.push({\n submissionId: sub.id,\n siteName: s.name,\n slug: siteSlug(s.name),\n formType: sub.formType,\n name: sub.name,\n email: sub.email,\n submittedAt: sub.submittedAt,\n });\n }\n\n const summary: CockpitSummary = {\n attention: cards.filter((c) => c.tier === \"attention\").length,\n watch: cards.filter((c) => c.tier === \"watch\").length,\n healthy: cards.filter((c) => c.tier === \"healthy\").length,\n preLaunch: cards.filter((c) => c.tier === \"pre-launch\").length,\n criticalHighVulns: tagged.filter((i) => i.kind === \"vuln\").reduce((s, i) => s + i.metric, 0),\n lighthouseBelowFloor: tagged.filter((i) => i.kind === \"lighthouse\").length,\n deliveryFailures: tagged.filter((i) => i.kind === \"delivery\").length,\n renovateFailing: tagged.filter((i) => i.kind === \"renovate\").reduce((s, i) => s + i.metric, 0),\n ciRed: tagged.filter((i) => i.kind === \"ci\").length,\n autoFixStuck: tagged.filter((i) => i.autoFixExhausted).length,\n pending: pending.length,\n newSubmissions: submissions.length,\n // Same population as newSubmissions (resolved entries — orphans excluded).\n newLeads: submissions.filter((s) => isLeadFormType(s.formType)).length,\n newSignups: submissions.filter((s) => !isLeadFormType(s.formType)).length,\n };\n\n const recent: RecentEntry[] = recentEvents.map((e) => {\n const url =\n e.type === \"pr_automerged\" &&\n e.data !== null &&\n typeof e.data === \"object\" &&\n \"url\" in e.data &&\n typeof (e.data as { url: unknown }).url === \"string\"\n ? (e.data as { url: string }).url\n : null;\n return {\n type: e.type,\n summary: e.summary,\n siteName: e.siteName,\n slug: e.siteName ? siteSlug(e.siteName) : null,\n url,\n ts: e.ts,\n };\n });\n\n return {\n summary,\n cards,\n pending,\n submissions,\n spam: spamTotals\n ? {\n caught: spamTotals.honeypot + spamTotals.tooFast,\n through: spamTotals.markedSpam,\n ...(spamTotals.computedAt !== undefined ? { computedAt: spamTotals.computedAt } : {}),\n }\n : null,\n recent,\n autoFiltered: autoFilteredCount,\n archived,\n unrecognizedStatus,\n };\n}\n\n/** Most recent `lastLighthouseAuditAt` across the cards, or null if none recorded.\n * Drives the cockpit verdict's \"fleet last audited Xh ago\" line. PURE. */\nexport function fleetLastAuditedAt(cards: SiteCard[]): string | null {\n let latestIso: string | null = null;\n let latestMs = -Infinity;\n for (const c of cards) {\n const iso = c.site.lastLighthouseAuditAt;\n if (!iso) continue;\n const ms = Date.parse(iso);\n if (!Number.isFinite(ms)) continue;\n if (ms > latestMs) {\n latestMs = ms;\n latestIso = iso;\n }\n }\n return latestIso;\n}\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { siteSlug } from \"../reports/airtable/websites.js\";\nimport type { CockpitModel, SiteCard, Tier } from \"./fleet-cockpit.js\";\nimport { isReadyDeployStatus, isFailedDeployStatus } from \"./fleet-cockpit.js\";\nimport { onboardingStatus, missingOnboarding } from \"./onboarding.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\n\nconst DASH = \"—\";\n\n// ---- MOVED VERBATIM from fleet-render.ts (do not change their bodies) ----\n\nfunction scoreSpan(category: \"perf\" | \"a11y-lh\" | \"bp\" | \"seo\", value: number | null): string {\n const display = value === null ? DASH : String(value);\n return `<span class=\"score ${category}\">${escapeHtml(display)}</span>`;\n}\n\nfunction a11ySpan(value: number | null): string {\n const display = value === null ? DASH : String(value);\n return `<span class=\"metric a11y\">${escapeHtml(display)}</span>`;\n}\n\nfunction depsSpan(\n drifted: number | null,\n majorBehind: number | null,\n outdated: number | null,\n majorOutdated: number | null,\n): string {\n if (drifted === null || majorBehind === null) {\n return `<span class=\"metric deps\">${DASH}</span>`;\n }\n // Declared-range drift vs baseline, plus the real outdated-install count when\n // it was determined (null = not checked this run → omit, don't imply clean).\n const driftPart = drifted === 0 ? \"0\" : `${drifted} drifted (${majorBehind} major)`;\n // The outdated part carries its own \"(N major)\" — majors behind npm latest,\n // distinct from the baseline \"(major)\" on driftPart. Shown only when known.\n const outdatedPart =\n outdated === null\n ? \"\"\n : ` · ${outdated} outdated${majorOutdated === null ? \"\" : ` (${majorOutdated} major)`}`;\n return `<span class=\"metric deps\">${escapeHtml(driftPart + outdatedPart)}</span>`;\n}\n\nfunction securitySpan(\n critical: number | null,\n high: number | null,\n moderate: number | null,\n low: number | null,\n): string {\n if (critical === null || high === null || moderate === null || low === null) {\n return `<span class=\"metric sec\">${DASH}</span>`;\n }\n const total = critical + high + moderate + low;\n const display = total === 0 ? \"0\" : `${critical}C/${high}H/${moderate}M/${low}L`;\n return `<span class=\"metric sec\">${escapeHtml(display)}</span>`;\n}\n\nfunction card(site: WebsiteRow): string {\n const name = escapeHtml(site.name);\n // The per-site dashboard at /s/<slug> is operator-only, gated by the shared\n // dashboard password (no per-site token). Cockpit visibility is Status-based;\n // the caller filters the fleet view.\n const href = `/s/${escapeHtml(siteSlug(site.name))}`;\n const onboarding = onboardingStatus(site);\n const missing = missingOnboarding(site);\n const setupTitle = escapeHtml(\n missing.length === 0 ? \"Setup complete\" : `Missing: ${missing.join(\", \")}`,\n );\n const audited = relativeTimeFromNow(site.lastLighthouseAuditAt);\n const safeSiteUrl = escapeHtml(safeUrl(site.url));\n const visibleUrl = escapeHtml(site.url);\n\n return `<article class=\"card\">\n <header class=\"card-head\">\n <a class=\"site\" href=\"${href}\">${name}</a>\n <a class=\"url\" href=\"${safeSiteUrl}\" target=\"_blank\" rel=\"noopener\">${visibleUrl}</a>\n <span class=\"setup\" title=\"${setupTitle}\">Setup: <strong>${onboarding.score}/${onboarding.total}</strong></span>\n <span class=\"audited\">Audited: <strong>${escapeHtml(audited)}</strong></span>\n </header>\n <div class=\"card-metrics\">\n <span class=\"cluster lighthouse\">\n <span class=\"metric-label\">Perf</span> ${scoreSpan(\"perf\", site.pScore)}\n <span class=\"metric-label\">Access</span> ${scoreSpan(\"a11y-lh\", site.rScore)}\n <span class=\"metric-label\">BP</span> ${scoreSpan(\"bp\", site.bpScore)}\n <span class=\"metric-label\">SEO</span> ${scoreSpan(\"seo\", site.seoScore)}\n </span>\n <span class=\"cluster health\">\n <span class=\"metric-label\">a11y</span> ${a11ySpan(site.a11yViolations)}\n <span class=\"metric-label\">deps</span> ${depsSpan(site.depsDrifted, site.depsMajorBehind, site.depsOutdated, site.depsMajorOutdated)}\n <span class=\"metric-label\">sec</span> ${securitySpan(\n site.securityVulnsCritical,\n site.securityVulnsHigh,\n site.securityVulnsModerate,\n site.securityVulnsLow,\n )}\n <span class=\"metric-label\">deploy</span> ${deployBadge(site)}\n </span>\n </div>\n </article>`;\n}\n\n/** Small colored deploy badge: green = ready/success, red = failed, grey =\n * unknown/none. Shows the deploy state + a relative \"Xd ago\" timestamp, and links\n * to the deploy log when one is known. PURE. A null state renders a grey \"—\" (the\n * site has no Netlify id wired, or no deploy was read this run). */\nfunction deployBadge(site: WebsiteRow): string {\n const state = site.deployStatus;\n if (state === null) {\n return `<span class=\"metric deploy unknown\" title=\"No deploy data\">${DASH}</span>`;\n }\n const cls = isReadyDeployStatus(state)\n ? \"deploy ready\"\n : isFailedDeployStatus(state)\n ? \"deploy failed\"\n : \"deploy building\";\n const when = relativeTimeFromNow(site.lastDeployAt);\n const label = escapeHtml(`${state}${when !== DASH ? ` · ${when}` : \"\"}`);\n // Only link when we have a real http(s) URL — scheme-allowlist it via safeUrl (which\n // returns \"#\" for anything non-http), since deployLogUrl is data that flowed in from\n // the Netlify API via Airtable. A \"#\" fallback would be a dead link, so drop it.\n const url = site.deployLogUrl ? safeUrl(site.deployLogUrl) : \"#\";\n if (url !== \"#\") {\n return `<a class=\"metric ${cls}\" href=\"${escapeHtml(url)}\" target=\"_blank\" rel=\"noopener\">${label}</a>`;\n }\n return `<span class=\"metric ${cls}\">${label}</span>`;\n}\n\nfunction submBadge(c: SiteCard): string {\n // Lead/signup split (2026-07-16). Legacy cards without the split fields fall back\n // to labelling the whole count \"new\" — same rendering as before the split.\n const leads = c.newLeads ?? c.newSubmissions ?? 0;\n const signups = c.newSignups ?? 0;\n if (leads === 0 && signups === 0) return \"\";\n const label =\n leads > 0 && signups > 0\n ? `📥 ${leads} new · +${signups} newsletter`\n : leads > 0\n ? `📥 ${leads} new`\n : `📥 +${signups} newsletter`;\n return `<span class=\"chip\">${label}</span>`;\n}\n\nconst PILL_LABEL: Record<Tier, string> = {\n attention: \"failing\",\n watch: \"watch\",\n healthy: \"ok\",\n \"pre-launch\": \"pre-launch\",\n};\n\nfunction attentionBadge(status?: string): string {\n if (status === \"new\") return `<span class=\"badge\">NEW</span>`;\n if (status === \"worse\") return `<span class=\"badge\">WORSE</span>`;\n return \"\";\n}\n\nfunction chips(c: SiteCard): string {\n const items = c.items.map((it) => {\n const cls = it.autoFixExhausted\n ? \"chip critical stuck\"\n : it.severity === \"critical\"\n ? \"chip critical\"\n : \"chip\";\n return `<span class=\"${cls}\">${attentionBadge(it.status)}${escapeHtml(it.title)}</span>`;\n });\n // Surface the exact accept token beside each watch chip so the operator can discover\n // what string mutes it — e.g. `on *.netlify.app (no custom domain) · accept: \"no custom\n // domain\"`. The literal quotes stay outside escapeHtml (the key itself is escaped).\n const acceptKeys = c.watchAcceptKeys ?? [];\n c.watchReasons.forEach((reason, i) => {\n const key = acceptKeys[i];\n const hint = key ? ` · accept: \"${escapeHtml(key)}\"` : \"\";\n items.push(\n `<span class=\"chip\" title=\"Add this exact text to the site's Accepted Watch Conditions to mute this watch\">${escapeHtml(reason)}${hint}</span>`,\n );\n });\n for (const reason of c.acceptedReasons)\n items.push(`<span class=\"chip accepted\">✓ accepted: ${escapeHtml(reason)}</span>`);\n return items.length ? `<div class=\"chips\">${items.join(\"\")}</div>` : \"\";\n}\n\n/** Space-separated signal tags for the client filter. Attention-item kinds\n * (\"vulns\"/\"lighthouse\"/\"delivery\"/\"prs\" from renovate/\"ci\") plus the structured\n * watch signals (\"lighthouse\" for a sub-floor-band score, \"stale\" for an old\n * commit) — so a watch-band Lighthouse card still matches the \"lighthouse\" filter. */\nfunction signalsAttr(c: SiteCard): string {\n const kinds = new Set<string>();\n for (const it of c.items) {\n kinds.add(it.kind === \"vuln\" ? \"vulns\" : it.kind === \"renovate\" ? \"prs\" : it.kind);\n }\n if (c.items.some((it) => it.autoFixExhausted)) kinds.add(\"auto-fix-failed\");\n for (const sig of c.watchSignals) kinds.add(sig);\n return [...kinds].join(\" \");\n}\n\n/** On-demand Renovate trigger button — only for repo-backed sites (nothing to\n * dispatch otherwise). Posts to the authed /api/sites/:slug/trigger-renovate. */\nfunction triggerRenovateBtn(c: SiteCard): string {\n if (!c.site.gitRepo?.trim()) return \"\";\n const url = `/api/sites/${escapeHtml(siteSlug(c.site.name))}/trigger-renovate`;\n return `<button class=\"trigger-renovate\" data-trigger-url=\"${url}\">Trigger Renovate</button>`;\n}\n\nfunction cockpitCard(c: SiteCard): string {\n const base = card(c.site); // existing header + metrics markup\n const pill = `<span class=\"pill ${c.tier}\">${PILL_LABEL[c.tier]}</span>`;\n const extra = `${pill}${chips(c)}${submBadge(c)}${triggerRenovateBtn(c)}`;\n const opening = `<article class=\"card\" data-signals=\"${signalsAttr(c)}\">`;\n // Inject the pill + chips before the article's closing tag, and add the filter\n // hook. Function replacers so a `$` in escaped chip text can't be read as a\n // String.replace special ($&, $1, …).\n return base\n .replace('<article class=\"card\">', () => opening)\n .replace(\"</article>\", () => `${extra}</article>`);\n}\n\n/** Signal filters for the Fleet panel — card-derived tags only. `pending` lives in the\n * Needs-you feed and `submissions` in the Inbox lane, so they are not card filters. */\nconst FLEET_FILTERS = [\n \"all\",\n \"vulns\",\n \"lighthouse\",\n \"delivery\",\n \"prs\",\n \"ci\",\n \"auto-fix-failed\",\n \"stale\",\n \"no-domain\",\n] as const;\n\n/** The fleet browser: one collapsed <details> holding the filter chips and a single flat\n * card grid (cards already ordered attention→watch→healthy by buildCockpitModel).\n * Flattening — no nested per-tier <details> — is what makes the filters actually work:\n * a filtered card can never hide inside a collapsed tier. */\nexport function renderFleetBrowsePanel(model: CockpitModel): string {\n const total = model.cards.length;\n const chips = FLEET_FILTERS.map(\n (f) =>\n `<button type=\"button\" data-filter=\"${f}\" aria-pressed=\"${f === \"all\" ? \"true\" : \"false\"}\">${f}</button>`,\n ).join(\"\");\n const body =\n total === 0\n ? `<div class=\"empty\">No sites on the fleet view yet.</div>`\n : `<div class=\"cards\">${model.cards.map(cockpitCard).join(\"\")}</div>`;\n return `<details class=\"fleet-browse\">\n <summary>Fleet (${total})</summary>\n <div class=\"filters\">${chips}</div>\n ${body}\n </details>`;\n}\n\n/** Client behavior scoped to the Fleet panel: tag-based card filtering + the per-card\n * Trigger Renovate dispatch. String-concat only (lives inside a TS template literal). */\nexport const FLEET_BROWSE_SCRIPT = `<script>\n(function(){\n var btns = document.querySelectorAll('.fleet-browse .filters button');\n var cards = document.querySelectorAll('.fleet-browse .cards .card');\n btns.forEach(function(b){\n b.addEventListener('click', function(){\n var f = b.getAttribute('data-filter');\n btns.forEach(function(x){ x.setAttribute('aria-pressed', x===b ? 'true':'false'); });\n cards.forEach(function(c){\n var sig = (c.getAttribute('data-signals')||'').split(' ');\n c.style.display = (f==='all' || sig.indexOf(f)!==-1) ? '' : 'none';\n });\n });\n });\n document.querySelectorAll('.fleet-browse button.trigger-renovate').forEach(function(b){\n b.addEventListener('click', async function(){\n b.disabled = true; b.textContent = 'Dispatching…';\n try { var res = await fetch(b.dataset.triggerUrl, { method: 'POST' });\n b.textContent = res.ok ? 'Dispatched ✓' : 'Failed';\n if (!res.ok) b.disabled = false; }\n catch(e){ b.textContent = 'Failed'; b.disabled = false; }\n });\n });\n})();\n</script>`;\n","import { renderAuthChrome } from \"./auth/render.js\";\nimport type {\n CockpitModel,\n SubmissionEntry,\n NeedsYouItem,\n NeedsYouGroup,\n RecentEntry,\n} from \"./fleet-cockpit.js\";\nimport { fleetLastAuditedAt, buildNeedsYouFeed } from \"./fleet-cockpit.js\";\nimport { isLeadFormType } from \"../forms/types.js\";\nimport type { FleetEventType } from \"../db/fleet-events.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml } from \"../util/html.js\";\nimport { FAVICON_LINK } from \"./favicon.js\";\nimport { renderFleetBrowsePanel, FLEET_BROWSE_SCRIPT } from \"./fleet-browse-render.js\";\n\nconst STYLES = `\n:root { color-scheme: light dark; }\nbody { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }\n@media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } }\nh1 { margin: 0 0 0.25rem; font-size: 1.75rem; }\n.meta { color: #666; margin-bottom: 1.5rem; }\n.empty { color: #999; padding: 2rem; text-align: center; border: 1px dashed #ccc; border-radius: 6px; }\n.cards { display: flex; flex-direction: column; gap: 0.75rem; }\n.card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 0.9rem 1.1rem; }\n@media (prefers-color-scheme: dark) { .card { border-color: #2a2a2a; background: #181818; } }\n.card-head { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; align-items: baseline; }\n.card-head .site { font-weight: 600; font-size: 1.05rem; }\n.card-head .url { color: #666; font-size: 0.85rem; }\n.card-head .setup, .card-head .audited { color: #666; font-size: 0.85rem; }\n.card-head .setup { margin-left: auto; }\n.card-metrics { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; margin-top: 0.5rem; font-variant-numeric: tabular-nums; }\n.cluster { display: inline-flex; gap: 0.5rem; align-items: baseline; }\n.cluster.lighthouse .score { display: inline-block; min-width: 2.25rem; text-align: right; }\n.metric-label { color: #999; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }\n.metric { font-feature-settings: \"tnum\"; }\n.metric.deploy { font-size:0.78rem; padding:0.05rem 0.4rem; border-radius:6px; text-decoration:none; }\n.metric.deploy.ready { background:#e6f4ea; color:#1a7f37; }\n.metric.deploy.failed { background:#fdecea; color:#b00; font-weight:600; }\n.metric.deploy.building { background:#fff4e5; color:#9a6700; }\n.metric.deploy.unknown { color:#999; }\n@media (prefers-color-scheme: dark) {\n .metric.deploy.ready { background:#0f2417; color:#4ac26b; }\n .metric.deploy.failed { background:#3a1412; color:#ff6b6b; }\n .metric.deploy.building { background:#2e2410; color:#e3b341; }\n}\n.spam-rollup { font-size:0.9rem; margin-bottom:1rem; }\n.muted { color:#999; }\n.filters { display:flex; flex-wrap:wrap; gap:0.4rem; margin-bottom:1.25rem; }\n.filters button { font:inherit; font-size:0.85rem; padding:0.25rem 0.7rem; border:1px solid #ccc; border-radius:999px; background:transparent; color:inherit; cursor:pointer; }\n.filters button[aria-pressed=\"true\"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }\n@media (prefers-color-scheme: dark) { .filters button[aria-pressed=\"true\"] { background:#e8e8e8; color:#111; } }\n.fleet-actions { margin-bottom:1.25rem; }\n.refresh-fleet { font:inherit; font-size:0.85rem; padding:0.3rem 0.8rem; border:1px solid #1a1a1a; border-radius:999px; background:#1a1a1a; color:#fff; cursor:pointer; }\n.refresh-fleet:disabled { opacity:0.6; cursor:default; }\n@media (prefers-color-scheme: dark) { .refresh-fleet { background:#e8e8e8; color:#111; border-color:#e8e8e8; } }\n.rf-status { margin-top:0.6rem; font-size:0.85rem; }\n.rf-row { padding:0.1rem 0; }\n.rf-row a { margin-left:0.3rem; }\n.rf-sub { color:#999; font-size:0.8rem; margin-left:1.1rem; }\n.rf-spin { display:inline-block; width:0.8em; height:0.8em; border:2px solid #999; border-top-color:transparent; border-radius:50%; animation:rf-spin 0.8s linear infinite; vertical-align:-0.1em; }\n@keyframes rf-spin { to { transform:rotate(360deg); } }\ndetails.fleet-browse, details.inbox { margin:0.75rem 0; }\ndetails.fleet-browse > summary, details.inbox > summary { cursor:pointer; font-weight:700; font-size:1.05rem; padding:0.35rem 0; list-style:none; }\n.approve-row { display:flex; flex-wrap:wrap; gap:0.5rem 1rem; align-items:center; padding:0.25rem 0; }\n.pill { font-size:0.75rem; padding:0.1rem 0.5rem; border-radius:999px; font-weight:700; }\n.pill.attention { background:#fdecea; color:#b00; }\n.pill.watch { background:#fff4e5; color:#a65a00; }\n.pill.healthy { background:#e8f5e9; color:#1b7a2f; }\n.pill.pre-launch { background:#eef1f5; color:#5b6570; }\n@media (prefers-color-scheme: dark) { .pill.pre-launch { background:#1c2530; color:#9aa7b4; } }\n.chips { display:flex; flex-wrap:wrap; gap:0.4rem; margin-top:0.5rem; }\n.chip { font-size:0.8rem; padding:0.1rem 0.5rem; border-radius:6px; background:#f0f0f0; }\n@media (prefers-color-scheme: dark) { .chip { background:#222; } }\n.chip.critical { background:#fdecea; color:#b00; }\n.chip.stuck { border:1px solid #b00; font-weight:600; }\n.chip.accepted { background:transparent; border:1px dashed #bbb; color:#888; }\n@media (prefers-color-scheme: dark) { .chip.accepted { border-color:#444; color:#888; } }\n.badge { font-weight:700; color:#C00; font-size:0.72rem; margin-right:0.25rem; }\n.verdict { border-radius:8px; padding:0.9rem 1.1rem; margin-bottom:1.25rem; }\n.verdict .verdict-line { font-weight:800; font-size:1.4rem; }\n.verdict .verdict-meta { color:#666; font-size:0.9rem; margin-top:0.2rem; }\n.verdict.ok { background:#e8f5e9; color:#1b7a2f; }\n.verdict.ok .verdict-meta { color:#2e7d32; }\n.verdict.warn { background:#fdecea; color:#b00; }\n.verdict.warn .verdict-meta { color:#b00; opacity:0.85; }\n.verdict.watch { background:#fff4e5; color:#a65a00; }\n.verdict.watch .verdict-meta { color:#a65a00; opacity:0.85; }\n.verdict.soft { background:#e7f1ff; color:#1c5d99; }\n.verdict.soft .verdict-meta { color:#1c5d99; opacity:0.85; }\n@media (prefers-color-scheme: dark) { .verdict.ok { background:#10240f; color:#7fce85; } .verdict.warn { background:#2a0f0d; color:#ff8a80; } .verdict.watch { background:#2a2410; color:#ffd454; } .verdict.soft { background:#0f1d2a; color:#7fb6e8; } }\n.verdict .fleet-actions { margin:0.6rem 0 0; }\n.needs-you { border:1px solid #e5e5e5; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.25rem; }\n@media (prefers-color-scheme: dark) { .needs-you { border-color:#2a2a2a; background:#181818; } }\n.needs-you h2 { font-size:1.05rem; margin:0 0 0.5rem; }\n.feed-group-label { text-transform:uppercase; letter-spacing:0.04em; font-size:0.72rem; color:#999; margin:0.6rem 0 0.2rem; }\n.feed-row { display:flex; gap:0.5rem; align-items:center; padding:0.3rem 0; border-bottom:1px dashed #eee; }\n.feed-row:last-child { border-bottom:0; }\n@media (prefers-color-scheme: dark) { .feed-row { border-bottom-color:#262626; } }\n.feed-what { flex:1; }\n.feed-open { white-space:nowrap; }\n.dot { width:0.55rem; height:0.55rem; border-radius:50%; display:inline-block; flex:0 0 auto; }\n.dot.broken { background:#dc2626; }\n.dot.approval { background:#2563eb; }\n.dot.watch { background:#f59e0b; }\n`;\n\n/** Per-band site counts for the verdict, derived from the Needs-you feed. PURE. */\nfunction needsYouCounts(feed: NeedsYouItem[]): {\n broken: number;\n watch: number;\n approval: number;\n} {\n let broken = 0;\n let watch = 0;\n let approval = 0;\n for (const i of feed) {\n if (i.group === \"broken\") broken++;\n else if (i.group === \"watch\") watch++;\n else approval++;\n }\n return { broken, watch, approval };\n}\n\n/** The glance verdict — worst band wins. Green \"✓ All clear\" on an empty feed; else\n * red (any broken), amber (watch, nothing broken), or blue (only approvals). Every\n * lower band's count + the healthy count ride in the meta line (zero terms omitted),\n * followed by the audit-recency suffix. Houses the ↻ Audit button + live panel.\n * CSS-class ↔ band map: ok=empty, warn=broken, watch=watch, soft=approval — `warn`\n * and `soft` are inherited from the prior binary verdict, so they differ from the\n * feed's `broken`/`approval` group names. */\nfunction verdictBar(model: CockpitModel, feed: NeedsYouItem[]): string {\n const auditedIso = fleetLastAuditedAt(model.cards);\n const auditedTerm = auditedIso\n ? `fleet last audited ${escapeHtml(relativeTimeFromNow(auditedIso))}`\n : null;\n const total = model.cards.length;\n const { broken, watch, approval } = needsYouCounts(feed);\n // Pre-launch (pre-live) cards never enter the feed and must not be counted as\n // \"healthy\" nor as \"broken\" — surface them as their own neutral meta term.\n const preLaunch = model.summary.preLaunch;\n const preLaunchTerm = preLaunch > 0 ? `${preLaunch} pre-launch` : null;\n // Archived (legacy/deprecated) rows are not in model.cards, so they never join\n // the healthy math below — a neutral count term only, mirroring pre-launch.\n const archivedCount = model.archived?.length ?? 0;\n const archivedTerm = archivedCount > 0 ? `${archivedCount} archived` : null;\n // Clamp: feed counts include pending-report sites, which (rarely) may not be among\n // the visible cards, so total − feed could otherwise underflow to a negative \"healthy\".\n const healthy = Math.max(0, total - (broken + watch + approval + preLaunch));\n const actions = `<div class=\"fleet-actions\">\n <button type=\"button\" class=\"refresh-fleet\" data-refresh-url=\"/api/fleet/refresh\">↻ Audit fleet</button>\n <div id=\"rf-status\" class=\"rf-status\" aria-live=\"polite\"></div>\n </div>`;\n const render = (cls: string, line: string, terms: Array<string | null>): string =>\n `<div class=\"verdict ${cls}\">\n <div class=\"verdict-line\">${line}</div>\n <div class=\"verdict-meta\">${terms.filter(Boolean).join(\" · \")}</div>\n ${actions}\n </div>`;\n\n if (broken === 0 && watch === 0 && approval === 0) {\n const healthyWord = healthy > 0 ? `${healthy} site${healthy === 1 ? \"\" : \"s\"} healthy` : null;\n return render(\"ok\", \"✓ All clear\", [healthyWord, preLaunchTerm, archivedTerm, auditedTerm]);\n }\n\n // Count terms are integer-derived static English — no escaping needed (unlike auditedTerm).\n const watchTerm = watch > 0 ? `${watch} watching` : null;\n const approvalTerm = approval > 0 ? `${approval} waiting on you` : null;\n const healthyTerm = healthy > 0 ? `${healthy} healthy` : null;\n\n if (broken > 0) {\n return render(\"warn\", `⚠ ${broken} site${broken === 1 ? \"\" : \"s\"} broken`, [\n watchTerm,\n approvalTerm,\n healthyTerm,\n preLaunchTerm,\n archivedTerm,\n auditedTerm,\n ]);\n }\n if (watch > 0) {\n return render(\"watch\", `${watch} site${watch === 1 ? \"\" : \"s\"} to watch`, [\n approvalTerm,\n healthyTerm,\n preLaunchTerm,\n archivedTerm,\n auditedTerm,\n ]);\n }\n return render(\"soft\", `${approval} waiting on you`, [\n healthyTerm,\n preLaunchTerm,\n archivedTerm,\n auditedTerm,\n ]);\n}\n\nconst NEEDS_YOU_GROUP_LABEL: Record<NeedsYouGroup, string> = {\n broken: \"Broken\",\n watch: \"Watch\",\n approval: \"Waiting on your yes\",\n};\n\n/** The single per-site triage feed. Every row is navigation-only: one Open ▸ to the\n * site page (where approve / Trigger Renovate / checklist already live). */\nfunction renderNeedsYouFeed(feed: NeedsYouItem[]): string {\n if (feed.length === 0) return \"\";\n const groups: NeedsYouGroup[] = [\"broken\", \"watch\", \"approval\"];\n const blocks = groups\n .map((g) => {\n const rows = feed.filter((i) => i.group === g);\n if (rows.length === 0) return \"\";\n const lis = rows\n .map(\n (i) => `<div class=\"feed-row\" data-group=\"${g}\">\n <span class=\"dot ${g}\"></span>\n <span class=\"feed-what\"><strong>${escapeHtml(i.siteName)}</strong> — ${escapeHtml(i.reasons.join(\" · \"))}</span>\n <a class=\"feed-open\" href=\"${escapeHtml(i.url)}\">Open ▸</a>\n </div>`,\n )\n .join(\"\");\n return `<div class=\"feed-group\"><div class=\"feed-group-label\">${NEEDS_YOU_GROUP_LABEL[g]}</div>${lis}</div>`;\n })\n .join(\"\");\n return `<section class=\"needs-you\"><h2>Needs you (${feed.length})</h2>${blocks}</section>`;\n}\n\n/** Most submissions to render in the cockpit inbox lane. The summary still shows the\n * true fleet total; overflow is triaged on each site's page (which lists 25). */\nconst SUBMISSIONS_STRIP_CAP = 10;\n\nconst RECENT_ICON: Record<FleetEventType, string> = {\n pr_automerged: \"🔧\",\n vuln_cleared: \"🛡\",\n ci_recovered: \"✅\",\n site_launched: \"🚀\",\n cert_renewed: \"🔒\",\n fleet_swept: \"🔄\",\n report_sent_with_override: \"✳️\",\n};\n\n/** The calm \"Recently\" lane: what the fleet did for you, collapsed by default\n * (reassurance, not an alarm). One row per event: icon · site + summary · when ·\n * optional link (PR url, else /s/<slug>). Returns \"\" when there is nothing. */\nfunction renderRecentlyLane(model: CockpitModel): string {\n const events: RecentEntry[] = model.recent ?? [];\n if (events.length === 0) return \"\";\n const rows = events\n .map((e) => {\n const icon = RECENT_ICON[e.type] ?? \"•\";\n const when = escapeHtml(relativeTimeFromNow(e.ts));\n const site = e.siteName ? `<strong>${escapeHtml(e.siteName)}</strong> — ` : \"\";\n const link = e.url\n ? `<a href=\"${escapeHtml(e.url)}\">view ▸</a>`\n : e.slug\n ? `<a href=\"/s/${escapeHtml(e.slug)}\">open ▸</a>`\n : \"\";\n return `<div class=\"recent-row\" data-type=\"${e.type}\">\n <span class=\"recent-icon\">${icon}</span>\n <span class=\"recent-what\">${site}${escapeHtml(e.summary)}</span>\n <span class=\"muted\">${when}</span>\n ${link}\n </div>`;\n })\n .join(\"\");\n return `<details class=\"recently\">\n <summary>🔧 Recently (${events.length})</summary>\n ${rows}\n </details>`;\n}\n\n/** Neutral roster of archived (legacy/deprecated) rows — excluded from every\n * fleet op, listed so rows can never silently vanish from the cockpit. Reuses\n * the approve-row/muted styling; /s/<slug> works for archived rows because\n * getWebsiteBySlug does not status-filter. Returns \"\" when there are none. */\nfunction renderArchivedLane(model: CockpitModel): string {\n const rows = model.archived ?? [];\n if (rows.length === 0) return \"\";\n const lis = rows\n .map(\n (s) =>\n `<div class=\"approve-row\"><strong>${escapeHtml(s.name)}</strong> <span class=\"muted\">${escapeHtml(s.status)}</span> <a href=\"/s/${escapeHtml(s.slug)}\">open ▸</a></div>`,\n )\n .join(\"\");\n return `<details class=\"archived\"><summary>🗄 Archived (${rows.length})</summary>${lis}</details>`;\n}\n\n/** The quiet inbox lane: newest submissions + the 30-day spam roll-up, in one collapsed\n * <details>. Submissions are a separate work stream — they never raise the verdict. */\nfunction renderInboxLane(model: CockpitModel): string {\n const subs: SubmissionEntry[] = model.submissions ?? [];\n const spam = model.spam;\n const hasSpam = !!spam && (spam.caught > 0 || spam.through > 0);\n const autoFiltered = model.autoFiltered ?? 0;\n const hasAutoFiltered = autoFiltered > 0;\n if (subs.length === 0 && !hasSpam && !hasAutoFiltered) return \"\";\n\n const shown = [...subs]\n .sort((a, b) => (b.submittedAt ?? \"\").localeCompare(a.submittedAt ?? \"\"))\n .slice(0, SUBMISSIONS_STRIP_CAP);\n const rows = shown\n .map((sub) => {\n const href = `/s/${escapeHtml(sub.slug)}`;\n const when = sub.submittedAt ? escapeHtml(relativeTimeFromNow(sub.submittedAt)) : \"\";\n const who = escapeHtml(sub.name || sub.email);\n return `<div class=\"approve-row\" data-signal=\"submissions\">\n <strong>${escapeHtml(sub.siteName)}</strong>\n <span class=\"muted\">${escapeHtml(sub.formType)} — ${who}</span>\n <span class=\"muted\">${when}</span>\n <a href=\"${href}\">open ▸</a>\n </div>`;\n })\n .join(\"\");\n const overflow = subs.length - shown.length;\n const more =\n overflow > 0\n ? `<div class=\"approve-row subm-more muted\"><a href=\"/submissions\">+${overflow} more — view all submissions</a></div>`\n : `<div class=\"approve-row subm-more muted\"><a href=\"/submissions\">View all submissions →</a></div>`;\n // These come from the nightly roll-up, not from a live aggregate, so they are\n // up to 24h old. Saying when they were taken is the difference between a\n // number the operator can trust and one that quietly lies on a busy morning.\n const spamAsOf =\n spam?.computedAt !== undefined\n ? ` · as of ${escapeHtml(spam.computedAt.slice(0, 16).replace(\"T\", \" \"))} UTC`\n : \"\";\n const spamLine = hasSpam\n ? `<div class=\"spam-rollup muted\">🛡 Spam (30d) — caught ${spam!.caught} · through ${spam!.through}${spamAsOf}</div>`\n : \"\";\n const spamInSummary = hasSpam ? \" · 🛡 Spam (30d)\" : \"\";\n const autoFilteredLine = hasAutoFiltered\n ? `<div class=\"spam-rollup muted\">🚫 ${autoFiltered} auto-filtered this week — <a href=\"/submissions?status=spam_auto\">review</a></div>`\n : \"\";\n // Actionable leads vs newsletter/rsvp signups (2026-07-16): a signup backlog must\n // not read as N leads waiting. The signup term only appears when nonzero.\n const leads = subs.filter((s) => isLeadFormType(s.formType)).length;\n const signups = subs.length - leads;\n const summaryCount =\n signups === 0 ? `${leads} new` : `${leads} new · +${signups} newsletter/rsvp`;\n return `<details class=\"inbox\">\n <summary>📥 Submissions (${summaryCount})${spamInSummary}</summary>\n ${rows}${subs.length > 0 ? more : \"\"}\n ${spamLine}\n ${autoFilteredLine}\n </details>`;\n}\n\nconst AUDIT_SCRIPT = `<script>\n(function(){\n // fleet-refresh live status: dispatch, then poll the actual runs and follow them.\n // Vanilla JS, string-concat only (no template literals) — this lives inside a TS\n // template string, so backticks or interpolation syntax would break the server render.\n var RF_KEY = 'reddoor:fleet-refresh';\n var RF_POLL_MS = 10000;\n var RF_MAX_MS = 90 * 60 * 1000; // safety ceiling; a full fleet Lighthouse run was ~48 min (2026-06-24)\n function rfPanel(){ return document.getElementById('rf-status'); }\n function rfStop(){ try { localStorage.removeItem(RF_KEY); } catch(e){} }\n // Per-workflow ETA hint (the lighthouse fleet sweep runs ~48 min; security ~1-2 min).\n function rfEta(label){ return label === 'lighthouse' ? '~48m' : label === 'security' ? '~2m' : ''; }\n // Escape before interpolating. The values here are server-side enums and a\n // GitHub html_url today, but this builds HTML from a REMOTE API response, and\n // the house rule everywhere else on these pages is that no server string ever\n // reaches the browser as markup. Cheaper to escape than to keep re-deciding.\n function rfEsc(v){\n return String(v == null ? '' : v)\n .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')\n .replace(/\"/g,'"').replace(/'/g,''');\n }\n // A run link must be a GitHub run URL. Anything else is dropped rather than\n // rendered, so a compromised or unexpected API response cannot place an\n // arbitrary href (javascript:, data:, an attacker's host) in the panel.\n function rfRunUrl(u){\n return typeof u === 'string' && u.indexOf('https://github.com/') === 0 ? rfEsc(u) : '';\n }\n function rfRender(status, startedAt){\n var failed = function(s){ return s === 'failure' || s === 'cancelled' || s === 'timed_out'; };\n var mins = Math.floor((Date.now() - startedAt) / 60000);\n var elapsed = mins < 1 ? '<1m' : mins + 'm';\n return status.perWorkflow.map(function(w){\n var label = String(w.workflow || '').replace('.yml','').replace('fleet-','');\n var done = w.state === 'success';\n var isFailed = failed(w.state);\n var icon = done ? '✓' : isFailed ? '✗' : '<span class=\"rf-spin\"></span>';\n var line = icon + ' ' + rfEsc(label) + ' — ' + rfEsc(String(w.state || '').replace('_',' '));\n var url = rfRunUrl(w.url);\n if (!done && !isFailed){\n var eta = rfEta(label);\n var link = url ? ' · <a href=\"'+url+'\" target=\"_blank\" rel=\"noopener\">view run ↗</a>' : '';\n line += '<div class=\"rf-sub\">' + elapsed + (eta ? ' / ' + eta : '') + link + '</div>';\n } else if (isFailed && url){\n line += ' <a href=\"'+url+'\" target=\"_blank\" rel=\"noopener\">run</a>';\n }\n return '<div class=\"rf-row\">' + line + '</div>';\n }).join('');\n }\n function rfPoll(since, startedAt){\n fetch('/api/fleet/refresh/status?since=' + encodeURIComponent(since)).then(function(res){\n if (res.status === 401) return { authFail: true };\n return res.ok ? res.json() : null;\n }).then(function(data){\n var p = rfPanel();\n if (data && data.authFail){\n if (p) p.innerHTML += '<div class=\"rf-row\">Session expired — reload to sign in.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Audit fleet'; }\n rfStop(); return;\n }\n if (data && data.status){\n if (p) p.innerHTML = rfRender(data.status, startedAt);\n if (data.status.allDone){\n if (!data.status.anyFailure){\n if (p) p.innerHTML += '<div class=\"rf-row\">✓ Done — reloading…</div>';\n rfStop(); setTimeout(function(){ location.reload(); }, 2000); return;\n }\n if (p) p.innerHTML += '<div class=\"rf-row\"><button type=\"button\" onclick=\"location.reload()\">Reload</button></div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Audit fleet'; }\n rfStop(); return;\n }\n }\n if (Date.now() - startedAt > RF_MAX_MS){\n if (p) p.innerHTML += '<div class=\"rf-row\">Still running — reload later.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Audit fleet'; }\n rfStop(); return;\n }\n setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);\n }).catch(function(){\n var p = rfPanel();\n if (Date.now() - startedAt > RF_MAX_MS){\n if (p) p.innerHTML += '<div class=\"rf-row\">Still running — reload later.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Audit fleet'; }\n rfStop(); return;\n }\n setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);\n });\n }\n function rfBegin(since, startedAt){\n try { localStorage.setItem(RF_KEY, JSON.stringify({ since: since, startedAt: startedAt })); } catch(e){}\n var p = rfPanel(); if (p) p.innerHTML = '<div class=\"rf-row\"><span class=\"rf-spin\"></span> starting…</div>';\n rfPoll(since, startedAt);\n }\n var rf = document.querySelector('button.refresh-fleet');\n if (rf) rf.addEventListener('click', async function(){\n if (!confirm('Kick off the security + Lighthouse sweeps for the whole fleet? They take a few minutes.')) return;\n rf.disabled = true; rf.textContent = 'Auditing…';\n try {\n var res = await fetch(rf.dataset.refreshUrl, { method: 'POST' });\n if (res.ok){\n var data = await res.json();\n rf.textContent = '↻ Audit running…';\n if (data && data.since) rfBegin(data.since, Date.now());\n } else { rf.textContent = 'Failed to start'; rf.disabled = false; }\n } catch(e){ rf.textContent = 'Failed to start'; rf.disabled = false; }\n });\n // Resume-on-reload: if a refresh is in flight (<90 min old), keep following it.\n try {\n var rfSaved = JSON.parse(localStorage.getItem(RF_KEY) || 'null');\n if (rfSaved && rfSaved.since && rfSaved.startedAt && (Date.now() - rfSaved.startedAt) < RF_MAX_MS){\n if (rf){ rf.disabled = true; rf.textContent = '↻ Audit running…'; }\n rfBegin(rfSaved.since, rfSaved.startedAt);\n } else if (rfSaved) { rfStop(); }\n } catch(e){}\n})();\n</script>`;\n\n/**\n * Render the fleet cockpit as a single HTML document. Pure function: no Airtable\n * access, no env reads, no I/O. The Netlify function handler builds the\n * CockpitModel (visible-site filter, tiering, NEW/WORSE badging, pending list)\n * and hands it here. Renders the doc shell + verdict bar + the per-site Needs-you\n * feed + the collapsed Fleet browse panel (one flat, filterable card grid).\n */\nexport function renderCockpitHtml(\n model: CockpitModel,\n operatorEmail: string | null = null,\n): string {\n const total = model.cards.length;\n const feed = buildNeedsYouFeed(model);\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n ${FAVICON_LINK}\n <title>Reddoor maintenance — fleet cockpit</title>\n <style>${STYLES}</style>\n</head>\n<body>\n ${renderAuthChrome(operatorEmail)}\n <h1>Reddoor fleet cockpit</h1>\n <div class=\"meta\">${total} site${total === 1 ? \"\" : \"s\"} on the Reddoor stack. <a href=\"/fleet\">Fleet table →</a></div>\n ${verdictBar(model, feed)}\n ${renderNeedsYouFeed(feed)}\n ${renderFleetBrowsePanel(model)}\n ${renderRecentlyLane(model)}\n ${renderArchivedLane(model)}\n ${renderInboxLane(model)}\n ${AUDIT_SCRIPT}\n ${FLEET_BROWSE_SCRIPT}\n</body>\n</html>`;\n}\n","import { timingSafeEqual } from \"node:crypto\";\n\n/**\n * Verify an `Authorization: Basic <base64>` header against the configured\n * dashboard password. Username is intentionally ignored — operators may\n * type anything when the browser prompts; only the password gates entry.\n *\n * Returns false for any of:\n * - missing/empty Authorization header\n * - non-Basic auth scheme\n * - malformed base64 or payload (no colon to split user:password)\n * - wrong password\n * - expected password missing (DASHBOARD_PASSWORD not configured)\n *\n * Wrong-password compare is constant-time; BYTE lengths are checked first\n * (timingSafeEqual throws a RangeError on a buffer-length mismatch, and the\n * length itself doesn't leak — operator's password length is fixed per deploy).\n * Comparing JS-string lengths instead of byte lengths could let an equal-char\n * but unequal-byte password (a multibyte char) reach timingSafeEqual and throw,\n * turning a wrong password into an uncaught 500.\n */\nexport function verifyBasicAuth(\n authHeader: string | null | undefined,\n expectedPassword: string | null,\n): boolean {\n if (!authHeader || !expectedPassword) return false;\n // RFC 7235: scheme is case-insensitive.\n const match = /^basic\\s+(.+)$/i.exec(authHeader.trim());\n if (!match) return false;\n let decoded: string;\n try {\n decoded = Buffer.from(match[1]!, \"base64\").toString(\"utf-8\");\n } catch {\n return false;\n }\n // Base64-decoding never throws in Node, but a payload of garbage may\n // produce a string with no colon. user:password form is required.\n const colonIdx = decoded.indexOf(\":\");\n if (colonIdx === -1) return false;\n const provided = decoded.slice(colonIdx + 1);\n // Compare BYTE lengths, not JS-string lengths: timingSafeEqual compares the\n // underlying buffers and throws a RangeError if they differ in byte length.\n // Two strings can share a JS length but differ in UTF-8 byte length (e.g. a\n // multibyte char), so a JS-length guard would let mismatched buffers through\n // and crash the handler with a 500.\n const a = Buffer.from(provided, \"utf-8\");\n const b = Buffer.from(expectedPassword, \"utf-8\");\n if (a.length !== b.length) return false;\n return timingSafeEqual(a, b);\n}\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Versioned HMAC-SHA256 over a string payload — the one security primitive the\n * cookie-session scheme rests on. Both the session cookie (session.ts) and the\n * OAuth state cookie (oauth-state.ts) are built from it, which is why it lives\n * on its own rather than inside either.\n *\n * Format: `v1.<base64url(payload)>.<base64url(mac)>`, where the MAC covers the\n * *encoded* payload segment. Signing the encoded form rather than the raw\n * string means verification never has to decode attacker-controlled bytes\n * before deciding whether to trust them.\n *\n * The version prefix exists so the format can change without silently\n * accepting old tokens: bump it and every previously-issued token stops\n * verifying.\n */\n\nconst VERSION = \"v1\";\n\nfunction mac(encodedPayload: string, secret: string): Buffer {\n return createHmac(\"sha256\", secret).update(encodedPayload).digest();\n}\n\n/** Sign a payload string. Throws on an empty secret — a deploy that signs with\n * \"\" would hand out forgeable tokens, so this fails loudly rather than\n * quietly issuing them. Callers check their env before getting here. */\nexport function sign(payload: string, secret: string): string {\n if (!secret) throw new Error(\"sign: refusing to sign with an empty secret\");\n const encoded = Buffer.from(payload, \"utf-8\").toString(\"base64url\");\n return `${VERSION}.${encoded}.${mac(encoded, secret).toString(\"base64url\")}`;\n}\n\n/**\n * Verify and unwrap a token, returning the payload string, or null for any\n * failure: wrong shape, unknown version, bad signature, wrong secret.\n *\n * Never throws. Every input here is attacker-controlled — it arrives in a\n * cookie — so malformed input has to be an ordinary rejection rather than a\n * 500. In particular the MAC's byte length is checked before `timingSafeEqual`,\n * which throws `RangeError` on a length mismatch; this is the same lesson\n * already recorded in basic-auth.ts, where a JS-length guard would have turned\n * a wrong password into an uncaught error.\n */\nexport function verify(token: string | null | undefined, secret: string): string | null {\n if (!token || !secret) return null;\n const parts = token.split(\".\");\n if (parts.length !== 3) return null;\n const [version, encoded, signature] = parts as [string, string, string];\n if (version !== VERSION || !encoded || !signature) return null;\n\n const expected = mac(encoded, secret);\n const provided = Buffer.from(signature, \"base64url\");\n if (provided.length !== expected.length) return null;\n if (!timingSafeEqual(provided, expected)) return null;\n\n return Buffer.from(encoded, \"base64url\").toString(\"utf-8\");\n}\n","import { sign, verify } from \"./signing.js\";\nimport { serializeCookie, clearCookie } from \"./cookies.js\";\n\n/**\n * The operator session: a signed cookie carrying a verified Google address and\n * an expiry. No server-side session record exists, deliberately.\n *\n * Revocation does not come from this module. `requireOperator` re-reads the\n * allowlist on every request, so removing an address ends that operator's\n * access on their next click even though their cookie is still cryptographically\n * valid. Rotating DASHBOARD_SESSION_SECRET invalidates every token at once.\n *\n * The consequence, recorded so it is not rediscovered as a surprise: an\n * individual live cookie (a stolen laptop) cannot be revoked on its own without\n * rotating the secret and signing everyone out. That is an accepted trade for a\n * three-operator tool, and the reason to revisit it is the operator list\n * growing, not the calendar.\n */\n\nexport const SESSION_COOKIE = \"rd_session\";\n\n/** 30 days. Long is safe here precisely because the allowlist is re-checked per\n * request — access can be withdrawn at any moment regardless of cookie age —\n * and re-authenticating is one click for someone already signed in to Google. */\nexport const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;\n\nexport type SessionPayload = {\n /** Lowercased, Google-verified address. */\n email: string;\n /** Issued-at, epoch seconds. Informational — not enforced. */\n iat: number;\n /** Expiry, epoch seconds. Enforced. */\n exp: number;\n};\n\n/**\n * Mint a session token for a verified address.\n *\n * The email is lowercased here so the allowlist comparison downstream never has\n * to care about the casing Google happened to return.\n */\nexport function mintSession(\n email: string,\n secret: string,\n now: Date,\n ttlSeconds: number = SESSION_TTL_SECONDS,\n): string {\n const iat = Math.floor(now.getTime() / 1000);\n const payload: SessionPayload = {\n email: email.trim().toLowerCase(),\n iat,\n exp: iat + ttlSeconds,\n };\n return sign(JSON.stringify(payload), secret);\n}\n\n/**\n * Verify a session token and return its payload, or null if it is missing,\n * malformed, forged, signed with a different secret, or expired.\n *\n * Only `exp` is enforced. `iat` is carried for debugging but deliberately not\n * validated: rejecting a future `iat` would turn ordinary clock skew between\n * deploys into a mysterious sign-out, and buys nothing — the token is signed by\n * us either way.\n */\nexport function verifySession(\n token: string | null | undefined,\n secret: string,\n now: Date,\n): SessionPayload | null {\n const payload = verify(token, secret);\n if (payload === null) return null;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(payload);\n } catch {\n return null;\n }\n if (typeof parsed !== \"object\" || parsed === null) return null;\n\n const { email, iat, exp } = parsed as Record<string, unknown>;\n if (typeof email !== \"string\" || email.length === 0) return null;\n if (typeof iat !== \"number\" || !Number.isFinite(iat)) return null;\n if (typeof exp !== \"number\" || !Number.isFinite(exp)) return null;\n if (exp * 1000 <= now.getTime()) return null;\n\n return { email, iat, exp };\n}\n\n/** `Set-Cookie` for a freshly minted session. */\nexport function sessionSetCookie(token: string): string {\n return serializeCookie(SESSION_COOKIE, token, {\n maxAgeSeconds: SESSION_TTL_SECONDS,\n path: \"/\",\n sameSite: \"Lax\",\n });\n}\n\n/**\n * `Set-Cookie` that ends the session.\n *\n * SameSite is Lax rather than Strict throughout, and that is a requirement\n * rather than a preference: the OAuth callback is a top-level navigation from\n * accounts.google.com, and Strict withholds cookies on cross-site navigations —\n * so a Strict state cookie would never arrive and every sign-in would fail\n * state validation. Lax sends on top-level GET and withholds on cross-site\n * POST, which is exactly the property this design wants.\n */\nexport function sessionClearCookie(): string {\n return clearCookie(SESSION_COOKIE, \"/\");\n}\n","import { assertUrlSegment } from \"../github/gh.js\";\nimport { makeGitHubRest } from \"../github/gh-rest.js\";\nimport { isHttpUrl, isPrivateOrLoopbackHost, hostnameOf } from \"../util/url.js\";\nimport type { ProspectAuditListItem } from \"../db/prospect-audits.js\";\n\n/**\n * Trigger logic for `POST /api/prospect-audit/run` (netlify/functions/prospect-audit-run.mts).\n * Kept here, deps-injected and framework-free, so it's unit-testable without\n * booting the Netlify handler — same split as trigger-renovate.ts.\n *\n * The audit itself runs as a `workflow_dispatch` in a PRIVATE repo (never this\n * one — this repo's Actions logs are world-readable, and a prospect URL there\n * would publish the sales pipeline), so every dispatch target is read from env\n * at the call site and threaded through as `{ repo, workflowFile }` — nothing\n * here hardcodes a repo.\n */\n\n/** A repeat submission of the SAME url within this window is refused rather\n * than spending a second audit — the double-press guard. A workflow retry\n * (or an impatient second click before the first request's 202 lands) must\n * not double-run and double-email. 10 minutes comfortably exceeds any\n * plausible client retry/backoff window while staying far shorter than the\n * audit's own several-minute runtime, so a genuine re-audit of the same\n * prospect a bit later is never blocked. */\nexport const PROSPECT_AUDIT_DUPLICATE_WINDOW_MS = 10 * 60 * 1000;\n\n/** How many of the most-recent audits (across every url) `triggerProspectAudit`\n * pulls to look for a same-url duplicate inside the window. Usage is\n * low-volume and human-triggered (one operator, one button), so this comfortably\n * covers the window without scanning the whole table; well under\n * MAX_RECENT_PROSPECT_AUDITS (src/db/prospect-audits.ts). */\nexport const DUPLICATE_CHECK_LOOKBACK = 25;\n\n/** Most audits that may be dispatched in any rolling 24 hours (#612 review).\n *\n * The duplicate window stops the SAME url being re-run; nothing stopped\n * DISTINCT urls. One authenticated session could dispatch ~30/minute against\n * 30 hostnames indefinitely, and one audit is structurally an Opus call plus\n * up to 28 Sonnet calls with up to 112 billed web searches, a 20-page double\n * crawl, a 3-pass Lighthouse and a PDF render, inside a billed Actions job.\n *\n * 25/day is far above real use (this is one operator clicking a button) and far\n * below a number that could quietly cost hundreds. A cap that never binds in\n * normal operation is the point: it is a runaway brake, not a quota. */\nexport const PROSPECT_AUDIT_DAILY_CAP = 25;\n\n/** Lookback for the daily cap. Must exceed the cap so the count can actually\n * reach it — a lookback at or below the cap would make the limit unreachable\n * and the brake permanently disengaged, which is exactly the kind of guard\n * that reads as working while doing nothing. */\nexport const DAILY_CAP_LOOKBACK = PROSPECT_AUDIT_DAILY_CAP * 2;\n\n/** The workflow file dispatched when `PROSPECT_AUDIT_WORKFLOW_FILE` is unset. */\nexport const DEFAULT_PROSPECT_AUDIT_WORKFLOW_FILE = \"prospect-audit.yml\";\n\n/** Shown in the success message when `PROSPECT_AUDIT_RECIPIENTS` is not mirrored\n * into this cockpit's env. The authoritative list lives in the PRIVATE dispatch\n * repo's workflow secrets, which this endpoint cannot read — so name no names.\n * A message that confidently lists the wrong people is worse than a vague one,\n * and it would drift silently the day someone is added. */\nexport const DEFAULT_PROSPECT_AUDIT_RECIPIENTS_LABEL = \"the configured recipients\";\n\n/** Workflow inputs sent with the dispatch. All-string, matching GitHub Actions'\n * `workflow_dispatch` input typing. */\nexport type ProspectAuditDispatchInputs = {\n url: string;\n business: string;\n requested_by: string;\n};\n\n/** Everything a dispatcher needs to fire one run — deliberately narrow (no\n * GitHub-specific types leak past this boundary) so a future executor (a\n * different CI, a queue) can implement this same shape without touching the\n * endpoint or `triggerProspectAudit`. */\nexport type ProspectAuditDispatchTarget = {\n repo: string; // \"owner/repo\"\n workflowFile: string;\n inputs: ProspectAuditDispatchInputs;\n};\n\nexport type ProspectAuditDispatchResult = { ok: true } | { ok: false; error: string };\n\n/** The injectable seam: real dispatch is GitHub Actions today\n * ({@link makeWorkflowDispatchDispatcher}); tests inject a fake. Never throws —\n * a failure is reported as `{ ok: false, error }` so callers don't need a\n * try/catch around every dispatch. */\nexport type ProspectAuditDispatcher = (\n target: ProspectAuditDispatchTarget,\n) => Promise<ProspectAuditDispatchResult>;\n\nconst GITHUB_API = \"https://api.github.com\";\n\n/**\n * The actual dispatch call: a GitHub Actions `workflow_dispatch` against\n * `target.repo`'s default branch, carrying `target.inputs`. Built on\n * `makeGitHubRest` for `defaultBranch` (repo lookup), but hand-rolls the\n * dispatch POST itself — `GitHubRest.dispatchWorkflow` (src/github/gh-rest.ts)\n * only sends `{ ref }`, with no `inputs` support, and this endpoint's whole\n * point is passing `url`/`business`/`requested_by` through to the runner.\n *\n * This is the one piece of the module that is \"GitHub Actions\" — everything\n * else depends only on the narrow {@link ProspectAuditDispatcher} shape, so\n * swapping the executor later means writing a new function of that type, not\n * touching the trigger logic or the endpoint.\n */\nexport function makeWorkflowDispatchDispatcher(deps: {\n token: string;\n fetch?: typeof fetch;\n}): ProspectAuditDispatcher {\n const doFetch = deps.fetch ?? fetch;\n const gh = makeGitHubRest({ token: deps.token, fetch: doFetch });\n return async ({ repo, workflowFile, inputs }) => {\n try {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n return { ok: false, error: `expected \"owner/repo\", got ${JSON.stringify(repo)}` };\n }\n const ref = await gh.defaultBranch(repo);\n // Defense in depth, mirroring gh-rest.ts's own dispatchWorkflow — every\n // segment below interpolates into the API path.\n assertUrlSegment(\"path\", owner);\n assertUrlSegment(\"path\", name);\n assertUrlSegment(\"path\", workflowFile);\n assertUrlSegment(\"branch\", ref);\n const res = await doFetch(\n `${GITHUB_API}/repos/${owner}/${name}/actions/workflows/${workflowFile}/dispatches`,\n {\n method: \"POST\",\n headers: {\n authorization: `Bearer ${deps.token}`,\n accept: \"application/vnd.github+json\",\n \"x-github-api-version\": \"2022-11-28\",\n \"user-agent\": \"reddoor-maintenance-dashboard\",\n \"content-type\": \"application/json\",\n },\n body: JSON.stringify({ ref, inputs }),\n },\n );\n if (!res.ok) {\n const body = await res.text().catch(() => \"<no body>\");\n return {\n ok: false,\n error: `GitHub workflow_dispatch ${owner}/${name}/${workflowFile} failed (${res.status}): ${body.slice(0, 300)}`,\n };\n }\n return { ok: true };\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n };\n}\n\n/** Everything `triggerProspectAudit` needs from the outside world, injected so\n * it never touches Turso or the network directly. */\nexport type ProspectAuditTriggerDeps = {\n /** Newest-first recent audits, e.g. `(limit) => listRecentProspectAudits(db, limit)`. */\n listRecent: (limit: number) => Promise<ProspectAuditListItem[]>;\n dispatch: ProspectAuditDispatcher;\n /** Injectable clock for the duplicate-window check; defaults to `Date.now`. */\n now?: () => Date;\n};\n\nexport type ProspectAuditTriggerInput = {\n url: string;\n business: string | null;\n requestedBy: string;\n};\n\nexport type ProspectAuditTriggerResult =\n | { status: \"invalid-url\" }\n | { status: \"private-host\" }\n | { status: \"duplicate\"; existing: ProspectAuditListItem }\n /** The 24h runaway brake tripped (#612 review). Carries both numbers so the\n * operator sees a real limit rather than a bare refusal. */\n | { status: \"daily-cap\"; count: number; cap: number }\n | { status: \"dispatch-failed\"; error: string }\n | { status: \"dispatched\" };\n\n/**\n * Validate, de-duplicate, then dispatch. Order matches the design's threat\n * model: cheap shape checks first (no DB read, no dispatch) — malformed and\n * private-host input never reach the database or the dispatcher — THEN the\n * duplicate check (one DB read, still no dispatch on a hit) — THEN the 24h\n * runaway brake — THEN, only for a genuinely new, safe, public URL under the\n * cap, the actual dispatch that spends an audit.\n */\nexport async function triggerProspectAudit(\n deps: ProspectAuditTriggerDeps,\n target: { repo: string; workflowFile: string },\n input: ProspectAuditTriggerInput,\n): Promise<ProspectAuditTriggerResult> {\n const url = input.url.trim();\n if (!isHttpUrl(url)) return { status: \"invalid-url\" };\n\n // isHttpUrl already proved this parses; re-parsing here is cheap and keeps\n // the hostname extraction local to this one call site.\n const hostname = new URL(url).hostname;\n if (isPrivateOrLoopbackHost(hostname)) return { status: \"private-host\" };\n\n const now = (deps.now ?? (() => new Date()))();\n const cutoff = now.getTime() - PROSPECT_AUDIT_DUPLICATE_WINDOW_MS;\n const recent = await deps.listRecent(Math.max(DUPLICATE_CHECK_LOOKBACK, DAILY_CAP_LOOKBACK));\n const existing = recent.find((r) => r.url === url && Date.parse(r.created_at) >= cutoff);\n if (existing) return { status: \"duplicate\", existing };\n\n // Runaway brake. Checked AFTER the duplicate check on purpose: a repeated\n // click on one url should read as \"duplicate\", which is the truthful and more\n // useful answer, and should not consume the day's budget.\n const dayAgo = now.getTime() - 24 * 60 * 60 * 1000;\n const today = recent.filter((r) => Date.parse(r.created_at) >= dayAgo).length;\n if (today >= PROSPECT_AUDIT_DAILY_CAP) {\n return { status: \"daily-cap\", count: today, cap: PROSPECT_AUDIT_DAILY_CAP };\n }\n\n const business = input.business?.trim() || null;\n const result = await deps.dispatch({\n repo: target.repo,\n workflowFile: target.workflowFile,\n inputs: { url, business: business ?? \"\", requested_by: input.requestedBy },\n });\n if (!result.ok) return { status: \"dispatch-failed\", error: result.error };\n return { status: \"dispatched\" };\n}\n\n/**\n * The `requested_by` workflow input: the operator's Google-verified address, or\n * `\"cockpit\"` when there is no identity to report.\n *\n * This takes `requireOperator`'s verified email rather than reading a name off\n * the request. The predecessor pulled the username out of the `Authorization`\n * header — which `verifyBasicAuth` documents itself as deliberately ignoring,\n * so any operator could type any name and the audit log recorded it verbatim.\n * That made `requested_by` unverified free text, which is precisely the problem\n * Google sign-in was introduced to fix.\n *\n * `null` (the shared-password fallback, used on deploy previews) still yields\n * `\"cockpit\"`: an anonymous shared credential genuinely has no person behind\n * it, and inventing one would put the old lie back.\n */\nexport function resolveRequestedBy(operatorEmail: string | null | undefined): string {\n return operatorEmail?.trim() || \"cockpit\";\n}\n\n/** Human label for who gets the audit email, from the optional\n * `PROSPECT_AUDIT_RECIPIENTS` value (comma-separated names/emails) mirrored\n * into this cockpit's env, falling back to the design doc's stated audience.\n * The actual send + its recipient list live in the private dispatch repo's\n * workflow — this is a best-effort label for the success message only. */\nexport function prospectAuditRecipientsLabel(raw: string | undefined): string {\n const trimmed = raw?.trim();\n if (!trimmed) return DEFAULT_PROSPECT_AUDIT_RECIPIENTS_LABEL;\n const parts = trimmed\n .split(\",\")\n .map((s) => s.trim())\n .filter(Boolean);\n return parts.length > 0 ? parts.join(\", \") : DEFAULT_PROSPECT_AUDIT_RECIPIENTS_LABEL;\n}\n\n/** JSON status + body for one `ProspectAuditTriggerResult`. Kept pure and\n * separate from the .mts handler so each message is unit-testable directly. */\nexport function respondToProspectAuditTrigger(\n result: ProspectAuditTriggerResult,\n opts: { recipientsLabel: string },\n): { status: number; body: Record<string, unknown> } {\n switch (result.status) {\n case \"invalid-url\":\n return {\n status: 400,\n body: { ok: false, error: \"invalid-url\", message: \"Enter a valid http(s) URL to audit.\" },\n };\n case \"private-host\":\n return {\n status: 400,\n body: {\n ok: false,\n error: \"private-host\",\n message: \"That host looks internal or private — audits only run against public sites.\",\n },\n };\n case \"duplicate\":\n return {\n status: 409,\n body: {\n ok: false,\n error: \"duplicate\",\n message: `${hostnameOf(result.existing.url)} was already audited in the last 10 minutes — see the existing report instead of running another.`,\n reportUrl: `/r/${result.existing.token}`,\n },\n };\n case \"daily-cap\":\n return {\n // 429, not 400: the request is well-formed and the caller is\n // authorised — it is the rate that is refused, and it will succeed\n // again later without any change on their part.\n status: 429,\n body: {\n ok: false,\n error: \"daily-cap\",\n message: `${result.count} audits have run in the last 24 hours (cap ${result.cap}). This is a runaway brake — if the run is genuinely needed, raise PROSPECT_AUDIT_DAILY_CAP.`,\n },\n };\n case \"dispatch-failed\":\n return {\n status: 502,\n body: {\n ok: false,\n error: \"dispatch-failed\",\n message: `Could not start the audit: ${result.error}`,\n },\n };\n case \"dispatched\":\n return {\n status: 202,\n body: {\n ok: true,\n message: `Audit started. ${opts.recipientsLabel} will get an email with the report in a few minutes.`,\n },\n };\n }\n}\n"],"mappings":"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","names":["sign","chips","card","DASH","chips","STYLES","timingSafeEqual"]}
|