@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/index.js CHANGED
@@ -2345,6 +2345,16 @@ header input[type="search"]::placeholder { color:var(--dim); }
2345
2345
  #moreMenu .dropdown { display:block; width:100%; }
2346
2346
  #moreMenu .dropdown > .tbtn, #moreMenu > .tbtn { display:block; width:100%; text-align:left; border:none; background:transparent; margin:2px 0; }
2347
2347
  #moreMenu .dropdown > .tbtn:hover, #moreMenu > .tbtn:hover { background:var(--hover-bg); }
2348
+ /* Staged header compaction (responsive only, never persisted): compact
2349
+ stand-ins for the search input, the mode tabs, and the ancestor crumbs.
2350
+ All hidden at full width, so a roomy header renders exactly as before. */
2351
+ #searchBtn { position:relative; }
2352
+ #searchBtn.hasq::after { content:''; position:absolute; top:3px; right:3px; width:7px; height:7px; border-radius:50%; background:var(--accent); }
2353
+ .search-menu { min-width:210px; padding:8px; }
2354
+ .search-menu input[type="search"] { width:100%; }
2355
+ #modeMenu button.active { background:var(--accent); color:#fff; font-weight:700; }
2356
+ #crumbs .dropdown { display:inline-flex; }
2357
+ #crumbs .crumbmore { font-weight:700; }
2348
2358
 
2349
2359
  /* Settings panel \u2014 toggle switches */
2350
2360
  .settings-menu { min-width:266px; }
@@ -2468,9 +2478,17 @@ body.presentation #exitPresent, body.presentation #presentDetails { display:bloc
2468
2478
  <button data-vm="types">Types</button>
2469
2479
  <button data-vm="databases">Databases</button>
2470
2480
  </div>
2481
+ <div class="dropdown" id="modeDd" style="display:none">
2482
+ <button class="tbtn" id="modeBtn" title="Switch between the component architecture, the type ERD, or the database schemas">Components \u25BE</button>
2483
+ <div class="menu" id="modeMenu"></div>
2484
+ </div>
2471
2485
  <nav id="crumbs"></nav>
2472
2486
  <span class="divider"></span>
2473
2487
  <input id="search" type="search" placeholder="Search this view\u2026">
2488
+ <div class="dropdown" id="searchDd" style="display:none">
2489
+ <button class="tbtn" id="searchBtn" title="Search this view">\u{1F50D}</button>
2490
+ <div class="menu search-menu" id="searchMenu"></div>
2491
+ </div>
2474
2492
  <div class="seg" id="typesDetailSeg" style="display:none" title="ERD detail level">
2475
2493
  <button data-td="full">Full</button>
2476
2494
  <button data-td="fields">Fields</button>
@@ -2987,13 +3005,300 @@ var MODEL = __MODEL_JSON__;
2987
3005
  var BOX_W = 200, BOX_H = 56, SUBBOX_W = 230, SUBBOX_H = 84, GAP_X = 110, GAP_Y = 34;
2988
3006
  var INNER_W = 130, INNER_H = 36, INNER_GAPX = 26, INNER_GAPY = 12, HEAD_H = 34, PADI = 14;
2989
3007
 
