@vernikr/size-report 2.5.0 → 2.6.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/src/metrics.js CHANGED
@@ -4,9 +4,9 @@ import { EXACT_STRATEGIES, assertCompilable, byteLen, minifyForm, strategyFor }
4
4
  import { MINIFY_LOADERS, minifier, minifyWithEsbuild } from './minify.js';
5
5
  import { CHARS_PER_TOKEN, isBinary, tokenCount, tokenizer } from './tokens.js';
6
6
 
7
- /* The registry of metrics: what is measured, whether a metric needs the text, and how honest
8
- * its number is. Separate from the ways of stripping ballast: a metric is a promise about a
9
- * number rather than a way to obtain one.
7
+ /* The registry of metrics: what is measured, whether a metric needs the text, and how its
8
+ * number is obtained. Separate from the ways of stripping ballast: a metric is a promise about
9
+ * a number rather than a way to obtain one.
10
10
  *
11
11
  * The description a reader sees comes from `metricView` rather than from the registry fields:
12
12
  * for one and the same metric it depends on the settings (`min` is either real compression or
@@ -14,8 +14,8 @@ import { CHARS_PER_TOKEN, isBinary, tokenCount, tokenizer } from './tokens.js';
14
14
  * question have nowhere to drift apart. */
15
15
 
16
16
  /* The ways to obtain the `min` metric: stripping ballast and real compression. Their mechanism
17
- * differs, and so does their promise, so each has its own method, its own honesty and its own
18
- * note — and neither is passed off as the other. */
17
+ * differs, and so does the method a reader is told, so each has its own text and neither is
18
+ * passed off as the other. */
19
19
  export const MINIFY_ENGINES = ['strip', 'esbuild'];
20
20
 
21
21
  const STYLES = {
@@ -33,12 +33,12 @@ const STYLES = {
33
33
  },
34
34
  method: { ru: 'esbuild {version} (minify, rename)', en: 'esbuild {version} (minify, rename)' },
35
35
  fallback: {
36
- ru: '; остальные форматы ({exts}) — упрощение без комментариев и отступов, то есть приближение',
37
- en: '; other formats ({exts}) lose comments and indentation — an approximation'
36
+ ru: '; остальные форматы ({exts}) — упрощение без комментариев и отступов',
37
+ en: '; other formats ({exts}) lose comments and indentation'
38
38
  },
39
39
  unavailable: {
40
- ru: ' (минификатор esbuild недоступен — счёт упрощением, то есть приближением)',
41
- en: ' (the esbuild minifier is unavailable — comments and indentation are stripped: an approximation)'
40
+ ru: ' (минификатор esbuild недоступен — счёт идёт упрощением)',
41
+ en: ' (the esbuild minifier is unavailable — comments and indentation are stripped)'
42
42
  }
43
43
  },
44
44
  tok: {
@@ -48,12 +48,12 @@ const STYLES = {
48
48
  },
49
49
  method: { ru: '{tool} {version}, {encoding} (BPE)', en: '{tool} {version}, {encoding} (BPE)' },
50
50
  binary: {
51
- ru: '; для бинарных форматов ({exts}) это счёт байтов, а не текста — приближение',
52
- en: '; binary formats ({exts}) are counted by bytes rather than text — an approximation'
51
+ ru: '; для бинарных форматов ({exts}) это счёт байтов, а не текста',
52
+ en: '; binary formats ({exts}) are counted by bytes rather than text'
53
53
  },
54
54
  estimate: {
55
- ru: 'оценка по длине: 1 токен ≈ {chars} знака ({encoding} недоступен) — приближение',
56
- en: 'length-based estimate: 1 token ≈ {chars} characters ({encoding} is unavailable) — an approximation'
55
+ ru: 'оценка по длине: 1 токен ≈ {chars} знака ({encoding} недоступен)',
56
+ en: 'length-based estimate: 1 token ≈ {chars} characters ({encoding} is unavailable)'
57
57
  }
58
58
  }
59
59
  };
@@ -71,7 +71,6 @@ export const METRICS = {
71
71
  ru: 'размер объекта git',
72
72
  en: 'the size of the git object'
73
73
  },
74
- accuracy: 'exact',
75
74
  measure: (text) => byteLen(text)
76
75
  },
