@downdraft/engine 0.1.2-alpha.5 → 0.3.0

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 (97) hide show
  1. package/app/src/renderer/bootstrap.ts +1 -1
  2. package/app/src/renderer/feature-log.ts +1 -1
  3. package/core/src/index.ts +2 -2
  4. package/core/src/input/local-player-manager.ts +2 -2
  5. package/core/src/platform/rpc.ts +4 -1
  6. package/core/src/render/game-renderer.ts +127 -64
  7. package/core/src/sab/grid-buffer.ts +55 -2
  8. package/core/src/sab/record-buffer.ts +48 -5
  9. package/core/src/sab/sim-channel.ts +181 -17
  10. package/core/src/sab/slot-buffer.ts +55 -1
  11. package/core/src/sab/types.ts +11 -0
  12. package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
  13. package/core/src/util/index.ts +1 -0
  14. package/core/src/util/logger.ts +2 -2
  15. package/core/src/util/session-timer.ts +23 -0
  16. package/core/src/worker/base-worker-host.ts +2 -1
  17. package/core/src/worker/buffer-sync.ts +20 -13
  18. package/core/src/worker/rpc.ts +54 -5
  19. package/core/src/worker/sim-worker-base.ts +6 -3
  20. package/core/src/worker/task-pool.ts +4 -0
  21. package/dist/.dd-manifest.json +879 -869
  22. package/dist/app/src/renderer/bootstrap.js +1 -1
  23. package/dist/app/src/renderer/feature-log.js +1 -1
  24. package/dist/core/src/index.js +2 -2
  25. package/dist/core/src/input/local-player-manager.js +2 -2
  26. package/dist/core/src/platform/rpc.js +2 -1
  27. package/dist/core/src/render/game-renderer.js +125 -59
  28. package/dist/core/src/sab/grid-buffer.js +53 -1
  29. package/dist/core/src/sab/record-buffer.js +49 -5
  30. package/dist/core/src/sab/sim-channel.js +173 -17
  31. package/dist/core/src/sab/slot-buffer.js +54 -1
  32. package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
  33. package/dist/core/src/util/index.js +1 -0
  34. package/dist/core/src/util/logger.js +2 -2
  35. package/dist/core/src/util/session-timer.js +22 -0
  36. package/dist/core/src/worker/base-worker-host.js +2 -1
  37. package/dist/core/src/worker/buffer-sync.js +16 -9
  38. package/dist/core/src/worker/rpc.js +46 -8
  39. package/dist/core/src/worker/sim-worker-base.js +6 -3
  40. package/dist/core/src/worker/task-pool.js +5 -0
  41. package/dist/libraries/devtools/src/backend.js +35 -9
  42. package/dist/libraries/devtools/src/collectors.js +73 -22
  43. package/dist/libraries/gamepad/src/feed-input.js +1 -1
  44. package/dist/libraries/gamepad/src/hub.js +2 -2
  45. package/dist/libraries/gamepad/src/source-sab.js +2 -2
  46. package/dist/libraries/gamepad/src/sysfs-enrich.js +2 -2
  47. package/dist/libraries/gpu-kernels/src/kernel.js +1 -1
  48. package/dist/libraries/gpu-kernels/src/transpile.js +1 -1
  49. package/dist/libraries/html-ui-kit/src/nav/nav-controller.js +1 -1
  50. package/dist/libraries/html-ui-kit/src/nav/nav-input.js +2 -2
  51. package/dist/libraries/networking/src/discovery.js +8 -8
  52. package/dist/libraries/persistence/src/file-save-store.js +5 -5
  53. package/dist/libraries/persistence/src/opfs-save-store.js +2 -2
  54. package/dist/modules/controller-ui/src/index.js +1 -1
  55. package/dist/modules/devtools/src/blitz/fonts.js +75 -0
  56. package/dist/modules/devtools/src/blitz/host.js +127 -33
  57. package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
  58. package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
  59. package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
  60. package/dist/modules/devtools/src/blitz/panels/performance.js +28 -14
  61. package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
  62. package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
  63. package/dist/modules/devtools/src/blitz/theme.js +26 -4
  64. package/dist/modules/html-ui/src/doc-backend.js +52 -3
  65. package/dist/modules/html-ui/src/host.js +6 -3
  66. package/dist/modules/html-ui/src/jsx-runtime.js +1 -1
  67. package/libraries/blitz-ui/native/Cargo.toml +6 -0
  68. package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
  69. package/libraries/devtools/src/backend.ts +38 -12
  70. package/libraries/devtools/src/collectors.ts +75 -22
  71. package/libraries/gamepad/src/feed-input.ts +1 -1
  72. package/libraries/gamepad/src/hub.ts +2 -2
  73. package/libraries/gamepad/src/source-sab.ts +2 -2
  74. package/libraries/gamepad/src/sysfs-enrich.ts +2 -2
  75. package/libraries/gpu-kernels/src/kernel.ts +2 -2
  76. package/libraries/gpu-kernels/src/transpile.ts +1 -1
  77. package/libraries/html-ui-kit/src/nav/nav-controller.ts +1 -1
  78. package/libraries/html-ui-kit/src/nav/nav-input.ts +2 -2
  79. package/libraries/networking/src/discovery.ts +8 -8
  80. package/libraries/persistence/src/file-save-store.ts +5 -5
  81. package/libraries/persistence/src/opfs-save-store.ts +2 -2
  82. package/libraries/physics-rapier/src/realm-worker-script.ts +1 -1
  83. package/modules/controller-ui/src/index.ts +1 -1
  84. package/modules/devtools/src/blitz/fonts.ts +78 -0
  85. package/modules/devtools/src/blitz/host.ts +110 -34
  86. package/modules/devtools/src/blitz/panels/console.ts +20 -8
  87. package/modules/devtools/src/blitz/panels/elements.ts +212 -31
  88. package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
  89. package/modules/devtools/src/blitz/panels/performance.ts +26 -14
  90. package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
  91. package/modules/devtools/src/blitz/panels/types.ts +2 -1
  92. package/modules/devtools/src/blitz/theme.ts +26 -4
  93. package/modules/html-ui/src/doc-backend.ts +67 -4
  94. package/modules/html-ui/src/host.ts +11 -3
  95. package/modules/html-ui/src/jsx-runtime.ts +1 -1
  96. package/modules/html-ui/src/protocol.ts +1 -1
  97. package/package.json +2 -2
