nilometer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +462 -2
  4. package/core/pricing/prices.json +348 -0
  5. package/core/schema/001_ingestion.sql +57 -0
  6. package/core/schema/002_line_count.sql +7 -0
  7. package/core/schema/003_derived.sql +70 -0
  8. package/core/schema/004_requests_dedup.sql +50 -0
  9. package/core/schema/005_repositories.sql +25 -0
  10. package/core/schema/006_status_readings.sql +33 -0
  11. package/core/schema/007_pricing.sql +95 -0
  12. package/core/schema/008_interruptions.sql +263 -0
  13. package/core/schema/009_headroom.sql +84 -0
  14. package/core/schema/010_attribution.sql +106 -0
  15. package/core/schema/011_projections.sql +83 -0
  16. package/core/schema/012_unsplit_cache_writes.sql +49 -0
  17. package/core/schema/013_repository_revalidation.sql +104 -0
  18. package/core/schema/014_observed_readings.sql +204 -0
  19. package/core/schema/015_response_observations.sql +127 -0
  20. package/core/schema/016_windows_repository_paths.sql +41 -0
  21. package/core/schema/017_unattributed_usage_once.sql +47 -0
  22. package/core/schema/018_request_time_index.sql +21 -0
  23. package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
  24. package/core/schema/020_limit_quota_fields.sql +131 -0
  25. package/core/schema/021_plan_periods.sql +121 -0
  26. package/core/schema/022_attachment_walk.sql +223 -0
  27. package/core/schema/023_auto_continuations.sql +129 -0
  28. package/core/schema/024_counted_limit_groups.sql +29 -0
  29. package/core/schema/025_model_fallback.sql +77 -0
  30. package/core/schema/026_changed_readings.sql +106 -0
  31. package/core/schema/027_weekly_crossings.sql +32 -0
  32. package/core/schema/028_stale_readings.sql +196 -0
  33. package/core/schema/029_lockout_sessions.sql +68 -0
  34. package/core/schema/030_repository_aliases.sql +81 -0
  35. package/core/schema/031_cte_cursor_budget.sql +153 -0
  36. package/dist/cli/main.cli.js +39 -0
  37. package/dist/cli/program.js +801 -0
  38. package/dist/core/db/database.js +182 -0
  39. package/dist/core/db/fingerprint.js +71 -0
  40. package/dist/core/ingest/attribution.js +131 -0
  41. package/dist/core/ingest/classify.js +382 -0
  42. package/dist/core/ingest/command.js +95 -0
  43. package/dist/core/ingest/derive.js +212 -0
  44. package/dist/core/ingest/discover.js +126 -0
  45. package/dist/core/ingest/ingest.js +160 -0
  46. package/dist/core/ingest/reader.js +89 -0
  47. package/dist/core/ingest/reset-time.js +97 -0
  48. package/dist/core/ingest/results.js +201 -0
  49. package/dist/core/ingest/spool.js +165 -0
  50. package/dist/core/ingest/store.js +99 -0
  51. package/dist/core/install/install.js +185 -0
  52. package/dist/core/install/locations.js +89 -0
  53. package/dist/core/install/private-files.js +259 -0
  54. package/dist/core/install/record.js +103 -0
  55. package/dist/core/plans/plan-prices.js +128 -0
  56. package/dist/core/plans/repository-aliases.js +136 -0
  57. package/dist/core/pricing/prices.js +147 -0
  58. package/dist/core/settings/settings-file.js +215 -0
  59. package/dist/core/settings/statusline.js +240 -0
  60. package/dist/core/util/commands.js +91 -0
  61. package/dist/core/util/node-version.js +58 -0
  62. package/dist/core/util/open-page.js +69 -0
  63. package/dist/core/util/paths.js +36 -0
  64. package/dist/core/verify/compare.js +77 -0
  65. package/dist/core/verify/verify.js +220 -0
  66. package/dist/viewer/explain.js +574 -0
  67. package/dist/viewer/format.js +150 -0
  68. package/dist/viewer/html-charts.js +1292 -0
  69. package/dist/viewer/html-contract.js +2 -0
  70. package/dist/viewer/html-data.js +718 -0
  71. package/dist/viewer/html.js +1654 -0
  72. package/dist/viewer/queries.js +128 -0
  73. package/dist/viewer/render.js +602 -0
  74. package/dist/viewer/report.js +120 -0
  75. package/dist/viewer/save.js +110 -0
  76. package/docs/requirements.md +113 -0
  77. package/hooks/statusline.sh +104 -0
  78. package/package.json +61 -4
