castle-web-cli 0.4.122 → 0.4.124

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 (60) hide show
  1. package/dist/agent-prompts.js +6 -2
  2. package/dist/castleJson.d.ts +2 -0
  3. package/dist/castleJson.js +39 -0
  4. package/dist/diffText.d.ts +14 -0
  5. package/dist/diffText.js +138 -0
  6. package/dist/headlessCover.d.ts +13 -0
  7. package/dist/headlessCover.js +99 -0
  8. package/dist/ide.d.ts +2 -1
  9. package/dist/ide.js +100 -43
  10. package/dist/imports.js +42 -2
  11. package/dist/index.js +49 -11
  12. package/dist/init.d.ts +1 -0
  13. package/dist/init.js +45 -1
  14. package/dist/localPaths.d.ts +1 -0
  15. package/dist/localPaths.js +5 -0
  16. package/dist/preview.d.ts +0 -1
  17. package/dist/preview.js +0 -58
  18. package/dist/save-deck.js +52 -1
  19. package/dist/serve.js +16 -0
  20. package/dist/shell/assets/Basteleur-Bold-CK8LF7Pt.woff +0 -0
  21. package/dist/shell/assets/Basteleur-Bold-DKFKedNb.woff2 +0 -0
  22. package/dist/shell/assets/index-BfOPkSej.css +1 -0
  23. package/dist/shell/assets/index-u0nYFqbF.js +434 -0
  24. package/dist/shell/index.html +2 -2
  25. package/dist/versionStore.d.ts +54 -0
  26. package/dist/versionStore.js +281 -0
  27. package/dist/versions.d.ts +82 -0
  28. package/dist/versions.js +446 -0
  29. package/kits/physics-2d/CLAUDE.md +2 -2
  30. package/kits/physics-2d/castle.json +10 -2
  31. package/kits/physics-2d/docs/pxart-format.md +33 -26
  32. package/kits/physics-2d/editors/PxArtEditor.jsx +120 -49
  33. package/kits/physics-2d/editors/SingleEditor.jsx +6 -3
  34. package/kits/physics-2d/editors/StyleEditor.jsx +95 -0
  35. package/kits/physics-2d/editors/pathOverlay.js +1 -1
  36. package/kits/physics-2d/editors/pathTools.js +9 -1
  37. package/kits/physics-2d/editors/pixelGeometry.js +14 -13
  38. package/kits/physics-2d/editors/pixelInspector.jsx +202 -53
  39. package/kits/physics-2d/editors/pxArtEditorModel.js +8 -63
  40. package/kits/physics-2d/editors/pxArtTools.js +3 -43
  41. package/kits/physics-2d/editors/styleEditor.module.css +105 -0
  42. package/kits/physics-2d/editors/styleTheme.js +16 -0
  43. package/kits/physics-2d/engine/files.js +2 -1
  44. package/kits/physics-2d/engine/liveReload.js +4 -3
  45. package/kits/physics-2d/engine/palettes.js +636 -0
  46. package/kits/physics-2d/engine/pxart.js +6 -6
  47. package/kits/physics-2d/engine/svgImport.js +1056 -0
  48. package/kits/physics-2d/engine/ui.jsx +2 -0
  49. package/kits/physics-2d/engine/ui.module.css +54 -9
  50. package/kits/physics-2d/package-lock.json +1 -1
  51. package/kits/physics-2d/package.json +1 -0
  52. package/kits/physics-2d/scripts/deckTheme.mjs +25 -0
  53. package/kits/physics-2d/scripts/draw.mjs +5 -3
  54. package/kits/physics-2d/scripts/import-svg.mjs +16 -1069
  55. package/kits/physics-2d/scripts/palette.mjs +10 -0
  56. package/kits/physics-2d/scripts/svg-emission-guide.md +5 -3
  57. package/kits/physics-2d/theme.style +3 -0
  58. package/package.json +1 -1
  59. package/dist/shell/assets/index-BkJ87APM.css +0 -1
  60. package/dist/shell/assets/index-DXBpj3-y.js +0 -434
@@ -7,6 +7,15 @@ import {
7
7
  renderSmoothSpriteFrame,
8
8
  } from '../engine/pxartSmooth';
9
9
  import { basename } from '../engine/files';
10
+ import {
11
+ allocateSpriteColor,
12
+ deckPaletteFromFiles,
13
+ hexForPaletteKey,
14
+ paletteRecord,
15
+ pickerColorsFor,
16
+ pickerPageIndexFor,
17
+ pickerPagesFor,
18
+ } from '../engine/palettes';
10
19
  import { EditorBody, Icon, IconButton, styles } from '../engine/ui';
