mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 23:49:14 +00:00
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:
1 parent
1d1e03b4c8
commit
95dd5397cd
2 files changed
+173
-21
No files matched your search
+89
-21
@@ -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>
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user