3008
+ // ---- deep expansion (opt-in via subsystem.deepInternals) -------------------
3009
+ // When Internals is on and a subsystem record carries deepInternals:true, its
3010
+ // box renders its WHOLE subtree instead of one layer: deep-flagged child
3011
+ // subsystems become NESTED boundary boxes (recursing, defensively capped),
3012
+ // every other child renders as a fixed leaf tile that is never expanded
3013
+ // further. Sizes are computed bottom-up (a nested container tile takes its
3014
+ // recursive {w,h}); positions are emitted top-down by accumulating parent
3015
+ // top-left offsets. Relations between concrete visible endpoints are drawn
3016
+ // as ONE direct line each by buildDeepContext \u2014 crossing nested boundaries
3017
+ // on purpose (the full org overview of project relations); only relations
3018
+ // that cannot resolve to two concrete endpoints keep today's port machinery,
3019
+ // and ONLY at the outermost box. Without the flag this whole path is inert
3020
+ // and the classic one-layer innerLayout runs unchanged.
3021
+ var DEEP_MAX_DEPTH = 6;
3022
+ function isDeepId(subId) {
3023
+ var s = subById[subId];
3024
+ return !!(s && s.deepInternals);
3025
+ }
3026
+ // The DEEPEST visible tile representing compId inside the deep-expanded box
3027
+ // rooted at rootSubId: descend deep-flagged containers (the same expansion
3028
+ // rule as deepContainerLayout) until the containing child is a leaf tile.
3029
+ // Returns the child entry { kind, id } (its node id is IN(kind, id)).
3030
+ function deepLeafFor(compId, rootSubId) {
3031
+ var subId = rootSubId, depth = 1;
3032
+ for (;;) {
3033
+ var child = childOfScopeContaining(compId, { kind: 'subsystem', id: subId });
3034
+ if (!child) return null;
3035
+ if (child.kind === 'subsystem' && isDeepId(child.id) && depth < DEEP_MAX_DEPTH) {
3036
+ subId = child.id; depth += 1;
3037
+ continue;
3038
+ }
3039
+ return child;
3040
+ }
3041
+ }
3042
+ // One deep container's DIRECT children, placed with PER-TILE sizes (nested
3043
+ // containers take their recursive size; leaves stay INNER_W x INNER_H). The
3044
+ // placement mirrors innerLayout's strategy switch, generalised to variable
3045
+ // tile sizes. Tiles are centres relative to THIS container's top-left corner
3046
+ // (content sits right of PADI, below the HEAD_H label band); nested tiles
3047
+ // are relative to their own container, so emission accumulates offsets.
3048
+ function deepContainerLayout(subId, depth) {
3049
+ var kids = childrenOf({ kind: 'subsystem', id: subId });
3050
+ if (!kids.length) {
3051
+ // An EMPTY deep subsystem still shows as a (min-size) boundary box.
3052
+ return { tiles: [], w: INNER_W + 2 * PADI, h: HEAD_H + PADI };
3053
+ }
3054
+ var scope = { kind: 'subsystem', id: subId };
3055
+ var kidKey = function (k) { return k.kind + ':' + k.id; };
3056
+ var size = {};
3057
+ kids.forEach(function (k) {
3058
+ if (k.kind === 'subsystem' && isDeepId(k.id) && depth < DEEP_MAX_DEPTH) {
3059
+ var nested = deepContainerLayout(k.id, depth + 1);
3060
+ size[kidKey(k)] = { w: nested.w, h: nested.h, sub: nested };
3061
+ } else {
3062
+ size[kidKey(k)] = { w: INNER_W, h: INNER_H, sub: null };
3063
+ }
3064
+ });
3065
+ // Intra-container edges lifted to DIRECT children \u2014 for LAYOUT ONLY (the
3066
+ // drawn lines come from buildDeepContext's direct pass, never per level).
3067
+ var intra = {};
3068
+ MODEL.edges.forEach(function (edge) {
3069
+ var a = childOfScopeContaining(edge.from, scope);
3070
+ var b = childOfScopeContaining(edge.to, scope);
3071
+ if (!a || !b) return;
3072
+ var ak = a.kind + ':' + a.id, bk = b.kind + ':' + b.id;
3073
+ if (!size[ak] || !size[bk] || ak === bk) return;
3074
+ intra[ak + '=>' + bk] = 1;
3075
+ });
3076
+ var layer = {};
3077
+ function calc(k, stack) {
3078
+ var key = kidKey(k);
3079
+ if (layer[key] !== undefined) return layer[key];
3080
+ if (stack[key]) return 0;
3081
+ stack[key] = 1;
3082
+ var l = 0;
3083
+ if (k.kind === 'component') {
3084
+ var c = compById[k.id];
3085
+ if (c && (c.componentType === 'Portal' || c.componentType === 'Observer')) { layer[key] = 0; delete stack[key]; return 0; }
3086
+ }
3087
+ Object.keys(intra).forEach(function (ek) {
3088
+ var cut = ek.indexOf('=>');
3089
+ if (ek.slice(cut + 2) !== key) return;
3090
+ var srcKid = kids.filter(function (x) { return kidKey(x) === ek.slice(0, cut); })[0];
3091
+ if (srcKid) l = Math.max(l, calc(srcKid, stack) + 1);
3092
+ });
3093
+ delete stack[key];
3094
+ layer[key] = l;
3095
+ return l;
3096
+ }
3097
+ kids.forEach(function (k) { calc(k, {}); });
3098
+ var tiles = [], contentW = 0, contentH = 0;
3099
+ if (state.layout === 'grid') {
3100
+ // Row packing by ACTUAL tile size (the fixed per-column grid assumed
3101
+ // uniform tiles); the target row width follows the tile count, widened
3102
+ // to at least the widest single tile.
3103
+ var gsorted = kids.slice().sort(function (a, b) { return a.id < b.id ? -1 : 1; });
3104
+ var target = Math.max(1, Math.ceil(Math.sqrt(kids.length))) * (INNER_W + INNER_GAPX);
3105
+ kids.forEach(function (k) { var s0 = size[kidKey(k)]; if (s0.w > target) target = s0.w; });
3106
+ var gx = 0, gy = 0, rowH = 0;
3107
+ gsorted.forEach(function (k) {
3108
+ var s = size[kidKey(k)];
3109
+ if (gx > 0 && gx + s.w > target) { gx = 0; gy += rowH + INNER_GAPY; rowH = 0; }
3110
+ tiles.push({ kid: k, x: gx + s.w / 2, y: gy + s.h / 2, w: s.w, h: s.h, sub: s.sub });
3111
+ gx += s.w + INNER_GAPX;
3112
+ if (s.h > rowH) rowH = s.h;
3113
+ if (gx - INNER_GAPX > contentW) contentW = gx - INNER_GAPX;
3114
+ if (gy + rowH > contentH) contentH = gy + rowH;
3115
+ });
3116
+ } else if (state.layout === 'concentric' || state.layout === 'force') {
3117
+ var ideg = {};
3118
+ kids.forEach(function (k) { ideg[kidKey(k)] = 0; });
3119
+ Object.keys(intra).forEach(function (ek) {
3120
+ var cut2 = ek.indexOf('=>');
3121
+ var sk = ek.slice(0, cut2), tk = ek.slice(cut2 + 2);
3122
+ if (ideg[sk] !== undefined) ideg[sk]++;
3123
+ if (ideg[tk] !== undefined) ideg[tk]++;
3124
+ });
3125
+ var rel = concentricPositions(
3126
+ kids.map(kidKey),
3127
+ function (key) { return ideg[key] || 0; },
3128
+ function (key) { return { w: size[key].w, h: size[key].h }; }
3129
+ );
3130
+ // Normalise by the tiles' BOUNDING BOX (not just the centres) so a wide
3131
+ // nested container on the rim still clears the container's left/top pad.
3132
+ var minL = Infinity, minT = Infinity;
3133
+ kids.forEach(function (k) {
3134
+ var s1 = size[kidKey(k)], p1 = rel[kidKey(k)] || { x: 0, y: 0 };
3135
+ if (p1.x - s1.w / 2 < minL) minL = p1.x - s1.w / 2;
3136
+ if (p1.y - s1.h / 2 < minT) minT = p1.y - s1.h / 2;
3137
+ });
3138
+ if (minL === Infinity) { minL = 0; minT = 0; }
3139
+ kids.forEach(function (k) {
3140
+ var s2 = size[kidKey(k)], p2 = rel[kidKey(k)] || { x: 0, y: 0 };
3141
+ var cx = p2.x - minL, cyy = p2.y - minT;
3142
+ tiles.push({ kid: k, x: cx, y: cyy, w: s2.w, h: s2.h, sub: s2.sub });
3143
+ if (cx + s2.w / 2 > contentW) contentW = cx + s2.w / 2;
3144
+ if (cyy + s2.h / 2 > contentH) contentH = cyy + s2.h / 2;
3145
+ });
3146
+ } else {
3147
+ // Layered dependency columns: the column is as wide as its widest tile,
3148
+ // and each tile advances by ITS OWN height.
3149
+ var cols = {};
3150
+ kids.forEach(function (k) { var l = layer[kidKey(k)] || 0; (cols[l] = cols[l] || []).push(k); });
3151
+ var colKeys = Object.keys(cols).map(Number).sort(function (a, b) { return a - b; });
3152
+ var x = 0;
3153
+ colKeys.forEach(function (ck) {
3154
+ var col = cols[ck].sort(function (a, b) { return a.id < b.id ? -1 : 1; });
3155
+ var colW = 0, y = 0;
3156
+ col.forEach(function (k) { var s3 = size[kidKey(k)]; if (s3.w > colW) colW = s3.w; });
3157
+ col.forEach(function (k) {
3158
+ var s = size[kidKey(k)];
3159
+ tiles.push({ kid: k, x: x + colW / 2, y: y + s.h / 2, w: s.w, h: s.h, sub: s.sub });
3160
+ y += s.h + INNER_GAPY;
3161
+ });
3162
+ if (y - INNER_GAPY > contentH) contentH = y - INNER_GAPY;
3163
+ x += colW + INNER_GAPX;
3164
+ });
3165
+ contentW = x - INNER_GAPX;
3166
+ }
3167
+ tiles.forEach(function (t) { t.x += PADI; t.y += HEAD_H; });
3168
+ return { tiles: tiles, w: contentW + 2 * PADI, h: HEAD_H + contentH + PADI };
3169
+ }
3170
+ // Top-level deep box: the recursive interior plus today's port machinery at
3171
+ // the OUTERMOST box only (buildDeepContext supplies which relations still
3172
+ // need ports; stubs run port <-> the DEEPEST visible leaf tile). Returns the
3173
+ // same shape as innerLayout, plus deep:true so emission recurses.
3174
+ function deepLayout(entry, portRec) {
3175
+ var box = deepContainerLayout(entry.id, 1);
3176
+ var parentId = anchorNodeId(entry);
3177
+ var pBaseIn = 'p~in~' + parentId + '~', pBaseOut = 'p~out~' + parentId + '~';
3178
+ var extIn = portRec ? portRec.extIn : {}, extOut = portRec ? portRec.extOut : {};
3179
+ var inIds = Object.keys(extIn).sort(), outIds = Object.keys(extOut).sort();
3180
+ var hasIn = inIds.length > 0, hasOut = outIds.length > 0;
3181
+ var PROXY_W = 22, PROXY_H = 22, PROXY_GAP = 8;
3182
+ var shift = hasIn ? PROXY_W + INNER_GAPX : 0;
3183
+ var tiles = box.tiles;
3184
+ if (shift) tiles.forEach(function (t) { t.x += shift; });
3185
+ var w = box.w + shift + (hasOut ? PROXY_W + INNER_GAPX : 0);
3186
+ if (w < SUBBOX_W) w = SUBBOX_W;
3187
+ var stackMax = Math.max(inIds.length, outIds.length);
3188
+ var h = Math.max(box.h, HEAD_H + stackMax * PROXY_H + Math.max(0, stackMax - 1) * PROXY_GAP + PADI);
3189
+ var midY = HEAD_H + Math.max(0, (h - HEAD_H - PADI) / 2);
3190
+ function stackPorts(ids, recs, base, cx, dir) {
3191
+ var total = ids.length * PROXY_H + Math.max(0, ids.length - 1) * PROXY_GAP;
3192
+ var y0 = Math.max(HEAD_H + PROXY_H / 2, midY - total / 2 + PROXY_H / 2);
3193
+ return ids.map(function (eid, i) {
3194
+ var km = recs[eid].kids, klist = [];
3195
+ Object.keys(km).forEach(function (key) { klist.push(km[key]); });
3196
+ 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 };
3197
+ });
3198
+ }
3199
+ return {
3200
+ deep: true,
3201
+ tiles: tiles,
3202
+ edges: portRec ? Object.keys(portRec.stubs).map(function (k) { return portRec.stubs[k]; }) : [],
3203
+ proxies: stackPorts(inIds, extIn, pBaseIn, PADI + PROXY_W / 2, 'in')
3204
+ .concat(stackPorts(outIds, extOut, pBaseOut, w - PADI - PROXY_W / 2, 'out')),
3205
+ w: w,
3206
+ h: h,
3207
+ };
3208
+ }
3209
+ // View-level deep context (null unless Internals is on AND at least one
3210
+ // in-view entry is deep-flagged \u2014 the classic path never sees it): which
3211
+ // top-level entries are deep-expanded; every relation drawn as a DIRECT
3212
+ // concrete line (deduped per src=>tgt pair); the per-top-pair count used to
3213
+ // suppress aggregated edges whose constituents are ALL drawn directly; and
3214
+ // the port records for relations that keep today's port semantics.
3215
+ function buildDeepContext(scope, entries) {
3216
+ if (!state.internals) return null;
3217
+ var deepByAnchor = {}, any = false;
3218
+ entries.forEach(function (e) {
3219
+ if (e.kind === 'subsystem' && isDeepId(e.id)) { deepByAnchor[anchorNodeId(e)] = e; any = true; }
3220
+ });
3221
+ if (!any) return null;
3222
+ var entryByAnchor = {};
3223
+ entries.forEach(function (e) { entryByAnchor[e.kind + ':' + e.id] = e; });
3224
+ // A relation endpoint's DIRECT-line node in this view: the deepest leaf
3225
+ // tile inside a deep-expanded entry, or a top-level component box ITSELF.
3226
+ // null = this endpoint keeps aggregated/port semantics (interiors of
3227
+ // non-deep entries, out-of-scope counterparts).
3228
+ function directEnd(compId) {
3229
+ var child = childOfScopeContaining(compId, scope);
3230
+ var entry = child && entryByAnchor[child.kind + ':' + child.id];
3231
+ if (!entry) return null;
3232
+ var aid = anchorNodeId(entry);
3233
+ if (deepByAnchor[aid]) {
3234
+ var leaf = deepLeafFor(compId, entry.id);
3235
+ return leaf ? { node: IN(leaf.kind, leaf.id), top: aid, deep: true } : null;
3236
+ }
3237
+ if (entry.kind === 'component' && entry.id === compId) return { node: aid, top: aid, deep: false };
3238
+ return null;
3239
+ }
3240
+ var direct = {}, directTopCount = {}, ports = {};
3241
+ function portRec(aid) { return ports[aid] = ports[aid] || { extIn: {}, extOut: {}, stubs: {} }; }
3242
+ MODEL.edges.forEach(function (edge) {
3243
+ var a = directEnd(edge.from), b = directEnd(edge.to);
3244
+ if (a && b && (a.deep || b.deep) && a.node !== b.node) {
3245
+ // Drawn as ONE direct line \u2014 never ALSO as ports/stubs (dedupe rule).
3246
+ var key = a.node + '=>' + b.node;
3247
+ if (!direct[key]) direct[key] = { src: a.node, tgt: b.node, cross: false, aTop: a.top, bTop: b.top };
3248
+ if (edge.cross) direct[key].cross = true;
3249
+ if (a.top !== b.top) {
3250
+ var tk = a.top + '=>' + b.top;
3251
+ directTopCount[tk] = (directTopCount[tk] || 0) + 1;
3252
+ }
3253
+ return;
3254
+ }
3255
+ // Not a direct line: keep today's port semantics on any deep box with
3256
+ // exactly one endpoint inside its subtree, stubbed to the deepest leaf.
3257
+ var ac = childOfScopeContaining(edge.from, scope);
3258
+ var bc = childOfScopeContaining(edge.to, scope);
3259
+ var aEnt = ac && entryByAnchor[ac.kind + ':' + ac.id];
3260
+ var bEnt = bc && entryByAnchor[bc.kind + ':' + bc.id];
3261
+ var aAid = aEnt ? anchorNodeId(aEnt) : null;
3262
+ var bAid = bEnt ? anchorNodeId(bEnt) : null;
3263
+ if (aAid === bAid) return; // internal to one entry, or neither in scope
3264
+ if (aAid && deepByAnchor[aAid]) {
3265
+ var leafA = deepLeafFor(edge.from, aEnt.id);
3266
+ if (leafA) {
3267
+ var recA = portRec(aAid);
3268
+ var ro = recA.extOut[edge.to] = recA.extOut[edge.to] || { kids: {}, raws: {} };
3269
+ ro.kids[leafA.kind + ':' + leafA.id] = leafA;
3270
+ ro.raws[edge.from] = 1;
3271
+ var poId = 'p~out~' + aAid + '~' + edge.to;
3272
+ recA.stubs[IN(leafA.kind, leafA.id) + '=>' + poId] = { src: IN(leafA.kind, leafA.id), tgt: poId, stub: true };
3273
+ }
3274
+ }
3275
+ if (bAid && deepByAnchor[bAid]) {
3276
+ var leafB = deepLeafFor(edge.to, bEnt.id);
3277
+ if (leafB) {
3278
+ var recB = portRec(bAid);
3279
+ var ri = recB.extIn[edge.from] = recB.extIn[edge.from] || { kids: {}, raws: {} };
3280
+ ri.kids[leafB.kind + ':' + leafB.id] = leafB;
3281
+ ri.raws[edge.to] = 1;
3282
+ var piId = 'p~in~' + bAid + '~' + edge.from;
3283
+ recB.stubs[piId + '=>' + IN(leafB.kind, leafB.id)] = { src: piId, tgt: IN(leafB.kind, leafB.id), stub: true };
3284
+ }
3285
+ }
3286
+ });
3287
+ return { deepByAnchor: deepByAnchor, direct: direct, directTopCount: directTopCount, ports: ports };
3288
+ }
3289
+
2990
3290
  // Micro-layout for a container's direct children when Internals is on:
