forma-arch 0.2.0 → 0.4.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.
@@ -64,6 +64,7 @@ svg{width:100%;height:100%;display:block}
64
64
  .edge.plan{stroke:var(--edgeplan);stroke-dasharray:3 6;opacity:.6;marker-end:url(#arp)}
65
65
  .edge.hot{stroke:var(--cyan);stroke-width:2.6;opacity:1}
66
66
  .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}
67
68
  @keyframes dash{to{stroke-dashoffset:-24}}
68
69
  .nd.pulse rect{animation:pul 2.4s ease-in-out infinite}
69
70
  @keyframes pul{0%,100%{filter:drop-shadow(0 0 8px rgba(62,231,255,.5))}50%{filter:drop-shadow(0 0 18px rgba(62,231,255,.95))}}
@@ -132,12 +133,14 @@ html[data-skin="blueprint"] button.on{text-shadow:none}
132
133
  <button id="bnow" class="on" type="button"></button>
133
134
  <button id="btgt" type="button"></button>
134
135
  <button id="brf" type="button"></button>
136
+ <button id="blbl" type="button"></button>
135
137
  <button id="brl" type="button"></button>
136
138
  <span class="menuwrap">
137
139
  <button id="pexp" type="button" aria-haspopup="true" aria-expanded="false"></button>
138
140
  <span id="pmenu" role="menu">
139
141
  <button id="pexpSvg" type="button" role="menuitem"></button>
140
142
  <button id="pexpPng" type="button" role="menuitem"></button>
143
+ <button id="pexpLayout" type="button" role="menuitem"></button>
141
144
  <button id="pexpPrint" type="button" role="menuitem"></button>
142
145
  </span>
143
146
  </span>
@@ -165,9 +168,10 @@ var STRINGS={
165
168
  en:{
166
169
  subhelp:"click = explain (any level) · double-click or [+] = drill in · drag to move · hover an arrow = label · ESC/BACK",
167
170
  back:"← BACK", now:"CURRENT STATE", tgt:"TARGET STATE", reverify:"RE-VERIFY",
171
+ labels:"LABELS", labelsTitle:"arrow labels (auto-off above 14 arrows)",
168
172
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"re-arrange",
169
- printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", print:"Print",
170
- legDone:"DONE", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
173
+ printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", exportLayout:"Export layout JSON", print:"Print",
174
+ legDone:"DONE (proven)", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
171
175
  legHint:"— animated arrow = active flow · dashed = to build",
172
176
  crumbRoot:"Context", crumbLevel:"C4", lvlContext:"CONTEXT",
173
177
  lvlNames:{context:"Context",container:"Container",component:"Components",leaf:"Leaves"},
@@ -175,7 +179,7 @@ var STRINGS={
175
179
  detTgt:"Target state (project)", detSrc:"Verification source", detIssue:"Issue",
176
180
  detTarget:"TARGET",
177
181
  rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match",
178
- stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology",
182
+ stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology", staticSrc:"(static source)",
179
183
  errEmpty:"Model unreachable or empty.",
180
184
  errNotFound:"Model not found (./c4-model.json). Open via `forma serve` or inject window.__C4_MODEL__.",
181
185
  errUnavail:"Model unavailable."
@@ -183,9 +187,10 @@ var STRINGS={
183
187
  it:{
184
188
  subhelp:"click = spiega (ogni livello) · doppio-click o [+] = entra · trascina per spostare · hover su una freccia = etichetta · ESC/BACK",
185
189
  back:"← BACK", now:"STATO ATTUALE", tgt:"STATO FINITO", reverify:"RE-VERIFICA",
190
+ labels:"ETICHETTE", labelsTitle:"etichette sugli archi (spente sopra i 14 archi)",
186
191
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"ridisponi",
187
- printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", print:"Stampa",
188
- legDone:"FATTO", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
192
+ printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", exportLayout:"Esporta layout JSON", print:"Stampa",
193
+ legDone:"FATTO (provato)", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
189
194
  legHint:"— freccia animata = flusso attivo · tratteggio = da costruire",
190
195
  crumbRoot:"Contesto", crumbLevel:"C4", lvlContext:"CONTESTO",
191
196
  lvlNames:{context:"Contesto",container:"Container",component:"Componenti",leaf:"Foglie"},
@@ -193,7 +198,7 @@ var STRINGS={
193
198
  detTgt:"Stato finito (progetto)", detSrc:"Fonte verifica", detIssue:"Issue",
194
199
  detTarget:"TARGET",
195
200
  rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato",
196
- stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology",
201
+ stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology", staticSrc:"(fonte statica)",
197
202
  errEmpty:"Modello non raggiungibile o vuoto.",
198
203
  errNotFound:"Modello non trovato (./c4-model.json). Apri via `forma serve` o inietta window.__C4_MODEL__.",
199
204
  errUnavail:"Modello non disponibile."
@@ -207,13 +212,18 @@ function pickLang(model){
207
212
  return STRINGS[cand]?cand:"en";
208
213
  }
209
214
  var CATALOG_THRESHOLD=24; // group of same-category siblings above this collapses to one catalogue node
215
+ var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed labels would knot the view
216
+ var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
210
217
  var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
211
218
  var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob"};
212
- var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={};
219
+ var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false;
213
220
  function $(i){return document.getElementById(i)}
214
221
  function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/"/g,"&quot;")}
215
222
  function setSkin(s){var k=SKIN_IDS.indexOf(s)>-1?s:"holo";document.documentElement.dataset.skin=k;var el=$("skin");if(el)el.value=k;}
216
- function keyOf(){return stack.join(">")||"root";}
223
+ // Layout key = the id of the level's parent ("root" at the top). Stable and documentable: it is
224
+ // exactly the key used by meta.layout hints and by the exported layout JSON.
225
+ function keyOf(){return stack[stack.length-1]||"root";}
226
+ function hintsFor(k){return (M&&M.meta&&M.meta.layout&&M.meta.layout[k])||null;}
217
227
  function childrenOf(pid){var o=[];for(var i=0;i<M.nodes.length;i++){var n=M.nodes[i];if((pid==null&&!n.parent)||n.parent===pid)o.push(n);}return o;}
218
228
  // FEATURE #4: collapse large homogeneous catalogs.
219
229
  // Given real children, returns render items where any group of >CATALOG_THRESHOLD
@@ -260,8 +270,25 @@ function nameOf(id){var n=node(id);return n?n.name:id;}
260
270
  function srcOf(n){if(n.verify){if(typeof n.verify==="string")return n.verify;if(n.verify.source)return n.verify.source;if(n.verify.ref)return n.verify.ref;if(n.verify.issue)return "#"+n.verify.issue;}
261
271
  var ev=(n.evidence||[]).filter(function(e){return e.type==="doc"||e.type==="path";})[0];return ev?ev.ref:((M.meta&&M.meta.verifyMethod)||"");}
262
272
 
263
- function seedLayout(kids){
264
- var NW=228,NH=106,pos={},i,hub=null;
273
+ var NW=228,NH=118; // NH fits 3 description lines without hitting the [+] DRILL
274
+ // Curated coordinates from meta.layout win; everything else keeps the automatic arrangement,
275
+ // placed BELOW the pinned block so a partial hint set can never overlap generated nodes.
276
+ function seedLayout(kids,hints){
277
+ var pinned=[],free=[],i;
278
+ for(i=0;i<kids.length;i++)(hints&&hints[kids[i].id]?pinned:free).push(kids[i]);
279
+ if(!pinned.length)return autoLayout(kids);
280
+ var pos={},maxX=0,maxY=0,h;
281
+ for(i=0;i<pinned.length;i++){var q=hints[pinned[i].id];
282
+ h={x:+q.x||0,y:+q.y||0,w:+q.w||NW,h:+q.h||NH};pos[pinned[i].id]=h;
283
+ maxX=Math.max(maxX,h.x+h.w);maxY=Math.max(maxY,h.y+h.h);}
284
+ var W=Math.max(1020,maxX+64),H=Math.max(560,maxY+58);
285
+ if(free.length){var sub=autoLayout(free),oy=maxY+58;
286
+ for(i=0;i<free.length;i++){var p=sub.pos[free[i].id];pos[free[i].id]={x:p.x,y:oy+p.y,w:p.w,h:p.h};}
287
+ W=Math.max(W,sub.W);H=Math.max(H,oy+sub.H);}
288
+ return {W:W,H:H,pos:pos};
289
+ }
290
+ function autoLayout(kids){
291
+ var pos={},i,hub=null;
265
292
  for(i=0;i<kids.length;i++)if(kids[i].kind==="system"){hub=kids[i];break;}
266
293
  if(hub&&kids.length<=10){
267
294
  var W=1080,H=660,cx=W/2,cy=H/2;
@@ -280,9 +307,9 @@ function seedLayout(kids){
280
307
  pos[kids[i].id]={x:offx+c*(NW+gx),y:py+r*(NH+gy),w:NW,h:NH};}
281
308
  return {W:W,H:H,pos:pos};
282
309
  }
283
- function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids);
310
+ function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids,hintsFor(k));
284
311
  // make sure every current kid has a slot (model may have changed)
285
- var lay=POS[k];for(var i=0;i<kids.length;i++)if(!lay.pos[kids[i].id]){var s=seedLayout(kids);POS[k]=s;return s;}return lay;}
312
+ var lay=POS[k];for(var i=0;i<kids.length;i++)if(!lay.pos[kids[i].id]){var s=seedLayout(kids,hintsFor(k));POS[k]=s;return s;}return lay;}
286
313
 
287
314
  function draw(animate){
288
315
  if(!M)return;
@@ -300,11 +327,17 @@ function draw(animate){
300
327
  var s='<svg viewBox="0 0 '+lay.W+' '+lay.H+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
301
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>'
302
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
+ // fixed arrow labels: on by default while the level stays readable, else hover-only (the tooltip
331
+ // is always there). A level with 40 arrows would be unreadable with every label painted at once.
332
+ var showLbl=(LABELS===null)?(edges.length<=LABEL_AUTO_MAX):LABELS;
333
+ var blbl=$("blbl");if(blbl)blbl.className=showLbl?"on":"";
303
334
  for(var i=0;i<edges.length;i++){
304
335
  var a=pos[edges[i].from],b=pos[edges[i].to];if(!a||!b)continue;
305
- var d=edgePath(a,b),cl=(edges[i].estatus==="to-build"?"plan":"flow");
306
- s+='<path class="edge '+cl+'" data-e="'+i+'" d="'+d+'"/>';
307
- s+='<path class="edgehit" data-e="'+i+'" data-label="'+esc(edges[i].label||"")+'" d="'+d+'"/>';
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)+"…";
340
+ s+='<text class="elbl" x="'+e.mx+'" y="'+(e.my-4)+'" text-anchor="middle">'+esc(lb)+'</text>';}
308
341
  }
309
342
  for(var j=0;j<kids.length;j++){
310
343
  var n=kids[j],p=pos[n.id];
@@ -313,18 +346,21 @@ function draw(animate){
313
346
  var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
314
347
  var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||n.tech||""));
315
348
  var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":(n.tech||""))));
316
- var tmax=Math.floor((p.w-70)/6.7),tt=n.name;if(String(tt).length>tmax)tt=String(tt).slice(0,tmax-1)+"…";
349
+ // the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
350
+ // instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
351
+ var tmax=Math.floor((p.w-34-String(badge).length*5.4)/6.7),tt=n.name;
352
+ if(String(tt).length>tmax)tt=String(tt).slice(0,Math.max(1,tmax-1))+"…";
317
353
  s+='<g class="'+cls+'" data-id="'+esc(n.id)+'">'
318
354
  +'<rect x="'+p.x+'" y="'+p.y+'" width="'+p.w+'" height="'+p.h+'" rx="10"/>'
319
355
  +'<text class="kk" x="'+(p.x+13)+'" y="'+(p.y+16)+'">'+esc(n.category||n.kind||"")+'</text>'
320
356
  +'<text class="tt" x="'+(p.x+13)+'" y="'+(p.y+33)+'">'+esc(tt)+'</text>'
321
357
  +'<text class="pp" x="'+(p.x+p.w-13)+'" y="'+(p.y+16)+'" text-anchor="end">'+esc(badge)+'</text>';
322
- var words=String(desc).split(" "),line="",ln=0,cpl=Math.floor((p.w-26)/5.4),MAXL=2;
323
- for(var k=0;k<words.length&&ln<MAXL;k++){
324
- if((line+words[k]).length>cpl){s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+ln*12.5)+'">'+esc(line.trim())+'</text>';line=words[k]+" ";ln++;}
325
- else line+=words[k]+" ";}
326
- if(ln<MAXL&&line)s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+ln*12.5)+'">'+esc(line.length>cpl?line.slice(0,cpl-1)+"…":line.trim())+'</text>';
327
- else if(ln===MAXL&&line.trim())s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+ln*12.5)+'">…</text>';
358
+ // Description lines: how many fit comes from the geometry (text starts at y+51, 12.5px apart,
359
+ // and must clear the footer control at h-25), so a curated layout hint with short boxes cannot
360
+ // 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);
362
+ var dlines=wrapDesc(desc,Math.floor((p.w-26)/5.4),MAXL);
363
+ for(var q=0;q<dlines.length;q++)s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+q*12.5)+'">'+esc(dlines[q])+'</text>';
328
364
  if(dr)s+='<rect class="drillhit" data-drill="1" x="'+(p.x+p.w-96)+'" y="'+(p.y+p.h-25)+'" width="88" height="20" rx="5"/>'
329
365
  +'<text class="plus" x="'+(p.x+p.w-13)+'" y="'+(p.y+p.h-9)+'" text-anchor="end">[+] DRILL</text>';
330
366
  else if(isCat)s+='<text class="plus" x="'+(p.x+p.w-13)+'" y="'+(p.y+p.h-9)+'" text-anchor="end">[≡] '+esc(String(n.count))+'</text>';
@@ -337,18 +373,37 @@ function draw(animate){
337
373
  st2.classList.toggle("anim",animate!==false);
338
374
  // breadcrumb
339
375
  var chain=[],c=pid;while(c){var nn=node(c);chain.unshift(nn);c=nn?nn.parent:null;}
340
- var lvl=pid?levelName(kids[0]&&kids[0].level):STR.lvlContext;
341
- var cb=STR.crumbLevel+" · "+lvl.toUpperCase()+" &nbsp;&nbsp; <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
376
+ var lvlKey=pid?(kids[0]&&kids[0].level):"context";
377
+ var lvl=pid?levelName(lvlKey):STR.lvlContext;
378
+ // C4 notation: number the level by its position in the model's own `levels` list (L1 = context).
379
+ var lvlIdx=((M.levels||[]).indexOf(lvlKey));
380
+ var cb=(lvlIdx>-1?STR.crumbLevel+"-L"+(lvlIdx+1):STR.crumbLevel)+" · "+lvl.toUpperCase()+" &nbsp;&nbsp; <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
342
381
  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>");
343
382
  $("crumb").innerHTML=cb;
344
383
  $("back").disabled=stack.length<2;
345
384
  $("detail").style.display="none";
346
385
  }
386
+ // Greedy word wrap into at most `max` lines of `cpl` characters. Every returned line is clamped to
387
+ // cpl — an unbreakable token (a long class name, a URL) would otherwise paint outside the box, and
388
+ // there is no clip-path on the node. The last line carries the ellipsis when text was dropped,
389
+ // rather than spending a whole line on it (on a short curated box that was half the visible text).
390
+ function wrapDesc(desc,cpl,max){
391
+ if(max<1||cpl<2)return [];
392
+ var words=String(desc==null?"":desc).split(" "),all=[],line="",i;
393
+ for(i=0;i<words.length;i++){if(line&&(line+words[i]).length>cpl){all.push(line.trim());line="";}line+=words[i]+" ";}
394
+ if(line.trim())all.push(line.trim());
395
+ var out=all.slice(0,max),dropped=all.length>out.length;
396
+ if(dropped&&out.length)out[out.length-1]=out[out.length-1]+"…";
397
+ for(i=0;i<out.length;i++)if(out[i].length>cpl)out[i]=out[i].slice(0,cpl-1)+"…";
398
+ return out;
399
+ }
400
+ // Returns the path plus the point ON the curve at t=0.5 — the label anchor. (The quadratic's
401
+ // control point sits at twice the bow, so anchoring a label there would float it off the arrow.)
347
402
  function edgePath(a,b){
348
403
  var ax=a.x+a.w/2,ay=a.y+a.h/2,bx=b.x+b.w/2,by=b.y+b.h/2,dx=bx-ax,dy=by-ay,L=Math.sqrt(dx*dx+dy*dy)||1;
349
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;
350
- var mx=(x1+x2)/2+(y2-y1)*.12,my=(y1+y2)/2-(x2-x1)*.12;
351
- return "M"+x1+","+y1+" Q"+mx+","+my+" "+x2+","+y2;
405
+ var cx=(x1+x2)/2+(y2-y1)*.12,cy=(y1+y2)/2-(x2-x1)*.12;
406
+ return {d:"M"+x1+","+y1+" Q"+cx+","+cy+" "+x2+","+y2, mx:(x1+2*cx+x2)/4, my:(y1+2*cy+y2)/4};
352
407
  }
353
408
  function levelName(l){return (STR.lvlNames&&STR.lvlNames[l])||l||"—";}
354
409
  function cssv(v){try{return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||"#2b6e8a";}catch(e){return "#2b6e8a";}}
@@ -456,7 +511,15 @@ function wire(){
456
511
  document.addEventListener("keydown",function(e){if(e.key==="Escape"&&stack.length>1){stack.pop();draw(true);}});
457
512
  $("bnow").addEventListener("click",function(){mode="now";$("bnow").className="on";$("btgt").className="";draw(false);});
458
513
  $("btgt").addEventListener("click",function(){mode="target";$("btgt").className="on";$("bnow").className="";draw(false);});
459
- $("brf").addEventListener("click",function(){var m=(M&&M.meta)||{};$("stamp").textContent=STR.stampBase+" "+(m.verifiedAt||(M&&M.source&&M.source.commit)||"—")+" · "+STR.stampMethod+" "+(m.verifyMethod||STR.stampVerify);});
514
+ $("brf").addEventListener("click",function(){
515
+ // Served model ⇒ actually re-read it (a `forma verify` run in the terminal shows up here without
516
+ // losing the level, the layout or the mode). Injected/offline ⇒ re-stamp, and say why.
517
+ if(!FETCHED){stamp(STR.staticSrc);return;}
518
+ fetch("./c4-model.json?ts="+(new Date()).getTime()).then(function(r){return r.json();})
519
+ .then(function(m){if(m&&m.nodes&&m.nodes.length){M=m;stamp("");draw(false);}else stamp(STR.staticSrc);})
520
+ .catch(function(){stamp(STR.staticSrc);});
521
+ });
522
+ $("blbl").addEventListener("click",function(){LABELS=!($("blbl").className==="on");draw(false);});
460
523
  $("brl").addEventListener("click",function(){delete POS[keyOf()];draw(true);});
461
524
  $("skin").innerHTML=SKINS.map(function(s){return '<option value="'+s[0]+'">'+s[1]+'</option>';}).join("");
462
525
  $("skin").addEventListener("change",function(e){setSkin(e.target.value);});
@@ -467,7 +530,9 @@ function applyStrings(){
467
530
  function set(id,txt){var e=$(id);if(e)e.textContent=txt;}
468
531
  set("subhelp",STR.subhelp);set("back",STR.back);set("bnow",STR.now);set("btgt",STR.tgt);
469
532
  set("brf",STR.reverify);set("brl",STR.resetLayout);var brl=$("brl");if(brl)brl.title=STR.resetLayoutTitle;
470
- set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);set("pexpPrint",STR.print);
533
+ set("blbl",STR.labels);var bl=$("blbl");if(bl)bl.title=STR.labelsTitle;
534
+ set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);
535
+ set("pexpLayout",STR.exportLayout);set("pexpPrint",STR.print);
471
536
  set("lg-done",STR.legDone);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
472
537
  set("lg-plan",STR.legPlan);set("lg-prob",STR.legProb);set("lg-hint",STR.legHint);
473
538
  }
