@mandujs/core 0.33.0 → 0.34.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.
@@ -0,0 +1,394 @@
1
+ /**
2
+ * @mandujs/core/a11y — accessibility audit runner (Phase 18.χ).
3
+ *
4
+ * # Design contract
5
+ *
6
+ * 1. **Zero runtime cost when unused.** axe-core (~1 MB) and jsdom are
7
+ * declared as optional peer dependencies in `@mandujs/core` —
8
+ * neither is pulled into user bundles. `runAudit` uses dynamic
9
+ * `import()` for both, so the axe-core bytes never reach Node's
10
+ * module graph unless the caller actually asked for an audit.
11
+ *
12
+ * 2. **Graceful degradation.** When axe-core is absent the runner
13
+ * returns `outcome: "axe-missing"` with an actionable `note` rather
14
+ * than throwing. CLI callers translate that into a single-line
15
+ * informational message and exit 0 (quality is opt-in).
16
+ *
17
+ * 3. **DOM provider preference.** JSDOM is the canonical host because
18
+ * axe-core was written against it. When jsdom is not installed we
19
+ * try HappyDOM (Mandu already uses `@happy-dom/global-registrator`
20
+ * as a dev-dep) which covers the 80% path. If neither provider is
21
+ * available we return `axe-missing` with a note that names which
22
+ * piece is missing — jsdom, HappyDOM, or both.
23
+ *
24
+ * 4. **Bounded work.** `maxFiles` caps the input list (default 500) so
25
+ * a misconfigured project that prerenders 10k routes can't hang CI.
26
+ * Each file is audited sequentially; axe-core is CPU-bound and
27
+ * parallelising across Bun's event loop offers zero wall-clock win.
28
+ *
29
+ * 5. **Testable.** `axeLoader` / `domLoader` options short-circuit the
30
+ * module resolution so `run-audit.test.ts` can inject deterministic
31
+ * fakes without mutating Bun's module cache.
32
+ */
33
+
34
+ import fs from "fs/promises";
35
+ import path from "path";
36
+ import { AUDIT_IMPACT_ORDER, impactAtLeast } from "./types";
37
+ import type {
38
+ AuditImpact,
39
+ AuditNode,
40
+ AuditReport,
41
+ AuditViolation,
42
+ RunAuditOptions,
43
+ } from "./types";
44
+ import { getFixHint } from "./fix-hints";
45
+
46
+ /** Minimal structural typing for the axe-core handle we actually use. */
47
+ interface AxeLike {
48
+ run(context: unknown, options?: unknown): Promise<AxeRunResult>;
49
+ }
50
+
51
+ /** axe-core's result shape (only the fields we consume). */
52
+ interface AxeRunResult {
53
+ violations: Array<{
54
+ id: string;
55
+ impact: AuditImpact | null;
56
+ help: string;
57
+ helpUrl?: string;
58
+ nodes: Array<{
59
+ target?: string[] | string;
60
+ failureSummary?: string;
61
+ html?: string;
62
+ }>;
63
+ }>;
64
+ }
65
+
66
+ /** Minimal structural typing for the DOM provider handle. */
67
+ interface DomProvider {
68
+ kind: "jsdom" | "happy-dom";
69
+ fromHtml(html: string, url: string): Promise<{ window: unknown; dispose: () => void }>;
70
+ }
71
+
72
+ const DEFAULT_MAX_FILES = 500;
73
+ const DEFAULT_MIN_IMPACT: AuditImpact = "minor";
74
+
75
+ /**
76
+ * Zero every entry in an impact-count record. Returned by value so
77
+ * callers never mutate a shared singleton.
78
+ */
79
+ function emptyImpactCounts(): Record<AuditImpact, number> {
80
+ return { minor: 0, moderate: 0, serious: 0, critical: 0 };
81
+ }
82
+
83
+ /**
84
+ * Resolve the axe-core module. Tries the caller-supplied loader first
85
+ * (test-only path), then falls back to dynamic `import("axe-core")`.
86
+ * Returns `null` when the package is not installed — NEVER throws.
87
+ */
88
+ async function resolveAxe(options: RunAuditOptions): Promise<AxeLike | null> {
89
+ const tryLoad = async (loader: () => Promise<unknown>): Promise<AxeLike | null> => {
90
+ try {
91
+ const mod = await loader();
92
+ if (!mod) return null;
93
+ // axe-core exports `.default` under ESM/CJS interop. Accept either.
94
+ const candidate = (mod as { default?: unknown }).default ?? mod;
95
+ if (candidate && typeof (candidate as AxeLike).run === "function") {
96
+ return candidate as AxeLike;
97
+ }
98
+ return null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ };
103
+
104
+ if (options.axeLoader) return tryLoad(options.axeLoader);
105
+ // @ts-ignore -- optional peer dependency, may not be resolvable at typecheck time
106
+ return tryLoad(() => import("axe-core"));
107
+ }
108
+
109
+ /**
110
+ * Resolve a DOM provider. Prefers jsdom; falls back to HappyDOM via
111
+ * `happy-dom`'s `Window` export (the same class Mandu's test harness
112
+ * already depends on).
113
+ */
114
+ async function resolveDomProvider(options: RunAuditOptions): Promise<DomProvider | null> {
115
+ // Caller override — used exclusively by tests that inject a fake
116
+ // with a `.kind` field.
117
+ if (options.domLoader) {
118
+ try {
119
+ const mod = await options.domLoader();
120
+ if (mod && typeof mod === "object" && "kind" in mod && "fromHtml" in mod) {
121
+ return mod as DomProvider;
122
+ }
123
+ } catch {
124
+ return null;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ // Preferred path — jsdom.
130
+ try {
131
+ // @ts-ignore -- optional peer dependency, may not be resolvable at typecheck time
132
+ const jsdom = await import("jsdom");
133
+ const JSDOMCtor = (jsdom as { JSDOM?: new (html: string, opts?: unknown) => unknown }).JSDOM;
134
+ if (JSDOMCtor) {
135
+ return {
136
+ kind: "jsdom",
137
+ async fromHtml(html: string, url: string) {
138
+ const instance = new JSDOMCtor(html, { url });
139
+ const window = (instance as { window: unknown }).window;
140
+ return {
141
+ window,
142
+ dispose() {
143
+ const w = window as { close?: () => void };
144
+ if (typeof w.close === "function") {
145
+ try { w.close(); } catch { /* no-op */ }
146
+ }
147
+ },
148
+ };
149
+ },
150
+ };
151
+ }
152
+ } catch {
153
+ // jsdom not installed — fall through to HappyDOM.
154
+ }
155
+
156
+ // Fallback path — HappyDOM.
157
+ try {
158
+ // @ts-ignore -- optional peer dependency, may not be resolvable at typecheck time
159
+ const happy = await import("happy-dom");
160
+ const WindowCtor = (happy as { Window?: new (opts?: { url?: string; innerWidth?: number }) => unknown }).Window;
161
+ if (WindowCtor) {
162
+ return {
163
+ kind: "happy-dom",
164
+ async fromHtml(html: string, url: string) {
165
+ const window = new WindowCtor({ url, innerWidth: 1024 }) as {
166
+ document: { write: (html: string) => void; close: () => void };
167
+ close?: () => Promise<void>;
168
+ };
169
+ window.document.write(html);
170
+ window.document.close();
171
+ return {
172
+ window,
173
+ dispose() {
174
+ if (typeof window.close === "function") {
175
+ try { window.close(); } catch { /* no-op */ }
176
+ }
177
+ },
178
+ };
179
+ },
180
+ };
181
+ }
182
+ } catch {
183
+ // HappyDOM not installed — both providers exhausted.
184
+ }
185
+
186
+ return null;
187
+ }
188
+
189
+ /**
190
+ * Flatten axe-core's node shape into our slim `AuditNode`. axe emits
191
+ * `target` as either a string or `string[]` depending on iframe
192
+ * context; we normalize to a single selector chain joined by `>`.
193
+ */
194
+ function normalizeNodes(
195
+ raw: AxeRunResult["violations"][number]["nodes"]
196
+ ): AuditNode[] {
197
+ const nodes: AuditNode[] = [];
198
+ for (const n of raw.slice(0, 10)) {
199
+ let target: string;
200
+ if (Array.isArray(n.target)) {
201
+ target = n.target.filter((s) => typeof s === "string").join(" > ");
202
+ } else if (typeof n.target === "string") {
203
+ target = n.target;
204
+ } else {
205
+ target = "(unknown)";
206
+ }
207
+ const html = typeof n.html === "string" && n.html.length > 300
208
+ ? n.html.slice(0, 297) + "..."
209
+ : n.html;
210
+ nodes.push({
211
+ target,
212
+ failureSummary: n.failureSummary ?? "",
213
+ ...(html ? { html } : {}),
214
+ });
215
+ }
216
+ return nodes;
217
+ }
218
+
219
+ /**
220
+ * Audit a single HTML file. Returns the violations discovered (already
221
+ * filtered by `minImpact`) or `null` when the file could not be read —
222
+ * caller decides whether to warn or abort.
223
+ */
224
+ async function auditFile(
225
+ absFile: string,
226
+ axe: AxeLike,
227
+ dom: DomProvider,
228
+ minImpact: AuditImpact
229
+ ): Promise<AuditViolation[] | null> {
230
+ let html: string;
231
+ try {
232
+ html = await fs.readFile(absFile, "utf-8");
233
+ } catch {
234
+ return null;
235
+ }
236
+
237
+ const pageUrl = "file://" + absFile.replace(/\\/g, "/");
238
+ let handle: { window: unknown; dispose: () => void };
239
+ try {
240
+ handle = await dom.fromHtml(html, pageUrl);
241
+ } catch {
242
+ return null;
243
+ }
244
+
245
+ try {
246
+ // axe-core accepts a `document` as context. Both jsdom and HappyDOM
247
+ // expose `.window.document`.
248
+ const w = handle.window as { document?: unknown };
249
+ const context = w.document ?? handle.window;
250
+ const result = await axe.run(context);
251
+ const out: AuditViolation[] = [];
252
+ for (const v of result.violations) {
253
+ if (!impactAtLeast(v.impact ?? null, minImpact)) continue;
254
+ out.push({
255
+ file: absFile,
256
+ rule: v.id,
257
+ impact: v.impact ?? null,
258
+ help: v.help,
259
+ helpUrl: v.helpUrl,
260
+ nodes: normalizeNodes(v.nodes),
261
+ ...(getFixHint(v.id) ? { fixHint: getFixHint(v.id)! } : {}),
262
+ });
263
+ }
264
+ return out;
265
+ } catch {
266
+ return null;
267
+ } finally {
268
+ handle.dispose();
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Public entry. Run axe-core against every HTML file in `htmlFiles`
274
+ * and aggregate the results. See `./types.ts` for the full report
275
+ * shape; this function never throws — every failure mode is surfaced
276
+ * via `outcome` + `note`.
277
+ */
278
+ export async function runAudit(
279
+ htmlFiles: string[],
280
+ options: RunAuditOptions = {}
281
+ ): Promise<AuditReport> {
282
+ const started = performance.now();
283
+ const minImpact = options.minImpact ?? DEFAULT_MIN_IMPACT;
284
+ const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
285
+ const bounded = htmlFiles.slice(0, maxFiles);
286
+
287
+ const axe = await resolveAxe(options);
288
+ if (!axe) {
289
+ return {
290
+ outcome: "axe-missing",
291
+ filesScanned: 0,
292
+ violations: [],
293
+ impactCounts: emptyImpactCounts(),
294
+ minImpact,
295
+ note: "axe-core not installed — skipping audit (bun add -d axe-core jsdom)",
296
+ durationMs: 0,
297
+ };
298
+ }
299
+
300
+ const dom = await resolveDomProvider(options);
301
+ if (!dom) {
302
+ return {
303
+ outcome: "axe-missing",
304
+ filesScanned: 0,
305
+ violations: [],
306
+ impactCounts: emptyImpactCounts(),
307
+ minImpact,
308
+ note: "No DOM provider available — install jsdom (recommended) or happy-dom",
309
+ durationMs: 0,
310
+ };
311
+ }
312
+
313
+ const allViolations: AuditViolation[] = [];
314
+ const impactCounts = emptyImpactCounts();
315
+ let scanned = 0;
316
+
317
+ for (const file of bounded) {
318
+ const abs = path.resolve(file);
319
+ const perFile = await auditFile(abs, axe, dom, minImpact);
320
+ if (perFile === null) continue; // unreadable — counts as not scanned
321
+ scanned += 1;
322
+ for (const v of perFile) {
323
+ allViolations.push(v);
324
+ if (v.impact) impactCounts[v.impact] += 1;
325
+ }
326
+ }
327
+
328
+ return {
329
+ outcome: allViolations.length > 0 ? "violations" : "ok",
330
+ filesScanned: scanned,
331
+ violations: allViolations,
332
+ impactCounts,
333
+ minImpact,
334
+ durationMs: Math.round(performance.now() - started),
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Pretty-print an audit report as a multi-line ASCII table suitable
340
+ * for CLI output. Separate from `runAudit` so JSON consumers stay
341
+ * unaffected by formatting concerns.
342
+ */
343
+ export function formatAuditReport(report: AuditReport): string {
344
+ const lines: string[] = [];
345
+ lines.push("Accessibility audit (axe-core)");
346
+ lines.push("=".repeat(50));
347
+
348
+ if (report.outcome === "axe-missing") {
349
+ lines.push(report.note ?? "axe-core not installed — skipping audit");
350
+ lines.push("");
351
+ lines.push(" Install the optional peers to enable:");
352
+ lines.push(" bun add -d axe-core jsdom");
353
+ return lines.join("\n");
354
+ }
355
+
356
+ lines.push(
357
+ ` Files scanned: ${report.filesScanned} · ` +
358
+ `Violations: ${report.violations.length} · ` +
359
+ `Duration: ${report.durationMs}ms`
360
+ );
361
+ lines.push(
362
+ ` By impact: ` +
363
+ AUDIT_IMPACT_ORDER
364
+ .map((i) => `${i}=${report.impactCounts[i]}`)
365
+ .join(" ")
366
+ );
367
+ lines.push(` Min impact: ${report.minImpact}`);
368
+ lines.push("");
369
+
370
+ if (report.outcome === "ok") {
371
+ lines.push(" No violations at or above minImpact. PASS.");
372
+ return lines.join("\n");
373
+ }
374
+
375
+ // Group by rule id so the table is navigable.
376
+ const byRule = new Map<string, AuditViolation[]>();
377
+ for (const v of report.violations) {
378
+ if (!byRule.has(v.rule)) byRule.set(v.rule, []);
379
+ byRule.get(v.rule)!.push(v);
380
+ }
381
+
382
+ for (const [rule, violations] of byRule) {
383
+ const first = violations[0];
384
+ const impact = first.impact ?? "unknown";
385
+ lines.push(` [${impact.toUpperCase()}] ${rule} — ${first.help}`);
386
+ if (first.fixHint) lines.push(` Fix: ${first.fixHint}`);
387
+ const totalNodes = violations.reduce((n, v) => n + v.nodes.length, 0);
388
+ lines.push(` ${violations.length} file(s), ${totalNodes} node(s)`);
389
+ if (first.helpUrl) lines.push(` Docs: ${first.helpUrl}`);
390
+ lines.push("");
391
+ }
392
+
393
+ return lines.join("\n");
394
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @mandujs/core/a11y — audit result types.
3
+ *
4
+ * Phase 18.χ introduces a framework-level accessibility guardrail that
5
+ * runs axe-core against prerendered HTML and aggregates WCAG violations
6
+ * into a structured report. axe-core and jsdom are **optional peer
7
+ * dependencies** — we never install them for the user. When absent, the
8
+ * runner degrades gracefully to an `"axe-missing"` outcome instead of
9
+ * throwing or bundling ~1 MB of rules into every consumer.
10
+ *
11
+ * Severity mapping matches axe-core's `impact` scale verbatim so CI
12
+ * gates using `--audit-fail-on=<impact>` speak the same vocabulary as
13
+ * the axe documentation:
14
+ *
15
+ * - `minor` — nice-to-fix cosmetic a11y issue
16
+ * - `moderate` — noticeable UX degradation
17
+ * - `serious` — significant barrier for users with disabilities
18
+ * - `critical` — blocks assistive-tech users outright (default gate)
19
+ */
20
+
21
+ /** axe-core impact scale (lowest → highest). */
22
+ export type AuditImpact = "minor" | "moderate" | "serious" | "critical";
23
+
24
+ /** Canonical ordering used by severity comparisons. */
25
+ export const AUDIT_IMPACT_ORDER: readonly AuditImpact[] = [
26
+ "minor",
27
+ "moderate",
28
+ "serious",
29
+ "critical",
30
+ ] as const;
31
+
32
+ /**
33
+ * Return `true` when `candidate` is at least as severe as `threshold`.
34
+ * Used by both the runner (severity filter) and the CLI gate
35
+ * (`--audit-fail-on`).
36
+ */
37
+ export function impactAtLeast(candidate: AuditImpact | null | undefined, threshold: AuditImpact): boolean {
38
+ if (!candidate) return false;
39
+ const ci = AUDIT_IMPACT_ORDER.indexOf(candidate);
40
+ const ti = AUDIT_IMPACT_ORDER.indexOf(threshold);
41
+ return ci >= 0 && ti >= 0 && ci >= ti;
42
+ }
43
+
44
+ /** Per-node failure detail. Keeps payloads small — we intentionally
45
+ * discard axe's full `any`/`all`/`none` check trees and keep only the
46
+ * fields humans act on (selector + failure summary). */
47
+ export interface AuditNode {
48
+ /** CSS selector chain axe-core emits (e.g. `html > body > div#root`). */
49
+ target: string;
50
+ /** axe's `failureSummary` — already-localized multi-line description. */
51
+ failureSummary: string;
52
+ /** Raw HTML snippet for the offending node (truncated to 300 chars). */
53
+ html?: string;
54
+ }
55
+
56
+ /** One WCAG violation emitted by axe-core, scoped to a single HTML file. */
57
+ export interface AuditViolation {
58
+ /** Source HTML file (absolute path). */
59
+ file: string;
60
+ /** axe-core rule id, e.g. `color-contrast`, `label`, `image-alt`. */
61
+ rule: string;
62
+ /** Severity — may be `null` when axe fails to classify (rare). */
63
+ impact: AuditImpact | null;
64
+ /** One-line human-readable rule summary (`node.help`). */
65
+ help: string;
66
+ /** URL to axe-core's documentation for this rule. */
67
+ helpUrl?: string;
68
+ /** Offending DOM nodes. Capped at 10 to keep reports consumable. */
69
+ nodes: AuditNode[];
70
+ /** Phase 18.χ hint — short actionable fix recipe when we recognize the rule. */
71
+ fixHint?: string;
72
+ }
73
+
74
+ /**
75
+ * Audit outcome. The three arms are mutually exclusive:
76
+ *
77
+ * - `ok` — runner executed, zero violations ≥ minImpact.
78
+ * - `violations` — runner executed, at least one violation fired.
79
+ * - `axe-missing` — optional dep not installed; runner was a no-op.
80
+ *
81
+ * The `filesScanned` counter is always present so callers can print a
82
+ * meaningful summary ("audited 12 files, 0 violations") regardless of
83
+ * outcome.
84
+ */
85
+ export interface AuditReport {
86
+ outcome: "ok" | "violations" | "axe-missing";
87
+ /** Number of HTML files actually fed to axe-core (0 when dep missing). */
88
+ filesScanned: number;
89
+ /** Aggregated violations across every file. Empty when `outcome !== "violations"`. */
90
+ violations: AuditViolation[];
91
+ /** Count of violations at each impact level. */
92
+ impactCounts: Record<AuditImpact, number>;
93
+ /** Effective severity filter applied during this run. */
94
+ minImpact: AuditImpact;
95
+ /** Optional human-readable note (e.g. why the runner skipped). */
96
+ note?: string;
97
+ /** Elapsed wallclock ms. Zero when runner was a no-op. */
98
+ durationMs: number;
99
+ }
100
+
101
+ /** Options accepted by `runAudit`. */
102
+ export interface RunAuditOptions {
103
+ /**
104
+ * Minimum severity to include in the report. Violations below this
105
+ * threshold are dropped at aggregation time. Default `"minor"`.
106
+ */
107
+ minImpact?: AuditImpact;
108
+ /**
109
+ * Cap on files to audit. Prevents catastrophic CI runs on projects
110
+ * that accidentally prerender thousands of routes. Default `500`.
111
+ */
112
+ maxFiles?: number;
113
+ /**
114
+ * Override for axe-core module resolution. When undefined, the runner
115
+ * uses dynamic `import("axe-core")`. Tests inject a fixture instead
116
+ * of shimming the module resolver.
117
+ */
118
+ axeLoader?: () => Promise<unknown>;
119
+ /**
120
+ * Override for jsdom module resolution. Same contract as `axeLoader`;
121
+ * returning `null` forces the runner to fall back to HappyDOM which is
122
+ * already a transitive test-time dep for Mandu.
123
+ */
124
+ domLoader?: () => Promise<unknown>;
125
+ }
@@ -79,6 +79,7 @@ import path from "path";
79
79
  import zlib from "zlib";
80
80
 
81
81
  import type { BundleManifest } from "./types";
82
+ import type { BudgetReport } from "./budget";
82
83
 
83
84
  // ============================================================================
84
85
  // Types
@@ -439,21 +440,26 @@ export async function analyzeBundle(
439
440
  *
440
441
  * Returns the absolute paths of both files so the CLI can print them.
441
442
  * Callers that want JSON only can skip the HTML step via `{ htmlPath: null }`.
443
+ *
444
+ * Phase 18.φ — `opts.budget` is an optional pre-computed budget report
445
+ * that, when present, renders a budget-bar section in the HTML output
446
+ * and is serialised alongside `report.json` as `report.budget`.
442
447
  */
443
448
  export async function writeAnalyzeReport(
444
449
  rootDir: string,
445
450
  report: AnalyzeReport,
446
- opts: { html?: boolean } = {}
451
+ opts: { html?: boolean; budget?: BudgetReport | null } = {}
447
452
  ): Promise<{ jsonPath: string; htmlPath: string | null }> {
448
453
  const outDir = path.join(rootDir, ".mandu", "analyze");
449
454
  await fs.mkdir(outDir, { recursive: true });
450
455
  const jsonPath = path.join(outDir, "report.json");
451
- await fs.writeFile(jsonPath, JSON.stringify(report, null, 2), "utf8");
456
+ const jsonPayload = opts.budget ? { ...report, budget: opts.budget } : report;
457
+ await fs.writeFile(jsonPath, JSON.stringify(jsonPayload, null, 2), "utf8");
452
458
 
453
459
  let htmlPath: string | null = null;
454
460
  if (opts.html !== false) {
455
461
  htmlPath = path.join(outDir, "report.html");
456
- await fs.writeFile(htmlPath, renderAnalyzeHtml(report), "utf8");
462
+ await fs.writeFile(htmlPath, renderAnalyzeHtml(report, opts.budget ?? null), "utf8");
457
463
  }
458
464
  return { jsonPath, htmlPath };
459
465
  }
@@ -475,7 +481,10 @@ export async function writeAnalyzeReport(
475
481
  * toggles visibility. This keeps the report working even with JS
476
482
  * disabled (you lose drill-down, but the island treemap still renders).
477
483
  */
478
- export function renderAnalyzeHtml(report: AnalyzeReport): string {
484
+ export function renderAnalyzeHtml(
485
+ report: AnalyzeReport,
486
+ budget: BudgetReport | null = null
487
+ ): string {
479
488
  const { islands, shared, summary } = report;
480
489
 
481
490
  // ── Island-level treemap ─────────────────────────────────────────────────
@@ -585,6 +594,18 @@ export function renderAnalyzeHtml(report: AnalyzeReport): string {
585
594
  <div class="card"><div class="label">Dedupe savings</div><div class="value">${fmtBytes(summary.dedupeSavings)}</div></div>
586
595
  `;
587
596
 
597
+ // ── Phase 18.φ — Budget bar section ──────────────────────────────────────
598
+ //
599
+ // Renders one horizontal bar per island when a budget was evaluated,
600
+ // coloured by `BudgetStatus`: green (within), yellow (within 10% of
601
+ // limit), red (exceeded). The bar width is proportional to
602
+ // `island.gz / gzLimit` (or `raw / rawLimit` if gzLimit is null).
603
+ // When every axis is unconstrained the bar is hidden with a muted "—"
604
+ // placeholder. Matches the "red/yellow/green" spec in Phase 18.φ.
605
+ const budgetSection = budget
606
+ ? renderBudgetSection(budget)
607
+ : "";
608
+
588
609
  return `<!doctype html>
589
610
  <html lang="en">
590
611
  <head>
@@ -621,6 +642,25 @@ export function renderAnalyzeHtml(report: AnalyzeReport): string {
621
642
  button.close { background: #1f2937; color: #e5e7eb; border: 1px solid #374151; padding: 4px 10px; border-radius: 3px; cursor: pointer; font-family: inherit; font-size: 11px; }
622
643
  button.close:hover { background: #374151; }
623
644
  .drill-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
645
+ /* Phase 18.φ — budget bars */
646
+ .budget-row { display: grid; grid-template-columns: 180px 1fr 160px; gap: 10px; align-items: center; padding: 4px 0; border-bottom: 1px solid #1f2937; }
647
+ .budget-row:last-child { border-bottom: none; }
648
+ .budget-name { font-size: 12px; color: #e5e7eb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
649
+ .budget-meta { font-size: 11px; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums; }
650
+ .budget-bar-track { position: relative; height: 10px; background: #0f172a; border: 1px solid #1f2937; border-radius: 2px; overflow: hidden; }
651
+ .budget-bar-fill { height: 100%; border-radius: 2px; }
652
+ .budget-bar-fill.within { background: linear-gradient(90deg, #16a34a, #22c55e); }
653
+ .budget-bar-fill.within10 { background: linear-gradient(90deg, #ca8a04, #eab308); }
654
+ .budget-bar-fill.exceeded { background: linear-gradient(90deg, #b91c1c, #ef4444); }
655
+ .budget-bar-fill.unbounded { background: repeating-linear-gradient(45deg, #1f2937, #1f2937 4px, #0f172a 4px, #0f172a 8px); }
656
+ .budget-legend { display: inline-flex; gap: 14px; font-size: 11px; color: #94a3b8; margin-bottom: 10px; }
657
+ .budget-legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
658
+ .budget-legend .lg-within::before { background: #22c55e; }
659
+ .budget-legend .lg-within10::before { background: #eab308; }
660
+ .budget-legend .lg-exceeded::before { background: #ef4444; }
661
+ .budget-mode { display: inline-block; font-size: 10px; text-transform: uppercase; padding: 2px 6px; border-radius: 2px; border: 1px solid #374151; color: #cbd5e1; margin-left: 8px; letter-spacing: 0.04em; }
662
+ .budget-mode.error { background: #7f1d1d; border-color: #991b1b; color: #fecaca; }
663
+ .budget-mode.warning { background: #713f12; border-color: #854d0e; color: #fde68a; }
624
664
  </style>
625
665
  </head>
626
666
  <body>
@@ -632,6 +672,8 @@ export function renderAnalyzeHtml(report: AnalyzeReport): string {
632
672
  <h2>Summary</h2>
633
673
  <div class="cards">${summaryCards}</div>
634
674
 
675
+ ${budgetSection}
676
+
635
677
  <h2>Islands (click to drill in)</h2>
636
678
  <svg class="treemap" viewBox="0 0 ${VIEW_W} ${VIEW_H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Island bundle treemap">
637
679
  ${islandSvg || `<text x="20" y="30" fill="#64748b">No islands to display.</text>`}
@@ -703,6 +745,79 @@ export function renderAnalyzeHtml(report: AnalyzeReport): string {
703
745
  // Helpers — formatting + squarify
704
746
  // ============================================================================
705
747
 
748
+ /**
749
+ * Phase 18.φ — render the budget-bar block. Colour-codes each island
750
+ * by {@link BudgetReport.BudgetStatus} and the project-wide total (when
751
+ * present). Islands without any applicable limit render a diagonal-
752
+ * hatched "unbounded" bar so the user sees the row but understands
753
+ * nothing is enforced. Self-contained: no JS, no external assets.
754
+ */
755
+ function renderBudgetSection(budget: BudgetReport): string {
756
+ const modeClass = budget.mode === "error" ? "error" : "warning";
757
+ const rows = budget.islands
758
+ .map((i) => renderBudgetRow(i.name, i.raw, i.gz, i.rawLimit, i.gzLimit, i.status))
759
+ .join("");
760
+ const totalRow = budget.total
761
+ ? renderBudgetRow(
762
+ "<project total>",
763
+ budget.total.raw,
764
+ budget.total.gz,
765
+ budget.total.rawLimit,
766
+ budget.total.gzLimit,
767
+ budget.total.status
768
+ )
769
+ : "";
770
+ const exceedHeadline = budget.hasExceeded
771
+ ? ` · <span style="color:#fca5a5">${budget.exceededCount} over limit</span>`
772
+ : "";
773
+ return `
774
+ <h2>Bundle budget <span class="budget-mode ${modeClass}">${escText(budget.mode)}</span></h2>
775
+ <div class="budget-legend">
776
+ <span class="lg-within">within</span>
777
+ <span class="lg-within10">approaching (≥90%)</span>
778
+ <span class="lg-exceeded">exceeded</span>
779
+ </div>
780
+ <p class="muted" style="margin:0 0 10px">
781
+ ${budget.withinCount}/${budget.islandCount} islands within limits${exceedHeadline}
782
+ </p>
783
+ <div class="card" style="padding:12px 14px">
784
+ ${rows}
785
+ ${totalRow}
786
+ </div>`;
787
+ }
788
+
789
+ function renderBudgetRow(
790
+ name: string,
791
+ raw: number,
792
+ gz: number,
793
+ rawLimit: number | null,
794
+ gzLimit: number | null,
795
+ status: "within" | "within10" | "exceeded"
796
+ ): string {
797
+ // Prefer gz-axis progress bar when a gz limit exists (the 90%-of-the-
798
+ // time-useful axis); fall back to raw when only raw is constrained.
799
+ let pct = 0;
800
+ let barClass: string = status;
801
+ let meta: string;
802
+ if (gzLimit !== null) {
803
+ pct = Math.min(100, Math.max(0, (gz / Math.max(gzLimit, 1)) * 100));
804
+ meta = `${fmtBytes(gz)} / ${fmtBytes(gzLimit)} gz`;
805
+ } else if (rawLimit !== null) {
806
+ pct = Math.min(100, Math.max(0, (raw / Math.max(rawLimit, 1)) * 100));
807
+ meta = `${fmtBytes(raw)} / ${fmtBytes(rawLimit)} raw`;
808
+ } else {
809
+ barClass = "unbounded";
810
+ pct = 100;
811
+ meta = `${fmtBytes(gz)} gz · no limit`;
812
+ }
813
+ return `
814
+ <div class="budget-row">
815
+ <div class="budget-name" title="${escAttr(name)}">${escText(name)}</div>
816
+ <div class="budget-bar-track"><div class="budget-bar-fill ${barClass}" style="width:${pct.toFixed(1)}%"></div></div>
817
+ <div class="budget-meta">${meta}</div>
818
+ </div>`;
819
+ }
820
+
706
821
  /** Human-readable byte formatter. Matches the style used by `printBundleStats`. */
707
822
  export function fmtBytes(n: number): string {
708
823
  if (!Number.isFinite(n) || n <= 0) return "0 B";