@vernikr/size-report 2.6.0 → 2.7.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.
package/README.md CHANGED
@@ -10,7 +10,7 @@ shows.
10
10
 
11
11
  ## Status
12
12
 
13
- **Release 2.6.0 (2026-09-17).** The tool lives as a package of its own: the registry name is
13
+ **Release 2.7.0 (2026-09-17).** The tool lives as a package of its own: the registry name is
14
14
  `@vernikr/size-report` (published by tag from CI, with no secret). A project may keep no settings at
15
15
  all: without a config file the tool derives them from the project itself and says so in one line,
16
16
  and `--init` pins what was derived into a file. The report is **one file**, the self-contained page
@@ -19,6 +19,28 @@ is installed and on the first run. The version is in the manifest, and every rel
19
19
  the journal — `worklog/` for today's entries, `worklog/archive/WORKLOG.md` for the earlier ones:
20
20
  what changes in the numbers is measured rather than retold.
21
21
 
22
+ 2.7.0 is about the page and what a reader does with it, and its figures are measured rather than retold.
23
+ **The report's address stays clean:** a switch writes the record into the browser's memory and nothing into the
24
+ address bar — the page no longer puts a `#size-report=…` tail on the report it was opened with — while a link
25
+ made by an earlier release is still read, and a foreign or broken one is still explained in words. **A test
26
+ is a category of its own**, right after Code: the files of `test`, `tests` and `fixtures` folders wherever
27
+ they stand, and every name carrying `.test` before its extension — on this repository's own history Tests
28
+ holds **47 of 317 columns**, and the checkbox of the documentation is called **Docs**. **The tree opens
29
+ folded**, and the unfolding is what the memory keeps, under a key of its own; inside a level a hidden name —
30
+ one beginning with a dot — stands after every visible one. **The drawing of a switch is measured, and it is one
31
+ task**: the click is counted and answered, a drawing short enough to be over before the browser could paint is
32
+ done on the click, and a longer one happens in the next task with a stripe over the top edge of the window. The
33
+ price of a switch is the browser's own relayout of the table — 234.5 ms of layout for a *single* cell of this
34
+ repository's report (249 rows, 317 columns, 238 thousand cells), 287 ms for 750, 539 ms for 6 000 — while the
35
+ class changes are about 2 µs a node; on this page the whole Table of Contents of the report (73 columns) is
36
+ 867 ms blocked with a 742 ms task. A queue worked off in slices was written first and refused by measurement:
37
+ the same click in 37 slices paid that relayout 37 times — **169 layouts and 151.9 s of pure layout time against
38
+ 1.04 s**, with the tab growing to gigabytes of repaint and the reader waiting four minutes for a switch that
39
+ costs him less than a second unsliced. Within one task nothing can repaint, so a bar that filled would be a bar
40
+ nobody could read: the stripe travels and says the work is going on (`probes/step-12-columns.mjs`, the records in
41
+ `worklog/0207-page-choice-bar.md`). The order of the columns did not change there: what the last commit touched
42
+ still stands first, the rest after it in the settings' order.
43
+
22
44
  2.6.0 changes the numbers themselves, and they are measured rather than retold: on this repository's own
23
45
  history the page this engine writes is **85 955 B against 1 899 370 B** written by 2.5.0 — 22.1 times
24
46
  smaller, −95.5 % — because the data travels as one packed block (49 392 B, `base64+gzip`) instead of a list
@@ -76,7 +98,8 @@ selection" are impossible in principle. The contract tells how each number was o
76
98
  no rule of counting of its own, and the split of numbers into exact and approximate was taken out of the
77
99
  package. The page's panel is a
78
100
  tree of files by folder, with a switch per folder for the whole subtree; a reader's choice survives
79
- a revisit and travels in a linkthe page's address is the link. The contract carries the **project
101
+ a revisit in the browser's memory, and the address keeps a clean tail an address with a link of its
102
+ own is read and never written (release 2.7.0). The contract carries the **project
80
103
  catalogue** too: every path git sees, so the page's tree is the project's tree, while numbers exist
81
104
  only for the files that became columns (release 2.2.0).
82
105
 
@@ -287,7 +310,9 @@ choice. The report itself is always in the catalogue, whether or not it is track
287
310
  of the moment, and the page must not depend on it, or the first rebuild in a fresh clone would give
288
311
  different bytes. A folder whose files are only partly in the report writes its count as a fraction
289
312
  ("2/5"), and everything outside the report stands after everything inside it — folders and leaves
290
- alike — so that the report is seen at once in a list where half the rows do not switch
313
+ alike — so that the report is seen at once in a list where half the rows do not switch. The name of a level
314
+ holds a second rule: a hidden name — one beginning with a dot — stands after every visible one, the alphabet
315
+ deciding the rest, so the service files of a project do not stand in the reader's way
291
316
  (`test/page-tree.test.js`).
292
317
 
293
318
  **A folder is a switch like a file, and its sign is a decision of its own.** The checkbox of a folder
@@ -296,8 +321,10 @@ next to it. Neither a folder nor a category button keeps state of its own: both
296
321
  checkboxes, so the tree, the buttons and the table cannot drift apart. The sign beside a folder answers
297
322
  a different question — how much of the tree is visible, which is the onlooker's business rather than the
298
323
  reader's choice — so it is remembered between visits in a record of its own, under a key of its own and
299
- the same report passport, and it never goes into the link; unfolding every folder removes that record,
300
- just as turning the checkboxes back on removes the choice.
324
+ the same report passport, and it never goes into a link. The tree opens **folded**: a project's tree is
325
+ longer than the window, and the reader looks at a short list first, so what the record keeps is the
326
+ unfolding rather than the folding — folding every folder back removes the record, just as turning the
327
+ checkboxes back on removes the choice.
301
328
 
302
329
  **The list scrolls, and there is one scroll.** On a narrow window it is the file list that scrolls —
303
330
  the panel grows with the page there — while on a wide one the whole panel does: otherwise the controls
@@ -309,6 +336,22 @@ row hides it, so a click on the sign changes exactly the three things the reader
309
336
  sign and the note in the memory. What guards this is that after folding the table is the same markup
310
337
  rather than a rebuilt one (`test/page-tree.test.js`).
311
338
 
339
+ **A switch draws its columns in one task, with a stripe over the page while it is going on.** The class
340
+ changes are cheap — about 2 µs a node, so a category of this repository's report (73 columns, 55 042 nodes) is
341
+ 120 ms of them — while the browser lays the whole table out again for any change of a column's visibility, and
342
+ that is where the seconds are (`probes/step-12-columns.mjs`). So `src/page/work.js` asks for the drawing once:
343
+ work short enough to be over before the browser could paint goes on the click itself, and a longer one goes to
344
+ the next task with the stripe over the top edge of the window. **The stripe carries no share, and that is the
345
+ measurement rather than a shortcut**: inside one task the browser cannot repaint, so a length would be a length
346
+ nobody could keep; what moves is an indeterminate highlight. A queue worked off between timeouts was written
347
+ first and refused by the same probe — 37 slices paid the table's relayout 37 times (2.5–4 s each, 169 layouts
348
+ against 1) — and the numbers stay out of all of it, because the totals are counted on the click itself, where
349
+ they cost arithmetic rather than nodes. Nothing is asked for a column that is already right either
350
+ (`appColumnStale`): a report opened with everything switched on has nothing to draw, and a record from the
351
+ memory or a link queues exactly the columns that differ from it. What guards the two halves is
352
+ `test/page-choice.test.js` — a long drawing that leaves the click's line at once, runs in the next task and
353
+ takes the stripe away, and a short one that is over before the click returns.
354
+
312
355
  **A click shows and hides rather than builds.** The table is assembled once, with every column of every
