diff --git a/index.html b/index.html
index b62efa9..33e12a2 100644
--- a/index.html
+++ b/index.html
@@ -36,21 +36,39 @@
#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, #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; }
+ .json-view { box-sizing: border-box; margin-block: .65rem; padding: .65rem; border: 1px solid #cbd5e1; border-radius: .4rem; background: #f8fafc; color: #1e293b; font: .88rem/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
+ .json-tree-tools { display: flex; flex-wrap: wrap; gap: .4rem; margin-block-end: .6rem; }
+ .json-tree-tools button { font-size: .88rem; padding: .3rem .55rem; }
+ .json-tree, .json-children { display: grid; gap: .3rem; }
+ .json-children { margin-inline-start: .7rem; padding-inline-start: .65rem; border-inline-start: 2px solid #cbd5e1; }
+ .json-node { min-width: 0; }
+ .json-node > summary { cursor: pointer; padding: .3rem .45rem; border-radius: .25rem; background: #e2e8f0; overflow-wrap: anywhere; }
+ .json-node > summary:focus-visible, .json-tree-tools button:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
+ .json-label { color: #075985; font-weight: 750; }
+ .json-kind { color: #475569; font-size: .86em; }
+ .json-entry { display: grid; grid-template-columns: minmax(8rem, .35fr) minmax(0, 1fr); gap: .5rem; padding: .2rem .45rem; border-radius: .2rem; overflow-wrap: anywhere; }
+ .json-entry:nth-child(even) { background: #f1f5f9; }
+ .json-key { color: #075985; font-weight: 700; }
+ .json-value-string { color: #166534; white-space: pre-wrap; }
+ .json-value-number { color: #6d28d9; font-weight: 650; }
+ .json-value-boolean { color: #b45309; font-weight: 750; }
+ .json-value-null { color: #be123c; font-style: italic; }
+ .json-empty { padding: .25rem .65rem; color: #475569; font-style: italic; }
+ .json-long-value { border-inline-start: 2px solid #94a3b8; margin-inline-start: .5rem; }
+ .json-long-value > summary { cursor: pointer; padding: .3rem .45rem; overflow-wrap: anywhere; }
+ .json-long-text { margin: .2rem .5rem .5rem; padding: .5rem; max-height: 24rem; overflow: auto; background: #e2e8f0; white-space: pre-wrap; overflow-wrap: anywhere; }
+ @media (max-width: 42rem) { .json-entry { grid-template-columns: 1fr; gap: .05rem; } }
@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; }
+ .json-children { border-inline-start-color: #475569; }
+ .json-node > summary { background: #263244; }
+ .json-label, .json-key { color: #7dd3fc; }
+ .json-kind, .json-empty { color: #cbd5e1; }
+ .json-entry:nth-child(even), .json-long-text { background: #1f2937; }
+ .json-value-string { color: #86efac; }
+ .json-value-number { color: #c4b5fd; }
+ .json-value-boolean { color: #fcd34d; }
+ .json-value-null { color: #fda4af; }
}
#admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
@@ -638,43 +656,154 @@
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);
+ function renderJsonTree(container, value) {
+ container.className = 'json-view';
+ container.setAttribute('aria-label', 'Utfällbart JSON-svar');
+ const tree = document.createElement('div');
+ tree.className = 'json-tree';
+ const nodeValues = new WeakMap();
+ const controls = document.createElement('div');
+ controls.className = 'json-tree-tools';
+ const expand = document.createElement('button');
+ expand.type = 'button';
+ expand.textContent = 'Öppna alla block';
+ const collapse = document.createElement('button');
+ collapse.type = 'button';
+ collapse.textContent = 'Fäll ihop nästlade block';
+ controls.append(expand, collapse);
+ container.replaceChildren(controls, tree);
+
+ const isGroup = item => item !== null && typeof item === 'object';
+ const groupSummary = item => Array.isArray(item)
+ ? `Lista · ${item.length} ${item.length === 1 ? 'post' : 'poster'}`
+ : `Objekt · ${Object.keys(item).length} fält`;
+ const addLeaf = (parent, label, item) => {
+ if (typeof item === 'string' && item.length > 180) {
+ const details = document.createElement('details');
+ details.className = 'json-long-value';
+ const summary = document.createElement('summary');
+ summary.append(document.createTextNode(`${label ? `${label} · ` : ''}Text · ${item.length} tecken · `));
+ const preview = document.createElement('span');
+ preview.className = 'json-value-string';
+ preview.textContent = `${item.slice(0, 100)}${item.length > 100 ? '…' : ''}`;
+ summary.append(preview);
+ const fullText = document.createElement('div');
+ fullText.className = 'json-long-text';
+ fullText.textContent = item;
+ details.append(summary, fullText);
+ parent.append(details);
+ return;
}
- offset = match.index + token.length;
+ const row = document.createElement('div');
+ row.className = 'json-entry';
+ if (label) {
+ const key = document.createElement('span');
+ key.className = 'json-key';
+ key.textContent = label;
+ row.append(key);
+ }
+ const valueNode = document.createElement('span');
+ const type = item === null ? 'null' : typeof item;
+ valueNode.className = `json-value-${type === 'boolean' ? 'boolean' : type}`;
+ valueNode.textContent = typeof item === 'string' ? JSON.stringify(item) : String(item);
+ row.append(valueNode);
+ parent.append(row);
+ };
+ const populate = (details, item, depth) => {
+ if (details.dataset.loaded === 'true') return;
+ details.dataset.loaded = 'true';
+ const children = document.createElement('div');
+ children.className = 'json-children';
+ if (depth >= 64) {
+ const note = document.createElement('div');
+ note.className = 'json-empty';
+ note.textContent = 'Ytterligare nästling döljs efter 64 nivåer.';
+ children.append(note);
+ } else {
+ const entries = Array.isArray(item)
+ ? item.map((child, index) => [`[${index}]`, child])
+ : Object.entries(item);
+ if (entries.length === 0) {
+ const empty = document.createElement('div');
+ empty.className = 'json-empty';
+ empty.textContent = Array.isArray(item) ? 'Listan är tom.' : 'Objektet saknar fält.';
+ children.append(empty);
+ }
+ for (const [key, child] of entries) {
+ if (isGroup(child)) {
+ const nested = document.createElement('details');
+ nested.className = 'json-node';
+ nodeValues.set(nested, child);
+ nested.dataset.depth = String(depth + 1);
+ const nestedSummary = document.createElement('summary');
+ const label = document.createElement('span');
+ label.className = 'json-label';
+ label.textContent = key;
+ const kind = document.createElement('span');
+ kind.className = 'json-kind';
+ kind.textContent = ` · ${groupSummary(child)}`;
+ nestedSummary.append(label, kind);
+ nested.append(nestedSummary);
+ nested.addEventListener('toggle', () => {
+ if (nested.open) populate(nested, child, depth + 1);
+ });
+ children.append(nested);
+ } else {
+ addLeaf(children, key, child);
+ }
+ }
+ }
+ details.append(children);
+ };
+
+ if (isGroup(value)) {
+ const root = document.createElement('details');
+ root.className = 'json-node json-root';
+ root.open = true;
+ const summary = document.createElement('summary');
+ const label = document.createElement('span');
+ label.className = 'json-label';
+ label.textContent = 'Innehåll';
+ const kind = document.createElement('span');
+ kind.className = 'json-kind';
+ kind.textContent = ` · ${groupSummary(value)}`;
+ summary.append(label, kind);
+ root.append(summary);
+ root.addEventListener('toggle', () => {
+ if (root.open) populate(root, value, 0);
+ });
+ populate(root, value, 0);
+ tree.append(root);
+ expand.addEventListener('click', () => {
+ const openAll = details => {
+ details.open = true;
+ if (details === root || details.dataset.loaded === 'true') {
+ const childGroups = [...details.querySelectorAll(':scope > .json-children > details.json-node')];
+ for (const child of childGroups) {
+ if (child.dataset.loaded !== 'true') {
+ if (nodeValues.has(child)) populate(child, nodeValues.get(child), Number(child.dataset.depth || 1));
+ }
+ openAll(child);
+ }
+ }
+ };
+ openAll(root);
+ });
+ collapse.addEventListener('click', () => {
+ for (const nested of tree.querySelectorAll('details.json-node:not(.json-root)')) nested.open = false;
+ root.open = true;
+ });
+ } else {
+ addLeaf(tree, '', value);
+ expand.disabled = true;
+ collapse.disabled = true;
}
- if (offset < source.length) code.append(document.createTextNode(source.slice(offset)));
- pre.replaceChildren(code);
- return pre;
+ return container;
}
function adminJson(title, value, parent = adminContent) {
const details = adminElement('details', undefined, parent);
adminElement('summary', title, details);
- appendFormattedJson(adminElement('pre', undefined, details), value);
+ renderJsonTree(adminElement('div', undefined, details), value);
return details;
}
async function loadAdminPlayers(query = '', offset = 0) {
@@ -914,7 +1043,7 @@
adminElement('p', 'Redigera profilens JSON. Kända spelvärden valideras av Rust; okända framtida egenskaper bevaras som JSON. Värld och position visas som referens och ändras inte här.');
const profileForm = adminElement('form');
const profileLabel = adminElement('label', 'NPC-profil i JSON', profileForm);
- const profileInput = document.createElement('textarea'); profileInput.rows = 18; profileInput.cols = 80; profileInput.required = true; profileInput.value = JSON.stringify(npc.profile, null, 2); profileLabel.append(profileInput);
+ const profileInput = document.createElement('textarea'); profileInput.rows = 18; profileInput.cols = 80; profileInput.required = true; profileInput.value = JSON.stringify(npc.profile, null, '\t'); profileLabel.append(profileInput);
const profileSave = adminElement('button', 'Spara egenskaper', profileForm); profileSave.type = 'submit';
const profileStatus = adminElement('p'); profileStatus.setAttribute('role', 'status');
profileForm.addEventListener('submit', async event => {
@@ -1211,9 +1340,9 @@
const addValue = (name, value) => {
const valueHeading = document.createElement('strong');
valueHeading.textContent = name;
- const pre = document.createElement('pre');
- appendFormattedJson(pre, value ?? 'Ingen uppgift sparad', true);
- card.append(valueHeading, pre);
+ const json = document.createElement('div');
+ renderJsonTree(json, value === undefined ? 'Ingen uppgift sparad' : value);
+ card.append(valueHeading, json);
};
addValue('Indata', input);
addValue('Utdata', output);
@@ -1271,10 +1400,10 @@
const raw = document.createElement('details');
raw.className = 'trace-raw';
const rawSummary = document.createElement('summary');
- rawSummary.textContent = 'Visa all rå spårdata';
- const pre = document.createElement('pre');
- appendFormattedJson(pre, group);
- raw.append(rawSummary, pre);
+ rawSummary.textContent = 'Visa hela spårdata';
+ const json = document.createElement('div');
+ renderJsonTree(json, group);
+ raw.append(rawSummary, json);
details.append(summary, chain, raw);
debugTrace.append(details);
}
diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh
index 156e418..0da6a6b 100755
--- a/scripts/check-frontend.sh
+++ b/scripts/check-frontend.sh
@@ -99,10 +99,12 @@ grep -q 'id="debug-panel"' index.html
grep -q "query AdminPlayerTrace" index.html
grep -q 'adminPlayerTrace(accountId: \$accountId, limit: \$limit, offset: \$offset)' index.html
grep -q 'function renderDecisionTrace' index.html
-grep -q 'appendFormattedJson(pre, group)' index.html
-grep -Fq "JSON.stringify(value, null, '\\t')" index.html
-grep -q 'json-token-key' index.html
-grep -q 'json-token-string' index.html
+grep -q 'function renderJsonTree' index.html
+grep -q "'Öppna alla block'" index.html
+grep -q "'Fäll ihop nästlade block'" index.html
+grep -q "nested.className = 'json-node'" index.html
+grep -q 'json-value-string' index.html
+grep -q "setAttribute('aria-label', 'Utfällbart JSON-svar')" index.html
grep -q 'debug-enabled' index.html
grep -q '/api/admin/players?' index.html
grep -q 'Välj konto att följa' index.html
diff --git a/tests/admin-conversation-trace.test.cjs b/tests/admin-conversation-trace.test.cjs
index 7b13fc5..342f6f5 100644
--- a/tests/admin-conversation-trace.test.cjs
+++ b/tests/admin-conversation-trace.test.cjs
@@ -44,14 +44,23 @@ test('admin can switch to a storyteller-only reading view and return to diagnost
});
-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\)/);
+test('admin JSON responses are readable as accessible collapsible trees', () => {
+ assert.match(page, /function renderJsonTree/);
+ assert.match(page, /nested\.className = 'json-node'/);
+ assert.match(page, /const nodeValues = new WeakMap\(\)/);
+ assert.match(page, /nodeValues\.set\(nested, child\)/);
+ assert.match(page, /groupSummary/);
+ assert.match(page, /Lista · \$\{item\.length\}/);
+ assert.match(page, /Objekt · \$\{Object\.keys\(item\)\.length\} fält/);
+ assert.match(page, /Öppna alla block/);
+ assert.match(page, /Fäll ihop nästlade block/);
+ assert.match(page, /setAttribute\('aria-label', 'Utfällbart JSON-svar'\)/);
+ assert.match(page, /json-value-string/);
+ assert.match(page, /json-value-number/);
+ assert.match(page, /json-value-boolean/);
+ assert.match(page, /json-value-null/);
+ assert.match(page, /renderJsonTree\(json, group\)/);
+ assert.match(page, /rawSummary\.textContent = 'Visa hela spårdata'/);
+ assert.match(page, /JSON\.stringify\(npc\.profile, null, '\\t'\)/);
+ assert.doesNotMatch(page, /jsnview|cdn\.jsdelivr/);
});