11
20
  import { eventToCell, eventToPoint } from './pixelCanvas';
12
21
  import { forEachDab } from './pixelGeometry';
@@ -84,9 +93,6 @@ import {
84
93
  import { useArtboardFit, useEditorCompactLayout } from './useArtboardFit';
85
94
  import { useTwoFingerZoomPan } from '../engine/gestureZoomPan';
86
95
  import {
87
- DEFAULT_KEY,
88
- EDITOR_KEYS,
89
- EDITOR_PALETTE,
90
96
  clearRegion,
91
97
  fillRegion,
92
98
  flipCellsH,
@@ -137,8 +143,8 @@ import tl from './pxArtTimeline.module.css';
137
143
  // Aseprite-style editor for the full .pxart sprite format: a full layers x frames
138
144
  // timeline below a compositing artboard. The artboard composites the active
139
145
  // frame's visible layers (renderSpriteFrame) so the artist sees the whole image
140
- // while painting targets the active layer's cell, over a FIXED Endesga-64
141
- // palette and power-of-two resolutions. Sprites that lose nothing in the compact
146
+ // while painting targets the active layer's cell, over the sprite's own palette
147
+ // (deck palette is a picker) and power-of-two resolutions. Sprites that lose nothing in the compact
142
148
  // form serialize to compact; anything richer serializes to the full form.
143
149
  const ONION_WARM = '#ff7a3c';
144
150
  const ONION_COOL = '#3ca0ff';
@@ -189,7 +195,7 @@ const PATH_ONLY_TOOL_IDS = new Set(
189
195
  )
190
196
  );
191
197
 
192
- export function PxArtEditor({ path, text, onChange, ...chrome }) {
198
+ export function PxArtEditor({ path, text, onChange, files, ...chrome }) {
193
199
  const canvasRef = useRef(null);
194
200
  const smoothRef = useRef(null);
195
201
  const overlayRef = useRef(null);
@@ -199,7 +205,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
199
205
  // edits apart from external rewrites (undo/redo) so the marquee can be dropped
200
206
  // when the art changes out from under it.
201
207
  const lastSelfTextRef = useRef(text);
202
- const [activeKey, setActiveKey] = useState(DEFAULT_KEY);
208
+ const [activeHex, setActiveHex] = useState(null);
203
209
  const [rawSelection, setRawSelection] = useState({ layerIndex: 0, frameIndex: 0 });
204
210
  const [playing, setPlaying] = useState(false);
205
211
  const [onion, setOnion] = useState({ enabled: false, range: 1 });
@@ -263,6 +269,29 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
263
269
  const [paletteOpen, setPaletteOpen] = useState(false);
264
270
 
265
271
  const sprite = deriveSprite(text);
272
+ const deckPalette = useMemo(() => deckPaletteFromFiles(files), [files]);
273
+ const deckHexes = useMemo(() => pickerColorsFor(deckPalette.colors), [deckPalette]);
274
+ const pages = useMemo(() => pickerPagesFor(deckPalette), [deckPalette]);
275
+ const [pageIndex, setPageIndex] = useState(() => pickerPageIndexFor(pages, deckPalette));
276
+ const onPage = useCallback((delta) => {
277
+ setPageIndex((index) => {
278
+ const n = pages.length;
279
+ return n ? (index + delta + n) % n : 0;
280
+ });
281
+ }, [pages.length]);
282
+ const workingPalette = useMemo(() => paletteRecord(sprite?.palette), [sprite]);
283
+ const workingKeys = useMemo(() => (sprite?.palette ?? []).map((entry) => entry.key), [sprite]);
284
+ const paintReady = useMemo(
285
+ () => (sprite && activeHex ? allocateSpriteColor(sprite, activeHex) : { sprite, key: null }),
286
+ [sprite, activeHex]
287
+ );
288
+ const paintKey = paintReady.key;
289
+ // Derived, not stored: the note shows exactly while the active color can't be
290
+ // allocated, and clears itself when undo or a palette edit frees a slot.
291
+ const paletteFull = paintReady.error === 'palette-full';
292
+ useEffect(() => {
293
+ if (!activeHex && deckHexes[0]) setActiveHex(deckHexes[0]);
294
+ }, [activeHex, deckHexes]);
266
295
  const selection = sprite
267
296
  ? clampSelection(sprite, rawSelection.layerIndex, rawSelection.frameIndex)
268
297
  : rawSelection;
@@ -308,7 +337,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
308
337
  picking,
309
338
  brushSize,
310
339
  eraseSize,
311
- activeKey,
340
+ activeHex,
312
341
  pathBakePreview,
313
342
  });
314
343
  const { canvasStyle, displaySize } = useArtboardFit({
@@ -577,7 +606,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
577
606
  drawPathOverlay(overlayRef.current, sprite?.resolution, playing ? null : activePath, {
578
607
  tool,
579
608
  brushMode,
580
- palette: EDITOR_PALETTE,
609
+ palette: workingPalette,
581
610
  brushInk,
582
611
  brushGhost,
583
612
  hoverPoint,
@@ -589,7 +618,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
589
618
  hoverEdge,
590
619
  shapeMarquee,
591
620
  fillHoverTarget,
592
- activeColor: EDITOR_PALETTE[activeKey],
621
+ activeColor: activeHex,
593
622
  smoothFinish: isVectorRenderer(sprite?.renderer),
594
623
  });
595
624
  } else {
@@ -605,7 +634,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
605
634
  picking,
606
635
  brushSize,
607
636
  eraseSize,
608
- activeKey,
637
+ activeHex,
609
638
  playing,
610
639
  });
611
640
  renderPixelOverlay(overlayRef.current, sprite?.resolution, {
@@ -634,7 +663,8 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
634
663
  hoverEdge,
635
664
  shapeMarquee,
636
665
  fillHoverTarget,
637
- activeKey,
666
+ paintKey,
667
+ activeHex,
638
668
  hoverCell,
639
669
  picking,
640
670
  brushSize,
@@ -747,7 +777,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
747
777
  text,
748
778
  selection.layerIndex,
749
779
  selection.frameIndex,
750
- activeKey,
780
+ paintKey,
751
781
  activePath,
752
782
  ]);
753
783
  // Shift/Alt during a brush primitive drag only update on pointermove unless
@@ -851,8 +881,8 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
851
881
  stroke.draftCells = nextCells;
852
882
  liveSprite(withCellsAt(stroke.base, stroke.layerIndex, stroke.frameIndex, nextCells));
853
883
  }