313
356
  file, and a switch afterwards changes only what is visible: a metric is one class on the table plus the
314
357
  `colSpan` of the group headings, a file's column is a class per node of it, and a folder or a category is
@@ -372,17 +415,18 @@ neither the numbers nor the markup. The passport holds neither the tool's versio
372
415
  history, and on purpose: updating the tool does not change what a column means, while a grown history is
373
416
  the very history the reader comes back to.
374
417
 
375
- **The same choice travels as a link.** The page's address is the link: the record that goes into the
376
- browser's memory goes into the anchor too (`#size-report=…`), so the sender copies the address and the
377
- recipient sees that choice with no action at all. The link outranks the memory it is the sender's
378
- explicit choice while it does not replace the reader's own until he changes something. A foreign or
379
- broken address is not applied, and is not silent either: a line above the table names the reason ("the
380
- link was made in another report" / "the choice in the address is unreadable"), the view stays the
381
- reader's own, and the incoming address is not rewritten; names the report does not hold are reported by
382
- count, skipped, and the rest is applied. The link works on an already open page as well: the browser does
383
- not reload the document when the anchor changes, so the page reads the address itself, or a link would
384
- only work in a new tab. The page makes no request to the network at all, and that is an assertion of a
385
- check rather than a promise (`test/page-view.test.js`, `test/parity.test.js`).
418
+ **The link is read and never written.** The page's address is the report's own: a choice lives in the
419
+ browser's memory, and the page puts nothing into the address bar a report opened from disk keeps the
420
+ address it was opened with, and not one switch lands in the tab's title bar. What an earlier release sent
421
+ as a link is still read, though the record in `#size-report=…`, in the same shape the memory holds it —
422
+ and such a link outranks the memory while it does not replace the reader's own until he changes something.
423
+ A foreign or broken address is not applied, and is not silent either: a line above the table names the
424
+ reason ("the link was made in another report" / "the choice in the address is unreadable"), the view stays
425
+ the reader's own, and the incoming address is not rewritten; names the report does not hold are reported
426
+ by count, skipped, and the rest is applied. The link works on an already open page as well: the browser
427
+ does not reload the document when the anchor changes, so the page reads the address itself, or a link
428
+ would only work in a new tab. The page makes no request to the network at all, and that is an assertion of
429
+ a check rather than a promise (`test/page-view.test.js`, `test/parity.test.js`).
386
430
 
387
431
  **The `min` metric can count for real.** The way of counting is chosen in the settings:
388
432
  `"minify": {"engine": "esbuild"}` minifies JS/TS/CSS for real through an optional dependency, while
@@ -516,11 +560,12 @@ acceptance for each.
516
560
  | `src/table.css` | The report's table: the geometry of a cell, the sticky header and commit column, the colour of deltas |
517
561
  | `src/page/app.css` | The page's styling on top of the shared part: the panel with the file tree and its sticky row of categories (a column on the left on a wide screen, the page fitting the window), the empty states, a narrow window |
518
562
  | `src/page/payload.js` | The page's block in sparse form, and the one place that unrolls it back: the history as changes (a file's appearance, its moves, its disappearance) turned into the snapshots the calculation and the table already speak — a value that did not move is one object shared by the rows that hold it |
519
- | `src/page/state.js` | The page's state: the report's data (the block unrolled by the payload chapter), the view of the checkboxes, the pointer "which path is which column", folded folders, the record's passport, the browser's memory and the exchange by link — a chapter of the page's program |
563
+ | `src/page/state.js` | The page's state: the report's data (the block unrolled by the payload chapter), the view of the checkboxes, the pointer "which path is which column", the unfolded folders, the record's passport, the browser's memory and the link that is read out of the address — a chapter of the page's program |
520
564
  | `src/page/dom.js` | The page's nodes: the small helpers of markup (`appEl`, `appBox`) — one set for the panel and the table alike |
521
- | `src/page/panel.js` | The panel of choices: the switches of metrics and files, the categories, the tree of the project's paths (files outside the report keep a checkbox off with a reason and stand after the rest; folders carry a folding sign that hides the subtree by a class rather than by a rebuild); built once, with the fields of the switches and of the folders and categories written where they stand |
522
- | `src/page/table.js` | The page's table, built once: a cell, a commit's caption, the header, the empty states and the cache of the nodes of every column — markup over the shared calculation, with the totals carried rather than recounted |
523
- | `src/page/app.js` | Assembling and starting the page: the first drawing, then a switch that shows, hides and rewrites the totals without making a node; an anchor change; pasted into the assembled page |
565
+ | `src/page/panel.js` | The panel of choices: the switches of metrics and files, the categories, the tree of the project's paths (files outside the report keep a checkbox off with a reason and stand after the rest, and a hidden name after every visible one; the tree opens folded and folders carry a sign that hides the subtree by a class rather than by a rebuild); built once, with the fields of the switches and of the folders and categories written where they stand |
566
+ | `src/page/table.js` | The page's table, built once: a cell, a commit's caption, the header, the empty states and the cache of the nodes of every column — markup over the shared calculation, with the totals carried rather than recounted, and the count answerable for how many nodes a column holds |
567
+ | `src/page/work.js` | The page's long drawing: the columns of a switch in one task, on the click when they are few and in the next task with an indeterminate stripe over the top edge of the window when they are many — one task because the browser's relayout of the table is the price, and it is paid once (this is where the measurement that refused the slices stands) |
568
+ | `src/page/app.js` | Assembling and starting the page: the first drawing, then a switch that counts the numbers on the click, hands the columns to the queue and writes the fields it reached without making a node; an anchor change; pasted into the assembled page |
524
569
  | `src/page/build.js` | Assembling the page: data, styling and program in one file with no external references — the pasted text is **squeezed** on the way in (comments and indentation out, the same stripping the `min` metric counts) while the sources keep them, and the result is guarded by the stripper's own `assertCompilable` |
525
570
  | `src/git.js` | The only border where git is called: the pinned settings, blobs by the batch, the history, the comparison with the working tree |
526
571
  | `src/strip.js` | Removing ballast: which form goes to which file (extension, strategy) and which strategies are minification itself — the entry to the parsing of forms |
@@ -541,7 +586,7 @@ acceptance for each.
541
586
  | `bin/postinstall.js` | Installing the hook after the package is added: it looks for the consumer project and stays silent when there is nowhere to install |
542
587
  | `src/artifact.js` | The report on disk: the only place where it becomes a file (both `--write` and the hook use it); the report is a self-contained page |
543
588
  | `src/journal.js` | The journal and links: which section a commit belongs to and where a description leads |
