@reddoorla/maintenance 0.75.1 → 0.78.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.
Files changed (157) hide show
  1. package/README.md +34 -7
  2. package/dist/{announce-V6EYPPUR.js → announce-RUT247HS.js} +9 -6
  3. package/dist/{announce-V6EYPPUR.js.map → announce-RUT247HS.js.map} +1 -1
  4. package/dist/{blux-C24OVDDW.js → blux-2P4FGCUL.js} +1867 -277
  5. package/dist/blux-2P4FGCUL.js.map +1 -0
  6. package/dist/{bump-deps-OQR3IXLN.js → bump-deps-XB2LJTVN.js} +3 -3
  7. package/dist/{chunk-RHPDQW6D.js → chunk-2SLED3NB.js} +1 -1
  8. package/dist/{chunk-RHPDQW6D.js.map → chunk-2SLED3NB.js.map} +1 -1
  9. package/dist/chunk-37S6L6G4.js +170 -0
  10. package/dist/chunk-37S6L6G4.js.map +1 -0
  11. package/dist/{chunk-HIN3P4ZN.js → chunk-4SOCPK7A.js} +10 -1
  12. package/dist/chunk-4SOCPK7A.js.map +1 -0
  13. package/dist/{run-migration-LJNNZWIV.js → chunk-6DOFNDXU.js} +5 -3
  14. package/dist/chunk-6DOFNDXU.js.map +1 -0
  15. package/dist/{chunk-NUIBQPB3.js → chunk-6SZBNALD.js} +62 -9
  16. package/dist/chunk-6SZBNALD.js.map +1 -0
  17. package/dist/{chunk-CASCWQQX.js → chunk-73UFRZKY.js} +34 -19
  18. package/dist/chunk-73UFRZKY.js.map +1 -0
  19. package/dist/{chunk-D3DQFC32.js → chunk-ATJ6ZA2V.js} +2 -2
  20. package/dist/{chunk-ZO7WNY57.js → chunk-B5ZUQZFL.js} +2 -2
  21. package/dist/{chunk-PH3AGF4Z.js → chunk-DC5H7KEQ.js} +2 -2
  22. package/dist/chunk-DRDAEELJ.js +197 -0
  23. package/dist/chunk-DRDAEELJ.js.map +1 -0
  24. package/dist/{chunk-IDBMYDVH.js → chunk-FVVGDIGX.js} +6 -4
  25. package/dist/{chunk-IDBMYDVH.js.map → chunk-FVVGDIGX.js.map} +1 -1
  26. package/dist/{chunk-7MJBTV2R.js → chunk-FX7RAMGC.js} +2 -2
  27. package/dist/{chunk-2AUUAJ5X.js → chunk-GWAOX3CU.js} +9 -9
  28. package/dist/{chunk-PKVZMFEB.js → chunk-K5JZ5KLO.js} +2 -2
  29. package/dist/{chunk-6OYICQSJ.js → chunk-KPQXB5CR.js} +9 -4
  30. package/dist/chunk-KPQXB5CR.js.map +1 -0
  31. package/dist/{chunk-BTEBRTW4.js → chunk-LS6QPANO.js} +2 -2
  32. package/dist/{chunk-2X322ZES.js → chunk-MGJ72BA4.js} +2 -2
  33. package/dist/chunk-MNDIED4M.js +137 -0
  34. package/dist/chunk-MNDIED4M.js.map +1 -0
  35. package/dist/{chunk-JCITWZQR.js → chunk-MNE374CZ.js} +2 -50
  36. package/dist/chunk-MNE374CZ.js.map +1 -0
  37. package/dist/chunk-MPBXOJ3N.js +51 -0
  38. package/dist/chunk-MPBXOJ3N.js.map +1 -0
  39. package/dist/{chunk-ZYBOT5PM.js → chunk-NT4WIP7I.js} +2 -2
  40. package/dist/{chunk-JNXCDUYB.js → chunk-Q276MZ7E.js} +3 -4
  41. package/dist/chunk-Q276MZ7E.js.map +1 -0
  42. package/dist/{chunk-GYNRZCM6.js → chunk-QG6OSL4M.js} +2 -2
  43. package/dist/{chunk-IRRZG66U.js → chunk-QMXQUL5Z.js} +34 -18
  44. package/dist/chunk-QMXQUL5Z.js.map +1 -0
  45. package/dist/{chunk-I55YWV6T.js → chunk-QWFHC3VH.js} +3 -3
  46. package/dist/{chunk-KLVMJYZZ.js → chunk-SEQA7MC3.js} +2 -2
  47. package/dist/chunk-UWJZXL2D.js +9 -0
  48. package/dist/chunk-UWJZXL2D.js.map +1 -0
  49. package/dist/{chunk-YEGHUODU.js → chunk-VSHC4HOI.js} +96 -1
  50. package/dist/chunk-VSHC4HOI.js.map +1 -0
  51. package/dist/{chunk-HBFMTM2E.js → chunk-YIQ7IWE3.js} +3 -3
  52. package/dist/{chunk-TDG5Z5OT.js → chunk-YSPA5AUT.js} +86 -13
  53. package/dist/chunk-YSPA5AUT.js.map +1 -0
  54. package/dist/{chunk-XKOZUVK2.js → chunk-ZTPPCHDB.js} +1 -1
  55. package/dist/chunk-ZTPPCHDB.js.map +1 -0
  56. package/dist/cli/bin.js +62 -24
  57. package/dist/cli/bin.js.map +1 -1
  58. package/dist/cli/commands/audit.d.ts +1 -1
  59. package/dist/cli/commands/audit.js +5 -5
  60. package/dist/client-X5WUJWDE.js +11 -0
  61. package/dist/configs/eslint.js +10 -0
  62. package/dist/configs/eslint.js.map +1 -1
  63. package/dist/{convert-to-pnpm-OVMASATZ.js → convert-to-pnpm-FZHSLUQB.js} +3 -3
  64. package/dist/{db-3QW2XEXA.js → db-JSIZYT23.js} +3 -3
  65. package/dist/{digest-LEMZS5ZK.js → digest-NQAQI34P.js} +16 -13
  66. package/dist/{digest-LEMZS5ZK.js.map → digest-NQAQI34P.js.map} +1 -1
  67. package/dist/favicon-SRHSJZ62.js +42 -0
  68. package/dist/favicon-SRHSJZ62.js.map +1 -0
  69. package/dist/forms/index.js.map +1 -1
  70. package/dist/freeze-Y5OJFOST.js +442 -0
  71. package/dist/freeze-Y5OJFOST.js.map +1 -0
  72. package/dist/{github-signals-TF3VSNIN.js → github-signals-4LWQGD5V.js} +5 -5
  73. package/dist/header-image-H4N54DHN.js +95 -0
  74. package/dist/header-image-H4N54DHN.js.map +1 -0
  75. package/dist/{health-endpoint-JZGYCMTV.js → health-endpoint-LFVE26QS.js} +3 -3
  76. package/dist/index.d.ts +65 -3
  77. package/dist/index.js +86 -31
  78. package/dist/index.js.map +1 -1
  79. package/dist/{init-Y2DMKKE7.js → init-KF4QHMWQ.js} +12 -12
  80. package/dist/{launch-ZDX7UA3Y.js → launch-CVCIP5NU.js} +11 -8
  81. package/dist/launch-CVCIP5NU.js.map +1 -0
  82. package/dist/migrate-LJUFGXQZ.js +7 -0
  83. package/dist/{onboard-PX36YDZA.js → onboard-RDGHVCNG.js} +3 -3
  84. package/dist/{orchestrate-NYRYEKQM.js → orchestrate-QOQXOJLI.js} +7 -6
  85. package/dist/{preflight-JCQFKB6M.js → preflight-2PWVVIRR.js} +8 -7
  86. package/dist/{preflight-JCQFKB6M.js.map → preflight-2PWVVIRR.js.map} +1 -1
  87. package/dist/protection-audit-X3BHT2MB.js +140 -0
  88. package/dist/protection-audit-X3BHT2MB.js.map +1 -0
  89. package/dist/recipes/sync-configs.d.ts +1 -1
  90. package/dist/recipes/sync-configs.js +3 -3
  91. package/dist/{renovate-dispatch-6II2JVOX.js → renovate-dispatch-2VYCEH42.js} +2 -2
  92. package/dist/renovate-dispatch-2VYCEH42.js.map +1 -0
  93. package/dist/{report-POHQEV6F.js → report-IU4CLJS5.js} +8 -6
  94. package/dist/{report-POHQEV6F.js.map → report-IU4CLJS5.js.map} +1 -1
  95. package/dist/reports/header-image/assets/plate.png +0 -0
  96. package/dist/run-migration-WP57U5XO.js +9 -0
  97. package/dist/run-migration-WP57U5XO.js.map +1 -0
  98. package/dist/{self-updating-2YSVNMPP.js → self-updating-ALMUWJNX.js} +5 -4
  99. package/dist/{self-updating-2YSVNMPP.js.map → self-updating-ALMUWJNX.js.map} +1 -1
  100. package/dist/{selftest-OO2WDSDM.js → selftest-PJFABZDM.js} +11 -9
  101. package/dist/{selftest-OO2WDSDM.js.map → selftest-PJFABZDM.js.map} +1 -1
  102. package/dist/{smoke-suite-CSP23NZM.js → smoke-suite-SUEJFBYP.js} +3 -3
  103. package/dist/{submissions-MDKWJYMM.js → submissions-3I3GAQAX.js} +13 -5
  104. package/dist/submissions-3I3GAQAX.js.map +1 -0
  105. package/dist/{submissions-BNRGY3JQ.js → submissions-SURN7BV5.js} +3 -3
  106. package/dist/{svelte-codemods-V54KOBWU.js → svelte-codemods-IWO7X3F3.js} +3 -3
  107. package/dist/{sync-configs-UBCJRASZ.js → sync-configs-7XGHFC6P.js} +4 -4
  108. package/dist/{types-0jreMCF5.d.ts → types-DnEL3NRn.d.ts} +1 -1
  109. package/dist/{upgrade-WR6ICPAG.js → upgrade-AM4S7JF3.js} +3 -3
  110. package/dist/webflow-624FEBFD.js +548 -0
  111. package/dist/webflow-624FEBFD.js.map +1 -0
  112. package/package.json +3 -3
  113. package/dist/blux-C24OVDDW.js.map +0 -1
  114. package/dist/chunk-6OYICQSJ.js.map +0 -1
  115. package/dist/chunk-CASCWQQX.js.map +0 -1
  116. package/dist/chunk-HIN3P4ZN.js.map +0 -1
  117. package/dist/chunk-IRRZG66U.js.map +0 -1
  118. package/dist/chunk-JCITWZQR.js.map +0 -1
  119. package/dist/chunk-JNXCDUYB.js.map +0 -1
  120. package/dist/chunk-NUIBQPB3.js.map +0 -1
  121. package/dist/chunk-TDG5Z5OT.js.map +0 -1
  122. package/dist/chunk-XKOZUVK2.js.map +0 -1
  123. package/dist/chunk-YEGHUODU.js.map +0 -1
  124. package/dist/client-NVVQK3HH.js +0 -11
  125. package/dist/launch-ZDX7UA3Y.js.map +0 -1
  126. package/dist/migrate-35DNNF3H.js +0 -7
  127. package/dist/renovate-dispatch-6II2JVOX.js.map +0 -1
  128. package/dist/run-migration-LJNNZWIV.js.map +0 -1
  129. package/dist/submissions-MDKWJYMM.js.map +0 -1
  130. /package/dist/{bump-deps-OQR3IXLN.js.map → bump-deps-XB2LJTVN.js.map} +0 -0
  131. /package/dist/{chunk-D3DQFC32.js.map → chunk-ATJ6ZA2V.js.map} +0 -0
  132. /package/dist/{chunk-ZO7WNY57.js.map → chunk-B5ZUQZFL.js.map} +0 -0
  133. /package/dist/{chunk-PH3AGF4Z.js.map → chunk-DC5H7KEQ.js.map} +0 -0
  134. /package/dist/{chunk-7MJBTV2R.js.map → chunk-FX7RAMGC.js.map} +0 -0
  135. /package/dist/{chunk-2AUUAJ5X.js.map → chunk-GWAOX3CU.js.map} +0 -0
  136. /package/dist/{chunk-PKVZMFEB.js.map → chunk-K5JZ5KLO.js.map} +0 -0
  137. /package/dist/{chunk-BTEBRTW4.js.map → chunk-LS6QPANO.js.map} +0 -0
  138. /package/dist/{chunk-2X322ZES.js.map → chunk-MGJ72BA4.js.map} +0 -0
  139. /package/dist/{chunk-ZYBOT5PM.js.map → chunk-NT4WIP7I.js.map} +0 -0
  140. /package/dist/{chunk-GYNRZCM6.js.map → chunk-QG6OSL4M.js.map} +0 -0
  141. /package/dist/{chunk-I55YWV6T.js.map → chunk-QWFHC3VH.js.map} +0 -0
  142. /package/dist/{chunk-KLVMJYZZ.js.map → chunk-SEQA7MC3.js.map} +0 -0
  143. /package/dist/{chunk-HBFMTM2E.js.map → chunk-YIQ7IWE3.js.map} +0 -0
  144. /package/dist/{client-NVVQK3HH.js.map → client-X5WUJWDE.js.map} +0 -0
  145. /package/dist/{convert-to-pnpm-OVMASATZ.js.map → convert-to-pnpm-FZHSLUQB.js.map} +0 -0
  146. /package/dist/{db-3QW2XEXA.js.map → db-JSIZYT23.js.map} +0 -0
  147. /package/dist/{github-signals-TF3VSNIN.js.map → github-signals-4LWQGD5V.js.map} +0 -0
  148. /package/dist/{health-endpoint-JZGYCMTV.js.map → health-endpoint-LFVE26QS.js.map} +0 -0
  149. /package/dist/{init-Y2DMKKE7.js.map → init-KF4QHMWQ.js.map} +0 -0
  150. /package/dist/{migrate-35DNNF3H.js.map → migrate-LJUFGXQZ.js.map} +0 -0
  151. /package/dist/{onboard-PX36YDZA.js.map → onboard-RDGHVCNG.js.map} +0 -0
  152. /package/dist/{orchestrate-NYRYEKQM.js.map → orchestrate-QOQXOJLI.js.map} +0 -0
  153. /package/dist/{smoke-suite-CSP23NZM.js.map → smoke-suite-SUEJFBYP.js.map} +0 -0
  154. /package/dist/{submissions-BNRGY3JQ.js.map → submissions-SURN7BV5.js.map} +0 -0
  155. /package/dist/{svelte-codemods-V54KOBWU.js.map → svelte-codemods-IWO7X3F3.js.map} +0 -0
  156. /package/dist/{sync-configs-UBCJRASZ.js.map → sync-configs-7XGHFC6P.js.map} +0 -0
  157. /package/dist/{upgrade-WR6ICPAG.js.map → upgrade-AM4S7JF3.js.map} +0 -0