@@ -115,8 +115,8 @@ export class ConsolePanel implements DtPanel {
115
115
 
116
116
  onBackendEvent(event: string, data: unknown): void {
117
117
  if (event === "console") {
118
- const e = data as { text?: string; severity?: number; thread?: string };
119
- this.rows.push({ text: e.text ?? "", severity: e.severity ?? 0, thread: e.thread ?? "main", seq: this.seq++ });
118
+ const e = data as { text?: unknown; severity?: number; thread?: string };
119
+ this.rows.push({ text: String(e?.text ?? ""), severity: e?.severity ?? 0, thread: e?.thread ?? "main", seq: this.seq++ });
120
120
  if (this.rows.length > MAX_ROWS) this.rows.splice(0, this.rows.length - MAX_ROWS);
121
121
  this.dirty = true;
122
122
  } else if (event === "console.clear") {
@@ -125,15 +125,24 @@ export class ConsolePanel implements DtPanel {
125
125
  this.dirty = true;
126
126
  } else if (event === "threads") {
127
127
  const list = (data as ThreadInfo[]) ?? [];
128
- this.threads = [{ id: "main", name: "main", kind: 0 }, ...list.filter((t) => t.id !== "main")];
129
- if (!this.threads.some((t) => t.id === this.replThread)) this.replThread = "main";
128
+ const next = [{ id: "main", name: "main", kind: 0 }, ...list.filter((t) => t.id !== "main")];
129
+ // Thread chips live in the bottom chrome — flag it so the row
130
+ // actually re-renders when a worker appears or disappears.
131
+ const changed = next.length !== this.threads.length
132
+ || next.some((t, i) => t.id !== this.threads[i]!.id || t.name !== this.threads[i]!.name);
133
+ this.threads = next;
134
+ if (!this.threads.some((t) => t.id === this.replThread)) {
135
+ this.replThread = "main";
136
+ this.shellDirty = true;
137
+ }
138
+ if (changed) this.shellDirty = true;
130
139
  }
131
140
  }
132
141
 
133
142
  onAction(data: Record<string, string>, _ev: OsrDomEvent): void {
134
143
  const act = data.action;
135
144
  if (act === "console.clear") {
136
- void this.ctx.call("console.clear");
145
+ void this.ctx.call("console.clear").catch(() => {});
137
146
  } else if (act === "console.sev") {
138
147
  this.minSev = (this.minSev + 1) % SEV_NAMES.length;
139
148
  this.needsFullRender = true;
@@ -176,8 +185,11 @@ export class ConsolePanel implements DtPanel {
176
185
  } else if (ev.k === "ArrowUp" && this.histIdx > 0) {
177
186
  this.replValue = this.history[--this.histIdx]!;
178
187
  this.ctx.setValue("[data-dt=repl]", this.replValue);
179
- } else if (ev.k === "ArrowDown" && this.histIdx < this.history.length - 1) {
180
- this.replValue = this.history[++this.histIdx]!;
188
+ } else if (ev.k === "ArrowDown" && this.histIdx >= 0 && this.histIdx < this.history.length) {
189
+ // Stepping past the newest entry restores the empty input —
190
+ // previously the last history item was unreachable-to-leave.
191
+ this.histIdx++;
192
+ this.replValue = this.histIdx < this.history.length ? this.history[this.histIdx]! : "";
181
193
  this.ctx.setValue("[data-dt=repl]", this.replValue);
182
194
  }
183
195
  }
@@ -212,7 +224,7 @@ export class ConsolePanel implements DtPanel {
212
224
  } else {
213
225
  const r = res?.result;
214
226
  this.addRow({
215
- text: typeof r === "string" ? r : JSON.stringify(r, null, 2),
227
+ text: typeof r === "string" ? r : (r === undefined ? "undefined" : JSON.stringify(r, null, 2)),
216
228
  severity: 0, thread: this.replThread,
217
229
  });
218
230
  }
@@ -1,15 +1,19 @@
1
1
  // ============================================================================
2
- // panels/elements.ts — Elements panel: scene graph / DOM / ECS entity tree.
3
- // Merges the web frontend's scene + dom tabs behind a mode switch, matching
4
- // the Chrome Elements panel's single-tree presentation.
2
+ // panels/elements.ts — Elements panel: scene graph / ECS entity tree.
3
+ //
4
+ // Clicking a row selects it (Chrome-style): the .sel class flips via
5
+ // incremental attr mutation so the scrollport survives, and a details strip
6
+ // in the bottom chrome shows the row fields plus `getNodeJSON` output when
7
+ // the node has a stable id and the scene inspector is live. The caret on
8
+ // parent rows folds/unfolds the subtree.
5
9
  // ============================================================================
6
10
 
7
11
  import type { TreeRow } from "@downdraft/engine/libraries/devtools";
8
- import type { OsrDomEvent } from "@downdraft/engine/modules/html-ui";
12
+ import type { DocMutation, OsrDomEvent } from "@downdraft/engine/modules/html-ui";
9
13
  import { esc, type DtPanel, type DtPanelCtx } from "./types";
10
14
 
11
- type Mode = "scene" | "dom" | "ecs";
12
- const MODE_LABEL: Record<Mode, string> = { scene: "Scene graph", dom: "DOM", ecs: "ECS entities" };
15
+ type Mode = "scene" | "ecs";
16
+ const MODE_LABEL: Record<Mode, string> = { scene: "Scene graph", ecs: "ECS entities" };
13
17
  const MAX_ROWS = 4000;
14
18
 
15
19
  export class ElementsPanel implements DtPanel {
@@ -21,6 +25,21 @@ export class ElementsPanel implements DtPanel {
21
25
  private ctx: DtPanelCtx;
22
26
  private mode: Mode = "scene";
23
27
  private rows: TreeRow[] = [];
28
+ /** Stable per-row key (nodeId or label path), parallel to rows. */
29
+ private keys: string[] = [];
30
+ private collapsed = new Set<string>();
31
+ /** Logical selection — survives refreshes via keys. */
32
+ private selKey: string | null = null;
33
+ /** Ephemeral DOM id of the row currently carrying .sel — lets pick flip
34
+ * the class without a body reparse. Stale after a refresh; harmless —
35
+ * renderBody re-emits .sel from selKey. */
36
+ private selRowId: number | null = null;
37
+ private details: { title: string; body: string } | null = null;
38
+ private lastError = "";
39
+ private lastSig = "";
40
+ /** Scroll offset of #dt-body — restored after every body re-render, since
41
+ * setInnerHtml replaces the subtree and drops scrollTop. */
42
+ private lastScrollTop = 0;
24
43
 
25
44
  constructor(ctx: DtPanelCtx) { this.ctx = ctx; }
26
45
 
@@ -29,64 +48,226 @@ export class ElementsPanel implements DtPanel {
29
48
  `<span class="dbtn${this.mode === m ? " on" : ""}" data-action="el.mode" data-mode="${m}">${MODE_LABEL[m]}</span>`;
30
49
  return `<div class="panel-toolbar">
31
50
  <span class="dbtn" data-action="el.refresh">Refresh</span>
32
- ${btn("scene")}${btn("dom")}${btn("ecs")}
51
+ ${btn("scene")}${btn("ecs")}
33
52
  <span class="spacer"></span>
34
- <span class="count">${this.rows.length} nodes</span>
53
+ <span class="count">${this.rows.length} nodes${this.collapsed.size ? ` · ${this.collapsed.size} folded` : ""}</span>
35
54
  </div>`;
36
55
  }
37
56
 
38
57
  renderBody(): string {
39
- if (!this.rows.length) return `<div class="empty-note">no nodes</div>`;
40
- const html = this.rows.slice(0, MAX_ROWS).map((n) =>
41
- `<div class="trow" style="padding-left:${4 + n.depth * 14}px" data-action="el.pick" data-node="${n.id}">
42
- <span>${n.childCount > 0 ? "▾" : " "} ${esc(n.label)}</span>
58
+ const err = this.lastError
59
+ ? `<div class="status-banner error">${esc(this.lastError)}</div>` : "";
60
+ const vis = this.visibleRows();
61
+ if (!vis.length) return err + `<div class="empty-note">no nodes</div>`;
62
+ const html = vis.slice(0, MAX_ROWS).map(({ row: n, key }) => {
63
+ const isSel = key === this.selKey;
64
+ const folded = this.collapsed.has(key);
65
+ const caret = n.childCount > 0
66
+ ? `<span class="caret" data-action="el.fold" data-node="${n.id}">${folded ? "▸" : "▾"}</span>`
67
+ : `<span class="caret leaf"></span>`;
68
+ return `<div class="trow${isSel ? " sel" : ""}" style="padding-left:${4 + n.depth * 14}px" data-action="el.pick" data-node="${n.id}">
69
+ ${caret}<span>${esc(n.label)}</span>
43
70
  ${n.detail ? `<span class="detail">${esc(n.detail)}</span>` : ""}
44
71
  ${n.childCount > 0 ? `<span class="kids">(${n.childCount})</span>` : ""}
45
- </div>`
46
- ).join("");
47
- const more = this.rows.length > MAX_ROWS
48
- ? `<div class="empty-note">… ${this.rows.length - MAX_ROWS} more rows</div>` : "";
49
- return `<div class="tree">${html}${more}</div>`;
72
+ </div>`;
73
+ }).join("");
74
+ const more = vis.length > MAX_ROWS
75
+ ? `<div class="empty-note">… ${vis.length - MAX_ROWS} more rows</div>` : "";
76
+ return err + `<div class="tree">${html}${more}</div>`;
50
77
  }
51
78
 
79
+ renderBottom(): string {
80
+ if (!this.details) return "";
81
+ return `<div class="el-details">
82
+ <div class="el-details-head"><span class="dt-title">${esc(this.details.title)}</span>
83
+ <span class="spacer"></span><span class="dbtn" data-action="el.unpick">✕</span></div>
84
+ <div class="el-details-body lines">${esc(this.details.body)}</div>
85
+ </div>`;
86
+ }
87
+
88
+ // ── Data ──
89
+
90
+ /** Cheap change-detection — scene/ecs pushes arrive every ~2s while the
91
+ * dock is visible; identical payloads must not force a re-render (it
92
+ * would bounce the scrollport twice per second). */
93
+ private applyRows(rows: TreeRow[]): void {
94
+ const sig = JSON.stringify(rows.map((r) => [r.label, r.detail, r.childCount, r.depth, r.nodeId ?? ""]));
95
+ if (sig === this.lastSig) return;
96
+ this.lastSig = sig;
97
+ this.setRows(rows);
98
+ this.dirty = true;
99
+ this.shellDirty = true;
100
+ }
101
+
102
+ private setRows(rows: TreeRow[]): void {
103
+ this.rows = rows;
104
+ // Rows arrive pre-order flattened — the parent of a row is the most
105
+ // recent row at depth-1. Keys: nodeId when the collector provided one,
106
+ // else a positional label path (stable while the tree shape holds).
107
+ const stack: string[] = [];
108
+ this.keys = rows.map((r, i) => {
109
+ const parent = r.depth > 0 ? (stack[r.depth - 1] ?? "") : "";
110
+ const k = r.nodeId ?? `${parent}/${r.label}@${i}`;
111
+ stack[r.depth] = k;
112
+ stack.length = r.depth + 1;
113
+ return k;
114
+ });
115
+ const present = new Set(this.keys);
116
+ for (const k of [...this.collapsed]) if (!present.has(k)) this.collapsed.delete(k);
117
+ if (this.selKey && !present.has(this.selKey)) {
118
+ this.selKey = null;
119
+ this.selRowId = null;
120
+ this.details = null;
121
+ }
122
+ }
123
+
124
+ /** Rows minus descendants of folded parents. */
125
+ private visibleRows(): { row: TreeRow; key: string }[] {
126
+ const out: { row: TreeRow; key: string }[] = [];
127
+ const skipDepths: number[] = [];
128
+ for (let i = 0; i < this.rows.length; i++) {
129
+ const r = this.rows[i]!;
130
+ const k = this.keys[i]!;
131
+ while (skipDepths.length && r.depth <= skipDepths[skipDepths.length - 1]!) skipDepths.pop();
132
+ if (!skipDepths.length) out.push({ row: r, key: k });
133
+ if (r.childCount > 0 && this.collapsed.has(k)) skipDepths.push(r.depth);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ private localDetails(r: TreeRow): string {
139
+ return [
140
+ `label: ${r.label}`,
141
+ r.detail ? `detail: ${r.detail}` : "",
142
+ `kind: ${r.kind === 1 ? "ecs entity" : "scene node"}`,
143
+ `children: ${r.childCount}`,
144
+ `depth: ${r.depth}`,
145
+ r.nodeId ? `nodeId: ${r.nodeId}` : "nodeId: (none — pick can't reach the inspector)",
146
+ ].filter(Boolean).join("\n");
147
+ }
148
+
149
+ private async loadDetails(row: TreeRow, key: string): Promise<void> {
150
+ this.details = { title: row.label, body: this.localDetails(row) };
151
+ this.shellDirty = true;
152
+ const nodeId = row.nodeId;
153
+ if (!nodeId) return;
154
+ // Best-effort inspector enrichment + in-scene selection highlight.
155
+ try {
156
+ await this.ctx.call("inspector.call", { method: "selectNode", args: [nodeId] });
157
+ } catch { /* inspector absent or id unknown — local details stand */ }
158
+ try {
159
+ const json = await this.ctx.call("inspector.call", { method: "getNodeJSON", args: [nodeId] });
160
+ // A newer pick while this call was in flight wins.
161
+ if (this.selKey === key && typeof json === "string" && json) {
162
+ this.details = { title: row.label, body: json };
163
+ this.shellDirty = true;
164
+ }
165
+ } catch { /* no inspector — keep the row-level details */ }
166
+ }
167
+
168
+ // ── Events ──
169
+
52
170
  onBackendEvent(event: string, data: unknown): void {
53
- // "scene" pushes feed the Scene-graph mode; "dom" pushes feed DOM/ECS.
171
+ // "scene" pushes feed scene mode; "dom" pushes feed ECS mode.
54
172
  if (event === "scene" && this.mode === "scene") {
55
- this.rows = (data as TreeRow[]) ?? [];
56
- this.dirty = true;
57
- } else if (event === "dom" && this.mode !== "scene") {
58
- this.rows = (data as TreeRow[]) ?? [];
59
- this.dirty = true;
173
+ this.applyRows((data as TreeRow[]) ?? []);
174
+ } else if (event === "dom" && this.mode === "ecs") {
175
+ this.applyRows((data as TreeRow[]) ?? []);
60
176
  }
61
177
  }
62
178
 
63
179
  onAction(data: Record<string, string>, _ev: OsrDomEvent): void {
64
- if (data.action === "el.refresh") {
180
+ const act = data.action;
181
+ if (act === "el.refresh") {
65
182
  void this.refresh();
66
- } else if (data.action === "el.mode") {
183
+ } else if (act === "el.mode") {
67
184
  const m = data.mode as Mode;
68
- if (m && m !== this.mode) {
185
+ if ((m === "scene" || m === "ecs") && m !== this.mode) {
69
186
  this.mode = m;
187
+ this.selKey = null;
188
+ this.selRowId = null;
189
+ this.details = null;
190
+ this.collapsed.clear();
70
191
  this.shellDirty = true;
192
+ // Point the backend's periodic push at the mode being viewed.
193
+ void this.ctx.call("domTree.mode", { mode: m }).catch(() => {});
71
194
  void this.refresh();
72
195
  }
196
+ } else if (act === "el.fold") {
197
+ const id = Number(data.node);
198
+ const i = this.rows.findIndex((r) => r.id === id);
199
+ if (i < 0) return;
200
+ const key = this.keys[i]!;
201
+ if (this.collapsed.has(key)) this.collapsed.delete(key);
202
+ else this.collapsed.add(key);
203
+ this.dirty = true;
204
+ this.shellDirty = true;
205
+ } else if (act === "el.pick") {
206
+ this.pick(Number(data.node));
207
+ } else if (act === "el.unpick") {
208
+ this.unpick();
209
+ }
210
+ }
211
+
212
+ private pick(id: number): void {
213
+ const i = this.rows.findIndex((r) => r.id === id);
214
+ if (i < 0) return;
215
+ const row = this.rows[i]!;
216
+ const key = this.keys[i]!;
217
+ this.selKey = key;
218
+ // Flip .sel via targeted mutations instead of a body re-render —
219
+ // reparse would reset the scrollport mid-browse. Falls back to a full
220
+ // render on backends without incremental ops.
221
+ const ops: DocMutation[] = [];
222
+ if (this.selRowId !== null && this.selRowId !== id) {
223
+ ops.push({ op: "attr", sel: `[data-node="${this.selRowId}"]`, name: "class", value: "trow" });
224
+ }
225
+ ops.push({ op: "attr", sel: `[data-node="${id}"]`, name: "class", value: "trow sel" });
226
+ this.ctx.mutate(ops);
227
+ this.selRowId = id;
228
+ void this.loadDetails(row, key);
229
+ }
230
+
231
+ private unpick(): void {
232
+ if (this.selRowId !== null) {
233
+ this.ctx.mutate([{ op: "attr", sel: `[data-node="${this.selRowId}"]`, name: "class", value: "trow" }]);
234
+ }
235
+ this.selKey = null;
236
+ this.selRowId = null;
237
+ this.details = null;
238
+ this.shellDirty = true;
239
+ void this.ctx.call("inspector.call", { method: "selectNode", args: [null] }).catch(() => {});
240
+ }
241
+
242
+ onEvent(ev: OsrDomEvent): void {
243
+ if (ev.t === "scroll" && ev.id === "dt-body") {
244
+ this.lastScrollTop = ev.st ?? 0;
73
245
  }
74
246
  }
75
247
 
76
248
  activate(): void {
249
+ // Keep the backend's push mode aligned with what's on screen.
250
+ void this.ctx.call("domTree.mode", { mode: this.mode }).catch(() => {});
77
251
  void this.refresh();
78
252
  }
79
253
 
254
+ afterRender(): void {
255
+ // Body reparse dropped the scroll position — put it back.
256
+ if (this.lastScrollTop > 0) this.ctx.scrollTo("#dt-body", 0, this.lastScrollTop);
257
+ }
258
+
80
259
  private async refresh(): Promise<void> {
81
260
  try {
82
261
  const rows = this.mode === "scene"
83
262
  ? await this.ctx.call("sceneTree")
84
- : await this.ctx.call("domTree", { mode: this.mode === "ecs" ? "ecs" : "scene" });
85
- this.rows = (rows as TreeRow[]) ?? [];
86
- } catch {
87
- this.rows = [];
263
+ : await this.ctx.call("domTree", { mode: "ecs" });
264
+ this.lastError = "";
265
+ this.applyRows((rows as TreeRow[]) ?? []);
266
+ } catch (err) {
267
+ // Keep the last good tree; surface the failure instead of blanking.
268
+ this.lastError = `refresh failed: ${String(err)}`;
88
269
  }
89
270
  this.dirty = true;
90
- this.shellDirty = true; // node count in the toolbar
271
+ this.shellDirty = true;
91
272
  }
92
273
  }
@@ -26,11 +26,13 @@ export class GpuPanel implements DtPanel {
26
26
 
27
27
  renderBody(): string {
28
28
  if (!this.info) return `<div class="empty-note">no GPU info</div>`;
29
- const times = (this.info.frameTimes ?? []).map((t) => t[0]);
29
+ const times = (Array.isArray(this.info.frameTimes) ? this.info.frameTimes : [])
30
+ .map((t) => (Array.isArray(t) ? t[0] : 0));
30
31
  const chart = `<div class="snap-section"><h3>Frame times (ms)</h3>${chartHtml(times, { max: Math.max(16.7, ...times), label: "" })}</div>`;
31
32
 
32
33
  const sections: { header: string | null; rows: { k: string; v: string }[] }[] = [];
33
- for (const e of this.info.entries ?? []) {
34
+ for (const e of (Array.isArray(this.info.entries) ? this.info.entries : []).values()) {
35
+ if (!e || typeof e !== "object") continue;
34
36
  if (e.isHeader || !sections.length) {
35
37
  sections.push({ header: e.isHeader ? e.key : null, rows: [] });
36
38
  if (e.isHeader) continue;
@@ -34,6 +34,12 @@ export class PerformancePanel implements DtPanel {
34
34
  }
35
35
 
36
36
  renderBody(): string {
37
+ // `chart + table + prof || note` could never fall through — the table
38
+ // markup is non-empty even with zero slots, so the empty-state hint was
39
+ // dead. Gate the whole thing on having anything to show.
40
+ if (!this.slots.length && !this.profile && !this.recording) {
41
+ return `<div class="empty-note">no metrics — is ProfilingSAB attached?</div>`;
42
+ }
37
43
  const heapSeries = [...this.history.entries()].map(([slot, rec]) => ({
38
44
  name: `heap — ${this.slotName(slot)}`,
39
45
  values: rec.heap,
@@ -44,10 +50,13 @@ export class PerformancePanel implements DtPanel {
44
50
  chartHtml(s.values, { max: Math.max(64, ...heapSeries.flatMap((x) => x.values)), color: s.color, label: s.name })).join("")}</div>`
45
51
  : "";
46
52
 
47
- let tbl = `<table class="dt"><tr>
53
+ let tbl = "";
54
+ if (this.slots.length) {
55
+ tbl = `<table class="dt"><tr>
48
56
  <th>slot</th><th>name</th><th>cpu%</th><th>heap</th>
49
57
  <th>gc max µs</th><th>task p95 µs</th></tr>`;
50
- for (const s of this.slots) {
58
+ }
59
+ for (const s of this.slots.values()) {
51
60
  const h = s.history?.[0] ?? {} as MetricsSlot["history"][0];
52
61
  tbl += `<tr><td>${s.slotIndex}</td><td>${esc(s.name)}</td>
53
62
  <td>${(h.cpuPercent ?? 0).toFixed(1)}</td>
@@ -55,35 +64,38 @@ export class PerformancePanel implements DtPanel {
55
64
  <td>${(h.gcPauseMaxUs ?? 0).toFixed(0)}</td>
56
65
  <td>${(h.taskLatencyP95Us ?? 0).toFixed(0)}</td></tr>`;
57
66
  }
58
- tbl += `</table>`;
59
- const table = `<div class="snap-section"><h3>Threads</h3>${tbl}</div>`;
67
+ if (this.slots.length) tbl += `</table>`;
68
+ const table = tbl ? `<div class="snap-section"><h3>Threads</h3>${tbl}</div>` : "";
60
69
 
61
70
  let prof = "";
62
71
  if (this.recording) {
63
72
  prof = `<div class="status-banner">● recording — press Stop to capture</div>`;
73
+ } else if (this.profile && !Array.isArray(this.profile.nodes)) {
74
+ prof = `<div class="status-banner error">malformed profile payload</div>`;
64
75
  } else if (this.profile) {
65
- const top = [...this.profile.nodes].sort((a, b) => b.hitCount - a.hitCount).slice(0, 40);
66
- const total = Math.max(1, top.reduce((s, n) => s + n.hitCount, 0));
67
- const durMs = (this.profile.endUs - this.profile.startUs) / 1000;
68
- prof = `<div class="snap-section"><h3>CPU profile — ${this.profile.nodes.length} nodes, ${durMs.toFixed(0)} ms</h3>
76
+ const nodes = this.profile.nodes;
77
+ const top = [...nodes].sort((a, b) => (b.hitCount ?? 0) - (a.hitCount ?? 0)).slice(0, 40);
78
+ const total = Math.max(1, top.reduce((s, n) => s + (n.hitCount ?? 0), 0));
79
+ const durMs = ((this.profile.endUs ?? 0) - (this.profile.startUs ?? 0)) / 1000;
80
+ prof = `<div class="snap-section"><h3>CPU profile — ${nodes.length} nodes, ${durMs.toFixed(0)} ms</h3>
69
81
  <div class="lines">${top.map((n) =>
70
- esc(`${(100 * n.hitCount / total).toFixed(1).padStart(5)}% ${n.callFrame} ${n.url}:${n.line}`)
82
+ esc(`${(100 * (n.hitCount ?? 0) / total).toFixed(1).padStart(5)}% ${n.callFrame} ${n.url}:${n.line}`)
71
83
  ).join("\n")}</div></div>`;
72
84
  }
73
85
 
74
- return chart + table + prof || `<div class="empty-note">no metrics — is ProfilingSAB attached?</div>`;
86
+ return chart + table + prof;
75
87
  }
76
88
 
77
89
  onBackendEvent(event: string, data: unknown): void {
78
90
  if (event === "metrics") {
79
- this.slots = (data as MetricsSlot[]) ?? [];
80
- for (const s of this.slots) {
91
+ this.slots = Array.isArray(data) ? data : [];
92
+ for (const s of this.slots.values()) {
81
93
  const h = s.history?.[0];
82
94
  if (!h) continue;
83
95
  let rec = this.history.get(s.slotIndex);
84
96
  if (!rec) { rec = { cpu: [], heap: [] }; this.history.set(s.slotIndex, rec); }
85
- rec.cpu.push(h.cpuPercent);
86
- rec.heap.push(h.heapUsed / 1048576);
97
+ rec.cpu.push(h.cpuPercent ?? 0);
98
+ rec.heap.push((h.heapUsed ?? 0) / 1048576);
87
99
  if (rec.cpu.length > HIST) { rec.cpu.shift(); rec.heap.shift(); }
88
100
  }
89
101
  this.dirty = true;
@@ -22,6 +22,7 @@ export class SnapshotPanel implements DtPanel {
22
22
  private slot: number;
23
23
  private snap: PanelSnapshot | null = null;
24
24
  private updatedAt = "";
25
+ private lastError = "";
25
26
 
26
27
  constructor(ctx: DtPanelCtx, slot: number, name: string) {
27
28
  this.ctx = ctx;
@@ -39,12 +40,14 @@ export class SnapshotPanel implements DtPanel {
39
40
  }
40
41
 
41
42
  renderBody(): string {
43
+ const err = this.lastError
44
+ ? `<div class="status-banner error">${esc(this.lastError)}</div>` : "";
42
45
  const snap = this.snap;
43
- if (!snap) return `<div class="empty-note">no data</div>`;
46
+ if (!snap) return err + `<div class="empty-note">no data</div>`;
44
47
  const banner = snap.status && snap.status !== "ok"
45
48
  ? `<div class="status-banner${snap.status === "error" ? " error" : ""}">${esc(snap.statusMsg ?? snap.status)}</div>`
46
49
  : "";
47
- return banner + (snap.sections ?? []).map((s) => this.renderSection(s)).join("");
50
+ return err + banner + (snap.sections ?? []).map((s) => this.renderSection(s)).join("");
48
51
  }
49
52
 
50
53
  onBackendEvent(event: string, data: unknown): void {
@@ -52,6 +55,7 @@ export class SnapshotPanel implements DtPanel {
52
55
  const d = data as { slot: number; snap: PanelSnapshot };
53
56
  if (d?.slot === this.slot) {
54
57
  this.snap = d.snap;
58
+ this.lastError = "";
55
59
  this.updatedAt = `updated ${new Date().toLocaleTimeString()}`;
56
60
  this.dirty = true;
57
61
  this.shellDirty = true;
@@ -67,7 +71,7 @@ export class SnapshotPanel implements DtPanel {
67
71
  if (!cmd) return;
68
72
  void this.ctx.call("command", {
69
73
  panel: this.slot, action: cmd, payload: data.payload ?? "",
70
- }).then(() => this.refresh());
74
+ }).then(() => this.refresh()).catch(() => {});
71
75
  }
72
76
  }
73
77
 
@@ -75,11 +79,24 @@ export class SnapshotPanel implements DtPanel {
75
79
  void this.refresh();
76
80
  }
77
81
 
82
+ private refreshSeq = 0;
83
+
78
84
  private async refresh(): Promise<void> {
85
+ // Overlapping refreshes (activate + provider push + manual Refresh) are
86
+ // last-call-wins — a slow stale response must not clobber a newer one.
87
+ const req = ++this.refreshSeq;
79
88
  try {
80
- this.snap = (await this.ctx.call("snapshot", { panel: this.slot })) as PanelSnapshot;
89
+ const snap = (await this.ctx.call("snapshot", { panel: this.slot })) as PanelSnapshot;
90
+ if (req !== this.refreshSeq) return;
91
+ this.snap = snap;
92
+ this.lastError = "";
81
93
  this.updatedAt = `updated ${new Date().toLocaleTimeString()}`;
82
- } catch { /* keep last */ }
94
+ } catch (err) {
95
+ if (req !== this.refreshSeq) return;
96
+ // Keep the last good snapshot; surface the failure instead of
97
+ // pretending the data is current.
98
+ this.lastError = `refresh failed: ${String(err)}`;
99
+ }
83
100
  this.dirty = true;
84
101
  this.shellDirty = true;
85
102
  }
@@ -129,12 +146,17 @@ export class SnapshotPanel implements DtPanel {
129
146
  return `<label><span class="dbtn${c.checked ? " on" : ""}" ${cmdAttr} data-payload="${c.checked ? "0" : "1"}">${c.checked ? "☑" : "☐"}</span> ${esc(c.label)}</label>`;
130
147
  }
131
148
  if (c.type === "slider") {
132
- const span = c.max - c.min || 1;
149
+ // Providers can emit a slider without min/max/value — coerce so the
150
+ // stepper never ships "NaN" payloads back through snap.cmd.
151
+ const min = Number.isFinite(c.min) ? c.min : 0;
152
+ const max = Number.isFinite(c.max) ? c.max : min + 1;
153
+ const value = Number.isFinite(c.value) ? c.value : min;
154
+ const span = max - min || 1;
133
155
  const step = span / 20;
134
- const dec = (d: number) => Math.min(c.max, Math.max(c.min, c.value + d * step)).toFixed(2);
156
+ const dec = (d: number) => Math.min(max, Math.max(min, value + d * step)).toFixed(2);
135
157
  return `<label>${esc(c.label)}
136
158
  <span class="dbtn" ${cmdAttr} data-payload="${dec(-1)}">−</span>
137
- <span class="val">${c.value.toFixed(2)}</span>
159
+ <span class="val">${value.toFixed(2)}</span>
138
160
  <span class="dbtn" ${cmdAttr} data-payload="${dec(1)}">+</span></label>`;
139
161
  }
140
162
  return esc((c as { label?: string }).label ?? "");
@@ -102,7 +102,8 @@ export function chartHtml(values: ArrayLike<number>, opts: {
102
102
  const step = Math.max(1, Math.floor(vals.length / cols));
103
103
  const cells: string[] = [];
104
104
  for (let i = 0; i < vals.length; i += step) {
105
- const h = Math.max(0, Math.min(100, (vals[i]! / max) * 100));
105
+ const v = Number.isFinite(vals[i]) ? vals[i]! : 0;
106
+ const h = Math.max(0, Math.min(100, (v / max) * 100));
106
107
  cells.push(`<div class="col" style="height:${h.toFixed(1)}%;background:${color}"></div>`);
107
108
  }
108
109
  if (!cells.length) cells.push(`<div class="col" style="height:0%"></div>`);
@@ -15,7 +15,7 @@ export const DEVTOOLS_CSS = `
15
15
  --warn: #e0b341;
16
16
  --err: #e5534b;
17
17
  --ok: #57ab5a;
18
- --mono: "SF Mono", "Cascadia Mono", Consolas, monospace;
18
+ --mono: "DevTools Mono", "SF Mono", "Cascadia Mono", Consolas, monospace;
19
19
  }
20
20
  * { box-sizing: border-box; margin: 0; padding: 0; }
21
21
  /* height:100% must be on html AND body — Blitz resolves body % against
@@ -23,7 +23,7 @@ export const DEVTOOLS_CSS = `
23
23
  html, body {
24
24
  width: 100%; height: 100%; overflow: hidden;
25
25
  background: var(--bg); color: var(--fg);
26
- font: 12px/1.45 system-ui, sans-serif;
26
+ font: 12px/1.45 "DevTools Sans", system-ui, sans-serif;
27
27
  }
28
28
  #dt-root {
29
29
  /* Absolute inset — Blitz doesn't resolve height:100% against the doc
@@ -51,7 +51,11 @@ html, body {
51
51
  display: flex; align-items: center; gap: 2px;
52
52
  background: var(--bg2); border-bottom: 1px solid var(--line);
53
53
  padding: 0 8px; height: 30px; flex-shrink: 0;
54
+ /* Provider tabs can overflow the strip — wheel-scroll it instead of
55
+ * clipping tabs unreachable. */
56
+ overflow-x: auto; overflow-y: hidden;
54
57
  }
58
+ #dt-tabs .tab, #dt-tabs .brand, #dt-tabs .hint { flex-shrink: 0; }
55
59
  #dt-tabs .brand { color: var(--accent); font-weight: 600; margin-right: 12px; }
56
60
  #dt-tabs .spacer { flex: 1; }
57
61
  #dt-tabs .hint { font-size: 11px; color: var(--fg-dim); }
@@ -125,14 +129,32 @@ html, body {
125
129
  .console-repl input:focus { border-color: var(--accent); }
126
130
  .console-repl .prompt { color: var(--accent); font-family: var(--mono); }
127
131
 
128
- /* ── Tree (scene/dom/ecs) ── */
132
+ /* ── Tree (scene/ecs) ── */
129
133
  .tree { font-family: var(--mono); font-size: 11.5px; }
130
- .trow { padding: 1px 4px; white-space: nowrap; }
134
+ .trow { padding: 1px 4px; white-space: nowrap; cursor: default; }
131
135
  .trow:hover { background: var(--bg3); }
132
136
  .trow.sel { background: #2d4a72; }
137
+ .trow .caret {
138
+ display: inline-block; width: 12px; color: var(--fg-dim);
139
+ cursor: pointer; user-select: none;
140
+ }
141
+ .trow .caret:hover { color: var(--fg); }
133
142
  .trow .detail { color: var(--fg-dim); margin-left: 8px; }
134
143
  .trow .kids { color: var(--fg-dim); }
135
144
 
145
+ /* ── Elements details strip (bottom chrome) ── */
146
+ .el-details { border-top: 1px solid var(--line); background: var(--bg); }
147
+ .el-details-head {
148
+ display: flex; align-items: center; gap: 8px;
149
+ padding: 3px 8px; border-bottom: 1px solid var(--line);
150
+ }
151
+ .el-details-head .dt-title { color: var(--accent); font-weight: 600; }
152
+ .el-details-head .spacer { flex: 1; }
153
+ .el-details-body {
154
+ max-height: 140px; overflow-y: auto; padding: 6px 8px;
155
+ font-family: var(--mono); font-size: 11.5px; white-space: pre-wrap;
156
+ }
157
+
136
158
  /* ── KV / tables / snapshot panels ── */
137
159
  .snap-section { margin-bottom: 14px; }
138
160
  .snap-section > h3 {