854
- function commitCells(nextCells) {
855
- const nextText = serializeModel(withCellsAt(sprite, selection.layerIndex, selection.frameIndex, nextCells));
884
+ function commitCells(nextCells, base = sprite) {
885
+ const nextText = serializeModel(withCellsAt(base, selection.layerIndex, selection.frameIndex, nextCells));
856
886
  lastSelfTextRef.current = nextText;
857
887
  history.commit(nextText);
858
888
  }
@@ -901,12 +931,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
901
931
  history.cancelSnapshot();
902
932
  }
903
933
  }
904
- function commitPath(nextPath, options) {
934
+ function commitPath(nextPath, options, base = sprite) {
905
935
  // Hold the invariant that an edge exists once, after any edit rather than
906
936
  // only the one that happened to produce a duplicate. Skipped mid-limb: a
907
937
  // half-drawn run can lie on the outline on its way somewhere else.
908
938
  const settled = penLimbRef.current ? nextPath : absorbRetracedSubpaths(nextPath);
909
- const nextText = serializeModel(withPathAt(sprite, selection.layerIndex, selection.frameIndex, settled));
939
+ const nextText = serializeModel(withPathAt(base, selection.layerIndex, selection.frameIndex, settled));
910
940
  lastSelfTextRef.current = nextText;
911
941
  history.commit(nextText, options);
912
942
  }
@@ -956,7 +986,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
956
986
  }
957
987
  function refitBrushGhost(ink, halfPixel = false) {
958
988
  const fit = fitBrushInk(ink, { halfPixel, cellPull: pathCellPull });
959
- const shape = fit ? shapeFromBrushFit(fit, activeKey) : null;
989
+ const shape = fit ? shapeFromBrushFit(fit, paintKey) : null;
960
990
  // Cyan ghost stays visible even when the bake blends into existing pixels.
961
991
  // Skip clearing on a null fit so the preview doesn't blink in the
962
992
  // degenerate snap cell around the gesture start.
@@ -966,7 +996,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
966
996
  function refitBrushPrimitiveGhost(start, end, halfPixel = false, constrain = false) {
967
997
  const stroke = strokeRef.current;
968
998
  const mode = stroke?.brushMode ?? brushMode;
969
- const shape = ghostFromBrushDrag(mode, start, end, { halfPixel, constrain, activeKey, cellPull: pathCellPull });
999
+ const shape = ghostFromBrushDrag(mode, start, end, { halfPixel, constrain, activeKey: paintKey, cellPull: pathCellPull });
970
1000
  if (shape) setBrushGhost(shape);
971
1001
  liveBrushBake(shape);
972
1002
  }
@@ -978,14 +1008,14 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
978
1008
  let shape = null;
979
1009
  if (stroke.brushMode === 'freehand') {
980
1010
  const fit = fitBrushInk(brushInkRef.current, { halfPixel, cellPull: pathCellPull });
981
- shape = fit ? shapeFromBrushFit(fit, activeKey) : null;
1011
+ shape = fit ? shapeFromBrushFit(fit, paintKey) : null;
982
1012
  } else {
983
1013
  const start = stroke.startPoint;
984
1014
  const end = endPoint ?? stroke.lastPoint ?? start;
985
1015
  shape = shapeFromBrushDrag(stroke.brushMode, start, end, {
986
1016
  halfPixel,
987
1017
  constrain,
988
- activeKey,
1018
+ activeKey: paintKey,
989
1019
  cellPull: pathCellPull,
990
1020
  });
991
1021
  }
@@ -1052,7 +1082,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
1052
1082
  return appendAnchorToSubpath(path, target.shapeIndex, target.subIndex, [sx, sy]);
1053
1083
  }
