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 | })(); |