@laitszkin/apollo-toolkit 3.11.3 → 3.11.4

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/CHANGELOG.md CHANGED
@@ -10,6 +10,20 @@ All notable changes to this repository are documented in this file.
10
10
 
11
11
  ### Fixed
12
12
 
13
+ ## [v3.11.4] - 2026-05-12
14
+
15
+ ### Added
16
+
17
+ - Macro atlas: `measureEdgeLabel` wraps long edge labels (including CJK) with honest width/height for elkjs so orthogonal routes reserve space proportional to label size; `renderMacroSvg` paints wrapped labels as stacked `<tspan>` lines on one anchor.
18
+ - Macro atlas: isolated feature clusters (no intra-feature edges and not an endpoint of a root-level cross-feature edge) use elk **rectpacking** with aspect-ratio hint for a grid-like pack instead of a tall vertical column of sub-modules.
19
+ - Tests: CJK submodule sizing, edge-label wrapping, rectpacking vs layered cluster selection, compact rectpack geometry, `m-edge--cross` markup, and cross-edge CSS dim/hover rules.
20
+
21
+ ### Changed
22
+
23
+ - Macro layout (`layout.js`): CJK-aware text width and visual-width wrapping for sub-module **role** lines; raised `SUB_WIDTH_MAX` / `SUB_HEIGHT_MAX` and `MAX_ROLE_LINES`; sub-module boxes prefer fewer wrapped lines when content allows.
24
+ - Root ELK graph: `elk.aspectRatio` 16:9, tighter node/layer spacing and padding, `BALANCED` node placement, `EDGE_LENGTH` post-compaction; cluster padding and internal spacing reduced.
25
+ - Cross-feature edges: root-level edges render with `m-edge--cross`; default lower opacity/thinner stroke, full strength on hover/focus so intra-feature flow reads first.
26
+
13
27
  ## [v3.11.3] - 2026-05-11
14
28
 
15
29
  ### Added
@@ -433,8 +433,23 @@ p { line-height: 1.6; color: var(--vellum-soft); }
433
433
  font-family: var(--font-mono);
434
434
  font-size: 11px;
435
435
  letter-spacing: 0.02em;
436
+ paint-order: stroke fill;
437
+ stroke: var(--ink);
438
+ stroke-width: 3.5;
439
+ stroke-linejoin: round;
440
+ pointer-events: none;
436
441
  }
437
442
 
443
+ /* Cross-feature edges are routed across the entire atlas and tend to
444
+ be the loudest lines on screen. Dim them so intra-feature flow
445
+ reads first; restore full strength on hover/focus. */
446
+ .m-edge--cross path { opacity: 0.5; stroke-width: 1.1; }
447
+ .m-edge--cross .m-edge__label { opacity: 0.7; }
448
+ .m-edge--cross:hover path,
449
+ .m-edge--cross:focus-within path { opacity: 1; stroke-width: 1.8; }
450
+ .m-edge--cross:hover .m-edge__label,
451
+ .m-edge--cross:focus-within .m-edge__label { opacity: 1; }
452
+
438
453
  /* =================================================================
439
454
  feature page
440
455
  ================================================================= */
@@ -17,11 +17,15 @@ const ELK = require('elkjs');
17
17
  const SUB_WIDTH = 240;
18
18
  const SUB_HEIGHT = 92;
19
19
 
20
- // Box-sizing knobs (intrinsic SVG coordinates).
20
+ // Box-sizing knobs (intrinsic SVG coordinates). The width/height caps
21
+ // are intentionally generous so CJK roles — which paint at ~1em per
22
+ // character instead of ~0.55em — can grow into a readable rectangle
23
+ // without spilling outside the box (the original ~360×220 cap was
24
+ // only honest for Latin text).
21
25
  const SUB_WIDTH_MIN = 220;
22
- const SUB_WIDTH_MAX = 360;
26
+ const SUB_WIDTH_MAX = 520;
23
27
  const SUB_HEIGHT_MIN = 92;
24
- const SUB_HEIGHT_MAX = 220;
28
+ const SUB_HEIGHT_MAX = 360;
25
29
  const SUB_SIDE_PAD = 16;
26
30
  const SUB_TOP_PAD = 14;
27
31
  const SUB_BOTTOM_PAD = 14;
