From 478975bc7ef547086b5df528c81381a813acfab2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:54:01 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20Switch=20to?= =?UTF-8?q?=20Core=20profile=20recovery=20actions=20to=20empty=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces contextual recovery buttons when selected profiles or status filters result in empty states across Server Map, Catalog, and Fabric views. Also updates tests to verify recovery button rendering. Co-authored-by: SoulWayy <285978010+SoulWayy@users.noreply.github.com> --- .Jules/palette.md | 5 +++++ src/kater/web/dashboard.py | 42 ++++++++++++++++++++++++++++++++++++-- tests/test_dashboard.py | 1 + 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.Jules/palette.md b/.Jules/palette.md index b687358..67867bd 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -34,3 +34,8 @@ **Learning:** In single-page applications die met toetsenbord en schermlezer worden gebruikt, gaat de focus verloren als een overlay sluit zonder focusherstel. Leg `document.activeElement` vast bij het openen van een overlay en herstel de focus bij sluiten, annuleren of succesvol opslaan. **Action:** Leg altijd het actieve element vast dat de overlay opent en herstel de focus bij sluiten, annuleren of succesvolle voltooiing. + +## 2026-08-02 - [Profile Empty State Recovery Actions] + +**Learning:** For tabbed or filtered single-page dashboard interfaces where empty states can be caused by profile-level filtering, offering a contextual "Switch profile to core" recovery action (using the `.view-empty-link` class) prevented users from hitting dead ends and significantly improved usability and keyboard/screen-reader accessibility. +**Action:** Always provide direct, one-click recovery links to fallback to a valid profile state when custom profiles filter out all results in empty-state views. diff --git a/src/kater/web/dashboard.py b/src/kater/web/dashboard.py index 09f5d86..737e794 100644 --- a/src/kater/web/dashboard.py +++ b/src/kater/web/dashboard.py @@ -2246,7 +2246,25 @@ class ApiError extends Error { btn.onclick = resetRouteFilter; empty.appendChild(btn); } - } else { empty.textContent = 'No servers in this profile.'; } + if (activeProfile !== 'core') { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'view-empty-link'; + btn.textContent = 'Switch profile to core'; + btn.onclick = () => switchProfile('core'); + empty.appendChild(btn); + } + } else { + empty.textContent = 'No servers in this profile.'; + if (activeProfile !== 'core') { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'view-empty-link'; + btn.textContent = 'Switch profile to core'; + btn.onclick = () => switchProfile('core'); + empty.appendChild(btn); + } + } el.appendChild(empty); return; } @@ -3455,14 +3473,26 @@ class ApiError extends Error { empty.textContent = 'No servers match "' + catalogQuery + '" in this status.'; addLink('Clear search', clearCatalogSearch); addLink('Switch filter to all', resetCatalogFilter); + if (activeProfile !== 'core') { + addLink('Switch profile to core', () => switchProfile('core')); + } } else if (hasQuery) { empty.textContent = 'No servers match "' + catalogQuery + '".'; addLink('Clear search', clearCatalogSearch); + if (activeProfile !== 'core') { + addLink('Switch profile to core', () => switchProfile('core')); + } } else if (hasFilter) { empty.textContent = 'No servers in this status.'; addLink('Switch filter to all', resetCatalogFilter); + if (activeProfile !== 'core') { + addLink('Switch profile to core', () => switchProfile('core')); + } } else { - empty.textContent = 'No servers in this profile. Switch profiles in the top bar.'; + empty.textContent = 'No servers in this profile.'; + if (activeProfile !== 'core') { + addLink('Switch profile to core', () => switchProfile('core')); + } } grid.appendChild(empty); return; @@ -4203,6 +4233,14 @@ class ApiError extends Error { const empty = document.createElement('div'); empty.className = 'view-empty'; empty.textContent = 'No capabilities discoverable for the current profile.'; + if (activeProfile !== 'core') { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'view-empty-link'; + btn.textContent = 'Switch profile to core'; + btn.onclick = () => switchProfile('core'); + empty.appendChild(btn); + } capsEl.appendChild(empty); } else { for (const item of caps.slice(0, 50)) { diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 4e3ac61..8bd4b7b 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -85,6 +85,7 @@ def test_zero_result_states_have_recovery_actions(): # Labels shown in the empty state, plus the shared styling hook. assert "Clear search" in html assert "Switch filter to all" in html + assert "Switch profile to core" in html assert "view-empty-link" in html # Buttons are defensively typed so they never submit a surrounding form. assert "type = 'button'" in html From 8ba3121e3839696622bad853add849bbab1b1097 Mon Sep 17 00:00:00 2001 From: SoulWayy Date: Sun, 2 Aug 2026 14:58:20 +0000 Subject: [PATCH 2/4] Test the rendered empty-state recovery buttons, not just the source string Co-authored-by: Codesmith --- tests/test_dashboard.py | 137 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 8bd4b7b..ff5982c 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -790,3 +790,140 @@ def test_credentials_modal_focus_restoration_behavior_node(tmp_path): assert res["body"]["capturedNothing"] is True assert res["body"]["cleared"] is True assert res["body"]["focusRestored"] is False + + +# Runs the shipped renderServerMap JS against a minimal DOM so the empty-state +# recovery actions are proven behaviorally: a non-core empty state must render +# a real button[type="button"].view-empty-link whose click switches the +# profile to core (complements the source-string checks in +# test_zero_result_states_have_recovery_actions). +_EMPTY_STATE_HARNESS = r""" +class El { + constructor(tag) { + this.tagName = String(tag).toUpperCase(); + this.children = []; + this.dataset = {}; + this.style = {}; + this.textContent = ''; + this.type = ''; + this.className = ''; + this.onclick = null; + } + get innerHTML() { return ''; } + set innerHTML(value) { + if (value !== '') throw new Error('harness only supports clearing innerHTML'); + this.children = []; + } + appendChild(child) { this.children.push(child); return child; } + addEventListener() {} +} + +let serverMap = null; +const document = { + createElement: (tag) => new El(tag), + getElementById: (id) => (id === 'server-map' ? serverMap : null), +}; +let servers = []; +let routeFilter = 'all'; +let activeProfile = 'core'; +let routeRows = []; +const profileSwitches = []; +function switchProfile(name) { profileSwitches.push(name); } +let filterResets = 0; +function resetRouteFilter() { filterResets += 1; } +const serverState = (s) => s.state; +const makeBadge = () => new El('span'); + +/*__DASHBOARD_JS__*/ + +function run(opts) { + serverMap = new El('div'); + servers = opts.servers; + routeFilter = opts.routeFilter; + activeProfile = opts.activeProfile; + renderServerMap(); + const empty = serverMap.children[0] || null; + const buttons = empty ? empty.children.filter((c) => c.tagName === 'BUTTON') : []; + return { + emptyClass: empty ? empty.className : null, + message: empty ? empty.textContent : null, + buttons: buttons.map((b) => { + const before = profileSwitches.length; + const beforeResets = filterResets; + if (typeof b.onclick === 'function') b.onclick(); + return { + type: b.type, + className: b.className, + label: b.textContent, + hasHandler: typeof b.onclick === 'function', + profileSwitchedTo: + profileSwitches.length > before ? profileSwitches[profileSwitches.length - 1] : null, + filterReset: filterResets > beforeResets, + }; + }), + }; +} + +process.stdout.write(JSON.stringify({ + nonCoreEmptyProfile: run({ servers: [], routeFilter: 'all', activeProfile: 'palette' }), + coreEmptyProfile: run({ servers: [], routeFilter: 'all', activeProfile: 'core' }), + nonCoreFilteredOut: run({ + servers: [{ name: 'demo', state: 'ready' }], + routeFilter: 'disabled', + activeProfile: 'palette', + }), +})); +""" + + +def test_server_map_empty_state_recovery_buttons_behavior_node(tmp_path): + node = shutil.which("node") or shutil.which("nodejs") + if node is None: # pragma: no cover - depends on the host toolchain + pytest.skip("node is required to execute the dashboard JS") + assert node is not None + html = render_dashboard() + dashboard_js = "\n".join( + _extract_js_function(html, name) + for name in ("visibleRouteServers", "renderServerMap") + ) + script = tmp_path / "empty_state_recovery.cjs" + script.write_text( + _EMPTY_STATE_HARNESS.replace("/*__DASHBOARD_JS__*/", dashboard_js), + encoding="utf-8", + ) + proc = subprocess.run( + [node, str(script)], capture_output=True, text=True, timeout=60, check=False + ) + assert proc.returncode == 0, proc.stderr + res = json.loads(proc.stdout) + + # Non-core profile with no servers: a semantic recovery button is rendered + # next to the message, and clicking it switches the profile to core. + non_core = res["nonCoreEmptyProfile"] + assert non_core["emptyClass"] == "view-empty" + assert non_core["message"] == "No servers in this profile." + assert [b["label"] for b in non_core["buttons"]] == ["Switch profile to core"] + btn = non_core["buttons"][0] + assert btn["type"] == "button" + assert btn["className"] == "view-empty-link" + assert btn["hasHandler"] is True + assert btn["profileSwitchedTo"] == "core" + + # Core profile: there is nowhere better to switch to, so no button. + core = res["coreEmptyProfile"] + assert core["message"] == "No servers in this profile." + assert core["buttons"] == [] + + # Non-core profile where a status filter hides all servers: both recovery + # actions appear, in order, and each click triggers its own handler. + filtered = res["nonCoreFilteredOut"] + assert filtered["message"] == "No servers match this filter." + assert [b["label"] for b in filtered["buttons"]] == [ + "Switch filter to all", + "Switch profile to core", + ] + for button in filtered["buttons"]: + assert button["type"] == "button" + assert button["className"] == "view-empty-link" + assert filtered["buttons"][0]["filterReset"] is True + assert filtered["buttons"][1]["profileSwitchedTo"] == "core" From e8fbd308633c54b9d7a78fcdba748158ff6e93ea Mon Sep 17 00:00:00 2001 From: SoulWayy Date: Sun, 2 Aug 2026 15:00:28 +0000 Subject: [PATCH 3/4] Propagate active profile to Fabric and share the empty-state link helper Co-authored-by: Codesmith --- src/kater/web/dashboard.py | 52 ++++++++++++++------------------------ tests/test_dashboard.py | 2 +- 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/src/kater/web/dashboard.py b/src/kater/web/dashboard.py index 737e794..b4c89fa 100644 --- a/src/kater/web/dashboard.py +++ b/src/kater/web/dashboard.py @@ -2094,6 +2094,7 @@ class ApiError extends Error { writeUrlState(); loadCatalog(); if (currentView === 'catalog') loadCatalogView(); + if (currentView === 'fabric') loadFabricView(); toast('profile: ' + p); } @@ -2227,6 +2228,18 @@ class ApiError extends Error { return servers.filter(s => serverState(s) === routeFilter); } +// Shared recovery-action factory for zero-result empty states (Server Map, +// Catalog, Fabric): one semantic