castle-web-cli 0.4.169 → 0.4.171

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 (106) hide show
  1. package/dist/agent-prompts.d.ts +1 -0
  2. package/dist/agent-prompts.js +3 -0
  3. package/dist/agent.js +70 -24
  4. package/dist/atomicFile.d.ts +29 -0
  5. package/dist/atomicFile.js +120 -0
  6. package/dist/castle-host/host.js +108 -0
  7. package/dist/get-deck.js +2 -11
  8. package/dist/headlessCover.d.ts +4 -0
  9. package/dist/headlessCover.js +7 -5
  10. package/dist/ide.d.ts +10 -0
  11. package/dist/ide.js +19 -11
  12. package/dist/index.js +10 -6
  13. package/dist/init.d.ts +1 -1
  14. package/dist/init.js +11 -1
  15. package/dist/remix.js +2 -14
  16. package/dist/save-deck.d.ts +3 -0
  17. package/dist/save-deck.js +40 -73
  18. package/dist/saveCover.d.ts +18 -0
  19. package/dist/saveCover.js +61 -0
  20. package/dist/serve.js +18 -5
  21. package/dist/serveSecurity.d.ts +11 -1
  22. package/dist/serveSecurity.js +38 -4
  23. package/dist/shell/assets/index-6odVZQSZ.css +1 -0
  24. package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
  25. package/dist/shell/index.html +3 -3
  26. package/dist/versionStore.js +13 -4
  27. package/kits/base/CLAUDE.md +9 -0
  28. package/kits/base/castle.json +16 -11
  29. package/kits/base/sdk/README.md +83 -2
  30. package/kits/base/sdk/commands.d.ts +37 -1
  31. package/kits/base/sdk/commands.js +9 -0
  32. package/kits/base/sdk/lifecycle.d.ts +20 -0
  33. package/kits/base/sdk/lifecycle.js +7 -1
  34. package/kits/base/sdk/resumeState.d.ts +2 -0
  35. package/kits/base/sdk/resumeState.js +84 -0
  36. package/kits/base/sdk/runtime.js +65 -0
  37. package/kits/base/sdk/transport.d.ts +3 -0
  38. package/kits/base/sdk/transport.js +39 -5
  39. package/kits/base/sdk/unloadHandshake.d.ts +6 -0
  40. package/kits/base/sdk/unloadHandshake.js +47 -0
  41. package/kits/base/sdk/user.d.ts +4 -0
  42. package/kits/base/sdk/user.js +36 -1
  43. package/kits/multiplayer-2d/CLAUDE.md +20 -9
  44. package/kits/multiplayer-2d/castle.json +4 -4
  45. package/kits/multiplayer-2d/code/server/players.js +27 -6
  46. package/kits/multiplayer-2d/code/server/world.js +24 -1
  47. package/kits/multiplayer-2d/code/systems/multiplayer.js +45 -4
  48. package/kits/multiplayer-2d/package-lock.json +2 -4
  49. package/kits/multiplayer-2d/package.json +1 -0
  50. package/kits/multiplayer-3d/CLAUDE.md +28 -9
  51. package/kits/multiplayer-3d/castle.json +5 -5
  52. package/kits/multiplayer-3d/code/server/players.js +54 -5
  53. package/kits/multiplayer-3d/code/server/world.js +45 -1
  54. package/kits/multiplayer-3d/code/systems/multiplayer.js +108 -6
  55. package/kits/multiplayer-3d/package-lock.json +2 -4
  56. package/kits/multiplayer-3d/package.json +1 -0
  57. package/kits/physics-2d/CLAUDE.md +75 -43
  58. package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
  59. package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
  60. package/kits/physics-2d/castle.json +6 -6
  61. package/kits/physics-2d/editors/PxArtEditor.jsx +85 -182
  62. package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
  63. package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
  64. package/kits/physics-2d/editors/overlayCanvas.js +77 -0
  65. package/kits/physics-2d/editors/pathOverlay.js +5 -13
  66. package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
  67. package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
  68. package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
  69. package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
  70. package/kits/physics-2d/editors/styleEditor.module.css +23 -4
  71. package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
  72. package/kits/physics-2d/engine/SceneUI.jsx +6 -12
  73. package/kits/physics-2d/engine/icons.js +56 -0
  74. package/kits/physics-2d/engine/panelSorting.jsx +7 -14
  75. package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
  76. package/kits/physics-2d/engine/physics/controls.js +76 -12
  77. package/kits/physics-2d/engine/scene.js +47 -9
  78. package/kits/physics-2d/engine/ui.jsx +13 -125
  79. package/kits/physics-2d/engine/ui.module.css +53 -28
  80. package/kits/physics-2d/package-lock.json +0 -29
  81. package/kits/physics-2d/package.json +3 -1
  82. package/kits/physics-2d/pnpm-lock.yaml +1 -17
  83. package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
  84. package/kits/physics-3d/castle.json +5 -5
  85. package/kits/physics-3d/package-lock.json +0 -29
  86. package/kits/physics-3d/package.json +0 -1
  87. package/kits/physics-3d/pnpm-lock.yaml +0 -17
  88. package/kits/real-time/CLAUDE.md +23 -4
  89. package/kits/real-time/castle.json +1 -1
  90. package/kits/real-time/code/client/connection.js +9 -4
  91. package/kits/real-time/code/client/joinOverlay.js +46 -0
  92. package/kits/real-time/code/client/messages.js +4 -0
  93. package/kits/real-time/code/server/gameHooks.js +4 -0
  94. package/kits/real-time/code/server/persist.js +105 -0
  95. package/kits/real-time/code/server/session.js +289 -19
  96. package/kits/real-time/package-lock.json +1139 -0
  97. package/kits/turn-based/CLAUDE.md +76 -20
  98. package/kits/turn-based/castle.json +3 -3
  99. package/kits/turn-based/code/server/index.js +16 -5
  100. package/kits/turn-based/package-lock.json +0 -27
  101. package/kits/turn-based/package.json +2 -2
  102. package/kits/turn-based/room.js +162 -13
  103. package/kits/turn-based/testing.js +276 -92
  104. package/package.json +5 -5
  105. package/dist/shell/assets/index-CVHj503j.css +0 -1
  106. package/dist/shell/assets/index-x_QkP3Xq.js +0 -444
