@sjunepark/fs 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1223 @@
1
+ import { HtmlSink, html } from "./html.js";
2
+ /*
3
+ * Visual direction: "Folio" — a bound financial report. A deep oxblood title
4
+ * band anchors the document; statements fill one 72rem measure on white, like
5
+ * filed statements spanning a page. System serif for identity only, claret as
6
+ * the committed accent, and hierarchy from typography, alignment, and
7
+ * accounting rules (single rule above subtotals, double rule below rollup
8
+ * roots) rather than fills or containers.
9
+ */
10
+ const stylesheet = html ` :root {
11
+ color-scheme: light;
12
+ font-synthesis: none;
13
+ --surface: #ffffff;
14
+ --band: #3a141d;
15
+ --band-ink: #ffffff;
16
+ --band-ink-2: #d9bcc3;
17
+ --band-ok: #93d8a8;
18
+ --band-fail: #f4abaf;
19
+ --band-warn: #e8c481;
20
+ --ink: #1a1c20;
21
+ --ink-2: #54575d;
22
+ --hairline: #dcdde0;
23
+ --rule-strong: #1a1c20;
24
+ --accent: #802636;
25
+ --accent-hover: #6e2130;
26
+ --accent-active: #5f1c29;
27
+ --ok: #216e3a;
28
+ --fail: #a4232b;
29
+ --warn: #7a5200;
30
+ --control-border: #c6c9ce;
31
+ --hover-row: #f6f4f4;
32
+ --footer-bg: #f4f4f5;
33
+ --tooltip-bg: #1a1c20;
34
+ --tooltip-ink: #ffffff;
35
+ --serif: ui-serif, Georgia, "Times New Roman", serif;
36
+ --sans: system-ui, -apple-system, "Segoe UI", sans-serif;
37
+ --measure: 72rem;
38
+ --gutter: 2.5rem;
39
+ /* Center a 72rem measure inside every full-width band without wrappers. */
40
+ --band-pad: max(var(--gutter), calc(50% - var(--measure) / 2));
41
+ --z-sticky: 3;
42
+ --z-skip: 6;
43
+ --z-tooltip: 9;
44
+ }
45
+ * { box-sizing: border-box; }
46
+ body {
47
+ background: var(--surface);
48
+ color: var(--ink);
49
+ font-family: var(--sans);
50
+ font-size: 1rem;
51
+ line-height: 1.5;
52
+ margin: 0;
53
+ }
54
+ [hidden] { display: none !important; }
55
+ a { color: var(--accent); text-underline-offset: 0.2em; }
56
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
57
+ .skip-link {
58
+ background: var(--ink);
59
+ color: var(--surface);
60
+ left: 1rem;
61
+ padding: 0.75rem 1rem;
62
+ position: fixed;
63
+ top: 1rem;
64
+ transform: translateY(-200%);
65
+ z-index: var(--z-skip);
66
+ }
67
+ .skip-link:focus { transform: none; }
68
+ .masthead {
69
+ align-items: baseline;
70
+ background: var(--band);
71
+ color: var(--band-ink);
72
+ display: flex;
73
+ flex-wrap: wrap;
74
+ gap: 1rem 2.5rem;
75
+ justify-content: space-between;
76
+ padding: 3rem var(--band-pad) 2.25rem;
77
+ }
78
+ .masthead :focus-visible { outline-color: var(--band-ink); }
79
+ h1, h2, p { margin-top: 0; }
80
+ h1, h2, .scope { overflow-wrap: anywhere; }
81
+ h1 {
82
+ font-family: var(--serif);
83
+ font-size: 2.5rem;
84
+ font-weight: 600;
85
+ letter-spacing: -0.015em;
86
+ line-height: 1.15;
87
+ margin-bottom: 0.4rem;
88
+ }
89
+ .scope { color: var(--band-ink-2); font-size: 1rem; margin-bottom: 0; }
90
+ .doc-checks { color: var(--band-ink-2); font-size: 0.875rem; margin-bottom: 0; text-align: right; }
91
+ .doc-checks-link { color: inherit; }
92
+ .doc-checks-link:hover { color: var(--band-ink); }
93
+ .masthead .check-glyph[data-status="satisfied"] { color: var(--band-ok); }
94
+ .masthead .check-glyph[data-status="unsatisfied"] { color: var(--band-fail); }
95
+ .masthead .check-glyph[data-status="error"] { color: var(--band-warn); }
96
+ .visually-hidden {
97
+ block-size: 1px;
98
+ clip-path: inset(50%);
99
+ inline-size: 1px;
100
+ margin: -1px;
101
+ overflow: hidden;
102
+ padding: 0;
103
+ position: absolute;
104
+ white-space: nowrap;
105
+ }
106
+ .check-glyph { font-weight: 700; }
107
+ .check-glyph[data-status="satisfied"] { color: var(--ok); }
108
+ .check-glyph[data-status="unsatisfied"] { color: var(--fail); }
109
+ .check-glyph[data-status="error"] { color: var(--warn); }
110
+ .statement-index {
111
+ background: var(--surface);
112
+ border-bottom: 1px solid var(--hairline);
113
+ overflow-x: auto;
114
+ padding: 0 var(--band-pad);
115
+ position: sticky;
116
+ top: 0;
117
+ z-index: var(--z-sticky);
118
+ }
119
+ .statement-index ol {
120
+ display: flex;
121
+ gap: 2rem;
122
+ list-style: none;
123
+ margin: 0;
124
+ min-width: max-content;
125
+ padding: 0;
126
+ }
127
+ .statement-index a {
128
+ align-items: baseline;
129
+ border-bottom: 2px solid transparent;
130
+ color: var(--ink-2);
131
+ display: flex;
132
+ font-size: 0.875rem;
133
+ font-weight: 500;
134
+ gap: 0.5rem;
135
+ padding: 1rem 0;
136
+ text-decoration: none;
137
+ }
138
+ .statement-index a:hover { color: var(--ink); }
139
+ .statement-index a:focus-visible { outline-offset: -3px; }
140
+ .statement-index a[aria-current="location"] {
141
+ border-bottom-color: var(--accent);
142
+ color: var(--ink);
143
+ }
144
+ .index-number { color: var(--accent); font-variant-numeric: tabular-nums; font-weight: 600; }
145
+ .index-flag { font-size: 0.8125rem; }
146
+ .statement { padding: 3rem var(--band-pad) 3.5rem; scroll-margin-top: 4rem; }
147
+ .statement + .statement { border-top: 1px solid var(--hairline); }
148
+ h2 {
149
+ align-items: baseline;
150
+ display: flex;
151
+ font-family: var(--serif);
152
+ font-size: 1.5rem;
153
+ font-weight: 600;
154
+ gap: 0.85rem;
155
+ margin-bottom: 1.5rem;
156
+ }
157
+ .ordinal {
158
+ color: var(--accent);
159
+ flex: 0 0 auto;
160
+ font-family: var(--sans);
161
+ font-size: 0.9375rem;
162
+ font-weight: 600;
163
+ font-variant-numeric: tabular-nums;
164
+ white-space: nowrap;
165
+ }
166
+ .table-tools {
167
+ align-items: center;
168
+ display: flex;
169
+ flex-wrap: wrap;
170
+ gap: 0.5rem;
171
+ justify-content: flex-end;
172
+ margin-bottom: 1rem;
173
+ }
174
+ .tools-spacer { display: none; }
175
+ .tool-button, .col-menu summary {
176
+ align-items: center;
177
+ background: var(--surface);
178
+ border: 1px solid var(--control-border);
179
+ border-radius: 4px;
180
+ color: var(--ink);
181
+ cursor: pointer;
182
+ display: inline-flex;
183
+ font: 500 0.8125rem var(--sans);
184
+ min-height: 2.25rem;
185
+ padding: 0.4rem 0.9rem;
186
+ }
187
+ .tool-button:hover, .col-menu summary:hover { background: #f7f7f8; border-color: var(--ink-2); }
188
+ .tool-button:active, .col-menu summary:active { background: var(--hover-row); }
189
+ .col-menu { position: relative; }
190
+ .col-menu summary { gap: 0.45rem; list-style: none; }
191
+ .col-menu summary::-webkit-details-marker { display: none; }
192
+ .col-menu summary::after { color: var(--ink-2); content: "▾"; font-size: 0.7rem; }
193
+ .col-menu[open] summary { background: var(--hover-row); }
194
+ .col-menu-panel {
195
+ background: var(--surface);
196
+ border: 1px solid var(--control-border);
197
+ border-radius: 4px;
198
+ display: grid;
199
+ left: auto;
200
+ min-width: 12rem;
201
+ padding: 0.35rem;
202
+ position: absolute;
203
+ right: 0;
204
+ top: calc(100% + 0.3rem);
205
+ z-index: 2;
206
+ }
207
+ .col-menu-panel label {
208
+ align-items: center;
209
+ border-radius: 3px;
210
+ cursor: pointer;
211
+ display: flex;
212
+ font-size: 0.875rem;
213
+ gap: 0.6rem;
214
+ min-height: 2.25rem;
215
+ padding: 0.3rem 0.6rem;
216
+ white-space: nowrap;
217
+ }
218
+ .col-menu-panel label:hover { background: var(--hover-row); }
219
+ .col-menu-panel input { accent-color: var(--accent); height: 1rem; margin: 0; width: 1rem; }
220
+ .table-caption {
221
+ caption-side: top;
222
+ color: var(--ink-2);
223
+ font-size: 0.8125rem;
224
+ padding: 0 0 0.75rem;
225
+ text-align: left;
226
+ }
227
+ .caption-inner { display: inline-block; left: 0; position: sticky; }
228
+ .overflow-cue { color: var(--ink-2); display: none; font-size: 0.8125rem; margin-bottom: 0.5rem; }
229
+ .table-scroll { max-width: 100%; overflow-x: auto; }
230
+ .table-scroll:focus-visible { outline-offset: 2px; }
231
+ table { border-collapse: separate; border-spacing: 0; scroll-margin-top: 4rem; width: 100%; }
232
+ th, td {
233
+ border-bottom: 1px solid var(--hairline);
234
+ font-size: 0.875rem;
235
+ font-weight: 400;
236
+ padding: 0.55rem 0.875rem;
237
+ vertical-align: baseline;
238
+ white-space: nowrap;
239
+ }
240
+ .col-text { text-align: left; }
241
+ thead th {
242
+ border-bottom: 2px solid var(--rule-strong);
243
+ color: var(--ink);
244
+ font-size: 0.8125rem;
245
+ font-weight: 600;
246
+ }
247
+ /* Filed statements span the page: the item column absorbs spare width so
248
+ figures keep to the right edge of the measure. */
249
+ [data-statement-table] thead th:first-child { width: 42%; }
250
+ tbody tr:hover > th, tbody tr:hover > td { background: var(--hover-row); }
251
+ tbody tr:last-child > th, tbody tr:last-child > td { border-bottom-color: var(--rule-strong); }
252
+ tbody th {
253
+ font-weight: 400;
254
+ min-width: 16rem;
255
+ text-align: left;
256
+ white-space: normal;
257
+ }
258
+ thead th:first-child, tbody th {
259
+ background: var(--surface);
260
+ left: 0;
261
+ position: sticky;
262
+ z-index: 1;
263
+ }
264
+ [data-overflowing] thead th:first-child, [data-overflowing] tbody th {
265
+ box-shadow: inset -1px 0 var(--hairline);
266
+ }
267
+ tbody tr:hover > th { background: var(--hover-row); }
268
+ .item-cell {
269
+ display: block;
270
+ padding-left: calc(1.5rem + var(--depth, 0) * 1.5rem);
271
+ position: relative;
272
+ }
273
+ .toggle-slot {
274
+ left: calc(var(--depth, 0) * 1.5rem - 0.25rem);
275
+ position: absolute;
276
+ top: 50%;
277
+ transform: translateY(-50%);
278
+ }
279
+ .row-toggle {
280
+ align-items: center;
281
+ background: none;
282
+ border: 0;
283
+ border-radius: 2px;
284
+ color: var(--ink-2);
285
+ cursor: pointer;
286
+ display: inline-flex;
287
+ font: inherit;
288
+ justify-content: center;
289
+ line-height: 1;
290
+ /* WCAG 2.5.8: the disclosure glyph alone is ~13px; keep the target ≥ 24px. */
291
+ min-block-size: 24px;
292
+ min-inline-size: 24px;
293
+ padding: 0.2rem;
294
+ }
295
+ .row-toggle::before {
296
+ content: "▾";
297
+ display: inline-block;
298
+ transition: transform 120ms ease-out;
299
+ }
300
+ .row-toggle:hover { color: var(--accent); }
301
+ .row-toggle[aria-expanded="false"]::before { transform: rotate(-90deg); }
302
+ .collapsed-count { color: var(--ink-2); font-size: 0.75rem; }
303
+ .item-description {
304
+ color: var(--ink-2);
305
+ display: block;
306
+ font-size: 0.75rem;
307
+ font-weight: 400;
308
+ margin-top: 0.1rem;
309
+ }
310
+ .row-heading > th { font-weight: 600; padding-top: 0.85rem; }
311
+ .row-heading > th, .row-heading > td { border-bottom-color: transparent; }
312
+ .row-parent > th, .row-parent > td { border-top: 1px solid var(--rule-strong); font-weight: 650; }
313
+ .row-total > th, .row-total > td { border-bottom: 3px double var(--rule-strong); }
314
+ tbody tr:first-child > th, tbody tr:first-child > td { border-top: 0; }
315
+ tbody tr:has(+ .row-parent) > th, tbody tr:has(+ .row-parent) > td { border-bottom: 0; }
316
+ th.col-num, td.col-num, .value { text-align: right; }
317
+ th.col-num { min-width: 8rem; }
318
+ td.col-num, .value { padding-left: 1.75rem; }
319
+ .value { font-variant-numeric: tabular-nums; }
320
+ .metadata, .missing, .unavailable { color: var(--ink-2); }
321
+ .missing, .unavailable { font-style: italic; text-align: right; }
322
+ .checks { margin-top: 1.75rem; scroll-margin-top: 4rem; }
323
+ .checks summary {
324
+ color: var(--ink-2);
325
+ cursor: pointer;
326
+ font-size: 0.875rem;
327
+ font-weight: 500;
328
+ padding: 0.75rem 0;
329
+ }
330
+ .checks summary:hover { color: var(--ink); }
331
+ .checks[open] summary { color: var(--ink); }
332
+ .check-table { margin: 0.5rem 0 1.25rem; min-width: 0; }
333
+ .check-table thead th { border-bottom-width: 1px; }
334
+ .check-table tbody th { font-weight: 400; min-width: 0; }
335
+ .check-status { white-space: nowrap; }
336
+ .check-status[data-status="satisfied"] { color: var(--ok); }
337
+ .check-status[data-status="unsatisfied"] { color: var(--fail); font-weight: 650; }
338
+ .check-status[data-status="error"] { color: var(--warn); }
339
+ .handoff {
340
+ align-items: center;
341
+ border-top: 1px solid var(--hairline);
342
+ display: grid;
343
+ gap: 0.5rem 1.5rem;
344
+ grid-template-columns: minmax(0, 1fr) auto;
345
+ margin-top: 2rem;
346
+ padding-top: 1.5rem;
347
+ }
348
+ .handoff-action { align-items: center; display: flex; gap: 1rem; }
349
+ .copy-button {
350
+ background: var(--accent);
351
+ border: 1px solid var(--accent);
352
+ border-radius: 4px;
353
+ color: #fff;
354
+ cursor: pointer;
355
+ font: 600 0.875rem var(--sans);
356
+ min-height: 44px;
357
+ padding: 0.65rem 1.3rem;
358
+ }
359
+ .copy-button:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
360
+ .copy-button:active { background: var(--accent-active); }
361
+ .copy-status { font-size: 0.875rem; font-weight: 600; min-width: 8rem; }
362
+ .copy-status[data-state="failure"] { color: var(--fail); }
363
+ .copy-status[data-state="success"] { color: var(--ok); }
364
+ .copy-help { color: var(--ink-2); font-size: 0.8125rem; grid-column: 1; margin-bottom: 0; }
365
+ .native-table-link {
366
+ align-items: center;
367
+ display: inline-flex;
368
+ font-size: 0.875rem;
369
+ grid-column: 2;
370
+ grid-row: 1;
371
+ justify-self: end;
372
+ min-height: 44px;
373
+ white-space: nowrap;
374
+ }
375
+ .copy-source { display: none; }
376
+ .colophon { background: var(--footer-bg); border-top: 1px solid var(--hairline); margin: 0; padding: 1.5rem var(--band-pad) 2rem; }
377
+ .colophon p { color: var(--ink-2); font-size: 0.8125rem; margin: 0; max-width: 75ch; }
378
+ .tooltip {
379
+ background: var(--tooltip-bg);
380
+ border-radius: 4px;
381
+ color: var(--tooltip-ink);
382
+ font-size: 0.8125rem;
383
+ line-height: 1.5;
384
+ max-width: 26rem;
385
+ padding: 0.55rem 0.8rem;
386
+ pointer-events: none;
387
+ position: fixed;
388
+ z-index: var(--z-tooltip);
389
+ }
390
+ .tooltip strong { font-weight: 600; }
391
+ @media (max-width: 64rem) {
392
+ :root { --gutter: 1.5rem; }
393
+ .masthead { padding-block: 2rem 1.5rem; }
394
+ h1 { font-size: 2rem; }
395
+ }
396
+ @media (max-width: 44rem) {
397
+ :root { --gutter: 1rem; }
398
+ .masthead { flex-direction: column; gap: 0.75rem; padding-block: 1.5rem 1.25rem; }
399
+ h1 { font-size: 1.75rem; }
400
+ .doc-checks { text-align: left; }
401
+ .statement { padding-block: 2rem 2.5rem; }
402
+ .overflow-cue { display: block; }
403
+ .handoff { grid-template-columns: minmax(0, 1fr); }
404
+ .native-table-link { grid-column: 1; grid-row: auto; justify-self: start; white-space: normal; }
405
+ }
406
+ @media (prefers-reduced-motion: reduce) {
407
+ .row-toggle::before { transition: none; }
408
+ }
409
+ @media print {
410
+ :root {
411
+ --surface: #fff;
412
+ --band: #fff;
413
+ --band-ink: #000;
414
+ --band-ink-2: #333;
415
+ --band-ok: #000;
416
+ --band-fail: #000;
417
+ --band-warn: #000;
418
+ --footer-bg: #fff;
419
+ --ink: #000;
420
+ --ink-2: #333;
421
+ --hairline: #999;
422
+ --rule-strong: #000;
423
+ --accent: #000;
424
+ --hover-row: #fff;
425
+ }
426
+ body { background: #fff; margin: 0; }
427
+ .skip-link, .statement-index, .table-tools, .overflow-cue, .handoff, .copy-source, .tooltip, .row-toggle, .collapsed-count { display: none !important; }
428
+ tbody tr[hidden] { display: table-row !important; }
429
+ th[data-col][hidden], td[data-col][hidden] { display: table-cell !important; }
430
+ thead th:first-child, tbody th { box-shadow: none; position: static; }
431
+ .masthead { border-bottom: 2px solid #000; padding: 0 0 1rem; }
432
+ .statement { break-before: page; padding-inline: 0; }
433
+ .statement:first-child { break-before: auto; }
434
+ .colophon { padding-inline: 0; }
435
+ .table-scroll { overflow: visible; }
436
+ thead { display: table-header-group; }
437
+ tr { break-inside: avoid; }
438
+ a { color: #000; text-decoration: none; }
439
+ }`;
440
+ const behavior = html ` <script>
441
+ (() => {
442
+ "use strict";
443
+
444
+ // Keyboard access to value cells is a roving tabindex per table; row
445
+ // collapse can hide the current tab stop, so collapse wiring calls the
446
+ // table's registered repair to move it back onto a visible cell.
447
+ const rovingRepairs = new Map();
448
+
449
+ const fallbackCopy = (text, control) => {
450
+ const temporary = document.createElement("textarea");
451
+ temporary.value = text;
452
+ temporary.readOnly = true;
453
+ temporary.tabIndex = -1;
454
+ temporary.setAttribute("aria-hidden", "true");
455
+ temporary.style.position = "fixed";
456
+ temporary.style.left = "-9999px";
457
+ temporary.style.top = "0";
458
+ let copied = false;
459
+ try {
460
+ document.body.append(temporary);
461
+ temporary.focus();
462
+ temporary.select();
463
+ temporary.setSelectionRange(0, temporary.value.length);
464
+ copied = document.execCommand("copy");
465
+ } catch {
466
+ copied = false;
467
+ } finally {
468
+ temporary.remove();
469
+ control.focus();
470
+ }
471
+ return copied;
472
+ };
473
+
474
+ const copyText = async (text, control) => {
475
+ try {
476
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
477
+ await navigator.clipboard.writeText(text);
478
+ return true;
479
+ }
480
+ } catch {
481
+ return fallbackCopy(text, control);
482
+ }
483
+ return fallbackCopy(text, control);
484
+ };
485
+
486
+ for (const control of document.querySelectorAll("[data-copy-control]")) {
487
+ const source = document.getElementById(control.dataset.copySource || "");
488
+ const status = document.getElementById(control.dataset.copyStatus || "");
489
+ const handoff = control.closest("[data-copy-handoff]");
490
+ if (!(source instanceof HTMLTextAreaElement) || !(status instanceof HTMLElement) || !(handoff instanceof HTMLElement)) continue;
491
+
492
+ let text;
493
+ try {
494
+ const parsed = JSON.parse(source.value);
495
+ if (typeof parsed !== "string") continue;
496
+ text = parsed;
497
+ } catch {
498
+ continue;
499
+ }
500
+
501
+ let attempt = 0;
502
+ handoff.hidden = false;
503
+ control.addEventListener("click", async () => {
504
+ const currentAttempt = ++attempt;
505
+ status.dataset.state = "pending";
506
+ status.textContent = "Copying…";
507
+ const copied = await copyText(text, control);
508
+ window.setTimeout(() => {
509
+ if (currentAttempt !== attempt) return;
510
+ status.dataset.state = copied ? "success" : "failure";
511
+ status.textContent = copied ? "Copied" : "Copy failed. Use the native table instead.";
512
+ }, 0);
513
+ });
514
+ }
515
+
516
+ for (const table of document.querySelectorAll("[data-statement-table]")) {
517
+ const scroller = table.closest(".table-scroll");
518
+ const updateOverflow = () => {
519
+ if (!(scroller instanceof HTMLElement)) return;
520
+ if (scroller.scrollWidth > scroller.clientWidth) scroller.dataset.overflowing = "true";
521
+ else delete scroller.dataset.overflowing;
522
+ };
523
+ updateOverflow();
524
+ window.addEventListener("resize", updateOverflow);
525
+
526
+ const statement = table.closest(".statement");
527
+ if (!(statement instanceof HTMLElement)) continue;
528
+ const tools = statement.querySelector("[data-table-tools]");
529
+ const body = table.tBodies[0];
530
+ if (!(tools instanceof HTMLElement) || body === undefined) continue;
531
+ tools.hidden = false;
532
+
533
+ const setColumn = (key, shown) => {
534
+ for (const cell of table.querySelectorAll("[data-col]")) {
535
+ if (cell.dataset.col === key) cell.hidden = !shown;
536
+ }
537
+ updateOverflow();
538
+ };
539
+ // Viewport width supplies only the initial default. Resizing must not
540
+ // discard the analyst's explicit checkbox choices.
541
+ const narrowViewport = window.matchMedia("(max-width: 44em)").matches;
542
+ for (const box of tools.querySelectorAll("input[data-col-toggle]")) {
543
+ box.addEventListener("change", () => {
544
+ setColumn(box.dataset.colToggle || "", box.checked);
545
+ });
546
+ if (narrowViewport) {
547
+ box.checked = false;
548
+ setColumn(box.dataset.colToggle || "", false);
549
+ }
550
+ }
551
+ const menu = tools.querySelector("details[data-col-menu]");
552
+ if (menu instanceof HTMLDetailsElement) {
553
+ const menuSummary = menu.querySelector("summary");
554
+ document.addEventListener("click", (event) => {
555
+ if (event.target instanceof Node && !menu.contains(event.target)) menu.open = false;
556
+ });
557
+ menu.addEventListener("keydown", (event) => {
558
+ if (event.key !== "Escape") return;
559
+ event.preventDefault();
560
+ menu.open = false;
561
+ if (menuSummary instanceof HTMLElement) menuSummary.focus();
562
+ });
563
+ }
564
+
565
+ const rows = Array.from(body.querySelectorAll("tr[data-row], tr.row-heading"));
566
+ const rowByIndex = new Map(
567
+ rows.filter((row) => row.dataset.row !== undefined).map((row) => [row.dataset.row, row])
568
+ );
569
+ const collapsed = new Set();
570
+
571
+ const applyRowState = () => {
572
+ for (const row of rows) {
573
+ let ancestor = row.dataset.parentRow;
574
+ let hidden = false;
575
+ while (ancestor !== undefined && !hidden) {
576
+ hidden = collapsed.has(ancestor);
577
+ ancestor = rowByIndex.get(ancestor)?.dataset.parentRow;
578
+ }
579
+ row.hidden = hidden;
580
+ }
581
+ updateOverflow();
582
+ for (const button of body.querySelectorAll("[data-row-toggle]")) {
583
+ const row = button.closest("tr");
584
+ if (!(row instanceof HTMLTableRowElement)) continue;
585
+ const expanded = !collapsed.has(row.dataset.row);
586
+ button.setAttribute("aria-expanded", expanded ? "true" : "false");
587
+ const label = row.querySelector(".item-label")?.textContent || "row";
588
+ button.setAttribute(
589
+ "aria-label",
590
+ (expanded ? "Collapse " : "Expand ") + label + " detail rows"
591
+ );
592
+ const count = row.querySelector(".collapsed-count");
593
+ if (count instanceof HTMLElement) count.hidden = expanded;
594
+ }
595
+ rovingRepairs.get(table)?.();
596
+ };
597
+
598
+ for (const button of body.querySelectorAll("[data-row-toggle]")) {
599
+ button.hidden = false;
600
+ button.addEventListener("click", () => {
601
+ const row = button.closest("tr");
602
+ if (!(row instanceof HTMLTableRowElement) || row.dataset.row === undefined) return;
603
+ if (collapsed.has(row.dataset.row)) collapsed.delete(row.dataset.row);
604
+ else collapsed.add(row.dataset.row);
605
+ applyRowState();
606
+ });
607
+ }
608
+
609
+ tools.querySelector("[data-rows-collapse]")?.addEventListener("click", () => {
610
+ for (const button of body.querySelectorAll("[data-row-toggle]")) {
611
+ const row = button.closest("tr");
612
+ if (row instanceof HTMLTableRowElement && row.dataset.row !== undefined) collapsed.add(row.dataset.row);
613
+ }
614
+ applyRowState();
615
+ });
616
+ tools.querySelector("[data-rows-expand]")?.addEventListener("click", () => {
617
+ collapsed.clear();
618
+ applyRowState();
619
+ });
620
+ }
621
+
622
+ const tooltip = document.createElement("div");
623
+ tooltip.className = "tooltip";
624
+ // aria-hidden keeps the singleton out of the reading order; a focused
625
+ // cell still exposes it through aria-describedby, which includes
626
+ // referenced hidden content in the accessible description.
627
+ tooltip.setAttribute("aria-hidden", "true");
628
+ tooltip.id = "cell-context";
629
+ tooltip.hidden = true;
630
+ document.body.append(tooltip);
631
+ let tooltipTimer = 0;
632
+ let anchorFrame = 0;
633
+ let describedCell = null;
634
+
635
+ const describeCell = (cell) => {
636
+ if (describedCell === cell) return;
637
+ describedCell?.removeAttribute("aria-describedby");
638
+ describedCell = cell;
639
+ cell?.setAttribute("aria-describedby", tooltip.id);
640
+ };
641
+
642
+ const tooltipLine = (label, value) => {
643
+ const line = document.createElement("div");
644
+ const name = document.createElement("strong");
645
+ name.textContent = label + ": ";
646
+ line.append(name, value);
647
+ return line;
648
+ };
649
+
650
+ const hideTooltip = () => {
651
+ window.clearTimeout(tooltipTimer);
652
+ if (anchorFrame !== 0) {
653
+ window.cancelAnimationFrame(anchorFrame);
654
+ anchorFrame = 0;
655
+ }
656
+ tooltip.hidden = true;
657
+ describeCell(null);
658
+ };
659
+
660
+ const showTooltip = (cell) => {
661
+ const row = cell.closest("tr");
662
+ const table = cell.closest("table");
663
+ if (!(row instanceof HTMLTableRowElement) || !(table instanceof HTMLTableElement)) return;
664
+ const headerRow = table.tHead?.rows[0];
665
+ const header = headerRow?.cells[cell.cellIndex];
666
+ if (header === undefined) return;
667
+
668
+ tooltip.replaceChildren();
669
+ const label = row.querySelector(".item-label");
670
+ if (label !== null) tooltip.append(tooltipLine("Item", label.textContent || ""));
671
+ tooltip.append(tooltipLine("Period", header.textContent || ""));
672
+ const unitCell = row.querySelector('[data-col="unit"]');
673
+ const unit = unitCell instanceof HTMLElement
674
+ ? unitCell.dataset.unitFull || unitCell.textContent || ""
675
+ : table.dataset.commonUnit || "";
676
+ if (unit !== "") tooltip.append(tooltipLine("Unit", unit));
677
+ tooltip.append(tooltipLine("Value", cell.textContent || ""));
678
+ const description = row.dataset.description;
679
+ if (description !== undefined) tooltip.append(tooltipLine("Description", description));
680
+
681
+ tooltip.hidden = false;
682
+ const anchor = cell.getBoundingClientRect();
683
+ const size = tooltip.getBoundingClientRect();
684
+ const left = Math.max(8, Math.min(anchor.right - size.width, window.innerWidth - size.width - 8));
685
+ const top = anchor.bottom + 6 + size.height > window.innerHeight
686
+ ? anchor.top - size.height - 6
687
+ : anchor.bottom + 6;
688
+ tooltip.style.left = left + "px";
689
+ tooltip.style.top = Math.max(8, top) + "px";
690
+ };
691
+
692
+ document.addEventListener("mouseover", (event) => {
693
+ if (!(event.target instanceof Element)) return;
694
+ const cell = event.target.closest("[data-statement-table] td.value, [data-statement-table] td.missing, [data-statement-table] td.unavailable");
695
+ window.clearTimeout(tooltipTimer);
696
+ // Keyboard focus owns the shared tooltip until focus leaves. Pointer
697
+ // hover must not replace the accessible description with another
698
+ // cell's context.
699
+ if (describedCell !== null && document.activeElement === describedCell) return;
700
+ if (!(cell instanceof HTMLTableCellElement)) {
701
+ if (describedCell === null) tooltip.hidden = true;
702
+ return;
703
+ }
704
+ tooltipTimer = window.setTimeout(() => {
705
+ showTooltip(cell);
706
+ }, 400);
707
+ });
708
+ document.addEventListener("scroll", () => {
709
+ // Focusing a cell may scroll it into view; keep the focus tooltip
710
+ // anchored instead of dismissing it under the analyst's focus. While
711
+ // the cell itself is scrolled offscreen the tooltip hides rather than
712
+ // floating detached at the viewport edge.
713
+ if (describedCell !== null && document.activeElement === describedCell) {
714
+ if (anchorFrame !== 0) return;
715
+ anchorFrame = window.requestAnimationFrame(() => {
716
+ anchorFrame = 0;
717
+ const cell = describedCell;
718
+ if (cell === null || document.activeElement !== cell) return;
719
+ const anchor = cell.getBoundingClientRect();
720
+ if (anchor.bottom < 0 || anchor.top > window.innerHeight) tooltip.hidden = true;
721
+ else showTooltip(cell);
722
+ });
723
+ return;
724
+ }
725
+ hideTooltip();
726
+ }, true);
727
+ document.addEventListener("keydown", (event) => {
728
+ if (event.key === "Escape") hideTooltip();
729
+ });
730
+
731
+ const valueCellSelector = "td.value, td.missing, td.unavailable";
732
+ for (const table of document.querySelectorAll("[data-statement-table]")) {
733
+ const body = table.tBodies[0];
734
+ if (body === undefined) continue;
735
+ const cells = Array.from(body.querySelectorAll(valueCellSelector));
736
+ const firstCell = cells[0];
737
+ if (firstCell === undefined) continue;
738
+ for (const cell of cells) cell.tabIndex = -1;
739
+ firstCell.tabIndex = 0;
740
+
741
+ const setRovingCell = (cell) => {
742
+ for (const other of cells) other.tabIndex = -1;
743
+ cell.tabIndex = 0;
744
+ };
745
+ rovingRepairs.set(table, () => {
746
+ const current = cells.find((cell) => cell.tabIndex === 0);
747
+ if (current !== undefined && !current.closest("tr")?.hidden) return;
748
+ const visible = cells.find((cell) => !cell.closest("tr")?.hidden);
749
+ if (visible !== undefined) setRovingCell(visible);
750
+ });
751
+
752
+ const rowValueCells = (row) =>
753
+ Array.from(row.cells).filter((cell) => cell.matches(valueCellSelector));
754
+ table.addEventListener("focusin", (event) => {
755
+ const cell = event.target;
756
+ if (!(cell instanceof HTMLTableCellElement) || !cell.matches(valueCellSelector)) return;
757
+ setRovingCell(cell);
758
+ window.clearTimeout(tooltipTimer);
759
+ showTooltip(cell);
760
+ describeCell(cell);
761
+ });
762
+ table.addEventListener("focusout", (event) => {
763
+ if (event.target === describedCell) hideTooltip();
764
+ });
765
+ table.addEventListener("keydown", (event) => {
766
+ const cell = event.target;
767
+ if (!(cell instanceof HTMLTableCellElement) || !cell.matches(valueCellSelector)) return;
768
+ const row = cell.closest("tr");
769
+ if (!(row instanceof HTMLTableRowElement)) return;
770
+ let target;
771
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
772
+ const siblings = rowValueCells(row);
773
+ target = siblings[siblings.indexOf(cell) + (event.key === "ArrowLeft" ? -1 : 1)];
774
+ } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
775
+ const rows = Array.from(body.rows).filter(
776
+ (candidate) => !candidate.hidden && rowValueCells(candidate).length > 0
777
+ );
778
+ const nextRow = rows[rows.indexOf(row) + (event.key === "ArrowUp" ? -1 : 1)];
779
+ const candidate = nextRow?.cells[cell.cellIndex];
780
+ if (candidate !== undefined && candidate.matches(valueCellSelector)) target = candidate;
781
+ }
782
+ if (target === undefined) return;
783
+ event.preventDefault();
784
+ target.focus();
785
+ });
786
+ }
787
+
788
+ const indexLinks = new Map();
789
+ for (const link of document.querySelectorAll(".statement-index a[href^='#']")) {
790
+ indexLinks.set(link.getAttribute("href")?.slice(1), link);
791
+ }
792
+ if (indexLinks.size > 0 && typeof IntersectionObserver === "function") {
793
+ const links = Array.from(indexLinks.values());
794
+ const lastLink = links[links.length - 1];
795
+ const setCurrent = (link) => {
796
+ for (const other of links) other.removeAttribute("aria-current");
797
+ link.setAttribute("aria-current", "location");
798
+ };
799
+ // The observer band can never reach a final statement shorter than the
800
+ // band offset, so the scrolled-to-end position marks the last
801
+ // statement explicitly. A page that cannot scroll never qualifies.
802
+ const atScrolledEnd = () => {
803
+ const root = document.documentElement;
804
+ return root.scrollHeight > window.innerHeight + 2 &&
805
+ window.scrollY + window.innerHeight >= root.scrollHeight - 2;
806
+ };
807
+ const observer = new IntersectionObserver((entries) => {
808
+ for (const entry of entries) {
809
+ const link = indexLinks.get(entry.target.id);
810
+ if (link === undefined || !entry.isIntersecting) continue;
811
+ setCurrent(atScrolledEnd() && lastLink !== undefined ? lastLink : link);
812
+ }
813
+ }, { rootMargin: "-10% 0px -70% 0px" });
814
+ for (const id of indexLinks.keys()) {
815
+ const section = document.getElementById(id || "");
816
+ if (section !== null) observer.observe(section);
817
+ }
818
+ window.addEventListener("scroll", () => {
819
+ if (atScrolledEnd() && lastLink !== undefined) setCurrent(lastLink);
820
+ }, { passive: true });
821
+ }
822
+ })();
823
+ </script>`;
824
+ const spreadsheetControlPrefix = /^[\u0009-\u000d\u0020\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]*["=+\-@]/u;
825
+ const periodLabel = (period) => period.kind === "instant" ? period.date : `${period.start} – ${period.end}`;
826
+ const unitText = (unit) => `${unit.label} (${unit.measure}, scale ${unit.scale})`;
827
+ const groupDigits = (integer) => {
828
+ const lead = integer.length % 3 || 3;
829
+ let grouped = integer.slice(0, lead);
830
+ for (let index = lead; index < integer.length; index += 3) {
831
+ grouped += `,${integer.slice(index, index + 3)}`;
832
+ }
833
+ return grouped;
834
+ };
835
+ const displayDecimal = (value) => {
836
+ // Validated documents cap decimals at 1,000 digits. Larger values only
837
+ // reach render as byte-budget probes; passing them through untouched keeps
838
+ // rendering from materializing new oversized strings before the sink
839
+ // rejects them.
840
+ if (value.length > 1_024)
841
+ return value;
842
+ const match = /^(-?)(\d+)((?:\.\d+)?)$/u.exec(value);
843
+ if (match === null)
844
+ return value;
845
+ const [, sign, integer, fraction] = match;
846
+ if (sign === undefined || integer === undefined || fraction === undefined)
847
+ return value;
848
+ return `${sign}${groupDigits(integer)}${fraction}`;
849
+ };
850
+ const authorTsvText = (text) => {
851
+ const normalized = text.replace(/[\t\r\n]/gu, " ");
852
+ return spreadsheetControlPrefix.test(normalized) ? `'${normalized}` : normalized;
853
+ };
854
+ const jsonStringFragment = (text) => JSON.stringify(text).slice(1, -1);
855
+ const appendValue = (sink, value) => {
856
+ if (value === null) {
857
+ sink.writeLine(html ` <td class="missing">Missing</td>`);
858
+ }
859
+ else if (typeof value === "object") {
860
+ sink.writeLine(html ` <td class="unavailable">Unavailable</td>`);
861
+ }
862
+ else {
863
+ sink.writeLine(html ` <td class="value">${displayDecimal(value)}</td>`);
864
+ }
865
+ };
866
+ const appendAuthorTsvCell = (sink, value) => {
867
+ sink.write(html `${jsonStringFragment(authorTsvText(value))}`);
868
+ };
869
+ const appendTsvValue = (sink, value) => {
870
+ if (value === null) {
871
+ sink.write(html `${jsonStringFragment("Missing")}`);
872
+ }
873
+ else if (typeof value === "object") {
874
+ sink.write(html `${jsonStringFragment("Unavailable")}`);
875
+ }
876
+ else {
877
+ sink.write(html `${jsonStringFragment(value)}`);
878
+ }
879
+ };
880
+ const appendTsvDelimiter = (sink, value) => {
881
+ sink.write(html `${jsonStringFragment(value)}`);
882
+ };
883
+ const appendCopySource = (presentation, statementPresentation, sink) => {
884
+ const { statement, copySourceId } = statementPresentation;
885
+ sink.write(html ` <textarea class="copy-source" id="${copySourceId}" readonly hidden aria-hidden="true" tabindex="-1">"`);
886
+ appendAuthorTsvCell(sink, "Item");
887
+ appendTsvDelimiter(sink, "\t");
888
+ appendAuthorTsvCell(sink, "Unit");
889
+ for (const periodId of statement.periods) {
890
+ const period = presentation.periods.get(periodId);
891
+ if (period === undefined)
892
+ throw new Error("Validated statement lost its period");
893
+ appendTsvDelimiter(sink, "\t");
894
+ appendAuthorTsvCell(sink, periodLabel(period));
895
+ }
896
+ for (const item of statement.items) {
897
+ appendTsvDelimiter(sink, "\n");
898
+ appendAuthorTsvCell(sink, item.label);
899
+ const unit = presentation.units.get(item.unit);
900
+ if (unit === undefined)
901
+ throw new Error("Validated item lost its unit");
902
+ appendTsvDelimiter(sink, "\t");
903
+ appendAuthorTsvCell(sink, unitText(unit));
904
+ for (const periodId of statement.periods) {
905
+ const value = item.values[periodId];
906
+ if (value === undefined)
907
+ throw new Error("Validated item lost its period value");
908
+ appendTsvDelimiter(sink, "\t");
909
+ appendTsvValue(sink, value);
910
+ }
911
+ if (sink.exceeded)
912
+ return;
913
+ }
914
+ sink.writeLine(html `"</textarea>`);
915
+ };
916
+ const captionTemplate = ({ statement, commonUnit }) => commonUnit === undefined
917
+ ? html `${statement.label} — Units shown by item`
918
+ : html `${statement.label} — Unit: ${unitText(commonUnit)}`;
919
+ const checkStatus = (application) => {
920
+ switch (application.status) {
921
+ case "satisfied":
922
+ return html `<td class="check-status" data-status="satisfied">= Satisfied</td>`;
923
+ case "unsatisfied":
924
+ return html `<td class="check-status" data-status="unsatisfied">≠ Not satisfied</td>`;
925
+ case "error":
926
+ return html `<td class="check-status" data-status="error">! Not checked</td>`;
927
+ }
928
+ };
929
+ const checkFormula = (check) => `${check.parent.label} = ${check.children.map(({ label }) => label).join(" + ")}`;
930
+ const checkIssueSummary = (applications) => {
931
+ const unsatisfied = applications.filter(({ status }) => status === "unsatisfied").length;
932
+ const notChecked = applications.filter(({ status }) => status === "error").length;
933
+ const issues = [
934
+ ...(unsatisfied === 0 ? [] : [`${unsatisfied} not satisfied`]),
935
+ ...(notChecked === 0 ? [] : [`${notChecked} not checked`])
936
+ ];
937
+ return issues.length === 0 ? undefined : issues.join(" · ");
938
+ };
939
+ const checkIssueStatus = (applications) => {
940
+ if (applications.some(({ status }) => status === "unsatisfied"))
941
+ return "unsatisfied";
942
+ if (applications.some(({ status }) => status === "error"))
943
+ return "error";
944
+ return undefined;
945
+ };
946
+ const appendChecks = (statementPresentation, sink) => {
947
+ const { checks, statement, checksId } = statementPresentation;
948
+ if (checks.length === 0)
949
+ return;
950
+ const applications = checks.map(({ application }) => application);
951
+ const issueSummary = checkIssueSummary(applications);
952
+ const summary = issueSummary === undefined
953
+ ? `${checks.length} · all satisfied`
954
+ : `${checks.length} · ${issueSummary}`;
955
+ // A statement whose checks are not all satisfied opens its disclosure so
956
+ // the document's most important signal is visible without hunting.
957
+ const openAttribute = checkIssueStatus(applications) === undefined ? html `` : html ` open`;
958
+ const itemsById = new Map(statement.items.map((item) => [item.id, item]));
959
+ sink.writeLine(html ` <details class="checks" id="${checksId}"${openAttribute}>
960
+ <summary>Rollup checks — ${summary}</summary>
961
+ <div class="table-scroll" role="region" aria-label="${statement.label} rollup checks. Scroll horizontally to review all columns." tabindex="0">
962
+ <table class="check-table">
963
+ <thead>
964
+ <tr>
965
+ <th scope="col" class="col-text">Check</th>
966
+ <th scope="col" class="col-text">Period</th>
967
+ <th scope="col" class="col-num">Actual</th>
968
+ <th scope="col" class="col-num">Expected</th>
969
+ <th scope="col" class="col-num">Difference</th>
970
+ <th scope="col" class="col-num">Tolerance</th>
971
+ <th scope="col" class="col-text">Result</th>
972
+ </tr>
973
+ </thead>
974
+ <tbody>`);
975
+ for (const check of checks) {
976
+ const { application } = check;
977
+ sink.writeLine(html ` <tr>
978
+ <th scope="row">${checkFormula(check)}</th>
979
+ <td class="metadata">${periodLabel(check.period)}</td>`);
980
+ if (application.status === "error") {
981
+ const cell = itemsById.get(application.cell.item);
982
+ const reason = application.reason === "missing-value" ? "missing" : "unavailable";
983
+ sink.writeLine(html ` <td class="metadata col-text" colspan="4">Value for ${cell?.label ?? application.cell.item} is ${reason}</td>`);
984
+ }
985
+ else {
986
+ sink.writeLine(html ` <td class="value">${application.actual}</td>
987
+ <td class="value">${application.expected}</td>
988
+ <td class="value">${application.difference}</td>
989
+ <td class="value">${application.tolerance}</td>`);
990
+ }
991
+ sink.writeLine(checkStatus(application));
992
+ sink.writeLine(html ` </tr>`);
993
+ if (sink.exceeded)
994
+ return;
995
+ }
996
+ sink.writeLine(html ` </tbody>
997
+ </table>
998
+ </div>
999
+ </details>`);
1000
+ };
1001
+ const appendTableTools = (statementPresentation, sink) => {
1002
+ const { commonUnit, items } = statementPresentation;
1003
+ const hasParents = items.some(({ isParent }) => isParent);
1004
+ const hasColumnToggles = commonUnit === undefined;
1005
+ if (!hasParents && !hasColumnToggles)
1006
+ return;
1007
+ sink.writeLine(html ` <div class="table-tools" data-table-tools hidden>`);
1008
+ if (hasColumnToggles) {
1009
+ sink.writeLine(html ` <details class="col-menu" data-col-menu>
1010
+ <summary>Columns</summary>
1011
+ <div class="col-menu-panel">`);
1012
+ if (commonUnit === undefined) {
1013
+ sink.writeLine(html ` <label><input type="checkbox" checked data-col-toggle="unit"> Unit</label>`);
1014
+ }
1015
+ sink.writeLine(html ` </div>
1016
+ </details>`);
1017
+ }
1018
+ if (hasParents) {
1019
+ sink.writeLine(html ` <span class="tools-spacer"></span>
1020
+ <button class="tool-button" type="button" data-rows-collapse>Collapse all</button>
1021
+ <button class="tool-button" type="button" data-rows-expand>Expand all</button>`);
1022
+ }
1023
+ sink.writeLine(html ` </div>`);
1024
+ };
1025
+ /*
1026
+ * A group-heading row restates a rollup parent's label above its contiguous
1027
+ * subtree, classic-statement style. It is presentation-only: no values, no
1028
+ * TSV representation, hidden with the group when the parent collapses.
1029
+ */
1030
+ const appendHeadingRow = (presentation, statementPresentation, parentIndex, sink) => {
1031
+ const parent = statementPresentation.items[parentIndex];
1032
+ if (parent === undefined)
1033
+ throw new Error("Validated statement lost its heading parent");
1034
+ const depthAttribute = parent.depth === 0 ? html `` : html ` style="--depth: ${parent.depth}"`;
1035
+ sink.writeLine(html ` <tr class="row-heading" data-parent-row="${parentIndex}"${depthAttribute}>
1036
+ <th scope="row"><span class="item-cell"><span class="item-label">${parent.item.label}</span></span></th>`);
1037
+ if (statementPresentation.commonUnit === undefined) {
1038
+ sink.writeLine(html ` <td data-col="unit"></td>`);
1039
+ }
1040
+ for (const period of statementPresentation.statement.periods) {
1041
+ if (!presentation.periods.has(period))
1042
+ throw new Error("Validated statement lost its period");
1043
+ sink.writeLine(html ` <td></td>`);
1044
+ }
1045
+ sink.writeLine(html ` </tr>`);
1046
+ };
1047
+ const rowOpening = (statementPresentation, index) => {
1048
+ const { items } = statementPresentation;
1049
+ const presentation = items[index];
1050
+ if (presentation === undefined)
1051
+ throw new Error("Validated statement lost its item presentation");
1052
+ const { item, depth, isParent, parentIndex } = presentation;
1053
+ const classes = [
1054
+ ...(isParent ? ["row-parent"] : []),
1055
+ ...(isParent && depth === 0 ? ["row-total"] : [])
1056
+ ];
1057
+ const classAttribute = classes.length === 0 ? html `` : html ` class="${classes.join(" ")}"`;
1058
+ const parentAttribute = parentIndex === undefined ? html `` : html ` data-parent-row="${parentIndex}"`;
1059
+ const depthAttribute = depth === 0 ? html `` : html ` style="--depth: ${depth}"`;
1060
+ const descriptionAttribute = item.description === undefined
1061
+ ? html ``
1062
+ : html ` data-description="${item.description}"`;
1063
+ return html ` <tr data-row="${index}"${parentAttribute}${classAttribute}${depthAttribute}${descriptionAttribute}>`;
1064
+ };
1065
+ const itemDescription = (description) => description === undefined
1066
+ ? html ``
1067
+ : html `<span class="item-description">${description}</span>`;
1068
+ const itemCell = (statementPresentation, index) => {
1069
+ const presentation = statementPresentation.items[index];
1070
+ if (presentation === undefined)
1071
+ throw new Error("Validated statement lost its item presentation");
1072
+ const { item, isParent, descendantCount } = presentation;
1073
+ if (!isParent) {
1074
+ return html ` <th scope="row"><span class="item-cell"><span class="toggle-slot"></span><span class="item-label">${item.label}</span>${itemDescription(item.description)}</span></th>`;
1075
+ }
1076
+ return html ` <th scope="row"><span class="item-cell"><span class="toggle-slot"><button class="row-toggle" type="button" hidden data-row-toggle aria-expanded="true" aria-label="Collapse ${item.label} detail rows"></button></span><span class="item-label">${item.label}</span> <span class="collapsed-count" hidden>· ${descendantCount} ${descendantCount === 1 ? "row" : "rows"}</span>${itemDescription(item.description)}</span></th>`;
1077
+ };
1078
+ const appendStatement = (presentation, statementPresentation, sink) => {
1079
+ const { statement, commonUnit, ordinal, anchorId, tableId, copySourceId, copyStatusId, copyHelpId } = statementPresentation;
1080
+ const commonUnitAttribute = commonUnit === undefined
1081
+ ? html ``
1082
+ : html ` data-common-unit="${unitText(commonUnit)}"`;
1083
+ sink.writeLine(html ` <section class="statement" id="${anchorId}">
1084
+ <h2><span class="ordinal">${String(ordinal).padStart(2, "0")}</span>${statement.label}</h2>`);
1085
+ appendTableTools(statementPresentation, sink);
1086
+ sink.writeLine(html ` <p class="overflow-cue">Scroll horizontally to review all columns.</p>
1087
+ <div class="table-scroll" role="region" aria-label="${statement.label} table. Scroll horizontally to review all columns." tabindex="0">
1088
+ <table id="${tableId}" data-statement-table${commonUnitAttribute}>
1089
+ <caption class="table-caption"><span class="caption-inner">${captionTemplate(statementPresentation)}</span></caption>
1090
+ <thead>
1091
+ <tr>
1092
+ <th scope="col" class="col-text">Item</th>`);
1093
+ if (commonUnit === undefined)
1094
+ sink.writeLine(html ` <th scope="col" class="col-text" data-col="unit">Unit</th>`);
1095
+ for (const periodId of statement.periods) {
1096
+ const period = presentation.periods.get(periodId);
1097
+ if (period === undefined)
1098
+ throw new Error("Validated statement lost its period");
1099
+ sink.writeLine(html ` <th scope="col" class="col-num">${periodLabel(period)}</th>`);
1100
+ }
1101
+ sink.writeLine(html ` </tr>
1102
+ </thead>
1103
+ <tbody>`);
1104
+ for (const [index, itemPresentation] of statementPresentation.items.entries()) {
1105
+ const { item, unit } = itemPresentation;
1106
+ for (const headingParent of statementPresentation.headings.get(index) ?? []) {
1107
+ appendHeadingRow(presentation, statementPresentation, headingParent, sink);
1108
+ if (sink.exceeded)
1109
+ return;
1110
+ }
1111
+ sink.writeLine(rowOpening(statementPresentation, index));
1112
+ sink.writeLine(itemCell(statementPresentation, index));
1113
+ if (commonUnit === undefined) {
1114
+ sink.writeLine(html ` <td class="metadata col-text" data-col="unit" data-unit-full="${unitText(unit)}">${unit.label}</td>`);
1115
+ }
1116
+ for (const period of statement.periods) {
1117
+ const value = item.values[period];
1118
+ if (value === undefined)
1119
+ throw new Error("Validated item lost its period value");
1120
+ appendValue(sink, value);
1121
+ }
1122
+ sink.writeLine(html ` </tr>`);
1123
+ if (sink.exceeded)
1124
+ return;
1125
+ }
1126
+ sink.writeLine(html ` </tbody>
1127
+ </table>
1128
+ </div>`);
1129
+ appendChecks(statementPresentation, sink);
1130
+ if (sink.exceeded)
1131
+ return;
1132
+ appendCopySource(presentation, statementPresentation, sink);
1133
+ if (sink.exceeded)
1134
+ return;
1135
+ sink.writeLine(html ` <div class="handoff" data-copy-handoff hidden>
1136
+ <div class="handoff-action">
1137
+ <button class="copy-button" type="button" data-copy-control data-copy-source="${copySourceId}" data-copy-status="${copyStatusId}" aria-describedby="${copyHelpId}" aria-label="Copy ${statement.label} for Excel">Copy for Excel</button>
1138
+ <span class="copy-status" id="${copyStatusId}" role="status" aria-live="polite" aria-atomic="true"></span>
1139
+ </div>
1140
+ <a class="native-table-link" href="#${tableId}">View native table</a>
1141
+ <p class="copy-help" id="${copyHelpId}">Copies the complete statement as tab-separated values, including any rows or columns hidden in this view.</p>
1142
+ </div>
1143
+ </section>`);
1144
+ };
1145
+ const appendNavigation = (presentation, sink) => {
1146
+ sink.writeLine(html ` <nav class="statement-index" aria-label="Statements">
1147
+ <ol>`);
1148
+ for (const { statement, checks, ordinal, anchorId } of presentation.statements) {
1149
+ const issueStatus = checkIssueStatus(checks.map(({ application }) => application));
1150
+ const flag = issueStatus === undefined
1151
+ ? html ``
1152
+ : issueStatus === "unsatisfied"
1153
+ ? html `<span class="check-glyph index-flag" data-status="unsatisfied">≠<span class="visually-hidden"> checks not satisfied</span></span>`
1154
+ : html `<span class="check-glyph index-flag" data-status="error">!<span class="visually-hidden"> checks not checked</span></span>`;
1155
+ sink.writeLine(html ` <li><a href="#${anchorId}"><span class="index-number">${String(ordinal).padStart(2, "0")}</span><span>${statement.label}</span>${flag}</a></li>`);
1156
+ }
1157
+ sink.writeLine(html ` </ol>
1158
+ </nav>`);
1159
+ };
1160
+ const documentChecks = (presentation) => {
1161
+ const { calculations } = presentation;
1162
+ if (calculations.status === "not-defined") {
1163
+ return html `<p class="doc-checks">No rollup checks defined</p>`;
1164
+ }
1165
+ const total = calculations.applications.length;
1166
+ const checksWord = total === 1 ? "check" : "checks";
1167
+ if (calculations.status === "consistent") {
1168
+ return html `<p class="doc-checks"><span class="check-glyph" data-status="satisfied">=</span> ${total} rollup ${checksWord} · all consistent</p>`;
1169
+ }
1170
+ const issueSummary = checkIssueSummary(calculations.applications);
1171
+ if (issueSummary === undefined)
1172
+ throw new Error("Inconsistent calculations lost their issue");
1173
+ const status = checkIssueStatus(calculations.applications);
1174
+ if (status === undefined)
1175
+ throw new Error("Inconsistent calculations lost their status");
1176
+ const glyph = status === "unsatisfied" ? "≠" : "!";
1177
+ const summary = html `<span class="check-glyph" data-status="${status}">${glyph}</span> ${total} rollup ${checksWord} · ${issueSummary}`;
1178
+ // The count links to the first statement whose expanded check disclosure
1179
+ // holds an issue, so the signal is one activation from proof.
1180
+ const failing = presentation.statements.find(({ checks }) => checkIssueStatus(checks.map(({ application }) => application)) !== undefined);
1181
+ if (failing === undefined)
1182
+ return html `<p class="doc-checks">${summary}</p>`;
1183
+ return html `<p class="doc-checks"><a class="doc-checks-link" href="#${failing.checksId}">${summary}</a></p>`;
1184
+ };
1185
+ export const renderTemplate = (presentation, sink) => {
1186
+ const { document } = presentation;
1187
+ sink.writeLine(html `<!doctype html>
1188
+ <html lang="en">
1189
+ <head>
1190
+ <meta charset="utf-8">
1191
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1192
+ <title>${document.entity.name} — ${document.scope.label}</title>
1193
+ <style>
1194
+ ${stylesheet}
1195
+ </style>
1196
+ </head>
1197
+ <body>
1198
+ <a class="skip-link" href="#statements">Skip to statements</a>
1199
+ <div class="document">
1200
+ <header class="masthead">
1201
+ <div>
1202
+ <h1>${document.entity.name}</h1>
1203
+ <p class="scope">${document.scope.label}</p>
1204
+ </div>
1205
+ ${documentChecks(presentation)}
1206
+ </header>`);
1207
+ appendNavigation(presentation, sink);
1208
+ sink.writeLine(html ` <main id="statements" tabindex="-1">`);
1209
+ for (const statement of presentation.statements) {
1210
+ appendStatement(presentation, statement, sink);
1211
+ if (sink.exceeded)
1212
+ return;
1213
+ }
1214
+ sink.writeLine(html ` </main>
1215
+ <footer class="colophon">
1216
+ <p>Generated by the FS reference CLI from a validated FS ${document.formatVersion} document. The FS JSON document is authoritative; this page is a reading and transfer convenience and adds no accounting meaning.</p>
1217
+ </footer>
1218
+ ${behavior}
1219
+ </div>
1220
+ </body>
1221
+ </html>`);
1222
+ };
1223
+ //# sourceMappingURL=template.js.map