@@ -0,0 +1,95 @@
1
+ import {
2
+ generateHeaderImage
3
+ } from "./chunk-37S6L6G4.js";
4
+ import {
5
+ uploadAttachment
6
+ } from "./chunk-MPBXOJ3N.js";
7
+ import {
8
+ ACTIVE_STATUSES,
9
+ listWebsites,
10
+ siteSlug
11
+ } from "./chunk-GXVX4HUH.js";
12
+ import {
13
+ openBase,
14
+ readAirtableConfig
15
+ } from "./chunk-HN6I2XWI.js";
16
+ import "./chunk-6VAL7XJT.js";
17
+
18
+ // src/cli/commands/header-image.ts
19
+ import { mkdir, writeFile } from "fs/promises";
20
+ import { resolve } from "path";
21
+ function resolveTargets(rows, opts) {
22
+ if (opts.site) {
23
+ const want = siteSlug(opts.site);
24
+ return rows.filter((r) => siteSlug(r.name) === want);
25
+ }
26
+ if (!opts.all) return [];
27
+ return rows.filter((r) => {
28
+ if (!r.url) return false;
29
+ if (r.status === null || !ACTIVE_STATUSES.has(r.status)) return false;
30
+ return opts.force ? true : !r.headerImage;
31
+ });
32
+ }
33
+ function parseSettleMs(value) {
34
+ if (value === void 0) return void 0;
35
+ const n = Number(value);
36
+ if (!Number.isFinite(n) || n < 0) {
37
+ throw Object.assign(
38
+ new Error(`--settle-ms must be a non-negative number of milliseconds, got "${value}"`),
39
+ { exitCode: 2 }
40
+ );
41
+ }
42
+ return n;
43
+ }
44
+ async function generateForTargets(targets, opts, generate = generateHeaderImage) {
45
+ const outDir = resolve(opts.outDir ?? "reports");
46
+ const settleMs = parseSettleMs(opts.settleMs);
47
+ if (!opts.writeAirtable) await mkdir(outDir, { recursive: true });
48
+ const lines = [];
49
+ let failed = 0;
50
+ for (const row of targets) {
51
+ try {
52
+ const gen = await generate({
53
+ url: row.url,
54
+ slug: siteSlug(row.name),
55
+ // `exactOptionalPropertyTypes` rejects an explicit `settleMs: undefined`
56
+ // against `settleMs?: number`, so omit the key entirely when unset.
57
+ ...settleMs === void 0 ? {} : { settleMs }
58
+ });
59
+ if (opts.writeAirtable) {
60
+ await uploadAttachment(row.id, "Header image", gen.bytes, gen.filename, gen.contentType);
61
+ lines.push(
62
+ `\u2714 ${row.name} \u2014 uploaded ${gen.filename} (${(gen.bytes.byteLength / 1024 / 1024).toFixed(2)} MB)`
63
+ );
64
+ } else {
65
+ const path = resolve(outDir, gen.filename);
66
+ await writeFile(path, gen.bytes);
67
+ lines.push(`\u2714 ${row.name} \u2014 wrote ${path} (review, then re-run with --write-airtable)`);
68
+ }
69
+ } catch (err) {
70
+ failed++;
71
+ lines.push(`\u2716 ${row.name} \u2014 ${err instanceof Error ? err.message : String(err)}`);
72
+ }
73
+ }
74
+ lines.push(`${targets.length - failed}/${targets.length} generated.`);
75
+ return { output: lines.join("\n"), code: failed > 0 ? 1 : 0 };
76
+ }
77
+ async function runHeaderImageCommand(site, opts) {
78
+ const base = openBase(readAirtableConfig());
79
+ const rows = await listWebsites(base);
80
+ const targets = resolveTargets(rows, { site, all: opts.all, force: opts.force });
81
+ if (targets.length === 0) {
82
+ return {
83
+ output: site ? `No site matched "${site}".` : "No sites need a header image.",
84
+ code: 1
85
+ };
86
+ }
87
+ return generateForTargets(targets, opts);
88
+ }
89
+ export {
90
+ generateForTargets,
91
+ parseSettleMs,
92
+ resolveTargets,
93
+ runHeaderImageCommand
94
+ };
95
+ //# sourceMappingURL=header-image-H4N54DHN.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli/commands/header-image.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { openBase, readAirtableConfig } from \"../../reports/airtable/client.js\";\nimport { listWebsites, siteSlug, ACTIVE_STATUSES } from \"../../reports/airtable/websites.js\";\nimport type { WebsiteRow } from \"../../reports/airtable/websites.js\";\nimport { uploadAttachment } from \"../../reports/airtable/attachments.js\";\nimport { generateHeaderImage } from \"../../reports/header-image/index.js\";\n\nexport type HeaderImageOptions = {\n all?: boolean;\n force?: boolean;\n writeAirtable?: boolean;\n outDir?: string;\n settleMs?: string;\n};\n\n/** Which rows this invocation should act on. Pure, so it is unit-tested. */\nexport function resolveTargets(\n rows: readonly WebsiteRow[],\n // `| undefined` spelled out because `exactOptionalPropertyTypes` otherwise\n // rejects the caller passing through possibly-undefined CLI flags.\n opts: { site?: string | undefined; all?: boolean | undefined; force?: boolean | undefined },\n): WebsiteRow[] {\n if (opts.site) {\n const want = siteSlug(opts.site);\n return rows.filter((r) => siteSlug(r.name) === want);\n }\n if (!opts.all) return [];\n return rows.filter((r) => {\n if (!r.url) return false;\n if (r.status === null || !ACTIVE_STATUSES.has(r.status)) return false;\n return opts.force ? true : !r.headerImage;\n });\n}\n\n/** Parse the `--settle-ms <ms>` flag. Unset → undefined (the generator's own\n * default settle). `Number(\"abc\")` is NaN, which would sail through as a NaN\n * timeout and silently skip the settle entirely, so anything non-finite or\n * negative is rejected up front rather than producing a subtly-wrong capture. */\nexport function parseSettleMs(value: string | undefined): number | undefined {\n if (value === undefined) return undefined;\n const n = Number(value);\n if (!Number.isFinite(n) || n < 0) {\n throw Object.assign(\n new Error(`--settle-ms must be a non-negative number of milliseconds, got \"${value}\"`),\n { exitCode: 2 },\n );\n }\n return n;\n}\n\n/** Injected in tests so the file-writing branch can be exercised without\n * driving a real browser (mirrors `GenerateInput.shooter`). */\ntype Generate = typeof generateHeaderImage;\n\n/**\n * Generate a header image per target row and either upload it to Airtable or\n * write it locally for review. Split out of {@link runHeaderImageCommand} so\n * the local-output path is testable without an Airtable base.\n */\nexport async function generateForTargets(\n targets: readonly WebsiteRow[],\n opts: HeaderImageOptions,\n generate: Generate = generateHeaderImage,\n): Promise<{ output: string; code: number }> {\n const outDir = resolve(opts.outDir ?? \"reports\");\n const settleMs = parseSettleMs(opts.settleMs);\n // `reports/` does not exist on a fresh checkout, so the writeFile() below\n // would throw ENOENT for the first operator to run the default (local) path.\n // Create it once, before the loop, rather than per site.\n if (!opts.writeAirtable) await mkdir(outDir, { recursive: true });\n\n const lines: string[] = [];\n let failed = 0;\n\n for (const row of targets) {\n try {\n const gen = await generate({\n url: row.url,\n slug: siteSlug(row.name),\n // `exactOptionalPropertyTypes` rejects an explicit `settleMs: undefined`\n // against `settleMs?: number`, so omit the key entirely when unset.\n ...(settleMs === undefined ? {} : { settleMs }),\n });\n if (opts.writeAirtable) {\n await uploadAttachment(row.id, \"Header image\", gen.bytes, gen.filename, gen.contentType);\n lines.push(\n `✔ ${row.name} — uploaded ${gen.filename} (${(gen.bytes.byteLength / 1024 / 1024).toFixed(2)} MB)`,\n );\n } else {\n const path = resolve(outDir, gen.filename);\n await writeFile(path, gen.bytes);\n lines.push(`✔ ${row.name} — wrote ${path} (review, then re-run with --write-airtable)`);\n }\n } catch (err) {\n failed++;\n lines.push(`✖ ${row.name} — ${err instanceof Error ? err.message : String(err)}`);\n }\n }\n\n lines.push(`${targets.length - failed}/${targets.length} generated.`);\n return { output: lines.join(\"\\n\"), code: failed > 0 ? 1 : 0 };\n}\n\n/**\n * `header-image [site]` — capture a site's live homepage and composite its\n * report header image. Defaults to writing the JPEG locally so the operator can\n * eyeball it; `--write-airtable` uploads it to the Websites row's Header image\n * field. `--all` backfills every live site that has no header image yet.\n */\nexport async function runHeaderImageCommand(\n site: string | undefined,\n opts: HeaderImageOptions,\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const rows = await listWebsites(base);\n const targets = resolveTargets(rows, { site, all: opts.all, force: opts.force });\n if (targets.length === 0) {\n return {\n output: site ? `No site matched \"${site}\".` : \"No sites need a header image.\",\n code: 1,\n };\n }\n return generateForTargets(targets, opts);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA,SAAS,OAAO,iBAAiB;AACjC,SAAS,eAAe;AAgBjB,SAAS,eACd,MAGA,MACc;AACd,MAAI,KAAK,MAAM;AACb,UAAM,OAAO,SAAS,KAAK,IAAI;AAC/B,WAAO,KAAK,OAAO,CAAC,MAAM,SAAS,EAAE,IAAI,MAAM,IAAI;AAAA,EACrD;AACA,MAAI,CAAC,KAAK,IAAK,QAAO,CAAC;AACvB,SAAO,KAAK,OAAO,CAAC,MAAM;AACxB,QAAI,CAAC,EAAE,IAAK,QAAO;AACnB,QAAI,EAAE,WAAW,QAAQ,CAAC,gBAAgB,IAAI,EAAE,MAAM,EAAG,QAAO;AAChE,WAAO,KAAK,QAAQ,OAAO,CAAC,EAAE;AAAA,EAChC,CAAC;AACH;AAMO,SAAS,cAAc,OAA+C;AAC3E,MAAI,UAAU,OAAW,QAAO;AAChC,QAAM,IAAI,OAAO,KAAK;AACtB,MAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,GAAG;AAChC,UAAM,OAAO;AAAA,MACX,IAAI,MAAM,mEAAmE,KAAK,GAAG;AAAA,MACrF,EAAE,UAAU,EAAE;AAAA,IAChB;AAAA,EACF;AACA,SAAO;AACT;AAWA,eAAsB,mBACpB,SACA,MACA,WAAqB,qBACsB;AAC3C,QAAM,SAAS,QAAQ,KAAK,UAAU,SAAS;AAC/C,QAAM,WAAW,cAAc,KAAK,QAAQ;AAI5C,MAAI,CAAC,KAAK,cAAe,OAAM,MAAM,QAAQ,EAAE,WAAW,KAAK,CAAC;AAEhE,QAAM,QAAkB,CAAC;AACzB,MAAI,SAAS;AAEb,aAAW,OAAO,SAAS;AACzB,QAAI;AACF,YAAM,MAAM,MAAM,SAAS;AAAA,QACzB,KAAK,IAAI;AAAA,QACT,MAAM,SAAS,IAAI,IAAI;AAAA;AAAA;AAAA,QAGvB,GAAI,aAAa,SAAY,CAAC,IAAI,EAAE,SAAS;AAAA,MAC/C,CAAC;AACD,UAAI,KAAK,eAAe;AACtB,cAAM,iBAAiB,IAAI,IAAI,gBAAgB,IAAI,OAAO,IAAI,UAAU,IAAI,WAAW;AACvF,cAAM;AAAA,UACJ,UAAK,IAAI,IAAI,oBAAe,IAAI,QAAQ,MAAM,IAAI,MAAM,aAAa,OAAO,MAAM,QAAQ,CAAC,CAAC;AAAA,QAC9F;AAAA,MACF,OAAO;AACL,cAAM,OAAO,QAAQ,QAAQ,IAAI,QAAQ;AACzC,cAAM,UAAU,MAAM,IAAI,KAAK;AAC/B,cAAM,KAAK,UAAK,IAAI,IAAI,iBAAY,IAAI,8CAA8C;AAAA,MACxF;AAAA,IACF,SAAS,KAAK;AACZ;AACA,YAAM,KAAK,UAAK,IAAI,IAAI,WAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC,EAAE;AAAA,IAClF;AAAA,EACF;AAEA,QAAM,KAAK,GAAG,QAAQ,SAAS,MAAM,IAAI,QAAQ,MAAM,aAAa;AACpE,SAAO,EAAE,QAAQ,MAAM,KAAK,IAAI,GAAG,MAAM,SAAS,IAAI,IAAI,EAAE;AAC9D;AAQA,eAAsB,sBACpB,MACA,MAC2C;AAC3C,QAAM,OAAO,SAAS,mBAAmB,CAAC;AAC1C,QAAM,OAAO,MAAM,aAAa,IAAI;AACpC,QAAM,UAAU,eAAe,MAAM,EAAE,MAAM,KAAK,KAAK,KAAK,OAAO,KAAK,MAAM,CAAC;AAC/E,MAAI,QAAQ,WAAW,GAAG;AACxB,WAAO;AAAA,MACL,QAAQ,OAAO,oBAAoB,IAAI,OAAO;AAAA,MAC9C,MAAM;AAAA,IACR;AAAA,EACF;AACA,SAAO,mBAAmB,SAAS,IAAI;AACzC;","names":[]}
@@ -11,9 +11,9 @@ import {
11
11
  } from "./chunk-4BHNP6Y2.js";
