castle-web-cli 0.4.77 → 0.4.79

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 (53) hide show
  1. package/dist/agent-prompts.d.ts +4 -1
  2. package/dist/agent-prompts.js +28 -7
  3. package/dist/agent.d.ts +7 -2
  4. package/dist/agent.js +655 -51
  5. package/dist/ide.js +2 -0
  6. package/dist/native/loop.d.ts +2 -0
  7. package/dist/native/loop.js +698 -0
  8. package/dist/native/openrouter.d.ts +55 -0
  9. package/dist/native/openrouter.js +354 -0
  10. package/dist/native/playtest-browser.d.ts +34 -0
  11. package/dist/native/playtest-browser.js +354 -0
  12. package/dist/native/playtest-executor.d.ts +3 -0
  13. package/dist/native/playtest-executor.js +156 -0
  14. package/dist/native/playtest.d.ts +131 -0
  15. package/dist/native/playtest.js +314 -0
  16. package/dist/native/tools.d.ts +38 -0
  17. package/dist/native/tools.js +630 -0
  18. package/dist/native/types.d.ts +40 -0
  19. package/dist/native/types.js +41 -0
  20. package/dist/serve.js +12 -0
  21. package/dist/shell/assets/{index-CvHiGhAV.js → index-CNT3KxJb.js} +37 -37
  22. package/dist/shell/assets/{index-QteLRDnK.css → index-RZrw5gQ2.css} +1 -1
  23. package/dist/shell/index.html +2 -2
  24. package/kits/basic-2d/CLAUDE.md +29 -3
  25. package/kits/basic-2d/behaviors/Layout.jsx +10 -0
  26. package/kits/basic-2d/behaviors/Sprite.jsx +16 -4
  27. package/kits/basic-2d/blueprints/cauldron.scene +22 -0
  28. package/kits/basic-2d/castle.json +5 -7
  29. package/kits/basic-2d/docs/pxart-format.md +83 -4
  30. package/kits/basic-2d/drawings/cauldron.pxart +113 -0
  31. package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
  32. package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
  33. package/kits/basic-2d/editors/PxArtEditor.jsx +68 -9
  34. package/kits/basic-2d/editors/SceneEditor.jsx +424 -418
  35. package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
  36. package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
  37. package/kits/basic-2d/editors/editorHistory.js +95 -17
  38. package/kits/basic-2d/editors/inspectorSheet.js +5 -19
  39. package/kits/basic-2d/editors/pixelEditorChrome.jsx +21 -8
  40. package/kits/basic-2d/editors/pixelInspector.jsx +39 -0
  41. package/kits/basic-2d/editors/pxArtEditorModel.js +15 -1
  42. package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
  43. package/kits/basic-2d/engine/blueprint.js +423 -0
  44. package/kits/basic-2d/engine/files.js +1 -1
  45. package/kits/basic-2d/engine/pxart.js +49 -2
  46. package/kits/basic-2d/engine/pxartSmooth.js +222 -0
  47. package/kits/basic-2d/engine/scene.js +29 -29
  48. package/kits/basic-2d/engine/ui.jsx +160 -21
  49. package/kits/basic-2d/engine/ui.module.css +263 -27
  50. package/kits/basic-2d/pnpm-workspace.yaml +3 -0
  51. package/kits/basic-2d/scenes/main.scene +3 -13
  52. package/package.json +2 -1
  53. package/kits/basic-2d/drawings/pig.pxart +0 -26
@@ -282,7 +282,85 @@ case).
282
282
 
283
283
  ---
284
284
 
