@threenative/ui 0.3.1 → 0.3.3

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.
package/dist/index.d.ts CHANGED
@@ -8,6 +8,14 @@ interface IGameCanvasProps<TState extends Record<string, unknown> = Record<strin
8
8
  }
9
9
  declare function GameCanvas<TState extends Record<string, unknown>, TPhysics>({ className, game, }: IGameCanvasProps<TState, TPhysics>): react_jsx_runtime.JSX.Element;
10
10
 
11
+ /**
12
+ * The dev-only gate, and the reason the view below is a separate component.
13
+ *
14
+ * `isDev` folds to `false` in a production build, so this returns null before any hook runs and
15
+ * `DebugOverlayView` becomes unreachable — the bundler then drops the whole view, and with it every
16
+ * reference to the devtools global. A guard placed after the hooks keeps those references alive
17
+ * instead, which `scripts/check-core-boundary.ts` rejects: the overlay must not ship in a game.
18
+ */
11
19
  declare function DebugOverlay(): react_jsx_runtime.JSX.Element | null;
12
20
 
13
21
  type GameSelector<TState, TSelected> = (state: TState) => TSelected;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { createContext, useRef, useState, useEffect, useSyncExternalStore, useMemo, useContext } from 'react';
2
- import { jsx, jsxs } from 'react/jsx-runtime';
3
- import { connectUiBridge, subscribeUiState, publishHitRegions, sendUiIntent, UI_READY_INTENT } from '@threenative/core/ui-layer';
1
+ import { createContext, useRef, useState, useEffect, useCallback, useMemo, useSyncExternalStore, useContext } from 'react';
2
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
+ import { connectUiBridge, subscribeUiState, publishHitRegions, sendUiIntent, UI_READY_INTENT, UI_DEV_METRICS_MESSAGE } from '@threenative/core/ui-layer';
4
4
 
5
5
  // src/GameCanvas.tsx