77
76
  min: {
@@ -103,57 +102,50 @@ export const METRICS = {
103
102
  needsText: true,
104
103
  note: { ru: 'сжатый поток (zlib, уровень 9)', en: 'compressed stream (zlib, level 9)' },
105
104
  method: { ru: 'zlib, уровень 9', en: 'zlib, level 9' },
106
- accuracy: 'exact',
107
105
  measure: (text) => zlib.gzipSync(Buffer.from(text, 'utf8'), { level: 9 }).length
108
106
  }
109
107
  };
110
108
 
111
109
  /* The description of a metric for a reader: `note` is what the number means, `method` how it
112
- * was obtained, `accuracy` whether it is exact or approximate. */
110
+ * was obtained. */
113
111
  export function metricView(name, cfg) {
114
112
  const metric = METRICS[name];
115
113
  if (metric.view !== undefined) return metric.view(cfg);
116
114
  return {
117
115
  label: metric.label,
118
116
  note: metric.note[cfg.locale],
119
- method: metric.method[cfg.locale],
120
- accuracy: metric.accuracy
117
+ method: metric.method[cfg.locale]
121
118
  };
122
119
  }
123
120
 
124
- /* The label of the `min` metric. The honesty convention: `accuracy` speaks about the worst in
125
- * the column, while the method says where exactly the approximation is so one format without
126
- * a minifier makes the whole metric approximate instead of hiding behind the "exact" of a
127
- * neighbouring file. The worst is taken by the same rule as the cell marks (`pointExact`)
128
- * rather than by the name of the method: a report with no approximate format at all is exact
129
- * even with the ballast stripped. */
121
+ /* The label of the `min` metric. The method names the formats this metric counts another way: a
122
+ * format the minifier does not take is given in words rather than hidden behind the neighbouring
123
+ * one, so a reader sees what the column is made of. The list comes from the same rule the counting
124
+ * uses (`otherCountFormats`) rather than from the name of the method. */
130
125
  function minView(cfg) {
131
126
  const loc = cfg.locale;
132
- const rough = approximateFormats('min', cfg);
127
+ const other = otherCountFormats('min', cfg);
133
128
  if (minEngine(cfg) === 'esbuild') {
134
129
  let method = STYLES.esbuild.method[loc].replace('{version}', minifier().version);
135
- if (rough.length > 0) method += STYLES.esbuild.fallback[loc].replace('{exts}', rough.join(' '));
130
+ if (other.length > 0) method += STYLES.esbuild.fallback[loc].replace('{exts}', other.join(' '));
136
131
  return {
137
132
  label: METRICS.min.label,
138
133
  note: STYLES.esbuild.note[loc],
139
- method: method,
140
- accuracy: rough.length === 0 ? 'exact' : 'approximate'
134
+ method: method
141
135
  };
142
136
  }
143
137
  const degraded = cfg.minify.engine === 'esbuild';
144
138
  return {
145
139
  label: METRICS.min.label,
146
140
  note: STYLES.strip.note[loc],
147
- method: STYLES.strip.method[loc] + (degraded ? STYLES.esbuild.unavailable[loc] : ''),
148
- accuracy: rough.length === 0 ? 'exact' : 'approximate'
141
+ method: STYLES.strip.method[loc] + (degraded ? STYLES.esbuild.unavailable[loc] : '')
149
142
  };
150
143
  }
151
144
 
152
- /* The label of the `tok` metric. The honesty convention is the same as for `min`: the method
153
- * says which dictionary produced the number (family and encoding are part of the count rather
154
- * than a detail), and `accuracy` says whether it is exact or approximate. It turns approximate
155
- * in two cases, both spelled out: formats for which tokens are not counted (their number runs
156
- * by bytes) and a missing dictionary (then the count is an estimate by length). */
145
+ /* The label of the `tok` metric. The method says which dictionary produced the number (family and
146
+ * encoding are part of the count rather than a detail) and names the two cases where the count is
147
+ * another one, both spelled out: formats for which tokens are not counted (their number runs by
148
+ * bytes) and a missing dictionary (then the count is an estimate by length). */
157
149
  function tokView(cfg) {
158
150
  const loc = cfg.locale;
159
151
  const settings = cfg.tokens;
@@ -163,44 +155,38 @@ function tokView(cfg) {
163
155
  label: METRICS.tok.label,
164
156
  note: STYLES.tok.note[loc],
165
157
  method: STYLES.tok.estimate[loc]
166
- .replace('{chars}', CHARS_PER_TOKEN).replace('{encoding}', settings.encoding),
167
- accuracy: 'approximate'
158
+ .replace('{chars}', CHARS_PER_TOKEN).replace('{encoding}', settings.encoding)
168
159
  };
169
160
  }