2991
3291
  // layered mini columns + intra-container edges. Each external relation gets
2992
3292
  // its own small PORT node INSIDE the container (one per external
2993
3293
  // counterpart; incoming left, outgoing right). Children connect to ports
2994
3294
  // with short edges that never leave the box \u2014 the real cross-boundary line
2995
3295
  // is only revealed on hover, or pinned while the port is selected.
2996
- function innerLayout(entry) {
3296
+ function innerLayout(entry, deepCtx) {
3297
+ // Deep-flagged subsystems take the recursive path (deepCtx exists only
3298
+ // when Internals is on and the view has deep entries \u2014 see buildElements).
3299
+ if (deepCtx && entry.kind === 'subsystem' && deepCtx.deepByAnchor[anchorNodeId(entry)]) {
3300
+ return deepLayout(entry, deepCtx.ports[anchorNodeId(entry)]);
3301
+ }
2997
3302
  var kids = state.internals && entry.hasKids ? childrenOf({ kind: entry.kind, id: entry.id }) : [];
2998
3303
  if (!kids.length) return null;
2999
3304
  var scope = { kind: entry.kind, id: entry.id };
@@ -3565,12 +3870,42 @@ var MODEL = __MODEL_JSON__;
3565
3870
  if (scope.kind === 'types' || scope.kind === 'databases') return buildTypeElements();
3566
3871
  var entries = childrenOf(scope);
3567
3872
  var eles = [];
3873
+ var deepCtx = buildDeepContext(scope, entries);
3568
3874
  var ve = viewEdges(scope, entries);
3569
3875
  // Data-coupling overlay: same scoping pipeline, a different edge source.
3570
3876
  var vd = state.dataCoupling ? viewEdges(scope, entries, MODEL.dataEdges) : { agg: {}, ghosts: {} };
3571
3877
  Object.keys(vd.ghosts).forEach(function (g) { if (!ve.ghosts[g]) ve.ghosts[g] = vd.ghosts[g]; });
3572
3878
  var inners = {};
3573
- entries.forEach(function (e) { inners[anchorNodeId(e)] = innerLayout(e); });
3879
+ entries.forEach(function (e) { inners[anchorNodeId(e)] = innerLayout(e, deepCtx); });
3880
+
3881
+ // Deep-mode recursive tile emission: a nested container becomes a cytoscape
3882
+ // compound parent (no explicit position \u2014 a compound derives its bounds
3883
+ // from its children); leaves and EMPTY containers are plain positioned
3884
+ // nodes. ox/oy = the emitting container's absolute top-left; tile.x/y are
3885
+ // centres relative to it, so offsets accumulate top-down. Search dimming
3886
+ // propagates the TOP entry's dim to the whole subtree.
3887
+ function emitDeepTiles(tiles, parentNodeId, ox, oy, dimCls) {
3888
+ tiles.forEach(function (tile) {
3889
+ var ax = ox + tile.x, ay = oy + tile.y;
3890
+ if (tile.sub) {
3891
+ var nid = SN(tile.kid.id);
3892
+ var selCls = state.selectedKind === 'subsystem' && state.selected === tile.kid.id ? ' sel' : '';
3893
+ var nested = {
3894
+ data: { id: nid, parent: parentNodeId, label: nameOf(tile.kid), w: tile.w, h: tile.h, tw: tile.w - 16 },
3895
+ classes: 'subsysBox' + (tile.sub.tiles.length ? ' drillable' : '') + dimCls + selCls,
3896
+ };
3897
+ if (!tile.sub.tiles.length) nested.position = { x: ax, y: ay };
3898
+ eles.push(nested);
3899
+ emitDeepTiles(tile.sub.tiles, nid, ax - tile.w / 2, ay - tile.h / 2, dimCls);
3900
+ } else {
3901
+ eles.push({
3902
+ 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 },
3903
+ position: { x: ax, y: ay },
3904
+ classes: 'inner' + dimCls,
3905
+ });
3906
+ }
3907
+ });
3908
+ }
3574
3909
 
3575
3910
  // Resolve a port's reveal target(s) in THIS view. Preference order: the
3576
3911
  // MATCHING PORT inside the counterpart's container (a port-to-port line
@@ -3702,17 +4037,25 @@ var MODEL = __MODEL_JSON__;
3702
4037
  + (state.showIssues && issuesBySpec[e.id] ? ' hasIssue' : '')
3703
4038
  + (state.selectedKind === e.kind && state.selected === e.id ? ' sel' : '');
3704
4039
  if (inner) {
3705
- 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 });
3706
- inner.tiles.forEach(function (tile) {
3707
- eles.push({
3708
- data: {
3709
- id: IN(tile.kid.kind, tile.kid.id), parent: aid,
3710
- label: nameOf(tile.kid), w: INNER_W, h: INNER_H, tw: INNER_W - 10,
3711
- },
3712
- position: { x: p.x - p.w / 2 + tile.x, y: p.y - p.h / 2 + tile.y },
3713
- classes: 'inner' + (dim ? ' dimmed' : ''),
4040
+ 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 };
4041
+ // An EMPTY deep boundary box has no children, so it is NOT a compound
4042
+ // parent \u2014 it needs (and honours) an explicit position and size.
4043
+ if (inner.deep && !inner.tiles.length && !(inner.proxies && inner.proxies.length)) boxNode.position = { x: p.x, y: p.y };
4044
+ eles.push(boxNode);
4045
+ if (inner.deep) {
4046
+ emitDeepTiles(inner.tiles, aid, p.x - p.w / 2, p.y - p.h / 2, dim ? ' dimmed' : '');
4047
+ } else {
4048
+ inner.tiles.forEach(function (tile) {
4049
+ eles.push({
4050
+ data: {
4051
+ id: IN(tile.kid.kind, tile.kid.id), parent: aid,
4052
+ label: nameOf(tile.kid), w: INNER_W, h: INNER_H, tw: INNER_W - 10,
4053
+ },
4054
+ position: { x: p.x - p.w / 2 + tile.x, y: p.y - p.h / 2 + tile.y },
4055
+ classes: 'inner' + (dim ? ' dimmed' : ''),
4056
+ });
3714
4057
  });
3715
- });
4058
+ }
3716
4059
  (inner.proxies || []).forEach(function (px) {
3717
4060
  eles.push({
3718
4061
  data: {
@@ -3865,9 +4208,28 @@ var MODEL = __MODEL_JSON__;
3865
4208
 
3866
4209
  var dimmedAnchors = {};
3867
4210
  entries.forEach(function (e) { if (state.query && !matches(e)) dimmedAnchors[anchorNodeId(e)] = true; });
4211
+
4212
+ // Deep mode: ONE direct line per related pair of concrete visible nodes \u2014
4213
+ // leaf tiles at any depth and/or top-level component boxes (deduped by
4214
+ // buildDeepContext). These lines cross nested boundaries on purpose.
4215
+ if (deepCtx) {
4216
+ var ddi = 0;
4217
+ Object.keys(deepCtx.direct).sort().forEach(function (key) {
4218
+ var d = deepCtx.direct[key];
4219
+ var ddim = state.query && (dimmedAnchors[d.aTop] || dimmedAnchors[d.bTop]);
4220
+ eles.push({
4221
+ data: { id: 'dd' + (ddi++), source: d.src, target: d.tgt, lbl: '' },
4222
+ classes: 'inneredge' + (d.cross ? ' cross' : '') + (ddim ? ' dimmed' : ''),
4223
+ });
4224
+ });
4225
+ }
4226
+
3868
4227
  var i = 0;
3869
4228
  Object.keys(ve.agg).forEach(function (key) {
3870
4229
  var e = ve.agg[key];
4230
+ // Prefer the leaf lines: drop an aggregated container edge whose
4231
+ // constituent relations were ALL drawn as direct deep lines above.
4232
+ if (deepCtx && deepCtx.directTopCount[key] >= e.n) return;
3871
4233
  var bundle = e.n > 1;
3872
4234
  var dim = state.query && (dimmedAnchors[e.src] || dimmedAnchors[e.tgt]);
3873
4235
  var route = routeData(e.src, e.tgt, key);
@@ -4126,22 +4488,58 @@ var MODEL = __MODEL_JSON__;
4126
4488
  }
4127
4489
  return path;
4128
4490
  }
4491
+ // Crumb compaction (compaction stage 5) is a RENDER MODE, not marker-based
4492
+ // reparenting: renderCrumbs rebuilds #crumbs' innerHTML on every navigation,
4493
+ // so nodes physically moved elsewhere would be destroyed by the next render.
4494
+ // The header-compaction stage toggles crumbsCompact and re-renders; compact
4495
+ // keeps the CURRENT scope visible and folds the ancestors into an ordered
4496
+ // "\\u2026" dropdown (document order, root first) whose entries navigate
4497
+ // exactly like the crumbs they replace.
4498
+ var crumbsCompact = false;
4499
+ var lastCrumbsHtml; // no initializer: the boot render at cy-init time precedes this line
4500
+ // Set by the header-compaction IIFE: crumb re-renders change the header's
4501
+ // CONTENT width without resizing #hdr itself (it is edge-anchored), so the
4502
+ // ResizeObserver never fires for them \u2014 renderCrumbs nudges a reflow here.
4503
+ var headerReflowHook = null;
4504
+ function crumbBtnHtml(p, cur) {
4505
+ return '<button class="crumb' + (cur ? ' cur' : '') + '" data-ck="' + p.kind + '" data-ci="' + (p.id || '') + '">' + p.label + '</button>';
4506
+ }
4129
4507
  function renderCrumbs() {
4130
4508
  var el = document.getElementById('crumbs');
4131
4509
  var path = crumbPath();
4132
- el.innerHTML = path.map(function (p, i) {
4133
- var cur = i === path.length - 1;
4134
- return '<button class="crumb' + (cur ? ' cur' : '') + '" data-ck="' + p.kind + '" data-ci="' + (p.id || '') + '">' + p.label + '</button>'
4135
- + (cur ? '' : '<span class="sep">\\u203A</span>');
4136
- }).join('');
4510
+ var html;
4511
+ if (crumbsCompact && path.length > 1) {
4512
+ html = '<span class="dropdown" id="crumbDd">'
4513
+ + '<button class="crumb crumbmore" id="crumbMoreBtn" title="Show the collapsed ancestor path">\\u2026</button>'
4514
+ + '<span class="menu" id="crumbMenu">'
4515
+ + path.slice(0, path.length - 1).map(function (p) {
4516
+ return '<button data-ck="' + p.kind + '" data-ci="' + (p.id || '') + '">' + p.label + '</button>';
4517
+ }).join('')
4518
+ + '</span></span>'
4519
+ + '<span class="sep">\\u203A</span>'
4520
+ + crumbBtnHtml(path[path.length - 1], true);
4521
+ } else {
4522
+ html = path.map(function (p, i) {
4523
+ var cur = i === path.length - 1;
4524
+ return crumbBtnHtml(p, cur) + (cur ? '' : '<span class="sep">\\u203A</span>');
4525
+ }).join('');
4526
+ }
4527
+ // No-op renders keep the already-wired nodes (and an open "\\u2026" menu)
4528
+ // intact \u2014 and don't churn the reflow scheduler while a search query types.
4529
+ if (html === lastCrumbsHtml) return;
4530
+ lastCrumbsHtml = html;
4531
+ el.innerHTML = html;
4137
4532
  var btns = el.querySelectorAll('button');
4138
4533
  for (var i = 0; i < btns.length; i++) {
4139
4534
  (function (b) {
4535
+ if (!b.getAttribute('data-ck')) return; // the "\\u2026" trigger toggles, never navigates
4140
4536
  b.addEventListener('click', function () {
4141
4537
  navigateTo(b.getAttribute('data-ck'), b.getAttribute('data-ci') || null);
4142
4538
  });
4143
4539
  })(btns[i]);
4144
4540
  }
4541
+ if (crumbsCompact && path.length > 1) wireDropdown('crumbDd', 'crumbMoreBtn');
4542
+ if (headerReflowHook) headerReflowHook();
4145
4543
  }
4146
4544
  function renderViewHint() {
4147
4545
  if (state.view.kind === 'types' || state.view.kind === 'databases') {
@@ -4408,7 +4806,16 @@ var MODEL = __MODEL_JSON__;
4408
4806
  return c ? { kind: 'subsystem', id: c.subsystem } : { kind: 'system', id: null };
4409
4807
  }
4410
4808
 
4411
- document.getElementById('search').addEventListener('input', function (ev) { state.query = ev.target.value.trim(); rebuild(false); });
4809
+ // While the search input is compacted behind the magnifier icon (compaction
4810
+ // stage 3), a non-empty query must stay discoverable \u2014 mark the icon with an
4811
+ // accent dot. The class is kept in sync on every query edit; the dot is only
4812
+ // ever visible while the compact icon itself is.
4813
+ function updateSearchBadge() {
4814
+ var b = document.getElementById('searchBtn');
4815
+ if (b && b.classList) b.classList[state.query ? 'add' : 'remove']('hasq');
4816
+ }
4817
+ document.getElementById('search').addEventListener('input', function (ev) { state.query = ev.target.value.trim(); updateSearchBadge(); rebuild(false); });
4818
+ updateSearchBadge();
4412
4819
  // Sync each View toggle's checkbox from the (possibly persisted) state, then
4413
4820
  // persist on change so the choices survive a refresh (see persist()/saved).
4414
4821
  document.getElementById('internalsToggle').checked = state.internals;
@@ -4462,9 +4869,24 @@ var MODEL = __MODEL_JSON__;
4462
4869
  })(btns[i]);
4463
4870
  }
4464
4871
  })();
4872
+ // Compaction stage 4 replaces the mode tabs with one dropdown trigger; its
4873
+ // label must follow the CURRENT mode. updateHeaderSegs runs on every rebuild,
4874
+ // so a mode change made while compact re-labels the trigger immediately.
4875
+ function updateModeBtn() {
4876
+ var b = document.getElementById('modeBtn');
4877
+ if (!b) return;
4878
+ var lbl = state.view.kind === 'types' ? 'Types' : state.view.kind === 'databases' ? 'Databases' : 'Components';
4879
+ b.textContent = lbl + ' \\u25BE';
4880
+ }
4465
4881
  function updateHeaderSegs() {
4466
4882
  var seg = document.getElementById('modeSeg');
4467
4883
  var btns = seg.querySelectorAll('button');
4884
+ if (!btns.length) {
4885
+ // Compaction stage 4 moved the real tab buttons into the mode dropdown \u2014
4886
+ // keep driving THEIR active classes there (they move back node-identical).
4887
+ var mm = document.getElementById('modeMenu');
4888
+ if (mm && mm.querySelectorAll) btns = mm.querySelectorAll('button');
4889
+ }
4468
4890
  for (var i = 0; i < btns.length; i++) {
4469
4891
  var vm = btns[i].getAttribute('data-vm');
4470
4892
  var active = vm === 'components'
@@ -4472,6 +4894,7 @@ var MODEL = __MODEL_JSON__;
4472
4894
  : vm === state.view.kind;
4473
4895
  if (btns[i].classList) btns[i].classList[active ? 'add' : 'remove']('active');
4474
4896
  }
4897
+ updateModeBtn();
4475
4898
  var td = document.getElementById('typesDetailSeg');
4476
4899
  td.style.display = (state.view.kind === 'types' || state.view.kind === 'databases') ? '' : 'none';
4477
4900
  var tbs = td.querySelectorAll('button');
@@ -4535,7 +4958,13 @@ var MODEL = __MODEL_JSON__;
4535
4958
  var r = btn.getBoundingClientRect();
4536
4959
  menu.style.top = (r.bottom + 6) + 'px';
4537
4960
  menu.style.left = 'auto';
4538
- menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px';
4961
+ // Right-aligned to the trigger, but never pushed off the LEFT edge \u2014 the
4962
+ // compact search / crumb triggers (header compaction) sit on the header's
4963
+ // left side, where a 200px menu right-aligned to a narrow button would clip.
4964
+ var right = Math.max(6, window.innerWidth - r.right);
4965
+ var mw = menu.getBoundingClientRect ? menu.getBoundingClientRect().width : 0;
4966
+ if (mw && window.innerWidth - right - mw < 6) right = Math.max(6, window.innerWidth - mw - 6);
4967
+ menu.style.right = right + 'px';
4539
4968
  }
4540
4969
  function wireDropdown(ddId, btnId) {
4541
4970
  var dd = document.getElementById(ddId);
@@ -4554,11 +4983,29 @@ var MODEL = __MODEL_JSON__;
4554
4983
  var ldd = wireDropdown('layoutDd', 'layoutBtn');
4555
4984
  var sdd = wireDropdown('settingsDd', 'settingsBtn');
4556
4985
  var mdd = wireDropdown('moreDd', 'moreBtn');
4986
+ // Compact stand-ins (header compaction stages 3-4): the search panel and the
4987
+ // mode-tab dropdown are ordinary dropdowns; their triggers stay hidden until
4988
+ // their compaction stage shows them, so wiring them here is inert at full width.
4989
+ var qdd = wireDropdown('searchDd', 'searchBtn');
4990
+ var vdd = wireDropdown('modeDd', 'modeBtn');
4991
+ // Opening the compact search panel focuses the REAL input (stage 3 moves the
4992
+ // node, never clones it, so its input listener keeps driving state.query).
4993
+ // Registered after wireDropdown's toggle, so 'open' reflects the new state.
4994
+ document.getElementById('searchBtn').addEventListener('click', function () {
4995
+ if (String(qdd.className || '').indexOf('open') >= 0) {
4996
+ var inp = document.getElementById('search');
4997
+ if (inp && inp.focus) inp.focus();
4998
+ }
4999
+ });
4557
5000
  // Keep the settings panel open while flipping switches (clicks inside it don't
4558
5001
  // bubble to the document-level close handler).
4559
5002
  (function () {
4560
5003
  var m = document.getElementById('settingsMenu');
4561
5004
  if (m && m.addEventListener) m.addEventListener('click', function (ev) { if (ev && ev.stopPropagation) ev.stopPropagation(); });
5005
+ // Same for the floating search panel: clicking into the input must not
5006
+ // bubble to the document-level close handler and shut the panel mid-typing.
5007
+ var sm = document.getElementById('searchMenu');
5008
+ if (sm && sm.addEventListener) sm.addEventListener('click', function (ev) { if (ev && ev.stopPropagation) ev.stopPropagation(); });
4562
5009
  })();
4563
5010
 
4564
5011
  // Layout picker: choose the auto-layout algorithm. Components use cytoscape's
@@ -4582,6 +5029,12 @@ var MODEL = __MODEL_JSON__;
4582
5029
  });
4583
5030
  updateLayoutBtn();
4584
5031
 
5032
+ // The compact crumb dropdown (compaction stage 5) is re-created by every
5033
+ // compact crumb render, so it is looked up per close instead of captured.
5034
+ function closeCrumbDd() {
5035
+ var cdd = document.getElementById('crumbDd');
5036
+ if (cdd && cdd.classList) cdd.classList.remove('open');
5037
+ }
4585
5038
  if (document.addEventListener) {
4586
5039
  document.addEventListener('click', function () {
4587
5040
  if (dd.classList) dd.classList.remove('open');
@@ -4589,6 +5042,9 @@ var MODEL = __MODEL_JSON__;
4589
5042
  if (ldd.classList) ldd.classList.remove('open');
4590
5043
  if (sdd.classList) sdd.classList.remove('open');
4591
5044
  if (mdd && mdd.classList) mdd.classList.remove('open');
5045
+ if (qdd && qdd.classList) qdd.classList.remove('open');
5046
+ if (vdd && vdd.classList) vdd.classList.remove('open');
5047
+ closeCrumbDd();
4592
5048
  });
4593
5049
  document.addEventListener('keydown', function (ev) {
4594
5050
  if (ev.key === 'Escape') {
@@ -4596,16 +5052,27 @@ var MODEL = __MODEL_JSON__;
4596
5052
  if (modal.classList && String(modal.className).indexOf('open') >= 0) { closeFlow(); return; }
4597
5053
  setPresentation(false);
4598
5054
  if (dd.classList) dd.classList.remove('open');
5055
+ if (qdd && qdd.classList) qdd.classList.remove('open');
5056
+ if (vdd && vdd.classList) vdd.classList.remove('open');
5057
+ closeCrumbDd();
4599
5058
  }
4600
5059
  });
4601
5060
  }
4602
5061
 
4603
- // \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
4604
- // When the floating header no longer fits its controls, trailing items
4605
- // COLLAPSE into the More menu instead of relying on horizontal scroll \u2014
4606
- // every control stays one click away. Whole items move (listeners survive
4607
- // reparenting); a hidden placeholder pins each item's original position so
4608
- // restoring keeps the exact order. Collapse order = least-used first.
5062
+ // \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
5063
+ // When the floating header no longer fits its controls, standard reusable
5064
+ // COMPACTION BEHAVIORS apply progressively \u2014 each stage only while the row
5065
+ // still overflows \u2014 and restore in REVERSE order when space returns:
5066
+ // 1. trailing buttons fold into the "\u22EF" menu (least-used first, one by one)
5067
+ // 2. the View dropdown folds in after them
5068
+ // 3. the search input compacts to a \u{1F50D} icon + floating panel
5069
+ // 4. the mode tabs compact to one current-mode dropdown
5070
+ // 5. ancestor crumbs compact into an ordered "\u2026" dropdown
5071
+ // Stages 1-2 move whole items (listeners survive reparenting) with a hidden
5072
+ // placeholder pinning each item's original spot for restore; stage 5 is a
5073
+ // render mode (renderCrumbs rebuilds its innerHTML, so reparenting would not
5074
+ // survive navigation). Nothing here is persisted \u2014 compaction is purely
5075
+ // responsive to the available width.
4609
5076
  (function () {
4610
5077
  if (typeof window === 'undefined') return;
4611
5078
  var hdr = document.getElementById('hdr');
@@ -4633,7 +5100,122 @@ var MODEL = __MODEL_JSON__;
4633
5100
  }
4634
5101
  return markers[id];
4635
5102
  }
4636
- var collapsed = [];
5103
+ // Fold stage (the classic behavior): move items into the "\u22EF" menu ONE per
5104
+ // apply() call \u2014 the reflow loop keeps a stage active until it reports no
5105
+ // further progress, preserving the original per-button granularity.
5106
+ function foldStage(ids) {
5107
+ var folded = [];
5108
+ return {
5109
+ apply: function () {
5110
+ while (folded.length < ids.length) {
5111
+ var id = ids[folded.length];
5112
+ var el = movableFor(id);
5113
+ if (!el || el === moreDd || el.parentNode === moreMenu) { folded.push({ el: null, marker: null }); continue; }
5114
+ var m = markerFor(id, el);
5115
+ // A dropdown moved while open would strand its fixed-positioned menu.
5116
+ if (el.classList) el.classList.remove('open');
5117
+ moreDd.style.display = '';
5118
+ moreMenu.appendChild(el);
5119
+ folded.push({ el: el, marker: m });
5120
+ return true;
5121
+ }
5122
+ return false;
5123
+ },
5124
+ restore: function () {
5125
+ for (var i = folded.length - 1; i >= 0; i--) {
5126
+ var it = folded[i];
5127
+ if (it.el && it.marker && it.marker.parentNode) it.marker.parentNode.insertBefore(it.el, it.marker);
5128
+ }
5129
+ folded = [];
5130
+ },
5131
+ };
5132
+ }
5133
+ // Stage 3: the search input compacts behind a \u{1F50D} icon; the REAL input node
5134
+ // MOVES into the floating panel (fixed-positioned by the same helper as
5135
+ // every dropdown menu), so its input listener keeps driving state.query.
5136
+ function searchStage() {
5137
+ var on = false;
5138
+ return {
5139
+ apply: function () {
5140
+ if (on) return false;
5141
+ var inp = document.getElementById('search');
5142
+ var ddw = document.getElementById('searchDd');
5143
+ var menu = document.getElementById('searchMenu');
5144
+ if (!inp || !ddw || !menu) return false;
5145
+ menu.appendChild(inp);
5146
+ ddw.style.display = '';
5147
+ on = true;
5148
+ return true;
5149
+ },
5150
+ restore: function () {
5151
+ if (!on) return;
5152
+ on = false;
5153
+ var inp = document.getElementById('search');
5154
+ var ddw = document.getElementById('searchDd');
5155
+ if (inp && ddw && ddw.parentNode) ddw.parentNode.insertBefore(inp, ddw);
5156
+ if (ddw) { ddw.style.display = 'none'; if (ddw.classList) ddw.classList.remove('open'); }
5157
+ },
5158
+ };
5159
+ }
5160
+ // Stage 4: the mode tabs collapse into ONE dropdown labelled with the
5161
+ // current mode. The REAL tab buttons move into its menu (listeners and
5162
+ // active styling survive); updateHeaderSegs keeps the trigger label in
5163
+ // sync when the mode changes while compact.
5164
+ function modeStage() {
5165
+ var moved = [];
5166
+ return {
5167
+ apply: function () {
5168
+ if (moved.length) return false;
5169
+ var seg = document.getElementById('modeSeg');
5170
+ var ddw = document.getElementById('modeDd');
5171
+ var menu = document.getElementById('modeMenu');
5172
+ if (!seg || !ddw || !menu) return false;
5173
+ var btns = seg.querySelectorAll('button');
5174
+ if (!btns.length) return false;
5175
+ for (var i = 0; i < btns.length; i++) moved.push(btns[i]);
5176
+ for (var j = 0; j < moved.length; j++) menu.appendChild(moved[j]);
5177
+ seg.style.display = 'none';
5178
+ ddw.style.display = '';
5179
+ updateModeBtn();
5180
+ return true;
5181
+ },
5182
+ restore: function () {
5183
+ if (!moved.length) return;
5184
+ var seg = document.getElementById('modeSeg');
5185
+ var ddw = document.getElementById('modeDd');
5186
+ for (var i = 0; i < moved.length; i++) seg.appendChild(moved[i]);
5187
+ moved = [];
5188
+ seg.style.display = '';
5189
+ if (ddw) { ddw.style.display = 'none'; if (ddw.classList) ddw.classList.remove('open'); }
5190
+ },
5191
+ };
5192
+ }
5193
+ // Stage 5: crumb compaction is a render-mode toggle consulted by
5194
+ // renderCrumbs itself \u2014 see crumbsCompact there. Never reparenting.
5195
+ function crumbStage() {
5196
+ return {
5197
+ apply: function () {
5198
+ if (crumbsCompact) return false;
5199
+ crumbsCompact = true;
5200
+ renderCrumbs();
5201
+ return true;
5202
+ },
5203
+ restore: function () {
5204
+ if (!crumbsCompact) return;
5205
+ crumbsCompact = false;
5206
+ renderCrumbs();
5207
+ },
5208
+ };
5209
+ }
5210
+ // Ordered compaction stages: applied first-to-last only while the header
5211
+ // overflows, restored last-to-first when space returns.
5212
+ var STAGES = [
5213
+ foldStage(COLLAPSE), // 1: trailing buttons \u2192 "\u22EF" menu
5214
+ foldStage(['settingsBtn']), // 2: the View dropdown folds in too
5215
+ searchStage(), // 3: search input \u2192 \u{1F50D} + floating panel
5216
+ modeStage(), // 4: mode tabs \u2192 current-mode dropdown
5217
+ crumbStage(), // 5: ancestor crumbs \u2192 "\u2026" dropdown
5218
+ ];
4637
5219
  // Signed fit measure in px: positive = overflowing, negative = headroom.
4638
5220
  // The header is a flex row whose ONLY flex:1 child is the .spacer, so the
4639
5221
  // spacer's rendered width IS the free space -- it grows to absorb all slack
@@ -4649,33 +5231,54 @@ var MODEL = __MODEL_JSON__;
4649
5231
  var slack = spacer ? spacer.getBoundingClientRect().width : 0;
4650
5232
  return (hdr.scrollWidth - hdr.clientWidth) - slack;
4651
5233
  }
