@vernikr/size-report 2.8.1 → 2.8.3

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.8.1 (2026-09-18).** The tool lives as a package of its own: the registry name is
13
+ **Release 2.8.3 (2026-09-18).** 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,38 @@ 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.8.3 is about what a sideways scroll of the report costs, and its figures are measured rather than retold. **A step
23
+ sideways moves the window instead of building it:** a row is made once and lives through the move, keeping its node and
24
+ its numbers — the window's columns are the range of that row's numbers, so the columns that entered it are made and the
25
+ ones that left are dropped, the header's line of names and its line of metrics by the same rule. A switch of the
26
+ reader's own and the shell's resize are the one thing that cannot be a move — a column that is not built cannot be
27
+ shown — and they build the window from nothing as they did. **Measured on this repository's own report in live Chrome**
28
+ (`probes/step-12-window.mjs`, two rounds, 1440 × 900): the page's own handling of one step **10.4 → 0.6 ms** (worst
29
+ 19.2 → 3.6) and the layout the step forces **14.4 → 5.9 ms**; over a sweep of forty steps the browser's own accounting
30
+ gives layout **0.605 → 0.246 s**, style recalculation 0.169 → 0.056 s and task time **1.281 → 0.350 s**. The checks go
31
+ 86 → **87** in the fast profile and 191 → **192** in the full one: `test/page-grid.test.js` holds the rule by node
32
+ identity — a page that built the same numbers again would read the same — and by the numbers of the window built at
33
+ that place in one leap. The figures and the reasoning stand in `worklog/0211-sideways-step.md`.
34
+
35
+ 2.8.2 is about the page's own sources and the checks that guard them, and its figures are measured rather than retold.
36
+ **The four page suites that went quiet with the `<table>` are alive again against the window:** `test/page-view.test.js`,
37
+ `page-cols`, `page-tree` and `page-choice` read the grid and the panel they guard rather than markup that no longer
38
+ exists, and what they promise is what they hold — the pasted program with no second calculation, the sparse block, the
39
+ recount by the choice, the order of the columns, the tree's folds and its memory, the link's refusals. **Their readers
40
+ are no longer each suite's own:** `tools/page-harness.js` keeps one set of them (a file's path at HEAD, the rows the
41
+ window built, the numbers of a row and its captions, a folder's and a category's switch, the place the shell stands at),
42
+ and the copies the suites carried left with it. **And the page's chapters shed the duplication the same request
43
+ found:** the choice's record and the folded tree share one reading and one writing of the browser's memory
44
+ (`appLoad`/`appKeep`), a folder's two figures come from one walk of its subtree (`appSub`), the three walks over the
45
+ metrics and the files of a record became the panels' one question asked twice (`appReach`), and the three paths of a
46
+ click became one drawing (`appChanged`) — while two rules that never took effect went with them (a dead `.cap` of the
47
+ styling, an undefined colour under the folding sign). Measured in bytes for the same history: the chapters **−745 B**
48
+ (37 lines), the suites **−18 462 B** (251 lines), the harness **+4 328 B** — the tree loses **14 879 B and 220 lines**,
49
+ and the artifact **85 730 → 84 991 B**, its program and its styling being exactly what the chapters are. The checks go
50
+ 89 → **86** in the fast profile and 194 → **191** in the full one: what a reader of the page does is one place now
51
+ rather than five, and a check that read the table it no longer has is not a check. The figures and the reasoning stand
52
+ in `worklog/0210-page-suites-on-the-window.md`.
53
+
22
54
  2.8.1 is the drawing of the page put right after its table became a window, and its figures are measured rather than
23
55
  retold. **The panel and the table were sharing one class:** the table's rows were styled by a bare `.row` while the
24
56
  panel's own rows are `.panel .row`, so the table's rule took the metrics row and the three method lines out of the flow
@@ -49,11 +81,10 @@ stripe over a drawing is gone with the freeze it was drawn for**: a switch costs
49
81
  for an indicator to indicate, and `src/page/work.js` left the tree with it. A virtualizer library was measured and not
50
82
  taken — `@tanstack/virtual-core` is ~6.7 kB gzip and headless, `virtua`'s grid is experimental and `Clusterize.js`
51
83
  knows rows and not columns — because each would be vendored into the artifact, which the report then measures as its own
52
- bytes. **The checks of the old table are deactivated while it is rebuilt:** `test/page-view.test.js`, `page-cols`,
53
- `page-tree` and `page-choice` skip every check with its reason at the top of the file, and this document names them
54
- where they are the promise-holders of what they described; `test/page-grid.test.js` is what guards the window meanwhile.
55
- The figures and the reasoning stand in `worklog/0208-table-window.md`. The checks grow with the work: 81 → **88** in the
56
- fast profile and 186 → **193** in the full one.
84
+ bytes. The checks of the old table went quiet with it, each naming its reason, and 2.8.2 brought them back against the
85
+ window; `test/page-grid.test.js` is what guards the window itself. The figures and the reasons stand in
86
+ `worklog/0208-table-window.md`. The checks grow with the work: 81 **88** in the fast profile and 186 **193**
87
+ in the full one.
57
88
 
58
89
  2.7.0 is about the page and what a reader does with it, and its figures are measured rather than retold.
59
90
  **The report's address stays clean:** a switch writes the record into the browser's memory and nothing into the
@@ -253,8 +284,8 @@ commits and installs hooks; the reason for each expensive file is named line by
253
284
 
254
285
  | Run | Command | Checks |
255
286
  |---|---|---|
256
- | Fast — every edit | `pnpm test` | **89 of 194** |
257
- | Full — release and CI | `pnpm test:all` | **194** |
287
+ | Fast — every edit | `pnpm test` | **87 of 192** |
288
+ | Full — release and CI | `pnpm test:all` | **192** |
258
289
 
259
290
  No check is lost or weakened: the full run starts every file, the fast one takes part of them. The default is the full run — a file becomes fast only explicitly and with a reason — so new
260
291
  expensive work cannot quietly move into the fast one. Two declarations guard that:
@@ -389,7 +420,10 @@ right edge of the pinned commit column rather than under it, and every line of t
389
420
  cell, along a group's left edge — is one pixel of one colour: a heavier line would promise a hierarchy to work out
390
421
  rather than read. The geometry is four figures — a column, a row, the header, the pinned column — written in the styling
391
422
  and in the script that counts the window's ordinals in them, and `test/page-grid.test.js` reads both and holds them
