castle-web-cli 0.4.166 → 0.4.168

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 (112) hide show
  1. package/dist/agent.d.ts +1 -2
  2. package/dist/agent.js +22 -13
  3. package/dist/api.js +16 -1
  4. package/dist/castle-host/host.d.ts +2 -0
  5. package/dist/castle-host/host.js +17 -3
  6. package/dist/imports.js +19 -4
  7. package/dist/localPaths.d.ts +2 -0
  8. package/dist/localPaths.js +6 -0
  9. package/dist/platformDoc.js +1 -1
  10. package/dist/save-deck.js +13 -1
  11. package/dist/serve.js +7 -1
  12. package/dist/serveSecurity.d.ts +12 -0
  13. package/dist/serveSecurity.js +42 -0
  14. package/dist/shell/assets/index-CVHj503j.css +1 -0
  15. package/dist/shell/assets/index-D-qJ5VGp.js +425 -0
  16. package/dist/shell/index.html +2 -2
  17. package/kits/base/castle.json +64 -2
  18. package/kits/base/editors/ImageViewer.js +259 -0
  19. package/kits/base/editors/MediaPlayer.js +90 -0
  20. package/kits/base/editors/imageViewer.css +95 -0
  21. package/kits/base/editors/mediaPlayer.css +58 -0
  22. package/kits/base/sdk/README.md +3 -1
  23. package/kits/base/sdk/castle.d.ts +1 -0
  24. package/kits/base/sdk/commands.d.ts +7 -0
  25. package/kits/base/sdk/multiplayer.d.ts +6 -0
  26. package/kits/base/sdk/multiplayer.js +6 -0
  27. package/kits/blank/.castle/agent/children.json +1 -0
  28. package/kits/blank/.castle/agent/messages.json +9 -0
  29. package/kits/blank/.castle/agent/pending-sends.json +1 -0
  30. package/kits/blank/.castle/agent/tasks/index.md +2 -0
  31. package/kits/blank/.castle/docs/platform.md +36 -0
  32. package/kits/blank/node_modules/.vite/deps/_metadata.json +8 -0
  33. package/kits/blank/node_modules/.vite/deps/package.json +3 -0
  34. package/kits/multiplayer-2d/CLAUDE.md +6 -0
  35. package/kits/multiplayer-2d/castle.json +3 -3
  36. package/kits/multiplayer-2d/code/systems/multiplayer.js +5 -0
  37. package/kits/multiplayer-2d/package-lock.json +4 -1
  38. package/kits/multiplayer-3d/CLAUDE.md +6 -1
  39. package/kits/multiplayer-3d/castle.json +3 -3
  40. package/kits/multiplayer-3d/code/systems/multiplayer.js +5 -0
  41. package/kits/multiplayer-3d/package-lock.json +1 -1
  42. package/kits/physics-2d/behaviors/Joints.jsx +11 -5
  43. package/kits/physics-2d/behaviors/Sprite.jsx +6 -21
  44. package/kits/physics-2d/castle.json +1 -61
  45. package/kits/physics-2d/editors/BlueprintLibrary.jsx +8 -68
  46. package/kits/physics-2d/editors/SingleEditor.jsx +3 -10
  47. package/kits/physics-2d/editors/editorRegistry.jsx +36 -2
  48. package/kits/physics-2d/engine/art.js +25 -1
  49. package/kits/physics-2d/engine/artThumbnail.jsx +92 -0
  50. package/kits/physics-2d/engine/files.js +4 -15
  51. package/kits/physics-2d/engine/spriteField.jsx +176 -0
  52. package/kits/physics-2d/engine/ui.module.css +136 -0
  53. package/kits/physics-2d/package-lock.json +5 -0
  54. package/kits/physics-2d/pnpm-lock.yaml +96 -3
  55. package/kits/physics-3d/castle.json +1 -1
  56. package/kits/physics-3d/package-lock.json +1 -1
  57. package/kits/physics-3d/pnpm-lock.yaml +88 -3
  58. package/kits/real-time/CLAUDE.md +5 -0
  59. package/kits/real-time/castle.json +2 -2
  60. package/kits/real-time/code/client/connection.js +4 -0
  61. package/kits/real-time/code/client/gameHooks.js +3 -0
  62. package/kits/real-time/code/client/soloBadge.js +71 -0
  63. package/kits/real-time/package.json +3 -0
  64. package/kits/turn-based/castle.json +1 -1
  65. package/kits/turn-based/package-lock.json +1 -1
  66. package/package.json +1 -1
  67. package/dist/shell/assets/index-CG9TrvCm.js +0 -444
  68. package/dist/shell/assets/index-CIRBt1qf.css +0 -1
  69. package/kits/base/node_modules/.package-lock.json +0 -28
  70. package/kits/base/node_modules/castle-web-fonts/README.md +0 -114
  71. package/kits/base/node_modules/castle-web-fonts/dist/catalog.d.ts +0 -18
  72. package/kits/base/node_modules/castle-web-fonts/dist/catalog.js +0 -142
  73. package/kits/base/node_modules/castle-web-fonts/dist/face.d.ts +0 -1
  74. package/kits/base/node_modules/castle-web-fonts/dist/face.js +0 -52
  75. package/kits/base/node_modules/castle-web-fonts/dist/faces/Bore.d.ts +0 -3
  76. package/kits/base/node_modules/castle-web-fonts/dist/faces/Bore.js +0 -10
  77. package/kits/base/node_modules/castle-web-fonts/dist/faces/CourierPrime.d.ts +0 -3
  78. package/kits/base/node_modules/castle-web-fonts/dist/faces/CourierPrime.js +0 -10
  79. package/kits/base/node_modules/castle-web-fonts/dist/faces/DMSans.d.ts +0 -3
  80. package/kits/base/node_modules/castle-web-fonts/dist/faces/DMSans.js +0 -10
  81. package/kits/base/node_modules/castle-web-fonts/dist/faces/Glacier.d.ts +0 -3
  82. package/kits/base/node_modules/castle-web-fonts/dist/faces/Glacier.js +0 -10
  83. package/kits/base/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.d.ts +0 -3
  84. package/kits/base/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.js +0 -10
  85. package/kits/base/node_modules/castle-web-fonts/dist/faces/Piazzolla.d.ts +0 -3
  86. package/kits/base/node_modules/castle-web-fonts/dist/faces/Piazzolla.js +0 -10
  87. package/kits/base/node_modules/castle-web-fonts/dist/faces/Synco.d.ts +0 -3
  88. package/kits/base/node_modules/castle-web-fonts/dist/faces/Synco.js +0 -10
  89. package/kits/base/node_modules/castle-web-fonts/dist/faces/Tektur.d.ts +0 -3
  90. package/kits/base/node_modules/castle-web-fonts/dist/faces/Tektur.js +0 -10
  91. package/kits/base/node_modules/castle-web-fonts/dist/faces/YatraOne.d.ts +0 -3
  92. package/kits/base/node_modules/castle-web-fonts/dist/faces/YatraOne.js +0 -10
  93. package/kits/base/node_modules/castle-web-fonts/dist/index.d.ts +0 -16
  94. package/kits/base/node_modules/castle-web-fonts/dist/index.js +0 -15
  95. package/kits/base/node_modules/castle-web-fonts/licenses/CC-BY-ND-4.0.txt +0 -393
  96. package/kits/base/node_modules/castle-web-fonts/licenses/FONTS.md +0 -64
  97. package/kits/base/node_modules/castle-web-fonts/licenses/OFL-1.1.txt +0 -95
  98. package/kits/base/node_modules/castle-web-fonts/package.json +0 -47
  99. package/kits/base/node_modules/castle-web-fonts/woff2/Bore.woff2 +0 -0
  100. package/kits/base/node_modules/castle-web-fonts/woff2/CourierPrime.woff2 +0 -0
  101. package/kits/base/node_modules/castle-web-fonts/woff2/DMSans.woff2 +0 -0
  102. package/kits/base/node_modules/castle-web-fonts/woff2/Glacier.woff2 +0 -0
  103. package/kits/base/node_modules/castle-web-fonts/woff2/HelicoCentrica.woff2 +0 -0
  104. package/kits/base/node_modules/castle-web-fonts/woff2/Piazzolla.woff2 +0 -0
  105. package/kits/base/node_modules/castle-web-fonts/woff2/SOURCE.txt +0 -2
  106. package/kits/base/node_modules/castle-web-fonts/woff2/Synco.woff2 +0 -0
  107. package/kits/base/node_modules/castle-web-fonts/woff2/Tektur.woff2 +0 -0
  108. package/kits/base/node_modules/castle-web-fonts/woff2/YatraOne.woff2 +0 -0
  109. package/kits/physics-2d/editors/ImageViewer.jsx +0 -206
  110. package/kits/physics-2d/editors/MediaPlayer.jsx +0 -57
  111. package/kits/physics-2d/editors/mediaFile.js +0 -31
  112. package/kits/physics-2d/editors/mediaViewer.module.css +0 -128
