@reddoorla/maintenance 0.63.0 → 0.65.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-GGZJUGQB.js +9 -0
- package/dist/{announce-QEON4O7E.js → announce-GFLKAPTT.js} +4 -4
- package/dist/{bump-deps-2U6GIHM5.js → bump-deps-3QQHYDKJ.js} +2 -2
- package/dist/{chunk-BMDBOPN3.js → chunk-4A5PYCGB.js} +2 -2
- package/dist/{chunk-OSRFDGQM.js → chunk-4GACMVNE.js} +2 -1
- package/dist/{chunk-BLD6AYJO.js → chunk-7RGQ4KLE.js} +5 -2
- package/dist/chunk-7RGQ4KLE.js.map +1 -0
- package/dist/{chunk-YYUY2OVP.js → chunk-CSTLFSGM.js} +2 -2
- package/dist/{chunk-SAOCVNX7.js → chunk-FH7WQHD7.js} +2 -2
- package/dist/{chunk-KTPPKO2C.js → chunk-FX3QBSUF.js} +2 -2
- package/dist/{chunk-L5F664ZA.js → chunk-G2YRXV26.js} +2 -2
- package/dist/chunk-GASBX52O.js +12 -0
- package/dist/chunk-GASBX52O.js.map +1 -0
- package/dist/{chunk-4REUHZAW.js → chunk-LM42CATB.js} +3 -3
- package/dist/{chunk-NPJFHBAX.js → chunk-M5LX2VSR.js} +2 -2
- package/dist/{chunk-ZEX2T6JH.js → chunk-MQM5YP4J.js} +2 -2
- package/dist/{chunk-P7VJM46I.js → chunk-OYN6H4CX.js} +3 -3
- package/dist/{chunk-6ZKM7OIJ.js → chunk-PTPTVVY7.js} +6 -4
- package/dist/chunk-PTPTVVY7.js.map +1 -0
- package/dist/{chunk-J37CZORZ.js → chunk-QSL3EJGI.js} +3 -3
- package/dist/{chunk-SS7BKOIX.js → chunk-TC7Z5LG7.js} +237 -1
- package/dist/chunk-TC7Z5LG7.js.map +1 -0
- package/dist/{chunk-3HM4S422.js → chunk-UATHDNWP.js} +7 -14
- package/dist/chunk-UATHDNWP.js.map +1 -0
- package/dist/cli/bin.js +16 -16
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +10 -8
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/{client-GTMJTXBK.js → client-C2GOZD2D.js} +2 -2
- package/dist/{convert-to-pnpm-JG3767ZQ.js → convert-to-pnpm-ZO673UFB.js} +2 -2
- package/dist/{db-Y6BVTNH7.js → db-YBLQR2YY.js} +2 -2
- package/dist/{digest-ABJROO4H.js → digest-R3ROSGLP.js} +3 -3
- package/dist/{github-signals-XTJLKOXJ.js → github-signals-N5JGWHW2.js} +9 -9
- package/dist/index.d.ts +39 -3
- package/dist/index.js +86 -40
- package/dist/index.js.map +1 -1
- package/dist/{init-DMAIKUQE.js → init-FOHNISF2.js} +6 -4
- package/dist/{init-DMAIKUQE.js.map → init-FOHNISF2.js.map} +1 -1
- package/dist/{launch-IBP23PNY.js → launch-TM3IHSSU.js} +9 -8
- package/dist/{launch-IBP23PNY.js.map → launch-TM3IHSSU.js.map} +1 -1
- package/dist/{lighthouse-airtable-HTEW6BBT.js → lighthouse-airtable-5FHELRCA.js} +3 -3
- package/dist/{onboard-TTIC3YZI.js → onboard-K2EL4MSG.js} +2 -2
- package/dist/{orchestrate-CYYZ737H.js → orchestrate-OX3Z4DUI.js} +5 -5
- package/dist/{renovate-dispatch-QDDMTATD.js → renovate-dispatch-H25CQ36P.js} +3 -3
- package/dist/{report-QNE7HFOZ.js → report-UEYKO6WH.js} +5 -5
- package/dist/{self-updating-2RLUP4AA.js → self-updating-YXOGQWH5.js} +5 -4
- package/dist/{self-updating-2RLUP4AA.js.map → self-updating-YXOGQWH5.js.map} +1 -1
- package/dist/{selftest-5ELLRH6Q.js → selftest-VE7CSGWE.js} +7 -7
- package/dist/{svelte-codemods-BPLKSVIA.js → svelte-codemods-745QM25J.js} +2 -2
- package/dist/{sync-configs-VFRV3TJ4.js → sync-configs-NYC3Z625.js} +2 -2
- package/dist/{upgrade-WCFD2TRN.js → upgrade-UBPPJ6HN.js} +2 -2
- package/dist/{websites-HM2NMZMR.js → websites-CSVLSEVL.js} +2 -2
- package/dist/{write-audits-to-airtable-QF26PCAR.js → write-audits-to-airtable-JCV7FYAW.js} +4 -4
- package/package.json +1 -1
- package/dist/airtable-HHAWHBNM.js +0 -9
- package/dist/chunk-3HM4S422.js.map +0 -1
- package/dist/chunk-6ZKM7OIJ.js.map +0 -1
- package/dist/chunk-BLD6AYJO.js.map +0 -1
- package/dist/chunk-SS7BKOIX.js.map +0 -1
- /package/dist/{airtable-HHAWHBNM.js.map → airtable-GGZJUGQB.js.map} +0 -0
- /package/dist/{announce-QEON4O7E.js.map → announce-GFLKAPTT.js.map} +0 -0
- /package/dist/{bump-deps-2U6GIHM5.js.map → bump-deps-3QQHYDKJ.js.map} +0 -0
- /package/dist/{chunk-BMDBOPN3.js.map → chunk-4A5PYCGB.js.map} +0 -0
- /package/dist/{chunk-OSRFDGQM.js.map → chunk-4GACMVNE.js.map} +0 -0
- /package/dist/{chunk-YYUY2OVP.js.map → chunk-CSTLFSGM.js.map} +0 -0
- /package/dist/{chunk-SAOCVNX7.js.map → chunk-FH7WQHD7.js.map} +0 -0
- /package/dist/{chunk-KTPPKO2C.js.map → chunk-FX3QBSUF.js.map} +0 -0
- /package/dist/{chunk-L5F664ZA.js.map → chunk-G2YRXV26.js.map} +0 -0
- /package/dist/{chunk-4REUHZAW.js.map → chunk-LM42CATB.js.map} +0 -0
- /package/dist/{chunk-NPJFHBAX.js.map → chunk-M5LX2VSR.js.map} +0 -0
- /package/dist/{chunk-ZEX2T6JH.js.map → chunk-MQM5YP4J.js.map} +0 -0
- /package/dist/{chunk-P7VJM46I.js.map → chunk-OYN6H4CX.js.map} +0 -0
- /package/dist/{chunk-J37CZORZ.js.map → chunk-QSL3EJGI.js.map} +0 -0
- /package/dist/{client-GTMJTXBK.js.map → client-C2GOZD2D.js.map} +0 -0
- /package/dist/{convert-to-pnpm-JG3767ZQ.js.map → convert-to-pnpm-ZO673UFB.js.map} +0 -0
- /package/dist/{db-Y6BVTNH7.js.map → db-YBLQR2YY.js.map} +0 -0
- /package/dist/{digest-ABJROO4H.js.map → digest-R3ROSGLP.js.map} +0 -0
- /package/dist/{github-signals-XTJLKOXJ.js.map → github-signals-N5JGWHW2.js.map} +0 -0
- /package/dist/{lighthouse-airtable-HTEW6BBT.js.map → lighthouse-airtable-5FHELRCA.js.map} +0 -0
- /package/dist/{onboard-TTIC3YZI.js.map → onboard-K2EL4MSG.js.map} +0 -0
- /package/dist/{orchestrate-CYYZ737H.js.map → orchestrate-OX3Z4DUI.js.map} +0 -0
- /package/dist/{renovate-dispatch-QDDMTATD.js.map → renovate-dispatch-H25CQ36P.js.map} +0 -0
- /package/dist/{report-QNE7HFOZ.js.map → report-UEYKO6WH.js.map} +0 -0
- /package/dist/{selftest-5ELLRH6Q.js.map → selftest-VE7CSGWE.js.map} +0 -0
- /package/dist/{svelte-codemods-BPLKSVIA.js.map → svelte-codemods-745QM25J.js.map} +0 -0
- /package/dist/{sync-configs-VFRV3TJ4.js.map → sync-configs-NYC3Z625.js.map} +0 -0
- /package/dist/{upgrade-WCFD2TRN.js.map → upgrade-UBPPJ6HN.js.map} +0 -0
- /package/dist/{websites-HM2NMZMR.js.map → websites-CSVLSEVL.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-QF26PCAR.js.map → write-audits-to-airtable-JCV7FYAW.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 {\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 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 \"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\nexport function renderSubmissionRow(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\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 const details = [\n kv(\"Phone\", s.phone),\n messageBlock,\n sourceLink,\n kv(\"UTM\", s.utm),\n extraFieldsList(s.extraFields),\n kv(\"Notify\", s.notifyStatus),\n kv(\"Resend ID\", s.resendMessageId),\n kv(\"Submission #\", s.submissionId),\n ].join(\"\");\n\n return `<li class=\"subm-item\">\n <details>\n <summary class=\"subm-head\"><strong>${type}</strong> · ${who} <span class=\"muted\">${email}</span> <span class=\"pill subm-${status}\">${status}</span> <span class=\"muted\">${when}</span></summary>\n <div class=\"subm-detail\">${details}</div>\n </details>\n <div class=\"subm-actions\">${btn(\"Read\", \"read\")}${btn(\"Archive\", \"archived\")}${btn(\"Spam\", \"spam\")}</div>\n </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; 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.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.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@]+$/;\nconst 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 { 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, isChecklistComplete } from \"../reports/checklist.js\";\nimport {\n renderSubmissionRow,\n SUBMISSION_STYLES,\n SUBMISSION_STATUS_SCRIPT,\n} from \"./submission-view.js\";\nimport { SITE_STATUS_OPTIONS, FREQ_OPTIONS } from \"./site-details.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 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>${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/** The interactive operator-checklist for one pending report: one checkbox per\n * `checklistFor(reportType)` item, current state from `report.checklist`, each\n * carrying the report record id + the Airtable field name so the client can POST\n * to /api/reports/:id/checklist and re-gate the Approve button. Launch/Announcement\n * reports (empty checklist) render NOTHING — they are never gated. */\nfunction checklistBlock(r: ReportRow): string {\n const items = checklistFor(r.reportType);\n if (items.length === 0) return \"\";\n const rid = escapeHtml(r.id);\n const url = `/api/reports/${encodeURIComponent(r.id)}/checklist`;\n const boxes = items\n .map((item) => {\n const checked = r.checklist[item.field] === true ? \" checked\" : \"\";\n const ev = r.autoEvidence?.[item.field];\n // Auto-tick provenance beside the box: green when the signal proved it (box also `checked`),\n // amber when a signal ran but isn't green (box left unticked, reason shown). No evidence →\n // a plain manual checkbox, exactly as before.\n const badge = ev\n ? ev.result === \"pass\"\n ? ` <span class=\"auto-badge auto-pass\" title=\"${escapeHtml(ev.note)}\">auto ✓</span>`\n : ` <span class=\"auto-badge auto-amber\" title=\"${escapeHtml(ev.note)}\">auto: ${escapeHtml(ev.note)}</span>`\n : \"\";\n return `<label class=\"check-item\"><input type=\"checkbox\" class=\"checklist-checkbox\" data-checklist-report-id=\"${rid}\" data-field=\"${escapeHtml(item.field)}\" data-checklist-url=\"${escapeHtml(url)}\"${checked} /> ${escapeHtml(item.label)}${badge}</label>`;\n })\n .join(\"\");\n return `<div class=\"checklist\" data-checklist-for=\"${rid}\">${boxes}</div>`;\n}\n\n/** The Approve button for a pending report. Server-renders `disabled` when the\n * report's checklist is incomplete (the convenience gate — approve.ts + orchestrate.ts\n * are the hard backstops). Launch/Announcement have an empty checklist → never gated. */\nfunction approveButton(r: ReportRow): string {\n const disabled = isChecklistComplete(r) ? \"\" : \" disabled\";\n return `<button class=\"approve\" data-report-id=\"${escapeHtml(r.id)}\" data-approve-url=\"${escapeHtml(`/api/reports/${encodeURIComponent(r.id)}/approve`)}\"${disabled}>Approve</button>`;\n}\n\nfunction pendingRow(r: ReportRow): string {\n const type = escapeHtml(r.reportType);\n const period = r.period ? escapeHtml(r.period) : \"—\";\n return `<li><div class=\"pending-head\"><strong>${type}</strong> <span class=\"muted\">${period}</span> ${approveButton(r)}</div>${checklistBlock(r)}</li>`;\n}\n\nfunction pendingSection(reports: ReportRow[]): 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(pendingRow).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): 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 const action = isPendingApproval(r) ? approveButton(r) : \"\";\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 if (submissions.length === 0) return \"\";\n const recent = [...submissions]\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 submissions.length > recent.length\n ? `<span class=\"muted\"> — showing ${recent.length} of ${submissions.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 (${submissions.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, and delivered counted from the submissions loaded for this page within the\n * window. Omitted when there's nothing to show. `delivered` undercounts only if the site\n * exceeds the 200-row submissions fetch within the window (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 delivered = submissions.filter(\n (s) => s.submittedAt !== null && Date.parse(s.submittedAt) >= sinceMs,\n ).length;\n const t = totals ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };\n if (delivered === 0 && t.honeypot === 0 && t.tooFast === 0 && t.markedSpam === 0) 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(\"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/** 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; }\nbutton.approve:disabled { opacity: 0.6; cursor: default; }\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; }\n.checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }\n.check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }\n.check-item input { margin: 0; }\n.auto-badge { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; }\n.auto-pass { background: #e6f4ea; color: #137333; }\n.auto-amber { background: #fef7e0; color: #b06000; }\n.pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }\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; }\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): 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(reportRow).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 ${pendingSection(reports)}\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 try {\n const res = await fetch(b.dataset.approveUrl, { method: \"POST\" });\n b.textContent = res.ok ? \"Approved\" : \"Failed\";\n if (!res.ok) b.disabled = false;\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 }\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 // Checklist gate: ticking a box POSTs the one field; the response { complete }\n // decides whether THIS report's Approve button is enabled. Scoped per report by\n // matching the checkbox's report id to the Approve button's id, so multiple\n // pending reports on one page never cross-toggle. On failure the checkbox reverts.\n document.querySelectorAll(\"input.checklist-checkbox\").forEach((cb) => {\n cb.addEventListener(\"change\", async () => {\n const reportId = cb.dataset.checklistReportId;\n const approveBtn = document.querySelector(\n 'button.approve[data-report-id=\"' + (window.CSS && CSS.escape ? CSS.escape(reportId) : reportId) + '\"]',\n );\n cb.disabled = true;\n try {\n const res = await fetch(cb.dataset.checklistUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ reportId, field: cb.dataset.field, value: cb.checked }),\n });\n if (!res.ok) throw new Error(\"bad status\");\n const data = await res.json();\n if (approveBtn) approveBtn.disabled = !data.complete;\n } catch {\n // Revert the optimistic flip so the box reflects the (unchanged) server state.\n cb.checked = !cb.checked;\n } finally {\n cb.disabled = false;\n }\n });\n });\n </script>\n</body>\n</html>`;\n}\n","// src/dashboard/fleet-cockpit.ts\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { siteSlug, isDashboardVisible } 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 type { FleetEvent, FleetEventType } from \"../db/fleet-events.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\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\";\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, mirroring how a sub-floor Lighthouse\n * score lands a site there. Otherwise 🟡 watch when a Lighthouse category sits in\n * [75,85) or the last commit to `main` is older than 30 days (a NULL commit is NOT\n * stale — it's a missing GitHub signal, not a regression). Else 🟢 healthy.\n *\n * `watchReasons` are the human labels for the card; `watchSignals` are the STRUCTURED\n * filter tags (\"lighthouse\" / \"stale\") the client filter keys off — derived here so\n * the renderer never has to regex-sniff a human string whose wording may change.\n */\nexport function assignTier(\n site: WebsiteRow,\n items: AttentionItem[],\n now: Date,\n): { tier: Tier; watchReasons: string[]; watchSignals: string[] } {\n if (items.length > 0) return { tier: \"attention\", watchReasons: [], watchSignals: [] };\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 { tier: \"attention\", watchReasons: [], watchSignals: [] };\n\n const watchReasons: string[] = [];\n const signals = new Set<string>();\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 watchReasons.push(`${cat.label} ${score}`);\n signals.add(\"lighthouse\");\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 watchReasons.push(`last commit ${relativeTimeFromNow(site.lastCommitAt, now)}`);\n signals.add(\"stale\");\n }\n }\n // A live (maintenance) site still served from *.netlify.app never got a custom\n // domain — a launch-completeness gap worth surfacing. Only for maintenance: a\n // launch-period site on netlify.app is expected (not launched yet).\n if (site.status === \"maintenance\" && isNetlifyAppUrl(site.url)) {\n watchReasons.push(\"on *.netlify.app (no custom domain)\");\n signals.add(\"no-domain\");\n }\n return watchReasons.length > 0\n ? { tier: \"watch\", watchReasons, watchSignals: [...signals] }\n : { tier: \"healthy\", watchReasons: [], watchSignals: [] };\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 /** Structured watch tags (\"lighthouse\" / \"stale\") for the client filter. */\n watchSignals: string[];\n /** Count of NEW submissions for this site (optional; populated by buildCockpitModel). */\n newSubmissions?: 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 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};\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\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};\n\nexport type NeedsYouGroup = \"broken\" | \"approval\" | \"slipping\";\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, approval: 1, slipping: 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 vuln counts as broken only once Renovate's\n * auto-fix is exhausted (`item.autoFixExhausted`); while the fleet is still retrying\n * it, the site stays off the feed and off the verdict. Order: broken → approval →\n * slipping; 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 approval: boolean;\n slipping: 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 approval: false,\n slipping: false,\n };\n bySlug.set(slug, a);\n }\n return a;\n };\n\n for (const card of model.cards) {\n if (card.tier === \"attention\") {\n for (const item of card.items) {\n if (item.kind === \"vuln\" && item.autoFixExhausted !== true) continue; // the gate\n const a = get(card.site.name);\n a.reasons.push(item.title);\n a.broken = true;\n if (item.severity === \"critical\") a.hasCritical = 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.slipping = 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 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.approval ? \"approval\" : \"slipping\";\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 };\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): CockpitModel {\n const visible = websites.filter(isDashboardVisible);\n const sitesById = new Map<string, WebsiteRow>(visible.map((w) => [w.id, w]));\n\n // Per-site NEW-submission counts, keyed by Websites record id. Used for the\n // per-card badge below; the strip resolves entries against ALL sites.\n const subCountBySite = new Map<string, number>();\n for (const sub of newSubmissions) {\n subCountBySite.set(sub.siteId, (subCountBySite.get(sub.siteId) ?? 0) + 1);\n }\n\n const rawItems: AttentionItem[] = [\n ...collectVulnAlerts(visible, baseUrl),\n ...collectLighthouseAlerts(visible, baseUrl),\n ...collectDeliveryFailures(reports, sitesById, baseUrl),\n ...collectRenovateAlerts(visible, baseUrl, now),\n ...collectCiAlerts(visible, baseUrl, now),\n ...collectAnalyticsFailures(visible, baseUrl, now),\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 items = (bySite.get(site.name) ?? []).sort(\n (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity],\n );\n const { tier, watchReasons, watchSignals } = assignTier(site, items, now);\n return {\n site,\n tier,\n items,\n watchReasons,\n watchSignals,\n newSubmissions: subCountBySite.get(site.id) ?? 0,\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 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 };\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 };\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): 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 const display = outdated === null ? driftPart : `${driftPart} · ${outdated} outdated`;\n return `<span class=\"metric deps\">${escapeHtml(display)}</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)}\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 const n = c.newSubmissions ?? 0;\n return n > 0 ? `<span class=\"chip\">📥 ${n} new</span>` : \"\";\n}\n\nconst PILL_LABEL: Record<Tier, string> = { attention: \"failing\", watch: \"watch\", healthy: \"ok\" };\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 for (const reason of c.watchReasons)\n items.push(`<span class=\"chip\">${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 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.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.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@media (prefers-color-scheme: dark) { .verdict.ok { background:#10240f; color:#7fce85; } .verdict.warn { background:#2a0f0d; color:#ff8a80; } }\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.slipping { background:#f59e0b; }\n`;\n\n/** The glance verdict: \"✓ All clear\", or \"⚠ N sites need you\". N is the per-site\n * Needs-you feed length (NOT submissions). Houses the ↻ Audit button + live panel. */\nfunction verdictBar(model: CockpitModel, feedCount: number): string {\n const auditedIso = fleetLastAuditedAt(model.cards);\n const audited = auditedIso\n ? ` · fleet last audited ${escapeHtml(relativeTimeFromNow(auditedIso))}`\n : \"\";\n const sites = model.cards.length;\n const sitesWord = `${sites} site${sites === 1 ? \"\" : \"s\"}`;\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 if (feedCount === 0) {\n return `<div class=\"verdict ok\">\n <div class=\"verdict-line\">✓ All clear</div>\n <div class=\"verdict-meta\">${sitesWord} healthy${audited}</div>\n ${actions}\n </div>`;\n }\n const noun = feedCount === 1 ? \"site needs\" : \"sites need\";\n return `<div class=\"verdict warn\">\n <div class=\"verdict-line\">⚠ ${feedCount} ${noun} you</div>\n <div class=\"verdict-meta\">${sitesWord}${audited}</div>\n ${actions}\n </div>`;\n}\n\nconst NEEDS_YOU_GROUP_LABEL: Record<NeedsYouGroup, string> = {\n broken: \"Broken\",\n approval: \"Waiting on your yes\",\n slipping: \"Slipping\",\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\", \"approval\", \"slipping\"];\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};\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/** 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 if (subs.length === 0 && !hasSpam) 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 return `<details class=\"inbox\">\n <summary>📥 Submissions (${subs.length} new)${spamInSummary}</summary>\n ${rows}${subs.length > 0 ? more : \"\"}\n ${spamLine}\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.length)}\n ${renderNeedsYouFeed(feed)}\n ${renderFleetBrowsePanel(model)}\n ${renderRecentlyLane(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":["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 {\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 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 \"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\nexport function renderSubmissionRow(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\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 const details = [\n kv(\"Phone\", s.phone),\n messageBlock,\n sourceLink,\n kv(\"UTM\", s.utm),\n extraFieldsList(s.extraFields),\n kv(\"Notify\", s.notifyStatus),\n kv(\"Resend ID\", s.resendMessageId),\n kv(\"Submission #\", s.submissionId),\n ].join(\"\");\n\n return `<li class=\"subm-item\">\n <details>\n <summary class=\"subm-head\"><strong>${type}</strong> · ${who} <span class=\"muted\">${email}</span> <span class=\"pill subm-${status}\">${status}</span> <span class=\"muted\">${when}</span></summary>\n <div class=\"subm-detail\">${details}</div>\n </details>\n <div class=\"subm-actions\">${btn(\"Read\", \"read\")}${btn(\"Archive\", \"archived\")}${btn(\"Spam\", \"spam\")}</div>\n </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; 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.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.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@]+$/;\nconst 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 { 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, isChecklistComplete } from \"../reports/checklist.js\";\nimport {\n renderSubmissionRow,\n SUBMISSION_STYLES,\n SUBMISSION_STATUS_SCRIPT,\n} from \"./submission-view.js\";\nimport { SITE_STATUS_OPTIONS, FREQ_OPTIONS } from \"./site-details.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/** The interactive operator-checklist for one pending report: one checkbox per\n * `checklistFor(reportType)` item, current state from `report.checklist`, each\n * carrying the report record id + the Airtable field name so the client can POST\n * to /api/reports/:id/checklist and re-gate the Approve button. Launch/Announcement\n * reports (empty checklist) render NOTHING — they are never gated. */\nfunction checklistBlock(r: ReportRow): string {\n const items = checklistFor(r.reportType);\n if (items.length === 0) return \"\";\n const rid = escapeHtml(r.id);\n const url = `/api/reports/${encodeURIComponent(r.id)}/checklist`;\n const boxes = items\n .map((item) => {\n const checked = r.checklist[item.field] === true ? \" checked\" : \"\";\n const ev = r.autoEvidence?.[item.field];\n // Auto-tick provenance beside the box: green when the signal proved it (box also `checked`),\n // amber when a signal ran but isn't green (box left unticked, reason shown). No evidence →\n // a plain manual checkbox, exactly as before.\n const badge = ev\n ? ev.result === \"pass\"\n ? ` <span class=\"auto-badge auto-pass\" title=\"${escapeHtml(ev.note)}\">auto ✓</span>`\n : ` <span class=\"auto-badge auto-amber\" title=\"${escapeHtml(ev.note)}\">auto: ${escapeHtml(ev.note)}</span>`\n : \"\";\n return `<label class=\"check-item\"><input type=\"checkbox\" class=\"checklist-checkbox\" data-checklist-report-id=\"${rid}\" data-field=\"${escapeHtml(item.field)}\" data-checklist-url=\"${escapeHtml(url)}\"${checked} /> ${escapeHtml(item.label)}${badge}</label>`;\n })\n .join(\"\");\n return `<div class=\"checklist\" data-checklist-for=\"${rid}\">${boxes}</div>`;\n}\n\n/** The Approve button for a pending report. Server-renders `disabled` when the\n * report's checklist is incomplete (the convenience gate — approve.ts + orchestrate.ts\n * are the hard backstops). Launch/Announcement have an empty checklist → never gated. */\nfunction approveButton(r: ReportRow): string {\n const disabled = isChecklistComplete(r) ? \"\" : \" disabled\";\n return `<button class=\"approve\" data-report-id=\"${escapeHtml(r.id)}\" data-approve-url=\"${escapeHtml(`/api/reports/${encodeURIComponent(r.id)}/approve`)}\"${disabled}>Approve</button>`;\n}\n\nfunction pendingRow(r: ReportRow): string {\n const type = escapeHtml(r.reportType);\n const period = r.period ? escapeHtml(r.period) : \"—\";\n return `<li><div class=\"pending-head\"><strong>${type}</strong> <span class=\"muted\">${period}</span> ${approveButton(r)}</div>${checklistBlock(r)}</li>`;\n}\n\nfunction pendingSection(reports: ReportRow[]): 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(pendingRow).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): 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 const action = isPendingApproval(r) ? approveButton(r) : \"\";\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 if (submissions.length === 0) return \"\";\n const recent = [...submissions]\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 submissions.length > recent.length\n ? `<span class=\"muted\"> — showing ${recent.length} of ${submissions.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 (${submissions.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, and delivered counted from the submissions loaded for this page within the\n * window. Omitted when there's nothing to show. `delivered` undercounts only if the site\n * exceeds the 200-row submissions fetch within the window (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 delivered = submissions.filter(\n (s) => s.submittedAt !== null && Date.parse(s.submittedAt) >= sinceMs,\n ).length;\n const t = totals ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };\n if (delivered === 0 && t.honeypot === 0 && t.tooFast === 0 && t.markedSpam === 0) 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(\"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/** 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; }\nbutton.approve:disabled { opacity: 0.6; cursor: default; }\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; }\n.checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }\n.check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }\n.check-item input { margin: 0; }\n.auto-badge { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; }\n.auto-pass { background: #e6f4ea; color: #137333; }\n.auto-amber { background: #fef7e0; color: #b06000; }\n.pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }\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; }\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): 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(reportRow).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 ${pendingSection(reports)}\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 try {\n const res = await fetch(b.dataset.approveUrl, { method: \"POST\" });\n b.textContent = res.ok ? \"Approved\" : \"Failed\";\n if (!res.ok) b.disabled = false;\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 }\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 // Checklist gate: ticking a box POSTs the one field; the response { complete }\n // decides whether THIS report's Approve button is enabled. Scoped per report by\n // matching the checkbox's report id to the Approve button's id, so multiple\n // pending reports on one page never cross-toggle. On failure the checkbox reverts.\n document.querySelectorAll(\"input.checklist-checkbox\").forEach((cb) => {\n cb.addEventListener(\"change\", async () => {\n const reportId = cb.dataset.checklistReportId;\n const approveBtn = document.querySelector(\n 'button.approve[data-report-id=\"' + (window.CSS && CSS.escape ? CSS.escape(reportId) : reportId) + '\"]',\n );\n cb.disabled = true;\n try {\n const res = await fetch(cb.dataset.checklistUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ reportId, field: cb.dataset.field, value: cb.checked }),\n });\n if (!res.ok) throw new Error(\"bad status\");\n const data = await res.json();\n if (approveBtn) approveBtn.disabled = !data.complete;\n } catch {\n // Revert the optimistic flip so the box reflects the (unchanged) server state.\n cb.checked = !cb.checked;\n } finally {\n cb.disabled = false;\n }\n });\n });\n </script>\n</body>\n</html>`;\n}\n","// src/dashboard/fleet-cockpit.ts\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { siteSlug, isDashboardVisible } 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 type { FleetEvent, FleetEventType } from \"../db/fleet-events.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\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\";\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 * A condition the operator has marked accepted (`site.acceptedWatchConditions`,\n * case-insensitive: a Lighthouse category label, \"stale repo\", or \"no custom domain\")\n * is routed to `acceptedReasons` instead of `watchReasons` — it leaves the watch band\n * (an all-accepted site becomes healthy) but stays visible as a muted chip. Acceptance\n * is watch-only: a sub-floor Lighthouse score arrives as an AttentionItem above and\n * still alarms broken, so accepting \"78\" never hides a drop to \"72\".\n *\n * `watchReasons` are the human labels for the card; `watchSignals` are the STRUCTURED\n * filter tags (\"lighthouse\" / \"stale\") the client filter keys off.\n */\nexport function assignTier(\n site: WebsiteRow,\n items: AttentionItem[],\n now: Date,\n): { tier: Tier; watchReasons: string[]; watchSignals: string[]; acceptedReasons: string[] } {\n if (items.length > 0)\n return { tier: \"attention\", watchReasons: [], watchSignals: [], acceptedReasons: [] };\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 { tier: \"attention\", watchReasons: [], watchSignals: [], acceptedReasons: [] };\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 const watchReasons: string[] = [];\n const acceptedReasons: string[] = [];\n const signals = new Set<string>();\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 const reason = `${cat.label} ${score}`;\n if (accepted.has(cat.label.toLowerCase())) {\n acceptedReasons.push(reason);\n } else {\n watchReasons.push(reason);\n signals.add(\"lighthouse\");\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 const reason = `last commit ${relativeTimeFromNow(site.lastCommitAt, now)}`;\n if (accepted.has(\"stale repo\")) {\n acceptedReasons.push(reason);\n } else {\n watchReasons.push(reason);\n signals.add(\"stale\");\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 const reason = \"on *.netlify.app (no custom domain)\";\n if (accepted.has(\"no custom domain\")) {\n acceptedReasons.push(reason);\n } else {\n watchReasons.push(reason);\n signals.add(\"no-domain\");\n }\n }\n return watchReasons.length > 0\n ? { tier: \"watch\", watchReasons, watchSignals: [...signals], acceptedReasons }\n : { tier: \"healthy\", watchReasons: [], watchSignals: [], acceptedReasons };\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 /** 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};\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 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};\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\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};\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 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 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 };\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): CockpitModel {\n const visible = websites.filter(isDashboardVisible);\n const sitesById = new Map<string, WebsiteRow>(visible.map((w) => [w.id, w]));\n\n // Per-site NEW-submission counts, keyed by Websites record id. Used for the\n // per-card badge below; the strip resolves entries against ALL sites.\n const subCountBySite = new Map<string, number>();\n for (const sub of newSubmissions) {\n subCountBySite.set(sub.siteId, (subCountBySite.get(sub.siteId) ?? 0) + 1);\n }\n\n const rawItems: AttentionItem[] = [\n ...collectVulnAlerts(visible, baseUrl),\n ...collectLighthouseAlerts(visible, baseUrl),\n ...collectDeliveryFailures(reports, sitesById, baseUrl),\n ...collectRenovateAlerts(visible, baseUrl, now),\n ...collectCiAlerts(visible, baseUrl, now),\n ...collectAnalyticsFailures(visible, baseUrl, now),\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 items = (bySite.get(site.name) ?? []).sort(\n (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity],\n );\n const { tier, watchReasons, watchSignals, acceptedReasons } = assignTier(site, items, now);\n return {\n site,\n tier,\n items,\n watchReasons,\n watchSignals,\n acceptedReasons,\n newSubmissions: subCountBySite.get(site.id) ?? 0,\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 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 };\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 };\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): 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 const display = outdated === null ? driftPart : `${driftPart} · ${outdated} outdated`;\n return `<span class=\"metric deps\">${escapeHtml(display)}</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)}\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 const n = c.newSubmissions ?? 0;\n return n > 0 ? `<span class=\"chip\">📥 ${n} new</span>` : \"\";\n}\n\nconst PILL_LABEL: Record<Tier, string> = { attention: \"failing\", watch: \"watch\", healthy: \"ok\" };\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 for (const reason of c.watchReasons)\n items.push(`<span class=\"chip\">${escapeHtml(reason)}</span>`);\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 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.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 // 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));\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 sitesWord = `${total} site${total === 1 ? \"\" : \"s\"}`;\n return render(\"ok\", \"✓ All clear\", [`${sitesWord} healthy`, 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 auditedTerm,\n ]);\n }\n if (watch > 0) {\n return render(\"watch\", `${watch} site${watch === 1 ? \"\" : \"s\"} to watch`, [\n approvalTerm,\n healthyTerm,\n auditedTerm,\n ]);\n }\n return render(\"soft\", `${approval} waiting on you`, [healthyTerm, auditedTerm]);\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};\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/** 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 if (subs.length === 0 && !hasSpam) 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 return `<details class=\"inbox\">\n <summary>📥 Submissions (${subs.length} new)${spamInSummary}</summary>\n ${rows}${subs.length > 0 ? more : \"\"}\n ${spamLine}\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 ${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":["card","DASH","chips","STYLES"]}
|