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:
+229
-1
@@ -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'>,
|
||||
|
||||
Reference in New Issue
Block a user