12
12
  import {
13
13
  healthEndpoint
14
- } from "./chunk-7MJBTV2R.js";
14
+ } from "./chunk-FX7RAMGC.js";
15
15
  import "./chunk-KRDLHAKK.js";
16
- import "./chunk-XKOZUVK2.js";
16
+ import "./chunk-ZTPPCHDB.js";
17
17
  import "./chunk-FDIFTIXC.js";
18
18
  import "./chunk-HVOOCK6L.js";
19
19
  import "./chunk-XXTZBPUY.js";
@@ -51,4 +51,4 @@ async function runHealthEndpointCommand(site, opts) {
51
51
  export {
52
52
  runHealthEndpointCommand
53
53
  };
54
- //# sourceMappingURL=health-endpoint-JZGYCMTV.js.map
54
+ //# sourceMappingURL=health-endpoint-LFVE26QS.js.map
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as AuditResult, S as Site, A as AuditName, b as RecipeResult, R as RecipeName, I as InventoryProvider } from './types-0jreMCF5.js';
2
- export { C as ConfigName } from './types-0jreMCF5.js';
1
+ import { a as AuditResult, S as Site, A as AuditName, b as RecipeResult, R as RecipeName, I as InventoryProvider } from './types-DnEL3NRn.js';
2
+ export { C as ConfigName } from './types-DnEL3NRn.js';
3
3
  import * as airtable_lib_airtable_base_js from 'airtable/lib/airtable_base.js';
