diff --git a/index.html b/index.html index 8743d13..b62efa9 100644 --- a/index.html +++ b/index.html @@ -35,7 +35,23 @@ #admin-content .admin-simple-conversation article > .admin-story-player, #admin-content .admin-simple-conversation article > .admin-story-reply { display: block; margin-block: .85rem; white-space: pre-wrap; } #admin-content .admin-simple-conversation article > .admin-story-player { font-weight: 650; } - #admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; } + #admin-content pre, #debug-trace pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; } + .json-view { box-sizing: border-box; margin-block: .65rem; padding: .8rem; border: 1px solid #cbd5e1; border-radius: .4rem; background: #f8fafc; color: #1e293b; tab-size: 2; font: .84rem/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; } + .json-token-key { color: #075985; font-weight: 700; } + .json-token-string { color: #166534; } + .json-token-number { color: #6d28d9; font-weight: 650; } + .json-token-boolean { color: #b45309; font-weight: 750; } + .json-token-null { color: #be123c; font-style: italic; } + .json-token-punctuation { color: #64748b; } + @media (prefers-color-scheme: dark) { + .json-view { border-color: #475569; background: #111827; color: #e5e7eb; } + .json-token-key { color: #7dd3fc; } + .json-token-string { color: #86efac; } + .json-token-number { color: #c4b5fd; } + .json-token-boolean { color: #fcd34d; } + .json-token-null { color: #fda4af; } + .json-token-punctuation { color: #94a3b8; } + } #admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; } #player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; } body.debug-layout { max-width: none; margin: 0; padding: .5rem; height: 100vh; overflow: hidden; } @@ -622,10 +638,43 @@ parent.append(element); return element; } + function appendFormattedJson(pre, value, stringAsText = false) { + pre.className = 'json-view'; + pre.setAttribute('aria-label', 'Formaterat JSON-svar'); + const code = document.createElement('code'); + if (stringAsText && typeof value === 'string') { + code.textContent = value; + pre.replaceChildren(code); + return pre; + } + const source = JSON.stringify(value, null, '\t') ?? 'null'; + const tokens = /(\"(?:\\.|[^\"\\])*\"(?=\s*:))|(\"(?:\\.|[^\"\\])*\")|(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b)|(\bnull\b)|([{}\[\],:])|(\s+)/g; + let offset = 0; + for (const match of source.matchAll(tokens)) { + if (match.index > offset) code.append(document.createTextNode(source.slice(offset, match.index))); + const token = match[0]; + if (match[7]) { + code.append(document.createTextNode(token)); + } else { + const span = document.createElement('span'); + span.className = match[1] ? 'json-token-key' + : match[2] ? 'json-token-string' + : match[3] ? 'json-token-number' + : match[4] ? 'json-token-boolean' + : match[5] ? 'json-token-null' : 'json-token-punctuation'; + span.textContent = token; + code.append(span); + } + offset = match.index + token.length; + } + if (offset < source.length) code.append(document.createTextNode(source.slice(offset))); + pre.replaceChildren(code); + return pre; + } function adminJson(title, value, parent = adminContent) { const details = adminElement('details', undefined, parent); adminElement('summary', title, details); - adminElement('pre', JSON.stringify(value, null, 2), details); + appendFormattedJson(adminElement('pre', undefined, details), value); return details; } async function loadAdminPlayers(query = '', offset = 0) { @@ -1163,7 +1212,7 @@ const valueHeading = document.createElement('strong'); valueHeading.textContent = name; const pre = document.createElement('pre'); - pre.textContent = typeof value === 'string' ? value : JSON.stringify(value ?? 'Ingen uppgift sparad', null, 2); + appendFormattedJson(pre, value ?? 'Ingen uppgift sparad', true); card.append(valueHeading, pre); }; addValue('Indata', input); @@ -1224,7 +1273,7 @@ const rawSummary = document.createElement('summary'); rawSummary.textContent = 'Visa all rå spårdata'; const pre = document.createElement('pre'); - pre.textContent = JSON.stringify(group, null, 2); + appendFormattedJson(pre, group); raw.append(rawSummary, pre); details.append(summary, chain, raw); debugTrace.append(details); diff --git a/tests/admin-conversation-trace.test.cjs b/tests/admin-conversation-trace.test.cjs index 8911467..7b13fc5 100644 --- a/tests/admin-conversation-trace.test.cjs +++ b/tests/admin-conversation-trace.test.cjs @@ -42,3 +42,16 @@ test('admin can switch to a storyteller-only reading view and return to diagnost assert.match(page, /article > :not\(\.admin-story-player\):not\(\.admin-story-reply\)/); assert.match(page, /article > \.admin-story-player,\s*#admin-content \.admin-simple-conversation article > \.admin-story-reply/); }); + + +test('admin JSON responses use tab-indented syntax-colored accessible output', () => { + assert.match(page, /function appendFormattedJson/); + assert.match(page, /JSON\.stringify\(value, null, '\\t'\)/); + assert.match(page, /json-token-key/); + assert.match(page, /json-token-string/); + assert.match(page, /json-token-number/); + assert.match(page, /json-token-boolean/); + assert.match(page, /json-token-null/); + assert.match(page, /setAttribute\('aria-label', 'Formaterat JSON-svar'\)/); + assert.match(page, /appendFormattedJson\(pre, group\)/); +});