castle-web-cli 0.4.105 → 0.4.107

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.
@@ -0,0 +1,145 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ // A multi-finger contact resolves to a TAP (undo/redo) only if the whole contact
4
+ // is this brief and never moved past the slop; longer or larger is a drag.
5
+ const TAP_MAX_MS = 450;
6
+ // Pinch/pan must move this many px (spread or centroid) before it's treated as a
7
+ // drag. Under it the two fingers are still a candidate tap and nothing is applied
8
+ // — this is what keeps a two-finger TAP (undo) cleanly apart from a pinch.
9
+ const GESTURE_SLOP = 10;
10
+
11
+ // Shared touch gestures for the canvas editors (scene + pxart):
12
+ // one finger -> the editor's own tool (draw / select), untouched here
13
+ // two-finger drag -> pinch-zoom about the centroid + pan (onGestureMove)
14
+ // two-finger TAP -> undo (onUndo)
15
+ // three-finger TAP -> redo (onRedo) [Procreate convention]
16
+ //
17
+ // Tap resolution waits for EVERY finger to lift and then keys off the PEAK finger
18
+ // count reached during the contact — so it doesn't matter whether the fingers land
19
+ // or lift together (an early first-lift used to resolve a three-finger tap as two).
20
+ //
21
+ // A second finger aborts the in-flight one-finger action (onGestureStart) so a
22
+ // gesture never strands a half-stroke. Gesture touches are stopPropagation'd so
23
+ // they never reach the editor's own pointer handlers. onGestureMove emits one
24
+ // step: { factor, cx, cy, dx, dy } — a zoom factor about the pinch centroid plus
25
+ // the centroid's screen-space movement — which each editor applies its own way.
26
+ // The target must have `touch-action: none` for the pinch/pan to beat the browser;
27
+ // the taps work without it (a tap doesn't move, so the browser never scrolls).
28
+ export function useTwoFingerZoomPan({
29
+ targetRef,
30
+ enabled = true,
31
+ onGestureStart,
32
+ onGestureMove,
33
+ onGestureEnd,
34
+ onUndo,
35
+ onRedo,
36
+ }) {
37
+ // Callbacks in a ref so the listeners attach once and never need re-binding.
38
+ const cb = useRef(null);
39
+ cb.current = { onGestureStart, onGestureMove, onGestureEnd, onUndo, onRedo };
40
+ useEffect(() => {
41
+ if (!enabled) return undefined;
42
+ const el = targetRef.current;
43
+ if (!el) return undefined;
44
+ const pts = new Map(); // pointerId -> { x, y }
45
+ // Per touch-sequence state (a sequence = first finger down … all fingers up):
46
+ let maxFingers = 0; // peak simultaneous fingers — decides undo (2) vs redo (3)
47
+ let multiStart = 0; // timeStamp the contact first reached two fingers
48
+ let startDist = 0;
49
+ let startCx = 0;
50
+ let startCy = 0;
51
+ let committed = false; // moved past slop → a pinch/pan drag, not a tap
52
+ let lastDist = 0;
53
+ let lastCx = 0;
54
+ let lastCy = 0;
55
+ const isTouch = (event) => event.pointerType === 'touch';
56
+ const twoFinger = () => {
57
+ const [a, b] = [...pts.values()];
58
+ return {
59
+ cx: (a.x + b.x) / 2,
60
+ cy: (a.y + b.y) / 2,
61
+ dist: Math.hypot(a.x - b.x, a.y - b.y),
62
+ };
63
+ };
64
+ const onDown = (event) => {
65
+ if (!isTouch(event)) return;
66
+ pts.set(event.pointerId, { x: event.clientX, y: event.clientY });
67
+ if (pts.size > maxFingers) maxFingers = pts.size;
68
+ if (pts.size === 2) {
69
+ const m = twoFinger();
70
+ multiStart = event.timeStamp;
71
+ startDist = lastDist = m.dist;
72
+ startCx = lastCx = m.cx;
73
+ startCy = lastCy = m.cy;
74
+ committed = false;
75
+ cb.current.onGestureStart?.(); // abort any in-flight one-finger action
76
+ }
77
+ if (pts.size >= 2) event.stopPropagation();
78
+ };
79
+ const onMove = (event) => {
80
+ if (!isTouch(event) || !pts.has(event.pointerId)) return;
81
+ pts.set(event.pointerId, { x: event.clientX, y: event.clientY });
82
+ if (pts.size < 2) return;
83
+ event.preventDefault();
84
+ event.stopPropagation();
85
+ if (pts.size !== 2) return; // zoom/pan is a two-finger interaction only
86
+ const m = twoFinger();
87
+ if (!committed) {
88
+ const spread = Math.abs(m.dist - startDist);
89
+ const slid = Math.hypot(m.cx - startCx, m.cy - startCy);
90
+ if (spread <= GESTURE_SLOP && slid <= GESTURE_SLOP) return; // still a candidate tap
91
+ committed = true;
92
+ // Reset the baseline to here so the first applied step doesn't jump by the
93
+ // whole slop distance.
94
+ lastDist = m.dist;
95
+ lastCx = m.cx;
96
+ lastCy = m.cy;
97
+ return;
98
+ }
99
+ const factor = lastDist > 0 && m.dist > 0 ? m.dist / lastDist : 1;
100
+ cb.current.onGestureMove?.({
101
+ factor,
102
+ cx: m.cx,
103
+ cy: m.cy,
104
+ dx: m.cx - lastCx,
105
+ dy: m.cy - lastCy,
106
+ });
107
+ lastDist = m.dist;
108
+ lastCx = m.cx;
109
+ lastCy = m.cy;
110
+ };
111
+ const onUp = (event) => {
112
+ if (pts.size >= 2 || committed) event.stopPropagation();
113
+ if (!pts.delete(event.pointerId)) return;
114
+ if (committed && pts.size < 2) cb.current.onGestureEnd?.();
115
+ if (pts.size > 0) return;
116
+ // Every finger is up — resolve the whole contact. A still (never a drag),
117
+ // brief multi-finger tap is undo at peak 2, redo at peak 3. Keying off the
118
+ // peak count makes it immune to the order fingers landed or lifted.
119
+ if (!committed && maxFingers >= 2 && event.timeStamp - multiStart < TAP_MAX_MS) {
120
+ if (maxFingers === 2) cb.current.onUndo?.();
121
+ else if (maxFingers === 3) cb.current.onRedo?.();
122
+ }
123
+ maxFingers = 0;
124
+ committed = false;
125
+ multiStart = 0;
126
+ };
127
+ // CAPTURE phase, not bubble: the artboard canvas captures the first finger
128
+ // (startTool → setPointerCapture) and consumes touches, so a bubble-phase
129
+ // listener misses fingers landing on/near it — three-finger taps rarely
130
+ // registered there. Capturing means we see every pointer BEFORE any descendant
131
+ // can swallow it, so the finger count is reliable wherever the taps land; and
132
+ // stopPropagation on the 2nd/3rd finger then keeps them out of the canvas too.
133
+ const opts = { passive: false, capture: true };
134
+ el.addEventListener('pointerdown', onDown, opts);
135
+ el.addEventListener('pointermove', onMove, opts);
136
+ el.addEventListener('pointerup', onUp, opts);
137
+ el.addEventListener('pointercancel', onUp, opts);
138
+ return () => {
139
+ el.removeEventListener('pointerdown', onDown, opts);
140
+ el.removeEventListener('pointermove', onMove, opts);
141
+ el.removeEventListener('pointerup', onUp, opts);
142
+ el.removeEventListener('pointercancel', onUp, opts);
143
+ };
144
+ }, [targetRef, enabled]);
145
+ }
@@ -286,87 +286,222 @@ export function TextField({ label, value, onChange, overridden, defaultValue, on
286
286
  </FieldRow>
287
287
  );
288
288
  }
