@wairon/cli 5.0.2-dev.11 → 5.0.2-dev.13

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/dist/cli/index.js CHANGED
@@ -65,7 +65,7 @@ var init_defaults = __esm({
65
65
  copilot: ".github/prompts",
66
66
  codex: ".codex/agents"
67
67
  };
68
- WAIRON_VERSION = "5.0.2-dev.11";
68
+ WAIRON_VERSION = "5.0.2-dev.13";
69
69
  GITHUB_REPO = "SYW-Apps/Waffle-AIron";
70
70
  ARCHITECT_AGENT_ID = "agent-architect";
71
71
  ARCHITECT_TEMPLATE_ID = "architect";
@@ -2999,6 +2999,16 @@ header input[type="search"]::placeholder { color:var(--dim); }
2999
2999
  #moreMenu .dropdown { display:block; width:100%; }
3000
3000
  #moreMenu .dropdown > .tbtn, #moreMenu > .tbtn { display:block; width:100%; text-align:left; border:none; background:transparent; margin:2px 0; }
3001
3001
  #moreMenu .dropdown > .tbtn:hover, #moreMenu > .tbtn:hover { background:var(--hover-bg); }
3002
+ /* Staged header compaction (responsive only, never persisted): compact
3003
+ stand-ins for the search input, the mode tabs, and the ancestor crumbs.
3004
+ All hidden at full width, so a roomy header renders exactly as before. */
3005
+ #searchBtn { position:relative; }
3006
+ #searchBtn.hasq::after { content:''; position:absolute; top:3px; right:3px; width:7px; height:7px; border-radius:50%; background:var(--accent); }
3007
+ .search-menu { min-width:210px; padding:8px; }
3008
+ .search-menu input[type="search"] { width:100%; }
3009
+ #modeMenu button.active { background:var(--accent); color:#fff; font-weight:700; }
3010
+ #crumbs .dropdown { display:inline-flex; }
3011
+ #crumbs .crumbmore { font-weight:700; }
3002
3012
 
3003
3013
  /* Settings panel \u2014 toggle switches */
3004
3014
  .settings-menu { min-width:266px; }
@@ -3122,9 +3132,17 @@ body.presentation #exitPresent, body.presentation #presentDetails { display:bloc
3122
3132
  <button data-vm="types">Types</button>
3123
3133
  <button data-vm="databases">Databases</button>
3124
3134
  </div>
3135
+ <div class="dropdown" id="modeDd" style="display:none">
3136
+ <button class="tbtn" id="modeBtn" title="Switch between the component architecture, the type ERD, or the database schemas">Components \u25BE</button>
3137
+ <div class="menu" id="modeMenu"></div>
3138
+ </div>
3125
3139
  <nav id="crumbs"></nav>
3126
3140
  <span class="divider"></span>
3127
3141
  <input id="search" type="search" placeholder="Search this view\u2026">
3142
+ <div class="dropdown" id="searchDd" style="display:none">
3143
+ <button class="tbtn" id="searchBtn" title="Search this view">\u{1F50D}</button>
3144
+ <div class="menu search-menu" id="searchMenu"></div>
3145
+ </div>
3128
3146
  <div class="seg" id="typesDetailSeg" style="display:none" title="ERD detail level">
3129
3147
  <button data-td="full">Full</button>
3130
3148
  <button data-td="fields">Fields</button>
@@ -3641,13 +3659,300 @@ var MODEL = __MODEL_JSON__;
3641
3659
  var BOX_W = 200, BOX_H = 56, SUBBOX_W = 230, SUBBOX_H = 84, GAP_X = 110, GAP_Y = 34;
3642
3660
  var INNER_W = 130, INNER_H = 36, INNER_GAPX = 26, INNER_GAPY = 12, HEAD_H = 34, PADI = 14;
3643
3661
 
3662
+ // ---- deep expansion (opt-in via subsystem.deepInternals) -------------------
3663
+ // When Internals is on and a subsystem record carries deepInternals:true, its
3664
+ // box renders its WHOLE subtree instead of one layer: deep-flagged child
3665
+ // subsystems become NESTED boundary boxes (recursing, defensively capped),
3666
+ // every other child renders as a fixed leaf tile that is never expanded
3667
+ // further. Sizes are computed bottom-up (a nested container tile takes its
3668
+ // recursive {w,h}); positions are emitted top-down by accumulating parent
3669
+ // top-left offsets. Relations between concrete visible endpoints are drawn
3670
+ // as ONE direct line each by buildDeepContext \u2014 crossing nested boundaries
3671
+ // on purpose (the full org overview of project relations); only relations
3672
+ // that cannot resolve to two concrete endpoints keep today's port machinery,
3673
+ // and ONLY at the outermost box. Without the flag this whole path is inert
3674
+ // and the classic one-layer innerLayout runs unchanged.
3675
+ var DEEP_MAX_DEPTH = 6;
3676
+ function isDeepId(subId) {
3677
+ var s = subById[subId];
3678
+ return !!(s && s.deepInternals);
3679
+ }
3680
+ // The DEEPEST visible tile representing compId inside the deep-expanded box
3681
+ // rooted at rootSubId: descend deep-flagged containers (the same expansion
3682
+ // rule as deepContainerLayout) until the containing child is a leaf tile.
3683
+ // Returns the child entry { kind, id } (its node id is IN(kind, id)).
3684
+ function deepLeafFor(compId, rootSubId) {
3685
+ var subId = rootSubId, depth = 1;
3686
+ for (;;) {
3687
+ var child = childOfScopeContaining(compId, { kind: 'subsystem', id: subId });
3688
+ if (!child) return null;
3689
+ if (child.kind === 'subsystem' && isDeepId(child.id) && depth < DEEP_MAX_DEPTH) {
3690
+ subId = child.id; depth += 1;
3691
+ continue;
3692
+ }
3693
+ return child;
3694
+ }
3695
+ }
3696
+ // One deep container's DIRECT children, placed with PER-TILE sizes (nested
3697
+ // containers take their recursive size; leaves stay INNER_W x INNER_H). The
3698
+ // placement mirrors innerLayout's strategy switch, generalised to variable
3699
+ // tile sizes. Tiles are centres relative to THIS container's top-left corner
3700
+ // (content sits right of PADI, below the HEAD_H label band); nested tiles
3701
+ // are relative to their own container, so emission accumulates offsets.
3702
+ function deepContainerLayout(subId, depth) {
3703
+ var kids = childrenOf({ kind: 'subsystem', id: subId });
3704
+ if (!kids.length) {
3705
+ // An EMPTY deep subsystem still shows as a (min-size) boundary box.
3706
+ return { tiles: [], w: INNER_W + 2 * PADI, h: HEAD_H + PADI };
3707
+ }
3708
+ var scope = { kind: 'subsystem', id: subId };
3709
+ var kidKey = function (k) { return k.kind + ':' + k.id; };
3710
+ var size = {};
3711
+ kids.forEach(function (k) {
3712
+ if (k.kind === 'subsystem' && isDeepId(k.id) && depth < DEEP_MAX_DEPTH) {
3713
+ var nested = deepContainerLayout(k.id, depth + 1);
3714
+ size[kidKey(k)] = { w: nested.w, h: nested.h, sub: nested };
3715
+ } else {
3716
+ size[kidKey(k)] = { w: INNER_W, h: INNER_H, sub: null };
3717
+ }
3718
+ });
3719
+ // Intra-container edges lifted to DIRECT children \u2014 for LAYOUT ONLY (the
3720
+ // drawn lines come from buildDeepContext's direct pass, never per level).
3721
+ var intra = {};
3722
+ MODEL.edges.forEach(function (edge) {
3723
+ var a = childOfScopeContaining(edge.from, scope);
3724
+ var b = childOfScopeContaining(edge.to, scope);
3725
+ if (!a || !b) return;
3726
+ var ak = a.kind + ':' + a.id, bk = b.kind + ':' + b.id;
3727
+ if (!size[ak] || !size[bk] || ak === bk) return;
3728
+ intra[ak + '=>' + bk] = 1;
3729
+ });
3730
+ var layer = {};
3731
+ function calc(k, stack) {
3732
+ var key = kidKey(k);
3733
+ if (layer[key] !== undefined) return layer[key];
3734
+ if (stack[key]) return 0;
3735
+ stack[key] = 1;
3736
+ var l = 0;
3737
+ if (k.kind === 'component') {
3738
+ var c = compById[k.id];
3739
+ if (c && (c.componentType === 'Portal' || c.componentType === 'Observer')) { layer[key] = 0; delete stack[key]; return 0; }
3740
+ }
3741
+ Object.keys(intra).forEach(function (ek) {
3742
+ var cut = ek.indexOf('=>');
3743
+ if (ek.slice(cut + 2) !== key) return;
3744
+ var srcKid = kids.filter(function (x) { return kidKey(x) === ek.slice(0, cut); })[0];
3745
+ if (srcKid) l = Math.max(l, calc(srcKid, stack) + 1);
3746
+ });
3747
+ delete stack[key];
3748
+ layer[key] = l;
3749
+ return l;
3750
+ }
3751
+ kids.forEach(function (k) { calc(k, {}); });
3752
+ var tiles = [], contentW = 0, contentH = 0;
3753
+ if (state.layout === 'grid') {
3754
+ // Row packing by ACTUAL tile size (the fixed per-column grid assumed
3755
+ // uniform tiles); the target row width follows the tile count, widened
3756
+ // to at least the widest single tile.
3757
+ var gsorted = kids.slice().sort(function (a, b) { return a.id < b.id ? -1 : 1; });
3758
+ var target = Math.max(1, Math.ceil(Math.sqrt(kids.length))) * (INNER_W + INNER_GAPX);
3759
+ kids.forEach(function (k) { var s0 = size[kidKey(k)]; if (s0.w > target) target = s0.w; });
3760
+ var gx = 0, gy = 0, rowH = 0;
3761
+ gsorted.forEach(function (k) {
3762
+ var s = size[kidKey(k)];
3763
+ if (gx > 0 && gx + s.w > target) { gx = 0; gy += rowH + INNER_GAPY; rowH = 0; }
3764
+ tiles.push({ kid: k, x: gx + s.w / 2, y: gy + s.h / 2, w: s.w, h: s.h, sub: s.sub });
3765
+ gx += s.w + INNER_GAPX;
3766
+ if (s.h > rowH) rowH = s.h;
3767
+ if (gx - INNER_GAPX > contentW) contentW = gx - INNER_GAPX;
3768
+ if (gy + rowH > contentH) contentH = gy + rowH;
3769
+ });
3770
+ } else if (state.layout === 'concentric' || state.layout === 'force') {
3771
+ var ideg = {};
3772
+ kids.forEach(function (k) { ideg[kidKey(k)] = 0; });
3773
+ Object.keys(intra).forEach(function (ek) {
3774
+ var cut2 = ek.indexOf('=>');
3775
+ var sk = ek.slice(0, cut2), tk = ek.slice(cut2 + 2);
3776
+ if (ideg[sk] !== undefined) ideg[sk]++;
3777
+ if (ideg[tk] !== undefined) ideg[tk]++;
3778
+ });
3779
+ var rel = concentricPositions(
3780
+ kids.map(kidKey),
3781
+ function (key) { return ideg[key] || 0; },
3782
+ function (key) { return { w: size[key].w, h: size[key].h }; }
3783
+ );
3784
+ // Normalise by the tiles' BOUNDING BOX (not just the centres) so a wide
3785
+ // nested container on the rim still clears the container's left/top pad.
3786
+ var minL = Infinity, minT = Infinity;
3787
+ kids.forEach(function (k) {
3788
+ var s1 = size[kidKey(k)], p1 = rel[kidKey(k)] || { x: 0, y: 0 };
3789
+ if (p1.x - s1.w / 2 < minL) minL = p1.x - s1.w / 2;
3790
+ if (p1.y - s1.h / 2 < minT) minT = p1.y - s1.h / 2;
3791
+ });
3792
+ if (minL === Infinity) { minL = 0; minT = 0; }
3793
+ kids.forEach(function (k) {
3794
+ var s2 = size[kidKey(k)], p2 = rel[kidKey(k)] || { x: 0, y: 0 };
3795
+ var cx = p2.x - minL, cyy = p2.y - minT;
3796
+ tiles.push({ kid: k, x: cx, y: cyy, w: s2.w, h: s2.h, sub: s2.sub });
3797
+ if (cx + s2.w / 2 > contentW) contentW = cx + s2.w / 2;
3798
+ if (cyy + s2.h / 2 > contentH) contentH = cyy + s2.h / 2;
3799
+ });
3800
+ } else {
3801
+ // Layered dependency columns: the column is as wide as its widest tile,
3802
+ // and each tile advances by ITS OWN height.
3803
+ var cols = {};
3804
+ kids.forEach(function (k) { var l = layer[kidKey(k)] || 0; (cols[l] = cols[l] || []).push(k); });
3805
+ var colKeys = Object.keys(cols).map(Number).sort(function (a, b) { return a - b; });
3806
+ var x = 0;
3807
+ colKeys.forEach(function (ck) {
3808
+ var col = cols[ck].sort(function (a, b) { return a.id < b.id ? -1 : 1; });
3809
+ var colW = 0, y = 0;
3810
+ col.forEach(function (k) { var s3 = size[kidKey(k)]; if (s3.w > colW) colW = s3.w; });
3811
+ col.forEach(function (k) {
3812
+ var s = size[kidKey(k)];
3813
+ tiles.push({ kid: k, x: x + colW / 2, y: y + s.h / 2, w: s.w, h: s.h, sub: s.sub });
3814
+ y += s.h + INNER_GAPY;
3815
+ });
3816
+ if (y - INNER_GAPY > contentH) contentH = y - INNER_GAPY;
3817
+ x += colW + INNER_GAPX;
3818
+ });
3819
+ contentW = x - INNER_GAPX;
3820
+ }
3821
+ tiles.forEach(function (t) { t.x += PADI; t.y += HEAD_H; });
3822
+ return { tiles: tiles, w: contentW + 2 * PADI, h: HEAD_H + contentH + PADI };
3823
+ }
3824
+ // Top-level deep box: the recursive interior plus today's port machinery at
3825
+ // the OUTERMOST box only (buildDeepContext supplies which relations still
3826
+ // need ports; stubs run port <-> the DEEPEST visible leaf tile). Returns the
3827
+ // same shape as innerLayout, plus deep:true so emission recurses.
3828
+ function deepLayout(entry, portRec) {
3829
+ var box = deepContainerLayout(entry.id, 1);
3830
+ var parentId = anchorNodeId(entry);
3831
+ var pBaseIn = 'p~in~' + parentId + '~', pBaseOut = 'p~out~' + parentId + '~';
3832
+ var extIn = portRec ? portRec.extIn : {}, extOut = portRec ? portRec.extOut : {};
3833
+ var inIds = Object.keys(extIn).sort(), outIds = Object.keys(extOut).sort();
3834
+ var hasIn = inIds.length > 0, hasOut = outIds.length > 0;
3835
+ var PROXY_W = 22, PROXY_H = 22, PROXY_GAP = 8;
3836
+ var shift = hasIn ? PROXY_W + INNER_GAPX : 0;
3837
+ var tiles = box.tiles;
3838
+ if (shift) tiles.forEach(function (t) { t.x += shift; });
3839
+ var w = box.w + shift + (hasOut ? PROXY_W + INNER_GAPX : 0);
3840
+ if (w < SUBBOX_W) w = SUBBOX_W;
3841
+ var stackMax = Math.max(inIds.length, outIds.length);
3842
+ var h = Math.max(box.h, HEAD_H + stackMax * PROXY_H + Math.max(0, stackMax - 1) * PROXY_GAP + PADI);
3843
+ var midY = HEAD_H + Math.max(0, (h - HEAD_H - PADI) / 2);
3844
+ function stackPorts(ids, recs, base, cx, dir) {
3845
+ var total = ids.length * PROXY_H + Math.max(0, ids.length - 1) * PROXY_GAP;
3846
+ var y0 = Math.max(HEAD_H + PROXY_H / 2, midY - total / 2 + PROXY_H / 2);
3847
+ return ids.map(function (eid, i) {
3848
+ var km = recs[eid].kids, klist = [];
3849
+ Object.keys(km).forEach(function (key) { klist.push(km[key]); });
3850
+ return { id: base + eid, extId: eid, dir: dir, kids: klist, raws: Object.keys(recs[eid].raws), x: cx, y: y0 + i * (PROXY_H + PROXY_GAP), w: PROXY_W, h: PROXY_H };
3851
+ });
3852
+ }
3853
+ return {
3854
+ deep: true,
3855
+ tiles: tiles,
3856
+ edges: portRec ? Object.keys(portRec.stubs).map(function (k) { return portRec.stubs[k]; }) : [],
3857
+ proxies: stackPorts(inIds, extIn, pBaseIn, PADI + PROXY_W / 2, 'in')
3858
+ .concat(stackPorts(outIds, extOut, pBaseOut, w - PADI - PROXY_W / 2, 'out')),
3859
+ w: w,
3860
+ h: h,
3861
+ };
3862
+ }
3863
+ // View-level deep context (null unless Internals is on AND at least one
3864
+ // in-view entry is deep-flagged \u2014 the classic path never sees it): which
3865
+ // top-level entries are deep-expanded; every relation drawn as a DIRECT
3866
+ // concrete line (deduped per src=>tgt pair); the per-top-pair count used to
3867
+ // suppress aggregated edges whose constituents are ALL drawn directly; and
3868
+ // the port records for relations that keep today's port semantics.
3869
+ function buildDeepContext(scope, entries) {
3870
+ if (!state.internals) return null;
3871
+ var deepByAnchor = {}, any = false;
3872
+ entries.forEach(function (e) {
3873
+ if (e.kind === 'subsystem' && isDeepId(e.id)) { deepByAnchor[anchorNodeId(e)] = e; any = true; }
3874
+ });
3875
+ if (!any) return null;
3876
+ var entryByAnchor = {};
3877
+ entries.forEach(function (e) { entryByAnchor[e.kind + ':' + e.id] = e; });
3878
+ // A relation endpoint's DIRECT-line node in this view: the deepest leaf
3879
+ // tile inside a deep-expanded entry, or a top-level component box ITSELF.
3880
+ // null = this endpoint keeps aggregated/port semantics (interiors of
3881
+ // non-deep entries, out-of-scope counterparts).
3882
+ function directEnd(compId) {
3883
+ var child = childOfScopeContaining(compId, scope);
3884
+ var entry = child && entryByAnchor[child.kind + ':' + child.id];
3885
+ if (!entry) return null;
3886
+ var aid = anchorNodeId(entry);
3887
+ if (deepByAnchor[aid]) {
3888
+ var leaf = deepLeafFor(compId, entry.id);
3889
+ return leaf ? { node: IN(leaf.kind, leaf.id), top: aid, deep: true } : null;
3890
+ }
3891
+ if (entry.kind === 'component' && entry.id === compId) return { node: aid, top: aid, deep: false };
3892
+ return null;
3893
+ }
3894
+ var direct = {}, directTopCount = {}, ports = {};
3895
+ function portRec(aid) { return ports[aid] = ports[aid] || { extIn: {}, extOut: {}, stubs: {} }; }
3896
+ MODEL.edges.forEach(function (edge) {
3897
+ var a = directEnd(edge.from), b = directEnd(edge.to);
3898
+ if (a && b && (a.deep || b.deep) && a.node !== b.node) {
3899
+ // Drawn as ONE direct line \u2014 never ALSO as ports/stubs (dedupe rule).
3900
+ var key = a.node + '=>' + b.node;
3901
+ if (!direct[key]) direct[key] = { src: a.node, tgt: b.node, cross: false, aTop: a.top, bTop: b.top };
3902
+ if (edge.cross) direct[key].cross = true;
3903
+ if (a.top !== b.top) {
3904
+ var tk = a.top + '=>' + b.top;
3905
+ directTopCount[tk] = (directTopCount[tk] || 0) + 1;
3906
+ }
3907
+ return;
3908
+ }
3909
+ // Not a direct line: keep today's port semantics on any deep box with
3910
+ // exactly one endpoint inside its subtree, stubbed to the deepest leaf.
3911
+ var ac = childOfScopeContaining(edge.from, scope);
3912
+ var bc = childOfScopeContaining(edge.to, scope);
3913
+ var aEnt = ac && entryByAnchor[ac.kind + ':' + ac.id];
3914
+ var bEnt = bc && entryByAnchor[bc.kind + ':' + bc.id];
3915
+ var aAid = aEnt ? anchorNodeId(aEnt) : null;
3916
+ var bAid = bEnt ? anchorNodeId(bEnt) : null;
3917
+ if (aAid === bAid) return; // internal to one entry, or neither in scope
3918
+ if (aAid && deepByAnchor[aAid]) {
3919
+ var leafA = deepLeafFor(edge.from, aEnt.id);
3920
+ if (leafA) {
3921
+ var recA = portRec(aAid);
3922
+ var ro = recA.extOut[edge.to] = recA.extOut[edge.to] || { kids: {}, raws: {} };
3923
+ ro.kids[leafA.kind + ':' + leafA.id] = leafA;
3924
+ ro.raws[edge.from] = 1;
3925
+ var poId = 'p~out~' + aAid + '~' + edge.to;
3926
+ recA.stubs[IN(leafA.kind, leafA.id) + '=>' + poId] = { src: IN(leafA.kind, leafA.id), tgt: poId, stub: true };
3927
+ }
3928
+ }
3929
+ if (bAid && deepByAnchor[bAid]) {
3930
+ var leafB = deepLeafFor(edge.to, bEnt.id);
3931
+ if (leafB) {
3932
+ var recB = portRec(bAid);
3933
+ var ri = recB.extIn[edge.from] = recB.extIn[edge.from] || { kids: {}, raws: {} };
3934
+ ri.kids[leafB.kind + ':' + leafB.id] = leafB;
3935
+ ri.raws[edge.to] = 1;
3936
+ var piId = 'p~in~' + bAid + '~' + edge.from;
3937
+ recB.stubs[piId + '=>' + IN(leafB.kind, leafB.id)] = { src: piId, tgt: IN(leafB.kind, leafB.id), stub: true };
3938
+ }
3939
+ }
3940
+ });
3941
+ return { deepByAnchor: deepByAnchor, direct: direct, directTopCount: directTopCount, ports: ports };
3942
+ }
3943
+
3644
3944
  // Micro-layout for a container's direct children when Internals is on:
3645
3945
  // layered mini columns + intra-container edges. Each external relation gets
3646
3946
  // its own small PORT node INSIDE the container (one per external
3647
3947
  // counterpart; incoming left, outgoing right). Children connect to ports
3648
3948
  // with short edges that never leave the box \u2014 the real cross-boundary line
3649
3949
  // is only revealed on hover, or pinned while the port is selected.
3650
- function innerLayout(entry) {
3950
+ function innerLayout(entry, deepCtx) {
3951
+ // Deep-flagged subsystems take the recursive path (deepCtx exists only
3952
+ // when Internals is on and the view has deep entries \u2014 see buildElements).
3953
+ if (deepCtx && entry.kind === 'subsystem' && deepCtx.deepByAnchor[anchorNodeId(entry)]) {
3954
+ return deepLayout(entry, deepCtx.ports[anchorNodeId(entry)]);
3955
+ }
3651
3956
  var kids = state.internals && entry.hasKids ? childrenOf({ kind: entry.kind, id: entry.id }) : [];
3652
3957
  if (!kids.length) return null;
3653
3958
  var scope = { kind: entry.kind, id: entry.id };
@@ -4219,12 +4524,42 @@ var MODEL = __MODEL_JSON__;
4219
4524
  if (scope.kind === 'types' || scope.kind === 'databases') return buildTypeElements();
4220
4525
  var entries = childrenOf(scope);
4221
4526
  var eles = [];
4527
+ var deepCtx = buildDeepContext(scope, entries);
4222
4528
  var ve = viewEdges(scope, entries);
4223
4529
  // Data-coupling overlay: same scoping pipeline, a different edge source.
4224
4530
  var vd = state.dataCoupling ? viewEdges(scope, entries, MODEL.dataEdges) : { agg: {}, ghosts: {} };
4225
4531
  Object.keys(vd.ghosts).forEach(function (g) { if (!ve.ghosts[g]) ve.ghosts[g] = vd.ghosts[g]; });
4226
4532
  var inners = {};
4227
- entries.forEach(function (e) { inners[anchorNodeId(e)] = innerLayout(e); });
4533
+ entries.forEach(function (e) { inners[anchorNodeId(e)] = innerLayout(e, deepCtx); });
4534
+
4535
+ // Deep-mode recursive tile emission: a nested container becomes a cytoscape
4536
+ // compound parent (no explicit position \u2014 a compound derives its bounds
4537
+ // from its children); leaves and EMPTY containers are plain positioned
4538
+ // nodes. ox/oy = the emitting container's absolute top-left; tile.x/y are
4539
+ // centres relative to it, so offsets accumulate top-down. Search dimming
4540
+ // propagates the TOP entry's dim to the whole subtree.
4541
+ function emitDeepTiles(tiles, parentNodeId, ox, oy, dimCls) {
4542
+ tiles.forEach(function (tile) {
4543
+ var ax = ox + tile.x, ay = oy + tile.y;
4544
+ if (tile.sub) {
4545
+ var nid = SN(tile.kid.id);
4546
+ var selCls = state.selectedKind === 'subsystem' && state.selected === tile.kid.id ? ' sel' : '';
4547
+ var nested = {
4548
+ data: { id: nid, parent: parentNodeId, label: nameOf(tile.kid), w: tile.w, h: tile.h, tw: tile.w - 16 },
4549
+ classes: 'subsysBox' + (tile.sub.tiles.length ? ' drillable' : '') + dimCls + selCls,
4550
+ };
4551
+ if (!tile.sub.tiles.length) nested.position = { x: ax, y: ay };
4552
+ eles.push(nested);
4553
+ emitDeepTiles(tile.sub.tiles, nid, ax - tile.w / 2, ay - tile.h / 2, dimCls);
4554
+ } else {
4555
+ eles.push({
4556
+ data: { id: IN(tile.kid.kind, tile.kid.id), parent: parentNodeId, label: nameOf(tile.kid), w: INNER_W, h: INNER_H, tw: INNER_W - 10 },
4557
+ position: { x: ax, y: ay },
4558
+ classes: 'inner' + dimCls,
4559
+ });
4560
+ }
4561
+ });
4562
+ }
4228
4563
 
4229
4564
  // Resolve a port's reveal target(s) in THIS view. Preference order: the
4230
4565
  // MATCHING PORT inside the counterpart's container (a port-to-port line
@@ -4356,17 +4691,25 @@ var MODEL = __MODEL_JSON__;
4356
4691
  + (state.showIssues && issuesBySpec[e.id] ? ' hasIssue' : '')
4357
4692
  + (state.selectedKind === e.kind && state.selected === e.id ? ' sel' : '');
4358
4693
  if (inner) {
4359
- eles.push({ data: { id: aid, label: e.kind === 'subsystem' ? nameOf(e) : nameOf(e), w: p.w, h: p.h, tw: p.w - 16 }, classes: classes });
4360
- inner.tiles.forEach(function (tile) {
4361
- eles.push({
4362
- data: {
4363
- id: IN(tile.kid.kind, tile.kid.id), parent: aid,
4364
- label: nameOf(tile.kid), w: INNER_W, h: INNER_H, tw: INNER_W - 10,
4365
- },
4366
- position: { x: p.x - p.w / 2 + tile.x, y: p.y - p.h / 2 + tile.y },
4367
- classes: 'inner' + (dim ? ' dimmed' : ''),
4694
+ var boxNode = { data: { id: aid, label: e.kind === 'subsystem' ? nameOf(e) : nameOf(e), w: p.w, h: p.h, tw: p.w - 16 }, classes: classes };
4695
+ // An EMPTY deep boundary box has no children, so it is NOT a compound
4696
+ // parent \u2014 it needs (and honours) an explicit position and size.
4697
+ if (inner.deep && !inner.tiles.length && !(inner.proxies && inner.proxies.length)) boxNode.position = { x: p.x, y: p.y };
4698
+ eles.push(boxNode);
4699
+ if (inner.deep) {
4700
+ emitDeepTiles(inner.tiles, aid, p.x - p.w / 2, p.y - p.h / 2, dim ? ' dimmed' : '');
4701
+ } else {
4702
+ inner.tiles.forEach(function (tile) {
4703
+ eles.push({
4704
+ data: {
4705
+ id: IN(tile.kid.kind, tile.kid.id), parent: aid,
4706
+ label: nameOf(tile.kid), w: INNER_W, h: INNER_H, tw: INNER_W - 10,
4707
+ },
4708
+ position: { x: p.x - p.w / 2 + tile.x, y: p.y - p.h / 2 + tile.y },
4709
+ classes: 'inner' + (dim ? ' dimmed' : ''),
4710
+ });
4368
4711
  });
4369
- });
4712
+ }
4370
4713
  (inner.proxies || []).forEach(function (px) {
4371
4714
  eles.push({
4372
4715
  data: {
@@ -4519,9 +4862,28 @@ var MODEL = __MODEL_JSON__;
4519
4862
 
4520
4863
  var dimmedAnchors = {};
4521
4864
  entries.forEach(function (e) { if (state.query && !matches(e)) dimmedAnchors[anchorNodeId(e)] = true; });
4865
+
4866
+ // Deep mode: ONE direct line per related pair of concrete visible nodes \u2014
4867
+ // leaf tiles at any depth and/or top-level component boxes (deduped by
4868
+ // buildDeepContext). These lines cross nested boundaries on purpose.
4869
+ if (deepCtx) {
4870
+ var ddi = 0;
4871
+ Object.keys(deepCtx.direct).sort().forEach(function (key) {
4872
+ var d = deepCtx.direct[key];
4873
+ var ddim = state.query && (dimmedAnchors[d.aTop] || dimmedAnchors[d.bTop]);
4874
+ eles.push({
4875
+ data: { id: 'dd' + (ddi++), source: d.src, target: d.tgt, lbl: '' },
4876
+ classes: 'inneredge' + (d.cross ? ' cross' : '') + (ddim ? ' dimmed' : ''),
4877
+ });
4878
+ });
4879
+ }
4880
+
4522
4881
  var i = 0;
4523
4882
  Object.keys(ve.agg).forEach(function (key) {
4524
4883
  var e = ve.agg[key];
4884
+ // Prefer the leaf lines: drop an aggregated container edge whose
4885
+ // constituent relations were ALL drawn as direct deep lines above.
4886
+ if (deepCtx && deepCtx.directTopCount[key] >= e.n) return;
4525
4887
  var bundle = e.n > 1;
4526
4888
  var dim = state.query && (dimmedAnchors[e.src] || dimmedAnchors[e.tgt]);
4527
4889
  var route = routeData(e.src, e.tgt, key);
@@ -4780,22 +5142,58 @@ var MODEL = __MODEL_JSON__;
4780
5142
  }
4781
5143
  return path;
4782
5144
  }
5145
+ // Crumb compaction (compaction stage 5) is a RENDER MODE, not marker-based
5146
+ // reparenting: renderCrumbs rebuilds #crumbs' innerHTML on every navigation,
5147
+ // so nodes physically moved elsewhere would be destroyed by the next render.
5148
+ // The header-compaction stage toggles crumbsCompact and re-renders; compact
5149
+ // keeps the CURRENT scope visible and folds the ancestors into an ordered
5150
+ // "\\u2026" dropdown (document order, root first) whose entries navigate
5151
+ // exactly like the crumbs they replace.
5152
+ var crumbsCompact = false;
5153
+ var lastCrumbsHtml; // no initializer: the boot render at cy-init time precedes this line
5154
+ // Set by the header-compaction IIFE: crumb re-renders change the header's
5155
+ // CONTENT width without resizing #hdr itself (it is edge-anchored), so the
5156
+ // ResizeObserver never fires for them \u2014 renderCrumbs nudges a reflow here.
5157
+ var headerReflowHook = null;
5158
+ function crumbBtnHtml(p, cur) {
5159
+ return '<button class="crumb' + (cur ? ' cur' : '') + '" data-ck="' + p.kind + '" data-ci="' + (p.id || '') + '">' + p.label + '</button>';
5160
+ }
4783
5161
  function renderCrumbs() {
4784
5162
  var el = document.getElementById('crumbs');
4785
5163
  var path = crumbPath();
4786
- el.innerHTML = path.map(function (p, i) {
4787
- var cur = i === path.length - 1;
4788
- return '<button class="crumb' + (cur ? ' cur' : '') + '" data-ck="' + p.kind + '" data-ci="' + (p.id || '') + '">' + p.label + '</button>'
4789
- + (cur ? '' : '<span class="sep">\\u203A</span>');
4790
- }).join('');
5164
+ var html;
5165
+ if (crumbsCompact && path.length > 1) {
5166
+ html = '<span class="dropdown" id="crumbDd">'
5167
+ + '<button class="crumb crumbmore" id="crumbMoreBtn" title="Show the collapsed ancestor path">\\u2026</button>'
5168
+ + '<span class="menu" id="crumbMenu">'
5169
+ + path.slice(0, path.length - 1).map(function (p) {
5170
+ return '<button data-ck="' + p.kind + '" data-ci="' + (p.id || '') + '">' + p.label + '</button>';
5171
+ }).join('')
5172
+ + '</span></span>'
5173
+ + '<span class="sep">\\u203A</span>'
5174
+ + crumbBtnHtml(path[path.length - 1], true);
5175
+ } else {
5176
+ html = path.map(function (p, i) {
5177
+ var cur = i === path.length - 1;
5178
+ return crumbBtnHtml(p, cur) + (cur ? '' : '<span class="sep">\\u203A</span>');
5179
+ }).join('');
5180
+ }
5181
+ // No-op renders keep the already-wired nodes (and an open "\\u2026" menu)
5182
+ // intact \u2014 and don't churn the reflow scheduler while a search query types.
5183
+ if (html === lastCrumbsHtml) return;
5184
+ lastCrumbsHtml = html;
5185
+ el.innerHTML = html;
4791
5186
  var btns = el.querySelectorAll('button');
4792
5187
  for (var i = 0; i < btns.length; i++) {
4793
5188
  (function (b) {
5189
+ if (!b.getAttribute('data-ck')) return; // the "\\u2026" trigger toggles, never navigates
4794
5190
  b.addEventListener('click', function () {
4795
5191
  navigateTo(b.getAttribute('data-ck'), b.getAttribute('data-ci') || null);
4796
5192
  });
4797
5193
  })(btns[i]);
4798
5194
  }
5195
+ if (crumbsCompact && path.length > 1) wireDropdown('crumbDd', 'crumbMoreBtn');
5196
+ if (headerReflowHook) headerReflowHook();
4799
5197
  }
4800
5198
  function renderViewHint() {
4801
5199
  if (state.view.kind === 'types' || state.view.kind === 'databases') {
@@ -5062,7 +5460,16 @@ var MODEL = __MODEL_JSON__;
5062
5460
  return c ? { kind: 'subsystem', id: c.subsystem } : { kind: 'system', id: null };
5063
5461
  }
5064
5462
 
5065
- document.getElementById('search').addEventListener('input', function (ev) { state.query = ev.target.value.trim(); rebuild(false); });
5463
+ // While the search input is compacted behind the magnifier icon (compaction
5464
+ // stage 3), a non-empty query must stay discoverable \u2014 mark the icon with an
5465
+ // accent dot. The class is kept in sync on every query edit; the dot is only
5466
+ // ever visible while the compact icon itself is.
5467
+ function updateSearchBadge() {
5468
+ var b = document.getElementById('searchBtn');
5469
+ if (b && b.classList) b.classList[state.query ? 'add' : 'remove']('hasq');
5470
+ }
5471
+ document.getElementById('search').addEventListener('input', function (ev) { state.query = ev.target.value.trim(); updateSearchBadge(); rebuild(false); });
5472
+ updateSearchBadge();
5066
5473
  // Sync each View toggle's checkbox from the (possibly persisted) state, then
5067
5474
  // persist on change so the choices survive a refresh (see persist()/saved).
5068
5475
  document.getElementById('internalsToggle').checked = state.internals;
@@ -5116,9 +5523,24 @@ var MODEL = __MODEL_JSON__;
5116
5523
  })(btns[i]);
5117
5524
  }
5118
5525
  })();
5526
+ // Compaction stage 4 replaces the mode tabs with one dropdown trigger; its
5527
+ // label must follow the CURRENT mode. updateHeaderSegs runs on every rebuild,
5528
+ // so a mode change made while compact re-labels the trigger immediately.
5529
+ function updateModeBtn() {
5530
+ var b = document.getElementById('modeBtn');
5531
+ if (!b) return;
5532
+ var lbl = state.view.kind === 'types' ? 'Types' : state.view.kind === 'databases' ? 'Databases' : 'Components';
5533
+ b.textContent = lbl + ' \\u25BE';
5534
+ }
5119
5535
  function updateHeaderSegs() {
5120
5536
  var seg = document.getElementById('modeSeg');
5121
5537
  var btns = seg.querySelectorAll('button');
5538
+ if (!btns.length) {
5539
+ // Compaction stage 4 moved the real tab buttons into the mode dropdown \u2014
5540
+ // keep driving THEIR active classes there (they move back node-identical).
5541
+ var mm = document.getElementById('modeMenu');
5542
+ if (mm && mm.querySelectorAll) btns = mm.querySelectorAll('button');
5543
+ }
5122
5544
  for (var i = 0; i < btns.length; i++) {
5123
5545
  var vm = btns[i].getAttribute('data-vm');
5124
5546
  var active = vm === 'components'
@@ -5126,6 +5548,7 @@ var MODEL = __MODEL_JSON__;
5126
5548
  : vm === state.view.kind;
5127
5549
  if (btns[i].classList) btns[i].classList[active ? 'add' : 'remove']('active');
5128
5550
  }
5551
+ updateModeBtn();
5129
5552
  var td = document.getElementById('typesDetailSeg');
5130
5553
  td.style.display = (state.view.kind === 'types' || state.view.kind === 'databases') ? '' : 'none';
5131
5554
  var tbs = td.querySelectorAll('button');
@@ -5189,7 +5612,13 @@ var MODEL = __MODEL_JSON__;
5189
5612
  var r = btn.getBoundingClientRect();
5190
5613
  menu.style.top = (r.bottom + 6) + 'px';
5191
5614
  menu.style.left = 'auto';
5192
- menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px';
5615
+ // Right-aligned to the trigger, but never pushed off the LEFT edge \u2014 the
5616
+ // compact search / crumb triggers (header compaction) sit on the header's
5617
+ // left side, where a 200px menu right-aligned to a narrow button would clip.
5618
+ var right = Math.max(6, window.innerWidth - r.right);
5619
+ var mw = menu.getBoundingClientRect ? menu.getBoundingClientRect().width : 0;
5620
+ if (mw && window.innerWidth - right - mw < 6) right = Math.max(6, window.innerWidth - mw - 6);
5621
+ menu.style.right = right + 'px';
5193
5622
  }
5194
5623
  function wireDropdown(ddId, btnId) {
5195
5624
  var dd = document.getElementById(ddId);
@@ -5208,11 +5637,29 @@ var MODEL = __MODEL_JSON__;
5208
5637
  var ldd = wireDropdown('layoutDd', 'layoutBtn');
5209
5638
  var sdd = wireDropdown('settingsDd', 'settingsBtn');
5210
5639
  var mdd = wireDropdown('moreDd', 'moreBtn');
5640
+ // Compact stand-ins (header compaction stages 3-4): the search panel and the
5641
+ // mode-tab dropdown are ordinary dropdowns; their triggers stay hidden until
5642
+ // their compaction stage shows them, so wiring them here is inert at full width.
5643
+ var qdd = wireDropdown('searchDd', 'searchBtn');
5644
+ var vdd = wireDropdown('modeDd', 'modeBtn');
5645
+ // Opening the compact search panel focuses the REAL input (stage 3 moves the
5646
+ // node, never clones it, so its input listener keeps driving state.query).
5647
+ // Registered after wireDropdown's toggle, so 'open' reflects the new state.
5648
+ document.getElementById('searchBtn').addEventListener('click', function () {
5649
+ if (String(qdd.className || '').indexOf('open') >= 0) {
5650
+ var inp = document.getElementById('search');
5651
+ if (inp && inp.focus) inp.focus();
5652
+ }
5653
+ });
5211
5654
  // Keep the settings panel open while flipping switches (clicks inside it don't
5212
5655
  // bubble to the document-level close handler).
5213
5656
  (function () {
5214
5657
  var m = document.getElementById('settingsMenu');
5215
5658
  if (m && m.addEventListener) m.addEventListener('click', function (ev) { if (ev && ev.stopPropagation) ev.stopPropagation(); });
5659
+ // Same for the floating search panel: clicking into the input must not
5660
+ // bubble to the document-level close handler and shut the panel mid-typing.
5661
+ var sm = document.getElementById('searchMenu');
5662
+ if (sm && sm.addEventListener) sm.addEventListener('click', function (ev) { if (ev && ev.stopPropagation) ev.stopPropagation(); });
5216
5663
  })();
5217
5664
 
5218
5665
  // Layout picker: choose the auto-layout algorithm. Components use cytoscape's
@@ -5236,6 +5683,12 @@ var MODEL = __MODEL_JSON__;
5236
5683
  });
5237
5684
  updateLayoutBtn();
5238
5685
 
5686
+ // The compact crumb dropdown (compaction stage 5) is re-created by every
5687
+ // compact crumb render, so it is looked up per close instead of captured.
5688
+ function closeCrumbDd() {
5689
+ var cdd = document.getElementById('crumbDd');
5690
+ if (cdd && cdd.classList) cdd.classList.remove('open');
5691
+ }
5239
5692
  if (document.addEventListener) {
5240
5693
  document.addEventListener('click', function () {
5241
5694
  if (dd.classList) dd.classList.remove('open');
@@ -5243,6 +5696,9 @@ var MODEL = __MODEL_JSON__;
5243
5696
  if (ldd.classList) ldd.classList.remove('open');
5244
5697
  if (sdd.classList) sdd.classList.remove('open');
5245
5698
  if (mdd && mdd.classList) mdd.classList.remove('open');
5699
+ if (qdd && qdd.classList) qdd.classList.remove('open');
5700
+ if (vdd && vdd.classList) vdd.classList.remove('open');
5701
+ closeCrumbDd();
5246
5702
  });
5247
5703
  document.addEventListener('keydown', function (ev) {
5248
5704
  if (ev.key === 'Escape') {
@@ -5250,16 +5706,27 @@ var MODEL = __MODEL_JSON__;
5250
5706
  if (modal.classList && String(modal.className).indexOf('open') >= 0) { closeFlow(); return; }
5251
5707
  setPresentation(false);
5252
5708
  if (dd.classList) dd.classList.remove('open');
5709
+ if (qdd && qdd.classList) qdd.classList.remove('open');
5710
+ if (vdd && vdd.classList) vdd.classList.remove('open');
5711
+ closeCrumbDd();
5253
5712
  }
5254
5713
  });
5255
5714
  }
5256
5715
 
5257
- // \u2500\u2500 Responsive header overflow \u2192 "\u22EF" dropdown \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5258
- // When the floating header no longer fits its controls, trailing items
5259
- // COLLAPSE into the More menu instead of relying on horizontal scroll \u2014
5260
- // every control stays one click away. Whole items move (listeners survive
5261
- // reparenting); a hidden placeholder pins each item's original position so
5262
- // restoring keeps the exact order. Collapse order = least-used first.
5716
+ // \u2500\u2500 Responsive header compaction \u2192 ordered stages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5717
+ // When the floating header no longer fits its controls, standard reusable
5718
+ // COMPACTION BEHAVIORS apply progressively \u2014 each stage only while the row
5719
+ // still overflows \u2014 and restore in REVERSE order when space returns:
5720
+ // 1. trailing buttons fold into the "\u22EF" menu (least-used first, one by one)
5721
+ // 2. the View dropdown folds in after them
5722
+ // 3. the search input compacts to a \u{1F50D} icon + floating panel
5723
+ // 4. the mode tabs compact to one current-mode dropdown
5724
+ // 5. ancestor crumbs compact into an ordered "\u2026" dropdown
5725
+ // Stages 1-2 move whole items (listeners survive reparenting) with a hidden
5726
+ // placeholder pinning each item's original spot for restore; stage 5 is a
5727
+ // render mode (renderCrumbs rebuilds its innerHTML, so reparenting would not
5728
+ // survive navigation). Nothing here is persisted \u2014 compaction is purely
5729
+ // responsive to the available width.
5263
5730
  (function () {
5264
5731
  if (typeof window === 'undefined') return;
5265
5732
  var hdr = document.getElementById('hdr');
@@ -5287,7 +5754,122 @@ var MODEL = __MODEL_JSON__;
5287
5754
  }
5288
5755
  return markers[id];
5289
5756
  }
5290
- var collapsed = [];
5757
+ // Fold stage (the classic behavior): move items into the "\u22EF" menu ONE per
5758
+ // apply() call \u2014 the reflow loop keeps a stage active until it reports no
5759
+ // further progress, preserving the original per-button granularity.
5760
+ function foldStage(ids) {
5761
+ var folded = [];
5762
+ return {
5763
+ apply: function () {
5764
+ while (folded.length < ids.length) {
5765
+ var id = ids[folded.length];
5766
+ var el = movableFor(id);
5767
+ if (!el || el === moreDd || el.parentNode === moreMenu) { folded.push({ el: null, marker: null }); continue; }
5768
+ var m = markerFor(id, el);
5769
+ // A dropdown moved while open would strand its fixed-positioned menu.
5770
+ if (el.classList) el.classList.remove('open');
5771
+ moreDd.style.display = '';
5772
+ moreMenu.appendChild(el);
5773
+ folded.push({ el: el, marker: m });
5774
+ return true;
5775
+ }
5776
+ return false;
5777
+ },
5778
+ restore: function () {
5779
+ for (var i = folded.length - 1; i >= 0; i--) {
5780
+ var it = folded[i];
5781
+ if (it.el && it.marker && it.marker.parentNode) it.marker.parentNode.insertBefore(it.el, it.marker);
5782
+ }
5783
+ folded = [];
5784
+ },
5785
+ };
5786
+ }
5787
+ // Stage 3: the search input compacts behind a \u{1F50D} icon; the REAL input node
5788
+ // MOVES into the floating panel (fixed-positioned by the same helper as
5789
+ // every dropdown menu), so its input listener keeps driving state.query.
5790
+ function searchStage() {
5791
+ var on = false;
5792
+ return {
5793
+ apply: function () {
5794
+ if (on) return false;
5795
+ var inp = document.getElementById('search');
5796
+ var ddw = document.getElementById('searchDd');
5797
+ var menu = document.getElementById('searchMenu');
5798
+ if (!inp || !ddw || !menu) return false;
5799
+ menu.appendChild(inp);
5800
+ ddw.style.display = '';
5801
+ on = true;
5802
+ return true;
5803
+ },
5804
+ restore: function () {
5805
+ if (!on) return;
5806
+ on = false;
5807
+ var inp = document.getElementById('search');
5808
+ var ddw = document.getElementById('searchDd');
5809
+ if (inp && ddw && ddw.parentNode) ddw.parentNode.insertBefore(inp, ddw);
5810
+ if (ddw) { ddw.style.display = 'none'; if (ddw.classList) ddw.classList.remove('open'); }
5811
+ },
5812
+ };
5813
+ }
5814
+ // Stage 4: the mode tabs collapse into ONE dropdown labelled with the
5815
+ // current mode. The REAL tab buttons move into its menu (listeners and
5816
+ // active styling survive); updateHeaderSegs keeps the trigger label in
5817
+ // sync when the mode changes while compact.
5818
+ function modeStage() {
5819
+ var moved = [];
5820
+ return {
5821
+ apply: function () {
5822
+ if (moved.length) return false;
5823
+ var seg = document.getElementById('modeSeg');
5824
+ var ddw = document.getElementById('modeDd');
5825
+ var menu = document.getElementById('modeMenu');
5826
+ if (!seg || !ddw || !menu) return false;
5827
+ var btns = seg.querySelectorAll('button');
5828
+ if (!btns.length) return false;
5829
+ for (var i = 0; i < btns.length; i++) moved.push(btns[i]);
5830
+ for (var j = 0; j < moved.length; j++) menu.appendChild(moved[j]);
5831
+ seg.style.display = 'none';
5832
+ ddw.style.display = '';
5833
+ updateModeBtn();
5834
+ return true;
5835
+ },
5836
+ restore: function () {
5837
+ if (!moved.length) return;
5838
+ var seg = document.getElementById('modeSeg');
5839
+ var ddw = document.getElementById('modeDd');
5840
+ for (var i = 0; i < moved.length; i++) seg.appendChild(moved[i]);
5841
+ moved = [];
5842
+ seg.style.display = '';
5843
+ if (ddw) { ddw.style.display = 'none'; if (ddw.classList) ddw.classList.remove('open'); }
5844
+ },
5845
+ };
5846
+ }
5847
+ // Stage 5: crumb compaction is a render-mode toggle consulted by
5848
+ // renderCrumbs itself \u2014 see crumbsCompact there. Never reparenting.
5849
+ function crumbStage() {
5850
+ return {
5851
+ apply: function () {
5852
+ if (crumbsCompact) return false;
5853
+ crumbsCompact = true;
5854
+ renderCrumbs();
5855
+ return true;
5856
+ },
5857
+ restore: function () {
5858
+ if (!crumbsCompact) return;
5859
+ crumbsCompact = false;
5860
+ renderCrumbs();
5861
+ },
5862
+ };
5863
+ }
5864
+ // Ordered compaction stages: applied first-to-last only while the header
5865
+ // overflows, restored last-to-first when space returns.
5866
+ var STAGES = [
5867
+ foldStage(COLLAPSE), // 1: trailing buttons \u2192 "\u22EF" menu
5868
+ foldStage(['settingsBtn']), // 2: the View dropdown folds in too
5869
+ searchStage(), // 3: search input \u2192 \u{1F50D} + floating panel
5870
+ modeStage(), // 4: mode tabs \u2192 current-mode dropdown
5871
+ crumbStage(), // 5: ancestor crumbs \u2192 "\u2026" dropdown
5872
+ ];
5291
5873
  // Signed fit measure in px: positive = overflowing, negative = headroom.
5292
5874
  // The header is a flex row whose ONLY flex:1 child is the .spacer, so the
5293
5875
  // spacer's rendered width IS the free space -- it grows to absorb all slack
@@ -5303,33 +5885,54 @@ var MODEL = __MODEL_JSON__;
5303
5885
  var slack = spacer ? spacer.getBoundingClientRect().width : 0;
5304
5886
  return (hdr.scrollWidth - hdr.clientWidth) - slack;
5305
5887
  }
5888
+ var inReflow = false;
5306
5889
  function reflow() {
5307
5890
  // Not laid out (hidden tab, non-browser DOM) \u2014 measuring would misfire.
5308
5891
  var box = hdr.getBoundingClientRect();
5309
5892
  if (!box || box.width <= 0) return;
5310
- // Restore everything, then collapse until the row fits (idempotent).
5311
- for (var i = collapsed.length - 1; i >= 0; i--) {
5312
- var it = collapsed[i];
5313
- if (it.el && it.marker && it.marker.parentNode) it.marker.parentNode.insertBefore(it.el, it.marker);
5314
- }
5315
- collapsed = [];
5316
- moreDd.style.display = 'none';
5317
- hdr.scrollLeft = 0;
5318
- var guard = 0;
5319
- // Demand a few px of headroom, not a bare fit \u2014 the marginal-fit widths
5320
- // are exactly where the phantom scrollbar appeared.
5321
- while (overflowPx() > -8 && guard < COLLAPSE.length) {
5322
- var id = COLLAPSE[guard++];
5323
- var el = movableFor(id);
5324
- if (!el || el === moreDd || el.parentNode === moreMenu) continue;
5325
- var m = markerFor(id, el);
5326
- // A dropdown moved while open would strand its fixed-positioned menu.
5327
- if (el.classList) el.classList.remove('open');
5328
- moreDd.style.display = '';
5329
- moreMenu.appendChild(el);
5330
- collapsed.push({ el: el, marker: m });
5331
- }
5332
- if (collapsed.length === 0) moreDd.style.display = 'none';
5893
+ inReflow = true;
5894
+ try {
5895
+ // The floating search panel must survive a reflow cycle: restore-all
5896
+ // would close it (and reparenting blurs the input), so capture its
5897
+ // open/focus state up front and reinstate it after the stage walk.
5898
+ var ddw = document.getElementById('searchDd');
5899
+ var inp = document.getElementById('search');
5900
+ var searchOpen = !!(ddw && String(ddw.className || '').indexOf('open') >= 0);
5901
+ var searchFocus = false;
5902
+ try {
5903
+ var ae = (typeof ROOT !== 'undefined' && ROOT ? ROOT : document).activeElement;
5904
+ searchFocus = !!(ae && inp && ae === inp);
5905
+ } catch (e) { /* stubbed DOM */ }
5906
+ // Restore every stage in REVERSE order, then re-apply progressively
5907
+ // while the row still overflows (idempotent).
5908
+ for (var i = STAGES.length - 1; i >= 0; i--) STAGES[i].restore();
5909
+ moreDd.style.display = 'none';
5910
+ hdr.scrollLeft = 0;
5911
+ // Demand a few px of headroom, not a bare fit \u2014 the marginal-fit widths
5912
+ // are exactly where the phantom scrollbar appeared. Every apply()
5913
+ // changes the very widths being measured, but restore-all + a strictly
5914
+ // forward stage walk make the outcome a pure function of the current
5915
+ // width, and reflow never reschedules itself (renderCrumbs' nudge is
5916
+ // suppressed via inReflow, and #hdr's own box never changes here), so
5917
+ // boundary widths settle in ONE pass instead of oscillating.
5918
+ var si = 0;
5919
+ var guard = 0;
5920
+ var bound = COLLAPSE.length + STAGES.length + 8;
5921
+ while (overflowPx() > -8 && si < STAGES.length && guard < bound) {
5922
+ guard++;
5923
+ if (!STAGES[si].apply()) si++;
5924
+ }
5925
+ if (searchOpen || searchFocus) {
5926
+ var compactNow = ddw && ddw.style && ddw.style.display !== 'none';
5927
+ if (compactNow && searchOpen) {
5928
+ if (ddw.classList) ddw.classList.add('open');
5929
+ positionDropdownMenu(ddw, document.getElementById('searchBtn'));
5930
+ }
5931
+ if (searchFocus && inp && inp.focus) inp.focus();
5932
+ }
5933
+ } finally {
5934
+ inReflow = false;
5935
+ }
5333
5936
  }
5334
5937
  var raf = null;
5335
5938
  var defer = window.requestAnimationFrame
@@ -5339,6 +5942,9 @@ var MODEL = __MODEL_JSON__;
5339
5942
  if (raf !== null) return;
5340
5943
  raf = defer(function () { raf = null; reflow(); });
5341
5944
  }
5945
+ // Crumb re-renders change the header's content width without resizing #hdr
5946
+ // itself \u2014 renderCrumbs nudges a reflow through this hook (no-op mid-reflow).
5947
+ headerReflowHook = function () { if (!inReflow) schedule(); };
5342
5948
  if (typeof ResizeObserver !== 'undefined') {
5343
5949
  new ResizeObserver(schedule).observe(hdr);
5344
5950
  } else if (window.addEventListener) {
@@ -29212,6 +29818,16 @@ function audienceDistance(resolution, targetProjectId) {
29212
29818
  // src/server/landscape.ts
29213
29819
  var yamlLib = __toESM(require("js-yaml"));
29214
29820
  init_filenames();
29821
+
29822
+ // src/server/openapiindex.ts
29823
+ function buildOpenApiSpecIndex(specs) {
29824
+ return { openapiIndex: true, specs: specs.map((s) => ({ portalId: s.portalId, name: s.name })) };
29825
+ }
29826
+ function openApiIndexDocument(specs) {
29827
+ return JSON.stringify(buildOpenApiSpecIndex(specs), null, 2);
29828
+ }
29829
+
29830
+ // src/server/landscape.ts
29215
29831
  var PROJECT_ADMIN_CAPABILITY3 = "project:admin";
29216
29832
  var PROJECT_READ_CAPABILITY3 = "project:read";
29217
29833
  var PROJECT_WRITE_CAPABILITY3 = "project:write";
@@ -29734,11 +30350,7 @@ function exportProjectSurface(cfg, credential, projectId, format, maxAudience, p
29734
30350
  };
29735
30351
  }
29736
30352
  return {
29737
- body: JSON.stringify(
29738
- { openapiIndex: true, specs: specs.map((s) => ({ portalId: s.portalId, name: s.name })) },
29739
- null,
29740
- 2
29741
- ),
30353
+ body: openApiIndexDocument(specs),
29742
30354
  contentType: "application/json",
29743
30355
  filename: `${safeFilenamePart(projectId)}-surface.openapi.index.json`
29744
30356
  };
@@ -31866,13 +32478,6 @@ var ShareSnapshotRegistry = class {
31866
32478
  return stored;
31867
32479
  }
31868
32480
  };
31869
- function openApiIndexDocument(specs) {
31870
- return JSON.stringify(
31871
- { openapiIndex: true, specs: specs.map((s) => ({ portalId: s.portalId, name: s.name })) },
31872
- null,
31873
- 2
31874
- );
31875
- }
31876
32481
  function selectCapturedOpenApi(snap, portalId) {
31877
32482
  const specs = snap.openapiSet;
31878
32483
  if (!specs || specs.length === 0) {
@@ -32538,7 +33143,31 @@ function reshapeLandscapeGraph(model, level) {
32538
33143
  }
32539
33144
  const kept = nodes.filter((n) => n.level <= level);
32540
33145
  const keptIds = new Set(kept.map((n) => n.id));
32541
- const edges = model.edges.filter((e) => keptIds.has(e.from) && keptIds.has(e.to));
33146
+ const projectNodeIdByProject = /* @__PURE__ */ new Map();
33147
+ const interfaceOwnerProject = /* @__PURE__ */ new Map();
33148
+ for (const n of model.nodes) {
33149
+ if (n.projectId === void 0) continue;
33150
+ if (n.nodeKind === "project") projectNodeIdByProject.set(n.projectId, n.id);
33151
+ else if (n.nodeKind === "publicInterface") interfaceOwnerProject.set(n.id, n.projectId);
33152
+ }
33153
+ const retarget = (endpoint) => {
33154
+ if (keptIds.has(endpoint)) return endpoint;
33155
+ const owner = interfaceOwnerProject.get(endpoint);
33156
+ const projectNode = owner === void 0 ? void 0 : projectNodeIdByProject.get(owner);
33157
+ return projectNode !== void 0 && keptIds.has(projectNode) ? projectNode : void 0;
33158
+ };
33159
+ const edges = [];
33160
+ for (const e of model.edges) {
33161
+ if (keptIds.has(e.from) && keptIds.has(e.to)) {
33162
+ edges.push(e);
33163
+ continue;
33164
+ }
33165
+ if (e.relationId === void 0) continue;
33166
+ const from = retarget(e.from);
33167
+ const to = retarget(e.to);
33168
+ if (from === void 0 || to === void 0 || from === to) continue;
33169
+ edges.push({ ...e, from, to });
33170
+ }
32542
33171
  return {
32543
33172
  tier: "landscape",
32544
33173
  nodes: kept,