castle-web-cli 0.4.181 → 0.4.183

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.
@@ -1,8 +1,18 @@
1
1
  import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { icons } from './icons';
4
+ import { FieldRow, useMediaQuery } from './fields/fields';
4
5
  import styles from './ui.module.css';
5
6
  export { styles };
7
+ export {
8
+ CheckboxField,
9
+ ColorField,
10
+ FieldNote,
11
+ FieldRow,
12
+ NumberField,
13
+ TextField,
14
+ useCoarsePointer,
15
+ } from './fields/fields';
6
16
  export const theme = {
7
17
  transparentChecker:
8
18
  'repeating-linear-gradient(45deg, #121212, #121212 4px, #1a1a1a 4px, #1a1a1a 8px)',
@@ -46,21 +56,6 @@ export function useElementSize(ref) {
46
56
  }, [ref]);
47
57
  return size;
48
58
  }
49
- // Live `matchMedia` as a hook: re-renders when the query starts/stops matching
50
- // (plugging in a mouse, rotating, resizing a panel).
51
- function useMediaQuery(query) {
52
- const [matches, setMatches] = useState(
53
- () => typeof window !== 'undefined' && window.matchMedia(query).matches
54
- );
55
- useEffect(() => {
56
- const mql = window.matchMedia(query);
57
- const onChange = (event) => setMatches(event.matches);
58
- mql.addEventListener('change', onChange);
59
- setMatches(mql.matches);
60
- return () => mql.removeEventListener('change', onChange);
61
- }, [query]);
62
- return matches;
63
- }
64
59
  // True when the viewport is in the compact (≤600px) layout, where the file
65
60
  // list and inspector become bottom sheets. Mirrors the `@media (max-width:
66
61
  // 600px)` breakpoint so JS behavior (toggle target) tracks the CSS layout.
@@ -72,14 +67,6 @@ function useMediaQuery(query) {
72
67
  export function useCompactViewport() {
73
68
  return useMediaQuery('(max-width: 600px)');
74
69
  }
75
- // True when the primary pointer is a finger rather than a mouse/trackpad/stylus
76
- // — i.e. the person can't reliably hit a small target, has no hover, and gets a
77
- // native keyboard instead of a physical one. The right gate for touch-first
78
- // affordances (the numpad sheet, big hit areas), since it stays false on a
79
- // desktop no matter how narrow the panel gets.
80
- export function useCoarsePointer() {
81
- return useMediaQuery('(pointer: coarse)');
82
- }
83
70
  export function AppShell({ children }) {
84
71
  return <div className={styles.appShell}>{children}</div>;
85
72
  }
@@ -354,71 +341,6 @@ export function Panel({ title, action, children, overridden = false }) {
354
341
  </section>
355
342
  );
356
343
  }
