From a91fbd919fec88022ccb2d49fcfa5630adb8c037 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 13 May 2026 11:02:17 -0600 Subject: [PATCH 1/5] feat(devtools-ui): add Payload tab to inspector panel Extract request/response bodies from the Headers tab into a dedicated "Payload" tab (matching Chrome DevTools naming). The tab only appears when a network event has body data, and each section includes a copy button for easy clipboard export. The Headers tab now shows only URL, method, and header data. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/devtools-ui/src/panel.css | 11 ++++ packages/devtools-ui/src/src/Inspector.elm | 65 +++++++++++++++++++--- packages/devtools-ui/src/src/Types.elm | 1 + packages/devtools-ui/src/src/Update.elm | 11 ++++ 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/packages/devtools-ui/src/panel.css b/packages/devtools-ui/src/panel.css index 23d1c9a..1640d2c 100644 --- a/packages/devtools-ui/src/panel.css +++ b/packages/devtools-ui/src/panel.css @@ -566,6 +566,17 @@ body { padding: 2px 8px; } +/* ── Payload Tab ─────────────────────────────────────────── */ +.payload-section { + margin-bottom: 8px; +} +.payload-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + /* ── JsonTree ────────────────────────────────────────────── */ .jt-sec { margin-bottom: 14px; diff --git a/packages/devtools-ui/src/src/Inspector.elm b/packages/devtools-ui/src/src/Inspector.elm index 1925469..e4ce76a 100644 --- a/packages/devtools-ui/src/src/Inspector.elm +++ b/packages/devtools-ui/src/src/Inspector.elm @@ -87,8 +87,24 @@ viewTabs maybeEvent activeTab maybeDiagnosis = else [] ) - ++ [ tabButton "Headers" HeadersTab activeTab - , tabButton "Cookies" CookiesTab activeTab + ++ [ tabButton "Headers" HeadersTab activeTab ] + ++ (case maybeEvent of + Just event -> + case event.data of + Network net -> + if net.requestBody /= Nothing || net.responseBody /= Nothing then + [ tabButton "Payload" PayloadTab activeTab ] + + else + [] + + _ -> + [] + + Nothing -> + [] + ) + ++ [ tabButton "Cookies" CookiesTab activeTab , tabButton "CORS" CorsTab activeTab , tabButton "SDK State" SdkStateTab activeTab ] @@ -166,19 +182,35 @@ viewContent maybeEvent activeTab maybeDiagnosis = Just h -> JsonTree.view "Response Headers" h Nothing -> viewEmptySection "Response Headers" ] - ++ (case net.requestBody of - Just b -> [ JsonTree.view "Request Body" b ] - Nothing -> [] - ) + ) + + _ -> + div [ class "insp-empty" ] + [ text "Select a network request to see headers." ] + + ( Just event, PayloadTab ) -> + case event.data of + Network net -> + div [] + ((case net.requestBody of + Just b -> + [ viewPayloadSection "Request Body" b ] + + Nothing -> + [] + ) ++ (case net.responseBody of - Just b -> [ JsonTree.view "Response Body" b ] - Nothing -> [] + Just b -> + [ viewPayloadSection "Response Body" b ] + + Nothing -> + [] ) ) _ -> div [ class "insp-empty" ] - [ text "Select a network request to see headers." ] + [ text "No payload data for this event." ] ( Just event, CookiesTab ) -> case event.data of @@ -407,6 +439,21 @@ viewEmptySection label = ] +viewPayloadSection : String -> Decode.Value -> Html Msg +viewPayloadSection label body = + div [ class "payload-section" ] + [ div [ class "payload-header" ] + [ div [ class "sect-hdr", style "margin" "0", style "border" "none", style "padding" "0" ] [ text label ] + , button + [ class "fv-copy-btn" + , onClick (CopyToClipboard (Encode.encode 4 body)) + ] + [ text "\u{2398}" ] + ] + , JsonTree.view label body + ] + + viewCookies : NetworkData -> List (Html Msg) viewCookies net = let diff --git a/packages/devtools-ui/src/src/Types.elm b/packages/devtools-ui/src/src/Types.elm index 1265623..83e007f 100644 --- a/packages/devtools-ui/src/src/Types.elm +++ b/packages/devtools-ui/src/src/Types.elm @@ -179,6 +179,7 @@ type alias OidcSemanticData = type InspectorTab = DiagnosisTab | HeadersTab + | PayloadTab | CookiesTab | CorsTab | SdkStateTab diff --git a/packages/devtools-ui/src/src/Update.elm b/packages/devtools-ui/src/src/Update.elm index 86f77c4..4136cb6 100644 --- a/packages/devtools-ui/src/src/Update.elm +++ b/packages/devtools-ui/src/src/Update.elm @@ -105,6 +105,17 @@ update msg model = else OidcTab + ( PayloadTab, Just e ) -> + case e.data of + Network net -> + if net.requestBody == Nothing && net.responseBody == Nothing then + HeadersTab + else + PayloadTab + + _ -> + HeadersTab + _ -> model.activeTab in From 0f2e09414f47e0f82d924c42740c3ce1518e7df2 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 13 May 2026 11:17:41 -0600 Subject: [PATCH 2/5] feat(devtools-ui): add light mode with theme toggle Add a light mode color scheme using CSS variable overrides via [data-theme="light"] on the root element. The toggle button is injected into the toolbar by panel.ts (same pattern as resize handles), persists the user's choice to localStorage, and defaults to the OS preference via prefers-color-scheme. Also cleans up hardcoded fallback CSS variables (--bg-2, --fg, --bg-hover, --bg-info) that bypassed the theme system, replacing them with proper theme-aware variables. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../devtools-extension/src/panel/panel.ts | 52 +++++++++++++ packages/devtools-ui/src/panel.css | 76 +++++++++++++++---- 2 files changed, 114 insertions(+), 14 deletions(-) diff --git a/packages/devtools-extension/src/panel/panel.ts b/packages/devtools-extension/src/panel/panel.ts index 7cc325e..05ed6fd 100644 --- a/packages/devtools-extension/src/panel/panel.ts +++ b/packages/devtools-extension/src/panel/panel.ts @@ -82,11 +82,63 @@ function initResizeHandles() { }); } +// ── Theme toggle ───────────────────────────────────────────────────────────── + +const THEME_KEY = 'wolfcola:theme'; + +function getPreferredTheme(): 'dark' | 'light' { + const stored = localStorage.getItem(THEME_KEY); + if (stored === 'light' || stored === 'dark') return stored; + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; +} + +function applyTheme(theme: 'dark' | 'light') { + if (theme === 'light') { + root.setAttribute('data-theme', 'light'); + } else { + root.removeAttribute('data-theme'); + } +} + +function initThemeToggle() { + let theme = getPreferredTheme(); + applyTheme(theme); + + const btn = document.createElement('button'); + btn.className = 'theme-toggle'; + btn.title = 'Toggle light/dark mode'; + btn.textContent = theme === 'light' ? '☀' : '☾'; + + btn.addEventListener('click', () => { + theme = theme === 'dark' ? 'light' : 'dark'; + applyTheme(theme); + localStorage.setItem(THEME_KEY, theme); + btn.textContent = theme === 'light' ? '☀' : '☾'; + }); + + // Insert into the toolbar once Elm renders it + const observer = new MutationObserver(() => { + const toolbar = document.querySelector('.toolbar'); + if (toolbar) { + // Insert before the first separator to keep it at the far left + const spacer = toolbar.querySelector('.tb-spacer'); + if (spacer) { + toolbar.insertBefore(btn, spacer); + } else { + toolbar.appendChild(btn); + } + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); +} + // ── App init ────────────────────────────────────────────────────────────────── const app = Elm.Main.init({ node: document.getElementById('app'), flags: null }); initResizeHandles(); +initThemeToggle(); function copyToClipboard(text: string): void { if (navigator.clipboard?.writeText) { diff --git a/packages/devtools-ui/src/panel.css b/packages/devtools-ui/src/panel.css index 1640d2c..2aa31af 100644 --- a/packages/devtools-ui/src/panel.css +++ b/packages/devtools-ui/src/panel.css @@ -24,6 +24,7 @@ --orange: #ffa657; --yellow: #d29922; --purple: #bc8cff; + --shadow-alpha: 0.3; --font-ui: 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; --font-mono: 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace; @@ -33,6 +34,26 @@ --toolbar-h: 32px; } +[data-theme='light'] { + --base: #ffffff; + --surface: #f6f8fa; + --raised: #eaeef2; + --hover: #eef1f5; + --sel: #ddf4ff; + --border: #d0d7de; + --bdim: #e4e8ec; + --text: #1f2328; + --muted: #636c76; + --dim: #8c959f; + --blue: #0969da; + --green: #1a7f37; + --red: #cf222e; + --orange: #bc4c00; + --yellow: #9a6700; + --purple: #8250df; + --shadow-alpha: 0.12; +} + html, body { height: 100%; @@ -205,6 +226,33 @@ body { color: var(--dim); } +/* ── Theme Toggle ───────────────────────────────────────── */ +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--muted); + cursor: pointer; + font-size: 13px; + line-height: 1; + transition: + color 0.12s, + border-color 0.12s, + background 0.12s; + flex-shrink: 0; +} +.theme-toggle:hover { + color: var(--text); + border-color: var(--muted); + background: var(--raised); +} + /* ── Error Banner ────────────────────────────────────────── */ .err-banner { padding: 5px 12px; @@ -531,7 +579,7 @@ body { border-radius: 4px; padding: 8px 10px; margin-bottom: 8px; - background: rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, calc(var(--shadow-alpha) * 0.7)); } .coll-card-header { display: flex; @@ -558,8 +606,8 @@ body { line-height: 1; } .fv-copy-btn:hover { - color: var(--fg); - border-color: var(--fg); + color: var(--text); + border-color: var(--text); } .coll-copy-all { font-size: 10px; @@ -1127,10 +1175,10 @@ body.resizing { top: 100%; left: 0; z-index: 100; - background: var(--bg-2, #252526); - border: 1px solid var(--border, #3c3c3c); + background: var(--raised); + border: 1px solid var(--border); border-radius: 4px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + box-shadow: 0 2px 8px rgba(0, 0, 0, var(--shadow-alpha)); min-width: 140px; padding: 2px 0; } @@ -1141,12 +1189,12 @@ body.resizing { text-align: left; background: none; border: none; - color: var(--fg, #ccc); + color: var(--text); cursor: pointer; font-size: 11px; } .tb-dropdown-item:hover { - background: var(--bg-hover, #2a2d2e); + background: var(--hover); } /* ── Snapshot dropdown ────────────────────────────────── */ @@ -1212,22 +1260,22 @@ body.resizing { align-items: center; justify-content: space-between; padding: 4px 12px; - background: var(--bg-info, #063b49); - border-bottom: 1px solid var(--border, #3c3c3c); + background: var(--sel); + border-bottom: 1px solid var(--border); font-size: 11px; - color: var(--fg, #ccc); + color: var(--text); } .import-banner-clear { background: none; - border: 1px solid var(--border, #3c3c3c); - color: var(--fg, #ccc); + border: 1px solid var(--border); + color: var(--text); padding: 1px 8px; border-radius: 3px; cursor: pointer; font-size: 10px; } .import-banner-clear:hover { - background: var(--bg-hover, #2a2d2e); + background: var(--hover); } /* ── Import paste panel ─────────────────────────────── */ From ec025077ced3851d76488baf9ffce7c654308813 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 18:06:29 +0000 Subject: [PATCH 3/5] fix(panel): keep MutationObserver alive to survive Elm VDOM re-renders --- packages/devtools-extension/src/panel/panel.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/devtools-extension/src/panel/panel.ts b/packages/devtools-extension/src/panel/panel.ts index 05ed6fd..15a157b 100644 --- a/packages/devtools-extension/src/panel/panel.ts +++ b/packages/devtools-extension/src/panel/panel.ts @@ -107,6 +107,7 @@ function initThemeToggle() { const btn = document.createElement('button'); btn.className = 'theme-toggle'; btn.title = 'Toggle light/dark mode'; + btn.ariaLabel = 'Toggle light/dark mode'; btn.textContent = theme === 'light' ? '☀' : '☾'; btn.addEventListener('click', () => { @@ -116,18 +117,17 @@ function initThemeToggle() { btn.textContent = theme === 'light' ? '☀' : '☾'; }); - // Insert into the toolbar once Elm renders it + // Keep the observer alive — Elm's virtual DOM re-renders the toolbar + // on any model change and removes nodes it doesn't know about. const observer = new MutationObserver(() => { const toolbar = document.querySelector('.toolbar'); - if (toolbar) { - // Insert before the first separator to keep it at the far left + if (toolbar && btn.parentElement !== toolbar) { const spacer = toolbar.querySelector('.tb-spacer'); if (spacer) { toolbar.insertBefore(btn, spacer); } else { toolbar.appendChild(btn); } - observer.disconnect(); } }); observer.observe(document.body, { childList: true, subtree: true }); From de5b6748de94afee71aae063a363d857574ef91a Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 13 May 2026 12:26:28 -0600 Subject: [PATCH 4/5] fix(devtools-extension): append theme toggle at end of toolbar to avoid Elm VDOM corruption The MutationObserver was inserting the theme toggle button before .tb-spacer (middle of the toolbar), which shifted child indices and caused Elm's index-based VDOM diffing to patch the wrong elements. This broke the recording toggle state and clear button in E2E tests. Using appendChild instead keeps the toggle after all Elm-managed children, where it won't interfere with virtual DOM patching. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/devtools-extension/src/panel/panel.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/devtools-extension/src/panel/panel.ts b/packages/devtools-extension/src/panel/panel.ts index 15a157b..4ef8bee 100644 --- a/packages/devtools-extension/src/panel/panel.ts +++ b/packages/devtools-extension/src/panel/panel.ts @@ -119,15 +119,13 @@ function initThemeToggle() { // Keep the observer alive — Elm's virtual DOM re-renders the toolbar // on any model change and removes nodes it doesn't know about. + // IMPORTANT: always appendChild (never insertBefore) so the toggle + // lives *after* all Elm-managed children. Elm patches by index, so + // inserting in the middle shifts indices and corrupts button state. const observer = new MutationObserver(() => { const toolbar = document.querySelector('.toolbar'); if (toolbar && btn.parentElement !== toolbar) { - const spacer = toolbar.querySelector('.tb-spacer'); - if (spacer) { - toolbar.insertBefore(btn, spacer); - } else { - toolbar.appendChild(btn); - } + toolbar.appendChild(btn); } }); observer.observe(document.body, { childList: true, subtree: true }); From ffe197c2b708483c79cb5544fd3e46631b226591 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 18:42:19 +0000 Subject: [PATCH 5/5] fix(devtools-ui): replace hardcoded rgba() with theme-aware CSS variables --- packages/devtools-ui/src/panel.css | 54 ++++++++++++++++++------------ 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/packages/devtools-ui/src/panel.css b/packages/devtools-ui/src/panel.css index 2aa31af..f152343 100644 --- a/packages/devtools-ui/src/panel.css +++ b/packages/devtools-ui/src/panel.css @@ -19,11 +19,17 @@ --muted: #8b949e; --dim: #484f58; --blue: #58a6ff; + --blue-rgb: 88, 166, 255; --green: #3fb950; + --green-rgb: 63, 185, 80; --red: #f85149; + --red-rgb: 248, 81, 73; --orange: #ffa657; + --orange-rgb: 255, 166, 87; --yellow: #d29922; + --yellow-rgb: 210, 153, 34; --purple: #bc8cff; + --purple-rgb: 188, 140, 255; --shadow-alpha: 0.3; --font-ui: 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; --font-mono: 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace; @@ -46,11 +52,17 @@ --muted: #636c76; --dim: #8c959f; --blue: #0969da; + --blue-rgb: 9, 105, 218; --green: #1a7f37; + --green-rgb: 26, 127, 55; --red: #cf222e; + --red-rgb: 207, 34, 46; --orange: #bc4c00; + --orange-rgb: 188, 76, 0; --yellow: #9a6700; + --yellow-rgb: 154, 103, 0; --purple: #8250df; + --purple-rgb: 130, 80, 223; --shadow-alpha: 0.12; } @@ -167,7 +179,7 @@ body { border-color: var(--red); } .tb-btn.recording:hover { - background: rgba(248, 81, 73, 0.1); + background: rgba(var(--red-rgb), 0.1); } .rec-dot { @@ -256,11 +268,11 @@ body { /* ── Error Banner ────────────────────────────────────────── */ .err-banner { padding: 5px 12px; - background: rgba(248, 81, 73, 0.08); + background: rgba(var(--red-rgb), 0.08); color: var(--red); font-family: var(--font-mono); font-size: 11px; - border-bottom: 1px solid rgba(248, 81, 73, 0.25); + border-bottom: 1px solid rgba(var(--red-rgb), 0.25); flex-shrink: 0; } @@ -331,19 +343,19 @@ body { flex-shrink: 0; } .b-net { - background: rgba(88, 166, 255, 0.1); + background: rgba(var(--blue-rgb), 0.1); color: var(--blue); } .b-sdk { - background: rgba(63, 185, 80, 0.1); + background: rgba(var(--green-rgb), 0.1); color: var(--green); } .b-ses { - background: rgba(255, 166, 87, 0.1); + background: rgba(var(--orange-rgb), 0.1); color: var(--orange); } .b-cfg { - background: rgba(188, 140, 255, 0.1); + background: rgba(var(--purple-rgb), 0.1); color: var(--purple); } @@ -427,15 +439,15 @@ body { overflow: visible; } .tag-cors { - background: rgba(248, 81, 73, 0.12); + background: rgba(var(--red-rgb), 0.12); color: var(--red); } .tag-oidc { - background: rgba(88, 166, 255, 0.12); + background: rgba(var(--blue-rgb), 0.12); color: var(--blue); } .tag-coll { - background: rgba(88, 166, 255, 0.12); + background: rgba(var(--blue-rgb), 0.12); color: var(--blue); } @@ -570,7 +582,7 @@ body { transition: background 0.12s; } .cause-btn:hover { - background: rgba(88, 166, 255, 0.1); + background: rgba(var(--blue-rgb), 0.1); } /* collector card */ @@ -692,8 +704,8 @@ body { font-weight: 700; letter-spacing: 0.04em; color: var(--purple); - background: rgba(188, 140, 255, 0.1); - border: 1px solid rgba(188, 140, 255, 0.3); + background: rgba(var(--purple-rgb), 0.1); + border: 1px solid rgba(var(--purple-rgb), 0.3); border-radius: 3px; padding: 1px 6px; list-style: none; @@ -712,8 +724,8 @@ details[open] > .jwt-summary::before { .jwt-body { margin-top: 6px; padding: 8px 10px; - background: rgba(188, 140, 255, 0.04); - border: 1px solid rgba(188, 140, 255, 0.15); + background: rgba(var(--purple-rgb), 0.04); + border: 1px solid rgba(var(--purple-rgb), 0.15); border-radius: 4px; font-family: var(--font-mono); font-size: 11px; @@ -765,7 +777,7 @@ details[open] > .jwt-summary::before { .jwt-expired { display: inline-flex; align-items: center; - background: rgba(248, 81, 73, 0.15); + background: rgba(var(--red-rgb), 0.15); color: var(--red); font-size: 9px; font-weight: 700; @@ -911,11 +923,11 @@ details[open] > .jwt-summary::before { font-size: 11px; } .fh-error { - background: rgba(248, 81, 73, 0.08); + background: rgba(var(--red-rgb), 0.08); border-left: 3px solid var(--red); } .fh-warning { - background: rgba(210, 153, 34, 0.08); + background: rgba(var(--yellow-rgb), 0.08); border-left: 3px solid var(--yellow); } .fh-header { @@ -1011,11 +1023,11 @@ details[open] > .jwt-summary::before { } .diag-issue-error { border-left-color: var(--red); - background: rgba(248, 81, 73, 0.06); + background: rgba(var(--red-rgb), 0.06); } .diag-issue-warning { border-left-color: var(--yellow); - background: rgba(210, 153, 34, 0.06); + background: rgba(var(--yellow-rgb), 0.06); } .diag-issue-info { border-left-color: var(--blue); @@ -1251,7 +1263,7 @@ body.resizing { } .snapshot-delete:hover { color: var(--red); - background: rgba(248, 81, 73, 0.1); + background: rgba(var(--red-rgb), 0.1); } /* ── Import banner ────────────────────────────────── */