@@ -0,0 +1,1654 @@
1
+ /**
2
+ * @file The HTML report's page (step G1.3, D-070): one self-contained document that frames the
3
+ * charts from `viewer/html-charts.ts` in the text report's order, with a table view of every chart
4
+ * and one small inline script that lists the events behind a selected element.
5
+ *
6
+ * Implements README section Measurement Principles as a page: observed sections sit above a
7
+ * visible seam labeled as projected and the monthly repricing below it (principle 2), every chart
8
+ * section states the span it covers (principle 5), and every drawn element and table cell with a
9
+ * number carries the raw line IDs behind it, which the script resolves to `file:line` and time the
10
+ * way `nilometer explain` does (principle 4). README section Privacy: the page embeds session log
11
+ * paths, so it says so in its header. See D-070, D-012 (wording), D-030 (saved copies), D-063
12
+ * (ASCII only: every symbol is an HTML entity and user text outside ASCII becomes a character
13
+ * reference), and D-066 (plain, factual tone in what the page tells the reader).
14
+ *
15
+ * Nothing here fetches or links anything: no external script, stylesheet, font, or image, so the
16
+ * README's "makes no network requests" promise holds for a file opened in any browser.
17
+ */
18
+ import { formatCoverage, formatInstant, formatNumber, printable } from "./format.js";
19
+ import { REPO_BARS, burnSentence, renderBurnRate, renderCoverage, renderGauge, renderLockouts, renderModelBars, renderMonthBars, renderRepoBars, renderWindowSeries, } from "./html-charts.js";
20
+ import { LABELS, displayPath, planPeriodMonths, planPeriodName, plural, repoKindName, windowName, } from "./render.js";
21
+ /** The `viewBox` width every chart is drawn at; CSS scales the SVG to its container. */
22
+ const CHART_WIDTH = 960;
23
+ /** The most events one selection lists; the rest are counted, as `explain` does without `--all`. */
24
+ export const EVENT_LIST_LIMIT = 200;
25
+ /**
26
+ * Visible text that is new with the HTML report. The text report's wording stays in `LABELS`; these
27
+ * follow the add-metric skill's section 2, and the banned-phrase guard reads the whole page.
28
+ */
29
+ export const HTML_LABELS = {
30
+ privacy: "Privacy: this file holds session IDs and the paths of your session logs, like nilometer explain output, and it makes no network requests. Think before sharing it.",
31
+ // 2026-10-22 click-through: "What each number covers" with two bars didn't say why they differ.
32
+ coverage: "Where the numbers come from",
33
+ coverageWhy: "Session logs give tokens, models and limit hits. Usage percentages come only from status line readings, so they start later: when the collector was installed.",
34
+ sessionLogsHold: "tokens, models, limit hits",
35
+ statusLineHolds: "usage windows",
36
+ source: "Source",
37
+ holds: "What it holds",
38
+ tableView: "Table view",
39
+ legendPeak: "Peak usage in the window",
40
+ legendLast: "Last observed usage (a lower bound)",
41
+ legendHit: "Locked out in this window",
42
+ over100: "Readings above 100% are shown as recorded.",
43
+ // D-087 (step G3.2): the section's heading and table columns; its sentences are the text report's.
44
+ crossingsHeading: "Weekly windows at 75% and 90%",
45
+ level: "Level",
46
+ firstReading: "First reading at or above",
47
+ usageAtReading: "Usage at that reading",
48
+ timeLeft: "Time left before the reset",
49
+ seriesHint: "Select a column to show that window's readings over time and list the events behind it. Select any bar, point, count, or table cell with a number to list its events.",
50
+ peakAt: "Peak reading at",
51
+ limitHits: "Limit hits",
52
+ time: "Time",
53
+ event: "Event",
54
+ usage: "Observed usage",
55
+ reading: "Status line reading",
56
+ hit: "Limit hit",
57
+ lockoutFrom: "First limit hit",
58
+ lockoutUntil: "Reset",
59
+ lockoutElapsed: "Elapsed lockout time",
60
+ lockoutDefinition: "Each lockout runs from its first limit hit to the window's reset; overlapping lockouts are counted once.",
61
+ hits: "Limit hits",
62
+ lockoutCaveat: "Each lockout runs from its first limit hit to the window's reset. What happened during it isn't observed.",
63
+ noLockouts: "No lockouts in the covered span.",
64
+ noRequestAfter: "no request after the reset in the logs",
65
+ resetAfterLogs: "reset came after the logs end",
66
+ models: "Claude Code output tokens by model",
67
+ noRequests: "No Claude Code requests in the logs yet.",
68
+ projectedTag: "Projected",
69
+ legendPrice: "Observed tokens at API list price",
70
+ legendPlan: "Plan price entered for that month",
71
+ repricing: "Only the plan price is a price. The bars reprice the tokens observed at API list rates; they aren't a bill, because paying per token changes how people work.",
72
+ // D-091: the caveat that stays beside the bars; the reasoning above is behind the section's "i".
73
+ notABill: "Only the plan price is a price: the bars aren't a bill.",
74
+ unpricedNote: "Unpriced requests have no verified rate and are left out of the amounts, not counted as $0.",
75
+ planHint: "To show a plan price beside these amounts: plan-price set <YYYY-MM> <usd> --name <plan>",
76
+ // Step G2.1 (D-084): the tabs, the overview, and the two charts new to the page.
77
+ tabsLabel: "Report views",
78
+ observedGroup: "Observed",
79
+ projectedGroup: "Projected: estimates, not observations",
80
+ tabOverview: "Overview",
81
+ tabWindows: "Usage windows",
82
+ tabInterruptions: "Interruptions",
83
+ tabModels: "By model",
84
+ tabRepos: "By repository",
85
+ tabBurn: "Burn rate",
86
+ tabPrice: "API list price",
87
+ openTab: "Open tab",
88
+ weeklyNow: "Weekly window, last observed usage",
89
+ weeklyPeak: "Highest weekly peak observed",
90
+ mostModel: "Most output tokens, by model",
91
+ mostRepo: "Most output tokens, by repository",
92
+ repos: "Claude Code output tokens by repository",
93
+ repoCaveat: "Worktrees and subfolders count as their repository. A directory that no longer exists keeps its last known repository and is drawn faded.",
94
+ legendRepo: "Repository or folder",
95
+ legendGone: "Directory no longer exists",
96
+ legendOther: "The rest, in one bar",
97
+ burnObserved: "Observed readings",
98
+ burnProjected: "Projected at the window's average rate",
99
+ noOpenWindow: "No open usage window in the status line readings.",
100
+ lastFrom: "Last observed usage the projection starts from",
101
+ byPlanCaption: "Lockouts by plan period",
102
+ legendResumed: "Resumed by Claude Code after the reset (logged auto-continuation)",
103
+ resumedColumn: "Resumed by Claude Code",
104
+ limitProjectedFor: "Limit projected for",
105
+ };
106
+ /** Which character each HTML-special character becomes. */
107
+ const ENTITIES = {
108
+ "&": "&amp;",
109
+ "<": "&lt;",
110
+ ">": "&gt;",
111
+ '"': "&quot;",
112
+ "'": "&#39;",
113
+ };
114
+ /**
115
+ * Makes text from the logs or a label safe to place in HTML text or a quoted attribute.
116
+ *
117
+ * Model names and paths come from data an attacker can influence (D-050), so the five special
118
+ * characters become entities. Control characters are first written as visible escapes, as the text
119
+ * report does, and anything outside printable ASCII becomes a numeric character reference, so the
120
+ * file stays ASCII whatever the logs hold (D-063).
121
+ * @param value - Any text.
122
+ * @returns The escaped text; never throws.
123
+ * @example
124
+ * escapeHtml('a<b & "c"'); // "a&lt;b &amp; &quot;c&quot;"
125
+ * escapeHtml("caf\u00e9"); // "caf&#xe9;"
126
+ */
127
+ export function escapeHtml(value) {
128
+ // `u` so an astral character is one code point and one reference, not two broken surrogates.
129
+ return printable(value).replace(/[&<>"']|[^ -~]/gu,
130
+ // A regex match is never empty, so the first code point always exists.
131
+ (char) => ENTITIES[char] ?? `&#x${char.codePointAt(0).toString(16)};`);
132
+ }
133
+ /**
134
+ * Serializes data for a `<script type="application/json">` block.
135
+ *
136
+ * A `<` inside the JSON could close the tag early (`</script>` in a path) or open a comment, so it
137
+ * is written as the six-character JSON escape backslash-u-003c, which JSON.parse reads back as the
138
+ * same character. `>` and `&` become their escapes (u003e, u0026) the same way for good measure,
139
+ * and every character outside printable ASCII is escaped as well, one UTF-16 unit at a time, which
140
+ * is valid JSON and keeps the file ASCII (D-063).
141
+ * @param value - Any JSON-serializable value.
142
+ * @returns JSON text safe to place between the script tags; never throws for plain data.
143
+ */
144
+ export function embedJson(value) {
145
+ // No `u` flag: an astral character must become its two surrogate escapes, which JSON requires.
146
+ return JSON.stringify(value).replace(/[<>&]|[^ -~]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
147
+ }
148
+ /**
149
+ * Merges event lists into one, each ID once, in ascending raw line order.
150
+ * @param lists - Event lists, possibly overlapping.
151
+ * @returns The union, sorted.
152
+ */
153
+ export function mergeEvents(...lists) {
154
+ return [...new Set(lists.flat())].sort((a, b) => a - b);
155
+ }
156
+ /**
157
+ * Builds the `data-events` attribute the page script reads (principle 4).
158
+ * @param events - Raw line IDs.
159
+ * @returns ` data-events="1 2 3"`, with a leading space so it can follow a tag name.
160
+ */
161
+ function eventsAttr(events) {
162
+ return ` data-events="${events.join(" ")}"`;
163
+ }
164
+ /**
165
+ * States a span with the source it comes from.
166
+ * @param source - `session logs` or `status line`, as the text report writes it.
167
+ * @param span - The span; nulls mean no data yet.
168
+ * @param timeZone - Display zone.
169
+ * @returns E.g. `session logs 2026-09-03 10:00 to 13:05 (UTC)`.
170
+ */
171
+ function sourceSpan(source, span, timeZone) {
172
+ return `${source} ${formatCoverage(span.from, span.to, timeZone)}`;
173
+ }
174
+ /**
175
+ * Finds one source's coverage.
176
+ * @param data - Report data.
177
+ * @param source - Which source.
178
+ * @returns Its span, or an empty span when the source has no row.
179
+ */
180
+ function coverageOf(data, source) {
181
+ return data.coverage.find((row) => row.source === source)?.span ?? { from: null, to: null };
182
+ }
183
+ /**
184
+ * Builds a plain text cell.
185
+ * @param text - Display text.
186
+ * @returns The cell.
187
+ */
188
+ function textCell(text) {
189
+ return { text };
190
+ }
191
+ /**
192
+ * Builds a number cell that resolves to its events.
193
+ * @param text - The formatted number.
194
+ * @param events - Raw line IDs behind it.
195
+ * @returns The cell.
196
+ */
197
+ function numberCell(text, events) {
198
+ return { text, events, numeric: true };
199
+ }
200
+ /**
201
+ * Renders a chart's table view, readable without JavaScript and by a screen reader (D-070).
202
+ *
203
+ * Built here from the same data as the chart, so the table and the picture can't disagree. A cell
204
+ * with events carries `data-events` and a label naming its column and row, so a keyboard user can
205
+ * list the events behind any number from the table too.
206
+ * @param caption - What the table shows.
207
+ * @param headers - Column names.
208
+ * @param rows - The rows.
209
+ * @param open - True to show the table expanded, for a split a tab exists to show (D-075).
210
+ * @returns A `<details>` holding the table, closed unless `open`.
211
+ */
212
+ function tableView(caption, headers, rows, open = false) {
213
+ // A column of numbers has its header right-aligned too, over the numbers it names.
214
+ const head = headers
215
+ .map((header, column) => {
216
+ const numeric = rows.some((row) => row.cells[column]?.numeric === true);
217
+ return `<th scope="col"${numeric ? ' class="num"' : ""}>${escapeHtml(header)}</th>`;
218
+ })
219
+ .join("");
220
+ const body = rows
221
+ .map((row) => {
222
+ const cells = row.cells
223
+ .map((cell, column) => {
224
+ const numeric = cell.numeric === true ? ' class="num"' : "";
225
+ const events = cell.events === undefined
226
+ ? ""
227
+ : // Rows are built beside their headers, one cell per column, so the header exists.
228
+ `${eventsAttr(cell.events)} data-label="${escapeHtml(`${String(headers[column])}, ${row.name}`)}"`;
229
+ return `<td${numeric}${events}>${escapeHtml(cell.text)}</td>`;
230
+ })
231
+ .join("");
232
+ return `<tr>${cells}</tr>`;
233
+ })
234
+ .join("\n");
235
+ return [
236
+ `<details class="nm-table"${open ? " open" : ""}><summary>${escapeHtml(`${HTML_LABELS.tableView}: ${caption}`)}</summary>`,
237
+ `<div class="table-scroll"><table><caption>${escapeHtml(caption)}</caption>`,
238
+ `<thead><tr>${head}</tr></thead>`,
239
+ `<tbody>\n${body}\n</tbody></table></div></details>`,
240
+ ].join("\n");
241
+ }
242
+ /**
243
+ * Renders an explanation behind a small "i" button (D-091). It opens on hover, keyboard focus, or a
244
+ * tap, works without the script through CSS, and prints in full. Its text is in the page either
245
+ * way, so the banned-phrase guard reads it.
246
+ * @param id - Unique among the page's disclosures; the text's element takes `${id}-info`.
247
+ * @param about - What it explains, for the button's accessible name.
248
+ * @param paragraphs - The explanation, one paragraph each, unescaped.
249
+ * @returns The disclosure's HTML, inline.
250
+ */
251
+ function info(id, about, paragraphs) {
252
+ const text = paragraphs.map((p) => `<span>${escapeHtml(p)}</span>`).join("");
253
+ return `<span class="nm-info"><button type="button" class="nm-info-btn" aria-expanded="false" aria-controls="${id}-info" aria-label="${escapeHtml(`About: ${about}`)}">i</button><span class="nm-info-text" id="${id}-info" role="note">${text}</span></span>`;
254
+ }
255
+ /**
256
+ * Wraps a chart in its section: heading, span, body, and a hidden panel the script fills with the
257
+ * events behind a selected element.
258
+ * @param parts - The section's parts.
259
+ * @returns The section HTML.
260
+ */
261
+ function chartSection(parts) {
262
+ const tag = parts.tag === undefined ? "" : `<p class="eyebrow projected">${escapeHtml(parts.tag)}</p>\n`;
263
+ return [
264
+ `<section class="panel nm-chart" id="${parts.id}" aria-labelledby="${parts.id}-h">`,
265
+ `${tag}<div class="panel-title"><h3 id="${parts.id}-h">${escapeHtml(parts.heading)}${parts.info === undefined ? "" : info(parts.id, parts.heading, parts.info)}</h3>`,
266
+ `<p class="span">${escapeHtml(parts.span)}</p></div>`,
267
+ parts.body,
268
+ // Hidden until the script fills it: without JavaScript it would be an empty box.
269
+ '<div class="nm-events" aria-live="polite" hidden></div>',
270
+ "</section>",
271
+ ].join("\n");
272
+ }
273
+ /**
274
+ * Wraps chart SVG so CSS can scale it.
275
+ * @param svg - SVG text from a chart function.
276
+ * @returns The wrapped chart.
277
+ */
278
+ function plot(svg) {
279
+ return `<div class="plot">${svg}</div>`;
280
+ }
281
+ /**
282
+ * Renders a paragraph of escaped text.
283
+ * @param text - Text, unescaped.
284
+ * @param className - CSS class.
285
+ * @returns The paragraph.
286
+ */
287
+ function para(text, className = "caveat") {
288
+ return `<p class="${className}">${escapeHtml(text)}</p>`;
289
+ }
290
+ /**
291
+ * Renders a paragraph stating a count, which resolves to the events behind it like any drawn
292
+ * element (principle 4): the script lists them when the paragraph is selected.
293
+ * @param text - The line, unescaped, with the count in it.
294
+ * @param events - Raw line IDs behind the count.
295
+ * @param label - Names the count at the head of its events list.
296
+ * @param className - CSS class.
297
+ * @returns The paragraph.
298
+ */
299
+ function countPara(text, events, label, className = "caveat") {
300
+ return `<p class="${className}"${eventsAttr(events)} data-label="${escapeHtml(label)}">${escapeHtml(text)}</p>`;
301
+ }
302
+ /**
303
+ * Renders a line of split counts in the text report's phrasing, each count its own element with
304
+ * its own events, so every number on the line resolves to exactly the hits behind it (principle 4).
305
+ * @param parts - Each split: its phrase before the count, the count, its events, and its name at
306
+ * the head of an events list.
307
+ * @returns The paragraph, with the visible text `a: 1 | b: 2`.
308
+ */
309
+ function splitPara(parts) {
310
+ // Only the parts that happened: a zero says nothing, and a split with one part left is the total
311
+ // above it again. The text report keeps every part (2026-10-22 click-through, D-091).
312
+ const shown = parts.filter((part) => part.count > 0);
313
+ if (shown.length <= 1) {
314
+ return "";
315
+ }
316
+ const counts = shown.map((part) => `${escapeHtml(part.phrase)}: <span class="num"${eventsAttr(part.events)} data-label="${escapeHtml(part.name)}">${escapeHtml(formatNumber(part.count, "count"))}</span>`);
317
+ return `<p class="sub">${counts.join(" | ")}</p>`;
318
+ }
319
+ /**
320
+ * Says how many limit hits no gauge column holds (D-023's membership rule in the contract), in the
321
+ * same plain terms as the other caveats. Singular and plural differ past the count itself.
322
+ * @param count - Hits in no column; above zero.
323
+ * @returns The line, unescaped.
324
+ */
325
+ function noColumnText(count) {
326
+ const one = count === 1;
327
+ // The three ways a hit misses every column, as the contract's membership rule defines them.
328
+ return one
329
+ ? "1 limit hit isn't in any column: its window is unknown, it has no time, or no counted window of its kind holds its time."
330
+ : `${plural(count, "limit hit", "limit hits")} aren't in any column: their windows are unknown, they have no time, or no counted window of their kind holds their time.`;
331
+ }
332
+ /**
333
+ * Finds the span of a list of instants.
334
+ * @param instants - ISO-8601 UTC text, in any order.
335
+ * @returns The earliest and latest; nulls when the list is empty.
336
+ */
337
+ function spanOf(instants) {
338
+ // ISO-8601 UTC text sorts chronologically, so a string sort finds both ends without parsing.
339
+ const sorted = [...instants].sort();
340
+ return { from: sorted[0] ?? null, to: sorted.at(-1) ?? null };
341
+ }
342
+ /**
343
+ * Renders a legend of color keys.
344
+ * @param entries - Key class and label pairs.
345
+ * @returns The legend.
346
+ */
347
+ function legend(entries) {
348
+ const items = entries
349
+ .map(([key, label]) => `<span><i class="${key}"></i>${escapeHtml(label)}</span>`)
350
+ .join("");
351
+ return `<div class="legend">${items}</div>`;
352
+ }
353
+ /**
354
+ * Renders the header: title, zone, last ingest, and the privacy note (README section Privacy).
355
+ * @param data - Report data.
356
+ * @returns The header HTML.
357
+ */
358
+ function renderHeader(data) {
359
+ const zone = data.timeZone;
360
+ const ingest = data.lastIngestAt === null ? "none yet" : formatInstant(data.lastIngestAt, zone);
361
+ const meta = `Times are in ${zone}. Last ingest: ${ingest}. Report produced: ${formatInstant(data.generatedAtUtc, zone)}.`;
362
+ // The text report's wording, above the privacy note: readings stop until it's fixed (D-081).
363
+ const hook = data.missingHookPath === null
364
+ ? []
365
+ : [
366
+ '<div class="hook-warning" role="note">',
367
+ para(LABELS.hookMissing),
368
+ para(`${LABELS.hookMissingPath}: ${displayPath(data.missingHookPath, data.home)}`, "meta"),
369
+ "</div>",
370
+ ];
371
+ return [
372
+ '<header class="nm-head">',
373
+ `<h1>${escapeHtml(LABELS.title)}</h1>`,
374
+ para(meta, "meta"),
375
+ ...hook,
376
+ para(HTML_LABELS.privacy, "privacy"),
377
+ "</header>",
378
+ ].join("\n");
379
+ }
380
+ /**
381
+ * Renders the coverage section: how far back each source reaches.
382
+ * @param data - Report data.
383
+ * @param options - Chart options.
384
+ * @returns The section.
385
+ */
386
+ function renderCoverageSection(data, options) {
387
+ const zone = data.timeZone;
388
+ const logs = coverageOf(data, "session_logs");
389
+ const status = coverageOf(data, "status_line");
390
+ const rows = [
391
+ {
392
+ name: "Session logs",
393
+ cells: [
394
+ textCell("Session logs"),
395
+ textCell(HTML_LABELS.sessionLogsHold),
396
+ textCell(formatCoverage(logs.from, logs.to, zone)),
397
+ ],
398
+ },
399
+ {
400
+ name: "Status line",
401
+ cells: [
402
+ textCell("Status line"),
403
+ textCell(HTML_LABELS.statusLineHolds),
404
+ textCell(formatCoverage(status.from, status.to, zone)),
405
+ ],
406
+ },
407
+ ];
408
+ return chartSection({
409
+ id: "nm-coverage",
410
+ heading: HTML_LABELS.coverage,
411
+ span: `Covers: ${sourceSpan("session logs", logs, zone)}; ${sourceSpan("status line", status, zone)}`,
412
+ body: [
413
+ para(HTML_LABELS.coverageWhy),
414
+ plot(renderCoverage(data.coverage, options)),
415
+ tableView(HTML_LABELS.coverage, [HTML_LABELS.source, HTML_LABELS.holds, LABELS.columns.covers], rows),
416
+ ].join("\n"),
417
+ });
418
+ }
419
+ /**
420
+ * Names a usage window instance for headings and table rows.
421
+ * @param window - Window key.
422
+ * @param resetAtUtc - Reset instant.
423
+ * @param timeZone - Display zone.
424
+ * @returns E.g. `5-hour window resetting 2026-09-03 13:00`.
425
+ */
426
+ function windowTitle(window, resetAtUtc, timeZone) {
427
+ return `${windowName(window)} window resetting ${formatInstant(resetAtUtc, timeZone)}`;
428
+ }
429
+ /**
430
+ * Renders one window's readings over time inside a closed `<details>`, which the script opens when
431
+ * its gauge column is selected; without JavaScript it opens by hand (D-070).
432
+ *
433
+ * Its span line states the readings' span as the status line's, and the drawn hits' span apart
434
+ * under their own name: a hit's time can come from a session log, so folding it into the status
435
+ * line's span would claim that source covers a time it doesn't (principle 5). When the column
436
+ * counts hits the series can't draw, a line says how many, with their events.
437
+ * @param series - The window's readings and hits.
438
+ * @param options - Chart options.
439
+ * @returns The series container.
440
+ */
441
+ function renderSeries(series, options) {
442
+ const zone = options.timeZone;
443
+ const readingSpan = spanOf(series.points.map((p) => p.atUtc));
444
+ const hitSpan = spanOf(series.hits.map((h) => h.atUtc));
445
+ const covers = `Covers: ${sourceSpan("status line", readingSpan, zone)}` +
446
+ (series.hits.length > 0 ? `; ${sourceSpan("limit hits", hitSpan, zone)}` : "");
447
+ const title = windowTitle(series.window, series.resetAtUtc, zone);
448
+ const rows = [
449
+ ...series.points.map((p) => ({
450
+ at: p.atUtc,
451
+ cells: [
452
+ textCell(formatInstant(p.atUtc, zone)),
453
+ textCell(HTML_LABELS.reading),
454
+ numberCell(formatNumber(p.used, "percent"), [p.event]),
455
+ ],
456
+ })),
457
+ ...series.hits.map((h) => ({
458
+ at: h.atUtc,
459
+ cells: [
460
+ textCell(formatInstant(h.atUtc, zone)),
461
+ // A hit has no usage value; its event hangs on the "Limit hit" cell instead.
462
+ { text: HTML_LABELS.hit, events: [h.event] },
463
+ { text: "", numeric: true },
464
+ ],
465
+ })),
466
+ ];
467
+ // Readings and hits interleaved in time order, as the chart draws them.
468
+ rows.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
469
+ const key = `${series.window}|${series.resetAtUtc}`;
470
+ return [
471
+ `<details class="nm-series" data-series="${escapeHtml(key)}">`,
472
+ `<summary>${escapeHtml(`${title}: readings over time`)}</summary>`,
473
+ para(covers, "span"),
474
+ plot(renderWindowSeries(series, options)),
475
+ tableView(`${title}: readings over time`, [HTML_LABELS.time, HTML_LABELS.event, HTML_LABELS.usage], rows.map((row) => ({ name: `${title}, ${formatInstant(row.at, zone)}`, cells: row.cells }))),
476
+ "</details>",
477
+ ]
478
+ .filter((part) => part !== "")
479
+ .join("\n");
480
+ }
481
+ /**
482
+ * Finds a gauge column's series by the window's identity (D-024), not by position, so a
483
+ * reordering in the data can't pair a column with another window's readings.
484
+ * @param data - Report data.
485
+ * @param window - Window key.
486
+ * @param resetAtUtc - Reset instant.
487
+ * @returns The series; undefined when the data has none for that window.
488
+ */
489
+ function seriesOf(data, window, resetAtUtc) {
490
+ return data.series.find((s) => s.window === window && s.resetAtUtc === resetAtUtc);
491
+ }
492
+ /**
493
+ * Renders the lines under the gauge that count what no column shows: readings captured after
494
+ * their window's reset (D-024) and limit hits in no column. Each appears only when its count is
495
+ * above zero, as the text report prints the first, and each resolves to its events.
496
+ * @param data - Report data.
497
+ * @returns Paragraphs, possibly none.
498
+ */
499
+ function gaugeNotes(data) {
500
+ const lines = [];
501
+ const after = data.afterReset;
502
+ if (after.count > 0) {
503
+ lines.push(countPara(`Readings captured after their window's reset, not counted: ${formatNumber(after.count, "count")}`, after.events, "Readings captured after their window's reset"));
504
+ }
505
+ // The stale points, as the text report counts readings_stale: counted readings below an earlier
506
+ // one in their window (D-089).
507
+ const stale = data.series.flatMap((s) => s.points.filter((p) => p.stale).map((p) => p.event));
508
+ if (stale.length > 0) {
509
+ lines.push(countPara(`${LABELS.staleReadings}: ${formatNumber(stale.length, "count")}`, stale, LABELS.staleReadings));
510
+ }
511
+ const totals = data.interruptions;
512
+ if (totals.hitsInNoColumn > 0) {
513
+ lines.push(countPara(noColumnText(totals.hitsInNoColumn), totals.hitsInNoColumnEvents, "Limit hits in no column"));
514
+ }
515
+ return lines;
516
+ }
517
+ /**
518
+ * Renders the weekly windows' first readings at or above 75% and 90% (D-087, step G3.2), in the
519
+ * text report's words, with each value and time left resolving to the reading behind it.
520
+ * @param data - Report data.
521
+ * @returns The section.
522
+ */
523
+ function renderCrossingsSection(data) {
524
+ const zone = data.timeZone;
525
+ const span = `Covers: ${sourceSpan("status line", coverageOf(data, "status_line"), zone)}`;
526
+ if (data.crossings.length === 0) {
527
+ return chartSection({
528
+ id: "nm-crossings",
529
+ heading: HTML_LABELS.crossingsHeading,
530
+ span,
531
+ body: para(LABELS.noCrossings),
532
+ });
533
+ }
534
+ const c = LABELS.columns;
535
+ const rows = data.crossings.map((x) => ({
536
+ name: `${windowTitle("seven_day", x.resetAtUtc, zone)}, ${String(x.threshold)}%`,
537
+ cells: [
538
+ textCell(formatInstant(x.resetAtUtc, zone)),
539
+ textCell(`${String(x.threshold)}%`),
540
+ textCell(formatInstant(x.firstAtUtc, zone)),
541
+ numberCell(formatNumber(x.usedPercentage, "percent"), x.events),
542
+ numberCell(formatNumber(x.secondsBeforeReset, "duration"), x.events),
543
+ ],
544
+ }));
545
+ return chartSection({
546
+ id: "nm-crossings",
547
+ heading: HTML_LABELS.crossingsHeading,
548
+ span,
549
+ info: [LABELS.crossingsWhat],
550
+ body: [
551
+ para(LABELS.crossingsCaveat),
552
+ tableView(HTML_LABELS.crossingsHeading, [
553
+ c.resets,
554
+ HTML_LABELS.level,
555
+ HTML_LABELS.firstReading,
556
+ HTML_LABELS.usageAtReading,
557
+ HTML_LABELS.timeLeft,
558
+ ], rows, true),
559
+ ].join("\n"),
560
+ });
561
+ }
562
+ /**
563
+ * Renders the gauge section: one column per window, each window's series, and the gauge's table.
564
+ * @param data - Report data.
565
+ * @param options - Chart options.
566
+ * @returns The section.
567
+ */
568
+ function renderGaugeSection(data, options) {
569
+ const zone = data.timeZone;
570
+ const status = coverageOf(data, "status_line");
571
+ const span = `Covers: ${sourceSpan("status line", status, zone)}. Readings start when the collector is installed.`;
572
+ const notes = gaugeNotes(data);
573
+ if (data.gauge.length === 0) {
574
+ return chartSection({
575
+ id: "nm-gauge",
576
+ heading: LABELS.windows,
577
+ span,
578
+ body: [para(LABELS.noReadings), ...notes].join("\n"),
579
+ });
580
+ }
581
+ const c = LABELS.columns;
582
+ const rows = data.gauge.map((w) => ({
583
+ name: windowTitle(w.window, w.resetAtUtc, zone),
584
+ cells: [
585
+ textCell(windowName(w.window)),
586
+ textCell(formatInstant(w.resetAtUtc, zone)),
587
+ numberCell(formatNumber(w.peak, "percent"), w.peakEvents),
588
+ textCell(formatInstant(w.peakAtUtc, zone)),
589
+ numberCell(formatNumber(w.last, "percent"), w.lastEvents),
590
+ textCell(formatInstant(w.lastAtUtc, zone)),
591
+ // The counted readings are the window's series points, one event each.
592
+ numberCell(formatNumber(w.readings, "count"), (seriesOf(data, w.window, w.resetAtUtc)?.points ?? []).map((p) => p.event)),
593
+ numberCell(formatNumber(w.limitHits, "count"), w.hitEvents),
594
+ textCell(w.open ? "open" : "reset"),
595
+ ],
596
+ }));
597
+ return chartSection({
598
+ id: "nm-gauge",
599
+ heading: LABELS.windows,
600
+ span,
601
+ // D-091: how to read the chart, behind the heading's "i". "A lower bound" stays in the legend.
602
+ info: [
603
+ `${LABELS.windowsCaveat} ${HTML_LABELS.over100}`,
604
+ LABELS.openNote,
605
+ HTML_LABELS.seriesHint,
606
+ ],
607
+ body: [
608
+ legend([
609
+ ["k-fill", HTML_LABELS.legendPeak],
610
+ ["k-tick", HTML_LABELS.legendLast],
611
+ ["k-hit", HTML_LABELS.legendHit],
612
+ ]),
613
+ plot(renderGauge(data.gauge, options)),
614
+ // The counts behind the chart stay visible: each lists its readings or hits (principle 4).
615
+ ...notes,
616
+ `<div class="nm-series-list">\n${data.series.map((s) => renderSeries(s, options)).join("\n")}\n</div>`,
617
+ tableView(LABELS.windows, [
618
+ c.window,
619
+ c.resets,
620
+ c.peak,
621
+ HTML_LABELS.peakAt,
622
+ c.lastUsage,
623
+ c.readingAt,
624
+ c.readings,
625
+ HTML_LABELS.limitHits,
626
+ c.status,
627
+ ], rows),
628
+ ].join("\n"),
629
+ });
630
+ }
631
+ /**
632
+ * Builds the cell for what followed a lockout's reset: a duration that resolves to the request
633
+ * that ended it, or, when no request followed, the reason as plain text with no events.
634
+ * @param bar - The lockout.
635
+ * @returns The cell.
636
+ */
637
+ function nextRequestCell(bar) {
638
+ if (bar.resetToNextRequestSeconds !== null) {
639
+ return numberCell(formatNumber(bar.resetToNextRequestSeconds, "duration"), bar.nextRequestEvents);
640
+ }
641
+ return textCell(bar.resetAfterCoverage ? HTML_LABELS.resetAfterLogs : HTML_LABELS.noRequestAfter);
642
+ }
643
+ /**
644
+ * Renders the counts the text report prints beside the lockouts (renderObserved), in its exact
645
+ * phrases, each resolving to its events. Each split count by window and by source has its own
646
+ * event list, so selecting one lists only the hits behind that number.
647
+ * @param data - Report data.
648
+ * @returns The total line and the count lines.
649
+ */
650
+ function interruptionCounts(data) {
651
+ const t = data.interruptions;
652
+ /**
653
+ * Formats a count as the text report does.
654
+ * @param value - The count.
655
+ * @returns E.g. `1,204`.
656
+ */
657
+ const n = (value) => formatNumber(value, "count");
658
+ // The intervals' hits are exactly the events behind the total; hits with an unknown reset aren't.
659
+ const inIntervals = mergeEvents(...data.lockouts.map((bar) => bar.events));
660
+ const total = `<p class="total"><span class="num"${eventsAttr(inIntervals)} data-label="${escapeHtml(HTML_LABELS.lockoutElapsed)}">${escapeHtml(formatNumber(t.lockoutSeconds, "duration"))}</span> ${escapeHtml(`across ${plural(t.lockoutIntervals, "lockout", "lockouts")}`)}</p>`;
661
+ return [
662
+ '<div class="nm-counts">',
663
+ total,
664
+ // Only when there are some: "…: 0" says nothing (2026-10-22 click-through).
665
+ t.hitsWithUnknownReset === 0
666
+ ? ""
667
+ : countPara(`Limit hits with an unknown reset time, in no lockout: ${n(t.hitsWithUnknownReset)}`, t.unknownResetEvents, "Limit hits with an unknown reset time", "sub"),
668
+ countPara(`${LABELS.limitHits}: ${n(t.hits)} in ${plural(t.lockoutIntervals, "lockout", "lockouts")}`, t.events, LABELS.limitHits, "count"),
669
+ splitPara([
670
+ {
671
+ phrase: "5-hour window",
672
+ count: t.fiveHourHits,
673
+ events: t.fiveHourEvents,
674
+ name: `${LABELS.limitHits}, 5-hour window`,
675
+ },
676
+ {
677
+ phrase: "weekly window",
678
+ count: t.sevenDayHits,
679
+ events: t.sevenDayEvents,
680
+ name: `${LABELS.limitHits}, weekly window`,
681
+ },
682
+ {
683
+ phrase: "window unknown",
684
+ count: t.unknownWindowHits,
685
+ events: t.unknownWindowEvents,
686
+ name: `${LABELS.limitHits}, window unknown`,
687
+ },
688
+ ]),
689
+ splitPara([
690
+ {
691
+ phrase: "From the session logs",
692
+ count: t.loggedHits,
693
+ events: t.loggedEvents,
694
+ name: `${LABELS.limitHits}, from the session logs`,
695
+ },
696
+ {
697
+ phrase: "seen only in the status line",
698
+ count: t.statusLineOnlyHits,
699
+ events: t.statusLineOnlyEvents,
700
+ name: `${LABELS.limitHits}, seen only in the status line`,
701
+ },
702
+ ]),
703
+ "</div>",
704
+ ]
705
+ .filter((line) => line !== "")
706
+ .join("\n");
707
+ }
708
+ /**
709
+ * Counts the sessions Claude Code resumed by itself across every lockout (D-077).
710
+ * @param data - Report data.
711
+ * @returns The sum of each lockout's count from obs_lockout_intervals.
712
+ */
713
+ function resumedCount(data) {
714
+ return data.lockouts.reduce((sum, bar) => sum + bar.autoContinuations, 0);
715
+ }
716
+ /**
717
+ * Names a plan period as the text report's per-plan table does (D-075).
718
+ * @param period - The period.
719
+ * @returns Its name and its months, e.g. `["Max 5x", "2026-08 onward"]`.
720
+ */
721
+ function periodText(period) {
722
+ const row = {
723
+ period_kind: period.kind,
724
+ plan_name: period.planName,
725
+ from_month: period.fromMonth,
726
+ to_month: period.toMonth,
727
+ until_month: period.untilMonth,
728
+ };
729
+ return [planPeriodName(row), planPeriodMonths(row)];
730
+ }
731
+ /**
732
+ * Renders the interruptions per plan period (D-075): the note the text report prints, and the
733
+ * table open, since it's the split this tab exists to show. Nothing until a plan has been entered.
734
+ * @param data - Report data.
735
+ * @returns Lines of HTML, or none.
736
+ */
737
+ function byPlanBlock(data) {
738
+ const periods = data.interruptions.byPlan;
739
+ if (periods.length === 0) {
740
+ return [];
741
+ }
742
+ const zone = data.timeZone;
743
+ const c = LABELS.columns;
744
+ const rows = periods.map((p) => {
745
+ const [name, months] = periodText(p.period);
746
+ return {
747
+ name: `${name}, ${months}`,
748
+ cells: [
749
+ textCell(name),
750
+ textCell(months),
751
+ numberCell(formatNumber(p.hits, "count"), p.hitEvents),
752
+ numberCell(formatNumber(p.lockouts, "count"), p.lockoutEvents),
753
+ numberCell(formatNumber(p.lockoutSeconds, "duration"), p.lockoutEvents),
754
+ // Without the zone, as the month table's span cells: the section's "i" names it.
755
+ textCell(p.period.kind === "unknown"
756
+ ? ""
757
+ : `${formatInstant(p.span.from, zone)} to ${formatInstant(p.span.to, zone)}`),
758
+ ],
759
+ };
760
+ });
761
+ // The by-plan rule is in the section's "i" (D-091); the table stays open, as the split it shows.
762
+ return [
763
+ tableView(HTML_LABELS.byPlanCaption, [c.plan, c.months, c.hits, c.lockouts, c.elapsed, c.covers], rows, true),
764
+ ];
765
+ }
766
+ /**
767
+ * Renders the elapsed lockout time section, with the interruption counts and caveats next to the
768
+ * number. The total is the view's own (`obs_lockout_time` through the data), not a sum of the
769
+ * drawn bars, so the page and the text report print the same figure.
770
+ * @param data - Report data.
771
+ * @param options - Chart options.
772
+ * @returns The section.
773
+ */
774
+ function renderLockoutSection(data, options) {
775
+ const zone = data.timeZone;
776
+ // The counts' own span, as the text report states it beside them.
777
+ const span = `Covers: ${sourceSpan("session logs", data.interruptions.span, zone)}; ${sourceSpan("status line", coverageOf(data, "status_line"), zone)}`;
778
+ const bars = data.lockouts;
779
+ const summary = [interruptionCounts(data), ...byPlanBlock(data)].join("\n");
780
+ // D-091: the heading's definition, the hits note and the by-plan rule, behind the heading's "i".
781
+ const explained = [
782
+ HTML_LABELS.lockoutDefinition,
783
+ LABELS.limitHitsNote,
784
+ ...(data.interruptions.byPlan.length === 0
785
+ ? []
786
+ : [`${LABELS.byPlan}: ${LABELS.byPlanNote} (months in ${zone}).`]),
787
+ ];
788
+ // The text report's auto-resume caveat qualifies the not-resumed counts, which the page doesn't
789
+ // print, so it stays with them in the text report rather than seeming to qualify the hits here.
790
+ const caveats = para(HTML_LABELS.lockoutCaveat);
791
+ if (bars.length === 0) {
792
+ return chartSection({
793
+ id: "nm-lockouts",
794
+ heading: HTML_LABELS.lockoutElapsed,
795
+ span,
796
+ info: explained,
797
+ body: [summary, para(HTML_LABELS.noLockouts), caveats].join("\n"),
798
+ });
799
+ }
800
+ const resetHeader = LABELS.resetToNextRequest.charAt(0).toUpperCase() + LABELS.resetToNextRequest.slice(1);
801
+ const rows = bars.map((bar) => ({
802
+ name: `Lockout from ${formatInstant(bar.lockedFromUtc, zone)}`,
803
+ cells: [
804
+ textCell(formatInstant(bar.lockedFromUtc, zone)),
805
+ textCell(formatInstant(bar.lockedUntilUtc, zone)),
806
+ numberCell(formatNumber(bar.seconds, "duration"), bar.events),
807
+ numberCell(formatNumber(bar.hits, "count"), bar.events),
808
+ nextRequestCell(bar),
809
+ // D-077: sessions Claude Code resumed by itself after the reset, as logged.
810
+ numberCell(formatNumber(bar.autoContinuations, "count"), bar.autoContinuationEvents),
811
+ ],
812
+ }));
813
+ const resumed = bars.some((bar) => bar.autoContinuations > 0);
814
+ return chartSection({
815
+ id: "nm-lockouts",
816
+ heading: HTML_LABELS.lockoutElapsed,
817
+ span,
818
+ info: explained,
819
+ body: [
820
+ summary,
821
+ caveats,
822
+ // The ring is the one shape on this chart that isn't a lockout, so it gets a key.
823
+ ...(resumed ? [legend([["k-resumed", HTML_LABELS.legendResumed]])] : []),
824
+ plot(renderLockouts(bars, options)),
825
+ tableView(LABELS.lockout, [
826
+ HTML_LABELS.lockoutFrom,
827
+ HTML_LABELS.lockoutUntil,
828
+ HTML_LABELS.lockoutElapsed,
829
+ HTML_LABELS.hits,
830
+ resetHeader,
831
+ HTML_LABELS.resumedColumn,
832
+ ], rows),
833
+ ].join("\n"),
834
+ });
835
+ }
836
+ /**
837
+ * Renders output tokens by model.
838
+ * @param data - Report data.
839
+ * @param options - Chart options.
840
+ * @returns The section.
841
+ */
842
+ function renderModelSection(data, options) {
843
+ const span = `Covers: ${sourceSpan("session logs", coverageOf(data, "session_logs"), data.timeZone)}`;
844
+ if (data.models.length === 0) {
845
+ return chartSection({
846
+ id: "nm-models",
847
+ heading: HTML_LABELS.models,
848
+ span,
849
+ body: para(HTML_LABELS.noRequests),
850
+ });
851
+ }
852
+ const c = LABELS.columns;
853
+ const rows = data.models.map((m) => ({
854
+ name: m.model,
855
+ cells: [
856
+ textCell(m.model),
857
+ numberCell(formatNumber(m.requests, "count"), m.events),
858
+ numberCell(formatNumber(m.outputTokens, "tokens"), m.events),
859
+ ],
860
+ }));
861
+ return chartSection({
862
+ id: "nm-models",
863
+ heading: HTML_LABELS.models,
864
+ span,
865
+ body: [
866
+ plot(renderModelBars(data.models, options)),
867
+ tableView(HTML_LABELS.models, [c.model, c.requests, c.output], rows),
868
+ ].join("\n"),
869
+ });
870
+ }
871
+ /**
872
+ * Shows a repository as the text report does: `~/...` under the home directory, and a name for a
873
+ * request with no working directory.
874
+ * @param bar - The repository.
875
+ * @param home - Home directory, or "".
876
+ * @returns The display text, unescaped.
877
+ */
878
+ function repoName(bar, home) {
879
+ return bar.repository === null
880
+ ? "unknown (no working directory)"
881
+ : displayPath(bar.repository, home);
882
+ }
883
+ /**
884
+ * Renders output tokens by repository (step G2.1, D-084), new to the page: until now only the
885
+ * text report showed it.
886
+ * @param data - Report data.
887
+ * @param options - Chart options.
888
+ * @returns The section.
889
+ */
890
+ function renderRepoSection(data, options) {
891
+ const span = `Covers: ${sourceSpan("session logs", coverageOf(data, "session_logs"), data.timeZone)}`;
892
+ if (data.repos.length === 0) {
893
+ return chartSection({
894
+ id: "nm-repos",
895
+ heading: HTML_LABELS.repos,
896
+ // D-091: how paths count, behind the heading's "i".
897
+ info: [HTML_LABELS.repoCaveat],
898
+ span,
899
+ body: para(HTML_LABELS.noRequests),
900
+ });
901
+ }
902
+ const c = LABELS.columns;
903
+ const rows = data.repos.map((r) => ({
904
+ name: repoName(r, data.home),
905
+ cells: [
906
+ textCell(repoName(r, data.home)),
907
+ textCell(repoKindName(r.kind)),
908
+ numberCell(formatNumber(r.requests, "count"), r.events),
909
+ numberCell(formatNumber(r.outputTokens, "tokens"), r.events),
910
+ ],
911
+ }));
912
+ const keys = [["k-fill", HTML_LABELS.legendRepo]];
913
+ if (data.repos.some((r) => r.kind === "missing")) {
914
+ keys.push(["k-faded", HTML_LABELS.legendGone]);
915
+ }
916
+ if (data.repos.length > REPO_BARS) {
917
+ keys.push(["k-other", HTML_LABELS.legendOther]);
918
+ }
919
+ return chartSection({
920
+ id: "nm-repos",
921
+ heading: HTML_LABELS.repos,
922
+ // D-091: how paths count, behind the heading's "i".
923
+ info: [HTML_LABELS.repoCaveat],
924
+ span,
925
+ body: [
926
+ legend(keys),
927
+ // D-092: each alias the user entered, beside the bars it moves, in the text report's words.
928
+ ...data.repositoryAliases.map((a) => para(`${LABELS.alias}: ${displayPath(a.from, data.home)} counts under ${displayPath(a.to, data.home)}`, "sub")),
929
+ plot(renderRepoBars({ bars: data.repos, home: data.home }, options)),
930
+ tableView(HTML_LABELS.repos, [c.repository, c.kind, c.requests, c.output], rows),
931
+ ].join("\n"),
932
+ });
933
+ }
934
+ /**
935
+ * Renders the burn rate of every open window (step G2.1, D-026, D-084), new to the page, below the
936
+ * seam: the premise next to the numbers, and each window's sentence exactly as the text report
937
+ * prints it.
938
+ * @param data - Report data.
939
+ * @param options - Chart options.
940
+ * @returns The section.
941
+ */
942
+ function renderBurnSection(data, options) {
943
+ const zone = data.timeZone;
944
+ // proj_burn_rate carries the status line's span on every row, as the text report prints it.
945
+ const first = data.burn[0];
946
+ const span = `Covers: ${sourceSpan("status line", first?.span ?? coverageOf(data, "status_line"), zone)}`;
947
+ const heading = { id: "nm-burn", heading: LABELS.burnRate, tag: HTML_LABELS.projectedTag };
948
+ const premise = para(LABELS.burnRatePremise);
949
+ if (data.burn.length === 0) {
950
+ return chartSection({
951
+ ...heading,
952
+ span,
953
+ body: [premise, para(HTML_LABELS.noOpenWindow)].join("\n"),
954
+ });
955
+ }
956
+ const c = LABELS.columns;
957
+ const windows = data.burn.map((burn) => {
958
+ const name = windowTitle(burn.window, burn.resetAtUtc, zone);
959
+ return [
960
+ countPara(burnSentence(burn, zone), burn.events, `${LABELS.burnRate}, ${name}`),
961
+ plot(renderBurnRate({ burn, series: seriesOf(data, burn.window, burn.resetAtUtc) }, options)),
962
+ ].join("\n");
963
+ });
964
+ const rows = data.burn.map((burn) => ({
965
+ name: windowTitle(burn.window, burn.resetAtUtc, zone),
966
+ cells: [
967
+ textCell(windowName(burn.window)),
968
+ textCell(formatInstant(burn.resetAtUtc, zone)),
969
+ numberCell(formatNumber(burn.last, "percent"), burn.events),
970
+ textCell(formatInstant(burn.lastAtUtc, zone)),
971
+ numberCell(formatNumber(burn.ratePerHour, "rate"), burn.events),
972
+ textCell(burn.limitReached
973
+ ? "limit reached at the last reading"
974
+ : formatInstant(burn.projectedLimitAtUtc, zone)),
975
+ ],
976
+ }));
977
+ return chartSection({
978
+ ...heading,
979
+ span,
980
+ body: [
981
+ legend([
982
+ ["k-fill", HTML_LABELS.burnObserved],
983
+ ["k-proj", HTML_LABELS.burnProjected],
984
+ ]),
985
+ premise,
986
+ ...windows,
987
+ tableView(LABELS.burnRate, [
988
+ c.window,
989
+ c.resets,
990
+ HTML_LABELS.lastFrom,
991
+ c.readingAt,
992
+ LABELS.burnRate,
993
+ HTML_LABELS.limitProjectedFor,
994
+ ], rows),
995
+ ].join("\n"),
996
+ });
997
+ }
998
+ /**
999
+ * Renders one overview card. It is a chart section of its own, so a number on it lists its events
1000
+ * in the card itself (principle 4). The thumbnail is `inert`: a picture of the tab's chart, with
1001
+ * its events still on every bar, but no extra keyboard stops and no clicks.
1002
+ * @param card - The card's parts.
1003
+ * @returns The card HTML.
1004
+ */
1005
+ function overviewCard(card) {
1006
+ const id = `nm-card-${card.tab}`;
1007
+ return [
1008
+ `<section class="panel nm-card nm-chart" id="${id}" aria-labelledby="${id}-h">`,
1009
+ `<div class="panel-title"><h3 id="${id}-h">${escapeHtml(card.heading)}</h3>`,
1010
+ `<button type="button" class="nm-goto" data-goto="${card.tab}">${escapeHtml(HTML_LABELS.openTab)}</button></div>`,
1011
+ `<div class="nm-card-body">${card.lines.join("\n")}</div>`,
1012
+ card.thumbnail === ""
1013
+ ? ""
1014
+ : `<div class="nm-thumb" inert aria-hidden="true">${plot(card.thumbnail)}</div>`,
1015
+ para(card.span, "span"),
1016
+ '<div class="nm-events" aria-live="polite" hidden></div>',
1017
+ "</section>",
1018
+ ]
1019
+ .filter((part) => part !== "")
1020
+ .join("\n");
1021
+ }
1022
+ /**
1023
+ * Renders the overview tab's cards (step G2.1, D-084): one per observed tab, never a projection
1024
+ * (principle 2), each with the numbers a reader looks for first.
1025
+ * @param data - Report data.
1026
+ * @param options - Chart options.
1027
+ * @returns The cards, in a grid.
1028
+ */
1029
+ function renderOverviewCards(data, options) {
1030
+ const zone = data.timeZone;
1031
+ // A thumbnail is a picture of its tab's chart: drawn compact, and its text hidden by CSS, since at
1032
+ // half size it can't be read and the card's own lines give the numbers.
1033
+ const thumb = { ...options, compact: true };
1034
+ const status = `Covers: ${sourceSpan("status line", coverageOf(data, "status_line"), zone)}`;
1035
+ const logs = `Covers: ${sourceSpan("session logs", coverageOf(data, "session_logs"), zone)}`;
1036
+ const weekly = data.gauge.filter((w) => w.window === "seven_day");
1037
+ // The open weekly window is the current one; with several (resets jittered), the latest reset.
1038
+ const current = weekly.filter((w) => w.open).at(-1);
1039
+ const highest = weekly.reduce((best, w) => (w.peak !== null && (best?.peak ?? -1) < w.peak ? w : best), undefined);
1040
+ const windowLines = data.gauge.length === 0
1041
+ ? [para(LABELS.noReadings)]
1042
+ : [
1043
+ ...(current === undefined
1044
+ ? []
1045
+ : [
1046
+ countPara(`${HTML_LABELS.weeklyNow}: ${formatNumber(current.last, "percent")} at ${formatInstant(current.lastAtUtc, zone)}; resets ${formatInstant(current.resetAtUtc, zone)}`, current.lastEvents, HTML_LABELS.weeklyNow, "count"),
1047
+ ]),
1048
+ ...(highest === undefined
1049
+ ? []
1050
+ : [
1051
+ countPara(`${HTML_LABELS.weeklyPeak}: ${formatNumber(highest.peak, "percent")}, in the ${windowTitle(highest.window, highest.resetAtUtc, zone)}`, highest.peakEvents, HTML_LABELS.weeklyPeak, "count"),
1052
+ ]),
1053
+ ];
1054
+ const t = data.interruptions;
1055
+ const inIntervals = mergeEvents(...data.lockouts.map((bar) => bar.events));
1056
+ const model = data.models[0];
1057
+ const repo = data.repos[0];
1058
+ const cards = [
1059
+ {
1060
+ tab: "windows",
1061
+ heading: HTML_LABELS.tabWindows,
1062
+ lines: windowLines,
1063
+ thumbnail: data.gauge.length === 0 ? "" : renderGauge(data.gauge, thumb),
1064
+ span: status,
1065
+ },
1066
+ {
1067
+ tab: "interruptions",
1068
+ heading: HTML_LABELS.tabInterruptions,
1069
+ lines: [
1070
+ // Lockouts lead: one is one limit reached, however many requests it refused (D-090).
1071
+ countPara(`${HTML_LABELS.lockoutElapsed}: ${formatNumber(t.lockoutSeconds, "duration")} across ${plural(t.lockoutIntervals, "lockout", "lockouts")}`, inIntervals, HTML_LABELS.lockoutElapsed, "count"),
1072
+ // One line per plan period once a plan is entered (D-075), each with its own lockouts.
1073
+ ...t.byPlan.map((p) => {
1074
+ const [name, months] = periodText(p.period);
1075
+ return countPara(`${name}, ${months}: ${plural(p.lockouts, "lockout", "lockouts")}`, p.lockoutEvents, `${LABELS.columns.lockouts}, ${name}`, "sub");
1076
+ }),
1077
+ countPara(`${LABELS.limitHits}: ${formatNumber(t.hits, "count")} in ${plural(t.lockoutIntervals, "lockout", "lockouts")}`, t.events, LABELS.limitHits, "sub"),
1078
+ // D-077: the sessions Claude Code resumed by itself, once any lockout has one.
1079
+ ...(resumedCount(data) === 0
1080
+ ? []
1081
+ : [
1082
+ countPara(`${HTML_LABELS.resumedColumn} after a lockout: ${plural(resumedCount(data), "session", "sessions")}`, mergeEvents(...data.lockouts.map((bar) => bar.autoContinuationEvents)), HTML_LABELS.resumedColumn, "sub"),
1083
+ ]),
1084
+ ],
1085
+ thumbnail: data.lockouts.length === 0 ? "" : renderLockouts(data.lockouts, thumb),
1086
+ span: `Covers: ${sourceSpan("session logs", t.span, zone)}`,
1087
+ },
1088
+ {
1089
+ tab: "models",
1090
+ heading: HTML_LABELS.tabModels,
1091
+ lines: model === undefined
1092
+ ? [para(HTML_LABELS.noRequests)]
1093
+ : [
1094
+ countPara(`${HTML_LABELS.mostModel}: ${model.model}, ${formatNumber(model.outputTokens, "tokens")} output tokens`, model.events, HTML_LABELS.mostModel, "count"),
1095
+ ],
1096
+ thumbnail: data.models.length === 0 ? "" : renderModelBars(data.models, thumb),
1097
+ span: logs,
1098
+ },
1099
+ {
1100
+ tab: "repos",
1101
+ heading: HTML_LABELS.tabRepos,
1102
+ lines: repo === undefined
1103
+ ? [para(HTML_LABELS.noRequests)]
1104
+ : [
1105
+ countPara(`${HTML_LABELS.mostRepo}: ${repoName(repo, data.home)}, ${formatNumber(repo.outputTokens, "tokens")} output tokens`, repo.events, HTML_LABELS.mostRepo, "count"),
1106
+ ],
1107
+ // The first five are enough for a picture; the tab draws them all.
1108
+ thumbnail: data.repos.length === 0
1109
+ ? ""
1110
+ : renderRepoBars({ bars: data.repos.slice(0, 5), home: data.home }, thumb),
1111
+ span: logs,
1112
+ },
1113
+ ];
1114
+ return `<div class="nm-cards">\n${cards.map(overviewCard).join("\n")}\n</div>`;
1115
+ }
1116
+ /** The page's tabs, in order: observed first, then projected after a visible gap (D-084). */
1117
+ const TABS = [
1118
+ { key: "overview", label: HTML_LABELS.tabOverview, group: "observed" },
1119
+ { key: "windows", label: HTML_LABELS.tabWindows, group: "observed" },
1120
+ { key: "interruptions", label: HTML_LABELS.tabInterruptions, group: "observed" },
1121
+ { key: "models", label: HTML_LABELS.tabModels, group: "observed" },
1122
+ { key: "repos", label: HTML_LABELS.tabRepos, group: "observed" },
1123
+ { key: "burn", label: HTML_LABELS.tabBurn, group: "projected" },
1124
+ { key: "price", label: HTML_LABELS.tabPrice, group: "projected" },
1125
+ ];
1126
+ /**
1127
+ * Renders the tab bar (D-084): two tab lists, observed and projected, apart and labeled. Shown only
1128
+ * when the script runs; without it the page reads top to bottom, seam included.
1129
+ * @returns The navigation HTML. The overview tab starts selected.
1130
+ */
1131
+ function renderTabBar() {
1132
+ /**
1133
+ * Renders one group's tab list.
1134
+ * @param group - Which group.
1135
+ * @param label - The group's visible label.
1136
+ * @returns The group HTML.
1137
+ */
1138
+ const group = (group, label) => {
1139
+ const tabs = TABS.filter((tab) => tab.group === group)
1140
+ .map((tab) => {
1141
+ const selected = tab.key === "overview";
1142
+ return `<button type="button" role="tab" class="nm-tab" id="nm-tabbtn-${tab.key}" aria-controls="nm-tab-${tab.key}" aria-selected="${String(selected)}" tabindex="${selected ? 0 : -1}" data-tab="${tab.key}">${escapeHtml(tab.label)}</button>`;
1143
+ })
1144
+ .join("");
1145
+ return `<div class="nm-tabgroup nm-tabgroup-${group}"><span class="nm-group-label">${escapeHtml(label)}</span><div role="tablist" class="nm-tablist" aria-label="${escapeHtml(label)}">${tabs}</div></div>`;
1146
+ };
1147
+ return [
1148
+ `<nav class="nm-tabbar" aria-label="${escapeHtml(HTML_LABELS.tabsLabel)}">`,
1149
+ group("observed", HTML_LABELS.observedGroup),
1150
+ group("projected", HTML_LABELS.projectedGroup),
1151
+ "</nav>",
1152
+ ].join("\n");
1153
+ }
1154
+ /**
1155
+ * Wraps a tab's content in its panel.
1156
+ * @param key - The tab.
1157
+ * @param content - The panel's HTML.
1158
+ * @returns The panel; the overview's starts active.
1159
+ */
1160
+ function tabPanel(key, content) {
1161
+ const active = key === "overview" ? " nm-active" : "";
1162
+ return `<div class="nm-tabpanel${active}" role="tabpanel" id="nm-tab-${key}" aria-labelledby="nm-tabbtn-${key}" data-tab="${key}">\n${content}\n</div>`;
1163
+ }
1164
+ /**
1165
+ * Formats the plan price cell, as the text report does.
1166
+ * @param month - A month.
1167
+ * @returns Name and price, or "none entered".
1168
+ */
1169
+ function planText(month) {
1170
+ return month.planName === null
1171
+ ? "none entered"
1172
+ : `${month.planName}, ${formatNumber(month.planUsdPerMonth, "usd")} per month`;
1173
+ }
1174
+ /**
1175
+ * Renders the notes the text report prints under the monthly table. The two counts resolve to
1176
+ * their requests like any other number on the page (principle 4); the plain notes don't count.
1177
+ * @param months - The months shown.
1178
+ * @param notes - Rate readings and the lower-bound count, with their requests.
1179
+ * @returns Paragraphs, escaped.
1180
+ */
1181
+ function monthNotes(months, notes) {
1182
+ const lines = [];
1183
+ // One line per reading date, each with its own count, as renderProjected prints them.
1184
+ for (const reading of notes.rateReadings) {
1185
+ lines.push(countPara(`Rates were read from Anthropic's pricing page on ${reading.verifiedOn}; ${formatNumber(reading.pricedBeforeVerifiedRequests, "count")} requests dated before that are priced at those rates.`, reading.events, `Requests priced at the rate read on ${reading.verifiedOn}`, "note"));
1186
+ }
1187
+ if (notes.lowerBoundRequests > 0) {
1188
+ lines.push(countPara(`Requests with cache writes of unrecorded duration, priced at the 5-minute rate (their cost is a lower bound): ${formatNumber(notes.lowerBoundRequests, "count")}`, notes.lowerBoundEvents, "Requests priced as a lower bound", "note"));
1189
+ }
1190
+ if (months.some((month) => month.unpricedRequests > 0)) {
1191
+ lines.push(para(HTML_LABELS.unpricedNote, "note"));
1192
+ }
1193
+ if (months.every((month) => month.planName === null)) {
1194
+ lines.push(para(HTML_LABELS.planHint, "note"));
1195
+ }
1196
+ return lines;
1197
+ }
1198
+ /**
1199
+ * Renders observed tokens at API list price by month, the projected section below the seam.
1200
+ * @param data - Report data.
1201
+ * @param options - Chart options.
1202
+ * @returns The section.
1203
+ */
1204
+ function renderMonthSection(data, options) {
1205
+ const zone = data.timeZone;
1206
+ const months = data.months;
1207
+ // Months come in order, so the first starts the span and the last ends it.
1208
+ const span = { from: months[0]?.span.from ?? null, to: months.at(-1)?.span.to ?? null };
1209
+ // D-091: the month zone and the reasoning behind "not a bill" are behind the heading's "i".
1210
+ const heading = {
1211
+ id: "nm-months",
1212
+ heading: LABELS.apiListPrice,
1213
+ tag: HTML_LABELS.projectedTag,
1214
+ info: [`Months in ${zone}.`, HTML_LABELS.repricing],
1215
+ };
1216
+ const spanText = `Covers: ${sourceSpan("session logs", span, zone)}`;
1217
+ // The Claude Code-only scope and "not a bill" change what the amounts mean, so they stay beside them.
1218
+ const scope = para(`${LABELS.apiListPriceScope} ${HTML_LABELS.notABill}`);
1219
+ if (months.length === 0) {
1220
+ return chartSection({
1221
+ ...heading,
1222
+ span: spanText,
1223
+ body: [scope, para(HTML_LABELS.noRequests)].join("\n"),
1224
+ });
1225
+ }
1226
+ const c = LABELS.columns;
1227
+ const rows = months.map((m) => ({
1228
+ name: m.month,
1229
+ cells: [
1230
+ textCell(m.month),
1231
+ numberCell(formatNumber(m.apiListPriceUsd, "usd"), m.events),
1232
+ textCell(planText(m)),
1233
+ numberCell(formatNumber(m.pricedRequests, "count"), m.events),
1234
+ numberCell(formatNumber(m.unpricedRequests, "count"), m.unpricedEvents),
1235
+ textCell(`${formatInstant(m.span.from, zone)} to ${formatInstant(m.span.to, zone)}`),
1236
+ ],
1237
+ }));
1238
+ return chartSection({
1239
+ ...heading,
1240
+ span: spanText,
1241
+ body: [
1242
+ legend([
1243
+ ["k-price", HTML_LABELS.legendPrice],
1244
+ ["k-plan", HTML_LABELS.legendPlan],
1245
+ ]),
1246
+ scope,
1247
+ plot(renderMonthBars(months, options)),
1248
+ tableView(LABELS.apiListPrice, [c.month, c.apiListPrice, c.planPrice, c.priced, c.unpriced, c.covers], rows),
1249
+ ...monthNotes(months, data.priceNotes),
1250
+ ].join("\n"),
1251
+ });
1252
+ }
1253
+ /**
1254
+ * The page's styles. Theme tokens are shared with the chart SVG (G1.2), which fills and strokes
1255
+ * with `var(--...)`, so one redefinition under `prefers-color-scheme: dark` switches both. System
1256
+ * font stacks only: a web font would be a network request.
1257
+ */
1258
+ const PAGE_STYLE = `
1259
+ :root {
1260
+ color-scheme: light dark;
1261
+ --page: #eef1f1; --surface: #fbfcfc; --sunken: #e3e8e8; --ink: #13212a; --ink-2: #44535c;
1262
+ --muted: #6f7d85; --grid: #d9e0e1; --axis: #b6c1c3; --water: #1f7a8c; --water-deep: #155e6c;
1263
+ --silt: #a8793a; --limit: #c2412d;
1264
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
1265
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
1266
+ }
1267
+ @media (prefers-color-scheme: dark) {
1268
+ :root {
1269
+ --page: #0c1417; --surface: #121c20; --sunken: #0f181b; --ink: #eef3f4; --ink-2: #b9c6ca;
1270
+ --muted: #8a999e; --grid: #223035; --axis: #33454b; --water: #3aa7b8; --water-deep: #7cc9d5;
1271
+ --silt: #d1a563; --limit: #e5604b;
1272
+ }
1273
+ }
1274
+ * { box-sizing: border-box; }
1275
+ body { margin: 0; background: var(--page); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.55; }
1276
+ main { max-width: 1040px; margin: 0 auto; padding: 32px 20px 64px; display: grid; gap: 28px; }
1277
+ @media (max-width: 520px) { main { padding: 20px 16px 48px; } }
1278
+ h1 { font-size: clamp(24px, 4vw, 32px); line-height: 1.15; font-weight: 600; margin: 0; }
1279
+ h2, h3 { margin: 0; }
1280
+ h3 { font-size: 16px; font-weight: 600; }
1281
+ p { margin: 0; }
1282
+ .nm-head { display: grid; gap: 6px; }
1283
+ .meta, .span, .note { font-family: var(--mono); font-size: 12px; color: var(--muted); }
1284
+ .privacy { font-size: 13px; color: var(--ink-2); }
1285
+ .hook-warning { display: grid; gap: 4px; border-left: 3px solid var(--ink); padding: 8px 12px; background: var(--surface); font-weight: 600; }
1286
+ .eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: 0.07em; text-transform: uppercase; color: var(--water); font-weight: 600; }
1287
+ .eyebrow.projected { color: var(--silt); }
1288
+ /* D-091: an explanation behind a small "i" beside a heading. Hover, keyboard focus, or a tap (the
1289
+ script's .nm-open) shows it; without the script, hover and focus still do. */
1290
+ .nm-info { position: relative; display: inline-block; margin-left: 8px; vertical-align: 2px; }
1291
+ .nm-info-btn { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--muted); background: transparent; color: var(--muted); font: italic 600 12px/1 Georgia, "Times New Roman", serif; padding: 0; cursor: help; }
1292
+ .nm-info-btn:hover, .nm-info-btn:focus-visible, .nm-info.nm-open .nm-info-btn { border-color: var(--ink); color: var(--ink); }
1293
+ .nm-info-text { display: none; position: absolute; left: 0; top: calc(100% + 6px); z-index: 10; width: max-content; max-width: min(420px, 80vw); padding: 10px 12px; background: var(--surface); border: 1px solid var(--axis); border-radius: 8px; box-shadow: 0 6px 20px rgb(0 0 0 / 0.2); font-size: 13px; font-weight: 400; line-height: 1.45; color: var(--ink-2); white-space: normal; letter-spacing: normal; text-transform: none; }
1294
+ .nm-info-text > span { display: block; }
1295
+ .nm-info-text > span + span { margin-top: 6px; }
1296
+ .nm-info:hover .nm-info-text, .nm-info:focus-within .nm-info-text, .nm-info.nm-open .nm-info-text { display: block; }
1297
+ /* On a narrow screen a popover beside the heading would run off the right edge: it opens as a sheet
1298
+ along the bottom of the screen instead. */
1299
+ @media (max-width: 600px) {
1300
+ .nm-info-text { position: fixed; left: 12px; right: 12px; top: auto; bottom: 12px; width: auto; max-width: none; z-index: 20; }
1301
+ }
1302
+ /* Read by screen readers, not drawn. */
1303
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
1304
+ /* An empty column's full-height track, faint: in light mode it drew a grey stripe per window. */
1305
+ .nm-track { fill-opacity: 0.5; }
1306
+ .nm-part { display: grid; gap: 28px; }
1307
+ .panel { display: grid; gap: 10px; background: var(--surface); border-radius: 10px; padding: 18px 20px; min-width: 0; }
1308
+ @media (max-width: 520px) { .panel { padding: 14px 12px; } }
1309
+ .panel-title { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; }
1310
+ .caveat { font-size: 13px; color: var(--ink-2); }
1311
+ .total { font-size: 15px; }
1312
+ .total .num { font-weight: 600; }
1313
+ .nm-counts { display: grid; gap: 2px; }
1314
+ .nm-counts .count { font-size: 14px; margin-top: 6px; }
1315
+ .nm-counts .sub { font-size: 13px; color: var(--ink-2); padding-left: 16px; }
1316
+ .legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12.5px; color: var(--ink-2); }
1317
+ .legend span { display: inline-flex; align-items: center; gap: 6px; }
1318
+ .legend i { display: inline-block; }
1319
+ .k-fill { width: 12px; height: 12px; border-radius: 2px; background: var(--water); }
1320
+ .k-tick { width: 14px; height: 2px; background: var(--ink); }
1321
+ .k-hit { width: 12px; height: 12px; border-radius: 2px; background: var(--limit); }
1322
+ .k-price { width: 12px; height: 12px; border-radius: 2px; background: var(--silt); opacity: 0.55; }
1323
+ .k-plan { width: 14px; height: 0; border-top: 2px dashed var(--silt); }
1324
+ .plot svg { display: block; width: 100%; height: auto; overflow: visible; }
1325
+ svg text { font-family: var(--mono); font-size: 10.5px; fill: var(--muted); }
1326
+ .nm-js [data-events], .nm-js .nm-col { cursor: pointer; }
1327
+ [data-events]:focus-visible, .nm-col:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
1328
+ .nm-sel { outline: 2px solid var(--ink); outline-offset: 1px; }
1329
+ .nm-hint { display: none; font-size: 13px; color: var(--muted); }
1330
+ .nm-js .nm-hint { display: block; }
1331
+ .nm-series-list { display: grid; gap: 6px; }
1332
+ .nm-js .nm-series:not([open]) { display: none; }
1333
+ .nm-series { background: var(--sunken); border-radius: 8px; padding: 10px 12px; }
1334
+ .nm-series > summary { font-weight: 600; cursor: pointer; }
1335
+ .nm-series[open] > * + * { margin-top: 8px; }
1336
+ .seam { display: flex; align-items: center; gap: 12px; font-family: var(--mono); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; color: var(--silt); }
1337
+ .seam::before, .seam::after { content: ""; flex: 1; border-top: 2px dashed var(--silt); opacity: 0.7; }
1338
+ #nm-observed-h { color: var(--water); }
1339
+ .nm-events { background: var(--sunken); border-radius: 8px; padding: 10px 12px; font-size: 13px; display: grid; gap: 6px; }
1340
+ .nm-events[hidden] { display: none; }
1341
+ .nm-events ol { margin: 0; padding-left: 22px; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
1342
+ details.nm-table > summary { cursor: pointer; font-size: 13px; color: var(--ink-2); }
1343
+ details.nm-table, .nm-series { min-width: 0; max-width: 100%; }
1344
+ .table-scroll { overflow-x: auto; max-width: 100%; margin-top: 8px; }
1345
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
1346
+ caption { text-align: left; font-size: 12px; color: var(--muted); padding-bottom: 4px; }
1347
+ th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--grid); vertical-align: top; }
1348
+ th { font-weight: 500; color: var(--muted); background: var(--sunken); white-space: nowrap; }
1349
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
1350
+ /* One line per cell: a narrow screen scrolls the table in its box rather than stacking its text. */
1351
+ td { white-space: nowrap; }
1352
+ code { font-family: var(--mono); }
1353
+ .k-faded { width: 12px; height: 12px; border-radius: 2px; background: var(--water); opacity: 0.5; }
1354
+ .k-other { width: 12px; height: 12px; border-radius: 2px; background: var(--axis); }
1355
+ .k-proj { width: 16px; height: 0; border-top: 2px dotted var(--ink-2); }
1356
+ .k-resumed { width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--ink); background: var(--surface); }
1357
+ .nm-tabpanel { display: grid; gap: 28px; min-width: 0; }
1358
+ .nm-tabbar { display: none; }
1359
+ .nm-tabs .nm-tabbar { position: sticky; top: 0; z-index: 5; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 36px; background: var(--page); padding-top: 10px; border-bottom: 1px solid var(--grid); }
1360
+ .nm-tabgroup { display: grid; gap: 4px; }
1361
+ .nm-group-label { font-family: var(--mono); font-size: 11px; letter-spacing: 0.07em; text-transform: uppercase; font-weight: 600; color: var(--water); }
1362
+ .nm-tabgroup-projected { padding-left: 20px; border-left: 2px dashed var(--silt); }
1363
+ .nm-tabgroup-projected .nm-group-label { color: var(--silt); }
1364
+ .nm-tablist { display: flex; flex-wrap: wrap; gap: 2px; }
1365
+ .nm-tab { font: inherit; font-size: 14px; color: var(--ink-2); background: none; border: 0; border-bottom: 3px solid transparent; border-radius: 6px 6px 0 0; padding: 8px 12px 9px; cursor: pointer; }
1366
+ .nm-tab:hover { background: var(--sunken); color: var(--ink); }
1367
+ .nm-tab[aria-selected="true"] { color: var(--ink); font-weight: 600; border-bottom-color: var(--water); }
1368
+ .nm-tabgroup-projected .nm-tab[aria-selected="true"] { border-bottom: 3px dashed var(--silt); }
1369
+ .nm-tab:focus-visible, .nm-goto:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
1370
+ .nm-tabs .nm-tabpanel:not(.nm-active) { display: none; }
1371
+ .nm-tabs .nm-part:not(.nm-has-active) { display: none; }
1372
+ .nm-cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
1373
+ @media (max-width: 720px) { .nm-cards { grid-template-columns: 1fr; } }
1374
+ .nm-card { align-content: start; }
1375
+ .nm-card-body { display: grid; gap: 2px; }
1376
+ .nm-goto { font: inherit; font-size: 13px; color: var(--water-deep); background: var(--sunken); border: 0; border-radius: 6px; padding: 4px 10px; cursor: pointer; }
1377
+ .nm-thumb { max-height: 160px; overflow: hidden; border-radius: 6px; background: var(--sunken); padding: 6px; }
1378
+ /* A thumbnail's text would print at about 5 px; the card's lines carry its numbers instead. */
1379
+ .nm-thumb text { display: none; }
1380
+ @media print {
1381
+ .nm-tabs .nm-tabbar, .nm-goto { display: none; }
1382
+ /* Every explanation in full on paper, where nothing can be hovered (D-091). */
1383
+ .nm-info-btn { display: none; }
1384
+ .nm-info { display: block; margin: 4px 0 0; }
1385
+ .nm-info-text { display: block; position: static; width: auto; max-width: none; padding: 0; border: 0; box-shadow: none; }
1386
+ /* Paper can't scroll a table's box, so cells wrap there again (screens keep one line per cell). */
1387
+ td { white-space: normal; }
1388
+ .table-scroll { overflow-x: visible; }
1389
+ /* The same selectors as the hiding rules, later in the sheet, so printing shows every tab. */
1390
+ .nm-tabs .nm-tabpanel:not(.nm-active), .nm-tabs .nm-part:not(.nm-has-active) { display: grid; }
1391
+ }
1392
+ `;
1393
+ /**
1394
+ * The page's one script. Plain ES2015 in a string, with no imports and no network: it reads the
1395
+ * embedded event table and, on a click or on Enter or Space, lists the events behind the selected
1396
+ * element in its section's panel, formatted as `nilometer explain` prints them (time, then
1397
+ * `file:line`), and opens a gauge column's series. Its pure helpers sit on a global `NM` object so
1398
+ * the unit tests can run them in Node; the DOM wiring runs only in a browser. Every value from the
1399
+ * data reaches the page through `textContent`, never `innerHTML`, so a crafted path can't inject
1400
+ * elements. It must never contain a closing-tag sequence, which a test checks.
1401
+ */
1402
+ const PAGE_SCRIPT = `
1403
+ "use strict";
1404
+ var NM = (function () {
1405
+ /* Formats an ISO instant as the text report does: YYYY-MM-DD HH:MM in the report's zone. */
1406
+ function formatAt(iso, zone) {
1407
+ if (iso === null || iso === undefined) { return "unknown"; }
1408
+ var parts = new Intl.DateTimeFormat("en-US", {
1409
+ timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit",
1410
+ hour: "2-digit", minute: "2-digit", hourCycle: "h23"
1411
+ }).formatToParts(new Date(iso));
1412
+ function part(type) {
1413
+ for (var i = 0; i < parts.length; i++) { if (parts[i].type === type) { return parts[i].value; } }
1414
+ return "";
1415
+ }
1416
+ return part("year") + "-" + part("month") + "-" + part("day") + " " + part("hour") + ":" + part("minute");
1417
+ }
1418
+ /* Reads a data-events value: raw line IDs separated by spaces. */
1419
+ function parseIds(text) {
1420
+ return String(text || "").split(/\\s+/).filter(function (s) { return s !== ""; })
1421
+ .map(Number).filter(function (n) { return isFinite(n); });
1422
+ }
1423
+ /* Merges ID lists, each ID once, ascending, the order the data layer uses. */
1424
+ function union(lists) {
1425
+ var seen = {}, out = [];
1426
+ lists.forEach(function (list) { list.forEach(function (id) { if (!seen[id]) { seen[id] = true; out.push(id); } }); });
1427
+ return out.sort(function (a, b) { return a - b; });
1428
+ }
1429
+ /* One line per event, as explain prints it: time, two spaces, file:line. Capped at limit. */
1430
+ function eventLines(ids, data, limit) {
1431
+ var lines = [];
1432
+ ids.slice(0, limit).forEach(function (id) {
1433
+ var e = data.events[id];
1434
+ if (!e) { lines.push("unknown raw line " + id + ": not in this file"); return; }
1435
+ var file = data.files[e.file];
1436
+ var where = file === undefined ? "unknown location" : file + ":" + e.line;
1437
+ lines.push(formatAt(e.atUtc, data.timeZone) + " " + where);
1438
+ });
1439
+ return { lines: lines, more: Math.max(0, ids.length - limit) };
1440
+ }
1441
+ /* Names a selected element at the head of its list: its data-label, else its SVG title, else
1442
+ its aria-label, which is how a gauge column names its window when opened by Enter. */
1443
+ function labelOf(el) {
1444
+ if (el.hasAttribute("data-label")) { return el.getAttribute("data-label"); }
1445
+ for (var i = 0; i < el.children.length; i++) {
1446
+ if (el.children[i].tagName.toLowerCase() === "title") { return el.children[i].textContent; }
1447
+ }
1448
+ if (el.hasAttribute("aria-label")) { return el.getAttribute("aria-label"); }
1449
+ return "";
1450
+ }
1451
+ /* Picks the tab a page address names ("#repos"), or the first tab when it names none (D-084). */
1452
+ function tabFromHash(hash, keys) {
1453
+ var key = String(hash || "").replace(/^#/, "");
1454
+ return keys.indexOf(key) >= 0 ? key : keys[0];
1455
+ }
1456
+ /* The tab an arrow, Home, or End key moves to, wrapping at both ends; -1 for any other key. */
1457
+ function nextTab(index, key, count) {
1458
+ if (key === "ArrowRight") { return (index + 1) % count; }
1459
+ if (key === "ArrowLeft") { return (index - 1 + count) % count; }
1460
+ if (key === "Home") { return 0; }
1461
+ if (key === "End") { return count - 1; }
1462
+ return -1;
1463
+ }
1464
+ return { formatAt: formatAt, parseIds: parseIds, union: union, eventLines: eventLines, labelOf: labelOf,
1465
+ tabFromHash: tabFromHash, nextTab: nextTab };
1466
+ })();
1467
+ if (typeof document !== "undefined") {
1468
+ (function () {
1469
+ /* Tabs (D-084): one panel at a time, and the part holding it, so the seam shows above a
1470
+ projected panel. Without this script every panel shows, top to bottom. */
1471
+ var tabs = Array.prototype.slice.call(document.querySelectorAll(".nm-tab"));
1472
+ if (tabs.length === 0) { return; }
1473
+ var keys = tabs.map(function (t) { return t.getAttribute("data-tab"); });
1474
+ document.documentElement.classList.add("nm-tabs");
1475
+ function select(key, focus) {
1476
+ tabs.forEach(function (t) {
1477
+ var on = t.getAttribute("data-tab") === key;
1478
+ t.setAttribute("aria-selected", on ? "true" : "false");
1479
+ t.tabIndex = on ? 0 : -1;
1480
+ if (on && focus) { t.focus(); }
1481
+ });
1482
+ Array.prototype.forEach.call(document.querySelectorAll(".nm-tabpanel"), function (p) {
1483
+ p.classList.toggle("nm-active", p.getAttribute("data-tab") === key);
1484
+ });
1485
+ Array.prototype.forEach.call(document.querySelectorAll(".nm-part"), function (part) {
1486
+ part.classList.toggle("nm-has-active", part.querySelector(".nm-tabpanel.nm-active") !== null);
1487
+ });
1488
+ if (window.history && window.history.replaceState) { window.history.replaceState(null, "", "#" + key); }
1489
+ }
1490
+ tabs.forEach(function (t, i) {
1491
+ t.addEventListener("click", function () { select(t.getAttribute("data-tab"), false); });
1492
+ t.addEventListener("keydown", function (e) {
1493
+ var n = NM.nextTab(i, e.key, tabs.length);
1494
+ if (n >= 0) { e.preventDefault(); select(keys[n], true); }
1495
+ });
1496
+ });
1497
+ document.addEventListener("click", function (e) {
1498
+ var go = e.target instanceof Element ? e.target.closest(".nm-goto") : null;
1499
+ if (go) { select(go.getAttribute("data-goto"), true); window.scrollTo(0, 0); }
1500
+ });
1501
+ select(NM.tabFromHash(window.location.hash, keys), false);
1502
+ })();
1503
+ }
1504
+ if (typeof document !== "undefined") {
1505
+ (function () {
1506
+ var LIMIT = ${EVENT_LIST_LIMIT};
1507
+ var source = document.getElementById("nm-data");
1508
+ if (!source) { return; }
1509
+ var data = JSON.parse(source.textContent);
1510
+ document.documentElement.classList.add("nm-js");
1511
+ var selected = null;
1512
+ /* Every element with events becomes reachable by keyboard; a gauge column is one stop. */
1513
+ Array.prototype.forEach.call(document.querySelectorAll("[data-events], .nm-col"), function (el) {
1514
+ var col = el.closest(".nm-col");
1515
+ if (col && col !== el) { return; }
1516
+ if (!el.hasAttribute("tabindex")) { el.setAttribute("tabindex", "0"); }
1517
+ if (el instanceof SVGElement && !el.hasAttribute("role")) { el.setAttribute("role", "button"); }
1518
+ });
1519
+ function collect(el) {
1520
+ var lists = [];
1521
+ if (el.hasAttribute("data-events")) { lists.push(NM.parseIds(el.getAttribute("data-events"))); }
1522
+ Array.prototype.forEach.call(el.querySelectorAll("[data-events]"), function (child) {
1523
+ lists.push(NM.parseIds(child.getAttribute("data-events")));
1524
+ });
1525
+ return NM.union(lists);
1526
+ }
1527
+ function show(panel, ids, label) {
1528
+ var listed = NM.eventLines(ids, data, LIMIT);
1529
+ panel.textContent = "";
1530
+ if (label) { var head = document.createElement("p"); head.className = "nm-events-label"; head.textContent = label; panel.appendChild(head); }
1531
+ var count = document.createElement("p");
1532
+ count.textContent = ids.length + (ids.length === 1 ? " event" : " events") + " behind it, each as time (" + data.timeZone + ") and file:line:";
1533
+ panel.appendChild(count);
1534
+ var list = document.createElement("ol");
1535
+ listed.lines.forEach(function (line) { var li = document.createElement("li"); li.textContent = line; list.appendChild(li); });
1536
+ panel.appendChild(list);
1537
+ if (listed.more > 0) {
1538
+ var more = document.createElement("p");
1539
+ more.textContent = listed.more + " more events not listed; nilometer explain lists every event.";
1540
+ panel.appendChild(more);
1541
+ }
1542
+ panel.hidden = false;
1543
+ }
1544
+ function openSeries(key) {
1545
+ Array.prototype.forEach.call(document.querySelectorAll("details.nm-series"), function (d) {
1546
+ d.open = d.getAttribute("data-series") === key;
1547
+ });
1548
+ }
1549
+ function activate(target) {
1550
+ var col = target.closest(".nm-col");
1551
+ var el = target.closest("[data-events]");
1552
+ /* An element inside the column lists its own events; the column itself lists them all. */
1553
+ var chosen = el && (!col || col.contains(el)) ? el : col;
1554
+ if (!chosen) { return; }
1555
+ if (col) { openSeries(col.getAttribute("data-series")); }
1556
+ var ids = chosen === el ? NM.parseIds(el.getAttribute("data-events")) : collect(col);
1557
+ var section = chosen.closest("section.nm-chart");
1558
+ var panel = section ? section.querySelector(".nm-events") : null;
1559
+ if (panel) { show(panel, ids, NM.labelOf(chosen)); }
1560
+ if (selected) { selected.classList.remove("nm-sel"); }
1561
+ selected = chosen;
1562
+ selected.classList.add("nm-sel");
1563
+ }
1564
+ /* D-091: a tap or click toggles an explanation; a click elsewhere or Escape closes them. */
1565
+ function closeInfo(except) {
1566
+ var open = document.querySelectorAll(".nm-info.nm-open");
1567
+ for (var i = 0; i < open.length; i += 1) {
1568
+ if (open[i] !== except) {
1569
+ open[i].classList.remove("nm-open");
1570
+ open[i].querySelector(".nm-info-btn").setAttribute("aria-expanded", "false");
1571
+ }
1572
+ }
1573
+ }
1574
+ document.addEventListener("click", function (e) {
1575
+ var button = e.target instanceof Element ? e.target.closest(".nm-info-btn") : null;
1576
+ if (button) {
1577
+ var box = button.parentElement;
1578
+ closeInfo(box);
1579
+ var opening = !box.classList.contains("nm-open");
1580
+ box.classList.toggle("nm-open", opening);
1581
+ button.setAttribute("aria-expanded", String(opening));
1582
+ return;
1583
+ }
1584
+ closeInfo(null);
1585
+ if (e.target instanceof Element) { activate(e.target); }
1586
+ });
1587
+ document.addEventListener("keydown", function (e) {
1588
+ if (e.key === "Escape") { closeInfo(null); }
1589
+ var t = e.target;
1590
+ if ((e.key === "Enter" || e.key === " ") && t instanceof Element && t.matches("[data-events], .nm-col")) {
1591
+ e.preventDefault();
1592
+ activate(t);
1593
+ }
1594
+ });
1595
+ })();
1596
+ }
1597
+ `;
1598
+ /**
1599
+ * Renders the whole page (step G1.3, D-070): header, coverage, the observed sections, the seam,
1600
+ * and the projected monthly section, in the text report's order, with the event table embedded for
1601
+ * the script and a table view under every chart.
1602
+ * @param data - Everything the report draws, from `loadHtmlData`.
1603
+ * @returns One self-contained, ASCII-only HTML document; never throws for well-formed data.
1604
+ * @see D-070
1605
+ */
1606
+ function renderPage(data) {
1607
+ const options = { timeZone: data.timeZone, width: CHART_WIDTH };
1608
+ const embedded = embedJson({ timeZone: data.timeZone, files: data.files, events: data.events });
1609
+ return [
1610
+ "<!doctype html>",
1611
+ '<html lang="en">',
1612
+ "<head>",
1613
+ '<meta charset="utf-8">',
1614
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
1615
+ `<title>${escapeHtml(`${LABELS.title} | Nilometer`)}</title>`,
1616
+ `<style>${PAGE_STYLE}</style>`,
1617
+ "</head>",
1618
+ "<body>",
1619
+ "<main>",
1620
+ renderHeader(data),
1621
+ // Navigation only: its labels name the tabs, so the projected group's label sits above the
1622
+ // seam, while every projected panel stays below it in the document (principle 2, D-084).
1623
+ renderTabBar(),
1624
+ '<section class="nm-part" id="nm-observed" aria-labelledby="nm-observed-h">',
1625
+ // The tab group's label already shows OBSERVED; the section keeps its heading for screen readers.
1626
+ `<h2 class="eyebrow sr-only" id="nm-observed-h">${escapeHtml(LABELS.observed)}</h2>`,
1627
+ tabPanel("overview", [renderCoverageSection(data, options), renderOverviewCards(data, options)].join("\n")),
1628
+ tabPanel("windows", [renderGaugeSection(data, options), renderCrossingsSection(data)].join("\n")),
1629
+ tabPanel("interruptions", renderLockoutSection(data, options)),
1630
+ tabPanel("models", renderModelSection(data, options)),
1631
+ tabPanel("repos", renderRepoSection(data, options)),
1632
+ "</section>",
1633
+ // The seam: everything below it is derived from the observations above (principle 2).
1634
+ '<section class="nm-part" id="nm-projected" aria-labelledby="nm-seam">',
1635
+ `<h2 class="seam" id="nm-seam">${escapeHtml(LABELS.projected)}</h2>`,
1636
+ tabPanel("burn", renderBurnSection(data, options)),
1637
+ tabPanel("price", renderMonthSection(data, options)),
1638
+ "</section>",
1639
+ "</main>",
1640
+ `<script type="application/json" id="nm-data">${embedded}</script>`,
1641
+ `<script>${PAGE_SCRIPT}</script>`,
1642
+ "</body>",
1643
+ "</html>",
1644
+ "",
1645
+ ].join("\n");
1646
+ }
1647
+ /**
1648
+ * Renders the HTML report, typed by the contract so a signature change fails the typecheck.
1649
+ * @param data - Everything the report draws.
1650
+ * @returns The page.
1651
+ * @see D-070
1652
+ */
1653
+ export const renderHtmlReport = renderPage;
1654
+ //# sourceMappingURL=html.js.map