castle-web-cli 0.4.73 → 0.4.74

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.
@@ -43,6 +43,27 @@ const EDIT_VIEWPORT = {
43
43
  originX: cardSize.width,
44
44
  originY: cardSize.height,
45
45
  };
46
+ // Edit-camera zoom bounds. zoom > 1 magnifies (the visible viewport shrinks);
47
+ // zoom < 1 pulls back to show more around the card.
48
+ const MIN_EDIT_ZOOM = 0.4;
49
+ const MAX_EDIT_ZOOM = 6;
50
+ // Wheel-delta -> zoom-factor sensitivity for trackpad pinch (ctrl+wheel).
51
+ const ZOOM_WHEEL_SPEED = 0.01;
52
+ const clampZoom = (zoom) =>
53
+ Math.min(MAX_EDIT_ZOOM, Math.max(MIN_EDIT_ZOOM, Number(zoom) || 1));
54
+ // Derive the effective edit viewport for a zoom level. Both extent and origin
55
+ // scale by 1/zoom, which keeps the canvas draw path, `screenToCard`, and the
56
+ // SelectionOverlay's `sx = box/card` projection mutually consistent (the overlay
57
+ // just multiplies its scale by the same zoom).
58
+ function editViewportForZoom(zoom) {
59
+ const z = clampZoom(zoom);
60
+ return {
61
+ width: EDIT_VIEWPORT.width / z,
62
+ height: EDIT_VIEWPORT.height / z,
63
+ originX: EDIT_VIEWPORT.originX / z,
64
+ originY: EDIT_VIEWPORT.originY / z,
65
+ };
66
+ }
46
67
  export function SceneEditor({
47
68
  path,
48
69
  text,
@@ -60,7 +81,7 @@ export function SceneEditor({
60
81
  const canvasRef = useRef(null);
61
82
  const runtimeRef = useRef(null);
62
83
  const marqueeRef = useRef(null);
63
- const editCameraRef = useRef({ x: 0, y: 0 });
84
+ const editCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
64
85
  const selectedActorIdsRef = useRef(selectedActorIds);
65
86
  selectedActorIdsRef.current = selectedActorIds;
66
87
  const [isPlaying, setIsPlaying] = useState(false);
@@ -215,7 +236,6 @@ export function SceneEditor({
215
236
  ? panGesture.onPointerUp(event)
216
237
  : gesture.onPointerUp(event)
217
238
  }
218
- onWheel={panGesture.onWheel}
219
239
  />
220
240
  {isPlaying && <SceneUI getRuntime={getRuntime} />}
221
241
  </div>
@@ -923,6 +943,10 @@ function usePlayPointerGesture({ canvasRef, runtimeRef }) {
923
943
  function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
924
944
  const dragRef = useRef(null);
925
945
  const spaceDownRef = useRef(false);
946
+ // Native wheel/gesture listeners (below) read play state through a ref so they
947
+ // never need to detach/reattach when play toggles.
948
+ const isPlayingRef = useRef(isPlaying);
949
+ isPlayingRef.current = isPlaying;
926
950
  useEffect(() => {
927
951
  function onKeyDown(event) {
928
952
  if (event.key !== ' ' || isEditableTarget(event.target)) return;
@@ -950,12 +974,104 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
950
974
  const viewportWidth = Number(canvas.dataset.viewportWidth) || cardSize.width;
951
975
  const viewportHeight = Number(canvas.dataset.viewportHeight) || cardSize.height;
952
976
  editCameraRef.current = {
977
+ ...editCameraRef.current,
953
978
  x: editCameraRef.current.x + (dx * viewportWidth) / rect.width,
954
979
  y: editCameraRef.current.y + (dy * viewportHeight) / rect.height,
955
980
  };
956
981
  },
957
982
  [canvasRef, editCameraRef]
958
983
  );
984
+ // Zoom about the pointer by a multiplicative factor: keep the world point
985
+ // under the cursor pinned while the zoom level changes. Because the effective
986
+ // origin scales as EDIT_VIEWPORT.origin / zoom, the camera shift to re-pin the
987
+ // cursor is (1/z0 - 1/z1) * (frac * EDIT_VIEWPORT.extent - EDIT_VIEWPORT.origin).
988
+ const zoomByFactorAtPointer = useCallback(
989
+ (clientX, clientY, factor) => {
990
+ const canvas = canvasRef.current;
991
+ if (!canvas || !Number.isFinite(factor) || factor <= 0) return;
992
+ const cam = editCameraRef.current;
993
+ const z0 = cam.zoom ?? 1;
994
+ const z1 = clampZoom(z0 * factor);
995
+ if (z1 === z0) return;
996
+ const rect = canvas.getBoundingClientRect();
997
+ const fracX = (clientX - rect.left) / rect.width;
998
+ const fracY = (clientY - rect.top) / rect.height;
999
+ const k = 1 / z0 - 1 / z1;
1000
+ editCameraRef.current = {
1001
+ x: cam.x + k * (fracX * EDIT_VIEWPORT.width - EDIT_VIEWPORT.originX),
1002
+ y: cam.y + k * (fracY * EDIT_VIEWPORT.height - EDIT_VIEWPORT.originY),
1003
+ zoom: z1,
1004
+ };
1005
+ },
1006
+ [canvasRef, editCameraRef]
1007
+ );
1008
+ // Wheel + pinch are bound as NATIVE, non-passive listeners (not React props):
1009
+ // React attaches `wheel` passively at its root, so a React onWheel can't call
1010
+ // preventDefault and the browser's own page-zoom wins. We also need Safari's
1011
+ // proprietary `gesture*` events, which is how it reports trackpad pinch (it
1012
+ // does not emit ctrl+wheel like Chromium). Both paths preventDefault to stop
1013
+ // the browser from zooming the page underneath the editor.
1014
+ useEffect(() => {
1015
+ const canvas = canvasRef.current;
1016
+ if (!canvas) return undefined;
1017
+ let gestureScale = 1;
1018
+ // Set while a Safari pinch gesture is in flight, so we don't double-apply
1019
+ // zoom if Safari also emits ctrl+wheel for the same pinch.
1020
+ let gestureActive = false;
1021
+ const onWheel = (event) => {
1022
+ if (isPlayingRef.current) return;
1023
+ event.preventDefault();
1024
+ // Trackpad pinch (and Ctrl+wheel) surface as wheel events with ctrlKey set
1025
+ // in Chromium; route those to zoom and leave plain scroll as pan.
1026
+ if (event.ctrlKey) {
1027
+ if (gestureActive) return;
1028
+ zoomByFactorAtPointer(
1029
+ event.clientX,
1030
+ event.clientY,
1031
+ Math.exp(-event.deltaY * ZOOM_WHEEL_SPEED)
1032
+ );
1033
+ } else {
1034
+ panByScreenDelta(event.deltaX, event.deltaY);
1035
+ }
1036
+ };
1037
+ const onGestureStart = (event) => {
1038
+ if (isPlayingRef.current) return;
1039
+ event.preventDefault();
1040
+ gestureActive = true;
1041
+ gestureScale = event.scale || 1;
1042
+ };
1043
+ const onGestureChange = (event) => {
1044
+ if (isPlayingRef.current) return;
1045
+ event.preventDefault();
1046
+ // Safari reports `scale` cumulatively from gesturestart (1.0 at start), so
1047
+ // zoom by the ratio against the last reading.
1048
+ const prev = gestureScale || 1;
1049
+ const next = event.scale || prev;
1050
+ gestureScale = next;
1051
+ // GestureEvent carries clientX/clientY (the pinch centroid); fall back to
1052
+ // the canvas center if a browser omits them.
1053
+ const rect = canvas.getBoundingClientRect();
1054
+ const px = Number.isFinite(event.clientX) ? event.clientX : rect.left + rect.width / 2;
1055
+ const py = Number.isFinite(event.clientY) ? event.clientY : rect.top + rect.height / 2;
1056
+ zoomByFactorAtPointer(px, py, next / prev);
1057
+ };
1058
+ const onGestureEnd = (event) => {
1059
+ if (isPlayingRef.current) return;
1060
+ event.preventDefault();
1061
+ gestureActive = false;
1062
+ };
1063
+ const opts = { passive: false };
1064
+ canvas.addEventListener('wheel', onWheel, opts);
1065
+ canvas.addEventListener('gesturestart', onGestureStart, opts);
1066
+ canvas.addEventListener('gesturechange', onGestureChange, opts);
1067
+ canvas.addEventListener('gestureend', onGestureEnd, opts);
1068
+ return () => {
1069
+ canvas.removeEventListener('wheel', onWheel, opts);
1070
+ canvas.removeEventListener('gesturestart', onGestureStart, opts);
1071
+ canvas.removeEventListener('gesturechange', onGestureChange, opts);
1072
+ canvas.removeEventListener('gestureend', onGestureEnd, opts);
1073
+ };
1074
+ }, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
959
1075
  const onPointerDown = useCallback((event) => {
960
1076
  if (isPlaying || !spaceDownRef.current) return;
961
1077
  event.preventDefault();
@@ -984,17 +1100,9 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
984
1100
  }
985
1101
  dragRef.current = null;
986
1102
  }, []);
987
- const onWheel = useCallback(
988
- (event) => {
989
- if (isPlaying) return;
990
- event.preventDefault();
991
- panByScreenDelta(event.deltaX, event.deltaY);
992
- },
993
- [isPlaying, panByScreenDelta]
994
- );
995
1103
  const isSpacePanning = useCallback(() => spaceDownRef.current, []);
996
1104
  const isActive = useCallback(() => Boolean(dragRef.current), []);
997
- return { onPointerDown, onPointerMove, onPointerUp, onWheel, isSpacePanning, isActive };
1105
+ return { onPointerDown, onPointerMove, onPointerUp, isSpacePanning, isActive };
998
1106
  }
999
1107
  function handleShiftPointerDown(drag, actor, current) {
1000
1108
  if (actor) {
@@ -1149,13 +1257,20 @@ function useScenePlayLoop({
1149
1257
  const canvas = canvasRef.current;
1150
1258
  const ctx = canvas.getContext('2d');
1151
1259
  if (!ctx) return undefined;
1152
- const viewport = isPlaying ? undefined : EDIT_VIEWPORT;
1260
+ const viewport = isPlaying
1261
+ ? undefined
1262
+ : editViewportForZoom(editCameraRef.current.zoom);
1153
1263
  configureSceneCanvas(canvas, ctx, viewport);
1154
1264
  let raf = 0;
1155
1265
  let last = performance.now();
1156
1266
  const frame = (now) => {
1157
1267
  const dt = Math.min(0.033, (now - last) / 1000);
1158
1268
  last = now;
1269
+ // Recompute each frame so the edit zoom (mutated on the camera ref by the
1270
+ // pinch gesture) takes effect without restarting the loop.
1271
+ const frameViewport = isPlaying
1272
+ ? undefined
1273
+ : editViewportForZoom(editCameraRef.current.zoom);
1159
1274
  const scene = isPlaying
1160
1275
  ? runtimeRef.current
1161
1276
  : makeScene(sceneData, behaviorClasses, sprites);
@@ -1163,7 +1278,7 @@ function useScenePlayLoop({
1163
1278
  const snap = getSnapSettings(sceneData);
1164
1279
  if (isPlaying) scene.update(dt);
1165
1280
  if (!isPlaying) scene.camera = { ...editCameraRef.current };
1166
- configureSceneCanvas(canvas, ctx, viewport);
1281
+ configureSceneCanvas(canvas, ctx, frameViewport);
1167
1282
  // Read selection from the ref each frame so the grid/box gating tracks
1168
1283
  // the current selection without resetting this loop.
1169
1284
  const editSelectedActorIds = isPlaying ? [] : selectedActorIdsRef.current ?? [];
@@ -1174,7 +1289,7 @@ function useScenePlayLoop({
1174
1289
  gridSize: snap.gridSize,
1175
1290
  showDebugColliders: false,
1176
1291
  showCropOutline: !isPlaying,
1177
- viewport,
1292
+ viewport: frameViewport,
1178
1293
  useCamera: true,
1179
1294
  editPlaceholders: !isPlaying,
1180
1295
  editSelectedActorIds,
@@ -63,7 +63,7 @@ export function SelectionOverlay({
63
63
  const rootRef = useRef(null);
64
64
  // Track the canvas-sized overlay box so the card-unit layer scales onto it.
65
65
  const box = useElementSize(rootRef);
66
- const [camera, setCamera] = useState({ x: 0, y: 0 });
66
+ const [camera, setCamera] = useState({ x: 0, y: 0, zoom: 1 });
67
67
  const [groupFrameRotation, setGroupFrameRotation] = useState(null);
68
68
  const [arrangeOpen, setArrangeOpen] = useState(false);
69
69
  const selectionKey = [...selectedActorIds].sort().join('|');
@@ -73,8 +73,13 @@ export function SelectionOverlay({
73
73
  useEffect(() => {
74
74
  let raf = 0;
75
75
  const tick = () => {
76
- const cam = editCameraRef.current ?? { x: 0, y: 0 };
77
- setCamera((prev) => (prev.x === cam.x && prev.y === cam.y ? prev : { x: cam.x, y: cam.y }));
76
+ const cam = editCameraRef.current ?? { x: 0, y: 0, zoom: 1 };
77
+ const zoom = cam.zoom ?? 1;
78
+ setCamera((prev) =>
79
+ prev.x === cam.x && prev.y === cam.y && prev.zoom === zoom
80
+ ? prev
81
+ : { x: cam.x, y: cam.y, zoom }
82
+ );
78
83
  raf = requestAnimationFrame(tick);
79
84
  };
80
85
  raf = requestAnimationFrame(tick);
@@ -334,8 +339,13 @@ function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
334
339
  }
335
340
 
336
341
  function getOverlayGeometry(frame, box, camera) {
337
- const sx = box.width / cardSize.width;
338
- const sy = box.height / cardSize.height;
342
+ // Fold the edit zoom into the card-unit -> screen-px scale. The canvas draws
343
+ // the card at (box / card) * zoom px-per-unit (the effective viewport extent
344
+ // and origin both scale by 1/zoom), and the chrome layer's `transform-origin:
345
+ // 0 0` projection from world (0,0) matches once sx/sy carry the same zoom.
346
+ const zoom = camera.zoom ?? 1;
347
+ const sx = (box.width / cardSize.width) * zoom;
348
+ const sy = (box.height / cardSize.height) * zoom;
339
349
  const camX = Math.round(camera.x);
340
350
  const camY = Math.round(camera.y);
341
351
  const rotation = frame.rotation;
@@ -1,7 +1,10 @@
1
- import React, { useCallback, useEffect, useRef } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
2
3
  import { configureSceneCanvas, makeScene } from './scene';
3
4
  import { SceneUI } from './SceneUI';
4
5
  import { TouchControls } from './TouchControls';
6
+ import { cx, Icon, styles } from './ui';
7
+ import { usePlayLogs } from './playConsole';
5
8
  // Engine-level scene player: mount a `SceneRuntime` against a canvas, wire
6
9
  // keyboard / pointer input, run the update+draw loop, and render the
7
10
  // behavior-driven UI overlay. No game logic lives here -- behaviors and
@@ -9,8 +12,12 @@ import { TouchControls } from './TouchControls';
9
12
  export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame }) {
10
13
  const canvasRef = useRef(null);
11
14
  const runtimeRef = useRef(null);
15
+ // Bumping this re-runs the mount effect below, which tears down the running
16
+ // loop/runtime and rebuilds a fresh scene from the same data -- a clean restart.
17
+ const [runToken, setRunToken] = useState(0);
12
18
  const getKeys = useCallback(() => runtimeRef.current?.keys ?? null, []);
13
19
  const getRuntime = useCallback(() => runtimeRef.current, []);
20
+ const restart = useCallback(() => setRunToken((token) => token + 1), []);
14
21
  useEffect(() => {
15
22
  const canvas = canvasRef.current;
16
23
  if (!canvas) return undefined;
@@ -56,7 +63,9 @@ export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame
56
63
  canvas.addEventListener('pointerup', onPointerUp);
57
64
  canvas.addEventListener('pointercancel', onPointerUp);
58
65
  canvas.focus();
59
- const stopLoop = startPlayerLoop(canvas, ctx, runtime, onFirstFrame);
66
+ // Only signal first-frame readiness on the initial run; a restart shouldn't
67
+ // re-fire the host launcher reveal.
68
+ const stopLoop = startPlayerLoop(canvas, ctx, runtime, runToken === 0 ? onFirstFrame : undefined);
60
69
  return () => {
61
70
  stopLoop();
62
71
  window.removeEventListener('keydown', onKeyDown);
@@ -68,16 +77,157 @@ export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame
68
77
  runtimeRef.current = null;
69
78
  };
70
79
  // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [runToken]);
81
+ return (
82
+ <>
83
+ <div style={{ position: 'fixed', inset: 0, background: '#000' }}>
84
+ <canvas
85
+ ref={canvasRef}
86
+ tabIndex={0}
87
+ style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
88
+ />
89
+ <SceneUI getRuntime={getRuntime} />
90
+ <TouchControls getKeys={getKeys} />
91
+ </div>
92
+ {/* Portal to <body> so the console drawer escapes the SDK's card-sized,
93
+ transformed `#root > *` wrapper and pins to the panel, outside the
94
+ play preview. */}
95
+ {createPortal(<PlayConsole onRestart={restart} />, document.body)}
96
+ </>
97
+ );
98
+ }
99
+ function PlayConsole({ onRestart }) {
100
+ const { logs, clearLogs } = usePlayLogs();
101
+ const [open, setOpen] = useState(false);
102
+ const rootRef = useRef(null);
103
+ const bodyRef = useRef(null);
104
+ // Reserve the drawer's footprint by shrinking + lifting the SDK deck card so it
105
+ // sits fully above the drawer (visible + interactive) instead of behind it.
106
+ // Driven by the live drawer height; recomputed on toggle (height change via the
107
+ // ResizeObserver) and on window/card resize.
108
+ useEffect(() => {
109
+ const el = rootRef.current;
110
+ if (!el) return undefined;
111
+ const docEl = document.documentElement;
112
+ docEl.dataset.castleLogs = '';
113
+ const TOP_MARGIN = 16;
114
+ const MIN_CARD_SCALE = 0.2;
115
+ const apply = () => {
116
+ const drawerHeight = el.offsetHeight;
117
+ const cs = getComputedStyle(docEl);
118
+ const fullW = parseFloat(cs.getPropertyValue('--castle-card-w'));
119
+ const fullH = parseFloat(cs.getPropertyValue('--castle-card-h'));
120
+ docEl.style.setProperty('--castle-logs-shift', `${drawerHeight / 2}px`);
121
+ // Only resize the card when we know its natural size; otherwise leave it to
122
+ // the SDK fallback (the rule's var() defaults handle this).
123
+ if (fullW > 0 && fullH > 0) {
124
+ const available = window.innerHeight - drawerHeight - TOP_MARGIN;
125
+ // Floor the scale so a very short panel (drawer taller than the area)
126
+ // can't drive the card to 0/negative and make the deck disappear.
127
+ const scale = Math.min(1, Math.max(MIN_CARD_SCALE, available / fullH));
128
+ docEl.style.setProperty('--castle-logs-card-w', `${fullW * scale}px`);
129
+ docEl.style.setProperty('--castle-logs-card-h', `${fullH * scale}px`);
130
+ } else {
131
+ docEl.style.removeProperty('--castle-logs-card-w');
132
+ docEl.style.removeProperty('--castle-logs-card-h');
133
+ }
134
+ };
135
+ apply();
136
+ const observer = new ResizeObserver(apply);
137
+ observer.observe(el);
138
+ window.addEventListener('resize', apply);
139
+ return () => {
140
+ observer.disconnect();
141
+ window.removeEventListener('resize', apply);
142
+ delete docEl.dataset.castleLogs;
143
+ docEl.style.removeProperty('--castle-logs-card-w');
144
+ docEl.style.removeProperty('--castle-logs-card-h');
145
+ docEl.style.removeProperty('--castle-logs-shift');
146
+ };
71
147
  }, []);
148
+ // Keep the newest line in view while expanded.
149
+ useEffect(() => {
150
+ if (open && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
151
+ }, [logs, open]);
152
+ const copy = useCallback(() => {
153
+ const text = logs.map((line) => line.text).join('\n');
154
+ navigator.clipboard?.writeText(text).catch(() => {});
155
+ }, [logs]);
156
+ const hasLogs = logs.length > 0;
72
157
  return (
73
- <div style={{ position: 'fixed', inset: 0, background: '#000' }}>
74
- <canvas
75
- ref={canvasRef}
76
- tabIndex={0}
77
- style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
78
- />
79
- <SceneUI getRuntime={getRuntime} />
80
- <TouchControls getKeys={getKeys} />
158
+ <div ref={rootRef} className={styles.playConsole}>
159
+ {/* The whole header row toggles; the action buttons stopPropagation so
160
+ they don't also collapse/expand. */}
161
+ <div
162
+ className={styles.playConsoleHeader}
163
+ role="button"
164
+ tabIndex={-1}
165
+ aria-expanded={open}
166
+ aria-label={open ? 'Collapse logs' : 'Expand logs'}
167
+ onClick={() => setOpen((value) => !value)}>
168
+ <span className={styles.playConsoleToggle}>
169
+ <span className={styles.playConsoleCaret}>
170
+ <Icon name={open ? 'chevron-down' : 'chevron-right'} />
171
+ </span>
172
+ <span className={styles.playConsoleTitle}>Logs</span>
173
+ </span>
174
+ <div
175
+ className={styles.playConsoleActions}
176
+ onClick={(event) => event.stopPropagation()}>
177
+ {open ? (
178
+ <>
179
+ <button
180
+ type="button"
181
+ tabIndex={-1}
182
+ className={styles.playConsoleBtn}
183
+ aria-label="Copy logs"
184
+ title="Copy logs"
185
+ disabled={!hasLogs}
186
+ onClick={copy}>
187
+ <Icon name="clone" />
188
+ </button>
189
+ <button
190
+ type="button"
191
+ tabIndex={-1}
192
+ className={styles.playConsoleBtn}
193
+ aria-label="Clear logs"
194
+ title="Clear logs"
195
+ disabled={!hasLogs}
196
+ onClick={clearLogs}>
197
+ <Icon name="trash" />
198
+ </button>
199
+ </>
200
+ ) : null}
201
+ <button
202
+ type="button"
203
+ tabIndex={-1}
204
+ className={styles.playConsoleBtn}
205
+ aria-label="Restart"
206
+ title="Restart"
207
+ onClick={onRestart}>
208
+ <Icon name="rotate" />
209
+ </button>
210
+ </div>
211
+ </div>
212
+ {open ? (
213
+ <div ref={bodyRef} className={styles.playConsoleBody}>
214
+ {hasLogs ? (
215
+ logs.map((line) => (
216
+ <div
217
+ key={line.id}
218
+ className={cx(
219
+ styles.playConsoleLine,
220
+ line.level === 'warn' && styles.playConsoleLineWarn,
221
+ line.level === 'error' && styles.playConsoleLineError
222
+ )}>
223
+ {line.text}
224
+ </div>
225
+ ))
226
+ ) : (
227
+ <div className={styles.playConsoleEmpty}>no output</div>
228
+ )}
229
+ </div>
230
+ ) : null}
81
231
  </div>
82
232
  );
83
233
  }
@@ -0,0 +1,66 @@
1
+ import { useEffect, useState } from 'react';
2
+ // Captures console output from the running deck into an in-memory ring buffer and
3
+ // notifies subscribers, so the on-panel play console can display it.
4
+ //
5
+ // The SDK already wraps console (to forward logs to the dev server over its
6
+ // websocket); this wraps ON TOP and always calls through, so that forwarding and
7
+ // the real devtools output are preserved. Install is idempotent.
8
+ const MAX_LINES = 500;
9
+ let buffer = [];
10
+ let nextId = 1;
11
+ let installed = false;
12
+ const subscribers = new Set();
13
+ function formatArgs(args) {
14
+ return args
15
+ .map((arg) => {
16
+ if (typeof arg === 'string') return arg;
17
+ if (arg instanceof Error) return arg.stack || arg.message;
18
+ try {
19
+ const json = JSON.stringify(arg);
20
+ return json ?? String(arg);
21
+ } catch {
22
+ return String(arg);
23
+ }
24
+ })
25
+ .join(' ');
26
+ }
27
+ function emit(level, args) {
28
+ const entry = { id: nextId++, level, text: formatArgs(args) };
29
+ const next = buffer.concat(entry);
30
+ buffer = next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next;
31
+ for (const cb of subscribers) cb(buffer);
32
+ }
33
+ export function installConsoleCapture() {
34
+ if (installed || typeof console === 'undefined') return;
35
+ installed = true;
36
+ for (const level of ['log', 'warn', 'error']) {
37
+ const original = typeof console[level] === 'function' ? console[level].bind(console) : null;
38
+ console[level] = (...args) => {
39
+ if (original) original(...args);
40
+ // Never let capture throw into the caller's logging path.
41
+ try {
42
+ emit(level, args);
43
+ } catch {
44
+ // ignore
45
+ }
46
+ };
47
+ }
48
+ }
49
+ export function subscribeLogs(callback) {
50
+ subscribers.add(callback);
51
+ callback(buffer);
52
+ return () => subscribers.delete(callback);
53
+ }
54
+ export function clearLogs() {
55
+ buffer = [];
56
+ for (const cb of subscribers) cb(buffer);
57
+ }
58
+ // React hook: install capture (idempotent) and track the live log buffer.
59
+ export function usePlayLogs() {
60
+ const [logs, setLogs] = useState(buffer);
61
+ useEffect(() => {
62
+ installConsoleCapture();
63
+ return subscribeLogs(setLogs);
64
+ }, []);
65
+ return { logs, clearLogs };
66
+ }
@@ -1901,3 +1901,120 @@
1901
1901
  display: flex;
1902
1902
  }
1903
1903
  }
1904
+
1905
+ /* On-panel play console: a collapsible log drawer pinned to the bottom of the
1906
+ play panel (portaled to <body> so it sits outside the SDK's card wrapper).
1907
+ Houses the restart control plus copy/clear log affordances. */
1908
+ .playConsole {
1909
+ position: fixed;
1910
+ left: 0;
1911
+ right: 0;
1912
+ bottom: 0;
1913
+ z-index: 60;
1914
+ display: flex;
1915
+ flex-direction: column;
1916
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
1917
+ background: rgba(13, 13, 13, 0.94);
1918
+ color: #e6e6e6;
1919
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
1920
+ 'Courier New', monospace;
1921
+ padding-bottom: env(safe-area-inset-bottom, 0px);
1922
+ overflow: hidden;
1923
+ }
1924
+ .playConsoleHeader {
1925
+ display: flex;
1926
+ align-items: center;
1927
+ justify-content: space-between;
1928
+ gap: 8px;
1929
+ padding: 7px 8px 7px 10px;
1930
+ cursor: pointer;
1931
+ }
1932
+ .playConsoleHeader:hover {
1933
+ background: rgba(255, 255, 255, 0.04);
1934
+ }
1935
+ .playConsoleToggle {
1936
+ display: flex;
1937
+ align-items: center;
1938
+ gap: 8px;
1939
+ }
1940
+ .playConsoleCaret {
1941
+ display: inline-flex;
1942
+ font-size: 11px;
1943
+ color: #8a8a8a;
1944
+ }
1945
+ .playConsoleTitle {
1946
+ font-size: 11px;
1947
+ font-weight: 700;
1948
+ letter-spacing: 1px;
1949
+ text-transform: uppercase;
1950
+ color: #b3b3b3;
1951
+ }
1952
+ .playConsoleActions {
1953
+ display: flex;
1954
+ align-items: center;
1955
+ gap: 6px;
1956
+ }
1957
+ .playConsoleBtn {
1958
+ display: inline-flex;
1959
+ align-items: center;
1960
+ justify-content: center;
1961
+ width: 30px;
1962
+ height: 28px;
1963
+ padding: 0;
1964
+ font-size: 13px;
1965
+ color: #cfcfcf;
1966
+ background: transparent;
1967
+ border: 1px solid rgba(255, 255, 255, 0.14);
1968
+ border-radius: 6px;
1969
+ cursor: pointer;
1970
+ }
1971
+ .playConsoleBtn:hover {
1972
+ background: rgba(255, 255, 255, 0.08);
1973
+ color: #fff;
1974
+ }
1975
+ .playConsoleBtn:disabled {
1976
+ opacity: 0.38;
1977
+ cursor: default;
1978
+ }
1979
+ .playConsoleBtn:disabled:hover {
1980
+ background: transparent;
1981
+ color: #cfcfcf;
1982
+ }
1983
+ .playConsoleBody {
1984
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
1985
+ height: 165px;
1986
+ overflow-y: auto;
1987
+ padding: 8px 10px;
1988
+ font-size: 12px;
1989
+ line-height: 1.5;
1990
+ }
1991
+ .playConsoleEmpty {
1992
+ color: #6a6a6a;
1993
+ }
1994
+ .playConsoleLine {
1995
+ white-space: pre-wrap;
1996
+ word-break: break-word;
1997
+ color: #c8c8c8;
1998
+ }
1999
+ .playConsoleLineWarn {
2000
+ color: #e0af68;
2001
+ }
2002
+ .playConsoleLineError {
2003
+ color: #ff7a93;
2004
+ }
2005
+
2006
+ /* While the logs drawer is mounted, scale the deck card to fit ABOVE the drawer
2007
+ (rather than sitting behind it) so play stays fully visible and interactive.
2008
+ The SDK's initPlayCard() pins `#root > *` as a viewport-centered card with
2009
+ !important; this higher-specificity (attribute + id) rule overrides its size
2010
+ and vertical offset. The three custom properties are driven from JS in
2011
+ PlayConsole using the live drawer height + the SDK's natural card size. */
2012
+ :global(html[data-castle-logs] #root > *) {
2013
+ width: var(--castle-logs-card-w, var(--castle-card-w, 100vw)) !important;
2014
+ height: var(--castle-logs-card-h, var(--castle-card-h, 100vh)) !important;
2015
+ transform: translate(-50%, calc(-50% - var(--castle-logs-shift, 0px))) !important;
2016
+ transition:
2017
+ transform 0.18s ease,
2018
+ width 0.18s ease,
2019
+ height 0.18s ease;
2020
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.73",
3
+ "version": "0.4.74",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"