@stati/core 1.29.2 → 2.0.1

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 (104) hide show
  1. package/README.md +10 -13
  2. package/dist/config/loader.d.ts +9 -0
  3. package/dist/config/loader.d.ts.map +1 -1
  4. package/dist/config/loader.js +77 -1
  5. package/dist/core/build.d.ts.map +1 -1
  6. package/dist/core/build.js +39 -29
  7. package/dist/core/content.d.ts.map +1 -1
  8. package/dist/core/content.js +9 -7
  9. package/dist/core/dev.d.ts.map +1 -1
  10. package/dist/core/dev.js +156 -197
  11. package/dist/core/eta-plugin.d.ts +60 -0
  12. package/dist/core/eta-plugin.d.ts.map +1 -0
  13. package/dist/core/eta-plugin.js +143 -0
  14. package/dist/core/isg/builder.d.ts +37 -19
  15. package/dist/core/isg/builder.d.ts.map +1 -1
  16. package/dist/core/isg/builder.js +70 -153
  17. package/dist/core/isg/deps.d.ts +8 -70
  18. package/dist/core/isg/deps.d.ts.map +1 -1
  19. package/dist/core/isg/deps.js +10 -401
  20. package/dist/core/isg/hash.d.ts.map +1 -1
  21. package/dist/core/isg/hash.js +2 -1
  22. package/dist/core/isg/index.d.ts +2 -2
  23. package/dist/core/isg/index.d.ts.map +1 -1
  24. package/dist/core/isg/index.js +3 -3
  25. package/dist/core/isg/manifest.d.ts.map +1 -1
  26. package/dist/core/isg/manifest.js +6 -1
  27. package/dist/core/markdown.d.ts +1 -1
  28. package/dist/core/markdown.d.ts.map +1 -1
  29. package/dist/core/markdown.js +3 -2
  30. package/dist/core/template-index.d.ts +209 -0
  31. package/dist/core/template-index.d.ts.map +1 -0
  32. package/dist/core/template-index.js +484 -0
  33. package/dist/core/template-render.d.ts +137 -0
  34. package/dist/core/template-render.d.ts.map +1 -0
  35. package/dist/core/template-render.js +444 -0
  36. package/dist/core/templates.d.ts +29 -11
  37. package/dist/core/templates.d.ts.map +1 -1
  38. package/dist/core/templates.js +115 -196
  39. package/dist/core/utils/context-validation.utils.d.ts +10 -6
  40. package/dist/core/utils/context-validation.utils.d.ts.map +1 -1
  41. package/dist/core/utils/context-validation.utils.js +39 -16
  42. package/dist/core/utils/date.utils.d.ts +1 -1
  43. package/dist/core/utils/date.utils.js +1 -1
  44. package/dist/core/utils/dev-css-watcher.utils.d.ts.map +1 -1
  45. package/dist/core/utils/dev-css-watcher.utils.js +3 -2
  46. package/dist/core/utils/error-overlay.utils.d.ts.map +1 -1
  47. package/dist/core/utils/error-overlay.utils.js +7 -45
  48. package/dist/core/utils/front-matter.utils.d.ts +50 -0
  49. package/dist/core/utils/front-matter.utils.d.ts.map +1 -0
  50. package/dist/core/utils/front-matter.utils.js +157 -0
  51. package/dist/core/utils/index.d.ts +4 -5
  52. package/dist/core/utils/index.d.ts.map +1 -1
  53. package/dist/core/utils/index.js +5 -7
  54. package/dist/core/utils/paths.utils.d.ts +3 -3
  55. package/dist/core/utils/paths.utils.js +6 -6
  56. package/dist/core/utils/tailwind-inventory.utils.d.ts +2 -2
  57. package/dist/core/utils/tailwind-inventory.utils.d.ts.map +1 -1
  58. package/dist/core/utils/tailwind-inventory.utils.js +15 -6
  59. package/dist/core/utils/template-discovery.utils.d.ts +3 -14
  60. package/dist/core/utils/template-discovery.utils.d.ts.map +1 -1
  61. package/dist/core/utils/template-discovery.utils.js +2 -62
  62. package/dist/core/utils/template-errors.utils.d.ts +14 -1
  63. package/dist/core/utils/template-errors.utils.d.ts.map +1 -1
  64. package/dist/core/utils/template-errors.utils.js +45 -4
  65. package/dist/core/utils/theme.utils.d.ts +17 -0
  66. package/dist/core/utils/theme.utils.d.ts.map +1 -0
  67. package/dist/core/utils/theme.utils.js +59 -0
  68. package/dist/images/index.d.ts +1 -2
  69. package/dist/images/index.d.ts.map +1 -1
  70. package/dist/images/index.js +0 -1
  71. package/dist/images/markdown-rule.d.ts +1 -2
  72. package/dist/images/markdown-rule.d.ts.map +1 -1
  73. package/dist/images/og.d.ts +2 -15
  74. package/dist/images/og.d.ts.map +1 -1
  75. package/dist/images/og.js +4 -47
  76. package/dist/images/runtime.d.ts +24 -5
  77. package/dist/images/runtime.d.ts.map +1 -1
  78. package/dist/images/runtime.js +14 -12
  79. package/dist/index.d.ts +2 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/metrics/noop.d.ts.map +1 -1
  82. package/dist/metrics/noop.js +4 -1
  83. package/dist/metrics/recorder.d.ts.map +1 -1
  84. package/dist/metrics/recorder.js +11 -8
  85. package/dist/metrics/types.d.ts +31 -5
  86. package/dist/metrics/types.d.ts.map +1 -1
  87. package/dist/metrics/utils/html-report.utils.d.ts +2 -1
  88. package/dist/metrics/utils/html-report.utils.d.ts.map +1 -1
  89. package/dist/metrics/utils/html-report.utils.js +1115 -1136
  90. package/dist/types/config.d.ts +18 -6
  91. package/dist/types/config.d.ts.map +1 -1
  92. package/dist/types/content.d.ts +83 -16
  93. package/dist/types/content.d.ts.map +1 -1
  94. package/dist/types/index.d.ts +4 -3
  95. package/dist/types/index.d.ts.map +1 -1
  96. package/dist/types/isg.d.ts +48 -2
  97. package/dist/types/isg.d.ts.map +1 -1
  98. package/package.json +6 -6
  99. package/dist/core/utils/callable-partials.utils.d.ts +0 -60
  100. package/dist/core/utils/callable-partials.utils.d.ts.map +0 -1
  101. package/dist/core/utils/callable-partials.utils.js +0 -127
  102. package/dist/core/utils/partial-validation.utils.d.ts +0 -9
  103. package/dist/core/utils/partial-validation.utils.d.ts.map +0 -1
  104. package/dist/core/utils/partial-validation.utils.js +0 -107
@@ -2,9 +2,11 @@
2
2
  * HTML Metrics Report Generator
3
3
  *
4
4
  * Generates a self-contained HTML file for visualizing build performance metrics.
5
- * Handles both standard and detailed (with pageTimings) metrics files.
5
+ * Handles both standard and detailed (with pageTimings) metrics files. The page shares its
6
+ * look with the Stati documentation site and the development error overlay.
6
7
  */
7
8
  import { writeFile } from 'node:fs/promises';
9
+ import { STATI_FAVICON_HREF, STATI_MARK_PATH, STATI_PRINT_STYLES, STATI_THEME_STYLES, } from '../../core/utils/theme.utils.js';
8
10
  /**
9
11
  * Format bytes to human-readable string
10
12
  */
@@ -14,6 +16,8 @@ function formatBytes(bytes) {
14
16
  const k = 1024;
15
17
  const sizes = ['B', 'KB', 'MB', 'GB'];
16
18
  const i = Math.floor(Math.log(bytes) / Math.log(k));
19
+ if (i === 0)
20
+ return `${bytes} B`;
17
21
  return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
18
22
  }
