Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/record.js

24.1 KiB, 1 run

created by r2519314175:1425, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// record.js — turn the way this app is actually used into a check that lasts.
2//
3// Every defect the owner of this app has personally hit has one shape: a claim
4// nobody measured on the path he actually takes. The network dialog asked three
5// times because two sessions shipped a mechanism and left the DEFAULT alone, and
6// nothing tested the default. Vision routing was dead on the one path production
7// uses. Folds never folded because model behaviour was assumed.
8//
9// So this records the real path — the presses, in order, with the shape of the
10// page after each one — and `dev/replay.mjs` drives the app back through it and
11// says whether it still works. A path he trips over once becomes an instrument.
12//
13// ── WHAT THIS MUST NEVER SEE ────────────────────────────────────────────────
14//
15// It records a real person using their own private workspace. So the rule is an
16// ALLOWLIST, not a blocklist: a fact reaches the recording only if it is named
17// below as safe, and everything else — every attribute, all text, every value —
18// is never read at all. A blocklist would be a list of the leaks somebody
19// thought of.
20//
21// Nothing typed, nothing displayed, no file name, no Diamond name, no chat text,
22// no model output, nothing out of localStorage or IndexedDB. Not one line here
23// reads `.textContent`, `.innerText`, `.title`, `.href`, `.src`, `.placeholder`,
24// `alt`, `aria-label` or any storage API -- grep the file and see.
25//
26// THE TWO EXCEPTIONS, NAMED HERE RATHER THAN LEFT TO BE FOUND. `sizeOf` reads
27// `.value` for its LENGTH and keeps a three-way bucket of it, for the reason
28// given at that function; `start({ sizes: false })` drops even that. And
29// `buildVocab` GETs the app's own `index.html` to learn which words the app
30// authored about itself -- a same-origin read of a file in this repository, with
31// no body and nothing of the person's in it. dev/RECORD.md states the rule, the
32// two exceptions, and what would defeat the whole thing.
33//
34// Off until a person turns it on (Ctrl+Alt+R, or `DaimondRecord.start()`), loud
35// while it is on (a badge that cannot be missed and stops it when pressed), and
36// local when it finishes: the recording is a file the browser downloads, and
37// there is no code here that sends one anywhere.
38//
39// ── HOW ─────────────────────────────────────────────────────────────────────
40//
41// Ctrl+Alt+R start; press again to stop and save the file
42// DaimondRecord.dump() the recording as an object, for a test harness
43// DaimondRecord.start({ sizes: false }) drop even the coarse size bucket
44//
45// A step's target is named by a SELECTOR built out of the app's own vocabulary —
46// ids and classes that appear in the app's own stylesheets and markup, layout
47// attributes, and `:nth-child` for position. A word the app did not author
48// cannot get in, because it is not in the vocabulary.
49(function () {
50 'use strict';
51
52 var VERSION = 1;
53
54 // ── The vocabulary: words this app authored about itself ─────────────
55 //
56 // Harvested from the same-origin stylesheets and from the served markup, both
57 // of which are files in the repository. A class or id is written into a
58 // recording only if it is in here, so a name that came from the person using
59 // the app — a Diamond called "tax-return", a folder called "clinic-notes" —
60 // has nowhere to land even if it were somehow both lowercase and hyphenated.
61 var vocab = null; // Set, or null until built
62
63 function harvestStyles(into) {
64 var sheets = document.styleSheets, i, j, rules, sel, m;
65 var re = /[.#](-?[A-Za-z_][-\w]*)/g;
66 // `[data-panel="rail"]` and the like: a layout attribute the stylesheet
67 // names is a layout attribute the app authored.
68 var av = /\[[\w-]+\s*[~^|*$]?=\s*"?([\w.-]+)"?\s*\]/g;
69 for (i = 0; i < sheets.length; i++) {
70 try { rules = sheets[i].cssRules; } catch (e) { continue; } // foreign origin
71 if (!rules) continue;
72 for (j = 0; j < rules.length; j++) {
73 sel = rules[j].selectorText;
74 if (!sel) continue;
75 re.lastIndex = 0;
76 while ((m = re.exec(sel))) into.add(m[1]);
77 av.lastIndex = 0;
78 while ((m = av.exec(sel))) into.add(m[1]);
79 }
80 }
81 }
82
83 function harvestMarkup(text, into) {
84 var re = /\b(?:id|class)="([^"]*)"/g, m, k, parts;
85 while ((m = re.exec(text))) {
86 parts = m[1].split(/\s+/);
87 for (k = 0; k < parts.length; k++) if (parts[k]) into.add(parts[k]);
88 }
89 // The values of the layout attributes too, so an attribute a later change
90 // fills from something a person typed has no more of a way in than a class
91 // would. An element built at runtime whose `data-act` is therefore not
92 // recognised simply falls back to its classes and its position, which the
93 // probe in dev/RECORD.md shows is still a usable selector.
94 var da = /\b(data-(?:act|panel|mpanel|view|zone|rail|mode|i18n))="([^"]*)"/g;
95 while ((m = da.exec(text))) if (m[2]) into.add(m[2]);
96 }
97
98 // The served markup is fetched rather than read off the live document: by the
99 // time anything here runs, scripts have already put their own elements in, and
100 // an id created at runtime out of a name is exactly the thing being guarded
101 // against. `index.html` on disk is authored, so every id in it is the app's
102 // own word.
103 function buildVocab() {
104 var v = new Set();
105 harvestStyles(v);
106 return fetch('index.html', { cache: 'no-store' })
107 .then(function (r) { return r.ok ? r.text() : ''; })
108 .catch(function () { return ''; })
109 .then(function (text) {
110 harvestMarkup(text, v);
111 vocab = v;
112 return v;
113 });
114 }
115
116 // ── The redaction rule, in three predicates ──────────────────────────
117
118 // Lowercase kebab, nothing else: `session-box`, `chat-input`, `admin-close`.
119 var KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
120
121 /// Is this token shaped like a word the app wrote, rather than like an id it
122 /// minted or a word a person typed?
123 ///
124 /// Capitals, spaces, punctuation, a long run of letters-and-digits (a base-36
125 /// stamp, `Date.now().toString(36)`) and a bare number of three digits or more
126 /// (a sequence, a year) all fail. Shape alone is not trusted for anything —
127 /// `known()` still has to recognise it — but it throws out the obvious first.
128 function safeToken(s) {
129 if (typeof s !== 'string' || !s || s.length > 48) return false;
130 if (!KEBAB.test(s)) return false;
131 var seg = s.split('-'), i, p;
132 for (i = 0; i < seg.length; i++) {
133 p = seg[i];
134 if (p.length > 24) return false;
135 if (/^[0-9]{3,}$/.test(p)) return false; // c1732, 2025
136 if (p.length >= 8 && /[0-9]/.test(p)) return false; // lz4k9x2m
137 }
138 return true;
139 }
140
141 /// Did the app author this word about itself?
142 function known(s) { return !!vocab && vocab.has(s); }
143
144 // Layout attributes, by NAME. Every one of these is set by the app out of a
145 // closed set of its own: which panel, which zone, which action. None of them
146 // is ever filled from something a person typed — which is exactly why
147 // `data-label` (a translated panel NAME) and `data-id` (an object's id) are
148 // not here, and why the list is a list rather than a prefix match on `data-`.
149 var ATTR_OK = ['data-act', 'data-panel', 'data-mpanel', 'data-view',
150 'data-zone', 'data-rail', 'data-mode'];
151 // A catalogue key, e.g. `rail.new_chat`. Its own shape, because it has dots.
152 var I18N_KEY = /^[a-z0-9_]+(?:\.[a-z0-9_]+)+$/;
153 // `type` on a form control is HTML grammar with a closed set of values, so it
154 // is allowed by VALUE rather than by shape.
155 var TYPE_OK = ['text', 'button', 'submit', 'reset', 'checkbox', 'radio',
156 'file', 'number', 'range', 'search', 'email', 'url', 'tel', 'date',
157 'time', 'color', 'hidden'];
158
159 function esc(s) { return s.replace(/["\\]/g, '\\$&'); }
160
161 /// One element's share of a selector, out of the allowlist and nothing else.
162 function token(el) {
163 var t = el.tagName.toLowerCase(), out = t, i, a, v, cls, n = 0;
164 if (safeToken(el.id) && known(el.id)) return '#' + el.id;
165 for (i = 0; i < ATTR_OK.length; i++) {
166 v = el.getAttribute(ATTR_OK[i]);
167 if (v && safeToken(v) && known(v)) { out += '[' + ATTR_OK[i] + '="' + esc(v) + '"]'; break; }
168 }
169 v = el.getAttribute('data-i18n');
170 if (v && v.length <= 48 && I18N_KEY.test(v) && known(v)) out += '[data-i18n="' + esc(v) + '"]';
171 if (t === 'input' || t === 'button') {
172 v = el.getAttribute('type');
173 if (v && TYPE_OK.indexOf(v) >= 0) out += '[type="' + v + '"]';
174 }
175 cls = (el.getAttribute('class') || '').split(/\s+/);
176 for (i = 0; i < cls.length && n < 3; i++) {
177 if (safeToken(cls[i]) && known(cls[i])) { out += '.' + cls[i]; n++; }
178 }
179 return out;
180 }
181
182 // What a person means by "I pressed that".
183 //
184 // A real mouse lands on whatever pixel is under it, which for an icon button
185 // is the `<path>` inside the `<svg>` inside the `<button>` -- so the first
186 // recording made this way named `#admin-close > svg.ic > path:nth-child(1)`.
187 // It works, because the event bubbles either way, but it is a selector that
188 // breaks the next time somebody redraws the icon, and it does not say what was
189 // pressed. The control is what was pressed.
190 var PRESSABLE = 'button, a, input, select, textarea, label, summary,'
191 + ' [role="button"], [role="tab"], [role="menuitem"], [data-act], [tabindex]';
192
193 function pressed(el) {
194 var c = el.closest && el.closest(PRESSABLE);
195 return (c && c !== document.body) ? c : el;
196 }
197
198 /// Which child of its parent, 1-based, so a token that names a whole row of
199 /// identical tiles still names ONE of them.
200 function nth(el) {
201 var i = 1, s = el;
202 while ((s = s.previousElementSibling)) i++;
203 return i;
204 }
205
206 /// A CSS selector for `el`, as short as still resolves to it alone.
207 ///
208 /// The full chain from `<body>` is built first and then trimmed from the LEFT
209 /// while the remainder still picks out the same single element, so an element
210 /// with an id of its own comes out as `#chat-send` and one without comes out
211 /// as however much of its ancestry it takes to be unambiguous. Trimming and
212 /// then re-testing is what keeps a short selector honest: it is short because
213 /// it was measured to be enough, not because it looked like enough.
214 function selectorFor(el) {
215 var chain = [], cur = el, depth = 0, full, i, cut;
216 while (cur && cur.nodeType === 1 && cur !== document.body && depth < 12) {
217 var tk = token(cur);
218 // A bare tag name says nothing; position rescues it.
219 if (/^[a-z]+$/.test(tk)) tk += ':nth-child(' + nth(cur) + ')';
220 chain.unshift(tk);
221 if (tk.charAt(0) === '#') break; // an id ends the walk
222 cur = cur.parentElement;
223 depth++;
224 }
225 if (!chain.length) return null;
226 full = chain.join(' > ');
227 for (i = 0; i < chain.length; i++) {
228 cut = chain.slice(i).join(' > ');
229 try {
230 if (document.querySelectorAll(cut).length === 1
231 && document.querySelector(cut) === el) return cut;
232 } catch (e) { /* an unparseable token: keep walking */ }
233 }
234 return full;
235 }
236
237 // ── The shape of the page, which is what a step is checked against ───
238 //
239 // A selector still resolving proves the button is there. It does not prove
240 // the press DID anything, and "the press did nothing" is the whole family of
241 // defects this exists for. So each step also carries the shape of the page
242 // immediately after it: which landmarks are on screen, how many tiles are in
243 // the rail, how many dialogs are up, whether the focus is a chat or a Diamond.
244 //
245 // Every field is a count, a boolean or a word out of a closed set. None of
246 // them can carry a name, a path or a sentence.
247 var LANDMARKS = ['identity-modal', 'admin-body', 'chat-input', 'chat-send',
248 'session-list', 'diamond-view', 'new-session-btn', 'admin-close'];
249
250 function visible(el) {
251 return !!el && !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
252 }
253
254 function shape() {
255 var s = { on: {}, n: {} }, i, el;
256 for (i = 0; i < LANDMARKS.length; i++) {
257 el = document.getElementById(LANDMARKS[i]);
258 s.on[LANDMARKS[i]] = visible(el);
259 }
260 // Counts of things a person can see, by the app's own classes.
261 s.n.tiles = document.querySelectorAll('#session-list .session-box').length;
262 s.n.dialog = [].filter.call(document.querySelectorAll('.modal'), visible).length;
263 s.n.start = [].filter.call(document.querySelectorAll('.tile-start'), visible).length;
264 // How many things are in the thread -- not one word of what any of them
265 // says. This is what makes "and then the turn happened" checkable: a send
266 // that reaches nothing leaves the count where it was.
267 s.n.msgs = document.querySelectorAll('#chat-output .chat-msg').length;
268 // KIND of focus, never its id: "a chat" and "a Diamond" are the two words
269 // this can hold.
270 try {
271 var f = window.DaimondAttach && window.DaimondAttach.focus();
272 s.focus = (f && f.kind === 'chat') ? 'chat' : (f && f.kind) ? 'diamond' : 'none';
273 } catch (e) { s.focus = 'none'; }
274 return s;
275 }
276
277 // ── Recording ────────────────────────────────────────────────────────
278
279 var on = false, steps = [], t0 = 0, opts = {}, badge = null, countEl = null;
280
281 /// Is this event inside the passphrase gate?
282 ///
283 /// Dropped whole, both halves of the reason. The gate holds the one field on
284 /// the page whose LENGTH is worth guessing at, and a replay signs itself in
285 /// through the harness anyway, so a recorded press of it would be a privacy
286 /// risk that also could not be replayed.
287 function atGate(el) {
288 if (!el || !el.closest) return false;
289 if (el.closest('#identity-modal')) return true;
290 if (el.tagName === 'INPUT' && el.getAttribute('type') === 'password') return true;
291 return false;
292 }
293
294 /// How much was typed, to the nearest order of magnitude — or nothing.
295 ///
296 /// THIS IS THE ONE FIELD DERIVED FROM CONTENT and it is called out here and in
297 /// dev/RECORD.md rather than buried. It is a bucket, not a length: `short`,
298 /// `medium`, `long`. It earns its place because a pasted essay and a two-word
299 /// question take different paths through this app — chunking, compaction, the
300 /// reply-length cap — and a replay that always typed the same filler could
301 /// never tell those paths apart. `start({ sizes: false })` drops it, and then
302 /// nothing whatever about what was typed is recorded.
303 function sizeOf(el) {
304 if (opts.sizes === false) return undefined;
305 var n = 0;
306 try { n = (el.value || '').length; } catch (e) { return undefined; }
307 return n === 0 ? 'empty' : n < 40 ? 'short' : n < 400 ? 'medium' : 'long';
308 }
309
310 function push(step, el) {
311 var sel = selectorFor(el);
312 if (!sel) return;
313 step.sel = sel;
314 step.t = Date.now() - t0;
315 steps.push(step);
316 // The shape is taken after the app has had a frame to react, which is what
317 // makes it a check on the press rather than on the moment before it.
318 setTimeout(function () { step.after = shape(); paint(); }, 250);
319 }
320
321 /// Did a person cause this event?
322 ///
323 /// `isTrusted` is false for anything a script dispatched, and this app
324 /// dispatches plenty: `input` and `change` on its own form controls, `click`
325 /// on a button it is driving itself. Those are the app talking to itself, and
326 /// a recording that carried them would hold steps NOBODY TOOK -- some of them
327 /// on elements that were never on the screen -- which a replay could then only
328 /// fail on for ever. A real press, a real keystroke and a real paste are all
329 /// trusted, so nothing a person actually does is lost by asking.
330 function byHand(ev) { return !!ev && ev.isTrusted; }
331
332 /// Is this the recorder's own badge? Its press is how a person stops, not a
333 /// step in what they were doing, and it is not part of the app under test.
334 function mine(el) {
335 return !!(el && el.closest && el.closest('#daimond-rec-badge'));
336 }
337
338 function onClick(ev) {
339 var el = ev.target;
340 if (!on || !el || el.nodeType !== 1 || !byHand(ev) || atGate(el) || mine(el)) return;
341 // A person cannot press what is not on the screen, so a click on a hidden
342 // element came from a script -- and a recorded step nobody could have taken
343 // is a step a replay would fail on forever.
344 if (!visible(el)) return;
345 push({ type: 'click' }, pressed(el));
346 }
347
348 // Keys are recorded ONLY where the key IS the command: Enter, Escape, Tab, the
349 // arrows, and anything held with Ctrl or Meta. A character key is a letter
350 // somebody typed, so it is not looked at.
351 var KEY_OK = ['Enter', 'Escape', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft',
352 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'Backspace', 'Delete'];
353
354 function onKey(ev) {
355 var el = ev.target;
356 if (!on || !byHand(ev) || atGate(el)) return;
357 var cmd = ev.ctrlKey || ev.metaKey;
358 if (!cmd && KEY_OK.indexOf(ev.key) < 0) return;
359 // A chord's letter is a command name, not prose, so it may be named; a
360 // bare key is named only because it is in the list above.
361 var name = (ev.ctrlKey ? 'Ctrl+' : '') + (ev.metaKey ? 'Meta+' : '')
362 + (ev.altKey ? 'Alt+' : '') + (ev.shiftKey ? 'Shift+' : '')
363 + (ev.key.length === 1 ? ev.key.toUpperCase() : ev.key);
364 push({ type: 'key', key: name }, el.nodeType === 1 ? el : document.body);
365 }
366
367 // A typing burst becomes ONE step when it stops, so a sentence is one row in
368 // the recording rather than forty.
369 var typingIn = null, typingTimer = 0;
370
371 function flushTyping() {
372 if (!typingIn) return;
373 var el = typingIn; typingIn = null;
374 push({ type: 'type', size: sizeOf(el) }, el);
375 }
376
377 function onInput(ev) {
378 var el = ev.target;
379 if (!on || !el || el.nodeType !== 1 || !byHand(ev) || atGate(el)) return;
380 var t = el.tagName;
381 if (t !== 'INPUT' && t !== 'TEXTAREA' && !el.isContentEditable) return;
382 if (typingIn && typingIn !== el) flushTyping();
383 typingIn = el;
384 clearTimeout(typingTimer);
385 typingTimer = setTimeout(flushTyping, 600);
386 }
387
388 function onChange(ev) {
389 var el = ev.target;
390 if (!on || !el || el.nodeType !== 1 || !byHand(ev) || atGate(el)) return;
391 var t = el.tagName;
392 if (t === 'SELECT') {
393 // The INDEX of the option, never its text: an option can be a file, a
394 // folder or a Diamond, and its position in the list cannot.
395 push({ type: 'select', index: el.selectedIndex }, el);
396 } else if (t === 'INPUT' && /^(checkbox|radio)$/.test(el.getAttribute('type') || '')) {
397 push({ type: 'toggle', checked: !!el.checked }, el);
398 } else if (t === 'INPUT' && el.getAttribute('type') === 'file') {
399 // That a file was chosen, and how many. Never which.
400 push({ type: 'files', n: (el.files || []).length }, el);
401 }
402 }
403
404 function onHash() {
405 if (!on) return;
406 var h = (location.hash || '').replace(/^#/, '');
407 steps.push({ type: 'route', route: safeToken(h) ? h : '', t: Date.now() - t0, after: shape() });
408 paint();
409 }
410
411 // ── The badge, which is how "it is recording" is not a thing to remember ──
412
413 function paint() {
414 if (countEl) countEl.textContent = String(steps.length);
415 }
416
417 function showBadge() {
418 if (badge) return;
419 badge = document.createElement('button');
420 badge.id = 'daimond-rec-badge';
421 badge.type = 'button';
422 badge.setAttribute('aria-live', 'polite');
423 badge.style.cssText = 'position:fixed;right:14px;bottom:14px;z-index:2147483647;'
424 + 'display:flex;align-items:center;gap:8px;padding:8px 13px;border-radius:999px;'
425 + 'border:1px solid #ff4d4d;background:#2a0d0d;color:#ffd9d9;cursor:pointer;'
426 + 'font:600 13px/1 system-ui,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.45)';
427 var dot = document.createElement('span');
428 dot.style.cssText = 'width:9px;height:9px;border-radius:50%;background:#ff3b3b;'
429 + 'animation:daimond-rec-blink 1.1s steps(2,start) infinite';
430 countEl = document.createElement('span');
431 var label = document.createElement('span');
432 label.textContent = 'recording — press to stop';
433 badge.appendChild(dot);
434 badge.appendChild(countEl);
435 badge.appendChild(label);
436 badge.addEventListener('click', function (e) { e.stopPropagation(); stop(); });
437 var css = document.createElement('style');
438 css.id = 'daimond-rec-css';
439 css.textContent = '@keyframes daimond-rec-blink{to{opacity:.15}}';
440 document.head.appendChild(css);
441 document.body.appendChild(badge);
442 paint();
443 }
444
445 function hideBadge() {
446 if (badge) badge.remove();
447 var c = document.getElementById('daimond-rec-css');
448 if (c) c.remove();
449 badge = null; countEl = null;
450 }
451
452 // ── The public surface ───────────────────────────────────────────────
453
454 var listening = false;
455 // The shape of the page at the moment recording began.
456 var startShape = null;
457
458 function listen() {
459 if (listening) return;
460 listening = true;
461 // Capture phase, so a handler that stops propagation cannot make a press
462 // invisible to the recording. Passive, so nothing here can change what
463 // the app does with the event.
464 var o = { capture: true, passive: true };
465 document.addEventListener('click', onClick, o);
466 document.addEventListener('keydown', onKey, o);
467 document.addEventListener('input', onInput, o);
468 document.addEventListener('change', onChange, o);
469 window.addEventListener('hashchange', onHash);
470 }
471
472 /// Begin recording. Nothing at all is captured before this is called.
473 function start(o) {
474 if (on) return Promise.resolve(false);
475 opts = o || {};
476 return (vocab ? Promise.resolve(vocab) : buildVocab()).then(function () {
477 on = true;
478 steps = [];
479 t0 = Date.now();
480 listen();
481 showBadge();
482 // The shape BEFORE the first press, so a replay can say "this recording
483 // began somewhere else" rather than blaming step one.
484 startShape = shape();
485 return true;
486 });
487 }
488
489 /// The recording as an object. This is the whole of what leaves the page.
490 function dump() {
491 return {
492 v: VERSION,
493 at: new Date().toISOString().slice(0, 19) + 'Z',
494 // The window, because a rail that collapses under 900px is a different
495 // path and a replay at another width would be testing another app.
496 viewport: { w: window.innerWidth, h: window.innerHeight },
497 // The page within the app, only if it is one of the app's own words.
498 route: safeToken((location.hash || '').replace(/^#/, ''))
499 ? location.hash.replace(/^#/, '') : '',
500 sizes: opts.sizes !== false,
501 start: startShape,
502 steps: steps.slice()
503 };
504 }
505
506 /// Stop, and hand the recording to the person as a file.
507 ///
508 /// A download, because that is a place they can read it, move it and delete
509 /// it. There is no other exit from this module: nothing here posts, fetches
510 /// with a body, opens a socket, or writes to any store the app syncs.
511 function stop() {
512 if (!on) return null;
513 flushTyping();
514 on = false;
515 hideBadge();
516 // A press in the last quarter-second has not had its shape taken yet, and a
517 // step with no shape is a step a replay can only check halfway. Take it now.
518 var live = shape();
519 for (var i = 0; i < steps.length; i++) if (!steps[i].after) steps[i].after = live;
520 var rec = dump();
521 try {
522 var blob = new Blob([JSON.stringify(rec, null, '\t')], { type: 'application/json' });
523 var a = document.createElement('a');
524 a.href = URL.createObjectURL(blob);
525 a.download = 'daimond-recording-' + rec.at.replace(/[:T]/g, '-').replace('Z', '') + '.json';
526 document.body.appendChild(a);
527 a.click();
528 a.remove();
529 setTimeout(function () { URL.revokeObjectURL(a.href); }, 4000);
530 } catch (e) { /* the object is still there for `dump()` */ }
531 return rec;
532 }
533
534 // Ctrl+Alt+R. Nothing else in this app binds Alt with Ctrl (the one keyboard
535 // chord in daimond.js is a bare F6), and the toggle is deliberately awkward so
536 // it is not reached by accident.
537 window.addEventListener('keydown', function (ev) {
538 if (!ev.ctrlKey || !ev.altKey || ev.metaKey) return;
539 if ((ev.key || '').toLowerCase() !== 'r') return;
540 ev.preventDefault();
541 if (on) stop(); else start();
542 }, true);
543
544 window.DaimondRecord = {
545 start: start,
546 stop: stop,
547 dump: dump,
548 recording: function () { return on; },
549 // Exposed for dev/replay.mjs, which asks the live page for the shape it can
550 // see so the comparison is made by the same code that made the recording.
551 shape: shape,
552 // Exposed so a reader can try the redaction rule on a word themselves.
553 selector: selectorFor,
554 _vocab: function () { return vocab ? vocab.size : 0; }
555 };
556})();