forma-arch 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -81,7 +84,11 @@ The viewer is a live C4 map, not a static picture:
81
84
  - **Drag boxes** to lay out the view your way; **RESET LAYOUT** restores the arrangement (your curated hints if the topology has them, the automatic one otherwise). To keep a layout, drag it, pick **Export layout JSON**, and paste the result under `"layout"` in the topology — `gen` carries it into `meta.layout` and the viewer pins those boxes, auto-arranging everything else clear of them.
82
85
  - **Arrow labels** are painted on the diagram while the level stays readable (≤14 arrows) and turn off above that; **LABELS** forces them on or off, and hovering an arrow always reveals its label.
83
86
  - **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`).
87
+ - The breadcrumb names the C4 level you are on (`C4-L1 · CONTEXT` → `C4-L3 · COMPONENTS`) and,
88
+ when the level carries curated state, tallies it: a dot per status with its count plus mean
89
+ completion, so a level reads as a programme board and not just a diagram.
90
+ - **Every level shrink-wraps its own content** — a context with four boxes renders zoomed and
91
+ dense instead of adrift in a fixed canvas.
85
92
 
86
93
  ## Skins
87
94
 
@@ -29,6 +29,9 @@ button.on{background:rgba(62,231,255,.18);color:var(--cyan);border-color:var(--c
29
29
  #back[disabled]{opacity:.28;cursor:default;border-color:var(--border);color:var(--ter)}
30
30
  select#skin option{background:#08202e;color:var(--nm)}
31
31
  #crumb{font-size:11px;color:var(--sec);margin:8px 0}
32
+ #crumb .agg{float:right;color:var(--ter2);letter-spacing:.05em}
33
+ #crumb .agg i{display:inline-block;width:7px;height:7px;border-radius:50%;margin:0 3px 0 8px;vertical-align:baseline}
34
+ #crumb .agg b{color:var(--nm)}
32
35
  #crumb b{color:var(--cyan)}
33
36
  #crumb a{color:#8fd8ec;cursor:pointer}
34
37
  #crumb a:hover{text-decoration:underline}
@@ -64,7 +67,8 @@ svg{width:100%;height:100%;display:block}
64
67
  .edge.plan{stroke:var(--edgeplan);stroke-dasharray:3 6;opacity:.6;marker-end:url(#arp)}
65
68
  .edge.hot{stroke:var(--cyan);stroke-width:2.6;opacity:1}
66
69
  .edgehit{stroke:transparent;stroke-width:14;fill:none;cursor:help}
67
- .elbl{font-size:8.2px;fill:var(--elbl);letter-spacing:.04em;pointer-events:none}
70
+ .elbl{font-size:8.2px;fill:var(--elbl);letter-spacing:.04em;pointer-events:none;
71
+ paint-order:stroke;stroke:var(--stagebg);stroke-width:3px;stroke-linejoin:round}
68
72
  @keyframes dash{to{stroke-dashoffset:-24}}
69
73
  .nd.pulse rect{animation:pul 2.4s ease-in-out infinite}
70
74
  @keyframes pul{0%,100%{filter:drop-shadow(0 0 8px rgba(62,231,255,.5))}50%{filter:drop-shadow(0 0 18px rgba(62,231,255,.95))}}
@@ -178,7 +182,7 @@ var STRINGS={
178
182
  detClose:"[x] close", detFunc:"What it does", detNow:"Current state (facts)",
179
183
  detTgt:"Target state (project)", detSrc:"Verification source", detIssue:"Issue",
180
184
  detTarget:"TARGET",
181
- rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match",
185
+ rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match", aggProgress:"progress",
182
186
  stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology", staticSrc:"(static source)",
183
187
  errEmpty:"Model unreachable or empty.",
184
188
  errNotFound:"Model not found (./c4-model.json). Open via `forma serve` or inject window.__C4_MODEL__.",
@@ -197,7 +201,7 @@ var STRINGS={
197
201
  detClose:"[x] chiudi", detFunc:"Cosa fa", detNow:"Stato attuale (fatti)",
198
202
  detTgt:"Stato finito (progetto)", detSrc:"Fonte verifica", detIssue:"Issue",
199
203
  detTarget:"TARGET",
200
- rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato",
204
+ rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato", aggProgress:"avanzamento",
201
205
  stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology", staticSrc:"(fonte statica)",
202
206
  errEmpty:"Modello non raggiungibile o vuoto.",
203
207
  errNotFound:"Modello non trovato (./c4-model.json). Apri via `forma serve` o inietta window.__C4_MODEL__.",
@@ -216,7 +220,8 @@ var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed
216
220
  var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
217
221
  var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
218
222
  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;
223
+ var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false
224
+ var VBOX={}; // last fitted viewBox per level — frozen while dragging so the canvas does not rescale
220
225
  function $(i){return document.getElementById(i)}
221
226
  function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/"/g,"&quot;")}
222
227
  function setSkin(s){var k=SKIN_IDS.indexOf(s)>-1?s:"holo";document.documentElement.dataset.skin=k;var el=$("skin");if(el)el.value=k;}
@@ -324,19 +329,44 @@ function draw(animate){
324
329
  function mapId(id){return memberMap[id]||id;}
325
330
  var edges=(M.edges||[]).map(function(e){return {from:mapId(e.from),to:mapId(e.to),label:e.label,estatus:e.estatus};})
326
331
  .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
332
  // fixed arrow labels: on by default while the level stays readable, else hover-only (the tooltip
331
333
  // is always there). A level with 40 arrows would be unreadable with every label painted at once.
332
334
  var showLbl=(LABELS===null)?(edges.length<=LABEL_AUTO_MAX):LABELS;
333
335
  var blbl=$("blbl");if(blbl)blbl.className=showLbl?"on":"";
334
- for(var i=0;i<edges.length;i++){
336
+ // geometry pre-pass: edge paths first, so the viewBox can shrink-wrap nodes AND label anchors
337
+ var geo=[],i;
338
+ for(i=0;i<edges.length;i++){
335
339
  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)+"…";
340
+ geo.push({e:edges[i],i:i,g:edgePath(a,b)});
341
+ }
342
+ // Fit-to-content viewBox: a sparse level renders zoomed and dense instead of lost in a fixed
343
+ // 1020px canvas — the hand-made status boards read well precisely because they waste no space.
344
+ // Minimums stop a single box from becoming comically large; extra room is split evenly so the
345
+ // content stays centred inside the enforced window.
346
+ var bx1=Infinity,by1=Infinity,bx2=-Infinity,by2=-Infinity,j,pp;
347
+ for(j=0;j<kids.length;j++){pp=pos[kids[j].id];if(!pp)continue;
348
+ if(pp.x<bx1)bx1=pp.x;if(pp.y<by1)by1=pp.y;if(pp.x+pp.w>bx2)bx2=pp.x+pp.w;if(pp.y+pp.h>by2)by2=pp.y+pp.h;}
349
+ for(j=0;j<geo.length;j++){var gm=geo[j].g;
350
+ if(gm.mx-14<bx1)bx1=gm.mx-14;if(gm.my-18<by1)by1=gm.my-18;
351
+ if(gm.mx+14>bx2)bx2=gm.mx+14;if(gm.my+6>by2)by2=gm.my+6;}
352
+ if(bx2<bx1){bx1=0;by1=0;bx2=lay.W;by2=lay.H;}
353
+ var VPAD=56,MINW=880,MINH=460;
354
+ var cw=bx2-bx1+2*VPAD,ch=by2-by1+2*VPAD;
355
+ var vw=Math.max(MINW,cw),vh=Math.max(MINH,ch);
356
+ var vx=bx1-VPAD-(vw-cw)/2,vy=by1-VPAD-(vh-ch)/2;
357
+ // …but hold it still while a box is being dragged: draw() runs on every mousemove, so a live
358
+ // refit would rescale the whole canvas under the pointer. The drop refits (see mouseup).
359
+ var vbk=keyOf();
360
+ if(drag&&VBOX[vbk]){vx=VBOX[vbk][0];vy=VBOX[vbk][1];vw=VBOX[vbk][2];vh=VBOX[vbk][3];}
361
+ else VBOX[vbk]=[vx,vy,vw,vh];
362
+ var s='<svg viewBox="'+vx.toFixed(1)+' '+vy.toFixed(1)+' '+vw.toFixed(1)+' '+vh.toFixed(1)+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
363
+ +'<defs><marker id="ar" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edge")+'"/></marker>'
364
+ +'<marker id="arp" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edgeplan")+'"/></marker></defs>';
365
+ for(j=0;j<geo.length;j++){
366
+ var e=geo[j].g,ed=geo[j].e,cl=(ed.estatus==="to-build"?"plan":"flow");
367
+ s+='<path class="edge '+cl+'" data-e="'+geo[j].i+'" d="'+e.d+'"/>';
368
+ s+='<path class="edgehit" data-e="'+geo[j].i+'" data-label="'+esc(ed.label||"")+'" d="'+e.d+'"/>';
369
+ if(showLbl&&ed.label){var lb=String(ed.label);if(lb.length>28)lb=lb.slice(0,27)+"…";
340
370
  s+='<text class="elbl" x="'+e.mx+'" y="'+(e.my-4)+'" text-anchor="middle">'+esc(lb)+'</text>';}
341
371
  }
342
372
  for(var j=0;j<kids.length;j++){
@@ -358,7 +388,7 @@ function draw(animate){
358
388
  // Description lines: how many fit comes from the geometry (text starts at y+51, 12.5px apart,
359
389
  // and must clear the footer control at h-25), so a curated layout hint with short boxes cannot
360
390
  // 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);
391
+ var floorY=p.h-((dr||isCat)?25:6),MAXL=Math.min(4,Math.floor((floorY-51)/12.5)+1);
362
392
  var dlines=wrapDesc(desc,Math.floor((p.w-26)/5.4),MAXL);
363
393
  for(var q=0;q<dlines.length;q++)s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+q*12.5)+'">'+esc(dlines[q])+'</text>';
364
394
  if(dr)s+='<rect class="drillhit" data-drill="1" x="'+(p.x+p.w-96)+'" y="'+(p.y+p.h-25)+'" width="88" height="20" rx="5"/>'
@@ -379,6 +409,21 @@ function draw(animate){
379
409
  var lvlIdx=((M.levels||[]).indexOf(lvlKey));
380
410
  var cb=(lvlIdx>-1?STR.crumbLevel+"-L"+(lvlIdx+1):STR.crumbLevel)+" · "+lvl.toUpperCase()+" &nbsp;&nbsp; <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
381
411
  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>");
412
+ // Per-level programme tally — the status-board essence in one glance: the status mix of THIS
413
+ // view plus mean completion. Counts use the real children (a collapsed catalogue still counts
414
+ // its members), so the tally never changes when the catalogue threshold does.
415
+ var cnt={},tot=0,sum=0,nc=0,st3;
416
+ for(j=0;j<realKids.length;j++){
417
+ st3=STMAP[realKids[j].status2]||(realKids[j].status==="planned"?"plan":"done");
418
+ cnt[st3]=(cnt[st3]||0)+1;tot++;
419
+ if(realKids[j].completion!=null){sum+=(+realKids[j].completion||0);nc++;}
420
+ }
421
+ if(tot){
422
+ var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d"},ord=["done","prog","next","plan","prob"],agg="";
423
+ for(j=0;j<ord.length;j++)if(cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+cnt[ord[j]];
424
+ if(nc)agg+=' &nbsp;'+esc(STR.aggProgress)+' <b>'+Math.round(sum/nc)+'%</b>';
425
+ cb='<span class="agg">'+agg+'</span>'+cb;
426
+ }
382
427
  $("crumb").innerHTML=cb;
383
428
  $("back").disabled=stack.length<2;
384
429
  $("detail").style.display="none";
@@ -399,9 +444,15 @@ function wrapDesc(desc,cpl,max){
399
444
  }
400
445
  // Returns the path plus the point ON the curve at t=0.5 — the label anchor. (The quadratic's
401
446
  // control point sits at twice the bow, so anchoring a label there would float it off the arrow.)
447
+ // Endpoints are trimmed at the true RECTANGLE border (not a radial guess): with wide boxes the
448
+ // radial trim landed inside the box on shallow angles, dragging the label anchor under the node.
402
449
  function edgePath(a,b){
403
450
  var ax=a.x+a.w/2,ay=a.y+a.h/2,bx=b.x+b.w/2,by=b.y+b.h/2,dx=bx-ax,dy=by-ay,L=Math.sqrt(dx*dx+dy*dy)||1;
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;
451
+ var ux=dx/L,uy=dy/L;
452
+ var ta=Math.min(ux?(a.w/2)/Math.abs(ux):Infinity,uy?(a.h/2)/Math.abs(uy):Infinity)+8;
453
+ var tb=Math.min(ux?(b.w/2)/Math.abs(ux):Infinity,uy?(b.h/2)/Math.abs(uy):Infinity)+11;
454
+ if(ta+tb>L-4){ta=(a.w/2+10);tb=(b.w/2+12);} // boxes touch/overlap: fall back to the radial trim
455
+ var x1=ax+ta*ux,y1=ay+ta*uy,x2=bx-tb*ux,y2=by-tb*uy;
405
456
  var cx=(x1+x2)/2+(y2-y1)*.12,cy=(y1+y2)/2-(x2-x1)*.12;
406
457
  return {d:"M"+x1+","+y1+" Q"+cx+","+cy+" "+x2+","+y2, mx:(x1+2*cx+x2)/4, my:(y1+2*cy+y2)/4};
407
458
  }
@@ -471,7 +522,9 @@ function wire(){
471
522
  pos[drag.id].y=clamp(p.y-drag.oy,4,lay.H-pos[drag.id].h-4);
472
523
  drag.moved=true;draw(false);
473
524
  });
474
- document.addEventListener("mouseup",function(){if(drag){if(drag.moved)justDragged=true;drag=null;}});
525
+ document.addEventListener("mouseup",function(){if(!drag)return;var m=drag.moved;drag=null;
526
+ // refit after the drop, deferred so the click that follows mouseup still lands on the live DOM
527
+ if(m){justDragged=true;setTimeout(function(){draw(false);},0);}});
475
528
  // edge label on hover (invisible fat hit-path)
476
529
  stage.addEventListener("mouseover",function(ev){
477
530
  if(ev.target.classList&&ev.target.classList.contains("edgehit")){
@@ -577,14 +630,14 @@ function serializeSvg(){
577
630
  clone.setAttribute("xmlns","http://www.w3.org/2000/svg");
578
631
  clone.setAttribute("xmlns:xlink","http://www.w3.org/1999/xlink");
579
632
  var vb=(svg.getAttribute("viewBox")||"0 0 1080 660").split(/\s+/);
580
- var w=+vb[2]||1080,h=+vb[3]||660;
633
+ var vbx=+vb[0]||0,vby=+vb[1]||0,w=+vb[2]||1080,h=+vb[3]||660;
581
634
  clone.setAttribute("width",w);clone.setAttribute("height",h);
582
635
  // Inline the resolved variables on the root so var(--x) resolves standalone,
583
636
  // independent of the skin-selector chain that won't exist in the exported file.
584
637
  var vars=resolvedVars();if(vars)clone.setAttribute("style",vars);
585
638
  // solid dark/light background so the standalone file isn't transparent
586
639
  var bg=cssv("--stagebg")||"#050d14";
587
- var rect='<rect x="0" y="0" width="'+w+'" height="'+h+'" fill="'+bg+'"/>';
640
+ var rect='<rect x="'+vbx+'" y="'+vby+'" width="'+w+'" height="'+h+'" fill="'+bg+'"/>';
588
641
  var style="<style>"+collectSvgCss()+"</style>";
589
642
  var inner=clone.innerHTML;
590
643
  var attrs='';for(var a=0;a<clone.attributes.length;a++){var at=clone.attributes[a];attrs+=' '+at.name+'="'+String(at.value).replace(/"/g,"&quot;")+'"';}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Forma — present your architecture instead of slides. An interactive, stack-agnostic C4 explorer (context → container → component → leaf) generated from your code and kept true to it by a deterministic drift check.",
5
5
  "keywords": [
6
6
  "architecture",