5234
+ var inReflow = false;
4652
5235
  function reflow() {
4653
5236
  // Not laid out (hidden tab, non-browser DOM) \u2014 measuring would misfire.
4654
5237
  var box = hdr.getBoundingClientRect();
4655
5238
  if (!box || box.width <= 0) return;
4656
- // Restore everything, then collapse until the row fits (idempotent).
4657
- for (var i = collapsed.length - 1; i >= 0; i--) {
4658
- var it = collapsed[i];
4659
- if (it.el && it.marker && it.marker.parentNode) it.marker.parentNode.insertBefore(it.el, it.marker);
4660
- }
4661
- collapsed = [];
4662
- moreDd.style.display = 'none';
4663
- hdr.scrollLeft = 0;
4664
- var guard = 0;
4665
- // Demand a few px of headroom, not a bare fit \u2014 the marginal-fit widths
4666
- // are exactly where the phantom scrollbar appeared.
4667
- while (overflowPx() > -8 && guard < COLLAPSE.length) {
4668
- var id = COLLAPSE[guard++];
4669
- var el = movableFor(id);
4670
- if (!el || el === moreDd || el.parentNode === moreMenu) continue;
4671
- var m = markerFor(id, el);
4672
- // A dropdown moved while open would strand its fixed-positioned menu.
4673
- if (el.classList) el.classList.remove('open');
4674
- moreDd.style.display = '';
4675
- moreMenu.appendChild(el);
4676
- collapsed.push({ el: el, marker: m });
5239
+ inReflow = true;
5240
+ try {
5241
+ // The floating search panel must survive a reflow cycle: restore-all
5242
+ // would close it (and reparenting blurs the input), so capture its
5243
+ // open/focus state up front and reinstate it after the stage walk.
5244
+ var ddw = document.getElementById('searchDd');
5245
+ var inp = document.getElementById('search');
5246
+ var searchOpen = !!(ddw && String(ddw.className || '').indexOf('open') >= 0);
5247
+ var searchFocus = false;
5248
+ try {
5249
+ var ae = (typeof ROOT !== 'undefined' && ROOT ? ROOT : document).activeElement;
5250
+ searchFocus = !!(ae && inp && ae === inp);
5251
+ } catch (e) { /* stubbed DOM */ }
5252
+ // Restore every stage in REVERSE order, then re-apply progressively
5253
+ // while the row still overflows (idempotent).
5254
+ for (var i = STAGES.length - 1; i >= 0; i--) STAGES[i].restore();
5255
+ moreDd.style.display = 'none';
5256
+ hdr.scrollLeft = 0;
5257
+ // Demand a few px of headroom, not a bare fit \u2014 the marginal-fit widths
5258
+ // are exactly where the phantom scrollbar appeared. Every apply()
5259
+ // changes the very widths being measured, but restore-all + a strictly
5260
+ // forward stage walk make the outcome a pure function of the current
5261
+ // width, and reflow never reschedules itself (renderCrumbs' nudge is
5262
+ // suppressed via inReflow, and #hdr's own box never changes here), so
5263
+ // boundary widths settle in ONE pass instead of oscillating.
5264
+ var si = 0;
5265
+ var guard = 0;
5266
+ var bound = COLLAPSE.length + STAGES.length + 8;
5267
+ while (overflowPx() > -8 && si < STAGES.length && guard < bound) {
5268
+ guard++;
5269
+ if (!STAGES[si].apply()) si++;
5270
+ }
5271
+ if (searchOpen || searchFocus) {
5272
+ var compactNow = ddw && ddw.style && ddw.style.display !== 'none';
5273
+ if (compactNow && searchOpen) {
5274
+ if (ddw.classList) ddw.classList.add('open');
5275
+ positionDropdownMenu(ddw, document.getElementById('searchBtn'));
5276
+ }
5277
+ if (searchFocus && inp && inp.focus) inp.focus();
5278
+ }
5279
+ } finally {
5280
+ inReflow = false;
4677
5281
  }
4678
- if (collapsed.length === 0) moreDd.style.display = 'none';
4679
5282
  }
4680
5283
  var raf = null;
4681
5284
  var defer = window.requestAnimationFrame
@@ -4685,6 +5288,9 @@ var MODEL = __MODEL_JSON__;
4685
5288
  if (raf !== null) return;
4686
5289
  raf = defer(function () { raf = null; reflow(); });
4687
5290
  }
5291
+ // Crumb re-renders change the header's content width without resizing #hdr
5292
+ // itself \u2014 renderCrumbs nudges a reflow through this hook (no-op mid-reflow).
5293
+ headerReflowHook = function () { if (!inReflow) schedule(); };
4688
5294
  if (typeof ResizeObserver !== 'undefined') {
4689
5295
  new ResizeObserver(schedule).observe(hdr);
4690
5296
  } else if (window.addEventListener) {
@@ -15725,7 +16331,7 @@ function defaultTargetConfig(type) {
15725
16331
  enabled: true
15726
16332
  };
15727
16333
  }
15728
- var WAIRON_VERSION = "5.0.2-dev.11";
16334
+ var WAIRON_VERSION = "5.0.2-dev.13";
15729
16335
  var GITHUB_REPO = "SYW-Apps/Waffle-AIron";
15730
16336
  var ARCHITECT_AGENT_ID = "agent-architect";
15731
16337
  var ARCHITECT_TEMPLATE_ID = "architect";