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 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` (default) | Headless / CI, with `ANTHROPIC_API_KEY`. | REST |
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 `foo_*` prefix. Deterministic: sorted group keys.
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.indexOf('_')
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
- if (node.kind === 'component') return { func: `Groups related files under ${node.name}.`, descSource: 'fallback' }
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 ? `Sibling modules: ${siblings.slice(0, 40).join(', ')}.` : '',
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.3.0'
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', 'anthropic')
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, 5-status, completion, current/target) where absent
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
- if (!n.status2) n.status2 = n.status === 'planned' ? 'planned' : 'done'
122
- if (n.completion == null) n.completion = n.status === 'planned' ? 0 : 100
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
- for (const nm of exposes.get(to)) { if (new RegExp('\\b' + nm.replace(/[^\w]/g, '') + '\\b').test(t)) c++ }
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,"&amp;").replace(/</g,"&lt;").replace(/"/g,"&quot;")}
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="done",done=0,tot=g.members.length;
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":"done");
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
- for(var i=0;i<edges.length;i++){
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
- var e=edgePath(a,b),cl=(edges[i].estatus==="to-build"?"plan":"flow");
337
- s+='<path class="edge '+cl+'" data-e="'+i+'" d="'+e.d+'"/>';
338
- s+='<path class="edgehit" data-e="'+i+'" data-label="'+esc(edges[i].label||"")+'" d="'+e.d+'"/>';
339
- if(showLbl&&edges[i].label){var lb=String(edges[i].label);if(lb.length>28)lb=lb.slice(0,27)+"…";
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":"done");
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||n.tech||""));
348
- var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":(n.tech||""))));
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(3,Math.floor((floorY-51)/12.5)+1);
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()+" &nbsp;&nbsp; <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
381
416
  for(var w=0;w<chain.length;w++)cb+=" &rsaquo; "+(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+=' &nbsp;'+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 x1=ax+(a.w/2+10)*dx/L,y1=ay+(a.h/2+10)*dy/L,x2=bx-(b.w/2+12)*dx/L,y2=by-(b.h/2+12)*dy/L;
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||n.tech||"",showSrc=src&&String(cur).indexOf(src)<0;
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||m.tech||"";
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){if(drag.moved)justDragged=true;drag=null;}});
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="0" y="0" width="'+w+'" height="'+h+'" fill="'+bg+'"/>';
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,"&quot;")+'"';}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.4.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",