castle-web-cli 0.4.76 → 0.4.78

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.
@@ -113,6 +113,45 @@ export function CanvasSizeBar({ width, height, onResize }) {
113
113
  );
114
114
  }
115
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
+
116
155
  function ResolutionSelect({ label, value, onChange }) {
117
156
  const options = RESOLUTION_STEPS.includes(value)
118
157
  ? RESOLUTION_STEPS
@@ -1,4 +1,5 @@
1
1
  import {
2
+ clampCornerRadius,
2
3
  colorForKeyV2,
3
4
  DEFAULT_DURATION_MS,
4
5
  DEFAULT_RESOLUTION,
@@ -77,10 +78,19 @@ function blankSprite() {
77
78
  defaultDurationMs: DEFAULT_DURATION_MS,
78
79
  tags: [],
79
80
  defaultTag: undefined,
81
+ cornerRadius: 0,
80
82
  layers: [makeLayer('layer-0', 'Layer 1', [null])],
81
83
  };
82
84
  }
83
85
 
86
+ // Set the file-level corner-rounding radius (0 = sharp/pixel). A no-op if
87
+ // it's already that value (keeps identical sprites from producing spurious
88
+ // history entries).
89
+ export function setCornerRadius(sprite, radius) {
90
+ const clamped = clampCornerRadius(radius);
91
+ return sprite.cornerRadius === clamped ? sprite : { ...sprite, cornerRadius: clamped };
92
+ }
93
+
84
94
  function makeLayer(id, name, cells) {
85
95
  return { id, name, visible: true, opacity: 1, blendMode: 'normal', kind: 'pixel', cells };
86
96
  }
@@ -635,7 +645,10 @@ function shiftCellOffset(cell, dx, dy) {
635
645
  // later needs no change here: if it survives the round-trip it was already
636
646
  // representable; if not, it correctly forces the full form.
637
647
  export function serializeModel(sprite) {
638
- const compact = serializeCompact(fromCells(EDITOR_PALETTE, cellsAt(sprite, 0, 0)));
648
+ const compact = serializeCompact({
649
+ ...fromCells(EDITOR_PALETTE, cellsAt(sprite, 0, 0)),
650
+ cornerRadius: sprite.cornerRadius,
651
+ });
639
652
  const roundTripped = parseFull(compact);
640
653
  if (roundTripped && losslesslyCompact(sprite, roundTripped)) return compact;
641
654
  return serializeFull(sprite);
@@ -659,6 +672,7 @@ function losslesslyCompact(sprite, roundTripped) {
659
672
  if (frameCount(sprite) !== frameCount(roundTripped)) return false;
660
673
  if (sprite.tags.length !== roundTripped.tags.length) return false;
661
674
  if ((sprite.defaultTag ?? null) !== (roundTripped.defaultTag ?? null)) return false;
675
+ if (sprite.cornerRadius !== roundTripped.cornerRadius) return false;
662
676
  if (sprite.layers.length !== roundTripped.layers.length) return false;
663
677
 
664
678
  const lookupA = paletteLookup(sprite.palette);
@@ -0,0 +1,88 @@
1
+ // Consumer side of the serve's `files_changed` broadcast. The engine decides
2
+ // what a change means for this bundle:
3
+ // - data files (.scene / .pxart / .drawing) are re-read over the serve's
4
+ // files API and folded into React state — live re-render, no reload.
5
+ // - code the bundle imports (per the event's module-graph closure), html,
6
+ // or added/deleted code files (import.meta.glob may pick them up) mean
7
+ // this context is stale — requestReload() (save-state hooks run first).
8
+ import { useEffect, useRef, useState } from 'react';
9
+ import { onFilesChanged, requestReload } from 'castle-web-sdk';
10
+ import { initialFiles } from './files';
11
+
12
+ const DATA_EXTS = ['.scene', '.pxart', '.drawing'];
13
+ const CODE_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
14
+
15
+ export function isDataFile(path) {
16
+ return DATA_EXTS.some((ext) => path.endsWith(ext));
17
+ }
18
+
19
+ function needsContextReload(change) {
20
+ if (isDataFile(change.path)) return false;
21
+ if (change.path.endsWith('.html')) return true;
22
+ // In the module graph (directly or as someone's import) -> our code changed.
23
+ if (change.affected.length > 0) return true;
24
+ // A new/removed code file isn't in the graph yet but a glob may scan it.
25
+ if (change.event !== 'change') return CODE_EXTS.some((ext) => change.path.endsWith(ext));
26
+ return false;
27
+ }
28
+
29
+ async function readDeckFile(path) {
30
+ const res = await fetch(`/__castle/files/read?path=${encodeURIComponent(path)}`);
31
+ if (!res.ok) throw new Error(`read failed: ${res.status}`);
32
+ const data = await res.json();
33
+ return typeof data.contents === 'string' ? data.contents : '';
34
+ }
35
+
36
+ // Deck files as live state: starts from the build-time glob snapshot and stays
37
+ // current as files change on disk from any source (editors, agents, terminal).
38
+ // `shouldSkipPath` lets an editor keep its own in-flight edits from being
39
+ // clobbered by the fs echo of a stale write. `dataVersion` bumps on every
40
+ // applied data change — key a player on it to rebuild from fresh data.
41
+ export function useLiveDeckFiles({ shouldSkipPath } = {}) {
42
+ const [files, setFiles] = useState(initialFiles);
43
+ const [dataVersion, setDataVersion] = useState(0);
44
+ const skipRef = useRef(shouldSkipPath);
45
+ skipRef.current = shouldSkipPath;
46
+
47
+ useEffect(
48
+ () =>
49
+ onFilesChanged((event) => {
50
+ if (event.changes.some(needsContextReload)) {
51
+ requestReload();
52
+ return;
53
+ }
54
+ const dataChanges = event.changes.filter(
55
+ (change) => isDataFile(change.path) && !skipRef.current?.(change.path)
56
+ );
57
+ if (dataChanges.length === 0) return;
58
+ void applyDataChanges(dataChanges, setFiles, setDataVersion);
59
+ }),
60
+ []
61
+ );
62
+
63
+ return { files, setFiles, dataVersion };
64
+ }
65
+
66
+ async function applyDataChanges(changes, setFiles, setDataVersion) {
67
+ const updates = await Promise.all(
68
+ changes.map(async (change) => {
69
+ if (change.event === 'delete') return { path: change.path, text: null };
70
+ try {
71
+ return { path: change.path, text: await readDeckFile(change.path) };
72
+ } catch {
73
+ return null; // read raced a delete / serve hiccup — skip this path
74
+ }
75
+ })
76
+ );
77
+ const applied = updates.filter(Boolean);
78
+ if (applied.length === 0) return;
79
+ setFiles((current) => {
80
+ const next = { ...current };
81
+ for (const { path, text } of applied) {
82
+ if (text === null) delete next[path];
83
+ else next[path] = text;
84
+ }
85
+ return next;
86
+ });
87
+ setDataVersion((version) => version + 1);
88
+ }
@@ -40,6 +40,46 @@ export const COMPACT_FORM = 'compact';
40
40
  export const FULL_FORM = 'full';
41
41
  export const TRANSPARENT = '.';
42
42
 
43
+ // ---------------------------------------------------------------------------
44
+ // cornerRadius (file-level corner-rounding radius, in native-pixel units)
45
+ // ---------------------------------------------------------------------------
46
+ //
47
+ // `cornerRadius` is a plain number: 0 renders sharp/nearest-neighbor (the
48
+ // historical/default look, 1px/cell); anything > 0 is a corner-rounding
49
+ // radius passed straight through to `pxartSmooth.js`'s local corner kernel.
50
+ // A VALUE rather than a "pixel" | "smooth" flag, so the amount of rounding is
51
+ // itself part of the portable file format instead of a fixed, code-side
52
+ // constant every smooth sprite is stuck with.
53
+
54
+ /** Corner cuts on the same 1-native-pixel edge must not overlap, so radii
55
+ * above this are clamped on parse (and by the editor's UI). */
56
+ export const MAX_CORNER_RADIUS = 0.5;
57
+
58
+ export function clampCornerRadius(radius) {
59
+ return Math.min(MAX_CORNER_RADIUS, Math.max(0, radius));
60
+ }
61
+
62
+ // This field used to be called `render`: first a "pixel" | "smooth" string
63
+ // enum, then (briefly) a bare numeric radius under that same key. Both
64
+ // migrate on read so files from either era keep rendering rounded rather
65
+ // than silently reverting to sharp; "smooth" specifically migrates to this
66
+ // fixed value, since the string enum never carried an amount of its own.
67
+ const LEGACY_SMOOTH_RADIUS = 0.25;
68
+
69
+ /** Read the corner radius off a parsed JSON object, preferring the current
70
+ * `cornerRadius` key and falling back to the legacy `render` key (see
71
+ * above). Defaults to 0 (sharp) for anything else (missing field, typo, a
72
+ * future value this parser doesn't know yet). */
73
+ function parseCornerRadius(data) {
74
+ if (typeof data.cornerRadius === 'number' && Number.isFinite(data.cornerRadius)) {
75
+ return clampCornerRadius(data.cornerRadius);
76
+ }
77
+ const legacy = data.render;
78
+ if (typeof legacy === 'number' && Number.isFinite(legacy)) return clampCornerRadius(legacy);
79
+ if (legacy === 'smooth') return LEGACY_SMOOTH_RADIUS;
80
+ return 0;
81
+ }
82
+
43
83
  /** Parse a .pxart string into a compact PxArt record, or null if it isn't the
44
84
  * compact shape. Detects by STRUCTURE (object `palette` + `grid`); the on-disk
45
85
  * `format` discriminator is optional. Tolerant of ragged rows and a `"."`
@@ -61,7 +101,7 @@ export function parseCompact(content) {
61
101
  if (typeof v === 'string') palette[k] = v;
62
102
  else if (v === null) palette[k] = null;
63
103
  }
64
- return { palette, grid: data.grid };
104
+ return { palette, grid: data.grid, cornerRadius: parseCornerRadius(data) };
65
105
  }
66
106
  } catch {
67
107
  /* not valid pxart json */
@@ -70,12 +110,14 @@ export function parseCompact(content) {
70
110
  }
71
111
 
72
112
  /** Serialize a compact PxArt record to a .pxart string. Always stamps
73
- * `format: "compact"`. */
113
+ * `format: "compact"`. `cornerRadius` is omitted when it's 0 (sharp), so
114
+ * existing (pixel) files stay byte-identical. */
74
115
  export function serializeCompact(art) {
75
116
  const out = {
76
117
  format: COMPACT_FORM,
77
118
  palette: art.palette,
78
119
  grid: art.grid,
120
+ ...(art.cornerRadius > 0 ? { cornerRadius: art.cornerRadius } : {}),
79
121
  };
80
122
  return JSON.stringify(out, null, 2) + '\n';
81
123
  }
@@ -548,6 +590,7 @@ export function parseFull(content) {
548
590
  defaultDurationMs,
549
591
  tags,
550
592
  defaultTag,
593
+ cornerRadius: parseCornerRadius(data),
551
594
  layers,
552
595
  };
553
596
  }
@@ -581,6 +624,7 @@ export function upgradeCompactToFull(art) {
581
624
  defaultDurationMs: DEFAULT_DURATION_MS,
582
625
  tags: [],
583
626
  defaultTag: undefined,
627
+ cornerRadius: art.cornerRadius,
584
628
  layers: [
585
629
  {
586
630
  id: 'layer-0',
@@ -641,6 +685,9 @@ export function serializeFull(sprite) {
641
685
  repeat: t.repeat,
642
686
  })),
643
687
  ...(sprite.defaultTag !== undefined ? { defaultTag: sprite.defaultTag } : {}),
688
+ // Omitted when it's 0 (sharp), so pixel-mode files stay byte-identical to
689
+ // their pre-smoothing shape.
690
+ ...(sprite.cornerRadius > 0 ? { cornerRadius: sprite.cornerRadius } : {}),
644
691
  layers: sprite.layers.map((l) => ({
645
692
  id: l.id,
646
693
  name: l.name,
@@ -0,0 +1,222 @@
1
+ // ============================================================================
2
+ // "Smooth" (Animal-Crossing-style) rendering for `.pxart` sprites.
3
+ // ============================================================================
4
+ //
5
+ // Sprites with a file-level `cornerRadius` > 0 (see pxart.js /
6
+ // docs/pxart-format.md) render through `renderSmoothSpriteFrame` instead of
7
+ // the normal 1px/cell `renderSpriteFrame`.
8
+ //
9
+ // This is a LOCAL, per-pixel kernel filter — the same shape of algorithm as
10
+ // Animal Crossing's actual smoothing (xBRZ-style template matching), not the
11
+ // global boundary-trace-and-round approach this file used to implement. That
12
+ // approach traced each region's FULL pixel-boundary loop and could detect
13
+ // long straight/staircase runs across the whole loop; it looked fine on
14
+ // blocky shapes but flattened organic curves it decided were "too regular",
15
+ // which isn't fixable by tuning since the run detection itself is the
16
+ // problem. This version never looks past a pixel's immediate 3x3
17
+ // neighborhood, so there is no run/regularity detection to mis-fire — it
18
+ // physically cannot flatten a curve, because it has no notion of a curve at
19
+ // all, only of each corner in isolation.
20
+ //
21
+ // Approach, per source pixel P:
22
+ // 1. Composite the frame normally (`renderSpriteFrame` — this already
23
+ // handles layer visibility/opacity/blend), then read back the native
24
+ // resolution raster. Regions are exact-RGBA-equality color runs, same as
25
+ // before.
26
+ // 2. Supersample: P gets its own `scale` x `scale` block of the output
27
+ // canvas (plain grid subdivision — every output pixel belongs to
28
+ // exactly one source pixel's block, so there is no possibility of a
29
+ // gap or overlap between neighboring pixels' rendering).
30
+ // 3. Fill P's whole block with its own color, then independently classify
31
+ // each of P's 4 corners against ONLY the 3 pixels touching that corner
32
+ // (2 edge-adjacent neighbors + 1 diagonal) and, for genuine convex
33
+ // corners, paint a `cornerRadius`-sized quarter-circle "cut" over that
34
+ // corner revealing the relevant neighbor's color (see `cornerFill`).
35
+ // Because every pixel only ever paints within its OWN block, this recoloring
36
+ // can never create a gap: it's the same guarantee a supersampled nearest-
37
+ // neighbor render already has, just with a softened corner instead of a hard
38
+ // one.
39
+ //
40
+ // Kept as a sibling of pxart.js (rather than inside it) so the format
41
+ // parser/serializer stays focused on the on-disk shape.
42
+ // ============================================================================
43
+
44
+ import { MAX_CORNER_RADIUS, renderSpriteFrame } from './pxart';
45
+
46
+ export const DEFAULT_SMOOTH_SCALE = 8;
47
+
48
+ // Fallback only for a caller that omits `cornerRadius` entirely; every real
49
+ // caller passes the sprite's own file-level `cornerRadius` value (see
50
+ // docs/pxart-format.md and Sprite.jsx/PxArtEditor.jsx), which is how the
51
+ // corner radius ends up being a portable, per-sprite part of the format
52
+ // rather than a fixed constant every smooth sprite is stuck with.
53
+ const FALLBACK_CORNER_RADIUS = 0.25;
54
+
55
+ /** Render one frame of a Sprite with locally corner-rounded fills,
56
+ * supersampled into `canvas` (sized to width*scale x height*scale). Reuses
57
+ * `renderSpriteFrame` for compositing (layers, opacity, visibility, blend),
58
+ * so those keep working unchanged. `cornerRadius` is in native-pixel units,
59
+ * clamped to `MAX_CORNER_RADIUS` (two cuts on the same edge must not
60
+ * overlap). */
61
+ export function renderSmoothSpriteFrame(
62
+ sprite,
63
+ frameIndex,
64
+ canvas,
65
+ { scale = DEFAULT_SMOOTH_SCALE, cornerRadius = FALLBACK_CORNER_RADIUS } = {}
66
+ ) {
67
+ const clampedRadius = Math.min(MAX_CORNER_RADIUS, Math.max(0, cornerRadius));
68
+ const { width, height } = sprite.resolution;
69
+ canvas.width = Math.max(1, Math.round(width * scale));
70
+ canvas.height = Math.max(1, Math.round(height * scale));
71
+ const ctx = canvas.getContext('2d');
72
+ if (!ctx) return;
73
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
74
+ if (width <= 0 || height <= 0) return;
75
+
76
+ const native = document.createElement('canvas');
77
+ renderSpriteFrame(sprite, frameIndex, native);
78
+ const nctx = native.getContext('2d');
79
+ const data = nctx?.getImageData(0, 0, width, height).data;
80
+ if (!data) return;
81
+
82
+ // null = out of canvas OR fully transparent; both read as "not this pixel's
83
+ // color" to every classification below, same as the old mask's treatment
84
+ // of out-of-canvas/transparent as background.
85
+ const colorAt = (x, y) => {
86
+ if (x < 0 || x >= width || y < 0 || y >= height) return null;
87
+ const i = (y * width + x) * 4;
88
+ const a = data[i + 3];
89
+ return a === 0 ? null : `${data[i]},${data[i + 1]},${data[i + 2]},${a}`;
90
+ };
91
+
92
+ ctx.save();
93
+ ctx.scale(scale, scale);
94
+ // Every pixel is processed, including transparent ones: a fully-enclosed
95
+ // transparent "hole" is, from its own corners' point of view, exactly the
96
+ // same kind of convex corner as an opaque pixel poking out of a
97
+ // background — it needs to cut (and round) its OWN corners the same way,
98
+ // or an enclosed 1px hole would stay a hard square forever (its opaque
99
+ // neighbors never round toward it, by the same-neighbor rule below).
100
+ for (let y = 0; y < height; y++) {
101
+ for (let x = 0; x < width; x++) drawPixelBlock(ctx, colorAt, x, y, colorAt(x, y), clampedRadius);
102
+ }
103
+ ctx.restore();
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // per-pixel local corner kernel
108
+ // ---------------------------------------------------------------------------
109
+
110
+ const NO_CUT = undefined;
111
+
112
+ // Fill pixel (x, y)'s own unit square [x, x+1] x [y, y+1] with `color` (a
113
+ // no-op when `color` is null — transparent, nothing to fill), then paint a
114
+ // small rounded cut over each of its 4 corners that qualifies (see
115
+ // `cornerFill`). Uses ONLY the 3x3 neighborhood of (x, y) — genuinely local,
116
+ // unlike the old global loop trace.
117
+ function drawPixelBlock(ctx, colorAt, x, y, color, cornerRadius) {
118
+ const north = colorAt(x, y - 1);
119
+ const south = colorAt(x, y + 1);
120
+ const west = colorAt(x - 1, y);
121
+ const east = colorAt(x + 1, y);
122
+
123
+ const cuts = [
124
+ ['TL', cornerFill(color, west, north, colorAt(x - 1, y - 1))],
125
+ ['TR', cornerFill(color, east, north, colorAt(x + 1, y - 1))],
126
+ ['BL', cornerFill(color, west, south, colorAt(x - 1, y + 1))],
127
+ ['BR', cornerFill(color, east, south, colorAt(x + 1, y + 1))],
128
+ ];
129
+
130
+ if (color) {
131
+ ctx.fillStyle = rgbaFillStyle(color);
132
+ ctx.fillRect(x, y, 1, 1);
133
+ }
134
+
135
+ if (cornerRadius <= 0) return;
136
+ for (const [corner, reveal] of cuts) {
137
+ if (reveal === NO_CUT) continue;
138
+ ctx.beginPath();
139
+ tracePixelWedge(ctx, corner, x, y, cornerRadius);
140
+ if (reveal === null) {
141
+ // Revealing transparency: clip to the wedge and clear it, rather than
142
+ // fill it, since there's no color to paint.
143
+ ctx.save();
144
+ ctx.clip();
145
+ ctx.clearRect(x, y, 1, 1);
146
+ ctx.restore();
147
+ } else {
148
+ ctx.fillStyle = rgbaFillStyle(reveal);
149
+ ctx.fill();
150
+ }
151
+ }
152
+ }
153
+
154
+ // Classify one corner of pixel `color`, given its two edge-adjacent
155
+ // neighbors (`a`, `b`) and its diagonal neighbor (`g`). Returns the neighbor
156
+ // color to reveal at that corner's rounded cut, or `NO_CUT` to leave the
157
+ // corner sharp:
158
+ // - `a` or `b` matches `color`: either a flat/interior corner, or (when
159
+ // they don't BOTH match) a straight edge passing by rather than a real
160
+ // corner. Either way, nothing to round from P's side — a matching
161
+ // neighbor's OWN corner classification (evaluated independently, when
162
+ // IT is P) is what rounds the opposite case; the physical wedge that
163
+ // gets cut always lives entirely inside whichever pixel's corner is
164
+ // convex, so there's no double-handling.
165
+ // - neither `a` nor `b` matches, but `g` DOES: a diagonal touch between
166
+ // two same-colored pixels (the "checkerboard" case). Left uncut, so a
167
+ // smoothed diagonal stroke doesn't get visually pinched off at every
168
+ // step.
169
+ // - neither `a`, `b`, nor `g` matches, and `a` and `b` are THE SAME color:
170
+ // a genuine, unambiguous convex corner of P's own region touching one
171
+ // other region. Cut it, revealing that color.
172
+ // - neither `a`, `b`, nor `g` matches, and `a` and `b` DIFFER: three (or
173
+ // four, counting `g`) distinct colors meet at this exact point — e.g. a
174
+ // "T" where one region's straight edge is crossed by the boundary
175
+ // between two others. Left uncut. Rounding here would have to guess
176
+ // which of `a`/`b` "wins", and since the pixel on the OTHER side of
177
+ // that guess is classifying this same point independently — and would
178
+ // guess differently — a pair of pixels each revealing the OTHER's
179
+ // color produces a little criss-crossed notch instead of one clean
180
+ // curve. Leaving every pixel at a 3+-way point sharp keeps it a single
181
+ // consistent (if unrounded) vertex.
182
+ function cornerFill(color, a, b, g) {
183
+ if (a === color || b === color) return NO_CUT;
184
+ if (g === color) return NO_CUT;
185
+ return a === b ? a : NO_CUT;
186
+ }
187
+
188
+ // `color` is always a `"r,g,b,a"` string (0-255 channels, alpha 0-255) —
189
+ // see `colorAt` — never the transparent sentinel (only opaque colors ever
190
+ // reach this function as `color`, only ever as `reveal`).
191
+ function rgbaFillStyle(color) {
192
+ const [r, g, b, a] = color.split(',').map(Number);
193
+ return `rgba(${r}, ${g}, ${b}, ${a / 255})`;
194
+ }
195
+
196
+ // Trace the small corner wedge that gets cut from pixel (x, y)'s unit square
197
+ // at `corner` (one of 'TL' | 'TR' | 'BL' | 'BR'): the sliver between the
198
+ // exact corner point and the quarter-circle of radius `r` tangent to both
199
+ // adjacent edges at distance `r` from the corner — i.e. exactly the piece a
200
+ // standard rounded-rect corner removes from a sharp one. `ctx.arcTo`'s
201
+ // corner-point-as-control-point form draws that same tangent arc without
202
+ // hand-computed sweep angles.
203
+ function tracePixelWedge(ctx, corner, x, y, r) {
204
+ if (corner === 'TL') {
205
+ ctx.moveTo(x, y);
206
+ ctx.lineTo(x + r, y);
207
+ ctx.arcTo(x, y, x, y + r, r);
208
+ } else if (corner === 'TR') {
209
+ ctx.moveTo(x + 1, y);
210
+ ctx.lineTo(x + 1, y + r);
211
+ ctx.arcTo(x + 1, y, x + 1 - r, y, r);
212
+ } else if (corner === 'BL') {
213
+ ctx.moveTo(x, y + 1);
214
+ ctx.lineTo(x, y + 1 - r);
215
+ ctx.arcTo(x, y + 1, x + r, y + 1, r);
216
+ } else {
217
+ ctx.moveTo(x + 1, y + 1);
218
+ ctx.lineTo(x + 1 - r, y + 1);
219
+ ctx.arcTo(x + 1, y + 1, x + 1, y + 1 - r, r);
220
+ }
221
+ ctx.closePath();
222
+ }
@@ -487,6 +487,9 @@
487
487
  height: 100%;
488
488
  display: block;
489
489
  touch-action: none;
490
+ /* tabIndex=-1 (keyboard-focus capture on pointer-down) would otherwise draw a
491
+ focus ring; focus here is programmatic only, never via keyboard. */
492
+ outline: none;
490
493
  }
491
494
 
492
495
  .stageCanvasEdit {
@@ -1112,6 +1115,18 @@
1112
1115
  max-height: 100%;
1113
1116
  }
1114
1117
 
1118
+ .canvasTopStack {
1119
+ display: flex;
1120
+ flex-direction: column;
1121
+ gap: 6px;
1122
+ }
1123
+
1124
+ .canvasTopBar {
1125
+ display: flex;
1126
+ align-items: center;
1127
+ gap: 16px;
1128
+ }
1129
+
1115
1130
  .canvasSizeBar {
1116
1131
  display: flex;
1117
1132
  align-items: center;
@@ -1121,6 +1136,54 @@
1121
1136
  color: var(--castle-inspector-muted);
1122
1137
  }
1123
1138
 
1139
+ .cornerRadiusBar {
1140
+ display: flex;
1141
+ align-items: center;
1142
+ gap: 6px;
1143
+ flex: 0 0 auto;
1144
+ font-size: 12px;
1145
+ color: var(--castle-inspector-muted);
1146
+ }
1147
+
1148
+ /* Corner-radius segmented control (0 / ¼ / ½) — same bordered-row-of-
1149
+ buttons pattern as .drawingShapeModeRow, sized for short text labels
1150
+ instead of square glyph buttons. */
1151
+ .cornerRadiusSegments {
1152
+ display: flex;
1153
+ border: 1px solid var(--castle-inspector-border);
1154
+ border-radius: var(--castle-radius);
1155
+ overflow: hidden;
1156
+ }
1157
+
1158
+ .cornerRadiusSegments > * + * {
1159
+ border-left: 1px solid var(--castle-inspector-border);
1160
+ }
1161
+
1162
+ .cornerRadiusSegment {
1163
+ min-width: 28px;
1164
+ padding: 3px 8px;
1165
+ border: 0;
1166
+ background: var(--castle-inspector-button-bg);
1167
+ color: var(--castle-inspector-text);
1168
+ cursor: pointer;
1169
+ font-size: 12px;
1170
+ font-variant-numeric: tabular-nums;
1171
+ line-height: 1.35;
1172
+ }
1173
+
1174
+ .cornerRadiusSegment:hover {
1175
+ background: var(--castle-inspector-input-bg);
1176
+ }
1177
+
1178
+ /* Compound selector (not a bare `.cornerRadiusSegmentOn`) for the same reason
1179
+ documented above `.drawingToolButton.drawingToolSelected`: it must beat
1180
+ `.cornerRadiusSegment:hover` on specificity no matter source order. */
1181
+ .cornerRadiusSegment.cornerRadiusSegmentOn,
1182
+ .cornerRadiusSegment.cornerRadiusSegmentOn:hover {
1183
+ background: var(--castle-selected);
1184
+ color: var(--castle-selected-ink);
1185
+ }
1186
+
1124
1187
  .canvasSizeSep {
1125
1188
  color: var(--castle-inspector-muted);
1126
1189
  user-select: none;
@@ -1306,23 +1369,20 @@
1306
1369
  justify-content: center;
1307
1370
  }
1308
1371
 
1309
- /* Stacks the native artboard canvas and the display-resolution overlay so the
1310
- marching-ants outline renders crisp instead of sub-pixel on the upscaled art. */
1372
+ /* Stacks the native artboard canvas, the smooth-preview canvas, and the
1373
+ display-resolution overlay so the marching-ants outline renders crisp
1374
+ instead of sub-pixel on the upscaled art. The checkerboard "transparency"
1375
+ background lives on the FRAME (not the main canvas) so it doesn't paint
1376
+ over the smooth-preview canvas underneath in "smooth" render mode. */
1311
1377
  .drawingArtboardFrame {
1312
1378
  position: relative;
1313
1379
  flex: 0 0 auto;
1314
- }
1315
-
1316
- .drawingOverlay {
1317
- position: absolute;
1318
- inset: 0;
1319
- width: 100%;
1320
- height: 100%;
1321
- pointer-events: none;
1322
- }
1323
-
1324
- .drawingCanvas {
1325
- image-rendering: pixelated;
1380
+ /* Own stacking context, so the negative-z .drawingSmooth canvas paints just
1381
+ above THIS element's checkerboard background instead of sinking below it
1382
+ (without isolation, negative z-index children paint before all in-flow
1383
+ descendant backgrounds of the outer stacking context — checkerboard
1384
+ included — hiding the smooth render entirely). */
1385
+ isolation: isolate;
1326
1386
  background:
1327
1387
  linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
1328
1388
  linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
@@ -1336,7 +1396,41 @@
1336
1396
  8px -8px,
1337
1397
  -8px 0;
1338
1398
  border: 1px solid var(--castle-inspector-border);
1399
+ }
1400
+
1401
+ .drawingOverlay {
1402
+ position: absolute;
1403
+ inset: 0;
1404
+ width: 100%;
1405
+ height: 100%;
1406
+ pointer-events: none;
1407
+ }
1408
+
1409
+ /* Behind the main canvas (negative z-index paints before static content in
1410
+ the same containing block) — holds the corner-rounded render for "smooth"
1411
+ sprites while the main canvas above it stays transparent except for tool
1412
+ overlays, so editing interactions are unaffected. Empty/0-sized in "pixel"
1413
+ mode. */
1414
+ .drawingSmooth {
1415
+ position: absolute;
1416
+ inset: 0;
1417
+ width: 100%;
1418
+ height: 100%;
1419
+ pointer-events: none;
1420
+ z-index: -1;
1421
+ }
1422
+
1423
+ .drawingCanvas {
1424
+ image-rendering: pixelated;
1425
+ position: absolute;
1426
+ inset: 0;
1427
+ width: 100%;
1428
+ height: 100%;
1339
1429
  flex: 0 0 auto;
1430
+ /* tabIndex=-1 (for keyboard-focus capture on pointer-down) would otherwise
1431
+ draw a focus ring around the artboard; it's focused programmatically, never
1432
+ via keyboard, so suppressing the outline is safe. */
1433
+ outline: none;
1340
1434
  }
1341
1435
 
1342
1436
  .palette {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.76",
3
+ "version": "0.4.78",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"
@@ -1,3 +0,0 @@
1
- allowBuilds:
2
- '@fortawesome/fontawesome-common-types': set this to true or false
3
- '@fortawesome/free-solid-svg-icons': set this to true or false