castle-web-cli 0.4.83 → 0.4.85

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 (85) hide show
  1. package/dist/agent-failures.d.ts +17 -0
  2. package/dist/agent-failures.js +151 -0
  3. package/dist/agent.d.ts +26 -0
  4. package/dist/agent.js +317 -74
  5. package/dist/ide.js +35 -13
  6. package/dist/native/loop.js +35 -0
  7. package/dist/native/openrouter.d.ts +7 -0
  8. package/dist/native/openrouter.js +25 -1
  9. package/dist/native/types.d.ts +2 -0
  10. package/dist/native/types.js +0 -38
  11. package/dist/openrouter-catalog.d.ts +28 -0
  12. package/dist/openrouter-catalog.js +299 -0
  13. package/dist/shell/assets/{index-C9Zhmien.js → index-BJLaUTJE.js} +60 -58
  14. package/dist/shell/assets/{index-BMkQt27u.css → index-DonnH--m.css} +1 -1
  15. package/dist/shell/index.html +2 -2
  16. package/kits/physics-2d/.prettierrc +8 -0
  17. package/kits/physics-2d/CLAUDE.md +329 -0
  18. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  19. package/kits/physics-2d/behaviors/Collider.jsx +199 -0
  20. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  21. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  22. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  23. package/kits/physics-2d/behaviors/tint.js +47 -0
  24. package/kits/physics-2d/blueprints/ball.scene +14 -0
  25. package/kits/physics-2d/blueprints/block.scene +12 -0
  26. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  27. package/kits/physics-2d/blueprints/crate.scene +14 -0
  28. package/kits/physics-2d/blueprints/goal.scene +12 -0
  29. package/kits/physics-2d/castle.json +13 -0
  30. package/kits/physics-2d/docs/pxart-format.md +377 -0
  31. package/kits/physics-2d/drawings/block.pxart +25 -0
  32. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  33. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  34. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  35. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  36. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  37. package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
  38. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  39. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  40. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  41. package/kits/physics-2d/editors/editorHistory.js +157 -0
  42. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  43. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  44. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  45. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  46. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  47. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  48. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  49. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  50. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  51. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  52. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  53. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  54. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  55. package/kits/physics-2d/engine/assets.js +15 -0
  56. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  57. package/kits/physics-2d/engine/blueprint.js +521 -0
  58. package/kits/physics-2d/engine/collider.js +196 -0
  59. package/kits/physics-2d/engine/files.js +117 -0
  60. package/kits/physics-2d/engine/liveReload.js +88 -0
  61. package/kits/physics-2d/engine/pxart.js +1032 -0
  62. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  63. package/kits/physics-2d/engine/scene.js +686 -0
  64. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  65. package/kits/physics-2d/engine/ui.jsx +688 -0
  66. package/kits/physics-2d/engine/ui.module.css +2287 -0
  67. package/kits/physics-2d/eslint.config.js +71 -0
  68. package/kits/physics-2d/index.html +24 -0
  69. package/kits/physics-2d/main.jsx +24 -0
  70. package/kits/physics-2d/package-lock.json +2706 -0
  71. package/kits/physics-2d/package.json +42 -0
  72. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  73. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  74. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  75. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  76. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  77. package/kits/physics-2d/physics/controls.js +79 -0
  78. package/kits/physics-2d/physics/index.js +26 -0
  79. package/kits/physics-2d/physics/matterBridge.js +126 -0
  80. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  81. package/kits/physics-2d/scenes/main.scene +12 -0
  82. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  83. package/kits/physics-2d/scripts/draw.mjs +121 -0
  84. package/kits/physics-2d/vite.config.js +1 -0
  85. package/package.json +1 -1