1054
1084
 
1055
- return appendShape(path, shapeFromPenDraft([[sx, sy]], { closed: false, activeKey }));
1085
+ return appendShape(path, shapeFromPenDraft([[sx, sy]], { closed: false, activeKey: paintKey }));
1056
1086
  }
1057
1087
  function updatePenHoverBake(point, halfPixel) {
1058
1088
  const now = performance.now();
@@ -1356,13 +1386,25 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
1356
1386
  if (!selectedShapeIndices.length) return;
1357
1387
  commitPath(rotateShapes90CW(activePath, selectedShapeIndices));
1358
1388
  },
1359
- setFill: (key) => {
1389
+ setFill: (hex) => {
1360
1390
  if (!selectedShapeIndices.length) return;
1361
- commitPath(setShapesFill(activePath, selectedShapeIndices, key));
1391
+ if (hex == null) {
1392
+ commitPath(setShapesFill(activePath, selectedShapeIndices, null));
1393
+ return;
1394
+ }
1395
+ const allocated = allocateSpriteColor(sprite, hex);
1396
+ if (!allocated.key) return;
1397
+ commitPath(setShapesFill(activePath, selectedShapeIndices, allocated.key), undefined, allocated.sprite);
1362
1398
  },
1363
- setStroke: (key) => {
1399
+ setStroke: (hex) => {
1364
1400
  if (!selectedShapeIndices.length) return;
1365
- commitPath(setShapesStroke(activePath, selectedShapeIndices, key));
1401
+ if (hex == null) {
1402
+ commitPath(setShapesStroke(activePath, selectedShapeIndices, null));
1403
+ return;
1404
+ }
1405
+ const allocated = allocateSpriteColor(sprite, hex);
1406
+ if (!allocated.key) return;
1407
+ commitPath(setShapesStroke(activePath, selectedShapeIndices, allocated.key), undefined, allocated.sprite);
1366
1408
  },
1367
1409
  };
1368
1410
  function startPenStroke(point, event) {
@@ -1426,11 +1468,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
1426
1468
  }
1427
1469
 
1428
1470
  // Start a new open stroked shape at this anchor.
1429
- const shape = shapeFromPenDraft([[sx, sy]], { closed: false, activeKey });
1471
+ if (!paintReady.key) return;
1472
+ const shape = shapeFromPenDraft([[sx, sy]], { closed: false, activeKey: paintKey });
1430
1473
  const next = appendShape(activePath, shape);
1431
1474
  const top = (next.shapes?.length ?? 1) - 1;
1432
1475
  penStubIndexRef.current = top;
1433
- commitPath(next);
1476
+ commitPath(next, undefined, paintReady.sprite);
1434
1477
  setSelectedShapeIndices([top]);
1435
1478
  setSelectedAnchors([]);
1436
1479
  }
@@ -1697,8 +1740,9 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
1697
1740
  startPenStroke(point, event);
1698
1741
  return;
1699
1742
  }
1743
+ if (!paintReady.key) return;
1700
1744
  const { halfPixel, constrain } = brushModifiersFromEvent(event);
1701
- const base = sprite;
1745
+ const base = paintReady.sprite;
1702
1746
  const baseText = serializeModel(sprite);
1703
1747
  const path = activePath;
1704
1748
  if (brushMode === 'freehand') {
@@ -1764,8 +1808,9 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
1764
1808
  }
