castle-web-cli 0.4.183 → 0.4.185

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 (36) hide show
  1. package/dist/agent-failures.d.ts +1 -0
  2. package/dist/agent-failures.js +31 -4
  3. package/dist/agent-prompts.d.ts +0 -3
  4. package/dist/agent-prompts.js +14 -28
  5. package/dist/agent.d.ts +1 -13
  6. package/dist/agent.js +191 -938
  7. package/dist/byo-accounts.d.ts +4 -4
  8. package/dist/byo-accounts.js +17 -38
  9. package/dist/byo-auth.d.ts +2 -5
  10. package/dist/byo-auth.js +4 -52
  11. package/dist/byo-login.d.ts +4 -5
  12. package/dist/byo-login.js +28 -56
  13. package/dist/mcpPlaytest.js +1 -2
  14. package/dist/metering.d.ts +35 -23
  15. package/dist/metering.js +57 -52
  16. package/dist/openrouter-catalog.d.ts +0 -1
  17. package/dist/openrouter-catalog.js +0 -11
  18. package/dist/platformDoc.d.ts +1 -1
  19. package/dist/platformDoc.js +9 -12
  20. package/dist/serve.js +3 -5
  21. package/dist/shell/assets/index-BHWfx7Tz.css +1 -0
  22. package/dist/shell/assets/index-BMu42DTS.js +449 -0
  23. package/dist/shell/index.html +2 -2
  24. package/kits/physics-2d/CLAUDE.md +5 -1
  25. package/kits/physics-2d/castle.json +1 -1
  26. package/kits/physics-2d/editors/SceneEditor.jsx +2 -1
  27. package/kits/physics-2d/engine/autoInspector.jsx +32 -3
  28. package/kits/physics-2d/engine/fields/fields.jsx +305 -5
  29. package/kits/physics-2d/engine/fields/fields.module.css +76 -0
  30. package/kits/physics-2d/engine/paletteField.jsx +8 -3
  31. package/kits/physics-2d/engine/spriteField.jsx +3 -3
  32. package/kits/physics-2d/engine/ui.jsx +5 -277
  33. package/kits/physics-2d/engine/ui.module.css +0 -69
  34. package/package.json +1 -1
  35. package/dist/shell/assets/index-DnNy-Z5u.js +0 -447
  36. package/dist/shell/assets/index-cdsH2lna.css +0 -1
@@ -10,8 +10,8 @@
10
10
  <link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
11
11
  <link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
12
12
  <link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
13
- <script type="module" crossorigin src="/__castle/ide/assets/index-DnNy-Z5u.js"></script>
14
- <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-cdsH2lna.css">
13
+ <script type="module" crossorigin src="/__castle/ide/assets/index-BMu42DTS.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-BHWfx7Tz.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -249,9 +249,13 @@ export class MyThing {
249
249
  // Optional. Inspector ranges for numeric props. Without one, a number field is
250
250
  // an unbounded scrubber stepping by 1 — wrong for anything living in 0..1.
251
251
  // Giving BOTH min and max makes it a real slider. A `max` also caps typed
252
- // entry, so only set one where going past it is meaningless.
252
+ // entry, so only set one where going past it is meaningless. `unit` suffixes
253
+ // the value with what it is measured in, and `options` makes a string or
254
+ // number prop a select over a fixed set of values.
253
255
  static propertyMeta = {
254
256
  power: { min: 0, max: 1, step: 0.05 },
257
+ speed: { min: 0, max: 600, unit: 'px/s' },
258
+ mode: { options: ['chase', 'patrol'] },
255
259
  };
256
260
 
257
261
  // Optional. Other components this one needs BESIDE IT on the same actor.
@@ -76,7 +76,7 @@
76
76
  "main": "main.jsx",
77
77
  "autoUpdateWhenImported": true,
78
78
  "title": "physics-2d",