@@ -30,12 +34,22 @@ const KIND_LINE = 16; // kind chip line
30
34
  const ROLE_LINE = 16; // each role line
31
35
  const KIND_GAP = 4;
32
36
  const ROLE_GAP = 8;
33
- const MAX_ROLE_LINES = 4;
37
+ const MAX_ROLE_LINES = 6;
34
38
 
35
- const CLUSTER_PAD_TOP = 60;
36
- const CLUSTER_PAD_SIDE = 24;
37
- const CLUSTER_PAD_BOTTOM = 28;
38
- const EDGE_LABEL_HEIGHT = 18;
39
+ const CLUSTER_PAD_TOP = 44;
40
+ const CLUSTER_PAD_SIDE = 16;
41
+ const CLUSTER_PAD_BOTTOM = 18;
42
+
43
+ // Font sizes mirror architecture.css so the layout math agrees with
44
+ // what the SVG actually paints.
45
+ const SLUG_FONT_PX = 15;
46
+ const KIND_FONT_PX = 10;
47
+ const ROLE_FONT_PX = 11.5;
48
+ const EDGE_LABEL_FONT_PX = 11;
49
+ const EDGE_LABEL_LINE_PX = 14;
50
+ const EDGE_LABEL_LINE_WIDTH_MAX = 220;
51
+ const EDGE_LABEL_PAD_X = 18;
52
+ const EDGE_LABEL_PAD_Y = 8;
39
53
 