@@ -11,7 +11,8 @@
11
11
  // really do differ -- rendering a frame, and how many frames there are -- are
12
12
  // `renderArtFrame` / `artFrameCount` here.
13
13
 
14
- import { frameCount, renderSpriteFrame } from './pxart';
14
+ import { displayFinish, frameCount, renderSpriteFrame } from './pxart';
15
+ import { renderSmoothCompositeFrame, renderSmoothSpriteFrame } from './pxartSmooth';
15
16
 
16
17
  // An image is one frame, held for as long as anyone asks (a still).
17
18
  const STILL_FRAME = Object.freeze({ durationMs: 0 });
@@ -89,6 +90,10 @@ export function artFrameCount(art) {
89
90
  // Draw one frame into `canvas` at the art's native resolution, resizing it to
90
91
  // match. Returns false when there was nothing to draw yet (an image still
91
92
  // loading), so callers don't cache the blank result.
93
+ //
94
+ // Pixel finish only — 1px/cell. Prefer `renderDisplayFrame` anywhere the art
95
+ // is shown to a person (belt, picker, scene), so Smooth / vector sprites keep
96
+ // their finish instead of looking blocky.
92
97
  export function renderArtFrame(art, frameIndex, canvas) {
93
98
  if (!isImageArt(art)) {
94
99
  renderSpriteFrame(art, frameIndex, canvas);
@@ -103,3 +108,22 @@ export function renderArtFrame(art, frameIndex, canvas) {
103
108
  ctx.drawImage(art.image, 0, 0);
104
109
  return true;
105
110
  }
111
+
112
+ // Same contract as `renderArtFrame`, but honours the sprite's display finish
113
+ // (vector / rounded corners → supersampled smooth render). Use this for any
114
+ // on-screen thumbnail or blit that should match what Sprite draws in the scene.
115
+ export function renderDisplayFrame(art, frameIndex, canvas) {
116
+ if (isImageArt(art)) return renderArtFrame(art, frameIndex, canvas);
117
+ if (!art) return false;
118
+ const finish = displayFinish(art);
119
+ if (finish.vector) {
120
+ renderSmoothCompositeFrame(art, frameIndex, canvas);
121
+ return true;
122
+ }
123
+ if (finish.cornerRadius > 0) {
124
+ renderSmoothSpriteFrame(art, frameIndex, canvas, { cornerRadius: finish.cornerRadius });
125
+ return true;
126
+ }
127
+ renderSpriteFrame(art, frameIndex, canvas);
128
+ return true;
129
+ }
@@ -0,0 +1,92 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { isImageArt, onArtLoaded, renderDisplayFrame } from './art';
3
+ import { displayFinish } from './pxart';
4
+ import { cx, Icon, styles } from './ui';
5
+
6
+ // Asked of the CANVAS, not the source art: counting non-transparent grid chars
7
+ // would call a path-drawn sprite blank. Skipped for image art, whose native
8
+ // resolution is whatever was uploaded.
9
+ export function artRendersNothing(art, canvas) {
10
+ if (isImageArt(art)) return false;
11
+ try {
12
+ // Must match the options the render path already used to create the
13
+ // context. Asking for `{ willReadFrequently: true }` after a plain
14
+ // `getContext('2d')` returns null in browsers, and the catch below would
15
+ // then treat every transparent sprite as "has art" — blank names never
16
+ // appear in the belt or the picker.
17
+ const ctx = canvas.getContext('2d');
18
+ if (!ctx || canvas.width < 1 || canvas.height < 1) return true;
19
+ const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
20
+ for (let i = 3; i < data.length; i += 4) if (data[i] !== 0) return false;
21
+ return true;
22
+ } catch {
23
+ // Tainted canvas: assume it draws something rather than mislabeling real
24
+ // art as blank.
25
+ return false;
26
+ }
27
+ }
28
+
29
+ // Shared art thumb used by the blueprint belt and the sprite picker. Falls
30
+ // back to `name` when there is no art, or the art is entirely transparent
31
+ // (a "Draw new" sprite nobody has drawn in yet) — same treatment the belt
32
+ // has always given undrawn blueprints.
33
+ //
34
+ // `className` sizes the outer slot (belt defaults to 36px via CSS; the picker
35
+ // stretches it to fill its tile). `fallbackIcon` is what shows when there is
36
+ // neither art nor a name (`clone` on the belt, `image` in the picker).
37
+ export function ArtThumbnail({
38
+ art,
39
+ name = '',
40
+ className,
41
+ preview = false,
42
+ fallbackIcon = 'image',
43
+ }) {
44
+ const canvasRef = useRef(null);
45
+ const [loadTick, setLoadTick] = useState(0);
46
+ const [blank, setBlank] = useState(false);
47
+ useEffect(() => onArtLoaded(() => setLoadTick((tick) => tick + 1)), []);
48
+ useEffect(() => {
49
+ const canvas = canvasRef.current;
50
+ if (!canvas || !art) return;
51
+ const drew = renderDisplayFrame(art, 0, canvas);
52
+ // `false` means "not here yet" (an image still loading), not blank.
53
+ if (drew !== false) setBlank(artRendersNothing(art, canvas));
54
+ }, [art, loadTick]);
55
+
56
+ // The inner span is load-bearing, not decoration: bare text inside a flex
57
+ // container becomes an anonymous item sized to max-content, so it never
58
+ // "overflows a line" and `overflow-wrap` never fires -- a long single-word
59
+ // name silently clipped at both edges instead of wrapping. A real child can
60
+ // be held to the box's width, which is what gives the wrap something to
61
+ // break against.
62
+ const label = name ? (
63
+ <span className={cx(styles.bpSlotName, preview && styles.bpSlotNamePreview)} title={name}>
64
+ <span className={styles.bpSlotNameText}>{name}</span>
65
+ </span>
66
+ ) : (
67
+ <span className={styles.bpSlotFallback}>
68
+ <Icon name={fallbackIcon} />
69
+ </span>
70
+ );
71
+
72
+ if (!art) {
73
+ return className ? <span className={className}>{label}</span> : label;
74
+ }
75
+
76
+ const smooth = displayFinish(art).smooth;
77
+ return (
78
+ <span className={cx(styles.bpSlotArt, className)}>
79
+ <canvas
80
+ ref={canvasRef}
81
+ className={cx(
82
+ styles.bpSlotThumb,
83
+ smooth && styles.bpSlotThumbSmooth,
84
+ preview && styles.bpPreviewThumb,
85
+ blank && styles.bpSlotThumbHidden
86
+ )}
87
+ aria-hidden="true"
88
+ />
89
+ {blank ? label : null}
90
+ </span>
91
+ );
92
+ }
@@ -117,26 +117,15 @@ export function mediaFilesOfKind(kind) {
117
117
  const matches = { image: isImageFile, audio: isAudioFile, video: isVideoFile }[kind];
118
118
  return Object.keys(initialMedia).filter((path) => matches(path));
119
119
  }
120
- // Which editor a file opens in. The image / audio kinds are VIEWERS -- an
121
- // uploaded asset is content this kit shows but doesn't edit (art you can edit is
122
- // `.pxart`). Each extension gets its own line: the serve parses this function to
123
- // learn which extensions the kit owns an editor for, and only matches one
124
- // `endsWith` per `return`.
120
+ // Which editor a file opens in when no `editorModule` was resolved. Media
121
+ // viewing (image / audio / video) lives on castle.base now; this only covers
122
+ // types this kit still routes itself (scene / sprite / style). Each extension
123
+ // gets its own line: legacy tooling matched one `endsWith` per `return`.
125
124
  export function getFileKind(path) {
126
125
  if (path.endsWith('.scene')) return 'scene';
127
126
  if (path.endsWith('.sprite')) return 'pxart';
128
127
  if (path.endsWith('.pxart')) return 'pxart';
129
128
  if (path.endsWith('.style')) return 'style';
130
- if (path.endsWith('.png')) return 'image';
131
- if (path.endsWith('.jpg')) return 'image';
132
- if (path.endsWith('.jpeg')) return 'image';
133
- if (path.endsWith('.gif')) return 'image';
134
- if (path.endsWith('.webp')) return 'image';
135
- if (path.endsWith('.svg')) return 'image';
136
- if (path.endsWith('.mp3')) return 'audio';
137
- if (path.endsWith('.wav')) return 'audio';
138
- if (path.endsWith('.m4a')) return 'audio';
139
- if (path.endsWith('.mp4')) return 'video';
140
129
  if (path.endsWith('.js') || path.endsWith('.jsx')) return 'code';
141
130
  return 'text';
142
131
  }
@@ -0,0 +1,176 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { ArtThumbnail } from './artThumbnail';
4
+ import { cx, FieldRow, Icon, styles } from './ui';
5
+
6
+ // Visual sprite / image picker for inspector fields that point at art files.
7
+ // Anchored popover (same family as FileField), not a modal: thumbnails only,
8
+ // name-without-extension on hover, pick closes. Blank art reuses ArtThumbnail's
9
+ // belt fallback (title in place of the empty canvas).
10
+
11
+ function basenameOf(path) {
12
+ if (!path) return '';
13
+ return path.slice(path.lastIndexOf('/') + 1);
14
+ }
15
+
16
+ function displayNameOf(path) {
17
+ const base = basenameOf(path);
18
+ if (!base) return 'None';
19
+ return base.replace(/\.[^.]+$/, '') || base;
20
+ }
21
+
22
+ function candidatePaths(files, sprites, current, allowEmpty) {
23
+ const paths = [];
24
+ if (allowEmpty) paths.push('');
25
+ const list = files ?? Object.keys(sprites ?? {}).sort();
26
+ for (const path of list) {
27
+ if (path && !paths.includes(path)) paths.push(path);
28
+ }
29
+ if (current && !paths.includes(current)) {
30
+ paths.splice(allowEmpty ? 1 : 0, 0, current);
31
+ }
32
+ return paths;
33
+ }
34
+
35
+ function placePopover(anchor, popover) {
36
+ const margin = 8;
37
+ const gap = 4;
38
+ const rect = anchor.getBoundingClientRect();
39
+ const box = popover.getBoundingClientRect();
40
+ const width = Math.min(Math.max(rect.width, 220), window.innerWidth - margin * 2);
41
+ let left = rect.left;
42
+ if (left + width > window.innerWidth - margin) {
43
+ left = Math.max(margin, window.innerWidth - width - margin);
44
+ }
45
+ let top = rect.bottom + gap;
46
+ if (top + box.height > window.innerHeight - margin) {
47
+ top = Math.max(margin, rect.top - gap - box.height);
48
+ }
49
+ return { top, left, width };
50
+ }
51
+
52
+ function SpritePickerPopover({ paths, current, sprites, anchorRef, onPick, onClose }) {
53
+ const listRef = useRef(null);
54
+ const [pos, setPos] = useState(null);
55
+
56
+ useLayoutEffect(() => {
57
+ if (!anchorRef.current || !listRef.current) return undefined;
58
+ function place() {
59
+ setPos(placePopover(anchorRef.current, listRef.current));
60
+ }
61
+ place();
62
+ window.addEventListener('resize', place);
63
+ window.addEventListener('scroll', place, true);
64
+ return () => {
65
+ window.removeEventListener('resize', place);
66
+ window.removeEventListener('scroll', place, true);
67
+ };
68
+ }, [anchorRef, paths.length]);
69
+
70
+ useEffect(() => {
71
+ function onKeyDown(event) {
72
+ if (event.key === 'Escape') onClose();
73
+ }
74
+ function onPointerDown(event) {
75
+ if (listRef.current?.contains(event.target) || anchorRef.current?.contains(event.target)) {
76
+ return;
77
+ }
78
+ onClose();
79
+ }
80
+ window.addEventListener('keydown', onKeyDown);
81
+ window.addEventListener('pointerdown', onPointerDown);
82
+ return () => {
83
+ window.removeEventListener('keydown', onKeyDown);
84
+ window.removeEventListener('pointerdown', onPointerDown);
85
+ };
86
+ }, [anchorRef, onClose]);
87
+
88
+ return (
89
+ <div
90
+ ref={listRef}
91
+ className={styles.spritePicker}
92
+ role="listbox"
93
+ aria-label="Sprite"
94
+ style={pos ?? { visibility: 'hidden' }}>
95
+ {paths.map((path) => {
96
+ const selected = path === current;
97
+ const name = displayNameOf(path);
98
+ return (
99
+ <button
100
+ key={path || '__none'}
101
+ type="button"
102
+ role="option"
103
+ aria-selected={selected}
104
+ aria-label={name}
105
+ className={cx(styles.spritePickerTile, selected && styles.spritePickerTileSelected)}
106
+ data-name={name}
107
+ onClick={() => onPick(path)}>
108
+ {path ? (
109
+ <ArtThumbnail art={sprites?.[path]} name={name} className={styles.spritePickerThumb} />
110
+ ) : (
111
+ <span className={cx(styles.bpSlotFallback, styles.spritePickerThumb)}>
112
+ <Icon name="times" />
113
+ </span>
114
+ )}
115
+ </button>
116
+ );
117
+ })}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ export function SpriteField({
123
+ label,
124
+ value,
125
+ onChange,
126
+ sprites,
127
+ files,
128
+ allowEmpty = false,
129
+ overridden,
130
+ defaultValue,
131
+ onReset,
132
+ }) {
133
+ const current = value ?? '';
134
+ const [open, setOpen] = useState(false);
135
+ const buttonRef = useRef(null);
136
+ const paths = candidatePaths(files, sprites, current, allowEmpty);
137
+ const art = current ? sprites?.[current] : null;
138
+ const name = current ? displayNameOf(current) : '';
139
+
140
+ return (
141
+ <FieldRow
142
+ label={label}
143
+ overridden={overridden}
144
+ defaultValue={defaultValue ? displayNameOf(defaultValue) : defaultValue}
145
+ onReset={onReset}>
146
+ <button
147
+ ref={buttonRef}
148
+ type="button"
149
+ className={cx(styles.select, styles.fileField, styles.spriteField)}
150
+ aria-haspopup="listbox"
151
+ aria-expanded={open}
152
+ onClick={() => setOpen((next) => !next)}>
153
+ <ArtThumbnail art={art} className={styles.spriteFieldThumb} />
154
+ <span className={cx(styles.fileFieldLabel, !current && styles.fileFieldEmpty)}>
155
+ {current ? name : 'None'}
156
+ </span>
157
+ </button>
158
+ {open
159
+ ? createPortal(
160
+ <SpritePickerPopover
161
+ paths={paths}
162
+ current={current}
163
+ sprites={sprites}
164
+ anchorRef={buttonRef}
165
+ onPick={(path) => {
166
+ onChange(path);
167
+ setOpen(false);
168
+ }}
169
+ onClose={() => setOpen(false)}
170
+ />,
171
+ document.body
172
+ )
173
+ : null}
174
+ </FieldRow>
175
+ );
176
+ }
@@ -465,6 +465,11 @@
465
465
  object-fit: contain;
466
466
  }
467
467
 
468
+ /* Smooth / vector finish is supersampled — nearest-neighbour would re-blockify it. */
469
+ .bpSlotThumbSmooth {
470
+ image-rendering: auto;
471
+ }
472
+
468
473
  .bpSlotFallback {
469
474
  display: inline-flex;
470
475
  align-items: center;
@@ -1871,6 +1876,137 @@
1871
1876
  color: var(--castle-inspector-muted);
1872
1877
  }
1873
1878
 
1879
+ /* SpriteField: closed chrome carries a tiny thumb beside the basename so you
1880
+ can see which art is selected without opening the picker. */
1881
+ .spriteField {
1882
+ gap: 8px;
1883
+ padding-left: 6px;
1884
+ min-height: 32px;
1885
+ }
1886
+
1887
+ .spriteFieldThumb {
1888
+ width: 22px;
1889
+ height: 22px;
1890
+ flex: 0 0 22px;
1891
+ border-radius: 3px;
1892
+ }
1893
+
1894
+ /* Anchored art grid — same shell family as FileField's path list, not a modal. */
1895
+ .spritePicker {
1896
+ position: fixed;
1897
+ z-index: 200;
1898
+ box-sizing: border-box;
1899
+ display: grid;
1900
+ grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
1901
+ gap: 6px;
1902
+ max-height: min(280px, calc(100vh - 16px));
1903
+ overflow-x: hidden;
1904
+ overflow-y: auto;
1905
+ overscroll-behavior-y: contain;
1906
+ padding: 8px;
1907
+ border: 1px solid var(--castle-inspector-border);
1908
+ border-radius: var(--castle-radius);
1909
+ background: var(--castle-workspace-bg);
1910
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
1911
+ scrollbar-width: thin;
1912
+ }
1913
+
1914
+ .spritePickerTile {
1915
+ position: relative;
1916
+ display: flex;
1917
+ align-items: center;
1918
+ justify-content: center;
1919
+ min-width: 0;
1920
+ aspect-ratio: 1;
1921
+ padding: 4px;
1922
+ border: 1px solid var(--castle-inspector-border);
1923
+ border-radius: 6px;
1924
+ background: var(--castle-inspector-input-bg);
1925
+ color: var(--castle-text);
1926
+ cursor: pointer;
1927
+ }
1928
+
1929
+ .spritePickerTile:hover {
1930
+ border-color: var(--castle-inspector-muted);
1931
+ }
1932
+
1933
+ .spritePickerTileSelected,
1934
+ .spritePickerTileSelected:hover {
1935
+ border-color: var(--castle-selected);
1936
+ box-shadow: 0 0 0 1px var(--castle-selected);
1937
+ }
1938
+
1939
+ /* Name without extension — hover only, so the grid stays pictures.
1940
+ Anchored to the bottom of the tile (not above it) so the top row stays
1941
+ readable and the scrollable picker never clips the label. */
1942
+ .spritePickerTile::after {
1943
+ content: attr(data-name);
1944
+ position: absolute;
1945
+ left: 0;
1946
+ right: 0;
1947
+ bottom: 0;
1948
+ z-index: 1;
1949
+ padding: 3px 4px;
1950
+ border-radius: 0 0 5px 5px;
1951
+ background: rgba(0, 0, 0, 0.72);
1952
+ color: var(--castle-text);
1953
+ font-size: 10px;
1954
+ line-height: 1.2;
1955
+ text-align: center;
1956
+ white-space: nowrap;
1957
+ overflow: hidden;
1958
+ text-overflow: ellipsis;
1959
+ pointer-events: none;
1960
+ opacity: 0;
1961
+ }
1962
+
1963
+ .spritePickerTile:hover::after,
1964
+ .spritePickerTile:focus-visible::after {
1965
+ opacity: 1;
1966
+ }
1967
+
1968
+ .spritePickerThumb {
1969
+ width: 100%;
1970
+ height: 100%;
1971
+ flex: 0 0 auto;
1972
+ max-width: 44px;
1973
+ max-height: 44px;
1974
+ border-radius: 4px;
1975
+ object-fit: contain;
1976
+ }
1977
+
1978
+ /* Picker tiles are larger than the belt's 36px slot — stretch the shared
1979
+ ArtThumbnail chrome (canvas / blank-name / fallback) to fill the tile. */
1980
+ .spritePickerThumb.bpSlotArt,
1981
+ .spritePickerThumb .bpSlotThumb,
1982
+ .spritePickerThumb .bpSlotName,
1983
+ .spritePickerThumb .bpSlotFallback {
1984
+ width: 100%;
1985
+ height: 100%;
1986
+ flex: none;
1987
+ max-width: none;
1988
+ max-height: none;
1989
+ }
1990
+
1991
+ .spritePickerThumb .bpSlotName {
1992
+ font-size: 9px;
1993
+ }
1994
+
1995
+ .spriteFieldThumb.bpSlotArt,
1996
+ .spriteFieldThumb .bpSlotThumb,
1997
+ .spriteFieldThumb .bpSlotName,
1998
+ .spriteFieldThumb .bpSlotFallback {
1999
+ width: 22px;
2000
+ height: 22px;
2001
+ flex: 0 0 22px;
2002
+ max-width: none;
2003
+ max-height: none;
2004
+ }
2005
+
2006
+ .spriteFieldThumb .bpSlotName {
2007
+ font-size: 5.5px;
2008
+ }
2009
+
1874
2010
  .filePicker {
1875
2011
  position: fixed;
1876
2012
  z-index: 200;
@@ -216,6 +216,7 @@
216
216
  "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
217
217
  "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
218
218
  "license": "MIT",
219
+ "peer": true,
219
220
  "dependencies": {
220
221
  "@dnd-kit/accessibility": "^3.1.1",
221
222
  "@dnd-kit/utilities": "^3.2.2",
@@ -669,6 +670,7 @@
669
670
  "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
670
671
  "dev": true,
671
672
  "license": "MIT",
673
+ "peer": true,
672
674
  "bin": {
673
675
  "acorn": "bin/acorn"
674
676
  },
@@ -1129,6 +1131,7 @@
1129
1131
  "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
1130
1132
  "dev": true,
1131
1133
  "license": "MIT",
1134
+ "peer": true,
1132
1135
  "dependencies": {
1133
1136
  "@eslint-community/eslint-utils": "^4.8.0",
1134
1137
  "@eslint-community/regexpp": "^4.12.1",
@@ -2438,6 +2441,7 @@
2438
2441
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
2439
2442
  "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
2440
2443
  "license": "MIT",
2444
+ "peer": true,
2441
2445
  "engines": {
2442
2446
  "node": ">=0.10.0"
2443
2447
  }
@@ -2447,6 +2451,7 @@
2447
2451
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
2448
2452
  "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
2449
2453
  "license": "MIT",
2454
+ "peer": true,
2450
2455
  "dependencies": {
2451
2456
  "scheduler": "^0.27.0"
2452
2457
  },