392
- together.
423
+ together. **A step sideways moves that window rather than building it:** the rows live through the move and only the
424
+ columns that entered are made, at 10.4 → **0.6 ms** for the page's own handling of the step, 14.4 → **5.9 ms** for the
425
+ layout it forces and 1.281 → **0.350 s** of task time over a sweep of forty steps
426
+ (`probes/step-12-window.mjs`).
393
427
 
394
428
  **A click builds the window again, and that is cheap now.** A file switched off is simply not among the columns that
395
429
  are built, and a metric switched off not among the metrics: there is nothing to hide and nothing to carry, and the
@@ -568,7 +602,7 @@ acceptance for each.
568
602
  | `tools/parity-freeze.js` | Takes the parity reference (`pnpm run parity`): with the frozen copy, at the project revision from the manifest — `--json`, the config, the artifact's hash, the tool's hash |
569
603
  | `tools/make-fixture.js` | Assembles the synthetic fixture (`pnpm run fixture`): a deterministic history with traps plus the reference numbers |
570
604
  | `tools/synthetic/` | The subjects of that assembly, one per matter: `repo.js` — how git is spoken to (pinned time, author, settings), `content.js` — what the files hold, `history.js` — which commits come of it, `note.js` — the fixture's note with the list of traps |
571
- | `probes/` | The scripted measurements behind `plans/2026-09-17-page-perf/`: one file per step, run by hand against live Chrome at the debug port — the fixed layout, where `content-visibility` acts at all, and the two border models with their pixels (`probes/README.md`). Outside the sensors' paths on purpose: they measure the product rather than being part of it, and a suite cannot see layout, paint or a browser's own skipping |
605
+ | `probes/` | The scripted measurements behind `plans/2026-09-17-page-perf/`: one file per step, run by hand against live Chrome at the debug port — the fixed layout, where `content-visibility` acts at all, the price of a switch and of a step sideways — (`probes/README.md`). Outside the sensors' paths on purpose: they measure the product rather than being part of it, and a suite cannot see layout, paint or a browser's own skipping |
572
606
  | `tools/parity-live.js` | Compares the engine with the live project on a clone: the numbers and the self-contained report at the path the consumer's settings give (`pnpm run parity:live`) |
573
607
  | `tools/pack-check.js` | Assembles the tarball and checks that everything works from it: all sources arrived, the numbers and the report as from the repository (`pnpm run pack:check`) |
574
608
  | `tools/check-standards.js` | Checks that both references reproduce: a re-take goes nowhere and is compared with what is committed (our files byte for byte, the bundle by content), and that the live-history bundle carries `HEAD` (`pnpm run check:standards`) |
@@ -604,7 +638,7 @@ acceptance for each.
604
638
  | `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 |
605
639
  | `src/page/dom.js` | The page's nodes: the small helpers of markup (`appEl`, `appBox`) — one set for the panel and the table alike |
606
640
  | `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 |
607
- | `src/page/table.js` | The page's table as a window: the rows and the columns the reader can see (plus four beyond each edge), a cell, a commit's caption, the header, the empty states and the order of the columns — markup over the shared calculation, with the totals counted per row from the choice, the geometry in pixels that the styling mirrors, and the reason a library was not taken |
641
+ | `src/page/table.js` | The page's table as a window: the rows and the columns the reader can see (plus four beyond each edge), moved by a scroll rather than built again (a row lives through a step sideways and only the columns that entered it are made), a cell, a commit's caption, the header, the empty states and the order of the columns — markup over the shared calculation, with the totals counted per row from the choice, the geometry in pixels that the styling mirrors, and the reason a library was not taken |
608
642
  | `src/page/app.js` | Assembling and starting the page: the first drawing, then a switch that builds the window again and writes the fields it reached without making a node of the panel; an anchor change; pasted into the assembled page |
609
643
  | `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` |
610
644
  | `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 |
@@ -637,7 +671,7 @@ acceptance for each.
637
671
  | `test/api.test.js` | The package's public API: the list of names is frozen, and splitting the engine may not change it |
638
672
  | `eslint.config.js` | The rules of formatting: the same as the consumer project's, plus a ban on gluing operators into one line (`pnpm run lint`, `pnpm run lint:strict`) |
639
673
  | `tools/harness.js` | The harness of the checks: paths, clones of the fixture (including one shared per suite and one with CRLF), running the tool, reading refusals, hashes |
640
- | `tools/page-harness.js` | The harness of the contract and page checks: the contract data, the assembled page, reading it in a real DOM, the panel's switches, the page's calculation and its decoder evaluated from their sources, the block unpacked, and the platform's unpacker put into jsdom (which has none) — one for seven suites |
674
+ | `tools/page-harness.js` | The harness of the contract and page checks: the contract data, the assembled page, reading it in a real DOM, one set of readers for every page suite (a file's path at HEAD, the rows the window built and their numbers, the captions over them, the order of the columns, a folder's and a category's switch, the place the shell stands at), the page's calculation and its decoder evaluated from their sources, the block unpacked, and the platform's unpacker put into jsdom (which has none) — one for seven suites |
641
675
  | `tools/suites.js` | The split of the suite: which files go into the fast run (with a reason for each) and why every dear one is in the full run |
642
676
  | `tools/run-tests.js` | Running the suite (`pnpm test`, `pnpm test:all`, `pnpm run suites:measure`): each file's duration measured on its own, and the counts of checks adding up |
643
677
  | `tools/docs-facts.js` | Reading facts out of the documentation — one layer for the four checks of the documentation guard: what a document names (paths, calls, section addresses) against what the repository holds |
@@ -654,10 +688,10 @@ acceptance for each.
654
688
  | `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 |
655
689
  | `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 |
656
690
  | `test/page-grid.test.js` | The grid of the page as a window: what the reader sees is built and no more, the geometry of the styling against the script's, the rows and the columns that a scroll builds and drops, a number under its own caption, a file and a metric switched off, every row of the window against the engine's own calculation, and the drawing's own rules — the order of the columns by the numbers, one border, the header's rows of one height, and the panel out of the shared styling's reach |
657
- | `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 **deactivated** while the table is rebuilt, every check skipped with its reason in the file |
658
- | `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 — **deactivated** with the table, the checks skipped with their reason |
659
- | `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 — **deactivated**: the choice and the link are the same, while the counts and the stripe are not |
660
- | `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 **deactivated** with the table: the columns are one fixed width now |
691
+ | `test/page-view.test.js` | The assembled page: pasted with no copy of the calculation, self-contained, the sparse block, the recount by the choice, every total of the window against the engine's own sums, no mark of precision left on a cell, the shared styling pasted once and the delta colour set once, the empty states, and the switches' focus |
692
+ | `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 |
693
+ | `test/page-choice.test.js` | The reader's choice: 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 a host without an unpacker that is told so in words |
694
+ | `test/page-cols.test.js` | The columns of the grid: their order by the newest row a file's numbers moved in, a caption over exactly the metrics of its group, and a choice that moves no other column |
661
695
  | `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 |
662
696
  | `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 |
