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
+1 -1
View File
@@ -2,7 +2,7 @@
## 0.9.0 — 2026-10-21 ## 0.9.0 — 2026-10-21
- **Model graph.** A **Model | Graph** switch on the `main.archify` page (next to **Export** when the file has a diagram, top right otherwise) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once, drawn with Cytoscape.js in Archify's type colours. Registry elements have a solid border, inline ones a dashed border. **Decisions** and **registry files** are nodes as well: a decision is a tag coloured by status with dotted links to the elements and connections it is about and dashed links to what it supersedes; a registry links to the elements it defines. Drag, zoom, **Fit**, **Layout**, **Labels**, legend chips that hide a type (decisions and registries included), **Export PNG**. The choice is remembered per file; the graph updates in place as the model changes. - **Model graph.** A **Model | Graph** switch on the `main.archify` page (next to **Export** when the file has a diagram, top right otherwise) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once, drawn with Cytoscape.js in Archify's type colours. Registry elements have a solid border, inline ones a dashed border. **Decisions** and **registry files** are nodes as well: a decision is a tag coloured by status with dotted links to the elements and connections it is about and dashed links to what it supersedes; a registry links to the elements it defines. Drag, zoom, **Fit**, **Layout**, **Labels**, a dark/light theme toggle of its own (remembered, and shared with the diagram pages' theme key), legend chips that hide a type (decisions and registries included), **Export PNG**. The choice is remembered per file; the graph updates in place as the model changes.
- **Queries.** A query box narrows the graph: `database`, `type:backend`, `diagram:0200-*`, `decision:<id>`, `uses:api`, `near:api,2`, `from:web to:db` (shortest path), `unused`, `shared`, `-shared`, `type:decision`, `type:registry`; terms AND together, a leading `-` excludes; decisions and registries follow their elements into a result, and walks never pass through them; the count reads "9 of 14 elements"; **?** lists the forms and runs an example on click. - **Queries.** A query box narrows the graph: `database`, `type:backend`, `diagram:0200-*`, `decision:<id>`, `uses:api`, `near:api,2`, `from:web to:db` (shortest path), `unused`, `shared`, `-shared`, `type:decision`, `type:registry`; terms AND together, a leading `-` excludes; decisions and registries follow their elements into a result, and walks never pass through them; the count reads "9 of 14 elements"; **?** lists the forms and runs an example on click.
- **Side panel.** An element shows its description, type, registry file and line (**Registry** opens it), the diagrams that draw it, its connections and its decisions, each one click away; **Neighbours** queries around it. A connection shows its labels, diagrams and decisions. A decision node shows its status, text, the elements and connections it is about and **Open decision**; a registry node the elements it defines and **Open registry**. - **Side panel.** An element shows its description, type, registry file and line (**Registry** opens it), the diagrams that draw it, its connections and its decisions, each one click away; **Neighbours** queries around it. A connection shows its labels, diagrams and decisions. A decision node shows its status, text, the elements and connections it is about and **Open decision**; a registry node the elements it defines and **Open registry**.
- **Entry points.** On a diagram page inside a model the switch reads **Diagram | Graph** and opens the graph narrowed to `diagram:<that diagram>`. New command *Archify | Open Model Graph* in the editor title, the Command Palette and the Explorer menu, wherever *Open Model* is. - **Entry points.** On a diagram page inside a model the switch reads **Diagram | Graph** and opens the graph narrowed to `diagram:<that diagram>`. New command *Archify | Open Model Graph* in the editor title, the Command Palette and the Explorer menu, wherever *Open Model* is.
+1 -1
View File
@@ -127,7 +127,7 @@ online-shop/
- **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **Show Source** opens the YAML beside it. - **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **Show Source** opens the YAML beside it.
- **Elements and Connectors.** Under every diagram, Archify's node index is titled **Elements**, and a **Connectors** panel next to it lists every connection as "from → to" with its label. Hovering highlights the connector in the diagram; clicking keeps it highlighted and scrolls to it. - **Elements and Connectors.** Under every diagram, Archify's node index is titled **Elements**, and a **Connectors** panel next to it lists every connection as "from → to" with its label. Hovering highlights the connector in the diagram; clicking keeps it highlighted and scrolls to it.
- **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers). Each has a registry and context, container and component diagrams. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`. - **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers). Each has a registry and context, container and component diagrams. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`.
- **The graph.** The **Model | Graph** switch at the top of the `main.archify` page (next to **Export** when the file has a diagram) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once. Registry elements have a solid border, inline ones a dashed border; colours are Archify's type colours and the legend chips at the bottom hide or show a type. **Decisions** are nodes too (tags coloured by status, dotted links to the elements they are about, dashed links to what they supersede), and so are **registries** (one node per file, dotted links to the elements it defines); their chips hide them when the picture gets busy. Drag nodes, scroll to zoom, **Fit** (or <kbd>0</kbd>) frames what matches, **Layout** lays it out again, **Labels** toggles connection labels, **Export PNG** saves the picture. On a diagram page the switch reads **Diagram | Graph** and opens the graph already narrowed to that diagram. *Archify | Open Model Graph* does the same from the Command Palette or Explorer. - **The graph.** The **Model | Graph** switch at the top of the `main.archify` page (next to **Export** when the file has a diagram) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once. Registry elements have a solid border, inline ones a dashed border; colours are Archify's type colours and the legend chips at the bottom hide or show a type. **Decisions** are nodes too (tags coloured by status, dotted links to the elements they are about, dashed links to what they supersede), and so are **registries** (one node per file, dotted links to the elements it defines); their chips hide them when the picture gets busy. Drag nodes, scroll to zoom, **Fit** (or <kbd>0</kbd>) frames what matches, **Layout** lays it out again, **Labels** toggles connection labels, the sun/moon button switches the page between its dark and light theme (remembered, shared with the diagram pages' theme choice), **Export PNG** saves the picture. On a diagram page the switch reads **Diagram | Graph** and opens the graph already narrowed to that diagram. *Archify | Open Model Graph* does the same from the Command Palette or Explorer.
- **Queries.** Type a few words in the box (<kbd>/</kbd> focuses it); matches stay bright, the rest dims, the count shows "9 of 14 elements". Terms combine with AND and a leading `-` excludes: `database` (id, label, sublabel, brand or type contain it), `type:backend`, `diagram:0200-*` (what those diagrams draw), `decision:<id>` (what a decision is about or is attached to), `uses:api` (an element and its neighbours), `near:api,2` (within two steps), `from:web to:db` (the shortest path, following the arrows first), `unused` (registry elements no diagram draws), `shared` and `-shared` (registry or inline elements only), `type:decision` and `type:registry` for the decision and registry nodes. Decisions and registries follow the elements they are attached to into a result; `-type:decision` or `-type:registry` leaves them out. Walks and paths follow the diagrams' connections only, so a registry is never a shortcut between its elements. The **?** button lists them; clicking an example runs it. - **Queries.** Type a few words in the box (<kbd>/</kbd> focuses it); matches stay bright, the rest dims, the count shows "9 of 14 elements". Terms combine with AND and a leading `-` excludes: `database` (id, label, sublabel, brand or type contain it), `type:backend`, `diagram:0200-*` (what those diagrams draw), `decision:<id>` (what a decision is about or is attached to), `uses:api` (an element and its neighbours), `near:api,2` (within two steps), `from:web to:db` (the shortest path, following the arrows first), `unused` (registry elements no diagram draws), `shared` and `-shared` (registry or inline elements only), `type:decision` and `type:registry` for the decision and registry nodes. Decisions and registries follow the elements they are attached to into a result; `-type:decision` or `-type:registry` leaves them out. Walks and paths follow the diagrams' connections only, so a registry is never a shortcut between its elements. The **?** button lists them; clicking an example runs it.
- **Side panel.** Click an element for its description, type, where it is defined (**Registry** opens the file at the line), the diagrams that draw it (click to open), its connections and its decisions (click to open). **Neighbours** runs `uses:` for it. Click a connection for its labels, the diagrams that draw it and its decisions. Click a decision node for its status, context and decision text, the elements and connections it is about, and **Open decision**; a registry node for the elements it defines, the ones no diagram draws yet, and **Open registry**. - **Side panel.** Click an element for its description, type, where it is defined (**Registry** opens the file at the line), the diagrams that draw it (click to open), its connections and its decisions (click to open). **Neighbours** runs `uses:` for it. Click a connection for its labels, the diagrams that draw it and its decisions. Click a decision node for its status, context and decision text, the elements and connections it is about, and **Open decision**; a registry node for the elements it defines, the ones no diagram draws yet, and **Open registry**.
- **Commands.** *Archify | Open Model* opens the model page of the current file, from the editor title, the Command Palette or the Explorer menu of a `.archify` or `.archify-registry` file; *Archify | Open Model Graph* opens the graph. - **Commands.** *Archify | Open Model* opens the model page of the current file, from the editor title, the Command Palette or the Explorer menu of a `.archify` or `.archify-registry` file; *Archify | Open Model Graph* opens the graph.
+70 -14
View File
@@ -18,6 +18,17 @@ interface SavedView {
query?: string; query?: string;
labels?: boolean; labels?: boolean;
hidden?: string[]; 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); cytoscape.use(fcose);
@@ -56,17 +67,17 @@ function cssVar(name: string, fallback: string): string {
return value || fallback; 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() { function palette() {
const dark = !document.body.classList.contains('vscode-light'); const dark = currentTheme() === 'dark';
return { return {
bg: cssVar('--vscode-editor-background', dark ? '#0b1020' : '#ffffff'), bg: cssVar('--ag-bg', dark ? '#0b1020' : '#ffffff'),
fg: cssVar('--vscode-foreground', dark ? '#e5e7eb' : '#1f2937'), fg: cssVar('--ag-fg', dark ? '#e5e7eb' : '#1f2937'),
muted: cssVar('--vscode-descriptionForeground', dark ? '#9ca3af' : '#6b7280'), muted: cssVar('--ag-muted', dark ? '#9ca3af' : '#6b7280'),
panel: cssVar('--vscode-editorWidget-background', dark ? '#131c31' : '#f3f4f6'), panel: cssVar('--ag-panel', dark ? '#131c31' : '#f3f4f6'),
accent: cssVar('--vscode-textLink-foreground', '#22d3ee'), accent: cssVar('--ag-accent', '#22d3ee'),
edge: dark ? '#64748b' : '#94a3b8', 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 queryInput = el('input', 'ag-query');
private readonly countEl = el('span', 'ag-count'); private readonly countEl = el('span', 'ag-count');
private readonly labelsButton = el('button', 'ag-button', 'Labels'); 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 legend = el('div', 'ag-legend');
private readonly side = el('aside', 'ag-side'); private readonly side = el('aside', 'ag-side');
private readonly main = el('div', 'ag-main'); private readonly main = el('div', 'ag-main');
@@ -98,6 +110,7 @@ class GraphPage {
if (initial.query !== undefined) this.saved.query = initial.query; if (initial.query !== undefined) this.saved.query = initial.query;
if (this.saved.labels === undefined) this.saved.labels = initial.graph.edges.length <= LABELS_UP_TO; if (this.saved.labels === undefined) this.saved.labels = initial.graph.edges.length <= LABELS_UP_TO;
this.hiddenTypes = new Set(this.saved.hidden ?? []); this.hiddenTypes = new Set(this.saved.hidden ?? []);
this.applyTheme(this.saved.theme ?? archifyThemePreference());
this.index(); this.index();
this.buildChrome(); this.buildChrome();
this.buildGraph(true); this.buildGraph(true);
@@ -177,7 +190,10 @@ class GraphPage {
exportButton.type = 'button'; exportButton.type = 'button';
exportButton.title = 'Save the graph as it is on screen, as a PNG'; exportButton.title = 'Save the graph as it is on screen, as a PNG';
exportButton.addEventListener('click', () => this.exportPng()); 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); top.append(titles, actions);
const bar = el('div', 'ag-bar'); const bar = el('div', 'ag-bar');
@@ -311,6 +327,39 @@ class GraphPage {
.run(); .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 { private applyHidden(): void {
this.cy.nodes().forEach((n) => { this.cy.nodes().forEach((n) => {
n.toggleClass('hidden', this.hiddenTypes.has(String(n.data('type') || 'other'))); n.toggleClass('hidden', this.hiddenTypes.has(String(n.data('type') || 'other')));
@@ -639,10 +688,7 @@ class GraphPage {
} }
}); });
window.addEventListener('resize', () => this.cy.resize()); window.addEventListener('resize', () => this.cy.resize());
new MutationObserver(() => { new MutationObserver(() => this.restyle()).observe(document.body, { attributes: true, attributeFilter: ['class'] });
this.cy.style(this.stylesheet());
this.syncLabels();
}).observe(document.body, { attributes: true, attributeFilter: ['class'] });
window.addEventListener('message', (event) => { window.addEventListener('message', (event) => {
const message = event.data || {}; const message = event.data || {};
if (message.type === 'graph' && message.graph) this.replace(message.graph as ModelGraph); if (message.type === 'graph' && message.graph) this.replace(message.graph as ModelGraph);
@@ -651,7 +697,17 @@ class GraphPage {
} }
private save(): void { 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;
} }
} }
+4
View File
@@ -5,7 +5,11 @@ export const GRAPH_PAGE_STYLE = `
--ag-input: var(--vscode-input-background, #1f2937); --ag-input-fg: var(--vscode-input-foreground, #e5e7eb); --ag-input-border: var(--vscode-input-border, var(--ag-border)); --ag-input: var(--vscode-input-background, #1f2937); --ag-input-fg: var(--vscode-input-foreground, #e5e7eb); --ag-input-border: var(--vscode-input-border, var(--ag-border));
--ag-error: var(--vscode-errorForeground, #f87171); --ag-font: var(--vscode-font-family, system-ui, sans-serif); --ag-mono: var(--vscode-editor-font-family, ui-monospace, Menlo, monospace); --ag-error: var(--vscode-errorForeground, #f87171); --ag-font: var(--vscode-font-family, system-ui, sans-serif); --ag-mono: var(--vscode-editor-font-family, ui-monospace, Menlo, monospace);
--ag-frontend: #22d3ee; --ag-backend: #34d399; --ag-database: #a78bfa; --ag-cloud: #fbbf24; --ag-security: #fb7185; --ag-messagebus: #fb923c; --ag-external: #94a3b8; --ag-decision: #e879f9; --ag-registry: #cbd5e1; } --ag-frontend: #22d3ee; --ag-backend: #34d399; --ag-database: #a78bfa; --ag-cloud: #fbbf24; --ag-security: #fb7185; --ag-messagebus: #fb923c; --ag-external: #94a3b8; --ag-decision: #e879f9; --ag-registry: #cbd5e1; }
/* A theme chosen on the page overrides VS Code's; without one the VS Code variables above apply. */
:root[data-theme="dark"] { --ag-bg: #0b1020; --ag-fg: #e5e7eb; --ag-muted: #9ca3af; --ag-panel: #111a2e; --ag-border: rgba(148, 163, 184, 0.28); --ag-accent: #22d3ee; --ag-input: #0f172a; --ag-input-fg: #e5e7eb; --ag-input-border: rgba(148, 163, 184, 0.28); --ag-error: #f87171; color-scheme: dark; }
:root[data-theme="light"] { --ag-bg: #ffffff; --ag-fg: #0f172a; --ag-muted: #64748b; --ag-panel: #f8fafc; --ag-border: rgba(15, 23, 42, 0.14); --ag-accent: #0e7490; --ag-input: #ffffff; --ag-input-fg: #0f172a; --ag-input-border: rgba(15, 23, 42, 0.2); --ag-error: #b91c1c; --ag-external: #64748b; --ag-registry: #94a3b8; color-scheme: light; }
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--ag-bg); color: var(--ag-fg); font-family: var(--ag-font); font-size: 13px; } html, body { margin: 0; height: 100%; overflow: hidden; background: var(--ag-bg); color: var(--ag-fg); font-family: var(--ag-font); font-size: 13px; }
.ag-theme { width: 1.9rem; padding: 0; font-size: 0.95rem; line-height: 1; }
.ag { display: grid; grid-template-rows: auto auto minmax(0, 1fr); height: 100%; } .ag { display: grid; grid-template-rows: auto auto minmax(0, 1fr); height: 100%; }
.ag-top { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--ag-border); } .ag-top { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--ag-border); }
.ag-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ag-accent); } .ag-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ag-accent); }