6
6
  function GameCanvas({
@@ -51,45 +51,391 @@ function GameCanvas({
51
51
  );
52
52
  }
53
53
  var isDev = import.meta.env?.DEV === true;
54
+ var ROW_LIMIT = 50;
55
+ var DEFAULT_SMALL_PIXELS = 32;
56
+ var SORTS = ["triangles", "draws", "projected"];
57
+ function devTools() {
58
+ return globalThis.window?.__THREENATIVE__;
59
+ }
54
60
  function readSnapshot() {
55
- const snapshot = globalThis.window?.__THREENATIVE__?.snapshot;
61
+ const snapshot = devTools()?.snapshot;
56
62
  return typeof snapshot === "function" ? snapshot() : {};
57
63
  }
58
64
  function displayValue(value) {
59
65
  return typeof value === "string" ? value : JSON.stringify(value) ?? String(value);
60
66
  }
67
+ function count(value) {
68
+ return value === void 0 ? "\u2014" : Math.round(value).toLocaleString("en-US");
69
+ }
70
+ function sortKey(row, sort) {
71
+ if (sort === "draws") return row.draws;
72
+ if (sort === "projected") return row.projectedPixels;
73
+ return row.submittedTriangles;
74
+ }
75
+ function rank(rows, sort) {
76
+ return [...rows].sort((left, right) => {
77
+ const a = sortKey(left, sort);
78
+ const b = sortKey(right, sort);
79
+ if (a === void 0 && b === void 0) return left.path.localeCompare(right.path);
80
+ if (a === void 0) return 1;
81
+ if (b === void 0) return -1;
82
+ return b - a;
83
+ });
84
+ }
61
85
  function DebugOverlay() {
86
+ if (!isDev) return null;
87
+ return /* @__PURE__ */ jsx(DebugOverlayView, {});
88
+ }
89
+ function DebugOverlayView() {
62
90
  const [open, setOpen] = useState(false);
91
+ const [tab, setTab] = useState("entities");
63
92
  const [snapshot, setSnapshot] = useState({});
93
+ const [report, setReport] = useState(void 0);
94
+ const [capturing, setCapturing] = useState(false);
95
+ const [failure, setFailure] = useState(void 0);
96
+ const [group, setGroup] = useState("objects");
97
+ const [sort, setSort] = useState("triangles");
98
+ const [smallOnly, setSmallOnly] = useState(false);
99
+ const [smallPixels, setSmallPixels] = useState(DEFAULT_SMALL_PIXELS);
100
+ const [expanded, setExpanded] = useState([]);
101
+ const [selected, setSelected] = useState(void 0);
102
+ const [copied, setCopied] = useState(void 0);
64
103
  useEffect(() => {
65
- if (!isDev || typeof window === "undefined") return void 0;
104
+ if (typeof window === "undefined") return void 0;
66
105
  const hostWindow = window;
67
106
  const toggle = (event) => {
68
107
  if (event.key === "`") setOpen((visible) => !visible);
69
108
  };
109
+ const clearOutline = () => setSelected(void 0);
70
110
  hostWindow.addEventListener("keydown", toggle);
111
+ hostWindow.addEventListener("resize", clearOutline);
71
112
  const timer = hostWindow.setInterval(() => setSnapshot(readSnapshot()), 100);
72
113
  return () => {
73
114
  hostWindow.removeEventListener("keydown", toggle);
115
+ hostWindow.removeEventListener("resize", clearOutline);
74
116
  hostWindow.clearInterval(timer);
75
117
  };
76
118
  }, []);
77
- if (!isDev || !open) return null;
78
- const rows = Object.entries(snapshot).flatMap(
119
+ const capture = useCallback(() => {
120
+ const geometry = devTools()?.geometry;
121
+ if (typeof geometry !== "function" || capturing) return;
122
+ setCapturing(true);
123
+ setSelected(void 0);
124
+ setCopied(void 0);
125
+ setFailure(void 0);
126
+ geometry({ limit: ROW_LIMIT, sort }).then((next) => setReport(next)).catch((error) => {
127
+ setReport(void 0);
128
+ setFailure(error instanceof Error ? error.message : String(error));
129
+ }).finally(() => setCapturing(false));
130
+ }, [capturing, sort]);
131
+ const rows = useMemo(() => rank(report?.objects ?? [], sort), [report, sort]);
132
+ const visibleRows = useMemo(
133
+ () => smallOnly ? rows.filter(
134
+ (row) => row.projectedPixels !== void 0 && row.projectedPixels < smallPixels
135
+ ) : rows,
136
+ [rows, smallOnly, smallPixels]
137
+ );
138
+ const selectedRow = rows.find((row) => row.id === selected);
139
+ if (!open) return null;
140
+ const entityRows = Object.entries(snapshot).flatMap(
79
141
  ([entity, fields]) => Object.entries(fields).map(([key, value]) => ({ entity, key, value }))
80
142
  );
81
- return /* @__PURE__ */ jsx("aside", { "aria-label": "ThreeNative entity debug overlay", "data-threenative-debug-overlay": "true", children: /* @__PURE__ */ jsxs("table", { children: [
82
- /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
83
- /* @__PURE__ */ jsx("th", { children: "entity" }),
84
- /* @__PURE__ */ jsx("th", { children: "key" }),
85
- /* @__PURE__ */ jsx("th", { children: "value" })
86
- ] }) }),
87
- /* @__PURE__ */ jsx("tbody", { children: rows.map(({ entity, key, value }) => /* @__PURE__ */ jsxs("tr", { children: [
88
- /* @__PURE__ */ jsx("td", { children: entity }),
89
- /* @__PURE__ */ jsx("td", { children: key }),
90
- /* @__PURE__ */ jsx("td", { children: displayValue(value) })
91
- ] }, `${entity}.${key}`)) })
92
- ] }) });
143
+ const captureAvailable = typeof devTools()?.geometry === "function";
144
+ return /* @__PURE__ */ jsxs(
145
+ "aside",
146
+ {
147
+ "aria-label": "ThreeNative entity debug overlay",
148
+ "data-threenative-debug-overlay": "true",
149
+ "data-tn-debug-tab": tab,
150
+ children: [
151
+ /* @__PURE__ */ jsxs("div", { className: "tn-debug-tabs", role: "tablist", "aria-label": "Debug views", children: [
152
+ /* @__PURE__ */ jsx(
153
+ "button",
154
+ {
155
+ "aria-controls": "tn-debug-entities",
156
+ "aria-selected": tab === "entities",
157
+ className: "tn-debug-tab",
158
+ id: "tn-debug-entities-tab",
159
+ onClick: () => setTab("entities"),
160
+ role: "tab",
161
+ type: "button",
162
+ children: "Entities"
163
+ }
164
+ ),
165
+ /* @__PURE__ */ jsx(
166
+ "button",
167
+ {
168
+ "aria-controls": "tn-debug-geometry",
169
+ "aria-selected": tab === "geometry",
170
+ className: "tn-debug-tab",
171
+ id: "tn-debug-geometry-tab",
172
+ onClick: () => setTab("geometry"),
173
+ role: "tab",
174
+ type: "button",
175
+ children: "Geometry"
176
+ }
177
+ )
178
+ ] }),
179
+ tab === "entities" ? /* @__PURE__ */ jsx("div", { "aria-labelledby": "tn-debug-entities-tab", id: "tn-debug-entities", role: "tabpanel", children: /* @__PURE__ */ jsxs("table", { children: [
180
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
181
+ /* @__PURE__ */ jsx("th", { children: "entity" }),
182
+ /* @__PURE__ */ jsx("th", { children: "key" }),
183
+ /* @__PURE__ */ jsx("th", { children: "value" })
184
+ ] }) }),
185
+ /* @__PURE__ */ jsx("tbody", { children: entityRows.map(({ entity, key, value }) => /* @__PURE__ */ jsxs("tr", { children: [
186
+ /* @__PURE__ */ jsx("td", { children: entity }),
187
+ /* @__PURE__ */ jsx("td", { children: key }),
188
+ /* @__PURE__ */ jsx("td", { children: displayValue(value) })
189
+ ] }, `${entity}.${key}`)) })
190
+ ] }) }) : /* @__PURE__ */ jsxs("div", { "aria-labelledby": "tn-debug-geometry-tab", id: "tn-debug-geometry", role: "tabpanel", children: [
191
+ /* @__PURE__ */ jsxs("div", { className: "tn-debug-controls", children: [
192
+ /* @__PURE__ */ jsx(
193
+ "button",
194
+ {
195
+ className: "tn-debug-action",
196
+ disabled: capturing || !captureAvailable,
197
+ onClick: capture,
198
+ type: "button",
199
+ children: capturing ? "Capturing\u2026" : report === void 0 ? "Capture" : "Refresh"
200
+ }
201
+ ),
202
+ report === void 0 ? null : /* @__PURE__ */ jsx(
203
+ "button",
204
+ {
205
+ className: "tn-debug-action",
206
+ onClick: () => {
207
+ const json = JSON.stringify(report, null, 2);
208
+ const clipboard = globalThis.navigator?.clipboard;
209
+ if (clipboard === void 0) {
210
+ setCopied(json);
211
+ return;
212
+ }
213
+ clipboard.writeText(json).catch(() => setCopied(json));
214
+ },
215
+ type: "button",
216
+ children: "Copy JSON"
217
+ }
218
+ )
219
+ ] }),
220
+ captureAvailable ? null : /* @__PURE__ */ jsx("p", { className: "tn-debug-note", children: "The running game exposes no geometry capture. Mount a development build to use this view." }),
221
+ failure === void 0 ? null : /* @__PURE__ */ jsx("p", { className: "tn-debug-note", children: failure }),
222
+ report === void 0 || report.status === "unavailable" ? report?.reason === void 0 ? null : /* @__PURE__ */ jsx("p", { className: "tn-debug-note", children: report.reason }) : /* @__PURE__ */ jsxs(Fragment, { children: [
223
+ /* @__PURE__ */ jsxs("p", { className: "tn-debug-note", children: [
224
+ "tick ",
225
+ count(report.tick),
226
+ " \xB7 ",
227
+ report.backend ?? "backend unknown",
228
+ " \xB7",
229
+ " ",
230
+ count(report.viewport?.width),
231
+ "\xD7",
232
+ count(report.viewport?.height),
233
+ " \xB7",
234
+ " ",
235
+ report.camera?.type ?? "camera unknown",
236
+ " \xB7 ",
237
+ (report.durationMs ?? 0).toFixed(1),
238
+ " ms \xB7 ",
239
+ count(report.returned),
240
+ " of ",
241
+ count(report.matched),
242
+ " rows"
243
+ ] }),
244
+ report.partialRanking === true ? /* @__PURE__ */ jsx("p", { className: "tn-debug-warn", children: "Ranking is partial \u2014 not a global claim." }) : null,
245
+ report.rowsTruncated === true ? /* @__PURE__ */ jsxs("p", { className: "tn-debug-warn", children: [
246
+ "Rows were cut by the limit (",
247
+ count(report.limit),
248
+ ")."
249
+ ] }) : null,
250
+ report.inspectionComplete === false ? /* @__PURE__ */ jsxs("p", { className: "tn-debug-warn", children: [
251
+ "Inspection stopped at the walk cap (",
252
+ count(report.inspectedNodes),
253
+ " nodes)."
254
+ ] }) : null,
255
+ sort === report.sort ? null : /* @__PURE__ */ jsxs("p", { className: "tn-debug-note", children: [
256
+ "These rows were ranked by ",
257
+ report.sort,
258
+ "; this table is re-sorted locally."
259
+ ] }),
260
+ /* @__PURE__ */ jsxs("table", { className: "tn-debug-passes", children: [
261
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
262
+ /* @__PURE__ */ jsx("th", { children: "pass" }),
263
+ /* @__PURE__ */ jsx("th", { children: "draws" }),
264
+ /* @__PURE__ */ jsx("th", { children: "triangles" }),
265
+ /* @__PURE__ */ jsx("th", { children: "rows" }),
266
+ /* @__PURE__ */ jsx("th", { children: "unattributed (measured \u2212 rows)" })
267
+ ] }) }),
268
+ /* @__PURE__ */ jsx("tbody", { children: (report.passes ?? []).map((pass) => /* @__PURE__ */ jsxs("tr", { children: [
269
+ /* @__PURE__ */ jsx("td", { children: pass.kind }),
270
+ /* @__PURE__ */ jsx("td", { children: count(pass.draws) }),
271
+ /* @__PURE__ */ jsx("td", { children: count(pass.triangles) }),
272
+ /* @__PURE__ */ jsx("td", { children: count(pass.attributedTriangles) }),
273
+ /* @__PURE__ */ jsx("td", { children: count(pass.unattributedTriangles) })
274
+ ] }, pass.kind)) })
275
+ ] }),
276
+ /* @__PURE__ */ jsxs("fieldset", { className: "tn-debug-controls", children: [
277
+ /* @__PURE__ */ jsx("legend", { children: "Group" }),
278
+ /* @__PURE__ */ jsxs("label", { children: [
279
+ /* @__PURE__ */ jsx(
280
+ "input",
281
+ {
282
+ checked: group === "objects",
283
+ name: "tn-debug-group",
284
+ onChange: () => setGroup("objects"),
285
+ type: "radio"
286
+ }
287
+ ),
288
+ "Objects"
289
+ ] }),
290
+ /* @__PURE__ */ jsxs("label", { children: [
291
+ /* @__PURE__ */ jsx(
292
+ "input",
293
+ {
294
+ checked: group === "assets",
295
+ name: "tn-debug-group",
296
+ onChange: () => setGroup("assets"),
297
+ type: "radio"
298
+ }
299
+ ),
300
+ "Assets"
301
+ ] })
302
+ ] }),
303
+ /* @__PURE__ */ jsxs("div", { className: "tn-debug-controls", children: [
304
+ /* @__PURE__ */ jsxs("label", { children: [
305
+ "Sort",
306
+ /* @__PURE__ */ jsx(
307
+ "select",
308
+ {
309
+ onChange: (event) => setSort(event.target.value),
310
+ value: sort,
311
+ children: SORTS.map((option) => /* @__PURE__ */ jsx("option", { value: option, children: option }, option))
312
+ }
313
+ )
314
+ ] }),
315
+ /* @__PURE__ */ jsxs("label", { children: [
316
+ /* @__PURE__ */ jsx(
317
+ "input",
318
+ {
319
+ checked: smallOnly,
320
+ onChange: (event) => setSmallOnly(event.target.checked),
321
+ type: "checkbox"
322
+ }
323
+ ),
324
+ "Small on screen (< ",
325
+ count(smallPixels),
326
+ " px)"
327
+ ] }),
328
+ /* @__PURE__ */ jsxs("label", { children: [
329
+ "Threshold",
330
+ /* @__PURE__ */ jsx(
331
+ "input",
332
+ {
333
+ min: 1,
334
+ onChange: (event) => setSmallPixels(Math.max(1, Number(event.target.value) || 1)),
335
+ type: "number",
336
+ value: smallPixels
337
+ }
338
+ )
339
+ ] })
340
+ ] }),
341
+ /* @__PURE__ */ jsx("p", { className: "tn-debug-note", children: "A diagnostic filter, not a recommendation: whether an object matters is an authoring decision, and an object with no measured bounds is excluded rather than called small." }),
342
+ group === "assets" ? /* @__PURE__ */ jsxs("table", { className: "tn-debug-assets", children: [
343
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
344
+ /* @__PURE__ */ jsx("th", { children: "asset" }),
345
+ /* @__PURE__ */ jsx("th", { children: "objects" }),
346
+ /* @__PURE__ */ jsx("th", { children: "triangles" }),
347
+ /* @__PURE__ */ jsx("th", { children: "unique" }),
348
+ /* @__PURE__ */ jsx("th", { children: "draws" })
349
+ ] }) }),
350
+ /* @__PURE__ */ jsx("tbody", { children: (report.assets ?? []).map((asset) => /* @__PURE__ */ jsxs("tr", { children: [
351
+ /* @__PURE__ */ jsx("td", { children: asset.asset }),
352
+ /* @__PURE__ */ jsx("td", { children: count(asset.objects) }),
353
+ /* @__PURE__ */ jsx("td", { children: count(asset.submittedTriangles) }),
354
+ /* @__PURE__ */ jsx("td", { children: count(asset.uniqueTriangles) }),
355
+ /* @__PURE__ */ jsx("td", { children: count(asset.draws) })
356
+ ] }, asset.asset)) })
357
+ ] }) : /* @__PURE__ */ jsxs("table", { className: "tn-debug-objects", children: [
358
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
359
+ /* @__PURE__ */ jsx("th", { children: "object" }),
360
+ /* @__PURE__ */ jsx("th", { children: "triangles" }),
361
+ /* @__PURE__ */ jsx("th", { children: "draws" }),
362
+ /* @__PURE__ */ jsx("th", { children: "projected px" }),
363
+ /* @__PURE__ */ jsx("th", { children: "copies" }),
364
+ /* @__PURE__ */ jsx("th", { children: "asset" })
365
+ ] }) }),
366
+ /* @__PURE__ */ jsx("tbody", { children: visibleRows.flatMap((row) => {
367
+ const isExpanded = expanded.includes(row.id);
368
+ const head = /* @__PURE__ */ jsxs("tr", { title: (row.unavailable ?? []).join(" "), children: [
369
+ /* @__PURE__ */ jsxs("td", { children: [
370
+ /* @__PURE__ */ jsx(
371
+ "button",
372
+ {
373
+ "aria-expanded": isExpanded,
374
+ className: "tn-debug-expand",
375
+ onClick: () => setExpanded(
376
+ (current) => current.includes(row.id) ? current.filter((id) => id !== row.id) : [...current, row.id]
377
+ ),
378
+ type: "button",
379
+ children: isExpanded ? "\u2212" : "+"
380
+ }
381
+ ),
382
+ /* @__PURE__ */ jsx(
383
+ "button",
384
+ {
385
+ "aria-pressed": selected === row.id,
386
+ className: "tn-debug-select",
387
+ onClick: () => setSelected((current) => current === row.id ? void 0 : row.id),
388
+ type: "button",
389
+ children: row.name
390
+ }
391
+ ),
392
+ (row.unavailable ?? []).length === 0 ? null : /* @__PURE__ */ jsx("small", { className: "tn-debug-unavailable", children: (row.unavailable ?? []).join(" ") })
393
+ ] }),
394
+ /* @__PURE__ */ jsxs("td", { children: [
395
+ count(row.submittedTriangles),
396
+ row.trianglesSource === "batchMembers" ? " (derived)" : ""
397
+ ] }),
398
+ /* @__PURE__ */ jsx("td", { children: count(row.draws) }),
399
+ /* @__PURE__ */ jsx("td", { children: count(row.projectedPixels) }),
400
+ /* @__PURE__ */ jsx("td", { children: count(row.copies) }),
401
+ /* @__PURE__ */ jsx("td", { children: row.asset ?? "\u2014" })
402
+ ] }, row.id);
403
+ if (!isExpanded) return [head];
404
+ return [
405
+ head,
406
+ ...row.meshes.map((mesh) => /* @__PURE__ */ jsxs("tr", { className: "tn-debug-mesh", children: [
407
+ /* @__PURE__ */ jsx("td", { children: mesh.name }),
408
+ /* @__PURE__ */ jsx("td", { children: count(mesh.submittedTriangles) }),
409
+ /* @__PURE__ */ jsx("td", { children: count(mesh.draws) }),
410
+ /* @__PURE__ */ jsx("td", { children: count(mesh.materials) }),
411
+ /* @__PURE__ */ jsx("td", { children: count(mesh.instances) }),
412
+ /* @__PURE__ */ jsx("td", { children: mesh.batchOwner ?? "\u2014" })
413
+ ] }, mesh.id))
414
+ ];
415
+ }) })
416
+ ] }),
417
+ selectedRow === void 0 ? null : selectedRow.projectedCenter === void 0 || selectedRow.projectedPixels === void 0 || report.viewport === void 0 ? /* @__PURE__ */ jsx("p", { className: "tn-debug-note", children: "bounds unavailable" }) : (
418
+ // Percentages of the captured drawing buffer, which is the canvas the overlay sits
419
+ // over. The box is the capture's ESTIMATED bounds, not a silhouette or a pixel mask.
420
+ /* @__PURE__ */ jsx("div", { className: "tn-debug-outline-layer", children: /* @__PURE__ */ jsx(
421
+ "div",
422
+ {
423
+ className: "tn-debug-outline",
424
+ style: {
425
+ height: `${String(selectedRow.projectedPixels / report.viewport.height * 100)}%`,
426
+ left: `${String((selectedRow.projectedCenter[0] - selectedRow.projectedPixels / 2) / report.viewport.width * 100)}%`,
427
+ top: `${String((selectedRow.projectedCenter[1] - selectedRow.projectedPixels / 2) / report.viewport.height * 100)}%`,
428
+ width: `${String(selectedRow.projectedPixels / report.viewport.width * 100)}%`
429
+ }
430
+ }
431
+ ) })
432
+ ),
433
+ copied === void 0 ? null : /* @__PURE__ */ jsx("textarea", { className: "tn-debug-json", readOnly: true, rows: 8, value: copied })
434
+ ] })
435
+ ] })
436
+ ]
437
+ }
438
+ );
93
439
  }
