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
@@ -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
  }
@@ -18,7 +18,8 @@ export const PIXEL_TOOLS = [
18
18
  ];
19
19
 
20
20
  // Path-layer tools: brush (pen / freehand / primitives), select (move/z-order),
21
- // bend (drag bows on any hit edge), fill (recolor fill or stroke).
21
+ // bend (drag bows on any hit edge), fill (paint a shape's fill or stroke
22
+ // an interior click adds a fill to a stroke-only shape).
22
23
  export const PATH_TOOLS = [
23
24
  { id: 'brush', label: 'Brush', icon: 'pencil', shortcut: 'B' },
24
25
  { id: 'fill', label: 'Fill', icon: 'fill', shortcut: 'G' },
@@ -27,8 +28,9 @@ export const PATH_TOOLS = [
27
28
  ];
28
29
 
29
30
  // Sub-modes for the shape tool. Each drags out an axis-aligned box: line draws
30
- // the diagonal, square the rectangle, circle the inscribed ellipse, triangle an
31
- // isosceles triangle. `filled` (a separate toggle) applies to the closed three.
31
+ // the diagonal, square the rectangle, circle the inscribed ellipse, triangle a
32
+ // right triangle (hypotenuse is the drag; right angle at (start.x, end.y)).
33
+ // `filled` (a separate toggle) applies to the closed three.
32
34
  export const SHAPE_MODES = [
33
35
  { id: 'line', label: 'Line', icon: 'slash' },
34
36
  { id: 'square', label: 'Square', icon: 'square' },
@@ -219,51 +221,132 @@ function ResolutionSelect({ label, value, onChange }) {
219
221
  );
220
222
  }
221
223
 
222
- // 12-column palette grid shared by the docked sidebar and the compact popover.
224
+ function sameHex(a, b) {
225
+ return !!a && !!b && a.toLowerCase() === b.toLowerCase();
226
+ }
227
+
228
+ function NoneSwatch({ selected, onSelect }) {
229
+ return (
230
+ <button
231
+ type="button"
232
+ className={cx(styles.swatch, selected && styles.swatchSelected)}
233
+ title="None"
234
+ aria-label="None"
235
+ style={{ position: 'relative', background: 'transparent' }}
236
+ onClick={onSelect}>
237
+ <span
238
+ aria-hidden
239
+ style={{
240
+ position: 'absolute',
241
+ inset: 4,
242
+ background:
243
+ 'linear-gradient(to top right, transparent calc(50% - 1px), #e66 calc(50% - 1px), #e66 calc(50% + 1px), transparent calc(50% + 1px))',
244
+ }}
245
+ />
246
+ </button>
247
+ );
248
+ }
249
+
250
+ // Swatch strip shared by the docked sidebar and the compact popover.
251
+ // `keys` paints a sprite's working palette; `hexes` paints a picker list.
252
+ // `wrap` is the unlabeled sprite row; the named official page is an 8-col grid.
223
253
  export function PaletteGrid({
224
254
  keys,
255
+ hexes,
225
256
  palette,
226
257
  activeKey,
258
+ activeHex,
227
259
  onSelectKey,
260
+ onSelectHex,
228
261
  allowNone = false,
229
262
  noneSelected = false,
230
263
  onSelectNone,
264
+ wrap = false,
231
265
  }) {
266
+ const items = hexes
267
+ ? hexes.map((hex) => ({ id: hex, hex, selected: sameHex(activeHex, hex), onClick: () => onSelectHex?.(hex) }))
268
+ : (keys ?? []).map((key) => ({
269
+ id: key,
270
+ hex: palette?.[key],
271
+ selected: activeKey === key || sameHex(activeHex, palette?.[key]),
272
+ onClick: () => onSelectKey?.(key),
273
+ }));
274
+ if (!allowNone && !items.length) return null;
232
275
  return (
233
- <div className={styles.palette}>
234
- {allowNone ? (
276
+ <div className={wrap ? styles.paletteSpriteRow : styles.palette}>
277
+ {allowNone ? <NoneSwatch selected={noneSelected} onSelect={onSelectNone} /> : null}
278
+ {items.map((item) => (
235
279
  <button
280
+ key={item.id}
236
281
  type="button"
237
- className={cx(styles.swatch, noneSelected && styles.swatchSelected)}
238
- title="None"
239
- aria-label="None"
240
- style={{ position: 'relative', background: 'transparent' }}
241
- onClick={onSelectNone}>
242
- <span
243
- aria-hidden
244
- style={{
245
- position: 'absolute',
246
- inset: 4,
247
- background:
248
- 'linear-gradient(to top right, transparent calc(50% - 1px), #e66 calc(50% - 1px), #e66 calc(50% + 1px), transparent calc(50% + 1px))',
249
- }}
250
- />
251
- </button>
252
- ) : null}
253
- {keys.map((key) => (
254
- <button
255
- key={key}
256
- type="button"
257
- className={cx(styles.swatch, activeKey === key && styles.swatchSelected)}
258
- title={palette[key]}
259
- style={{ background: palette[key] }}
260
- onClick={() => onSelectKey(key)}
282
+ className={cx(styles.swatch, item.selected && styles.swatchSelected)}
283
+ title={item.hex}
284
+ style={{ background: item.hex }}
285
+ onClick={item.onClick}
261
286
  />
262
287
  ))}
263
288
  </div>
264
289
  );
265
290
  }
266
291
 
292
+ function PalettePager({ name, onPage }) {
293
+ return (
294
+ <div className={styles.palettePager}>
295
+ <span className={styles.palettePagerName}>{name}</span>
296
+ <div className={styles.palettePagerBtns}>
297
+ <IconButton icon="chevron-left" label="Previous palette" onClick={() => onPage(-1)} />
298
+ <IconButton icon="chevron-right" label="Next palette" onClick={() => onPage(1)} />
299
+ </div>
300
+ </div>
301
+ );
302
+ }
303
+
304
+ // Unlabeled sprite-color row (when the sprite has colors, or when fill/stroke
305
+ // needs a none swatch) plus a paged official-palette grid. Paging only changes
306
+ // which named page is shown — it does not write theme.style.
307
+ export function PaletteSections({
308
+ spriteKeys,
309
+ spritePalette,
310
+ pages,
311
+ pageIndex,
312
+ onPage,
313
+ activeKey,
314
+ activeHex,
315
+ onSelectKey,
316
+ onSelectHex,
317
+ allowNone = false,
318
+ noneSelected = false,
319
+ onSelectNone,
320
+ paletteFull = false,
321
+ }) {
322
+ const page = pages?.[pageIndex] ?? pages?.[0];
323
+ const showSpriteRow = !!spriteKeys?.length || allowNone;
324
+ return (
325
+ <div>
326
+ {showSpriteRow ? (
327
+ <PaletteGrid
328
+ keys={spriteKeys}
329
+ palette={spritePalette}
330
+ activeKey={activeKey}
331
+ activeHex={activeHex}
332
+ onSelectKey={onSelectKey}
333
+ allowNone={allowNone}
334
+ noneSelected={noneSelected}
335
+ onSelectNone={onSelectNone}
336
+ wrap
337
+ />
338
+ ) : null}
339
+ {page ? (
340
+ <>
341
+ <PalettePager name={page.name} onPage={onPage} />
342
+ <PaletteGrid hexes={page.colors} activeHex={activeHex} onSelectHex={onSelectHex} />
343
+ </>
344
+ ) : null}
345
+ {paletteFull ? <div className={styles.paletteFullNote}>Palette is full (64 colors). Recolor an existing swatch instead.</div> : null}
346
+ </div>
347
+ );
348
+ }
349
+
267
350
  // Anchored palette + eyedropper popover for compact/mobile paint-strip color tap.
268
351
  export function PalettePopover({ open, anchorRef, onClose, children }) {
269
352
  const popoverRef = useRef(null);
@@ -276,7 +359,7 @@ export function PalettePopover({ open, anchorRef, onClose, children }) {
276
359
  const anchor = anchorRef.current.getBoundingClientRect();
277
360
  const rect = popoverRef.current?.getBoundingClientRect();
278
361
  const margin = 8;
279
- const width = rect?.width ?? 280;
362
+ const width = rect?.width ?? 231;
280
363
  const height = rect?.height ?? 320;
281
364
  const maxLeft = Math.max(margin, window.innerWidth - width - margin);
282
365
  const maxTop = Math.max(margin, window.innerHeight - height - margin);
@@ -464,7 +547,18 @@ const ShapeSwatch = React.forwardRef(function ShapeSwatch(
464
547
  );
465
548
  });
466
549
 
467
- function ShapeInspectorButtons({ pathActions, palette, paletteKeys, buttonClass, groupClass }) {
550
+ function ShapeInspectorButtons({
551
+ pathActions,
552
+ palette,
553
+ paletteKeys,
554
+ pages,
555
+ pageIndex,
556
+ onPage,
557
+ onSelectHex,
558
+ paletteFull,
559
+ buttonClass,
560
+ groupClass,
561
+ }) {
468
562
  const [slotPicker, setSlotPicker] = useState(null);
469
563
  const fillRef = useRef(null);
470
564
  const strokeRef = useRef(null);
@@ -485,9 +579,10 @@ function ShapeInspectorButtons({ pathActions, palette, paletteKeys, buttonClass,
485
579
  setSlotPicker(null);
486
580
  }
487
581
 
488
- function applyPicker(key) {
489
- if (slotPicker === 'fill') pathActions?.setFill(key);
490
- else if (slotPicker === 'stroke') pathActions?.setStroke(key);
582
+ function applyColor(hex) {
583
+ if (hex) onSelectHex?.(hex);
584
+ if (slotPicker === 'fill') pathActions?.setFill(hex ?? null);
585
+ else if (slotPicker === 'stroke') pathActions?.setStroke(hex ?? null);
491
586
  closePicker();
492
587
  }
493
588
 
@@ -590,14 +685,20 @@ function ShapeInspectorButtons({ pathActions, palette, paletteKeys, buttonClass,
590
685
  {slotPicker === 'fill' ? 'Fill' : 'Stroke'}
591
686
  </span>
592
687
  </div>
593
- <PaletteGrid
594
- keys={paletteKeys}
595
- palette={palette}
688
+ <PaletteSections
689
+ spriteKeys={paletteKeys}
690
+ spritePalette={palette}
691
+ pages={pages}
692
+ pageIndex={pageIndex}
693
+ onPage={onPage}
596
694
  activeKey={pickerKey}
695
+ activeHex={pickerKey ? palette?.[pickerKey] : null}
696
+ onSelectKey={(key) => applyColor(palette?.[key])}
697
+ onSelectHex={applyColor}
597
698
  allowNone
598
699
  noneSelected={pickerNoneSelected}
599
- onSelectNone={() => applyPicker(null)}
600
- onSelectKey={applyPicker}
700
+ onSelectNone={() => applyColor(null)}
701
+ paletteFull={paletteFull}
601
702
  />
602
703
  </PalettePopover>
603
704
  </>
@@ -614,8 +715,13 @@ export function PaintStrip({
614
715
  onTogglePalette,
615
716
  colorButtonRef,
616
717
  onSelectKey,
718
+ onSelectHex,
617
719
  paletteKeys,
618
720
  palette,
721
+ pages,
722
+ pageIndex,
723
+ onPage,
724
+ paletteFull,
619
725
  moveActions,
620
726
  selectActions,
621
727
  pathActions,
@@ -662,6 +768,15 @@ export function PaintStrip({
662
768
  style={{ background: activeColor }}
663
769
  onClick={onTogglePalette}
664
770
  />
771
+ <button
772
+ type="button"
773
+ className={cx(styles.paintStripModeButton, picking && styles.drawingToolSelected)}
774
+ title="Pick color"
775
+ aria-label="Pick color"
776
+ aria-pressed={picking ? 'true' : 'false'}
777
+ onClick={() => setPicking(!picking)}>
778
+ <Icon name="eyedropper" />
779
+ </button>
665
780
  </div>
666
781
  ) : null}
667
782
  {showBrushSize || showEraseSize ? (
@@ -772,6 +887,11 @@ export function PaintStrip({
772
887
  pathActions={pathActions}
773
888
  palette={palette}
774
889
  paletteKeys={paletteKeys}
890
+ pages={pages}
891
+ pageIndex={pageIndex}
892
+ onPage={onPage}
893
+ onSelectHex={onSelectHex}
894
+ paletteFull={paletteFull}
775
895
  buttonClass={styles.paintStripModeButton}
776
896
  groupClass={styles.paintStripSection}
777
897
  />
@@ -780,22 +900,24 @@ export function PaintStrip({
780
900
  <PalettePopover open={paletteOpen} anchorRef={colorButtonRef} onClose={() => onTogglePalette(false)}>
781
901
  <div className={styles.palettePopoverHeader}>
782
902
  <span className={styles.palettePopoverTitle}>Palette</span>
783
- <IconButton
784
- icon="eyedropper"
785
- label="Pick color"
786
- active={picking}
787
- aria-pressed={picking ? 'true' : 'false'}
788
- onClick={() => setPicking(!picking)}
789
- />
790
903
  </div>
791
- <PaletteGrid
792
- keys={paletteKeys}
793
- palette={palette}
904
+ <PaletteSections
905
+ spriteKeys={paletteKeys}
906
+ spritePalette={palette}
907
+ pages={pages}
908
+ pageIndex={pageIndex}
909
+ onPage={onPage}
794
910
  activeKey={activeKey}
911
+ activeHex={activeColor}
795
912
  onSelectKey={(key) => {
796
913
  onSelectKey(key);
797
914
  onTogglePalette(false);
798
915
  }}
916
+ onSelectHex={(hex) => {
917
+ onSelectHex(hex);
918
+ onTogglePalette(false);
919
+ }}
920
+ paletteFull={paletteFull}
799
921
  />
800
922
  </PalettePopover>
801
923
  </>
@@ -806,9 +928,15 @@ export function PaintStrip({
806
928
  export function PxArtInspectorDock({
807
929
  toolState,
808
930
  activeKey,
931
+ activeHex,
809
932
  onSelectKey,
933
+ onSelectHex,
810
934
  paletteKeys,
811
935
  palette,
936
+ pages,
937
+ pageIndex,
938
+ onPage,
939
+ paletteFull,
812
940
  moveActions,
813
941
  selectActions,
814
942
  pathActions,
@@ -829,11 +957,17 @@ export function PxArtInspectorDock({
829
957
  onClick={() => setPicking(!picking)}
830
958
  />
831
959
  }>
832
- <PaletteGrid
833
- keys={paletteKeys}
834
- palette={palette}
960
+ <PaletteSections
961
+ spriteKeys={paletteKeys}
962
+ spritePalette={palette}
963
+ pages={pages}
964
+ pageIndex={pageIndex}
965
+ onPage={onPage}
835
966
  activeKey={activeKey}
967
+ activeHex={activeHex}
836
968
  onSelectKey={onSelectKey}
969
+ onSelectHex={onSelectHex}
970
+ paletteFull={paletteFull}
837
971
  />
838
972
  </Panel>
839
973
  ) : null}
@@ -844,6 +978,11 @@ export function PxArtInspectorDock({
844
978
  pathActions={pathActions}
845
979
  palette={palette}
846
980
  paletteKeys={paletteKeys}
981
+ pages={pages}
982
+ pageIndex={pageIndex}
983
+ onPage={onPage}
984
+ onSelectHex={onSelectHex}
985
+ paletteFull={paletteFull}
847
986
  />
848
987
  </div>
849
988
  </aside>
@@ -876,6 +1015,11 @@ export function ToolSettingsPanel({
876
1015
  pathActions,
877
1016
  palette,
878
1017
  paletteKeys,
1018
+ pages,
1019
+ pageIndex,
1020
+ onPage,
1021
+ onSelectHex,
1022
+ paletteFull,
879
1023
  }) {
880
1024
  const {
881
1025
  tool,
@@ -987,6 +1131,11 @@ export function ToolSettingsPanel({
987
1131
  pathActions={pathActions}
988
1132
  palette={palette}
989
1133
  paletteKeys={paletteKeys}
1134
+ pages={pages}
1135
+ pageIndex={pageIndex}
1136
+ onPage={onPage}
1137
+ onSelectHex={onSelectHex}
1138
+ paletteFull={paletteFull}
990
1139
  buttonClass={styles.drawingToolButton}
991
1140
  groupClass={styles.drawingShapeModeRow}
992
1141
  />
@@ -19,12 +19,8 @@ import {
19
19
  TRANSPARENT,
20
20
  VECTOR_RENDERER,
21
21
  } from '../engine/pxart';
22
- import {
23
- blankCells,
24
- EDITOR_KEYS,
25
- EDITOR_PALETTE,
26
- editorKeyForHex,
27
- } from './pxArtTools';
22
+ import { paletteRecord } from '../engine/palettes';
23
+ import { blankCells } from './pxArtTools';
28
24
  import {
29
25
  bakePathCell,
30
26
  clonePathData,
@@ -36,15 +32,11 @@ import {
36
32
  // ============================================================================
37
33
  // Working-state helpers for the full Sprite the PxArt editor edits in memory.
38
34
  //
39
- // The editor always works on a Sprite normalized onto the FIXED Endesga-64
40
- // palette, so painting + serialization stay consistent no matter what palette
41
- // an externally-authored file arrived with. Every mutation is immutable
42
- // (returns a fresh Sprite) so it flows straight into the text undo/redo history.
35
+ // The editor works on the sprite's own palette. Opening never remaps colors;
36
+ // painting allocates keys into that palette as needed. Every mutation is
37
+ // immutable (returns a fresh Sprite) so it flows into the text undo/redo history.
43
38
  // ============================================================================
44
39
 
45
- // The Endesga-64 palette as an ordered { key, hex } list (the full palette shape).
46
- export const EDITOR_ORDERED = EDITOR_KEYS.map((key) => ({ key, hex: EDITOR_PALETTE[key] }));
47
-
48
40
  // Size a canvas to a sprite resolution, disable smoothing, and clear it.
49
41
  // Returns the 2D context (or null). Shared by the artboard + thumbnail renders.
50
42
  export function setupSpriteCanvas(canvas, resolution) {
@@ -75,14 +67,14 @@ export function forEachGridCell(grid, width, height, visit) {
75
67
  export function deriveSprite(text) {
76
68
  if (!(text ?? '').trim()) return blankSprite();
77
69
  const parsed = parseFull(text);
78
- return parsed ? normalizeToPalette(parsed) : null;
70
+ return parsed ?? null;
79
71
  }
80
72
 
81
73
  function blankSprite() {
82
74
  const { width, height } = DEFAULT_RESOLUTION;
83
75
  return {
84
76
  resolution: { width, height },
85
- palette: EDITOR_ORDERED,
77
+ palette: [],
86
78
  // The lone frame inherits the global duration (no per-frame override).
87
79
  frames: [{}],
88
80
  defaultDurationMs: DEFAULT_DURATION_MS,
@@ -119,53 +111,6 @@ function gridCell(grid, x, y) {
119
111
  return x || y ? { grid, x, y } : { grid };
120
112
  }
121
113
 
122
- // Remap every grid char onto the nearest Endesga-64 key and swap in the full
123
- // palette, leaving links / null cells / structure untouched. Idempotent for an
124
- // already-normalized sprite.
125
- function normalizeToPalette(sprite) {
126
- const lookup = paletteLookup(sprite.palette);
127
- const layers = sprite.layers.map((layer) => ({
128
- ...layer,
129
- cells: layer.cells.map((cell) => remapCell(cell, lookup)),
130
- }));
131
- return { ...sprite, palette: EDITOR_ORDERED, layers };
132
- }
133
-
134
- function remapCell(cell, lookup) {
135
- if (cell == null) return null;
136
- if ('link' in cell) return { link: cell.link };
137
- // Preserve the cel's offset while remapping its palette keys.
138
- const next = gridCell(cell.grid.map((row) => remapRow(row, lookup)), cell.x ?? 0, cell.y ?? 0);
139
- return cell.path ? { ...next, path: remapPathKeys(cell.path, lookup) } : next;
140
- }
141
-
142
- function remapPathKeys(path, lookup) {
143
- const next = clonePathData(path);
144
- for (const shape of next.shapes) {
145
- if (shape.fill) shape.fill = remapKey(shape.fill, lookup);
146
- if (shape.stroke) shape.stroke = remapKey(shape.stroke, lookup);
147
- }
148
- return next;
149
- }
150
-
151
- function remapKey(key, lookup) {
152
- const hex = colorForKeyV2(lookup, key);
153
- return hex ? editorKeyForHex(hex) : key;
154
- }
155
-
156
- function remapRow(row, lookup) {
157
- let out = '';
158
- for (const ch of row) {
159
- if (ch === TRANSPARENT) {
160
- out += TRANSPARENT;
161
- continue;
162
- }
163
- const hex = colorForKeyV2(lookup, ch);
164
- out += hex ? editorKeyForHex(hex) : TRANSPARENT;
165
- }
166
- return out;
167
- }
168
-
169
114
  // ---------------------------------------------------------------------------
170
115
  // cells <-> (layer, frame)
171
116
  // ---------------------------------------------------------------------------
@@ -798,7 +743,7 @@ function shiftCellOffset(cell, dx, dy, width, height) {
798
743
  // representable; if not, it correctly forces the full form.
799
744
  export function serializeModel(sprite) {
800
745
  const compact = serializeCompact({
801
- ...fromCells(EDITOR_PALETTE, cellsAt(sprite, 0, 0)),
746
+ ...fromCells(paletteRecord(sprite.palette), cellsAt(sprite, 0, 0)),
802
747
  renderer: sprite.renderer,
803
748
  cornerRadius: sprite.cornerRadius,
804
749
  });