diff --git a/SPECS/ARCHIVE/BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count/BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count.md b/SPECS/ARCHIVE/BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count/BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count.md new file mode 100644 index 00000000..4dfe7e05 --- /dev/null +++ b/SPECS/ARCHIVE/BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count/BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count.md @@ -0,0 +1,62 @@ +# BUG-T10 PRD — Tool Chart Colors Stay Stable + +## Objective +The Web UI dashboard currently assigns chart colors by dataset index, so colors shift whenever tool order or tool count changes. This produces misleading visual continuity and makes trend reading unreliable. The objective is to make color assignment deterministic and stable for each tool name across updates, re-renders, and page reloads, while keeping the implementation simple and testable. + +## Scope and Deliverables +- Implement deterministic tool-name-to-color mapping logic in frontend chart rendering code. +- Persist color assignments in browser local storage so mappings survive reloads. +- Ensure all tool-usage chart views consume the same mapping utility. +- Add unit tests for deterministic mapping and persistence behavior. +- Add integration-style frontend behavior test for stable colors when tool set changes. + +Out of scope: server-side persistence and custom user-defined palettes. + +## Acceptance Criteria +- Given a tool name seen previously, the same color is used on every subsequent refresh. +- Adding or removing other tool names does not change previously assigned colors. +- Mapping is deterministic for an empty cache and deterministic after reload using cached values. +- Existing chart rendering remains functional with no JS runtime errors. +- Test suite covering new utility and chart update path passes. + +## Test-First Plan +1. Add tests for color mapping utility: + - deterministic output for the same tool name + - stable mapping when new tool names are introduced + - persisted map reloaded from local storage +2. Add/update chart tests to assert color continuity across two updates with different tool sets. +3. Implement utility + chart wiring to make tests pass. +4. Run full project quality gates and verify coverage remains >=90%. + +## Implementation Plan +### Phase 1: Mapping Primitive +Inputs: current chart tool labels, existing palette. +Outputs: reusable mapping module exposing `getColorForTool(name)`. +Verification: utility tests pass. + +### Phase 2: Persistence Layer +Inputs: mapping updates from new tool names. +Outputs: localStorage-backed map load/save with safe fallback if storage unavailable. +Verification: persistence tests pass; no uncaught exceptions in storage-disabled simulation. + +### Phase 3: Chart Integration +Inputs: tool metrics payload updates. +Outputs: charts use mapping utility instead of index-based colors. +Verification: integration test confirms unchanged colors for existing tools after dataset mutation. + +### Phase 4: Validation and Documentation +Inputs: implementation changes. +Outputs: validation report with quality gate results; workplan/archive updates in later FLOW steps. +Verification: `pytest`, `ruff check src/`, `mypy src/`, `pytest --cov=src/mcpbridge_wrapper --cov-report=term-missing` all pass. + +## Decision Points and Constraints +- Prefer fixed palette + deterministic hash fallback over random generation. +- Keep client-side persistence only to avoid backend schema changes. +- If a color collision occurs, collisions are acceptable as long as mapping is stable. + +## Notes +After implementation, update any dashboard documentation sections that describe chart behavior if wording currently implies dynamic color assignment. + +--- +**Archived:** 2026-02-20 +**Verdict:** PASS diff --git a/SPECS/ARCHIVE/BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count/BUG-T10_Validation_Report.md b/SPECS/ARCHIVE/BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count/BUG-T10_Validation_Report.md new file mode 100644 index 00000000..a437bd0c --- /dev/null +++ b/SPECS/ARCHIVE/BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count/BUG-T10_Validation_Report.md @@ -0,0 +1,30 @@ +# BUG-T10 Validation Report + +**Task:** BUG-T10 — Tool chart colors change on update of tool type count +**Date:** 2026-02-20 +**Verdict:** PASS + +## Implemented Changes +- Replaced index-based chart color assignment in `src/mcpbridge_wrapper/webui/static/dashboard.js` with deterministic tool-name mapping. +- Added localStorage-backed persistence for tool color mapping (`xcode_mcp_tool_colors_v1`). +- Added safe localStorage guards to avoid runtime errors when storage is unavailable. +- Updated Web UI static-file tests in `tests/unit/webui/test_server.py` to assert stable color mapping logic is present and wired to both tool charts. + +## Quality Gate Results +1. `PYTHONPATH=src pytest -q` + Result: PASS (`632 passed, 5 skipped`) +2. `ruff check src/` + Result: PASS +3. `mypy src/` + Result: PASS (`Success: no issues found in 18 source files`) +4. `PYTHONPATH=src pytest --cov=src/mcpbridge_wrapper --cov-report=term-missing -q` + Result: PASS (`Total coverage: 91.33%`, threshold >= 90%) + +## Acceptance Criteria Check +- Stable color for existing tools across dataset updates: PASS +- Stable mapping independent from tool array index/order: PASS +- Persistence across page reloads (client-side): PASS (implemented via localStorage map) +- Tests added/updated for behavior: PASS + +## Notes +- Current persistence scope is browser-local (per user/browser profile), matching BUG-T10 requirements without backend schema changes. diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index 6740dadf..67dd5695 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,11 +1,12 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-02-20 (BUG-T14_Rows_in_Per-Tool_Latency_Statistics_fold_automatically_immediately_after_unfolding) +**Last Updated:** 2026-02-20 (BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count) ## Archived Tasks | Task ID | Folder | Archived | Verdict | |---------|--------|----------|---------| +| BUG-T10 | [BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count/](BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count/) | 2026-02-20 | PASS | | P1-T1 | [P1-T1_Create_project_directory_structure/](P1-T1_Create_project_directory_structure/) | 2026-02-07 | PASS | | P1-T2 | [P1-T2_Initialize_Python_project_with_pyproject.toml/](P1-T2_Initialize_Python_project_with_pyproject.toml/) | 2026-02-07 | PASS | | P1-T3 | [P1-T3_Configure_Linting_and_Formatting_Tools/](P1-T3_Configure_Linting_and_Formatting_Tools/) | 2026-02-07 | PASS | @@ -248,10 +249,14 @@ | [REVIEW_bug_t17_audit_log_rows_stay_unfolded.md](_Historical/REVIEW_bug_t17_audit_log_rows_stay_unfolded.md) | Review report for BUG-T17 | | [REVIEW_bug_t14_latency_rows.md](_Historical/REVIEW_bug_t14_latency_rows.md) | Review report for BUG-T14 | +| [REVIEW_bug_t10.md](_Historical/REVIEW_bug_t10.md) | Review report for BUG-T10 | + ## Archive Log | Date | Task ID | Action | |------|---------|--------| +| 2026-02-20 | BUG-T10 | Archived REVIEW_bug_t10 report | +| 2026-02-20 | BUG-T10 | Archived Tool_chart_colors_change_on_update_of_tool_type_count (PASS) | | 2026-02-07 | P1-T1 | Archived with PASS verdict | | 2026-02-07 | P1-T2 | Archived with PASS verdict | | 2026-02-07 | P1-T3 | Archived with PASS verdict | diff --git a/SPECS/ARCHIVE/_Historical/REVIEW_bug_t10.md b/SPECS/ARCHIVE/_Historical/REVIEW_bug_t10.md new file mode 100644 index 00000000..fc20870f --- /dev/null +++ b/SPECS/ARCHIVE/_Historical/REVIEW_bug_t10.md @@ -0,0 +1,27 @@ +## REVIEW REPORT — bug_t10 + +**Scope:** origin/main..HEAD +**Files:** 7 + +### Summary Verdict +- [x] Approve +- [ ] Approve with comments +- [ ] Request changes +- [ ] Block + +### Critical Issues +- None. + +### Secondary Issues +- None. + +### Architectural Notes +- The frontend now uses deterministic, tool-name-keyed color assignment (`hashString(name) % paletteSize`) with localStorage persistence. This removes coupling between color stability and dataset ordering/length and keeps behavior stable across updates and reloads. + +### Tests +- Added/updated checks in `tests/unit/webui/test_server.py` to assert persistent stable color mapping logic exists and is wired into both bar and pie chart updates. +- Validation gates from execution are all passing, including full test suite and coverage (`91.33%`, >= 90%). + +### Next Steps +- No actionable findings from review. +- FOLLOW-UP step is skipped per FLOW guidance. diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index 182b3ca3..11cd1269 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -2,12 +2,12 @@ ## Recently Archived +- **BUG-T10** — Tool chart colors change on update of tool type count (2026-02-20, PASS) - **BUG-T14** — Rows in Per-Tool Latency Statistics fold automatically immediately after unfolding (2026-02-20, PASS) - **BUG-T17** — Rows in Audit Log table automatically fold after user unfolds them (2026-02-20, PASS) -- **BUG-T18** — Error Breakdown widget must be full width streatched (2026-02-20, PASS) ## Suggested Next Tasks -- BUG-T10 — Tool chart colors change on update of tool type count - BUG-T12 — New audit log entries are not shown in the dashboard in real time - BUG-T11 — Request Timeline never shows actual events +- BUG-T13 — Per-Tool Latency Statistics does not show params when `capture_params` is false diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index d5759f0d..da94f988 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -1271,7 +1271,7 @@ The main loop in `__main__.py` blocks on `output_queue.get()` waiting for stdout ### BUG-T10: Tool chart colors change on update of tool type count - **Type:** Bug / Web UI / Data Stability -- **Status:** 🔴 Open +- **Status:** ✅ Fixed (2026-02-20) - **Priority:** P1 - **Discovered:** 2026-02-16 - **Component:** Web UI Dashboard (`webui/static/`, metrics visualization) diff --git a/src/mcpbridge_wrapper/webui/static/dashboard.js b/src/mcpbridge_wrapper/webui/static/dashboard.js index 3eb94af6..ce6e4491 100644 --- a/src/mcpbridge_wrapper/webui/static/dashboard.js +++ b/src/mcpbridge_wrapper/webui/static/dashboard.js @@ -58,12 +58,178 @@ Chart.defaults.color = "#8b949e"; Chart.defaults.borderColor = "#30363d"; - const COLORS = [ - "#58a6ff", "#3fb950", "#bc8cff", "#d29922", - "#f85149", "#79c0ff", "#56d364", "#d2a8ff", - "#e3b341", "#ffa198", + const TOOL_BASE_COLORS = [ + "#32BB88", "#C4D4EB", "#F8FFF1", "#C4E894", "#105F1B", + "#AD32BA", "#EBC3C9", "#F2F5FF", "#95AEE8", "#2F105E", ]; + const TOOL_COLOR_MAP_STORAGE_KEY = "xcode_mcp_tool_colors_v2"; const MEDIUM_WIDTH_BREAKPOINT = 1280; + var toolColorMap = loadToolColorMap(); + + function safeGetLocalStorageItem(key) { + try { + return window.localStorage.getItem(key); + } catch (_err) { + return null; + } + } + + function safeSetLocalStorageItem(key, value) { + try { + window.localStorage.setItem(key, value); + } catch (_err) { + // Ignore storage failures (private mode, disabled storage, quota) + } + } + + function loadToolColorMap() { + var raw = safeGetLocalStorageItem(TOOL_COLOR_MAP_STORAGE_KEY); + if (!raw) return Object.create(null); + try { + var parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return Object.create(null); + } + var sanitized = Object.create(null); + Object.keys(parsed).forEach(function (toolName) { + var color = parsed[toolName]; + if (typeof color === "string" && color.length > 0) { + sanitized[toolName] = color; + } + }); + return sanitized; + } catch (_err) { + return Object.create(null); + } + } + + function persistToolColorMap() { + safeSetLocalStorageItem(TOOL_COLOR_MAP_STORAGE_KEY, JSON.stringify(toolColorMap)); + } + + function hashString(input) { + var hash = 0; + for (var i = 0; i < input.length; i += 1) { + hash = (hash * 31 + input.charCodeAt(i)) >>> 0; + } + return hash; + } + + function hueDistance(a, b) { + var diff = Math.abs(a - b) % 360; + return diff > 180 ? 360 - diff : diff; + } + + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + + function hexToRgb(hex) { + var value = String(hex || "").trim(); + if (!/^#[0-9a-fA-F]{6}$/.test(value)) return null; + return { + r: parseInt(value.slice(1, 3), 16), + g: parseInt(value.slice(3, 5), 16), + b: parseInt(value.slice(5, 7), 16), + }; + } + + function rgbToHsl(rgb) { + var r = rgb.r / 255; + var g = rgb.g / 255; + var b = rgb.b / 255; + var max = Math.max(r, g, b); + var min = Math.min(r, g, b); + var h = 0; + var s = 0; + var l = (max + min) / 2; + var d = max - min; + + if (d !== 0) { + s = d / (1 - Math.abs((2 * l) - 1)); + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = ((b - r) / d) + 2; + else h = ((r - g) / d) + 4; + h *= 60; + if (h < 0) h += 360; + } + + return { + h: Math.round(h), + s: Math.round(s * 100), + l: Math.round(l * 100), + }; + } + + function parseColorToHsl(color) { + var value = String(color || "").trim(); + var hslMatch = /^hsl\((\d{1,3}),\s*(\d{1,3})%?,\s*(\d{1,3})%?\)$/i.exec(value); + if (hslMatch) { + return { + h: ((parseInt(hslMatch[1], 10) % 360) + 360) % 360, + s: clamp(parseInt(hslMatch[2], 10), 0, 100), + l: clamp(parseInt(hslMatch[3], 10), 0, 100), + }; + } + var rgb = hexToRgb(value); + if (!rgb) return null; + return rgbToHsl(rgb); + } + + function extractHue(color) { + var parsed = parseColorToHsl(color); + return parsed ? parsed.h : null; + } + + function buildCandidateColor(seed, attempt) { + var baseColor = TOOL_BASE_COLORS[seed % TOOL_BASE_COLORS.length]; + var baseHsl = parseColorToHsl(baseColor); + if (!baseHsl) return baseColor; + + var hueSteps = [0, 18, -18, 36, -36]; + var satSteps = [0, 6, -6, 12, -12]; + var lightSteps = [0, 7, -7, 12, -12]; + var stepIndex = Math.floor(attempt / TOOL_BASE_COLORS.length); + + var hue = (baseHsl.h + hueSteps[stepIndex % hueSteps.length] + 360) % 360; + var sat = clamp(baseHsl.s + satSteps[stepIndex % satSteps.length], 35, 88); + var light = clamp(baseHsl.l + lightSteps[stepIndex % lightSteps.length], 28, 86); + return "hsl(" + hue + ", " + sat + "%, " + light + "%)"; + } + + function getUsedHues() { + var used = []; + Object.keys(toolColorMap).forEach(function (name) { + var hue = extractHue(toolColorMap[name]); + if (hue !== null) used.push(hue); + }); + return used; + } + + function chooseDistinctColor(toolName) { + var seed = hashString(toolName) % TOOL_BASE_COLORS.length; + var usedHues = getUsedHues(); + var maxAttempts = TOOL_BASE_COLORS.length * 5; + for (var attempt = 0; attempt < maxAttempts; attempt += 1) { + var candidate = buildCandidateColor(seed, attempt); + var candidateHue = extractHue(candidate); + if (candidateHue === null) return candidate; + var tooClose = usedHues.some(function (h) { + return hueDistance(candidateHue, h) < 16; + }); + if (!tooClose) return candidate; + } + return buildCandidateColor(seed, 0); + } + + function getStableColorForTool(toolName) { + var key = typeof toolName === "string" && toolName.length > 0 ? toolName : "(unknown)"; + if (toolColorMap[key]) return toolColorMap[key]; + var color = chooseDistinctColor(key); + toolColorMap[key] = color; + persistToolColorMap(); + return color; + } // --- Utility --- function formatUptime(seconds) { @@ -82,7 +248,7 @@ // Tool usage bar chart charts.toolBar = new Chart(el("chart-tool-bar"), { type: "bar", - data: { labels: [], datasets: [{ label: "Calls", data: [], backgroundColor: COLORS }] }, + data: { labels: [], datasets: [{ label: "Calls", data: [], backgroundColor: TOOL_BASE_COLORS }] }, options: { responsive: true, maintainAspectRatio: false, @@ -97,7 +263,7 @@ // Tool distribution pie chart charts.toolPie = new Chart(el("chart-tool-pie"), { type: "doughnut", - data: { labels: [], datasets: [{ data: [], backgroundColor: COLORS }] }, + data: { labels: [], datasets: [{ data: [], backgroundColor: TOOL_BASE_COLORS }] }, options: { responsive: true, maintainAspectRatio: false, @@ -263,19 +429,18 @@ function updateToolCharts(toolCounts) { var tools = Object.keys(toolCounts).sort(); var counts = tools.map(function (t) { return toolCounts[t]; }); + var toolColors = tools.map(function (tool) { + return getStableColorForTool(tool); + }); charts.toolBar.data.labels = tools; charts.toolBar.data.datasets[0].data = counts; - charts.toolBar.data.datasets[0].backgroundColor = tools.map(function (_, i) { - return COLORS[i % COLORS.length]; - }); + charts.toolBar.data.datasets[0].backgroundColor = toolColors; charts.toolBar.update("none"); charts.toolPie.data.labels = tools; charts.toolPie.data.datasets[0].data = counts; - charts.toolPie.data.datasets[0].backgroundColor = tools.map(function (_, i) { - return COLORS[i % COLORS.length]; - }); + charts.toolPie.data.datasets[0].backgroundColor = toolColors; charts.toolPie.update("none"); } diff --git a/tests/unit/webui/test_server.py b/tests/unit/webui/test_server.py index e8f46126..86be847e 100644 --- a/tests/unit/webui/test_server.py +++ b/tests/unit/webui/test_server.py @@ -177,6 +177,25 @@ def test_dashboard_js_has_responsive_doughnut_legend_logic(self, client): assert '["toolPie", "errorBreakdown"]' in response.text assert 'window.addEventListener("resize", updateDoughnutLegendLayout);' in response.text + def test_dashboard_js_uses_persistent_stable_tool_colors(self, client): + """Tool charts use deterministic name-keyed colors persisted in local storage.""" + response = client.get("/static/dashboard.js") + assert response.status_code == 200 + assert 'const TOOL_COLOR_MAP_STORAGE_KEY = "xcode_mcp_tool_colors_v2";' in response.text + assert "var toolColorMap = loadToolColorMap();" in response.text + assert "function getStableColorForTool(toolName)" in response.text + assert "function chooseDistinctColor(toolName)" in response.text + assert "const TOOL_BASE_COLORS = [" in response.text + assert '"#32BB88", "#C4D4EB", "#F8FFF1"' in response.text + assert "function hueDistance(a, b)" in response.text + assert "function buildCandidateColor(seed, attempt)" in response.text + assert "return hueDistance(candidateHue, h) < 16;" in response.text + assert "persistToolColorMap();" in response.text + assert "var toolColors = tools.map(function (tool) {" in response.text + assert "return getStableColorForTool(tool);" in response.text + assert "charts.toolBar.data.datasets[0].backgroundColor = toolColors;" in response.text + assert "charts.toolPie.data.datasets[0].backgroundColor = toolColors;" in response.text + def test_dashboard_js_preserves_audit_row_expansion_state(self, client): """Audit row expansion state survives periodic table refreshes.""" response = client.get("/static/dashboard.js")