1765
1809
  function startPathTool(point, event) {
1766
1810
  if (tool === 'fill') {
1767
- const next = recolorTopShapeAt(activePath, point.x, point.y, activeKey);
1768
- if (next !== activePath) commitPath(next);
1811
+ if (!paintReady.key) return;
1812
+ const next = recolorTopShapeAt(activePath, point.x, point.y, paintReady.key);
1813
+ if (next !== activePath) commitPath(next, undefined, paintReady.sprite);
1769
1814
  } else if (tool === 'brush') {
1770
1815
  startBrushStroke(point, event);
1771
1816
  } else if (tool === 'path-select') {
@@ -1786,7 +1831,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
1786
1831
  const point = eventToPoint(event, width, height);
1787
1832
  if (picking) {
1788
1833
  const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
1789
- setActiveKey(
1834
+ pickSampledKey(
1790
1835
  sampleKey(cells, {
1791
1836
  x: Math.min(width - 1, Math.floor(point.x)),
1792
1837
  y: Math.min(height - 1, Math.floor(point.y)),
@@ -1806,7 +1851,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
1806
1851
  // engaged it intercepts the pick, leaves the underlying tool untouched, and
1807
1852
  // exits picker mode so the previously selected tool resumes after one pick.
1808
1853
  if (picking) {
1809
- setActiveKey(sampleKey(cells, point));
1854
+ pickSampledKey(sampleKey(cells, point));
1810
1855
  setPicking(false);
1811
1856
  return;
1812
1857
  }
@@ -1833,8 +1878,11 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
1833
1878
  return;
1834
1879
  }
1835
1880
  if (tool === 'fill') {
1836
- const next = fillSwapMode ? swapKey(cells, point, activeKey) : fillRegion(cells, point, activeKey);
1837
- if (next !== cells) commitCells(next);
1881
+ if (!paintReady.key) return;
1882
+ const next = fillSwapMode
1883
+ ? swapKey(cells, point, paintReady.key)
1884
+ : fillRegion(cells, point, paintReady.key);
1885
+ if (next !== cells) commitCells(next, paintReady.sprite);
1838
1886
  return;
1839
1887
  }
1840
1888
  if (tool === 'erase' && eraseFillMode) {
@@ -1842,11 +1890,13 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
1842
1890
  if (next !== cells) commitCells(next);
1843
1891
  return;
1844
1892
  }
1893
+ if ((tool === 'brush' || tool === 'shape') && !paintReady.key) return;
1845
1894
  strokeRef.current = {
1846
1895
  tool,
1847
1896
  startPoint: point,
1848
1897
  lastPoint: point,
1849
- base: sprite,
1898
+ base: paintReady.key ? paintReady.sprite : sprite,
1899
+ paintKey: paintReady.key,
1850
1900
  layerIndex: selection.layerIndex,
1851
1901
  frameIndex: selection.frameIndex,
1852
1902
  // Offset of the cel at gesture start, so move applies an ABSOLUTE offset
@@ -1963,7 +2013,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
1963
2013
  return;
1964
2014
  }
1965
2015
  if (stroke.tool === 'brush' && stroke.brushMode == null) {
1966
- liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, activeKey, brushSize));
2016
+ liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, stroke.paintKey, brushSize));
1967
2017
  stroke.lastPoint = point;
1968
2018
  } else if (stroke.tool === 'erase') {
1969
2019
  liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, TRANSPARENT, eraseSize));
@@ -1971,7 +2021,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
1971
2021
  } else if (stroke.tool === 'shape') {
1972
2022
  // Recompute from the gesture-start snapshot so the shape rubber-bands to
1973
2023
  // the current point rather than stacking each intermediate draft.
1974
- liveStroke(paintShape(stroke.originCells, stroke.startPoint, point, activeKey, shapeMode, shapeFill));
2024
+ liveStroke(paintShape(stroke.originCells, stroke.startPoint, point, stroke.paintKey, shapeMode, shapeFill));
1975
2025
  } else if (stroke.tool === 'move-all') {
1976
2026
  // Translate the cel's OFFSET by the drag delta instead of shifting and
1977
2027
  // clipping pixels — the full cel image is preserved, so pushing art off
@@ -2163,8 +2213,18 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
2163
2213
  playing,
2164
2214
  setOnion,
2165
2215
  });
2216
+ function pickSampledKey(key) {
2217
+ if (key === TRANSPARENT) return;
2218
+ const hex = hexForPaletteKey(sprite?.palette, key);
2219
+ if (hex) setActiveHex(hex);
2220
+ }
2166
2221
  function selectPaletteKey(key) {
2167
- setActiveKey(key);
2222
+ const hex = workingPalette[key];
2223
+ if (hex) setActiveHex(hex);
2224
+ }
2225
+ function selectPaletteHex(hex) {
2226
+ if (!hex) return;
2227
+ setActiveHex(hex);
2168
2228
  }
