forma-arch 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -5
- package/bin/forma.mjs +2 -1
- package/lib/check.mjs +24 -2
- package/lib/doc.mjs +12 -1
- package/lib/enrich.mjs +0 -0
- package/lib/gen.mjs +93 -8
- package/lib/init.mjs +5 -2
- package/lib/schema/c4-model.schema.json +11 -0
- package/lib/verify.mjs +79 -0
- package/lib/viewer/c4-hologram.html +182 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,9 @@ them.
|
|
|
8
8
|
Forma is the companion to `arbiter`: arbiter governs the process, Forma shows the system and
|
|
9
9
|
guarantees the picture matches reality.
|
|
10
10
|
|
|
11
|
+
**[▶ Live demo](https://LucaDominici.github.io/forma/)** — forma's own architecture, generated by
|
|
12
|
+
forma from this repo on every push to `main`.
|
|
13
|
+
|
|
11
14
|
## Why
|
|
12
15
|
|
|
13
16
|
A hand-drawn architecture diagram is stale the moment code changes. Forma walks your source for the
|
|
@@ -29,10 +32,29 @@ npx forma-arch <command> # or: npm i -D forma-arch
|
|
|
29
32
|
| `forma check` | Deterministic drift check — **fails if the model no longer matches the code** |
|
|
30
33
|
| `forma doc` | Project the arc42 scaffold (`ARCHITECTURE.scaffold.md`), or `--attach <file>` to inject a governed block into an existing doc |
|
|
31
34
|
| `forma serve` | Open the live explorer at `http://localhost:4173` |
|
|
35
|
+
| `forma verify` | Refresh status from live GitHub issues through your `gh` CLI — the **only** networked command |
|
|
36
|
+
|
|
37
|
+
**Box text comes from your docs.** `gen` fills each box with the module's docstring (Python `"""…"""`, JS/TS leading block), else the directory `README.md`, else a mapped arc42 section — so the explorer shows meaning, not a list of symbols. On a flat directory of many `foo_*` files it also synthesizes a **component** layer, described from its children's docs (`--no-cluster` to disable; `--cluster-min <n>` = leaves before a container is clustered, default 8; `--group-min <n>` = files sharing a prefix before they become a component, default 3).
|
|
38
|
+
|
|
39
|
+
**Programme state is curated, not guessed.** Code shows what exists, never how far along it is. Drop a `docs/architecture/c4-status.json` (`--status <path>` to move it) and `gen` decorates nodes by id with `status2`, `completion`, `statusWord`, `current`, `target`, `verify`, `issues` — never `func`, which belongs to the docs. `gen` validates the *form* (ids resolve, fields known, enums and issue numbers well-shaped) and never the prose; `forma check` fails if the overlay decorates a node the model no longer has.
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{ "nodes": { "engine": { "status2": "in-progress", "completion": 60, "statusWord": "v2 in progress",
|
|
43
|
+
"current": "Live on ACA: RAG + citations. Hardening this week.",
|
|
44
|
+
"target": "Multi-surface substrate with client-ready output.",
|
|
45
|
+
"verify": { "source": "ADR-040 on main" }, "issues": ["#534"] } } }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Curated state, verified against reality.** `forma verify` asks your `gh` CLI for the state of every issue the model references (`--gh-repo owner/repo`, or `meta.ghRepo` in the topology), marks the nodes whose issues are closed as done, and prefixes their `current` with dated evidence. It touches state, never structure, and re-running it never stacks the evidence. It is opt-in and separate on purpose: `gen` and `check` never open a socket. In the served viewer, **RE-VERIFY** re-reads the model without losing your level, layout or mode.
|
|
32
49
|
|
|
33
|
-
**
|
|
50
|
+
**One source of truth.** `forma doc --attach docs/architecture/arc42.md` injects the generated diagrams/tables between `<!-- forma:begin -->` / `<!-- forma:end -->` markers in your existing doc; your prose lives outside them, and `forma check` fails if that block drifts. Attached files are recorded in `source.attachedDocs`, so the gate governs **every** doc you attach — not just the model's `docPath`; deleting the markers (or the file) from a registered doc fails the check rather than quietly un-governing it. That registry lives in `c4-model.json`, so **commit the model** — a lost model takes the registry with it. Where a repo lacks docs, `forma gen --enrich` can fill the remaining box holes with an LLM — opt-in, cached, never on the deterministic gate:
|
|
34
51
|
|
|
35
|
-
|
|
52
|
+
| `--enricher` | Use it when | Network |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `agent` | **An agent is driving forma.** Writes `enrich-plan.json` with the holes; the agent writes the sentences (reading the sources if it wants) and `gen --enrich-apply <file>` applies them with the same cache and provenance. | none |
|
|
55
|
+
| `anthropic` (default) | Headless / CI, with `ANTHROPIC_API_KEY`. | REST |
|
|
56
|
+
| `openai` | Same, with `OPENAI_API_KEY`. | REST |
|
|
57
|
+
| `ollama` | Sensitive repos: a local model, nothing leaves the machine. | localhost |
|
|
36
58
|
|
|
37
59
|
## How it fits together
|
|
38
60
|
|
|
@@ -59,9 +81,14 @@ The viewer is a live C4 map, not a static picture:
|
|
|
59
81
|
|
|
60
82
|
- **Click any box** to read its explanation — what it does, current state, target, verification source — at *every* level, from context down to a leaf.
|
|
61
83
|
- **Double-click a box** (or its `[+] DRILL`) to descend into it; **BACK**, the breadcrumb, or `ESC` climb back out.
|
|
62
|
-
- **Drag boxes** to lay out the view your way; **RESET LAYOUT** restores the auto-
|
|
63
|
-
- **
|
|
64
|
-
- **PRINT / EXPORT** to SVG or PNG for docs and slides.
|
|
84
|
+
- **Drag boxes** to lay out the view your way; **RESET LAYOUT** restores the arrangement (your curated hints if the topology has them, the automatic one otherwise). To keep a layout, drag it, pick **Export layout JSON**, and paste the result under `"layout"` in the topology — `gen` carries it into `meta.layout` and the viewer pins those boxes, auto-arranging everything else clear of them.
|
|
85
|
+
- **Arrow labels** are painted on the diagram while the level stays readable (≤14 arrows) and turn off above that; **LABELS** forces them on or off, and hovering an arrow always reveals its label.
|
|
86
|
+
- **PRINT / EXPORT** to SVG or PNG for docs and slides — exported arrows carry their labels.
|
|
87
|
+
- The breadcrumb names the C4 level you are on (`C4-L1 · CONTEXT` → `C4-L3 · COMPONENTS`) and,
|
|
88
|
+
when the level carries curated state, tallies it: a dot per status with its count plus mean
|
|
89
|
+
completion, so a level reads as a programme board and not just a diagram.
|
|
90
|
+
- **Every level shrink-wraps its own content** — a context with four boxes renders zoomed and
|
|
91
|
+
dense instead of adrift in a fixed canvas.
|
|
65
92
|
|
|
66
93
|
## Skins
|
|
67
94
|
|
package/bin/forma.mjs
CHANGED
|
@@ -12,7 +12,7 @@ const LIB = join(HERE, '..', 'lib')
|
|
|
12
12
|
const cmd = process.argv[2]
|
|
13
13
|
const rest = process.argv.slice(3)
|
|
14
14
|
|
|
15
|
-
const MAP = { init: 'init.mjs', gen: 'gen.mjs', check: 'check.mjs', doc: 'doc.mjs', serve: 'serve.mjs' }
|
|
15
|
+
const MAP = { init: 'init.mjs', gen: 'gen.mjs', check: 'check.mjs', doc: 'doc.mjs', serve: 'serve.mjs', verify: 'verify.mjs' }
|
|
16
16
|
|
|
17
17
|
if (cmd === '-v' || cmd === '--version') {
|
|
18
18
|
const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf-8'))
|
|
@@ -28,6 +28,7 @@ Usage: forma <command> [--repo <path>]
|
|
|
28
28
|
check deterministic drift check — fails if the model no longer matches the code
|
|
29
29
|
doc project the arc42 scaffold (ARCHITECTURE.scaffold.md) from the model
|
|
30
30
|
serve open the live explorer at http://localhost:4173
|
|
31
|
+
verify refresh status from live GitHub issues via your gh CLI (the only networked command)
|
|
31
32
|
|
|
32
33
|
The file contract is lib/schema/c4-model.schema.json. Enrichment (curate the topology, write the
|
|
33
34
|
arc42 prose) is model-agnostic — any agent edits the same JSON/Markdown.`)
|
package/lib/check.mjs
CHANGED
|
@@ -75,17 +75,39 @@ for (const p of topo.plannedLeaves || []) if (p.sourceMustContain) {
|
|
|
75
75
|
|
|
76
76
|
// 6) attach-mode doc-block freshness — if docPath carries forma markers, the region between them
|
|
77
77
|
// must equal a freshly rendered block. Opt-in: no markers → nothing runs. No LLM, no network.
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
// Governs docPath AND every doc registered by `forma doc --attach` (source.attachedDocs): a block
|
|
79
|
+
// injected into a file no gate looks at is the false-green this whole command exists to kill.
|
|
80
|
+
const src = model.source || {}
|
|
81
|
+
const registered = new Set(src.attachedDocs || []) // registry membership PROVES a block was injected
|
|
82
|
+
const governed = [...new Set([src.docPath, ...registered].filter(Boolean))]
|
|
83
|
+
const deregister = 'remove it from source.attachedDocs if the block is gone for good'
|
|
84
|
+
for (const docRel of governed) {
|
|
85
|
+
if (!existsSync(rp(docRel))) {
|
|
86
|
+
// docPath may simply never have been attached; a REGISTERED doc that vanished is drift.
|
|
87
|
+
if (registered.has(docRel)) fail(`DOC BLOCK: ${docRel} is registered in source.attachedDocs but the file is gone — ${deregister}.`)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
80
90
|
const text = readFileSync(rp(docRel), 'utf-8')
|
|
81
91
|
const inner = extractBetween(text)
|
|
82
92
|
if (inner != null) {
|
|
83
93
|
if (norm(inner) !== norm(renderBlock(model, { repo: REPO }))) fail(`DOC BLOCK DRIFT: ${docRel} forma block is stale — run \`forma doc --attach\`.`)
|
|
84
94
|
} else if (text.includes('<!-- forma:begin') || text.includes('<!-- forma:end')) {
|
|
85
95
|
fail(`DOC BLOCK: malformed forma markers in ${docRel} (begin/end mismatch).`)
|
|
96
|
+
} else if (registered.has(docRel)) {
|
|
97
|
+
// Both markers deleted: the generated block stopped being governed while its (now frozen)
|
|
98
|
+
// text keeps shipping to readers. Exactly the false green attachedDocs exists to prevent.
|
|
99
|
+
fail(`DOC BLOCK: ${docRel} is registered in source.attachedDocs but has no forma markers — re-run \`forma doc --attach\`, or ${deregister}.`)
|
|
86
100
|
}
|
|
87
101
|
}
|
|
88
102
|
|
|
103
|
+
// 7) the status overlay decorates nodes by id: an id that no longer resolves is drift (the node was
|
|
104
|
+
// renamed or deleted and the curated state was left behind). Form only — never the prose itself.
|
|
105
|
+
if (src.statusPath && existsSync(rp(src.statusPath))) {
|
|
106
|
+
let ov = null
|
|
107
|
+
try { ov = JSON.parse(readFileSync(rp(src.statusPath), 'utf-8')) } catch (e) { fail(`status overlay: ${src.statusPath} is not valid JSON: ${(e && e.message) || e}`) }
|
|
108
|
+
if (ov) for (const id of Object.keys(ov.nodes || {})) if (!byId.has(id)) fail(`status overlay: "${id}" is not a node in the model — ${src.statusPath} is stale.`)
|
|
109
|
+
}
|
|
110
|
+
|
|
89
111
|
// SOFT: LLM-enriched prose whose inputs changed is only a reminder, never a gate failure (structure
|
|
90
112
|
// is the gate; enrichment freshness is advisory). check never calls the LLM — it recomputes the hash.
|
|
91
113
|
for (const n of model.nodes || []) {
|
package/lib/doc.mjs
CHANGED
|
@@ -83,7 +83,18 @@ if (attachIdx > -1) {
|
|
|
83
83
|
catch { console.error('[forma doc] --attach: malformed forma markers in ' + relative(REPO, target) + ' — fix by hand'); process.exit(1) }
|
|
84
84
|
mkdirSync(join(target, '..'), { recursive: true })
|
|
85
85
|
writeFileSync(target, out)
|
|
86
|
-
|
|
86
|
+
const rel = relative(REPO, target)
|
|
87
|
+
console.log(`[forma doc] spliced generated block into ${rel} — ${containers.length} container(s); prose outside the markers untouched.`)
|
|
88
|
+
// Register the target so `forma check` governs THIS file too, not just source.docPath — an
|
|
89
|
+
// injected block nobody gates is exactly the false-green forma exists to kill. `gen` carries
|
|
90
|
+
// the registry forward across regens.
|
|
91
|
+
const known = m.source.attachedDocs || []
|
|
92
|
+
if (rel !== m.source.docPath && !known.includes(rel)) {
|
|
93
|
+
m.source.attachedDocs = [...known, rel].sort()
|
|
94
|
+
writeFileSync(MODEL, JSON.stringify(m, null, 2) + '\n')
|
|
95
|
+
console.log(`[forma doc] registered ${rel} in ${relative(REPO, MODEL)} — \`forma check\` now governs its block.`)
|
|
96
|
+
console.log('[forma doc] commit the model: the registry lives in it, and a lost model un-governs the doc.')
|
|
97
|
+
}
|
|
87
98
|
process.exit(0)
|
|
88
99
|
}
|
|
89
100
|
writeFileSync(OUT, md)
|
package/lib/enrich.mjs
CHANGED
|
Binary file
|
package/lib/gen.mjs
CHANGED
|
@@ -8,23 +8,29 @@ import { execSync } from 'node:child_process'
|
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { containerOf, componentsFor } from './cluster.mjs'
|
|
10
10
|
import { makeDescribeCtx, resolveDescription } from './describe.mjs'
|
|
11
|
-
import { loadCache, mergeCache, enrich } from './enrich.mjs'
|
|
11
|
+
import { loadCache, mergeCache, enrich, agentPlan, applyFills } from './enrich.mjs'
|
|
12
12
|
|
|
13
13
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
14
14
|
const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
|
|
15
15
|
const REPO = arg('--repo', process.cwd())
|
|
16
16
|
const TOPO = arg('--topology', join(REPO, 'docs/architecture/c4-topology.json'))
|
|
17
17
|
const OUT = arg('--out', join(REPO, 'docs/architecture/c4-model.json'))
|
|
18
|
-
const SCHEMA_VERSION = '1.
|
|
18
|
+
const SCHEMA_VERSION = '1.3.0'
|
|
19
19
|
const CLUSTER = !process.argv.includes('--no-cluster') // §2: auto-cluster flat containers; --no-cluster to disable
|
|
20
20
|
const ENRICH = process.argv.includes('--enrich') // §7: opt-in LLM prose for description holes
|
|
21
21
|
const ENRICHER = arg('--enricher', 'anthropic')
|
|
22
22
|
const ENRICH_MODEL = arg('--enrich-model', null)
|
|
23
|
-
const
|
|
23
|
+
const STATUS = arg('--status', join(REPO, 'docs/architecture/c4-status.json')) // curated programme state, optional
|
|
24
|
+
const STATUS_SET = process.argv.includes('--status')
|
|
25
|
+
const APPLY = arg('--enrich-apply', null) // prose written by the agent driving forma
|
|
24
26
|
|
|
25
27
|
const topo = JSON.parse(readFileSync(TOPO, 'utf-8'))
|
|
26
28
|
const rp = (p) => join(REPO, p)
|
|
27
29
|
const fail = (m) => { console.error('[gen-c4] FAIL: ' + m); process.exit(1) }
|
|
30
|
+
// §2 thresholds, overridable. Guarded: a non-integer would silently disable clustering
|
|
31
|
+
// (`length >= NaN` is false for every group), so a bad value fails loud instead.
|
|
32
|
+
const posInt = (flag, dflt) => { const v = Number(arg(flag, dflt)); if (!Number.isInteger(v) || v < 1) fail(`${flag}: expected a positive integer, got "${arg(flag, dflt)}"`); return v }
|
|
33
|
+
const CLUSTER_MIN = posInt('--cluster-min', 8), GROUP_MIN = posInt('--group-min', 3)
|
|
28
34
|
|
|
29
35
|
function walk(spec) {
|
|
30
36
|
const dir = rp(spec.dir)
|
|
@@ -114,7 +120,9 @@ for (const n of nodes) {
|
|
|
114
120
|
if (!n.category) n.category = n.kind === 'leaf' ? (par && par.category) || 'leaf' : n.kind
|
|
115
121
|
if (!n.status2) n.status2 = n.status === 'planned' ? 'planned' : 'done'
|
|
116
122
|
if (n.completion == null) n.completion = n.status === 'planned' ? 0 : 100
|
|
117
|
-
|
|
123
|
+
// `current` is left EMPTY unless curated (topology or status overlay): the viewer falls back to
|
|
124
|
+
// `func`, which since §1a carries the module's real documentation. The old "Exists: <path>"
|
|
125
|
+
// filler restated the evidence path in the one field meant for programme facts.
|
|
118
126
|
if (n.target == null) n.target = ''
|
|
119
127
|
}
|
|
120
128
|
|
|
@@ -123,12 +131,74 @@ for (const n of nodes) {
|
|
|
123
131
|
const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf })
|
|
124
132
|
for (const n of nodes) { const r = resolveDescription(n, dctx); n.func = r.func; n.descSource = r.descSource }
|
|
125
133
|
|
|
134
|
+
// §1a-bis) a synthesized component has no doc of its own: before settling for "Groups related
|
|
135
|
+
// files under X", compose its box from its children's docs (first sentence of up to 3, ordered by
|
|
136
|
+
// name — deterministic). descSource stays 'fallback': this is a heuristic, and --enrich may still
|
|
137
|
+
// improve it. Separate pass so it does not depend on components being added after their leaves.
|
|
138
|
+
const firstSentence = (s) => (String(s).match(/^[^.!?]*[.!?]/) || [String(s)])[0].trim()
|
|
139
|
+
for (const n of nodes) {
|
|
140
|
+
if (n.kind !== 'component' || n.descSource !== 'fallback') continue
|
|
141
|
+
const kids = nodes.filter((k) => k.parent === n.id && ['curated', 'docstring', 'readme'].includes(k.descSource))
|
|
142
|
+
.sort((a, b) => (String(a.name) < String(b.name) ? -1 : String(a.name) > String(b.name) ? 1 : 0)).slice(0, 3)
|
|
143
|
+
const txt = kids.map((k) => firstSentence(k.func)).filter(Boolean).join(' ')
|
|
144
|
+
if (txt) n.func = txt.length > 200 ? txt.slice(0, 199) + '…' : txt
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// §WP-A1) programme-status overlay: the one channel for curated STATE (what is true now, where it
|
|
148
|
+
// must land, what proves it). Code can't know it, so a human/agent writes it in c4-status.json and
|
|
149
|
+
// gen validates only its FORM — ids resolve, fields are known, enums/ranges hold. Never `func`:
|
|
150
|
+
// what a module DOES comes from its docs (§1a), what it's WORTH is programme state.
|
|
151
|
+
const STATUS_FIELDS = new Set(['status2', 'completion', 'statusWord', 'current', 'target', 'verify', 'issues'])
|
|
152
|
+
const STATUS2 = new Set(['done', 'in-progress', 'next', 'planned', 'problem'])
|
|
153
|
+
let statusPath = null
|
|
154
|
+
if (existsSync(STATUS) || STATUS_SET) {
|
|
155
|
+
if (!existsSync(STATUS)) fail(`--status: file missing: ${STATUS}`)
|
|
156
|
+
let ov
|
|
157
|
+
try { ov = JSON.parse(readFileSync(STATUS, 'utf-8')) } catch (e) { fail(`--status: ${STATUS} is not valid JSON: ${(e && e.message) || e}`) }
|
|
158
|
+
let decorated = 0
|
|
159
|
+
for (const [id, patch] of Object.entries(ov.nodes || {})) {
|
|
160
|
+
const target = byId.get(id)
|
|
161
|
+
if (!target) fail(`status overlay: unknown node id "${id}" — it is not in the model (stale overlay?)`)
|
|
162
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
163
|
+
if (k.startsWith('$')) continue // $comment and friends
|
|
164
|
+
if (!STATUS_FIELDS.has(k)) fail(`status overlay: "${id}" has field "${k}"; allowed: ${[...STATUS_FIELDS].join(', ')}${k === 'func' ? ' (func comes from the docs, not the overlay)' : ''}`)
|
|
165
|
+
if (k === 'status2' && !STATUS2.has(v)) fail(`status overlay: "${id}" status2 "${v}" — expected one of ${[...STATUS2].join('|')}`)
|
|
166
|
+
if (k === 'completion' && !(Number.isInteger(v) && v >= 0 && v <= 100)) fail(`status overlay: "${id}" completion must be an integer 0-100, got ${JSON.stringify(v)}`)
|
|
167
|
+
if (k === 'issues') {
|
|
168
|
+
if (!Array.isArray(v)) fail(`status overlay: "${id}" issues must be an array`)
|
|
169
|
+
for (const is of v) if (!/^#?\d+$/.test(String(is))) fail(`status overlay: "${id}" issue ${JSON.stringify(is)} — expected "#123"`)
|
|
170
|
+
}
|
|
171
|
+
target[k] = v
|
|
172
|
+
}
|
|
173
|
+
decorated++
|
|
174
|
+
}
|
|
175
|
+
statusPath = relative(REPO, STATUS)
|
|
176
|
+
console.log(`[gen-c4] status overlay: ${decorated} node(s) decorated from ${statusPath}`)
|
|
177
|
+
}
|
|
178
|
+
|
|
126
179
|
// §7) enrichment (prose only, never structure/gate). Cache-merge ALWAYS: preserve prior LLM prose
|
|
127
180
|
// whose inputs are unchanged, so a plain regen doesn't discard it. --enrich additionally fills the
|
|
128
181
|
// remaining holes over the network (best-effort — a failure keeps the deterministic fallback).
|
|
129
182
|
const ectx = { repo: REPO, byId, containerOf }
|
|
130
|
-
mergeCache(nodes,
|
|
131
|
-
|
|
183
|
+
mergeCache(nodes, loadCache(OUT))
|
|
184
|
+
// --enrich-apply: prose written by the agent that drives forma (see --enricher agent below).
|
|
185
|
+
// Independent of --enrich, and applied first — an explicit fill outranks anything generated.
|
|
186
|
+
if (APPLY) {
|
|
187
|
+
if (!existsSync(APPLY)) fail(`--enrich-apply: file missing: ${APPLY}`)
|
|
188
|
+
let fills
|
|
189
|
+
try { fills = JSON.parse(readFileSync(APPLY, 'utf-8')).fills } catch (e) { fail(`--enrich-apply: ${APPLY} is not valid JSON: ${(e && e.message) || e}`) }
|
|
190
|
+
if (!Array.isArray(fills)) fail(`--enrich-apply: ${APPLY} must contain {"fills":[{"id","func"}]}`)
|
|
191
|
+
try { console.log(`[gen-c4] --enrich-apply: ${applyFills(nodes, ectx, fills)} fill(s) applied`) }
|
|
192
|
+
catch (e) { fail(String((e && e.message) || e)) }
|
|
193
|
+
}
|
|
194
|
+
if (ENRICH && ENRICHER === 'agent') {
|
|
195
|
+
// No network: emit the work for the agent already in the session, keep the deterministic model.
|
|
196
|
+
const plan = agentPlan(nodes, ectx)
|
|
197
|
+
const planPath = join(dirname(OUT), 'enrich-plan.json')
|
|
198
|
+
writeFileSync(planPath, JSON.stringify({ entries: plan }, null, 2) + '\n')
|
|
199
|
+
console.log(`[gen-c4] --enricher agent: ${plan.length} hole(s) → ${relative(REPO, planPath)} — write the prose yourself, then:`)
|
|
200
|
+
console.log(`[gen-c4] forma gen --enrich-apply ${relative(REPO, join(dirname(OUT), 'enrich-fill.json'))}`)
|
|
201
|
+
} else if (ENRICH) {
|
|
132
202
|
try { const r = await enrich(nodes, ectx, { provider: ENRICHER, model: ENRICH_MODEL }); console.log(`[gen-c4] --enrich (${ENRICHER}): filled ${r.filled}/${r.holes} description hole(s)`) }
|
|
133
203
|
catch (e) { console.error('[gen-c4] --enrich skipped: ' + String((e && e.message) || e)) }
|
|
134
204
|
}
|
|
@@ -173,11 +243,26 @@ const gitOpts = { cwd: REPO, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
|
173
243
|
try { commit = execSync('git rev-parse HEAD', gitOpts).toString().trim() } catch {}
|
|
174
244
|
try { branch = execSync('git rev-parse --abbrev-ref HEAD', gitOpts).toString().trim() } catch {}
|
|
175
245
|
|
|
246
|
+
// §1b) carry the attach registry forward from the prior model — `forma doc --attach` writes it
|
|
247
|
+
// there, and without this a plain regen would drop it and un-govern the attached docs.
|
|
248
|
+
// The registry lives ONLY in the generated model, so that model has to be committed (or at least
|
|
249
|
+
// kept) for the gate to keep governing. If a prior model is there but unreadable, say so rather
|
|
250
|
+
// than silently ungoverning every attached doc.
|
|
251
|
+
let attachedDocs = []
|
|
252
|
+
if (existsSync(OUT)) {
|
|
253
|
+
try { attachedDocs = JSON.parse(readFileSync(OUT, 'utf-8')).source.attachedDocs || [] }
|
|
254
|
+
catch (e) { console.error(`[gen-c4] WARNING: ${OUT} is unreadable (${(e && e.message) || e}) — any source.attachedDocs registry in it is lost; re-run \`forma doc --attach\` for each attached doc.`) }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// generatedAt is the ONE per-run volatile field (a plain `gen` x2 must diff on nothing else).
|
|
258
|
+
// meta.verifiedAt is reserved for `forma verify` (network, opt-in) — gen never writes it.
|
|
176
259
|
const model = {
|
|
177
260
|
schemaVersion: SCHEMA_VERSION,
|
|
178
261
|
generatedAt: new Date().toISOString(),
|
|
179
|
-
source: { repo: topo.meta.repo, commit, branch, docPath: topo.docPath, generator: 'gen-c4-model@' + SCHEMA_VERSION },
|
|
180
|
-
|
|
262
|
+
source: { repo: topo.meta.repo, commit, branch, docPath: topo.docPath, generator: 'gen-c4-model@' + SCHEMA_VERSION, ...(attachedDocs.length ? { attachedDocs } : {}), ...(statusPath ? { statusPath } : {}) },
|
|
263
|
+
// topo.layout (optional curated coordinates, keyed by parent id) rides along verbatim: the
|
|
264
|
+
// viewer pins those nodes and auto-arranges the rest. `meta` is the model's free-form area.
|
|
265
|
+
meta: { ...topo.meta, ...(topo.layout ? { layout: topo.layout } : {}), verifyMethod: 'code+topology (gh re-verify optional)' },
|
|
181
266
|
levels: topo.levels,
|
|
182
267
|
nodes,
|
|
183
268
|
edges: topo.edges.map((e) => ({ status: 'current', estatus: e.estatus || 'active', ...e })),
|
package/lib/init.mjs
CHANGED
|
@@ -22,13 +22,16 @@ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g
|
|
|
22
22
|
|
|
23
23
|
if (existsSync(OUT) && !FORCE) { console.error(`[forma init] ${relative(REPO, OUT)} already exists — use --force to reseed.`); process.exit(1) }
|
|
24
24
|
|
|
25
|
-
// 1) detect dominant language by extension count
|
|
25
|
+
// 1) detect dominant language by extension count. Skips the SAME dirs the container pass skips
|
|
26
|
+
// (build junk AND data/fixture/doc dirs): counting files that can never become a container is how
|
|
27
|
+
// a repo full of Python fixtures gets detected as a Python repo and then finds no source dir.
|
|
26
28
|
const counts = {}
|
|
27
29
|
;(function walk(dir, depth) {
|
|
28
30
|
if (depth > 12) return
|
|
29
31
|
let ents; try { ents = readdirSync(dir) } catch { return }
|
|
30
32
|
for (const e of ents) {
|
|
31
33
|
if (IGNORE.has(e) || e.startsWith('.')) continue
|
|
34
|
+
if (SKIP_DIRS.has(e.toLowerCase()) && !KEEP.has(e.toLowerCase())) continue
|
|
32
35
|
const p = join(dir, e)
|
|
33
36
|
let st; try { st = statSync(p) } catch { continue }
|
|
34
37
|
if (st.isDirectory()) walk(p, depth + 1)
|
|
@@ -86,7 +89,7 @@ const skipped = []
|
|
|
86
89
|
// loose files directly in root → an entry container
|
|
87
90
|
if (hasSrc(root)) { nodes.push({ id: 'app', level: 'container', kind: 'container', parent: sysId, name: 'app', tech: lang, description: 'Entry point / loose top-level sources.' }); leafSources.push({ parent: 'app', dir: relative(REPO, root) || '.', match: matchRe }) }
|
|
88
91
|
|
|
89
|
-
if (leafSources.length === 0) { console.error(
|
|
92
|
+
if (leafSources.length === 0) { console.error(`[forma init] detected ${lang} (*.${ext[0]}) as the dominant language, but no directory under ${relative(REPO, root) || '.'}/ holds *.${ext[0]} files directly. Pass --include <dir,...> if the sources live in a skipped dir, or add leafSources manually.`); process.exit(1) }
|
|
90
93
|
|
|
91
94
|
const topo = {
|
|
92
95
|
meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init' },
|
|
@@ -45,6 +45,17 @@
|
|
|
45
45
|
"type": "string",
|
|
46
46
|
"description": "The arc42 architecture doc this model derives from."
|
|
47
47
|
},
|
|
48
|
+
"statusPath": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"description": "Repo-relative curated programme-status overlay applied by `forma gen` (c4-status.json). The gate fails if it decorates a node id the model no longer has."
|
|
51
|
+
},
|
|
52
|
+
"attachedDocs": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"items": {
|
|
55
|
+
"type": "string"
|
|
56
|
+
},
|
|
57
|
+
"description": "Repo-relative docs carrying an injected forma block (written by `forma doc --attach`). The drift gate governs every one of them, not just docPath."
|
|
58
|
+
},
|
|
48
59
|
"generator": {
|
|
49
60
|
"type": "string"
|
|
50
61
|
}
|
package/lib/verify.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// forma verify — refresh programme status from LIVE GitHub issues, through the user's `gh` CLI.
|
|
3
|
+
// This is the ONLY command that touches the network, and it is opt-in: `gen` and `check` stay
|
|
4
|
+
// deterministic and offline forever (that is the product). It never touches structure — no nodes,
|
|
5
|
+
// no edges, no func — only the state fields of nodes that reference an issue.
|
|
6
|
+
// Usage: node verify.mjs [--repo <path>] [--model <path>] [--gh-repo <owner/repo>] [--gh-cmd <cmd>]
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
8
|
+
import { execFileSync } from 'node:child_process'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
|
|
12
|
+
const REPO = arg('--repo', process.cwd())
|
|
13
|
+
const MODEL = arg('--model', join(REPO, 'docs/architecture/c4-model.json'))
|
|
14
|
+
const GH_CMD = arg('--gh-cmd', 'gh') // split on whitespace; the tests point it at a stub
|
|
15
|
+
const LIMIT = arg('--limit', '250')
|
|
16
|
+
const fail = (m) => { console.error('[forma verify] ' + m); process.exit(1) }
|
|
17
|
+
|
|
18
|
+
if (!existsSync(MODEL)) fail(`model missing: ${MODEL} — run \`forma gen\` first.`)
|
|
19
|
+
const model = JSON.parse(readFileSync(MODEL, 'utf-8'))
|
|
20
|
+
const GH_REPO = arg('--gh-repo', (model.meta && model.meta.ghRepo) || null)
|
|
21
|
+
if (!GH_REPO) fail('no target repo: pass --gh-repo <owner/repo>, or set meta.ghRepo in the topology.')
|
|
22
|
+
|
|
23
|
+
// Which nodes claim which issue — from issues[] and from verify.issue.
|
|
24
|
+
const num = (v) => { const m = /^#?(\d+)$/.exec(String(v)); return m ? Number(m[1]) : null }
|
|
25
|
+
const refs = new Map()
|
|
26
|
+
for (const n of model.nodes || []) {
|
|
27
|
+
const claimed = [...(n.issues || []), ...(n.verify && n.verify.issue != null ? [n.verify.issue] : [])]
|
|
28
|
+
for (const raw of claimed) {
|
|
29
|
+
const i = num(raw); if (!i) continue
|
|
30
|
+
if (!refs.has(i)) refs.set(i, [])
|
|
31
|
+
refs.get(i).push(n)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!refs.size) { console.log('[forma verify] no issue references in the model — nothing to verify (add issues[] via the status overlay).'); process.exit(0) }
|
|
35
|
+
|
|
36
|
+
// ONE call for every issue: state for the whole repo, then match locally.
|
|
37
|
+
const parts = String(GH_CMD).split(/\s+/).filter(Boolean)
|
|
38
|
+
let raw
|
|
39
|
+
try {
|
|
40
|
+
raw = execFileSync(parts[0], [...parts.slice(1), 'issue', 'list', '--repo', GH_REPO, '--state', 'all', '--limit', String(LIMIT), '--json', 'number,state'],
|
|
41
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
42
|
+
} catch (e) {
|
|
43
|
+
const why = (e && e.code === 'ENOENT') ? `\`${parts[0]}\` not found — install the GitHub CLI or pass --gh-cmd` : String((e && (e.stderr || e.message)) || e).trim()
|
|
44
|
+
console.error(`[forma verify] ${why}`)
|
|
45
|
+
fail('model left untouched.')
|
|
46
|
+
}
|
|
47
|
+
let issues
|
|
48
|
+
try { issues = JSON.parse(raw) } catch { fail(`unexpected output from \`${parts[0]}\` (wanted JSON): ${String(raw).slice(0, 160)}`) }
|
|
49
|
+
if (!Array.isArray(issues)) fail('unexpected output: expected a JSON array of {number,state}.')
|
|
50
|
+
|
|
51
|
+
const ts = new Date().toISOString()
|
|
52
|
+
const seen = new Set()
|
|
53
|
+
let closed = 0, decorated = 0
|
|
54
|
+
for (const it of issues) {
|
|
55
|
+
const n = Number(it.number); seen.add(n)
|
|
56
|
+
if (String(it.state || '').toUpperCase() !== 'CLOSED') continue
|
|
57
|
+
const nodes = refs.get(n); if (!nodes) continue
|
|
58
|
+
closed++
|
|
59
|
+
for (const node of nodes) {
|
|
60
|
+
node.status2 = 'done'; node.completion = 100
|
|
61
|
+
// The badge shows statusWord when there is one, so a curated "NEXT"/"50%" would survive the
|
|
62
|
+
// node turning green — a box claiming both at once. Marking done owns the badge too.
|
|
63
|
+
if (node.statusWord) node.statusWord = '100%'
|
|
64
|
+
const mark = `(#${n} CLOSED` // re-running must not stack prefixes
|
|
65
|
+
if (!String(node.current || '').includes(mark)) {
|
|
66
|
+
node.current = `Closed with evidence ${mark}, gh ${ts}). ${String(node.current || '').trim()}`.trim()
|
|
67
|
+
decorated++
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const unknown = [...refs.keys()].filter((n) => !seen.has(n))
|
|
72
|
+
|
|
73
|
+
model.meta = model.meta || {}
|
|
74
|
+
model.meta.verifiedAt = ts // the ONE field only verify writes — gen must stay single-volatile (R2)
|
|
75
|
+
model.meta.verifyMethod = 'gh live'
|
|
76
|
+
writeFileSync(MODEL, JSON.stringify(model, null, 2) + '\n')
|
|
77
|
+
console.log(`[forma verify] ${GH_REPO}: ${refs.size} referenced issue(s), ${closed} closed → ${decorated} node(s) marked done`)
|
|
78
|
+
if (unknown.length) console.log(`[forma verify] not returned by gh (closed long ago, wrong repo, or beyond --limit ${LIMIT}): ${unknown.map((n) => '#' + n).join(', ')}`)
|
|
79
|
+
console.log(`[forma verify] fact base stamped ${ts} — structure untouched, \`forma check\` unaffected.`)
|
|
@@ -29,6 +29,9 @@ button.on{background:rgba(62,231,255,.18);color:var(--cyan);border-color:var(--c
|
|
|
29
29
|
#back[disabled]{opacity:.28;cursor:default;border-color:var(--border);color:var(--ter)}
|
|
30
30
|
select#skin option{background:#08202e;color:var(--nm)}
|
|
31
31
|
#crumb{font-size:11px;color:var(--sec);margin:8px 0}
|
|
32
|
+
#crumb .agg{float:right;color:var(--ter2);letter-spacing:.05em}
|
|
33
|
+
#crumb .agg i{display:inline-block;width:7px;height:7px;border-radius:50%;margin:0 3px 0 8px;vertical-align:baseline}
|
|
34
|
+
#crumb .agg b{color:var(--nm)}
|
|
32
35
|
#crumb b{color:var(--cyan)}
|
|
33
36
|
#crumb a{color:#8fd8ec;cursor:pointer}
|
|
34
37
|
#crumb a:hover{text-decoration:underline}
|
|
@@ -64,6 +67,8 @@ svg{width:100%;height:100%;display:block}
|
|
|
64
67
|
.edge.plan{stroke:var(--edgeplan);stroke-dasharray:3 6;opacity:.6;marker-end:url(#arp)}
|
|
65
68
|
.edge.hot{stroke:var(--cyan);stroke-width:2.6;opacity:1}
|
|
66
69
|
.edgehit{stroke:transparent;stroke-width:14;fill:none;cursor:help}
|
|
70
|
+
.elbl{font-size:8.2px;fill:var(--elbl);letter-spacing:.04em;pointer-events:none;
|
|
71
|
+
paint-order:stroke;stroke:var(--stagebg);stroke-width:3px;stroke-linejoin:round}
|
|
67
72
|
@keyframes dash{to{stroke-dashoffset:-24}}
|
|
68
73
|
.nd.pulse rect{animation:pul 2.4s ease-in-out infinite}
|
|
69
74
|
@keyframes pul{0%,100%{filter:drop-shadow(0 0 8px rgba(62,231,255,.5))}50%{filter:drop-shadow(0 0 18px rgba(62,231,255,.95))}}
|
|
@@ -132,12 +137,14 @@ html[data-skin="blueprint"] button.on{text-shadow:none}
|
|
|
132
137
|
<button id="bnow" class="on" type="button"></button>
|
|
133
138
|
<button id="btgt" type="button"></button>
|
|
134
139
|
<button id="brf" type="button"></button>
|
|
140
|
+
<button id="blbl" type="button"></button>
|
|
135
141
|
<button id="brl" type="button"></button>
|
|
136
142
|
<span class="menuwrap">
|
|
137
143
|
<button id="pexp" type="button" aria-haspopup="true" aria-expanded="false"></button>
|
|
138
144
|
<span id="pmenu" role="menu">
|
|
139
145
|
<button id="pexpSvg" type="button" role="menuitem"></button>
|
|
140
146
|
<button id="pexpPng" type="button" role="menuitem"></button>
|
|
147
|
+
<button id="pexpLayout" type="button" role="menuitem"></button>
|
|
141
148
|
<button id="pexpPrint" type="button" role="menuitem"></button>
|
|
142
149
|
</span>
|
|
143
150
|
</span>
|
|
@@ -165,17 +172,18 @@ var STRINGS={
|
|
|
165
172
|
en:{
|
|
166
173
|
subhelp:"click = explain (any level) · double-click or [+] = drill in · drag to move · hover an arrow = label · ESC/BACK",
|
|
167
174
|
back:"← BACK", now:"CURRENT STATE", tgt:"TARGET STATE", reverify:"RE-VERIFY",
|
|
175
|
+
labels:"LABELS", labelsTitle:"arrow labels (auto-off above 14 arrows)",
|
|
168
176
|
resetLayout:"RESET LAYOUT", resetLayoutTitle:"re-arrange",
|
|
169
|
-
printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", print:"Print",
|
|
170
|
-
legDone:"DONE", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
|
|
177
|
+
printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", exportLayout:"Export layout JSON", print:"Print",
|
|
178
|
+
legDone:"DONE (proven)", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
|
|
171
179
|
legHint:"— animated arrow = active flow · dashed = to build",
|
|
172
180
|
crumbRoot:"Context", crumbLevel:"C4", lvlContext:"CONTEXT",
|
|
173
181
|
lvlNames:{context:"Context",container:"Container",component:"Components",leaf:"Leaves"},
|
|
174
182
|
detClose:"[x] close", detFunc:"What it does", detNow:"Current state (facts)",
|
|
175
183
|
detTgt:"Target state (project)", detSrc:"Verification source", detIssue:"Issue",
|
|
176
184
|
detTarget:"TARGET",
|
|
177
|
-
rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match",
|
|
178
|
-
stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology",
|
|
185
|
+
rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match", aggProgress:"progress",
|
|
186
|
+
stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology", staticSrc:"(static source)",
|
|
179
187
|
errEmpty:"Model unreachable or empty.",
|
|
180
188
|
errNotFound:"Model not found (./c4-model.json). Open via `forma serve` or inject window.__C4_MODEL__.",
|
|
181
189
|
errUnavail:"Model unavailable."
|
|
@@ -183,17 +191,18 @@ var STRINGS={
|
|
|
183
191
|
it:{
|
|
184
192
|
subhelp:"click = spiega (ogni livello) · doppio-click o [+] = entra · trascina per spostare · hover su una freccia = etichetta · ESC/BACK",
|
|
185
193
|
back:"← BACK", now:"STATO ATTUALE", tgt:"STATO FINITO", reverify:"RE-VERIFICA",
|
|
194
|
+
labels:"ETICHETTE", labelsTitle:"etichette sugli archi (spente sopra i 14 archi)",
|
|
186
195
|
resetLayout:"RESET LAYOUT", resetLayoutTitle:"ridisponi",
|
|
187
|
-
printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", print:"Stampa",
|
|
188
|
-
legDone:"FATTO", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
|
|
196
|
+
printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", exportLayout:"Esporta layout JSON", print:"Stampa",
|
|
197
|
+
legDone:"FATTO (provato)", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
|
|
189
198
|
legHint:"— freccia animata = flusso attivo · tratteggio = da costruire",
|
|
190
199
|
crumbRoot:"Contesto", crumbLevel:"C4", lvlContext:"CONTESTO",
|
|
191
200
|
lvlNames:{context:"Contesto",container:"Container",component:"Componenti",leaf:"Foglie"},
|
|
192
201
|
detClose:"[x] chiudi", detFunc:"Cosa fa", detNow:"Stato attuale (fatti)",
|
|
193
202
|
detTgt:"Stato finito (progetto)", detSrc:"Fonte verifica", detIssue:"Issue",
|
|
194
203
|
detTarget:"TARGET",
|
|
195
|
-
rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato",
|
|
196
|
-
stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology",
|
|
204
|
+
rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato", aggProgress:"avanzamento",
|
|
205
|
+
stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology", staticSrc:"(fonte statica)",
|
|
197
206
|
errEmpty:"Modello non raggiungibile o vuoto.",
|
|
198
207
|
errNotFound:"Modello non trovato (./c4-model.json). Apri via `forma serve` o inietta window.__C4_MODEL__.",
|
|
199
208
|
errUnavail:"Modello non disponibile."
|
|
@@ -207,13 +216,19 @@ function pickLang(model){
|
|
|
207
216
|
return STRINGS[cand]?cand:"en";
|
|
208
217
|
}
|
|
209
218
|
var CATALOG_THRESHOLD=24; // group of same-category siblings above this collapses to one catalogue node
|
|
219
|
+
var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed labels would knot the view
|
|
220
|
+
var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
|
|
210
221
|
var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
|
|
211
222
|
var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob"};
|
|
212
|
-
var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}
|
|
223
|
+
var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false
|
|
224
|
+
var VBOX={}; // last fitted viewBox per level — frozen while dragging so the canvas does not rescale
|
|
213
225
|
function $(i){return document.getElementById(i)}
|
|
214
226
|
function esc(s){return String(s==null?"":s).replace(/&/g,"&").replace(/</g,"<").replace(/"/g,""")}
|
|
215
227
|
function setSkin(s){var k=SKIN_IDS.indexOf(s)>-1?s:"holo";document.documentElement.dataset.skin=k;var el=$("skin");if(el)el.value=k;}
|
|
216
|
-
|
|
228
|
+
// Layout key = the id of the level's parent ("root" at the top). Stable and documentable: it is
|
|
229
|
+
// exactly the key used by meta.layout hints and by the exported layout JSON.
|
|
230
|
+
function keyOf(){return stack[stack.length-1]||"root";}
|
|
231
|
+
function hintsFor(k){return (M&&M.meta&&M.meta.layout&&M.meta.layout[k])||null;}
|
|
217
232
|
function childrenOf(pid){var o=[];for(var i=0;i<M.nodes.length;i++){var n=M.nodes[i];if((pid==null&&!n.parent)||n.parent===pid)o.push(n);}return o;}
|
|
218
233
|
// FEATURE #4: collapse large homogeneous catalogs.
|
|
219
234
|
// Given real children, returns render items where any group of >CATALOG_THRESHOLD
|
|
@@ -260,8 +275,25 @@ function nameOf(id){var n=node(id);return n?n.name:id;}
|
|
|
260
275
|
function srcOf(n){if(n.verify){if(typeof n.verify==="string")return n.verify;if(n.verify.source)return n.verify.source;if(n.verify.ref)return n.verify.ref;if(n.verify.issue)return "#"+n.verify.issue;}
|
|
261
276
|
var ev=(n.evidence||[]).filter(function(e){return e.type==="doc"||e.type==="path";})[0];return ev?ev.ref:((M.meta&&M.meta.verifyMethod)||"");}
|
|
262
277
|
|
|
263
|
-
|
|
264
|
-
|
|
278
|
+
var NW=228,NH=118; // NH fits 3 description lines without hitting the [+] DRILL
|
|
279
|
+
// Curated coordinates from meta.layout win; everything else keeps the automatic arrangement,
|
|
280
|
+
// placed BELOW the pinned block so a partial hint set can never overlap generated nodes.
|
|
281
|
+
function seedLayout(kids,hints){
|
|
282
|
+
var pinned=[],free=[],i;
|
|
283
|
+
for(i=0;i<kids.length;i++)(hints&&hints[kids[i].id]?pinned:free).push(kids[i]);
|
|
284
|
+
if(!pinned.length)return autoLayout(kids);
|
|
285
|
+
var pos={},maxX=0,maxY=0,h;
|
|
286
|
+
for(i=0;i<pinned.length;i++){var q=hints[pinned[i].id];
|
|
287
|
+
h={x:+q.x||0,y:+q.y||0,w:+q.w||NW,h:+q.h||NH};pos[pinned[i].id]=h;
|
|
288
|
+
maxX=Math.max(maxX,h.x+h.w);maxY=Math.max(maxY,h.y+h.h);}
|
|
289
|
+
var W=Math.max(1020,maxX+64),H=Math.max(560,maxY+58);
|
|
290
|
+
if(free.length){var sub=autoLayout(free),oy=maxY+58;
|
|
291
|
+
for(i=0;i<free.length;i++){var p=sub.pos[free[i].id];pos[free[i].id]={x:p.x,y:oy+p.y,w:p.w,h:p.h};}
|
|
292
|
+
W=Math.max(W,sub.W);H=Math.max(H,oy+sub.H);}
|
|
293
|
+
return {W:W,H:H,pos:pos};
|
|
294
|
+
}
|
|
295
|
+
function autoLayout(kids){
|
|
296
|
+
var pos={},i,hub=null;
|
|
265
297
|
for(i=0;i<kids.length;i++)if(kids[i].kind==="system"){hub=kids[i];break;}
|
|
266
298
|
if(hub&&kids.length<=10){
|
|
267
299
|
var W=1080,H=660,cx=W/2,cy=H/2;
|
|
@@ -280,9 +312,9 @@ function seedLayout(kids){
|
|
|
280
312
|
pos[kids[i].id]={x:offx+c*(NW+gx),y:py+r*(NH+gy),w:NW,h:NH};}
|
|
281
313
|
return {W:W,H:H,pos:pos};
|
|
282
314
|
}
|
|
283
|
-
function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids);
|
|
315
|
+
function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids,hintsFor(k));
|
|
284
316
|
// make sure every current kid has a slot (model may have changed)
|
|
285
|
-
var lay=POS[k];for(var i=0;i<kids.length;i++)if(!lay.pos[kids[i].id]){var s=seedLayout(kids);POS[k]=s;return s;}return lay;}
|
|
317
|
+
var lay=POS[k];for(var i=0;i<kids.length;i++)if(!lay.pos[kids[i].id]){var s=seedLayout(kids,hintsFor(k));POS[k]=s;return s;}return lay;}
|
|
286
318
|
|
|
287
319
|
function draw(animate){
|
|
288
320
|
if(!M)return;
|
|
@@ -297,14 +329,45 @@ function draw(animate){
|
|
|
297
329
|
function mapId(id){return memberMap[id]||id;}
|
|
298
330
|
var edges=(M.edges||[]).map(function(e){return {from:mapId(e.from),to:mapId(e.to),label:e.label,estatus:e.estatus};})
|
|
299
331
|
.filter(function(e){return idset[e.from]&&idset[e.to]&&e.from!==e.to;});
|
|
300
|
-
|
|
332
|
+
// fixed arrow labels: on by default while the level stays readable, else hover-only (the tooltip
|
|
333
|
+
// is always there). A level with 40 arrows would be unreadable with every label painted at once.
|
|
334
|
+
var showLbl=(LABELS===null)?(edges.length<=LABEL_AUTO_MAX):LABELS;
|
|
335
|
+
var blbl=$("blbl");if(blbl)blbl.className=showLbl?"on":"";
|
|
336
|
+
// geometry pre-pass: edge paths first, so the viewBox can shrink-wrap nodes AND label anchors
|
|
337
|
+
var geo=[],i;
|
|
338
|
+
for(i=0;i<edges.length;i++){
|
|
339
|
+
var a=pos[edges[i].from],b=pos[edges[i].to];if(!a||!b)continue;
|
|
340
|
+
geo.push({e:edges[i],i:i,g:edgePath(a,b)});
|
|
341
|
+
}
|
|
342
|
+
// Fit-to-content viewBox: a sparse level renders zoomed and dense instead of lost in a fixed
|
|
343
|
+
// 1020px canvas — the hand-made status boards read well precisely because they waste no space.
|
|
344
|
+
// Minimums stop a single box from becoming comically large; extra room is split evenly so the
|
|
345
|
+
// content stays centred inside the enforced window.
|
|
346
|
+
var bx1=Infinity,by1=Infinity,bx2=-Infinity,by2=-Infinity,j,pp;
|
|
347
|
+
for(j=0;j<kids.length;j++){pp=pos[kids[j].id];if(!pp)continue;
|
|
348
|
+
if(pp.x<bx1)bx1=pp.x;if(pp.y<by1)by1=pp.y;if(pp.x+pp.w>bx2)bx2=pp.x+pp.w;if(pp.y+pp.h>by2)by2=pp.y+pp.h;}
|
|
349
|
+
for(j=0;j<geo.length;j++){var gm=geo[j].g;
|
|
350
|
+
if(gm.mx-14<bx1)bx1=gm.mx-14;if(gm.my-18<by1)by1=gm.my-18;
|
|
351
|
+
if(gm.mx+14>bx2)bx2=gm.mx+14;if(gm.my+6>by2)by2=gm.my+6;}
|
|
352
|
+
if(bx2<bx1){bx1=0;by1=0;bx2=lay.W;by2=lay.H;}
|
|
353
|
+
var VPAD=56,MINW=880,MINH=460;
|
|
354
|
+
var cw=bx2-bx1+2*VPAD,ch=by2-by1+2*VPAD;
|
|
355
|
+
var vw=Math.max(MINW,cw),vh=Math.max(MINH,ch);
|
|
356
|
+
var vx=bx1-VPAD-(vw-cw)/2,vy=by1-VPAD-(vh-ch)/2;
|
|
357
|
+
// …but hold it still while a box is being dragged: draw() runs on every mousemove, so a live
|
|
358
|
+
// refit would rescale the whole canvas under the pointer. The drop refits (see mouseup).
|
|
359
|
+
var vbk=keyOf();
|
|
360
|
+
if(drag&&VBOX[vbk]){vx=VBOX[vbk][0];vy=VBOX[vbk][1];vw=VBOX[vbk][2];vh=VBOX[vbk][3];}
|
|
361
|
+
else VBOX[vbk]=[vx,vy,vw,vh];
|
|
362
|
+
var s='<svg viewBox="'+vx.toFixed(1)+' '+vy.toFixed(1)+' '+vw.toFixed(1)+' '+vh.toFixed(1)+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
|
|
301
363
|
+'<defs><marker id="ar" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edge")+'"/></marker>'
|
|
302
364
|
+'<marker id="arp" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edgeplan")+'"/></marker></defs>';
|
|
303
|
-
for(
|
|
304
|
-
var
|
|
305
|
-
|
|
306
|
-
s+='<path class="
|
|
307
|
-
|
|
365
|
+
for(j=0;j<geo.length;j++){
|
|
366
|
+
var e=geo[j].g,ed=geo[j].e,cl=(ed.estatus==="to-build"?"plan":"flow");
|
|
367
|
+
s+='<path class="edge '+cl+'" data-e="'+geo[j].i+'" d="'+e.d+'"/>';
|
|
368
|
+
s+='<path class="edgehit" data-e="'+geo[j].i+'" data-label="'+esc(ed.label||"")+'" d="'+e.d+'"/>';
|
|
369
|
+
if(showLbl&&ed.label){var lb=String(ed.label);if(lb.length>28)lb=lb.slice(0,27)+"…";
|
|
370
|
+
s+='<text class="elbl" x="'+e.mx+'" y="'+(e.my-4)+'" text-anchor="middle">'+esc(lb)+'</text>';}
|
|
308
371
|
}
|
|
309
372
|
for(var j=0;j<kids.length;j++){
|
|
310
373
|
var n=kids[j],p=pos[n.id];
|
|
@@ -313,18 +376,21 @@ function draw(animate){
|
|
|
313
376
|
var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
|
|
314
377
|
var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||n.tech||""));
|
|
315
378
|
var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":(n.tech||""))));
|
|
316
|
-
|
|
379
|
+
// the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
|
|
380
|
+
// instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
|
|
381
|
+
var tmax=Math.floor((p.w-34-String(badge).length*5.4)/6.7),tt=n.name;
|
|
382
|
+
if(String(tt).length>tmax)tt=String(tt).slice(0,Math.max(1,tmax-1))+"…";
|
|
317
383
|
s+='<g class="'+cls+'" data-id="'+esc(n.id)+'">'
|
|
318
384
|
+'<rect x="'+p.x+'" y="'+p.y+'" width="'+p.w+'" height="'+p.h+'" rx="10"/>'
|
|
319
385
|
+'<text class="kk" x="'+(p.x+13)+'" y="'+(p.y+16)+'">'+esc(n.category||n.kind||"")+'</text>'
|
|
320
386
|
+'<text class="tt" x="'+(p.x+13)+'" y="'+(p.y+33)+'">'+esc(tt)+'</text>'
|
|
321
387
|
+'<text class="pp" x="'+(p.x+p.w-13)+'" y="'+(p.y+16)+'" text-anchor="end">'+esc(badge)+'</text>';
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
388
|
+
// Description lines: how many fit comes from the geometry (text starts at y+51, 12.5px apart,
|
|
389
|
+
// and must clear the footer control at h-25), so a curated layout hint with short boxes cannot
|
|
390
|
+
// print through [+] DRILL. wrapDesc is pure and clamps every line to the box width.
|
|
391
|
+
var floorY=p.h-((dr||isCat)?25:6),MAXL=Math.min(4,Math.floor((floorY-51)/12.5)+1);
|
|
392
|
+
var dlines=wrapDesc(desc,Math.floor((p.w-26)/5.4),MAXL);
|
|
393
|
+
for(var q=0;q<dlines.length;q++)s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+q*12.5)+'">'+esc(dlines[q])+'</text>';
|
|
328
394
|
if(dr)s+='<rect class="drillhit" data-drill="1" x="'+(p.x+p.w-96)+'" y="'+(p.y+p.h-25)+'" width="88" height="20" rx="5"/>'
|
|
329
395
|
+'<text class="plus" x="'+(p.x+p.w-13)+'" y="'+(p.y+p.h-9)+'" text-anchor="end">[+] DRILL</text>';
|
|
330
396
|
else if(isCat)s+='<text class="plus" x="'+(p.x+p.w-13)+'" y="'+(p.y+p.h-9)+'" text-anchor="end">[≡] '+esc(String(n.count))+'</text>';
|
|
@@ -337,18 +403,58 @@ function draw(animate){
|
|
|
337
403
|
st2.classList.toggle("anim",animate!==false);
|
|
338
404
|
// breadcrumb
|
|
339
405
|
var chain=[],c=pid;while(c){var nn=node(c);chain.unshift(nn);c=nn?nn.parent:null;}
|
|
340
|
-
var
|
|
341
|
-
var
|
|
406
|
+
var lvlKey=pid?(kids[0]&&kids[0].level):"context";
|
|
407
|
+
var lvl=pid?levelName(lvlKey):STR.lvlContext;
|
|
408
|
+
// C4 notation: number the level by its position in the model's own `levels` list (L1 = context).
|
|
409
|
+
var lvlIdx=((M.levels||[]).indexOf(lvlKey));
|
|
410
|
+
var cb=(lvlIdx>-1?STR.crumbLevel+"-L"+(lvlIdx+1):STR.crumbLevel)+" · "+lvl.toUpperCase()+" <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
|
|
342
411
|
for(var w=0;w<chain.length;w++)cb+=" › "+(w===chain.length-1?"<b>"+esc(chain[w].name)+"</b>":"<a data-d='"+w+"'>"+esc(chain[w].name)+"</a>");
|
|
412
|
+
// Per-level programme tally — the status-board essence in one glance: the status mix of THIS
|
|
413
|
+
// view plus mean completion. Counts use the real children (a collapsed catalogue still counts
|
|
414
|
+
// its members), so the tally never changes when the catalogue threshold does.
|
|
415
|
+
var cnt={},tot=0,sum=0,nc=0,st3;
|
|
416
|
+
for(j=0;j<realKids.length;j++){
|
|
417
|
+
st3=STMAP[realKids[j].status2]||(realKids[j].status==="planned"?"plan":"done");
|
|
418
|
+
cnt[st3]=(cnt[st3]||0)+1;tot++;
|
|
419
|
+
if(realKids[j].completion!=null){sum+=(+realKids[j].completion||0);nc++;}
|
|
420
|
+
}
|
|
421
|
+
if(tot){
|
|
422
|
+
var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d"},ord=["done","prog","next","plan","prob"],agg="";
|
|
423
|
+
for(j=0;j<ord.length;j++)if(cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+cnt[ord[j]];
|
|
424
|
+
if(nc)agg+=' '+esc(STR.aggProgress)+' <b>'+Math.round(sum/nc)+'%</b>';
|
|
425
|
+
cb='<span class="agg">'+agg+'</span>'+cb;
|
|
426
|
+
}
|
|
343
427
|
$("crumb").innerHTML=cb;
|
|
344
428
|
$("back").disabled=stack.length<2;
|
|
345
429
|
$("detail").style.display="none";
|
|
346
430
|
}
|
|
431
|
+
// Greedy word wrap into at most `max` lines of `cpl` characters. Every returned line is clamped to
|
|
432
|
+
// cpl — an unbreakable token (a long class name, a URL) would otherwise paint outside the box, and
|
|
433
|
+
// there is no clip-path on the node. The last line carries the ellipsis when text was dropped,
|
|
434
|
+
// rather than spending a whole line on it (on a short curated box that was half the visible text).
|
|
435
|
+
function wrapDesc(desc,cpl,max){
|
|
436
|
+
if(max<1||cpl<2)return [];
|
|
437
|
+
var words=String(desc==null?"":desc).split(" "),all=[],line="",i;
|
|
438
|
+
for(i=0;i<words.length;i++){if(line&&(line+words[i]).length>cpl){all.push(line.trim());line="";}line+=words[i]+" ";}
|
|
439
|
+
if(line.trim())all.push(line.trim());
|
|
440
|
+
var out=all.slice(0,max),dropped=all.length>out.length;
|
|
441
|
+
if(dropped&&out.length)out[out.length-1]=out[out.length-1]+"…";
|
|
442
|
+
for(i=0;i<out.length;i++)if(out[i].length>cpl)out[i]=out[i].slice(0,cpl-1)+"…";
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
// Returns the path plus the point ON the curve at t=0.5 — the label anchor. (The quadratic's
|
|
446
|
+
// control point sits at twice the bow, so anchoring a label there would float it off the arrow.)
|
|
447
|
+
// Endpoints are trimmed at the true RECTANGLE border (not a radial guess): with wide boxes the
|
|
448
|
+
// radial trim landed inside the box on shallow angles, dragging the label anchor under the node.
|
|
347
449
|
function edgePath(a,b){
|
|
348
450
|
var ax=a.x+a.w/2,ay=a.y+a.h/2,bx=b.x+b.w/2,by=b.y+b.h/2,dx=bx-ax,dy=by-ay,L=Math.sqrt(dx*dx+dy*dy)||1;
|
|
349
|
-
var
|
|
350
|
-
var
|
|
351
|
-
|
|
451
|
+
var ux=dx/L,uy=dy/L;
|
|
452
|
+
var ta=Math.min(ux?(a.w/2)/Math.abs(ux):Infinity,uy?(a.h/2)/Math.abs(uy):Infinity)+8;
|
|
453
|
+
var tb=Math.min(ux?(b.w/2)/Math.abs(ux):Infinity,uy?(b.h/2)/Math.abs(uy):Infinity)+11;
|
|
454
|
+
if(ta+tb>L-4){ta=(a.w/2+10);tb=(b.w/2+12);} // boxes touch/overlap: fall back to the radial trim
|
|
455
|
+
var x1=ax+ta*ux,y1=ay+ta*uy,x2=bx-tb*ux,y2=by-tb*uy;
|
|
456
|
+
var cx=(x1+x2)/2+(y2-y1)*.12,cy=(y1+y2)/2-(x2-x1)*.12;
|
|
457
|
+
return {d:"M"+x1+","+y1+" Q"+cx+","+cy+" "+x2+","+y2, mx:(x1+2*cx+x2)/4, my:(y1+2*cy+y2)/4};
|
|
352
458
|
}
|
|
353
459
|
function levelName(l){return (STR.lvlNames&&STR.lvlNames[l])||l||"—";}
|
|
354
460
|
function cssv(v){try{return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||"#2b6e8a";}catch(e){return "#2b6e8a";}}
|
|
@@ -416,7 +522,9 @@ function wire(){
|
|
|
416
522
|
pos[drag.id].y=clamp(p.y-drag.oy,4,lay.H-pos[drag.id].h-4);
|
|
417
523
|
drag.moved=true;draw(false);
|
|
418
524
|
});
|
|
419
|
-
document.addEventListener("mouseup",function(){if(drag)
|
|
525
|
+
document.addEventListener("mouseup",function(){if(!drag)return;var m=drag.moved;drag=null;
|
|
526
|
+
// refit after the drop, deferred so the click that follows mouseup still lands on the live DOM
|
|
527
|
+
if(m){justDragged=true;setTimeout(function(){draw(false);},0);}});
|
|
420
528
|
// edge label on hover (invisible fat hit-path)
|
|
421
529
|
stage.addEventListener("mouseover",function(ev){
|
|
422
530
|
if(ev.target.classList&&ev.target.classList.contains("edgehit")){
|
|
@@ -456,7 +564,15 @@ function wire(){
|
|
|
456
564
|
document.addEventListener("keydown",function(e){if(e.key==="Escape"&&stack.length>1){stack.pop();draw(true);}});
|
|
457
565
|
$("bnow").addEventListener("click",function(){mode="now";$("bnow").className="on";$("btgt").className="";draw(false);});
|
|
458
566
|
$("btgt").addEventListener("click",function(){mode="target";$("btgt").className="on";$("bnow").className="";draw(false);});
|
|
459
|
-
$("brf").addEventListener("click",function(){
|
|
567
|
+
$("brf").addEventListener("click",function(){
|
|
568
|
+
// Served model ⇒ actually re-read it (a `forma verify` run in the terminal shows up here without
|
|
569
|
+
// losing the level, the layout or the mode). Injected/offline ⇒ re-stamp, and say why.
|
|
570
|
+
if(!FETCHED){stamp(STR.staticSrc);return;}
|
|
571
|
+
fetch("./c4-model.json?ts="+(new Date()).getTime()).then(function(r){return r.json();})
|
|
572
|
+
.then(function(m){if(m&&m.nodes&&m.nodes.length){M=m;stamp("");draw(false);}else stamp(STR.staticSrc);})
|
|
573
|
+
.catch(function(){stamp(STR.staticSrc);});
|
|
574
|
+
});
|
|
575
|
+
$("blbl").addEventListener("click",function(){LABELS=!($("blbl").className==="on");draw(false);});
|
|
460
576
|
$("brl").addEventListener("click",function(){delete POS[keyOf()];draw(true);});
|
|
461
577
|
$("skin").innerHTML=SKINS.map(function(s){return '<option value="'+s[0]+'">'+s[1]+'</option>';}).join("");
|
|
462
578
|
$("skin").addEventListener("change",function(e){setSkin(e.target.value);});
|
|
@@ -467,7 +583,9 @@ function applyStrings(){
|
|
|
467
583
|
function set(id,txt){var e=$(id);if(e)e.textContent=txt;}
|
|
468
584
|
set("subhelp",STR.subhelp);set("back",STR.back);set("bnow",STR.now);set("btgt",STR.tgt);
|
|
469
585
|
set("brf",STR.reverify);set("brl",STR.resetLayout);var brl=$("brl");if(brl)brl.title=STR.resetLayoutTitle;
|
|
470
|
-
set("
|
|
586
|
+
set("blbl",STR.labels);var bl=$("blbl");if(bl)bl.title=STR.labelsTitle;
|
|
587
|
+
set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);
|
|
588
|
+
set("pexpLayout",STR.exportLayout);set("pexpPrint",STR.print);
|
|
471
589
|
set("lg-done",STR.legDone);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
|
|
472
590
|
set("lg-plan",STR.legPlan);set("lg-prob",STR.legProb);set("lg-hint",STR.legHint);
|
|
473
591
|
}
|
|
@@ -486,7 +604,7 @@ function collectSvgCss(){
|
|
|
486
604
|
if(!rules)continue;
|
|
487
605
|
for(j=0;j<rules.length;j++){var r=rules[j],t=r.cssText||"";
|
|
488
606
|
// keep rules that target svg-internal classes/elements + the color vars on :root/html
|
|
489
|
-
if(/\.(nd|edge|edgehit|s-done|s-prog|s-next|s-plan|s-prob|mode-t|tt|kk|dd|pp|plus|cat)\b/.test(t)
|
|
607
|
+
if(/\.(nd|edge|edgehit|elbl|s-done|s-prog|s-next|s-plan|s-prob|mode-t|tt|kk|dd|pp|plus|cat)\b/.test(t)
|
|
490
608
|
||/(^|[\s,])svg\b/.test(t)||/:root|html\[data-skin/.test(t)){
|
|
491
609
|
// drop animations for a static export (keepdefs stable)
|
|
492
610
|
out.push(t);
|
|
@@ -512,14 +630,14 @@ function serializeSvg(){
|
|
|
512
630
|
clone.setAttribute("xmlns","http://www.w3.org/2000/svg");
|
|
513
631
|
clone.setAttribute("xmlns:xlink","http://www.w3.org/1999/xlink");
|
|
514
632
|
var vb=(svg.getAttribute("viewBox")||"0 0 1080 660").split(/\s+/);
|
|
515
|
-
var w=+vb[2]||1080,h=+vb[3]||660;
|
|
633
|
+
var vbx=+vb[0]||0,vby=+vb[1]||0,w=+vb[2]||1080,h=+vb[3]||660;
|
|
516
634
|
clone.setAttribute("width",w);clone.setAttribute("height",h);
|
|
517
635
|
// Inline the resolved variables on the root so var(--x) resolves standalone,
|
|
518
636
|
// independent of the skin-selector chain that won't exist in the exported file.
|
|
519
637
|
var vars=resolvedVars();if(vars)clone.setAttribute("style",vars);
|
|
520
638
|
// solid dark/light background so the standalone file isn't transparent
|
|
521
639
|
var bg=cssv("--stagebg")||"#050d14";
|
|
522
|
-
var rect='<rect x="
|
|
640
|
+
var rect='<rect x="'+vbx+'" y="'+vby+'" width="'+w+'" height="'+h+'" fill="'+bg+'"/>';
|
|
523
641
|
var style="<style>"+collectSvgCss()+"</style>";
|
|
524
642
|
var inner=clone.innerHTML;
|
|
525
643
|
var attrs='';for(var a=0;a<clone.attributes.length;a++){var at=clone.attributes[a];attrs+=' '+at.name+'="'+String(at.value).replace(/"/g,""")+'"';}
|
|
@@ -531,6 +649,17 @@ function triggerDownload(blob,filename){
|
|
|
531
649
|
a.href=url;a.download=filename;document.body.appendChild(a);a.click();
|
|
532
650
|
document.body.removeChild(a);setTimeout(function(){URL.revokeObjectURL(url);},4000);
|
|
533
651
|
}
|
|
652
|
+
// Every level you have arranged, in the exact shape meta.layout expects — drag the boxes, export,
|
|
653
|
+
// paste into the topology's "layout" and `gen` bakes it in. No storage: the repo is the memory.
|
|
654
|
+
function exportLayout(){
|
|
655
|
+
var out={},k,id,p;
|
|
656
|
+
for(k in POS){if(!POS.hasOwnProperty(k))continue;
|
|
657
|
+
var lvl={},any=false;
|
|
658
|
+
for(id in POS[k].pos){if(!POS[k].pos.hasOwnProperty(id))continue;
|
|
659
|
+
p=POS[k].pos[id];lvl[id]={x:Math.round(p.x),y:Math.round(p.y),w:Math.round(p.w),h:Math.round(p.h)};any=true;}
|
|
660
|
+
if(any)out[k]=lvl;}
|
|
661
|
+
triggerDownload(new Blob([JSON.stringify({layout:out},null,2)+"\n"],{type:"application/json"}),exportBaseName()+"-layout.json");
|
|
662
|
+
}
|
|
534
663
|
function exportSvg(){
|
|
535
664
|
var s=serializeSvg();if(!s)return;
|
|
536
665
|
triggerDownload(new Blob([s.xml],{type:"image/svg+xml;charset=utf-8"}),exportBaseName()+".svg");
|
|
@@ -564,23 +693,34 @@ function wireExport(){
|
|
|
564
693
|
document.addEventListener("keydown",function(e){if(e.key==="Escape")close();});
|
|
565
694
|
var sv=$("pexpSvg");if(sv)sv.addEventListener("click",function(){close();exportSvg();});
|
|
566
695
|
var pn=$("pexpPng");if(pn)pn.addEventListener("click",function(){close();exportPng();});
|
|
696
|
+
var lay=$("pexpLayout");if(lay)lay.addEventListener("click",function(){close();exportLayout();});
|
|
567
697
|
var pr=$("pexpPrint");if(pr)pr.addEventListener("click",function(){close();window.print();});
|
|
568
698
|
}
|
|
569
699
|
function moveTip(ev){var st=$("stage").getBoundingClientRect(),t=$("etip");
|
|
570
700
|
var x=ev.clientX-st.left+12,y=ev.clientY-st.top+12;
|
|
571
701
|
t.style.left=Math.min(x,st.width-t.offsetWidth-6)+"px";t.style.top=Math.min(y,st.height-t.offsetHeight-6)+"px";}
|
|
572
702
|
|
|
703
|
+
// Fact-base line. `suffix` marks a RE-VERIFY that could not re-read the model (injected/offline).
|
|
704
|
+
function stamp(suffix){
|
|
705
|
+
var m=(M&&M.meta)||{};
|
|
706
|
+
$("stamp").textContent=STR.stampBase+" "+(m.verifiedAt||(M&&M.generatedAt)||(M&&M.source&&M.source.commit)||"—")
|
|
707
|
+
+" · "+STR.stampMethod+" "+(m.verifyMethod||STR.stampVerify)+(suffix?" "+suffix:"");
|
|
708
|
+
}
|
|
573
709
|
function boot(model){
|
|
574
710
|
M=model;
|
|
575
711
|
LANG=pickLang(M);STR=STRINGS[LANG];
|
|
576
712
|
try{document.documentElement.setAttribute("lang",LANG);}catch(e){}
|
|
577
713
|
applyStrings();
|
|
578
714
|
if(!M||!M.nodes||!M.nodes.length){$("err").style.display="block";$("err").textContent=STR.errEmpty;return;}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
715
|
+
var ttl=(M.meta&&M.meta.title)||((M.source&&M.source.repo)?"Forma · "+M.source.repo:null);
|
|
716
|
+
if(ttl){$("title").textContent=ttl;try{document.title=ttl;}catch(e){}}
|
|
717
|
+
stamp("");
|
|
718
|
+
setSkin((new URLSearchParams(location.search).get("skin"))||window.__C4_SKIN__||(M.meta&&M.meta.skin)||"holo");
|
|
582
719
|
stack=[null];mode="now";POS={};
|
|
583
720
|
draw(true);
|
|
721
|
+
// Minimal hook for embedders (the Cowork artifact re-verifies through its own MCP bridge and
|
|
722
|
+
// needs to push the result back in without forking the viewer).
|
|
723
|
+
window.__C4_API__={model:function(){return M;},redraw:function(){draw(false);},stamp:function(t){$("stamp").textContent=t;}};
|
|
584
724
|
}
|
|
585
725
|
function start(){
|
|
586
726
|
// Pick language from URL/global up-front so header + errors render even if the model never loads.
|
|
@@ -589,6 +729,7 @@ function start(){
|
|
|
589
729
|
applyStrings();
|
|
590
730
|
wire();
|
|
591
731
|
if(window.__C4_MODEL__){boot(window.__C4_MODEL__);return;}
|
|
732
|
+
FETCHED=true; // the model has a live URL ⇒ RE-VERIFY can re-read it instead of only re-stamping
|
|
592
733
|
try{fetch("./c4-model.json").then(function(r){return r.json();}).then(boot).catch(function(){
|
|
593
734
|
$("err").style.display="block";$("err").textContent=STR.errNotFound;});}
|
|
594
735
|
catch(e){$("err").style.display="block";$("err").textContent=STR.errUnavail;}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forma-arch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Forma — present your architecture instead of slides. An interactive, stack-agnostic C4 explorer (context → container → component → leaf) generated from your code and kept true to it by a deterministic drift check.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"architecture",
|