4
4
  import { F as FormType } from './types-RXY-vY-5.js';
5
5
  export { SyncConfigsOptions, syncConfigs } from './recipes/sync-configs.js';
@@ -93,9 +93,18 @@ type PageFetch = {
93
93
  title: string | null;
94
94
  metaDescription: string | null;
95
95
  };
96
+ /** What a no-follow GET of the base URL observed: the raw status (null = network error/timeout)
97
+ * and the raw Location header (null when absent). See canonicalizeBaseUrl. */
98
+ type RedirectFetch = {
99
+ status: number | null;
100
+ location: string | null;
101
+ };
96
102
  /** Injected plain-fetch re-verification IO (see reverifyRoutes). Tests pass a fake. */
97
103
  type VerifyDeps = {
98
104
  fetchPage: (url: string) => Promise<PageFetch>;
105
+ /** Optional single `redirect:"manual"` GET, for canonicalizeBaseUrl's base-URL preflight.
106
+ * Optional so existing fakes stay valid; absent → canonicalization is a no-op. */
107
+ fetchRedirect?: (url: string) => Promise<RedirectFetch>;
99
108
  };
100
109
 
101
110
  /** Outcome of driving one site's contact form. `formPresent:false` ⇒ n/a
@@ -761,6 +770,47 @@ type ReportRow = {
761
770
  overrideAt: string | null;
762
771
  };
763
772
 
773
+ type ShootOptions = {
774
+ url: string;
775
+ width: number;
776
+ height: number;
777
+ deviceScaleFactor: number;
778
+ settleMs: number;
779
+ };
780
+ /** Injected browser IO. The real impl drives Playwright; tests pass a fake. */
781
+ type Shooter = {
782
+ shoot: (opts: ShootOptions) => Promise<Uint8Array>;
783
+ };
784
+
785
+ type GenerateInput = {
786
+ /** The site's production URL. */
787
+ url: string;
788
+ /** Airtable site slug; drives the attachment filename. */
789
+ slug?: string;
790
+ /** Injected browser IO (tests). */
791
+ shooter?: Shooter;
792
+ /** Per-site settle override for animation-heavy or consent-gated homepages. */
793
+ settleMs?: number;
794
+ };
795
+ type GeneratedHeaderImage = {
796
+ bytes: Uint8Array;
797
+ /** The domain as printed on the plate. */
798
+ domain: string;
799
+ filename: string;
800
+ contentType: "image/jpeg";
801
+ };
802
+ /** Strip scheme, leading www and any path — the plate prints a bare domain. */
803
+ declare function domainFromUrl(url: string): string;
804
+ /** Capture a site's homepage and composite its header image. */
805
+ declare function generateHeaderImage(input: GenerateInput): Promise<GeneratedHeaderImage>;
806
+
807
+ type RefreshHeaderDeps = {
808
+ generate?: (input: {
809
+ url: string;
810
+ slug?: string;
811
+ }) => Promise<GeneratedHeaderImage>;
812
+ upload?: (recordId: string, field: string, bytes: Uint8Array, filename: string, contentType: string) => Promise<void>;
813
+ };
764
814
  type DraftOptions = {
765
815
  /** Where to write the local preview HTML when `previewOnly`. Defaults to `reports/<slug>/draft.html`. */
766
816
  previewPath?: string;
@@ -777,6 +827,10 @@ type DraftOptions = {
777
827
  /** The mapped ReportRow being completed, returned as `reportRow` from the
778
828
  * complete path so callers keep the same shape they get on the create path. */
779
829
  existingRow?: ReportRow;
830
+ /** Injected deps for the draft-time header refresh, or `false` to skip it
831
+ * entirely. Tests set `false` (or a stub) so a unit suite never launches a
832
+ * browser or resolves DNS. Production leaves it unset and gets the real thing. */
833
+ refreshHeader?: RefreshHeaderDeps | false;
780
834
  };
781
835
  /** An enrichment fetch that *errored* (not one that was legitimately skipped
782
836
  * because it isn't configured / the site lacks the inputs). Surfaced so a
@@ -954,6 +1008,11 @@ type SubmissionRow = {
954
1008
  spamScore?: number | null;
955
1009
  /** Comma-joined classifier reason codes (e.g. "links:3,disposable-email"); null when unscored. */
956
1010
  spamReason?: string | null;
1011
+ /** Comma-joined newsletter fan-out outcome per destination — `webhook:ok`,
1012
+ * `mailchimp:401`, `mailchimp:threw`, `mailchimp-tags:failed`. Null means nothing
1013
+ * was attempted: a non-newsletter form, a spam row, a site with no destination
1014
+ * configured, or a row written before 0005. See ingest.ts for the token grammar. */
1015
+ fanoutStatus?: string | null;
957
1016
  };