19
23
  /**
@@ -32,36 +36,57 @@ function formatDuration(ms) {
32
36
  function formatPercent(value) {
33
37
  return `${(value * 100).toFixed(1)}%`;
34
38
  }
39
+ /**
40
+ * Format a count with thousands separators
41
+ */
42
+ function formatCount(value) {
43
+ return value.toLocaleString('en-US');
44
+ }
45
+ /**
46
+ * Escapes text for HTML content and attribute values.
47
+ */
48
+ function escapeHtml(text) {
49
+ return text
50
+ .replace(/&/g, '&')
51
+ .replace(/</g, '&lt;')
52
+ .replace(/>/g, '&gt;')
53
+ .replace(/"/g, '&quot;')
54
+ .replace(/'/g, '&#39;');
55
+ }
35
56
  /**
36
57
  * Get human-readable phase name
37
58
  */
38
59
  function getPhaseLabel(key) {
39
60
  const labels = {
40
- configLoadMs: 'Config Loading',
41
- contentDiscoveryMs: 'Content Discovery',
42
- navigationBuildMs: 'Navigation Build',
43
- cacheManifestLoadMs: 'Cache Manifest Load',
44
- typescriptCompileMs: 'TypeScript Compile',
45
- pageRenderingMs: 'Page Rendering',
46
- shouldRebuildTotalMs: 'Rebuild Checks',
47
- renderPageTotalMs: 'Render Pages',
48
- fileWriteTotalMs: 'File Writing',
49
- cacheEntryTotalMs: 'Cache Entry Updates',
50
- searchIndexGenerationMs: 'Search Index Generation',
51
- searchIndexWriteMs: 'Search Index Write',
52
- assetCopyMs: 'Asset Copying',
53
- cacheManifestSaveMs: 'Cache Manifest Save',
54
- sitemapGenerationMs: 'Sitemap Generation',
55
- rssGenerationMs: 'RSS Generation',
56
- tailwindInitMs: 'Tailwind Init',
57
- tailwindInventoryMs: 'Tailwind Inventory',
58
- getDirectorySizeMs: 'Directory Size Calc',
59
- lockAcquireMs: 'Lock Acquire',
60
- lockReleaseMs: 'Lock Release',
61
- hookBeforeAllMs: 'Hook: beforeAll',
62
- hookAfterAllMs: 'Hook: afterAll',
63
- hookBeforeRenderTotalMs: 'Hook: beforeRender (total)',
64
- hookAfterRenderTotalMs: 'Hook: afterRender (total)',
61
+ configLoadMs: 'Config loading',
62
+ contentDiscoveryMs: 'Content discovery',
63
+ navigationBuildMs: 'Navigation build',
64
+ cacheManifestLoadMs: 'Cache manifest load',
65
+ typescriptCompileMs: 'TypeScript compile',
66
+ templateIndexMs: 'Template index',
67
+ pageRenderingMs: 'Page rendering',
68
+ shouldRebuildTotalMs: 'Rebuild checks',
69
+ markdownRenderTotalMs: 'Markdown',
70
+ renderPageTotalMs: 'Render pages',
71
+ postRenderTotalMs: 'After rendering',
72
+ fileWriteTotalMs: 'File writing',
73
+ cacheEntryTotalMs: 'Cache entry updates',
74
+ searchIndexGenerationMs: 'Search index generation',
75
+ searchIndexWriteMs: 'Search index write',
76
+ assetCopyMs: 'Asset copying',
77
+ cacheManifestSaveMs: 'Cache manifest save',
78
+ sitemapGenerationMs: 'Sitemap generation',
79
+ rssGenerationMs: 'RSS generation',
80
+ tailwindInitMs: 'Tailwind init',
81
+ tailwindInventoryMs: 'Tailwind inventory',
82
+ getDirectorySizeMs: 'Directory size',
83
+ lockAcquireMs: 'Lock acquire',
84
+ lockReleaseMs: 'Lock release',
85
+ hookBeforeAllMs: 'beforeAll hook',
86
+ hookAfterAllMs: 'afterAll hook',
87
+ hookBeforeRenderTotalMs: 'beforeRender hooks',
88
+ hookAfterRenderTotalMs: 'afterRender hooks',
89
+ imageSettleMs: 'Image generation',
65
90
  };
66
91
  return (labels[key] ||
67
92
  key
@@ -79,12 +104,15 @@ function getPhaseDescription(key) {
79
104
  navigationBuildMs: 'Time spent constructing the navigation tree from discovered pages. This includes sorting pages, building parent-child relationships, and generating breadcrumbs. Complex nested structures may increase this time.',
80
105
  cacheManifestLoadMs: 'Time spent reading the ISG cache manifest from disk. The manifest tracks which pages are cached and their dependencies. A large manifest or slow disk I/O can increase this time.',
81
106
  typescriptCompileMs: 'Time spent compiling TypeScript bundles using esbuild. This includes bundling client-side scripts defined in the config. More bundles or larger codebases will increase compile time.',
82
- pageRenderingMs: 'Total time spent in the page rendering pipeline, including rebuild checks, template rendering, file writing, and cache updates. This is typically the largest phase in a build.',
83
- shouldRebuildTotalMs: 'Aggregate time spent checking if each page needs to be rebuilt. This includes computing content hashes and comparing dependencies. Fast hash comparisons keep this time low.',
84
- renderPageTotalMs: 'Aggregate time spent actually rendering pages through the Eta template engine. Complex templates with many partials or heavy computations will increase this time.',
107
+ templateIndexMs: 'Time spent listing every .eta file in the source directory, once per build. Template names resolve against this list, so neither rendering nor the cache checks search folders page by page. A dev server keeps the list between rebuilds.',
108
+ pageRenderingMs: 'Total time spent in the page pipeline: the beforeAll hook, the search index, and for every page the rebuild check, rendering, hooks, file writing and cache update. The steps listed under it are its parts; the per-page ones are summed over all pages. This is typically the largest phase in a build.',
109
+ shouldRebuildTotalMs: 'Aggregate time spent checking if each page needs to be rebuilt. This includes computing content hashes, hashing the templates each page rendered, and checking the template names it looked up against the template index.',
110
+ markdownRenderTotalMs: "Aggregate time spent rendering each rendered page's Markdown to HTML with markdown-it, including its plugins, heading anchors, the table of contents and image sizing. Long pages and heavy plugins increase this time.",
111
+ renderPageTotalMs: 'Aggregate time spent actually rendering pages through the Eta template engine: each layout, the parents it names and the templates it includes. Templates a page does not use are never rendered. Complex templates with many includes or heavy computations will increase this time.',
112
+ postRenderTotalMs: 'Aggregate time spent on each rendered page between its templates and the file write: injecting SEO, search and bundle tags, registering the images the HTML uses, and hashing the data the page read for the cache.',
85
113
  fileWriteTotalMs: 'Aggregate time spent writing rendered HTML files to the dist directory. Slow disk I/O or antivirus scanning can increase this time significantly.',
86
114
  cacheEntryTotalMs: 'Aggregate time spent updating the ISG cache manifest with new page entries. This includes serializing page metadata and dependency information.',
87
- searchIndexGenerationMs: 'Time spent generating the search index from rendered page content. This includes extracting text, building the index structure, and computing relevance scores.',
115
+ searchIndexGenerationMs: 'Time spent generating the search index from page content before the pages render. This includes extracting text and headings and building the index structure.',
88
116
  searchIndexWriteMs: 'Time spent writing the search index JSON file to disk. Large indices with many documents will take longer to serialize and write.',
89
117
  assetCopyMs: 'Time spent copying static assets from the public directory to dist. Many files or large files will increase copy time. File watching may add overhead in dev mode.',
90
118
  cacheManifestSaveMs: 'Time spent persisting the updated cache manifest to disk. This happens at the end of every build to preserve ISG state for subsequent builds.',
@@ -99,11 +127,12 @@ function getPhaseDescription(key) {
99
127
  hookAfterAllMs: 'Time spent executing the afterAll hook defined in your config. Post-build processing like compression or uploads will show here.',
100
128
  hookBeforeRenderTotalMs: 'Aggregate time spent in beforeRender hooks across all pages. Per-page setup logic accumulates here.',
101
129
  hookAfterRenderTotalMs: 'Aggregate time spent in afterRender hooks across all pages. Per-page post-processing accumulates here.',
130
+ imageSettleMs: 'Time spent waiting for the responsive image variants and Open Graph cards the written pages use, and saving the images manifest. Reused files make this fast.',
102
131
  };
103
132
  return descriptions[key] || 'No description available for this phase.';
104
133
  }
105
134
  /**
106
- * Define the canonical execution order of phases
135
+ * Phases in the order a build runs them. The phases measured inside page rendering follow it.
107
136
  */
108
137
  const PHASE_EXECUTION_ORDER = [
109
138
  'lockAcquireMs',
@@ -112,17 +141,21 @@ const PHASE_EXECUTION_ORDER = [
112
141
  'cacheManifestLoadMs',
113
142
  'contentDiscoveryMs',
114
143
  'navigationBuildMs',
115
- 'hookBeforeAllMs',
116
144
  'typescriptCompileMs',
145
+ 'templateIndexMs',
146
+ 'pageRenderingMs',
147
+ 'hookBeforeAllMs',
148
+ 'searchIndexGenerationMs',
149
+ 'searchIndexWriteMs',
117
150
  'shouldRebuildTotalMs',
118
151
  'hookBeforeRenderTotalMs',
152
+ 'markdownRenderTotalMs',
119
153
  'renderPageTotalMs',
120
- 'hookAfterRenderTotalMs',
154
+ 'postRenderTotalMs',
121
155
  'fileWriteTotalMs',
122
156
  'cacheEntryTotalMs',
123
- 'pageRenderingMs',
124
- 'searchIndexGenerationMs',
125
- 'searchIndexWriteMs',
157
+ 'hookAfterRenderTotalMs',
158
+ 'imageSettleMs',
126
159
  'tailwindInventoryMs',
127
160
  'cacheManifestSaveMs',
128
161
  'assetCopyMs',
@@ -133,1396 +166,1342 @@ const PHASE_EXECUTION_ORDER = [
133
166
  'lockReleaseMs',
134
167
  ];
135
168
  /**
136
- * Generate phase breakdown data sorted by duration
169
+ * Phases measured inside page rendering, so its time includes theirs. The per-page ones are
170
+ * sums over all pages.
137
171
  */
138
- function generatePhaseData(phases) {
139
- return Object.entries(phases)
140
- .filter(([, value]) => value !== undefined && value > 0)
141
- .map(([key, value]) => ({
142
- name: key,
143
- value: value,
144
- label: getPhaseLabel(key),
145
- description: getPhaseDescription(key),
146
- }))
147
- .sort((a, b) => b.value - a.value);
172
+ const PAGE_RENDERING_STEPS = new Set([
173
+ 'hookBeforeAllMs',
174
+ 'searchIndexGenerationMs',
175
+ 'searchIndexWriteMs',
176
+ 'shouldRebuildTotalMs',
177
+ 'hookBeforeRenderTotalMs',
178
+ 'markdownRenderTotalMs',
179
+ 'renderPageTotalMs',
180
+ 'postRenderTotalMs',
181
+ 'fileWriteTotalMs',
182
+ 'cacheEntryTotalMs',
183
+ 'hookAfterRenderTotalMs',
184
+ ]);
185
+ function executionIndex(key) {
186
+ const index = PHASE_EXECUTION_ORDER.indexOf(key);
187
+ return index === -1 ? PHASE_EXECUTION_ORDER.length : index;
148
188
  }
149
189
  /**
150
- * Generate phase data in execution order for timeline view
190
+ * The recorded phases in the order the build runs them, each with its share of the build and
191
+ * where its bar starts on the timeline: where the phase before it ended. The phases measured
192
+ * inside page rendering are nested under it, so no time is counted twice.
151
193
  */
152
- function generateTimelineData(phases) {
153
- const activePhases = Object.entries(phases)
154
- .filter(([, value]) => value !== undefined && value > 0)
155
- .map(([key, value]) => ({
156
- name: key,
157
- value: value,
194
+ function generatePhaseRows(phases, durationMs) {
195
+ const recorded = Object.entries(phases)
196
+ .filter((entry) => entry[1] !== undefined && entry[1] > 0)
197
+ .sort(([a], [b]) => executionIndex(a) - executionIndex(b));
198
+ const nested = recorded.some(([key]) => key === 'pageRenderingMs');
199
+ const isStep = (key) => nested && PAGE_RENDERING_STEPS.has(key);
200
+ const topLevel = recorded.filter(([key]) => !isStep(key));
201
+ const scale = Math.max(durationMs, topLevel.reduce((sum, [, value]) => sum + value, 0));
202
+ const toRow = ([key, value], start) => ({
203
+ key,
158
204
  label: getPhaseLabel(key),
159
205
  description: getPhaseDescription(key),
160
- order: PHASE_EXECUTION_ORDER.indexOf(key),
161
- }));
162
- // Sort by execution order, unknown phases go to the end
163
- return activePhases.sort((a, b) => {
164
- const orderA = a.order === -1 ? 999 : a.order;
165
- const orderB = b.order === -1 ? 999 : b.order;
166
- return orderA - orderB;
206
+ value,
207
+ share: (value / scale) * 100,
208
+ start,
209
+ steps: [],
210
+ });
211
+ let elapsed = 0;
212
+ return topLevel.map((entry) => {
213
+ const row = toRow(entry, (elapsed / scale) * 100);
214
+ elapsed += entry[1];
215
+ if (entry[0] === 'pageRenderingMs') {
216
+ let stepStart = row.start;
217
+ row.steps = recorded
218
+ .filter(([key]) => isStep(key))
219
+ .map((step) => {
220
+ const stepRow = toRow(step, stepStart);
221
+ stepStart += stepRow.share;
222
+ return stepRow;
223
+ });
224
+ }
225
+ return row;
167
226
  });
168
227
  }
169
228
  /**
170
- * Generate the HTML report content
229
+ * The same phases, slowest first, and the steps inside a phase slowest first as well.
171
230
  */
172
- export function generateMetricsHtml(metrics) {
173
- const { meta, totals, phases, counts, isg, pageTimings, incremental } = metrics;
174
- const phaseData = generatePhaseData(phases);
175
- const timelineData = generateTimelineData(phases);
176
- const totalPhaseTime = phaseData.reduce((sum, p) => sum + p.value, 0);
177
- const hasDetailedTimings = pageTimings && pageTimings.length > 0;
178
- const hasIncremental = !!incremental;
179
- // Calculate phase percentages for the chart
180
- const phaseChartData = phaseData.map((p) => ({
181
- ...p,
182
- percent: (p.value / totals.durationMs) * 100,
183
- }));
184
- // Calculate timeline data with cumulative offsets for visualization
185
- let cumulativeTime = 0;
186
- const timelineChartData = timelineData.map((p) => {
187
- const item = {
188
- ...p,
189
- percent: (p.value / totals.durationMs) * 100,
190
- startOffset: (cumulativeTime / totals.durationMs) * 100,
191
- };
192
- cumulativeTime += p.value;
193
- return item;
194
- });
195
- // Sort page timings by duration for detailed view
196
- const sortedPageTimings = hasDetailedTimings
197
- ? [...pageTimings].sort((a, b) => b.durationMs - a.durationMs)
198
- : [];
199
- // Generate JSON for embedding
200
- const jsonData = JSON.stringify(metrics, null, 2);
201
- return `<!DOCTYPE html>
202
- <html lang="en">
203
- <head>
204
- <meta charset="UTF-8">
205
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
206
- <title>Stati Build Metrics Report - ${meta.timestamp}</title>
207
- <style>
208
- :root {
209
- --bg-primary: #0d1117;
210
- --bg-secondary: #161b22;
211
- --bg-tertiary: #21262d;
212
- --border-color: #30363d;
213
- --text-primary: #e6edf3;
214
- --text-secondary: #8b949e;
215
- --text-muted: #6e7681;
216
- --accent-blue: #58a6ff;
217
- --accent-green: #3fb950;
218
- --accent-yellow: #d29922;
219
- --accent-red: #f85149;
220
- --accent-purple: #a371f7;
221
- --accent-cyan: #39c5cf;
222
- --accent-orange: #db6d28;
223
- --shadow: 0 3px 6px rgba(0,0,0,0.4);
224
- --radius: 8px;
225
- }
226
-
227
- * {
231
+ function sortByDuration(rows) {
232
+ return [...rows]
233
+ .sort((a, b) => b.value - a.value)
234
+ .map((row) => ({ ...row, steps: [...row.steps].sort((a, b) => b.value - a.value) }));
235
+ }
236
+ const CHEVRON_ICON = `<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>`;
237
+ /**
238
+ * A horizontal bar. Thin values stay visible, and a bar never runs past the end of its track.
239
+ */
240
+ function renderMeter(start, share) {
241
+ const width = Math.min(Math.max(share, 0.4), 100 - start);
242
+ return `<span class="meter" aria-hidden="true"><span class="meter-fill" style="left: ${start.toFixed(2)}%; width: ${width.toFixed(2)}%"></span></span>`;
243
+ }
244
+ /**
245
+ * Phase rows that open to describe what the phase measures. The timeline numbers the phases and
246
+ * starts each bar where the phase began.
247
+ */
248
+ function renderPhaseRows(rows, view, isStep = false) {
249
+ return rows
250
+ .map((row, index) => {
251
+ const order = view === 'timeline' && !isStep
252
+ ? `
253
+ <span class="phase-order">${String(index + 1).padStart(2, '0')}</span>`
254
+ : '';
255
+ const steps = row.steps.length > 0
256
+ ? `
257
+ <ol class="phase-steps" aria-label="${escapeHtml(`Parts of ${row.label.toLowerCase()}`)}">${renderPhaseRows(row.steps, view, true)}
258
+ </ol>`
259
+ : '';
260
+ return `
261
+ <li class="${isStep ? 'phase phase-step' : 'phase'}">
262
+ <details>
263
+ <summary class="phase-row" title="${escapeHtml(row.key)}">${order}
264
+ <span class="phase-name">${escapeHtml(row.label)}</span>
265
+ ${renderMeter(view === 'timeline' ? row.start : 0, row.share)}
266
+ <span class="phase-time">${formatDuration(row.value)}</span>
267
+ <span class="phase-share">${row.share.toFixed(1)}%</span>
268
+ ${CHEVRON_ICON}
269
+ </summary>
270
+ <p class="phase-description">${escapeHtml(row.description)}</p>
271
+ </details>${steps}
272
+ </li>`;
273
+ })
274
+ .join('');
275
+ }
276
+ /**
277
+ * The phases, slowest first or in the order the build runs them.
278
+ */
279
+ function renderPhasesSection(rows) {
280
+ const count = rows.reduce((sum, row) => sum + 1 + row.steps.length, 0);
281
+ const body = rows.length === 0
282
+ ? `
283
+ <p class="empty">No phases were recorded for this build.</p>`
284
+ : `
285
+ <div class="tabs" role="tablist" aria-label="Phase order">
286
+ <button type="button" class="tab" role="tab" id="phases-tab-duration" aria-controls="phases-duration" aria-selected="true">By duration</button>
287
+ <button type="button" class="tab" role="tab" id="phases-tab-timeline" aria-controls="phases-timeline" aria-selected="false" tabindex="-1">Timeline</button>
288
+ </div>
289
+
290
+ <div class="tab-panel" role="tabpanel" id="phases-duration" aria-labelledby="phases-tab-duration">
291
+ <p class="view-intro">Slowest first, as a share of the build. Open a phase to see what it measures.</p>
292
+ <ol class="phases" data-view="duration">${renderPhaseRows(sortByDuration(rows), 'duration')}
293
+ </ol>
294
+ </div>
295
+
296
+ <div class="tab-panel" role="tabpanel" id="phases-timeline" aria-labelledby="phases-tab-timeline" hidden>
297
+ <p class="view-intro">In the order the build runs them. Steps inside page rendering are totals over all pages, so their bars show how long they took, not when.</p>
298
+ <ol class="phases" data-view="timeline">${renderPhaseRows(rows, 'timeline')}
299
+ </ol>
300
+ </div>`;
301
+ return `
302
+ <section class="section" id="phases" aria-labelledby="phases-title">
303
+ <div class="section-head">
304
+ <h2 class="section-title" id="phases-title">Phase breakdown</h2>${count > 0
305
+ ? `
306
+ <span class="section-note">${count} ${count === 1 ? 'phase' : 'phases'}</span>`
307
+ : ''}
308
+ </div>${body}
309
+ </section>`;
310
+ }
311
+ /**
312
+ * One group of counts, headed by a rule, with a value per row.
313
+ */
314
+ function renderCountGroup(title, items, status) {
315
+ const rows = items
316
+ .filter((item) => item !== null)
317
+ .map(([label, value]) => `
318
+ <div class="count"><dt>${label}</dt><dd>${value}</dd></div>`)
319
+ .join('');
320
+ return `
321
+ <div class="group">
322
+ <h3 class="group-title">${title}${status ? `<span class="group-status">${status}</span>` : ''}</h3>
323
+ <dl class="counts">${rows}
324
+ </dl>
325
+ </div>`;
326
+ }
327
+ /**
328
+ * Every count of the build, grouped.
329
+ */
330
+ function renderCountsSection(metrics) {
331
+ const { counts, isg } = metrics;
332
+ const optional = (label, value) => value === undefined ? null : [label, formatCount(value)];
333
+ const perPage = counts.renderedPages > 0 ? (counts.templatesLoaded / counts.renderedPages).toFixed(1) : '0';
334
+ return `
335
+ <section class="section" id="counts" aria-labelledby="counts-title">
336
+ <h2 class="section-title" id="counts-title">Counts</h2>
337
+ <div class="groups">${renderCountGroup('Pages', [
338
+ ['Total', formatCount(counts.totalPages)],
339
+ ['Rendered', formatCount(counts.renderedPages)],
340
+ ['From cache', formatCount(counts.cachedPages)],
341
+ ['Markdown files', formatCount(counts.markdownFilesProcessed)],
342
+ ])}${renderCountGroup('Templates', [
343
+ optional('Indexed', counts.templatesIndexed),
344
+ ['Loaded', formatCount(counts.templatesLoaded)],
345
+ ['Per rendered page', perPage],
346
+ optional('Renders', counts.templateRenders),
347
+ optional('Cached includes', counts.includeCacheHits),
348
+ ])}${renderCountGroup('ISG cache', [
349
+ ['Hit rate', formatPercent(isg.cacheHitRate)],
350
+ ['Manifest entries', formatCount(isg.manifestEntries)],
351
+ ['Invalidated', formatCount(isg.invalidatedEntries)],
352
+ ], isg.enabled ? 'Enabled' : 'Disabled')}${renderCountGroup('Assets', [
353
+ ['Copied', formatCount(counts.assetsCopied)],
354
+ optional('Images generated', counts.imagesGenerated),
355
+ optional('Images reused', counts.imagesCached),
356
+ optional('Images failed', counts.imagesFailed),
357
+ ])}
358
+ </div>
359
+ </section>`;
360
+ }
361
+ /**
362
+ * What triggered a dev server rebuild and what it did.
363
+ */
364
+ function renderIncrementalSection(incremental) {
365
+ return `
366
+ <section class="section" id="rebuild" aria-labelledby="rebuild-title">
367
+ <h2 class="section-title" id="rebuild-title">Incremental rebuild</h2>
368
+ <dl class="facts">
369
+ <div class="fact">
370
+ <dt>Changed file</dt>
371
+ <dd>${escapeHtml(incremental.triggerFile)}</dd>
372
+ </div>
373
+ <div class="fact">
374
+ <dt>Type</dt>
375
+ <dd>${escapeHtml(incremental.triggerType)}</dd>
376
+ </div>
377
+ <div class="fact">
378
+ <dt>Duration</dt>
379
+ <dd>${formatDuration(incremental.durationMs)}</dd>
380
+ </div>
381
+ <div class="fact">
382
+ <dt>Pages</dt>
383
+ <dd>${formatCount(incremental.renderedPages)} rendered, ${formatCount(incremental.cachedPages)} cached</dd>
384
+ </div>
385
+ </dl>
386
+ </section>`;
387
+ }
388
+ /**
389
+ * Every page, slowest first: rendered (solid square) or served from cache (hollow circle),
390
+ * with its duration against the slowest page and the templates it rendered.
391
+ */
392
+ function renderPagesSection(pageTimings) {
393
+ const pages = [...pageTimings].sort((a, b) => b.durationMs - a.durationMs);
394
+ const slowest = pages.reduce((max, page) => Math.max(max, page.durationMs), 0);
395
+ const rendered = pages.filter((page) => !page.cached).length;
396
+ const rows = pages
397
+ .map((page) => {
398
+ const status = page.cached ? 'cached' : 'rendered';
399
+ const meter = slowest > 0 && page.durationMs > 0
400
+ ? renderMeter(0, (page.durationMs / slowest) * 100)
401
+ : '<span class="meter meter-empty" aria-hidden="true"></span>';
402
+ // A page served from cache did not render, so it has no render time
403
+ const duration = page.cached && page.durationMs === 0 ? '–' : formatDuration(page.durationMs);
404
+ return `
405
+ <tr>
406
+ <td class="page-url"><span title="${escapeHtml(page.url)}">${escapeHtml(page.url)}</span></td>
407
+ <td><span class="status status-${status}"><span class="marker" aria-hidden="true"></span>${status}</span></td>
408
+ <td class="page-duration"><span class="duration">${meter}<span class="num">${duration}</span></span></td>
409
+ <td class="num">${page.templatesLoaded ?? '–'}</td>
410
+ <td class="num">${page.templateRenders ?? '–'}</td>
411
+ </tr>`;
412
+ })
413
+ .join('');
414
+ return `
415
+ <section class="section" id="pages" aria-labelledby="pages-title">
416
+ <h2 class="section-title" id="pages-title">Page timings</h2>
417
+ <figure class="panel">
418
+ <figcaption class="panel-bar">
419
+ <span>${formatCount(pages.length)} ${pages.length === 1 ? 'page' : 'pages'}, slowest first</span>
420
+ <span class="panel-summary">${formatCount(rendered)} rendered · ${formatCount(pages.length - rendered)} cached</span>
421
+ </figcaption>
422
+ <div class="table-scroll" tabindex="0" role="region" aria-labelledby="pages-title">
423
+ <table class="pages">
424
+ <thead>
425
+ <tr>
426
+ <th scope="col">Page</th>
427
+ <th scope="col">Result</th>
428
+ <th scope="col" class="page-duration">Duration</th>
429
+ <th scope="col" class="num">Templates</th>
430
+ <th scope="col" class="num">Renders</th>
431
+ </tr>
432
+ </thead>
433
+ <tbody>${rows}
434
+ </tbody>
435
+ </table>
436
+ </div>
437
+ </figure>
438
+ </section>`;
439
+ }
440
+ const REPORT_STYLES = `
441
+ *,
442
+ *::before,
443
+ *::after {
228
444
  box-sizing: border-box;
229
- margin: 0;
230
- padding: 0;
445
+ }
446
+
447
+ html {
448
+ background-color: rgb(var(--canvas));
449
+ scrollbar-color: rgb(var(--line-strong)) transparent;
450
+ scroll-padding-top: 1.5rem;
231
451
  }
232
452
 
233
453
  body {
234
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
235
- background: var(--bg-primary);
236
- color: var(--text-primary);
237
- line-height: 1.5;
454
+ margin: 0;
238
455
  min-height: 100vh;
456
+ display: flex;
457
+ flex-direction: column;
458
+ background-color: rgb(var(--canvas));
459
+ color: rgb(var(--ink));
460
+ font-family: var(--font-sans);
461
+ font-size: 16px;
462
+ line-height: 1.6;
463
+ font-feature-settings: 'kern' 1, 'liga' 1, 'calt' 1, 'cv11' 1;
464
+ -webkit-font-smoothing: antialiased;
465
+ -moz-osx-font-smoothing: grayscale;
239
466
  }
240
467
 
241
- .container {
242
- max-width: 1400px;
243
- margin: 0 auto;
244
- padding: 24px;
468
+ ::selection {
469
+ background-color: rgb(var(--accent) / 0.25);
245
470
  }
246
471
 
247
- /* Header */
248
- header {
249
- background: var(--bg-secondary);
250
- border: 1px solid var(--border-color);
251
- border-radius: var(--radius);
252
- padding: 24px;
253
- margin-bottom: 24px;
254
- box-shadow: var(--shadow);
472
+ :focus-visible {
473
+ outline: 2px solid rgb(var(--accent));
474
+ outline-offset: 2px;
255
475
  }
256
476
 
257
- header h1 {
258
- font-size: 24px;
259
- font-weight: 600;
260
- margin-bottom: 8px;
261
- display: flex;
262
- align-items: center;
263
- gap: 12px;
477
+ h1,
478
+ h2,
479
+ h3,
480
+ p,
481
+ pre,
482
+ dl,
483
+ dd,
484
+ ol,
485
+ figure {
486
+ margin: 0;
487
+ }
488
+
489
+ ol {
490
+ padding: 0;
491
+ list-style: none;
492
+ }
493
+
494
+ .sr-only {
495
+ position: absolute;
496
+ width: 1px;
497
+ height: 1px;
498
+ padding: 0;
499
+ margin: -1px;
500
+ overflow: hidden;
501
+ clip: rect(0, 0, 0, 0);
502
+ white-space: nowrap;
503
+ border: 0;
504
+ }
505
+
506
+ .meta-time,
507
+ .fact dd,
508
+ .stat-value,
509
+ .section-note,
510
+ .phase-order,
511
+ .phase-time,
512
+ .phase-share,
513
+ .counts dd,
514
+ .panel-bar,
515
+ .page-url,
516
+ .num,
517
+ .code {
518
+ font-family: var(--font-mono);
519
+ font-feature-settings: normal;
520
+ font-variant-ligatures: none;
521
+ font-variant-numeric: tabular-nums;
522
+ }
523
+
524
+ .shell {
525
+ width: 100%;
526
+ max-width: 72rem;
527
+ margin: 0 auto;
528
+ padding-left: 1rem;
529
+ padding-right: 1rem;
264
530
  }
265
531
 
266
- header h1 svg {
267
- width: 32px;
268
- height: 32px;
269
- color: var(--accent-blue);
532
+ /* Header bar */
533
+
534
+ .bar {
535
+ border-bottom: 1px solid rgb(var(--line));
270
536
  }
271
537
 
272
- .meta-info {
538
+ .bar .shell {
539
+ min-height: 3.5rem;
273
540
  display: flex;
274
541
  flex-wrap: wrap;
275
- gap: 16px 32px;
276
- margin-top: 16px;
277
- font-size: 13px;
278
- color: var(--text-secondary);
542
+ align-items: center;
543
+ justify-content: space-between;
544
+ gap: 0.5rem 1.5rem;
545
+ padding-top: 0.625rem;
546
+ padding-bottom: 0.625rem;
279
547
  }
280
548
 
281
- .meta-info span {
549
+ .brand {
282
550
  display: flex;
283
551
  align-items: center;
284
- gap: 6px;
552
+ gap: 0.625rem;
553
+ min-width: 0;
285
554
  }
286
555
 
287
- .meta-info svg {
288
- width: 14px;
289
- height: 14px;
290
- opacity: 0.7;
556
+ .brand-mark {
557
+ width: 1.25rem;
558
+ height: 1.25rem;
559
+ flex: none;
560
+ color: rgb(var(--accent));
291
561
  }
292
562
 
293
- /* Summary Cards */
294
- .summary-grid {
295
- display: grid;
296
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
297
- gap: 16px;
298
- margin-bottom: 24px;
563
+ .brand-name {
564
+ font-family: var(--font-display);
565
+ font-size: 1.125rem;
566
+ font-weight: 800;
567
+ font-stretch: 112%;
568
+ letter-spacing: -0.02em;
569
+ line-height: 1;
299
570
  }
300
571
 
301
- .summary-card {
302
- background: var(--bg-secondary);
303
- border: 1px solid var(--border-color);
304
- border-radius: var(--radius);
305
- padding: 20px;
306
- box-shadow: var(--shadow);
572
+ .brand-rule {
573
+ width: 1px;
574
+ height: 1rem;
575
+ background-color: rgb(var(--line-strong));
307
576
  }
308
577
 
309
- .summary-card .label {
310
- font-size: 12px;
578
+ .brand-context {
579
+ font-size: 0.875rem;
311
580
  font-weight: 500;
312
- color: var(--text-secondary);
313
- text-transform: uppercase;
314
- letter-spacing: 0.5px;
315
- margin-bottom: 8px;
581
+ color: rgb(var(--muted));
582
+ white-space: nowrap;
316
583
  }
317
584
 
318
- .summary-card .value {
319
- font-size: 28px;
320
- font-weight: 600;
321
- color: var(--text-primary);
585
+ .bar-nav {
586
+ display: flex;
587
+ flex-wrap: wrap;
588
+ gap: 0.25rem;
589
+ margin: 0 -0.5rem;
322
590
  }
323
591
 
324
- .summary-card .subtext {
325
- font-size: 12px;
326
- color: var(--text-muted);
327
- margin-top: 4px;
592
+ .bar-nav a {
593
+ padding: 0.375rem 0.5rem;
594
+ border-radius: 6px;
595
+ font-size: 0.875rem;
596
+ font-weight: 500;
597
+ line-height: 1.25;
598
+ color: rgb(var(--muted));
599
+ text-decoration: none;
600
+ transition: color 150ms, background-color 150ms;
328
601
  }
329
602
 
330
- .summary-card.highlight {
331
- border-color: var(--accent-blue);
332
- background: linear-gradient(135deg, var(--bg-secondary) 0%, rgba(88, 166, 255, 0.1) 100%);
603
+ .bar-nav a:hover {
604
+ background-color: rgb(var(--raised));
605
+ color: rgb(var(--ink));
333
606
  }
334
607
 
335
- .summary-card.success .value {
336
- color: var(--accent-green);
337
- }
608
+ /* Summary */
338
609
 
339
- .summary-card.warning .value {
340
- color: var(--accent-yellow);
610
+ .main {
611
+ flex: 1;
612
+ padding-top: 2.5rem;
613
+ padding-bottom: 4rem;
341
614
  }
342
615
 
343
- /* Section */
344
- .section {
345
- background: var(--bg-secondary);
346
- border: 1px solid var(--border-color);
347
- border-radius: var(--radius);
348
- margin-bottom: 24px;
349
- box-shadow: var(--shadow);
350
- overflow: hidden;
616
+ .headline {
617
+ font-family: var(--font-display);
618
+ font-size: 1.875rem;
619
+ font-weight: 800;
620
+ font-stretch: 108%;
621
+ letter-spacing: -0.015em;
622
+ line-height: 1.1;
623
+ text-wrap: balance;
351
624
  }
352
625
 
353
- .section-header {
626
+ .meta {
354
627
  display: flex;
628
+ flex-wrap: wrap;
355
629
  align-items: center;
356
- justify-content: space-between;
357
- padding: 16px 20px;
358
- border-bottom: 1px solid var(--border-color);
359
- background: var(--bg-tertiary);
630
+ gap: 0.5rem 0.75rem;
631
+ margin-top: 0.875rem;
632
+ font-size: 0.875rem;
633
+ color: rgb(var(--muted));
360
634
  }
361
635
 
362
- .section-header h2 {
363
- font-size: 16px;
364
- font-weight: 600;
365
- display: flex;
636
+ .type-badge {
637
+ display: inline-flex;
366
638
  align-items: center;
367
- gap: 10px;
368
- }
369
-
370
- .section-header svg {
371
- width: 18px;
372
- height: 18px;
373
- color: var(--accent-blue);
374
- }
375
-
376
- .section-content {
377
- padding: 20px;
639
+ padding: 0.125rem 0.5rem;
640
+ border: 1px solid rgb(var(--line-strong));
641
+ border-radius: 4px;
642
+ font-size: 0.75rem;
643
+ font-weight: 600;
644
+ line-height: 1.5;
645
+ color: rgb(var(--ink));
646
+ text-transform: capitalize;
378
647
  }
379
648
 
380
- /* Phase Breakdown */
381
- .phase-list {
382
- display: flex;
383
- flex-direction: column;
384
- gap: 8px;
649
+ .meta-time {
650
+ font-size: 0.75rem;
651
+ color: rgb(var(--faint));
385
652
  }
386
653
 
387
- .phase-item {
654
+ .stats {
388
655
  display: grid;
389
- grid-template-columns: 200px 1fr 100px 80px 32px;
390
- align-items: center;
391
- gap: 16px;
392
- padding: 12px 16px;
656
+ grid-template-columns: repeat(2, minmax(0, 1fr));
657
+ gap: 1px;
658
+ margin-top: 1.75rem;
659
+ overflow: hidden;
660
+ border: 1px solid rgb(var(--line));
393
661
  border-radius: 8px;
394
- background: var(--bg-tertiary);
395
- cursor: pointer;
396
- transition: all 0.2s ease;
662
+ background-color: rgb(var(--line));
397
663
  }
398
664
 
399
- .phase-item:hover {
400
- background: var(--bg-secondary);
401
- box-shadow: 0 0 0 1px var(--border-color);
665
+ .stat {
666
+ min-width: 0;
667
+ padding: 1rem 1.25rem;
668
+ background-color: rgb(var(--canvas));
402
669
  }
403
670
 
404
- .phase-item.expanded {
405
- background: var(--bg-secondary);
406
- box-shadow: 0 0 0 1px var(--accent-blue);
671
+ .stat dt {
672
+ font-size: 0.8125rem;
673
+ color: rgb(var(--muted));
407
674
  }
408
675
 
409
- .phase-name {
410
- font-size: 14px;
676
+ .stat-value {
677
+ margin-top: 0.25rem;
678
+ font-size: 1.5rem;
411
679
  font-weight: 500;
412
- color: var(--text-primary);
680
+ line-height: 1.25;
681
+ letter-spacing: -0.02em;
682
+ color: rgb(var(--ink));
683
+ overflow-wrap: anywhere;
413
684
  }
414
685
 
415
- .phase-bar-container {
416
- height: 24px;
417
- background: rgba(0,0,0,0.3);
418
- border-radius: 4px;
419
- overflow: hidden;
420
- position: relative;
686
+ .stat-note {
687
+ margin-top: 0.25rem;
688
+ font-size: 0.8125rem;
689
+ line-height: 1.5;
690
+ color: rgb(var(--faint));
421
691
  }
422
692
 
423
- .phase-bar {
424
- height: 100%;
425
- background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan));
426
- border-radius: 4px;
427
- transition: width 0.3s ease;
428
- min-width: 2px;
693
+ .facts {
694
+ display: flex;
695
+ flex-wrap: wrap;
696
+ gap: 0.75rem 2.5rem;
697
+ margin-top: 1.25rem;
429
698
  }
430
699
 
431
- .phase-duration {
432
- font-size: 14px;
433
- font-weight: 600;
434
- color: var(--text-primary);
435
- text-align: right;
436
- font-variant-numeric: tabular-nums;
700
+ .fact {
701
+ min-width: 0;
437
702
  }
438
703
 
439
- .phase-percent {
440
- font-size: 13px;
441
- color: var(--text-secondary);
442
- text-align: right;
443
- font-variant-numeric: tabular-nums;
704
+ .fact dt {
705
+ font-size: 0.8125rem;
706
+ color: rgb(var(--faint));
444
707
  }
445
708
 
446
- .phase-expand-icon {
447
- width: 20px;
448
- height: 20px;
449
- color: var(--text-muted);
450
- transition: transform 0.2s ease;
709
+ .fact dd {
710
+ margin-top: 0.125rem;
711
+ font-size: 0.8125rem;
712
+ overflow-wrap: anywhere;
713
+ color: rgb(var(--ink));
451
714
  }
452
715
 
453
- .phase-item.expanded .phase-expand-icon {
454
- transform: rotate(180deg);
455
- color: var(--accent-blue);
716
+ /* Sections */
717
+
718
+ .section {
719
+ margin-top: 2.5rem;
720
+ padding-top: 1.75rem;
721
+ border-top: 1px solid rgb(var(--line));
456
722
  }
457
723
 
458
- .phase-description {
459
- grid-column: 1 / -1;
460
- padding: 16px;
461
- margin-top: 8px;
462
- background: var(--bg-primary);
463
- border-radius: 6px;
464
- font-size: 13px;
465
- line-height: 1.6;
466
- color: var(--text-secondary);
467
- display: none;
724
+ .section-head {
725
+ display: flex;
726
+ flex-wrap: wrap;
727
+ align-items: baseline;
728
+ justify-content: space-between;
729
+ gap: 0.25rem 1rem;
468
730
  }
469
731
 
470
- .phase-item.expanded .phase-description {
471
- display: block;
732
+ .section-title {
733
+ font-family: var(--font-display);
734
+ font-size: 1.125rem;
735
+ font-weight: 700;
736
+ font-stretch: 108%;
737
+ letter-spacing: -0.01em;
738
+ line-height: 1.25;
472
739
  }
473
740
 
474
- .phase-description code {
475
- background: var(--bg-tertiary);
476
- padding: 2px 6px;
477
- border-radius: 4px;
478
- font-family: 'SF Mono', Monaco, Consolas, monospace;
479
- font-size: 12px;
480
- color: var(--accent-cyan);
741
+ .section-note {
742
+ font-size: 0.75rem;
743
+ color: rgb(var(--faint));
481
744
  }
482
745
 
483
- /* Timeline View */
484
- .timeline-container {
485
- position: relative;
486
- padding: 20px 0;
746
+ .section > .section-title + * {
747
+ margin-top: 1rem;
487
748
  }
488
749
 
489
- .timeline-track {
490
- height: 40px;
491
- background: var(--bg-tertiary);
492
- border-radius: 8px;
493
- position: relative;
494
- overflow: hidden;
495
- margin-bottom: 16px;
750
+ .empty {
751
+ margin-top: 1rem;
752
+ font-size: 0.875rem;
753
+ color: rgb(var(--muted));
496
754
  }
497
755
 
498
- .timeline-segment {
499
- position: absolute;
500
- height: 100%;
756
+ /* Tabs */
757
+
758
+ .tabs {
501
759
  display: flex;
502
- align-items: center;
503
- justify-content: center;
504
- font-size: 10px;
760
+ gap: 1.5rem;
761
+ margin-top: 1rem;
762
+ border-bottom: 1px solid rgb(var(--line));
763
+ }
764
+
765
+ .tab {
766
+ margin-bottom: -1px;
767
+ padding: 0.5rem 0;
768
+ border: 0;
769
+ border-radius: 0;
770
+ background: none;
771
+ font: inherit;
772
+ font-size: 0.875rem;
505
773
  font-weight: 500;
506
- color: white;
507
- text-shadow: 0 1px 2px rgba(0,0,0,0.5);
508
- overflow: hidden;
774
+ line-height: 1.5;
775
+ color: rgb(var(--muted));
509
776
  cursor: pointer;
510
- transition: filter 0.2s ease;
511
- min-width: 2px;
777
+ transition: color 150ms;
512
778
  }
513
779
 
514
- .timeline-segment:hover {
515
- filter: brightness(1.2);
516
- z-index: 10;
780
+ .tab:hover {
781
+ color: rgb(var(--ink));
517
782
  }
518
783
 
519
- .timeline-segment span {
520
- white-space: nowrap;
521
- overflow: hidden;
522
- text-overflow: ellipsis;
523
- padding: 0 4px;
784
+ .tab[aria-selected='true'] {
785
+ color: rgb(var(--ink));
786
+ box-shadow: inset 0 -2px 0 rgb(var(--accent));
524
787
  }
525
788
 
526
- .timeline-list {
527
- display: flex;
528
- flex-direction: column;
529
- gap: 6px;
789
+ .view-intro {
790
+ max-width: 40rem;
791
+ margin-top: 1rem;
792
+ font-size: 0.875rem;
793
+ color: rgb(var(--muted));
794
+ }
795
+
796
+ /* Phases */
797
+
798
+ .phases {
799
+ --order: 0rem;
800
+ margin-top: 1rem;
801
+ border-bottom: 1px solid rgb(var(--line));
530
802
  }
531
803
 
532
- .timeline-item {
804
+ .phases[data-view='timeline'] {
805
+ --order: 2.75rem;
806
+ }
807
+
808
+ .phase-row {
533
809
  display: grid;
534
- grid-template-columns: 32px 180px 1fr 100px;
810
+ grid-template-columns: minmax(0, 13rem) minmax(0, 1fr) 5.5rem 3.25rem 1rem;
811
+ grid-template-areas: 'name meter time share icon';
535
812
  align-items: center;
536
- gap: 12px;
537
- padding: 10px 12px;
538
- border-radius: 6px;
539
- background: var(--bg-tertiary);
813
+ column-gap: 1rem;
814
+ padding: 0.625rem 0;
815
+ border-top: 1px solid rgb(var(--line));
540
816
  cursor: pointer;
541
- transition: all 0.2s ease;
817
+ list-style: none;
542
818
  }
543
819
 
544
- .timeline-item:hover {
545
- background: var(--bg-secondary);
820
+ .phase-row::-webkit-details-marker {
821
+ display: none;
546
822
  }
547
823
 
548
- .timeline-item.expanded {
549
- background: var(--bg-secondary);
550
- box-shadow: 0 0 0 1px var(--accent-blue);
824
+ .phases > .phase:first-child > details > .phase-row {
825
+ border-top-color: rgb(var(--ink));
551
826
  }
552
827
 
553
- .timeline-order {
554
- width: 24px;
555
- height: 24px;
556
- border-radius: 50%;
557
- background: var(--bg-primary);
558
- display: flex;
559
- align-items: center;
560
- justify-content: center;
561
- font-size: 11px;
562
- font-weight: 600;
563
- color: var(--text-secondary);
828
+ .phases[data-view='timeline'] .phase-row {
829
+ grid-template-columns: 1.75rem minmax(0, 13rem) minmax(0, 1fr) 5.5rem 3.25rem 1rem;
830
+ grid-template-areas: 'order name meter time share icon';
564
831
  }
565
832
 
566
- .timeline-item:nth-child(1) .timeline-order { background: var(--accent-blue); color: white; }
567
- .timeline-item:nth-child(2) .timeline-order { background: var(--accent-cyan); color: white; }
568
- .timeline-item:nth-child(3) .timeline-order { background: var(--accent-green); color: white; }
833
+ .phase-order {
834
+ grid-area: order;
835
+ font-size: 0.75rem;
836
+ color: rgb(var(--faint));
837
+ }
569
838
 
570
- .timeline-name {
571
- font-size: 13px;
839
+ .phase-name {
840
+ grid-area: name;
841
+ min-width: 0;
842
+ font-size: 0.875rem;
572
843
  font-weight: 500;
573
- color: var(--text-primary);
844
+ line-height: 1.4;
845
+ overflow-wrap: anywhere;
846
+ transition: color 150ms;
574
847
  }
575
848
 
576
- .timeline-bar-container {
577
- height: 20px;
578
- background: rgba(0,0,0,0.3);
579
- border-radius: 4px;
580
- overflow: hidden;
581
- position: relative;
849
+ .phase-row:hover .phase-name {
850
+ color: rgb(var(--accent-ink));
582
851
  }
583
852
 
584
- .timeline-bar {
585
- height: 100%;
586
- border-radius: 4px;
587
- min-width: 2px;
853
+ .phase-row .meter {
854
+ grid-area: meter;
588
855
  }
589
856
 
590
- .timeline-time {
591
- font-size: 13px;
592
- font-weight: 600;
593
- color: var(--text-primary);
857
+ .phase-time {
858
+ grid-area: time;
859
+ font-size: 0.8125rem;
594
860
  text-align: right;
595
- font-variant-numeric: tabular-nums;
861
+ white-space: nowrap;
596
862
  }
597
863
 
598
- .timeline-description {
599
- grid-column: 1 / -1;
600
- padding: 12px;
601
- margin-top: 8px;
602
- background: var(--bg-primary);
603
- border-radius: 6px;
604
- font-size: 12px;
605
- line-height: 1.6;
606
- color: var(--text-secondary);
607
- display: none;
864
+ .phase-share {
865
+ grid-area: share;
866
+ font-size: 0.75rem;
867
+ text-align: right;
868
+ color: rgb(var(--faint));
608
869
  }
609
870
 
610
- .timeline-item.expanded .timeline-description {
611
- display: block;
871
+ .chevron {
872
+ grid-area: icon;
873
+ width: 1rem;
874
+ height: 1rem;
875
+ color: rgb(var(--faint));
876
+ transition: transform 150ms, color 150ms;
612
877
  }
613
878
 
614
- /* Phase colors for timeline */
615
- .phase-color-0 { background: linear-gradient(90deg, #58a6ff, #388bfd); }
616
- .phase-color-1 { background: linear-gradient(90deg, #39c5cf, #2ea9b3); }
617
- .phase-color-2 { background: linear-gradient(90deg, #3fb950, #2ea043); }
618
- .phase-color-3 { background: linear-gradient(90deg, #a371f7, #8957e5); }
619
- .phase-color-4 { background: linear-gradient(90deg, #d29922, #bb8009); }
620
- .phase-color-5 { background: linear-gradient(90deg, #f85149, #da3633); }
621
- .phase-color-6 { background: linear-gradient(90deg, #db6d28, #bd561d); }
622
- .phase-color-7 { background: linear-gradient(90deg, #8b949e, #6e7681); }
879
+ .phase-row:hover .chevron {
880
+ color: rgb(var(--ink));
881
+ }
623
882
 
624
- /* Waterfall Chart */
625
- .waterfall-container {
626
- overflow-x: auto;
627
- margin-top: 16px;
883
+ details[open] > .phase-row .chevron {
884
+ transform: rotate(90deg);
628
885
  }
629
886
 
630
- .waterfall {
631
- min-width: 600px;
887
+ .phase-description {
888
+ max-width: 42rem;
889
+ padding: 0 0 1rem var(--order);
890
+ font-size: 0.875rem;
891
+ line-height: 1.65;
892
+ color: rgb(var(--muted));
893
+ }
894
+
895
+ .phase-step {
632
896
  position: relative;
633
897
  }
634
898
 
635
- .waterfall-row {
636
- display: flex;
637
- align-items: center;
638
- height: 32px;
639
- gap: 12px;
640
- border-bottom: 1px solid var(--border-color);
899
+ .phase-step::before {
900
+ content: '';
901
+ position: absolute;
902
+ top: 0;
903
+ bottom: 0;
904
+ left: var(--order);
905
+ width: 1px;
906
+ background-color: rgb(var(--line-strong));
641
907
  }
642
908
 
643
- .waterfall-row:last-child {
644
- border-bottom: none;
909
+ .phase-step .phase-row {
910
+ border-top: 0;
645
911
  }
646
912
 
647
- .waterfall-label {
648
- width: 160px;
649
- flex-shrink: 0;
650
- font-size: 12px;
651
- color: var(--text-secondary);
652
- overflow: hidden;
653
- text-overflow: ellipsis;
654
- white-space: nowrap;
913
+ .phase-step .phase-name {
914
+ padding-left: 0.875rem;
915
+ font-weight: 400;
916
+ color: rgb(var(--muted));
655
917
  }
656
918
 
657
- .waterfall-track {
658
- flex: 1;
659
- height: 16px;
660
- background: var(--bg-tertiary);
661
- border-radius: 2px;
919
+ .phase-step .phase-description {
920
+ padding-left: calc(var(--order) + 0.875rem);
921
+ }
922
+
923
+ /* Bars */
924
+
925
+ .meter {
662
926
  position: relative;
927
+ display: block;
928
+ height: 0.375rem;
929
+ overflow: hidden;
930
+ border-radius: 2px;
931
+ background-color: rgb(var(--line));
663
932
  }
664
933
 
665
- .waterfall-bar {
934
+ .meter-fill {
666
935
  position: absolute;
667
- height: 100%;
936
+ top: 0;
937
+ bottom: 0;
938
+ min-width: 2px;
668
939
  border-radius: 2px;
669
- min-width: 3px;
940
+ background-color: rgb(var(--ink));
670
941
  }
671
942
 
672
- .waterfall-bar.cached {
673
- background: var(--accent-green);
674
- opacity: 0.6;
943
+ .phase-step .meter-fill {
944
+ background-color: rgb(var(--muted));
675
945
  }
676
946
 
677
- .waterfall-bar.rendered {
678
- background: var(--accent-blue);
679
- }
947
+ /* Counts */
680
948
 
681
- .waterfall-time {
682
- width: 80px;
683
- flex-shrink: 0;
684
- font-size: 12px;
685
- color: var(--text-secondary);
686
- text-align: right;
687
- font-variant-numeric: tabular-nums;
688
- }
689
-
690
- /* Stats Grid */
691
- .stats-grid {
949
+ .groups {
692
950
  display: grid;
693
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
694
- gap: 16px;
951
+ grid-template-columns: minmax(0, 1fr);
952
+ gap: 2rem 2.5rem;
953
+ margin-top: 1.25rem;
695
954
  }
696
955
 
697
- .stat-item {
956
+ .group-title {
698
957
  display: flex;
699
- flex-direction: column;
700
- gap: 4px;
701
- }
702
-
703
- .stat-label {
704
- font-size: 12px;
705
- color: var(--text-secondary);
706
- }
707
-
708
- .stat-value {
709
- font-size: 18px;
958
+ justify-content: space-between;
959
+ gap: 1rem;
960
+ padding-bottom: 0.5rem;
961
+ border-bottom: 1px solid rgb(var(--ink));
962
+ font-size: 0.875rem;
710
963
  font-weight: 600;
711
- color: var(--text-primary);
712
- font-variant-numeric: tabular-nums;
964
+ line-height: 1.25;
713
965
  }
714
966
 
715
- /* ISG Section */
716
- .isg-grid {
717
- display: grid;
718
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
719
- gap: 20px;
967
+ .group-status {
968
+ font-weight: 400;
969
+ color: rgb(var(--muted));
720
970
  }
721
971
 
722
- .isg-stat {
723
- text-align: center;
724
- padding: 20px;
725
- background: var(--bg-tertiary);
726
- border-radius: var(--radius);
972
+ .count {
973
+ display: flex;
974
+ align-items: baseline;
975
+ justify-content: space-between;
976
+ gap: 1rem;
977
+ padding: 0.5rem 0;
978
+ border-bottom: 1px solid rgb(var(--line));
979
+ font-size: 0.875rem;
727
980
  }
728
981
 
729
- .isg-stat .value {
730
- font-size: 32px;
731
- font-weight: 700;
982
+ .count dt {
983
+ color: rgb(var(--muted));
732
984
  }
733
985
 
734
- .isg-stat .label {
735
- font-size: 13px;
736
- color: var(--text-secondary);
737
- margin-top: 4px;
986
+ .counts dd {
987
+ font-size: 0.8125rem;
988
+ color: rgb(var(--ink));
738
989
  }
739
990
 
740
- .isg-stat.hit .value {
741
- color: var(--accent-green);
742
- }
991
+ /* Panels */
743
992
 
744
- .isg-stat.miss .value {
745
- color: var(--accent-yellow);
993
+ .panel {
994
+ overflow: hidden;
995
+ border: 1px solid rgb(var(--line));
996
+ border-radius: 8px;
997
+ background-color: rgb(var(--sunken));
746
998
  }
747
999
 
748
- /* Cache Hit Rate Ring */
749
- .cache-ring-container {
1000
+ .panel-bar {
750
1001
  display: flex;
751
- justify-content: center;
1002
+ flex-wrap: wrap;
752
1003
  align-items: center;
753
- padding: 20px;
1004
+ justify-content: space-between;
1005
+ gap: 0.25rem 1rem;
1006
+ min-height: 2.5rem;
1007
+ padding: 0.375rem 1rem;
1008
+ border-bottom: 1px solid rgb(var(--line));
1009
+ background-color: rgb(var(--canvas));
1010
+ font-size: 0.75rem;
1011
+ line-height: 1.5;
1012
+ color: rgb(var(--faint));
754
1013
  }
755
1014
 
756
- .cache-ring {
757
- position: relative;
758
- width: 150px;
759
- height: 150px;
1015
+ .panel-summary {
1016
+ font-family: var(--font-sans);
760
1017
  }
761
1018
 
762
- .cache-ring svg {
763
- transform: rotate(-90deg);
1019
+ .table-scroll {
1020
+ max-height: 32rem;
1021
+ overflow: auto;
764
1022
  }
765
1023
 
766
- .cache-ring-bg {
767
- fill: none;
768
- stroke: var(--bg-tertiary);
769
- stroke-width: 12;
1024
+ .pages {
1025
+ width: 100%;
1026
+ border-collapse: collapse;
1027
+ font-size: 0.8125rem;
770
1028
  }
771
1029
 
772
- .cache-ring-progress {
773
- fill: none;
774
- stroke: var(--accent-green);
775
- stroke-width: 12;
776
- stroke-linecap: round;
777
- transition: stroke-dashoffset 0.5s ease;
1030
+ .pages th {
1031
+ position: sticky;
1032
+ top: 0;
1033
+ z-index: 1;
1034
+ padding: 0.5rem 1rem;
1035
+ border-bottom: 1px solid rgb(var(--line));
1036
+ background-color: rgb(var(--sunken));
1037
+ font-size: 0.75rem;
1038
+ font-weight: 600;
1039
+ text-align: left;
1040
+ white-space: nowrap;
1041
+ color: rgb(var(--muted));
778
1042
  }
779
1043
 
780
- .cache-ring-text {
781
- position: absolute;
782
- top: 50%;
783
- left: 50%;
784
- transform: translate(-50%, -50%);
785
- text-align: center;
1044
+ .pages td {
1045
+ padding: 0.5rem 1rem;
1046
+ border-top: 1px solid rgb(var(--line));
1047
+ vertical-align: middle;
1048
+ white-space: nowrap;
786
1049
  }
787
1050
 
788
- .cache-ring-value {
789
- font-size: 28px;
790
- font-weight: 700;
791
- color: var(--accent-green);
1051
+ .pages tbody tr:first-child td {
1052
+ border-top: 0;
792
1053
  }
793
1054
 
794
- .cache-ring-label {
795
- font-size: 11px;
796
- color: var(--text-secondary);
1055
+ .pages .num {
1056
+ text-align: right;
797
1057
  }
798
1058
 
799
- /* Page Timings Table */
800
- .page-table {
801
- width: 100%;
802
- border-collapse: collapse;
803
- font-size: 13px;
1059
+ .page-url {
1060
+ color: rgb(var(--ink));
804
1061
  }
805
1062
 
806
- .page-table th {
807
- text-align: left;
808
- padding: 12px 16px;
809
- background: var(--bg-tertiary);
810
- color: var(--text-secondary);
811
- font-weight: 500;
812
- font-size: 12px;
813
- text-transform: uppercase;
814
- letter-spacing: 0.5px;
815
- border-bottom: 1px solid var(--border-color);
1063
+ .page-url span {
1064
+ display: block;
1065
+ max-width: 26rem;
1066
+ overflow: hidden;
1067
+ text-overflow: ellipsis;
816
1068
  }
817
1069
 
818
- .page-table td {
819
- padding: 12px 16px;
820
- border-bottom: 1px solid var(--border-color);
1070
+ .status {
1071
+ display: inline-flex;
1072
+ align-items: center;
1073
+ gap: 0.5rem;
821
1074
  vertical-align: middle;
822
1075
  }
823
1076
 
824
- .page-table tr:hover {
825
- background: var(--bg-tertiary);
1077
+ .status-rendered {
1078
+ color: rgb(var(--ink));
826
1079
  }
827
1080
 
828
- .page-table .url {
829
- font-family: 'SF Mono', Monaco, Consolas, monospace;
830
- font-size: 12px;
831
- color: var(--accent-blue);
832
- max-width: 400px;
833
- overflow: hidden;
834
- text-overflow: ellipsis;
835
- white-space: nowrap;
1081
+ .status-cached {
1082
+ color: rgb(var(--faint));
836
1083
  }
837
1084
 
838
- .page-table .duration {
839
- font-variant-numeric: tabular-nums;
840
- font-weight: 500;
1085
+ .marker {
1086
+ width: 0.375rem;
1087
+ height: 0.375rem;
1088
+ flex: none;
841
1089
  }
842
1090
 
843
- .page-table .status {
844
- display: inline-flex;
845
- align-items: center;
846
- gap: 6px;
847
- padding: 4px 10px;
848
- border-radius: 20px;
849
- font-size: 11px;
850
- font-weight: 500;
1091
+ .status-rendered .marker {
1092
+ background-color: rgb(var(--ink));
851
1093
  }
852
1094
 
853
- .page-table .status.cached {
854
- background: rgba(63, 185, 80, 0.15);
855
- color: var(--accent-green);
1095
+ .status-cached .marker {
1096
+ border: 1px solid currentColor;
1097
+ border-radius: 50%;
856
1098
  }
857
1099
 
858
- .page-table .status.rendered {
859
- background: rgba(88, 166, 255, 0.15);
860
- color: var(--accent-blue);
1100
+ .page-duration {
1101
+ width: 40%;
1102
+ text-align: right;
861
1103
  }
862
1104
 
863
- /* Tabs */
864
- .tabs {
1105
+ .duration {
865
1106
  display: flex;
866
- gap: 4px;
867
- padding: 8px;
868
- background: var(--bg-tertiary);
869
- border-radius: var(--radius);
870
- margin-bottom: 16px;
1107
+ align-items: center;
1108
+ gap: 0.75rem;
871
1109
  }
872
1110
 
873
- .tab {
874
- padding: 8px 16px;
875
- border-radius: 6px;
876
- font-size: 13px;
877
- font-weight: 500;
878
- color: var(--text-secondary);
879
- background: transparent;
880
- border: none;
881
- cursor: pointer;
882
- transition: all 0.2s ease;
1111
+ .duration .meter {
1112
+ flex: 1;
1113
+ min-width: 6rem;
883
1114
  }
884
1115
 
885
- .tab:hover {
886
- color: var(--text-primary);
887
- background: var(--bg-secondary);
1116
+ .duration .num {
1117
+ min-width: 4.75rem;
1118
+ text-align: right;
888
1119
  }
889
1120
 
890
- .tab.active {
891
- color: var(--text-primary);
892
- background: var(--bg-secondary);
893
- box-shadow: var(--shadow);
1121
+ .meter-empty {
1122
+ background-color: transparent;
894
1123
  }
895
1124
 
896
- .tab-content {
897
- display: none;
898
- }
1125
+ /* Raw JSON */
899
1126
 
900
- .tab-content.active {
901
- display: block;
1127
+ .disclosure summary {
1128
+ display: flex;
1129
+ align-items: center;
1130
+ gap: 0.75rem;
1131
+ width: fit-content;
1132
+ cursor: pointer;
1133
+ list-style: none;
1134
+ border-radius: 4px;
902
1135
  }
903
1136
 
904
- .phase-view {
1137
+ .disclosure summary::-webkit-details-marker {
905
1138
  display: none;
906
1139
  }
907
1140
 
908
- .phase-view.active {
909
- display: block;
910
- }
911
-
912
- /* JSON View */
913
- .json-view {
914
- background: var(--bg-tertiary);
915
- border-radius: var(--radius);
916
- padding: 16px;
917
- overflow-x: auto;
918
- }
919
-
920
- .json-view pre {
921
- font-family: 'SF Mono', Monaco, Consolas, monospace;
922
- font-size: 12px;
923
- line-height: 1.6;
924
- color: var(--text-primary);
925
- margin: 0;
1141
+ .disclosure summary:hover .section-title {
1142
+ color: rgb(var(--accent-ink));
926
1143
  }
927
1144
 
928
- /* Incremental Section */
929
- .incremental-info {
930
- display: grid;
931
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
932
- gap: 20px;
1145
+ .disclosure[open] summary .chevron {
1146
+ transform: rotate(90deg);
933
1147
  }
934
1148
 
935
- .trigger-file {
936
- font-family: 'SF Mono', Monaco, Consolas, monospace;
937
- font-size: 12px;
938
- color: var(--accent-yellow);
939
- background: rgba(210, 153, 34, 0.1);
940
- padding: 8px 12px;
941
- border-radius: 4px;
942
- word-break: break-all;
1149
+ .disclosure .panel {
1150
+ margin-top: 1rem;
943
1151
  }
944
1152
 
945
- /* Badges */
946
- .badge {
1153
+ .copy-button {
947
1154
  display: inline-flex;
948
1155
  align-items: center;
949
- gap: 4px;
950
- padding: 2px 8px;
951
- border-radius: 12px;
952
- font-size: 11px;
1156
+ padding: 0.25rem 0.5rem;
1157
+ margin-right: -0.5rem;
1158
+ border: 0;
1159
+ border-radius: 6px;
1160
+ background: none;
1161
+ font: inherit;
1162
+ font-family: var(--font-sans);
1163
+ font-size: 0.75rem;
953
1164
  font-weight: 500;
1165
+ color: rgb(var(--muted));
1166
+ cursor: pointer;
1167
+ transition: color 150ms, background-color 150ms;
954
1168
  }
955
1169
 
956
- .badge.ci {
957
- background: rgba(163, 113, 247, 0.15);
958
- color: var(--accent-purple);
1170
+ .copy-button:hover {
1171
+ background-color: rgb(var(--raised));
1172
+ color: rgb(var(--ink));
959
1173
  }
960
1174
 
961
- .badge.local {
962
- background: rgba(88, 166, 255, 0.15);
963
- color: var(--accent-blue);
1175
+ .copy-button[data-copied='true'] {
1176
+ color: rgb(var(--accent-ink));
964
1177
  }
965
1178
 
966
- .badge.detailed {
967
- background: rgba(57, 197, 207, 0.15);
968
- color: var(--accent-cyan);
1179
+ .code {
1180
+ max-height: 36rem;
1181
+ overflow: auto;
1182
+ padding: 1rem 1.25rem;
1183
+ font-size: 0.8125rem;
1184
+ line-height: 1.65;
1185
+ tab-size: 2;
1186
+ color: rgb(var(--ink));
969
1187
  }
970
1188
 
971
- /* Flags */
972
- .flags {
973
- display: flex;
974
- gap: 8px;
975
- flex-wrap: wrap;
976
- }
1189
+ /* Footer */
977
1190
 
978
- .flag {
979
- display: inline-flex;
980
- align-items: center;
981
- gap: 4px;
982
- padding: 4px 10px;
983
- background: var(--bg-tertiary);
984
- border-radius: 4px;
985
- font-size: 12px;
986
- color: var(--text-secondary);
1191
+ .footer {
1192
+ border-top: 1px solid rgb(var(--line));
1193
+ font-size: 0.8125rem;
1194
+ color: rgb(var(--faint));
987
1195
  }
988
1196
 
989
- .flag.enabled {
990
- color: var(--accent-green);
1197
+ .footer .shell {
1198
+ display: flex;
1199
+ flex-wrap: wrap;
1200
+ gap: 0.25rem 0.75rem;
1201
+ padding-top: 1rem;
1202
+ padding-bottom: 1rem;
991
1203
  }
992
1204
 
993
- .flag.disabled {
994
- color: var(--text-muted);
1205
+ .footer-name {
1206
+ font-weight: 600;
1207
+ color: rgb(var(--muted));
995
1208
  }
996
1209
 
997
- /* Tooltip */
998
- [data-tooltip] {
999
- position: relative;
1000
- cursor: help;
1001
- }
1210
+ @media (prefers-reduced-motion: no-preference) {
1211
+ .main {
1212
+ animation: report-in 240ms cubic-bezier(0.16, 1, 0.3, 1) both;
1213
+ }
1002
1214
 
1003
- [data-tooltip]:hover::after {
1004
- content: attr(data-tooltip);
1005
- position: absolute;
1006
- bottom: 100%;
1007
- left: 50%;
1008
- transform: translateX(-50%);
1009
- padding: 6px 10px;
1010
- background: var(--bg-tertiary);
1011
- color: var(--text-primary);
1012
- font-size: 12px;
1013
- border-radius: 4px;
1014
- white-space: nowrap;
1015
- z-index: 100;
1016
- box-shadow: var(--shadow);
1017
- border: 1px solid var(--border-color);
1215
+ @keyframes report-in {
1216
+ from {
1217
+ opacity: 0;
1218
+ transform: translateY(6px);
1219
+ }
1220
+ }
1018
1221
  }
1019
1222
 
1020
- /* Responsive */
1021
- @media (max-width: 768px) {
1022
- .container {
1023
- padding: 16px;
1223
+ @media (max-width: 639px) {
1224
+ .phase-row {
1225
+ grid-template-columns: minmax(0, 1fr) auto auto 1rem;
1226
+ grid-template-areas:
1227
+ 'name time share icon'
1228
+ 'meter meter meter meter';
1229
+ row-gap: 0.5rem;
1024
1230
  }
1025
1231
 
1026
- .phase-item {
1027
- grid-template-columns: 1fr;
1028
- gap: 8px;
1232
+ .phases[data-view='timeline'] .phase-row {
1233
+ grid-template-columns: 1.75rem minmax(0, 1fr) auto auto 1rem;
1234
+ grid-template-areas:
1235
+ 'order name time share icon'
1236
+ '. meter meter meter meter';
1029
1237
  }
1030
1238
 
1031
- .phase-duration,
1032
- .phase-percent {
1033
- text-align: left;
1239
+ .phase-step .meter {
1240
+ margin-left: 0.875rem;
1034
1241
  }
1035
1242
 
1036
- .meta-info {
1037
- flex-direction: column;
1038
- gap: 8px;
1243
+ .duration .meter {
1244
+ display: none;
1245
+ }
1246
+
1247
+ .page-duration {
1248
+ width: auto;
1039
1249
  }
1040
1250
  }
1041
1251
 
1042
- /* Print styles */
1043
- @media print {
1044
- body {
1045
- background: white;
1046
- color: black;
1252
+ @media (min-width: 640px) {
1253
+ .shell {
1254
+ padding-left: 1.5rem;
1255
+ padding-right: 1.5rem;
1047
1256
  }
1048
1257
 
1049
- .section {
1050
- break-inside: avoid;
1258
+ .main {
1259
+ padding-top: 3.5rem;
1051
1260
  }
1052
1261
 
1053
- .tab {
1054
- display: none !important;
1262
+ .headline {
1263
+ font-size: 2.25rem;
1055
1264
  }
1056
1265
 
1057
- .tab-content {
1058
- display: block !important;
1266
+ .groups {
1267
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1059
1268
  }
1060
1269
  }
1061
1270
 
1062
- /* Animations */
1063
- @keyframes fadeIn {
1064
- from { opacity: 0; transform: translateY(10px); }
1065
- to { opacity: 1; transform: translateY(0); }
1066
- }
1271
+ @media (min-width: 1024px) {
1272
+ .shell {
1273
+ padding-left: 3rem;
1274
+ padding-right: 3rem;
1275
+ }
1067
1276
 
1068
- .section {
1069
- animation: fadeIn 0.3s ease-out;
1070
- }
1277
+ .headline {
1278
+ font-size: 2.75rem;
1279
+ }
1071
1280
 
1072
- .summary-card {
1073
- animation: fadeIn 0.3s ease-out;
1281
+ .stats,
1282
+ .groups {
1283
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1284
+ }
1074
1285
  }
1075
1286
 
1076
- .summary-card:nth-child(2) { animation-delay: 0.05s; }
1077
- .summary-card:nth-child(3) { animation-delay: 0.1s; }
1078
- .summary-card:nth-child(4) { animation-delay: 0.15s; }
1079
- .summary-card:nth-child(5) { animation-delay: 0.2s; }
1080
- .summary-card:nth-child(6) { animation-delay: 0.25s; }
1287
+ @media print {
1288
+ .bar-nav,
1289
+ .copy-button {
1290
+ display: none;
1291
+ }
1292
+
1293
+ .table-scroll,
1294
+ .code {
1295
+ max-height: none;
1296
+ overflow: visible;
1297
+ }
1298
+ }`;
1299
+ /**
1300
+ * Tabs that switch the phase views, with the arrow keys moving between them, and copying the
1301
+ * raw JSON.
1302
+ */
1303
+ const REPORT_SCRIPT = `
1304
+ (function () {
1305
+ document.querySelectorAll('[role="tablist"]').forEach(function (list) {
1306
+ var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
1307
+
1308
+ function select(tab) {
1309
+ tabs.forEach(function (other) {
1310
+ var selected = other === tab;
1311
+ other.setAttribute('aria-selected', selected ? 'true' : 'false');
1312
+ other.tabIndex = selected ? 0 : -1;
1313
+ document.getElementById(other.getAttribute('aria-controls')).hidden = !selected;
1314
+ });
1315
+ }
1316
+
1317
+ tabs.forEach(function (tab, index) {
1318
+ tab.addEventListener('click', function () {
1319
+ select(tab);
1320
+ });
1321
+ tab.addEventListener('keydown', function (event) {
1322
+ var step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
1323
+ if (!step) return;
1324
+ event.preventDefault();
1325
+ var next = tabs[(index + step + tabs.length) % tabs.length];
1326
+ select(next);
1327
+ next.focus();
1328
+ });
1329
+ });
1330
+ });
1331
+
1332
+ // The JSON section starts closed; following a link to it opens it
1333
+ var json = document.getElementById('raw-json-data');
1334
+ document.querySelectorAll('a[href="#raw-json"]').forEach(function (link) {
1335
+ link.addEventListener('click', function () {
1336
+ json.open = true;
1337
+ });
1338
+ });
1339
+
1340
+ var copyButton = document.getElementById('copy-json');
1341
+ var copyStatus = document.getElementById('copy-status');
1342
+ if (!navigator.clipboard) {
1343
+ copyButton.hidden = true;
1344
+ return;
1345
+ }
1346
+ copyButton.addEventListener('click', function () {
1347
+ var text = document.getElementById('json-content').textContent;
1348
+ navigator.clipboard.writeText(text).then(function () {
1349
+ copyButton.setAttribute('data-copied', 'true');
1350
+ copyButton.textContent = 'Copied';
1351
+ copyStatus.textContent = 'JSON copied to the clipboard';
1352
+ setTimeout(function () {
1353
+ copyButton.removeAttribute('data-copied');
1354
+ copyButton.textContent = 'Copy';
1355
+ copyStatus.textContent = '';
1356
+ }, 2000);
1357
+ });
1358
+ });
1359
+ })();`;
1360
+ /**
1361
+ * Generate the HTML report content
1362
+ */
1363
+ export function generateMetricsHtml(metrics) {
1364
+ const { meta, totals, phases, counts, isg, pageTimings, incremental } = metrics;
1365
+ const phaseRows = generatePhaseRows(phases, totals.durationMs);
1366
+ const hasDetailedTimings = pageTimings !== undefined && pageTimings.length > 0;
1367
+ const enabledFlags = [
1368
+ meta.flags.force ? '--force' : null,
1369
+ meta.flags.clean ? '--clean' : null,
1370
+ meta.flags.includeDrafts ? '--include-drafts' : null,
1371
+ ].filter((flag) => flag !== null);
1372
+ const commit = meta.gitCommit ? meta.gitCommit.substring(0, 7) : undefined;
1373
+ const source = meta.gitBranch ? `${meta.gitBranch}${commit ? ` (${commit})` : ''}` : commit;
1374
+ const perPage = counts.renderedPages > 0 ? (counts.templatesLoaded / counts.renderedPages).toFixed(1) : '0';
1375
+ const navigation = [
1376
+ ['phases', 'Phases'],
1377
+ ['counts', 'Counts'],
1378
+ incremental ? ['rebuild', 'Rebuild'] : null,
1379
+ hasDetailedTimings ? ['pages', 'Pages'] : null,
1380
+ ['raw-json', 'JSON'],
1381
+ ]
1382
+ .filter((link) => link !== null)
1383
+ .map(([id, label]) => `<a href="#${id}">${label}</a>`)
1384
+ .join('\n ');
1385
+ // Escaped for the <pre>: the JSON stays readable in the page source and copies unchanged
1386
+ const json = JSON.stringify(metrics, null, 2);
1387
+ const jsonData = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1388
+ return `<!DOCTYPE html>
1389
+ <html lang="en">
1390
+ <head>
1391
+ <meta charset="UTF-8">
1392
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1393
+ <meta name="color-scheme" content="light dark">
1394
+ <link rel="icon" href="${STATI_FAVICON_HREF}">
1395
+ <title>Stati Build Metrics Report - ${escapeHtml(meta.timestamp)}</title>
1396
+ <style>${STATI_THEME_STYLES}
1397
+ ${REPORT_STYLES}
1398
+ ${STATI_PRINT_STYLES}
1081
1399
  </style>
1082
1400
  </head>
1083
1401
  <body>
1084
- <div class="container">
1085
- <!-- Header -->
1086
- <header>
1087
- <h1>
1088
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1089
- <path d="M12 20V10M18 20V4M6 20v-4"/>
1090
- </svg>
1091
- Stati Build Metrics Report
1092
- ${hasDetailedTimings ? '<span class="badge detailed">Detailed</span>' : ''}
1093
- ${meta.ci ? '<span class="badge ci">CI</span>' : '<span class="badge local">Local</span>'}
1094
- </h1>
1095
- <div class="meta-info">
1096
- <span>
1097
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1098
- <circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
1099
- </svg>
1100
- ${new Date(meta.timestamp).toLocaleString()}
1101
- </span>
1102
- <span>
1103
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1104
- <path d="M4 19.5A2.5 2.5 0 016.5 17H20"/>
1105
- <path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/>
1106
- </svg>
1107
- Command: ${meta.command}
1108
- </span>
1109
- <span>
1110
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1111
- <rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
1112
- </svg>
1113
- ${meta.platform} / ${meta.arch}
1114
- </span>
1115
- <span>
1116
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1117
- <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
1118
- </svg>
1119
- Node ${meta.nodeVersion}
1120
- </span>
1121
- <span>
1122
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1123
- <path d="M20 7h-9M14 17H5M17 17a3 3 0 100-6 3 3 0 000 6zM7 7a3 3 0 100-6 3 3 0 000 6z"/>
1124
- </svg>
1125
- CLI v${meta.cliVersion} / Core v${meta.coreVersion}
1126
- </span>
1127
- ${meta.gitBranch
1128
- ? `
1129
- <span>
1130
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1131
- <line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/>
1132
- <path d="M18 9a9 9 0 01-9 9"/>
1133
- </svg>
1134
- ${meta.gitBranch}${meta.gitCommit ? ` (${meta.gitCommit.substring(0, 7)})` : ''}
1135
- </span>`
1136
- : ''}
1137
- </div>
1138
- ${Object.keys(meta.flags).length > 0
1139
- ? `
1140
- <div class="flags" style="margin-top: 12px;">
1141
- ${meta.flags.force !== undefined ? `<span class="flag ${meta.flags.force ? 'enabled' : 'disabled'}">--force: ${meta.flags.force}</span>` : ''}
1142
- ${meta.flags.clean !== undefined ? `<span class="flag ${meta.flags.clean ? 'enabled' : 'disabled'}">--clean: ${meta.flags.clean}</span>` : ''}
1143
- ${meta.flags.includeDrafts !== undefined ? `<span class="flag ${meta.flags.includeDrafts ? 'enabled' : 'disabled'}">--include-drafts: ${meta.flags.includeDrafts}</span>` : ''}
1144
- </div>`
1145
- : ''}
1146
- </header>
1147
-
1148
- <!-- Summary Cards -->
1149
- <div class="summary-grid">
1150
- <div class="summary-card highlight">
1151
- <div class="label">Total Duration</div>
1152
- <div class="value">${formatDuration(totals.durationMs)}</div>
1153
- <div class="subtext">${(totals.durationMs / 1000).toFixed(3)} seconds</div>
1154
- </div>
1155
- <div class="summary-card ${counts.cachedPages > 0 ? 'success' : ''}">
1156
- <div class="label">Pages</div>
1157
- <div class="value">${counts.totalPages}</div>
1158
- <div class="subtext">${counts.renderedPages} rendered, ${counts.cachedPages} cached</div>
1159
- </div>
1160
- <div class="summary-card">
1161
- <div class="label">Cache Hit Rate</div>
1162
- <div class="value" style="color: ${isg.cacheHitRate > 0.5 ? 'var(--accent-green)' : isg.cacheHitRate > 0 ? 'var(--accent-yellow)' : 'var(--text-secondary)'}">${formatPercent(isg.cacheHitRate)}</div>
1163
- <div class="subtext">${isg.manifestEntries} manifest entries</div>
1164
- </div>
1165
- <div class="summary-card">
1166
- <div class="label">Peak Memory</div>
1167
- <div class="value">${formatBytes(totals.peakRssBytes)}</div>
1168
- <div class="subtext">Heap: ${formatBytes(totals.heapUsedBytes)}</div>
1169
- </div>
1170
- <div class="summary-card">
1171
- <div class="label">Templates Loaded</div>
1172
- <div class="value">${counts.templatesLoaded}</div>
1173
- <div class="subtext">${(counts.templatesLoaded / counts.totalPages).toFixed(1)} avg per page</div>
1174
- </div>
1175
- <div class="summary-card">
1176
- <div class="label">Assets Copied</div>
1177
- <div class="value">${counts.assetsCopied}</div>
1178
- <div class="subtext">${counts.markdownFilesProcessed} markdown files</div>
1402
+ <header class="bar">
1403
+ <div class="shell">
1404
+ <div class="brand">
1405
+ <svg class="brand-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${STATI_MARK_PATH}"/></svg>
1406
+ <span class="brand-name">Stati</span>
1407
+ <span class="brand-rule" aria-hidden="true"></span>
1408
+ <span class="brand-context">Build metrics</span>
1179
1409
  </div>
1410
+ <nav class="bar-nav" aria-label="Report sections">
1411
+ ${navigation}
1412
+ </nav>
1180
1413
  </div>
1181
-
1182
- <!-- Phase Breakdown -->
1183
- <div class="section">
1184
- <div class="section-header">
1185
- <h2>
1186
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1187
- <path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
1188
- </svg>
1189
- Phase Breakdown
1190
- </h2>
1191
- <span style="font-size: 13px; color: var(--text-secondary);">
1192
- ${phaseData.length} phases tracked • ${formatDuration(totalPhaseTime)} total
1193
- </span>
1194
- </div>
1195
- <div class="section-content">
1196
- <!-- View Tabs -->
1197
- <div class="tabs" style="margin-bottom: 20px;">
1198
- <button class="tab active" onclick="switchPhaseView('duration')">
1199
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 6px;">
1200
- <path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-3 3"/>
1201
- </svg>
1202
- By Duration
1203
- </button>
1204
- <button class="tab" onclick="switchPhaseView('timeline')">
1205
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 6px;">
1206
- <circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
1207
- </svg>
1208
- Timeline
1209
- </button>
1414
+ </header>
1415
+
1416
+ <main class="main shell">
1417
+ <section class="summary" aria-labelledby="report-title">
1418
+ <h1 class="headline" id="report-title">Built ${formatCount(counts.totalPages)} ${counts.totalPages === 1 ? 'page' : 'pages'} in ${formatDuration(totals.durationMs)}</h1>
1419
+ <p class="meta">
1420
+ <span class="type-badge">${escapeHtml(meta.command)}</span>
1421
+ <span class="type-badge">${meta.ci ? 'CI' : 'Local'}</span>
1422
+ ${hasDetailedTimings ? '<span class="type-badge">Detailed</span>' : ''}
1423
+ <time class="meta-time" datetime="${escapeHtml(meta.timestamp)}">${escapeHtml(new Date(meta.timestamp).toLocaleString())}</time>
1424
+ </p>
1425
+
1426
+ <dl class="stats">
1427
+ <div class="stat">
1428
+ <dt>Pages rendered</dt>
1429
+ <dd class="stat-value">${formatCount(counts.renderedPages)}</dd>
1430
+ <dd class="stat-note">of ${formatCount(counts.totalPages)}, ${formatCount(counts.cachedPages)} from cache</dd>
1210
1431
  </div>
1211
-
1212
- <!-- Duration View (sorted by duration) -->
1213
- <div id="duration-view" class="phase-view active">
1214
- <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 16px;">
1215
- Click on any phase to see a detailed description of what happens during that phase.
1216
- </p>
1217
- <div class="phase-list">
1218
- ${phaseChartData
1219
- .map((phase) => `
1220
- <div class="phase-item" onclick="togglePhase(this)">
1221
- <div class="phase-name" title="${phase.name}">${phase.label}</div>
1222
- <div class="phase-bar-container">
1223
- <div class="phase-bar" style="width: ${Math.max(phase.percent, 0.5)}%"></div>
1224
- </div>
1225
- <div class="phase-duration">${formatDuration(phase.value)}</div>
1226
- <div class="phase-percent">${phase.percent.toFixed(1)}%</div>
1227
- <svg class="phase-expand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1228
- <polyline points="6,9 12,15 18,9"/>
1229
- </svg>
1230
- <div class="phase-description">${phase.description}</div>
1231
- </div>`)
1232
- .join('')}
1233
- </div>
1432
+ <div class="stat">
1433
+ <dt>Cache hit rate</dt>
1434
+ <dd class="stat-value">${formatPercent(isg.cacheHitRate)}</dd>
1435
+ <dd class="stat-note">${formatCount(isg.manifestEntries)} manifest entries</dd>
1234
1436
  </div>
1235
-
1236
- <!-- Timeline View (in execution order) -->
1237
- <div id="timeline-view" class="phase-view" style="display: none;">
1238
- <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 16px;">
1239
- Phases shown in their approximate execution order during the build process.
1240
- </p>
1241
-
1242
- <!-- Visual Timeline Track -->
1243
- <div class="timeline-track">
1244
- ${timelineChartData
1245
- .map((phase, i) => `
1246
- <div class="timeline-segment phase-color-${i % 8}"
1247
- style="left: ${phase.startOffset}%; width: ${Math.max(phase.percent, 0.5)}%;"
1248
- title="${phase.label}: ${formatDuration(phase.value)} (${phase.percent.toFixed(1)}%)"
1249
- onclick="expandTimelineItem(${i})">
1250
- <span>${phase.percent > 8 ? phase.label : ''}</span>
1251
- </div>`)
1252
- .join('')}
1253
- </div>
1254
-
1255
- <!-- Timeline List -->
1256
- <div class="timeline-list">
1257
- ${timelineChartData
1258
- .map((phase, i) => `
1259
- <div class="timeline-item" id="timeline-item-${i}" onclick="toggleTimeline(this)">
1260
- <div class="timeline-order">${i + 1}</div>
1261
- <div class="timeline-name">${phase.label}</div>
1262
- <div class="timeline-bar-container">
1263
- <div class="timeline-bar phase-color-${i % 8}" style="width: ${Math.max(phase.percent, 0.5)}%"></div>
1264
- </div>
1265
- <div class="timeline-time">${formatDuration(phase.value)}</div>
1266
- <div class="timeline-description">${phase.description}</div>
1267
- </div>`)
1268
- .join('')}
1269
- </div>
1437
+ <div class="stat">
1438
+ <dt>Templates loaded</dt>
1439
+ <dd class="stat-value">${formatCount(counts.templatesLoaded)}</dd>
1440
+ <dd class="stat-note">${perPage} per rendered page</dd>
1270
1441
  </div>
1271
- </div>
1272
- </div>
1273
-
1274
- <!-- ISG Cache Details -->
1275
- <div class="section">
1276
- <div class="section-header">
1277
- <h2>
1278
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1279
- <path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/>
1280
- </svg>
1281
- ISG Cache Performance
1282
- </h2>
1283
- <span style="font-size: 13px; color: ${isg.enabled ? 'var(--accent-green)' : 'var(--text-muted)'};">
1284
- ${isg.enabled ? '● Enabled' : '○ Disabled'}
1285
- </span>
1286
- </div>
1287
- <div class="section-content">
1288
- <div style="display: flex; gap: 40px; align-items: center; flex-wrap: wrap;">
1289
- <div class="cache-ring-container">
1290
- <div class="cache-ring">
1291
- <svg width="150" height="150" viewBox="0 0 150 150">
1292
- <circle class="cache-ring-bg" cx="75" cy="75" r="60"/>
1293
- <circle class="cache-ring-progress" cx="75" cy="75" r="60"
1294
- stroke-dasharray="${2 * Math.PI * 60}"
1295
- stroke-dashoffset="${2 * Math.PI * 60 * (1 - isg.cacheHitRate)}"/>
1296
- </svg>
1297
- <div class="cache-ring-text">
1298
- <div class="cache-ring-value">${formatPercent(isg.cacheHitRate)}</div>
1299
- <div class="cache-ring-label">Cache Hit Rate</div>
1300
- </div>
1301
- </div>
1302
- </div>
1303
- <div class="isg-grid" style="flex: 1;">
1304
- <div class="isg-stat hit">
1305
- <div class="value">${counts.cachedPages}</div>
1306
- <div class="label">Pages from Cache</div>
1307
- </div>
1308
- <div class="isg-stat miss">
1309
- <div class="value">${counts.renderedPages}</div>
1310
- <div class="label">Pages Rendered</div>
1311
- </div>
1312
- <div class="isg-stat">
1313
- <div class="value" style="color: var(--accent-purple)">${isg.manifestEntries}</div>
1314
- <div class="label">Manifest Entries</div>
1315
- </div>
1316
- <div class="isg-stat">
1317
- <div class="value" style="color: var(--accent-orange)">${isg.invalidatedEntries}</div>
1318
- <div class="label">Invalidated</div>
1319
- </div>
1320
- </div>
1442
+ <div class="stat">
1443
+ <dt>Peak memory</dt>
1444
+ <dd class="stat-value">${formatBytes(totals.peakRssBytes)}</dd>
1445
+ <dd class="stat-note">Heap ${formatBytes(totals.heapUsedBytes)}</dd>
1321
1446
  </div>
1322
- </div>
1323
- </div>
1447
+ </dl>
1324
1448
 
1325
- ${hasIncremental
1326
- ? `
1327
- <!-- Incremental Rebuild -->
1328
- <div class="section">
1329
- <div class="section-header">
1330
- <h2>
1331
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1332
- <path d="M23 4v6h-6M1 20v-6h6"/>
1333
- <path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
1334
- </svg>
1335
- Incremental Rebuild
1336
- </h2>
1337
- </div>
1338
- <div class="section-content">
1339
- <div class="incremental-info">
1340
- <div class="stat-item">
1341
- <div class="stat-label">Trigger File</div>
1342
- <div class="trigger-file">${incremental.triggerFile}</div>
1343
- </div>
1344
- <div class="stat-item">
1345
- <div class="stat-label">Trigger Type</div>
1346
- <div class="stat-value">${incremental.triggerType}</div>
1347
- </div>
1348
- <div class="stat-item">
1349
- <div class="stat-label">Rebuild Duration</div>
1350
- <div class="stat-value">${formatDuration(incremental.durationMs)}</div>
1351
- </div>
1352
- <div class="stat-item">
1353
- <div class="stat-label">Pages Affected</div>
1354
- <div class="stat-value">${incremental.renderedPages} rendered / ${incremental.cachedPages} cached</div>
1355
- </div>
1449
+ <dl class="facts">
1450
+ <div class="fact">
1451
+ <dt>Flags</dt>
1452
+ <dd>${enabledFlags.length > 0 ? enabledFlags.join(' ') : 'none'}</dd>
1356
1453
  </div>
1357
- </div>
1358
- </div>`
1454
+ ${source
1455
+ ? `<div class="fact">
1456
+ <dt>${meta.gitBranch ? 'Branch' : 'Commit'}</dt>
1457
+ <dd>${escapeHtml(source)}</dd>
1458
+ </div>`
1359
1459
  : ''}
1360
-
1361
- ${hasDetailedTimings
1362
- ? `
1363
- <!-- Detailed Page Timings -->
1364
- <div class="section">
1365
- <div class="section-header">
1366
- <h2>
1367
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1368
- <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
1369
- <path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>
1370
- </svg>
1371
- Page Timings
1372
- </h2>
1373
- <span style="font-size: 13px; color: var(--text-secondary);">
1374
- ${sortedPageTimings.length} pages • Sorted by duration
1375
- </span>
1376
- </div>
1377
- <div class="section-content">
1378
- <div class="tabs">
1379
- <button class="tab active" onclick="switchTab('table')">Table View</button>
1380
- <button class="tab" onclick="switchTab('waterfall')">Waterfall View</button>
1460
+ <div class="fact">
1461
+ <dt>Stati</dt>
1462
+ <dd>CLI ${escapeHtml(meta.cliVersion)}, core ${escapeHtml(meta.coreVersion)}</dd>
1381
1463
  </div>
1382
-
1383
- <div id="tab-table" class="tab-content active">
1384
- <div style="overflow-x: auto;">
1385
- <table class="page-table">
1386
- <thead>
1387
- <tr>
1388
- <th>URL</th>
1389
- <th>Duration</th>
1390
- <th>Status</th>
1391
- <th>Templates</th>
1392
- </tr>
1393
- </thead>
1394
- <tbody>
1395
- ${sortedPageTimings
1396
- .map((page) => `
1397
- <tr>
1398
- <td class="url" title="${page.url}">${page.url}</td>
1399
- <td class="duration">${formatDuration(page.durationMs)}</td>
1400
- <td><span class="status ${page.cached ? 'cached' : 'rendered'}">${page.cached ? '● Cached' : '◐ Rendered'}</span></td>
1401
- <td>${page.templatesLoaded !== undefined ? page.templatesLoaded : '-'}</td>
1402
- </tr>`)
1403
- .join('')}
1404
- </tbody>
1405
- </table>
1406
- </div>
1464
+ <div class="fact">
1465
+ <dt>Node</dt>
1466
+ <dd>${escapeHtml(meta.nodeVersion)}</dd>
1407
1467
  </div>
1408
-
1409
- <div id="tab-waterfall" class="tab-content">
1410
- <div class="waterfall-container">
1411
- <div class="waterfall">
1412
- ${(() => {
1413
- const maxDuration = Math.max(...sortedPageTimings.map((p) => p.durationMs));
1414
- return sortedPageTimings
1415
- .slice(0, 50)
1416
- .map((page) => {
1417
- const widthPercent = (page.durationMs / maxDuration) * 100;
1418
- return `
1419
- <div class="waterfall-row">
1420
- <div class="waterfall-label" title="${page.url}">${page.url.split('/').pop() || page.url}</div>
1421
- <div class="waterfall-track">
1422
- <div class="waterfall-bar ${page.cached ? 'cached' : 'rendered'}" style="width: ${Math.max(widthPercent, 1)}%"></div>
1423
- </div>
1424
- <div class="waterfall-time">${formatDuration(page.durationMs)}</div>
1425
- </div>`;
1426
- })
1427
- .join('');
1428
- })()}
1429
- ${sortedPageTimings.length > 50
1430
- ? `
1431
- <div style="text-align: center; padding: 12px; color: var(--text-muted); font-size: 12px;">
1432
- Showing 50 of ${sortedPageTimings.length} pages
1433
- </div>`
1434
- : ''}
1435
- </div>
1436
- </div>
1468
+ <div class="fact">
1469
+ <dt>Platform</dt>
1470
+ <dd>${escapeHtml(`${meta.platform} ${meta.arch}`)}, ${meta.cpuCount} ${meta.cpuCount === 1 ? 'CPU' : 'CPUs'}</dd>
1437
1471
  </div>
1438
- </div>
1439
- </div>`
1440
- : ''}
1441
-
1442
- <!-- Raw JSON -->
1443
- <div class="section">
1444
- <div class="section-header">
1445
- <h2>
1446
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1447
- <path d="M16 18l6-6-6-6M8 6l-6 6 6 6"/>
1448
- </svg>
1449
- Raw JSON Data
1450
- </h2>
1451
- <button onclick="copyJson()" style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-secondary); padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;">
1452
- Copy to Clipboard
1453
- </button>
1454
- </div>
1455
- <div class="section-content">
1456
- <div class="json-view">
1457
- <pre id="json-content">${jsonData.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>
1458
- </div>
1459
- </div>
1472
+ </dl>
1473
+ </section>
1474
+ ${renderPhasesSection(phaseRows)}
1475
+ ${renderCountsSection(metrics)}
1476
+ ${incremental ? renderIncrementalSection(incremental) : ''}
1477
+ ${pageTimings && pageTimings.length > 0 ? renderPagesSection(pageTimings) : ''}
1478
+ <section class="section" id="raw-json" aria-labelledby="raw-json-title">
1479
+ <details class="disclosure" id="raw-json-data">
1480
+ <summary>
1481
+ ${CHEVRON_ICON}
1482
+ <h2 class="section-title" id="raw-json-title">Raw JSON data</h2>
1483
+ <span class="section-note">${formatBytes(Buffer.byteLength(json))}</span>
1484
+ </summary>
1485
+ <figure class="panel">
1486
+ <figcaption class="panel-bar">
1487
+ <span>Schema version ${escapeHtml(metrics.schemaVersion)}</span>
1488
+ <button type="button" class="copy-button" id="copy-json">Copy</button>
1489
+ </figcaption>
1490
+ <pre class="code" id="json-content" tabindex="0">${jsonData}</pre>
1491
+ </figure>
1492
+ <span class="sr-only" id="copy-status" role="status"></span>
1493
+ </details>
1494
+ </section>
1495
+ </main>
1496
+
1497
+ <footer class="footer">
1498
+ <div class="shell">
1499
+ <span class="footer-name">Stati build metrics</span>
1500
+ <span>Generated ${escapeHtml(new Date().toISOString())}</span>
1460
1501
  </div>
1502
+ </footer>
1461
1503
 
1462
- <!-- Footer -->
1463
- <footer style="text-align: center; padding: 24px; color: var(--text-muted); font-size: 12px;">
1464
- Generated by Stati Build Metrics • ${new Date().toISOString()}
1465
- </footer>
1466
- </div>
1467
-
1468
- <script>
1469
- function switchTab(tabName) {
1470
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
1471
- document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
1472
- document.querySelector(\`[onclick="switchTab('\${tabName}')"]\`).classList.add('active');
1473
- document.getElementById(\`tab-\${tabName}\`).classList.add('active');
1474
- }
1475
-
1476
- function switchPhaseView(viewName) {
1477
- // Update tabs
1478
- document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
1479
- document.querySelector(\`[onclick="switchPhaseView('\${viewName}')"]\`).classList.add('active');
1480
-
1481
- // Update views
1482
- document.querySelectorAll('.phase-view').forEach(v => {
1483
- v.style.display = 'none';
1484
- v.classList.remove('active');
1485
- });
1486
- const activeView = document.getElementById(\`\${viewName}-view\`);
1487
- if (activeView) {
1488
- activeView.style.display = 'block';
1489
- activeView.classList.add('active');
1490
- }
1491
- }
1492
-
1493
- function togglePhase(element) {
1494
- element.classList.toggle('expanded');
1495
- }
1496
-
1497
- function toggleTimeline(element) {
1498
- element.classList.toggle('expanded');
1499
- }
1500
-
1501
- function expandTimelineItem(index) {
1502
- const item = document.getElementById(\`timeline-item-\${index}\`);
1503
- if (item) {
1504
- // Collapse others first
1505
- document.querySelectorAll('.timeline-item.expanded').forEach(el => {
1506
- if (el !== item) el.classList.remove('expanded');
1507
- });
1508
- item.classList.add('expanded');
1509
- item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1510
- }
1511
- }
1512
-
1513
- function copyJson() {
1514
- const json = document.getElementById('json-content').textContent;
1515
- navigator.clipboard.writeText(json).then(() => {
1516
- const btn = event.target;
1517
- const originalText = btn.textContent;
1518
- btn.textContent = 'Copied!';
1519
- btn.style.color = 'var(--accent-green)';
1520
- setTimeout(() => {
1521
- btn.textContent = originalText;
1522
- btn.style.color = '';
1523
- }, 2000);
1524
- });
1525
- }
1504
+ <script>${REPORT_SCRIPT}
1526
1505
  </script>
1527
1506
  </body>
1528
1507
  </html>`;