2169
2229
 
2170
2230
  return (
@@ -2253,14 +2313,19 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
2253
2313
  <div className={styles.paintColumn}>
2254
2314
  <PaintStrip
2255
2315
  toolState={toolState}
2256
- activeKey={activeKey}
2257
- activeColor={EDITOR_PALETTE[activeKey]}
2316
+ activeKey={paintKey}
2317
+ activeColor={activeHex}
2258
2318
  paletteOpen={paletteOpen}
2259
2319
  onTogglePalette={togglePalette}
2260
2320
  colorButtonRef={colorButtonRef}
2261
2321
  onSelectKey={selectPaletteKey}
2262
- paletteKeys={EDITOR_KEYS}
2263
- palette={EDITOR_PALETTE}
2322
+ onSelectHex={selectPaletteHex}
2323
+ paletteKeys={workingKeys}
2324
+ palette={workingPalette}
2325
+ pages={pages}
2326
+ pageIndex={pageIndex}
2327
+ onPage={onPage}
2328
+ paletteFull={paletteFull}
2264
2329
  moveActions={moveActions}
2265
2330
  selectActions={selectActions}
2266
2331
  pathActions={isPathLayer ? pathActions : null}
@@ -2291,10 +2356,16 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
2291
2356
  </div>
2292
2357
  <PxArtInspectorDock
2293
2358
  toolState={toolState}
2294
- activeKey={activeKey}
2359
+ activeKey={paintKey}
2360
+ activeHex={activeHex}
2295
2361
  onSelectKey={selectPaletteKey}
2296
- paletteKeys={EDITOR_KEYS}
2297
- palette={EDITOR_PALETTE}
2362
+ onSelectHex={selectPaletteHex}
2363
+ paletteKeys={workingKeys}
2364
+ palette={workingPalette}
2365
+ pages={pages}
2366
+ pageIndex={pageIndex}
2367
+ onPage={onPage}
2368
+ paletteFull={paletteFull}
2298
2369
  moveActions={moveActions}
2299
2370
  selectActions={selectActions}
2300
2371
  pathActions={isPathLayer ? pathActions : null}
@@ -2399,7 +2470,7 @@ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing
2399
2470
  p.picking,
2400
2471
  p.brushSize,
2401
2472
  p.eraseSize,
2402
- p.activeKey,
2473
+ p.activeHex,
2403
2474
  p.selection.layerIndex,
2404
2475
  p.selection.frameIndex,
2405
2476
  p.pathBakePreview,
@@ -2731,9 +2802,9 @@ function fillPreviewCells(cells, start) {
2731
2802
 
2732
2803
  // Describe the overlay to draw for the hovered cell, or null when nothing should
2733
2804
  // show (no hover, playback, or the move-all tool which has no footprint).
2734
- function buildToolPreview({ sprite, selection, hoverCell, tool, picking, brushSize, eraseSize, activeKey, playing }) {
2805
+ function buildToolPreview({ sprite, selection, hoverCell, tool, picking, brushSize, eraseSize, activeHex, playing }) {
2735
2806
  if (!hoverCell || playing) return null;
2736
- const color = EDITOR_PALETTE[activeKey] || '#ffffff';
2807
+ const color = activeHex || '#ffffff';
2737
2808
  if (picking) return { kind: 'cell', cell: hoverCell };
2738
2809
  if (tool === 'brush') return { kind: 'footprint', cell: hoverCell, size: brushSize, color };
2739
2810
  if (tool === 'erase') return { kind: 'erase', cell: hoverCell, size: eraseSize };
@@ -1,8 +1,8 @@
1
1
  // Single-editor mount for the panel system. The shell (dockview) opens this as
2
2
  // its own iframe via the deck route in main.jsx:
3
3
  // ?file=<path>[&editor=<id>] -> <SingleEditor> (one editor for one file)
4
- // The shell only routes the kit's RICH file types here (scene / pxart); file
5
- // browsing and the code/text editor are now builtin shell panels.
4
+ // The shell only routes the kit's RICH file types here (scene / pxart / style);
5
+ // file browsing and the code/text editor are now builtin shell panels.
6
6
 
7
7
  import React, { useEffect, useRef, useState } from 'react';
8
8
  import { onBeforeRestart, onSaveReloadState, takeReloadState, writeFile } from 'castle-web-sdk';
@@ -15,6 +15,7 @@ import { ImageViewer } from './ImageViewer';
15
15
  import { MediaPlayer } from './MediaPlayer';
16
16
  import { PxArtEditor } from './PxArtEditor';
17
17
  import { SceneEditor } from './SceneEditor';
18
+ import { StyleEditor } from './StyleEditor';
18
19
 
19
20
  // Debounced writeFile per path, flushed on reload -- the same save behavior the
20
21
  // combined editor (App.jsx) uses, factored for a single-editor mount.
@@ -153,7 +154,9 @@ export function SingleEditor({ path, editor, editorModule }) {
153
154
  />
154
155
  );
155
156
  } else if (kind === 'pxart') {
156
- body = <PxArtEditor path={path} text={text} onChange={onChange} />;
157
+ body = <PxArtEditor path={path} text={text} onChange={onChange} files={files} />;
158
+ } else if (kind === 'style') {
159
+ body = <StyleEditor path={path} text={text} onChange={onChange} />;
157
160
  } else if (kind === 'image') {
158
161
  // Viewers read their file's BYTES over the serve, not the text file map --
159
162
  // `files` holds source, and an image decoded as utf-8 is nothing.
@@ -0,0 +1,95 @@
1
+ // Deck-palette picker for `theme.style`. Official entries come from
2
+ // OFFICIAL_PALETTES; a custom `{ name, colors }` in the file shows as a
3
+ // read-only selected card. Opening the editor never writes — only an
4
+ // explicit official pick does.
5
+
6
+ import { createElement } from 'react';
7
+ import { OFFICIAL_PALETTES, parseThemeData, resolveDeckPalette } from '../engine/palettes';
8
+ import { EditorBody } from '../engine/ui';
9
+ import { applyOfficialPalette } from './styleTheme';
10
+ import styles from './styleEditor.module.css';
11
+
12
+ function cardClassName(selected, clickable) {
13
+ return [styles.card, selected ? styles.cardSelected : '', clickable ? '' : styles.cardStatic]
14
+ .filter(Boolean)
15
+ .join(' ');
16
+ }
17
+
18
+ function swatchStrip(colors) {
19
+ return (
20
+ <div className={styles.strip} aria-hidden="true">
21
+ {colors.map((hex, index) => (
22
+ <span key={`${hex}:${index}`} className={styles.chip} style={{ backgroundColor: hex }} />
23
+ ))}
24
+ </div>
25
+ );
26
+ }
27
+
28
+ function paletteCard({ name, colors, selected, onPick, badge, cardKey }) {
29
+ const className = cardClassName(selected, Boolean(onPick));
30
+ const body = (
31
+ <>
32
+ <div className={styles.cardHead}>
33
+ <div className={styles.cardName}>{name}</div>
34
+ <div className={styles.cardMeta}>
35
+ {badge ? <span className={styles.badge}>{badge}</span> : null}
36
+ <span>{colors.length} colors</span>
37
+ </div>
38
+ </div>
39
+ {swatchStrip(colors)}
40
+ </>
41
+ );
42
+ if (!onPick) {
43
+ return (
44
+ <div key={cardKey} className={className}>
45
+ {body}
46
+ </div>
47
+ );
48
+ }
49
+ return (
50
+ <button key={cardKey} type="button" className={className} onClick={onPick} aria-pressed={selected}>
51
+ {body}
52
+ </button>
53
+ );
54
+ }
55
+
56
+ function customCardName(palette) {
57
+ const name = palette?.name?.trim();
58
+ return name && name !== 'custom' ? name : 'Custom';
59
+ }
60
+
61
+ export function StyleEditor({ text, onChange }) {
62
+ const current = resolveDeckPalette(parseThemeData(text));
63
+ const isCustom = current.id == null;
64
+ function pick(id) {
65
+ onChange(applyOfficialPalette(text, id));
66
+ }
67
+ return createElement(
68
+ EditorBody,
69
+ null,
70
+ <div className={styles.root}>
71
+ {isCustom
72
+ ? paletteCard({
73
+ name: customCardName(current),
74
+ colors: current.colors,
75
+ selected: true,
76
+ badge: 'Custom',
77
+ })
78
+ : null}
79
+ {isCustom ? (
80
+ <p className={styles.replaceNote}>Picking an official palette replaces this custom palette.</p>
81
+ ) : null}
82
+ <div className={styles.list}>
83
+ {Object.values(OFFICIAL_PALETTES).map((palette) =>
84
+ paletteCard({
85
+ cardKey: palette.id,
86
+ name: palette.name,
87
+ colors: palette.colors,
88
+ selected: current.id === palette.id,
89
+ onPick: () => pick(palette.id),
90
+ })
91
+ )}
92
+ </div>
93
+ </div>
94
+ );
95
+ }
@@ -171,7 +171,7 @@ function drawFillToolPreview(ctx, path, ui, at, sx, sy) {
171
171
  if (!shape) return;
172
172
 
173
173
  ctx.save();
174
- if (target.slot === 'fill' && shape.fill) {
174
+ if (target.slot === 'fill') {
175
175
  const geometry = buildShapeFillPath2D(shape, at);
176
176
  if (geometry) {
177
177
  ctx.fillStyle = color;
@@ -813,12 +813,20 @@ export function setEdgeBow(path, shapeIndex, subIndex, edgeIndex, bow) {
813
813
  });
814
814
  }
815
815
 
816
+ /**
817
+ * Fill-tool target at (x,y): stroke first, then interior, top-most shape down.
818
+ *
819
+ * An interior counts whether or not the shape already has a fill, so the tool
820
+ * ADDS one to a stroke-only shape — which also means an unfilled shape is not a
821
+ * hole here the way it is for `hitTestShape`. Only closed subpaths bound a fill,
822
+ * so an all-open shape has no interior to hit even if it still carries a key.
823
+ */
816
824
  export function hitTestFillToolTarget(path, x, y) {
817
825
  const shapes = path?.shapes ?? [];
818
826
  for (let i = shapes.length - 1; i >= 0; i--) {
819
827
  const shape = shapes[i];
820
828
  if (nearStrokedOutline(shape, x, y)) return { shapeIndex: i, slot: 'stroke' };
821
- if (shape.fill && shapeContainsPoint(shape, x, y)) return { shapeIndex: i, slot: 'fill' };
829
+ if (shapeContainsPoint(shape, x, y)) return { shapeIndex: i, slot: 'fill' };
822
830
  }
823
831
  return null;
824
832
  }
@@ -107,15 +107,16 @@ function edgeCross(a, b, cx, cy) {
107
107
  return (b.x - a.x) * (cy - a.y) - (b.y - a.y) * (cx - a.x);
108
108
  }
109
109
 
110
- // Visit the cells of an isosceles triangle inscribed in the `from`/`to` box:
111
- // apex centered on the top edge, base spanning the bottom edge. `filled` uses a
112
- // barycentric point-in-triangle test over the box; the outline walks the three
113
- // edges. A shared `seen` set dedups cells shared by adjacent edges/scanlines.
110
+ // Visit the cells of a right triangle whose hypotenuse is the drag `from`→`to`
111
+ // and whose right angle sits at (from.x, to.y) same corner rule as the path
112
+ // brush triangle. `filled` uses a barycentric point-in-triangle test over the
113
+ // box; the outline walks the three edges. A shared `seen` set dedups cells
114
+ // shared by adjacent edges/scanlines.
114
115
  export function forEachTriangleCells(from, to, filled, visit) {
115
116
  const { x0, x1, y0, y1 } = shapeBounds(from, to);
116
- const apex = { x: Math.round((x0 + x1) / 2), y: y0 };
117
- const left = { x: x0, y: y1 };
118
- const right = { x: x1, y: y1 };
117
+ const start = { x: from.x, y: from.y };
118
+ const right = { x: from.x, y: to.y };
119
+ const end = { x: to.x, y: to.y };
119
120
  const seen = new Set();
120
121
  const emit = (x, y) => {
121
122
  const key = `${x},${y}`;
@@ -126,9 +127,9 @@ export function forEachTriangleCells(from, to, filled, visit) {
126
127
  if (filled) {
127
128
  for (let y = y0; y <= y1; y++) {
128
129
  for (let x = x0; x <= x1; x++) {
129
- const w0 = edgeCross(left, right, x, y);
130
- const w1 = edgeCross(right, apex, x, y);
131
- const w2 = edgeCross(apex, left, x, y);
130
+ const w0 = edgeCross(right, end, x, y);
131
+ const w1 = edgeCross(end, start, x, y);
132
+ const w2 = edgeCross(start, right, x, y);
132
133
  const hasNeg = w0 < 0 || w1 < 0 || w2 < 0;
133
134
  const hasPos = w0 > 0 || w1 > 0 || w2 > 0;
134
135
  if (!(hasNeg && hasPos)) emit(x, y);
@@ -136,7 +137,7 @@ export function forEachTriangleCells(from, to, filled, visit) {
136
137
  }
137
138
  return;
138
139
  }
139
- forEachLinePoint(apex, left, emit);
140
- forEachLinePoint(left, right, emit);
141
- forEachLinePoint(right, apex, emit);
140
+ forEachLinePoint(start, right, emit);
141
+ forEachLinePoint(right, end, emit);
142
+ forEachLinePoint(end, start, emit);
142
143
  }