@@ -92,6 +92,8 @@ import {
92
92
  } from './pixelInspector';
93
93
  import { useArtboardFit, useEditorCompactLayout } from './useArtboardFit';
94
94
  import { useTwoFingerZoomPan } from '../engine/gestureZoomPan';
95
+ import { useArtboardZoomPan } from './useArtboardZoomPan';
96
+ import { renderSnapLattice, setupOverlayContext } from './overlayCanvas';
95
97
  import {
96
98
  clearRegion,
97
99
  fillRegion,
@@ -149,11 +151,6 @@ import tl from './pxArtTimeline.module.css';
149
151
  const ONION_WARM = '#ff7a3c';
150
152
  const ONION_COOL = '#3ca0ff';
151
153
 
152
- // Artboard zoom bounds and wheel sensitivity. zoom 1 = fit (the artboard fills
153
- // its region); zooming in scrolls the surrounding viewport to pan.
154
- const MIN_ART_ZOOM = 1;
155
- const MAX_ART_ZOOM = 12;
156
- const ART_ZOOM_WHEEL_SPEED = 0.01;
157
154
  // Floating zoom/pan control cluster, tucked into the true bottom-right corner of
158
155
  // the artboard region (over the palette column's empty lower area — the palette
159
156
  // keeps its swatches top-aligned).
@@ -214,6 +211,7 @@ export function PxArtEditor({ path, text, onChange, files, readOnly = false, ...
214
211
  const canvasRef = useRef(null);
215
212
  const smoothRef = useRef(null);
216
213
  const overlayRef = useRef(null);
214
+ const latticeRef = useRef(null);
217
215
  const strokeRef = useRef(null);
218
216
  const refitActiveBrushStrokeRef = useRef(() => {});
219
217
  // The serialized text this editor last wrote itself. Lets us tell our own
@@ -369,14 +367,22 @@ export function PxArtEditor({ path, text, onChange, files, readOnly = false, ...
369
367
  resolution: sprite?.resolution ?? { width: 16, height: 16 },
370
368
  });
371
369
  // Artboard zoom + pan. `zoom` scales the canvas above its fit size; the
372
- // surrounding viewport (overflow: auto) scrolls to pan. Middle-drag pans and
373
- // the wheel zooms at the cursor (wired below); the button cluster drives the
374
- // same via `applyZoom`.
370
+ // surrounding viewport scrolls (programmatically -- see useArtboardZoomPan) to
371
+ // pan, over half a viewport of overscroll on every side so any part of the
372
+ // sprite can be brought to the middle of the screen.
375
373
  const viewportRef = useRef(null);
376
- const panRef = useRef(null);
377
- const [zoom, setZoom] = useState(1);
378
- const zoomRef = useRef(zoom);
379
- zoomRef.current = zoom;
374
+ const {
375
+ zoom,
376
+ zoomBy,
377
+ resetZoom,
378
+ applyZoomPan,
379
+ beginGesture,
380
+ endGesture,
381
+ viewportHandlers,
382
+ viewportStyle: artboardViewportStyle,
383
+ contentStyle: artboardContentStyle,
384
+ canvasSize,
385
+ } = useArtboardZoomPan({ viewportRef, displaySize });
380
386
  const pathCellPull =
381
387
  displaySize?.width && sprite?.resolution?.width
382
388
  ? magneticCellPull(sprite.resolution.width, displaySize.width, zoom)
@@ -391,62 +397,6 @@ export function PxArtEditor({ path, text, onChange, files, readOnly = false, ...
391
397
  stroke: hitRadiusCells(EDGE_HIT_SCREEN_PX, pathCellPx),
392
398
  openEnd: hitRadiusCells(OPEN_END_HIT_SCREEN_PX, pathCellPx),
393
399
  };
394
- // Set zoom to `nextRaw` (clamped) while pinning the point under (anchorX,
395
- // anchorY) client coords — defaulting to the viewport center. Scroll is fixed
396
- // up on the next frame, once the resized canvas has laid out.
397
- const applyZoom = useCallback((nextRaw, anchorX, anchorY) => {
398
- const vp = viewportRef.current;
399
- const z0 = zoomRef.current;
400
- const z1 = Math.max(MIN_ART_ZOOM, Math.min(MAX_ART_ZOOM, nextRaw));
401
- if (!vp || z1 === z0) return;
402
- const rect = vp.getBoundingClientRect();
403
- const ax = (Number.isFinite(anchorX) ? anchorX : rect.left + rect.width / 2) - rect.left;
404
- const ay = (Number.isFinite(anchorY) ? anchorY : rect.top + rect.height / 2) - rect.top;
405
- const contentX = vp.scrollLeft + ax;
406
- const contentY = vp.scrollTop + ay;
407
- const ratio = z1 / z0;
408
- setZoom(z1);
409
- requestAnimationFrame(() => {
410
- vp.scrollLeft = contentX * ratio - ax;
411
- vp.scrollTop = contentY * ratio - ay;
412
- });
413
- }, []);
414
- const resetZoom = useCallback(() => {
415
- setZoom(1);
416
- const vp = viewportRef.current;
417
- if (vp) requestAnimationFrame(() => {
418
- vp.scrollLeft = 0;
419
- vp.scrollTop = 0;
420
- });
421
- }, []);
422
- // Two-finger gesture step. Pan SYNCHRONOUSLY by scrolling — smooth and
423
- // accumulative, exactly like the middle-drag pan. (Applying the pan inside the
424
- // zoom's rAF made successive moves recompute from a scroll that hadn't been
425
- // written yet, dropping intermediate deltas — the "funky" pan.) Zoom about the
426
- // centroid only when the pinch actually changed scale; `applyZoom` reads the
427
- // just-scrolled position, so the pan is preserved. The epsilon keeps finger
428
- // jitter during a pure pan from flipping the zoom on and off.
429
- const applyZoomPan = useCallback(({ factor, cx, cy, dx, dy }) => {
430
- const vp = viewportRef.current;
431
- if (!vp) return;
432
- if (dx) vp.scrollLeft -= dx;
433
- if (dy) vp.scrollTop -= dy;
434
- if (Math.abs(factor - 1) > 0.002) applyZoom(zoomRef.current * factor, cx, cy);
435
- }, [applyZoom]);
436
- // Wheel zoom as a NATIVE non-passive listener so preventDefault stops the
437
- // page/browser zoom underneath. Delta is clamped so a chunky mouse notch is a
438
- // sane step while trackpad deltas stay smooth.
439
- useEffect(() => {
440
- const vp = viewportRef.current;
441
- if (!vp) return undefined;
442
- const onWheel = (event) => {
443
- event.preventDefault();
444
- const clamped = Math.max(-50, Math.min(50, event.deltaY));
445
- applyZoom(zoomRef.current * Math.exp(-clamped * ART_ZOOM_WHEEL_SPEED), event.clientX, event.clientY);
446
- };
447
- vp.addEventListener('wheel', onWheel, { passive: false });
448
- return () => vp.removeEventListener('wheel', onWheel, { passive: false });
449
- }, [applyZoom]);
450
400
  // Touch: one finger draws, a second finger pinch-zooms about the centroid and
451
401
  // pans (the viewport already has touch-action:none). The second finger aborts
452
402
  // the in-flight stroke so it doesn't leave a stray mark. See gestureZoomPan.
@@ -455,62 +405,21 @@ export function PxArtEditor({ path, text, onChange, files, readOnly = false, ...
455
405
  // lands entirely inside the canvas. Zoom/pan still applies to the viewport.
456
406
  useTwoFingerZoomPan({
457
407
  targetRef: editorBodyRef,
458
- onGestureStart: abortStroke,
408
+ onGestureStart: () => {
409
+ abortStroke();
410
+ beginGesture();
411
+ },
459
412
  onGestureMove: applyZoomPan,
413
+ // Releasing a pinch near fit settles onto exactly fit.
414
+ onGestureEnd: endGesture,
460
415
  // Procreate convention: two-finger tap undoes, three-finger tap redoes.
461
416
  onUndo: readOnly ? undefined : () => history.undo(),
462
417
  onRedo: readOnly ? undefined : () => history.redo(),
463
418
  });
464
- // Middle-button drag pans by scrolling the viewport. Intercept in the capture
465
- // phase so the press never reaches the canvas's draw handler (startTool).
466
- const onViewportPointerDownCapture = (event) => {
467
- // Pan on a middle-button drag or a ⌘+left-drag (metaKey). Both are caught in
468
- // the capture phase so the press never reaches the canvas's draw handler.
469
- const startsPan = event.button === 1 || (event.button === 0 && event.metaKey);
470
- if (!startsPan) return;
471
- event.preventDefault();
472
- event.stopPropagation();
473
- const vp = viewportRef.current;
474
- vp.setPointerCapture(event.pointerId);
475
- panRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
476
- };
477
- const onViewportPointerMove = (event) => {
478
- const pan = panRef.current;
479
- if (!pan || pan.id !== event.pointerId) return;
480
- const vp = viewportRef.current;
481
- vp.scrollLeft -= event.clientX - pan.x;
482
- vp.scrollTop -= event.clientY - pan.y;
483
- pan.x = event.clientX;
484
- pan.y = event.clientY;
485
- };
486
- const onViewportPointerUp = (event) => {
487
- const pan = panRef.current;
488
- if (!pan || pan.id !== event.pointerId) return;
489
- try {
490
- viewportRef.current?.releasePointerCapture(event.pointerId);
491
- } catch {
492
- // pointer capture may already be gone
493
- }
494
- panRef.current = null;
495
- };
496
- // Viewport is sized to the fit baseline; the canvas inside scales by zoom, so
497
- // zoom > 1 overflows and the viewport scrolls (= pan).
498
- const artboardViewportStyle = displaySize
499
- ? {
500
- width: displaySize.width,
501
- height: displaySize.height,
502
- maxWidth: '100%',
503
- maxHeight: '100%',
504
- overflow: 'auto',
505
- touchAction: 'none',
506
- }
507
- : { overflow: 'auto' };
508
- const artboardCanvasStyle = displaySize
509
- ? { width: displaySize.width * zoom, height: displaySize.height * zoom }
510
- : canvasStyle;
419
+ const artboardCanvasStyle = canvasSize ?? canvasStyle;
511
420
  // `canvasStyle`'s width is the artboard's CURRENT display size (recomputed
512
421
  // by useArtboardFit's own ResizeObserver whenever the wrap resizes — e.g. a
513
- // dockview panel drag). Feeding it in here is what keeps the supersampled
422
+ // panel resize). Feeding it in here is what keeps the supersampled
514
423
  // canvas's resolution in sync with that display size: without it, this
515
424
  // effect has no way to know the wrap resized (nothing about `text`,
516
425
  // `frameIndex`, or `cornerRadius` changes on a pure container resize), so
@@ -625,6 +534,12 @@ export function PxArtEditor({ path, text, onChange, files, readOnly = false, ...
625
534
  const id = setInterval(() => setAntsOffset((o) => (o + 1) % 1000), 80);
626
535
  return () => clearInterval(id);
627
536
  }, [tool, marquee]);
537
+ // Snap lattice for path layers. Redrawn on the artboard's CURRENT display size
538
+ // (`artboardCanvasStyle.width` carries the zoom), because the dots are placed
539
+ // per cell in screen space -- there is nothing tiled here to stretch.
540
+ useEffect(() => {
541
+ renderSnapLattice(latticeRef.current, sprite?.resolution, isPathLayer);
542
+ }, [isPathLayer, sprite?.resolution, artboardCanvasStyle?.width, artboardCanvasStyle?.height]);
628
543
  useEffect(() => {
629
544
  if (isPathLayer) {
630
545
  drawPathOverlay(overlayRef.current, sprite?.resolution, playing ? null : activePath, {
@@ -2314,36 +2229,32 @@ export function PxArtEditor({ path, text, onChange, files, readOnly = false, ...
2314
2229
  />
2315
2230
  </div>
2316
2231
  </div>
2317
- <div
2318
- ref={viewportRef}
2319
- style={artboardViewportStyle}
2320
- onPointerDownCapture={onViewportPointerDownCapture}
2321
- onPointerMove={onViewportPointerMove}
2322
- onPointerUp={onViewportPointerUp}
2323
- onPointerCancel={onViewportPointerUp}
2324
- >
2325
- <PixelArtboard
2326
- canvasRef={canvasRef}
2327
- smoothRef={smoothRef}
2328
- overlayRef={overlayRef}
2329
- smoothPreview={displayFinish(sprite).smooth}
2330
- style={{
2331
- ...artboardCanvasStyle,
2332
- ...latticeBackground(isPathLayer, artboardCanvasStyle, width, height),
2333
- cursor,
2334
- }}
2335
- handlers={
2336
- readOnly
2337
- ? {}
2338
- : {
2339
- onPointerDown: startTool,
2340
- onPointerMove: handlePointerMove,
2341
- onPointerUp: finishTool,
2342
- onPointerCancel: finishTool,
2343
- onPointerLeave: clearHover,
2344
- }
2345
- }
2346
- />
2232
+ <div ref={viewportRef} style={artboardViewportStyle} {...viewportHandlers}>
2233
+ <div style={artboardContentStyle}>
2234
+ <PixelArtboard
2235
+ canvasRef={canvasRef}
2236
+ smoothRef={smoothRef}
2237
+ overlayRef={overlayRef}
2238
+ latticeRef={latticeRef}
2239
+ smoothPreview={displayFinish(sprite).smooth}
2240
+ style={{
2241
+ ...artboardCanvasStyle,
2242
+ ...artboardBackground(isPathLayer, artboardCanvasStyle, zoom),
2243
+ cursor,
2244
+ }}
2245
+ handlers={
2246
+ readOnly
2247
+ ? {}
2248
+ : {
2249
+ onPointerDown: startTool,
2250
+ onPointerMove: handlePointerMove,
2251
+ onPointerUp: finishTool,
2252
+ onPointerCancel: finishTool,
2253
+ onPointerLeave: clearHover,
2254
+ }
2255
+ }
2256
+ />
2257
+ </div>
2347
2258
  </div>
2348
2259
  </div>
2349
2260
  </div>
@@ -2375,12 +2286,12 @@ export function PxArtEditor({ path, text, onChange, files, readOnly = false, ...
2375
2286
  <IconButton
2376
2287
  icon="zoom-in"
2377
2288
  label="Zoom in"
2378
- onClick={() => applyZoom(zoomRef.current * 1.25)}
2289
+ onClick={() => zoomBy(1.25)}
2379
2290
  />
2380
2291
  <IconButton
2381
2292
  icon="zoom-out"
2382
2293
  label="Zoom out"
2383
- onClick={() => applyZoom(zoomRef.current * 0.8)}
2294
+ onClick={() => zoomBy(0.8)}
2384
2295
  />
2385
2296
  <IconButton icon="fit" label="Fit" onClick={resetZoom} />
2386
2297
  </div>
@@ -2547,7 +2458,7 @@ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing
2547
2458
  // the `sprite` object (a fresh object every render) so it only redraws on an
2548
2459
  // actual content/frame/radius change, not on every hover-driven re-render of
2549
2460
  // the tool preview — PLUS `displayWidth` (the artboard's current CSS width
2550
- // from useArtboardFit), so a wrap resize (e.g. a dockview panel drag) also
2461
+ // from useArtboardFit), so a wrap resize (e.g. a panel drag) also
2551
2462
  // triggers a re-render at the new supersample scale, not just a content
2552
2463
  // change. The supersample factor auto-fits to the displayed size (capped
2553
2464
  // between 4x and 16x).
@@ -2573,21 +2484,23 @@ function useSmoothPreviewRender(smoothRef, text, sprite, frameIndex, displayWidt
2573
2484
  }, [text, frameIndex, smoothRef, cornerRadius, vector, displayWidth, bakePreview]);
2574
2485
  }
2575
2486
 
2576
- // Snap-lattice dot field for path layers always on the artboard frame so it
2577
- // stays consistent across Pixel / ¼ / ½ / Smooth (shows through transparent
2578
- // canvas areas; the inline background overrides the frame checkerboard).
2579
- function latticeBackground(isPathLayer, canvasStyle, gridWidth, gridHeight) {
2580
- if (!isPathLayer || !canvasStyle) return {};
2581
- const pitchX = parseFloat(canvasStyle.width) / gridWidth;
2582
- const pitchY = parseFloat(canvasStyle.height) / gridHeight;
2583
- if (!Number.isFinite(pitchX) || !Number.isFinite(pitchY)) return {};
2584
- // Dots centered on cell centers (half-cell offset from the corner lattice).
2585
- const dots =
2586
- Math.min(pitchX, pitchY) >= 5
2587
- ? `radial-gradient(circle, rgba(255, 255, 255, 0.28) 1px, transparent 1.6px) ` +
2588
- `0 0 / ${pitchX}px ${pitchY}px, `
2589
- : '';
2590
- return { background: `${dots}#121212` };
2487
+ // What the artboard frame paints UNDER the art. On a path layer that is a flat
2488
+ // ground for the snap lattice (drawn on its own canvas -- see renderSnapLattice,
2489
+ // which explains why it is not a background); everywhere else it is the
2490
+ // transparency checkerboard, scaled so it zooms with the sprite instead of
2491
+ // sitting still behind it.
2492
+ //
2493
+ // Either way it belongs to the FRAME, not to a canvas, so it shows through
2494
+ // transparent art in every finish (Pixel / ¼ / ½ / Smooth) without painting over
2495
+ // the smooth-preview canvas underneath.
2496
+ //
2497
+ // The checkerboard check at fit; `--checker-size` scales it from there, so the
2498
+ // pattern travels with a pinch instead of re-tiling underneath it.
2499
+ const CHECKER_FIT_SIZE = 16;
2500
+ function artboardBackground(isPathLayer, canvasStyle, zoom) {
2501
+ if (!canvasStyle) return {};
2502
+ if (isPathLayer) return { background: '#121212' };
2503
+ return { '--checker-size': `${CHECKER_FIT_SIZE * zoom}px` };
2591
2504
  }
2592
2505
 
2593
2506
  // Window-scoped tool keyboard shortcuts, mounted only while the editor is.
@@ -2628,24 +2541,14 @@ function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
2628
2541
  }
2629
2542
 
2630
2543
  function setupOverlayCanvas(canvas, resolution) {
2631
- if (!canvas || !resolution) return null;
2632
- const ctx = canvas.getContext('2d');
2633
- if (!ctx) return null;
2634
- const cssW = canvas.clientWidth;
2635
- const cssH = canvas.clientHeight;
2636
- if (!cssW || !cssH) return null;
2637
- const dpr = window.devicePixelRatio || 1;
2638
- const bw = Math.round(cssW * dpr);
2639
- const bh = Math.round(cssH * dpr);
2640
- if (canvas.width !== bw) canvas.width = bw;
2641
- if (canvas.height !== bh) canvas.height = bh;
2642
- ctx.setTransform(1, 0, 0, 1, 0, 0);
2643
- ctx.clearRect(0, 0, bw, bh);
2544
+ if (!resolution) return null;
2545
+ const surface = setupOverlayContext(canvas);
2546
+ if (!surface) return null;
2644
2547
  return {
2645
- ctx,
2646
- dpr,
2647
- sx: bw / resolution.width,
2648
- sy: bh / resolution.height,
2548
+ ctx: surface.ctx,
2549
+ dpr: surface.dpr,
2550
+ sx: surface.bw / resolution.width,
2551
+ sy: surface.bh / resolution.height,
2649
2552
  };
2650
2553
  }
2651
2554
 
@@ -886,6 +886,7 @@ export function SceneEditor({
886
886
  ? undefined
887
887
  : () => onSelectBlueprint(selectedActorBlueprint)
888
888
  }
889
+ onEditSprite={onEditSprite}
889
890
  onBeginPick={beginPick}
890
891
  pickActive={Boolean(pickRequest)}
891
892
  onSelectShape={setColliderShapeSel}
@@ -2083,6 +2084,7 @@ function ActorInspector({
2083
2084
  onRemoveBehavior,
2084
2085
  onNewBlueprint,
2085
2086
  onEditBlueprint,
2087
+ onEditSprite,
2086
2088
  onBeginPick,
2087
2089
  pickActive,
2088
2090
  onSelectShape,
@@ -2096,6 +2098,14 @@ function ActorInspector({
2096
2098
  readOnly = false,
2097
2099
  }) {
2098
2100
  const isInstance = Boolean(rawActor?.blueprint);
2101
+ // Same art the thumbnail shows (blueprint face, else the actor's own Sprite).
2102
+ const thumbBlueprint = blueprintTemplate ?? { components: selectedActor.components };
2103
+ const spritePath = thumbBlueprint.components?.Sprite?.file;
2104
+ const canEditSprite =
2105
+ Boolean(spritePath) &&
2106
+ !readOnly &&
2107
+ !isImportedPath(spritePath) &&
2108
+ files?.[spritePath] !== undefined;
2099
2109
  const presentNames = new Set(
2100
2110
  Object.entries(selectedActor.components)
2101
2111
  .filter(([, component]) => !!component)
@@ -2148,10 +2158,21 @@ function ActorInspector({
2148
2158
  {isInstance ? (
2149
2159
  <div className={styles.instanceHeader}>
2150
2160
  <div className={styles.instanceHeaderName}>
2151
- <BlueprintThumbnail
2152
- blueprint={blueprintTemplate ?? { components: selectedActor.components }}
2153
- sprites={sprites}
2154
- />
2161
+ {canEditSprite ? (
2162
+ <button
2163
+ type="button"
2164
+ className={styles.editSpriteButton}
2165
+ title={`Edit ${spritePath}`}
2166
+ aria-label="Edit sprite"
2167
+ onClick={() => void onEditSprite?.(spritePath)}>
2168
+ <BlueprintThumbnail blueprint={thumbBlueprint} sprites={sprites} />
2169
+ <span className={styles.editSpriteHint}>
2170
+ <Icon name="pencil" />
2171
+ </span>
2172
+ </button>
2173
+ ) : (
2174
+ <BlueprintThumbnail blueprint={thumbBlueprint} sprites={sprites} />
2175
+ )}
2155
2176
  <div className={styles.instanceHeaderNameCol}>
2156
2177
  <span className={styles.instanceHeaderNameText}>
2157
2178
  <strong className={styles.instanceHeaderNameStrong}>{blueprintName}</strong> Actor
@@ -31,14 +31,18 @@ function cardClassName(selected, clickable) {
31
31
  // One card, palette or font: a head (name + meta) over whatever the type shows
32
32
  // of itself. A button when it can be picked, a plain div when it can't.
33
33
  function styleCard({ cardKey, name, meta, preview, selected, onPick }) {
34
+ // Inner wrapper is the flex column, not the <button>. WebKit still wraps
35
+ // button contents in a shrink-to-fit anonymous box, so a flex button never
36
+ // stretches its children on some iOS versions — the palette strip stays
37
+ // colorCount × min-width wide. A block button + width:100% inner avoids that.
34
38
  const body = (
35
- <>
39
+ <div className={styles.cardBody}>
36
40
  <div className={styles.cardHead}>
37
41
  <div className={styles.cardName}>{name}</div>
38
42
  {meta ? <div className={styles.cardMeta}>{meta}</div> : null}
39
43
  </div>
40
44
  {preview}
41
- </>
45
+ </div>
42
46
  );
43
47
  const className = cardClassName(selected, Boolean(onPick));
44
48
  if (!onPick) {
@@ -0,0 +1,77 @@
1
+ // Backing store for the display-resolution overlay canvases (marching ants, path
2
+ // chrome). They are sized from the artboard's CSS size, which grows with zoom --
3
+ // so at 12x on a 3x screen a 460px artboard asks for a 16560 x 16560 store,
4
+ // around 274 megapixels. WebKit refuses an allocation that large (the overlay
5
+ // silently stops drawing) and every zoom step reallocates it, which is most of
6
+ // what made a deep pinch stutter on a phone.
7
+ //
8
+ // So cap the AREA and let the effective ratio fall below the device's. Past the
9
+ // cap the art underneath is upscaled anyway, so nothing goes soft that was still
10
+ // sharp; at fit on any normal artboard the cap is not reached and the overlay is
11
+ // exactly as crisp as before.
12
+ const MAX_OVERLAY_PIXELS = 4e6;
13
+
14
+ // Returns the cleared 2D context plus the ratio actually used, or null when the
15
+ // canvas has no layout yet. `dpr` is the CSS-pixel -> backing-store scale to draw
16
+ // with; `bw`/`bh` are the backing store, `cssW`/`cssH` the element.
17
+ export function setupOverlayContext(canvas) {
18
+ if (!canvas) return null;
19
+ const ctx = canvas.getContext('2d');
20
+ if (!ctx) return null;
21
+ // The measured box, NOT clientWidth: the artboard is sized by a continuous
22
+ // zoom, so its width is fractional at nearly every zoom, and clientWidth
23
+ // rounds it to a whole pixel. Dividing a rounded width by the grid gives a
24
+ // pitch that is wrong by up to half a pixel per cell, which accumulates
25
+ // across the row -- the marks drift further from their cells the further
26
+ // right you look, and the drift resets each time the rounding flips.
27
+ const { width: cssW, height: cssH } = canvas.getBoundingClientRect();
28
+ if (!cssW || !cssH) return null;
29
+ const device = window.devicePixelRatio || 1;
30
+ const wanted = cssW * cssH * device * device;
31
+ const dpr = wanted > MAX_OVERLAY_PIXELS ? device * Math.sqrt(MAX_OVERLAY_PIXELS / wanted) : device;
32
+ const bw = Math.max(1, Math.round(cssW * dpr));
33
+ const bh = Math.max(1, Math.round(cssH * dpr));
34
+ if (canvas.width !== bw) canvas.width = bw;
35
+ if (canvas.height !== bh) canvas.height = bh;
36
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
37
+ ctx.clearRect(0, 0, bw, bh);
38
+ return { ctx, dpr, cssW, cssH, bw, bh };
39
+ }
40
+
41
+ // Snap-lattice dot field for path layers: one dot at the centre of every cell.
42
+ //
43
+ // This used to be a repeating radial-gradient on the artboard frame, which
44
+ // cannot do the job: the pitch is the artboard width over the grid, so it is
45
+ // fractional at nearly every zoom, and while the browser repeats the tile at the
46
+ // exact fractional pitch it rounds the dot's offset WITHIN the tile to whole
47
+ // device pixels. The dots therefore sat up to a pixel off the cells they mark,
48
+ // drifting further out and snapping back as the pitch crossed each pixel
49
+ // boundary -- a sawtooth you could watch while pinching. Marks that say "snap
50
+ // here" have to be where the snap actually is, so they are drawn instead.
51
+ const LATTICE_DOT_RADIUS = 1.3;
52
+ const LATTICE_DOT_FILL = 'rgba(255, 255, 255, 0.28)';
53
+ // Below this the cells are too small for a dot per cell to read as anything but
54
+ // noise, so the field is left off.
55
+ const LATTICE_MIN_PITCH = 5;
56
+
57
+ export function renderSnapLattice(canvas, resolution, enabled) {
58
+ const surface = setupOverlayContext(canvas);
59
+ if (!surface) return;
60
+ const { ctx, dpr, bw, bh } = surface;
61
+ if (!enabled || !resolution?.width || !resolution?.height) return;
62
+ // Placed in BACKING pixels, not CSS pixels through a transform: the store is a
63
+ // whole number of pixels spanning exactly the artboard, so a cell centre is
64
+ // exact by construction and no rounding is left to accumulate. The radius is
65
+ // the only thing scaled by the ratio, so the dots stay one screen size.
66
+ const pitchX = bw / resolution.width;
67
+ const pitchY = bh / resolution.height;
68
+ if (Math.min(pitchX, pitchY) < LATTICE_MIN_PITCH * dpr) return;
69
+ ctx.fillStyle = LATTICE_DOT_FILL;
70
+ for (let y = 0; y < resolution.height; y++) {
71
+ for (let x = 0; x < resolution.width; x++) {
72
+ ctx.beginPath();
73
+ ctx.arc((x + 0.5) * pitchX, (y + 0.5) * pitchY, LATTICE_DOT_RADIUS * dpr, 0, Math.PI * 2);
74
+ ctx.fill();
75
+ }
76
+ }
77
+ }
@@ -1,6 +1,7 @@
1
1
  import { absoluteSegment, subpathEdgeFrom, subpathPoints } from '../engine/pxartPath.js';
2
2
  import { primitiveHandleIndices } from '../engine/pxartPath.js';
3
3
  import { anchorKey, coincidentSiteKeys, isOpenPenShape } from './pathTools';
4
+ import { setupOverlayContext } from './overlayCanvas';
4
5
 
5
6
  // Render-only overlay for path-layer tools: selection chrome (outline + anchor
6
7
  // dots), pen rubber-band to hover, fill-tool hover preview, and shape marquee.
@@ -40,19 +41,10 @@ function toCanvas(sx, sy, x, y) {
40
41
  }
41
42
 
42
43
  export function drawPathOverlay(canvas, resolution, path, ui) {
43
- if (!canvas || !resolution) return;
44
- const ctx = canvas.getContext('2d');
45
- if (!ctx) return;
46
- const cssW = canvas.clientWidth;
47
- const cssH = canvas.clientHeight;
48
- if (!cssW || !cssH) return;
49
- const dpr = window.devicePixelRatio || 1;
50
- const bw = Math.round(cssW * dpr);
51
- const bh = Math.round(cssH * dpr);
52
- if (canvas.width !== bw) canvas.width = bw;
53
- if (canvas.height !== bh) canvas.height = bh;
54
- ctx.setTransform(1, 0, 0, 1, 0, 0);
55
- ctx.clearRect(0, 0, bw, bh);
44
+ if (!resolution) return;
45
+ const surface = setupOverlayContext(canvas);
46
+ if (!surface) return;
47
+ const { ctx, dpr, cssW, cssH } = surface;
56
48
  // From here on the drawing units are CSS pixels while the backing store stays
57
49
  // at device resolution, so the chrome is both crisp and a fixed screen size.
58
50
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
@@ -45,14 +45,18 @@ export function PixelEditorHeader({ title, subtitle, shell, chrome }) {
45
45
  // callbacks the editor wires for tools. `overlayRef` is a sibling canvas at
46
46
  // display resolution used for crisp overlays (marching-ants selection) that would
47
47
  // otherwise render sub-pixel on the upscaled native canvas. `smoothRef` is a
48
+ // `latticeRef` is a sibling canvas BEHIND even that one, holding the path-layer
49
+ // snap lattice, so the dots sit under the art exactly as the frame background
50
+ // they replaced did. `smoothRef` is a
48
51
  // sibling canvas BEHIND the main one, holding the corner-rounded render for
49
52
  // "smooth"-mode sprites (empty/unused in "pixel" mode) so the interactive canvas
50
53
  // above it can stay at native resolution — pointer math and tool-preview overlays
51
54
  // are unaffected by the render mode.
52
- export function PixelArtboard({ canvasRef, smoothRef, overlayRef, style, handlers, smoothPreview = false }) {
55
+ export function PixelArtboard({ canvasRef, smoothRef, overlayRef, latticeRef, style, handlers, smoothPreview = false }) {
53
56
  return (
54
57
  <div className={styles.drawingArtboard}>
55
58
  <div className={styles.drawingArtboardFrame} style={style}>
59
+ <canvas ref={latticeRef} className={styles.drawingLattice} aria-hidden="true" />
56
60
  <canvas ref={smoothRef} className={styles.drawingSmooth} aria-hidden="true" />
57
61
  <canvas
58
62
  ref={canvasRef}
@@ -23,7 +23,7 @@ export const PIXEL_TOOLS = [
23
23
  export const PATH_TOOLS = [
24
24
  { id: 'brush', label: 'Brush', icon: 'pencil', shortcut: 'B' },
25
25
  { id: 'fill', label: 'Fill', icon: 'fill', shortcut: 'G' },
26
- { id: 'path-select', label: 'Select', icon: 'location-arrow', shortcut: 'V' },
26
+ { id: 'path-select', label: 'Select', icon: 'navigation-arrow', shortcut: 'V' },
27
27
  { id: 'bend', label: 'Bend', icon: 'bezier-curve', shortcut: 'N' },
28
28
  ];
29
29
 
@@ -40,7 +40,7 @@ export const SHAPE_MODES = [
40
40
 
41
41
  // Sub-modes for the path brush tool: click-to-place pen, freehand fit, drag primitives.
42
42
  export const BRUSH_MODES = [
43
- { id: 'freehand', label: 'Freehand', icon: 'paintbrush' },
43
+ { id: 'freehand', label: 'Freehand', icon: 'scribble' },
44
44
  { id: 'pen', label: 'Pen', icon: 'pen-nib', shortcut: 'P' },
45
45
  { id: 'line', label: 'Line', icon: 'slash', shortcut: 'L' },
46
46
  { id: 'rect', label: 'Rectangle', icon: 'square', shortcut: 'R' },
@@ -58,7 +58,7 @@ export const PATH_BRUSH_MODE_BY_KEY = Object.fromEntries(
58
58
  export const MOVE_ACTIONS = [
59
59
  { id: 'flipH', label: 'Flip horizontal', icon: 'flip-h' },
60
60
  { id: 'flipV', label: 'Flip vertical', icon: 'flip-v' },
61
- { id: 'rotateCW', label: 'Rotate clockwise', icon: 'redo' },
61
+ { id: 'rotateCW', label: 'Rotate clockwise', icon: 'rotate' },
62
62
  ];
63
63
 
64
64
  // Tools whose work is driven by the active palette color, so the palette panel
@@ -636,7 +636,7 @@ function ShapeInspectorButtons({
636
636
  aria-label="Rotate 90 degrees clockwise"
637
637
  disabled={!pathActions?.hasSelection}
638
638
  onClick={pathActions?.rotateCW}>
639
- <Icon name="redo" />
639
+ <Icon name="rotate" />
640
640
  </button>
641
641
  <button
642
642
  type="button"