pmx-canvas 0.5.1 → 0.6.2

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/Readme.md +10 -7
  3. package/dist/canvas/global.css +101 -7
  4. package/dist/canvas/index.js +171 -128
  5. package/dist/canvas/mermaid-entry.js +1303 -1303
  6. package/dist/json-render/index.js +101 -102
  7. package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
  8. package/docs/adr-001-bun-only-runtime.md +1 -1
  9. package/docs/bun-webview-integration.md +2 -2
  10. package/docs/node-types.md +1 -1
  11. package/docs/product-context-vision-2026-09.md +207 -0
  12. package/docs/product-review-2026-09.md +174 -0
  13. package/docs/product-vision-2026-09.md +200 -0
  14. package/docs/screenshot.png +0 -0
  15. package/package.json +2 -2
  16. package/skills/pmx-canvas/SKILL.md +62 -36
  17. package/skills/pmx-canvas/evals/evals.json +1 -1
  18. package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +5 -0
  19. package/skills/pmx-canvas/references/full-reference.md +5 -4
  20. package/skills/pmx-canvas/references/installing-pmx-canvas.md +126 -18
  21. package/skills/pmx-canvas-testing/SKILL.md +13 -4
  22. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +18 -8
  23. package/src/client/canvas/CanvasNode.tsx +9 -12
  24. package/src/client/canvas/CanvasViewport.tsx +20 -33
  25. package/src/client/canvas/ContextMenu.tsx +13 -0
  26. package/src/client/canvas/EdgeLayer.tsx +2 -2
  27. package/src/client/canvas/SessionPanel.tsx +23 -4
  28. package/src/client/canvas/ShortcutOverlay.tsx +2 -2
  29. package/src/client/canvas/TopBar.tsx +85 -45
  30. package/src/client/canvas/use-pan-zoom.ts +23 -3
  31. package/src/client/nodes/ExtAppFrame.tsx +97 -28
  32. package/src/client/state/canvas-store.ts +17 -5
  33. package/src/client/state/sse-bridge.ts +3 -1
  34. package/src/client/theme/global.css +101 -7
  35. package/src/server/canvas-operations.ts +2 -2
  36. package/src/server/operations/mcp.ts +45 -31
  37. package/src/server/operations/ops/ax-presence.ts +3 -0
  38. package/src/server/operations/ops/viewport.ts +6 -1
  39. package/src/server/scope-fence.ts +1 -1
  40. package/src/server/server.ts +9 -9
  41. package/src/server/web-artifacts/scripts/bundle-artifact.sh +29 -8
@@ -986,10 +986,21 @@ body,
986
986
  /* NOT overflow:hidden — the BarHint tooltips hang below the bar and were
987
987
  clipped invisible for everyone (the e2e asserted opacity, which cannot
988
988
  see ancestor clipping). Children ellipsize themselves; the narrow-screen
989
- collapse is media-query display, not clipping. */
989
+ collapse is media-query display, not clipping. overflow-x: clip is the
990
+ one safe axis (visible/clip is the valid mixed pair): tooltips still
991
+ hang below, but bar content can never paint over the session panel
992
+ again (0.5.1 user feedback — the agent-chip flood pushed the zoom
993
+ cluster out of the bar). */
994
+ overflow-x: clip;
990
995
  min-width: 0;
991
996
  z-index: 9998;
992
997
  }
998
+ /* CHIP BarHint anchors must pass shrink through to their chip (flex min-auto
999
+ sizing) or an unshrinkable anchor re-creates the overflow. Chip anchors
1000
+ ONLY — a blanket min-width: 0 crushed the zoom buttons to slivers. */
1001
+ .top-bar .toolbar-tooltip-anchor:has(.agent-chip) {
1002
+ min-width: 0;
1003
+ }
993
1004
 