40
54
  const KIND_LABELS = {
41
55
  ui: 'UI',
@@ -47,30 +61,128 @@ const KIND_LABELS = {
47
61
  external: 'external',
48
62
  };
49
63
 
50
- function estimateLabelWidth(text) {
51
- if (!text) return 0;
52
- return Math.min(220, Math.max(40, String(text).length * 7 + 16));
64
+ // East-Asian Wide / Full-width detection. The ranges follow the
65
+ // canonical "wide" / "fullwidth" code blocks (Unicode TR11) we use
66
+ // them to switch between a ~0.55em-per-char Latin metric and a
67
+ // ~1.0em-per-char CJK metric so layout matches paint.
68
+ function isWideChar(ch) {
69
+ const code = ch.codePointAt(0);
70
+ return (
71
+ (code >= 0x1100 && code <= 0x115F) ||
72
+ (code >= 0x2E80 && code <= 0x9FFF) ||
73
+ (code >= 0xA000 && code <= 0xA4CF) ||
74
+ (code >= 0xAC00 && code <= 0xD7A3) ||
75
+ (code >= 0xF900 && code <= 0xFAFF) ||
76
+ (code >= 0xFE30 && code <= 0xFE4F) ||
77
+ (code >= 0xFF00 && code <= 0xFF60) ||
78
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
79
+ (code >= 0x20000 && code <= 0x2FFFD)
80
+ );
81
+ }
82
+
83
+ function charWidthFactor(ch) {
84
+ if (isWideChar(ch)) return 1.0;
85
+ if (ch === ' ') return 0.30;
86
+ if (/[A-Za-z0-9]/.test(ch)) return 0.55;
87
+ return 0.50;
53
88
  }
54
89
 
55
- // Approximate render width of a string in the target font. The 0.6
56
- // factor is a deliberate over-estimate for our sans-serif stack so
57
- // the chosen width almost always leaves a little breathing room.
58
90
  function approxTextWidth(text, fontPx) {
59
- return String(text || '').length * fontPx * 0.6;
91
+ if (!text) return 0;
92
+ let w = 0;
93
+ for (const ch of String(text)) w += fontPx * charWidthFactor(ch);
94
+ return w;
60
95
  }
61
96
 
62
- function wrapToLines(text, maxChars) {
97
+ // Greedy wrap by *visual* width. CJK characters break between any
98
+ // two characters; ASCII words stay whole; whitespace is a soft break.
99
+ function wrapByVisualWidth(text, maxWidthPx, fontPx) {
63
100
  if (!text) return [];
64
- const words = String(text).split(/\s+/).filter(Boolean);
101
+ const str = String(text);
65
102
  const lines = [];
66
- let current = '';
67
- for (const word of words) {
68
- if (!current) { current = word; continue; }
69
- if ((current.length + 1 + word.length) <= maxChars) current = `${current} ${word}`;
70
- else { lines.push(current); current = word; }
103
+ let line = '';
104
+ let lineW = 0;
105
+
106
+ function flush() {
107
+ if (line.length > 0) lines.push(line.replace(/\s+$/, ''));
108
+ line = '';
109
+ lineW = 0;
71
110
  }
72
- if (current) lines.push(current);
73
- return lines;
111
+
112
+ let i = 0;
113
+ while (i < str.length) {
114
+ const cp = str.codePointAt(i);
115
+ const ch = String.fromCodePoint(cp);
116
+ const advance = ch.length;
117
+
118
+ if (ch === '\n') {
119
+ flush();
120
+ i += advance;
121
+ } else if (isWideChar(ch)) {
122
+ const w = fontPx * 1.0;
123
+ if (line && lineW + w > maxWidthPx) flush();
124
+ line += ch;
125
+ lineW += w;
126
+ i += advance;
127
+ } else if (/\s/.test(ch)) {
128
+ const w = fontPx * 0.30;
129
+ if (line) {
130
+ if (lineW + w > maxWidthPx) flush();
131
+ else { line += ch; lineW += w; }
132
+ }
133
+ i += advance;
134
+ } else {
135
+ let word = '';
136
+ let wordW = 0;
137
+ while (i < str.length) {
138
+ const cp2 = str.codePointAt(i);
139
+ const ch2 = String.fromCodePoint(cp2);
140
+ if (isWideChar(ch2) || /\s/.test(ch2)) break;
141
+ word += ch2;
142
+ wordW += fontPx * charWidthFactor(ch2);
143
+ i += ch2.length;
144
+ }
145
+ if (line && lineW + wordW > maxWidthPx) flush();
146
+ line += word;
147
+ lineW += wordW;
148
+ }
149
+ }
150
+ flush();
151
+ return lines.filter((l) => l.length > 0);
152
+ }
153
+
154
+ // Short labels (feature title, sub-module slug) — single-line width
155
+ // only. Capped so very long ASCII slugs do not blow up the cluster
156
+ // header band; CJK titles still get an honest visual width.
157
+ function estimateLabelWidth(text) {
158
+ if (!text) return 0;
159
+ return Math.min(320, Math.max(40, Math.ceil(approxTextWidth(text, 13)) + 16));
160
+ }
161
+
162
+ // Edge labels — wrap into a tall enough rectangle so elkjs reserves
163
+ // proportional edge length (longer text ⇒ longer arrow). The wrapped
164
+ // text is stored with '\n' separators so render.js can paint each
165
+ // line as its own <tspan> centered on the same anchor.
166
+ function measureEdgeLabel(text) {
167
+ if (!text) return { text: '', lines: [], width: 0, height: 0 };
168
+ const raw = String(text);
169
+ const singleW = approxTextWidth(raw, EDGE_LABEL_FONT_PX);
170
+ if (singleW <= EDGE_LABEL_LINE_WIDTH_MAX) {
171
+ return {
172
+ text: raw,
173
+ lines: [raw],
174
+ width: Math.max(40, Math.ceil(singleW) + EDGE_LABEL_PAD_X),
175
+ height: EDGE_LABEL_LINE_PX + EDGE_LABEL_PAD_Y,
176
+ };
177
+ }
178
+ const lines = wrapByVisualWidth(raw, EDGE_LABEL_LINE_WIDTH_MAX, EDGE_LABEL_FONT_PX);
179
+ const widestLine = lines.reduce((m, l) => Math.max(m, approxTextWidth(l, EDGE_LABEL_FONT_PX)), 0);
180
+ return {
181
+ text: lines.join('\n'),
182
+ lines,
183
+ width: Math.ceil(widestLine) + EDGE_LABEL_PAD_X,
184
+ height: lines.length * EDGE_LABEL_LINE_PX + EDGE_LABEL_PAD_Y,
185
+ };
74
186
  }
75
187
 
76
188
  // measureSubmodule picks a width + height that fit the slug, the kind
@@ -83,26 +195,33 @@ function measureSubmodule(sub) {
83
195
  const kindLabel = KIND_LABELS[sub && sub.kind] || (sub && sub.kind) || 'service';
84
196
  const role = (sub && sub.role) || '';
85
197
 
86
- const slugW = approxTextWidth(slug, 14);
87
- const kindW = approxTextWidth(kindLabel, 11);
198
+ const slugW = approxTextWidth(slug, SLUG_FONT_PX);
199
+ // Kind chip is upper-cased + letter-spaced in CSS (0.22em); add ~30% slack.
200
+ const kindW = approxTextWidth(kindLabel.toUpperCase(), KIND_FONT_PX) * 1.3;
88
201
  const baseInner = Math.max(slugW, kindW);
89
202
 
90
- // Aim to keep the role within ~3 lines: choose a width whose text
91
- // area can hold ceil(roleLen / 3) characters, then clamp.
92
- const roleLen = role.length;
203
+ // Auto-expand the box to fit the role with as few lines as
204
+ // possible: prefer 1 line if it fits inside SUB_WIDTH_MAX, else 2,
205
+ // else 3 — and only wrap further when even 3 lines would exceed
206
+ // the cap. This is what "boxes auto-expand to their content" means
207
+ // for CJK roles whose glyphs are ~2× wider than Latin.
208
+ const roleVisualW = approxTextWidth(role, ROLE_FONT_PX);
209
+ const maxInner = SUB_WIDTH_MAX - SUB_SIDE_PAD * 2;
93
210
  let chosenInner;
94
211
  if (!role) {
95
212
  chosenInner = baseInner;
96
213
  } else {
97
- const targetCharsPerLine = Math.max(20, Math.ceil(roleLen / 3));
98
- chosenInner = Math.max(baseInner, targetCharsPerLine * 11 * 0.55);
214
+ let target;
215
+ if (roleVisualW <= maxInner) target = roleVisualW;
216
+ else if (Math.ceil(roleVisualW / 2) <= maxInner) target = Math.ceil(roleVisualW / 2);
217
+ else target = Math.ceil(roleVisualW / 3);
218
+ chosenInner = Math.max(baseInner, Math.max(180, target));
99
219
  }
100
220
  const width = Math.max(SUB_WIDTH_MIN, Math.min(SUB_WIDTH_MAX, Math.ceil(chosenInner + SUB_SIDE_PAD * 2)));
101
221
 
102
222
  // With the chosen width fixed, wrap the role for real and count lines.
103
223
  const innerW = width - SUB_SIDE_PAD * 2;
104
- const charsPerLine = Math.max(12, Math.floor(innerW / (11 * 0.55)));
105
- let roleLines = role ? wrapToLines(role, charsPerLine) : [];
224
+ let roleLines = role ? wrapByVisualWidth(role, innerW, ROLE_FONT_PX) : [];
106
225
  if (roleLines.length > MAX_ROLE_LINES) {
107
226
  roleLines = roleLines.slice(0, MAX_ROLE_LINES);
108
227
  const last = roleLines[MAX_ROLE_LINES - 1];
@@ -129,7 +248,53 @@ function endpointId(endpoint, ownerFeature) {
129
248
  return null;
130
249
  }
131
250
 
251
+ function clusterLayoutOptions(feature, isCrossEdgeEndpoint) {
252
+ // If the feature declares intra-feature flow edges OR is an
253
+ // endpoint of any root-level cross-feature edge, the cluster must
254
+ // use a hierarchy-friendly directional algorithm (layered) so elk
255
+ // can route edges into/out of its children. Mixing rectpacking
256
+ // with layered hierarchy edges raises UnsupportedGraphException.
257
+ //
258
+ // Otherwise (truly isolated leaf cluster) pack sub-modules into a
259
+ // roughly square grid so a 10-sub-module feature does not become a
260
+ // tall column that wastes the rest of the viewport.
261
+ const hasInternalEdges = Array.isArray(feature.edges) && feature.edges.length > 0;
262
+ const common = {
263
+ 'elk.padding': `[top=${CLUSTER_PAD_TOP},left=${CLUSTER_PAD_SIDE},bottom=${CLUSTER_PAD_BOTTOM},right=${CLUSTER_PAD_SIDE}]`,
264
+ 'elk.spacing.nodeNode': '16',
265
+ 'elk.nodeLabels.placement': '[H_CENTER, V_TOP, INSIDE]',
266
+ };
267
+ if (hasInternalEdges || isCrossEdgeEndpoint) {
268
+ return {
269
+ ...common,
270
+ 'elk.algorithm': 'layered',
271
+ 'elk.direction': 'DOWN',
272
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '28',
273
+ 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
274
+ };
275
+ }
276
+ return {
277
+ ...common,
278
+ 'elk.algorithm': 'rectpacking',
279
+ 'elk.aspectRatio': '1.4',
280
+ 'elk.rectpacking.optimizationGoal': 'MAX_SCALE_DRIVEN',
281
+ };
282
+ }
283
+
284
+ function collectCrossEdgeFeatures(state) {
285
+ const set = new Set();
286
+ function note(endpoint) {
287
+ if (endpoint && typeof endpoint === 'object' && endpoint.feature) set.add(endpoint.feature);
288
+ }
289
+ for (const edge of state.edges || []) {
290
+ note(edge.from);
291
+ note(edge.to);
292
+ }
293
+ return set;
294
+ }
295
+
132
296
  function buildGraph(state) {
297
+ const crossEndpoints = collectCrossEdgeFeatures(state);
133
298
  const children = (state.features || []).map((feature) => ({
134
299
  id: `feature::${feature.slug}`,
135
300
  labels: [{
@@ -138,14 +303,7 @@ function buildGraph(state) {
138
303
  width: estimateLabelWidth(feature.title || feature.slug),
139
304
  height: 24,
140
305
  }],
141
- layoutOptions: {
142
- 'elk.padding': `[top=${CLUSTER_PAD_TOP},left=${CLUSTER_PAD_SIDE},bottom=${CLUSTER_PAD_BOTTOM},right=${CLUSTER_PAD_SIDE}]`,
143
- 'elk.spacing.nodeNode': '24',
144
- 'elk.algorithm': 'layered',
145
- 'elk.direction': 'DOWN',
146
- 'elk.layered.spacing.nodeNodeBetweenLayers': '36',
147
- 'elk.nodeLabels.placement': '[H_CENTER, V_TOP, INSIDE]',
148
- },
306
+ layoutOptions: clusterLayoutOptions(feature, crossEndpoints.has(feature.slug)),
149
307
  children: (feature.submodules || []).map((sub) => {
150
308
  const box = measureSubmodule(sub);
151
309
  return {
@@ -168,17 +326,18 @@ function buildGraph(state) {
168
326
 
169
327
  function pushEdge(list, raw, sourceId, targetId) {
170
328
  if (!sourceId || !targetId) return;
171
- list.push({
172
- id: raw.id || `e-${nextEdgeId++}`,
173
- sources: [sourceId],
174
- targets: [targetId],
175
- labels: raw.label ? [{
176
- id: `${raw.id || `e-${nextEdgeId}`}::label`,
177
- text: raw.label,
178
- width: estimateLabelWidth(raw.label),
179
- height: EDGE_LABEL_HEIGHT,
180
- }] : [],
181
- });
329
+ const id = raw.id || `e-${nextEdgeId++}`;
330
+ let labels = [];
331
+ if (raw.label) {
332
+ const m = measureEdgeLabel(raw.label);
333
+ labels = [{
334
+ id: `${id}::label`,
335
+ text: m.text,
336
+ width: m.width,
337
+ height: m.height,
338
+ }];
339
+ }
340
+ list.push({ id, sources: [sourceId], targets: [targetId], labels });
182
341
  }
183
342
 
184
343
  for (const feature of state.features || []) {
@@ -203,12 +362,20 @@ function buildGraph(state) {
203
362
  'elk.algorithm': 'layered',
204
363
  'elk.direction': 'RIGHT',
205
364
  'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
206
- 'elk.spacing.nodeNode': '60',
207
- 'elk.layered.spacing.nodeNodeBetweenLayers': '100',
208
- 'elk.padding': '[top=40,left=40,bottom=40,right=40]',
365
+ // 16:9 hint so elk stops sprawling along one axis and leaving
366
+ // the other half of the viewport empty.
367
+ 'elk.aspectRatio': '1.778',
368
+ 'elk.spacing.nodeNode': '32',
369
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '60',
370
+ 'elk.padding': '[top=20,left=20,bottom=20,right=20]',
209
371
  'elk.edgeRouting': 'ORTHOGONAL',
210
372
  'elk.edgeLabels.inline': 'false',
211
373
  'elk.edgeLabels.placement': 'CENTER',
374
+ // Tighter post-layout placement: BALANCED keeps related nodes
375
+ // adjacent, EDGE_LENGTH compaction pulls disconnected
376
+ // sub-graphs in toward each other so unused columns collapse.
377
+ 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
378
+ 'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH',
212
379
  },
213
380
  children,
214
381
  edges: rootEdges,
@@ -327,4 +494,7 @@ module.exports = {
327
494
  assertNoOverlap,
328
495
  buildGraph,
329
496
  measureSubmodule,
497
+ measureEdgeLabel,
498
+ approxTextWidth,
499
+ wrapByVisualWidth,
330
500
  };
@@ -81,13 +81,13 @@ function edgeKindFor(stateEdge) {
81
81
  function findEdgeMeta(state, edgeId) {
82
82
  for (const feature of state.features || []) {
83
83
  for (const e of feature.edges || []) {
84
- if (e.id === edgeId) return e;
84
+ if (e.id === edgeId) return { edge: e, scope: 'feature' };
85
85
  }
86
86
  }
87
87
  for (const e of state.edges || []) {
88
- if (e.id === edgeId) return e;
88
+ if (e.id === edgeId) return { edge: e, scope: 'root' };
89
89
  }
90
- return null;
90
+ return { edge: null, scope: 'feature' };
91
91
  }
92
92
 
93
93
  function renderMacroSvg(layout, state) {
@@ -144,17 +144,29 @@ function renderMacroSvg(layout, state) {
144
144
  }
145
145
 
146
146
  for (const edge of layout.edges) {
147
- const meta = findEdgeMeta(state, edge.id);
147
+ const { edge: meta, scope } = findEdgeMeta(state, edge.id);
148
148
  const kind = edgeKindFor(meta);
149
149
  const d = renderEdgePath(edge);
150
150
  if (!d) continue;
151
- parts.push(` <g class="m-edge m-edge--${kind}" data-edge="${htmlEscape(edge.id)}">`);
151
+ const scopeClass = scope === 'root' ? ' m-edge--cross' : '';
152
+ parts.push(` <g class="m-edge m-edge--${kind}${scopeClass}" data-edge="${htmlEscape(edge.id)}">`);
152
153
  parts.push(` <path d="${d}" fill="none" marker-end="url(#arrow-${kind})" />`);
153
154
  for (const label of edge.labels || []) {
154
155
  if (!label.text) continue;
155
- const lx = label.x + (label.width || 0) / 2;
156
- const ly = label.y + (label.height || 0) / 2 + 4;
157
- parts.push(` <text class="m-edge__label" x="${lx.toFixed(2)}" y="${ly.toFixed(2)}" text-anchor="middle">${htmlEscape(label.text)}</text>`);
156
+ const lines = String(label.text).split('\n');
157
+ const cx = label.x + (label.width || 0) / 2;
158
+ const lineH = 14; // matches EDGE_LABEL_LINE_PX in layout.js
159
+ const blockH = lines.length * lineH;
160
+ const firstBaseline = label.y + ((label.height || 0) - blockH) / 2 + (lineH - 3);
161
+ parts.push(` <text class="m-edge__label" x="${cx.toFixed(2)}" y="${firstBaseline.toFixed(2)}" text-anchor="middle">`);
162
+ lines.forEach((line, idx) => {
163
+ if (idx === 0) {
164
+ parts.push(` <tspan x="${cx.toFixed(2)}">${htmlEscape(line)}</tspan>`);
165
+ } else {
166
+ parts.push(` <tspan x="${cx.toFixed(2)}" dy="${lineH}">${htmlEscape(line)}</tspan>`);
167
+ }
168
+ });
169
+ parts.push(' </text>');
158
170
  }
159
171
  parts.push(' </g>');
160
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laitszkin/apollo-toolkit",
3
- "version": "3.11.3",
3
+ "version": "3.11.4",
4
4
  "description": "Apollo Toolkit npm installer for managed skill copying across Codex, OpenClaw, and Trae.",
5
5
  "license": "MIT",
6
6
  "author": "LaiTszKin",