Model graph: dark/light theme toggle on the page

- sun/moon button next to Export; the choice is remembered in the page state and written to the archify-theme key the diagram pages use
- explicit dark and light token sets; without a choice the page follows VS Code's theme as before
- Cytoscape restyles from the page tokens whenever the theme changes
This commit is contained in:
2026-10-21 21:33:38 +03:00
parent 99ea076571
commit 3ed2d7eed8
4 changed files with 76 additions and 16 deletions
+70 -14
View File
@@ -18,6 +18,17 @@ interface SavedView {
query?: string;
labels?: boolean;
hidden?: string[];
/** A theme chosen on the page; absent means follow VS Code. */
theme?: 'dark' | 'light';
}
type Theme = 'dark' | 'light';
/** The theme in effect: the page's own choice, else VS Code's. */
function currentTheme(): Theme {
const chosen = document.documentElement.dataset.theme;
if (chosen === 'dark' || chosen === 'light') return chosen;
return document.body.classList.contains('vscode-light') ? 'light' : 'dark';
}
cytoscape.use(fcose);
@@ -56,17 +67,17 @@ function cssVar(name: string, fallback: string): string {
return value || fallback;
}
/** Theme colours Cytoscape needs as literals; read again when VS Code switches theme. */
/** Theme colours Cytoscape needs as literals: the page's tokens, read again whenever the theme changes. */
function palette() {
const dark = !document.body.classList.contains('vscode-light');
const dark = currentTheme() === 'dark';
return {
bg: cssVar('--vscode-editor-background', dark ? '#0b1020' : '#ffffff'),
fg: cssVar('--vscode-foreground', dark ? '#e5e7eb' : '#1f2937'),
muted: cssVar('--vscode-descriptionForeground', dark ? '#9ca3af' : '#6b7280'),
panel: cssVar('--vscode-editorWidget-background', dark ? '#131c31' : '#f3f4f6'),
accent: cssVar('--vscode-textLink-foreground', '#22d3ee'),
bg: cssVar('--ag-bg', dark ? '#0b1020' : '#ffffff'),
fg: cssVar('--ag-fg', dark ? '#e5e7eb' : '#1f2937'),
muted: cssVar('--ag-muted', dark ? '#9ca3af' : '#6b7280'),
panel: cssVar('--ag-panel', dark ? '#131c31' : '#f3f4f6'),
accent: cssVar('--ag-accent', '#22d3ee'),
edge: dark ? '#64748b' : '#94a3b8',
font: cssVar('--vscode-font-family', 'system-ui, sans-serif'),
font: cssVar('--ag-font', 'system-ui, sans-serif'),
};
}
@@ -86,6 +97,7 @@ class GraphPage {
private readonly queryInput = el('input', 'ag-query');
private readonly countEl = el('span', 'ag-count');
private readonly labelsButton = el('button', 'ag-button', 'Labels');
private readonly themeButton = el('button', 'ag-button ag-theme');
private readonly legend = el('div', 'ag-legend');
private readonly side = el('aside', 'ag-side');
private readonly main = el('div', 'ag-main');
@@ -98,6 +110,7 @@ class GraphPage {
if (initial.query !== undefined) this.saved.query = initial.query;
if (this.saved.labels === undefined) this.saved.labels = initial.graph.edges.length <= LABELS_UP_TO;
this.hiddenTypes = new Set(this.saved.hidden ?? []);
this.applyTheme(this.saved.theme ?? archifyThemePreference());
this.index();
this.buildChrome();
this.buildGraph(true);
@@ -177,7 +190,10 @@ class GraphPage {
exportButton.type = 'button';
exportButton.title = 'Save the graph as it is on screen, as a PNG';
exportButton.addEventListener('click', () => this.exportPng());
actions.append(sw, exportButton);
this.themeButton.type = 'button';
this.themeButton.addEventListener('click', () => this.setTheme(currentTheme() === 'dark' ? 'light' : 'dark'));
this.syncThemeButton();
actions.append(sw, this.themeButton, exportButton);
top.append(titles, actions);
const bar = el('div', 'ag-bar');
@@ -311,6 +327,39 @@ class GraphPage {
.run();
}
// --------------------------------------------------------------- theme
/** Sets the page's own theme (or follows VS Code when undefined) without touching the saved choice. */
private applyTheme(theme: Theme | undefined): void {
if (theme) document.documentElement.dataset.theme = theme;
else delete document.documentElement.dataset.theme;
}
private setTheme(theme: Theme): void {
this.applyTheme(theme);
this.saved.theme = theme;
this.save();
try {
localStorage.setItem('archify-theme', theme); // the diagram pages read the same key
} catch {
// storage may be unavailable; the page state keeps the choice
}
this.restyle();
}
private syncThemeButton(): void {
const dark = currentTheme() === 'dark';
this.themeButton.textContent = dark ? '\u263e' : '\u2600';
this.themeButton.title = dark ? 'Switch to the light theme' : 'Switch to the dark theme';
this.themeButton.setAttribute('aria-label', this.themeButton.title);
}
private restyle(): void {
this.cy?.style(this.stylesheet());
this.syncLabels();
this.syncThemeButton();
}
private applyHidden(): void {
this.cy.nodes().forEach((n) => {
n.toggleClass('hidden', this.hiddenTypes.has(String(n.data('type') || 'other')));
@@ -639,10 +688,7 @@ class GraphPage {
}
});
window.addEventListener('resize', () => this.cy.resize());
new MutationObserver(() => {
this.cy.style(this.stylesheet());
this.syncLabels();
}).observe(document.body, { attributes: true, attributeFilter: ['class'] });
new MutationObserver(() => this.restyle()).observe(document.body, { attributes: true, attributeFilter: ['class'] });
window.addEventListener('message', (event) => {
const message = event.data || {};
if (message.type === 'graph' && message.graph) this.replace(message.graph as ModelGraph);
@@ -651,7 +697,17 @@ class GraphPage {
}
private save(): void {
this.host.setState({ graphView: { query: this.saved.query, labels: this.saved.labels, hidden: this.saved.hidden ?? [] } });
this.host.setState({ graphView: { query: this.saved.query, labels: this.saved.labels, hidden: this.saved.hidden ?? [], theme: this.saved.theme } });
}
}
/** The theme the Archify diagram pages last used, when they stored one. */
function archifyThemePreference(): Theme | undefined {
try {
const value = localStorage.getItem('archify-theme');
return value === 'dark' || value === 'light' ? value : undefined;
} catch {
return undefined;
}
}