capdag 0.174.430 → 0.176.436

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.
@@ -148,9 +148,219 @@ function cssVarNumber(name, fallback) {
148
148
  return parsed;
149
149
  }
150
150
 
151
+ // =============================================================================
152
+ // Label shaping — fit-to-content with a soft max width. The renderer's
153
+ // node sizing is `width: 'label'` / `height: 'label'` (the cytoscape style
154
+ // rule), which means each node grows or shrinks to its text. We pre-shape
155
+ // every label so:
156
+ //
157
+ // * a single short label produces a single short line (no preset width);
158
+ // * a label longer than the soft max wraps to a second line;
159
+ // * a label longer than two soft-max-width lines is truncated with a
160
+ // trailing horizontal ellipsis instead of overflowing or wrapping
161
+ // further. Ellipses appear only past the second line.
162
+ //
163
+ // Cytoscape's label-wrap implementation respects newlines and the
164
+ // `text-max-width` style rule, but it cannot truncate-after-N-lines on
165
+ // its own. So we do the wrap+truncate here in JS, set the label to the
166
+ // shaped string, and drop `text-max-width` from the stylesheet — the
167
+ // label text itself dictates the node's width.
168
+ // =============================================================================
169
+
170
+ // Soft node label width in pixels. Picked to keep nodes readable on the
171
+ // inline (non-expanded) panel while still tolerating the longer media
172
+ // titles. Edge labels are NOT wrapped; their length feeds the layout
173
+ // engine's between-layer spacing instead.
174
+ const NODE_LABEL_SOFT_MAX_WIDTH_PX = 160;
175
+ // Maximum number of wrapped lines for a node label. Beyond this we
176
+ // truncate with `…` rather than letting the label overflow.
177
+ const NODE_LABEL_MAX_LINES = 2;
178
+
179
+ // Shared offscreen canvas used for label width measurement. Lives at
180
+ // module scope so we don't churn the GC creating one per call.
181
+ let __sharedMeasureCtx = null;
182
+ function measureTextWidth(text, font) {
183
+ if (typeof document === 'undefined') {
184
+ // Non-DOM environment (e.g. node tests). Approximate with a
185
+ // monospace-ish constant so the wrap heuristic still produces
186
+ // sensible output and tests can exercise it without a canvas.
187
+ return text.length * 6.5;
188
+ }
189
+ if (__sharedMeasureCtx === null) {
190
+ const canvas = document.createElement('canvas');
191
+ __sharedMeasureCtx = canvas.getContext('2d');
192
+ }
193
+ __sharedMeasureCtx.font = font;
194
+ return __sharedMeasureCtx.measureText(text).width;
195
+ }
196
+
197
+ // Break `text` into chunks each of which fits within `maxWidth` when
198
+ // rendered with `font`. Word-aware: prefers to break at whitespace, but
199
+ // will hard-break a single word that is wider than `maxWidth`.
200
+ function wrapTextToWidth(text, font, maxWidth) {
201
+ if (text.length === 0) return [''];
202
+ const words = text.split(/(\s+)/); // keep the whitespace runs
203
+ const lines = [];
204
+ let current = '';
205
+ function pushCurrent() {
206
+ if (current.length > 0) {
207
+ lines.push(current);
208
+ current = '';
209
+ }
210
+ }
211
+ for (const piece of words) {
212
+ if (piece.length === 0) continue;
213
+ const candidate = current + piece;
214
+ if (measureTextWidth(candidate, font) <= maxWidth) {
215
+ current = candidate;
216
+ continue;
217
+ }
218
+ if (/^\s+$/.test(piece)) {
219
+ // The whitespace itself overflows — start a new line and skip it.
220
+ pushCurrent();
221
+ continue;
222
+ }
223
+ if (current.length === 0) {
224
+ // Single word wider than maxWidth — hard-break it character by
225
+ // character. The terminal piece becomes `current` for the next
226
+ // round so a subsequent short word can still join it.
227
+ let chunk = '';
228
+ for (const ch of piece) {
229
+ if (measureTextWidth(chunk + ch, font) <= maxWidth) {
230
+ chunk += ch;
231
+ } else {
232
+ if (chunk.length > 0) lines.push(chunk);
233
+ chunk = ch;
234
+ }
235
+ }
236
+ current = chunk;
237
+ } else {
238
+ pushCurrent();
239
+ current = piece.replace(/^\s+/, '');
240
+ }
241
+ }
242
+ pushCurrent();
243
+ return lines.length === 0 ? [''] : lines;
244
+ }
245
+
246
+ // Truncate a single line so that it fits within `maxWidth` once a
247
+ // trailing horizontal ellipsis has been appended. Used only for the
248
+ // final line of an overflowing wrapped label.
249
+ function truncateLineWithEllipsis(line, font, maxWidth) {
250
+ const ellipsis = '…';
251
+ if (measureTextWidth(line + ellipsis, font) <= maxWidth) return line + ellipsis;
252
+ let truncated = line;
253
+ while (truncated.length > 0 && measureTextWidth(truncated + ellipsis, font) > maxWidth) {
254
+ truncated = truncated.slice(0, -1);
255
+ }
256
+ return truncated + ellipsis;
257
+ }
258
+
259
+ // Shape a node label: fit-to-content with a soft cap, wrap to at most
260
+ // NODE_LABEL_MAX_LINES, and truncate the last line with `…` only when
261
+ // the underlying text would otherwise need a third line.
262
+ //
263
+ // Returns `{ shaped, widthPx }`. `widthPx` is the natural width of the
264
+ // shaped label (the longer of the two line widths), which the renderer
265
+ // uses to seed ELK's per-graph spacing.
266
+ function shapeNodeLabel(rawLabel, font) {
267
+ const text = (rawLabel == null) ? '' : String(rawLabel);
268
+ if (text.length === 0) return { shaped: '', widthPx: 0 };
269
+ // Honour pre-existing newlines (some payload builders embed them on
270
+ // purpose, e.g. body titles): wrap each segment separately and
271
+ // concatenate.
272
+ const segments = text.split('\n');
273
+ const lines = [];
274
+ for (const segment of segments) {
275
+ const wrapped = wrapTextToWidth(segment, font, NODE_LABEL_SOFT_MAX_WIDTH_PX);
276
+ for (const line of wrapped) lines.push(line);
277
+ }
278
+ if (lines.length <= NODE_LABEL_MAX_LINES) {
279
+ const widthPx = lines.reduce(
280
+ (acc, line) => Math.max(acc, measureTextWidth(line, font)),
281
+ 0
282
+ );
283
+ return { shaped: lines.join('\n'), widthPx };
284
+ }
285
+ const kept = lines.slice(0, NODE_LABEL_MAX_LINES - 1);
286
+ // Re-flow the leftover into the final line so the truncation
287
+ // happens at a natural boundary rather than mid-second-line.
288
+ const overflow = lines.slice(NODE_LABEL_MAX_LINES - 1).join(' ');
289
+ const finalLine = truncateLineWithEllipsis(
290
+ overflow, font, NODE_LABEL_SOFT_MAX_WIDTH_PX
291
+ );
292
+ kept.push(finalLine);
293
+ const widthPx = kept.reduce(
294
+ (acc, line) => Math.max(acc, measureTextWidth(line, font)),
295
+ 0
296
+ );
297
+ return { shaped: kept.join('\n'), widthPx };
298
+ }
299
+
300
+ // Walk the cytoscape elements list once, returning a NEW array in
301
+ // which every node has its `data.label` replaced by the shaped form.
302
+ // Edges and non-shape-relevant fields are passed through by reference
303
+ // to avoid an unnecessary clone — only the node objects whose labels
304
+ // we touched get shallow-copied (one level into `data`) so re-renders
305
+ // from the same source payload don't compound their shaping.
306
+ //
307
+ // Returns the metrics alongside the new elements so the renderer can
308
+ // thread them to the layout engine and the zoom backstop.
309
+ function shapeLabelsInElements(elements) {
310
+ // Cytoscape resolves node `font-size` against the element's computed
311
+ // style at render time; we shape against the renderer's static node
312
+ // font (defined in `buildStylesheet`). Kept in sync manually — bump
313
+ // both together.
314
+ const nodeFont = '500 9px "JetBrains Mono", ui-monospace, monospace';
315
+ const edgeFont = '500 8px "JetBrains Mono", ui-monospace, monospace';
316
+ let maxEdgeLabelPx = 0;
317
+ let maxNodeLabelPx = 0;
318
+ const reshaped = new Array(elements.length);
319
+ for (let i = 0; i < elements.length; i++) {
320
+ const element = elements[i];
321
+ if (!element || !element.data) {
322
+ reshaped[i] = element;
323
+ continue;
324
+ }
325
+ const isEdge = !!element.data.source && !!element.data.target;
326
+ if (isEdge) {
327
+ const edgeLabel = element.data.label;
328
+ const w = (typeof edgeLabel === 'string' && edgeLabel.length > 0)
329
+ ? measureTextWidth(edgeLabel, edgeFont)
330
+ : 0;
331
+ if (w > maxEdgeLabelPx) maxEdgeLabelPx = w;
332
+ // Stamp the per-edge label width onto the element's data so the
333
+ // post-layout per-edge stretcher can size each edge individually
334
+ // (rather than inflating the whole graph by the longest label).
335
+ reshaped[i] = Object.assign({}, element, {
336
+ data: Object.assign({}, element.data, { _labelPx: w }),
337
+ });
338
+ continue;
339
+ }
340
+ const { shaped, widthPx } = shapeNodeLabel(element.data.label, nodeFont);
341
+ if (widthPx > maxNodeLabelPx) maxNodeLabelPx = widthPx;
342
+ if (shaped === element.data.label) {
343
+ reshaped[i] = element;
344
+ } else {
345
+ reshaped[i] = Object.assign({}, element, {
346
+ data: Object.assign({}, element.data, { label: shaped }),
347
+ });
348
+ }
349
+ }
350
+ return { elements: reshaped, maxNodeLabelPx, maxEdgeLabelPx };
351
+ }
352
+
151
353
  // =============================================================================