94
440
  function useGameState(game, selector = (state) => state) {
95
441
  return useSyncExternalStore(
@@ -106,6 +452,54 @@ function useUiLayer(name) {
106
452
  }
107
453
  return value;
108
454
  }
455
+ function DevFrameRateChip({ bridge }) {
456
+ const [fps, setFps] = useState(void 0);
457
+ const [verdict, setVerdict] = useState(void 0);
458
+ useEffect(
459
+ () => bridge.onMessage((message) => {
460
+ if (message.type !== UI_DEV_METRICS_MESSAGE) return;
461
+ const value = message.fps;
462
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) setFps(value);
463
+ const warning = message.sceneWarning;
464
+ if (typeof warning === "string" && warning.length > 0) setVerdict(warning);
465
+ }),
466
+ [bridge]
467
+ );
468
+ if (fps === void 0) return null;
469
+ return (
470
+ // `<output>` carries the status role natively, so the readout is announced without an ARIA
471
+ // attribute standing in for an element that already means this.
472
+ /* @__PURE__ */ jsxs(
473
+ "output",
474
+ {
475
+ className: "tn-dev-fps",
476
+ style: {
477
+ position: "absolute",
478
+ top: 8,
479
+ right: 8,
480
+ padding: "2px 6px",
481
+ borderRadius: 4,
482
+ background: "rgba(0, 0, 0, 0.55)",
483
+ color: "#e8f0ff",
484
+ font: "500 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
485
+ pointerEvents: "none",
486
+ zIndex: 2147483647
487
+ },
488
+ children: [
489
+ `${Math.round(fps)} fps`,
490
+ verdict === void 0 ? null : /* @__PURE__ */ jsx(
491
+ "span",
492
+ {
493
+ className: "tn-dev-scene-warning",
494
+ style: { display: "block", maxWidth: 420, color: "#ffd98a", whiteSpace: "normal" },
495
+ children: verdict
496
+ }
497
+ )
498
+ ]
499
+ }
500
+ )
501
+ );
502
+ }
109
503
  function UiLayer({ children }) {
110
504
  const [value, setValue] = useState(void 0);
111
505
  const registryRef = useRef(void 0);
@@ -131,7 +525,10 @@ function UiLayer({ children }) {
131
525
  };
132
526
  }, [value]);
