forma-arch 0.5.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
@@ -52,10 +52,15 @@ npx forma-arch <command> # or: npm i -D forma-arch
52
52
  | `--enricher` | Use it when | Network |
53
53
  |---|---|---|
54
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 |
55
+ | `anthropic` | Headless / CI, with `ANTHROPIC_API_KEY`. | REST |
56
56
  | `openai` | Same, with `OPENAI_API_KEY`. | REST |
57
57
  | `ollama` | Sensitive repos: a local model, nothing leaves the machine. | localhost |
58
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
+
59
64
  ## How it fits together
60
65
 
61
66
  ```
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}
@@ -60,6 +60,8 @@ svg{width:100%;height:100%;display:block}
60
60
  .s-plan .tt,.s-plan .pp{fill:var(--planTxt)}
61
61
  .s-prob rect{stroke:var(--prob);filter:drop-shadow(0 0 8px rgba(255,93,93,.6))}
62
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)}
63
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}
64
66
  .mode-t .nd .tt,.mode-t .nd .pp{fill:var(--next)!important}
65
67
  .edge{stroke:var(--edge);stroke-width:1.3;fill:none;marker-end:url(#ar);cursor:help}
@@ -119,7 +121,7 @@ svg{width:100%;height:100%;display:block}
119
121
  html[data-skin="blueprint"]{--bg:#eef3f9;--grid:rgba(31,119,180,.10);--halo:rgba(31,119,180,.08);
120
122
  --cyan:#1f77b4;--sec:#5a6b7d;--ter:#6b7b8c;--ter2:#5a6b7d;--nm:#16273a;
121
123
  --stagebg:#ffffff;--stageborder:rgba(31,119,180,.25);--nodebg:#ffffff;--detailbg:#ffffff;--border:rgba(31,119,180,.35);
122
- --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;
123
125
  --edge:#8aa0b4;--edgeplan:#b9c6d3;--elbl:#5a6b7d;--ddtxt:#40536a;--now:#16273a;--tgt:#1f5f8a;--src:#1f9d57}
124
126
  html[data-skin="blueprint"] h1{text-shadow:none}
125
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}
@@ -160,6 +162,7 @@ html[data-skin="blueprint"] button.on{text-shadow:none}
160
162
  <span><i style="background:#3ee7ff"></i><span id="lg-next"></span></span>
161
163
  <span><i style="background:#2d5a72"></i><span id="lg-plan"></span></span>
162
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>
163
166
  <span class="hint" id="lg-hint"></span>
164
167
  </div>
165
168
  <div id="detail"></div>
@@ -176,6 +179,7 @@ var STRINGS={
176
179
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"re-arrange",
177
180
  printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", exportLayout:"Export layout JSON", print:"Print",
178
181
  legDone:"DONE (proven)", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
182
+ legUnk:"UNKNOWN (no curated state)", stUnk:"?",
179
183
  legHint:"— animated arrow = active flow · dashed = to build",
180
184
  crumbRoot:"Context", crumbLevel:"C4", lvlContext:"CONTEXT",
181
185
  lvlNames:{context:"Context",container:"Container",component:"Components",leaf:"Leaves"},
@@ -195,6 +199,7 @@ var STRINGS={
195
199
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"ridisponi",
196
200
  printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", exportLayout:"Esporta layout JSON", print:"Stampa",
197
201
  legDone:"FATTO (provato)", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
202
+ legUnk:"IGNOTO (stato non curato)", stUnk:"?",
198
203
  legHint:"— freccia animata = flusso attivo · tratteggio = da costruire",
199
204
  crumbRoot:"Contesto", crumbLevel:"C4", lvlContext:"CONTESTO",
200
205
  lvlNames:{context:"Contesto",container:"Container",component:"Componenti",leaf:"Foglie"},
@@ -219,7 +224,7 @@ var CATALOG_THRESHOLD=24; // group of same-category siblings above this collapse
219
224
  var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed labels would knot the view
220
225
  var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
221
226
  var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
222
- var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob"};
227
+ var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob",unknown:"unk"};
223
228
  var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false
224
229
  var VBOX={}; // last fitted viewBox per level — frozen while dragging so the canvas does not rescale
225
230
  function $(i){return document.getElementById(i)}
@@ -258,9 +263,9 @@ function collapseCatalogs(pid,kids){
258
263
  }
259
264
  function makeCatalogNode(pid,g){
260
265
  // Aggregate status: worst-of-a-few heuristic — problem > next > in-progress > planned > done.
261
- 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;
262
267
  for(var i=0;i<g.members.length;i++){
263
- 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");
264
269
  if((rank[s2]||0)>best){best=rank[s2]||0;bestKey=s2;}
265
270
  if(s2==="done")done++;
266
271
  }
@@ -371,11 +376,11 @@ function draw(animate){
371
376
  }
372
377
  for(var j=0;j<kids.length;j++){
373
378
  var n=kids[j],p=pos[n.id];
374
- var st=STMAP[n.status2]||(n.status==="planned"?"plan":"done");
379
+ var st=STMAP[n.status2]||(n.status==="planned"?"plan":"unk");
375
380
  var pulse=(st==="next"||st==="prog")&&mode!=="target"&&!n.__catalog;
376
381
  var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
377
- var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||n.tech||""));
378
- 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)));
379
384
  // the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
380
385
  // instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
381
386
  var tmax=Math.floor((p.w-34-String(badge).length*5.4)/6.7),tt=n.name;
@@ -414,12 +419,12 @@ function draw(animate){
414
419
  // its members), so the tally never changes when the catalogue threshold does.
415
420
  var cnt={},tot=0,sum=0,nc=0,st3;
416
421
  for(j=0;j<realKids.length;j++){
417
- st3=STMAP[realKids[j].status2]||(realKids[j].status==="planned"?"plan":"done");
422
+ st3=STMAP[realKids[j].status2]||(realKids[j].status==="planned"?"plan":"unk");
418
423
  cnt[st3]=(cnt[st3]||0)+1;tot++;
419
424
  if(realKids[j].completion!=null){sum+=(+realKids[j].completion||0);nc++;}
420
425
  }
421
426
  if(tot){
422
- var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d"},ord=["done","prog","next","plan","prob"],agg="";
427
+ var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d",unk:"#7d8f99"},ord=["done","prog","next","plan","prob","unk"],agg="";
423
428
  for(j=0;j<ord.length;j++)if(cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+cnt[ord[j]];
424
429
  if(nc)agg+=' &nbsp;'+esc(STR.aggProgress)+' <b>'+Math.round(sum/nc)+'%</b>';
425
430
  cb='<span class="agg">'+agg+'</span>'+cb;
@@ -461,7 +466,7 @@ function cssv(v){try{return getComputedStyle(document.documentElement).getProper
461
466
 
462
467
  function showDetail(n){
463
468
  if(n&&n.__catalog)return showRoster(n);
464
- 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;
465
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>'
466
471
  +(n.func?'<div class="row"><span class="lbl">'+esc(STR.detFunc)+'</span><span class="func">'+esc(n.func)+'</span></div>':'')
467
472
  +(cur&&cur!==n.func?'<div class="row"><span class="lbl">'+esc(STR.detNow)+'</span><span class="now">'+esc(cur)+'</span></div>':'')
@@ -482,7 +487,7 @@ function showRoster(cat){
482
487
  $("detail").innerHTML=head;$("detail").style.display="block";
483
488
  var dc=$("dc");if(dc)dc.addEventListener("click",function(){$("detail").style.display="none";});
484
489
  function rowHtml(m){
485
- var d=m.current||m.func||m.tech||"";
490
+ var d=m.current||m.func||"";
486
491
  var pct=m.completion!=null?(m.completion+"%"):(m.statusWord||"");
487
492
  return '<span class="ritem">'+(pct?'<span class="rp">'+esc(pct)+'</span>':'')
488
493
  +'<span class="rn">'+esc(m.name)+'</span>'
@@ -587,7 +592,7 @@ function applyStrings(){
587
592
  set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);
588
593
  set("pexpLayout",STR.exportLayout);set("pexpPrint",STR.print);
589
594
  set("lg-done",STR.legDone);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
590
- 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);
591
596
  }
592
597
  // ---- FEATURE #2: PRINT / EXPORT (all client-side, offline) ----
593
598
  function exportBaseName(){
@@ -604,7 +609,7 @@ function collectSvgCss(){
604
609
  if(!rules)continue;
605
610
  for(j=0;j<rules.length;j++){var r=rules[j],t=r.cssText||"";
606
611
  // keep rules that target svg-internal classes/elements + the color vars on :root/html
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)
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)
608
613
  ||/(^|[\s,])svg\b/.test(t)||/:root|html\[data-skin/.test(t)){
609
614
  // drop animations for a static export (keepdefs stable)
610
615
  out.push(t);
@@ -617,7 +622,7 @@ function collectSvgCss(){
617
622
  // with the on-screen palette. Returns a "--k:v;--k:v" inline-style string.
618
623
  var SVG_VARS=["--bg","--grid","--halo","--cyan","--sec","--ter","--ter2","--nm",
619
624
  "--stagebg","--stageborder","--nodebg","--detailbg","--border","--done","--prog",
620
- "--next","--plan","--planTxt","--prob","--edge","--edgeplan","--elbl","--ddtxt",
625
+ "--next","--plan","--planTxt","--prob","--unk","--edge","--edgeplan","--elbl","--ddtxt",
621
626
  "--now","--tgt","--src"];
622
627
  function resolvedVars(){
623
628
  var cs;try{cs=getComputedStyle(document.documentElement);}catch(e){return "";}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.5.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",