663
697
  | `test/runner.test.js` | Reading a process's output: chunks are glued as buffers rather than appended to a string — a multi-byte character at a chunk border does not turn into two replacement characters |
@@ -737,7 +771,7 @@ The same release can be taken by a reference to the repository — installation
737
771
  registry, but stays tied to a revision:
738
772
 
739
773
  ```bash
740
- pnpm add -D github:vernikr/size-report#v2.8.1
774
+ pnpm add -D github:vernikr/size-report#v2.8.3
741
775
  ```
742
776
 
743
777
  With no network (or nothing to fetch from codeload) — the tarball: `pnpm pack` in the package clone, then
@@ -749,7 +783,7 @@ the branch moves on the installation fails with `Could not resolve <sha> to a co
749
783
  observation rather than reasoning: the short pin `6530237` installed while `main` stood on it and stopped
750
784
  working at the very next commit, while the same sha in full installed. A branch name (`#main`) and a tag
751
785
  are both accepted, but a branch is a moving target and a tag is constant: this release stands on the tag
752
- `v2.8.1`, which is also the one in the example (forty characters work as well, but they have to be copied
786
+ `v2.8.3`, which is also the one in the example (forty characters work as well, but they have to be copied
753
787
  out of the history by eye).
754
788
 
755
789
  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.8.1",
3
+ "version": "2.8.3",
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.8.0",
68
+ "@vernikr/size-report": "2.8.2",
69
69
  "c8": "10",
70
70
  "dependency-cruiser": "17",
71
71
  "eslint": "^9.18.0",
package/src/page/app.css CHANGED
@@ -45,7 +45,7 @@ h1 { margin: 0 0 3px; font-size: 21px; font-weight: 650; letter-spacing: -.012em
45
45
  }
46
46
  .panel fieldset { margin: 0; padding: 0; border: 0; }
47
47
  .panel fieldset + fieldset { margin-top: 10px; }
48
- .panel legend, .panel .cap {
48
+ .panel legend {
49
49
  padding: 0;
50
50
  color: var(--muted);
51
51
  font-size: 11px;
@@ -53,7 +53,6 @@ h1 { margin: 0 0 3px; font-size: 21px; font-weight: 650; letter-spacing: -.012em
53
53
  text-transform: uppercase;
54
54
  letter-spacing: .07em;
55
55
  }
56
- .panel .cap { display: block; margin-bottom: 4px; }
57
56
  .panel .row { display: flex; flex-wrap: wrap; gap: 3px 6px; align-items: center; }
58
57
  /* The way of counting is visible text under the switches: the token dictionary and the way of compression come from the
59
58
  * settings of the run rather than from a checkbox, so pointing a mouse is not enough — the reader has to see what produced
@@ -91,7 +90,7 @@ h1 { margin: 0 0 3px; font-size: 21px; font-weight: 650; letter-spacing: -.012em
91
90
  cursor: pointer;
92
91
  user-select: none;
93
92
  }
94
- .panel .tree li > .fold:hover { color: var(--ink); }
93
+ .panel .tree li > .fold:hover { color: CanvasText; }
95
94
 
96
95
  /* A switch is a label around an input: one label and one click target, which is why a mouse, the keyboard (`Space` on the
97
96
  * input) and assistive technology all reach it. */
@@ -131,11 +130,7 @@ h1 { margin: 0 0 3px; font-size: 21px; font-weight: 650; letter-spacing: -.012em
131
130
  /* Numbers are denser than the page's text: there are more of them, they are shorter, and they are read by their digits. */
132
131
  #grid { font-size: 12.5px; }
133
132
 
134
- /* There is no stripe of progress over the page any longer, and that is a measurement rather than an omission: it stood
135
- * over the browser's relayout of the whole table, and the table is built as a window now — a switch costs a few
136
- * milliseconds (`src/page/table.js`), so an indicator would be a promise the page does not keep.
137
- *
138
- * The message about a link that came in stands above the table, so that it cannot be missed, while it does not push the
133
+ /* The message about a link that came in stands above the table, so that it cannot be missed, while it does not push the
139
134
  * table away: one line in the place of the page. Its colour is the system one (the accent), for the page keeps no colours
140
135
  * of its own. */
141
136
  .notice {
package/src/page/app.js CHANGED
@@ -4,15 +4,11 @@ import { appState, appTable, appWindow } from './table.js';
4
4
  import { appPanel, appPanelAll, appPanelState } from './panel.js';
5
5
 
6
6
  /* Assembling the report: the table is a window of the grid (`appWindow` of the table chapter) and nothing is built
7
- * that the reader cannot see. Two paths and no third, as before: `appPaint` draws the whole view (the first drawing, a
8
- * record from the browser's memory, a link in the address), while a click on a box does the same work for the choice
9
- * it made — and the difference between them is only how much of the view moved.
10
- *
11
- * What a click costs now: the window of rows and columns is built again — a few hundred cells, measured at 1–5 ms on
12
- * this repository's report — instead of a class on every node of a column of a table the browser lays out whole. The
13
- * stripe that used to stand over a long drawing (`src/page/work.js`) is gone with the reason for it: a switch no
14
- * longer has anything to wait for, and an indicator over work that is over before it could be painted would be a
15
- * promise the page does not keep.
7
+ * that the reader cannot see. Two paths and no third: `appPaint` draws the whole view (the first drawing, a record from
8
+ * the browser's memory, a link in the address), while a click on a box does the same work for the choice it made — the
9
+ * difference between them is only how much of the view moved. Neither has anything to wait for: a switch builds the
10
+ * window of the grid, a few hundred cells and milliseconds, so the page needs no indicator and keeps no promise it
11
+ * cannot measure.
16
12
  */
17
13
 
18
14
  // The table's window: made once, at the first drawing.
@@ -42,38 +38,36 @@ export function appPaint() {
42
38
  appWrite();
43
39
  }
44
40
 
45
- /* One file switched by the reader: the view, the window (its column is simply not among the columns that are built),
46
- * the fields it shows in. The message about a link fades here: by this action the reader has read it. */
47
- export function appSwitch(i, on) {
48
- if (appView.files[i] === on) return;
49
- appView.files[i] = on;
41
+ /* One drawing after a click, whatever was clicked: the window of the grid (the columns of what is switched off are
42
+ * simply not among the columns that are built), the counts of the empty states, the fields the choice really reached
43
+ * (`touched` — a whole group is one click, and a field that did not move is not written) and the memory. The message
44
+ * about a link fades here: by this action the reader has read it. */
45
+ function appChanged(touched) {
50
46
  appWindow(appCache, true);
51
47
  appCounts();
52
- appPanelState([i]);
48
+ if (touched.length > 0) appPanelState(touched);
53
49
  appWrite();
54
50
  appNotice('');
55
51
  }
56
52
 
57
- /* A group switched at once a folder or a category: the same work per file, then the fields of the files the choice
58
- * really reached (switching a folder on when a part of it was already on touches only the rest, and a field that did
59
- * not move is not written). The columns of the whole group leave or enter the window together. */
53
+ // One file switched by the reader. A switch that changes nothing is not a change: the click costs no drawing.
54
+ export function appSwitch(i, on) {
55
+ if (appView.files[i] === on) return;
56
+ appView.files[i] = on;
57
+ appChanged([i]);
58
+ }
59
+
60
+ /* A group switched at once — a folder or a category: the same work per file, and the fields of the files the choice
61
+ * really reached (switching a folder on when a part of it was already on touches only the rest). */
60
62
  export function appSwitchGroup(indexes, on) {
61
63
  const touched = indexes.filter((i) => appView.files[i] !== on);
62
64
  touched.forEach((i) => { appView.files[i] = on; });
63
- appWindow(appCache, true);
64
- appCounts();
65
- appPanelState(touched);
66
- appWrite();
67
- appNotice('');
65
+ appChanged(touched);
68
66
  }
69
67
 
70
- /* One metric switched: the columns of that metric are not among the columns that are built any longer, so this is the
71
- * same drawing as a file's switch. The metric's own field is the box the reader just clicked. */
68
+ /* One metric switched. Only the panel's metric box shows it, and that box is the one the reader just clicked. */
72
69
  export function appSwitchMetric() {
73
- appWindow(appCache, true);
74
- appCounts();
75
- appWrite();
76
- appNotice('');
70
+ appChanged([]);
77
71
  }
78
72
 
79
73
  /* The first drawing: the choice is already in the view (the link and the memory are applied above), the panel is
package/src/page/panel.js CHANGED
@@ -22,19 +22,18 @@ function appUnmeasuredBox(entry) {
22
22
  + (entry.why === 'rule' ? appUi.notMeasuredRule : appUi.notMeasuredChoice));
23
23
  }
24
24
 
25
- /* Every measured file of a subtree what a folder's switch controls: a file outside the report has nothing to
26
- * switch. */
27
- function appIndexes(node) {
28
- const out = node.files.slice();
29
- node.dirs.forEach((sub) => { out.push(...appIndexes(sub)); });
30
- return out;
31
- }
32
-
33
- /* How many files a subtree holds — including the ones that made it into no report. */
34
- function appCount(node) {
35
- let n = node.files.length + node.others.length;
36
- node.dirs.forEach((sub) => { n += appCount(sub); });
37
- return n;
25
+ /* A subtree in the two figures its folder shows: the measured files its checkbox controls, and how many files the
26
+ * folder holds at all — including the ones that made it into no report. One walk, because the two are asked together
27
+ * (a folder of five files with two measured reads "2/5"). */
28
+ function appSub(node) {
29
+ const idx = node.files.slice();
30
+ let total = node.files.length + node.others.length;
31
+ node.dirs.forEach((sub) => {
32
+ const inner = appSub(sub);
33
+ idx.push(...inner.idx);
34
+ total += inner.total;
35
+ });
36
+ return { idx: idx, total: total };
38
37
  }
39
38
 
40
39
  /* A tree node: the measured files (columns), the project's other files and the subfolders. */
@@ -48,8 +47,9 @@ function appNode() {
48
47
  * without a single measured file stays in place with its checkbox off and unavailable: there is nothing to switch on
49
48
  * in it. */
50
49
  function appDirHead(name, here, sub) {
51
- const idx = appIndexes(sub);
52
- const total = appCount(sub);
50
+ const own = appSub(sub);
51
+ const idx = own.idx;
52
+ const total = own.total;
53
53
  const label = name + '/';
54
54
  let head;
55
55
  if (idx.length === 0) {
@@ -141,13 +141,14 @@ function appLeaf(leaf) {
141
141
  function appTreeList(node, prefix) {
142
142
  const list = appEl('ul', 'tree');
143
143
  const dirs = [...node.dirs.keys()].sort(appName)
144
- .map((name) => ({ name: name, sub: node.dirs.get(name), inReport: appIndexes(node.dirs.get(name)).length > 0 }));
144
+ .map((name) => ({ name: name, sub: node.dirs.get(name), inReport: appSub(node.dirs.get(name)).idx.length > 0 }));
145
145
  const leaves = appLeaves(node);
146
146
  const inside = leaves.filter((leaf) => leaf.entry === null);
147
+ const outside = leaves.filter((leaf) => leaf.entry !== null);
147
148
  dirs.filter((d) => d.inReport).forEach((d) => list.appendChild(appDir(d.name, d.sub, prefix)));
148
149
  inside.forEach((leaf) => list.appendChild(appLeaf(leaf)));
149
150
  dirs.filter((d) => !d.inReport).forEach((d) => list.appendChild(appDir(d.name, d.sub, prefix)));
150
- leaves.filter((leaf) => leaf.entry !== null).forEach((leaf) => list.appendChild(appLeaf(leaf)));
151
+ outside.forEach((leaf) => list.appendChild(appLeaf(leaf)));
151
152
  return list;
152
153
  }
153
154
 
@@ -237,10 +238,10 @@ function appRowBoxes(box) {
237
238
  }
238
239
 
239
240
  // A folder's field from its files: all on — checked, some — the third state, none — simply unchecked.
240
- function appDirState(box) {
241
- const boxes = appRowBoxes(box);
241
+ function appDirState(path) {
242
+ const boxes = appRowBoxes(appFields.dir[path]);
242
243
  const on = boxes.filter((b) => b.checked).length;
243
- const input = box.querySelector('input');
244
+ const input = appFields.dir[path].querySelector('input');
244
245
  input.checked = on === boxes.length;
245
246
  input.indeterminate = on > 0 && on < boxes.length;
246
247
  }
@@ -267,29 +268,17 @@ function appDirPath(path) {
267
268
  return parts.slice(0, -1).map((_part, i) => parts.slice(0, i + 1).join('/'));
268
269
  }
269
270
 
270
- /* A click reaches a folder's field through the files below it: every folder on the path of a switched file shows the
271
- * share of what is left on. A folder without measured files has a field of its own too it is off and unavailable,
272
- * which is not the reader's state and must not be overwritten here. */
273
- function appDirsOf(indexes) {
274
- const seen = {};
275
- indexes.forEach((i) => {
276
- appDirPath(appFileAt(i)).forEach((path) => {
277
- if (seen[path] === true) return;
278
- seen[path] = true;
279
- if (appFields.dir[path] !== undefined) appDirState(appFields.dir[path]);
280
- });
281
- });
282
- }
283
-
284
- // The same for the quick buttons of the categories: every category one of the switched files belongs to.
285
- function appCatsOf(indexes) {
271
+ /* A click reaches the fields of a folder and of a category through the files below them one walk for both, because
272
+ * it is one question: which groups the reader sees hold a switched file, and what do they show now. A field that is not
273
+ * there is left alone: a folder without measured files has a box of its own, off and unavailable, which is not the
274
+ * reader's state. */
275
+ function appReach(indexes, fields, namesOf, stateOf) {
286
276
  const seen = {};
287
- indexes.forEach((i) => {
288
- const key = appData.files[i].category;
289
- if (seen[key] === true || appFields.cat[key] === undefined) return;
290
- seen[key] = true;
291
- appCatState(key);
292
- });
277
+ indexes.forEach((i) => namesOf(i).forEach((name) => {
278
+ if (seen[name] === true || fields[name] === undefined) return;
279
+ seen[name] = true;
280
+ stateOf(name);
281
+ }));
293
282
  }
294
283
 
295
284
  /* What a click changed, written where it stands: the files' own boxes, then the fields of the folders and categories
@@ -299,8 +288,8 @@ export function appPanelState(indexes) {
299
288
  const input = appFields.file[i];
300
289
  if (input !== undefined) input.checked = appView.files[i] === true;
301
290
  });
302
- appDirsOf(indexes);
303
- appCatsOf(indexes);
291
+ appReach(indexes, appFields.dir, (i) => appDirPath(appFileAt(i)), appDirState);
292
+ appReach(indexes, appFields.cat, (i) => [appData.files[i].category], appCatState);
304
293
  }
305
294
 
306
295
  /* The whole panel from the view: what a link, a record from the memory and the first drawing need. The metric boxes
@@ -309,5 +298,5 @@ export function appPanelAll() {
309
298
  appData.categories.forEach((c) => appCatState(c.key));
310
299
  appData.metrics.forEach((m) => { appFields.metric[m.key].checked = appView.metrics[m.key] === true; });
311
300
  appData.files.forEach((_f, i) => { appFields.file[i].checked = appView.files[i] === true; });
312
- Object.keys(appFields.dir).forEach((path) => appDirState(appFields.dir[path]));
301
+ Object.keys(appFields.dir).forEach((path) => appDirState(path));
313
302
  }
package/src/page/state.js CHANGED
@@ -106,15 +106,16 @@ function appPassport() {
106
106
  return appPassportValue;
107
107
  }
108
108
 
109
- /* One record of the choice for everything: it goes both into the memory and into the address, so there are no two formats
110
- * of one state. Only what is switched off is kept, by name: "switched on" and "no record" are the same state, which is
111
- * why turning every checkbox back on removes the record instead of leaving a trace indistinguishable from a choice. */
109
+ /* One record of the choice: only what is switched off is kept, by name. "Switched on" and "no record" are the same
110
+ * state, hence `null` rather than an empty record turning every checkbox back on removes the record instead of leaving
111
+ * a trace indistinguishable from a choice. */
112
112
  function appRecord() {
113
113
  const metrics = {};
114
114
  const files = {};
115
115
  appData.metrics.forEach((m) => { if (!appView.metrics[m.key]) metrics[m.key] = false; });
116
116
  appData.files.forEach((_f, i) => { if (!appView.files[i]) files[appFileAt(i)] = false; });
117
- return { v: 1, passport: appPassport(), metrics: metrics, files: files };
117
+ const empty = Object.keys(metrics).length === 0 && Object.keys(files).length === 0;
118
+ return empty ? null : { v: 1, passport: appPassport(), metrics: metrics, files: files };
118
119
  }
119
120
 
120
121
  // Whether a record is ours and of the right format — one rule for the memory and the address alike.
@@ -122,24 +123,40 @@ function appRecordOk(rec) {
122
123
  return rec !== null && typeof rec === 'object' && rec.v === 1 && rec.passport === appPassport();
123
124
  }
124
125
 
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. */
130
- export function appWrite() {
131
- if (appTransient) return;
132
- const rec = appRecord();
133
- const empty = Object.keys(rec.metrics).length === 0 && Object.keys(rec.files).length === 0;
126
+ /* A record into the browser's memory or out of it, and what is read back from it: the two halves of the memory, written
127
+ * once, because the page keeps two records of the same kind under two keys the choice and the unfolded tree — and
128
+ * their rules are one. Nothing but the browser's memory is written: it is written on the click itself, which is what
129
+ * survives a closing, and the page's address keeps a clean tail the report is a local page whose address is copied as
130
+ * it is, and a reader's choice belongs in the browser that made it rather than in the tab's title bar. What a link sent
131
+ * from an earlier release holds is still read (`appLinkUse`), and it is not written into the reader's memory: what came
132
+ * in is not his choice until he changes something. */
133
+ function appKeep(key, rec) {
134
134
  try {
135
- if (empty) window.localStorage.removeItem(appKey);
136
- else window.localStorage.setItem(appKey, JSON.stringify(rec));
135
+ if (rec === null) window.localStorage.removeItem(key);
136
+ else window.localStorage.setItem(key, JSON.stringify(rec));
137
137
  } catch (_e) {
138
138
  /* There is no memory (the browser grants this page none): the choice will not survive a closing, while the numbers
139
139
  * and the markup do not depend on it. */
140
140
  }
141
141
  }
142
142
 
143
+ export function appWrite() {
144
+ if (!appTransient) appKeep(appKey, appRecord());
145
+ }
146
+
147
+ /* What the browser saved under a key: its absence, a foreign record, a broken one and a record of another report amount
148
+ * to the same thing here — nothing to read. `JSON.parse` of a missing value answers `null`, so the two absences need no
149
+ * telling apart. */
150
+ function appLoad(key) {
151
+ let rec = null;
152
+ try {
153
+ rec = JSON.parse(window.localStorage.getItem(key));
154
+ } catch (_e) {
155
+ return null;
156
+ }
157
+ return appRecordOk(rec) ? rec : null;
158
+ }
159
+
143
160
  /* A reset to "everything on": the border between "this is no longer in the report" and "switched off" is the record
144
161
  * rather than a missing value. A link carries the sender's whole choice, which is why it is applied to a clean view
145
162
  * rather than on top of someone else's. */
@@ -174,15 +191,12 @@ function appLinkRead() {
174
191
  }
175
192
 
176
193
  /* How many names in the link are unknown to this report: the reader has to be told about them — otherwise he would look
177
- * in the table for something that was never there. */
194
+ * in the table for something that was never there. The two tables the model already holds answer it: a file by its path
195
+ * at HEAD (`appMeasured`) and a metric by its key (`appMetric`), the same names a record is written in (`appFileAt`). */
178
196
  function appUnknown(rec) {
179
- const known = {};
180
- const metricKeys = {};
181
- appData.files.forEach((_f, i) => { known[appFileAt(i)] = true; });
182
- appData.metrics.forEach((m) => { metricKeys[m.key] = true; });
183
197
  let n = 0;
184
- Object.keys(rec.metrics || {}).forEach((k) => { if (!metricKeys[k]) n++; });
185
- Object.keys(rec.files || {}).forEach((k) => { if (!known[k]) n++; });
198
+ Object.keys(rec.metrics || {}).forEach((k) => { if (appMetric[k] === undefined) n++; });
199
+ Object.keys(rec.files || {}).forEach((k) => { if (appMeasured[k] === undefined) n++; });
186
200
  return n;
187
201
  }
188
202
 
@@ -213,23 +227,9 @@ export function appLinkUse() {
213
227
  return 'none';
214
228
  }
215
229
 
216
- /* Reading: only a record of ours of our format version and our passport. Another report's record lies under another key,
217
- * while a foreign, outdated or broken one amounts to its absence. */
230
+ // Reading: the reader's own choice, as he left it another report's record lies under another key.
218
231
  export function appRead() {
219
- let text = null;
220
- try {
221
- text = window.localStorage.getItem(appKey);
222
- } catch (_e) {
223
- return null;
224
- }
225
- if (text === null) return null;
226
- let rec = null;
227
- try {
228
- rec = JSON.parse(text);
229
- } catch (_e) {
230
- return null;
231
- }
232
- return appRecordOk(rec) ? rec : null;
232
+ return appLoad(appKey);
233
233
  }
234
234
 
235
235
  /* Applying goes by name: a file is recognised by its path, a metric by its key. A name the report does not hold matches
@@ -248,37 +248,22 @@ export function appApply(rec) {
248
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
249
  * leaves the browser. The tree opens folded, so **the unfolded folders are what is kept** (`true`) — the default is the
250
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. */
251
+ * so a vanished name simply means nothing. `appFoldKey` is set with the model (`appBoot`), for the reason the key itself
252
+ * is. */
254
253
  export function appFoldRead() {
255
- let text = null;
256
- try {
257
- text = window.localStorage.getItem(appFoldKey);
258
- } catch (_e) {
259
- return;
260
- }
261
- if (text === null) return;
262
- let rec = null;
263
- try {
264
- rec = JSON.parse(text);
265
- } catch (_e) {
266
- return;
267
- }
268
- if (!appRecordOk(rec)) return;
254
+ const rec = appLoad(appFoldKey);
255
+ if (rec === null) return;
269
256
  const open = rec.open || {};
270
257
  Object.keys(open).forEach((p) => { if (open[p] === true) appView.open[p] = true; });
271
258
  }
272
259
 
260
+ /* What a folder's sign does with the memory: the unfolded folders are what is kept (`true`), so a tree folded whole is
261
+ * the absence of the record — the default — and the record goes away with the last unfolded folder. */
273
262
  export function appFoldSet(path, open) {
274
263
  if (open) appView.open[path] = true;
275
264
  else delete appView.open[path];
276
- const rec = { v: 1, passport: appPassport(), open: Object.assign({}, appView.open) };
277
- try {
278
- if (Object.keys(rec.open).length === 0) window.localStorage.removeItem(appFoldKey);
279
- else window.localStorage.setItem(appFoldKey, JSON.stringify(rec));
280
- } catch (_e) {
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. */
283
- }
265
+ const names = Object.keys(appView.open);
266
+ appKeep(appFoldKey, names.length === 0
267
+ ? null
268
+ : { v: 1, passport: appPassport(), open: appView.open });
284
269
  }
package/src/page/table.js CHANGED
@@ -31,6 +31,14 @@ import { appData, appUi, appView } from './state.js';
31
31
  * table, against 6.5 ms for building the whole window again). The header sticks to the shell's top and the commit
32
32
  * column to its left (both `position: sticky`), so the row and the column a number belongs to are always in sight.
33
33
  *
34
+ * **A step down the table and a step sideways cost the same kind of work.** A row is made once and then stands at its
35
+ * own place in the scrolled content while the window moves over it: the window's columns are the range of that row's
36
+ * numbers, so a step sideways makes the columns that entered and drops the ones that left (`appStrip`) — a cell per
37
+ * row, the header's two lines included. A row that left the window vertically is dropped and made again when the
38
+ * reader comes back to it, which is what makes a jump down the table cost the same as a step. Building the rows again
39
+ * on every sideways scroll — the first version of this step — is what `probes/step-12-window.mjs` measures against the
40
+ * splice (`worklog/0211-sideways-step.md`).
41
+ *
34
42
  * The choice is applied by building the window again: the columns of a switched-off file are simply not among the
35
43
  * columns that are built, so there is nothing to hide and nothing to recount — the totals are the sum over the files
36
44
  * that are on, counted by `rowModel` for the rows the window holds (`src/derived.js`, one place for that
@@ -138,6 +146,27 @@ export function appState(metricsCount, filesCount) {
138
146
  document.getElementById('shell').hidden = metricsCount === 0;
139
147
  }
140
148
 
149
+ /* A strip of pieces of one range, moved to another: the pieces the two ranges share are kept, and only the ends are
150
+ * made and dropped. The unit of a strip is its child — a row's numbers are one column to a child, the header's line of
151
+ * names one group, its line of metrics one column — so the same movement serves all three, and a group of metrics
152
+ * cannot be split by the window (a column of numbers would lose its caption). A window that jumped clear of the old
153
+ * range — the reader dragged the scrollbar — has nothing to keep and the strip is made again; the empty range
154
+ * `[c, c - 1]` is how a strip that holds nothing yet says so. */
155
+ function appStrip(strip, was, now, make) {
156
+ const a = Math.max(was[0], now[0]);
157
+ const b = Math.min(was[1], now[1]);
158
+ if (a > b) {
159
+ strip.textContent = '';
160
+ for (let c = now[0]; c <= now[1]; c++) strip.appendChild(make(c));
161
+ return;
162
+ }
163
+ for (let n = a - was[0]; n > 0; n--) strip.firstChild.remove();
164
+ for (let n = was[1] - b; n > 0; n--) strip.lastChild.remove();
165
+ const edge = strip.firstChild;
166
+ for (let c = a - 1; c >= now[0]; c--) strip.insertBefore(make(c), edge);
167
+ for (let c = b + 1; c <= now[1]; c++) strip.appendChild(make(c));
168
+ }
169
+
141
170
  /* One cell of the window: the column decides what it holds. The group is the column's ordinal divided by the number of
142
171
  * metrics — `0` is the total over the files, `g ≥ 1` is the g-th column of the order — and it is the same arithmetic
143
172
  * the header is placed by, so a column and its caption cannot drift apart. The model lists the *enabled* files in the
@@ -153,9 +182,13 @@ function appCell(model, c, now, cache) {
153
182
  return appNum(now ? valueParts(cell) : cellParts(cell.value, cell.delta, '−'), cls);
154
183
  }
155
184
 
156
- /* A row of the window: the caption of the commit and the numbers of the columns the window holds. The top row is the
157
- * state at HEAD (`nowModel`) rather than the last commit's cells: an absolute number stands in the table once, and
158
- * the deltas below it add up to it. */
185
+ /* A row of the window: the caption of the commit and an empty strip of numbers for the window to fill (`appCells`).
186
+ * The top row is the state at HEAD (`nowModel`) rather than the last commit's cells: an absolute number stands in the
187
+ * table once, and the deltas below it add up to it.
188
+ *
189
+ * The model is kept in the entry beside the node because the numbers of a row depend on the commit and the choice
190
+ * rather than on the place the window stands at: a step sideways asks for a cell's arithmetic, never for `rowModel`
191
+ * again. The strip is born empty — the range `[c0, c0 - 1]` — and is filled by the first `appCells`. */
159
192
  function appRow(cache, r, span) {
160
193
  const last = appData.rows.length;
161
194
  const now = r === 0;
@@ -163,28 +196,36 @@ function appRow(cache, r, span) {
163
196
  const model = now
164
197
  ? nowModel(appData.now, cache.keys, appView.files)
165
198
  : rowModel(appData.rows[i].values, i === 0 ? null : appData.rows[i - 1].values, cache.keys, appView.files);
166
- const row = appEl('div', 'row' + (now ? ' now' : ''));
167
- row.style.top = (APP_HEAD + r * APP_ROW) + 'px';
199
+ const el = appEl('div', 'row' + (now ? ' now' : ''));
200
+ el.style.top = (APP_HEAD + r * APP_ROW) + 'px';
168
201
  const commit = appEl('div', 'c-commit');
169
202
  if (now) commit.textContent = appUi.now;
170
203
  else commit.appendChild(appCommit(appData.rows[i]));
171
- row.appendChild(commit);
204
+ el.appendChild(commit);
172
205
  const cells = appEl('div', 'cells');
173
- /* The numbers stand beside the commit column rather than under it: the column is pinned over the content, so what the
174
- * grid holds begins where the column ends. */
175
- cells.style.left = (APP_COMMIT + span.c0 * APP_COL) + 'px';
176
- for (let c = span.c0; c <= span.c1; c++) cells.appendChild(appCell(model, c, now, cache));
177
- row.appendChild(cells);
178
- return row;
206
+ el.appendChild(cells);
207
+ return { el: el, cells: cells, model: model, now: now, cols: [span.c0, span.c0 - 1] };
208
+ }
209
+
210
+ /* A row's numbers at the window's columns: the columns the two windows share keep their cells, and a column that
211
+ * entered is made. The numbers stand beside the pinned commit column rather than under it — the column lies over the
212
+ * content — so the strip is placed by the window's first column, and the cells of it follow their strip. A step down
213
+ * the table asks for the columns it already holds, and the row is not touched at all. */
214
+ function appCells(entry, cache, span) {
215
+ const cols = [span.c0, span.c1];
216
+ if (entry.cols[0] === cols[0] && entry.cols[1] === cols[1]) return;
217
+ entry.cells.style.left = (APP_COMMIT + cols[0] * APP_COL) + 'px';
218
+ appStrip(entry.cells, entry.cols, cols, (c) => appCell(entry.model, c, entry.now, cache));
219
+ entry.cols = cols;
179
220
  }
180
221
 
181
222
  /* A built row into the window: the map is what says which rows are in the markup, so a row goes into it where it is
182
223
  * made — otherwise a window that moved would build its rows beside the ones that are still there. */
183
224
  function appPlace(cache, r, span) {
184
- const row = appRow(cache, r, span);
185
- cache.rows.set(r, row);
186
- cache.grid.appendChild(row);
187
- return row;
225
+ const entry = appRow(cache, r, span);
226
+ cache.rows.set(r, entry);
227
+ appCells(entry, cache, span);
228
+ cache.grid.appendChild(entry.el);
188
229
  }
189
230
 
190
231
  /* A caption of the header: a file's name or a metric's. The room is a fixed number of columns, so a name that does
@@ -197,30 +238,39 @@ function appCaption(text, cls, span) {
197
238
  return el;
198
239
  }
199
240
 
200
- /* The header: the total and the enabled files over their columns, and one caption per metric column under them. It
201
- * holds the window's columns alone and is built again only when the window moves sideways scrolling down leaves it
202
- * untouched, and the two rows of it are placed by `grid-column`, so a group of metrics is a group in the grid. */
203
- function appHead(cache, span) {
204
- const count = cache.keys.length;
205
- const g0 = Math.floor(span.c0 / count);
206
- const g1 = Math.floor(span.c1 / count);
207
- const left = (APP_COMMIT + g0 * count * APP_COL) + 'px';
208
- const head = appEl('div', 'head');
209
- head.appendChild(appEl('div', 'c-commit', appUi.commit));
241
+ /* The header's frame: the commit column and the two empty strips that `appStrip` fills the names of the groups over
242
+ * the columns, their metrics under them. An empty strip says `[0, -1]`, which is what makes the first `appHead` fill
243
+ * it rather than move it. */
244
+ function appHeadNew() {
245
+ const el = appEl('div', 'head');
210
246
  const groups = appEl('div', 'hgroups');
211
- groups.style.left = left;
212
247
  const metrics = appEl('div', 'hmetrics');
213
- metrics.style.left = left;
214
- for (let g = g0; g <= g1; g++) {
215
- const label = g === 0 ? appUi.total : appData.files[cache.list[g - 1]].label;
216
- groups.appendChild(appCaption(label, 'gh', count));
217
- for (let mi = 0; mi < count; mi++) {
218
- metrics.appendChild(appCaption(cache.labels[mi], mi === 0 ? 'g' : '', 1));
219
- }
220
- }
221
- head.appendChild(groups);
222
- head.appendChild(metrics);
223
- return head;
248
+ el.appendChild(appEl('div', 'c-commit', appUi.commit));
249
+ el.appendChild(groups);
250
+ el.appendChild(metrics);
251
+ return { el: el, groups: groups, metrics: metrics, g: [0, -1] };
252
+ }
253
+
254
+ /* The header: the total and the enabled files over their columns, and one caption per metric column under them. The two
255
+ * strips move by the same `appStrip` as a row's numbers, and the unit of the metrics line is a column, so its strip is
256
+ * asked for whole groups — the metrics of a column stand under their file even when the window cuts the last group.
257
+ * A window that did not leave a group (a step down the table) leaves the header alone. */
258
+ function appHead(cache, span) {
259
+ const count = cache.keys.length;
260
+ const g0 = count === 0 ? 0 : Math.floor(span.c0 / count);
261
+ const g1 = count === 0 ? -1 : Math.floor(span.c1 / count);
262
+ if (cache.head === null) cache.head = appHeadNew();
263
+ const h = cache.head;
264
+ if (h.g[0] === g0 && h.g[1] === g1) return h.el;
265
+ const was = h.g;
266
+ h.g = [g0, g1];
267
+ h.groups.style.left = (APP_COMMIT + g0 * count * APP_COL) + 'px';
268
+ h.metrics.style.left = h.groups.style.left;
269
+ appStrip(h.groups, was, h.g, (g) => appCaption(
270
+ g === 0 ? appUi.total : appData.files[cache.list[g - 1]].label, 'gh', count));
271
+ appStrip(h.metrics, [was[0] * count, was[1] * count + count - 1], [g0 * count, g1 * count + count - 1],
272
+ (c) => appCaption(cache.labels[c % count], c % count === 0 ? 'g' : '', 1));
273
+ return h.el;
224
274
  }
225
275
 
226
276
  /* The window the shell shows: the rows and the columns, in the ordinals of the whole grid, and the size of that
@@ -259,26 +309,32 @@ export function appSpan(cache) {
259
309
  };
260
310
  }
261
311
 
262
- /* The window drawn. A scroll costs what left the window and what entered it, and nothing else: the row that is
263
- * already built is not touched. Sideways the window of columns is another window the header and the rows are
264
- * built again, because a column that is not there cannot be shown; the reader's own choice (`redraw`) is the same
265
- * kind of change, and comes here by the same road. */
312
+ /* The window drawn. Moving over the grid costs what left it and what entered it, and nothing else: the rows in the
313
+ * markup are the window's rows (what left it is dropped, what entered it is made) and each of them holds the columns
314
+ * of the window (`appCells`), so a step in either direction is paid by the rows that moved rather than by the table.
315
+ *
316
+ * The reader's own choice (`redraw`) is the one thing that cannot be a move: the columns of a switched-off file are
317
+ * simply not among the columns that are built, so there is nothing to hide and nothing to recount — the totals are the
318
+ * sum over the files that are on (`rowModel`), and the window is built from nothing. The shell's own size is the same
319
+ * kind of change: it decides how much of the grid is in sight, which is not a place to move to. */
266
320
  export function appWindow(cache, redraw) {
267
321
  const span = appSpan(cache);
268
322
  const was = cache.win;
269
323
  cache.win = span;
270
- if (redraw === true || was === null || was.c0 !== span.c0 || was.c1 !== span.c1) {
271
- const head = cache.grid.querySelector('.head');
272
- if (head !== null) head.remove();
273
- cache.grid.insertBefore(appHead(cache, span), cache.grid.firstChild);
324
+ if (redraw === true || was === null) {
325
+ /* The grid holds the header and the rows of the window and nothing else, so the whole of it is thrown away rather
326
+ * than tracked piece by piece — and the rows that are made below say in the map that they are there. */
327
+ cache.grid.textContent = '';
274
328
  cache.rows.clear();
275
- [...cache.grid.querySelectorAll('.row')].forEach((row) => row.remove());
329
+ cache.head = null;
330
+ cache.grid.appendChild(appHead(cache, span));
276
331
  for (let r = span.r0; r <= span.r1; r++) appPlace(cache, r, span);
277
332
  return;
278
333
  }
334
+ appHead(cache, span);
279
335
  [...cache.rows.keys()].forEach((r) => {
280
- if (r >= span.r0 && r <= span.r1) return;
281
- cache.rows.get(r).remove();
336
+ if (r >= span.r0 && r <= span.r1) { appCells(cache.rows.get(r), cache, span); return; }
337
+ cache.rows.get(r).el.remove();
282
338
  cache.rows.delete(r);
283
339
  });
284
340
  for (let r = span.r0; r <= span.r1; r++) {
@@ -299,6 +355,7 @@ export function appTable(grid) {
299
355
  rank: [],
300
356
  slot: [],
301
357
  rows: new Map(),
358
+ head: null,
302
359
  win: null
303
360
  };
304
361
  cache.shell.addEventListener('scroll', () => appWindow(cache));