133
527
  if (value === void 0) return null;
134
- return /* @__PURE__ */ jsx(UiLayerContext.Provider, { value, children });
528
+ return /* @__PURE__ */ jsxs(UiLayerContext.Provider, { value, children: [
529
+ children,
530
+ /* @__PURE__ */ jsx(DevFrameRateChip, { bridge: value.bridge })
531
+ ] });
135
532
  }
136
533
  function useUiState(selector) {
137
534
  const { mirror } = useUiLayer("useUiState");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threenative/ui",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "license": "MIT",
5
5
  "description": "React bindings for mounting a ThreeNative game and reading throttled state.",
6
6
  "keywords": [
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "sideEffects": false,
43
43
  "peerDependencies": {
44
- "@threenative/core": ">=0.3.1 <0.4.0",
44
+ "@threenative/core": ">=0.3.3 <0.4.0",
45
45
  "react": ">=19.2.0 <20",
46
46
  "react-dom": ">=19.2.0 <20"
47
47
  },
@@ -55,7 +55,7 @@
55
55
  "react-test-renderer": "19.2.0",
56
56
  "tsup": "8.5.1",
57
57
  "typescript": "5.9.3",
58
- "@threenative/core": "0.3.1"
58
+ "@threenative/core": "0.3.3"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsup --config tsup.config.ts && publint",