castle-web-cli 0.4.115 → 0.4.117

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 (55) hide show
  1. package/dist/agent-prompts.js +2 -2
  2. package/dist/agent.d.ts +9 -9
  3. package/dist/agent.js +590 -589
  4. package/dist/castleJson.d.ts +6 -0
  5. package/dist/castleJson.js +10 -0
  6. package/dist/editorConfig.js +27 -3
  7. package/dist/imports.d.ts +4 -0
  8. package/dist/imports.js +59 -10
  9. package/dist/init.d.ts +3 -0
  10. package/dist/init.js +103 -87
  11. package/dist/install.d.ts +1 -1
  12. package/dist/install.js +26 -24
  13. package/dist/shell/assets/{index-CUamb8rK.js → index-DiPlPGyg.js} +2 -2
  14. package/dist/shell/index.html +1 -1
  15. package/dist/vitePlugins.js +1 -1
  16. package/kits/physics-2d/CLAUDE.md +28 -16
  17. package/kits/physics-2d/behaviors/Joints.jsx +1 -1
  18. package/kits/physics-2d/behaviors/Sprite.jsx +17 -12
  19. package/kits/physics-2d/blueprints/ball.scene +1 -1
  20. package/kits/physics-2d/blueprints/block.scene +1 -1
  21. package/kits/physics-2d/blueprints/cauldron.scene +1 -1
  22. package/kits/physics-2d/blueprints/crate.scene +1 -1
  23. package/kits/physics-2d/castle.json +11 -3
  24. package/kits/physics-2d/docs/pxart-format.md +537 -51
  25. package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
  26. package/kits/physics-2d/editors/brushFit.js +535 -0
  27. package/kits/physics-2d/editors/brushShapes.js +140 -0
  28. package/kits/physics-2d/editors/mediaFile.js +12 -1
  29. package/kits/physics-2d/editors/pathOverlay.js +340 -0
  30. package/kits/physics-2d/editors/pathTools.js +1906 -0
  31. package/kits/physics-2d/editors/pixelCanvas.js +13 -0
  32. package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
  33. package/kits/physics-2d/editors/pixelGeometry.js +4 -2
  34. package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
  35. package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
  36. package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
  37. package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
  38. package/kits/physics-2d/engine/assets.js +1 -1
  39. package/kits/physics-2d/engine/blueprint.js +3 -3
  40. package/kits/physics-2d/engine/files.js +3 -1
  41. package/kits/physics-2d/engine/liveReload.js +1 -1
  42. package/kits/physics-2d/engine/physics/jointArt.js +3 -3
  43. package/kits/physics-2d/engine/pxart.js +153 -35
  44. package/kits/physics-2d/engine/pxartPath.js +1356 -0
  45. package/kits/physics-2d/engine/pxartSmooth.js +276 -125
  46. package/kits/physics-2d/engine/ui.jsx +22 -1
  47. package/kits/physics-2d/engine/ui.module.css +36 -12
  48. package/kits/physics-2d/package-lock.json +1 -1
  49. package/kits/physics-2d/scripts/draw.mjs +7 -7
  50. package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
  51. package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
  52. package/package.json +1 -1
  53. /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
  54. /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
  55. /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
@@ -9,3 +9,16 @@ export function eventToCell(event, width, height) {
9
9
  if (x < 0 || y < 0 || x >= width || y >= height) return null;
10
10
  return { x, y };
11
11
  }
