feat(html): add a light/dark/system theme toggle to graph.html (#3894)

Co-Authored-By: Grok Bot <noreply@x.ai>
This commit is contained in:
Mahaboob pashaandGrok Bot committed 2026-10-10 22:06:19 +05:30
1 parent 1d1e03b4c8
commit 95dd5397cd
2 files changed
+173 -21

No files matched your search

+89 -21
View File
@@ -31,42 +31,56 @@ def _viz_node_limit() -> int:
def _html_styles() -> str:
return """<style>
:root {
--bg: #0f0f1a; --panel: #1a1a2e; --border: #2a2a4e; --input-border: #3a3a5e;
--text: #e0e0e0; --text-2: #aaa; --text-3: #ccc; --muted: #555; --count: #666;
--neighbor-border: #333; --node-label: #ffffff;
}
[data-theme="light"] {
--bg: #ffffff; --panel: #f8f9fa; --border: #d9dee5; --input-border: #c3cad4;
--text: #222222; --text-2: #666666; --text-3: #333333; --muted: #888888; --count: #777777;
--neighbor-border: #ccc; --node-label: #222222;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
#graph { flex: 1; }
#sidebar { width: 280px; background: #1a1a2e; border-left: 1px solid #2a2a4e; display: flex; flex-direction: column; overflow: hidden; }
#search-wrap { padding: 12px; border-bottom: 1px solid #2a2a4e; }
#search { width: 100%; background: #0f0f1a; border: 1px solid #3a3a5e; color: #e0e0e0; padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
#sidebar { width: 280px; background: var(--panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
#search-wrap { padding: 12px; border-bottom: 1px solid var(--border); }
#search { width: 100%; background: var(--bg); border: 1px solid var(--input-border); color: var(--text); padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
#search:focus { border-color: #4E79A7; }
#search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid #2a2a4e; display: none; }
#search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid var(--border); display: none; }
.search-item { padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-item:hover { background: #2a2a4e; }
#info-panel { padding: 14px; border-bottom: 1px solid #2a2a4e; min-height: 140px; }
#info-panel h3 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
#info-content { font-size: 13px; color: #ccc; line-height: 1.6; }
.search-item:hover { background: var(--border); }
#info-panel { padding: 14px; border-bottom: 1px solid var(--border); min-height: 140px; }
#info-panel h3 { font-size: 13px; color: var(--text-2); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
#info-content { font-size: 13px; color: var(--text-3); line-height: 1.6; }
#info-content .field { margin-bottom: 5px; }
#info-content .field b { color: #e0e0e0; }
#info-content .empty { color: #555; font-style: italic; }
.neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; }
.neighbor-link:hover { background: #2a2a4e; }
#info-content .field b { color: var(--text); }
#info-content .empty { color: var(--muted); font-style: italic; }
.neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid var(--neighbor-border); }
.neighbor-link:hover { background: var(--border); }
#neighbors-list { max-height: 160px; overflow-y: auto; margin-top: 4px; }
#legend-wrap { flex: 1; overflow-y: auto; padding: 12px; }
#legend-wrap h3 { font-size: 13px; color: #aaa; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
#legend-wrap h3 { font-size: 13px; color: var(--text-2); margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }
.legend-item:hover { background: #2a2a4e; padding-left: 4px; }
.legend-item:hover { background: var(--border); padding-left: 4px; }
.legend-item.dimmed { opacity: 0.35; }
.legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.legend-count { color: #666; font-size: 11px; }
#stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }
.legend-count { color: var(--count); font-size: 11px; }
#stats { padding: 10px 14px; border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); }
#legend-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 4px 0; }
#legend-controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: #aaa; user-select: none; }
#legend-controls label:hover { color: #e0e0e0; }
.legend-cb, #select-all-cb { appearance: none; -webkit-appearance: none; width: 14px; height: 14px; border: 1.5px solid #3a3a5e; border-radius: 3px; background: #0f0f1a; cursor: pointer; position: relative; flex-shrink: 0; }
#legend-controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: var(--text-2); user-select: none; }
#legend-controls label:hover { color: var(--text); }
.legend-cb, #select-all-cb { appearance: none; -webkit-appearance: none; width: 14px; height: 14px; border: 1.5px solid var(--input-border); border-radius: 3px; background: var(--bg); cursor: pointer; position: relative; flex-shrink: 0; }
.legend-cb:checked, #select-all-cb:checked { background: #4E79A7; border-color: #4E79A7; }
.legend-cb:checked::after, #select-all-cb:checked::after { content: ''; position: absolute; left: 3.5px; top: 1px; width: 4px; height: 7px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
#select-all-cb:indeterminate { background: #4E79A7; border-color: #4E79A7; }
#select-all-cb:indeterminate::after { content: ''; position: absolute; left: 2px; top: 5px; width: 8px; height: 2px; background: #fff; border: none; transform: none; }
#theme-wrap { display: flex; gap: 4px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
#theme-wrap button { flex: 1; background: var(--bg); color: var(--text-2); border: 1px solid var(--input-border); border-radius: 4px; padding: 3px 0; font-size: 11px; cursor: pointer; }
#theme-wrap button:hover { color: var(--text); }
#theme-wrap button[aria-pressed="true"] { background: #4E79A7; border-color: #4E79A7; color: #fff; }
</style>"""
def _hyperedge_script(hyperedges_json: str) -> str:
@@ -198,6 +212,43 @@ const network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }},
edges: {{ smooth: {{ type: 'continuous', roundness: 0.2 }}, selectionWidth: 3 }},
}});
// Theme toggle (#3894): Light / Dark / System, persisted in localStorage.
const THEME_KEY = 'graphify-theme';
const themeMQ = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
let themeLabelColor = '#ffffff'; // matches the per-node font color set in Python
function themePref() {{
let p = null;
try {{ p = localStorage.getItem(THEME_KEY); }} catch (e) {{}}
return (p === 'light' || p === 'dark' || p === 'system') ? p : null;
}}
function applyTheme(pref) {{
const t = pref === 'light' ? 'light' : (pref === 'system' && themeMQ && themeMQ.matches) ? 'light' : 'dark';
const root = document.documentElement;
if (!root) return;
root.setAttribute('data-theme', t);
document.querySelectorAll('[data-theme-choice]').forEach(b => {{
b.setAttribute('aria-pressed', String(b.dataset.themeChoice === (pref || 'dark')));
}});
const labelColor = getComputedStyle(root).getPropertyValue('--node-label').trim();
if (labelColor && labelColor !== themeLabelColor) {{
themeLabelColor = labelColor;
nodesDS.update(nodesDS.get().map(n => ({{ id: n.id, font: Object.assign({{}}, n.font, {{ color: labelColor }}) }})));
}}
}}
document.querySelectorAll('[data-theme-choice]').forEach(b => {{
b.addEventListener('click', () => {{
const pref = b.dataset.themeChoice;
try {{ localStorage.setItem(THEME_KEY, pref); }} catch (e) {{}}
applyTheme(pref);
}});
}});
if (themeMQ) {{
const onSchemeChange = () => {{ if (themePref() === 'system') applyTheme('system'); }};
if (themeMQ.addEventListener) themeMQ.addEventListener('change', onSchemeChange);
else if (themeMQ.addListener) themeMQ.addListener(onSchemeChange);
}}
applyTheme(themePref());
network.once('stabilizationIterationsDone', () => {{
network.setOptions({{ physics: {{ enabled: false }} }});
}});
@@ -224,7 +275,7 @@ function showInfo(nodeId) {{
}}
html += `<div class="field">Degree: ${{n.degree}}</div>`;
if (neighborIds.length) {{
html += `<div class="field" style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>`;
html += `<div class="field" style="margin-top:8px;color:var(--text-2);font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>`;
}}
document.getElementById('info-content').innerHTML = html;
}}
@@ -633,10 +684,27 @@ def to_html(
integrity="sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1"
crossorigin="anonymous"></script>
{_html_styles()}
<script id="graphify-theme-init">
// Apply the saved theme before first paint (#3894). No saved choice keeps the
// historical dark look; "system" follows prefers-color-scheme.
(function () {{
var pref = null;
try {{ pref = localStorage.getItem('graphify-theme'); }} catch (e) {{}}
var t = pref === 'light' ? 'light'
: pref === 'system' && window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light'
: 'dark';
document.documentElement.setAttribute('data-theme', t);
}})();
</script>
</head>
<body>
<div id="graph"></div>
<div id="sidebar">
<div id="theme-wrap" role="group" aria-label="Theme">
<button type="button" data-theme-choice="light">Light</button>
<button type="button" data-theme-choice="dark">Dark</button>
<button type="button" data-theme-choice="system">System</button>
</div>
<div id="search-wrap">
<input id="search" type="text" placeholder="Search nodes..." autocomplete="off">
<div id="search-results"></div>
+84
View File
@@ -0,0 +1,84 @@
"""graph.html light/dark/system theme toggle (#3894)."""
import re
import networkx as nx
from graphify.export import to_html
# Page-chrome colors graph.html hardcoded before #3894. The dark defaults must
# keep these exact values so the out-of-the-box look does not change.
DARK_DEFAULTS = {
"--bg": "#0f0f1a",
"--panel": "#1a1a2e",
"--border": "#2a2a4e",
"--input-border": "#3a3a5e",
"--text": "#e0e0e0",
"--text-2": "#aaa",
"--text-3": "#ccc",
"--muted": "#555",
"--count": "#666",
"--neighbor-border": "#333",
"--node-label": "#ffffff",
}
def _render(tmp_path):
G = nx.Graph()
G.add_node("a", label="Alpha", source_file="a.py", file_type="code")
G.add_node("b", label="Beta", source_file="b.py", file_type="code")
G.add_edge("a", "b", relation="calls", confidence="EXTRACTED")
out = tmp_path / "graph.html"
to_html(G, {0: ["a", "b"]}, str(out), community_labels={0: "Core"})
return out.read_text(encoding="utf-8")
def _block(html, selector):
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", html)
assert m, f"missing CSS block {selector}"
return dict(re.findall(r"(--[\w-]+):\s*([^;]+);", m.group(1)))
def test_dark_defaults_match_previous_hardcoded_colors(tmp_path):
root = _block(_render(tmp_path), ":root")
for name, value in DARK_DEFAULTS.items():
assert root.get(name) == value, name
def test_light_override_defines_every_variable(tmp_path):
light = _block(_render(tmp_path), '[data-theme="light"]')
assert set(light) == set(DARK_DEFAULTS)
assert light["--bg"] != DARK_DEFAULTS["--bg"]
def test_chrome_styles_use_variables_not_hardcoded_dark_colors(tmp_path):
html = _render(tmp_path)
styles = html.split("</style>")[0].split("[data-theme=\"light\"]")[1].split("}", 1)[1]
for color in ("#0f0f1a", "#1a1a2e", "#2a2a4e", "#3a3a5e", "#e0e0e0"):
assert color not in styles, color
assert "background: var(--bg)" in styles
assert "background: var(--panel)" in styles
def test_toggle_control_and_persistence(tmp_path):
html = _render(tmp_path)
for choice in ("light", "dark", "system"):
assert f'data-theme-choice="{choice}"' in html
assert "graphify-theme" in html
assert "try { pref = localStorage.getItem('graphify-theme'); } catch (e) {}" in html
assert "try { localStorage.setItem(THEME_KEY, pref); } catch (e) {}" in html
assert "prefers-color-scheme: light" in html
assert "addEventListener('change'" in html
def test_theme_applied_in_head_before_first_paint(tmp_path):
html = _render(tmp_path)
head = html.split("</head>")[0]
assert "setAttribute('data-theme'" in head
# Unsaved preference falls back to dark, not the OS setting.
assert ": 'dark';" in head
def test_output_is_deterministic(tmp_path):
a = _render(tmp_path)
b = _render(tmp_path)
assert a == b