958
1017
 
959
1018
  type ScreenOutTotals = {
@@ -1147,4 +1206,7 @@ declare function renderCockpitHtml(model: CockpitModel): string;
1147
1206
  */
1148
1207
  declare function verifyBasicAuth(authHeader: string | null | undefined, expectedPassword: string | null): boolean;
1149
1208
 
1150
- export { ALL_AUDIT_NAMES, ALL_RECIPE_NAMES, type AirtableInventoryOptions, AuditName, AuditResult, BLURRED_CID, type BumpDepsOptions, type BundledImage, CHECK_CID, type ConvertToPnpmOptions, DEFAULT_INIT_STEPS, type DraftOptions, type DraftResult, type DueItem, type HeaderImage, type InitOptions, type InitResult, type InitStep, type InitStepResult, InventoryProvider, type LighthouseScores, type LocalPathOptions, type OnboardAudit, type OnboardOptions, type OrchestrateOptions, RecipeName, RecipeResult, type RenderResult, type ReportData, type ReportType, Site, type UpgradeSvelte4to5Options, a11yAudit, a11yFixturesPage, bumpDeps, convertToPnpm, depsAudit, draftReportForSite, findDueReports, fromAirtableBase, fromJsonFile, init, isRecipeName, lighthouseAudit, lintAudit, loadBundledImages, localPath, onboard, renderCockpitHtml, renderReportHtml, renderSiteDashboardHtml, runAudits, runAuditsAcross, securityAudit, selfCaretRange, selfPackageVersion, sendApprovedReports, svelteCodemods, upgradeSvelte4to5, verifyBasicAuth };
1209
+ /** Read the bundled 2400x3200 plate. */
1210
+ declare function loadPlate(): Promise<Uint8Array>;
1211
+
1212
+ export { ALL_AUDIT_NAMES, ALL_RECIPE_NAMES, type AirtableInventoryOptions, AuditName, AuditResult, BLURRED_CID, type BumpDepsOptions, type BundledImage, CHECK_CID, type ConvertToPnpmOptions, DEFAULT_INIT_STEPS, type DraftOptions, type DraftResult, type DueItem, type GenerateInput, type GeneratedHeaderImage, type HeaderImage, type InitOptions, type InitResult, type InitStep, type InitStepResult, InventoryProvider, type LighthouseScores, type LocalPathOptions, type OnboardAudit, type OnboardOptions, type OrchestrateOptions, RecipeName, RecipeResult, type RefreshHeaderDeps, type RenderResult, type ReportData, type ReportType, Site, type UpgradeSvelte4to5Options, a11yAudit, a11yFixturesPage, bumpDeps, convertToPnpm, depsAudit, domainFromUrl, draftReportForSite, findDueReports, fromAirtableBase, fromJsonFile, generateHeaderImage, init, isRecipeName, lighthouseAudit, lintAudit, loadBundledImages, loadPlate, localPath, onboard, renderCockpitHtml, renderReportHtml, renderSiteDashboardHtml, runAudits, runAuditsAcross, securityAudit, selfCaretRange, selfPackageVersion, sendApprovedReports, svelteCodemods, upgradeSvelte4to5, verifyBasicAuth };
package/dist/index.js CHANGED
@@ -1,43 +1,48 @@
1
+ import "./chunk-KPQXB5CR.js";
1
2
  import {
2
3
  draftReportForSite
3
- } from "./chunk-NUIBQPB3.js";
4
- import "./chunk-KLVMJYZZ.js";
5
- import "./chunk-RHPDQW6D.js";
6
- import "./chunk-6OYICQSJ.js";
4
+ } from "./chunk-6SZBNALD.js";
5
+ import "./chunk-SEQA7MC3.js";
6
+ import {
7
+ domainFromUrl,
8
+ generateHeaderImage,
9
+ loadPlate
10
+ } from "./chunk-37S6L6G4.js";
7
11
  import {
8
12
  approveBlockers
9
- } from "./chunk-HBFMTM2E.js";
13
+ } from "./chunk-YIQ7IWE3.js";
10
14
  import {
11
15
  findDueReports
12
16
  } from "./chunk-D4FVAIEJ.js";