12
+
13
+ // Fractional cell coordinates for vector-path tools. Unlike eventToCell,
14
+ // drags clamp to the canvas edge instead of being rejected.
15
+ export function eventToPoint(event, width, height) {
16
+ const rect = event.currentTarget.getBoundingClientRect();
17
+ const x = ((event.clientX - rect.left) / rect.width) * width;
18
+ const y = ((event.clientY - rect.top) / rect.height) * height;
19
+ return { x: clamp(x, 0, width), y: clamp(y, 0, height) };
20
+ }
21
+
22
+ function clamp(value, lo, hi) {
23
+ return Math.min(hi, Math.max(lo, value));
24
+ }
@@ -49,14 +49,14 @@ export function PixelEditorHeader({ title, subtitle, shell, chrome }) {
49
49
  // "smooth"-mode sprites (empty/unused in "pixel" mode) so the interactive canvas
50
50
  // above it can stay at native resolution — pointer math and tool-preview overlays
51
51
  // are unaffected by the render mode.
52
- export function PixelArtboard({ canvasRef, smoothRef, overlayRef, style, handlers }) {
52
+ export function PixelArtboard({ canvasRef, smoothRef, overlayRef, style, handlers, smoothPreview = false }) {
53
53
  return (
54
54
  <div className={styles.drawingArtboard}>
55
55
  <div className={styles.drawingArtboardFrame} style={style}>
56
56
  <canvas ref={smoothRef} className={styles.drawingSmooth} aria-hidden="true" />
57
57
  <canvas
58
58
  ref={canvasRef}
59
- className={styles.drawingCanvas}
59
+ className={smoothPreview ? styles.drawingCanvasSmooth : styles.drawingCanvas}
60
60
  // Focusable so a pointer-down can pull keyboard focus into this editor
61
61
  // iframe (see startTool). Without it, Safari often leaves keyboard focus
62
62
  // on the top document and Cmd+Z falls through to the browser's own undo.
@@ -45,8 +45,10 @@ export function forEachLinePoint(from, to, visit) {
45
45
  }
46
46
 
47
47
  // The shape tools (square/circle/triangle) are bounded by the axis-aligned box
48
- // the drag sweeps out, so they share this normalized corner unpacking.
49
- function shapeBounds(from, to) {
48
+ // the drag sweeps out, so they share this normalized corner unpacking. Exported
49
+ // for the path-layer brush primitives, which sweep the same box in continuous
50
+ // cell coords rather than integer cells.
51
+ export function shapeBounds(from, to) {
50
52
  return {
51
53
  x0: Math.min(from.x, to.x),
52
54
  x1: Math.max(from.x, to.x),
@@ -1,6 +1,6 @@
1
1
  import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
- import { RESOLUTION_STEPS } from '../engine/pxart';
3
+ import { GRID_RENDERER, RESOLUTION_STEPS, VECTOR_RENDERER } from '../engine/pxart';
4
4
  import { cx, Icon, IconButton, Panel, styles } from '../engine/ui';
5
5
 
6
6
  // Brush/erase diameters offered by the pixel editors' size sliders.
@@ -17,6 +17,15 @@ export const PIXEL_TOOLS = [
17
17
  { id: 'erase', label: 'Erase', icon: 'eraser' },
18
18
  ];
19
19
 
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).
22
+ export const PATH_TOOLS = [
23
+ { id: 'brush', label: 'Brush', icon: 'pencil', shortcut: 'B' },
24
+ { id: 'fill', label: 'Fill', icon: 'fill', shortcut: 'G' },
25
+ { id: 'path-select', label: 'Select', icon: 'location-arrow', shortcut: 'V' },
26
+ { id: 'bend', label: 'Bend', icon: 'bezier-curve', shortcut: 'N' },
27
+ ];
28
+
20
29
  // Sub-modes for the shape tool. Each drags out an axis-aligned box: line draws
21
30
  // the diagonal, square the rectangle, circle the inscribed ellipse, triangle an
22
31
  // isosceles triangle. `filled` (a separate toggle) applies to the closed three.
@@ -27,6 +36,21 @@ export const SHAPE_MODES = [
27
36
  { id: 'triangle', label: 'Triangle', icon: 'triangle' },
28
37
  ];
29
38
 
39
+ // Sub-modes for the path brush tool: click-to-place pen, freehand fit, drag primitives.
40
+ export const BRUSH_MODES = [
41
+ { id: 'freehand', label: 'Freehand', icon: 'paintbrush' },
42
+ { id: 'pen', label: 'Pen', icon: 'pen-nib', shortcut: 'P' },
43
+ { id: 'line', label: 'Line', icon: 'slash', shortcut: 'L' },
44
+ { id: 'rect', label: 'Rectangle', icon: 'square', shortcut: 'R' },
45
+ { id: 'circle', label: 'Ellipse', icon: 'circle', shortcut: 'O' },
46
+ { id: 'triangle', label: 'Triangle', icon: 'triangle' },
47
+ ];
48
+
49
+ /** Path-layer brush sub-mode picks keyed by shortcut letter (lowercase). */
50
+ export const PATH_BRUSH_MODE_BY_KEY = Object.fromEntries(
51
+ BRUSH_MODES.filter((mode) => mode.shortcut).map((mode) => [mode.shortcut.toLowerCase(), mode.id])
52
+ );
53
+
30
54
  // Momentary transform actions the move tool exposes (it has no persistent
31
55
  // settings). Each `id` maps to a handler on the editor's `moveActions`.
32
56
  export const MOVE_ACTIONS = [
@@ -54,6 +78,7 @@ export function usePixelToolState() {
54
78
  const [eraseFillMode, setEraseFillMode] = useState(false);
55
79
  const [fillSwapMode, setFillSwapMode] = useState(false);
56
80
  const [shapeMode, setShapeMode] = useState('line');
81
+ const [brushMode, setBrushMode] = useState('freehand');
57
82
  const [shapeFill, setShapeFill] = useState(false);
58
83
  return {
59
84
  tool,
@@ -70,6 +95,8 @@ export function usePixelToolState() {
70
95
  setFillSwapMode,
71
96
  shapeMode,
72
97
  setShapeMode,
98
+ brushMode,
99
+ setBrushMode,
73
100
  shapeFill,
74
101
  setShapeFill,
75
102
  };
@@ -84,7 +111,9 @@ export function PixelToolStrip({ tools, tool, onSelectTool, ariaLabel, disabled
84
111
  <button
85
112
  key={candidate.id}
86
113
  type="button"
87
- title={candidate.label}
114
+ title={
115
+ candidate.shortcut ? `${candidate.label} (${candidate.shortcut})` : candidate.label
116
+ }
88
117
  aria-label={candidate.label}
89
118
  aria-pressed={tool === candidate.id ? 'true' : 'false'}
90
119
  disabled={disabled}
@@ -113,38 +142,57 @@ export function CanvasSizeBar({ width, height, onResize }) {
113
142
  );
114
143
  }
115
144
 
116
- // The 3 corner-radius presets offered by CornerRadiusBar. 0 is sharp/pixel;
117
- // the other two are "nice" rounded amounts. The file format itself isn't
118
- // limited to these three (any value up to MAX_CORNER_RADIUS is valid see
119
- // pxart.js)this is just the curated set the editor's segmented control
120
- // exposes, so hand-picking a value never means hunting a slider.
121
- const CORNER_RADIUS_STEPS = [
122
- { value: 0, label: '0' },
123
- { value: 0.25, label: '¼' },
124
- { value: 0.5, label: '½' },
145
+ // Curated finish presets: sharp pixels, two corner radii, and vector-aware
146
+ // smooth rendering.
147
+ // The curated presets over the two finish fields. The format itself is not
148
+ // limited to these any in-range radius is valid under either renderer (e.g.
149
+ // from hand-edited JSON) — but picking a finish shouldn't mean hunting a slider.
150
+ const FINISH_STEPS = [
151
+ {
152
+ id: 'pixel',
153
+ label: 'Pixel',
154
+ icon: 'square',
155
+ title: 'Sharp (pixel) corners',
156
+ renderer: GRID_RENDERER,
157
+ cornerRadius: 0,
158
+ },
159
+ { id: 'quarter', label: '¼', title: 'Round corners, radius 0.25', renderer: GRID_RENDERER, cornerRadius: 0.25 },
160
+ { id: 'half', label: '½', title: 'Round corners, radius 0.5', renderer: GRID_RENDERER, cornerRadius: 0.5 },
161
+ {
162
+ id: 'vector',
163
+ label: 'Smooth',
164
+ icon: 'circle',
165
+ title: 'Smooth vector render (per layer)',
166
+ renderer: VECTOR_RENDERER,
167
+ cornerRadius: 0,
168
+ },
125
169
  ];
126
170
 
127
- // Corner-radius segmented control, meant to sit next to CanvasSizeBar above
128
- // the artboard. Sets the sprite's file-level `cornerRadius` field (a property
129
- // of the .pxart asset — see docs/pxart-format.md — not a per-actor Sprite
130
- // prop). `radius` is a plain number (0 = sharp/pixel corners); highlights
131
- // whichever preset it exactly matches, or none if it's a value from outside
132
- // this set (e.g. hand-edited JSON).
133
- export function CornerRadiusBar({ radius, onChange }) {
171
+ // Finish segmented control, meant to sit next to CanvasSizeBar above the
172
+ // artboard. Sets the sprite's file-level `renderer` + `cornerRadius` fields
173
+ // (properties of the .pxart asset — see docs/pxart-format.md — not per-actor
174
+ // Sprite props). Highlights whichever preset the pair exactly matches, or none
175
+ // when it matches no preset.
176
+ export function FinishBar({ renderer, cornerRadius, onChange }) {
177
+ const isOn = (step) =>
178
+ step.renderer === (renderer ?? GRID_RENDERER) && step.cornerRadius === (cornerRadius ?? 0);
134
179
  return (
135
- <div className={styles.cornerRadiusBar} aria-label="Corner rounding">
136
- <span>Round</span>
137
- <div className={styles.cornerRadiusSegments} role="radiogroup" aria-label="Corner radius">
138
- {CORNER_RADIUS_STEPS.map((step) => (
180
+ <div className={styles.finishBar} aria-label="Finish">
181
+ <span>Finish</span>
182
+ <div className={styles.finishSegments} role="radiogroup" aria-label="Finish">
183
+ {FINISH_STEPS.map((step) => (
139
184
  <button
140
- key={step.value}
185
+ key={step.id}
141
186
  type="button"
142
187
  role="radio"
143
- aria-checked={radius === step.value ? 'true' : 'false'}
144
- className={cx(styles.cornerRadiusSegment, radius === step.value && styles.cornerRadiusSegmentOn)}
145
- title={step.value === 0 ? 'Sharp (pixel) corners' : `Round corners, radius ${step.value}`}
146
- onClick={() => onChange(step.value)}>
147
- {step.label}
188
+ aria-label={step.label}
189
+ aria-checked={isOn(step) ? 'true' : 'false'}
190
+ className={cx(styles.finishSegment, isOn(step) && styles.finishSegmentOn)}
191
+ title={step.title}
192
+ onClick={() =>
193
+ onChange({ renderer: step.renderer, cornerRadius: step.cornerRadius })
194
+ }>
195
+ {step.icon ? <Icon name={step.icon} /> : step.label}
148
196
  </button>
149
197
  ))}
150
198
  </div>
@@ -172,9 +220,36 @@ function ResolutionSelect({ label, value, onChange }) {
172
220
  }
173
221
 
174
222
  // 12-column palette grid shared by the docked sidebar and the compact popover.
175
- export function PaletteGrid({ keys, palette, activeKey, onSelectKey }) {
223
+ export function PaletteGrid({
224
+ keys,
225
+ palette,
226
+ activeKey,
227
+ onSelectKey,
228
+ allowNone = false,
229
+ noneSelected = false,
230
+ onSelectNone,
231
+ }) {
176
232
  return (
177
233
  <div className={styles.palette}>
234
+ {allowNone ? (
235
+ <button
236
+ 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}
178
253
  {keys.map((key) => (
179
254
  <button
180
255
  key={key}
@@ -267,6 +342,31 @@ function ShapeModeButtons({ shapeMode, setShapeMode, buttonClass, radio = false
267
342
  );
268
343
  }
269
344
 
345
+ function BrushModeButtons({ brushMode, setBrushMode, buttonClass, radio = false }) {
346
+ return (
347
+ <>
348
+ {BRUSH_MODES.map((mode) => {
349
+ const selected = brushMode === mode.id;
350
+ const title = mode.shortcut ? `${mode.label} (${mode.shortcut})` : mode.label;
351
+ return (
352
+ <button
353
+ key={mode.id}
354
+ type="button"
355
+ role={radio ? 'radio' : undefined}
356
+ className={cx(buttonClass, selected && styles.drawingToolSelected)}
357
+ title={title}
358
+ aria-label={mode.label}
359
+ aria-pressed={radio ? undefined : selected ? 'true' : 'false'}
360
+ aria-checked={radio ? (selected ? 'true' : 'false') : undefined}
361
+ onClick={() => setBrushMode(mode.id)}>
362
+ <Icon name={mode.icon} />
363
+ </button>
364
+ );
365
+ })}
366
+ </>
367
+ );
368
+ }
369
+
270
370
  // The move tool's transform actions (flip H / flip V / rotate CW), shared by the
271
371
  // compact paint strip and the wide-layout settings panel. These are momentary
272
372
  // buttons (no selected state); `buttonClass` styles them for the host strip.
@@ -288,6 +388,201 @@ function MoveActionButtons({ moveActions, buttonClass }) {
288
388
  );
289
389
  }
290
390
 
391
+ const ShapeSwatch = React.forwardRef(function ShapeSwatch(
392
+ { label, color, none, mixed, onClick, buttonClass },
393
+ ref
394
+ ) {
395
+ const title = mixed
396
+ ? `${label}: mixed. Click to edit.`
397
+ : `${label}: ${none ? 'none' : color}. Click to edit.`;
398
+ return (
399
+ <button
400
+ ref={ref}
401
+ type="button"
402
+ className={buttonClass}
403
+ title={title}
404
+ aria-label={`${label} ${mixed ? 'mixed' : none ? 'none' : color}`}
405
+ aria-haspopup="dialog"
406
+ onClick={onClick}
407
+ style={{
408
+ position: 'relative',
409
+ background: mixed
410
+ ? 'linear-gradient(135deg, #888 50%, #444 50%)'
411
+ : none
412
+ ? 'transparent'
413
+ : color,
414
+ boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.35)',
415
+ }}>
416
+ {mixed ? (
417
+ <span
418
+ aria-hidden
419
+ style={{
420
+ position: 'absolute',
421
+ inset: 0,
422
+ display: 'grid',
423
+ placeItems: 'center',
424
+ fontSize: 11,
425
+ fontWeight: 700,
426
+ color: '#fff',
427
+ textShadow: '0 1px 1px #000',
428
+ }}>
429
+ ±
430
+ </span>
431
+ ) : none ? (
432
+ <span
433
+ aria-hidden
434
+ style={{
435
+ position: 'absolute',
436
+ inset: 4,
437
+ background:
438
+ 'linear-gradient(to top right, transparent calc(50% - 1px), #e66 calc(50% - 1px), #e66 calc(50% + 1px), transparent calc(50% + 1px))',
439
+ }}
440
+ />
441
+ ) : null}
442
+ </button>
443
+ );
444
+ });
445
+
446
+ function ShapeInspectorButtons({ pathActions, palette, paletteKeys, buttonClass, groupClass }) {
447
+ const [slotPicker, setSlotPicker] = useState(null);
448
+ const fillRef = useRef(null);
449
+ const strokeRef = useRef(null);
450
+ const anchorRef = slotPicker === 'fill' ? fillRef : strokeRef;
451
+ const fillSlot = pathActions?.fillSlot;
452
+ const strokeSlot = pathActions?.strokeSlot;
453
+ const fillMixed = fillSlot === 'mixed';
454
+ const strokeMixed = strokeSlot === 'mixed';
455
+ const fillColor = !fillMixed && fillSlot != null ? palette?.[fillSlot] : null;
456
+ const strokeColor = !strokeMixed && strokeSlot != null ? palette?.[strokeSlot] : null;
457
+ const pickerSlotValue = slotPicker === 'fill' ? fillSlot : slotPicker === 'stroke' ? strokeSlot : null;
458
+ const pickerMixed = pickerSlotValue === 'mixed';
459
+ const pickerKey =
460
+ pickerMixed || pickerSlotValue == null ? null : pickerSlotValue;
461
+ const pickerNoneSelected = !pickerMixed && pickerSlotValue == null;
462
+
463
+ function closePicker() {
464
+ setSlotPicker(null);
465
+ }
466
+
467
+ function applyPicker(key) {
468
+ if (slotPicker === 'fill') pathActions?.setFill(key);
469
+ else if (slotPicker === 'stroke') pathActions?.setStroke(key);
470
+ closePicker();
471
+ }
472
+
473
+ return (
474
+ <>
475
+ <div className={groupClass} role="group" aria-label="Fill and stroke">
476
+ <ShapeSwatch
477
+ ref={fillRef}
478
+ label="Fill"
479
+ color={fillColor ?? '#888'}
480
+ none={!fillMixed && fillSlot == null}
481
+ mixed={fillMixed}
482
+ onClick={() => setSlotPicker('fill')}
483
+ buttonClass={buttonClass}
484
+ />
485
+ <ShapeSwatch
486
+ ref={strokeRef}
487
+ label="Stroke"
488
+ color={strokeColor ?? '#888'}
489
+ none={!strokeMixed && strokeSlot == null}
490
+ mixed={strokeMixed}
491
+ onClick={() => setSlotPicker('stroke')}
492
+ buttonClass={buttonClass}
493
+ />
494
+ </div>
495
+ <div className={groupClass} role="group" aria-label="Transform and order">
496
+ <button
497
+ type="button"
498
+ className={buttonClass}
499
+ title="Flip horizontal"
500
+ aria-label="Flip horizontal"
501
+ disabled={!pathActions?.hasSelection}
502
+ onClick={pathActions?.flipH}>
503
+ <Icon name="flip-h" />
504
+ </button>
505
+ <button
506
+ type="button"
507
+ className={buttonClass}
508
+ title="Flip vertical"
509
+ aria-label="Flip vertical"
510
+ disabled={!pathActions?.hasSelection}
511
+ onClick={pathActions?.flipV}>
512
+ <Icon name="flip-v" />
513
+ </button>
514
+ <button
515
+ type="button"
516
+ className={buttonClass}
517
+ title="Rotate 90° clockwise"
518
+ aria-label="Rotate 90 degrees clockwise"
519
+ disabled={!pathActions?.hasSelection}
520
+ onClick={pathActions?.rotateCW}>
521
+ <Icon name="redo" />
522
+ </button>
523
+ <button
524
+ type="button"
525
+ className={buttonClass}
526
+ title="Bring forward (])"
527
+ aria-label="Bring shape forward"
528
+ disabled={!pathActions?.canForward}
529
+ onClick={pathActions?.bringForward}>
530
+ <Icon name="chevron-up" />
531
+ </button>
532
+ <button
533
+ type="button"
534
+ className={buttonClass}
535
+ title="Send backward ([)"
536
+ aria-label="Send shape backward"
537
+ disabled={!pathActions?.canBackward}
538
+ onClick={pathActions?.sendBackward}>
539
+ <Icon name="chevron-down" />
540
+ </button>
541
+ </div>
542
+ <div className={groupClass} role="group" aria-label="Delete and punch">
543
+ <button
544
+ type="button"
545
+ className={buttonClass}
546
+ title="Delete shape"
547
+ aria-label="Delete shape"
548
+ disabled={!pathActions?.hasSelection}
549
+ onClick={pathActions?.deleteSelected}>
550
+ <Icon name="trash" />
551
+ </button>
552
+ <button
553
+ type="button"
554
+ className={buttonClass}
555
+ title={
556
+ pathActions?.canPunchHole
557
+ ? 'Punch Hole'
558
+ : 'Select one shape that fully contains the others'
559
+ }
560
+ aria-label="Punch Hole"
561
+ disabled={!pathActions?.canPunchHole}
562
+ onClick={pathActions?.punchHole}>
563
+ <Icon name="dot-circle" />
564
+ </button>
565
+ </div>
566
+ <PalettePopover open={!!slotPicker} anchorRef={anchorRef} onClose={closePicker}>
567
+ <div className={styles.palettePopoverHeader}>
568
+ <span className={styles.palettePopoverTitle}>
569
+ {slotPicker === 'fill' ? 'Fill' : 'Stroke'}
570
+ </span>
571
+ </div>
572
+ <PaletteGrid
573
+ keys={paletteKeys}
574
+ palette={palette}
575
+ activeKey={pickerKey}
576
+ allowNone
577
+ noneSelected={pickerNoneSelected}
578
+ onSelectNone={() => applyPicker(null)}
579
+ onSelectKey={applyPicker}
580
+ />
581
+ </PalettePopover>
582
+ </>
583
+ );
584
+ }
585
+
291
586
  // Right-side paint strip (compact layout): color swatch, vertical size slider, and
292
587
  // tool-contextual mode toggles. Color tap opens the palette popover.
293
588
  export function PaintStrip({
@@ -302,6 +597,7 @@ export function PaintStrip({
302
597
  palette,
303
598
  moveActions,
304
599
  selectActions,
600
+ pathActions,
305
601
  }) {
306
602
  const {
307
603
  tool,
@@ -313,15 +609,17 @@ export function PaintStrip({
313
609
  fillSwapMode,
314
610
  shapeMode,
315
611
  shapeFill,
612
+ brushMode,
316
613
  setBrushSize,
317
614
  setEraseSize,
318
615
  setEraseFillMode,
319
616
  setFillSwapMode,
320
617
  setShapeMode,
618
+ setBrushMode,
321
619
  setShapeFill,
322
620
  } = toolState;
323
621
 
324
- const showBrushSize = tool === 'brush';
622
+ const showBrushSize = tool === 'brush' && !pathActions;
325
623
  const showEraseSize = tool === 'erase';
326
624
  const size = showBrushSize ? brushSize : eraseSize;
327
625
  const onSizeChange = showBrushSize ? setBrushSize : setEraseSize;
@@ -372,6 +670,15 @@ export function PaintStrip({
372
670
  />
373
671
  </div>
374
672
  ) : null}
673
+ {tool === 'brush' && pathActions ? (
674
+ <div className={styles.paintStripSection}>
675
+ <BrushModeButtons
676
+ brushMode={brushMode}
677
+ setBrushMode={setBrushMode}
678
+ buttonClass={styles.paintStripModeButton}
679
+ />
680
+ </div>
681
+ ) : null}
375
682
  {tool === 'move-all' ? (
376
683
  <div className={styles.paintStripSection}>
377
684
  <MoveActionButtons moveActions={moveActions} buttonClass={styles.paintStripModeButton} />
@@ -406,7 +713,7 @@ export function PaintStrip({
406
713
  </button>
407
714
  </div>
408
715
  ) : null}
409
- {tool === 'fill' ? (
716
+ {tool === 'fill' && !pathActions ? (
410
717
  <div className={styles.paintStripSection}>
411
718
  <button
412
719
  type="button"
@@ -438,6 +745,16 @@ export function PaintStrip({
438
745
  </button>
439
746
  </div>
440
747
  ) : null}
748
+ {/* Compact strip: shape actions only on select/bend — brush keeps room for modes. */}
749
+ {(tool === 'path-select' || tool === 'bend') && pathActions?.hasSelection ? (
750
+ <ShapeInspectorButtons
751
+ pathActions={pathActions}
752
+ palette={palette}
753
+ paletteKeys={paletteKeys}
754
+ buttonClass={styles.paintStripModeButton}
755
+ groupClass={styles.paintStripSection}
756
+ />
757
+ ) : null}
441
758
  </div>
442
759
  <PalettePopover open={paletteOpen} anchorRef={colorButtonRef} onClose={() => onTogglePalette(false)}>
443
760
  <div className={styles.palettePopoverHeader}>
@@ -465,7 +782,16 @@ export function PaintStrip({
465
782
  }
466
783
 
467
784
  // Wide-layout docked inspector: palette (when relevant) + tool settings only.
468
- export function PxArtInspectorDock({ toolState, activeKey, onSelectKey, paletteKeys, palette, moveActions, selectActions }) {
785
+ export function PxArtInspectorDock({
786
+ toolState,
787
+ activeKey,
788
+ onSelectKey,
789
+ paletteKeys,
790
+ palette,
791
+ moveActions,
792
+ selectActions,
793
+ pathActions,
794
+ }) {
469
795
  const { tool, picking, setPicking } = toolState;
470
796
  return (
471
797
  <aside className={cx(styles.pxArtInspector, styles.drawingToolsPanel)}>
@@ -490,13 +816,19 @@ export function PxArtInspectorDock({ toolState, activeKey, onSelectKey, paletteK
490
816
  />
491
817
  </Panel>
492
818
  ) : null}
493
- <ToolSettingsPanel toolState={toolState} moveActions={moveActions} selectActions={selectActions} />
819
+ <ToolSettingsPanel
820
+ toolState={toolState}
821
+ moveActions={moveActions}
822
+ selectActions={selectActions}
823
+ pathActions={pathActions}
824
+ palette={palette}
825
+ paletteKeys={paletteKeys}
826
+ />
494
827
  </div>
495
828
  </aside>
496
829
  );
497
830
  }
498
831
 
499
- // A labeled on/off switch used for the editors' boolean tool modes.
500
832
  export function ModeToggle({ label, active, onChange }) {
501
833
  return (
502
834
  <div className={styles.drawingModeRow}>
@@ -516,7 +848,14 @@ export function ModeToggle({ label, active, onChange }) {
516
848
  // The "Tool Settings" panel for the pixel editor: brush/erase size sliders plus
517
849
  // the per-tool mode toggle / hint. It reads the whole usePixelToolState bundle
518
850
  // to keep callers from re-spreading the same prop list.
519
- export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
851
+ export function ToolSettingsPanel({
852
+ toolState,
853
+ moveActions,
854
+ selectActions,
855
+ pathActions,
856
+ palette,
857
+ paletteKeys,
858
+ }) {
520
859
  const {
521
860
  tool,
522
861
  brushSize,
@@ -525,11 +864,13 @@ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
525
864
  fillSwapMode,
526
865
  shapeMode,
527
866
  shapeFill,
867
+ brushMode,
528
868
  setBrushSize,
529
869
  setEraseSize,
530
870
  setEraseFillMode,
531
871
  setFillSwapMode,
532
872
  setShapeMode,
873
+ setBrushMode,
533
874
  setShapeFill,
534
875
  } = toolState;
535
876
  return (
@@ -538,7 +879,7 @@ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
538
879
  <ToolSizePicker
539
880
  label="Brush"
540
881
  value={brushSize}
541
- visible={tool === 'brush'}
882
+ visible={tool === 'brush' && !pathActions}
542
883
  onChange={setBrushSize}
543
884
  />
544
885
  <ToolSizePicker
@@ -549,7 +890,7 @@ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
549
890
  fill={eraseFillMode}
550
891
  onFillChange={setEraseFillMode}
551
892
  />
552
- {tool === 'fill' ? (
893
+ {tool === 'fill' && !pathActions ? (
553
894
  <ModeToggle label="Swap Mode" active={fillSwapMode} onChange={setFillSwapMode} />
554
895
  ) : null}
555
896
  {tool === 'shape' ? (
@@ -568,6 +909,21 @@ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
568
909
  <ModeToggle label="Fill Shape" active={shapeFill} onChange={setShapeFill} />
569
910
  </div>
570
911
  ) : null}
912
+ {tool === 'brush' && pathActions ? (
913
+ <div className={styles.drawingSizePicker}>
914
+ <div className={styles.drawingSizeLabel}>
915
+ <span>Brush</span>
916
+ </div>
917
+ <div className={styles.drawingShapeModeRow} role="radiogroup" aria-label="Brush mode">
918
+ <BrushModeButtons
919
+ brushMode={brushMode}
920
+ setBrushMode={setBrushMode}
921
+ buttonClass={styles.drawingToolButton}
922
+ radio
923
+ />
924
+ </div>
925
+ </div>
926
+ ) : null}
571
927
  {tool === 'move-all' ? (
572
928
  <div className={styles.drawingSizePicker}>
573
929
  <div className={styles.drawingSizeLabel}>
@@ -599,6 +955,23 @@ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
599
955
  {tool === 'eyedropper' ? (
600
956
  <div className={styles.drawingToolHint}>Click a pixel to make it the active color.</div>
601
957
  ) : null}
958
+ {pathActions?.hasSelection &&
959
+ (tool === 'path-select' || (tool === 'brush' && pathActions) || tool === 'bend') ? (
960
+ <div className={styles.drawingSizePicker}>
961
+ <div className={styles.drawingSizeLabel}>
962
+ <span>Shape</span>
963
+ </div>
964
+ <div className={styles.drawingShapeModeGroups}>
965
+ <ShapeInspectorButtons
966
+ pathActions={pathActions}
967
+ palette={palette}
968
+ paletteKeys={paletteKeys}
969
+ buttonClass={styles.drawingToolButton}
970
+ groupClass={styles.drawingShapeModeRow}
971
+ />
972
+ </div>
973
+ </div>
974
+ ) : null}
602
975
  </div>
603
976
  </Panel>
604
977
  );