castle-web-cli 0.4.123 → 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 (50) hide show
  1. package/dist/castleJson.d.ts +2 -0
  2. package/dist/castleJson.js +39 -0
  3. package/dist/headlessCover.d.ts +13 -0
  4. package/dist/headlessCover.js +99 -0
  5. package/dist/ide.js +12 -42
  6. package/dist/imports.js +42 -2
  7. package/dist/index.js +1 -9
  8. package/dist/init.d.ts +1 -0
  9. package/dist/init.js +15 -1
  10. package/dist/preview.d.ts +0 -1
  11. package/dist/preview.js +0 -58
  12. package/dist/save-deck.js +52 -1
  13. package/dist/serve.js +16 -0
  14. package/dist/shell/assets/Basteleur-Bold-CK8LF7Pt.woff +0 -0
  15. package/dist/shell/assets/Basteleur-Bold-DKFKedNb.woff2 +0 -0
  16. package/dist/shell/assets/index-BfOPkSej.css +1 -0
  17. package/dist/shell/assets/index-u0nYFqbF.js +434 -0
  18. package/dist/shell/index.html +2 -2
  19. package/kits/physics-2d/CLAUDE.md +2 -2
  20. package/kits/physics-2d/castle.json +10 -2
  21. package/kits/physics-2d/docs/pxart-format.md +33 -26
  22. package/kits/physics-2d/editors/PxArtEditor.jsx +120 -49
  23. package/kits/physics-2d/editors/SingleEditor.jsx +6 -3
  24. package/kits/physics-2d/editors/StyleEditor.jsx +95 -0
  25. package/kits/physics-2d/editors/pathOverlay.js +1 -1
  26. package/kits/physics-2d/editors/pathTools.js +9 -1
  27. package/kits/physics-2d/editors/pixelGeometry.js +14 -13
  28. package/kits/physics-2d/editors/pixelInspector.jsx +202 -53
  29. package/kits/physics-2d/editors/pxArtEditorModel.js +8 -63
  30. package/kits/physics-2d/editors/pxArtTools.js +3 -43
  31. package/kits/physics-2d/editors/styleEditor.module.css +105 -0
  32. package/kits/physics-2d/editors/styleTheme.js +16 -0
  33. package/kits/physics-2d/engine/files.js +2 -1
  34. package/kits/physics-2d/engine/liveReload.js +4 -3
  35. package/kits/physics-2d/engine/palettes.js +636 -0
  36. package/kits/physics-2d/engine/pxart.js +6 -6
  37. package/kits/physics-2d/engine/svgImport.js +1056 -0
  38. package/kits/physics-2d/engine/ui.jsx +2 -0
  39. package/kits/physics-2d/engine/ui.module.css +54 -9
  40. package/kits/physics-2d/package-lock.json +1 -1
  41. package/kits/physics-2d/package.json +1 -0
  42. package/kits/physics-2d/scripts/deckTheme.mjs +25 -0
  43. package/kits/physics-2d/scripts/draw.mjs +5 -3
  44. package/kits/physics-2d/scripts/import-svg.mjs +16 -1069
  45. package/kits/physics-2d/scripts/palette.mjs +10 -0
  46. package/kits/physics-2d/scripts/svg-emission-guide.md +5 -3
  47. package/kits/physics-2d/theme.style +3 -0
  48. package/package.json +1 -1
  49. package/dist/shell/assets/index-CARLHafh.css +0 -1
  50. package/dist/shell/assets/index-DWgt4KzC.js +0 -434
@@ -10,8 +10,8 @@
10
10
  <link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
11
11
  <link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
12
12
  <link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
13
- <script type="module" crossorigin src="/__castle/ide/assets/index-DWgt4KzC.js"></script>
14
- <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-CARLHafh.css">
13
+ <script type="module" crossorigin src="/__castle/ide/assets/index-u0nYFqbF.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-BfOPkSej.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -448,11 +448,11 @@ Physics is just this kit's use of it.
448
448
  Don't hand-write pixel grids. Generate sprites with the `draw` command: **emit a terse svg-rect** — a tiny `<svg viewBox="0 0 16 16">` with **one `<rect>` per pixel** — and pipe it to `npm run draw`. It quantizes every fill to the agent palette and writes a single-frame `drawings/<name>.sprite`.
