@reddoorla/maintenance 0.82.0 → 0.83.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/airtable-5ZPU3MT4.js +9 -0
- package/dist/{announce-7QHQGJPV.js → announce-4H64L2Z6.js} +4 -4
- package/dist/{blux-EATHOHKK.js → blux-ZGDSHI3U.js} +5 -5
- package/dist/{bump-deps-ZREAMK7M.js → bump-deps-2OL6PGTM.js} +5 -5
- package/dist/{chunk-OIDDJLS7.js → chunk-2WN4SNBQ.js} +2 -2
- package/dist/{chunk-HVOOCK6L.js → chunk-4DHQCN5H.js} +2 -1
- package/dist/{chunk-5E4L7WKE.js → chunk-5P2HVNLZ.js} +3 -3
- package/dist/{chunk-ZXRRKKMR.js → chunk-6V7565O2.js} +8 -8
- package/dist/{chunk-YRM6EED7.js → chunk-CLWNAMUX.js} +28 -2
- package/dist/chunk-CLWNAMUX.js.map +1 -0
- package/dist/{chunk-VXXF4Y25.js → chunk-FSRJAF2Q.js} +60 -3
- package/dist/chunk-FSRJAF2Q.js.map +1 -0
- package/dist/{chunk-B5ZUQZFL.js → chunk-ICXV25N7.js} +2 -2
- package/dist/{chunk-UH5YPYS4.js → chunk-JVWJJ36K.js} +5 -4
- package/dist/chunk-JVWJJ36K.js.map +1 -0
- package/dist/{chunk-4PW6ISX7.js → chunk-KV7DIRRO.js} +3 -3
- package/dist/{chunk-ODC4PXZL.js → chunk-MQUOUVQP.js} +2 -2
- package/dist/{chunk-LMCO7CYQ.js → chunk-OQFOMKBP.js} +2 -2
- package/dist/{chunk-FFI7P74K.js → chunk-PBCGXXG6.js} +2 -2
- package/dist/{chunk-T4FQ6TZT.js → chunk-QRDB4NLB.js} +6 -6
- package/dist/{chunk-K5JZ5KLO.js → chunk-R4AB6GCT.js} +3 -3
- package/dist/chunk-R53VL3RI.js +32 -0
- package/dist/chunk-R53VL3RI.js.map +1 -0
- package/dist/{chunk-FX7RAMGC.js → chunk-R6FPFMOS.js} +3 -3
- package/dist/{chunk-MGJ72BA4.js → chunk-RJB3QUSP.js} +2 -2
- package/dist/{chunk-ATJ6ZA2V.js → chunk-SDUJIANJ.js} +2 -2
- package/dist/{chunk-7ADQL5Y3.js → chunk-THEFTZ7F.js} +2 -2
- package/dist/{chunk-NT4WIP7I.js → chunk-UYCAF77E.js} +2 -2
- package/dist/chunk-VNB5XBQ4.js +699 -0
- package/dist/chunk-VNB5XBQ4.js.map +1 -0
- package/dist/{chunk-YAMRVXOD.js → chunk-VRWUL4TR.js} +2 -2
- package/dist/{chunk-2BHSGLWR.js → chunk-XDCRVYFL.js} +3 -3
- package/dist/chunk-XDCRVYFL.js.map +1 -0
- package/dist/{chunk-4NAOR44V.js → chunk-ZMIPG6TR.js} +2 -2
- package/dist/cli/bin.js +51 -23
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +1 -1
- package/dist/cli/commands/audit.js +10 -10
- package/dist/{convert-to-pnpm-UT2R6EOW.js → convert-to-pnpm-KCXOYOHW.js} +5 -5
- package/dist/{digest-CLFUWW62.js → digest-WGEPAWMP.js} +12 -7
- package/dist/digest-WGEPAWMP.js.map +1 -0
- package/dist/{ensure-site-LLPOXLWP.js → ensure-site-HYAIVZY2.js} +2 -2
- package/dist/{forms-notify-target-PN6P3WPW.js → forms-notify-target-HSL4ZMT5.js} +2 -2
- package/dist/{github-signals-HTH7474O.js → github-signals-I7XACMCE.js} +6 -6
- package/dist/{header-image-WMHZFU7B.js → header-image-ADYPTGVY.js} +2 -2
- package/dist/{health-endpoint-4E53ZMLE.js → health-endpoint-JNCUILHO.js} +6 -6
- package/dist/index.d.ts +65 -3
- package/dist/index.js +32 -31
- package/dist/index.js.map +1 -1
- package/dist/{init-3ZJ7Z5NG.js → init-FLGLJCMJ.js} +13 -13
- package/dist/{launch-FRCDOB56.js → launch-AVKH2JVF.js} +10 -10
- package/dist/{lighthouse-airtable-I46QCEIS.js → lighthouse-airtable-CHANWVO6.js} +3 -3
- package/dist/{onboard-45NPDMMW.js → onboard-HF4FTJDE.js} +5 -5
- package/dist/{orchestrate-34JYNIXT.js → orchestrate-5FMNYJEA.js} +3 -3
- package/dist/{preflight-WVD32COX.js → preflight-HTWIX3MN.js} +4 -4
- package/dist/prismic-ci-BH225Q4L.js +363 -0
- package/dist/prismic-ci-BH225Q4L.js.map +1 -0
- package/dist/prismic-models-HXCAM4ZL.js +1020 -0
- package/dist/prismic-models-HXCAM4ZL.js.map +1 -0
- package/dist/{protection-audit-2UR45SOC.js → protection-audit-64PLLBLI.js} +3 -3
- package/dist/recipes/sync-configs.d.ts +1 -1
- package/dist/{renovate-dispatch-LOOCNZTW.js → renovate-dispatch-HBZG2UEU.js} +4 -4
- package/dist/{report-CLF2753O.js → report-LV7NY5W3.js} +20 -13
- package/dist/report-LV7NY5W3.js.map +1 -0
- package/dist/{self-updating-5TJ2NB26.js → self-updating-4Q65OVXY.js} +6 -6
- package/dist/{selftest-BTCZLCTL.js → selftest-K5XZKVTE.js} +7 -7
- package/dist/{smoke-suite-GHKXMKIR.js → smoke-suite-J3LL3JZJ.js} +6 -6
- package/dist/{submissions-KUXLKCSQ.js → submissions-GMBUP4LV.js} +2 -2
- package/dist/{svelte-codemods-YTCTLK7G.js → svelte-codemods-JTMX4BSX.js} +4 -4
- package/dist/{sync-configs-KSSQZU5L.js → sync-configs-5DXSL64Q.js} +4 -4
- package/dist/{types-DnEL3NRn.d.ts → types-OIQZ5hWN.d.ts} +1 -1
- package/dist/{upgrade-UDAPY3SN.js → upgrade-HCR25P25.js} +5 -5
- package/dist/{websites-JXG3WWAU.js → websites-VITQ3WXN.js} +6 -2
- package/dist/{write-audits-to-airtable-7D3LV4KP.js → write-audits-to-airtable-3SOXLKJK.js} +4 -4
- package/package.json +1 -1
- package/dist/airtable-ZDHLKK5K.js +0 -9
- package/dist/chunk-2BHSGLWR.js.map +0 -1
- package/dist/chunk-KRDLHAKK.js +0 -17
- package/dist/chunk-KRDLHAKK.js.map +0 -1
- package/dist/chunk-UH5YPYS4.js.map +0 -1
- package/dist/chunk-VXXF4Y25.js.map +0 -1
- package/dist/chunk-YRM6EED7.js.map +0 -1
- package/dist/digest-CLFUWW62.js.map +0 -1
- package/dist/report-CLF2753O.js.map +0 -1
- /package/dist/{airtable-ZDHLKK5K.js.map → airtable-5ZPU3MT4.js.map} +0 -0
- /package/dist/{announce-7QHQGJPV.js.map → announce-4H64L2Z6.js.map} +0 -0
- /package/dist/{blux-EATHOHKK.js.map → blux-ZGDSHI3U.js.map} +0 -0
- /package/dist/{bump-deps-ZREAMK7M.js.map → bump-deps-2OL6PGTM.js.map} +0 -0
- /package/dist/{chunk-OIDDJLS7.js.map → chunk-2WN4SNBQ.js.map} +0 -0
- /package/dist/{chunk-HVOOCK6L.js.map → chunk-4DHQCN5H.js.map} +0 -0
- /package/dist/{chunk-5E4L7WKE.js.map → chunk-5P2HVNLZ.js.map} +0 -0
- /package/dist/{chunk-ZXRRKKMR.js.map → chunk-6V7565O2.js.map} +0 -0
- /package/dist/{chunk-B5ZUQZFL.js.map → chunk-ICXV25N7.js.map} +0 -0
- /package/dist/{chunk-4PW6ISX7.js.map → chunk-KV7DIRRO.js.map} +0 -0
- /package/dist/{chunk-ODC4PXZL.js.map → chunk-MQUOUVQP.js.map} +0 -0
- /package/dist/{chunk-LMCO7CYQ.js.map → chunk-OQFOMKBP.js.map} +0 -0
- /package/dist/{chunk-FFI7P74K.js.map → chunk-PBCGXXG6.js.map} +0 -0
- /package/dist/{chunk-T4FQ6TZT.js.map → chunk-QRDB4NLB.js.map} +0 -0
- /package/dist/{chunk-K5JZ5KLO.js.map → chunk-R4AB6GCT.js.map} +0 -0
- /package/dist/{chunk-FX7RAMGC.js.map → chunk-R6FPFMOS.js.map} +0 -0
- /package/dist/{chunk-MGJ72BA4.js.map → chunk-RJB3QUSP.js.map} +0 -0
- /package/dist/{chunk-ATJ6ZA2V.js.map → chunk-SDUJIANJ.js.map} +0 -0
- /package/dist/{chunk-7ADQL5Y3.js.map → chunk-THEFTZ7F.js.map} +0 -0
- /package/dist/{chunk-NT4WIP7I.js.map → chunk-UYCAF77E.js.map} +0 -0
- /package/dist/{chunk-YAMRVXOD.js.map → chunk-VRWUL4TR.js.map} +0 -0
- /package/dist/{chunk-4NAOR44V.js.map → chunk-ZMIPG6TR.js.map} +0 -0
- /package/dist/{convert-to-pnpm-UT2R6EOW.js.map → convert-to-pnpm-KCXOYOHW.js.map} +0 -0
- /package/dist/{ensure-site-LLPOXLWP.js.map → ensure-site-HYAIVZY2.js.map} +0 -0
- /package/dist/{forms-notify-target-PN6P3WPW.js.map → forms-notify-target-HSL4ZMT5.js.map} +0 -0
- /package/dist/{github-signals-HTH7474O.js.map → github-signals-I7XACMCE.js.map} +0 -0
- /package/dist/{header-image-WMHZFU7B.js.map → header-image-ADYPTGVY.js.map} +0 -0
- /package/dist/{health-endpoint-4E53ZMLE.js.map → health-endpoint-JNCUILHO.js.map} +0 -0
- /package/dist/{init-3ZJ7Z5NG.js.map → init-FLGLJCMJ.js.map} +0 -0
- /package/dist/{launch-FRCDOB56.js.map → launch-AVKH2JVF.js.map} +0 -0
- /package/dist/{lighthouse-airtable-I46QCEIS.js.map → lighthouse-airtable-CHANWVO6.js.map} +0 -0
- /package/dist/{onboard-45NPDMMW.js.map → onboard-HF4FTJDE.js.map} +0 -0
- /package/dist/{orchestrate-34JYNIXT.js.map → orchestrate-5FMNYJEA.js.map} +0 -0
- /package/dist/{preflight-WVD32COX.js.map → preflight-HTWIX3MN.js.map} +0 -0
- /package/dist/{protection-audit-2UR45SOC.js.map → protection-audit-64PLLBLI.js.map} +0 -0
- /package/dist/{renovate-dispatch-LOOCNZTW.js.map → renovate-dispatch-HBZG2UEU.js.map} +0 -0
- /package/dist/{self-updating-5TJ2NB26.js.map → self-updating-4Q65OVXY.js.map} +0 -0
- /package/dist/{selftest-BTCZLCTL.js.map → selftest-K5XZKVTE.js.map} +0 -0
- /package/dist/{smoke-suite-GHKXMKIR.js.map → smoke-suite-J3LL3JZJ.js.map} +0 -0
- /package/dist/{submissions-KUXLKCSQ.js.map → submissions-GMBUP4LV.js.map} +0 -0
- /package/dist/{svelte-codemods-YTCTLK7G.js.map → svelte-codemods-JTMX4BSX.js.map} +0 -0
- /package/dist/{sync-configs-KSSQZU5L.js.map → sync-configs-5DXSL64Q.js.map} +0 -0
- /package/dist/{upgrade-UDAPY3SN.js.map → upgrade-HCR25P25.js.map} +0 -0
- /package/dist/{websites-JXG3WWAU.js.map → websites-VITQ3WXN.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-7D3LV4KP.js.map → write-audits-to-airtable-3SOXLKJK.js.map} +0 -0
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/recipes/index.ts","../src/dashboard/relative-time.ts","../src/dashboard/favicon.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"],"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 \"init\",\n];\n\nexport function isRecipeName(value: string): value is RecipeName {\n return (ALL_RECIPE_NAMES as string[]).includes(value);\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","// 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 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\";\n\n/** Status options the editor offers (the code Status union; rare Airtable values\n * like \"legacy\" are set directly in Airtable, not from the dashboard). */\nexport const SITE_STATUS_OPTIONS = [\n \"in development\",\n \"launch period\",\n \"maintenance\",\n \"hosting\",\n \"probably not our problem\",\n \"deprecated\",\n] as const;\nexport const FREQ_OPTIONS = [\"None\", \"Monthly\", \"Quarterly\", \"Yearly\"] as const;\n\ntype FieldKind = \"text\" | \"email\" | \"emails\" | \"enum\" | \"gitrepo\";\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};\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\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): string | 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 \"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: string) => Promise<void>;\n};\n\nexport type SiteDetailResult =\n | { status: \"updated\"; 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 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 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 { 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 { SITE_STATUS_OPTIONS, FREQ_OPTIONS } 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\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 const preview = r.renderedHtmlAttachment\n ? `<a href=\"${escapeHtml(safeUrl(r.renderedHtmlAttachment.url))}\" 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} ${approveButton(r, blocked)}</div><div class=\"pending-info\">${recipientsLine(site)} ${sendLine}</div>${checklistBlock(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(safeUrl(r.renderedHtmlAttachment.url))}\">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 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>`;\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/** 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 selectRow(\"Status\", \"status\", SITE_STATUS_OPTIONS, site.status, 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 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): 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 <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 document.querySelectorAll(\"button.approve\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.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 b.textContent = \"Approved\";\n // Terminal success: darker green, and it stays disabled (approving twice is\n // a no-op server-side, but the button should not invite the second click).\n b.classList.add(\"is-approved\");\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 b.textContent = 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 if (data && Array.isArray(data.blockers)) b.title = data.blockers.join(\"\\\\n\");\n b.disabled = false;\n }\n } catch {\n // Network rejection (offline, DNS, abort): mirror the !res.ok path so\n // the button doesn't sit permanently disabled reading \"Approve\".\n b.textContent = \"Failed\";\n b.disabled = false;\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 const form = b.nextElementSibling;\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 button too —\n // it stays server-gated disabled, but the label should stop implying\n // \"waiting on you\" once an override has gone through.\n const approveBtn = document.querySelector(\n 'button.approve[data-report-id=\"' + b.dataset.reportId + '\"]',\n );\n if (approveBtn) approveBtn.textContent = \"Overridden\";\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 function saveDetail(el) {\n const span = document.querySelector('.detail-saved[data-for=\"' + el.dataset.detailField + '\"]');\n fetch(el.dataset.detailsUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ field: el.dataset.detailField, value: el.value }),\n })\n .then((r) => {\n if (span) span.textContent = r.ok ? \" ✓\" : \" ✗\";\n })\n .catch(() => {\n if (span) span.textContent = \" ✗\";\n });\n }\n document.querySelectorAll(\"select[data-detail-field]\").forEach((s) => {\n s.addEventListener(\"change\", () => saveDetail(s));\n });\n document.querySelectorAll(\"input[data-detail-field], textarea[data-detail-field]\").forEach((i) => {\n i.addEventListener(\"blur\", () => {\n if (i.value !== i.defaultValue) saveDetail(i);\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} 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 \"launch period\" 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 \"launch period\" site\n // is PRE-LIVE prep, not a live site (Status flips to \"maintenance\" 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 \"launch period\" reaches\n // the cockpit — isDashboardVisible = {maintenance, launch period}.)\n if (site.status === \"launch period\") {\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 === \"maintenance\" && 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 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 \"launch period\" 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. `status` is the raw cell value. */\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 ];\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 const archived: OffFleetSiteEntry[] = websites\n .filter((w) => isArchivedStatus(w.status))\n .map((w) => ({ name: w.name, slug: siteSlug(w.name), status: w.status as string }))\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) => ({ name: w.name, slug: siteSlug(w.name), status: w.status as string }));\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 =\n site.status === \"launch period\" ? 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 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 // Map a raw GitHub step name to a coarse human phase. Order matters (the audit step\n // name also contains 'browser'; 'playwright install' also contains 'install').\n function rfPhase(step){\n if (!step) return '';\n var s = step.toLowerCase();\n if (s.indexOf('audit') !== -1 || s.indexOf('lighthouse') !== -1) return 'auditing the fleet…';\n if (s.indexOf('build') !== -1) return 'building…';\n if (s.indexOf('playwright') !== -1 || s.indexOf('browser') !== -1) return 'installing browsers…';\n if (s.indexOf('install') !== -1 || s.indexOf('depend') !== -1) return 'installing dependencies…';\n if (s.indexOf('set up') !== -1 || s.indexOf('checkout') !== -1) return 'setting up…';\n return step;\n }\n // Safe to build raw HTML: workflow/state/step are server-fixed (enums + GitHub step\n // names) and url is GitHub's own html_url for our central repo — none are\n // user-supplied. Don't interpolate untrusted fields here without escaping.\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 = 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 + ' ' + label + ' — ' + w.state.replace('_',' ');\n if (!done && !isFailed){\n var phase = rfPhase(w.step);\n var eta = rfEta(label);\n var detail = [];\n if (phase) detail.push(phase);\n detail.push(elapsed + (eta ? ' / ' + eta : ''));\n var link = w.url ? ' · <a href=\"'+w.url+'\" target=\"_blank\" rel=\"noopener\">view run ↗</a>' : '';\n line += '<div class=\"rf-sub\">' + detail.join(' · ') + link + '</div>';\n } else if (isFailed && w.url){\n line += ' <a href=\"'+w.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(model: CockpitModel): 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 <h1>Reddoor fleet cockpit</h1>\n <div class=\"meta\">${total} site${total === 1 ? \"\" : \"s\"} on the Reddoor stack.</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"],"mappings":"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","names":["chips","card","DASH","chips","STYLES"]}
|
|
1
|
+
{"version":3,"sources":["../src/recipes/index.ts","../src/dashboard/relative-time.ts","../src/dashboard/favicon.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"],"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","/** 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","// 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 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\";\n\n/** Status options the editor offers (the code Status union; rare Airtable values\n * like \"legacy\" are set directly in Airtable, not from the dashboard). */\nexport const SITE_STATUS_OPTIONS = [\n \"in development\",\n \"launch period\",\n \"maintenance\",\n \"hosting\",\n \"probably not our problem\",\n \"deprecated\",\n] as const;\nexport const FREQ_OPTIONS = [\"None\", \"Monthly\", \"Quarterly\", \"Yearly\"] as const;\n\ntype FieldKind = \"text\" | \"email\" | \"emails\" | \"enum\" | \"gitrepo\";\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};\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\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): string | 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 \"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: string) => Promise<void>;\n};\n\nexport type SiteDetailResult =\n | { status: \"updated\"; 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 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 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 { 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 { SITE_STATUS_OPTIONS, FREQ_OPTIONS } 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\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 const preview = r.renderedHtmlAttachment\n ? `<a href=\"${escapeHtml(safeUrl(r.renderedHtmlAttachment.url))}\" 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} ${approveButton(r, blocked)}</div><div class=\"pending-info\">${recipientsLine(site)} ${sendLine}</div>${checklistBlock(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(safeUrl(r.renderedHtmlAttachment.url))}\">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 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>`;\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/** 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 selectRow(\"Status\", \"status\", SITE_STATUS_OPTIONS, site.status, 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 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): 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 <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 document.querySelectorAll(\"button.approve\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.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 b.textContent = \"Approved\";\n // Terminal success: darker green, and it stays disabled (approving twice is\n // a no-op server-side, but the button should not invite the second click).\n b.classList.add(\"is-approved\");\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 b.textContent = 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 if (data && Array.isArray(data.blockers)) b.title = data.blockers.join(\"\\\\n\");\n b.disabled = false;\n }\n } catch {\n // Network rejection (offline, DNS, abort): mirror the !res.ok path so\n // the button doesn't sit permanently disabled reading \"Approve\".\n b.textContent = \"Failed\";\n b.disabled = false;\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 const form = b.nextElementSibling;\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 button too —\n // it stays server-gated disabled, but the label should stop implying\n // \"waiting on you\" once an override has gone through.\n const approveBtn = document.querySelector(\n 'button.approve[data-report-id=\"' + b.dataset.reportId + '\"]',\n );\n if (approveBtn) approveBtn.textContent = \"Overridden\";\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 function saveDetail(el) {\n const span = document.querySelector('.detail-saved[data-for=\"' + el.dataset.detailField + '\"]');\n fetch(el.dataset.detailsUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ field: el.dataset.detailField, value: el.value }),\n })\n .then((r) => {\n if (span) span.textContent = r.ok ? \" ✓\" : \" ✗\";\n })\n .catch(() => {\n if (span) span.textContent = \" ✗\";\n });\n }\n document.querySelectorAll(\"select[data-detail-field]\").forEach((s) => {\n s.addEventListener(\"change\", () => saveDetail(s));\n });\n document.querySelectorAll(\"input[data-detail-field], textarea[data-detail-field]\").forEach((i) => {\n i.addEventListener(\"blur\", () => {\n if (i.value !== i.defaultValue) saveDetail(i);\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} 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 \"launch period\" 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 \"launch period\" site\n // is PRE-LIVE prep, not a live site (Status flips to \"maintenance\" 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 \"launch period\" reaches\n // the cockpit — isDashboardVisible = {maintenance, launch period}.)\n if (site.status === \"launch period\") {\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 === \"maintenance\" && 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 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 \"launch period\" 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. `status` is the raw cell value. */\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 const archived: OffFleetSiteEntry[] = websites\n .filter((w) => isArchivedStatus(w.status))\n .map((w) => ({ name: w.name, slug: siteSlug(w.name), status: w.status as string }))\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) => ({ name: w.name, slug: siteSlug(w.name), status: w.status as string }));\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 =\n site.status === \"launch period\" ? 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 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 // Map a raw GitHub step name to a coarse human phase. Order matters (the audit step\n // name also contains 'browser'; 'playwright install' also contains 'install').\n function rfPhase(step){\n if (!step) return '';\n var s = step.toLowerCase();\n if (s.indexOf('audit') !== -1 || s.indexOf('lighthouse') !== -1) return 'auditing the fleet…';\n if (s.indexOf('build') !== -1) return 'building…';\n if (s.indexOf('playwright') !== -1 || s.indexOf('browser') !== -1) return 'installing browsers…';\n if (s.indexOf('install') !== -1 || s.indexOf('depend') !== -1) return 'installing dependencies…';\n if (s.indexOf('set up') !== -1 || s.indexOf('checkout') !== -1) return 'setting up…';\n return step;\n }\n // Safe to build raw HTML: workflow/state/step are server-fixed (enums + GitHub step\n // names) and url is GitHub's own html_url for our central repo — none are\n // user-supplied. Don't interpolate untrusted fields here without escaping.\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 = 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 + ' ' + label + ' — ' + w.state.replace('_',' ');\n if (!done && !isFailed){\n var phase = rfPhase(w.step);\n var eta = rfEta(label);\n var detail = [];\n if (phase) detail.push(phase);\n detail.push(elapsed + (eta ? ' / ' + eta : ''));\n var link = w.url ? ' · <a href=\"'+w.url+'\" target=\"_blank\" rel=\"noopener\">view run ↗</a>' : '';\n line += '<div class=\"rf-sub\">' + detail.join(' · ') + link + '</div>';\n } else if (isFailed && w.url){\n line += ' <a href=\"'+w.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(model: CockpitModel): 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 <h1>Reddoor fleet cockpit</h1>\n <div class=\"meta\">${total} site${total === 1 ? \"\" : \"s\"} on the Reddoor stack.</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"],"mappings":"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","names":["chips","card","DASH","chips","STYLES"]}
|