@vernikr/size-report 2.4.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/README.md +989 -1081
- package/bin/postinstall.js +17 -18
- package/bin/size.js +2 -2
- package/package.json +3 -4
- package/src/args.js +72 -72
- package/src/artifact.js +14 -14
- package/src/check.js +41 -42
- package/src/cli.js +26 -29
- package/src/config.js +87 -91
- package/src/css.js +14 -14
- package/src/data.js +26 -50
- package/src/derived.js +31 -35
- package/src/doctor.js +95 -99
- package/src/explain.js +46 -47
- package/src/git.js +66 -71
- package/src/history.js +74 -83
- package/src/hook.js +130 -149
- package/src/init.js +37 -37
- package/src/journal.js +17 -15
- package/src/locales.js +31 -22
- package/src/metrics.js +72 -89
- package/src/minify.js +28 -27
- package/src/modes.js +57 -60
- package/src/optional.js +13 -11
- package/src/page/app.css +76 -94
- package/src/page/app.js +124 -80
- package/src/page/build.js +193 -50
- package/src/page/dom.js +8 -9
- package/src/page/panel.js +157 -69
- package/src/page/payload.js +168 -0
- package/src/page/state.js +144 -104
- package/src/page/table.js +270 -86
- package/src/parse-worker.js +10 -10
- package/src/parse.js +43 -45
- package/src/project.js +100 -104
- package/src/refusal.js +75 -76
- package/src/size-table.js +41 -76
- package/src/strip/forms.js +5 -5
- package/src/strip/guard.js +28 -28
- package/src/strip/js.js +27 -27
- package/src/strip.js +17 -21
- package/src/table.css +54 -19
- package/src/tokens.js +27 -27
- package/src/tool.js +10 -11
- package/templates/README.md +71 -77
- package/templates/ci.yml +33 -33
- package/templates/size-report.config.json +3 -3
- package/CHANGELOG.md +0 -690
package/src/page/build.js
CHANGED
|
@@ -1,32 +1,38 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import zlib from 'zlib';
|
|
2
3
|
import { fill, LOCALES } from '../locales.js';
|
|
3
4
|
import { PAGE_CSS, TABLE_CSS } from '../css.js';
|
|
5
|
+
import { assertCompilable, stripCss, stripJs, stripLines } from '../strip.js';
|
|
4
6
|
|
|
5
|
-
/*
|
|
6
|
-
*
|
|
7
|
+
/* Escaping text for markup lives here, because this builder is the only place that turns data into markup: the rest
|
|
8
|
+
* is drawn as nodes by the page. */
|
|
7
9
|
export function esc(s) {
|
|
8
10
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
/*
|
|
12
|
-
*
|
|
13
|
-
* оформление страницы (`app.css`).
|
|
13
|
+
/* Building the report's page: data and program in one file, with no external reference. The styling comes as ordinary
|
|
14
|
+
* files too: the table's shared part (`table.css`) and the page's own (`app.css`).
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
* `src/derived.js`)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* The page's program is ordinary sources (the chapters in `src/page/*.js` and the shared calculation in
|
|
17
|
+
* `src/derived.js`) rather than strings inside the engine: a linter sees them, and the engine pastes them into the
|
|
18
|
+
* page. Module syntax is removed while pasting: in a browser that opened a file from disk there is nothing to resolve
|
|
19
|
+
* `import` with, and the declarations have to reach the shared scope in the order of pasting — the calculation first,
|
|
20
|
+
* then the chapters.
|
|
20
21
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* What is pasted is **squeezed** on the way in (`squeezedJs`, `squeezedCss`): comments and indentation leave only the
|
|
23
|
+
* copy inside the report, which is read in a browser, while the sources stay the ordinary files a person edits.
|
|
24
|
+
*
|
|
25
|
+
* The chapters follow the page's subjects, and the order of pasting (the list below) is the order of declarations in
|
|
26
|
+
* the assembled program: the calculation first, then the choice's state, the nodes, the panel, the table and the
|
|
27
|
+
* assembling. The chapters are **slices of one text**: pasting glues them in a row, so the assembled page would stay
|
|
28
|
+
* the same if the chapters became one file again. */
|
|
26
29
|
export function stripModules(src) {
|
|
27
30
|
return src.split('\n')
|
|
28
31
|
.filter((line) => !/^import\s.*;\s*$/.test(line))
|
|
29
|
-
|
|
32
|
+
/* `async` is part of the form rather than a decoration of it: a declaration marked `export async` is as ordinary
|
|
33
|
+
* a source as the rest, and the pasted page has no module syntax to resolve either of them with. */
|
|
34
|
+
.map((line) => line.replace(/^export\s+(?:async\s+)?(?:function|const|let|var|class)\s/,
|
|
35
|
+
(head) => head.slice('export '.length)))
|
|
30
36
|
.join('\n');
|
|
31
37
|
}
|
|
32
38
|
|
|
@@ -34,16 +40,37 @@ export function pageSource(file) {
|
|
|
34
40
|
return stripModules(fs.readFileSync(new URL(file, import.meta.url), 'utf8'));
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
/*
|
|
38
|
-
* (`test/page-view.test.js`
|
|
39
|
-
|
|
43
|
+
/* The list of chapters lives here rather than in the tests: one copy for the builder and for the guard
|
|
44
|
+
* (`test/page-view.test.js` reads the same program and compares it with the sources). The payload chapter comes
|
|
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'];
|
|
47
|
+
|
|
48
|
+
/* The form the artifact carries: the same stripping the `min` metric counts (`src/strip.js`) — comments out,
|
|
49
|
+
* indentation and blank lines out — applied to what is pasted, while the sources on disk keep everything: they are
|
|
50
|
+
* read, edited and linted by people, and only the copy inside the report is squeezed. Exported because the checks
|
|
51
|
+
* compare the page with exactly this form rather than with the sources. */
|
|
52
|
+
export function squeezedJs(code) {
|
|
53
|
+
return stripLines(stripJs(code));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function squeezedCss(css) {
|
|
57
|
+
return stripLines(stripCss(css));
|
|
58
|
+
}
|
|
40
59
|
|
|
60
|
+
/* The page's program: the chapters glued, the module syntax stripped line by line, the text squeezed — in that
|
|
61
|
+
* order, because the stripping works line by line and a comment could otherwise hide a line's shape. The squeeze
|
|
62
|
+
* may throw away nothing but comments and air, so what comes out has to parse, and the same guard the `min` metric
|
|
63
|
+
* uses says so: with no revision (the text is built here and now) and without the unparsed original, because a text
|
|
64
|
+
* that does not parse at all is not "a damaged file" but a bug of this assembler. */
|
|
41
65
|
export function pageScript() {
|
|
42
|
-
|
|
66
|
+
const code = squeezedJs(pageSource('../derived.js') + '\n'
|
|
67
|
+
+ PAGE_PARTS.map((part) => pageSource(part)).join(''));
|
|
68
|
+
assertCompilable(code, '', 'the page’s program');
|
|
69
|
+
return code;
|
|
43
70
|
}
|
|
44
71
|
|
|
45
|
-
/*
|
|
46
|
-
*
|
|
72
|
+
/* The note under the heading: what built the report and where it lies. The path is plain text rather than a link: the
|
|
73
|
+
* page opens from disk and depends on nothing. */
|
|
47
74
|
function subText(data, page) {
|
|
48
75
|
return fill(page.sub, {
|
|
49
76
|
tool: data.tool.name,
|
|
@@ -52,8 +79,8 @@ function subText(data, page) {
|
|
|
52
79
|
});
|
|
53
80
|
}
|
|
54
81
|
|
|
55
|
-
/*
|
|
56
|
-
*
|
|
82
|
+
/* The page's texts: column captions, panel labels, the legend and the empty states. They are the page's dictionary
|
|
83
|
+
* rather than the report's: the data block carries none of them, and the report's own words live in the locale. */
|
|
57
84
|
function uiText(page, loc) {
|
|
58
85
|
return {
|
|
59
86
|
commit: loc.commit,
|
|
@@ -69,47 +96,163 @@ function uiText(page, loc) {
|
|
|
69
96
|
linkForeign: page.linkForeign,
|
|
70
97
|
linkBroken: page.linkBroken,
|
|
71
98
|
linkExtra: page.linkExtra,
|
|
72
|
-
/*
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
/* Почему файла нет в отчёте — словами: причину называет движок знаком (`why`),
|
|
78
|
-
* а страница одевает знак в текст, как и всё остальное в панели. */
|
|
99
|
+
/* What a host that cannot unpack the block is told: the page's one message about its own file rather than about
|
|
100
|
+
* the report's numbers (see `appBegin`). */
|
|
101
|
+
unpack: page.unpack,
|
|
102
|
+
/* Why a file is not in the report, in words: the engine names the reason with a mark (`why`), and the page dresses
|
|
103
|
+
* the mark in text, as it does with everything else in the panel. */
|
|
79
104
|
notMeasuredRule: page.notMeasuredRule,
|
|
80
105
|
notMeasuredChoice: page.notMeasuredChoice,
|
|
106
|
+
/* The tooltip of a file's checkbox: where the file stands and how its category was decided. The
|
|
107
|
+
* panel keeps no words of its own — a Russian report stays Russian in its chrome too, and an
|
|
108
|
+
* English one gets English there. */
|
|
109
|
+
notOnHead: page.notOnHead,
|
|
110
|
+
category: page.category,
|
|
111
|
+
categoryFromConfig: page.categoryFromConfig,
|
|
112
|
+
categoryByExtension: page.categoryByExtension,
|
|
81
113
|
methodLabel: page.panelMethod,
|
|
82
114
|
empty: page.emptyMetrics,
|
|
83
115
|
noFiles: page.noFiles,
|
|
84
|
-
/* {command}
|
|
116
|
+
/* {command} is substituted by the page, which holds the data, while {now} is filled in here. */
|
|
85
117
|
note: page.note.replace(/\{now\}/g, loc.now)
|
|
86
118
|
};
|
|
87
119
|
}
|
|
88
120
|
|
|
89
|
-
/*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
|
|
121
|
+
/* The page's block is the contract in **sparse form** (`schema: 2`): the history as changes rather than as a snapshot
|
|
122
|
+
* per commit, the texts in a dictionary of their own, and the rows' links cut by the part they share. The dense form
|
|
123
|
+
* stays what `--data` answers with — an agent reads the contract as it is — and the page's own chapter unrolls this
|
|
124
|
+
* one (`src/page/payload.js`, `appDecode`), so the two places it is read are the encoder here and the decoder there.
|
|
125
|
+
* `test/contract-data.test.js` holds the round trip between them.
|
|
126
|
+
*
|
|
127
|
+
* What the block leaves out besides the sparse form is the list of skipped commits: it changes with the report's own
|
|
128
|
+
* commit (one with nothing to say lands in the list), and the file would stop being a **fixed point** — a rebuild
|
|
129
|
+
* after its own commit would yield different bytes and the hook would commit the report forever. The page has no use
|
|
130
|
+
* for the list at all: it does not show it. It stays available to the reader — `--data`, `--json` and `explain`
|
|
131
|
+
* answer from the same run.
|
|
132
|
+
*
|
|
133
|
+
* How much this is worth: the artifact of this repository carried 1 370 627 B of data as a snapshot per commit — 95 %
|
|
134
|
+
* of the whole file — while nine tenths of the cells repeat the row above; the same history as changes is about 84 000
|
|
135
|
+
* B. Reproducibility is untouched: the artifact stays a fixed point, rebuilt byte for byte after its own commit. */
|
|
136
|
+
|
|
137
|
+
/* The block's fields, in the order the encoder writes them. The shape is closed: a field added here has to be read in
|
|
138
|
+
* the decoder, and the page's checks compare the block with this list rather than with a description of it. */
|
|
139
|
+
export const PAGE_KEYS = ['schema', 'tool', 'report', 'hrefPrefix', 'strs', 'metrics', 'cats', 'files',
|
|
140
|
+
'catalog', 'rows', 'last', 'hist'];
|
|
141
|
+
|
|
142
|
+
/* The texts of the block, each written once. The dictionary is extended in the order of the walk `pagePayload` makes
|
|
143
|
+
* — files in the column order, rows in the history order — and the order of the first appearance is what decides an
|
|
144
|
+
* index. That is part of the format rather than a detail: the artifact is rebuilt after every commit and has to come
|
|
145
|
+
* out byte-identical on any machine. */
|
|
146
|
+
function dictionary() {
|
|
147
|
+
const list = [];
|
|
148
|
+
const at = new Map();
|
|
149
|
+
function of(text) {
|
|
150
|
+
if (text === null) return null;
|
|
151
|
+
const seen = at.get(text);
|
|
152
|
+
if (seen !== undefined) return seen;
|
|
153
|
+
list.push(text);
|
|
154
|
+
at.set(text, list.length - 1);
|
|
155
|
+
return list.length - 1;
|
|
156
|
+
}
|
|
157
|
+
return { list: list, of: of };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* What the rows' links have in common: for an ordinary row the commit template with the sha cut out, and, when some
|
|
161
|
+
* rows lead to a journal section instead, the part the two kinds share. A row keeps only the rest of its link — which
|
|
162
|
+
* for an ordinary row is the sha the row carries anyway, so a link costs nothing beyond the prefix written once. */
|
|
163
|
+
function linkPrefix(rows) {
|
|
164
|
+
const links = rows.map((r) => r.href).filter((href) => href !== null);
|
|
165
|
+
let out = links.length === 0 ? null : links[0];
|
|
166
|
+
links.forEach((href) => {
|
|
167
|
+
let n = 0;
|
|
168
|
+
while (n < out.length && href[n] === out[n]) n++;
|
|
169
|
+
out = out.slice(0, n);
|
|
170
|
+
});
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* One file between two commits: nothing when the numbers are the same, the absolute numbers when the file was absent
|
|
175
|
+
* before, the deltas when it moved, and a record of the row alone when it is gone — the three shapes the decoder
|
|
176
|
+
* reads. A commit that did not touch a file has no record for it, which is what makes the block small: the numbers are
|
|
177
|
+
* compared rather than the list of paths a commit changed. */
|
|
178
|
+
function change(was, nums) {
|
|
179
|
+
if (nums === null) return was === null ? null : [];
|
|
180
|
+
if (was === null) return nums;
|
|
181
|
+
const deltas = nums.map((n, mi) => n - was[mi]);
|
|
182
|
+
return deltas.every((d) => d === 0) ? null : deltas;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The history of every file, in the column order: the rows it appeared in, moved in and disappeared in.
|
|
186
|
+
function history(keys, files, rows) {
|
|
187
|
+
const was = files.map(() => null);
|
|
188
|
+
const hist = files.map(() => []);
|
|
189
|
+
rows.forEach((row, r) => {
|
|
190
|
+
row.values.forEach((v, i) => {
|
|
191
|
+
const nums = v === null ? null : keys.map((key) => v[key]);
|
|
192
|
+
const rec = change(was[i], nums);
|
|
193
|
+
if (rec !== null) hist[i].push([r].concat(rec));
|
|
194
|
+
was[i] = nums;
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
return hist;
|
|
198
|
+
}
|
|
96
199
|
|
|
97
200
|
export function pagePayload(data) {
|
|
98
|
-
const
|
|
99
|
-
|
|
201
|
+
const keys = data.metrics.map((m) => m.key);
|
|
202
|
+
const dict = dictionary();
|
|
203
|
+
const prefix = linkPrefix(data.rows);
|
|
204
|
+
const out = {
|
|
205
|
+
schema: 2,
|
|
206
|
+
tool: data.tool,
|
|
207
|
+
/* The report's own words are the ones the page reads: `heading` is the artifact's `<h1>` and `journal` is null
|
|
208
|
+
* today, so neither is carried — the page builds no heading and prints no journal. */
|
|
209
|
+
report: {
|
|
210
|
+
locale: data.report.locale,
|
|
211
|
+
title: data.report.title,
|
|
212
|
+
artifact: data.report.artifact,
|
|
213
|
+
fixCommand: data.report.fixCommand,
|
|
214
|
+
showSha: data.report.showSha
|
|
215
|
+
},
|
|
216
|
+
hrefPrefix: prefix,
|
|
217
|
+
strs: dict.list,
|
|
218
|
+
metrics: data.metrics.map((m) => [dict.of(m.key), dict.of(m.label), dict.of(m.note), dict.of(m.method)]),
|
|
219
|
+
cats: data.categories.map((c) => [dict.of(c.key), dict.of(c.label)]),
|
|
220
|
+
files: data.files.map((f) => [dict.of(f.label), dict.of(f.path),
|
|
221
|
+
f.paths.map((p) => dict.of(p)), dict.of(f.category), dict.of(f.categoryBy)]),
|
|
222
|
+
catalog: data.catalog.map((e) => [dict.of(e.path), dict.of(e.why)]),
|
|
223
|
+
rows: data.rows.map((r) => [dict.of(r.sha), dict.of(r.when), dict.of(r.subject),
|
|
224
|
+
r.section === null ? null : dict.of(r.section.id),
|
|
225
|
+
r.section === null ? null : dict.of(r.section.head),
|
|
226
|
+
r.section !== null && r.section.added ? 1 : 0,
|
|
227
|
+
r.href === null ? null : dict.of(r.href.slice(prefix.length))]),
|
|
228
|
+
last: [],
|
|
229
|
+
hist: history(keys, data.files, data.rows)
|
|
230
|
+
};
|
|
231
|
+
data.last.forEach((on, i) => { if (on) out.last.push(i); });
|
|
100
232
|
return out;
|
|
101
233
|
}
|
|
102
234
|
|
|
103
|
-
/*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
235
|
+
/* The block as the artifact carries it: gzip of its JSON, base64. The packing is a **transport, not the shape** —
|
|
236
|
+
* what the page unpacks is the very block it received before (`schema: 2`), and `--data`/`--json` keep answering
|
|
237
|
+
* with the dense contract. It is here because the block is most of the file: this repository's report carried
|
|
238
|
+
* 88 786 B of it against 32 142 B compressed, 42 856 B with base64's third.
|
|
239
|
+
*
|
|
240
|
+
* Level 9 because the block is written once per commit and read by whoever opens the file, not by a server under
|
|
241
|
+
* load. What the fixed point rests on is that the bytes are reproducible: zlib writes no time into the gzip header,
|
|
242
|
+
* so the same text gives the same bytes (the version of zlib is the machine's, like the version of git). Base64 is
|
|
243
|
+
* what makes the bytes survive a text file — a gzip stream is not valid UTF-8, and the report is one. */
|
|
244
|
+
export function pagePacked(text) {
|
|
245
|
+
return zlib.gzipSync(Buffer.from(text, 'utf8'), { level: 9 }).toString('base64');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* The report's page is one file: the data lies in it, the script is pasted in, there are no external references. Hence
|
|
249
|
+
* it opens with a double click and works without a network. */
|
|
107
250
|
export function pageHtml(data, cfg) {
|
|
108
251
|
const loc = LOCALES[cfg.locale];
|
|
109
252
|
return '<!doctype html>\n<html lang="' + esc(loc.html) + '">\n<head>\n<meta charset="utf-8">\n'
|
|
110
253
|
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
|
111
254
|
+ '<title>' + esc(data.report.title) + '</title>\n<style>\n'
|
|
112
|
-
+ TABLE_CSS + '\n' + PAGE_CSS + '\n</style>\n</head>\n<body>\n'
|
|
255
|
+
+ squeezedCss(TABLE_CSS) + '\n' + squeezedCss(PAGE_CSS) + '\n</style>\n</head>\n<body>\n'
|
|
113
256
|
+ '<header>\n<h1>' + esc(data.report.heading) + '</h1>\n'
|
|
114
257
|
+ '<p class="sub">' + esc(subText(data, loc.page)) + '</p>\n</header>\n'
|
|
115
258
|
+ '<div id="panel" class="panel"></div>\n'
|
|
@@ -117,14 +260,14 @@ export function pageHtml(data, cfg) {
|
|
|
117
260
|
+ '<div id="shell" class="shell"><table id="grid"></table></div>\n'
|
|
118
261
|
+ '<p id="state" class="state" hidden></p>\n'
|
|
119
262
|
+ '<p id="note" class="note"></p>\n'
|
|
120
|
-
+ '<script type="application/
|
|
263
|
+
+ '<script type="application/octet-stream" id="data" data-pack="base64+gzip">'
|
|
264
|
+
+ pagePacked(jsonInHtml(pagePayload(data))) + '</script>\n'
|
|
121
265
|
+ '<script type="application/json" id="ui">' + jsonInHtml(uiText(loc.page, loc)) + '</script>\n'
|
|
122
266
|
+ '<script>\n' + pageScript() + '</script>\n</body>\n</html>\n';
|
|
123
267
|
}
|
|
124
268
|
|
|
125
|
-
/* JSON
|
|
126
|
-
*
|
|
127
|
-
* обычный). */
|
|
269
|
+
/* JSON inside the page: `<` is escaped, or a commit's subject or a path would close the tag early (inside a JSON
|
|
270
|
+
* string such an escaped character reads as a most ordinary one). */
|
|
128
271
|
function jsonInHtml(value) {
|
|
129
272
|
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
130
273
|
}
|
package/src/page/dom.js
CHANGED
|
@@ -6,9 +6,8 @@ export function appEl(tag, cls, text) {
|
|
|
6
6
|
return el;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/*
|
|
10
|
-
*
|
|
11
|
-
* технологии. Подпись видимая, подробности — во всплывающей строке. */
|
|
9
|
+
/* A switch is a label around an input: one click target, which is why a mouse, the keyboard (`Space` on the input)
|
|
10
|
+
* and assistive technology all reach it. The label is visible, the details live in the tooltip. */
|
|
12
11
|
export function appBox(label, title, checked, onChange, cls) {
|
|
13
12
|
const box = appEl('label', 'box' + (cls ? ' ' + cls : ''));
|
|
14
13
|
const input = document.createElement('input');
|
|
@@ -21,16 +20,16 @@ export function appBox(label, title, checked, onChange, cls) {
|
|
|
21
20
|
return box;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
/*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
/* A checkbox with nothing to switch: the place in the tree exists while there is nothing to switch on — the file is
|
|
24
|
+
* not among the columns and the report does not measure it. The checkbox is off and unavailable: that way the row
|
|
25
|
+
* looks like every other one (the eye compares like with like) while showing that this is not "switched off by the
|
|
26
|
+
* reader" but "not measured". The reason lives in the tooltip. */
|
|
28
27
|
export function appOffBox(label, title, cls) {
|
|
29
28
|
const box = appBox(label, title, false, null, cls);
|
|
30
29
|
box.classList.add('plain');
|
|
31
30
|
box.querySelector('input').disabled = true;
|
|
32
|
-
/*
|
|
33
|
-
*
|
|
31
|
+
/* The tooltip goes on the whole row rather than the input alone: a browser shows none for a disabled input, while
|
|
32
|
+
* the reader needs the reason right here. */
|
|
34
33
|
box.title = title;
|
|
35
34
|
return box;
|
|
36
35
|
}
|