289
+ function numberText(v) {
290
+ return Number.isFinite(v) ? String(v) : '0';
291
+ }
292
+
293
+ // Keys in reading order for a 4-col grid: 0 spans the bottom three, Done the last.
294
+ const NUMPAD_KEYS = [
295
+ '7', '8', '9', 'back',
296
+ '4', '5', '6', 'sign',
297
+ '1', '2', '3', 'dot',
298
+ '0', 'done',
299
+ ];
300
+ const NUMPAD_GLYPH = { back: '⌫', sign: '±', dot: '.', done: 'Done' };
301
+
302
+ // A custom numeric pad shown as a bottom sheet — the ONLY reliable way to offer a
303
+ // minus on mobile (the native numpad has none, and there's no web API to add one).
304
+ // The field has no <input>, so no native keyboard ever appears; this edits a draft
305
+ // string and commits on Done / dismiss.
306
+ function NumpadSheet({ label, value, min, max, step, onCommit, onClose }) {
307
+ const [draft, setDraft] = useState(() => numberText(value));
308
+ const pristineRef = useRef(true); // first digit replaces the seeded value
309
+ function finish() {
310
+ let v = Number(draft);
311
+ if (!Number.isFinite(v)) v = value;
312
+ if (min != null && v < min) v = min;
313
+ if (max != null && v > max) v = max;
314
+ onCommit(v);
315
+ onClose();
316
+ }
317
+ function press(key) {
318
+ if (key === 'done') return finish();
319
+ if (key === 'back') {
320
+ pristineRef.current = false;
321
+ return setDraft((d) => d.slice(0, -1));
322
+ }
323
+ if (key === 'sign') {
324
+ pristineRef.current = false;
325
+ return setDraft((d) => (d.startsWith('-') ? d.slice(1) : '-' + (d === '0' ? '' : d)));
326
+ }
327
+ // The first digit/dot after opening replaces the seeded value (calculator-style).
328
+ const fresh = pristineRef.current;
329
+ pristineRef.current = false;
330
+ if (key === 'dot') {
331
+ return setDraft((d) =>
332
+ fresh ? '0.' : d.includes('.') ? d : d === '' || d === '-' ? d + '0.' : d + '.'
333
+ );
334
+ }
335
+ setDraft((d) => {
336
+ if (fresh) return key;
337
+ if (d === '0') return key;
338
+ if (d === '-0') return '-' + key;
339
+ return d + key;
340
+ });
341
+ }
342
+ return createPortal(
343
+ <div className={styles.numpadBackdrop} onPointerDown={finish}>
344
+ <div
345
+ className={styles.numpadSheet}
346
+ role="dialog"
347
+ aria-label={`Edit ${label}`}
348
+ onPointerDown={(event) => event.stopPropagation()}>
349
+ <div className={styles.numpadValueRow}>
350
+ <span className={styles.numpadValueLabel}>{label}</span>
351
+ <span className={styles.numpadValueNum}>{draft === '' || draft === '-' ? '0' : draft}</span>
352
+ </div>
353
+ <div className={styles.numpadGrid}>
354
+ {NUMPAD_KEYS.map((key) => (
355
+ <button
356
+ key={key}
357
+ type="button"
358
+ className={cx(
359
+ styles.numpadKey,
360
+ key === 'done' && styles.numpadKeyDone,
361
+ key === '0' && styles.numpadKeyZero
362
+ )}
363
+ onClick={() => press(key)}>
364
+ {NUMPAD_GLYPH[key] ?? key}
365
+ </button>
366
+ ))}
367
+ </div>
368
+ </div>
369
+ </div>,
370
+ document.body
371
+ );
372
+ }
373
+
374
+ // Scrub tuning: horizontal travel before a drag counts as a scrub (vs a tap), the
375
+ // base pixels-per-step, and how fast the per-pixel step grows with drag distance
376
+ // (acceleration, so a big range is reachable without running off the screen edge).
377
+ const SCRUB_THRESHOLD = 12;
378
+ const SCRUB_PX_PER_STEP = 12;
379
+ const SCRUB_ACCEL_RANGE = 240;
380
+
289
381
  export function NumberField({ label, value, onChange, min, max, step = 1, overridden, defaultValue, onReset }) {
290
382
  const current = Number.isFinite(value) ? (value ?? 0) : 0;
291
- const [draft, setDraft] = useState(String(current));
292
- const editingRef = useRef(false);
293
- const cancelRef = useRef(false);
294
- // keep the draft in sync with the committed value while not editing
295
- if (!editingRef.current && draft !== String(current)) {
296
- setDraft(String(current));
383
+ // A field with BOTH a min and max is a true slider (fill + thumb), the pointer's X
384
+ // mapped onto [min,max]. Everything else is a horizontal scrubber that can pass
385
+ // into negatives, marked with a grip so it reads as draggable.
386
+ const bounded = min != null && max != null && max > min;
387
+ const [padOpen, setPadOpen] = useState(false);
388
+ // While scrubbing, a value bubble floats above the finger (which occludes the
389
+ // field itself) — { x, y } client coords, or null when not scrubbing.
390
+ const [scrubPos, setScrubPos] = useState(null);
391
+ const boxRef = useRef(null);
392
+ const dragRef = useRef(null);
393
+ function clampVal(v) {
394
+ if (min != null && v < min) v = min;
395
+ if (max != null && v > max) v = max;
396
+ return v;
297
397
  }
298
- function commit(raw) {
299
- let next = Number(raw);
300
- if (!Number.isFinite(next)) next = current;
301
- if (min != null && next < min) next = min;
302
- if (max != null && next > max) next = max;
303
- setDraft(String(next));
304
- onChange(next);
398
+ function scrubTo(v) {
399
+ if (step) v = Math.round(v / step) * step;
400
+ v = clampVal(v);
401
+ if (v !== current) onChange(v);
305
402
  }
306
- function stepBy(delta) {
307
- let next = current + delta;
308
- if (min != null && next < min) next = min;
309
- if (max != null && next > max) next = max;
310
- setDraft(String(next));
311
- onChange(next);
403
+ function onPointerDown(event) {
404
+ if (padOpen) return;
405
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
406
+ // Don't capture or preventDefault yet: `touch-action: pan-y` lets the browser
407
+ // own a VERTICAL drag (scroll the field list); we only claim a HORIZONTAL one.
408
+ dragRef.current = {
409
+ id: event.pointerId,
410
+ startX: event.clientX,
411
+ startY: event.clientY,
412
+ startValue: current,
413
+ mode: 'pending',
414
+ };
312
415
  }
416
+ function onPointerMove(event) {
417
+ const drag = dragRef.current;
418
+ if (!drag || drag.id !== event.pointerId) return;
419
+ const dx = event.clientX - drag.startX;
420
+ const dy = event.clientY - drag.startY;
421
+ if (drag.mode === 'pending') {
422
+ if (Math.abs(dx) < SCRUB_THRESHOLD && Math.abs(dy) < SCRUB_THRESHOLD) return;
423
+ if (Math.abs(dy) >= Math.abs(dx)) {
424
+ drag.mode = 'scroll'; // vertical intent — leave it to the list scroll
425
+ return;
426
+ }
427
+ drag.mode = 'scrub';
428
+ boxRef.current?.setPointerCapture?.(event.pointerId);
429
+ }
430
+ if (drag.mode !== 'scrub') return;
431
+ event.preventDefault();
432
+ setScrubPos({ x: event.clientX, y: event.clientY });
433
+ if (bounded) {
434
+ const rect = boxRef.current.getBoundingClientRect();
435
+ const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
436
+ scrubTo(min + frac * (max - min));
437
+ } else {
438
+ // Acceleration: each pixel counts for more the farther you've dragged.
439
+ const accel = 1 + Math.abs(dx) / SCRUB_ACCEL_RANGE;
440
+ scrubTo(drag.startValue + Math.round((dx / SCRUB_PX_PER_STEP) * accel) * step);
441
+ }
442
+ }
443
+ function onPointerUp(event) {
444
+ const drag = dragRef.current;
445
+ if (!drag || drag.id !== event.pointerId) return;
446
+ if (drag.mode === 'scrub') boxRef.current?.releasePointerCapture?.(event.pointerId);
447
+ const wasTap = drag.mode === 'pending';
448
+ dragRef.current = null;
449
+ setScrubPos(null);
450
+ if (wasTap) setPadOpen(true); // a plain tap opens the numpad
451
+ }
452
+ // A pointercancel = the browser took the gesture (a vertical scroll under pan-y).
453
+ // Just drop the drag; it must NOT be treated as a tap.
454
+ function onPointerCancel() {
455
+ dragRef.current = null;
456
+ setScrubPos(null);
457
+ }
458
+ const fillPct = bounded ? Math.min(100, Math.max(0, ((current - min) / (max - min)) * 100)) : 0;
313
459
  return (
314
460
  <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
315
- <div className={styles.numberField}>
316
- <input
317
- className={cx(styles.input, styles.numberInput)}
318
- type="number"
461
+ <div
462
+ ref={boxRef}
463
+ className={cx(styles.input, styles.numberScrub, bounded ? styles.numberSlider : styles.numberGrip)}
464
+ style={{ touchAction: 'pan-y' }}
465
+ onPointerDown={onPointerDown}
466
+ onPointerMove={onPointerMove}
467
+ onPointerUp={onPointerUp}
468
+ onPointerCancel={onPointerCancel}>
469
+ {bounded ? (
470
+ <>
471
+ <div className={styles.sliderFill} style={{ width: `${fillPct}%` }} aria-hidden="true" />
472
+ <div className={styles.sliderThumb} style={{ left: `${fillPct}%` }} aria-hidden="true" />
473
+ </>
474
+ ) : (
475
+ <span className={styles.scrubGrip} aria-hidden="true">
476
+ ⋮⋮
477
+ </span>
478
+ )}
479
+ <span className={styles.numberValue}>{numberText(current)}</span>
480
+ </div>
481
+ {scrubPos
482
+ ? createPortal(
483
+ <div
484
+ className={styles.scrubBubble}
485
+ style={{ left: scrubPos.x, top: scrubPos.y - 40 }}
486
+ aria-hidden="true">
487
+ {numberText(current)}
488
+ </div>,
489
+ document.body
490
+ )
491
+ : null}
492
+ {padOpen ? (
493
+ <NumpadSheet
494
+ label={label}
495
+ value={current}
319
496
  min={min}
320
497
  max={max}
321
498
  step={step}
322
- value={draft}
323
- onFocus={() => {
324
- editingRef.current = true;
325
- }}
326
- onChange={(event) => setDraft(event.target.value)}
327
- onBlur={(event) => {
328
- editingRef.current = false;
329
- if (cancelRef.current) {
330
- cancelRef.current = false;
331
- setDraft(String(current));
332
- return;
333
- }
334
- commit(event.target.value);
335
- }}
336
- onKeyDown={(event) => {
337
- if (event.key === 'Enter') {
338
- event.preventDefault();
339
- commit(event.currentTarget.value);
340
- event.currentTarget.blur();
341
- } else if (event.key === 'Escape') {
342
- event.preventDefault();
343
- cancelRef.current = true;
344
- setDraft(String(current));
345
- event.currentTarget.blur();
346
- }
499
+ onCommit={(v) => {
500
+ if (v !== current) onChange(v);
347
501
  }}
502
+ onClose={() => setPadOpen(false)}
348
503
  />
349
- <div className={styles.numberSteppers}>
350
- <button
351
- type="button"
352
- className={styles.numberStepper}
353
- tabIndex={-1}
354
- aria-label="Increment"
355
- disabled={max != null && current >= max}
356
- onClick={() => stepBy(step)}>
357
- <Icon name="chevron-up" />
358
- </button>
359
- <button
360
- type="button"
361
- className={styles.numberStepper}
362
- tabIndex={-1}
363
- aria-label="Decrement"
364
- disabled={min != null && current <= min}
365
- onClick={() => stepBy(-step)}>
366
- <Icon name="chevron-down" />
367
- </button>
368
- </div>
369
- </div>
504
+ ) : null}
370
505
  </FieldRow>
371
506
  );
372
507
  }
@@ -494,9 +629,11 @@ function saveStoredSheetHeight(key, px) {
494
629
  // storage full/unavailable -- height still works in-memory this session
495
630
  }
496
631
  }
497
- export function useMobileSheet({ open = true, baseClassName, storageKey }) {
632
+ export function useMobileSheet({ open = true, baseClassName, storageKey, defaultPeek = false }) {
498
633
  const [height, setHeightState] = useState(() =>
499
- clampSheetHeight(loadStoredSheetHeight(storageKey) ?? sheetSizes().expanded)
634
+ clampSheetHeight(
635
+ loadStoredSheetHeight(storageKey) ?? (defaultPeek ? sheetSizes().peek : sheetSizes().expanded)
636
+ )
500
637
  );
501
638
  const [dragging, setDragging] = useState(false);
502
639
  const heightRef = useRef(height);
@@ -1191,6 +1191,160 @@
1191
1191
  border-bottom: 1px solid var(--castle-inspector-divider);
1192
1192
  }
1193
1193
 
1194
+ /* Number fields are drag scrubbers: drag the field to change the value — a bounded
1195
+ field (min AND max) maps the pointer across [min,max] like a slider and shows a
1196
+ fill; an unbounded field scrubs relatively and into negatives — tap to type. The
1197
+ input sits transparent on top and only takes pointer events while editing, so a
1198
+ drag reaches the box; a tap focuses it (JS) to raise the keyboard. */
1199
+ /* Number fields are drag scrubbers with a tap-to-open custom numpad (no native
1200
+ keyboard). A bounded field (min AND max) is a slider with fill + thumb; an
1201
+ unbounded one shows a grip and scrubs into negatives. touch-action: pan-y (set
1202
+ inline) lets a vertical drag scroll the field list while horizontal scrubs. */
1203
+ .numberScrub {
1204
+ position: relative;
1205
+ display: flex;
1206
+ align-items: center;
1207
+ gap: 6px;
1208
+ overflow: hidden;
1209
+ padding: 5px 8px;
1210
+ cursor: ew-resize;
1211
+ user-select: none;
1212
+ -webkit-user-select: none;
1213
+ }
1214
+
1215
+ .numberSlider {
1216
+ cursor: pointer;
1217
+ }
1218
+
1219
+ .numberValue {
1220
+ position: relative;
1221
+ z-index: 1;
1222
+ flex: 1 1 auto;
1223
+ min-width: 0;
1224
+ text-align: right;
1225
+ font-variant-numeric: tabular-nums;
1226
+ }
1227
+
1228
+ .scrubGrip {
1229
+ position: relative;
1230
+ z-index: 1;
1231
+ flex: 0 0 auto;
1232
+ color: var(--castle-inspector-muted);
1233
+ font-size: 12px;
1234
+ line-height: 1;
1235
+ letter-spacing: -2px;
1236
+ opacity: 0.4;
1237
+ }
1238
+
1239
+ /* Value bubble that floats above the finger while scrubbing (the finger covers the
1240
+ field, so the live number needs to show somewhere visible). */
1241
+ .scrubBubble {
1242
+ position: fixed;
1243
+ z-index: 1001;
1244
+ transform: translate(-50%, -100%);
1245
+ padding: 3px 10px;
1246
+ border-radius: 8px;
1247
+ background: rgba(20, 24, 30, 0.96);
1248
+ border: 1px solid var(--castle-inspector-border);
1249
+ color: var(--castle-inspector-text);
1250
+ font-size: 15px;
1251
+ font-variant-numeric: tabular-nums;
1252
+ pointer-events: none;
1253
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
1254
+ }
1255
+
1256
+ .sliderFill {
1257
+ position: absolute;
1258
+ left: 0;
1259
+ top: 0;
1260
+ bottom: 0;
1261
+ background: rgba(90, 140, 210, 0.3);
1262
+ pointer-events: none;
1263
+ z-index: 0;
1264
+ }
1265
+
1266
+ .sliderThumb {
1267
+ position: absolute;
1268
+ top: 50%;
1269
+ width: 3px;
1270
+ height: 60%;
1271
+ border-radius: 2px;
1272
+ background: rgba(150, 190, 240, 0.95);
1273
+ transform: translate(-50%, -50%);
1274
+ pointer-events: none;
1275
+ z-index: 1;
1276
+ }
1277
+
1278
+ /* Custom numeric pad, shown as a bottom sheet. */
1279
+ .numpadBackdrop {
1280
+ position: fixed;
1281
+ inset: 0;
1282
+ z-index: 1000;
1283
+ display: flex;
1284
+ flex-direction: column;
1285
+ justify-content: flex-end;
1286
+ background: rgba(0, 0, 0, 0.35);
1287
+ }
1288
+
1289
+ .numpadSheet {
1290
+ background: var(--castle-inspector-bg, #16181d);
1291
+ border-top: 1px solid var(--castle-inspector-border);
1292
+ border-radius: 14px 14px 0 0;
1293
+ padding: 10px 10px calc(10px + env(safe-area-inset-bottom, 0px));
1294
+ box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
1295
+ }
1296
+
1297
+ .numpadValueRow {
1298
+ display: flex;
1299
+ align-items: baseline;
1300
+ justify-content: space-between;
1301
+ gap: 12px;
1302
+ padding: 6px 8px 12px;
1303
+ }
1304
+
1305
+ .numpadValueLabel {
1306
+ color: var(--castle-inspector-muted);
1307
+ font-size: 13px;
1308
+ }
1309
+
1310
+ .numpadValueNum {
1311
+ color: var(--castle-inspector-text);
1312
+ font-size: 22px;
1313
+ font-variant-numeric: tabular-nums;
1314
+ }
1315
+
1316
+ .numpadGrid {
1317
+ display: grid;
1318
+ grid-template-columns: repeat(4, 1fr);
1319
+ gap: 8px;
1320
+ }
1321
+
1322
+ .numpadKey {
1323
+ height: 52px;
1324
+ border: 1px solid var(--castle-inspector-border);
1325
+ border-radius: 10px;
1326
+ background: var(--castle-inspector-input-bg);
1327
+ color: var(--castle-inspector-text);
1328
+ font-size: 20px;
1329
+ cursor: pointer;
1330
+ touch-action: manipulation;
1331
+ }
1332
+
1333
+ .numpadKey:active {
1334
+ background: var(--castle-inspector-divider);
1335
+ }
1336
+
1337
+ .numpadKeyZero {
1338
+ grid-column: span 3;
1339
+ }
1340
+
1341
+ .numpadKeyDone {
1342
+ background: rgba(90, 140, 210, 0.9);
1343
+ border-color: transparent;
1344
+ color: #fff;
1345
+ font-size: 16px;
1346
+ }
1347
+
1194
1348
  .numberStepper:hover {
1195
1349
  background: var(--castle-inspector-input-bg);
1196
1350
  color: var(--castle-inspector-text);
@@ -1358,6 +1512,14 @@
1358
1512
  display: none;
1359
1513
  }
1360
1514
 
1515
+ /* Stack the tool rail and the undo/redo rail vertically (the shared rule above is
1516
+ a flex row, which pushed the second rail out of the 50px column). */
1517
+ .artboardToolColumn {
1518
+ flex-direction: column;
1519
+ align-items: center;
1520
+ gap: 8px;
1521
+ }
1522
+
1361
1523
  .drawingToolColumn {
1362
1524
  display: flex;
1363
1525
  align-items: flex-start;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.105",
3
+ "version": "0.4.107",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"
@@ -8,7 +8,7 @@
8
8
  "scripts": {
9
9
  "build": "rm -rf dist && tsc && vite build && cp -R src/castle-host dist/castle-host && rm -rf kits && cp -R ../kits kits",
10
10
  "dev": "tsc --watch",
11
- "check": "eslint . && jscpd && tsc --noEmit"
11
+ "check": "eslint . && jscpd && tsc --noEmit && tsc --noEmit -p src/shell/tsconfig.json"
12
12
  },
13
13
  "jscpd": {
14
14
  "path": [