449
449
 
450
450
  - **Resolution is 16×16.** Use `viewBox="0 0 16 16"` and **one 1×1 `<rect>` per filled pixel** (`width="1" height="1"` at integer `x`/`y`). Leave background pixels out (omitted = transparent). Keep every rect 1×1 — the decoder infers cell size from the smallest rect, so mixing in larger blocks shrinks the whole grid.
451
- - **Palette is the agent 16-color subset** (a fixed subset of the editor's full Endesga-64 painting palette) — every `fill` must be one of:
451
+ - **Palette is the deck's generation palette.** The default (no `theme.style`, or Endesga-64) is this 16-color subset — every `fill` must be one of:
452
452
 
453
453
  `#e69c69` `#bf6f4a` `#8a4836` `#391f21` `#891e2b` `#ea323c` `#ffa214` `#ffeb57` `#5ac54f` `#1e6f50` `#134c4c` `#657392` `#c7cfdd` `#ffffff` `#0cf1ff` `#0098dc`
454
454
 
455
- (Off-palette fills are snapped to the nearest of these, so stay on-palette to keep what you intend. People editing in the app can paint with the full Endesga-64 palette; only this generation path is constrained to the 16.)
455
+ If the deck defines a palette in `theme.style`, that governs instead. `npm run palette` prints the active generation palette. Off-palette fills snap to the nearest of these, so stay on-palette to keep what you intend. People editing in the app paint from the deck palette (and the sprite's own colors); only this generation path is quantized.
456
456
  - **Run it** (sprite name as the arg; stdin is the svg):
457
457
 
458
458
  ```bash
@@ -23,7 +23,8 @@
23
23
  "scenes/**",
24
24
  "blueprints/**",
25
25
  "behaviors/**",
26
- "assets/**"
26
+ "assets/**",
27
+ "theme.style"
27
28
  ],
28
29
  "fileTypes": [
29
30
  {
@@ -55,6 +56,13 @@
55
56
  "new": "New behavior",
56
57
  "icon": "code"
57
58
  },
59
+ {
60
+ "ext": ".style",
61
+ "label": "Style",
62
+ "icon": "file",
63
+ "editor": "kit",
64
+ "data": true
65
+ },
58
66
  {
59
67
  "ext": ".png",
60
68
  "label": "Image",
@@ -123,5 +131,5 @@
123
131
  "main": "main.jsx",
124
132
  "autoUpdateWhenImported": true,
125
133
  "title": "physics-2d",
126
- "publishedVersion": "2026-08-16T04:02:06.732Z"
134
+ "publishedVersion": "2026-08-18T21:05:46.663Z"
127
135
  }
@@ -88,36 +88,42 @@ temporal versions — that the kit treats as one in-memory model:
88
88
  transparent.
89
89
  - Indexed-with-hex only: **not** RGBA-per-pixel, **not** grayscale.
90
90
 
91
- ### Two-tier palette system (Endesga-64)
92
-
93
- The kit uses a **two-tier** fixed palette, both tiers drawn from Lospec's
94
- [Endesga 64](https://lospec.com/palette-list/endesga-64). There is no free color
95
- picking at either tier.
96
-
97
- **Full palette Endesga 64 (`EDG64`, all 64 colors): what USERS paint with.**
98
- The kit's `PxArtEditor` exposes the **full 64-color swatch** (canonical Lospec
99
- order). The editor assigns each color a stable single-char key (`KEY_ALPHABET`,
100
- 64 distinct keys) plus `.` for transparent. When an existing sprite is opened for
101
- editing, each cell's resolved color is **snapped to the nearest Endesga-64
102
- color** by RGB distance, so editing always stays on the fixed swatch. (Sprites
103
- still *render* with their own stored palette via `renderSpriteFrame`; the snap
104
- only affects what the editor writes back.) The swatch UI renders as an 8-column
105
- grid (8 rows for the 64 colors) that scrolls within the inspector.
106
-
107
- **Agent subset — 16 colors (`AGENT_PALETTE_16`): what the LLM generates with.**
108
- The agent generation path (the `draw` svg-rect `.sprite` quantizer and the
109
- prompt the model receives) is constrained to a **fixed 16-color subset of
110
- Endesga-64**, so generated art stays coherent. Every color below is a member of
111
- `EDG64`. In order:
91
+ ### Deck palette + derived agent subset
92
+
93
+ Each sprite file carries its **own** ordered `{ key, hex }` palette. That list
94
+ is truth: opening a sprite for editing never remaps its colors. The editor
95
+ paints with those keys, and picking a new color **allocates** a key into the
96
+ sprite's palette (`KEY_ALPHABET`, capped at 64) rather than snapping existing
97
+ pixels onto a global swatch.
98
+
99
+ The **deck palette** is a picker source, not a normalization target. It lives
100
+ in `theme.style` at the deck root (`engine/palettes.js`). Missing or malformed
101
+ theme data falls back to Lospec's [Endesga 64](https://lospec.com/palette-list/endesga-64)
102
+ (`EDG64`). The inspector picker is an unlabeled row of the sprite's own colors
103
+ (when that palette is non-empty) plus an 8-column grid of official palettes,
104
+ paged with named arrows. The deck's `theme.style` palette is the default page;
105
+ paging does not write the theme. A custom `{ name, colors }` deck adds one
106
+ extra read-only page. Picking a hex still allocates onto the sprite.
107
+
108
+ **Agent subset what the LLM generates with.** The generation path (`draw`
109
+ svg-rect `.sprite`, and `import-svg`) quantizes to a derived subset of the
110
+ deck palette (`agentPaletteFor`):
111
+
112
+ - default Endesga-64 → the hand-curated `AGENT_PALETTE_16`
113
+ - custom palette larger than 16 → greedy farthest-point subset in Oklab
114
+ - 16 or fewer colors → used as-is
115
+
116
+ `npm run palette` prints both the deck palette and the derived generation
117
+ subset. The default 16, in agent-facing order (every entry is a member of
118
+ `EDG64`):
112
119
 
113
120
  ```
114
121
  #e69c69 #bf6f4a #8a4836 #391f21 #891e2b #ea323c #ffa214 #ffeb57
115
122
  #5ac54f #1e6f50 #134c4c #657392 #c7cfdd #ffffff #0cf1ff #0098dc
116
123
  ```
117
124
 
118
- So: the editor offers all 64 to people; the agent generation path snaps to these
119
- 16. Both constants (`EDG64`, `AGENT_PALETTE_16`) live in the kit's
120
- `engine/pxart.js`.
125
+ `EDG64` and `AGENT_PALETTE_16` live in `engine/pxart.js`; derivation and
126
+ theme resolution live in `engine/palettes.js`.
121
127
 
122
128
  ---
123
129
 
@@ -656,8 +662,9 @@ system treats it as a full (degenerate) Sprite.
656
662
  ### Single-layer / single-frame generation contract
657
663
 
658
664
  The **generation contract** for this kit is to emit the **compact shorthand**: a
659
- single 16×16 (default) `{ palette, grid }` sprite over the agent 16-color subset
660
- (`AGENT_PALETTE_16`).
665
+ single 16×16 (default) `{ palette, grid }` sprite quantized to the deck's
666
+ generation palette (`agentPaletteFor` of the `theme.style` palette, or
667
+ `AGENT_PALETTE_16` when the deck uses the Endesga-64 default).
661
668
  The CLI generation worker and the kit's `PxArtEditor` both produce/store this
662
669
  compact form when nothing is lost by it. Multi-layer / multi-frame structure is
663
670
  authored by tools later, not hand-generated — and the minimal `PxArtEditor`
@@ -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.