544
- | `src/data.js` | The file categories and the contract with the page (`--data`): the numbers, the shape of the table and the catalogue of the project's paths |
589
+ | `src/data.js` | The file categories (a test by its path — a `test`, `tests` or `fixtures` folder, or `.test` before the extension — otherwise the extension, and the settings above both) and the contract with the page (`--data`): the numbers, the shape of the table and the catalogue of the project's paths |
545
590
  | `src/config.js` | The consumer project's settings: the defaults, reading them, checking them |
546
591
  | `src/project.js` | The settings derived from the project itself (its tree and history): columns, the journal, the exceptions, the catalogue of paths for the page's tree. Without a settings file it *is* the settings; `--init` pins it as a file |
547
592
  | `src/locales.js`, `src/refusal.js`, `src/tool.js` | The report's texts; the exit codes and the help; the package's name and version |
@@ -569,8 +614,8 @@ acceptance for each.
569
614
  | `test/contract-data.test.js` | The data contract: the numbers against the reference, the set of fields against the derived quantities, the metric's method against the way the numbers were counted — and the round trip through the page's sparse block, which restores the contract whole and twice over the same bytes |
570
615
  | `test/contract-derived.test.js` | The derived quantities against the artifact's numbers: a row's totals, a cell's delta and the delta of a total — on the code that lies in the tree |
571
616
  | `test/page-view.test.js` | The assembled page: pasted with no copy of the calculation, self-contained, the empty states, the styling, the switches, a click that makes no table and the carried totals against the engine's own sums |
572
- | `test/page-tree.test.js` | The panel's file tree: folders by the project's paths, three states, the subtree, files and folders outside the report (a checkbox off, a place after the rest), folding without a rebuild and a scroll a click does not touch |
573
- | `test/page-choice.test.js` | The memory of the choice and the exchange by link: a revisit, someone else's report, a foreign and a broken record, an address change on an open page |
617
+ | `test/page-tree.test.js` | The panel's file tree: folders by the project's paths, three states, the subtree, files and folders outside the report (a checkbox off, a place after the rest), the hidden names at the end of a level, the tree that opens folded and the unfolding the memory keeps, folding without a rebuild and a scroll a click does not touch |
618
+ | `test/page-choice.test.js` | The reader's choice and the work a click starts: the memory and a revisit, someone else's report, a foreign and a broken record, an address that stays clean, a link read at opening and on an open page, and the drawing of a long switch in a task of its own behind the stripe |
574
619
  | `test/page-cols.test.js` | The fixed layout the page carries: a column's width is counted from the model rather than measured in a laid-out cell, the clip keeps a caption inside its column, the sticky header and commit column keep their edges, and the table names its own width (`width: auto` would hand the layout back to the automatic algorithm) |
575
620
  | `test/module.test.js` | A module under a `.js` extension: measured without touching the settings; the stripper's guard is alive (proved by mutation) and does not accuse the innocent |
576
621
  | `test/guard.test.js` | Parsing a module: it goes through a thread, both paths give one verdict, the fallback works with the thread's file away, and hundreds of parses are cheaper than a launch |
@@ -651,7 +696,7 @@ The same release can be taken by a reference to the repository — installation
651
696
  registry, but stays tied to a revision:
652
697
 
653
698
  ```bash
654
- pnpm add -D github:vernikr/size-report#v2.6.0
699
+ pnpm add -D github:vernikr/size-report#v2.7.0
655
700
  ```
656
701
 
657
702
  With no network (or nothing to fetch from codeload) — the tarball: `pnpm pack` in the package clone, then
@@ -663,7 +708,7 @@ the branch moves on the installation fails with `Could not resolve <sha> to a co
663
708
  observation rather than reasoning: the short pin `6530237` installed while `main` stood on it and stopped
664
709
  working at the very next commit, while the same sha in full installed. A branch name (`#main`) and a tag
665
710
  are both accepted, but a branch is a moving target and a tag is constant: this release stands on the tag