@@ -0,0 +1,633 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { RESOLUTION_STEPS } from '../engine/pxart';
4
+ import { cx, Icon, IconButton, Panel, styles } from '../engine/ui';
5
+
6
+ // Brush/erase diameters offered by the pixel editors' size sliders.
7
+ export const BRUSH_SIZES = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32];
8
+
9
+ // The tool strip for the pixel (PxArt) editor. Eyedropper is not in the strip —
10
+ // it lives in the palette panel / popover action button.
11
+ export const PIXEL_TOOLS = [
12
+ { id: 'brush', label: 'Brush', icon: 'pencil' },
13
+ { id: 'fill', label: 'Fill', icon: 'fill' },
14
+ { id: 'shape', label: 'Shape', icon: 'shapes' },
15
+ { id: 'select', label: 'Select', icon: 'marquee' },
16
+ { id: 'move-all', label: 'Move all', icon: 'move' },
17
+ { id: 'erase', label: 'Erase', icon: 'eraser' },
18
+ ];
19
+
20
+ // Sub-modes for the shape tool. Each drags out an axis-aligned box: line draws
21
+ // the diagonal, square the rectangle, circle the inscribed ellipse, triangle an
22
+ // isosceles triangle. `filled` (a separate toggle) applies to the closed three.
23
+ export const SHAPE_MODES = [
24
+ { id: 'line', label: 'Line', icon: 'slash' },
25
+ { id: 'square', label: 'Square', icon: 'square' },
26
+ { id: 'circle', label: 'Circle', icon: 'circle' },
27
+ { id: 'triangle', label: 'Triangle', icon: 'triangle' },
28
+ ];
29
+
30
+ // Momentary transform actions the move tool exposes (it has no persistent
31
+ // settings). Each `id` maps to a handler on the editor's `moveActions`.
32
+ export const MOVE_ACTIONS = [
33
+ { id: 'flipH', label: 'Flip horizontal', icon: 'flip-h' },
34
+ { id: 'flipV', label: 'Flip vertical', icon: 'flip-v' },
35
+ { id: 'rotateCW', label: 'Rotate clockwise', icon: 'redo' },
36
+ ];
37
+
38
+ // Tools whose work is driven by the active palette color, so the palette panel
39
+ // (and its eyedropper action) shows.
40
+ export const PALETTE_TOOLS = new Set(['brush', 'fill', 'shape', 'eyedropper']);
41
+
42
+ // Shared tool-mode state for the pixel editors: the selected tool plus the
43
+ // brush/erase sizes and the fill/erase mode toggles. The active color/key stays
44
+ // editor-local (free hex vs fixed palette key).
45
+ //
46
+ // `picking` is a TRANSIENT eyedropper overlay, kept separate from `tool` so the
47
+ // real tool (and its settings panel) never changes when the picker is engaged —
48
+ // it just sits on top of whatever tool is active until you pick or toggle it off.
49
+ export function usePixelToolState() {
50
+ const [tool, setTool] = useState('brush');
51
+ const [picking, setPicking] = useState(false);
52
+ const [brushSize, setBrushSize] = useState(1);
53
+ const [eraseSize, setEraseSize] = useState(4);
54
+ const [eraseFillMode, setEraseFillMode] = useState(false);
55
+ const [fillSwapMode, setFillSwapMode] = useState(false);
56
+ const [shapeMode, setShapeMode] = useState('line');
57
+ const [shapeFill, setShapeFill] = useState(false);
58
+ return {
59
+ tool,
60
+ setTool,
61
+ picking,
62
+ setPicking,
63
+ brushSize,
64
+ setBrushSize,
65
+ eraseSize,
66
+ setEraseSize,
67
+ eraseFillMode,
68
+ setEraseFillMode,
69
+ fillSwapMode,
70
+ setFillSwapMode,
71
+ shapeMode,
72
+ setShapeMode,
73
+ shapeFill,
74
+ setShapeFill,
75
+ };
76
+ }
77
+
78
+ // Vertical icon tool-strip shared by the pixel editors. `tools` is a list of
79
+ // { id, label, icon }; `disabled` greys out the whole strip (read-only files).
80
+ export function PixelToolStrip({ tools, tool, onSelectTool, ariaLabel, disabled = false }) {
81
+ return (
82
+ <div className={styles.drawingToolStrip} aria-label={ariaLabel}>
83
+ {tools.map((candidate) => (
84
+ <button
85
+ key={candidate.id}
86
+ type="button"
87
+ title={candidate.label}
88
+ aria-label={candidate.label}
89
+ aria-pressed={tool === candidate.id ? 'true' : 'false'}
90
+ disabled={disabled}
91
+ className={cx(
92
+ styles.drawingToolButton,
93
+ tool === candidate.id && styles.drawingToolSelected
94
+ )}
95
+ onClick={() => onSelectTool(candidate.id)}>
96
+ <Icon name={candidate.icon} />
97
+ </button>
98
+ ))}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ // Compact W × H selects above the artboard (not a sidebar panel).
104
+ export function CanvasSizeBar({ width, height, onResize }) {
105
+ return (
106
+ <div className={styles.canvasSizeBar} aria-label="Canvas size">
107
+ <ResolutionSelect label="Width" value={width} onChange={(w) => onResize(w, height)} />
108
+ <span className={styles.canvasSizeSep} aria-hidden="true">
109
+ ×
110
+ </span>
111
+ <ResolutionSelect label="Height" value={height} onChange={(h) => onResize(width, h)} />
112
+ </div>
113
+ );
114
+ }
115
+
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: '½' },
125
+ ];
126
+
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 }) {
134
+ 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) => (
139
+ <button
140
+ key={step.value}
141
+ type="button"
142
+ 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}
148
+ </button>
149
+ ))}
150
+ </div>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ function ResolutionSelect({ label, value, onChange }) {
156
+ const options = RESOLUTION_STEPS.includes(value)
157
+ ? RESOLUTION_STEPS
158
+ : [...RESOLUTION_STEPS, value].sort((a, b) => a - b);
159
+ return (
160
+ <select
161
+ className={styles.canvasSizeSelect}
162
+ aria-label={label}
163
+ value={value}
164
+ onChange={(event) => onChange(Number(event.target.value))}>
165
+ {options.map((step) => (
166
+ <option key={step} value={step}>
167
+ {step}
168
+ </option>
169
+ ))}
170
+ </select>
171
+ );
172
+ }
173
+
174
+ // 12-column palette grid shared by the docked sidebar and the compact popover.
175
+ export function PaletteGrid({ keys, palette, activeKey, onSelectKey }) {
176
+ return (
177
+ <div className={styles.palette}>
178
+ {keys.map((key) => (
179
+ <button
180
+ key={key}
181
+ type="button"
182
+ className={cx(styles.swatch, activeKey === key && styles.swatchSelected)}
183
+ title={palette[key]}
184
+ style={{ background: palette[key] }}
185
+ onClick={() => onSelectKey(key)}
186
+ />
187
+ ))}
188
+ </div>
189
+ );
190
+ }
191
+
192
+ // Anchored palette + eyedropper popover for compact/mobile paint-strip color tap.
193
+ export function PalettePopover({ open, anchorRef, onClose, children }) {
194
+ const popoverRef = useRef(null);
195
+ const [position, setPosition] = useState({ top: 0, left: 0 });
196
+
197
+ useLayoutEffect(() => {
198
+ if (!open || !anchorRef.current) return;
199
+ const anchor = anchorRef.current.getBoundingClientRect();
200
+ const popoverWidth = 280;
201
+ const margin = 8;
202
+ let left = anchor.left - popoverWidth - margin;
203
+ if (left < margin) left = anchor.right + margin;
204
+ let top = anchor.top;
205
+ const maxTop = window.innerHeight - margin;
206
+ if (top + 320 > maxTop) top = Math.max(margin, maxTop - 320);
207
+ setPosition({ top, left });
208
+ }, [open, anchorRef]);
209
+
210
+ useEffect(() => {
211
+ if (!open) return undefined;
212
+ function onKeyDown(event) {
213
+ if (event.key === 'Escape') onClose();
214
+ }
215
+ function onPointerDown(event) {
216
+ if (popoverRef.current?.contains(event.target) || anchorRef.current?.contains(event.target)) {
217
+ return;
218
+ }
219
+ onClose();
220
+ }
221
+ window.addEventListener('keydown', onKeyDown);
222
+ window.addEventListener('pointerdown', onPointerDown);
223
+ return () => {
224
+ window.removeEventListener('keydown', onKeyDown);
225
+ window.removeEventListener('pointerdown', onPointerDown);
226
+ };
227
+ }, [open, onClose, anchorRef]);
228
+
229
+ if (!open) return null;
230
+ return createPortal(
231
+ <div
232
+ ref={popoverRef}
233
+ className={styles.palettePopover}
234
+ style={{ top: position.top, left: position.left }}
235
+ role="dialog"
236
+ aria-label="Palette">
237
+ {children}
238
+ </div>,
239
+ document.body
240
+ );
241
+ }
242
+
243
+ // The shape tool's sub-mode picker, shared by the compact paint strip and the
244
+ // wide-layout settings panel. `buttonClass` styles each glyph button for its
245
+ // host strip; `radio` switches the a11y semantics to a radiogroup.
246
+ function ShapeModeButtons({ shapeMode, setShapeMode, buttonClass, radio = false }) {
247
+ return (
248
+ <>
249
+ {SHAPE_MODES.map((mode) => {
250
+ const selected = shapeMode === mode.id;
251
+ return (
252
+ <button
253
+ key={mode.id}
254
+ type="button"
255
+ role={radio ? 'radio' : undefined}
256
+ className={cx(buttonClass, selected && styles.drawingToolSelected)}
257
+ title={mode.label}
258
+ aria-label={mode.label}
259
+ aria-pressed={radio ? undefined : selected ? 'true' : 'false'}
260
+ aria-checked={radio ? (selected ? 'true' : 'false') : undefined}
261
+ onClick={() => setShapeMode(mode.id)}>
262
+ <Icon name={mode.icon} />
263
+ </button>
264
+ );
265
+ })}
266
+ </>
267
+ );
268
+ }
269
+
270
+ // The move tool's transform actions (flip H / flip V / rotate CW), shared by the
271
+ // compact paint strip and the wide-layout settings panel. These are momentary
272
+ // buttons (no selected state); `buttonClass` styles them for the host strip.
273
+ function MoveActionButtons({ moveActions, buttonClass }) {
274
+ return (
275
+ <>
276
+ {MOVE_ACTIONS.map((action) => (
277
+ <button
278
+ key={action.id}
279
+ type="button"
280
+ className={buttonClass}
281
+ title={action.label}
282
+ aria-label={action.label}
283
+ onClick={moveActions?.[action.id]}>
284
+ <Icon name={action.icon} />
285
+ </button>
286
+ ))}
287
+ </>
288
+ );
289
+ }
290
+
291
+ // Right-side paint strip (compact layout): color swatch, vertical size slider, and
292
+ // tool-contextual mode toggles. Color tap opens the palette popover.
293
+ export function PaintStrip({
294
+ toolState,
295
+ activeKey,
296
+ activeColor,
297
+ paletteOpen,
298
+ onTogglePalette,
299
+ colorButtonRef,
300
+ onSelectKey,
301
+ paletteKeys,
302
+ palette,
303
+ moveActions,
304
+ selectActions,
305
+ }) {
306
+ const {
307
+ tool,
308
+ picking,
309
+ setPicking,
310
+ brushSize,
311
+ eraseSize,
312
+ eraseFillMode,
313
+ fillSwapMode,
314
+ shapeMode,
315
+ shapeFill,
316
+ setBrushSize,
317
+ setEraseSize,
318
+ setEraseFillMode,
319
+ setFillSwapMode,
320
+ setShapeMode,
321
+ setShapeFill,
322
+ } = toolState;
323
+
324
+ const showBrushSize = tool === 'brush';
325
+ const showEraseSize = tool === 'erase';
326
+ const size = showBrushSize ? brushSize : eraseSize;
327
+ const onSizeChange = showBrushSize ? setBrushSize : setEraseSize;
328
+ const sliderIndex = Math.max(0, BRUSH_SIZES.indexOf(size));
329
+ const sliderDisabled = showEraseSize && eraseFillMode;
330
+
331
+ return (
332
+ <>
333
+ <div className={styles.paintStrip} aria-label="Paint controls">
334
+ {PALETTE_TOOLS.has(tool) || picking ? (
335
+ <div className={styles.paintStripSection}>
336
+ <button
337
+ ref={colorButtonRef}
338
+ type="button"
339
+ className={styles.paintStripColor}
340
+ title={activeColor}
341
+ aria-label={`Active color ${activeColor}. Open palette.`}
342
+ aria-expanded={paletteOpen ? 'true' : 'false'}
343
+ style={{ background: activeColor }}
344
+ onClick={onTogglePalette}
345
+ />
346
+ </div>
347
+ ) : null}
348
+ {showBrushSize || showEraseSize ? (
349
+ <div className={styles.paintStripSection}>
350
+ <div className={styles.paintStripSlider}>
351
+ <input
352
+ className={styles.drawingSizeSliderVertical}
353
+ type="range"
354
+ min="0"
355
+ max={BRUSH_SIZES.length - 1}
356
+ step="1"
357
+ value={sliderIndex}
358
+ disabled={sliderDisabled}
359
+ aria-label={showBrushSize ? 'Brush size' : 'Erase size'}
360
+ title={`${showBrushSize ? 'Brush' : 'Erase'} size ${size}`}
361
+ onChange={(event) => onSizeChange(BRUSH_SIZES[Number(event.target.value)])}
362
+ />
363
+ </div>
364
+ </div>
365
+ ) : null}
366
+ {tool === 'shape' ? (
367
+ <div className={styles.paintStripSection}>
368
+ <ShapeModeButtons
369
+ shapeMode={shapeMode}
370
+ setShapeMode={setShapeMode}
371
+ buttonClass={styles.paintStripModeButton}
372
+ />
373
+ </div>
374
+ ) : null}
375
+ {tool === 'move-all' ? (
376
+ <div className={styles.paintStripSection}>
377
+ <MoveActionButtons moveActions={moveActions} buttonClass={styles.paintStripModeButton} />
378
+ </div>
379
+ ) : null}
380
+ {tool === 'select' ? (
381
+ <div className={styles.paintStripSection}>
382
+ <button
383
+ type="button"
384
+ className={styles.paintStripModeButton}
385
+ title="Delete selection"
386
+ aria-label="Delete selection"
387
+ disabled={!selectActions?.hasSelection}
388
+ onClick={selectActions?.deletePixels}>
389
+ <Icon name="trash" />
390
+ </button>
391
+ </div>
392
+ ) : null}
393
+ {tool === 'erase' ? (
394
+ <div className={styles.paintStripSection}>
395
+ <button
396
+ type="button"
397
+ className={cx(
398
+ styles.paintStripModeButton,
399
+ eraseFillMode && styles.drawingToolSelected
400
+ )}
401
+ title="Fill mode"
402
+ aria-label="Fill mode"
403
+ aria-pressed={eraseFillMode ? 'true' : 'false'}
404
+ onClick={() => setEraseFillMode(!eraseFillMode)}>
405
+ <Icon name="fill" />
406
+ </button>
407
+ </div>
408
+ ) : null}
409
+ {tool === 'fill' ? (
410
+ <div className={styles.paintStripSection}>
411
+ <button
412
+ type="button"
413
+ className={cx(
414
+ styles.paintStripModeButton,
415
+ fillSwapMode && styles.drawingToolSelected
416
+ )}
417
+ title="Swap mode"
418
+ aria-label="Swap mode"
419
+ aria-pressed={fillSwapMode ? 'true' : 'false'}
420
+ onClick={() => setFillSwapMode(!fillSwapMode)}>
421
+ <Icon name="rotate" />
422
+ </button>
423
+ </div>
424
+ ) : null}
425
+ {tool === 'shape' ? (
426
+ <div className={styles.paintStripSection}>
427
+ <button
428
+ type="button"
429
+ className={cx(
430
+ styles.paintStripModeButton,
431
+ shapeFill && styles.drawingToolSelected
432
+ )}
433
+ title="Fill shape"
434
+ aria-label="Fill shape"
435
+ aria-pressed={shapeFill ? 'true' : 'false'}
436
+ onClick={() => setShapeFill(!shapeFill)}>
437
+ <Icon name="fill" />
438
+ </button>
439
+ </div>
440
+ ) : null}
441
+ </div>
442
+ <PalettePopover open={paletteOpen} anchorRef={colorButtonRef} onClose={() => onTogglePalette(false)}>
443
+ <div className={styles.palettePopoverHeader}>
444
+ <span className={styles.palettePopoverTitle}>Palette</span>
445
+ <IconButton
446
+ icon="eyedropper"
447
+ label="Pick color"
448
+ active={picking}
449
+ aria-pressed={picking ? 'true' : 'false'}
450
+ onClick={() => setPicking(!picking)}
451
+ />
452
+ </div>
453
+ <PaletteGrid
454
+ keys={paletteKeys}
455
+ palette={palette}
456
+ activeKey={activeKey}
457
+ onSelectKey={(key) => {
458
+ onSelectKey(key);
459
+ onTogglePalette(false);
460
+ }}
461
+ />
462
+ </PalettePopover>
463
+ </>
464
+ );
465
+ }
466
+
467
+ // Wide-layout docked inspector: palette (when relevant) + tool settings only.
468
+ export function PxArtInspectorDock({ toolState, activeKey, onSelectKey, paletteKeys, palette, moveActions, selectActions }) {
469
+ const { tool, picking, setPicking } = toolState;
470
+ return (
471
+ <aside className={cx(styles.pxArtInspector, styles.drawingToolsPanel)}>
472
+ <div className={cx(styles.sheetBody, styles.inspectorBody, styles.drawingSettingsColumn)}>
473
+ {PALETTE_TOOLS.has(tool) || picking ? (
474
+ <Panel
475
+ title="Palette"
476
+ action={
477
+ <IconButton
478
+ icon="eyedropper"
479
+ label="Pick color"
480
+ active={picking}
481
+ aria-pressed={picking ? 'true' : 'false'}
482
+ onClick={() => setPicking(!picking)}
483
+ />
484
+ }>
485
+ <PaletteGrid
486
+ keys={paletteKeys}
487
+ palette={palette}
488
+ activeKey={activeKey}
489
+ onSelectKey={onSelectKey}
490
+ />
491
+ </Panel>
492
+ ) : null}
493
+ <ToolSettingsPanel toolState={toolState} moveActions={moveActions} selectActions={selectActions} />
494
+ </div>
495
+ </aside>
496
+ );
497
+ }
498
+
499
+ // A labeled on/off switch used for the editors' boolean tool modes.
500
+ export function ModeToggle({ label, active, onChange }) {
501
+ return (
502
+ <div className={styles.drawingModeRow}>
503
+ <span>{label}</span>
504
+ <button
505
+ type="button"
506
+ role="switch"
507
+ className={cx(styles.drawingSwitch, active && styles.drawingSwitchOn)}
508
+ aria-checked={active ? 'true' : 'false'}
509
+ onClick={() => onChange(!active)}>
510
+ <span className={styles.drawingSwitchKnob} />
511
+ </button>
512
+ </div>
513
+ );
514
+ }
515
+
516
+ // The "Tool Settings" panel for the pixel editor: brush/erase size sliders plus
517
+ // the per-tool mode toggle / hint. It reads the whole usePixelToolState bundle
518
+ // to keep callers from re-spreading the same prop list.
519
+ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
520
+ const {
521
+ tool,
522
+ brushSize,
523
+ eraseSize,
524
+ eraseFillMode,
525
+ fillSwapMode,
526
+ shapeMode,
527
+ shapeFill,
528
+ setBrushSize,
529
+ setEraseSize,
530
+ setEraseFillMode,
531
+ setFillSwapMode,
532
+ setShapeMode,
533
+ setShapeFill,
534
+ } = toolState;
535
+ return (
536
+ <Panel title="Tool Settings">
537
+ <div className={styles.drawingToolSettings}>
538
+ <ToolSizePicker
539
+ label="Brush"
540
+ value={brushSize}
541
+ visible={tool === 'brush'}
542
+ onChange={setBrushSize}
543
+ />
544
+ <ToolSizePicker
545
+ label="Erase"
546
+ value={eraseSize}
547
+ visible={tool === 'erase'}
548
+ onChange={setEraseSize}
549
+ fill={eraseFillMode}
550
+ onFillChange={setEraseFillMode}
551
+ />
552
+ {tool === 'fill' ? (
553
+ <ModeToggle label="Swap Mode" active={fillSwapMode} onChange={setFillSwapMode} />
554
+ ) : null}
555
+ {tool === 'shape' ? (
556
+ <div className={styles.drawingSizePicker}>
557
+ <div className={styles.drawingSizeLabel}>
558
+ <span>Shape</span>
559
+ </div>
560
+ <div className={styles.drawingShapeModeRow} role="radiogroup" aria-label="Shape">
561
+ <ShapeModeButtons
562
+ shapeMode={shapeMode}
563
+ setShapeMode={setShapeMode}
564
+ buttonClass={styles.drawingToolButton}
565
+ radio
566
+ />
567
+ </div>
568
+ <ModeToggle label="Fill Shape" active={shapeFill} onChange={setShapeFill} />
569
+ </div>
570
+ ) : null}
571
+ {tool === 'move-all' ? (
572
+ <div className={styles.drawingSizePicker}>
573
+ <div className={styles.drawingSizeLabel}>
574
+ <span>Transform</span>
575
+ </div>
576
+ <div className={styles.drawingShapeModeRow}>
577
+ <MoveActionButtons moveActions={moveActions} buttonClass={styles.drawingToolButton} />
578
+ </div>
579
+ </div>
580
+ ) : null}
581
+ {tool === 'select' ? (
582
+ <div className={styles.drawingSizePicker}>
583
+ <div className={styles.drawingSizeLabel}>
584
+ <span>Selection</span>
585
+ </div>
586
+ <div className={styles.drawingShapeModeRow}>
587
+ <button
588
+ type="button"
589
+ className={styles.drawingToolButton}
590
+ title="Delete selection"
591
+ aria-label="Delete selection"
592
+ disabled={!selectActions?.hasSelection}
593
+ onClick={selectActions?.deletePixels}>
594
+ <Icon name="trash" />
595
+ </button>
596
+ </div>
597
+ </div>
598
+ ) : null}
599
+ {tool === 'eyedropper' ? (
600
+ <div className={styles.drawingToolHint}>Click a pixel to make it the active color.</div>
601
+ ) : null}
602
+ </div>
603
+ </Panel>
604
+ );
605
+ }
606
+
607
+ // Discrete brush/erase size slider (steps through BRUSH_SIZES). Hidden unless
608
+ // `visible`. An optional fill toggle disables the slider when fill mode is on.
609
+ export function ToolSizePicker({ label, value, visible, onChange, fill = false, onFillChange }) {
610
+ if (!visible) return null;
611
+ const sliderIndex = Math.max(0, BRUSH_SIZES.indexOf(value));
612
+ return (
613
+ <div className={styles.drawingSizePicker}>
614
+ <div className={styles.drawingSizeLabel}>
615
+ <span>{label} size</span>
616
+ <span>{value}</span>
617
+ </div>
618
+ <div className={styles.drawingSizeControlRow}>
619
+ <input
620
+ className={styles.drawingSizeSlider}
621
+ type="range"
622
+ min="0"
623
+ max={BRUSH_SIZES.length - 1}
624
+ step="1"
625
+ value={sliderIndex}
626
+ disabled={fill}
627
+ onChange={(event) => onChange(BRUSH_SIZES[Number(event.target.value)])}
628
+ />
629
+ </div>
630
+ {onFillChange ? <ModeToggle label="Fill Mode" active={fill} onChange={onFillChange} /> : null}
631
+ </div>
632
+ );
633
+ }