79
- "publishedVersion": "2026-09-12T00:14:01.916Z",
79
+ "publishedVersion": "2026-09-12T03:16:20.185Z",
80
80
  "imports": {
81
81
  "castle.base": {
82
82
  "deckId": "yRcmH4_aYllE",
@@ -57,6 +57,7 @@ import {
57
57
  ConfirmDialog,
58
58
  EditorBody,
59
59
  EditorHeader,
60
+ fieldStyles,
60
61
  Icon,
61
62
  IconButton,
62
63
  NumberField,
@@ -2414,7 +2415,7 @@ function AddBehaviorPicker({ available, onAdd }) {
2414
2415
  borderBottom: '1px solid var(--castle-inspector-divider)',
2415
2416
  }}>
2416
2417
  <select
2417
- className={styles.select}
2418
+ className={cx(fieldStyles.input, fieldStyles.select)}
2418
2419
  value=""
2419
2420
  onChange={(event) => {
2420
2421
  if (event.target.value) onAdd(event.target.value);
@@ -1,5 +1,14 @@
1
1
  import React from 'react';
2
- import { CheckboxField, ColorField, FieldNote, NumberField, Panel, TextField, isHexColor } from './ui';
2
+ import {
3
+ CheckboxField,
4
+ ColorField,
5
+ FieldNote,
6
+ NumberField,
7
+ Panel,
8
+ SelectField,
9
+ TextField,
10
+ isHexColor,
11
+ } from './ui';
3
12
  // Build the override-indicator field props (purple tint + "Default: X [Reset]")
4
13
  // for a single property from a behavior panel's override context. Returns an
5
14
  // empty object when there's no context (blueprint template editing) or the
@@ -16,7 +25,12 @@ export function overrideProps(override, prop) {
16
25
  // `{ min, max, step }` there to get a real range instead of NumberField's
17
26
  // defaults -- which are an unbounded scrubber stepping by whole numbers, wrong
18
27
  // for anything that lives in 0..1. Declaring BOTH min and max makes it a true
19
- // slider (fill + thumb). Props that declare nothing are unchanged.
28
+ // slider (fill + thumb). `unit` suffixes the value with what it is measured in.
29
+ // Props that declare nothing are unchanged.
30
+ //
31
+ // `options` turns a string or number prop into a select -- `['box', 'circle']`,
32
+ // or `{ value, label }` entries when the label should read differently. Not a
33
+ // boolean: a select hands back strings, and a `true` would come back `'true'`.
20
34
  //
21
35
  // Any prop can also declare `hint`, a line of explanation shown under the field.
22
36
  // As a function it receives the prop's current value and the whole component, so
@@ -24,8 +38,22 @@ export function overrideProps(override, prop) {
24
38
  // halves of a toggle at once -- which is the only way a one-line note beats the
25
39
  // prop's own name.
26
40
  function fieldFor({ label, current, set, sample, entry, ov }) {
41
+ if (entry?.options && (typeof sample === 'string' || typeof sample === 'number')) {
42
+ // A select's value is a string in the DOM; a numeric prop converts back so
43
+ // its type does not change under it.
44
+ const numeric = typeof sample === 'number';
45
+ return (
46
+ <SelectField
47
+ label={label}
48
+ value={current == null ? '' : String(current)}
49
+ onChange={(next) => set(numeric ? Number(next) : next)}
50
+ options={entry.options}
51
+ {...ov}
52
+ />
53
+ );
54
+ }
27
55
  if (typeof sample === 'number') {
28
- const { min, max, step } = entry ?? {};
56
+ const { min, max, step, unit } = entry ?? {};
29
57
  return (
30
58
  <NumberField
31
59
  label={label}
@@ -34,6 +62,7 @@ function fieldFor({ label, current, set, sample, entry, ov }) {
34
62
  {...(min == null ? {} : { min })}
35
63
  {...(max == null ? {} : { max })}
36
64
  {...(step == null ? {} : { step })}
65
+ {...(unit == null ? {} : { unit })}
37
66
  {...ov}
38
67
  />
39
68
  );
@@ -3,7 +3,8 @@
3
3
 
4
4
  // The inspector's field widgets: one source for every place that edits a value
5
5
  // with them, so a param in the shell scrubs exactly like a behavior prop in a
6
- // kit's scene inspector.
6
+ // kit's scene inspector. Text, number, checkbox, color, select and file, plus
7
+ // the row chrome and the note line under a field.
7
8
  //
8
9
  // The canonical copy of this file is shared/fields/fields.tsx. Edit it there and
9
10
  // run `npm run sync:shared`: scripts/sync-shared.mjs copies it byte-for-byte to
@@ -14,7 +15,7 @@
14
15
  // The host supplies the --castle-* tokens the CSS reads. Anything portaled to
15
16
  // document.body leaves the host's tree, so a host whose tokens live on a panel
16
17
  // root names a class for the portal root through FieldsChrome.
17
- import { createContext, useContext, useEffect, useRef, useState } from 'react';
18
+ import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
18
19
  import { createPortal } from 'react-dom';
19
20
  import styles from './fields.module.css';
20
21
  function cx(...parts) {
@@ -102,6 +103,12 @@ export function TextField({ value, onChange, ...row }) {
102
103
  function numberText(v) {
103
104
  return Number.isFinite(v) ? String(v) : '0';
104
105
  }
106
+ // `0.08 s`. The space is a nbsp because a value and its unit are one token to
107
+ // read and must never wrap apart.
108
+ function UnitSuffix({ unit }) {
109
+ if (!unit) return null;
110
+ return <span className={styles.unitSuffix}>{`\u00a0${unit}`}</span>;
111
+ }
105
112
  function clamp(v, min, max) {
106
113
  if (typeof min === 'number' && v < min) v = min;
107
114
  if (typeof max === 'number' && v > max) v = max;
@@ -124,7 +131,7 @@ const NUMPAD_GLYPH = {
124
131
  // minus on mobile (the native numpad has none, and there's no web API to add one).
125
132
  // The field has no <input>, so no native keyboard ever appears; this edits a draft
126
133
  // string and commits on Done / dismiss.
127
- function NumpadSheet({ label, value, min, max, onCommit, onClose }) {
134
+ function NumpadSheet({ label, value, min, max, unit, onCommit, onClose }) {
128
135
  const [draft, setDraft] = useState(() => numberText(value));
129
136
  const pristineRef = useRef(true); // first digit replaces the seeded value
130
137
  function finish() {
@@ -169,6 +176,7 @@ function NumpadSheet({ label, value, min, max, onCommit, onClose }) {
169
176
  <span className={styles.numpadValueLabel}>{label}</span>
170
177
  <span className={styles.numpadValueNum}>
171
178
  {draft === '' || draft === '-' ? '0' : draft}
179
+ <UnitSuffix unit={unit} />
172
180
  </span>
173
181
  </div>
174
182
  <div className={styles.numpadGrid}>
@@ -256,7 +264,7 @@ function snapToStep(v, step) {
256
264
  function sliderRange(min, max) {
257
265
  return typeof min === 'number' && typeof max === 'number' && max > min ? { min, max } : null;
258
266
  }
259
- export function NumberField({ value, onChange, min, max, step = 1, ...row }) {
267
+ export function NumberField({ value, onChange, min, max, step = 1, unit, ...row }) {
260
268
  const current = Number.isFinite(value) ? (value ?? 0) : 0;
261
269
  const range = sliderRange(min, max);
262
270
  // Drag-to-scrub is for every pointer, but what a plain CLICK/TAP opens differs:
@@ -366,6 +374,7 @@ export function NumberField({ value, onChange, min, max, step = 1, ...row }) {
366
374
  range ? styles.numberSlider : styles.numberGrip
367
375
  )}
368
376
  data-field="number"
377
+ data-slider={range ? '' : undefined}
369
378
  style={{ touchAction: 'pan-y' }}
370
379
  onPointerDown={onPointerDown}
371
380
  onPointerMove={onPointerMove}
@@ -389,7 +398,10 @@ export function NumberField({ value, onChange, min, max, step = 1, ...row }) {
389
398
  ⋮⋮
390
399
  </span>
391
400
  )}
392
- <span className={styles.numberValue}>{numberText(current)}</span>
401
+ <span className={styles.numberValue}>
402
+ {numberText(current)}
403
+ <UnitSuffix unit={unit} />
404
+ </span>
393
405
  </div>
394
406
  {scrubPos ? (
395
407
  <Overlay>
@@ -398,6 +410,7 @@ export function NumberField({ value, onChange, min, max, step = 1, ...row }) {
398
410
  style={{ left: scrubPos.x, top: scrubPos.y - 40 }}
399
411
  aria-hidden="true">
400
412
  {numberText(current)}
413
+ <UnitSuffix unit={unit} />
401
414
  </div>
402
415
  </Overlay>
403
416
  ) : null}
@@ -407,6 +420,7 @@ export function NumberField({ value, onChange, min, max, step = 1, ...row }) {
407
420
  value={current}
408
421
  min={min}
409
422
  max={max}
423
+ unit={unit}
410
424
  onCommit={commit}
411
425
  onClose={() => setPadOpen(false)}
412
426
  />
@@ -438,6 +452,38 @@ export function CheckboxField({ checked, onChange, ...row }) {
438
452
  </FieldRow>
439
453
  );
440
454
  }
455
+ function optionPair(option) {
456
+ if (typeof option === 'object') return { value: String(option.value), label: option.label };
457
+ return { value: String(option), label: String(option) };
458
+ }
459
+ // A select's value is a string in the DOM whatever the underlying type is; a
460
+ // host that holds numbers converts on the way back out.
461
+ export function SelectField({ value, onChange, options, ...row }) {
462
+ const current = value ?? '';
463
+ const listed = options.map(optionPair);
464
+ // A current value the options don't have is prepended rather than dropped: a
465
+ // select that renders blank for it reads as unset, and a stale or misspelled
466
+ // value is exactly the case where you need to see what it actually says.
467
+ const shown =
468
+ current === '' || listed.some((option) => option.value === current)
469
+ ? listed
470
+ : [{ value: current, label: current }, ...listed];
471
+ return (
472
+ <FieldRow {...row}>
473
+ <select
474
+ className={cx(styles.input, styles.select)}
475
+ data-field="select"
476
+ value={current}
477
+ onChange={(event) => onChange(event.target.value)}>
478
+ {shown.map((option) => (
479
+ <option key={option.value} value={option.value}>
480
+ {option.label}
481
+ </option>
482
+ ))}
483
+ </select>
484
+ </FieldRow>
485
+ );
486
+ }
441
487
  // Explanatory text under a field, for a prop whose name doesn't carry its
442
488
  // meaning. Rendered inside the panel's field grid, so it lands under the control
443
489
  // rather than under the label.
@@ -473,3 +519,257 @@ function normalizeHex(value) {
473
519
  if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(raw)) return raw;
474
520
  return '#000000';
475
521
  }
522
+ // ── file paths ───────────────────────────────────────────────────────────────
523
+ // Which extensions are an image, a sound or a video. A browser fact rather than
524
+ // any one kit's taxonomy -- the same kind the CLI's own unsupportedMedia list
525
+ // holds -- so the host that asks for `file: 'image'` need not carry its own.
526
+ export const FILE_KINDS = {
527
+ image: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],
528
+ audio: ['.mp3', '.wav', '.m4a'],
529
+ video: ['.mp4'],
530
+ };
531
+ export function isFileKind(path, kind) {
532
+ if (kind === 'any') return true;
533
+ const exts = FILE_KINDS[kind];
534
+ return exts ? exts.some((ext) => path.toLowerCase().endsWith(ext)) : false;
535
+ }
536
+ /** Which kind a path is, or null for a file that is none of them. */
537
+ export function mediaKindOf(path) {
538
+ for (const kind of Object.keys(FILE_KINDS)) {
539
+ if (isFileKind(path, kind)) return kind;
540
+ }
541
+ return null;
542
+ }
543
+ function basenameOf(path) {
544
+ if (!path) return '';
545
+ return path.slice(path.lastIndexOf('/') + 1);
546
+ }
547
+ let measureCanvasCtx = null;
548
+ function measureTextWidth(text, font) {
549
+ if (!measureCanvasCtx) {
550
+ measureCanvasCtx = document.createElement('canvas').getContext('2d');
551
+ }
552
+ if (!measureCanvasCtx) return 0;
553
+ measureCanvasCtx.font = font;
554
+ return measureCanvasCtx.measureText(text).width;
555
+ }
556
+ // `getComputedStyle().font` is empty in some browsers; rebuild it when it is.
557
+ function fontOf(style) {
558
+ return style.font && style.font !== '0px'
559
+ ? style.font
560
+ : `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
561
+ }
562
+ // Keep the filename intact; the directory is what yields. Binary-search the
563
+ // prefix of the directory until `{prefix}…/{basename}` fits `maxWidth`.
564
+ function fitPathKeepBasename(path, maxWidth, font) {
565
+ const measure = (text) => measureTextWidth(text, font);
566
+ if (maxWidth <= 0 || measure(path) <= maxWidth) return path;
567
+ const slash = path.lastIndexOf('/');
568
+ const dir = slash > 0 ? path.slice(0, slash) : '';
569
+ const base = slash > 0 ? path.slice(slash + 1) : path;
570
+ const ellipsed = `…/${base}`;
571
+ if (!dir || measure(ellipsed) > maxWidth) return fitKeepEnd(base, maxWidth, measure);
572
+ let lo = 0;
573
+ let hi = dir.length;
574
+ let best = ellipsed;
575
+ while (lo <= hi) {
576
+ const mid = (lo + hi) >> 1;
577
+ const candidate = `${dir.slice(0, mid)}…/${base}`;
578
+ if (measure(candidate) <= maxWidth) {
579
+ best = candidate;
580
+ lo = mid + 1;
581
+ } else {
582
+ hi = mid - 1;
583
+ }
584
+ }
585
+ return best;
586
+ }
587
+ function fitKeepEnd(text, maxWidth, measure) {
588
+ if (measure(text) <= maxWidth) return text;
589
+ if (measure('…') > maxWidth) return '';
590
+ let lo = 0;
591
+ let hi = text.length;
592
+ let best = '…';
593
+ while (lo <= hi) {
594
+ const mid = (lo + hi) >> 1;
595
+ const candidate = `…${text.slice(text.length - mid)}`;
596
+ if (measure(candidate) <= maxWidth) {
597
+ best = candidate;
598
+ lo = mid + 1;
599
+ } else {
600
+ hi = mid - 1;
601
+ }
602
+ }
603
+ return best;
604
+ }
605
+ function FilePathLabel({ path }) {
606
+ const ref = useRef(null);
607
+ const [shown, setShown] = useState(path);
608
+ useLayoutEffect(() => {
609
+ const node = ref.current;
610
+ if (!node) return undefined;
611
+ function fit() {
612
+ const max = node.clientWidth;
613
+ if (max <= 0) return;
614
+ setShown(fitPathKeepBasename(path, max, fontOf(getComputedStyle(node))));
615
+ }
616
+ fit();
617
+ const observer = new ResizeObserver(fit);
618
+ observer.observe(node);
619
+ return () => observer.disconnect();
620
+ }, [path]);
621
+ return (
622
+ <span ref={ref} className={styles.filePickerPath} title={path}>
623
+ {shown}
624
+ </span>
625
+ );
626
+ }
627
+ function pickerRowChrome(list) {
628
+ if (!list) return 26;
629
+ const box = getComputedStyle(list);
630
+ const opt = list.querySelector('button');
631
+ const op = opt ? getComputedStyle(opt) : null;
632
+ return (
633
+ (parseFloat(box.paddingLeft) || 0) +
634
+ (parseFloat(box.paddingRight) || 0) +
635
+ (parseFloat(box.borderLeftWidth) || 0) +
636
+ (parseFloat(box.borderRightWidth) || 0) +
637
+ (op ? (parseFloat(op.paddingLeft) || 0) + (parseFloat(op.paddingRight) || 0) : 16)
638
+ );
639
+ }
640
+ function longestPathWidth(list, paths) {
641
+ if (!list || !paths.length) return 0;
642
+ const probe = document.createElement('span');
643
+ probe.style.cssText = 'position:absolute;left:-9999px;white-space:nowrap;pointer-events:none;';
644
+ const opt = list.querySelector('button');
645
+ if (opt) probe.style.font = fontOf(getComputedStyle(opt));
646
+ list.appendChild(probe);
647
+ let max = 0;
648
+ for (const path of paths) {
649
+ probe.textContent = path || 'None';
650
+ max = Math.max(max, probe.offsetWidth);
651
+ }
652
+ probe.remove();
653
+ return max;
654
+ }
655
+ // Place a body-portaled popover now and again on every resize or scroll, so it
656
+ // keeps to its anchor. Scroll is captured because it doesn't bubble, and the
657
+ // anchor can sit in any scrolling ancestor. Returns the cleanup.
658
+ export function followAnchor(place) {
659
+ place();
660
+ window.addEventListener('resize', place);
661
+ window.addEventListener('scroll', place, true);
662
+ return () => {
663
+ window.removeEventListener('resize', place);
664
+ window.removeEventListener('scroll', place, true);
665
+ };
666
+ }
667
+ // File path field. Closed chrome shows the basename; the open list shows full
668
+ // paths. Callers pass the candidate paths — this does not decide what a sprite
669
+ // or sound is. `allowEmpty` adds a None row that writes ''.
670
+ export function FileField({ value, onChange, files = [], allowEmpty = false, ...row }) {
671
+ const current = value ?? '';
672
+ const [open, setOpen] = useState(false);
673
+ const buttonRef = useRef(null);
674
+ const listRef = useRef(null);
675
+ const [pos, setPos] = useState(null);
676
+ const paths = [];
677
+ if (allowEmpty) paths.push('');
678
+ for (const path of files) {
679
+ if (path && !paths.includes(path)) paths.push(path);
680
+ }
681
+ if (current && !paths.includes(current)) paths.splice(allowEmpty ? 1 : 0, 0, current);
682
+ useLayoutEffect(() => {
683
+ if (!open || !buttonRef.current) return undefined;
684
+ function place() {
685
+ const anchor = buttonRef.current.getBoundingClientRect();
686
+ const list = listRef.current;
687
+ const margin = 8;
688
+ const maxWidth = window.innerWidth - margin * 2;
689
+ const needed = longestPathWidth(list, paths) + pickerRowChrome(list) + 4;
690
+ const width = Math.min(Math.max(needed, anchor.width), maxWidth);
691
+ // Grow left from the field if the paths need more than the control width.
692
+ let left = anchor.right - width;
693
+ if (left < margin) left = margin;
694
+ if (left + width > window.innerWidth - margin) {
695
+ left = Math.max(margin, window.innerWidth - width - margin);
696
+ }
697
+ const gap = 4;
698
+ const height = list?.getBoundingClientRect().height ?? 0;
699
+ let top = anchor.bottom + gap;
700
+ if (height && top + height > window.innerHeight - margin) {
701
+ top = Math.max(margin, anchor.top - gap - height);
702
+ }
703
+ setPos({ top, left, width });
704
+ }
705
+ return followAnchor(place);
706
+ }, [open, paths.length]);
707
+ useEffect(() => {
708
+ if (!open) return undefined;
709
+ function onKeyDown(event) {
710
+ if (event.key === 'Escape') setOpen(false);
711
+ }
712
+ function onPointerDown(event) {
713
+ const target = event.target;
714
+ if (listRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
715
+ setOpen(false);
716
+ }
717
+ window.addEventListener('keydown', onKeyDown);
718
+ window.addEventListener('pointerdown', onPointerDown);
719
+ return () => {
720
+ window.removeEventListener('keydown', onKeyDown);
721
+ window.removeEventListener('pointerdown', onPointerDown);
722
+ };
723
+ }, [open]);
724
+ return (
725
+ <FieldRow
726
+ {...row}
727
+ defaultValue={row.defaultValue ? basenameOf(String(row.defaultValue)) : row.defaultValue}>
728
+ <button
729
+ ref={buttonRef}
730
+ type="button"
731
+ className={cx(styles.input, styles.select, styles.fileField)}
732
+ data-field="file"
733
+ aria-haspopup="listbox"
734
+ aria-expanded={open}
735
+ onClick={() => setOpen((next) => !next)}>
736
+ <span className={cx(styles.fileFieldLabel, !current && styles.fileFieldEmpty)}>
737
+ {current ? basenameOf(current) : 'None'}
738
+ </span>
739
+ </button>
740
+ {open ? (
741
+ <Overlay>
742
+ <div
743
+ ref={listRef}
744
+ className={styles.filePicker}
745
+ role="listbox"
746
+ aria-label={row.label}
747
+ style={pos ?? { visibility: 'hidden' }}>
748
+ {paths.map((path) => {
749
+ const selected = path === current;
750
+ return (
751
+ <button
752
+ key={path || '__none'}
753
+ type="button"
754
+ role="option"
755
+ aria-selected={selected}
756
+ className={cx(
757
+ styles.filePickerOption,
758
+ selected && styles.filePickerOptionSelected,
759
+ !path && styles.fileFieldEmpty
760
+ )}
761
+ title={path || 'None'}
762
+ onClick={() => {
763
+ onChange(path);
764
+ setOpen(false);
765
+ }}>
766
+ {path ? <FilePathLabel path={path} /> : 'None'}
767
+ </button>
768
+ );
769
+ })}
770
+ </div>
771
+ </Overlay>
772
+ ) : null}
773
+ </FieldRow>
774
+ );
775
+ }
@@ -113,6 +113,18 @@
113
113
  font: inherit;
114
114
  }
115
115
 
116
+ /* Worn with .input, which carries the box; this is only what makes it a select:
117
+ the native chrome off and our own arrow in its place. */
118
+ .select {
119
+ appearance: none;
120
+ -webkit-appearance: none;
121
+ padding-right: 26px;
122
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5L6 8l3.5-3.5' fill='none' stroke='%23888888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
123
+ background-repeat: no-repeat;
124
+ background-position: right 8px center;
125
+ text-overflow: ellipsis;
126
+ }
127
+
116
128
  .numberField {
117
129
  position: relative;
118
130
  width: 100%;
@@ -159,6 +171,10 @@
159
171
  font-variant-numeric: tabular-nums;
160
172
  }
161
173
 
174
+ .unitSuffix {
175
+ color: var(--castle-inspector-muted);
176
+ }
177
+
162
178
  .scrubGrip {
163
179
  position: relative;
164
180
  z-index: 1;
@@ -328,3 +344,63 @@
328
344
  .checkboxOn .checkboxMark {
329
345
  display: block;
330
346
  }
347
+
348
+ /* The file picker's closed chrome wears .input and .select as well; this is the
349
+ part that makes it a button rather than a box with text in it. */
350
+ .fileField {
351
+ display: flex;
352
+ align-items: center;
353
+ font: inherit;
354
+ box-shadow: none;
355
+ cursor: pointer;
356
+ }
357
+
358
+ .fileFieldLabel {
359
+ min-width: 0;
360
+ overflow: hidden;
361
+ text-overflow: ellipsis;
362
+ white-space: nowrap;
363
+ }
364
+
365
+ .fileFieldEmpty {
366
+ color: var(--castle-inspector-muted);
367
+ }
368
+
369
+ .filePicker {
370
+ position: fixed;
371
+ z-index: 200;
372
+ box-sizing: border-box;
373
+ max-height: min(240px, calc(100vh - 16px));
374
+ overflow: auto;
375
+ padding: 4px;
376
+ border: 1px solid var(--castle-inspector-border);
377
+ border-radius: var(--castle-radius);
378
+ background: var(--castle-workspace-bg);
379
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
380
+ }
381
+
382
+ .filePickerOption {
383
+ display: block;
384
+ width: 100%;
385
+ overflow: hidden;
386
+ text-align: left;
387
+ border: 0;
388
+ border-radius: 3px;
389
+ background: transparent;
390
+ color: var(--castle-inspector-text);
391
+ font: inherit;
392
+ font-size: 13px;
393
+ padding: 6px 8px;
394
+ cursor: pointer;
395
+ }
396
+
397
+ .filePickerPath {
398
+ display: block;
399
+ overflow: hidden;
400
+ white-space: nowrap;
401
+ }
402
+
403
+ .filePickerOption:hover,
404
+ .filePickerOptionSelected {
405
+ background: var(--castle-inspector-input-bg);
406
+ }
@@ -5,7 +5,7 @@ import {
5
5
  pickerPageIndexFor,
6
6
  pickerPagesFor,
7
7
  } from './palettes';
8
- import { cx, FieldRow, followAnchor, IconButton, styles } from './ui';
8
+ import { cx, FieldRow, fieldStyles, followAnchor, IconButton, styles } from './ui';
9
9
  import { attachDismissListeners } from './popoverDismiss';
10
10
 
11
11
  export function sameHex(a, b) {
@@ -180,7 +180,12 @@ export function PaletteColorField({
180
180
  <button
181
181
  ref={buttonRef}
182
182
  type="button"
183
- className={cx(styles.select, styles.fileField, styles.paletteColorField)}
183
+ className={cx(
184
+ fieldStyles.input,
185
+ fieldStyles.select,
186
+ fieldStyles.fileField,
187
+ styles.paletteColorField
188
+ )}
184
189
  aria-haspopup="dialog"
185
190
  aria-expanded={open}
186
191
  onClick={() => setOpen((next) => !next)}>
@@ -200,7 +205,7 @@ export function PaletteColorField({
200
205
  }
201
206
  }
202
207
  />
203
- <span className={cx(styles.fileFieldLabel, !current && styles.fileFieldEmpty)}>
208
+ <span className={cx(fieldStyles.fileFieldLabel, !current && fieldStyles.fileFieldEmpty)}>
204
209
  {current || 'None'}
205
210
  </span>
206
211
  </button>
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { ArtThumbnail } from './artThumbnail';
4
- import { cx, FieldRow, followAnchor, Icon, styles } from './ui';
4
+ import { cx, FieldRow, fieldStyles, followAnchor, Icon, styles } from './ui';
5
5
  import { attachDismissListeners } from './popoverDismiss';
6
6
 
7
7
  // Visual sprite / image picker for inspector fields that point at art files.
@@ -124,12 +124,12 @@ export function SpriteField({
124
124
  <button
125
125
  ref={buttonRef}
126
126
  type="button"
127
- className={cx(styles.select, styles.fileField, styles.spriteField)}
127
+ className={cx(fieldStyles.input, fieldStyles.select, fieldStyles.fileField, styles.spriteField)}
128
128
  aria-haspopup="listbox"
129
129
  aria-expanded={open}
130
130
  onClick={() => setOpen((next) => !next)}>
131
131
  <ArtThumbnail art={art} className={styles.spriteFieldThumb} />
132
- <span className={cx(styles.fileFieldLabel, !current && styles.fileFieldEmpty)}>
132
+ <span className={cx(fieldStyles.fileFieldLabel, !current && fieldStyles.fileFieldEmpty)}>
133
133
  {current ? name : 'None'}
134
134
  </span>
135
135
  </button>