From 6aad21c82dd90ecfc35714fdc657dae93a0d1ef9 Mon Sep 17 00:00:00 2001 From: Max Harris <15605269+skibum1869@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:47:52 -0600 Subject: [PATCH 1/8] feat(viewer): fix Raw dialog layout and add syntax coloring Wrap long lines, keep the close button clear of the text, size the dialog to its content, darken the backdrop, and color XML and JSON output. Co-Authored-By: Claude Sonnet 5.5 --- styles/mtc-app.js | 79 ++++++++++++++++++++++++++++++++++++++++++++--- styles/viewer.css | 51 ++++++++++++++++++++++++------ 2 files changed, 116 insertions(+), 14 deletions(-) diff --git a/styles/mtc-app.js b/styles/mtc-app.js index 799d0aab..ec5d2176 100644 --- a/styles/mtc-app.js +++ b/styles/mtc-app.js @@ -142,6 +142,78 @@ function setupAutorefresh() { }) } +// Raw output coloring. Built from spans with textContent so the document is never parsed as HTML. +const XML_TOKENS = + /()|(<\?[\s\S]*?\?>)|(<\/?)([\w:.-]+)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)(\s*\/?>)|([^<]+)|(<)/g +const XML_ATTRS = /(\s+)([\w:.-]+)(\s*=\s*)("[^"]*"|'[^']*')/g +const JSON_TOKENS = + /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[{}[\],:]/g + +function tok(parent, cls, text) { + if (!text) return + if (!cls) return parent.append(text) + const span = document.createElement('span') + span.className = 'tok-' + cls + span.textContent = text + parent.append(span) +} + +function highlightXml(text) { + const out = document.createDocumentFragment() + let last = 0 + for (const m of text.matchAll(XML_TOKENS)) { + tok(out, null, text.slice(last, m.index)) + last = m.index + m[0].length + if (m[1]) tok(out, 'comment', m[1]) + else if (m[2]) tok(out, 'decl', m[2]) + else if (m[4]) { + tok(out, 'punct', m[3]) + tok(out, 'tag', m[4]) + let end = 0 + for (const a of m[5].matchAll(XML_ATTRS)) { + tok(out, null, a[1]) + tok(out, 'attr', a[2]) + tok(out, 'punct', a[3]) + tok(out, 'str', a[4]) + end = a.index + a[0].length + } + tok(out, null, m[5].slice(end)) + tok(out, 'punct', m[6]) + } else if (m[7]) tok(out, m[7].trim() ? 'text' : null, m[7]) + else tok(out, 'punct', m[8]) + } + tok(out, null, text.slice(last)) + return out +} + +function highlightJson(text) { + const out = document.createDocumentFragment() + let last = 0 + for (const m of text.matchAll(JSON_TOKENS)) { + tok(out, null, text.slice(last, m.index)) + last = m.index + m[0].length + if (m[1]) { + tok(out, m[2] ? 'key' : 'str', m[1]) + tok(out, 'punct', m[2]) + } else if (m[3]) tok(out, 'lit', m[3]) + else if (/^[-\d]/.test(m[0])) tok(out, 'num', m[0]) + else tok(out, 'punct', m[0]) + } + tok(out, null, text.slice(last)) + return out +} + +function showRaw(pre, text, format) { + const trimmed = text.trimStart() + if (format === 'json' || trimmed.startsWith('{') || trimmed.startsWith('[')) { + pre.replaceChildren(highlightJson(text)) + } else if (trimmed.startsWith('<')) { + pre.replaceChildren(highlightXml(text)) + } else { + pre.textContent = text + } +} + function setupDialogs() { $('help').addEventListener('click', () => $('help-dialog').showModal()) $('raw').addEventListener('click', async () => { @@ -149,10 +221,9 @@ function setupDialogs() { pre.textContent = 'Loading...' $('raw-dialog').showModal() try { - pre.textContent = await fetchDocument( - window.location.pathname + window.location.search, - $('format').value - ) + const format = $('format').value + const text = await fetchDocument(window.location.pathname + window.location.search, format) + showRaw(pre, text, format) } catch (err) { pre.textContent = err.message } diff --git a/styles/viewer.css b/styles/viewer.css index 3c07b8b9..2ab9ec0b 100644 --- a/styles/viewer.css +++ b/styles/viewer.css @@ -137,26 +137,29 @@ dialog { max-width: 60em; border: 1px solid #ccc; border-radius: 6px; + position: fixed; + padding: 3em 2em 1.5em; } +/* Raw dialog: sized to its content, close button above the text, long lines wrap */ dialog.mtc-wide { width: 90vw; - height: 80vh; + max-width: 90em; + max-height: 85vh; } -dialog pre { - max-height: 65vh; - overflow: auto; +dialog.mtc-wide[open] { + display: flex; + flex-direction: column; } -dialog { - position: fixed; - padding: 1.5em 2em 1em; +dialog::backdrop { + background: rgba(0, 0, 0, 0.55); } .mtc-close { position: absolute; - top: 0.25em; + top: 0.3em; right: 0.5em; border: none; background: none; @@ -171,10 +174,38 @@ dialog { color: #000; } -dialog.mtc-wide pre { - margin-top: 0.5em; +dialog pre { + margin: 0; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + tab-size: 2; } +dialog.mtc-wide pre { + flex: 1 1 auto; + min-height: 0; + padding: 1em; + border: none; + border-radius: 4px; + background: #1e1e1e; + color: #d4d4d4; + font-size: 13px; + line-height: 1.5; +} + +/* Raw output colors */ +.tok-punct { color: #808080; } +.tok-tag { color: #4fc1ff; } +.tok-attr { color: #9cdcfe; } +.tok-str { color: #ce9178; } +.tok-num { color: #b5cea8; } +.tok-lit { color: #569cd6; } +.tok-key { color: #9cdcfe; } +.tok-text { color: #dcdcaa; } +.tok-comment { color: #6a9955; font-style: italic; } +.tok-decl { color: #c586c0; } + /* Header and detail subtables scroll inside their row instead of widening the page */ .mtc-scroll { overflow-x: auto; From 1aa7961d00238d7a6dda0027b7053d097de87c29 Mon Sep 17 00:00:00 2001 From: Max Harris <15605269+skibum1869@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:12:46 -0600 Subject: [PATCH 2/8] fix(viewer): equalize Raw dialog padding The top padding was double the bottom to make room for the close button, so the border looked uneven. Use the same padding on all sides and fit a smaller close button inside the top edge. Co-Authored-By: Claude Opus 5.5 --- styles/viewer.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/styles/viewer.css b/styles/viewer.css index 2ab9ec0b..ac7488f5 100644 --- a/styles/viewer.css +++ b/styles/viewer.css @@ -146,6 +146,14 @@ dialog.mtc-wide { width: 90vw; max-width: 90em; max-height: 85vh; + /* Equal border on all sides; the close button fits inside the top edge */ + padding: 2em; +} + +dialog.mtc-wide .mtc-close { + top: 0.15em; + right: 0.4em; + font-size: 1.5em; } dialog.mtc-wide[open] { From 9805a9a7175cc92a2e08ae5d035d8aad192a4b77 Mon Sep 17 00:00:00 2001 From: Max Harris <15605269+skibum1869@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:49:59 -0600 Subject: [PATCH 3/8] fix(viewer): make the Raw dialog light and readable Names were split mid-word because Bootstrap sets break-all on pre, and wrapped lines lost their nesting. Wrap between words and continue a wrapped line past its own indent. Use a light palette where every token color reaches at least 4.5:1 contrast. Co-Authored-By: Claude Opus 5.5 --- styles/mtc-app.js | 33 +++++++++++++++++++++++++++++++-- styles/viewer.css | 38 ++++++++++++++++++++++++-------------- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/styles/mtc-app.js b/styles/mtc-app.js index ec5d2176..4d40dcc4 100644 --- a/styles/mtc-app.js +++ b/styles/mtc-app.js @@ -203,12 +203,41 @@ function highlightJson(text) { return out } +// How far a wrapped line continues past its own indent, in characters +const HANG = 4 + +// Tokens can span lines, so each one is split at its line breaks into one block per line +function intoLines(tokens) { + const out = document.createDocumentFragment() + let line = null + const startLine = () => { + line = document.createElement('div') + line.className = 'mtc-line' + out.append(line) + } + startLine() + for (const node of [...tokens.childNodes]) { + node.textContent.split('\n').forEach((part, i) => { + if (i > 0) startLine() + if (!part) return + const piece = node.cloneNode(false) + piece.textContent = part + line.append(piece) + }) + } + for (const block of out.children) { + const indent = block.textContent.length - block.textContent.trimStart().length + block.style.setProperty('--hang', indent + HANG + 'ch') + } + return out +} + function showRaw(pre, text, format) { const trimmed = text.trimStart() if (format === 'json' || trimmed.startsWith('{') || trimmed.startsWith('[')) { - pre.replaceChildren(highlightJson(text)) + pre.replaceChildren(intoLines(highlightJson(text))) } else if (trimmed.startsWith('<')) { - pre.replaceChildren(highlightXml(text)) + pre.replaceChildren(intoLines(highlightXml(text))) } else { pre.textContent = text } diff --git a/styles/viewer.css b/styles/viewer.css index ac7488f5..b39d1e78 100644 --- a/styles/viewer.css +++ b/styles/viewer.css @@ -186,33 +186,43 @@ dialog pre { margin: 0; overflow: auto; white-space: pre-wrap; - overflow-wrap: anywhere; + /* Bootstrap sets break-all on pre, which splits names mid-word */ + word-break: normal; + overflow-wrap: break-word; tab-size: 2; } +/* A wrapped Raw line continues just past its own indent, so the nesting stays easy to follow. + The limit keeps deeply nested lines readable in a narrow window. */ +.mtc-line { + min-height: 1.5em; + padding-left: min(var(--hang), 40%); + text-indent: calc(-1 * min(var(--hang), 40%)); +} + dialog.mtc-wide pre { flex: 1 1 auto; min-height: 0; padding: 1em; - border: none; + border: 1px solid #d0d7de; border-radius: 4px; - background: #1e1e1e; - color: #d4d4d4; + background: #f6f8fa; + color: #24292f; font-size: 13px; line-height: 1.5; } /* Raw output colors */ -.tok-punct { color: #808080; } -.tok-tag { color: #4fc1ff; } -.tok-attr { color: #9cdcfe; } -.tok-str { color: #ce9178; } -.tok-num { color: #b5cea8; } -.tok-lit { color: #569cd6; } -.tok-key { color: #9cdcfe; } -.tok-text { color: #dcdcaa; } -.tok-comment { color: #6a9955; font-style: italic; } -.tok-decl { color: #c586c0; } +.tok-punct { color: #57606a; } +.tok-tag { color: #116329; } +.tok-attr { color: #0550ae; } +.tok-str { color: #a40e26; } +.tok-num { color: #953800; } +.tok-lit { color: #8250df; } +.tok-key { color: #0550ae; } +.tok-text { color: #24292f; } +.tok-comment { color: #57606a; font-style: italic; } +.tok-decl { color: #8250df; } /* Header and detail subtables scroll inside their row instead of widening the page */ .mtc-scroll { From 893a219d1ebb9f6e8a2fb14270490f9096366c8b Mon Sep 17 00:00:00 2001 From: Max Harris <15605269+skibum1869@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:49:59 -0600 Subject: [PATCH 4/8] fix(viewer): keep keyboard focus across autorefresh Each refresh replaces every row, which moved focus to the body. Find the focused control again by its row key and class. Co-Authored-By: Claude Opus 5.5 --- styles/mtc-app.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/styles/mtc-app.js b/styles/mtc-app.js index 4d40dcc4..65e7a94d 100644 --- a/styles/mtc-app.js +++ b/styles/mtc-app.js @@ -52,11 +52,27 @@ function notice(message) { return h('p', { class: 'mtc-notice mtc-error', role: 'status' }, message) } +// Rendering replaces every row, so a focused control is found again by its row's key and its class +function focusedControl(container) { + const active = document.activeElement + if (!container.contains(active)) return null + const key = active.closest('tr')?.dataset.key + return key && active.classList.length ? { key, cls: active.classList[0] } : null +} + +function restoreFocus(container, focus) { + if (!focus) return + const row = container.querySelector('tr[data-key="' + CSS.escape(focus.key) + '"]') + row?.querySelector('.' + focus.cls)?.focus({ preventScroll: true }) +} + function render(embedded) { const container = $('main-container') try { const root = decode(embedded) + const focus = focusedControl(container) container.replaceChildren(renderDocument(root)) + restoreFocus(container, focus) linkModel(root) } catch (err) { container.replaceChildren(notice(err.message)) From e60d7bad80fa05cbe3d3d933ebe13d8d8b853614 Mon Sep 17 00:00:00 2001 From: Max Harris <15605269+skibum1869@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:49:59 -0600 Subject: [PATCH 5/8] fix(viewer): stay on Sample when fetching a path A fetch with only a path went to Current, because only From and Count selected Sample. Co-Authored-By: Claude Opus 5.5 --- styles/mtc-app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/styles/mtc-app.js b/styles/mtc-app.js index 65e7a94d..f92d2d4c 100644 --- a/styles/mtc-app.js +++ b/styles/mtc-app.js @@ -127,7 +127,7 @@ function setupForm() { event.preventDefault() const query = new URLSearchParams() for (const id of ['path', 'from', 'count']) if ($(id).value) query.set(id, $(id).value) - const sample = query.has('from') || query.has('count') + const sample = tab === 'sample' || query.has('from') || query.has('count') const qs = query.toString() window.location.assign(prefix + (sample ? '/sample' : '/current') + (qs ? '?' + qs : '')) }) From d5e298e068cb0162bd66a4afc08b8b7606df720d Mon Sep 17 00:00:00 2001 From: Max Harris <15605269+skibum1869@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:49:59 -0600 Subject: [PATCH 6/8] fix(viewer): guard all access to browser storage Autorefresh read localStorage unguarded. Where storage is blocked that throws, which stopped the dialogs and the table from being set up. Co-Authored-By: Claude Opus 5.5 --- styles/mtc-app.js | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/styles/mtc-app.js b/styles/mtc-app.js index f92d2d4c..b64e78ca 100644 --- a/styles/mtc-app.js +++ b/styles/mtc-app.js @@ -133,27 +133,36 @@ function setupForm() { }) } -// The format for the Raw dialog, remembered between pages -function setupFormat() { - const select = $('format') - let saved = null +// Settings are remembered between pages when the browser allows it. Storage access throws when it +// is blocked, and the page has to work without it. +function loadSetting(name) { + try { + return localStorage.getItem(name) + } catch { + return null + } +} + +function saveSetting(name, value) { try { - saved = localStorage.getItem('mtc-format') + localStorage.setItem(name, value) } catch {} +} + +// The format for the Raw dialog +function setupFormat() { + const select = $('format') + const saved = loadSetting('mtc-format') select.value = saved in FORMATS ? saved : 'xml' - select.addEventListener('change', () => { - try { - localStorage.setItem('mtc-format', select.value) - } catch {} - }) + select.addEventListener('change', () => saveSetting('mtc-format', select.value)) } function setupAutorefresh() { const button = $('autorefresh') - if (localStorage.getItem('mtc-autorefresh') === 'on') button.classList.add('active') + if (loadSetting('mtc-autorefresh') === 'on') button.classList.add('active') button.addEventListener('click', () => { button.classList.toggle('active') - localStorage.setItem('mtc-autorefresh', button.classList.contains('active') ? 'on' : '') + saveSetting('mtc-autorefresh', button.classList.contains('active') ? 'on' : '') scheduleRefresh() }) } From 7515f7fa79b132f4a8b2b3bd62b972230fa48fc5 Mon Sep 17 00:00:00 2001 From: Max Harris <15605269+skibum1869@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:50:00 -0600 Subject: [PATCH 7/8] fix(viewer): use a valid doctype put the page in quirks mode. Co-Authored-By: Claude Opus 5.5 --- demo/styles/viewer.html | 2 +- styles/viewer.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/styles/viewer.html b/demo/styles/viewer.html index 8f89798d..272ade5a 100644 --- a/demo/styles/viewer.html +++ b/demo/styles/viewer.html @@ -1,4 +1,4 @@ - +