152
354
  // Layout configs per mode. Same ELK algorithm; spacing is tuned per mode
153
355
  // to match the typical graph density and reading direction of each.
356
+ //
357
+ // We do NOT inflate `nodeNodeBetweenLayers` to fit the longest edge
358
+ // label — that punishes every short-labelled edge in the graph with
359
+ // pointless empty space. Instead, the renderer post-processes the
360
+ // laid-out positions in `_stretchLayersForEdgeLabels` to give each
361
+ // edge the horizontal room its own label needs, leaving short-labelled
362
+ // edges short. The per-mode defaults below are the floor; the
363
+ // stretcher only ever pushes nodes further apart, never closer.
154
364
  // =============================================================================
155
365
 
156
366
  function layoutForMode(mode) {
@@ -249,8 +459,13 @@ function buildStylesheet() {
249
459
  'label': 'data(label)',
250
460
  'text-valign': 'center',
251
461
  'text-halign': 'center',
462
+ // Labels are pre-shaped in JS (see `shapeNodeLabel`): wrapped
463
+ // to a soft max width with a 2-line cap and trailing ellipsis
464
+ // when the text would otherwise need a third line. We honour
465
+ // the embedded newlines but never re-wrap, so `text-max-width`
466
+ // is intentionally absent — `width: 'label'` then makes each
467
+ // node fit its actual shaped text.
252
468
  'text-wrap': 'wrap',
253
- 'text-max-width': '150px',
254
469
  'line-height': 1.3,
255
470
  'font-family': '"JetBrains Mono", ui-monospace, monospace',
256
471
  'font-size': '9px',
@@ -306,7 +521,11 @@ function buildStylesheet() {
306
521
  style: {
307
522
  'label': 'data(label)',
308
523
  'font-family': '"JetBrains Mono", ui-monospace, monospace',
309
- 'font-size': '9px',
524
+ // Slightly smaller than the node label font (9px) so edge
525
+ // labels read as secondary metadata rather than primary
526
+ // identity. Kept in sync with the `edgeFont` constant in
527
+ // `shapeLabelsInElements`.
528
+ 'font-size': '8px',
310
529
  'font-weight': '500',
311
530
  'color': 'data(color)',
312
531
  'text-background-color': edgeTextBg,
@@ -2088,6 +2307,16 @@ class CapFabRenderer {
2088
2307
  // Rust `Machine::to_render_payload_json`).
2089
2308
  this._machineBuilt = null;
2090
2309
 
2310
+ // True until the user first interacts with the graph (taps a
2311
+ // node/edge, drags, or zooms via wheel). While false, every
2312
+ // post-layout refit re-centers the entire graph in the viewport
2313
+ // without animation — so the very first paint, plus the 100/300ms
2314
+ // post-paint resize ticks (which catch container-size settling in
2315
+ // WebKit), all land at a stable centered fit. Flipped to true on
2316
+ // the first user gesture so subsequent refits respect selection,
2317
+ // path-mode focus, and animate normally.
2318
+ this._initialFitDone = false;
2319
+
2091
2320
  // Theme observer.
2092
2321
  this.themeObserver = new MutationObserver((mutations) => {
2093
2322
  for (const mutation of mutations) {
@@ -2175,12 +2404,24 @@ class CapFabRenderer {
2175
2404
  throw new Error('CapFabRenderer: container is missing');
2176
2405
  }
2177
2406
 
2178
- const elements = this._buildCytoscapeElements();
2179
- if (elements.length === 0) {
2407
+ const rawElements = this._buildCytoscapeElements();
2408
+ if (rawElements.length === 0) {
2180
2409
  this.container.innerHTML = '<div class="cap-fab-empty"><p>No graph data</p></div>';
2181
2410
  return this;
2182
2411
  }
2183
2412
 
2413
+ // Shape every node label up-front (fit-to-content with a 2-line
2414
+ // soft cap and trailing ellipsis past that) and measure the
2415
+ // longest edge label in pixels. The latter feeds into ELK's
2416
+ // between-layer spacing so edges are always long enough for their
2417
+ // labels. Both the per-node label width and the per-graph edge
2418
+ // label width are recorded on the renderer so the zoom-backstop
2419
+ // logic can reason about them after layout-stop.
2420
+ const labelMetrics = shapeLabelsInElements(rawElements);
2421
+ const elements = labelMetrics.elements;
2422
+ this._labelMaxNodeWidthPx = labelMetrics.maxNodeLabelPx;
2423
+ this._labelMaxEdgeWidthPx = labelMetrics.maxEdgeLabelPx;
2424
+
2184
2425
  // Clear container and size it to the window.
2185
2426
  this.container.innerHTML = '';
2186
2427
  this.container.style.width = window.innerWidth + 'px';
@@ -2203,18 +2444,63 @@ class CapFabRenderer {
2203
2444
  stop: function () {
2204
2445
  self.cy.resize();
2205
2446
  self._layoutReady = true;
2206
- if (self._pendingFocusCap) {
2447
+ // Per-edge stretch FIRST (mutates node x-coordinates so
2448
+ // each edge has the horizontal room its own label needs),
2449
+ // THEN lock the cytoscape wheel-zoom limits to the
2450
+ // dynamic per-graph values, THEN fit the (possibly
2451
+ // stretched) bounding box to the viewport so the user
2452
+ // always opens at a fully-visible padded fit. Order
2453
+ // matters: `_recomputeZoomLimits` needs the final
2454
+ // post-stretch bounding box; `fitToVisibleViewport` needs
2455
+ // the relaxed minZoom (it sits above the tight-fit zoom)
2456
+ // so its padding actually shows.
2457
+ self._stretchLayersForEdgeLabels();
2458
+ self._recomputeZoomLimits();
2459
+ const hadPendingFocus = !!self._pendingFocusCap;
2460
+ if (hadPendingFocus) {
2207
2461
  const pending = self._pendingFocusCap;
2208
2462
  self._pendingFocusCap = null;
2209
2463
  self.highlightCapability(pending);
2210
2464
  }
2211
- self.refitCurrentSelection();
2465
+ // First paint with no preselected focus: snap (no
2466
+ // animation) to a centered fit of the full, post-stretch
2467
+ // graph. Going through `_centerOnGraphInitial` rather
2468
+ // than `refitCurrentSelection` guarantees three things
2469
+ // on the bootstrap pass that the selection-aware refit
2470
+ // can't:
2471
+ // - never animate, so the user never sees a transient
2472
+ // un-centered state on the way to the final fit;
2473
+ // - always operate on the entire element set, so
2474
+ // per-edge stretching can't push the centered focus
2475
+ // off-viewport;
2476
+ // - reapply on every post-paint resize tick (see
2477
+ // `resizeAndRefit` below) until the user first
2478
+ // interacts, absorbing late WebKit container-size
2479
+ // settling without surprising a user who's already
2480
+ // scrolled or zoomed.
2481
+ //
2482
+ // If a focus cap WAS pending (browse-mode deep link from
2483
+ // capdag-dot-com), defer to the selection-aware refit so
2484
+ // the linked element lands centered instead.
2485
+ if (hadPendingFocus) {
2486
+ self.refitCurrentSelection();
2487
+ // Treat the deep-link landing as the user's chosen
2488
+ // viewport — late resize ticks shouldn't yank them
2489
+ // back to a fit-of-all.
2490
+ self._markInitialFitDone();
2491
+ } else {
2492
+ self._centerOnGraphInitial();
2493
+ }
2212
2494
  },
2213
2495
  }
2214
2496
  ),
2215
2497
  style: buildStylesheet(),
2216
- minZoom: 0.05,
2217
- maxZoom: 10,
2498
+ // Initial loose limits — `_recomputeZoomLimits()` tightens them
2499
+ // on every layout-stop and resize. We keep an absolute safety
2500
+ // floor/ceiling so a degenerate graph (zero bbox) can't make us
2501
+ // pass NaN/Infinity to cytoscape.
2502
+ minZoom: 0.01,
2503
+ maxZoom: 100,
2218
2504
  wheelSensitivity: 0.3,
2219
2505
  boxSelectionEnabled: false,
2220
2506
  autounselectify: this.mode === 'editor-graph' || this.mode === 'machine',
@@ -2223,17 +2509,343 @@ class CapFabRenderer {
2223
2509
  const resizeAndRefit = () => {
2224
2510
  if (!this.cy) return;
2225
2511
  this.cy.resize();
2226
- this.refitCurrentSelection();
2512
+ // Container size may have changed — recompute the zoom limits
2513
+ // so "fit the entire graph" still corresponds to actual pixel
2514
+ // capacity, not the size we had at first paint.
2515
+ this._recomputeZoomLimits();
2516
+ // Until the user first interacts with the graph we keep
2517
+ // re-centering on every resize tick. WebKit's container
2518
+ // dimensions can lag the layout-stop callback by a frame or
2519
+ // two (especially in the editor split view, where the graph
2520
+ // pane width depends on a CSS grid that's still resolving),
2521
+ // and the `setTimeout(_, 100/300)` ticks below are how we
2522
+ // catch those late settles without an animation. Once the
2523
+ // user has tapped, dragged, or zoomed we explicitly do NOT
2524
+ // touch their viewport on these late ticks — the `cy.resize()`
2525
+ // and `_recomputeZoomLimits` calls above are enough to keep
2526
+ // the engine internally consistent with the new container
2527
+ // size; yanking them back to a fit would be a hostile
2528
+ // surprise.
2529
+ if (!this._initialFitDone) {
2530
+ this._centerOnGraphInitial();
2531
+ }
2227
2532
  };
2228
2533
  this.cy.on('ready', resizeAndRefit);
2534
+ this.cy.on('resize', () => this._recomputeZoomLimits());
2229
2535
  requestAnimationFrame(resizeAndRefit);
2230
2536
  setTimeout(resizeAndRefit, 100);
2231
2537
  setTimeout(resizeAndRefit, 300);
2232
2538
 
2233
2539
  this._setupEventHandlers();
2540
+ this._installZoomBackstop();
2234
2541
  return this;
2235
2542
  }
2236
2543
 
2544
+ // ===========================================================================
2545
+ // Zoom backstop — dynamic per-graph minimum and maximum zoom levels.
2546
+ //
2547
+ // Minimum zoom = the zoom at which the entire graph's bounding box just
2548
+ // fits inside the container. Below that the user is asking for more
2549
+ // blank canvas, which is what the parent scroll view should be doing.
2550
+ // Maximum zoom = the zoom at which a representative ("default") node
2551
+ // would occupy more than a quarter of the *bigger* viewport dimension.
2552
+ // Past that the user is zoomed in past the point where any single
2553
+ // node still fits visually, so further wheel events are forwarded
2554
+ // to the parent responder instead of continuing to zoom.
2555
+ //
2556
+ // The wheel listener reports `{ atLimit, zoomingOut }` to
2557
+ // `interaction.onZoomLimit` on every wheel event so the host can
2558
+ // latch the direction and forward subsequent wheel events up the
2559
+ // responder chain (see `ScrollPassthroughWebView` on the Swift side).
2560
+ // ===========================================================================
2561
+
2562
+ // Slack below the strict "graph fits the viewport" zoom that the
2563
+ // backstop allows. With the strict zoom (1.0× of fit) the graph
2564
+ // touches all four viewport edges; the user expects a little visual
2565
+ // padding at the zoomed-out limit, so we let cytoscape zoom out a
2566
+ // further `1 - ZOOM_OUT_FIT_SLACK` of fit before forwarding the
2567
+ // wheel to the parent responder. Picked to match the padding
2568
+ // `fitToVisibleViewport(undefined, 50)` produces on a typical
2569
+ // viewport (≈15% slack at 800×600).
2570
+ static get ZOOM_OUT_FIT_SLACK() { return 0.15; }
2571
+
2572
+ // Bootstrap fit: snap (no animation) to a centered, padded fit of
2573
+ // the entire graph. Used during the first paint and the post-paint
2574
+ // resize ticks while `_initialFitDone` is false. The padding here
2575
+ // matches `fitToVisibleViewport(undefined, 50)` so the visual
2576
+ // result is identical to the steady-state refit, just without the
2577
+ // animation and without the selection-aware branching.
2578
+ //
2579
+ // Math: ELK lays the graph out at an arbitrary pan; we override
2580
+ // both zoom and pan in one synchronous pair so the user never sees
2581
+ // an intermediate state. We deliberately read the bbox AFTER
2582
+ // `_stretchLayersForEdgeLabels` has run (the caller's
2583
+ // responsibility) so the centering accounts for the per-edge
2584
+ // stretch — without that, a graph whose layers shifted right would
2585
+ // appear hugging the right edge of the viewport.
2586
+ _centerOnGraphInitial() {
2587
+ if (!this.cy) return;
2588
+ const cy = this.cy;
2589
+ const containerWidth = cy.width();
2590
+ const containerHeight = cy.height();
2591
+ if (containerWidth <= 0 || containerHeight <= 0) return;
2592
+ const elements = cy.elements();
2593
+ if (elements.length === 0) return;
2594
+ const bb = elements.boundingBox();
2595
+ if (bb.w === 0 && bb.h === 0) return;
2596
+
2597
+ const padding = 50;
2598
+ const excluded = Math.max(0, this.bottomExcludedRegion() | 0);
2599
+ const visibleWidth = containerWidth - padding * 2;
2600
+ const visibleHeight = containerHeight - excluded - padding * 2;
2601
+ if (visibleWidth <= 0 || visibleHeight <= 0) return;
2602
+
2603
+ const fitZoom = Math.min(visibleWidth / bb.w, visibleHeight / bb.h);
2604
+ // Clamp to the dynamic per-graph limits set by
2605
+ // `_recomputeZoomLimits` so the centered fit can't exceed them
2606
+ // (the relaxed minZoom is `strictFit * (1 - ZOOM_OUT_FIT_SLACK)`,
2607
+ // which sits a hair below `fitZoom` for typical viewports — so
2608
+ // the clamp is usually a no-op, but we still apply it for
2609
+ // tiny-viewport degenerate cases).
2610
+ const clampedZoom = Math.min(Math.max(fitZoom, cy.minZoom()), cy.maxZoom());
2611
+
2612
+ const modelCenterX = (bb.x1 + bb.x2) / 2;
2613
+ const modelCenterY = (bb.y1 + bb.y2) / 2;
2614
+ const screenCenterX = containerWidth / 2;
2615
+ const screenCenterY = (containerHeight - excluded) / 2;
2616
+ const panX = screenCenterX - modelCenterX * clampedZoom;
2617
+ const panY = screenCenterY - modelCenterY * clampedZoom;
2618
+
2619
+ // Stop any in-flight animation before snapping — otherwise a
2620
+ // late-arriving `cy.animate` from an earlier path could fight
2621
+ // our zoom/pan write and leave the graph drifting.
2622
+ cy.stop(true);
2623
+ this._internalPanZoom = true;
2624
+ try {
2625
+ cy.zoom(clampedZoom);
2626
+ cy.pan({ x: panX, y: panY });
2627
+ } finally {
2628
+ this._internalPanZoom = false;
2629
+ }
2630
+ }
2631
+
2632
+ // Mark the bootstrap centering as complete so subsequent refits
2633
+ // respect selection state, path-mode focus, and animation.
2634
+ // Idempotent — safe to call from every interaction handler.
2635
+ _markInitialFitDone() {
2636
+ if (this._initialFitDone) return;
2637
+ this._initialFitDone = true;
2638
+ }
2639
+
2640
+ _recomputeZoomLimits() {
2641
+ if (!this.cy) return;
2642
+ const w = this.cy.width();
2643
+ const h = this.cy.height();
2644
+ if (w <= 0 || h <= 0) return;
2645
+ // A "default node" is what a typical (single-line, average-width)
2646
+ // node looks like in this graph. We use the longest shaped node
2647
+ // label width measured during element construction, falling back
2648
+ // to a sensible constant when the graph has no labelled nodes
2649
+ // (browse-mode title bars, blank slates, etc.).
2650
+ const fallbackNodeWidthPx = 120;
2651
+ const defaultNodePx = Math.max(
2652
+ fallbackNodeWidthPx,
2653
+ (this._labelMaxNodeWidthPx || 0) + /* node padding × 2 */ 24
2654
+ );
2655
+ // Maximum zoom: one default node fills > 1/4 of the bigger
2656
+ // viewport dimension. Solving for the boundary:
2657
+ // defaultNodePx * zoomMax = max(w, h) / 4
2658
+ const biggerDim = Math.max(w, h);
2659
+ const zoomMax = (biggerDim / 4) / defaultNodePx;
2660
+ // Minimum zoom: the entire graph's bounding box fits, with a
2661
+ // small slack so the user can pull back a bit further for visual
2662
+ // padding before the parent-scroll forwarding kicks in. The
2663
+ // strict-fit zoom is `min(w/bb.w, h/bb.h)`; we multiply by
2664
+ // `(1 - ZOOM_OUT_FIT_SLACK)` to relax it. The initial
2665
+ // `fitToVisibleViewport(undefined, 50)` lands at a padded zoom
2666
+ // that sits comfortably above this relaxed minimum, so opening
2667
+ // the view shows the graph centred with margin rather than
2668
+ // bleeding to all four edges.
2669
+ const bb = this.cy.elements().boundingBox();
2670
+ let zoomMin;
2671
+ if (bb.w > 0 && bb.h > 0) {
2672
+ const strictFit = Math.min(w / bb.w, h / bb.h);
2673
+ zoomMin = strictFit * (1 - CapFabRenderer.ZOOM_OUT_FIT_SLACK);
2674
+ } else {
2675
+ // Empty / degenerate graph — leave the min loose; there's
2676
+ // nothing to fit.
2677
+ zoomMin = 0.05;
2678
+ }
2679
+ // Order-preserving guard: a graph small enough to fit the
2680
+ // viewport at any zoom would otherwise produce zoomMin > zoomMax.
2681
+ // Pin them together so cytoscape's internal `setZoom` clamp can't
2682
+ // throw or oscillate.
2683
+ if (zoomMin > zoomMax) zoomMin = zoomMax;
2684
+ // Avoid infinitesimal / non-finite values reaching cytoscape.
2685
+ if (!Number.isFinite(zoomMin) || zoomMin <= 0) zoomMin = 0.01;
2686
+ if (!Number.isFinite(zoomMax) || zoomMax <= 0) zoomMax = 100;
2687
+ this._dynamicMinZoom = zoomMin;
2688
+ this._dynamicMaxZoom = zoomMax;
2689
+ this.cy.minZoom(zoomMin);
2690
+ this.cy.maxZoom(zoomMax);
2691
+ }
2692
+
2693
+ // ===========================================================================
2694
+ // Per-edge layer stretching.
2695
+ //
2696
+ // ELK's layered algorithm assigns each node to a discrete layer; every
2697
+ // edge between layers L and L+1 gets the same horizontal length (the
2698
+ // `nodeNodeBetweenLayers` spacing). That means we can size the gap per
2699
+ // pair of consecutive layers, but not per individual edge. We do that
2700
+ // here, after the layout has run: the source-side x-coordinate of
2701
+ // each layer's nodes is shifted right just enough that every incoming
2702
+ // edge has room for its own label, plus a small padding allowance.
2703
+ // Edges with short labels keep the engine's tight default spacing;
2704
+ // edges with long labels push their target layer (and every layer
2705
+ // downstream) further to the right.
2706
+ //
2707
+ // Algorithm:
2708
+ // 1. Snap nodes into layer buckets by their post-layout x.
2709
+ // 2. Walk the buckets left-to-right.
2710
+ // 3. For each edge whose source is in the previous layer and target
2711
+ // in the current one, compute the minimum target-x that would
2712
+ // give the edge label clearance:
2713
+ // srcEdge.x + sourceWidth/2 + targetWidth/2 + labelPx + pad
2714
+ // Take the max across the layer's incoming edges.
2715
+ // 4. If that max exceeds the layer's current x, shift every node in
2716
+ // the layer (and only that layer) right by the difference.
2717
+ //
2718
+ // Cross-layer edges (skipping a layer) are accounted for by the same
2719
+ // walk — the per-layer max is computed over every edge that lands in
2720
+ // the layer, regardless of how many layers it skipped.
2721
+ // ===========================================================================
2722
+
2723
+ _stretchLayersForEdgeLabels() {
2724
+ if (!this.cy) return;
2725
+ const cy = this.cy;
2726
+ const nodes = cy.nodes();
2727
+ if (nodes.length === 0) return;
2728
+
2729
+ // Padding around an edge label so its background and the source/
2730
+ // target node's right/left edges don't touch. Sized to the
2731
+ // stylesheet's `text-background-padding: 4px` plus arrow-head
2732
+ // clearance and a few pixels of breathing room.
2733
+ const LABEL_GAP_PX = 24;
2734
+ // Layer-bucketing tolerance. Nodes within `EPS` of each other on x
2735
+ // are treated as one layer. ELK places nodes within a layer at the
2736
+ // same x within rounding error.
2737
+ const EPS = 1.0;
2738
+
2739
+ // Group nodes into ordered layer buckets by current x.
2740
+ const sortedNodes = nodes.toArray().slice().sort(
2741
+ (a, b) => a.position('x') - b.position('x')
2742
+ );
2743
+ const layers = [];
2744
+ for (const node of sortedNodes) {
2745
+ const x = node.position('x');
2746
+ const last = layers.length > 0 ? layers[layers.length - 1] : null;
2747
+ if (last !== null && Math.abs(last.x - x) <= EPS) {
2748
+ last.nodes.push(node);
2749
+ } else {
2750
+ layers.push({ x, nodes: [node] });
2751
+ }
2752
+ }
2753
+ if (layers.length < 2) return; // Nothing to stretch.
2754
+
2755
+ // Build a quick layer-index lookup so we can ask "what layer is
2756
+ // this node in?" in O(1) when iterating edges.
2757
+ const layerIndexById = new Map();
2758
+ for (let li = 0; li < layers.length; li++) {
2759
+ for (const n of layers[li].nodes) layerIndexById.set(n.id(), li);
2760
+ }
2761
+
2762
+ // Pre-bucket edges by their target layer so the per-layer pass
2763
+ // below is O(layer-edges) rather than O(all-edges) per layer.
2764
+ const incomingByTarget = layers.map(() => []);
2765
+ cy.edges().forEach((edge) => {
2766
+ const srcLayer = layerIndexById.get(edge.source().id());
2767
+ const tgtLayer = layerIndexById.get(edge.target().id());
2768
+ if (srcLayer === undefined || tgtLayer === undefined) return;
2769
+ if (tgtLayer <= srcLayer) return; // Back-edge or self-loop.
2770
+ incomingByTarget[tgtLayer].push(edge);
2771
+ });
2772
+
2773
+ // Walk layers left-to-right. For each layer, compute the minimum
2774
+ // x its nodes need based on the CURRENT positions of source-layer
2775
+ // nodes (which already include any shift from earlier iterations).
2776
+ // Only ever shift right — never compress. This way a long-labelled
2777
+ // edge between layers k and k+1 stretches only the gap between
2778
+ // those two layers, while every later layer shifts right by the
2779
+ // same amount as a side-effect (so layer k+2 sits at its own
2780
+ // tight default distance from k+1, never inflating the whole
2781
+ // graph by the longest single label).
2782
+ for (let li = 1; li < layers.length; li++) {
2783
+ let minLayerX = layers[li].x;
2784
+ for (const edge of incomingByTarget[li]) {
2785
+ const labelPx = (typeof edge.data('_labelPx') === 'number')
2786
+ ? edge.data('_labelPx')
2787
+ : 0;
2788
+ const srcNode = edge.source();
2789
+ const tgtNode = edge.target();
2790
+ // `outerWidth` includes the node's border so the gap sits
2791
+ // outside the visible node, not under it.
2792
+ const srcHalf = srcNode.outerWidth() / 2;
2793
+ const tgtHalf = tgtNode.outerWidth() / 2;
2794
+ const srcRightEdge = srcNode.position('x') + srcHalf;
2795
+ const candidate = srcRightEdge + LABEL_GAP_PX + labelPx + LABEL_GAP_PX + tgtHalf;
2796
+ if (candidate > minLayerX) minLayerX = candidate;
2797
+ }
2798
+ if (minLayerX > layers[li].x) {
2799
+ const dx = minLayerX - layers[li].x;
2800
+ for (const node of layers[li].nodes) {
2801
+ const p = node.position();
2802
+ node.position({ x: p.x + dx, y: p.y });
2803
+ }
2804
+ layers[li].x += dx;
2805
+ // Cascade: every later layer must shift right by at least the
2806
+ // same dx so the relative spacing produced by ELK between them
2807
+ // is preserved. The per-layer recomputation above will only
2808
+ // *add* to that, never subtract.
2809
+ for (let lj = li + 1; lj < layers.length; lj++) {
2810
+ for (const node of layers[lj].nodes) {
2811
+ const p = node.position();
2812
+ node.position({ x: p.x + dx, y: p.y });
2813
+ }
2814
+ layers[lj].x += dx;
2815
+ }
2816
+ }
2817
+ }
2818
+ }
2819
+
2820
+ _installZoomBackstop() {
2821
+ if (!this.container) return;
2822
+ if (this._zoomBackstopInstalled) return;
2823
+ this._zoomBackstopInstalled = true;
2824
+ const self = this;
2825
+ // `passive: true` because we never call preventDefault — we only
2826
+ // observe and report. Cytoscape's own wheel listener (registered
2827
+ // separately on the same container) does the actual zoom and
2828
+ // honours the dynamic min/max we set on `cy`.
2829
+ this.container.addEventListener('wheel', function (evt) {
2830
+ if (!self.cy) return;
2831
+ const min = self._dynamicMinZoom;
2832
+ const max = self._dynamicMaxZoom;
2833
+ if (typeof min !== 'number' || typeof max !== 'number') return;
2834
+ const currentZoom = self.cy.zoom();
2835
+ // Tolerances to absorb the tiny rounding cytoscape's own
2836
+ // clamping introduces — without them we never report
2837
+ // at-limit because the actual zoom sits a few ulps inside.
2838
+ const atMin = currentZoom <= min * 1.01;
2839
+ const atMax = currentZoom >= max * 0.99;
2840
+ const zoomingOut = evt.deltaY > 0;
2841
+ const zoomingIn = evt.deltaY < 0;
2842
+ const atLimit = (zoomingOut && atMin) || (zoomingIn && atMax);
2843
+ if (typeof self.interaction.onZoomLimit === 'function') {
2844
+ self.interaction.onZoomLimit({ atLimit, zoomingOut });
2845
+ }
2846
+ }, { passive: true });
2847
+ }
2848
+
2237
2849
  _buildCytoscapeElements() {
2238
2850
  if (this.mode === 'browse') {
2239
2851
  return browseCytoscapeElements({
@@ -2271,6 +2883,21 @@ class CapFabRenderer {
2271
2883
  _setupEventHandlers() {
2272
2884
  const self = this;
2273
2885
 
2886
+ // First user interaction — tap, zoom, drag — flips
2887
+ // `_initialFitDone` so subsequent post-paint resize ticks stop
2888
+ // re-centering and let the user keep their viewport. Hooked here
2889
+ // (cytoscape-side) for taps and zoom; the wheel-based zoom path
2890
+ // also flips it from `_installZoomBackstop`.
2891
+ //
2892
+ // The zoom/pan listener checks `_internalPanZoom` so that
2893
+ // renderer-initiated centering (`_centerOnGraphInitial`,
2894
+ // `fitToVisibleViewport`) doesn't trip the flag — only genuine
2895
+ // user-initiated viewport changes do.
2896
+ this.cy.on('tap', function () { self._markInitialFitDone(); });
2897
+ this.cy.on('zoom pan', function () {
2898
+ if (!self._internalPanZoom) self._markInitialFitDone();
2899
+ });
2900
+
2274
2901
  this.cy.on('tap', 'node', function (evt) {
2275
2902
  evt.stopPropagation();
2276
2903
  self._handleNodeTap(evt.target);
@@ -2765,16 +3392,27 @@ class CapFabRenderer {
2765
3392
  const panX = screenCenterX - modelCenterX * clampedZoom;
2766
3393
  const panY = screenCenterY - modelCenterY * clampedZoom;
2767
3394
 
3395
+ // Same `_internalPanZoom` guard as `_centerOnGraphInitial` — a
3396
+ // renderer-driven fit (highlight, dbltap, refit-after-resize)
3397
+ // must not be mistaken for a user gesture, or the bootstrap
3398
+ // re-centering ticks below would stop firing prematurely.
3399
+ this._internalPanZoom = true;
2768
3400
  if (animate) {
2769
3401
  this.cy.animate({
2770
3402
  zoom: clampedZoom,
2771
3403
  pan: { x: panX, y: panY },
2772
3404
  duration: 400,
2773
3405
  easing: 'ease-out-cubic',
3406
+ complete: () => { this._internalPanZoom = false; },
3407
+ stop: () => { this._internalPanZoom = false; },
2774
3408
  });
2775
3409
  } else {
2776
- this.cy.zoom(clampedZoom);
2777
- this.cy.pan({ x: panX, y: panY });
3410
+ try {
3411
+ this.cy.zoom(clampedZoom);
3412
+ this.cy.pan({ x: panX, y: panY });
3413
+ } finally {
3414
+ this._internalPanZoom = false;
3415
+ }
2778
3416
  }
2779
3417
  }
2780
3418
 
package/capdag.js CHANGED
@@ -1086,16 +1086,22 @@ const MEDIA_AVAILABILITY_OUTPUT = 'media:model-availability;record;textable';
1086
1086
  const MEDIA_PATH_OUTPUT = 'media:model-path;record;textable';
1087
1087
  // Media URN for embedding vector output - has record marker
1088
1088
  const MEDIA_EMBEDDING_VECTOR = 'media:embedding-vector;record;textable';
1089
- // Media URN for LLM inference output - has record marker
1090
- const MEDIA_LLM_INFERENCE_OUTPUT = 'media:generated-text;record;textable';
1091
- // Media URN for vision inference output - textable, scalar by default
1092
- const MEDIA_IMAGE_DESCRIPTION = 'media:image-description;textable';
1089
+ // Media URN for vision inference output a concrete textable terminal.
1090
+ // Carries `image-description` (the vision-specific marker), `plain-text` (the
1091
+ // finalised-text marker that opts into cap:save-as-txt's persistence path),
1092
+ // and `file-type=txt` (binds the URN to the `.txt` extension).
1093
+ const MEDIA_IMAGE_DESCRIPTION = 'media:image-description;plain-text;textable;txt';
1094
+ // Media URN for finalised plain text — the canonical input/output of cap:save-as-txt.
1095
+ // Producers of user-facing prose (LLM text-generation, OCR's extracted text,
1096
+ // summarisation) declare this URN as their `out` so the planner restricts the .txt
1097
+ // persistence path to those caps. See fabric/media/plain-text.toml.
1098
+ const MEDIA_PLAIN_TEXT = 'media:plain-text;textable;txt';
1093
1099
  // Media URN for transcription output - has record marker
1094
1100
  const MEDIA_TRANSCRIPTION_OUTPUT = 'media:record;textable;transcription';
1095
1101
  // Media URN for decision output - JSON record with textable
1096
1102
  const MEDIA_DECISION = 'media:decision;json;record;textable';
1097
1103
  // Media URN for textable page output
1098
- const MEDIA_TEXTABLE_PAGE = 'media:textable;page';
1104
+ const MEDIA_TEXTABLE_PAGE = 'media:page;plain-text;textable;txt';
1099
1105
  // Media URN for Hugging Face API token (secret, textable)
1100
1106
  const MEDIA_HF_TOKEN = 'media:hf-token;secret;textable';
1101
1107
  // Media URN for a list of model architectures — JSON record
@@ -6192,8 +6198,8 @@ module.exports = {
6192
6198
  MEDIA_PATH_OUTPUT,
6193
6199
  // Semantic output types - inference
6194
6200
  MEDIA_EMBEDDING_VECTOR,
6195
- MEDIA_LLM_INFERENCE_OUTPUT,
6196
6201
  MEDIA_IMAGE_DESCRIPTION,
6202
+ MEDIA_PLAIN_TEXT,
6197
6203
  MEDIA_TRANSCRIPTION_OUTPUT,
6198
6204
  // File path type — single URN; cardinality lives on is_sequence.
6199
6205
  MEDIA_FILE_PATH,
package/capdag.test.js CHANGED
@@ -22,7 +22,7 @@ const {
22
22
  MEDIA_PDF, MEDIA_EPUB, MEDIA_MD, MEDIA_TXT, MEDIA_RST, MEDIA_LOG,
23
23
  MEDIA_HTML, MEDIA_XML, MEDIA_JSON, MEDIA_YAML, MEDIA_JSON_SCHEMA,
24
24
  MEDIA_MODEL_SPEC, MEDIA_AVAILABILITY_OUTPUT, MEDIA_PATH_OUTPUT,
25
- MEDIA_LLM_INFERENCE_OUTPUT,
25
+ MEDIA_PLAIN_TEXT,
26
26
  MEDIA_FILE_PATH,
27
27
  MEDIA_COLLECTION, MEDIA_COLLECTION_LIST,
28
28
  MEDIA_DECISION,
@@ -958,7 +958,7 @@ function test072_constantsParse() {
958
958
  MEDIA_MD, MEDIA_TXT, MEDIA_RST, MEDIA_LOG, MEDIA_HTML, MEDIA_XML,
959
959
  MEDIA_JSON, MEDIA_YAML, MEDIA_JSON_SCHEMA, MEDIA_AUDIO, MEDIA_VIDEO,
960
960
  MEDIA_MODEL_SPEC, MEDIA_AVAILABILITY_OUTPUT, MEDIA_PATH_OUTPUT,
961
- MEDIA_LLM_INFERENCE_OUTPUT
961
+ MEDIA_PLAIN_TEXT
962
962
  ];
963
963
  for (const constant of constants) {
964
964
  const parsed = MediaUrn.fromString(constant);
package/package.json CHANGED
@@ -40,5 +40,5 @@
40
40
  "pretest": "npm run build:parser",
41
41
  "test": "node capdag.test.js"
42
42
  },
43
- "version": "0.174.430"
43
+ "version": "0.176.436"
44
44
  }