994
1005
  .top-bar-title {
995
1006
  font-size: 13px;
@@ -1104,14 +1115,46 @@ body,
1104
1115
  font-size: 11px;
1105
1116
  font-weight: 600;
1106
1117
  white-space: nowrap;
1118
+ /* Rigid: the chip never shrinks and never lies about its min size (a
1119
+ min-width: 0 here let BarHint anchors collapse under their chips, and
1120
+ neighboring chips painted over each other). The .agent-chips wrapper is
1121
+ the ONE squeeze point — it clips whole trailing chips at its edge. */
1107
1122
  flex-shrink: 0;
1108
- min-width: 0;
1109
1123
  max-width: 280px;
1110
1124
  }
1125
+ /* Many-agent bar (0.5.1 user feedback): the visible chips sit in ONE
1126
+ clipping flex wrapper. Chips keep their natural readable width; when the
1127
+ bar is overconstrained the WRAPPER shrinks and clips at its edge, so chip
1128
+ content can never shove or paint over the census chip, the context meter,
1129
+ or the zoom cluster (16 unshrinkable chips used to push the zoom controls
1130
+ out of the bar and over the session panel). */
1131
+ .agent-chips {
1132
+ display: inline-flex;
1133
+ align-items: center;
1134
+ gap: 12px;
1135
+ min-width: 0;
1136
+ overflow: hidden;
1137
+ }
1138
+ /* Rigid anchors: nested-flex min-content lets a BarHint anchor collapse
1139
+ under its chip (ellipsizing spans zero the chip's min-content), and the
1140
+ chip then paints into the next anchor. Only the wrapper above may give. */
1141
+ .agent-chips .toolbar-tooltip-anchor {
1142
+ flex-shrink: 0;
1143
+ }
1111
1144
  .agent-chip.phase-idle {
1112
1145
  background: none;
1113
1146
  border-color: var(--c-line);
1114
1147
  }
1148
+ /* The census chip is the "how many" answer — it never compresses away. Its
1149
+ BarHint anchor must hold width too: a shrinking anchor around an
1150
+ unshrinkable chip lets the chip paint over the next bar item. */
1151
+ .agent-chip-more {
1152
+ flex-shrink: 0;
1153
+ }
1154
+ .top-bar .toolbar-tooltip-anchor:has(.agent-chip-more) {
1155
+ flex-shrink: 0;
1156
+ min-width: max-content;
1157
+ }
1115
1158
  .agent-chip-dot {
1116
1159
  width: 7px;
1117
1160
  height: 7px;
@@ -1134,6 +1177,11 @@ body,
1134
1177
  font-weight: 400;
1135
1178
  color: var(--c-muted);
1136
1179
  white-space: nowrap;
1180
+ /* Leaf-level truncation: a compressed chip must ellipsize the agent name,
1181
+ never paint it past the pill (0.5.1 user feedback, many-agent bar). */
1182
+ overflow: hidden;
1183
+ text-overflow: ellipsis;
1184
+ min-width: 0;
1137
1185
  }
1138
1186
 
1139
1187
  /* On-canvas cursor + phase chip (world layer; counter-scaled inner) */
@@ -1146,7 +1194,9 @@ body,
1146
1194
  top: 0;
1147
1195
  z-index: 9000;
1148
1196
  pointer-events: none;
1149
- transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
1197
+ transition:
1198
+ transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
1199
+ opacity 320ms ease;
1150
1200
  color: var(--presence-color);
1151
1201
  }
1152
1202
  .agent-cursor.is-worker {
@@ -1160,6 +1210,20 @@ body,
1160
1210
  font-size: 9px;
1161
1211
  padding: 1px 6px;
1162
1212
  }
1213
+ /* Idle cursors leave the board (user feedback, 0.5.1 cycle): with many agents
1214
+ attached, a wall of parked "Idle" pills covers real node content. The
1215
+ element stays MOUNTED — counts, twin-merge, and cursor-follow logic are
1216
+ untouched, and visibility (not display) keeps its layout box so position
1217
+ polls still resolve — but it paints nothing; any non-idle phase fades it
1218
+ back in. Placed after .is-worker so opacity: 0 wins that collision. */
1219
+ .agent-cursor.phase-idle {
1220
+ opacity: 0;
1221
+ visibility: hidden;
1222
+ transition:
1223
+ transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
1224
+ opacity 320ms ease,
1225
+ visibility 0s linear 320ms;
1226
+ }
1163
1227
  .agent-chip-workers {
1164
1228
  margin-left: 4px;
1165
1229
  padding: 0 5px;
@@ -1313,7 +1377,7 @@ body,
1313
1377
  animation: session-panel-in 180ms ease;
1314
1378
  }