285
- ## 10. Reserved / out of scope
285
+ ## 10. Corner rounding
286
+
287
+ ```json
288
+ "cornerRadius": 0.25
289
+ ```
290
+
291
+ - A top-level **number**: a corner-rounding radius, in native-pixel units.
292
+ `0` (the default) renders sharp/nearest-neighbor, same as before this field
293
+ existed. Any value `> 0` renders rounded, by that amount — see "Rendering"
294
+ below. **File-level** — a property of the sprite asset itself, not a
295
+ per-actor/per-placement override. There is no equivalent `Sprite` behavior
296
+ prop.
297
+ - **A value, not a flag**, deliberately: the amount of rounding is itself
298
+ part of the portable file format, not a fixed code-side constant every
299
+ smooth sprite would otherwise be stuck with. `MAX_CORNER_RADIUS` (0.5, in
300
+ `engine/pxart.js`) is the ceiling — two corner cuts on the same
301
+ 1-native-pixel edge must not overlap — and any value is clamped to
302
+ `[0, MAX_CORNER_RADIUS]` on read. The `PxArtEditor` UI curates this down to
303
+ a 3-way segmented control (`0` / `¼` / `½`, see `CornerRadiusBar` in
304
+ `editors/pixelInspector.jsx`) so picking a value never means hunting a
305
+ slider, but the format itself isn't limited to those three — any in-range
306
+ number is valid, e.g. from hand-edited JSON or a future finer-grained UI.
307
+ - **`0` is the default** and is never written to disk: `serializeFull` and
308
+ `serializeCompact` both OMIT the field when it's `0`, so existing (and
309
+ newly authored, unsmoothed) files stay byte-identical to their
310
+ pre-`cornerRadius`-field shape.
311
+ - **Present in BOTH on-disk forms.** `parseCompact` reads a top-level
312
+ `cornerRadius` alongside `palette`/`grid`; `parseFull`'s native-full branch
313
+ reads it alongside `resolution`/`layers`; `upgradeCompactToFull` carries a
314
+ compact file's `cornerRadius` through into the upgraded Sprite. This means
315
+ the kit's save path — which picks compact vs. full by a serialize →
316
+ re-parse round-trip (`serializeModel` in `editors/pxArtEditorModel.js`, see
317
+ [§9](#9-compact-shorthand)) — never drops the field: a single-layer/frame
318
+ rounded sprite still serializes compact, with `cornerRadius: 0.25` alongside
319
+ `palette`/`grid`.
320
+ - **Parser tolerance:** any non-finite-number value (missing field, `null`, a
321
+ typo) defaults to `0`. Numbers are clamped to `[0, MAX_CORNER_RADIUS]`.
322
+ **Legacy back-compat:** this field used to be called `render` — first a
323
+ `"pixel"` | `"smooth"` string enum, then (briefly) a bare numeric radius
324
+ under that same key. Both migrate on read: a legacy `render: "smooth"`
325
+ becomes a fixed radius (`0.25`); a legacy numeric `render` value is read
326
+ as-is; anything else (including the original `render: "pixel"`) defaults to
327
+ `0`. This keeps old files rendering rounded rather than silently reverting
328
+ to sharp. Detection of compact vs. full form ([§1](#1-form-discriminator))
329
+ is unaffected — neither key is ever used as a structural signal.
330
+ - **Rendering.** `cornerRadius === 0` renders as before: 1px/cell,
331
+ nearest-neighbor (`renderSpriteFrame`, `imageSmoothingEnabled = false`).
332
+ `cornerRadius > 0` renders through `engine/pxartSmooth.js`'s
333
+ `renderSmoothSpriteFrame`, using a LOCAL per-pixel kernel — the same shape
334
+ of algorithm as Animal Crossing's actual smoothing (xBRZ-style template
335
+ matching), not a global vectorization pass: it composites the frame
336
+ normally (so layer visibility/opacity/blend keep working), then for each
337
+ pixel independently, classifies each of its 4 corners against only the 3
338
+ pixels touching that corner (its two edge-adjacent neighbors and their
339
+ shared diagonal neighbor). A corner rounds only when it's a genuine convex
340
+ corner of that pixel's own color region — a diagonal touch between two
341
+ same-colored pixels is deliberately left sharp so it doesn't get visually
342
+ pinched off, and a corner already matched by an adjacent same-color
343
+ neighbor is left for that neighbor's own (independent) classification to
344
+ handle. Because the kernel never looks past a pixel's immediate
345
+ neighborhood, there's no notion of a "run" or "line" to (mis)detect across
346
+ a whole shape — every pixel supersamples into its own fixed block of the
347
+ output canvas (by default 8x the sprite's native resolution), so rounding
348
+ a corner is always a same-block recoloring, never a shape that could leave
349
+ a gap against its neighbors. This suits small pixel-art sprites, not
350
+ general raster upscaling.
351
+ - `behaviors/Sprite.jsx` blits a `cornerRadius > 0` sprite's cached canvas
352
+ with `imageSmoothingEnabled = true` (vs. `false` for `cornerRadius === 0`),
353
+ passing the sprite's own `cornerRadius` value through as
354
+ `renderSmoothSpriteFrame`'s `cornerRadius` option; the offscreen canvas
355
+ cache itself is unaffected (still one WeakMap entry per sprite object —
356
+ smoothing is a property of the sprite, not an extra cache dimension). The
357
+ `PxArtEditor` artboard previews rounded sprites live (a display-resolution
358
+ canvas behind the pixel-grid editing surface) while keeping normal
359
+ pixel-grid editing/selection interactions on the grid underneath.
360
+
361
+ ---
362
+
363
+ ## 11. Reserved / out of scope
286
364
 
287
365
  Named so the format can grow without a breaking change, but **not built**:
288
366
  tilemap/tileset layers (a future `kind`), slices, layer groups, vector/avatar
@@ -293,6 +371,7 @@ layers, and rich blend modes (beyond `"normal"`). Reserving the `kind` and
293
371
 
294
372
  ## Appendix — sample
295
373
 
296
- `drawings/pig.pxart` is a 16×16 single-layer sprite referenced by the
297
- `pig_sprite` actor in `scenes/main.scene` through the `Sprite` behavior — a
298
- working rendered example of the format in this kit.
374
+ `drawings/cauldron.pxart` is a 16×16 multi-layer animated sprite referenced by
375
+ the `pig` actor in `scenes/main.scene` (via `blueprints/cauldron.scene`)
376
+ through the `Sprite` behavior — a working rendered example of the format in
377
+ this kit.
@@ -0,0 +1,113 @@
1
+ {
2
+ "format": "full",
3
+ "resolution": {
4
+ "width": 16,
5
+ "height": 16
6
+ },
7
+ "palette": [
8
+ {
9
+ "key": "I",
10
+ "hex": "#5ac54f"
11
+ },
12
+ {
13
+ "key": "1",
14
+ "hex": "#622461"
15
+ },
16
+ {
17
+ "key": "2",
18
+ "hex": "#93388f"
19
+ }
20
+ ],
21
+ "frames": [
22
+ {},
23
+ {}
24
+ ],
25
+ "defaultDurationMs": 500,
26
+ "tags": [],
27
+ "cornerRadius": 0.25,
28
+ "layers": [
29
+ {
30
+ "id": "layer-0",
31
+ "name": "Layer 1",
32
+ "visible": true,
33
+ "opacity": 1,
34
+ "blendMode": "normal",
35
+ "kind": "pixel",
36
+ "cells": [
37
+ {
38
+ "grid": [
39
+ "................",
40
+ "................",
41
+ "................",
42
+ "...11111111111..",
43
+ ".211IIIIIIIII11.",
44
+ ".22IIIIIIIII221.",
45
+ "..22222222222...",
46
+ "...1111111111...",
47
+ "..222222221111..",
48
+ ".22222222221111.",
49
+ ".22222222221111.",
50
+ ".22222222221111.",
51
+ ".22222222211111.",
52
+ "..222222211111..",
53
+ ".11222211111111.",
54
+ ".11.11111111.11."
55
+ ]
56
+ },
57
+ {
58
+ "link": 0
59
+ }
60
+ ]
61
+ },
62
+ {
63
+ "id": "layer-1",
64
+ "name": "Layer 1 copy",
65
+ "visible": true,
66
+ "opacity": 1,
67
+ "blendMode": "normal",
68
+ "kind": "pixel",
69
+ "cells": [
70
+ {
71
+ "grid": [
72
+ "...........I....",
73
+ "....I...........",
74
+ ".......I........",
75
+ "................",
76
+ "................",
77
+ "................",
78
+ "................",
79
+ "................",
80
+ "................",
81
+ "................",
82
+ "................",
83
+ "................",
84
+ "................",
85
+ "................",
86
+ "................",
87
+ "................"
88
+ ]
89
+ },
90
+ {
91
+ "grid": [
92
+ "...I............",
93
+ "........I.......",
94
+ "............I...",
95
+ "................",
96
+ "................",
97
+ "................",
98
+ "................",
99
+ "................",
100
+ "................",
101
+ "................",
102
+ "................",
103
+ "................",
104
+ "................",
105
+ "................",
106
+ "................",
107
+ "................"
108
+ ]
109
+ }
110
+ ]
111
+ }
112
+ ]
113
+ }
@@ -0,0 +1,247 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { renderSpriteFrame } from '../engine/pxart';
3
+ import { screenToCard } from '../engine/scene';
4
+ import { countBlueprintInstances, listBlueprints } from '../engine/blueprint';
5
+ import { ConfirmDialog, ContextMenu, cx, Icon, styles } from '../engine/ui';
6
+
7
+ const DRAG_THRESHOLD = 4;
8
+
9
+ // Deck-level blueprint library / hotbar: lists `blueprints/*.scene` (replacing
10
+ // the old scene-local stamp system). Mirrors castle-client's belt: TAP a slot
11
+ // to select the blueprint for editing in the sidebar; DRAG a slot onto the
12
+ // canvas to place a new instance; right-click for a delete option.
13
+ export function BlueprintLibrary({
14
+ files,
15
+ sprites,
16
+ canvasRef,
17
+ editCameraRef,
18
+ isPlaying,
19
+ selectedBlueprintPath,
20
+ onSelectBlueprint,
21
+ onAddActor,
22
+ dragPlace,
23
+ onDeleteBlueprint,
24
+ }) {
25
+ const blueprints = listBlueprints(files);
26
+ const dragRef = useRef(null);
27
+ const suppressClickRef = useRef(false);
28
+ const [dragPreview, setDragPreview] = useState(null);
29
+ const [contextMenu, setContextMenu] = useState(null);
30
+ const [confirmDelete, setConfirmDelete] = useState(null);
31
+
32
+ const onSlotContextMenu = (event, blueprint) => {
33
+ if (isPlaying) return;
34
+ event.preventDefault();
35
+ event.stopPropagation();
36
+ setContextMenu({ blueprint, x: event.clientX, y: event.clientY });
37
+ };
38
+
39
+ const onSlotPointerDown = (event, blueprint) => {
40
+ if (isPlaying || event.button !== 0) return;
41
+ event.preventDefault();
42
+ const drag = {
43
+ blueprint,
44
+ pointerId: event.pointerId,
45
+ startX: event.clientX,
46
+ startY: event.clientY,
47
+ moved: false,
48
+ onStage: false,
49
+ };
50
+ dragRef.current = drag;
51
+ try {
52
+ event.currentTarget.setPointerCapture(event.pointerId);
53
+ } catch {
54
+ // Window listeners still finish the drag if capture is unavailable.
55
+ }
56
+ const onMove = (moveEvent) => {
57
+ if (moveEvent.pointerId !== drag.pointerId) return;
58
+ if (!drag.moved && Math.hypot(moveEvent.clientX - drag.startX, moveEvent.clientY - drag.startY) > DRAG_THRESHOLD) {
59
+ drag.moved = true;
60
+ }
61
+ if (!drag.moved) return;
62
+ const position = eventToStagePoint(moveEvent, canvasRef, editCameraRef);
63
+ if (position) {
64
+ // Over the stage: the drag becomes a REAL placed instance immediately,
65
+ // so it grid-snaps and renders exactly like the actor it is. The
66
+ // floating thumbnail only exists off-stage.
67
+ if (drag.onStage) {
68
+ dragPlace.move(position);
69
+ } else {
70
+ drag.onStage = true;
71
+ setDragPreview(null);
72
+ dragPlace.enter(blueprint.path, position);
73
+ }
74
+ } else {
75
+ if (drag.onStage) {
76
+ drag.onStage = false;
77
+ dragPlace.leave();
78
+ }
79
+ setDragPreview({ blueprint, x: moveEvent.clientX, y: moveEvent.clientY, overStage: false });
80
+ }
81
+ };
82
+ const onUp = (upEvent) => {
83
+ if (upEvent.pointerId !== drag.pointerId) return;
84
+ window.removeEventListener('pointermove', onMove);
85
+ window.removeEventListener('pointerup', onUp);
86
+ window.removeEventListener('pointercancel', onUp);
87
+ dragRef.current = null;
88
+ setDragPreview(null);
89
+ if (!drag.moved) return;
90
+ suppressClickRef.current = true;
91
+ // Cancelled drags (pointercancel) count as a release off-stage.
92
+ if (drag.onStage && upEvent.type !== 'pointercancel') dragPlace.drop();
93
+ else if (drag.onStage) dragPlace.leave();
94
+ };
95
+ window.addEventListener('pointermove', onMove);
96
+ window.addEventListener('pointerup', onUp);
97
+ window.addEventListener('pointercancel', onUp);
98
+ };
99
+
100
+ return (
101
+ <div className={styles.bpHotbar} aria-label="Blueprints">
102
+ <button
103
+ type="button"
104
+ className={styles.bpActionSlot}
105
+ aria-label="Add actor"
106
+ title="Add actor (creates a new blueprint)"
107
+ onClick={onAddActor}
108
+ disabled={isPlaying}>
109
+ <span className={styles.dashedSquareIcon} aria-hidden="true" />
110
+ </button>
111
+ {blueprints.map((blueprint) => (
112
+ <button
113
+ key={blueprint.path}
114
+ type="button"
115
+ className={cx(styles.bpSlot, selectedBlueprintPath === blueprint.path && styles.bpSlotSelected)}
116
+ title={`${blueprint.name} -- click to edit, drag to place`}
117
+ disabled={isPlaying}
118
+ onPointerDown={(event) => onSlotPointerDown(event, blueprint)}
119
+ onContextMenu={(event) => onSlotContextMenu(event, blueprint)}
120
+ onClick={() => {
121
+ if (suppressClickRef.current) {
122
+ suppressClickRef.current = false;
123
+ return;
124
+ }
125
+ onSelectBlueprint(blueprint.path);
126
+ }}>
127
+ <BlueprintThumbnail blueprint={blueprint} sprites={sprites} />
128
+ <InstanceCountBadge files={files} blueprintPath={blueprint.path} />
129
+ </button>
130
+ ))}
131
+ {dragPreview ? <BlueprintDragPreview preview={dragPreview} sprites={sprites} /> : null}
132
+ {contextMenu ? (
133
+ <ContextMenu
134
+ x={contextMenu.x}
135
+ y={contextMenu.y}
136
+ onClose={() => setContextMenu(null)}
137
+ items={[
138
+ {
139
+ key: 'delete',
140
+ label: `Delete ${contextMenu.blueprint.name}...`,
141
+ onClick: () => setConfirmDelete(contextMenu.blueprint),
142
+ },
143
+ ]}
144
+ />
145
+ ) : null}
146
+ {confirmDelete ? (
147
+ <DeleteBlueprintConfirm
148
+ files={files}
149
+ blueprint={confirmDelete}
150
+ onCancel={() => setConfirmDelete(null)}
151
+ onConfirm={() => {
152
+ onDeleteBlueprint(confirmDelete.path);
153
+ setConfirmDelete(null);
154
+ }}
155
+ />
156
+ ) : null}
157
+ </div>
158
+ );
159
+ }
160
+
161
+ function DeleteBlueprintConfirm({ files, blueprint, onCancel, onConfirm }) {
162
+ const count = countBlueprintInstances(files, blueprint.path);
163
+ const instanceText = count === 1 ? '1 placed actor' : `${count} placed actors`;
164
+ return (
165
+ <ConfirmDialog
166
+ title={`Delete ${blueprint.name}?`}
167
+ message={
168
+ count > 0
169
+ ? `This removes ${blueprint.name} and ${instanceText} across every scene in the deck. This can't be undone.`
170
+ : `${blueprint.name} has no placed actors. This can't be undone.`
171
+ }
172
+ confirmLabel="Delete"
173
+ onCancel={onCancel}
174
+ onConfirm={onConfirm}
175
+ />
176
+ );
177
+ }
178
+
179
+ function InstanceCountBadge({ files, blueprintPath }) {
180
+ const count = countBlueprintInstances(files, blueprintPath);
181
+ if (count === 0) return null;
182
+ return <span className={styles.bpSlotBadge}>{count > 99 ? '99+' : count}</span>;
183
+ }
184
+
185
+ function BlueprintDragPreview({ preview, sprites }) {
186
+ const layout = preview.blueprint.components?.Layout;
187
+ const width = Math.max(24, Math.min(80, layout?.width ?? 48));
188
+ const height = Math.max(24, Math.min(80, layout?.height ?? 48));
189
+ return (
190
+ <div
191
+ className={cx(styles.bpDragPreview, preview.overStage && styles.bpDragPreviewOverStage)}
192
+ style={{
193
+ left: preview.x,
194
+ top: preview.y,
195
+ width,
196
+ height,
197
+ transform: `translate(-50%, -50%) rotate(${layout?.rotation ?? 0}deg)`,
198
+ }}>
199
+ <BlueprintThumbnail blueprint={preview.blueprint} sprites={sprites} preview />
200
+ </div>
201
+ );
202
+ }
203
+
204
+ function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
205
+ const canvasRef = useRef(null);
206
+ const spritePath = blueprint.components?.Sprite?.file;
207
+ const sprite = spritePath ? sprites?.[spritePath] : null;
208
+ useEffect(() => {
209
+ const canvas = canvasRef.current;
210
+ if (!canvas || !sprite) return;
211
+ renderSpriteFrame(sprite, 0, canvas);
212
+ }, [sprite]);
213
+ if (!sprite) {
214
+ return (
215
+ <span className={styles.bpSlotFallback}>
216
+ <Icon name="clone" />
217
+ </span>
218
+ );
219
+ }
220
+ return (
221
+ <canvas
222
+ ref={canvasRef}
223
+ className={cx(styles.bpSlotThumb, preview && styles.bpPreviewThumb)}
224
+ aria-hidden="true"
225
+ />
226
+ );
227
+ }
228
+
229
+ function eventToStagePoint(event, canvasRef, editCameraRef) {
230
+ const canvas = canvasRef.current;
231
+ if (!canvas) return null;
232
+ const rect = canvas.getBoundingClientRect();
233
+ if (
234
+ event.clientX < rect.left ||
235
+ event.clientX > rect.right ||
236
+ event.clientY < rect.top ||
237
+ event.clientY > rect.bottom
238
+ ) {
239
+ return null;
240
+ }
241
+ const raw = screenToCard(canvas, event.clientX, event.clientY);
242
+ const camera = editCameraRef.current ?? { x: 0, y: 0 };
243
+ return {
244
+ x: raw.x + camera.x,
245
+ y: raw.y + camera.y,
246
+ };
247
+ }
@@ -21,6 +21,7 @@ export function PlayOnly() {
21
21
  key={dataVersion}
22
22
  sceneData={sceneData}
23
23
  sprites={sprites}
24
+ files={files}
24
25
  behaviorClasses={behaviorClasses}
25
26
  onFirstFrame={Lifecycle.ready}
26
27
  />
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  import { frameCount, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
3
+ import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
3
4
  import { basename } from '../engine/files';
4
5
  import { EditorBody, styles } from '../engine/ui';
5
6
  import { eventToCell } from './pixelCanvas';
@@ -8,6 +9,7 @@ import { PixelArtboard, PixelEditorHeader, usePixelEditorShell } from './pixelEd
8
9
  import {
9
10
  BRUSH_SIZES,
10
11
  CanvasSizeBar,
12
+ CornerRadiusBar,
11
13
  PaintStrip,
12
14
  PIXEL_TOOLS,
13
15
  PixelToolStrip,
@@ -53,6 +55,7 @@ import {
53
55
  setCellOffset,
54
56
  setDefaultDuration,
55
57
  setDefaultTag,
58
+ setCornerRadius,
56
59
  setFrameDuration,
57
60
  setupSpriteCanvas,
58
61
  unlinkCell,
@@ -85,6 +88,7 @@ const TOOL_CURSORS = {
85
88
 
86
89
  export function PxArtEditor({ path, text, onChange, ...chrome }) {
87
90
  const canvasRef = useRef(null);
91
+ const smoothRef = useRef(null);
88
92
  const overlayRef = useRef(null);
89
93
  const strokeRef = useRef(null);
90
94
  // The serialized text this editor last wrote itself. Lets us tell our own
@@ -116,7 +120,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
116
120
  eraseSize,
117
121
  setEraseSize,
118
122
  } = toolState;
119
- const { history, headerShell } = usePixelEditorShell(text, onChange);
123
+ const { history, headerShell } = usePixelEditorShell(text, onChange, path);
120
124
  const canvasWrapRef = useRef(null);
121
125
  const canvasSizeBarRef = useRef(null);
122
126
  const colorButtonRef = useRef(null);
@@ -143,12 +147,20 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
143
147
  eraseSize,
144
148
  activeKey,
145
149
  });
146
- useToolShortcuts({ tool, picking, setTool, setPicking, setBrushSize, setEraseSize });
147
150
  const { canvasStyle } = useArtboardFit({
148
151
  wrapRef: canvasWrapRef,
149
152
  sizeBarRef: canvasSizeBarRef,
150
153
  resolution: sprite?.resolution ?? { width: 16, height: 16 },
151
154
  });
155
+ // `canvasStyle`'s width is the artboard's CURRENT display size (recomputed
156
+ // by useArtboardFit's own ResizeObserver whenever the wrap resizes — e.g. a
157
+ // dockview panel drag). Feeding it in here is what keeps the supersampled
158
+ // canvas's resolution in sync with that display size: without it, this
159
+ // effect has no way to know the wrap resized (nothing about `text`,
160
+ // `frameIndex`, or `cornerRadius` changes on a pure container resize), so
161
+ // it would keep rendering at whatever scale was last measured.
162
+ useSmoothPreviewRender(smoothRef, text, sprite, previewIndex, canvasStyle?.width);
163
+ useToolShortcuts({ tool, picking, setTool, setPicking, setBrushSize, setEraseSize });
152
164
  const isCompactLayout = useEditorCompactLayout(editorBodyRef);
153
165
  useEffect(() => {
154
166
  if (!isCompactLayout) setPaletteOpen(false);
@@ -272,6 +284,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
272
284
  deletePixels: deleteMarqueePixels,
273
285
  };
274
286
  function startTool(event) {
287
+ // Pull keyboard focus into this editor iframe so Cmd+Z reaches our undo
288
+ // handler (bound on this iframe's window) instead of falling through to the
289
+ // browser. Safari in particular won't keep keyboard focus in a subframe
290
+ // after clicking a non-focusable canvas, so we focus it explicitly. Done
291
+ // before the early-return so even a click outside a cell claims focus.
292
+ event.currentTarget.focus({ preventScroll: true });
275
293
  const point = eventToCell(event, width, height);
276
294
  if (!point) return;
277
295
  event.currentTarget.setPointerCapture(event.pointerId);
@@ -433,15 +451,22 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
433
451
  </div>
434
452
  <div ref={canvasWrapRef} className={styles.drawingCanvasWrap}>
435
453
  <div className={styles.artboardStack}>
436
- <div ref={canvasSizeBarRef}>
437
- <CanvasSizeBar
438
- width={width}
439
- height={height}
440
- onResize={(w, h) => history.commit(serializeModel(resizeSprite(sprite, w, h)))}
441
- />
454
+ <div ref={canvasSizeBarRef} className={styles.canvasTopStack}>
455
+ <div className={styles.canvasTopBar}>
456
+ <CanvasSizeBar
457
+ width={width}
458
+ height={height}
459
+ onResize={(w, h) => history.commit(serializeModel(resizeSprite(sprite, w, h)))}
460
+ />
461
+ <CornerRadiusBar
462
+ radius={sprite.cornerRadius}
463
+ onChange={(radius) => history.commit(serializeModel(setCornerRadius(sprite, radius)))}
464
+ />
465
+ </div>
442
466
  </div>
443
467
  <PixelArtboard
444
468
  canvasRef={canvasRef}
469
+ smoothRef={smoothRef}
445
470
  overlayRef={overlayRef}
446
471
  style={{ ...canvasStyle, cursor }}
447
472
  handlers={{
@@ -592,6 +617,34 @@ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing
592
617
  ]);
593
618
  }
594
619
 
620
+ // Render the "smooth" preview into its own display-resolution canvas, behind
621
+ // the interactive artboard (see .drawingSmooth). Left empty when
622
+ // `cornerRadius` is 0 (sharp/pixel) — the interactive canvas's own unrounded
623
+ // frame draw is what shows then. Runs off `text`/`previewIndex` rather than
624
+ // the `sprite` object (a fresh object every render) so it only redraws on an
625
+ // actual content/frame/radius change, not on every hover-driven re-render of
626
+ // the tool preview — PLUS `displayWidth` (the artboard's current CSS width
627
+ // from useArtboardFit), so a wrap resize (e.g. a dockview panel drag) also
628
+ // triggers a re-render at the new supersample scale, not just a content
629
+ // change. The supersample factor auto-fits to the displayed size (capped
630
+ // between 4x and 16x).
631
+ function useSmoothPreviewRender(smoothRef, text, sprite, frameIndex, displayWidth) {
632
+ const cornerRadius = sprite?.cornerRadius ?? 0;
633
+ useEffect(() => {
634
+ const canvas = smoothRef.current;
635
+ if (!canvas) return;
636
+ if (!sprite || cornerRadius <= 0) {
637
+ canvas.width = 0;
638
+ canvas.height = 0;
639
+ return;
640
+ }
641
+ const cssWidth = canvas.clientWidth || sprite.resolution.width;
642
+ const dpr = window.devicePixelRatio || 1;
643
+ const autoScale = Math.min(16, Math.max(4, Math.round((cssWidth * dpr) / sprite.resolution.width)));
644
+ renderSmoothSpriteFrame(sprite, frameIndex, canvas, { scale: autoScale, cornerRadius });
645
+ }, [text, frameIndex, smoothRef, cornerRadius, displayWidth]);
646
+ }
647
+
595
648
  // Window-scoped tool keyboard shortcuts, mounted only while the editor is.
596
649
  function useToolShortcuts(ctx) {
597
650
  const { tool, picking, setTool, setPicking, setBrushSize, setEraseSize } = ctx;
@@ -614,7 +667,13 @@ function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
614
667
  }
615
668
  }
616
669
  ctx.globalAlpha = 1;
617
- ctx.drawImage(frameCanvas(sprite, frameIndex), 0, 0);
670
+ // "Smooth" sprites are drawn by the display-resolution .drawingSmooth canvas
671
+ // BEHIND this one (see useSmoothPreviewRender); leaving this canvas
672
+ // transparent here lets that show through while pointer math, the marquee,
673
+ // and the tool-preview ghost below all keep working in native grid units.
674
+ if (sprite.cornerRadius <= 0) {
675
+ ctx.drawImage(frameCanvas(sprite, frameIndex), 0, 0);
676
+ }
618
677
  if (preview) drawToolPreview(ctx, canvas, preview);
619
678
  }
620
679