17
+ import "./chunk-2SLED3NB.js";
13
18
  import {
14
19
  upgradeSvelte4to5
15
- } from "./chunk-ZO7WNY57.js";
20
+ } from "./chunk-B5ZUQZFL.js";
16
21
  import {
17
22
  DEFAULT_INIT_STEPS,
18
23
  a11yFixturesPage,
19
24
  init
20
- } from "./chunk-2AUUAJ5X.js";
25
+ } from "./chunk-GWAOX3CU.js";
21
26
  import {
22
27
  convertToPnpm
23
- } from "./chunk-2X322ZES.js";
28
+ } from "./chunk-MGJ72BA4.js";
24
29
  import {
25
30
  svelteCodemods
26
- } from "./chunk-BTEBRTW4.js";
31
+ } from "./chunk-LS6QPANO.js";
27
32
  import "./chunk-J7SZQUCW.js";
28
- import "./chunk-7MJBTV2R.js";
29
- import "./chunk-PKVZMFEB.js";
33
+ import "./chunk-FX7RAMGC.js";
34
+ import "./chunk-K5JZ5KLO.js";
30
35
  import "./chunk-KRDLHAKK.js";
31
36
  import {
32
37
  onboard,
33
38
  selfCaretRange,
34
39
  selfPackageVersion
35
- } from "./chunk-ZYBOT5PM.js";
40
+ } from "./chunk-NT4WIP7I.js";
36
41
  import {
37
42
  parseAddresses,
38
43
  sendApprovedReports,
39
44
  withGlobalCc
40
- } from "./chunk-IDBMYDVH.js";
45
+ } from "./chunk-FVVGDIGX.js";
41
46
  import "./chunk-GECESMVS.js";
42
47
  import {
43
48
  BLURRED_CID,
@@ -50,19 +55,20 @@ import {
50
55
  loadBundledImages,
51
56
  renderReportHtml,
52
57
  safeUrl
53
- } from "./chunk-JCITWZQR.js";
58
+ } from "./chunk-MNE374CZ.js";
59
+ import "./chunk-MPBXOJ3N.js";
54
60
  import "./chunk-WEKL2HYJ.js";
55
61
  import {
56
62
  bumpDeps
57
- } from "./chunk-D3DQFC32.js";
63
+ } from "./chunk-ATJ6ZA2V.js";
58
64
  import {
59
65
  isLeadFormType
60
66
  } from "./chunk-2OP4JKIQ.js";
61
67
  import {
62
68
  syncConfigs
63
- } from "./chunk-JNXCDUYB.js";
64
- import "./chunk-CASCWQQX.js";
65
- import "./chunk-XKOZUVK2.js";
69
+ } from "./chunk-Q276MZ7E.js";
70
+ import "./chunk-73UFRZKY.js";
71
+ import "./chunk-ZTPPCHDB.js";
66
72
  import "./chunk-OTGB6YQO.js";
67
73
  import {
68
74
  ALL_AUDIT_NAMES,
@@ -73,13 +79,13 @@ import {
73
79
  runAudits,
74
80
  runAuditsAcross,
75
81
  securityAudit
76
- } from "./chunk-TDG5Z5OT.js";
82
+ } from "./chunk-YSPA5AUT.js";
77
83
  import "./chunk-GASBX52O.js";
78
- import "./chunk-YEGHUODU.js";
84
+ import "./chunk-VSHC4HOI.js";
79
85
  import "./chunk-LKGVSM2O.js";
