diff --git a/CHANGELOG.md b/CHANGELOG.md index d777a52..ad3131b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 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:`, `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**. - **Entry points.** On a diagram page inside a model the switch reads **Diagram | Graph** and opens the graph narrowed to `diagram:`. New command *Archify | Open Model Graph* in the editor title, the Command Palette and the Explorer menu, wherever *Open Model* is. diff --git a/README.md b/README.md index 7899ae9..7c58127 100644 --- a/README.md +++ b/README.md @@ -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. - **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`. -- **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 0) 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 0) 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 (/ 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:` (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**. - **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. diff --git a/src/graph/index.ts b/src/graph/index.ts index d428200..191ba5b 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -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; } } diff --git a/src/graph/style.ts b/src/graph/style.ts index 3ed39f0..b2fbdbb 100644 --- a/src/graph/style.ts +++ b/src/graph/style.ts @@ -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-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; } +/* 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; } +.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-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); }