357
- // ---------------------------------------------------------------------------
358
- // THE FIELD SET BELOW IS COPIED INTO THE SHELL. Keep the two in sync by hand.
359
- //
360
- // `FieldRow`, `TextField`, `NumberField` (with `NumpadSheet`,
361
- // `NumberInlineInput`, `snapToStep` and the SCRUB_* constants), `CheckboxField`,
362
- // `ColorField` and `FieldNote` are ported into
363
- // `cli/src/shell/paramFields.tsx`, which renders the Params view over a source
364
- // file's PARAMS object -- so a param scrubs exactly like a behavior prop. Their
365
- // styles are copied the same way, from `ui.module.css` into
366
- // `cli/src/shell/kitPanels.css`.
367
- //
368
- // It is a copy because there is no seam to share through: the shell cannot
369
- // import kit code, kits have no shared package, and the SDK ships runtime
370
- // services with no React in it. Nothing enforces the mirroring, so a change here
371
- // -- a new field type, a scrub-feel tweak, a fix -- has to be made there too, or
372
- // the two inspectors drift apart.
373
- //
374
- // The shell copy deliberately drops the instance-override chrome (`overridden`,
375
- // the "Default: X [Reset]" sub-line): a module-level constant has no template to
376
- // override. Don't port that part back.
377
- //
378
- // If you find yourself making this a THIRD copy, make it a shared source instead.
379
- // ---------------------------------------------------------------------------
380
-
381
- // Render the "Default: X" value in an instance override's sub-line.
382
- function formatFieldDefault(value) {
383
- if (typeof value === 'boolean') return value ? 'On' : 'Off';
384
- if (value == null || value === '') return 'none';
385
- return String(value);
386
- }
387
- export function FieldRow({ label, overridden, defaultValue, onReset, children }) {
388
- const row = (
389
- <label className={cx(styles.fieldRow, overridden && styles.fieldRowOverridden)}>
390
- <span className={cx(styles.fieldLabel, overridden && styles.fieldLabelOverridden)}>{label}</span>
391
- {children}
392
- </label>
393
- );
394
- if (!overridden) return row;
395
- return (
396
- <div className={styles.fieldOverride}>
397
- {row}
398
- <div className={styles.fieldDefault}>
399
- <div className={styles.fieldDefaultInner}>
400
- <span>Default: {formatFieldDefault(defaultValue)}</span>
401
- {onReset ? (
402
- <button type="button" className={styles.fieldResetBtn} onClick={onReset}>
403
- Reset
404
- </button>
405
- ) : null}
406
- </div>
407
- </div>
408
- </div>
409
- );
410
- }
411
- export function TextField({ label, value, onChange, overridden, defaultValue, onReset }) {
412
- return (
413
- <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
414
- <input
415
- className={styles.input}
416
- value={value ?? ''}
417
- onChange={(event) => onChange(event.target.value)}
418
- />
419
- </FieldRow>
420
- );
421
- }
422
344
  export function TextAreaField({
423
345
  label,
424
346
  value,
@@ -439,359 +361,6 @@ export function TextAreaField({
439
361
  </FieldRow>
440
362
  );
441
363
  }
442
- function numberText(v) {
443
- return Number.isFinite(v) ? String(v) : '0';
444
- }
445
-
446
- // Keys in reading order for a 4-col grid: 0 spans the bottom three, Done the last.
447
- const NUMPAD_KEYS = [
448
- '7', '8', '9', 'back',
449
- '4', '5', '6', 'sign',
450
- '1', '2', '3', 'dot',
451
- '0', 'done',
452
- ];
453
- const NUMPAD_GLYPH = { back: '⌫', sign: '±', dot: '.', done: 'Done' };
454
-
455
- // A custom numeric pad shown as a bottom sheet — the ONLY reliable way to offer a
456
- // minus on mobile (the native numpad has none, and there's no web API to add one).
457
- // The field has no <input>, so no native keyboard ever appears; this edits a draft
458
- // string and commits on Done / dismiss.
459
- function NumpadSheet({ label, value, min, max, step, onCommit, onClose }) {
460
- const [draft, setDraft] = useState(() => numberText(value));
461
- const pristineRef = useRef(true); // first digit replaces the seeded value
462
- function finish() {
463
- let v = Number(draft);
464
- if (!Number.isFinite(v)) v = value;
465
- if (min != null && v < min) v = min;
466
- if (max != null && v > max) v = max;
467
- onCommit(v);
468
- onClose();
469
- }
470
- function press(key) {
471
- if (key === 'done') return finish();
472
- if (key === 'back') {
473
- pristineRef.current = false;
474
- return setDraft((d) => d.slice(0, -1));
475
- }
476
- if (key === 'sign') {
477
- pristineRef.current = false;
478
- return setDraft((d) => (d.startsWith('-') ? d.slice(1) : '-' + (d === '0' ? '' : d)));
479
- }
480
- // The first digit/dot after opening replaces the seeded value (calculator-style).
481
- const fresh = pristineRef.current;
482
- pristineRef.current = false;
483
- if (key === 'dot') {
484
- return setDraft((d) =>
485
- fresh ? '0.' : d.includes('.') ? d : d === '' || d === '-' ? d + '0.' : d + '.'
486
- );
487
- }
488
- setDraft((d) => {
489
- if (fresh) return key;
490
- if (d === '0') return key;
491
- if (d === '-0') return '-' + key;
492
- return d + key;
493
- });
494
- }
495
- return createPortal(
496
- <div className={styles.numpadBackdrop} onPointerDown={finish}>
497
- <div
498
- className={styles.numpadSheet}
499
- role="dialog"
500
- aria-label={`Edit ${label}`}
501
- onPointerDown={(event) => event.stopPropagation()}>
502
- <div className={styles.numpadValueRow}>
503
- <span className={styles.numpadValueLabel}>{label}</span>
504
- <span className={styles.numpadValueNum}>{draft === '' || draft === '-' ? '0' : draft}</span>
505
- </div>
506
- <div className={styles.numpadGrid}>
507
- {NUMPAD_KEYS.map((key) => (
508
- <button
509
- key={key}
510
- type="button"
511
- className={cx(
512
- styles.numpadKey,
513
- key === 'done' && styles.numpadKeyDone,
514
- key === '0' && styles.numpadKeyZero
515
- )}
516
- onClick={() => press(key)}>
517
- {NUMPAD_GLYPH[key] ?? key}
518
- </button>
519
- ))}
520
- </div>
521
- </div>
522
- </div>,
523
- document.body
524
- );
525
- }
526
-
527
- // Scrub tuning: horizontal travel before a drag counts as a scrub (vs a tap), the
528
- // base pixels-per-step, and how fast the per-pixel step grows with drag distance
529
- // (acceleration, so a big range is reachable without running off the screen edge).
530
- const SCRUB_THRESHOLD = 12;
531
- const SCRUB_PX_PER_STEP = 12;
532
- const SCRUB_ACCEL_RANGE = 240;
533
-
534
- // The mouse/trackpad answer to "I want to type an exact number": a real input in
535
- // place of the scrub box, focused and selected so typing replaces the value.
536
- // Enter/blur commits, Esc reverts. The numpad sheet exists because a phone has
537
- // no minus key and we don't want the native keyboard covering the field — with a
538
- // physical keyboard neither is a problem, so a fine pointer never sees it.
539
- function NumberInlineInput({ value, min, max, step, onCommit, onDone }) {
540
- const [draft, setDraft] = useState(() => numberText(value));
541
- const cancelRef = useRef(false);
542
- function finish(raw) {
543
- if (cancelRef.current) return onDone();
544
- const next = Number(raw);
545
- onCommit(Number.isFinite(next) ? next : value);
546
- onDone();
547
- }
548
- return (
549
- <div className={styles.numberField}>
550
- <input
551
- className={cx(styles.input, styles.numberInput)}
552
- type="number"
553
- min={min}
554
- max={max}
555
- step={step}
556
- value={draft}
557
- autoFocus
558
- onFocus={(event) => event.currentTarget.select()}
559
- onChange={(event) => setDraft(event.target.value)}
560
- onBlur={(event) => finish(event.target.value)}
561
- onKeyDown={(event) => {
562
- if (event.key === 'Enter') {
563
- event.preventDefault();
564
- event.currentTarget.blur();
565
- } else if (event.key === 'Escape') {
566
- event.preventDefault();
567
- cancelRef.current = true;
568
- event.currentTarget.blur();
569
- }
570
- }}
571
- />
572
- </div>
573
- );
574
- }
575
-
576
- // Snap to a multiple of `step`. `Math.round(v / step) * step` alone reintroduces
577
- // binary error the moment `step` isn't representable in base 2 -- 0.05 x 14 is
578
- // 0.7000000000000001, which then gets stored and rendered in full -- so re-round
579
- // to the decimal places the step itself implies.
580
- function snapToStep(v, step) {
581
- const snapped = Math.round(v / step) * step;
582
- const text = String(step);
583
- const decimals = text.includes('e-')
584
- ? Number(text.split('e-')[1])
585
- : (text.split('.')[1] ?? '').length;
586
- return Number(snapped.toFixed(Math.min(decimals, 100)));
587
- }
588
-
589
- export function NumberField({ label, value, onChange, min, max, step = 1, overridden, defaultValue, onReset }) {
590
- const current = Number.isFinite(value) ? (value ?? 0) : 0;
591
- // A field with BOTH a min and max is a true slider (fill + thumb), the pointer's X
592
- // mapped onto [min,max]. Everything else is a horizontal scrubber that can pass
593
- // into negatives, marked with a grip so it reads as draggable.
594
- const bounded = min != null && max != null && max > min;
595
- // Drag-to-scrub is for every pointer, but what a plain CLICK/TAP opens differs:
596
- // a finger gets the numpad sheet, a mouse gets an inline text input (see
597
- // NumberInlineInput). Both are the "type an exact value" path for their device.
598
- const coarse = useCoarsePointer();
599
- const [padOpen, setPadOpen] = useState(false);
600
- const [typing, setTyping] = useState(false);
601
- // While scrubbing, a value bubble floats above the finger (which occludes the
602
- // field itself) — { x, y } client coords, or null when not scrubbing.
603
- const [scrubPos, setScrubPos] = useState(null);
604
- const boxRef = useRef(null);
605
- const dragRef = useRef(null);
606
- function clampVal(v) {
607
- if (min != null && v < min) v = min;
608
- if (max != null && v > max) v = max;
609
- return v;
610
- }
611
- function scrubTo(v) {
612
- if (step) v = snapToStep(v, step);
613
- v = clampVal(v);
614
- if (v !== current) onChange(v);
615
- }
616
- function onPointerDown(event) {
617
- if (padOpen || typing) return;
618
- if (event.pointerType === 'mouse' && event.button !== 0) return;
619
- // Don't capture or preventDefault yet: `touch-action: pan-y` lets the browser
620
- // own a VERTICAL drag (scroll the field list); we only claim a HORIZONTAL one.
621
- dragRef.current = {
622
- id: event.pointerId,
623
- startX: event.clientX,
624
- startY: event.clientY,
625
- startValue: current,
626
- mode: 'pending',
627
- };
628
- }
629
- function onPointerMove(event) {
630
- const drag = dragRef.current;
631
- if (!drag || drag.id !== event.pointerId) return;
632
- const dx = event.clientX - drag.startX;
633
- const dy = event.clientY - drag.startY;
634
- if (drag.mode === 'pending') {
635
- if (Math.abs(dx) < SCRUB_THRESHOLD && Math.abs(dy) < SCRUB_THRESHOLD) return;
636
- if (Math.abs(dy) >= Math.abs(dx)) {
637
- drag.mode = 'scroll'; // vertical intent — leave it to the list scroll
638
- return;
639
- }
640
- drag.mode = 'scrub';
641
- boxRef.current?.setPointerCapture?.(event.pointerId);
642
- }
643
- if (drag.mode !== 'scrub') return;
644
- event.preventDefault();
645
- setScrubPos({ x: event.clientX, y: event.clientY });
646
- if (bounded) {
647
- const rect = boxRef.current.getBoundingClientRect();
648
- const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
649
- scrubTo(min + frac * (max - min));
650
- } else {
651
- // Acceleration: each pixel counts for more the farther you've dragged.
652
- const accel = 1 + Math.abs(dx) / SCRUB_ACCEL_RANGE;
653
- scrubTo(drag.startValue + Math.round((dx / SCRUB_PX_PER_STEP) * accel) * step);
654
- }
655
- }
656
- function onPointerUp(event) {
657
- const drag = dragRef.current;
658
- if (!drag || drag.id !== event.pointerId) return;
659
- if (drag.mode === 'scrub') boxRef.current?.releasePointerCapture?.(event.pointerId);
660
- const wasTap = drag.mode === 'pending';
661
- dragRef.current = null;
662
- setScrubPos(null);
663
- // A plain tap/click opens the device-appropriate way to type a value.
664
- if (wasTap) (coarse ? setPadOpen : setTyping)(true);
665
- }
666
- // A pointercancel = the browser took the gesture (a vertical scroll under pan-y).
667
- // Just drop the drag; it must NOT be treated as a tap.
668
- function onPointerCancel() {
669
- dragRef.current = null;
670
- setScrubPos(null);
671
- }
672
- const fillPct = bounded ? Math.min(100, Math.max(0, ((current - min) / (max - min)) * 100)) : 0;
673
- const commit = (v) => {
674
- const next = clampVal(v);
675
- if (next !== current) onChange(next);
676
- };
677
- if (typing) {
678
- return (
679
- <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
680
- <NumberInlineInput
681
- value={current}
682
- min={min}
683
- max={max}
684
- step={step}
685
- onCommit={commit}
686
- onDone={() => setTyping(false)}
687
- />
688
- </FieldRow>
689
- );
690
- }
691
- return (
692
- <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
693
- <div
694
- ref={boxRef}
695
- className={cx(styles.input, styles.numberScrub, bounded ? styles.numberSlider : styles.numberGrip)}
696
- style={{ touchAction: 'pan-y' }}
697
- onPointerDown={onPointerDown}
698
- onPointerMove={onPointerMove}
699
- onPointerUp={onPointerUp}
700
- onPointerCancel={onPointerCancel}>
701
- {bounded ? (
702
- <>
703
- <div className={styles.sliderFill} style={{ width: `${fillPct}%` }} aria-hidden="true" />
704
- <div className={styles.sliderThumb} style={{ left: `${fillPct}%` }} aria-hidden="true" />
705
- </>
706
- ) : (
707
- <span className={styles.scrubGrip} aria-hidden="true">
708
- ⋮⋮
709
- </span>
710
- )}
711
- <span className={styles.numberValue}>{numberText(current)}</span>
712
- </div>
713
- {scrubPos
714
- ? createPortal(
715
- <div
716
- className={styles.scrubBubble}
717
- style={{ left: scrubPos.x, top: scrubPos.y - 40 }}
718
- aria-hidden="true">
719
- {numberText(current)}
720
- </div>,
721
- document.body
722
- )
723
- : null}
724
- {padOpen ? (
725
- <NumpadSheet
726
- label={label}
727
- value={current}
728
- min={min}
729
- max={max}
730
- step={step}
731
- onCommit={commit}
732
- onClose={() => setPadOpen(false)}
733
- />
734
- ) : null}
735
- </FieldRow>
736
- );
737
- }
738
- export function CheckboxField({ label, checked, onChange, overridden, defaultValue, onReset }) {
739
- return (
740
- <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
741
- <button
742
- type="button"
743
- className={cx(styles.checkbox, checked && styles.checkboxOn)}
744
- role="checkbox"
745
- aria-checked={!!checked}
746
- onClick={() => onChange(!checked)}>
747
- <svg className={styles.checkboxMark} viewBox="0 0 12 12" aria-hidden="true">
748
- <path
749
- d="M2 6.2L4.8 9 10 3"
750
- fill="none"
751
- stroke="currentColor"
752
- strokeWidth="1.8"
753
- strokeLinecap="round"
754
- strokeLinejoin="round"
755
- />
756
- </svg>
757
- </button>
758
- </FieldRow>
759
- );
760
- }
761
- // Explanatory text under a field, for a prop whose name doesn't carry its
762
- // meaning. Rendered inside the panel's field grid, so it lands under the control
763
- // rather than under the label.
764
- export function FieldNote({ children }) {
765
- if (!children) return null;
766
- return (
767
- <div className={styles.fieldNote}>
768
- <div className={styles.fieldNoteInner}>{children}</div>
769
- </div>
770
- );
771
- }
772
- export function ColorField({ label, value, onChange, overridden, defaultValue, onReset }) {
773
- const hex = normalizeHex(value);
774
- const alpha = hex.length === 9 ? hex.slice(7) : '';
775
- const rgb = hex.slice(0, 7);
776
- return (
777
- <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
778
- <input
779
- className={styles.colorInput}
780
- type="color"
781
- value={rgb}
782
- onChange={(event) => onChange(event.target.value + alpha)}
783
- />
784
- </FieldRow>
785
- );
786
- }
787
- function normalizeHex(value) {
788
- const raw = (value ?? '').trim();
789
- if (/^#[0-9a-fA-F]{3}$/.test(raw)) {
790
- return '#' + raw.slice(1).replace(/./g, (c) => c + c);
791
- }
792
- if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(raw)) return raw;
793
- return '#000000';
794
- }
795
364
  export function isHexColor(value) {
796
365
  return (
797
366
  typeof value === 'string' && /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3}([0-9a-fA-F]{2})?)?$/.test(value)
@@ -1198,6 +767,19 @@ function longestPathWidth(list, paths) {
1198
767
  return max;
1199
768
  }
1200
769
 
770
+ // Place a body-portaled popover now and again on every resize or scroll, so it
771
+ // keeps to its anchor. Scroll is captured because it doesn't bubble, and the
772
+ // anchor can sit in any scrolling ancestor. Returns the cleanup.
773
+ export function followAnchor(place) {
774
+ place();
775
+ window.addEventListener('resize', place);
776
+ window.addEventListener('scroll', place, true);
777
+ return () => {
778
+ window.removeEventListener('resize', place);
779
+ window.removeEventListener('scroll', place, true);
780
+ };
781
+ }
782
+
1201
783
  // File path field. Closed chrome shows the basename; the open list shows full
1202
784
  // paths. Callers pass the candidate paths — this does not decide what a sprite
1203
785
  // or sound is. `allowEmpty` adds a None row that writes ''.
@@ -1247,13 +829,7 @@ export function FileField({
1247
829
  }
1248
830
  setPos({ top, left, width });
1249
831
  }
1250
- place();
1251
- window.addEventListener('resize', place);
1252
- window.addEventListener('scroll', place, true);
1253
- return () => {
1254
- window.removeEventListener('resize', place);
1255
- window.removeEventListener('scroll', place, true);
1256
- };
832
+ return followAnchor(place);
1257
833
  }, [open, paths.length]);
1258
834
 
1259
835
  useEffect(() => {