80
- import "./chunk-GYNRZCM6.js";
81
- import "./chunk-PH3AGF4Z.js";
82
- import "./chunk-HIN3P4ZN.js";
86
+ import "./chunk-QG6OSL4M.js";
87
+ import "./chunk-DC5H7KEQ.js";
88
+ import "./chunk-4SOCPK7A.js";
83
89
  import {
84
90
  fromJsonFile,
85
91
  localPath
@@ -189,6 +195,9 @@ function isVisibleInStrip(s) {
189
195
  function spamReasonTokens(reason) {
190
196
  return (reason ?? "").split(",").map((t) => t.trim()).filter((t) => t !== "");
191
197
  }
198
+ function fanoutFailures(fanoutStatus) {
199
+ return (fanoutStatus ?? "").split(",").map((t) => t.trim()).filter((t) => t !== "" && !t.endsWith(":ok"));
200
+ }
192
201
  var REASON_CHIP_MAX_TOKENS = 3;
193
202
  function reasonChipText(tokens) {
194
203
  const shown = tokens.slice(0, REASON_CHIP_MAX_TOKENS).join(" \xB7 ");
@@ -209,6 +218,8 @@ function renderSubmissionRowInner(s) {
209
218
  const provenance = s.status === "spam_auto" && s.spamScore !== null && s.spamScore !== void 0 ? ` <span class="subm-provenance" title="${escapeHtml(s.spamReason ?? "")}">auto-spam \xB7 ${escapeHtml(String(s.spamScore))}</span>${reasonChip}` : "";
210
219
  const recover = s.status === "spam_auto" ? btn("Not spam \u2192 new", "new") : "";
211
220
  const bounceChip = s.notifyStatus === "bounced" ? ` <span class="subm-bounce" title="The lead notification email bounced \u2014 the point-of-contact address may be dead">notify bounced</span>` : "";
221
+ const fanoutBad = fanoutFailures(s.fanoutStatus);
222
+ const fanoutChip = fanoutBad.length > 0 ? ` <span class="subm-fanout-fail" title="This signup did not reach every configured destination">fan-out: ${escapeHtml(fanoutBad.join(" \xB7 "))}</span>` : "";
212
223
  const kv = (label, value) => value === null || value === "" ? "" : `<div class="subm-kv"><span class="k">${label}</span> ${escapeHtml(String(value))}</div>`;
213
224
  const sourceLink = s.sourceUrl ? `<div class="subm-kv"><span class="k">Source</span> <a href="${escapeHtml(safeUrl(s.sourceUrl))}" rel="noopener noreferrer">${escapeHtml(s.sourceUrl)}</a></div>` : "";
214
225
  const messageBlock = s.message ? `<div class="subm-kv"><span class="k">Message</span></div><div class="subm-msg">${escapeHtml(s.message)}</div>` : "";
@@ -222,11 +233,12 @@ function renderSubmissionRowInner(s) {
222
233
  extraFieldsList(s.extraFields),
223
234
  kv("Spam", spamDetail),
224
235
  kv("Notify", s.notifyStatus),
236
+ kv("Fan-out", s.fanoutStatus ?? null),
225
237
  kv("Resend ID", s.resendMessageId),
226
238
  kv("Submission #", s.submissionId)
227
239
  ].join("");
228
240
  return `<details>
229
- <summary class="subm-head"><strong>${type}</strong> \xB7 ${who} <span class="muted">${email}</span> <span class="pill subm-${status}">${status}</span>${provenance}${bounceChip} <span class="muted">${when}</span></summary>
241
+ <summary class="subm-head"><strong>${type}</strong> \xB7 ${who} <span class="muted">${email}</span> <span class="pill subm-${status}">${status}</span>${provenance}${bounceChip}${fanoutChip} <span class="muted">${when}</span></summary>
230
242
  <div class="subm-detail">${details}</div>
231
243
  </details>
232
244
  <div class="subm-actions">${recover}${btn("Read", "read")}${btn("Archive", "archived")}${btn("Spam", "spam")}</div>`;
@@ -256,6 +268,7 @@ button.subm-status:disabled { opacity: 0.6; cursor: default; }
256
268
  .pill.subm-spam_auto { background: #fff4e5; color: #a65a00; }
257
269
  .subm-provenance { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; background: #fff4e5; color: #a65a00; }
258
270
  .subm-bounce { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; background: #fdecea; color: #b00; font-weight: 700; }
271
+ .subm-fanout-fail { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; background: #fdecea; color: #b00; font-weight: 700; }
259
272
  .subm-reasons { font-size: 0.72rem; color: #999; }
260
273
  .subm-viewall { font-size: 0.8rem; font-weight: normal; margin-left: 0.4rem; white-space: nowrap; }`;
261
274
  var SUBMISSION_STATUS_SCRIPT = `document.querySelectorAll("button.subm-status").forEach((b) => {
@@ -606,8 +619,23 @@ th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
606
619
  @media (prefers-color-scheme: dark) { th, td { border-color: #2a2a2a; } }
607
620
  .muted { color: #999; }
608
621
  .empty { color: #999; padding: 1rem; border: 1px dashed #ccc; border-radius: 6px; text-align: center; }
609
- button.approve { font: inherit; padding: 0.35rem 0.85rem; border: 1px solid #2c7; border-radius: 6px; background: #2c7; color: #fff; cursor: pointer; }
622
+ button.approve { font: inherit; padding: 0.35rem 0.85rem; border: 1px solid #2c7; border-radius: 6px; background: #2c7; color: #fff; cursor: pointer; position: relative; transition: background-color 0.12s ease, border-color 0.12s ease; }
623
+ button.approve:hover:not(:disabled) { background: #25b36a; border-color: #25b36a; }
624
+ button.approve:active:not(:disabled) { background: #1e9a5b; border-color: #1e9a5b; }
625
+ button.approve:focus-visible { outline: 2px solid #2c7; outline-offset: 2px; }
610
626
  button.approve:disabled { opacity: 0.6; cursor: default; }
627
+ /* In-flight: the label goes transparent rather than being removed, so the button keeps
628
+ its exact width and the row never reflows mid-request. The spinner is a pseudo-element,
629
+ NOT injected markup \u2014 this handler is deliberately textContent/title-only so server
630
+ strings can never become HTML, and a ::after keeps that property intact. */
631
+ button.approve.is-loading { color: transparent; cursor: progress; }
632
+ button.approve.is-loading::after { content: ""; position: absolute; top: 50%; left: 50%; width: 0.85em; height: 0.85em; margin: -0.45em 0 0 -0.45em; border: 2px solid #ffffff80; border-top-color: transparent; border-radius: 50%; animation: approve-spin 0.8s linear infinite; }
633
+ @keyframes approve-spin { to { transform: rotate(360deg); } }
634
+ /* Never remove the spin outright \u2014 a frozen ring reads as "hung". Slow it instead. */
635
+ @media (prefers-reduced-motion: reduce) { button.approve.is-loading::after { animation-duration: 2.4s; } }
636
+ /* Terminal success. Wins over :disabled's dimming (the button stays disabled after a
637
+ successful approve, and a 0.6-opacity "Approved" reads as not-quite-done). */
638
+ button.approve.is-approved, button.approve.is-approved:disabled { background: #14663c; border-color: #14663c; color: #fff; opacity: 1; }
611
639
  .pending-info { display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; font-size: 0.82rem; margin: 0.3rem 0 0.15rem; }
612
640
  .recipients-missing { color: #e57373; }
613
641
  .preflight { font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; white-space: nowrap; }
@@ -677,12 +705,17 @@ button.approve:disabled { opacity: 0.6; cursor: default; }
677
705
  }
678
706
  .pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }
679
707
  .override { margin: 0.4rem 0 0.15rem 0.25rem; }
680
- button.override-toggle { font: inherit; font-size: 0.78rem; padding: 0.15rem 0.6rem; border: 1px solid #b00; border-radius: 6px; background: transparent; color: #b00; cursor: pointer; }
681
- @media (prefers-color-scheme: dark) { button.override-toggle { border-color: #ff8a80; color: #ff8a80; } }
708
+ button.override-toggle { font: inherit; font-size: 0.78rem; padding: 0.15rem 0.6rem; border: 1px solid #b00; border-radius: 6px; background: transparent; color: #b00; cursor: pointer; transition: background-color 0.12s ease; }
709
+ button.override-toggle:hover { background: #bb00001a; }
710
+ button.override-toggle:focus-visible { outline: 2px solid #b00; outline-offset: 2px; }
711
+ @media (prefers-color-scheme: dark) { button.override-toggle { border-color: #ff8a80; color: #ff8a80; } button.override-toggle:hover { background: #ff8a801f; } button.override-toggle:focus-visible { outline-color: #ff8a80; } }
682
712
  .override-form { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
683
713
  .override-reason { font: inherit; font-size: 0.85rem; padding: 0.25rem 0.45rem; border: 1px solid #ccc; border-radius: 4px; background: transparent; color: inherit; min-width: 220px; flex: 1 1 220px; }
684
714
  @media (prefers-color-scheme: dark) { .override-reason { border-color: #444; } }
685
- button.override-submit { font: inherit; font-size: 0.8rem; padding: 0.25rem 0.7rem; border: 1px solid #b00; border-radius: 6px; background: #b00; color: #fff; cursor: pointer; }
715
+ button.override-submit { font: inherit; font-size: 0.8rem; padding: 0.25rem 0.7rem; border: 1px solid #b00; border-radius: 6px; background: #b00; color: #fff; cursor: pointer; transition: background-color 0.12s ease, border-color 0.12s ease; }
716
+ button.override-submit:hover:not(:disabled) { background: #900; border-color: #900; }
717
+ button.override-submit:active:not(:disabled) { background: #7a0000; border-color: #7a0000; }
718
+ button.override-submit:focus-visible { outline: 2px solid #b00; outline-offset: 2px; }
686
719
  button.override-submit:disabled { opacity: 0.6; cursor: default; }
687
720
  .override-status { font-size: 0.78rem; color: #999; }
688
721
  .vuln-list { list-style: none; padding: 0; margin: 0; }
@@ -704,7 +737,10 @@ button.override-submit:disabled { opacity: 0.6; cursor: default; }
704
737
  .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; }
705
738
  .detail dd textarea { min-height: 3.5rem; resize: vertical; }
706
739
  .detail-saved { font-size: 0.8rem; color: #2a8; }
707
- button.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; }
740
+ button.trigger-renovate { font: inherit; font-size: 0.8rem; padding: 0.15rem 0.6rem; margin-left: 0.5rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; transition: background-color 0.12s ease, border-color 0.12s ease; }
741
+ button.trigger-renovate:hover:not(:disabled) { background: #88888822; border-color: #666; }
742
+ button.trigger-renovate:focus-visible { outline: 2px solid #888; outline-offset: 2px; }
743
+ button.trigger-renovate:disabled { opacity: 0.6; cursor: default; }
708
744
  /* Alarm chip strip \u2014 mirrors fleet-render.ts chip styles (NOT the page's .pill classes). */
709
745
  .chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
710
746
  .chip { font-size: 0.8rem; padding: 0.1rem 0.5rem; border-radius: 6px; background: #f0f0f0; }
@@ -779,18 +815,31 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
779
815
  document.querySelectorAll("button.approve").forEach((b) => {
780
816
  b.addEventListener("click", async () => {
781
817
  b.disabled = true;
818
+ // is-loading swaps the label for a spinner; aria-busy is the same news for a
819
+ // screen reader, which cannot see it. Both are cleared in the finally below so
820
+ // no exit path can strand the button spinning forever.
821
+ b.classList.add("is-loading");
822
+ b.setAttribute("aria-busy", "true");
782
823
  try {
783
824
  const res = await fetch(b.dataset.approveUrl, { method: "POST" });
784
825
  if (res.ok) {
785
826
  b.textContent = "Approved";
827
+ // Terminal success: darker green, and it stays disabled (approving twice is
828
+ // a no-op server-side, but the button should not invite the second click).
829
+ b.classList.add("is-approved");
786
830
  } else {
787
831
  // A 409 carries { reason, blockers } \u2014 surface WHY instead of a bare
788
832
  // "Failed" next to a possibly-stale green chip. textContent/title
789
833
  // assignment only (no innerHTML), so server strings stay inert.
790
834
  const data = await res.json().catch(() => null);
791
835
  b.textContent = data && data.reason === "send-blocked" ? "Blocked" : "Failed";
792
- if (data && Array.isArray(data.blockers)) b.title = data.blockers.join("
793
- ");
836
+ // Double-backslash, NOT single: this whole block lives inside the renderer's
837
+ // template literal, so a single-backslash escape is consumed HERE at build
838
+ // time and emits a real newline into the served HTML \u2014 an unterminated string
839
+ // literal that kills the parse of the entire script element, taking every
840
+ // handler on the page with it (exactly how approve/override/renovate went
841
+ // dead). Note this comment must also avoid backticks for the same reason.
842
+ if (data && Array.isArray(data.blockers)) b.title = data.blockers.join("\\n");
794
843
  b.disabled = false;
795
844
  }
796
845
  } catch {
@@ -798,6 +847,9 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
798
847
  // the button doesn't sit permanently disabled reading "Approve".
799
848
  b.textContent = "Failed";
800
849
  b.disabled = false;
850
+ } finally {
851
+ b.classList.remove("is-loading");
852
+ b.removeAttribute("aria-busy");
801
853
  }
802
854
  });
803
855
  });
@@ -1614,15 +1666,18 @@ export {
1614
1666
  bumpDeps,
1615
1667
  convertToPnpm,
1616
1668
  depsAudit,
1669
+ domainFromUrl,
1617
1670
  draftReportForSite,
1618
1671
  findDueReports,
1619
1672
  fromAirtableBase,
1620
1673
  fromJsonFile,
1674
+ generateHeaderImage,
1621
1675
  init,
1622
1676
  isRecipeName,
1623
1677
  lighthouseAudit,
1624
1678
  lintAudit,
1625
1679
  loadBundledImages,
1680
+ loadPlate,
1626
1681
  localPath,
1627
1682
  onboard,
1628
1683
  renderCockpitHtml,