{
"note": "Generated by scripts/generate-trace-assets.mjs from js/src/traces; do not edit.",
"recorderGlobal": "__browserCommanderTrace__",
"redacted": "[redacted]",
"capture": {
"captureSnapshot": "function captureSnapshotInPage(options) {\n const {\n redactSelectors = [],\n redactAttributes = [],\n redacted = '[redacted]',\n html: wantHtml = true,\n liveControlState = true,\n openShadowRoots = true,\n maxHtmlBytes = 0,\n } = options || {};\n\n const isSecret = (element) =>\n redactSelectors.some((selector) => {\n try {\n return element.matches(selector);\n } catch {\n return false;\n }\n });\n\n const cssPath = (element) => {\n const parts = [];\n let node = element;\n while (node && node.nodeType === 1 && parts.length < 10) {\n let part = node.localName;\n if (node.id) {\n parts.unshift(`#${node.id}`);\n break;\n }\n const parent = node.parentNode;\n if (parent && parent.children) {\n const siblings = [...parent.children].filter(\n (child) => child.localName === node.localName\n );\n if (siblings.length > 1) {\n part += `:nth-of-type(${siblings.indexOf(node) + 1})`;\n }\n }\n parts.unshift(part);\n node =\n node.parentNode && node.parentNode.host ? node.parentNode.host : parent;\n }\n return parts.join(' > ');\n };\n\n const controls = [];\n const collectControls = (root) => {\n const found = root.querySelectorAll\n ? root.querySelectorAll('input, textarea, select, [contenteditable]')\n : [];\n for (const element of found) {\n const secret = isSecret(element);\n const entry = {\n path: cssPath(element),\n tag: element.localName,\n type: element.type || null,\n name: element.name || null,\n id: element.id || null,\n focused: element === document.activeElement,\n };\n if (element.localName === 'select') {\n entry.value = secret ? redacted : element.value;\n entry.selected = [...element.selectedOptions].map((option) =>\n secret ? redacted : option.value\n );\n } else if (element.type === 'checkbox' || element.type === 'radio') {\n entry.checked = element.checked;\n entry.value = secret ? redacted : element.value;\n } else if (element.isContentEditable) {\n entry.value = secret ? redacted : element.textContent;\n } else {\n entry.value = secret ? redacted : element.value;\n }\n if (element.scrollTop || element.scrollLeft) {\n entry.scroll = { top: element.scrollTop, left: element.scrollLeft };\n }\n entry.redacted = secret;\n controls.push(entry);\n }\n if (openShadowRoots && root.querySelectorAll) {\n for (const element of root.querySelectorAll('*')) {\n if (element.shadowRoot) {\n collectControls(element.shadowRoot);\n }\n }\n }\n };\n\n if (liveControlState) {\n collectControls(document);\n }\n\n // The clone carries the *live* state into the markup: a serialized\n // `<input>` otherwise shows the value the server sent, not the one the user\n // typed.\n const carryLiveState = (original, copy, secret) => {\n for (const attribute of redactAttributes) {\n if (copy.hasAttribute && copy.hasAttribute(attribute)) {\n copy.setAttribute(attribute, redacted);\n }\n }\n if (original.localName === 'input' || original.localName === 'textarea') {\n const value = secret ? redacted : original.value;\n copy.setAttribute('value', value);\n if (original.localName === 'textarea') {\n copy.textContent = value;\n }\n if (original.type === 'checkbox' || original.type === 'radio') {\n if (original.checked) {\n copy.setAttribute('checked', '');\n } else {\n copy.removeAttribute('checked');\n }\n }\n } else if (original.localName === 'option') {\n if (original.selected) {\n copy.setAttribute('selected', '');\n } else {\n copy.removeAttribute('selected');\n }\n } else if (secret) {\n copy.textContent = redacted;\n }\n if (original.shadowRoot && openShadowRoots) {\n // Shadow content is not serialized by `outerHTML`; a declarative\n // template keeps it in the same document the viewer renders.\n const template = document.createElement('template');\n template.setAttribute('shadowrootmode', 'open');\n template.innerHTML = original.shadowRoot.innerHTML;\n copy.appendChild(template);\n }\n };\n\n let html = null;\n let truncated = false;\n if (wantHtml) {\n const clone = document.documentElement.cloneNode(true);\n const originals = document.documentElement.querySelectorAll('*');\n const copies = clone.querySelectorAll('*');\n for (let index = 0; index < originals.length; index++) {\n const copy = copies[index];\n if (!copy) {\n break;\n }\n carryLiveState(originals[index], copy, isSecret(originals[index]));\n }\n\n html = `<!DOCTYPE html>\\n${clone.outerHTML}`;\n if (maxHtmlBytes > 0 && html.length > maxHtmlBytes) {\n html = html.slice(0, maxHtmlBytes);\n truncated = true;\n }\n }\n\n return {\n html,\n state: {\n url: location.href,\n title: document.title,\n readyState: document.readyState,\n scroll: { x: window.scrollX, y: window.scrollY },\n viewport: { width: window.innerWidth, height: window.innerHeight },\n activeElement: document.activeElement\n ? cssPath(document.activeElement)\n : null,\n controls,\n frames: [...document.querySelectorAll('iframe, frame')].map((frame) => ({\n path: cssPath(frame),\n src: frame.getAttribute('src'),\n name: frame.getAttribute('name'),\n })),\n },\n truncated,\n };\n}",
"installMutationRecorder": "function installMutationRecorderInPage(options) {\n const {\n globalName = '__browserCommanderTrace__',\n redactSelectors = [],\n redacted = '[redacted]',\n maxQueued = 5000,\n liveState = true,\n } = options || {};\n\n if (window[globalName] && window[globalName].observing) {\n return true;\n }\n\n const state = {\n observing: true,\n queue: [],\n dropped: 0,\n sequence: 0,\n // Every document gets its own name, so a batch drained from an iframe is\n // never mistaken for one drained from the page that holds it.\n frameId: `frame-${Math.random().toString(36).slice(2, 10)}`,\n frameName: window.name || null,\n mainFrame: window.top === window,\n };\n\n const isSecret = (node) => {\n const element =\n node && node.nodeType === 1 ? node : node && node.parentElement;\n if (!element || !element.matches) {\n return false;\n }\n return redactSelectors.some((selector) => {\n try {\n return element.matches(selector) || element.closest(selector) !== null;\n } catch {\n return false;\n }\n });\n };\n\n // A path the viewer can look the node up by: replay needs to find the same\n // element again in a parsed copy of the checkpoint.\n const pathOf = (node) => {\n const steps = [];\n for (let at = node; at && at.nodeType === 1; at = at.parentElement) {\n if (at.id) {\n steps.unshift(`#${at.id}`);\n break;\n }\n const siblings = at.parentElement\n ? [...at.parentElement.children].filter(\n (c) => c.localName === at.localName\n )\n : [];\n const nth =\n siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(at) + 1})` : '';\n steps.unshift(at.localName + nth);\n }\n return steps.join(' > ');\n };\n\n const childIndexOf = (parent, node) => {\n if (!parent || !node || !parent.childNodes) {\n return -1;\n }\n return Array.prototype.indexOf.call(parent.childNodes, node);\n };\n\n const describe = (node) => {\n if (!node) {\n return null;\n }\n if (node.nodeType === 3) {\n return {\n type: 'text',\n path: pathOf(node.parentElement),\n text: isSecret(node) ? redacted : String(node.nodeValue).slice(0, 2000),\n };\n }\n if (node.nodeType !== 1) {\n return { type: 'node', nodeType: node.nodeType };\n }\n return {\n type: 'element',\n tag: node.localName,\n id: node.id || null,\n path: pathOf(node),\n html: isSecret(node) ? redacted : String(node.outerHTML).slice(0, 4000),\n };\n };\n\n /**\n * Describe a node that is no longer where it was.\n *\n * A removed node has no `parentElement`, so `pathOf` can only name the node\n * itself. Its position in the parent it left is what a replay needs, and\n * that is read from the siblings the record still points at.\n *\n * @param {Node} node - The node that moved or was removed\n * @param {MutationRecord} record - The record it came from\n * @returns {Object|null} The description, with `index` when one is knowable\n */\n const describeAt = (node, record) => {\n const described = describe(node);\n if (!described) {\n return null;\n }\n const parent = record.target;\n const own = childIndexOf(parent, node);\n if (own >= 0) {\n described.index = own;\n return described;\n }\n const after = childIndexOf(parent, record.previousSibling);\n if (after >= 0) {\n described.index = after + 1;\n return described;\n }\n const before = childIndexOf(parent, record.nextSibling);\n described.index = before >= 0 ? before : null;\n return described;\n };\n\n const push = (batch) => {\n if (state.queue.length >= maxQueued) {\n state.dropped += 1;\n return false;\n }\n state.queue.push({\n sequence: ++state.sequence,\n at: Date.now(),\n url: location.href,\n frameId: state.frameId,\n mainFrame: state.mainFrame,\n ...batch,\n });\n return true;\n };\n\n const observer = new MutationObserver((records) => {\n if (state.queue.length >= maxQueued) {\n state.dropped += records.length;\n return;\n }\n push({\n records: records.map((record) => {\n const entry = {\n kind: record.type,\n target: describe(record.target),\n };\n if (record.type === 'attributes') {\n entry.attribute = record.attributeName;\n const secret =\n isSecret(record.target) ||\n redactSelectors.includes(record.attributeName);\n entry.before = secret ? redacted : record.oldValue;\n entry.after =\n secret || !record.target.getAttribute\n ? redacted\n : record.target.getAttribute(record.attributeName);\n } else if (record.type === 'characterData') {\n entry.before = isSecret(record.target) ? redacted : record.oldValue;\n entry.after = isSecret(record.target)\n ? redacted\n : record.target.nodeValue;\n } else {\n // Where a node went is as much of the change as what it was:\n // appending every addition to the end replays an insertion before a\n // sibling in the wrong place, and drops moves and removals entirely.\n entry.added = [...record.addedNodes].map((node) =>\n describeAt(node, record)\n );\n entry.removed = [...record.removedNodes].map((node) =>\n describeAt(node, record)\n );\n entry.previous = describe(record.previousSibling);\n entry.next = describe(record.nextSibling);\n }\n return entry;\n }),\n });\n });\n\n observer.observe(document, {\n subtree: true,\n childList: true,\n attributes: true,\n attributeOldValue: true,\n characterData: true,\n characterDataOldValue: true,\n });\n\n const liveDetachers = [];\n if (liveState) {\n const lastValues = new WeakMap();\n const lastLive = new WeakMap();\n\n // One interaction fires more than one event for the same state: typing\n // into a field and then leaving it fires `input` for each keystroke and\n // `change` for the value it ended at, and a checkbox fires both for the\n // one click. Replaying a state that is already on screen shows nothing, so\n // a repeat of what this element last reported is not recorded.\n const repeats = (element, property, after) => {\n const seen = lastLive.get(element) || {};\n const asText = JSON.stringify(after === undefined ? null : after);\n if (seen[property] === asText) {\n return true;\n }\n seen[property] = asText;\n lastLive.set(element, seen);\n return false;\n };\n\n const liveEntry = (target, property, after, before) => ({\n kind: 'live-state',\n property,\n target: describe(target),\n before: before === undefined ? null : before,\n after,\n });\n\n const pushLive = (entry) => {\n // Scrolling fires far more often than it changes anything worth\n // replaying, so consecutive scrolls of one element collapse into the\n // position it ended at. Every other property keeps each step, because\n // \"stepwise replay must show each state\" (issue #93).\n const last = state.queue[state.queue.length - 1];\n const only = last && last.records.length === 1 ? last.records[0] : null;\n if (\n entry.property === 'scroll' &&\n only &&\n only.kind === 'live-state' &&\n only.property === 'scroll' &&\n only.target?.path === entry.target?.path\n ) {\n only.after = entry.after;\n last.at = Date.now();\n return;\n }\n push({ records: [entry] });\n };\n\n const valueOf = (element) =>\n isSecret(element)\n ? redacted\n : element.isContentEditable\n ? element.textContent\n : element.value;\n\n const onValue = (event) => {\n const element = event.target;\n if (!element || element.nodeType !== 1) {\n return;\n }\n if (element.type === 'checkbox' || element.type === 'radio') {\n if (!repeats(element, 'checked', element.checked)) {\n pushLive(liveEntry(element, 'checked', element.checked));\n }\n return;\n }\n if (element.localName === 'select') {\n const chosen = [...element.selectedOptions].map((option) =>\n isSecret(element) ? redacted : option.value\n );\n if (!repeats(element, 'selected', chosen)) {\n pushLive(liveEntry(element, 'selected', chosen));\n }\n return;\n }\n if (\n element.localName !== 'input' &&\n element.localName !== 'textarea' &&\n !element.isContentEditable\n ) {\n return;\n }\n const after = valueOf(element);\n if (repeats(element, 'value', after)) {\n return;\n }\n const before = lastValues.get(element);\n lastValues.set(element, after);\n pushLive(liveEntry(element, 'value', after, before));\n };\n\n const onFocus = (event) => {\n pushLive(liveEntry(event.target, 'focus', event.type === 'focusin'));\n };\n\n const onScroll = (event) => {\n const target = event.target;\n const scrolling =\n target === document || target === document.documentElement || !target\n ? null\n : target;\n pushLive({\n kind: 'live-state',\n property: 'scroll',\n target: scrolling ? describe(scrolling) : null,\n before: null,\n after: scrolling\n ? { top: scrolling.scrollTop, left: scrolling.scrollLeft }\n : { top: window.scrollY, left: window.scrollX },\n });\n };\n\n const listen = (type, handler) => {\n document.addEventListener(type, handler, true);\n liveDetachers.push(() =>\n document.removeEventListener(type, handler, true)\n );\n };\n\n listen('input', onValue);\n listen('change', onValue);\n listen('focusin', onFocus);\n listen('focusout', onFocus);\n listen('scroll', onScroll);\n }\n\n state.stop = () => {\n observer.disconnect();\n for (const detach of liveDetachers) {\n detach();\n }\n state.observing = false;\n };\n\n window[globalName] = state;\n return true;\n}",
"drainMutations": "function drainMutationsInPage(globalName) {\n const state = window[globalName || '__browserCommanderTrace__'];\n if (!state) {\n return {\n batches: [],\n dropped: 0,\n installed: false,\n frameId: null,\n mainFrame: window.top === window,\n url: location.href,\n };\n }\n const batches = state.queue;\n const dropped = state.dropped;\n state.queue = [];\n state.dropped = 0;\n return {\n batches,\n dropped,\n installed: true,\n frameId: state.frameId,\n mainFrame: state.mainFrame,\n url: location.href,\n };\n}",
"stopMutationRecorder": "function stopMutationRecorderInPage(globalName) {\n const state = window[globalName || '__browserCommanderTrace__'];\n if (!state || !state.observing) {\n return false;\n }\n state.stop();\n return true;\n}"
},
"viewer": {
"style": "\n :root { color-scheme: light dark; font-family: system-ui, sans-serif; }\n body { margin: 0; display: grid; grid-template-columns: 22rem 1fr;\n grid-template-rows: auto 1fr; height: 100vh; }\n header { grid-column: 1 / -1; padding: 0.5rem 1rem; border-bottom: 1px solid #8884; }\n h1 { font-size: 1rem; margin: 0 0 0.25rem; }\n .meta { font-size: 0.75rem; opacity: 0.75; }\n .meta.replay { opacity: 0.9; font-style: italic; }\n aside { overflow: auto; border-right: 1px solid #8884; }\n main { display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; }\n ol { list-style: none; margin: 0; padding: 0; font-size: 0.8rem; }\n li { padding: 0.35rem 0.75rem; border-bottom: 1px solid #8882; cursor: pointer; }\n li.selected { background: #3b82f633; }\n li .kind { font-weight: 600; }\n li .at { opacity: 0.6; float: right; }\n iframe { width: 100%; height: 100%; border: 0; background: #fff; }\n .panel { padding: 0.5rem 1rem; border-top: 1px solid #8884; max-height: 14rem;\n overflow: auto; font-size: 0.8rem; }\n table { border-collapse: collapse; width: 100%; }\n td, th { text-align: left; padding: 0.15rem 0.5rem; border-bottom: 1px solid #8882;\n font-family: ui-monospace, monospace; font-size: 0.75rem; }\n .controls { padding: 0.5rem 1rem; display: flex; gap: 0.5rem; align-items: center; }\n .dropped { color: #b91c1c; }\n button { font: inherit; }\n",
"script": "\nconst trace = JSON.parse(document.getElementById('trace-data').textContent);\nconst timeline = document.getElementById('timeline');\nconst frame = document.getElementById('stage');\nconst details = document.getElementById('details');\nconst diffPanel = document.getElementById('diff');\nconst stepLabel = document.getElementById('step');\nlet selectedCheckpoint = null;\nlet mutationStep = 0;\n\n// Everything in the trace is what the recorded page made it, and the policy\n// allows inline handlers for this script, so recorded text is escaped before\n// it is put into the viewer's markup.\nfunction escapeHtml(value) {\n return String(value).replace(/[&<>\"']/g, (c) => '&#' + c.charCodeAt(0) + ';');\n}\n\nfunction checkpointFor(event) {\n if (event.kind === 'checkpoint') return event.index;\n let latest = null;\n for (const other of trace.events) {\n if (other.sequence > event.sequence) break;\n if (other.kind === 'checkpoint') latest = other.index;\n }\n return latest;\n}\n\nfunction renderHtml(index, html) {\n // The captured document is handed to a sandboxed frame with no\n // allow-scripts and no allow-forms: it is shown, never run.\n frame.srcdoc = html == null ? '<p style=\"font:1rem system-ui\">no HTML captured for this checkpoint</p>' : html;\n}\n\nfunction nodeOf(parsed, described) {\n if (!described) return null;\n if (described.type === 'text') {\n return parsed.createTextNode(described.text == null ? '' : described.text);\n }\n if (described.type === 'element' && described.html) {\n const holder = parsed.createElement('template');\n holder.innerHTML = described.html;\n return holder.content.firstChild;\n }\n return null;\n}\n\nfunction looksLike(node, described) {\n if (!node || !described) return false;\n if (described.type === 'text') return node.nodeType === 3;\n if (described.type !== 'element') return false;\n if (node.nodeType !== 1 || node.localName !== described.tag) return false;\n return described.id ? node.id === described.id : true;\n}\n\nfunction childLike(parent, described) {\n if (!described) return null;\n // An id is identity; an index is only a hint. A node that moved is reported\n // at the position it moved *to*, so the index alone would find the wrong one.\n if (described.id) {\n for (const child of parent.childNodes) {\n if (child.nodeType === 1 && child.id === described.id) return child;\n }\n }\n const at = described.index == null ? null : parent.childNodes[described.index];\n if (looksLike(at, described)) return at;\n for (const child of parent.childNodes) {\n if (looksLike(child, described)) return child;\n }\n return null;\n}\n\nfunction insertAt(parent, node, index) {\n if (!node) return false;\n const reference = index == null ? null : parent.childNodes[index] || null;\n parent.insertBefore(node, reference);\n return true;\n}\n\nfunction applyLiveState(parsed, target, record) {\n const value = record.after;\n const text = value == null ? '' : String(value);\n if (record.property === 'scroll') {\n // A static copy of a document cannot be scrolled, so where it was\n // scrolled to is written onto the element rather than quietly lost.\n const element = target || parsed.documentElement;\n const at = value || {};\n element.setAttribute('data-bc-scroll', at.top + ',' + at.left);\n return true;\n }\n if (!target) return false;\n if (record.property === 'value') {\n // Serialized HTML carries attributes, not properties, so what was typed\n // is written where a re-parse will still find it.\n if (target.localName === 'textarea' || target.hasAttribute('contenteditable')) {\n target.textContent = text;\n } else {\n target.setAttribute('value', text);\n }\n return true;\n }\n if (record.property === 'checked') {\n if (value) target.setAttribute('checked', '');\n else target.removeAttribute('checked');\n return true;\n }\n if (record.property === 'selected') {\n const chosen = (Array.isArray(value) ? value : []).map(String);\n for (const option of target.querySelectorAll('option')) {\n if (chosen.indexOf(option.value) !== -1) option.setAttribute('selected', '');\n else option.removeAttribute('selected');\n }\n return true;\n }\n if (record.property === 'focus') {\n for (const had of parsed.querySelectorAll('[data-bc-focus]')) {\n had.removeAttribute('data-bc-focus');\n }\n if (value) target.setAttribute('data-bc-focus', '');\n return true;\n }\n return false;\n}\n\nfunction applyMutations(index, upTo) {\n const html = trace.html[index];\n if (html == null) return null;\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n const batches = trace.mutations[index] || [];\n let applied = 0;\n let skipped = 0;\n for (const batch of batches.slice(0, upTo)) {\n // The stage holds one document. A batch from an iframe belongs to a\n // different one, so it is counted rather than applied to the wrong page.\n if (batch.mainFrame === false) { skipped += (batch.records || []).length; continue; }\n for (const record of batch.records) {\n const target = record.target && record.target.path\n ? parsed.querySelector(record.target.path)\n : null;\n if (record.kind === 'live-state') {\n if (applyLiveState(parsed, target, record)) applied++; else skipped++;\n } else if (!target) {\n skipped++;\n continue;\n } else if (record.kind === 'attributes' && record.attribute) {\n if (record.after == null) target.removeAttribute(record.attribute);\n else target.setAttribute(record.attribute, record.after);\n applied++;\n } else if (record.kind === 'characterData') {\n target.textContent = record.after == null ? '' : record.after;\n applied++;\n } else if (record.kind === 'childList') {\n // Where a node went is as much of the change as what it was: appending\n // every addition to the end replays an insertion before a sibling in\n // the wrong place, and drops removals entirely (issue #93).\n for (const removed of record.removed || []) {\n const node = childLike(target, removed);\n if (node) { target.removeChild(node); applied++; } else { skipped++; }\n }\n const added = (record.added || []).filter(Boolean)\n .slice().sort((a, b) => (a.index == null ? 1e9 : a.index) - (b.index == null ? 1e9 : b.index));\n for (const entry of added) {\n if (insertAt(target, nodeOf(parsed, entry), entry.index)) applied++;\n else skipped++;\n }\n } else {\n skipped++;\n }\n if (target && target.style) target.style.outline = '2px solid #f59e0b';\n }\n }\n return { html: '<!DOCTYPE html>' + parsed.documentElement.outerHTML, applied, skipped };\n}\n\nfunction stepText(step, total, result) {\n const base = 'batch ' + step + ' of ' + total;\n if (!result || !result.skipped) return base;\n return base + ' ยท ' + result.skipped + ' not replayable here';\n}\n\nfunction showCheckpoint(index) {\n selectedCheckpoint = index;\n mutationStep = 0;\n renderHtml(index, trace.html[index]);\n const state = trace.state[index];\n const previous = trace.state[index - 1];\n details.innerHTML = state\n ? '<table><tr><th>url</th><td>' + escapeHtml(state.url) + '</td></tr>' +\n '<tr><th>title</th><td>' + escapeHtml(state.title || '') + '</td></tr>' +\n '<tr><th>controls</th><td>' + (state.controls || []).length + '</td></tr></table>'\n : '<p>no state captured</p>';\n const changes = trace.diffs[index] || [];\n diffPanel.innerHTML = previous && changes.length\n ? '<table><tr><th>control</th><th>before</th><th>after</th></tr>' +\n changes.map((c) => '<tr><td>' + escapeHtml(c.path) + '</td><td>' +\n escapeHtml(c.before ?? '') + '</td><td>' + escapeHtml(c.after ?? '') +\n '</td></tr>').join('') + '</table>'\n : '<p>no control changes against the previous checkpoint</p>';\n stepLabel.textContent = (trace.mutations[index] || []).length + ' mutation batches';\n}\n\nfunction select(event, element) {\n for (const li of timeline.children) li.classList.remove('selected');\n element.classList.add('selected');\n const index = checkpointFor(event);\n if (index != null && index !== selectedCheckpoint) showCheckpoint(index);\n if (event.kind !== 'checkpoint') {\n details.innerHTML = '<table>' + Object.entries(event)\n .map(([k, v]) => '<tr><th>' + escapeHtml(k) + '</th><td>' + escapeHtml(String(\n typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400)) + '</td></tr>')\n .join('') + '</table>';\n }\n}\n\nfor (const event of trace.events) {\n const li = document.createElement('li');\n if (event.kind === 'dropped') li.className = 'dropped';\n li.innerHTML = '<span class=\"kind\"></span> <span class=\"at\"></span><div class=\"what\"></div>';\n li.querySelector('.kind').textContent = event.kind;\n li.querySelector('.at').textContent = (event.at || '').slice(11, 23);\n li.querySelector('.what').textContent =\n event.name || event.action || event.text || event.message || event.url || event.reason || '';\n li.addEventListener('click', () => select(event, li));\n timeline.append(li);\n}\n\ndocument.getElementById('play').addEventListener('click', async () => {\n if (selectedCheckpoint == null) return;\n const batches = trace.mutations[selectedCheckpoint] || [];\n for (let step = 1; step <= batches.length; step++) {\n const result = applyMutations(selectedCheckpoint, step);\n if (result) frame.srcdoc = result.html;\n stepLabel.textContent = stepText(step, batches.length, result);\n await new Promise((resolve) => setTimeout(resolve, 250));\n }\n});\n\ndocument.getElementById('step-forward').addEventListener('click', () => {\n if (selectedCheckpoint == null) return;\n const batches = trace.mutations[selectedCheckpoint] || [];\n mutationStep = Math.min(mutationStep + 1, batches.length);\n const result = applyMutations(selectedCheckpoint, mutationStep);\n if (result) frame.srcdoc = result.html;\n stepLabel.textContent = stepText(mutationStep, batches.length, result);\n});\n\ndocument.getElementById('reset').addEventListener('click', () => {\n if (selectedCheckpoint != null) showCheckpoint(selectedCheckpoint);\n});\n\nif (trace.checkpoints.length) showCheckpoint(trace.checkpoints[0].index);\n"
}
}