Architecture decisions: files, diagram references, views and panels

- *.archify-decision files: id (unique, words), title, status, date,
  elements, diagrams, supersedes, and prose; parsed, merged and link-checked
  in the model index; diagnostics on the file
- diagrams point at decisions with decisions: [...] on the diagram, nodes
  and edges; stripped before Archify, unknown ids reported, completion,
  hover and go-to-definition for the ids and for elements/diagrams/
  supersedes in decision files
- Decisions panel on the main.archify page; Decisions rail panel under a
  diagram (hover lights up the node or connection); decisions on element
  cards and in the element dialog; decision view for the file, with a
  lost-model note outside a model
- Archify | New Decision… command and template; editor schemas; the three
  New Model examples carry decisions; docs, changelog, 0.8.0
This commit is contained in:
2026-10-19 23:13:05 +03:00
parent 33ebdd6328
commit ebf320a023
49 changed files with 1637 additions and 55 deletions
+229 -1
View File
@@ -22,9 +22,14 @@ export interface PageOptions {
descriptions?: Record<string, string>;
/** Registry page: the elements of one .archify-registry file. */
registry?: RegistryPanel;
/** Decision page: one .archify-decision file. */
decision?: DecisionPanel;
/** Diagram page: decisions of this diagram, for the Decisions rail panel. */
decisions?: DiagramDecision[];
}
import type { ModelPanel, RegistryPanel } from '../model/panel';
import type { DecisionPanel, ModelPanel, RegistryPanel } from '../model/panel';
import type { DiagramDecision } from '../model/prepare';
export function makeNonce(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
@@ -79,6 +84,8 @@ function bridgeScript(options: PageOptions): string {
links: options.links ?? {},
model: options.model,
registry: options.registry,
decision: options.decision,
decisions: options.decisions || [],
descriptions: options.descriptions || {},
};
return `(function () {
@@ -234,12 +241,48 @@ function bridgeScript(options: PageOptions): string {
});
} else used.appendChild(el('span', 'archify-model-item-meta', 'not used yet'));
list.appendChild(used);
if (element.decisions && element.decisions.length) {
var decided = el('li', 'archify-model-card-used');
element.decisions.forEach(function (row) { decided.appendChild(decisionChip(row)); });
list.appendChild(decided);
}
card.appendChild(list);
card.addEventListener('click', function () { openElementModal(element); });
card.addEventListener('keydown', function (event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openElementModal(element); } });
return card;
}
// A decision as a chip (id, title, status) that opens its page.
function decisionChip(row) {
var chip = el('button', 'archify-model-used-link archify-decision-chip is-' + row.status, '');
chip.type = 'button';
chip.appendChild(el('span', 'archify-decision-dot'));
chip.appendChild(document.createTextNode(row.id + (row.title ? ' · ' + row.title : '')));
chip.title = 'Decision ' + row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')';
chip.addEventListener('click', function (event) { event.stopPropagation(); if (row.file) post({ type: 'openDecision', file: row.file }); });
return chip;
}
function statusBadge(row) {
var badge = el('span', 'archify-decision-status is-' + row.status, row.status + (row.supersededBy ? ' by ' + row.supersededBy : ''));
return badge;
}
// A decision as a row in a list: id, title, status, date; click opens it.
function decisionRowItem(row) {
var item = el('li', 'archify-model-item archify-decision-item is-' + row.status);
var button = el('button', 'archify-model-open archify-decision-open');
button.type = 'button';
button.title = row.file || row.id;
var line = el('span', 'archify-decision-line');
line.appendChild(el('span', 'archify-decision-id', row.id));
line.appendChild(el('span', 'archify-model-item-title', row.title || ''));
line.appendChild(statusBadge(row));
if (row.date) line.appendChild(el('span', 'archify-model-item-meta', row.date));
button.appendChild(line);
button.addEventListener('click', function () { if (row.file) post({ type: 'openDecision', file: row.file }); });
item.appendChild(button);
return item;
}
// Modal: one element, where it is defined and the diagrams that use it.
function openElementModal(element) {
closeElementModal();
@@ -292,6 +335,12 @@ function bridgeScript(options: PageOptions): string {
});
modal.appendChild(list);
}
if (element.decisions && element.decisions.length) {
modal.appendChild(el('h3', 'archify-model-title', 'Decisions · ' + element.decisions.length));
var decisions = el('ul', 'archify-model-list archify-decision-list');
element.decisions.forEach(function (row) { decisions.appendChild(decisionRowItem(row)); });
modal.appendChild(decisions);
}
backdrop.appendChild(modal);
backdrop.addEventListener('click', function (event) { if (event.target === backdrop) closeElementModal(); });
document.body.appendChild(backdrop);
@@ -325,6 +374,130 @@ function bridgeScript(options: PageOptions): string {
document.body.appendChild(body);
}
// The decision page: one .archify-decision file.
function renderDecision(page) {
if (!page) return;
var d = page.decision;
var head = el('section', 'archify-model archify-model-head');
head.appendChild(el('div', 'archify-model-kicker', 'Decision' + (page.model && page.model.id ? ' · ' + page.model.id : '') + (d ? ' · ' + d.id : '')));
head.appendChild(el('h1', 'archify-model-name', d && d.title ? d.title : page.file));
if (d) {
var meta = el('p', 'archify-decision-meta');
meta.appendChild(statusBadge(d));
if (d.date) meta.appendChild(el('span', 'archify-model-item-meta', d.date));
meta.appendChild(el('span', 'archify-model-item-meta', page.file));
head.appendChild(meta);
}
if (!page.inModel) head.appendChild(el('p', 'archify-model-problem', 'This decision has lost its model: there is no main.archify in a folder above it, so its elements and diagrams cannot be resolved.'));
page.problems.forEach(function (problem) { head.appendChild(el('p', 'archify-model-problem', problem)); });
document.body.appendChild(head);
var body = el('section', 'archify-model archify-model-body');
if (d) {
[['Context', d.context], ['Decision', d.decision], ['Consequences', d.consequences]].forEach(function (pair) {
if (!pair[1]) return;
var section = el('section', 'archify-decision-section');
section.appendChild(el('h2', 'archify-model-title', pair[0]));
section.appendChild(el('p', 'archify-decision-text', pair[1].trim()));
body.appendChild(section);
});
if (d.supersedes.length || page.supersededBy.length) {
var rel = el('section', 'archify-decision-section');
rel.appendChild(el('h2', 'archify-model-title', 'Related decisions'));
var relList = el('ul', 'archify-model-list archify-decision-list');
d.supersedes.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'supersedes'), item.firstChild.firstChild); relList.appendChild(item); });
page.supersededBy.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'superseded by'), item.firstChild.firstChild); relList.appendChild(item); });
rel.appendChild(relList);
body.appendChild(rel);
}
}
if (page.elements.length) {
var elementsPanel = panel('decision:elements', 'archify-model-registry', 'Elements', page.elements.length, true);
var cards = el('div', 'cards archify-model-cards');
page.elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
elementsPanel.appendChild(cards);
body.appendChild(elementsPanel);
}
var diagramRows = [];
page.diagrams.forEach(function (use) { diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: 'named by the decision' }); });
page.referencedBy.forEach(function (use) {
var how = use.attachedTo.kind === 'node' ? 'on ' + use.attachedTo.id : use.attachedTo.kind === 'edge' ? 'on ' + use.attachedTo.from + ' \u2192 ' + use.attachedTo.to : 'on the diagram';
var existing = diagramRows.filter(function (r) { return r.path === use.path; })[0];
if (existing) existing.how += ', ' + how; else diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: how });
});
if (diagramRows.length) {
var diagramsPanel = panel('decision:diagrams', 'archify-model-diagrams', 'Diagrams', diagramRows.length, true);
var list = el('ul', 'archify-model-list');
diagramRows.forEach(function (row) {
var item = el('li', 'archify-model-item');
var button = el('button', 'archify-model-open');
button.type = 'button';
button.title = row.path;
button.appendChild(el('span', 'archify-model-item-title', row.title || row.path.split('/').pop()));
button.appendChild(el('span', 'archify-model-item-meta', (row.kind || 'diagram') + ' \u00b7 ' + row.path + ' \u00b7 ' + row.how));
button.addEventListener('click', function () { if (page.inModel) post({ type: 'openDiagram', path: row.path }); });
item.appendChild(button);
list.appendChild(item);
});
diagramsPanel.appendChild(list);
body.appendChild(diagramsPanel);
}
document.body.appendChild(body);
}
// Decisions rail panel on a diagram page: the decisions this diagram points
// at or that name it. Hover lights up what the decision is attached to.
function renderDecisionsRail() {
var rows = config.decisions || [];
var outline = document.getElementById('node-outline');
var svg = document.querySelector('.diagram-container > svg');
if (!rows.length || !outline || !svg || document.getElementById('archify-decisions')) return;
var nav = el('nav', 'node-outline no-print archify-connectors archify-decisions');
nav.id = 'archify-decisions';
var head = el('div', 'node-outline-head');
head.appendChild(el('strong', '', 'Decisions'));
head.appendChild(el('span', 'node-outline-count', String(rows.length)));
nav.appendChild(head);
var list = el('ol', 'node-outline-list');
var group = el('li', 'node-outline-group');
var items = el('ul');
function targets(row) {
var found = [];
row.attachedTo.forEach(function (a) {
if (a.kind === 'node') Array.prototype.push.apply(found, svg.querySelectorAll('[data-node-id="' + a.id + '"]'));
if (a.kind === 'edge') Array.prototype.push.apply(found, svg.querySelectorAll('[data-edge-from="' + a.from + '"][data-edge-to="' + a.to + '"]'));
});
return found;
}
function mark(row, on) { targets(row).forEach(function (t) { if (on) t.setAttribute('data-archify-hit', ''); else t.removeAttribute('data-archify-hit'); }); }
rows.forEach(function (row) {
var item = el('li');
var button = el('button', 'node-outline-item archify-decision-rail-item is-' + row.status);
button.type = 'button';
button.style.setProperty('--outline-tone', 'var(--text-muted)');
var where = row.attachedTo.map(function (a) { return a.kind === 'node' ? a.id : a.kind === 'edge' ? a.from + ' \u2192 ' + a.to : 'diagram'; }).join(', ');
button.title = row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')' + (where ? ' \u00b7 ' + where : '');
var dot = el('span', 'node-outline-dot');
dot.setAttribute('aria-hidden', 'true');
button.appendChild(dot);
button.appendChild(el('strong', '', row.id + (row.title ? ' \u00b7 ' + row.title : '')));
button.appendChild(el('small', '', row.status + (where ? ' \u00b7 ' + where : '')));
button.addEventListener('pointerenter', function () { mark(row, true); });
button.addEventListener('pointerleave', function () { mark(row, false); });
button.addEventListener('focus', function () { mark(row, true); });
button.addEventListener('blur', function () { mark(row, false); });
button.addEventListener('click', function () { post({ type: 'openDecision', file: row.file }); });
item.appendChild(button);
items.appendChild(item);
});
group.appendChild(items);
list.appendChild(group);
nav.appendChild(list);
var connectors = document.getElementById('archify-connectors');
(connectors || outline).insertAdjacentElement('afterend', nav);
var archify = window.Archify;
if (archify && archify.readerLayout && typeof archify.readerLayout.schedule === 'function') archify.readerLayout.schedule();
}
// Elements: registry descriptions as tooltips on Archify's node index rows.
function describeElements() {
var items = document.querySelectorAll('#node-outline .node-outline-item[data-outline-node]');
@@ -484,6 +657,14 @@ function bridgeScript(options: PageOptions): string {
registryPanel.appendChild(filePanel);
});
}
// Decisions: one list, live ones first, superseded and rejected dimmed and last.
if (model.decisions && model.decisions.length) {
var decisionsPanel = panel('decisions', 'archify-model-decisions', 'Decisions', model.decisions.length, true);
body.appendChild(decisionsPanel);
var decisionList = el('ul', 'archify-model-list archify-decision-list');
model.decisions.forEach(function (row) { decisionList.appendChild(decisionRowItem(row)); });
decisionsPanel.appendChild(decisionList);
}
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
}
@@ -497,7 +678,9 @@ function bridgeScript(options: PageOptions): string {
}
if (config.model) onReady(function () { renderModel(config.model); });
if (config.registry) onReady(function () { renderRegistry(config.registry); });
if (config.decision) onReady(function () { renderDecision(config.decision); });
onReady(renderConnectors);
onReady(renderDecisionsRail);
onReady(describeElements);
if (config.notice) {
if (document.readyState === 'loading') {
@@ -559,6 +742,24 @@ const MODEL_STYLE = `
.archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
.archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; }
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
/* Decisions: rows, chips and status badges. */
.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; }
.archify-decision-open { padding: 0.55rem 0.85rem; }
.archify-decision-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; }
.archify-decision-id { font-family: ui-monospace, Menlo, monospace; font-size: 0.8125rem; color: var(--m-muted); }
.archify-decision-status { font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; padding: 0.05rem 0.45rem; border-radius: 999px; border: 1px solid var(--m-border); color: var(--m-muted); }
.archify-decision-status.is-accepted { color: var(--m-backend); border-color: var(--m-backend); }
.archify-decision-status.is-proposed { color: var(--m-cloud); border-color: var(--m-cloud); }
.archify-decision-status.is-rejected { color: var(--m-security); border-color: var(--m-security); }
.archify-decision-item.is-superseded .archify-model-open, .archify-decision-item.is-rejected .archify-model-open { opacity: 0.6; }
.archify-decision-chip { display: inline-flex; align-items: center; gap: 0.35rem; font-family: inherit; }
.archify-decision-dot { width: 0.4rem; height: 0.4rem; border-radius: 50%; background: var(--m-muted); }
.archify-decision-chip.is-accepted .archify-decision-dot { background: var(--m-backend); }
.archify-decision-chip.is-proposed .archify-decision-dot { background: var(--m-cloud); }
.archify-decision-chip.is-rejected .archify-decision-dot { background: var(--m-security); }
.archify-decision-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin: 0.4rem 0 0; }
.archify-decision-section { margin-top: 0.5rem; }
.archify-decision-text { margin: 0; max-width: 72ch; font-size: 0.9375rem; line-height: 1.6; color: var(--m-text); white-space: pre-line; }
/* Registry cards: Archify's own .card look, with fallbacks for the page without a diagram. */
.archify-model { --m-frontend: var(--frontend-stroke, #22d3ee); --m-backend: var(--backend-stroke, #34d399); --m-database: var(--database-stroke, #a78bfa); --m-cloud: var(--cloud-stroke, #fbbf24); --m-security: var(--security-stroke, #fb7185); --m-messagebus: var(--messagebus-stroke, #fb923c); --m-external: var(--external-stroke, #94a3b8); }
.archify-model-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); gap: 1rem; margin-top: 0.25rem; }
@@ -582,6 +783,14 @@ const CONNECTOR_STYLE = `
.diagram-container svg [data-edge-key][data-archify-hit] path, .diagram-container svg [data-edge-key][data-archify-selected] path { stroke-width: 3px; filter: drop-shadow(0 0 3px currentColor); }
.diagram-container svg [data-edge-key][data-archify-selected] text { font-weight: 700; }
.archify-connectors .node-outline-item strong { font-weight: 600; }
.diagram-container svg [data-node-id][data-archify-hit] { filter: drop-shadow(0 0 6px var(--frontend-stroke)); }
/* Bottom rail with a Decisions panel: three columns. */
html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions) { grid-template-areas: "cards cards cards" "index connectors decisions"; grid-template-columns: repeat(3, minmax(0, 1fr)); }
html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions):has(> #node-outline[hidden]) { grid-template-areas: "cards cards" "connectors decisions"; grid-template-columns: repeat(2, minmax(0, 1fr)); }
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions { display: block; grid-area: decisions; padding: 0 1.25rem 0.75rem; border: 1px solid var(--panel-border); border-radius: 0.75rem; background: var(--panel); }
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions .node-outline-head { padding: 1rem 0.25rem 0.375rem; }
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions .node-outline-list { columns: 15rem; column-gap: 1.75rem; padding: 0; overflow: visible; -webkit-mask-image: none; mask-image: none; }
.archify-decision-rail-item.is-superseded, .archify-decision-rail-item.is-rejected { opacity: 0.6; }
/* Bottom rail: Elements and Connectors side by side; Connectors alone when Archify hides its index. */
html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors) { grid-template-areas: "cards cards" "index connectors"; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 1rem; }
html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors):has(> #node-outline[hidden]) { grid-template-areas: "cards" "connectors"; grid-template-columns: minmax(0, 1fr); }
@@ -675,6 +884,25 @@ export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' |
</html>`;
}
/** The decision page for one .archify-decision file. */
export function decisionPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, decision: DecisionPanel): string {
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, decision, links: {} });
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
<script nonce="${options.nonce}">${bridge}</script>
<style>
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
${MODEL_STYLE}
</style>
</head>
<body></body>
</html>`;
}
/** A small VS Code-styled page for loading and error states. */
export function messagePage(
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,