1315
1379
  .session-panel.is-collapsed {
1316
- width: 28px;
1380
+ width: 40px;
1317
1381
  align-items: center;
1318
1382
  padding-top: 10px;
1319
1383
  gap: 10px;
@@ -1326,6 +1390,17 @@ body,
1326
1390
  text-transform: uppercase;
1327
1391
  color: var(--c-dim);
1328
1392
  }
1393
+ .session-collapsed-live {
1394
+ display: inline-flex;
1395
+ flex-direction: column;
1396
+ align-items: center;
1397
+ gap: 4px;
1398
+ font: 9px var(--mono);
1399
+ color: var(--c-dim);
1400
+ }
1401
+ .session-collapsed-live .session-live-dot {
1402
+ background: var(--c-ok);
1403
+ }
1329
1404
 
1330
1405
  .session-panel-header {
1331
1406
  height: var(--hud-bar-height);
@@ -1653,7 +1728,7 @@ body,
1653
1728
  }
1654
1729
  .session-timeline-filters .activity-filter {
1655
1730
  flex-shrink: 0;
1656
- padding: 2px 4px;
1731
+ padding: 2px 3px;
1657
1732
  }
1658
1733
 
1659
1734
  .session-timeline-row {
@@ -2830,9 +2905,20 @@ body,
2830
2905
  }
2831
2906
 
2832
2907
  /* Pan tool / held Space: the world layer goes pointer-inert so drags land on
2833
- the viewport container itself and pan — nodes cannot swallow the drag. */
2908
+ the viewport container itself and pan — nodes cannot swallow the drag.
2909
+ user-select too: text selection sweeps straight through pointer-inert
2910
+ content, and a selection made mid-pan later becomes a native text drag the
2911
+ drop handler mints webpage nodes from (0.5.1 Amp finding D). */
2834
2912
  .canvas-viewport.pan-mode .canvas-world {
2835
2913
  pointer-events: none;
2914
+ -webkit-user-select: none;
2915
+ user-select: none;
2916
+ }
2917
+ /* The resize handle and connect ports carry their own pointer-events: auto,
2918
+ which punches through the inert world — a pan started on them never pans. */
2919
+ .canvas-viewport.pan-mode .node-resize-handle,
2920
+ .canvas-viewport.pan-mode .node-port {
2921
+ pointer-events: none;
2836
2922
  }
2837
2923
  .canvas-viewport.pan-mode:active {
2838
2924
  cursor: grabbing;
@@ -3670,6 +3756,12 @@ body,
3670
3756
  }
3671
3757
 
3672
3758
  /* Node resize handle */
