forma-arch 0.4.0 → 0.6.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 +14 -2
- package/lib/cluster.mjs +5 -2
- package/lib/describe.mjs +21 -2
- package/lib/enrich.mjs +13 -4
- package/lib/gen.mjs +20 -6
- package/lib/schema/c4-model.schema.json +3 -2
- package/lib/viewer/c4-hologram.html +88 -30
- 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
|
|
@@ -49,10 +52,15 @@ npx forma-arch <command> # or: npm i -D forma-arch
|
|
|
49
52
|
| `--enricher` | Use it when | Network |
|
|
50
53
|
|---|---|---|
|
|
51
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 |
|
|
52
|
-
| `anthropic`
|
|
55
|
+
| `anthropic` | Headless / CI, with `ANTHROPIC_API_KEY`. | REST |
|
|
53
56
|
| `openai` | Same, with `OPENAI_API_KEY`. | REST |
|
|
54
57
|
| `ollama` | Sensitive repos: a local model, nothing leaves the machine. | localhost |
|
|
55
58
|
|
|
59
|
+
`--enricher` has **no default**: `forma gen --enrich` on its own fails loud and lists the four. A
|
|
60
|
+
default provider is a silent choice about your network and your API keys — and the old default
|
|
61
|
+
(`anthropic`) meant that anyone without `ANTHROPIC_API_KEY` exported got a skip line, exit 0 and the
|
|
62
|
+
same empty boxes they ran `--enrich` to fill.
|
|
63
|
+
|
|
56
64
|
## How it fits together
|
|
57
65
|
|
|
58
66
|
```
|
|
@@ -81,7 +89,11 @@ The viewer is a live C4 map, not a static picture:
|
|
|
81
89
|
- **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.
|
|
82
90
|
- **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.
|
|
83
91
|
- **PRINT / EXPORT** to SVG or PNG for docs and slides — exported arrows carry their labels.
|
|
84
|
-
- The breadcrumb names the C4 level you are on (`C4-L1 · CONTEXT` → `C4-L3 · COMPONENTS`)
|
|
92
|
+
- The breadcrumb names the C4 level you are on (`C4-L1 · CONTEXT` → `C4-L3 · COMPONENTS`) and,
|
|
93
|
+
when the level carries curated state, tallies it: a dot per status with its count plus mean
|
|
94
|
+
completion, so a level reads as a programme board and not just a diagram.
|
|
95
|
+
- **Every level shrink-wraps its own content** — a context with four boxes renders zoomed and
|
|
96
|
+
dense instead of adrift in a fixed canvas.
|
|
85
97
|
|
|
86
98
|
## Skins
|
|
87
99
|
|
package/lib/cluster.mjs
CHANGED
|
@@ -13,14 +13,17 @@ export function containerOf(node, byId) {
|
|
|
13
13
|
return n ? n.id : null
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// Group a flat container's leaves by common `
|
|
16
|
+
// Group a flat container's leaves by common `foo<sep>*` prefix, where sep is any of - _ . — the
|
|
17
|
+
// three conventions real repos use (kebab dominates JS/TS, snake Python, dot some Java/config
|
|
18
|
+
// layouts). Splitting on `_` alone gave kebab and dot repos no component level at all.
|
|
19
|
+
// Deterministic: sorted group keys.
|
|
17
20
|
// Returns { components: Node[], reparent: Map(leafId → componentId) }. Leftovers stay under the container.
|
|
18
21
|
export function componentsFor(container, leaves, opts = {}) {
|
|
19
22
|
const groupMin = opts.groupMin || 3
|
|
20
23
|
const groups = new Map()
|
|
21
24
|
for (const l of leaves) {
|
|
22
25
|
const base = String(l.name)
|
|
23
|
-
const i = base.
|
|
26
|
+
const i = base.search(/[-_.]/)
|
|
24
27
|
if (i <= 0) continue // no prefix (e.g. "health", "version") → stays flat
|
|
25
28
|
const prefix = base.slice(0, i)
|
|
26
29
|
if (!groups.has(prefix)) groups.set(prefix, [])
|
package/lib/describe.mjs
CHANGED
|
@@ -60,6 +60,19 @@ function buildArc42Index(repo, docPath) {
|
|
|
60
60
|
return idx
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Last resort for a node that HAS children: say what it measurably holds. Never the language —
|
|
64
|
+
// a box reading "TypeScript" states the one thing the reader can already see in the header.
|
|
65
|
+
function measuredFunc(node, ctx) {
|
|
66
|
+
const kids = [...ctx.byId.values()].filter((k) => k.parent === node.id)
|
|
67
|
+
.sort((a, b) => (String(a.name) < String(b.name) ? -1 : String(a.name) > String(b.name) ? 1 : 0))
|
|
68
|
+
if (!kids.length) return null
|
|
69
|
+
const count = (k) => kids.filter((x) => x.kind === k).length
|
|
70
|
+
const parts = [['container', count('container')], ['component', count('component')], ['file', count('leaf')]]
|
|
71
|
+
.filter((p) => p[1]).map((p) => `${p[1]} ${p[0]}${p[1] === 1 ? '' : 's'}`)
|
|
72
|
+
const names = kids.slice(0, 3).map((k) => k.name).join(', ')
|
|
73
|
+
return `${parts.join(' + ')}: ${names}${kids.length > 3 ? ', …' : '.'}`.slice(0, 240)
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
export function makeDescribeCtx({ repo, byId, descriptions, docPath, containerOf }) {
|
|
64
77
|
return { repo, byId, D: descriptions || {}, containerOf, readmeCache: new Map(), arc42: buildArc42Index(repo, docPath) }
|
|
65
78
|
}
|
|
@@ -79,12 +92,18 @@ export function resolveDescription(node, ctx) {
|
|
|
79
92
|
const rd = readmeFirstPara(dirname(abs), ctx.readmeCache)
|
|
80
93
|
if (rd) return { func: rd, descSource: 'readme' }
|
|
81
94
|
}
|
|
95
|
+
// A container's evidence is a GLOB over its own directory, never a `path` — which is why the
|
|
96
|
+
// README sitting in that very directory never reached it, and the box fell through to `tech`.
|
|
97
|
+
const glb = (node.evidence || []).find((e) => e.type === 'glob')
|
|
98
|
+
if (!pth && glb) {
|
|
99
|
+
const rd = readmeFirstPara(join(ctx.repo, glb.ref), ctx.readmeCache)
|
|
100
|
+
if (rd) return { func: rd, descSource: 'readme' }
|
|
101
|
+
}
|
|
82
102
|
const a = ctx.arc42.get(norm(node.name)) || ctx.arc42.get(norm(node.id))
|
|
83
103
|
if (a) return { func: a, descSource: 'arc42' }
|
|
84
104
|
if (node.kind === 'leaf') {
|
|
85
105
|
const cn = (ctx.byId.get(cont) || {}).name || cont || node.parent
|
|
86
106
|
return { func: `Component of module ${cn}.`, descSource: 'fallback' }
|
|
87
107
|
}
|
|
88
|
-
|
|
89
|
-
return { func: node.description || '', descSource: 'fallback' }
|
|
108
|
+
return { func: node.description || measuredFunc(node, ctx) || '', descSource: 'fallback' }
|
|
90
109
|
}
|
package/lib/enrich.mjs
CHANGED
|
@@ -36,15 +36,21 @@ export function descInputHash(node, ctx) {
|
|
|
36
36
|
|
|
37
37
|
function promptFor(node, ctx, opts = {}) {
|
|
38
38
|
const { cont, siblings } = siblingsOf(node, ctx)
|
|
39
|
+
// A container is its OWN container: unguarded, the prompt tells the model that "auth" belongs to
|
|
40
|
+
// the container "auth" and calls its own children its siblings. Its filesystem pointer is a glob
|
|
41
|
+
// over its directory, not a path, so it also had nothing to read.
|
|
42
|
+
const self = cont === node.id
|
|
39
43
|
const contName = (ctx.byId && ctx.byId.get(cont) || {}).name || cont || 'the system'
|
|
40
44
|
const path = (node.evidence || []).find((e) => e.type === 'path')
|
|
45
|
+
const glob = !path && (node.evidence || []).find((e) => e.type === 'glob')
|
|
41
46
|
return [
|
|
42
|
-
`In ONE plain sentence (max 18 words), say what the software module "${node.name}" does.`,
|
|
43
|
-
`It belongs to the container "${contName}".`,
|
|
44
|
-
siblings.length ?
|
|
47
|
+
`In ONE plain sentence (max 18 words), say what the software ${self ? 'module group' : 'module'} "${node.name}" does.`,
|
|
48
|
+
self ? '' : `It belongs to the container "${contName}".`,
|
|
49
|
+
siblings.length ? `${self ? 'The modules it holds' : 'Sibling modules'}: ${siblings.slice(0, 40).join(', ')}.` : '',
|
|
45
50
|
// Only the agent enricher can act on this: a REST provider has no filesystem. That asymmetry
|
|
46
51
|
// IS the point of the agent mode — the model driving forma can read the source it describes.
|
|
47
52
|
(opts.canRead && path) ? `Read the file at ${path.ref} if you need certainty.` : '',
|
|
53
|
+
(opts.canRead && glob) ? `Read the sources under ${glob.ref}/ if you need certainty.` : '',
|
|
48
54
|
'Answer with the sentence only — no preamble, no quotes.',
|
|
49
55
|
].filter(Boolean).join('\n')
|
|
50
56
|
}
|
|
@@ -103,8 +109,11 @@ export function mergeCache(nodes, cache) {
|
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
// The holes an enricher would fill: never-described nodes, plus llm prose whose inputs moved.
|
|
112
|
+
// Containers count. They were excluded on the theory that the topology describes them — but on a
|
|
113
|
+
// repo with no curated topology (i.e. anything `forma init` seeded) they are exactly the boxes
|
|
114
|
+
// left on a measured fallback, and the biggest ones on screen.
|
|
106
115
|
export function holesIn(nodes, ctx) {
|
|
107
|
-
return nodes.filter((n) => (n.kind === 'leaf' || n.kind === 'component') &&
|
|
116
|
+
return nodes.filter((n) => (n.kind === 'leaf' || n.kind === 'component' || n.kind === 'container') &&
|
|
108
117
|
(n.descSource === 'fallback' || (n.descSource === 'llm' && n.descInputHash !== descInputHash(n, ctx))))
|
|
109
118
|
}
|
|
110
119
|
|
package/lib/gen.mjs
CHANGED
|
@@ -15,10 +15,10 @@ const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? proce
|
|
|
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.4.0' // +status2:"unknown" (additive enum value → MINOR)
|
|
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
|
-
const ENRICHER = arg('--enricher',
|
|
21
|
+
const ENRICHER = arg('--enricher', null)
|
|
22
22
|
const ENRICH_MODEL = arg('--enrich-model', null)
|
|
23
23
|
const STATUS = arg('--status', join(REPO, 'docs/architecture/c4-status.json')) // curated programme state, optional
|
|
24
24
|
const STATUS_SET = process.argv.includes('--status')
|
|
@@ -31,6 +31,12 @@ const fail = (m) => { console.error('[gen-c4] FAIL: ' + m); process.exit(1) }
|
|
|
31
31
|
// (`length >= NaN` is false for every group), so a bad value fails loud instead.
|
|
32
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
33
|
const CLUSTER_MIN = posInt('--cluster-min', 8), GROUP_MIN = posInt('--group-min', 3)
|
|
34
|
+
// §7 has no default provider ON PURPOSE. It used to be `anthropic`, i.e. the one path that needs
|
|
35
|
+
// an API key most people have not exported — so `--enrich` "worked", printed a skip line, exited 0
|
|
36
|
+
// and left every box empty: the defect this command exists to fix, reintroduced by its own default.
|
|
37
|
+
// Flipping the default to `agent` would be just as silent the other way (a CI job with a key would
|
|
38
|
+
// quietly stop filling boxes and start writing a plan file). So: choose, explicitly.
|
|
39
|
+
if (ENRICH && !ENRICHER) fail('--enrich needs an explicit --enricher: `agent` (no key — an agent writes the prose, see --enrich-apply), `anthropic`/`openai` (REST, needs the API key in the environment), `ollama` (localhost), or `echo` (offline, testing).')
|
|
34
40
|
|
|
35
41
|
function walk(spec) {
|
|
36
42
|
const dir = rp(spec.dir)
|
|
@@ -114,12 +120,16 @@ if (CLUSTER) for (const cid of [...new Set(topo.leafSources.map((s) => s.parent)
|
|
|
114
120
|
console.log(`[gen-c4] container ${cid}: clustered ${leaves.length} leaves into ${components.length} component(s)`)
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
// enrich: fill hologram defaults (category,
|
|
123
|
+
// enrich: fill hologram defaults (category, 6-status, completion, current/target) where absent
|
|
118
124
|
for (const n of nodes) {
|
|
119
125
|
const par = n.parent ? byId.get(n.parent) : null
|
|
120
126
|
if (!n.category) n.category = n.kind === 'leaf' ? (par && par.category) || 'leaf' : n.kind
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
// Code can prove a file EXISTS; it cannot prove the work behind it is finished. Marking every
|
|
128
|
+
// undecorated node done/100 turned a virgin repo into a board reading "10/10 complete" — the
|
|
129
|
+
// exact false green this tool exists to kill. No overlay (§WP-A1), no verdict: `unknown`, and
|
|
130
|
+
// no completion at all (a percentage nobody curated is a made-up number, including 0).
|
|
131
|
+
if (!n.status2) n.status2 = n.status === 'planned' ? 'planned' : 'unknown'
|
|
132
|
+
if (n.completion == null && n.status === 'planned') n.completion = 0
|
|
123
133
|
// `current` is left EMPTY unless curated (topology or status overlay): the viewer falls back to
|
|
124
134
|
// `func`, which since §1a carries the module's real documentation. The old "Exists: <path>"
|
|
125
135
|
// filler restated the evidence path in the one field meant for programme facts.
|
|
@@ -226,7 +236,11 @@ if (!process.argv.includes('--no-auto-edges')) {
|
|
|
226
236
|
for (const to of exposes.keys()) {
|
|
227
237
|
if (to === from || have.has(from + '|' + to)) continue
|
|
228
238
|
let c = 0
|
|
229
|
-
|
|
239
|
+
// A module name is matched VERBATIM (metacharacters escaped, never deleted: stripping them
|
|
240
|
+
// turned "session-store" into /\bsessionstore\b/, which matches nothing — every kebab-case
|
|
241
|
+
// repo, i.e. most JS/TS ones, rendered edges=0). `-` and `.` are word separators here, so
|
|
242
|
+
// the boundary is spelled out instead of using \b (which sits INSIDE "session-store").
|
|
243
|
+
for (const nm of exposes.get(to)) { if (new RegExp('(^|[^\\w-])' + nm.replace(/[.*+?^${}()|[\]\\\/-]/g, '\\$&') + '([^\\w-]|$)').test(t)) c++ }
|
|
230
244
|
if (c > 0) { derived.push({ from, to, label: c + '×', kind: 'import', estatus: 'active' }); have.add(from + '|' + to); have.add(to + '|' + from) }
|
|
231
245
|
}
|
|
232
246
|
}
|
|
@@ -179,9 +179,10 @@
|
|
|
179
179
|
"in-progress",
|
|
180
180
|
"next",
|
|
181
181
|
"planned",
|
|
182
|
-
"problem"
|
|
182
|
+
"problem",
|
|
183
|
+
"unknown"
|
|
183
184
|
],
|
|
184
|
-
"description": "Hologram completion status (colour)."
|
|
185
|
+
"description": "Hologram completion status (colour). `unknown` is the default for a node no curated overlay has ruled on — code proves existence, never completion."
|
|
185
186
|
},
|
|
186
187
|
"completion": {
|
|
187
188
|
"type": "integer",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
--bg:#050d14; --grid:rgba(62,231,255,.04); --halo:rgba(62,231,255,.10);
|
|
10
10
|
--cyan:#3ee7ff; --sec:#6fa8bc; --ter:#4c7f94; --ter2:#41738a; --nm:#bfe9f7;
|
|
11
11
|
--stagebg:rgba(4,12,19,.72); --stageborder:#143547; --nodebg:rgba(8,26,38,.92); --detailbg:rgba(6,20,30,.97);
|
|
12
|
-
--border:#1e4a5e; --done:#2fe08a; --prog:#ffc74d; --next:#3ee7ff; --plan:#2d5a72; --planTxt:#7fb3c6; --prob:#ff5d5d;
|
|
12
|
+
--border:#1e4a5e; --done:#2fe08a; --prog:#ffc74d; --next:#3ee7ff; --plan:#2d5a72; --planTxt:#7fb3c6; --prob:#ff5d5d; --unk:#7d8f99;
|
|
13
13
|
--edge:#2b6e8a; --edgeplan:#264d61; --elbl:#3d7f9c; --ddtxt:#7fb3c6; --now:#cfeefb; --tgt:#8fd8ec; --src:#4fbf8d;}
|
|
14
14
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
15
15
|
body{font-family:"SF Mono",Menlo,Consolas,ui-monospace,monospace;background:var(--bg);color:var(--nm);overflow-x:hidden}
|
|
@@ -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}
|
|
@@ -57,6 +60,8 @@ svg{width:100%;height:100%;display:block}
|
|
|
57
60
|
.s-plan .tt,.s-plan .pp{fill:var(--planTxt)}
|
|
58
61
|
.s-prob rect{stroke:var(--prob);filter:drop-shadow(0 0 8px rgba(255,93,93,.6))}
|
|
59
62
|
.s-prob .tt,.s-prob .pp{fill:var(--prob)}
|
|
63
|
+
.s-unk rect{stroke:var(--unk);stroke-dasharray:1 3}
|
|
64
|
+
.s-unk .tt,.s-unk .pp{fill:var(--unk)}
|
|
60
65
|
.mode-t .nd rect{stroke:var(--next)!important;stroke-dasharray:none!important;filter:drop-shadow(0 0 8px rgba(62,231,255,.5))!important}
|
|
61
66
|
.mode-t .nd .tt,.mode-t .nd .pp{fill:var(--next)!important}
|
|
62
67
|
.edge{stroke:var(--edge);stroke-width:1.3;fill:none;marker-end:url(#ar);cursor:help}
|
|
@@ -64,7 +69,8 @@ svg{width:100%;height:100%;display:block}
|
|
|
64
69
|
.edge.plan{stroke:var(--edgeplan);stroke-dasharray:3 6;opacity:.6;marker-end:url(#arp)}
|
|
65
70
|
.edge.hot{stroke:var(--cyan);stroke-width:2.6;opacity:1}
|
|
66
71
|
.edgehit{stroke:transparent;stroke-width:14;fill:none;cursor:help}
|
|
67
|
-
.elbl{font-size:8.2px;fill:var(--elbl);letter-spacing:.04em;pointer-events:none
|
|
72
|
+
.elbl{font-size:8.2px;fill:var(--elbl);letter-spacing:.04em;pointer-events:none;
|
|
73
|
+
paint-order:stroke;stroke:var(--stagebg);stroke-width:3px;stroke-linejoin:round}
|
|
68
74
|
@keyframes dash{to{stroke-dashoffset:-24}}
|
|
69
75
|
.nd.pulse rect{animation:pul 2.4s ease-in-out infinite}
|
|
70
76
|
@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))}}
|
|
@@ -115,7 +121,7 @@ svg{width:100%;height:100%;display:block}
|
|
|
115
121
|
html[data-skin="blueprint"]{--bg:#eef3f9;--grid:rgba(31,119,180,.10);--halo:rgba(31,119,180,.08);
|
|
116
122
|
--cyan:#1f77b4;--sec:#5a6b7d;--ter:#6b7b8c;--ter2:#5a6b7d;--nm:#16273a;
|
|
117
123
|
--stagebg:#ffffff;--stageborder:rgba(31,119,180,.25);--nodebg:#ffffff;--detailbg:#ffffff;--border:rgba(31,119,180,.35);
|
|
118
|
-
--done:#1f9d57;--prog:#c07a12;--next:#1f77b4;--plan:#8592a3;--planTxt:#5a6b7d;--prob:#d1453b;
|
|
124
|
+
--done:#1f9d57;--prog:#c07a12;--next:#1f77b4;--plan:#8592a3;--planTxt:#5a6b7d;--prob:#d1453b;--unk:#8a97a3;
|
|
119
125
|
--edge:#8aa0b4;--edgeplan:#b9c6d3;--elbl:#5a6b7d;--ddtxt:#40536a;--now:#16273a;--tgt:#1f5f8a;--src:#1f9d57}
|
|
120
126
|
html[data-skin="blueprint"] h1{text-shadow:none}
|
|
121
127
|
html[data-skin="blueprint"] .s-done rect,html[data-skin="blueprint"] .s-prog rect,html[data-skin="blueprint"] .s-next rect,html[data-skin="blueprint"] .s-prob rect{filter:none}
|
|
@@ -156,6 +162,7 @@ html[data-skin="blueprint"] button.on{text-shadow:none}
|
|
|
156
162
|
<span><i style="background:#3ee7ff"></i><span id="lg-next"></span></span>
|
|
157
163
|
<span><i style="background:#2d5a72"></i><span id="lg-plan"></span></span>
|
|
158
164
|
<span><i style="background:#ff5d5d"></i><span id="lg-prob"></span></span>
|
|
165
|
+
<span><i style="background:#7d8f99"></i><span id="lg-unk"></span></span>
|
|
159
166
|
<span class="hint" id="lg-hint"></span>
|
|
160
167
|
</div>
|
|
161
168
|
<div id="detail"></div>
|
|
@@ -172,13 +179,14 @@ var STRINGS={
|
|
|
172
179
|
resetLayout:"RESET LAYOUT", resetLayoutTitle:"re-arrange",
|
|
173
180
|
printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", exportLayout:"Export layout JSON", print:"Print",
|
|
174
181
|
legDone:"DONE (proven)", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
|
|
182
|
+
legUnk:"UNKNOWN (no curated state)", stUnk:"?",
|
|
175
183
|
legHint:"— animated arrow = active flow · dashed = to build",
|
|
176
184
|
crumbRoot:"Context", crumbLevel:"C4", lvlContext:"CONTEXT",
|
|
177
185
|
lvlNames:{context:"Context",container:"Container",component:"Components",leaf:"Leaves"},
|
|
178
186
|
detClose:"[x] close", detFunc:"What it does", detNow:"Current state (facts)",
|
|
179
187
|
detTgt:"Target state (project)", detSrc:"Verification source", detIssue:"Issue",
|
|
180
188
|
detTarget:"TARGET",
|
|
181
|
-
rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match",
|
|
189
|
+
rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match", aggProgress:"progress",
|
|
182
190
|
stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology", staticSrc:"(static source)",
|
|
183
191
|
errEmpty:"Model unreachable or empty.",
|
|
184
192
|
errNotFound:"Model not found (./c4-model.json). Open via `forma serve` or inject window.__C4_MODEL__.",
|
|
@@ -191,13 +199,14 @@ var STRINGS={
|
|
|
191
199
|
resetLayout:"RESET LAYOUT", resetLayoutTitle:"ridisponi",
|
|
192
200
|
printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", exportLayout:"Esporta layout JSON", print:"Stampa",
|
|
193
201
|
legDone:"FATTO (provato)", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
|
|
202
|
+
legUnk:"IGNOTO (stato non curato)", stUnk:"?",
|
|
194
203
|
legHint:"— freccia animata = flusso attivo · tratteggio = da costruire",
|
|
195
204
|
crumbRoot:"Contesto", crumbLevel:"C4", lvlContext:"CONTESTO",
|
|
196
205
|
lvlNames:{context:"Contesto",container:"Container",component:"Componenti",leaf:"Foglie"},
|
|
197
206
|
detClose:"[x] chiudi", detFunc:"Cosa fa", detNow:"Stato attuale (fatti)",
|
|
198
207
|
detTgt:"Stato finito (progetto)", detSrc:"Fonte verifica", detIssue:"Issue",
|
|
199
208
|
detTarget:"TARGET",
|
|
200
|
-
rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato",
|
|
209
|
+
rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato", aggProgress:"avanzamento",
|
|
201
210
|
stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology", staticSrc:"(fonte statica)",
|
|
202
211
|
errEmpty:"Modello non raggiungibile o vuoto.",
|
|
203
212
|
errNotFound:"Modello non trovato (./c4-model.json). Apri via `forma serve` o inietta window.__C4_MODEL__.",
|
|
@@ -215,8 +224,9 @@ var CATALOG_THRESHOLD=24; // group of same-category siblings above this collapse
|
|
|
215
224
|
var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed labels would knot the view
|
|
216
225
|
var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
|
|
217
226
|
var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
|
|
218
|
-
var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob"};
|
|
219
|
-
var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false
|
|
227
|
+
var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob",unknown:"unk"};
|
|
228
|
+
var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false
|
|
229
|
+
var VBOX={}; // last fitted viewBox per level — frozen while dragging so the canvas does not rescale
|
|
220
230
|
function $(i){return document.getElementById(i)}
|
|
221
231
|
function esc(s){return String(s==null?"":s).replace(/&/g,"&").replace(/</g,"<").replace(/"/g,""")}
|
|
222
232
|
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;}
|
|
@@ -253,9 +263,9 @@ function collapseCatalogs(pid,kids){
|
|
|
253
263
|
}
|
|
254
264
|
function makeCatalogNode(pid,g){
|
|
255
265
|
// Aggregate status: worst-of-a-few heuristic — problem > next > in-progress > planned > done.
|
|
256
|
-
var rank={problem:5,next:4,"in-progress":3,planned:2,done:1},best=0,bestKey="
|
|
266
|
+
var rank={problem:5,next:4,"in-progress":3,planned:2,unknown:1.5,done:1},best=0,bestKey="unknown",done=0,tot=g.members.length;
|
|
257
267
|
for(var i=0;i<g.members.length;i++){
|
|
258
|
-
var s2=g.members[i].status2||(g.members[i].status==="planned"?"planned":"
|
|
268
|
+
var s2=g.members[i].status2||(g.members[i].status==="planned"?"planned":"unknown");
|
|
259
269
|
if((rank[s2]||0)>best){best=rank[s2]||0;bestKey=s2;}
|
|
260
270
|
if(s2==="done")done++;
|
|
261
271
|
}
|
|
@@ -324,28 +334,53 @@ function draw(animate){
|
|
|
324
334
|
function mapId(id){return memberMap[id]||id;}
|
|
325
335
|
var edges=(M.edges||[]).map(function(e){return {from:mapId(e.from),to:mapId(e.to),label:e.label,estatus:e.estatus};})
|
|
326
336
|
.filter(function(e){return idset[e.from]&&idset[e.to]&&e.from!==e.to;});
|
|
327
|
-
var s='<svg viewBox="0 0 '+lay.W+' '+lay.H+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
|
|
328
|
-
+'<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>'
|
|
329
|
-
+'<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>';
|
|
330
337
|
// fixed arrow labels: on by default while the level stays readable, else hover-only (the tooltip
|
|
331
338
|
// is always there). A level with 40 arrows would be unreadable with every label painted at once.
|
|
332
339
|
var showLbl=(LABELS===null)?(edges.length<=LABEL_AUTO_MAX):LABELS;
|
|
333
340
|
var blbl=$("blbl");if(blbl)blbl.className=showLbl?"on":"";
|
|
334
|
-
|
|
341
|
+
// geometry pre-pass: edge paths first, so the viewBox can shrink-wrap nodes AND label anchors
|
|
342
|
+
var geo=[],i;
|
|
343
|
+
for(i=0;i<edges.length;i++){
|
|
335
344
|
var a=pos[edges[i].from],b=pos[edges[i].to];if(!a||!b)continue;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
345
|
+
geo.push({e:edges[i],i:i,g:edgePath(a,b)});
|
|
346
|
+
}
|
|
347
|
+
// Fit-to-content viewBox: a sparse level renders zoomed and dense instead of lost in a fixed
|
|
348
|
+
// 1020px canvas — the hand-made status boards read well precisely because they waste no space.
|
|
349
|
+
// Minimums stop a single box from becoming comically large; extra room is split evenly so the
|
|
350
|
+
// content stays centred inside the enforced window.
|
|
351
|
+
var bx1=Infinity,by1=Infinity,bx2=-Infinity,by2=-Infinity,j,pp;
|
|
352
|
+
for(j=0;j<kids.length;j++){pp=pos[kids[j].id];if(!pp)continue;
|
|
353
|
+
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;}
|
|
354
|
+
for(j=0;j<geo.length;j++){var gm=geo[j].g;
|
|
355
|
+
if(gm.mx-14<bx1)bx1=gm.mx-14;if(gm.my-18<by1)by1=gm.my-18;
|
|
356
|
+
if(gm.mx+14>bx2)bx2=gm.mx+14;if(gm.my+6>by2)by2=gm.my+6;}
|
|
357
|
+
if(bx2<bx1){bx1=0;by1=0;bx2=lay.W;by2=lay.H;}
|
|
358
|
+
var VPAD=56,MINW=880,MINH=460;
|
|
359
|
+
var cw=bx2-bx1+2*VPAD,ch=by2-by1+2*VPAD;
|
|
360
|
+
var vw=Math.max(MINW,cw),vh=Math.max(MINH,ch);
|
|
361
|
+
var vx=bx1-VPAD-(vw-cw)/2,vy=by1-VPAD-(vh-ch)/2;
|
|
362
|
+
// …but hold it still while a box is being dragged: draw() runs on every mousemove, so a live
|
|
363
|
+
// refit would rescale the whole canvas under the pointer. The drop refits (see mouseup).
|
|
364
|
+
var vbk=keyOf();
|
|
365
|
+
if(drag&&VBOX[vbk]){vx=VBOX[vbk][0];vy=VBOX[vbk][1];vw=VBOX[vbk][2];vh=VBOX[vbk][3];}
|
|
366
|
+
else VBOX[vbk]=[vx,vy,vw,vh];
|
|
367
|
+
var s='<svg viewBox="'+vx.toFixed(1)+' '+vy.toFixed(1)+' '+vw.toFixed(1)+' '+vh.toFixed(1)+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
|
|
368
|
+
+'<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>'
|
|
369
|
+
+'<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>';
|
|
370
|
+
for(j=0;j<geo.length;j++){
|
|
371
|
+
var e=geo[j].g,ed=geo[j].e,cl=(ed.estatus==="to-build"?"plan":"flow");
|
|
372
|
+
s+='<path class="edge '+cl+'" data-e="'+geo[j].i+'" d="'+e.d+'"/>';
|
|
373
|
+
s+='<path class="edgehit" data-e="'+geo[j].i+'" data-label="'+esc(ed.label||"")+'" d="'+e.d+'"/>';
|
|
374
|
+
if(showLbl&&ed.label){var lb=String(ed.label);if(lb.length>28)lb=lb.slice(0,27)+"…";
|
|
340
375
|
s+='<text class="elbl" x="'+e.mx+'" y="'+(e.my-4)+'" text-anchor="middle">'+esc(lb)+'</text>';}
|
|
341
376
|
}
|
|
342
377
|
for(var j=0;j<kids.length;j++){
|
|
343
378
|
var n=kids[j],p=pos[n.id];
|
|
344
|
-
var st=STMAP[n.status2]||(n.status==="planned"?"plan":"
|
|
379
|
+
var st=STMAP[n.status2]||(n.status==="planned"?"plan":"unk");
|
|
345
380
|
var pulse=(st==="next"||st==="prog")&&mode!=="target"&&!n.__catalog;
|
|
346
381
|
var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
|
|
347
|
-
var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||
|
|
348
|
-
var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":
|
|
382
|
+
var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||""));
|
|
383
|
+
var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":STR.stUnk)));
|
|
349
384
|
// the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
|
|
350
385
|
// instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
|
|
351
386
|
var tmax=Math.floor((p.w-34-String(badge).length*5.4)/6.7),tt=n.name;
|
|
@@ -358,7 +393,7 @@ function draw(animate){
|
|
|
358
393
|
// Description lines: how many fit comes from the geometry (text starts at y+51, 12.5px apart,
|
|
359
394
|
// and must clear the footer control at h-25), so a curated layout hint with short boxes cannot
|
|
360
395
|
// print through [+] DRILL. wrapDesc is pure and clamps every line to the box width.
|
|
361
|
-
var floorY=p.h-((dr||isCat)?25:6),MAXL=Math.min(
|
|
396
|
+
var floorY=p.h-((dr||isCat)?25:6),MAXL=Math.min(4,Math.floor((floorY-51)/12.5)+1);
|
|
362
397
|
var dlines=wrapDesc(desc,Math.floor((p.w-26)/5.4),MAXL);
|
|
363
398
|
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>';
|
|
364
399
|
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"/>'
|
|
@@ -379,6 +414,21 @@ function draw(animate){
|
|
|
379
414
|
var lvlIdx=((M.levels||[]).indexOf(lvlKey));
|
|
380
415
|
var cb=(lvlIdx>-1?STR.crumbLevel+"-L"+(lvlIdx+1):STR.crumbLevel)+" · "+lvl.toUpperCase()+" <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
|
|
381
416
|
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>");
|
|
417
|
+
// Per-level programme tally — the status-board essence in one glance: the status mix of THIS
|
|
418
|
+
// view plus mean completion. Counts use the real children (a collapsed catalogue still counts
|
|
419
|
+
// its members), so the tally never changes when the catalogue threshold does.
|
|
420
|
+
var cnt={},tot=0,sum=0,nc=0,st3;
|
|
421
|
+
for(j=0;j<realKids.length;j++){
|
|
422
|
+
st3=STMAP[realKids[j].status2]||(realKids[j].status==="planned"?"plan":"unk");
|
|
423
|
+
cnt[st3]=(cnt[st3]||0)+1;tot++;
|
|
424
|
+
if(realKids[j].completion!=null){sum+=(+realKids[j].completion||0);nc++;}
|
|
425
|
+
}
|
|
426
|
+
if(tot){
|
|
427
|
+
var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d",unk:"#7d8f99"},ord=["done","prog","next","plan","prob","unk"],agg="";
|
|
428
|
+
for(j=0;j<ord.length;j++)if(cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+cnt[ord[j]];
|
|
429
|
+
if(nc)agg+=' '+esc(STR.aggProgress)+' <b>'+Math.round(sum/nc)+'%</b>';
|
|
430
|
+
cb='<span class="agg">'+agg+'</span>'+cb;
|
|
431
|
+
}
|
|
382
432
|
$("crumb").innerHTML=cb;
|
|
383
433
|
$("back").disabled=stack.length<2;
|
|
384
434
|
$("detail").style.display="none";
|
|
@@ -399,9 +449,15 @@ function wrapDesc(desc,cpl,max){
|
|
|
399
449
|
}
|
|
400
450
|
// Returns the path plus the point ON the curve at t=0.5 — the label anchor. (The quadratic's
|
|
401
451
|
// control point sits at twice the bow, so anchoring a label there would float it off the arrow.)
|
|
452
|
+
// Endpoints are trimmed at the true RECTANGLE border (not a radial guess): with wide boxes the
|
|
453
|
+
// radial trim landed inside the box on shallow angles, dragging the label anchor under the node.
|
|
402
454
|
function edgePath(a,b){
|
|
403
455
|
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;
|
|
404
|
-
var
|
|
456
|
+
var ux=dx/L,uy=dy/L;
|
|
457
|
+
var ta=Math.min(ux?(a.w/2)/Math.abs(ux):Infinity,uy?(a.h/2)/Math.abs(uy):Infinity)+8;
|
|
458
|
+
var tb=Math.min(ux?(b.w/2)/Math.abs(ux):Infinity,uy?(b.h/2)/Math.abs(uy):Infinity)+11;
|
|
459
|
+
if(ta+tb>L-4){ta=(a.w/2+10);tb=(b.w/2+12);} // boxes touch/overlap: fall back to the radial trim
|
|
460
|
+
var x1=ax+ta*ux,y1=ay+ta*uy,x2=bx-tb*ux,y2=by-tb*uy;
|
|
405
461
|
var cx=(x1+x2)/2+(y2-y1)*.12,cy=(y1+y2)/2-(x2-x1)*.12;
|
|
406
462
|
return {d:"M"+x1+","+y1+" Q"+cx+","+cy+" "+x2+","+y2, mx:(x1+2*cx+x2)/4, my:(y1+2*cy+y2)/4};
|
|
407
463
|
}
|
|
@@ -410,7 +466,7 @@ function cssv(v){try{return getComputedStyle(document.documentElement).getProper
|
|
|
410
466
|
|
|
411
467
|
function showDetail(n){
|
|
412
468
|
if(n&&n.__catalog)return showRoster(n);
|
|
413
|
-
var src=srcOf(n),cur=n.current||n.func||
|
|
469
|
+
var src=srcOf(n),cur=n.current||n.func||"",showSrc=src&&String(cur).indexOf(src)<0;
|
|
414
470
|
$("detail").innerHTML='<span class="cl" id="dc">'+esc(STR.detClose)+'</span><h3>'+esc(n.name)+' <span class="cat">· '+esc(n.category||n.kind||"")+(n.tech?" · "+esc(n.tech):"")+'</span></h3>'
|
|
415
471
|
+(n.func?'<div class="row"><span class="lbl">'+esc(STR.detFunc)+'</span><span class="func">'+esc(n.func)+'</span></div>':'')
|
|
416
472
|
+(cur&&cur!==n.func?'<div class="row"><span class="lbl">'+esc(STR.detNow)+'</span><span class="now">'+esc(cur)+'</span></div>':'')
|
|
@@ -431,7 +487,7 @@ function showRoster(cat){
|
|
|
431
487
|
$("detail").innerHTML=head;$("detail").style.display="block";
|
|
432
488
|
var dc=$("dc");if(dc)dc.addEventListener("click",function(){$("detail").style.display="none";});
|
|
433
489
|
function rowHtml(m){
|
|
434
|
-
var d=m.current||m.func||
|
|
490
|
+
var d=m.current||m.func||"";
|
|
435
491
|
var pct=m.completion!=null?(m.completion+"%"):(m.statusWord||"");
|
|
436
492
|
return '<span class="ritem">'+(pct?'<span class="rp">'+esc(pct)+'</span>':'')
|
|
437
493
|
+'<span class="rn">'+esc(m.name)+'</span>'
|
|
@@ -471,7 +527,9 @@ function wire(){
|
|
|
471
527
|
pos[drag.id].y=clamp(p.y-drag.oy,4,lay.H-pos[drag.id].h-4);
|
|
472
528
|
drag.moved=true;draw(false);
|
|
473
529
|
});
|
|
474
|
-
document.addEventListener("mouseup",function(){if(drag)
|
|
530
|
+
document.addEventListener("mouseup",function(){if(!drag)return;var m=drag.moved;drag=null;
|
|
531
|
+
// refit after the drop, deferred so the click that follows mouseup still lands on the live DOM
|
|
532
|
+
if(m){justDragged=true;setTimeout(function(){draw(false);},0);}});
|
|
475
533
|
// edge label on hover (invisible fat hit-path)
|
|
476
534
|
stage.addEventListener("mouseover",function(ev){
|
|
477
535
|
if(ev.target.classList&&ev.target.classList.contains("edgehit")){
|
|
@@ -534,7 +592,7 @@ function applyStrings(){
|
|
|
534
592
|
set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);
|
|
535
593
|
set("pexpLayout",STR.exportLayout);set("pexpPrint",STR.print);
|
|
536
594
|
set("lg-done",STR.legDone);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
|
|
537
|
-
set("lg-plan",STR.legPlan);set("lg-prob",STR.legProb);set("lg-hint",STR.legHint);
|
|
595
|
+
set("lg-plan",STR.legPlan);set("lg-prob",STR.legProb);set("lg-unk",STR.legUnk);set("lg-hint",STR.legHint);
|
|
538
596
|
}
|
|
539
597
|
// ---- FEATURE #2: PRINT / EXPORT (all client-side, offline) ----
|
|
540
598
|
function exportBaseName(){
|
|
@@ -551,7 +609,7 @@ function collectSvgCss(){
|
|
|
551
609
|
if(!rules)continue;
|
|
552
610
|
for(j=0;j<rules.length;j++){var r=rules[j],t=r.cssText||"";
|
|
553
611
|
// keep rules that target svg-internal classes/elements + the color vars on :root/html
|
|
554
|
-
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)
|
|
612
|
+
if(/\.(nd|edge|edgehit|elbl|s-done|s-prog|s-next|s-plan|s-prob|s-unk|mode-t|tt|kk|dd|pp|plus|cat)\b/.test(t)
|
|
555
613
|
||/(^|[\s,])svg\b/.test(t)||/:root|html\[data-skin/.test(t)){
|
|
556
614
|
// drop animations for a static export (keepdefs stable)
|
|
557
615
|
out.push(t);
|
|
@@ -564,7 +622,7 @@ function collectSvgCss(){
|
|
|
564
622
|
// with the on-screen palette. Returns a "--k:v;--k:v" inline-style string.
|
|
565
623
|
var SVG_VARS=["--bg","--grid","--halo","--cyan","--sec","--ter","--ter2","--nm",
|
|
566
624
|
"--stagebg","--stageborder","--nodebg","--detailbg","--border","--done","--prog",
|
|
567
|
-
"--next","--plan","--planTxt","--prob","--edge","--edgeplan","--elbl","--ddtxt",
|
|
625
|
+
"--next","--plan","--planTxt","--prob","--unk","--edge","--edgeplan","--elbl","--ddtxt",
|
|
568
626
|
"--now","--tgt","--src"];
|
|
569
627
|
function resolvedVars(){
|
|
570
628
|
var cs;try{cs=getComputedStyle(document.documentElement);}catch(e){return "";}
|
|
@@ -577,14 +635,14 @@ function serializeSvg(){
|
|
|
577
635
|
clone.setAttribute("xmlns","http://www.w3.org/2000/svg");
|
|
578
636
|
clone.setAttribute("xmlns:xlink","http://www.w3.org/1999/xlink");
|
|
579
637
|
var vb=(svg.getAttribute("viewBox")||"0 0 1080 660").split(/\s+/);
|
|
580
|
-
var w=+vb[2]||1080,h=+vb[3]||660;
|
|
638
|
+
var vbx=+vb[0]||0,vby=+vb[1]||0,w=+vb[2]||1080,h=+vb[3]||660;
|
|
581
639
|
clone.setAttribute("width",w);clone.setAttribute("height",h);
|
|
582
640
|
// Inline the resolved variables on the root so var(--x) resolves standalone,
|
|
583
641
|
// independent of the skin-selector chain that won't exist in the exported file.
|
|
584
642
|
var vars=resolvedVars();if(vars)clone.setAttribute("style",vars);
|
|
585
643
|
// solid dark/light background so the standalone file isn't transparent
|
|
586
644
|
var bg=cssv("--stagebg")||"#050d14";
|
|
587
|
-
var rect='<rect x="
|
|
645
|
+
var rect='<rect x="'+vbx+'" y="'+vby+'" width="'+w+'" height="'+h+'" fill="'+bg+'"/>';
|
|
588
646
|
var style="<style>"+collectSvgCss()+"</style>";
|
|
589
647
|
var inner=clone.innerHTML;
|
|
590
648
|
var attrs='';for(var a=0;a<clone.attributes.length;a++){var at=clone.attributes[a];attrs+=' '+at.name+'="'+String(at.value).replace(/"/g,""")+'"';}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forma-arch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|