castle-web-cli 0.4.104 → 0.4.106

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.
@@ -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.104",
3
+ "version": "0.4.106",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"