3759
+ .canvas-node:has(.node-resize-handle) {
3760
+ /* Keep the four-pixel corner grab inside the shell's inner clip, not on
3761
+ its rounded border. The other corners retain the standard card radius. */
3762
+ border-bottom-right-radius: 8px;
3763
+ }
3764
+
3673
3765
  .canvas-node .node-resize-handle {
3674
3766
  position: absolute;
3675
3767
  bottom: 0;
@@ -3714,7 +3806,9 @@ html.is-node-dragging .attention-field-layer {
3714
3806
  }
3715
3807
 
3716
3808
  html.is-node-dragging,
3717
- html.is-node-dragging * {
3809
+ html.is-node-dragging *,
3810
+ html.is-canvas-panning,
3811
+ html.is-canvas-panning * {
3718
3812
  cursor: grabbing !important;
3719
3813
  user-select: none !important;
3720
3814
  -webkit-user-select: none !important;
@@ -726,7 +726,7 @@ function shouldTreatFileContentAsPath(input: CanvasAddNodeInput): boolean {
726
726
  const content = input.content?.trim() ?? '';
727
727
  if (!content || content.includes('\n') || content.includes('\r')) return false;
728
728
  if (typeof input.data?.path === 'string' && input.data.path.length > 0) return true;
729
- if (existsSync(resolve(content))) return true;
729
+ if (existsSync(resolve(canvasState.getWorkspaceRoot(), content))) return true;
730
730
  if (!input.title) return true;
731
731
  return content.startsWith('/') || content.startsWith('./') || content.startsWith('../') || content.includes('/');
732
732
  }
@@ -748,7 +748,7 @@ function buildFileNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
748
748
 
749
749
  const rawPath =
750
750
  typeof input.data?.path === 'string' && input.data.path.length > 0 ? input.data.path : (input.content ?? '');
751
- const resolved = resolve(rawPath);
751
+ const resolved = resolve(canvasState.getWorkspaceRoot(), rawPath);
752
752
  const fileName = basename(resolved) || rawPath;
753
753
  const data: Record<string, unknown> = {
754
754
  ...(input.data ?? {}),
@@ -27,10 +27,18 @@ export function registerOperationTools(server: McpServer, getHost: () => Promise
27
27
  if (foldedOpNames.has(op.name)) continue;
28
28
  const tool = op.mcp;
29
29
  if (!tool) continue;
30
- server.tool(
30
+ server.registerTool(
31
31
  tool.toolName,
32
- tool.description,
33
- { ...op.inputShape, ...(tool.extraShape ?? {}) },
32
+ {
33
+ description: tool.description,
34
+ // A LOOSE object, not a raw shape: the SDK wraps raw shapes in a
35
+ // stripping z.object, which silently dropped unknown args before the
36
+ // op layer could reject them — a removed field (dockPosition) looked
37
+ // successful over MCP while the same HTTP call 400'd (0.5.1 Amp
38
+ // finding A). Loose parsing forwards unknown keys to the op layer,
39
+ // the single authority on dead-field rejection, HTTP and MCP alike.
40
+ inputSchema: z.looseObject({ ...op.inputShape, ...(tool.extraShape ?? {}) }),
41
+ },
34
42
  async (input: Record<string, unknown>) => {
35
43
  try {
36
44
  const host = await getHost();
@@ -231,34 +239,40 @@ export function registerCompositeTools(
231
239
  for (const [publicKey, opField] of Object.entries(def.fieldRemap ?? {})) {
232
240
  publicFieldName.set(opField, publicKey);
233
241
  }
234
- server.tool(def.toolName, def.description, buildCompositeShape(def), async (input: Record<string, unknown>) => {
235
- try {
236
- const host = await getHost();
237
- const opName = resolveCompositeOp(def, input);
238
- const op = getOperation(opName);
239
- // Strip the composite discriminators (action + any extra, e.g. `kind`)
240
- // and undo any field remap; the rest is the op's raw MCP args — the same
241
- // value the standalone tool would receive.
242
- const rest = stripCompositeDiscriminators(def, input);
243
- const missing = requiredMcpFields(op).filter((field) => !requiredFieldSatisfied(opName, field, rest));
244
- if (missing.length > 0) {
245
- const named = missing.map((field) => `"${publicFieldName.get(field) ?? field}"`).join(', ');
246
- throw new OperationError(
247
- `${def.toolName} action "${String(input.action)}" requires ${named}. Unknown parameters are ignored — check the argument names.`,
248
- );
249
- }
250
- const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
251
- const result = await host.invoker().invoke(opName, opInput);
252
- if (op.mcp?.formatResult) {
253
- return await op.mcp.formatResult(result, rest, host);
242
+ // Loose for the same reason as registerOperationTools above: unknown args
243
+ // must reach the op layer's dead-field rejection instead of being stripped.
244
+ server.registerTool(
245
+ def.toolName,
246
+ { description: def.description, inputSchema: z.looseObject(buildCompositeShape(def)) },
247
+ async (input: Record<string, unknown>) => {
248
+ try {
249
+ const host = await getHost();
250
+ const opName = resolveCompositeOp(def, input);
251
+ const op = getOperation(opName);
252
+ // Strip the composite discriminators (action + any extra, e.g. `kind`)
253
+ // and undo any field remap; the rest is the op's raw MCP args — the same
254
+ // value the standalone tool would receive.
255
+ const rest = stripCompositeDiscriminators(def, input);
256
+ const missing = requiredMcpFields(op).filter((field) => !requiredFieldSatisfied(opName, field, rest));
257
+ if (missing.length > 0) {
258
+ const named = missing.map((field) => `"${publicFieldName.get(field) ?? field}"`).join(', ');
259
+ throw new OperationError(
260
+ `${def.toolName} action "${String(input.action)}" requires ${named}. Unknown parameters are ignored — check the argument names.`,
261
+ );
262
+ }
263
+ const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
264
+ const result = await host.invoker().invoke(opName, opInput);
265
+ if (op.mcp?.formatResult) {
266
+ return await op.mcp.formatResult(result, rest, host);
267
+ }
268
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
269
+ } catch (error) {
270
+ return {
271
+ content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
272
+ isError: true,
273
+ };
254
274
  }
255
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
256
- } catch (error) {
257
- return {
258
- content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
259
- isError: true,
260
- };
261
- }
262
- });
275
+ },
276
+ );
263
277
  }
264
278
  }
@@ -34,6 +34,9 @@ const presenceSetShape = {
34
34
  focusNodeId: PRESENCE_SET_SHAPE.focusNodeId.describe('Node the agent is working on'),
35
35
  cursor: PRESENCE_SET_SHAPE.cursor.describe('Agent cursor in world coordinates'),
36
36
  attached: PRESENCE_SET_SHAPE.attached.describe('true attaches a session (Focus Session chrome); false detaches it'),
37
+ contextUsage: PRESENCE_SET_SHAPE.contextUsage.describe(
38
+ "The agent's real token window as {used,total} — shown on the board's context meter instead of the pinned-context estimate (null clears it)",
39
+ ),
37
40
  };
38
41
  const presenceSetSchema = z.looseObject(presenceSetShape);
39
42
 
@@ -135,8 +135,10 @@ const focusOperation = defineOperation<z.infer<typeof focusSchema>, Record<strin
135
135
  }
136
136
  const focus = canvasState.setAxFocus([nodeId], { source: 'api', recordHistory: false });
137
137
  ctx.emit('ax-state-changed', { focus });
138
- ctx.emit('canvas-focus-node', { nodeId, noPan });
138
+ // Apply the server fallback first; the browser then refines it using its
139
+ // actual canvas region and floating chrome instead of cancelling that fit.
139
140
  if (!noPan) ctx.emit('canvas-viewport-update', { viewport: canvasState.viewport });
141
+ ctx.emit('canvas-focus-node', { nodeId, noPan });
140
142
  return { ok: true, focused: nodeId, panned: !noPan, axFocus: focus };
141
143
  },
142
144
  });
@@ -250,6 +252,9 @@ const viewportSetOperation = defineOperation<z.infer<typeof viewportSetSchema>,
250
252
  // sizes to the human's actual window instead of a hardcoded 1440x900
251
253
  // (0.4.6 orb feedback #2).
252
254
  setClientViewportSize(body.clientWidth, body.clientHeight);
255
+ // A size-only report is not a camera command. Echoing the stored camera
256
+ // here would cancel an in-flight browser focus with its stale fallback.
257
+ if (![body.x, body.y, body.scale].some((value) => typeof value === 'number')) return { ok: true };
253
258
  const next = {
254
259
  x: typeof body.x === 'number' ? body.x : canvasState.viewport.x,
255
260
  y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
@@ -199,7 +199,7 @@ export function checkScopeOwnership(rawInput: unknown, writerKey?: string): stri
199
199
  return 'the scope fence is set and cleared by the human in the session panel, not by the agent.';
200
200
  const agentScopes = input.agentScopes;
201
201
  if (writerKey && agentScopes && typeof agentScopes === 'object' && !Array.isArray(agentScopes)) {
202
- if (Object.prototype.hasOwnProperty.call(agentScopes, writerKey)) {
202
+ if (Object.hasOwn(agentScopes, writerKey)) {
203
203
  return `an agent cannot set or clear its OWN territory ("${writerKey}") — only the human or a different writer (the orchestrator) may.`;
204
204
  }
205
205
  }
@@ -556,22 +556,22 @@ export async function startCanvasAutomationWebView(
556
556
  await closeCanvasAutomationWebViewInternal();
557
557
  }
558
558
 
559
- const view = new WebView({
560
- width: normalized.width,
561
- height: normalized.height,
562
- headless: true,
563
- backend,
564
- dataStore: normalized.dataStoreDir ? { directory: normalized.dataStoreDir } : 'ephemeral',
565
- });
566
-
559
+ let view: CanvasWebViewLike | null = null;
567
560
  try {
561
+ view = new WebView({
562
+ width: normalized.width,
563
+ height: normalized.height,
564
+ headless: true,
565
+ backend,
566
+ dataStore: normalized.dataStoreDir ? { directory: normalized.dataStoreDir } : 'ephemeral',
567
+ });
568
568
  await withCanvasAutomationWebViewTimeout(view.navigate(url), 'starting the workbench automation WebView');
569
569
  } catch (error) {
570
570
  canvasAutomationWebViewStatus = {
571
571
  ...canvasAutomationWebViewStatus,
572
572
  lastError: error instanceof Error ? error.message : String(error),
573
573
  };
574
- await Promise.resolve(view.close()).catch(() => undefined);
574
+ if (view) await Promise.resolve(view.close()).catch(() => undefined);
575
575
  throw error;
576
576
  }
577
577
 
@@ -56,14 +56,12 @@ function replay_filtered_stderr() {
56
56
 
57
57
  function run_with_filtered_stderr() {
58
58
  local stderr_file
59
+ local status
59
60
  stderr_file="$(mktemp)"
60
- if "$@" 2>"$stderr_file"; then
61
- replay_filtered_stderr "$stderr_file"
62
- rm -f "$stderr_file"
63
- return 0
64
- fi
65
-
66
- local status=$?
61
+ set +e
62
+ "$@" 2>"$stderr_file"
63
+ status=$?
64
+ set -e
67
65
  replay_filtered_stderr "$stderr_file"
68
66
  rm -f "$stderr_file"
69
67
  return "$status"
@@ -109,6 +107,17 @@ function ensure_bundle_dependencies() {
109
107
  fi
110
108
  done
111
109
 
110
+ # package.json lists the deps, but node_modules may not be in sync (e.g. fresh
111
+ # CI checkout where node_modules is gitignored). Run a regular install so the
112
+ # local binaries actually exist before we hand off to Parcel. The build
113
+ # allowlist is already persisted in package.json's pnpm.onlyBuiltDependencies
114
+ # by the original `pnpm add` step, so plain `install` is enough here.
115
+ if [ ! -x "./node_modules/.bin/parcel" ]; then
116
+ echo "📦 Syncing bundling dependencies into node_modules..."
117
+ run_pnpm_quiet install
118
+ return 0
119
+ fi
120
+
112
121
  echo "✅ Reusing existing bundling dependencies"
113
122
  }
114
123
 
@@ -150,7 +159,19 @@ rm -rf dist bundle.html
150
159
 
151
160
  # Build with Parcel
152
161
  echo "🔨 Building with Parcel..."
153
- run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error
162
+ parcel_status=0
163
+ run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error || parcel_status=$?
164
+
165
+ # Self-heal a wedged cache: these build projects are REUSED across artifact
166
+ # builds, and a parcel killed mid-write (e.g. the canvas server torn down
167
+ # during a build) leaves a corrupted .parcel-cache that makes every later
168
+ # build die before emitting anything — permanently, until the cache goes.
169
+ # Retry exactly once from a cold cache, then preserve a genuine build failure.
170
+ if [ "$parcel_status" -ne 0 ] || [ ! -s "dist/index.html" ]; then
171
+ echo "🧹 Parcel build failed or produced no output — clearing .parcel-cache and retrying cold..."
172
+ rm -rf .parcel-cache dist
173
+ run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error
174
+ fi
154
175
 
155
176
  if [ ! -s "dist/index.html" ]; then
156
177
  echo "❌ Error: Parcel did not produce dist/index.html" >&2