170
- const binary = approximateFormats('tok', cfg);
161
+ const binary = otherCountFormats('tok', cfg);
171
162
  let method = STYLES.tok.method[loc]
172
163
  .replace('{tool}', 'gpt-tokenizer').replace('{version}', version).replace('{encoding}', settings.encoding);
173
164
  if (binary.length > 0) method += STYLES.tok.binary[loc].replace('{exts}', binary.join(' '));
174
165
  return {
175
166
  label: METRICS.tok.label,
176
167
  note: STYLES.tok.note[loc],
177
- method: method,
178
- accuracy: binary.length === 0 ? 'exact' : 'approximate'
168
+ method: method
179
169
  };
180
170
  }
181
171
 
182
- /* Whether a particular cell holds an exact number one rule for both the metric label and the
183
- * cell mark. That is why the label cannot drift from the cells, and why the list of
184
- * approximate formats is computed here, by the same rule.
185
- *
186
- * `min` is exact where the file really is minified: by the minifier, or by parsing a format
187
- * that cannot get any shorter (JSON loses only insignificant whitespace — the list of exact
188
- * strategies is owned by `strip.js`, where the strategies live). `tok` is exact where a
189
- * dictionary exists and the format is text: the "tokens" of a picture or a font are its bytes.
190
- * `raw` and `gzip` are always exact: they are unambiguous quantities. */
191
- export function pointExact(name, file, cfg) {
192
- if (name === 'min') return minifiedForm(file, cfg);
193
- if (name === 'tok') return tokenizer(cfg.tokens).tool !== null && !isBinary(file);
194
- return true;
172
+ /* Whether a file of this report is really minified: by the minifier, or by parsing a format that
173
+ * cannot get any shorter (JSON loses only insignificant whitespace the list of exact strategies
174
+ * is owned by `strip.js`, where the strategies live). */
175
+ function minifiedForm(file, cfg) {
176
+ return esbuildLoader(file, cfg) !== null || EXACT_STRATEGIES.indexOf(strategyFor(file, cfg)) >= 0;
195
177
  }
196
178
 
197
- /* The formats of this report that will be measured approximately. The list is derived from the
198
- * settings and the rule of exactness rather than written by hand. */
199
- function approximateFormats(name, cfg) {
179
+ /* The formats of this report that the metric counts another way the minifier does not take
180
+ * them, or tokens are meaningless for them (the "tokens" of a picture or a font are its bytes).
181
+ * The list is derived from the settings and the counting rule rather than written by hand, and
182
+ * it is what the method names in words. */
183
+ function otherCountFormats(name, cfg) {
200
184
  const exts = [];
201
185
  cfg.columns.forEach((col) => {
202
186
  col.paths.forEach((p) => {
203
- if (pointExact(name, p, cfg)) return;
187
+ const counted = name === 'min' ? minifiedForm(p, cfg)
188
+ : tokenizer(cfg.tokens).tool !== null && !isBinary(p);
189
+ if (counted) return;
204
190
  const ext = path.extname(p).toLowerCase();
205
191
  if (exts.indexOf(ext) < 0) exts.push(ext);
206
192
  });
@@ -208,10 +194,6 @@ function approximateFormats(name, cfg) {
208
194
  return exts.sort();
209
195
  }
210
196
 
211
- function minifiedForm(file, cfg) {
212
- return esbuildLoader(file, cfg) !== null || EXACT_STRATEGIES.indexOf(strategyFor(file, cfg)) >= 0;
213
- }
214
-
215
197
  /* Whether the file goes to the minifier: compression is requested, available and not overridden
216
198
  * by an explicit choice of the project — `minify.ext` outranks the engine and serves as the way
217
199
  * out when an extension lied about its content. One answer serves two questions: how to count
@@ -224,8 +206,8 @@ function esbuildLoader(file, cfg) {
224
206
  }
225
207
 
226
208
  /* The engine actually in force: the requested one may be unavailable, in which case the metric
227
- * falls back to another count — and the fallback is announced (`sensorGaps`), or an
228
- * approximation would travel as an exact number. */
209
+ * counts another way — and that is announced (`sensorGaps`), or a different count would travel
210
+ * into CI as the requested one. */
229
211
  export function minEngine(cfg) {
230
212
  if (cfg.minify.engine !== 'esbuild') return 'strip';
231
213
  return minifier().tool === null ? 'strip' : 'esbuild';
package/src/minify.js CHANGED
@@ -10,8 +10,8 @@ import { refuseCause } from './refusal.js';
10
10
  * number. */
11
11
 
12
12
  /* The extensions the minifier answers for. This table is the single source of truth both for
13
- * the measurement and for the metric label ("the other formats are an approximation"), so the
14
- * two cannot drift apart. JSX and TSX are not here: the output depends on the project's `jsx`
13
+ * the measurement and for the metric label ("the other formats lose comments and
14
+ * indentation"), so the two cannot drift apart. JSX and TSX are not here: the output depends on the project's `jsx`
15
15
  * setting (`React.createElement` versus `react/jsx-runtime`), and measuring someone else's
16
16
  * decision about a runtime is not this tool's business — such files are honestly counted as a
17
17
  * simplification. */
package/src/modes.js CHANGED
@@ -26,9 +26,9 @@ function kmb(bytes) {
26
26
  }
27
27
 
28
28
  /* Degradation is a fact of the report, not an error: the numbers came from a different
29
- * method (stripping instead of minification, an estimate instead of an exact count) because
29
+ * method (stripping instead of minification, an estimate instead of a counted one) because
30
30
  * an optional dependency is missing. The fact is printed once per sensor and becomes code
31
- * 4 — otherwise an approximation would travel into CI as success. */
31
+ * 4 — otherwise a different count would travel into CI as the requested one. */
32
32
  function note(gaps) {
33
33
  gaps.forEach((gap) => console.error('! ' + gap.why + '\n fix: ' + gap.fix));
34
34
  return gaps.length === 0 ? EXIT.OK : EXIT.SENSOR;
@@ -39,11 +39,11 @@ function sensorNote(cfg) {
39
39
  }
40
40
 
41
41
  /* The mode's verdict together with the sensor notes: the note is printed always — silence
42
- * about a different count reads as an exact number, and a disagreement would be left without
43
- * a cause — while the code stays the more important one. A violation outranks an
44
- * approximation (the same order as in `check` and `doctor`): code 4 claims the numbers are
45
- * honest but counted differently, and when the table disagrees nobody checked that — the
46
- * disagreement may be a real edit that went past the report. */
42
+ * about a different count reads as the requested one, and a disagreement would be left without
43
+ * a cause — while the code stays the more important one. A violation outranks a sensor note
44
+ * (the same order as in `check` and `doctor`): code 4 claims the difference is explained by the
45
+ * missing sensor, and when the table disagrees nobody checked that — the disagreement may be a
46
+ * real edit that went past the report. */
47
47
  function verdict(code, gaps) {
48
48
  const sensors = note(gaps);
49
49
  return code === EXIT.OK ? sensors : code;
@@ -127,7 +127,7 @@ export function coverageMode(cfg, root, configFile, asJson) {
127
127
  /* Diagnostics in one answer (`size doctor`): environment, dependencies, settings and
128
128
  * coverage, assembled from the same pieces as the other modes. The exit code is not
129
129
  * "something is wrong" but the first by importance (settings → history → coverage →
130
- * approximation): an agent branches on it, a human reads the text. */
130
+ * sensor): an agent branches on it, a human reads the text. */
131
131
  export function doctorMode(root, configFile, asJson) {
132
132
  return answer(doctor(root, configFile), asJson, doctorText).exit;
133
133
  }
package/src/optional.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createRequire } from 'module';
2
2
 
3
3
  /* Optional dependencies: the minifier and the tokenizer. Missing ones are not a refusal but a
4
- * different count (a simplification instead of compression, an estimate instead of an exact
5
- * count), so their loading is shared and shaped the same way: lazy, synchronous
4
+ * different count (a simplification instead of compression, an estimate instead of a counted
5
+ * one), so their loading is shared and shaped the same way: lazy, synchronous
6
6
  * (`createRequire` — measuring is one synchronous pass, and `import()` would make the whole
7
7
  * chain asynchronous for the sake of a single sensor) and without an exception escaping —
8
8
  * unavailability comes back as an answer.
package/src/page/app.css CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * The shared part is frozen by the artifact's bytes (`src/css.js`), which is why everything where the page departs from
8
8
  * its geometry is gathered in the "adaptations" section together with its reason: the shared part cannot grow, while on a
9
- * narrow screen the 300px commit column would eat the whole screen. */
9
+ * narrow screen the commit column would eat the whole screen. */
10
10
 
11
11
  :root {
12
12
  color-scheme: light dark;
@@ -111,12 +111,8 @@ h1 { margin: 0 0 3px; font-size: 21px; font-weight: 650; letter-spacing: -.012em
111
111
  .box.plain input { cursor: default; }
112
112
 
113
113
  /* There is no legend under the file tree, and on purpose: below the list it pushed the numbers away, while its content
114
- * already stands next to what it explains — the colour of a delta is named by the sign of the number itself, accuracy
115
- * stands under the metric switches, and the mark of a file that is not there yet lives in the cell's tooltip.
116
- *
117
- * An approximate number is marked by a dashed line rather than a colour: colour in the table is taken by the delta (growth
118
- * and fall), and a second meaning on the same sign would read as the first. */
119
- #grid td.approx { text-decoration: underline dotted; text-underline-offset: 2.5px; }
114
+ * already stands next to what it explains — the colour of a delta is named by the sign of the number itself, the way each
115
+ * number was counted stands under the metric switches, and the mark of a gap lives in the cell's text. */
120
116
 
121
117
  /* The table has a frame and a scroll of its own: the header and the commit column stick to it (the rules of stickiness
122
118
  * live in the shared part) rather than to the page, so scrolling sideways shows whose row it is while scrolling down shows
@@ -230,5 +226,7 @@ h1 { margin: 0 0 3px; font-size: 21px; font-weight: 650; letter-spacing: -.012em
230
226
  h1 { font-size: 18px; }
231
227
  #grid { font-size: 12px; }
232
228
  .shell { max-height: calc(100vh - 260px); }
233
- .clip { width: 190px; }
229
+ /* The commit column needs no rule of its own here any more: its width is the measure `--clip` of the shared part
230
+ * (`src/table.css`), and on a narrow screen that measure stands as it is. A shorter one would be `--clip: 150px`
231
+ * here — the adaptation the shared part cannot make for itself. */
234
232
  }
package/src/page/app.js CHANGED
@@ -1,101 +1,150 @@
1
- import { appData, appUi, appView, appWrite, appNotice, appLinkUse, appRead, appApply, appFoldRead } from './state.js';
2
- import { appBody, appHead, appState } from './table.js';
3
- import { appPanel } from './panel.js';
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';
4
+ import { appPanel, appPanelAll, appPanelState } from './panel.js';
4
5
 
5
- /* Assembling the table: what to show (the metrics and files the reader left on) and where to put it. The table
6
- * chapter builds the markup of the head and the rows, the shared calculation gives the numbers all that is
7
- * left here is the decision and the insertion, with no numbers of its own. */
8
- function appTable() {
9
- const shown = appData.metrics.filter((m) => appView.metrics[m.key]);
10
- const metrics = shown.map((m) => m.key);
11
- const on = appView.files;
12
- const files = [];
13
- appData.files.forEach((f, i) => { if (on[i]) files.push(i); });
14
- /* The columns the last commit touched come first: the report is rebuilt after every commit, and a reader's first
15
- * question is what that edit brought. Inside each part the order stays as it comes from the settings — `sort` is
16
- * stable, and the order of the columns is what the reader is used to. The mark comes from the history (which the
17
- * engine knows) rather than from the numbers: an edit that changed no size is an edit too. */
18
- files.sort((a, b) => (appData.last[a] === true ? 0 : 1) - (appData.last[b] === true ? 0 : 1));
6
+ /* Assembling the report: the table is built once (`appTable` of the table chapter) and everything afterwards only
7
+ * shows, hides and recounts. A click on any switch therefore costs a class, a number and the fields it reached the
8
+ * whole table used to be destroyed and built again, which was 81 % of the cost of a click and produced a hundred
9
+ * thousand dead nodes for the collector to walk.
10
+ *
11
+ * Hence two paths and no third: `appPaint` draws the whole view (the first drawing, a record from the browser's
12
+ * 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
+ */
19
15
 
20
- const table = document.getElementById('grid');
21
- table.textContent = '';
22
- appState(metrics.length, files.length);
23
- if (metrics.length === 0) return;
16
+ // The table's cache of node references: made once, at the first drawing.
17
+ let appCache = null;
24
18
 
25
- table.appendChild(appHead(shown, files, metrics));
26
- table.appendChild(appBody(metrics, files));
19
+ /* 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
+ * written once — with the table rather than with every drawing of it. */
21
+ function appNote() {
27
22
  document.getElementById('note').textContent = appUi.note
28
23
  .replace('{rows}', appData.rows.length)
29
24
  .replace('{command}', appData.report.fixCommand);
25
+ }
26
+
27
+ /* What the empty states are told: how many metrics and how many files are left. The table stands there in either
28
+ * case (it is built once) — the words are about what is shown. */
29
+ export function appCounts() {
30
+ appState(appData.metrics.filter((m) => appView.metrics[m.key] === true).length,
31
+ appView.files.filter((on) => on === true).length);
32
+ }
33
+
34
+ /* The whole view drawn: every column, the totals of the whole selection, the metrics, the empty states and the panel's
35
+ * fields. This is what a link, a record from the memory and the first drawing need — and it makes no node either. */
36
+ export function appPaint() {
37
+ appData.files.forEach((_f, i) => appColumn(appCache, i, appView.files[i]));
38
+ appTotalsReset(appCache);
39
+ appMetrics(appCache);
40
+ appCounts();
41
+ appPanelAll();
30
42
  appWrite();
31
43
  }
32
44
 
33
- /* The panel's scroll is a property of the panel rather than of the markup, which is why it survives a rebuild:
34
- * otherwise every click on a checkbox would send the list back to the top and the files at its end would be
35
- * unreachable. Both the panel's scroll and the file list's are remembered — each has one of its own, and in a
36
- * narrow window it is the list that scrolls. The elements are the ones the page really has (`#panel` from the
37
- * markup, `.files` inside it from the panel): there is no second list of scroll places in the package. */
38
- const appScrolled = ['#panel', '#panel .files'];
39
- function appScrollTop() {
40
- return appScrolled.map((sel) => {
41
- const el = document.querySelector(sel);
42
- return el === null ? 0 : el.scrollTop;
43
- });
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. */
47
+ export function appSwitch(i, on) {
48
+ if (appView.files[i] === on) return;
49
+ appView.files[i] = on;
50
+ appColumn(appCache, i, on);
51
+ appContribute(appCache, i, on);
52
+ appTotals(appCache);
53
+ appCounts();
54
+ appPanelState([i]);
55
+ appWrite();
56
+ appNotice('');
44
57
  }
45
58
 
46
- function appScrollBack(saved) {
47
- appScrolled.forEach((sel, i) => {
48
- const el = document.querySelector(sel);
49
- if (el !== null) el.scrollTop = saved[i];
59
+ /* A group switched at once — a folder or a category: the same work per file, then the totals once and the fields of
60
+ * 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). */
62
+ export function appSwitchGroup(indexes, on) {
63
+ const touched = indexes.filter((i) => appView.files[i] !== on);
64
+ touched.forEach((i) => {
65
+ appView.files[i] = on;
66
+ appColumn(appCache, i, on);
67
+ appContribute(appCache, i, on);
50
68
  });
69
+ appTotals(appCache);
70
+ appCounts();
71
+ appPanelState(touched);
72
+ appWrite();
73
+ appNotice('');
51
74
  }
52
75
 
53
- /* The panel is redrawn whole, so the field under the keyboard and the scroll come back to their places after every
54
- * rebuild: otherwise switching with Tab and Space would mean walking the panel from the start again, and the scroll
55
- * would have to find its place anew. A field is identified by its ordinal number — the order of the panel's fields
56
- * does not change between rebuilds. The focus is set without scrolling (`preventScroll`): it returns the keyboard
57
- * rather than moving the list. */
58
- function appRender(keepNotice) {
59
- const at = Array.from(document.querySelectorAll('#panel input')).indexOf(document.activeElement);
60
- const saved = appScrollTop();
76
+ /* One metric switched: a class on the table and the headings' `colSpan`. The totals do not move with a metric — they
77
+ * are sums over files and the metric's own field is the box the reader just clicked. */
78
+ export function appSwitchMetric() {
79
+ appMetrics(appCache);
80
+ appCounts();
81
+ appWrite();
82
+ appNotice('');
83
+ }
84
+
85
+ /* The first drawing: the choice is already in the view (the link and the memory are applied above), the panel is
86
+ * built to match it, the table is built once — every column of every file — and the view is painted over it. */
87
+ function appFirst() {
61
88
  appPanel();
62
- appScrollBack(saved);
63
- if (at >= 0) document.querySelectorAll('#panel input')[at].focus({ preventScroll: true });
64
- appTable();
65
- /* The message about the link survives the very drawing it caused, and fades on the reader's next action: he has read
66
- * it by then. */
67
- if (keepNotice !== true) appNotice('');
89
+ appCache = appTable(document.getElementById('grid'));
90
+ appNote();
91
+ appPaint();
68
92
  }
69
93
 
94
+ /* The page's one asynchronous step, and why there is one. The block in the artifact is packed, and the platform's own
95
+ * unpacker answers with a promise, so the first drawing waits for it; everything after the first drawing is as
96
+ * synchronous as it was, and a click costs what it cost. A host that cannot unpack is told in words instead of being
97
+ * left with an empty table — the reader would not know whether the report or the browser is at fault. */
98
+ let appBooted = false;
99
+
70
100
  /* Restoring happens before the first drawing: for someone opening the page for the first time the view has to be the
71
101
  * default rather than someone else's choice. A link outranks the memory: it is the sender's explicit choice, and
72
102
  * while the reader has changed nothing it does not replace his own — writing it to the memory is what does not
73
103
  * happen. A refused link is not an empty table but a message: the reader sees both what happened and what is shown
74
104
  * instead. */
75
- const appStart = appLinkUse();
76
- if (appStart === 'ours') appTransient = true;
77
- else if (appStart === 'refused') appForeign = true;
78
- if (appStart !== 'ours') {
79
- const appSaved = appRead();
80
- if (appSaved !== null) appApply(appSaved);
105
+ async function appBegin() {
106
+ try {
107
+ appBoot(await appUnpack(document.getElementById('data')));
108
+ } catch (_e) {
109
+ appNotice(appUi.unpack);
110
+ return;
111
+ }
112
+ const appStart = appLinkUse();
113
+ if (appStart === 'ours') appTransient = true;
114
+ else if (appStart === 'refused') appForeign = true;
115
+ if (appStart !== 'ours') {
116
+ const appSaved = appRead();
117
+ if (appSaved !== null) appApply(appSaved);
118
+ }
119
+ /* The folded tree is the onlooker's memory rather than the reader's choice: it comes back even when someone
120
+ * else's link is open (otherwise a link sent over would unfold the tree again on every visit). */
121
+ appFoldRead();
122
+ appFirst();
123
+ appBooted = true;
124
+ appStartup = false;
125
+ appForeign = false;
126
+ appTransient = false;
81
127
  }
82
- /* The folded tree is the onlooker's memory rather than the reader's choice: it comes back even when someone else's
83
- * link is open (otherwise a link sent over would unfold the tree again on every visit). */
84
- appFoldRead();
85
- appRender(true);
86
- appStartup = false;
87
- appForeign = false;
88
- appTransient = false;
128
+
129
+ /* Whoever opened the page and has to know when the first drawing is over waits for this promise the checks do
130
+ * (`tools/page-harness.js`); the page itself has no use for it. */
131
+ window.appDrawn = appBegin();
89
132
 
90
133
  /* The anchor changed on an open page: the choice in the new address is applied by the same code as at opening. The
91
134
  * page's own address raises no such event (`replaceState` does not), so there is no loop here. A refusal touches
92
135
  * neither the view — the reader keeps looking at what he looked at — nor the address: it was sent to the reader,
93
- * and until he acts it is not ours. */
136
+ * and until he acts it is not ours. The message about the link stays: this drawing is exactly what it explains. */
94
137
  window.addEventListener('hashchange', () => {
138
+ /* The first drawing has not happened yet: the address the page was opened with is the business of that drawing,
139
+ * and a change that arrives before it has drawn nothing to replace. */
140
+ 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();
95
144
  const state = appLinkUse();
96
145
  if (state === 'refused') appForeign = true;
97
146
  appTransient = state === 'ours';
98
- appRender(true);
147
+ appPaint();
99
148
  appForeign = false;
100
149
  appTransient = false;
101
150
  });