666
- `v2.6.0`, which is also the one in the example (forty characters work as well, but they have to be copied
711
+ `v2.7.0`, which is also the one in the example (forty characters work as well, but they have to be copied
667
712
  out of the history by eye).
668
713
 
669
714
  The revision in the example is a part of the claim rather than decoration: what is described below is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vernikr/size-report",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "author": "vernikr",
5
5
  "repository": {
6
6
  "type": "git",
@@ -65,7 +65,7 @@
65
65
  "report"
66
66
  ],
67
67
  "devDependencies": {
68
- "@vernikr/size-report": "2.5.0",
68
+ "@vernikr/size-report": "2.6.0",
69
69
  "c8": "10",
70
70
  "dependency-cruiser": "17",
71
71
  "eslint": "^9.18.0",
package/src/data.js CHANGED
@@ -10,19 +10,35 @@ import { TOOL_PKG } from './tool.js';
10
10
  * derived quantity at all. Everything the page counts itself begins where this module ends. */
11
11
 
12
12
  /* A file's category is only for the page's quick on/off buttons for a group: it does not reach the
13
- * numbers. The rule is one: the extension gives the category, everything else counts as code; a category
14
- * set in the column's settings outranks that rule, and the data says where it came from (`categoryBy`) — a
15
- * manual decision is explainable, while a table of extensions is a guess by file name. */
13
+ * numbers. The rule is: a category set in the column's settings outranks everything, a test is
14
+ * recognised by its path rather than by its extension, and for the rest the extension gives the
15
+ * category while everything else counts as code. The data says where the category came from
16
+ * (`categoryBy`) — a manual decision is explainable, while a table of extensions is a guess by file
17
+ * name. */
16
18
  export const CATEGORY_EXTS = {
17
19
  docs: ['.md', '.markdown', '.rst', '.txt', '.adoc'],
18
20
  chore: ['.json', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.lock', '.editorconfig'],
19
21
  assets: ['.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.woff', '.woff2', '.ttf', '.otf']
20
22
  };
21
- export const CATEGORY_ORDER = ['code', 'docs', 'chore', 'assets'];
23
+ /* Tests stand next to code: they are files of the same kind (`*.test.js` is JavaScript too), and a reader
24
+ * switching code off wants to see what the tests weigh separately. */
25
+ export const CATEGORY_ORDER = ['code', 'tests', 'docs', 'chore', 'assets'];
26
+
27
+ /* What makes a file a test: the folder it lies in, wherever that folder is in the project, or a name
28
+ * carrying `.test` before its extension. Both are read off the path rather than off the extension, which is
29
+ * how a test is recognised in practice — a test may be written in any language the tool can measure. */
30
+ const TEST_DIRS = ['test', 'tests', 'fixtures'];
31
+ const TEST_NAME = /\.test\.[^./]+$/;
32
+
33
+ function isTest(p) {
34
+ return p.split('/').some((part) => TEST_DIRS.indexOf(part) >= 0) || TEST_NAME.test(path.basename(p));
35
+ }
22
36
 
23
37
  export function categoryOf(col) {
24
38
  if (col.category) return { key: col.category, by: 'config' };
25
- const ext = path.extname(col.paths[col.paths.length - 1]).toLowerCase();
39
+ const last = col.paths[col.paths.length - 1];
40
+ if (isTest(last)) return { key: 'tests', by: 'auto' };
41
+ const ext = path.extname(last).toLowerCase();
26
42
  const known = CATEGORY_ORDER.find((key) => (CATEGORY_EXTS[key] || []).indexOf(ext) >= 0);
27
43
  return { key: known === undefined ? 'code' : known, by: 'auto' };
28
44
  }
package/src/locales.js CHANGED
@@ -9,7 +9,7 @@ export const LOCALES = {
9
9
  commit: 'Коммит',
10
10
  total: 'Общий объём',
11
11
  now: 'сейчас',
12
- categories: { code: 'Код', docs: 'Документация', chore: 'Служебные', assets: 'Ресурсы' },
12
+ categories: { code: 'Код', tests: 'Тесты', docs: 'Документация', chore: 'Служебные', assets: 'Ресурсы' },
13
13
  /* The page texts live inside the report file itself (a dictionary of their own, next to
14
14
  * the data), so changing a word costs a rebuild of the report — otherwise the file
15
15
  * drifts from the history. */
@@ -53,6 +53,9 @@ export const LOCALES = {
53
53
  * таблица не сказала бы читателю ничего. */
54
54
  unpack: 'Данные отчёта сжаты, а браузер не умеет их распаковать: в нём нет DecompressionStream. '
55
55
  + 'Обновите браузер — таблицу показывать не из чего.',
56
+ /* Полоска вверху страницы: имя для тех, кто читает страницу не глазами, — она показывает,
57
+ * что идёт отрисовка колонок, а не что отчёт завис. */
58
+ working: 'отрисовка колонок',
56
59
  note: 'Абсолютные размеры стоят один раз — в строке «{now}». Строк: {rows}: коммиты без '
57
60
  + 'изменения объёма и коммиты, тронувшие только выключенные файлы, строк не получают. '
58
61
  + 'Собрано из истории git: {command}.'
@@ -80,7 +83,7 @@ export const LOCALES = {
80
83
  commit: 'Commit',
81
84
  total: 'Total',
82
85
  now: 'now',
83
- categories: { code: 'Code', docs: 'Documentation', chore: 'Chores', assets: 'Assets' },
86
+ categories: { code: 'Code', tests: 'Tests', docs: 'Docs', chore: 'Chores', assets: 'Assets' },
84
87
  page: {
85
88
  metrics: 'Metrics',
86
89
  files: 'Files',
@@ -113,6 +116,9 @@ export const LOCALES = {
113
116
  * browser without `DecompressionStream` gets instead of an empty table. */
114
117
  unpack: 'The report\u2019s data is compressed and this browser cannot unpack it: there is no '
115
118
  + 'DecompressionStream. Update the browser — there is nothing to build the table from.',
119
+ /* The bar at the top of the page: a name for whoever reads the page without eyes — it shows
120
+ * that the columns are being drawn rather than that the report has hung. */
121
+ working: 'drawing the columns',
116
122
  note: 'Absolute sizes appear once — in the “{now}” row. Rows: {rows}: commits that change '
117
123
  + 'no number, and commits touching only the disabled files, get no row. '
118
124
  + 'Collected from git history: {command}.'
package/src/page/app.css CHANGED
@@ -127,6 +127,33 @@ h1 { margin: 0 0 3px; font-size: 21px; font-weight: 650; letter-spacing: -.012em
127
127
  /* Numbers are denser than the page's text: there are more of them, they are shorter, and they are read by their digits. */
128
128
  #grid { font-size: 12.5px; }
129
129
 
130
+ /* The stripe of a long drawing: over the window's own top edge for as long as a switch is being drawn (`src/page/work.js`).
131
+ * It is fixed rather than laid out — the page's shape is a grid of five rows, and a stripe appearing inside it would move the
132
+ * numbers every time a switch is drawn — and it is taken away as soon as the drawing is over, so the reader never sees it at
133
+ * rest. It **does not fill**: the drawing is one task of the browser's own layout, inside which nothing can repaint, so a
134
+ * share would be a share nobody has (`src/page/work.js` says what was measured). What moves instead is an indeterminate
135
+ * stripe — a travelling highlight, which is what this state is in the language of progress — and it moves by a transform,
136
+ * so the stripe itself costs the compositor rather than the page. Its colour is the system accent, the one colour the page
137
+ * uses for anything but the numbers, and it carries a role and a name, because a reader without eyes has to be told that
138
+ * work is going on rather than that the report is broken. */
139
+ .bar { position: fixed; top: 0; left: 0; right: 0; z-index: 5; height: 3px; overflow: hidden; }
140
+ .bar[hidden] { display: none; }
141
+ .bar > i {
142
+ display: block;
143
+ width: 30%;
144
+ height: 100%;
145
+ background: AccentColor;
146
+ animation: bar-run 1.1s linear infinite;
147
+ }
148
+ @keyframes bar-run {
149
+ from { transform: translateX(-100%); }
150
+ to { transform: translateX(333%); }
151
+ }
152
+ /* Whoever asked the system for less motion gets a still stripe: the state is what matters, not its travelling. */
153
+ @media (prefers-reduced-motion: reduce) {
154
+ .bar > i { animation: none; width: 100%; }
155
+ }
156
+
130
157
  /* The message about a link that came in stands above the table, so that it cannot be missed, while it does not push the
131
158
  * table away: one line in the place of the page. Its colour is the system one (the accent), for the page keeps no colours
132
159
  * of its own. */
package/src/page/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { appUnpack } from './payload.js';
2
- import { appAddressDrop, appApply, appBoot, appData, appFoldRead, appLinkUse, appNotice, appRead, appUi, appView, appWrite } from './state.js';
3
- import { appColumn, appContribute, appMetrics, appState, appTable, appTotals, appTotalsReset } from './table.js';
2
+ import { appApply, appBoot, appData, appFoldRead, appLinkUse, appNotice, appRead, appUi, appView, appWrite } from './state.js';
3
+ import { appColumn, appColumnSize, appColumnStale, appContribute, appMetrics, appState, appTable, appTotals, appTotalsReset } from './table.js';
4
+ import { appDraw } from './work.js';
4
5
  import { appPanel, appPanelAll, appPanelState } from './panel.js';
5
6
 
6
7
  /* Assembling the report: the table is built once (`appTable` of the table chapter) and everything afterwards only
@@ -10,12 +11,32 @@ import { appPanel, appPanelAll, appPanelState } from './panel.js';
10
11
  *
11
12
  * Hence two paths and no third: `appPaint` draws the whole view (the first drawing, a record from the browser's
12
13
  * memory, a link in the address), while `appSwitch`, `appSwitchGroup` and `appSwitchMetric` are what one click on a
13
- * box does. Neither makes a node.
14
+ * box does. The numbers are counted on the click itself — that is arithmetic over the data — while the nodes of the
15
+ * columns are handed to the work chapter (`appDraw`), which draws the short work on the click and the long one in the
16
+ * next task with a stripe over the page (`src/page/work.js` says what was measured: the price of a switch is the
17
+ * browser's own relayout of the table, and it is paid once here rather than once per slice). Neither path makes a
18
+ * node.
14
19
  */
15
20
 
16
21
  // The table's cache of node references: made once, at the first drawing.
17
22
  let appCache = null;
18
23
 
24
+ /* One file's column drawn from the view — the state is read when the slice runs rather than kept from the click that
25
+ * queued it, so a click that arrives while the queue is running is drawn by the next slice rather than after it. */
26
+ function appDrawColumn(i) {
27
+ appColumn(appCache, i, appView.files[i] === true);
28
+ }
29
+
30
+ /* The columns of a switch that have to be drawn, as the units the work chapter weighs (`{i, units}` — the file and the
31
+ * nodes of its column). Only the columns whose nodes are out of step with the view are asked for (`appColumnStale`): a
32
+ * report opened with everything switched on has nothing to draw, and a column that is already right would cost its
33
+ * nodes again. The count is the table's (`appColumnSize`), because the table is the only place that knows how many
34
+ * nodes a column has. */
35
+ function appColumns(indexes) {
36
+ const todo = indexes.filter((i) => appColumnStale(appCache, i));
37
+ appDraw(appDrawColumn, todo.map((i) => ({ i: i, units: appColumnSize(appCache, i) })));
38
+ }
39
+
19
40
  /* The note under the table: what a row is and how the report was made. It does not depend on the choice, so it is
20
41
  * written once — with the table rather than with every drawing of it. */
21
42
  function appNote() {
@@ -34,7 +55,7 @@ export function appCounts() {
34
55
  /* The whole view drawn: every column, the totals of the whole selection, the metrics, the empty states and the panel's
35
56
  * fields. This is what a link, a record from the memory and the first drawing need — and it makes no node either. */
36
57
  export function appPaint() {
37
- appData.files.forEach((_f, i) => appColumn(appCache, i, appView.files[i]));
58
+ appColumns(appData.files.map((_f, i) => i));
38
59
  appTotalsReset(appCache);
39
60
  appMetrics(appCache);
40
61
  appCounts();
@@ -42,28 +63,29 @@ export function appPaint() {
42
63
  appWrite();
43
64
  }
44
65
 
45
- /* One file switched by the reader: the view, its column, its share of the totals and the fields it shows in — each in
46
- * its own place. The message about a link fades here: by this action the reader has read it. */
66
+ /* One file switched by the reader: the view, its share of the totals, the fields it shows in and its column the
67
+ * last through the queue, because a column may be long. The message about a link fades here: by this action the
68
+ * reader has read it. */
47
69
  export function appSwitch(i, on) {
48
70
  if (appView.files[i] === on) return;
49
71
  appView.files[i] = on;
50
- appColumn(appCache, i, on);
51
72
  appContribute(appCache, i, on);
52
73
  appTotals(appCache);
53
74
  appCounts();
54
75
  appPanelState([i]);
55
76
  appWrite();
56
77
  appNotice('');
78
+ appColumns([i]);
57
79
  }
58
80
 
59
81
  /* A group switched at once — a folder or a category: the same work per file, then the totals once and the fields of
60
82
  * the files the choice really reached (switching a folder on when a part of it was already on touches only the rest,
61
- * and a field that did not move is not written). */
83
+ * and a field that did not move is not written). The columns of the whole group go into the queue together, so the bar
84
+ * counts them as one piece of work. */
62
85
  export function appSwitchGroup(indexes, on) {
63
86
  const touched = indexes.filter((i) => appView.files[i] !== on);
64
87
  touched.forEach((i) => {
65
88
  appView.files[i] = on;
66
- appColumn(appCache, i, on);
67
89
  appContribute(appCache, i, on);
68
90
  });
69
91
  appTotals(appCache);
@@ -71,6 +93,7 @@ export function appSwitchGroup(indexes, on) {
71
93
  appPanelState(touched);
72
94
  appWrite();
73
95
  appNotice('');
96
+ appColumns(touched);
74
97
  }
75
98
 
76
99
  /* One metric switched: a class on the table and the headings' `colSpan`. The totals do not move with a metric — they
@@ -110,19 +133,18 @@ async function appBegin() {
110
133
  return;
111
134
  }
112
135
  const appStart = appLinkUse();
113
- if (appStart === 'ours') appTransient = true;
114
- else if (appStart === 'refused') appForeign = true;
136
+ /* A link that came in is the sender's choice rather than the reader's: while it is drawn, the memory is not touched
137
+ * (`appWrite`), so opening a link does not make it the reader's own. */
138
+ appTransient = appStart === 'ours';
115
139
  if (appStart !== 'ours') {
116
140
  const appSaved = appRead();
117
141
  if (appSaved !== null) appApply(appSaved);
118
142
  }
119
- /* The folded tree is the onlooker's memory rather than the reader's choice: it comes back even when someone
143
+ /* The unfolded tree is the onlooker's memory rather than the reader's choice: it comes back even when someone
120
144
  * else's link is open (otherwise a link sent over would unfold the tree again on every visit). */
121
145
  appFoldRead();
122
146
  appFirst();
123
147
  appBooted = true;
124
- appStartup = false;
125
- appForeign = false;
126
148
  appTransient = false;
127
149
  }
128
150
 
@@ -138,13 +160,10 @@ window.addEventListener('hashchange', () => {
138
160
  /* The first drawing has not happened yet: the address the page was opened with is the business of that drawing,
139
161
  * and a change that arrives before it has drawn nothing to replace. */
140
162
  if (!appBooted) return;
141
- /* An address that came in from outside is read first and a write this page was still holding is dropped: the reader
142
- * has the address they were sent, not the one the previous click armed (see `appAddressDrop`). */
143
- appAddressDrop();
144
- const state = appLinkUse();
145
- if (state === 'refused') appForeign = true;
146
- appTransient = state === 'ours';
163
+ /* An address that came in from outside is read here the way it is read at opening, and a link is not the reader's
164
+ * choice until he changes something — the memory stays his own. The address itself is never rewritten: the page has
165
+ * no business in the tab's title bar, and what a link holds is read rather than made. */
166
+ appTransient = appLinkUse() === 'ours';
147
167
  appPaint();
148
- appForeign = false;
149
168
  appTransient = false;
150
169
  });
package/src/page/build.js CHANGED
@@ -43,7 +43,7 @@ export function pageSource(file) {
43
43
  /* The list of chapters lives here rather than in the tests: one copy for the builder and for the guard
44
44
  * (`test/page-view.test.js` reads the same program and compares it with the sources). The payload chapter comes
45
45
  * first of the page's own: it is what turns the block into the data everything else reads. */
46
- export const PAGE_PARTS = ['./payload.js', './state.js', './dom.js', './panel.js', './table.js', './app.js'];
46
+ export const PAGE_PARTS = ['./payload.js', './state.js', './dom.js', './work.js', './panel.js', './table.js', './app.js'];
47
47
 
48
48
  /* The form the artifact carries: the same stripping the `min` metric counts (`src/strip.js`) — comments out,
49
49
  * indentation and blank lines out — applied to what is pasted, while the sources on disk keep everything: they are
@@ -255,6 +255,11 @@ export function pageHtml(data, cfg) {
255
255
  + squeezedCss(TABLE_CSS) + '\n' + squeezedCss(PAGE_CSS) + '\n</style>\n</head>\n<body>\n'
256
256
  + '<header>\n<h1>' + esc(data.report.heading) + '</h1>\n'
257
257
  + '<p class="sub">' + esc(subText(data, loc.page)) + '</p>\n</header>\n'
258
+ /* The stripe of the drawing stands first in the page and is fixed to the window's top edge rather than laid out
259
+ * with the rest: the page's shape is a grid of five rows (`src/page/app.css`), and a stripe that appeared and
260
+ * disappeared inside it would move the numbers under the reader's eyes every time a switch is drawn. It holds an
261
+ * empty child rather than a share: the length of an indeterminate stripe is the styling's business. */
262
+ + '<div id="bar" class="bar" role="progressbar" aria-label="' + esc(loc.page.working) + '" hidden><i></i></div>\n'
258
263
  + '<div id="panel" class="panel"></div>\n'
259
264
  + '<p id="notice" class="notice" hidden></p>\n'
260
265
  + '<div id="shell" class="shell"><table id="grid"></table></div>\n'
package/src/page/panel.js CHANGED
@@ -66,27 +66,41 @@ function appDirHead(name, here, sub) {
66
66
  return head;
67
67
  }
68
68
 
69
+ /* The order inside one level, and the two rules of it: a hidden name (a leading dot) stands after every visible one —
70
+ * a project's service files are not what a reader looks for first — and otherwise the alphabet decides. "Other things
71
+ * being equal" is the whole of it: what the report holds stands before what it does not, and that partition is made
72
+ * before the names are compared. The rule is a function of two strings, which is what lets it be checked on its own
73
+ * rather than through a tree of a fixture that has no hidden files in it. */
74
+ export function appName(a, b) {
75
+ const hidden = (name) => (name.charAt(0) === '.' ? 1 : 0);
76
+ if (hidden(a) !== hidden(b)) return hidden(a) - hidden(b);
77
+ return a < b ? -1 : (a > b ? 1 : 0);
78
+ }
79
+
69
80
  /* The folder's sign is a click target of its own, separate from the checkbox: the checkbox answers for the numbers (it
70
81
  * switches the subtree's files on), while the sign answers for how much of the tree is visible. One target for two
71
82
  * different decisions would mean a folder can be folded only together with switching its files on. The sign is drawn
72
83
  * as a span rather than a button and stands beside the label rather than inside it: a label is one click target, and a
73
84
  * control nested in it would be reached as that same target.
74
85
  *
86
+ * The tree opens folded — the sign of an untouched folder says so — and the reader's unfolding is what the memory
87
+ * keeps (`appFoldSet`).
88
+ *
75
89
  * A click on the sign rebuilds nothing: the subtree lies in the markup and a class on the row hides it. A rebuild here
76
90
  * would be honest work for nothing — it counts the whole table (every row by every column) and so pays for numbers
77
91
  * folding does not change. That is why only the three things the reader sees change: the class, the sign and the note
78
92
  * in the memory. */
79
93
  function appFoldBox(name, path) {
80
- const folded = appView.folded[path] === true;
81
- const box = appEl('span', 'fold', folded ? '' : '');
82
- box.title = (folded ? appUi.foldOpen : appUi.foldClose).replace('{name}', name);
94
+ const open = appView.open[path] === true;
95
+ const box = appEl('span', 'fold', open ? '' : '');
96
+ box.title = (open ? appUi.foldClose : appUi.foldOpen).replace('{name}', name);
83
97
  box.addEventListener('click', () => {
84
- const now = !(appView.folded[path] === true);
98
+ const now = !(appView.open[path] === true);
85
99
  appFoldSet(path, now);
86
100
  const li = box.closest('li');
87
- if (li !== null) li.classList.toggle('folded', now);
88
- box.textContent = now ? '' : '';
89
- box.title = (now ? appUi.foldOpen : appUi.foldClose).replace('{name}', name);
101
+ if (li !== null) li.classList.toggle('folded', !now);
102
+ box.textContent = now ? '' : '';
103
+ box.title = (now ? appUi.foldClose : appUi.foldOpen).replace('{name}', name);
90
104
  });
91
105
  return box;
92
106
  }
@@ -98,14 +112,14 @@ function appLeaves(node) {
98
112
  node.others.forEach((entry) => {
99
113
  items.push({ name: entry.path.split('/').pop(), i: null, entry: entry });
100
114
  });
101
- return items.sort((a, b) => (a.name < b.name ? -1 : (a.name > b.name ? 1 : 0)));
115
+ return items.sort((a, b) => appName(a.name, b.name));
102
116
  }
103
117
 
104
118
  /* A folder row: the folding sign, the checkbox with the number of files and the subtree. A folded folder differs by
105
119
  * its class alone — the markup stays the same. */
106
120
  function appDir(name, sub, prefix) {
107
121
  const here = prefix === '' ? name : prefix + '/' + name;
108
- const folded = appView.folded[here] === true;
122
+ const folded = appView.open[here] !== true;
109
123
  const li = appEl('li', folded ? 'folded' : null);
110
124
  li.appendChild(appFoldBox(name, here));
111
125
  li.appendChild(appDirHead(name, here, sub));
@@ -126,7 +140,7 @@ function appLeaf(leaf) {
126
140
  * it does not distract from what is in the table, while it can still be found — in the same place where it was. */
127
141
  function appTreeList(node, prefix) {
128
142
  const list = appEl('ul', 'tree');
129
- const dirs = [...node.dirs.keys()].sort()
143
+ const dirs = [...node.dirs.keys()].sort(appName)
130
144
  .map((name) => ({ name: name, sub: node.dirs.get(name), inReport: appIndexes(node.dirs.get(name)).length > 0 }));
131
145
  const leaves = appLeaves(node);
132
146
  const inside = leaves.filter((leaf) => leaf.entry === null);
package/src/page/state.js CHANGED
@@ -11,10 +11,10 @@ import { appDecode } from './payload.js';
11
11
  * network: the styling arrives in the same file, and the cell markup follows the rules of the shared part of the styling
12
12
  * (`clip`, a commit's caption).
13
13
  *
14
- * The panel remembers the reader's choice between visits and can hand it over as a link ("the choice's memory" below):
15
- * the record is tied to the report's passport and keeps only what is switched off, by name, so someone else's record is
16
- * not applied while a vanished name simply means nothing. The same record goes into the address which is what one
17
- * sends to a colleague.
14
+ * The panel remembers the reader's choice between visits ("the choice's memory" below): the record is tied to the
15
+ * report's passport and keeps only what is switched off, by name, so someone else's record is not applied while a
16
+ * vanished name simply means nothing. The record stays in the browser's memory and nowhere else: a report opened from
17
+ * disk keeps a clean address, and a link made in an earlier release is still read (`appLinkUse`).
18
18
  *
19
19
  * The page draws no conclusion about how a number was obtained: the method of each metric arrives in the data, and the
20
20
  * page prints it. There is no second rule of counting here, and no vocabulary of precision either. */
@@ -47,7 +47,10 @@ let appFoldKey = null;
47
47
  * on the data, and until the block is unpacked there is nothing to count it from. */
48
48
  export function appBoot(text) {
49
49
  appData = appDecode(JSON.parse(text));
50
- appView = { metrics: {}, files: [], folded: {} };
50
+ /* The tree is folded as it opens: a project's tree is longer than the window, and the reader's first look is at a
51
+ * short list rather than at everything. What is remembered is the opposite — the folders the reader unfolded
52
+ * (`appFoldRead`). */
53
+ appView = { metrics: {}, files: [], open: {} };
51
54
  appMetric = {};
52
55
  appMeasured = {};
53
56
  appData.metrics.forEach((m) => { appView.metrics[m.key] = true; appMetric[m.key] = m; });
@@ -57,14 +60,12 @@ export function appBoot(text) {
57
60
  }
58
61
 
59
62
  /* A link is that same choice in the address, under a name of its own: someone else's anchor on the page does not count
60
- * as a link, and there is nothing to argue with it about. */
63
+ * as a link, and there is nothing to argue with it about. The page does not write it any more — it only reads what came
64
+ * with the address — and this paragraph is what such a link is read by. */
61
65
  const APP_LINK = '#size-report=';
62
66
 
63
- /* Three circumstances of the first drawing, which act on it alone: the address is not rewritten during it (it was sent to
64
- * the reader rather than the other way), the memory is not touched (a link that came in is not the reader's choice), and
65
- * the message about the link has not faded yet. */
66
- let appStartup = true;
67
- let appForeign = false;
67
+ /* One circumstance of the first drawing, and it acts on it alone: the memory is not written while somebody else's link
68
+ * is open what came in is not the reader's choice, and only his own action makes it his. */
68
69
  let appTransient = false;
69
70
 
70
71
  /* -------- the reader's memory of his choice -------- */
@@ -121,59 +122,27 @@ function appRecordOk(rec) {
121
122
  return rec !== null && typeof rec === 'object' && rec.v === 1 && rec.passport === appPassport();
122
123
  }
123
124
 
124
- /* The address is the link for a colleague, while the memory is the reader's own: the memory is written on the click
125
- * itself that is what survives a closingand the address 200 ms after the last of a burst of switches, because a
126
- * burst is one link rather than five history entries and five URL parses. The delay is short enough for a person and
127
- * long enough to swallow a run of clicks; a timer that fires after the page is gone writes nothing useful, which is the
128
- * price of not writing the address five times. */
129
- const APP_ADDRESS_DELAY = 200;
130
- let appAddressTimer = null;
131
-
132
- /* An address that came in from outside wins over a write this page has not made yet: a click arms a write, a link
133
- * arrives within the delay, and the choice left behind must not land on the address the reader was sent — a refused link
134
- * arms nothing to replace it, so without this the page would rewrite someone else's address a fifth of a second later. */
135
- export function appAddressDrop() {
136
- if (appAddressTimer === null) return;
137
- clearTimeout(appAddressTimer);
138
- appAddressTimer = null;
139
- }
140
-
141
- function appAddressLater(text) {
142
- appAddressDrop();
143
- appAddressTimer = setTimeout(() => {
144
- appAddressTimer = null;
145
- try {
146
- window.history.replaceState(null, '', APP_LINK + encodeURIComponent(text));
147
- } catch (_e) {
148
- /* The browser grants no change of the address: the link is then taken from the browser's memory. */
149
- }
150
- }, APP_ADDRESS_DELAY);
151
- }
152
-
153
- /* One record for a click and two destinations: the same text goes into the memory and — a moment later — into the
154
- * address, so the two cannot describe different choices. */
125
+ /* The record goes into the browser's memory and nowhere else: it is written on the click itself, which is what survives
126
+ * a closing, and the page's address keeps a clean tail — the report is a local page whose address is copied as it is,
127
+ * and a reader's choice belongs in the browser that made it rather than in the tab's title bar. What a link sent from an
128
+ * earlier release holds is still read (`appLinkUse`), and it is not written into the reader's memory: what came in is
129
+ * not his choice until he changes something. */
155
130
  export function appWrite() {
131
+ if (appTransient) return;
156
132
  const rec = appRecord();
157
- const text = JSON.stringify(rec);
158
133
  const empty = Object.keys(rec.metrics).length === 0 && Object.keys(rec.files).length === 0;
159
- if (!appTransient) {
160
- try {
161
- if (empty) window.localStorage.removeItem(appKey);
162
- else window.localStorage.setItem(appKey, text);
163
- } catch (_e) {
164
- /* There is no memory (the browser grants this page none): the choice will not survive a closing, while the numbers
165
- * and the markup do not depend on it. */
166
- }
134
+ try {
135
+ if (empty) window.localStorage.removeItem(appKey);
136
+ else window.localStorage.setItem(appKey, JSON.stringify(rec));
137
+ } catch (_e) {
138
+ /* There is no memory (the browser grants this page none): the choice will not survive a closing, while the numbers
139
+ * and the markup do not depend on it. */
167
140
  }
168
- /* But not during the first drawing and not when the link turned out to be someone else's: an address that came in is
169
- * not ours, and the reader has yet to read it. */
170
- if (appStartup || appForeign) return;
171
- appAddressLater(text);
172
141
  }
173
142
 
174
143
  /* A reset to "everything on": the border between "this is no longer in the report" and "switched off" is the record
175
- * rather than a missing value. A link carries the sender's whole choice, which is why it is applied to a clean view rather
176
- * than on top of someone else's. */
144
+ * rather than a missing value. A link carries the sender's whole choice, which is why it is applied to a clean view
145
+ * rather than on top of someone else's. */
177
146
  function appAll() {
178
147
  appData.metrics.forEach((m) => { appView.metrics[m.key] = true; });
179
148
  appData.files.forEach((_f, i) => { appView.files[i] = true; });
@@ -273,14 +242,15 @@ export function appApply(rec) {
273
242
  appData.files.forEach((_f, i) => { if (files[appFileAt(i)] === false) appView.files[i] = false; });
274
243
  }
275
244
 
276
- /* -------- the folded tree -------- */
277
-
245
+ /* -------- the unfolded tree -------- */
278
246
 
279
- /* Folded folders are a memory of the same kind as the choice, but of a record of their own: it is about how much of the
280
- * tree is visible rather than about which numbers are read. Hence it does not go into the address: a link is sent for the
281
- * sake of the numbers, while an unfolded tree is the onlooker's business. As with the choice, only what is folded is kept
282
- * (`true`), and a folder's name is its path ("src/page"), so a vanished name simply means nothing. `appFoldKey` is
283
- * set with the rest of the model (`appBoot`), for the reason the key itself is. */
247
+ /* How much of the tree is visible is a memory of the same kind as the choice, but of a record of its own: it is about
248
+ * what the onlooker looks at rather than about which numbers are read, which is why it never goes into a link and never
249
+ * leaves the browser. The tree opens folded, so **the unfolded folders are what is kept** (`true`) the default is the
250
+ * absence of the name, the same way "switched on" is the absence of a choice. A folder's name is its path ("src/page"),
251
+ * so a vanished name simply means nothing, and unfolding nothing is the state the page opens in: then the record is not
252
+ * kept at all rather than being kept empty. `appFoldKey` is set with the model (`appBoot`), for the reason the key
253
+ * itself is. */
284
254
  export function appFoldRead() {
285
255
  let text = null;
286
256
  try {
@@ -296,19 +266,19 @@ export function appFoldRead() {
296
266
  return;
297
267
  }
298
268
  if (!appRecordOk(rec)) return;
299
- const folded = rec.folded || {};
300
- Object.keys(folded).forEach((p) => { if (folded[p] === true) appView.folded[p] = true; });
269
+ const open = rec.open || {};
270
+ Object.keys(open).forEach((p) => { if (open[p] === true) appView.open[p] = true; });
301
271
  }
302
272
 
303
- export function appFoldSet(path, folded) {
304
- if (folded) appView.folded[path] = true;
305
- else delete appView.folded[path];
306
- const rec = { v: 1, passport: appPassport(), folded: Object.assign({}, appView.folded) };
273
+ export function appFoldSet(path, open) {
274
+ if (open) appView.open[path] = true;
275
+ else delete appView.open[path];
276
+ const rec = { v: 1, passport: appPassport(), open: Object.assign({}, appView.open) };
307
277
  try {
308
- if (Object.keys(rec.folded).length === 0) window.localStorage.removeItem(appFoldKey);
278
+ if (Object.keys(rec.open).length === 0) window.localStorage.removeItem(appFoldKey);
309
279
  else window.localStorage.setItem(appFoldKey, JSON.stringify(rec));
310
280
  } catch (_e) {
311
- /* There is no memory: what is folded will not survive a closing, while the view does not depend on it — the tree is
312
- * folded exactly the way the reader folded it just now. */
281
+ /* There is no memory: what is unfolded will not survive a closing, while the view does not depend on it — the tree
282
+ * is unfolded exactly the way the reader unfolded it just now. */
313
283
  }
314
284
  }
package/src/page/table.js CHANGED
@@ -253,7 +253,9 @@ export function appBody(files, metrics, cache) {
253
253
  export function appTable(grid) {
254
254
  const metrics = appData.metrics.map((m) => m.key);
255
255
  const files = appOrder();
256
- const cache = { grid: grid, keys: metrics, cols: [], spans: [], cells: [], sums: [], all: [] };
256
+ const cache = { grid: grid, keys: metrics, cols: [], drawn: [], spans: [], cells: [], sums: [], all: [] };
257
+ /* The columns are built shown: that is the state the nodes carry before anything is drawn. */
258
+ files.forEach((i) => { cache.drawn[i] = true; });
257
259
  metrics.forEach(() => {
258
260
  cache.cells.push([]);
259
261
  cache.sums.push([]);
@@ -269,10 +271,28 @@ export function appTable(grid) {
269
271
  return cache;
270
272
  }
271
273
 
274
+ /* How many nodes a file's column holds — its cells, its captions and its `<col>`. It is the unit the page's bar
275
+ * counts in (`appDraw`), so it is answered here, where the column's nodes are: the choice's state costs arithmetic,
276
+ * and the nodes are the table's business. */
277
+ export function appColumnSize(cache, i) {
278
+ return cache.cols[i].length;
279
+ }
280
+
281
+ /* Whether a file's column has to be drawn at all: `drawn` is the state its nodes carry (the table is built with every
282
+ * column shown), so a column that is on and was never drawn is already right. The first drawing of a report has
283
+ * nothing switched off and therefore costs nothing, and a record from the memory or a link queues exactly the columns
284
+ * that differ from it — on a table of a few hundred thousand cells that is the difference between a page that opens
285
+ * and a page that works for a minute after opening. */
286
+ export function appColumnStale(cache, i) {
287
+ return cache.drawn[i] !== (appView.files[i] === true);
288
+ }
289
+
272
290
  /* A file's column shown or hidden: its cells and its headings together, one class per node and no new node. A hidden
273
- * column keeps its place in the markup — the order of the columns is the files' business, not the choice's. */
291
+ * column keeps its place in the markup — the order of the columns is the files' business, not the choice's. What the
292
+ * nodes carry is remembered (`drawn`), or a repeated switch would walk a column nobody has to see again. */
274
293
  export function appColumn(cache, i, on) {
275
294
  cache.cols[i].forEach((node) => node.classList.toggle('off', !on));
295
+ cache.drawn[i] = on;
276
296
  }
277
297
 
278
298
  /* A file's share of the totals: switched off it takes exactly its own numbers out of the running sums, switched on
@@ -0,0 +1,55 @@
1
+ /* The page's long work, and the stripe that says it is going on.
2
+ *
3
+ * A switch on a file's box changes a class on every node of that file's column, and a folder or a category is that same
4
+ * work over every file below it. That alone is cheap — measured at about 2 µs a node, so a whole category of this
5
+ * repository's report (73 columns, 55 042 nodes) is 120 ms of it — but it is not the price. **The browser lays this
6
+ * table out again for any change of a column's visibility, and that is close to a second on a table of a few hundred
7
+ * thousand cells** (`probes/step-12-columns.mjs`: 1 cell toggled 234 ms of layout, 750 cells 287 ms, 6 000 cells
8
+ * 539 ms, and the whole click on the shipped page 867 ms blocked with a 742 ms task).
9
+ *
10
+ * Hence the shape of this chapter, and it is a decision rather than a default. The drawing of a switch is **one task**:
11
+ * what is queued is drawn in a single go, one layout, one repaint. Slicing it — a queue worked off between timeouts —
12
+ * was written first and measured (Chrome 153, this repository's report): 37 slices of two columns each paid the table's
13
+ * relayout 37 times, 2.5–4 s a slice, 95 frames and 169 layouts of 151.9 s of pure layout time against 1.04 s for the
14
+ * same click when it is not sliced, with the tab growing to several gigabytes of repaint. The slice is the thing that
15
+ * freezes the page, only more often, so there is none.
16
+ *
17
+ * What is left is honesty about it: work short enough to be over before the browser could paint a stripe is done on the
18
+ * click itself, and above that the stripe appears, the drawing happens in the next task, and the stripe goes away —
19
+ * so a reader sees that the page is working rather than wondering whether it hung. The stripe carries no share: within
20
+ * one task the browser cannot repaint, so a bar that filled would be a bar that lies, and the page already knows the
21
+ * one thing that is true about it (the work is going on).
22
+ */
23
+
24
+ /* Above this much node work the drawing is one task with a stripe rather than a task on the click: a stripe that
25
+ * appears and disappears within the same frame is worse than none, and the figure is one file's column of a report of
26
+ * this repository's size (754 nodes). */
27
+ export const APP_LONG = 2000;
28
+
29
+ /* The stripe: on while a switch is being drawn, off when it is done. There is nothing to count here, and the length is
30
+ * carried by the styling (an indeterminate stripe), which is why this function takes a switch rather than a share. */
31
+ export function appBar(shown) {
32
+ const bar = document.getElementById('bar');
33
+ if (bar !== null) bar.hidden = !shown;
34
+ }
35
+
36
+ /* What a switch asks for: `items` are the columns that have to be drawn (`{i, units}` — the file's index and the nodes
37
+ * of its column), `step` draws one of them from the view, and the total decides whether the drawing is the reader's own
38
+ * click or the next task with the stripe over it. Only the columns out of step with the view are asked for at all
39
+ * (`appColumnStale`), so a report opened with everything switched on has nothing to draw here. */
40
+ export function appDraw(step, items) {
41
+ /* Counted by a plain walk rather than by `reduce`: the page's shell is held to a rule that it counts no totals of the
42
+ * table itself, and a guard that has to tell a sum of nodes from a sum of numbers is a guard that will be argued with
43
+ * one day. The count of nodes is not one of the report's numbers. */
44
+ let units = 0;
45
+ items.forEach((item) => { units += item.units; });
46
+ if (units <= APP_LONG) {
47
+ items.forEach((item) => step(item.i));
48
+ return;
49
+ }
50
+ appBar(true);
51
+ setTimeout(() => {
52
+ items.forEach((item) => step(item.i));
53
+ appBar(false);
54
+ }, 0);
55
+ }