@@ -486,7 +551,7 @@ function collectSvgCss(){
486
551
  if(!rules)continue;
487
552
  for(j=0;j<rules.length;j++){var r=rules[j],t=r.cssText||"";
488
553
  // keep rules that target svg-internal classes/elements + the color vars on :root/html
489
- if(/\.(nd|edge|edgehit|s-done|s-prog|s-next|s-plan|s-prob|mode-t|tt|kk|dd|pp|plus|cat)\b/.test(t)
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)
490
555
  ||/(^|[\s,])svg\b/.test(t)||/:root|html\[data-skin/.test(t)){
491
556
  // drop animations for a static export (keepdefs stable)
492
557
  out.push(t);
@@ -531,6 +596,17 @@ function triggerDownload(blob,filename){
531
596
  a.href=url;a.download=filename;document.body.appendChild(a);a.click();
532
597
  document.body.removeChild(a);setTimeout(function(){URL.revokeObjectURL(url);},4000);
533
598
  }
599
+ // Every level you have arranged, in the exact shape meta.layout expects — drag the boxes, export,
600
+ // paste into the topology's "layout" and `gen` bakes it in. No storage: the repo is the memory.
601
+ function exportLayout(){
602
+ var out={},k,id,p;
603
+ for(k in POS){if(!POS.hasOwnProperty(k))continue;
604
+ var lvl={},any=false;
605
+ for(id in POS[k].pos){if(!POS[k].pos.hasOwnProperty(id))continue;
606
+ p=POS[k].pos[id];lvl[id]={x:Math.round(p.x),y:Math.round(p.y),w:Math.round(p.w),h:Math.round(p.h)};any=true;}
607
+ if(any)out[k]=lvl;}
608
+ triggerDownload(new Blob([JSON.stringify({layout:out},null,2)+"\n"],{type:"application/json"}),exportBaseName()+"-layout.json");
609
+ }
534
610
  function exportSvg(){
535
611
  var s=serializeSvg();if(!s)return;
536
612
  triggerDownload(new Blob([s.xml],{type:"image/svg+xml;charset=utf-8"}),exportBaseName()+".svg");
@@ -564,23 +640,34 @@ function wireExport(){
564
640
  document.addEventListener("keydown",function(e){if(e.key==="Escape")close();});
565
641
  var sv=$("pexpSvg");if(sv)sv.addEventListener("click",function(){close();exportSvg();});
566
642
  var pn=$("pexpPng");if(pn)pn.addEventListener("click",function(){close();exportPng();});
643
+ var lay=$("pexpLayout");if(lay)lay.addEventListener("click",function(){close();exportLayout();});
567
644
  var pr=$("pexpPrint");if(pr)pr.addEventListener("click",function(){close();window.print();});
568
645
  }
569
646
  function moveTip(ev){var st=$("stage").getBoundingClientRect(),t=$("etip");
570
647
  var x=ev.clientX-st.left+12,y=ev.clientY-st.top+12;
571
648
  t.style.left=Math.min(x,st.width-t.offsetWidth-6)+"px";t.style.top=Math.min(y,st.height-t.offsetHeight-6)+"px";}
572
649
 
650
+ // Fact-base line. `suffix` marks a RE-VERIFY that could not re-read the model (injected/offline).
651
+ function stamp(suffix){
652
+ var m=(M&&M.meta)||{};
653
+ $("stamp").textContent=STR.stampBase+" "+(m.verifiedAt||(M&&M.generatedAt)||(M&&M.source&&M.source.commit)||"—")
654
+ +" · "+STR.stampMethod+" "+(m.verifyMethod||STR.stampVerify)+(suffix?" "+suffix:"");
655
+ }
573
656
  function boot(model){
574
657
  M=model;
575
658
  LANG=pickLang(M);STR=STRINGS[LANG];
576
659
  try{document.documentElement.setAttribute("lang",LANG);}catch(e){}
577
660
  applyStrings();
578
661
  if(!M||!M.nodes||!M.nodes.length){$("err").style.display="block";$("err").textContent=STR.errEmpty;return;}
579
- if(M.source&&M.source.repo)$("title").textContent="Forma · "+M.source.repo;
580
- var m=M.meta||{};$("stamp").textContent=STR.stampBase+" "+(m.verifiedAt||(M.source&&M.source.commit)||"—")+" · "+(m.verifyMethod||STR.stampVerify);
581
- setSkin((new URLSearchParams(location.search).get("skin"))||window.__C4_SKIN__||(m.skin)||"holo");
662
+ var ttl=(M.meta&&M.meta.title)||((M.source&&M.source.repo)?"Forma · "+M.source.repo:null);
663
+ if(ttl){$("title").textContent=ttl;try{document.title=ttl;}catch(e){}}
664
+ stamp("");
665
+ setSkin((new URLSearchParams(location.search).get("skin"))||window.__C4_SKIN__||(M.meta&&M.meta.skin)||"holo");
582
666
  stack=[null];mode="now";POS={};
583
667
  draw(true);
668
+ // Minimal hook for embedders (the Cowork artifact re-verifies through its own MCP bridge and
669
+ // needs to push the result back in without forking the viewer).
670
+ window.__C4_API__={model:function(){return M;},redraw:function(){draw(false);},stamp:function(t){$("stamp").textContent=t;}};
584
671
  }
585
672
  function start(){
586
673
  // Pick language from URL/global up-front so header + errors render even if the model never loads.
@@ -589,6 +676,7 @@ function start(){
589
676
  applyStrings();
590
677
  wire();
591
678
  if(window.__C4_MODEL__){boot(window.__C4_MODEL__);return;}
679
+ FETCHED=true; // the model has a live URL ⇒ RE-VERIFY can re-read it instead of only re-stamping
592
680
  try{fetch("./c4-model.json").then(function(r){return r.json();}).then(boot).catch(function(){
593
681
  $("err").style.display="block";$("err").textContent=STR.errNotFound;});}
594
682
  catch(e){$("err").style.display="block";$("err").textContent=STR.errUnavail;}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",