@octanejs/devtools 0.0.1
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/LICENSE +21 -0
- package/README.md +49 -0
- package/package.json +57 -0
- package/src/bridge.ts +182 -0
- package/src/client.ts +84 -0
- package/src/index.ts +35 -0
- package/src/panel/ComponentsTab.tsrx +100 -0
- package/src/panel/ComponentsTab.tsrx.d.ts +6 -0
- package/src/panel/OctanePanel.tsrx +62 -0
- package/src/panel/OctanePanel.tsrx.d.ts +6 -0
- package/src/panel/PerfModelTab.tsrx +92 -0
- package/src/panel/PerfModelTab.tsrx.d.ts +6 -0
- package/src/panel/ProfilerTab.tsrx +66 -0
- package/src/panel/ProfilerTab.tsrx.d.ts +6 -0
- package/src/panel/TransitionsTab.tsrx +43 -0
- package/src/panel/TransitionsTab.tsrx.d.ts +6 -0
- package/src/panel/styles.ts +329 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dominic Gannaway
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @octanejs/devtools
|
|
2
|
+
|
|
3
|
+
Live Octane runtime diagnostics for the [TanStack Devtools](https://tanstack.com/devtools)
|
|
4
|
+
panel — a Components tab showing the mounted component tree and per-node state,
|
|
5
|
+
streamed over `@tanstack/devtools-event-client` from a dev-only runtime hook.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @octanejs/devtools
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { TanStackDevtools } from '@octanejs/tanstack-devtools';
|
|
17
|
+
import { octaneDevtools } from '@octanejs/devtools';
|
|
18
|
+
|
|
19
|
+
function App() @{
|
|
20
|
+
<>
|
|
21
|
+
<TanStackDevtools plugins={[octaneDevtools()]} />
|
|
22
|
+
<MyApp />
|
|
23
|
+
</>
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Enable the runtime hook in dev with `octane({ devtools: true })` in your Vite
|
|
28
|
+
config — production builds tree-shake the hook away entirely.
|
|
29
|
+
|
|
30
|
+
The Components tab shows the live component tree, and on selecting a node: its
|
|
31
|
+
hook cells (state/reducer/ref/memo-or-callback), context values, and effect
|
|
32
|
+
count. It's read-only in v1 — selecting a node never mutates app state.
|
|
33
|
+
|
|
34
|
+
The Profiler tab ranks components by self time (renders, self/max/queue ms,
|
|
35
|
+
dominant cause) and lists recent commits (mount/update, self ms, causes).
|
|
36
|
+
|
|
37
|
+
The Transitions & Suspense tab shows the live pending-transition count and
|
|
38
|
+
each Suspense boundary's state (init/pending/resolved/caught), with a
|
|
39
|
+
"(resolved once)" marker once a boundary has shown its primary content.
|
|
40
|
+
|
|
41
|
+
The Performance-model tab is an aggregate "what's slow" view over the same
|
|
42
|
+
profiler data as the Profiler tab: slowest mounts by self time, queue-delay
|
|
43
|
+
hotspots, and a recent commit self-time trend.
|
|
44
|
+
|
|
45
|
+
Only include the devtools in development, e.g. behind an `import.meta.env.DEV`
|
|
46
|
+
check or via `lazy()`.
|
|
47
|
+
|
|
48
|
+
See [`docs/devtools.md`](../../docs/devtools.md) for the full setup guide,
|
|
49
|
+
guarantees, and known caveats.
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/devtools",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"octane": {
|
|
10
|
+
"hookSlots": {
|
|
11
|
+
"manual": [
|
|
12
|
+
"src"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"description": "Octane DevTools — a TanStack Devtools plugin exposing live Octane runtime diagnostics (component tree, state, profiler).",
|
|
17
|
+
"author": {
|
|
18
|
+
"name": "Dominic Gannaway",
|
|
19
|
+
"email": "dg@domgan.com"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
27
|
+
"directory": "packages/devtools"
|
|
28
|
+
},
|
|
29
|
+
"main": "src/index.ts",
|
|
30
|
+
"module": "src/index.ts",
|
|
31
|
+
"types": "src/index.ts",
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"exports": {
|
|
38
|
+
".": "./src/index.ts"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@tanstack/devtools": "0.12.5",
|
|
42
|
+
"@tanstack/devtools-event-client": "0.5.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"octane": "0.1.13"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@tanstack/devtools-event-bus": "0.4.2",
|
|
49
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
50
|
+
"vitest": "^4.1.10",
|
|
51
|
+
"octane": "0.1.13",
|
|
52
|
+
"@octanejs/testing-library": "0.1.10"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"test": "vitest run"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OctaneDevtoolsEventClient,
|
|
3
|
+
type NodeDetail,
|
|
4
|
+
type ProfileEventWire,
|
|
5
|
+
type ProfileSnapshot,
|
|
6
|
+
type TransitionSnapshot,
|
|
7
|
+
type WireTreeNode,
|
|
8
|
+
} from './client';
|
|
9
|
+
|
|
10
|
+
interface RuntimeHook {
|
|
11
|
+
version: number;
|
|
12
|
+
getTree(): WireTreeNode[];
|
|
13
|
+
inspect(id: number): NodeDetail | null;
|
|
14
|
+
subscribe(listener: () => void): () => void;
|
|
15
|
+
getTransitionState(): TransitionSnapshot;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getHook(): RuntimeHook | undefined {
|
|
19
|
+
return (globalThis as unknown as { __OCTANE_DEVTOOLS__?: RuntimeHook }).__OCTANE_DEVTOOLS__;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Structural shape of the profile-build global (installed by `octane/profiling`).
|
|
23
|
+
// Read via the global rather than imported so devtools stays decoupled from the
|
|
24
|
+
// profiling package and testable with a plain fake.
|
|
25
|
+
interface ProfilerGlobal {
|
|
26
|
+
summary(): Array<Record<string, unknown>>;
|
|
27
|
+
getEvents(): Array<Record<string, unknown>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getProfiler(): ProfilerGlobal | undefined {
|
|
31
|
+
return (globalThis as unknown as { __OCTANE_PROFILER__?: ProfilerGlobal }).__OCTANE_PROFILER__;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The DevTools panel is itself an Octane tree, so the profiler records ITS
|
|
35
|
+
// components too. Filter them out of everything we report: it's noise (you're
|
|
36
|
+
// profiling your app, not the inspector) and, critically, it stops the panel's
|
|
37
|
+
// own re-renders from perpetually changing the profile snapshot — which, with
|
|
38
|
+
// the dedupe below, lets the live view settle when the app is idle.
|
|
39
|
+
const DEVTOOLS_OWN_COMPONENTS = new Set([
|
|
40
|
+
'OctanePanel',
|
|
41
|
+
'ComponentsTab',
|
|
42
|
+
'ProfilerTab',
|
|
43
|
+
'TransitionsTab',
|
|
44
|
+
'PerfModelTab',
|
|
45
|
+
'TreeRow',
|
|
46
|
+
'DevtoolsPortal',
|
|
47
|
+
'TanStackDevtools',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
function pruneTree(nodes: WireTreeNode[]): WireTreeNode[] {
|
|
51
|
+
return nodes
|
|
52
|
+
.filter((n) => !DEVTOOLS_OWN_COMPONENTS.has(n.name))
|
|
53
|
+
.map((n) => ({ ...n, children: pruneTree(n.children) }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toSnapshot(profiler: ProfilerGlobal): ProfileSnapshot {
|
|
57
|
+
const summaries = profiler
|
|
58
|
+
.summary()
|
|
59
|
+
.filter((s) => !DEVTOOLS_OWN_COMPONENTS.has(String(s.component)))
|
|
60
|
+
.map((s) => ({
|
|
61
|
+
componentId: String(s.componentId),
|
|
62
|
+
component: String(s.component),
|
|
63
|
+
file: String(s.file),
|
|
64
|
+
attempts: Number(s.attempts) || 0,
|
|
65
|
+
completed: Number(s.completed) || 0,
|
|
66
|
+
suspended: Number(s.suspended) || 0,
|
|
67
|
+
errored: Number(s.errored) || 0,
|
|
68
|
+
bails: Number(s.bails) || 0,
|
|
69
|
+
totalSelfTime: Number(s.totalSelfTime) || 0,
|
|
70
|
+
maxInclusiveTime: Number(s.maxInclusiveTime) || 0,
|
|
71
|
+
averageSelfTime: Number(s.averageSelfTime) || 0,
|
|
72
|
+
averageQueueDelay: Number(s.averageQueueDelay) || 0,
|
|
73
|
+
dominantCause: (s.dominantCause as string | null) ?? null,
|
|
74
|
+
}));
|
|
75
|
+
const recentEvents = profiler
|
|
76
|
+
.getEvents()
|
|
77
|
+
.filter((e) => !DEVTOOLS_OWN_COMPONENTS.has(String(e.component)))
|
|
78
|
+
.slice(-100)
|
|
79
|
+
.map((e) => ({
|
|
80
|
+
component: String(e.component),
|
|
81
|
+
phase: (e.phase === 'update' ? 'update' : 'mount') as ProfileEventWire['phase'],
|
|
82
|
+
outcome: String(e.outcome) as ProfileEventWire['outcome'],
|
|
83
|
+
selfDuration: Number(e.selfDuration) || 0,
|
|
84
|
+
duration: Number(e.duration) || 0,
|
|
85
|
+
queueDelay: Number(e.queueDelay) || 0,
|
|
86
|
+
causes: Array.isArray(e.causes)
|
|
87
|
+
? (e.causes as Array<{ type?: unknown }>).map((c) => String(c.type ?? '')).filter(Boolean)
|
|
88
|
+
: [],
|
|
89
|
+
}));
|
|
90
|
+
return { summaries, recentEvents };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Wire the runtime hook to the event bus. Emits a coalesced `tree` snapshot once
|
|
95
|
+
* per scheduler flush; answers `inspect-request` with `inspect` (lazy detail — only
|
|
96
|
+
* the selected node is serialized). Returns a stop function. No-op if the runtime
|
|
97
|
+
* devtools hook is not installed (e.g. a non-profile build).
|
|
98
|
+
*/
|
|
99
|
+
export function startBridge(
|
|
100
|
+
client: OctaneDevtoolsEventClient = new OctaneDevtoolsEventClient(),
|
|
101
|
+
): () => void {
|
|
102
|
+
const hook = getHook();
|
|
103
|
+
if (!hook) return () => {};
|
|
104
|
+
|
|
105
|
+
// The panel is itself an Octane tree in the SAME runtime it observes, so an
|
|
106
|
+
// emit → panel re-render → flush → emit cycle would spin the CPU. Two guards
|
|
107
|
+
// tame it into a bounded, mostly-idle live view (as React DevTools does):
|
|
108
|
+
// 1. Throttle: at most one emit per MIN_INTERVAL_MS (leading + trailing).
|
|
109
|
+
// 2. Dedupe: skip an event whose serialized payload is unchanged, so tree
|
|
110
|
+
// and transition stop emitting entirely once the app goes idle.
|
|
111
|
+
const MIN_INTERVAL_MS = 400;
|
|
112
|
+
let lastEmitAt = 0;
|
|
113
|
+
let trailing: ReturnType<typeof setTimeout> | null = null;
|
|
114
|
+
let lastTree = '';
|
|
115
|
+
let lastProfile = '';
|
|
116
|
+
let lastTransition = '';
|
|
117
|
+
|
|
118
|
+
const emitNow = () => {
|
|
119
|
+
if (trailing !== null) {
|
|
120
|
+
clearTimeout(trailing);
|
|
121
|
+
trailing = null;
|
|
122
|
+
}
|
|
123
|
+
lastEmitAt = Date.now();
|
|
124
|
+
const nodes = pruneTree(hook.getTree());
|
|
125
|
+
const treeJson = JSON.stringify(nodes);
|
|
126
|
+
if (treeJson !== lastTree) {
|
|
127
|
+
lastTree = treeJson;
|
|
128
|
+
client.emit('tree', { nodes });
|
|
129
|
+
}
|
|
130
|
+
const profiler = getProfiler();
|
|
131
|
+
if (profiler) {
|
|
132
|
+
const snapshot = toSnapshot(profiler);
|
|
133
|
+
const profileJson = JSON.stringify(snapshot);
|
|
134
|
+
if (profileJson !== lastProfile) {
|
|
135
|
+
lastProfile = profileJson;
|
|
136
|
+
client.emit('profile', snapshot);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (typeof hook.getTransitionState === 'function') {
|
|
140
|
+
const transition = hook.getTransitionState();
|
|
141
|
+
const transitionJson = JSON.stringify(transition);
|
|
142
|
+
if (transitionJson !== lastTransition) {
|
|
143
|
+
lastTransition = transitionJson;
|
|
144
|
+
client.emit('transition', transition);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const schedule = () => {
|
|
150
|
+
if (trailing !== null) return;
|
|
151
|
+
const since = Date.now() - lastEmitAt;
|
|
152
|
+
if (since >= MIN_INTERVAL_MS) emitNow();
|
|
153
|
+
else trailing = setTimeout(emitNow, MIN_INTERVAL_MS - since);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const offFlush = hook.subscribe(schedule);
|
|
157
|
+
|
|
158
|
+
const offRequest = client.on('inspect-request', (e) => {
|
|
159
|
+
const detail = hook.inspect(e.payload.id);
|
|
160
|
+
if (detail) client.emit('inspect', detail);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// The panel asks for a fresh snapshot when it mounts; force a full re-emit
|
|
164
|
+
// (reset the dedupe caches) so its tabs populate even if nothing changed
|
|
165
|
+
// since the last emit.
|
|
166
|
+
const offRefresh = client.on('refresh', () => {
|
|
167
|
+
lastTree = '';
|
|
168
|
+
lastProfile = '';
|
|
169
|
+
lastTransition = '';
|
|
170
|
+
emitNow();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Initial snapshot so a panel opened after mount sees current state.
|
|
174
|
+
emitNow();
|
|
175
|
+
|
|
176
|
+
return () => {
|
|
177
|
+
offFlush();
|
|
178
|
+
offRequest();
|
|
179
|
+
offRefresh();
|
|
180
|
+
if (trailing !== null) clearTimeout(trailing);
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { EventClient } from '@tanstack/devtools-event-client';
|
|
2
|
+
|
|
3
|
+
export const PLUGIN_ID = 'octane';
|
|
4
|
+
|
|
5
|
+
// --- Wire types (the serialized contract; decoupled from runtime in-memory types) ---
|
|
6
|
+
export interface WireTreeNode {
|
|
7
|
+
id: number;
|
|
8
|
+
name: string;
|
|
9
|
+
kind: string;
|
|
10
|
+
children: WireTreeNode[];
|
|
11
|
+
}
|
|
12
|
+
export interface TreeSnapshot {
|
|
13
|
+
nodes: WireTreeNode[];
|
|
14
|
+
}
|
|
15
|
+
export interface WireHookCell {
|
|
16
|
+
kind: string;
|
|
17
|
+
value: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface NodeDetail {
|
|
20
|
+
id: number;
|
|
21
|
+
name: string;
|
|
22
|
+
hooks: WireHookCell[];
|
|
23
|
+
context: Array<{ name: string; value: unknown }>;
|
|
24
|
+
effectCount: number;
|
|
25
|
+
}
|
|
26
|
+
export interface InspectRequest {
|
|
27
|
+
id: number;
|
|
28
|
+
}
|
|
29
|
+
export interface ProfileSummaryWire {
|
|
30
|
+
componentId: string;
|
|
31
|
+
component: string;
|
|
32
|
+
file: string;
|
|
33
|
+
attempts: number;
|
|
34
|
+
completed: number;
|
|
35
|
+
suspended: number;
|
|
36
|
+
errored: number;
|
|
37
|
+
bails: number;
|
|
38
|
+
totalSelfTime: number;
|
|
39
|
+
maxInclusiveTime: number;
|
|
40
|
+
averageSelfTime: number;
|
|
41
|
+
averageQueueDelay: number;
|
|
42
|
+
dominantCause: string | null;
|
|
43
|
+
}
|
|
44
|
+
export interface ProfileEventWire {
|
|
45
|
+
component: string;
|
|
46
|
+
phase: 'mount' | 'update';
|
|
47
|
+
outcome: 'completed' | 'suspended' | 'errored' | 'bailout';
|
|
48
|
+
selfDuration: number;
|
|
49
|
+
duration: number;
|
|
50
|
+
queueDelay: number;
|
|
51
|
+
causes: string[]; // cause.type values, flattened for display
|
|
52
|
+
}
|
|
53
|
+
export interface ProfileSnapshot {
|
|
54
|
+
summaries: ProfileSummaryWire[];
|
|
55
|
+
recentEvents: ProfileEventWire[];
|
|
56
|
+
}
|
|
57
|
+
export interface BoundaryWire {
|
|
58
|
+
id: number;
|
|
59
|
+
branch: number;
|
|
60
|
+
state: 'init' | 'catch' | 'resolved' | 'pending';
|
|
61
|
+
hasResolved: boolean;
|
|
62
|
+
label: string;
|
|
63
|
+
}
|
|
64
|
+
export interface TransitionSnapshot {
|
|
65
|
+
pendingCount: number;
|
|
66
|
+
boundaries: BoundaryWire[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type OctaneDevtoolsEventMap = {
|
|
70
|
+
tree: TreeSnapshot;
|
|
71
|
+
inspect: NodeDetail;
|
|
72
|
+
'inspect-request': InspectRequest;
|
|
73
|
+
profile: ProfileSnapshot;
|
|
74
|
+
transition: TransitionSnapshot;
|
|
75
|
+
// Panel → bridge: "send me a fresh snapshot now" (emitted when the panel
|
|
76
|
+
// mounts, so tabs populate regardless of subscribe-vs-first-flush timing).
|
|
77
|
+
refresh: Record<string, never>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export class OctaneDevtoolsEventClient extends EventClient<OctaneDevtoolsEventMap> {
|
|
81
|
+
constructor() {
|
|
82
|
+
super({ pluginId: PLUGIN_ID });
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** @jsxImportSource octane */
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import { createElement } from 'octane';
|
|
5
|
+
import { OctaneDevtoolsEventClient } from './client';
|
|
6
|
+
import { startBridge } from './bridge';
|
|
7
|
+
import { OctanePanel } from './panel/OctanePanel.tsrx';
|
|
8
|
+
|
|
9
|
+
export { OctaneDevtoolsEventClient } from './client';
|
|
10
|
+
export { startBridge } from './bridge';
|
|
11
|
+
export { ComponentsTab } from './panel/ComponentsTab.tsrx';
|
|
12
|
+
export { ProfilerTab } from './panel/ProfilerTab.tsrx';
|
|
13
|
+
export { TransitionsTab } from './panel/TransitionsTab.tsrx';
|
|
14
|
+
export { PerfModelTab } from './panel/PerfModelTab.tsrx';
|
|
15
|
+
export { OctanePanel } from './panel/OctanePanel.tsrx';
|
|
16
|
+
export type { TreeSnapshot, NodeDetail, WireTreeNode } from './client';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A TanStack Devtools plugin exposing live Octane runtime diagnostics. Add it to
|
|
20
|
+
* `<TanStackDevtools plugins={[octaneDevtools()]} />`. Starts the app-side bridge
|
|
21
|
+
* on first render; the bridge is a no-op unless the app was built with devtools
|
|
22
|
+
* enabled (`octane({ devtools: true })`).
|
|
23
|
+
*/
|
|
24
|
+
export function octaneDevtools() {
|
|
25
|
+
const client = new OctaneDevtoolsEventClient();
|
|
26
|
+
let stop: (() => void) | null = null;
|
|
27
|
+
return {
|
|
28
|
+
id: 'octane',
|
|
29
|
+
name: 'Octane',
|
|
30
|
+
render: () => {
|
|
31
|
+
if (stop === null) stop = startBridge(client);
|
|
32
|
+
return createElement(OctanePanel, { client });
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The Components tab: subscribes to the app-side bridge's `tree`/`inspect` events
|
|
2
|
+
// (startBridge) and renders the live component tree plus a selected-node state
|
|
3
|
+
// inspector. Read-only — selecting a node only asks the app to describe it
|
|
4
|
+
// (`inspect-request`); it never mutates app state (see docs/devtools.md P1 scope).
|
|
5
|
+
import { useEffect, useState } from 'octane';
|
|
6
|
+
import type { OctaneNode } from 'octane';
|
|
7
|
+
import { OctaneDevtoolsEventClient, type NodeDetail, type WireTreeNode } from '../client';
|
|
8
|
+
|
|
9
|
+
function TreeRow(props: {
|
|
10
|
+
node: WireTreeNode;
|
|
11
|
+
onSelect: (id: number) => void;
|
|
12
|
+
selected: number | null;
|
|
13
|
+
}) @{
|
|
14
|
+
<div>
|
|
15
|
+
<button
|
|
16
|
+
class={['odt-tree-row', props.selected === props.node.id && 'odt-selected']}
|
|
17
|
+
onClick={() => props.onSelect(props.node.id)}
|
|
18
|
+
>
|
|
19
|
+
<span
|
|
20
|
+
class={['odt-kind', props.node.kind === 'root' && 'odt-kind-root']}
|
|
21
|
+
>{props.node.kind}</span>
|
|
22
|
+
<span>{props.node.name}</span>
|
|
23
|
+
</button>
|
|
24
|
+
@if (props.node.children.length > 0) {
|
|
25
|
+
<div class="odt-child">
|
|
26
|
+
@for (const child of props.node.children; key child.id) {
|
|
27
|
+
<TreeRow node={child} onSelect={props.onSelect} selected={props.selected} />
|
|
28
|
+
}
|
|
29
|
+
</div>
|
|
30
|
+
}
|
|
31
|
+
</div>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ComponentsTab(props: { client: OctaneDevtoolsEventClient }): OctaneNode {
|
|
35
|
+
const { client } = props;
|
|
36
|
+
const [nodes, setNodes] = useState<WireTreeNode[]>([]);
|
|
37
|
+
const [selected, setSelected] = useState<number | null>(null);
|
|
38
|
+
const [detail, setDetail] = useState<NodeDetail | null>(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const offTree = client.on('tree', (e) => setNodes(e.payload.nodes));
|
|
42
|
+
const offInspect = client.on('inspect', (e) => setDetail(e.payload));
|
|
43
|
+
return () => {
|
|
44
|
+
offTree();
|
|
45
|
+
offInspect();
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const onSelect = (id: number) => {
|
|
50
|
+
setSelected(id);
|
|
51
|
+
client.emit('inspect-request', { id });
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return <div class="odt-split">
|
|
55
|
+
<div class="odt-col odt-col-tree">
|
|
56
|
+
<div class="odt-h">Component tree</div>
|
|
57
|
+
@if (nodes.length === 0) {
|
|
58
|
+
<div class="odt-empty">No mounted roots yet.</div>
|
|
59
|
+
} @else {
|
|
60
|
+
@for (const node of nodes; key node.id) {
|
|
61
|
+
<TreeRow node={node} onSelect={onSelect} selected={selected} />
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
</div>
|
|
65
|
+
<div class="odt-col">
|
|
66
|
+
@if (detail) {
|
|
67
|
+
<div>
|
|
68
|
+
<div class="odt-node-name">
|
|
69
|
+
{detail.name}
|
|
70
|
+
<span class="odt-badge">{detail.effectCount} effects</span>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="odt-h">Hooks</div>
|
|
73
|
+
@if (detail.hooks.length === 0) {
|
|
74
|
+
<div class="odt-empty odt-muted">No tracked hooks.</div>
|
|
75
|
+
} @else {
|
|
76
|
+
@for (const cell of detail.hooks; index i) {
|
|
77
|
+
<div class="odt-kv">
|
|
78
|
+
<span class="odt-kv-key">{cell.kind}</span>
|
|
79
|
+
<span class="odt-kv-val"> {JSON.stringify(cell.value)}</span>
|
|
80
|
+
</div>
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
@if (detail.context.length > 0) {
|
|
84
|
+
<>
|
|
85
|
+
<div class="odt-h">Context</div>
|
|
86
|
+
@for (const c of detail.context; index i) {
|
|
87
|
+
<div class="odt-kv">
|
|
88
|
+
<span class="odt-kv-key">{c.name}</span>
|
|
89
|
+
<span class="odt-kv-val"> {JSON.stringify(c.value)}</span>
|
|
90
|
+
</div>
|
|
91
|
+
}
|
|
92
|
+
</>
|
|
93
|
+
}
|
|
94
|
+
</div>
|
|
95
|
+
} @else {
|
|
96
|
+
<div class="odt-empty">Select a component.</div>
|
|
97
|
+
}
|
|
98
|
+
</div>
|
|
99
|
+
</div>;
|
|
100
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The tab shell hosting the Components (P1), Profiler (P2), Transitions (P3),
|
|
2
|
+
// and Performance-model (P4) tabs. This is the plugin's top-level render()
|
|
3
|
+
// node — see ../index.ts. It injects the Octane-branded panel stylesheet once
|
|
4
|
+
// and asks the bridge for a fresh snapshot on mount so tabs populate immediately.
|
|
5
|
+
import { useEffect, useState } from 'octane';
|
|
6
|
+
import type { OctaneNode } from 'octane';
|
|
7
|
+
import { OctaneDevtoolsEventClient } from '../client';
|
|
8
|
+
import { ComponentsTab } from './ComponentsTab.tsrx';
|
|
9
|
+
import { ProfilerTab } from './ProfilerTab.tsrx';
|
|
10
|
+
import { TransitionsTab } from './TransitionsTab.tsrx';
|
|
11
|
+
import { PerfModelTab } from './PerfModelTab.tsrx';
|
|
12
|
+
import { OCTANE_DEVTOOLS_CSS, OCTANE_DEVTOOLS_STYLE_ID } from './styles';
|
|
13
|
+
|
|
14
|
+
type Tab = 'components' | 'profiler' | 'transitions' | 'performance';
|
|
15
|
+
|
|
16
|
+
const TABS: Array<{ id: Tab; label: string }> = [
|
|
17
|
+
{ id: 'components', label: 'Components' },
|
|
18
|
+
{ id: 'profiler', label: 'Profiler' },
|
|
19
|
+
{ id: 'transitions', label: 'Transitions & Suspense' },
|
|
20
|
+
{ id: 'performance', label: 'Performance' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function OctanePanel(props: { client: OctaneDevtoolsEventClient }): OctaneNode {
|
|
24
|
+
const { client } = props;
|
|
25
|
+
const [tab, setTab] = useState<Tab>('components');
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// The panel is portaled into the TanStack DevTools host, so site CSS never
|
|
29
|
+
// reaches it — inject our own Octane-branded stylesheet once.
|
|
30
|
+
if (typeof document !== 'undefined' && !document.getElementById(OCTANE_DEVTOOLS_STYLE_ID)) {
|
|
31
|
+
const el = document.createElement('style');
|
|
32
|
+
el.id = OCTANE_DEVTOOLS_STYLE_ID;
|
|
33
|
+
el.textContent = OCTANE_DEVTOOLS_CSS;
|
|
34
|
+
document.head.appendChild(el);
|
|
35
|
+
}
|
|
36
|
+
// Pull a current snapshot so tabs show data on open, not only after the
|
|
37
|
+
// next scheduler flush.
|
|
38
|
+
client.emit('refresh', {});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return <div class="odt-root">
|
|
42
|
+
<div class="odt-tabs">
|
|
43
|
+
@for (const t of TABS; key t.id) {
|
|
44
|
+
<button
|
|
45
|
+
class={['odt-tab', tab === t.id && 'odt-tab-active']}
|
|
46
|
+
onClick={() => setTab(t.id)}
|
|
47
|
+
>{t.label}</button>
|
|
48
|
+
}
|
|
49
|
+
</div>
|
|
50
|
+
<div class="odt-body">
|
|
51
|
+
@if (tab === 'components') {
|
|
52
|
+
<ComponentsTab client={client} />
|
|
53
|
+
} @else if (tab === 'profiler') {
|
|
54
|
+
<ProfilerTab client={client} />
|
|
55
|
+
} @else if (tab === 'transitions') {
|
|
56
|
+
<TransitionsTab client={client} />
|
|
57
|
+
} @else {
|
|
58
|
+
<PerfModelTab client={client} />
|
|
59
|
+
}
|
|
60
|
+
</div>
|
|
61
|
+
</div>;
|
|
62
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The Performance-model tab: derives aggregate "what's slow" views from the SAME
|
|
2
|
+
// `profile` event the Profiler tab already consumes — no new event, bridge path, or
|
|
3
|
+
// runtime probe. Where the Profiler tab shows a per-commit table, this tab shows
|
|
4
|
+
// ranked aggregates: slowest mounts by self time, queue-delay hotspots, and a recent
|
|
5
|
+
// commit self-time trend.
|
|
6
|
+
import { useEffect, useMemo, useState } from 'octane';
|
|
7
|
+
import type { OctaneNode } from 'octane';
|
|
8
|
+
import { OctaneDevtoolsEventClient, type ProfileSnapshot } from '../client';
|
|
9
|
+
|
|
10
|
+
function fmt(ms: number): string {
|
|
11
|
+
return (Math.round(ms * 10) / 10).toString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function PerfModelTab(props: { client: OctaneDevtoolsEventClient }): OctaneNode {
|
|
15
|
+
const { client } = props;
|
|
16
|
+
const [snapshot, setSnapshot] = useState<ProfileSnapshot | null>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const off = client.on('profile', (e) => setSnapshot(e.payload));
|
|
20
|
+
return () => off();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Aggregate views derived from the profile snapshot.
|
|
24
|
+
const slowestMounts = useMemo(() => {
|
|
25
|
+
if (snapshot === null) return [];
|
|
26
|
+
const mounts = snapshot.recentEvents.filter((e) => e.phase === 'mount');
|
|
27
|
+
return mounts.sort((a, b) => b.selfDuration - a.selfDuration).slice(0, 5);
|
|
28
|
+
}, [snapshot]);
|
|
29
|
+
|
|
30
|
+
// Excludes components already surfaced by the mounts list above — the two
|
|
31
|
+
// aggregate lists highlight distinct signals (heavy initial renders vs.
|
|
32
|
+
// scheduling delay), not the same component twice.
|
|
33
|
+
const queueHotspots = useMemo(() => {
|
|
34
|
+
if (snapshot === null) return [];
|
|
35
|
+
const alreadyShown = new Set(slowestMounts.map((e) => e.component));
|
|
36
|
+
const others = snapshot.summaries.filter((s) => !alreadyShown.has(s.component));
|
|
37
|
+
return others.sort((a, b) => b.averageQueueDelay - a.averageQueueDelay).slice(0, 5);
|
|
38
|
+
}, [snapshot, slowestMounts]);
|
|
39
|
+
|
|
40
|
+
const maxSelf = useMemo(() => {
|
|
41
|
+
if (snapshot === null) return 1;
|
|
42
|
+
return Math.max(1, ...snapshot.recentEvents.map((e) => e.selfDuration));
|
|
43
|
+
}, [snapshot]);
|
|
44
|
+
|
|
45
|
+
return <div>
|
|
46
|
+
@if (snapshot === null || snapshot.summaries.length === 0) {
|
|
47
|
+
<div class="odt-empty">No performance data yet — interact with the app.</div>
|
|
48
|
+
} @else {
|
|
49
|
+
<>
|
|
50
|
+
<div class="odt-h">Slowest mounts (self time)</div>
|
|
51
|
+
@if (slowestMounts.length === 0) {
|
|
52
|
+
<div class="odt-empty odt-muted">No mounts recorded yet.</div>
|
|
53
|
+
} @else {
|
|
54
|
+
@for (const e of slowestMounts; index i) {
|
|
55
|
+
<div class="odt-commit-row">
|
|
56
|
+
<span class="odt-name-cell">{e.component}</span>
|
|
57
|
+
<span
|
|
58
|
+
class="odt-bar"
|
|
59
|
+
style={{ width: `${Math.max(6, (e.selfDuration / maxSelf) * 120)}px` }}
|
|
60
|
+
></span>
|
|
61
|
+
<span class="odt-num">{fmt(e.selfDuration)}ms</span>
|
|
62
|
+
</div>
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
<div class="odt-h">Queue-delay hotspots</div>
|
|
66
|
+
@if (queueHotspots.length === 0) {
|
|
67
|
+
<div class="odt-empty odt-muted">No additional hotspots.</div>
|
|
68
|
+
} @else {
|
|
69
|
+
@for (const s of queueHotspots; index i) {
|
|
70
|
+
<div class="odt-commit-row">
|
|
71
|
+
<span class="odt-name-cell">{s.component}</span>
|
|
72
|
+
<span class="odt-muted">{fmt(s.averageQueueDelay)}ms avg queue</span>
|
|
73
|
+
</div>
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
<div class="odt-h">Recent commit self-time</div>
|
|
77
|
+
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '2px', height: '30px' }}>
|
|
78
|
+
@for (const ev of snapshot.recentEvents; index i) {
|
|
79
|
+
<span
|
|
80
|
+
class="odt-bar"
|
|
81
|
+
title={`${ev.component}: ${fmt(ev.selfDuration)}ms`}
|
|
82
|
+
style={{
|
|
83
|
+
width: '7px',
|
|
84
|
+
height: `${Math.min(28, Math.max(2, (ev.selfDuration / maxSelf) * 28))}px`,
|
|
85
|
+
}}
|
|
86
|
+
></span>
|
|
87
|
+
}
|
|
88
|
+
</div>
|
|
89
|
+
</>
|
|
90
|
+
}
|
|
91
|
+
</div>;
|
|
92
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The Profiler tab: subscribes to the app-side bridge's `profile` event (startBridge,
|
|
2
|
+
// which reads globalThis.__OCTANE_PROFILER__ per flush) and renders a ranked
|
|
3
|
+
// self-time table plus a recent-commits list. Absent a profiler global on the app
|
|
4
|
+
// side, no `profile` event ever arrives and the tab stays in its empty state.
|
|
5
|
+
import { useEffect, useState } from 'octane';
|
|
6
|
+
import type { OctaneNode } from 'octane';
|
|
7
|
+
import { OctaneDevtoolsEventClient, type ProfileSnapshot } from '../client';
|
|
8
|
+
|
|
9
|
+
function fmt(ms: number): string {
|
|
10
|
+
return (Math.round(ms * 10) / 10).toString();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ProfilerTab(props: { client: OctaneDevtoolsEventClient }): OctaneNode {
|
|
14
|
+
const { client } = props;
|
|
15
|
+
const [snapshot, setSnapshot] = useState<ProfileSnapshot | null>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const off = client.on('profile', (e) => setSnapshot(e.payload));
|
|
19
|
+
return () => off();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return <div>
|
|
23
|
+
@if (snapshot === null || snapshot.summaries.length === 0) {
|
|
24
|
+
<div class="odt-empty">No profiling data yet — interact with the app.</div>
|
|
25
|
+
} @else {
|
|
26
|
+
<>
|
|
27
|
+
<div class="odt-h">Slowest components (self time)</div>
|
|
28
|
+
<table class="odt-table">
|
|
29
|
+
<thead>
|
|
30
|
+
<tr>
|
|
31
|
+
<th>Component</th>
|
|
32
|
+
<th class="odt-num">Renders</th>
|
|
33
|
+
<th class="odt-num">Self ms</th>
|
|
34
|
+
<th class="odt-num">Max ms</th>
|
|
35
|
+
<th class="odt-num">Queue ms</th>
|
|
36
|
+
<th>Cause</th>
|
|
37
|
+
</tr>
|
|
38
|
+
</thead>
|
|
39
|
+
<tbody>
|
|
40
|
+
@for (const s of snapshot.summaries; index i) {
|
|
41
|
+
<tr>
|
|
42
|
+
<td class="odt-name-cell">{s.component}</td>
|
|
43
|
+
<td class="odt-num">{s.attempts}</td>
|
|
44
|
+
<td class="odt-num">{fmt(s.totalSelfTime)}</td>
|
|
45
|
+
<td class="odt-num">{fmt(s.maxInclusiveTime)}</td>
|
|
46
|
+
<td class="odt-num">{fmt(s.averageQueueDelay)}</td>
|
|
47
|
+
<td class="odt-muted">{(s.dominantCause ?? '-') as string}</td>
|
|
48
|
+
</tr>
|
|
49
|
+
}
|
|
50
|
+
</tbody>
|
|
51
|
+
</table>
|
|
52
|
+
<div class="odt-h">Recent commits</div>
|
|
53
|
+
@for (const ev of snapshot.recentEvents; index i) {
|
|
54
|
+
<div class="odt-commit-row">
|
|
55
|
+
{ev.component}
|
|
56
|
+
·
|
|
57
|
+
{ev.phase}
|
|
58
|
+
·
|
|
59
|
+
{fmt(ev.selfDuration)}ms ·
|
|
60
|
+
{ev.causes.join(',') || ev.outcome}
|
|
61
|
+
</div>
|
|
62
|
+
}
|
|
63
|
+
</>
|
|
64
|
+
}
|
|
65
|
+
</div>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The Transitions & Suspense tab: subscribes to the app-side bridge's `transition`
|
|
2
|
+
// event (startBridge, which reads the devtools hook's getTransitionState() per
|
|
3
|
+
// flush) and renders the live pending-transition count plus each Suspense boundary's
|
|
4
|
+
// state. Absent a subscriber-observed hook on the app side, no `transition` event
|
|
5
|
+
// ever arrives and the tab stays in its empty state.
|
|
6
|
+
import { useEffect, useState } from 'octane';
|
|
7
|
+
import type { OctaneNode } from 'octane';
|
|
8
|
+
import { OctaneDevtoolsEventClient, type BoundaryWire, type TransitionSnapshot } from '../client';
|
|
9
|
+
|
|
10
|
+
function stateClass(b: BoundaryWire): string {
|
|
11
|
+
if (b.state === 'pending') return 'odt-state odt-state-pending';
|
|
12
|
+
if (b.state === 'resolved') return 'odt-state odt-state-resolved';
|
|
13
|
+
if (b.state === 'catch') return 'odt-state odt-state-catch';
|
|
14
|
+
return 'odt-state odt-state-init';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function TransitionsTab(props: { client: OctaneDevtoolsEventClient }): OctaneNode {
|
|
18
|
+
const { client } = props;
|
|
19
|
+
const [snapshot, setSnapshot] = useState<TransitionSnapshot>({ pendingCount: 0, boundaries: [] });
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const off = client.on('transition', (e) => setSnapshot(e.payload));
|
|
23
|
+
return () => off();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return <div>
|
|
27
|
+
<div class="odt-stat">Pending transitions: {snapshot.pendingCount}</div>
|
|
28
|
+
<div class="odt-h">Suspense boundaries</div>
|
|
29
|
+
@if (snapshot.boundaries.length === 0) {
|
|
30
|
+
<div class="odt-empty">No Suspense boundaries.</div>
|
|
31
|
+
} @else {
|
|
32
|
+
@for (const b of snapshot.boundaries; index i) {
|
|
33
|
+
<div class="odt-boundary">
|
|
34
|
+
<span class={stateClass(b)}>{b.state}</span>
|
|
35
|
+
<span>{b.label}</span>
|
|
36
|
+
@if (b.hasResolved && b.state !== 'resolved') {
|
|
37
|
+
<span class="odt-muted">resolved once</span>
|
|
38
|
+
}
|
|
39
|
+
</div>
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
</div>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Self-contained Octane-branded stylesheet for the DevTools panel. The panel is
|
|
2
|
+
// portaled into the TanStack DevTools host, so the website's `:root` custom
|
|
3
|
+
// properties are not reliably in scope — we inject our own tokens + rules once,
|
|
4
|
+
// namespaced under `.odt-` to avoid collisions with the host. Palette + fonts
|
|
5
|
+
// mirror the Octane website (docs/website/src/routes/__root.tsrx): coral accent
|
|
6
|
+
// #ff415a on dark slate, Inter for UI, SF Mono for values.
|
|
7
|
+
|
|
8
|
+
export const OCTANE_DEVTOOLS_STYLE_ID = 'octane-devtools-styles';
|
|
9
|
+
|
|
10
|
+
export const OCTANE_DEVTOOLS_CSS = `
|
|
11
|
+
.odt-root {
|
|
12
|
+
--odt-bg: #23272f;
|
|
13
|
+
--odt-panel: #2b3138;
|
|
14
|
+
--odt-code: #16181d;
|
|
15
|
+
--odt-text: #f4eee8;
|
|
16
|
+
--odt-body: #d5d9e0;
|
|
17
|
+
--odt-sec: #99a1b3;
|
|
18
|
+
--odt-accent: #ff415a;
|
|
19
|
+
--odt-accent2: #ff5d72;
|
|
20
|
+
--odt-on-accent: #16181d;
|
|
21
|
+
--odt-border: rgba(255, 255, 255, 0.1);
|
|
22
|
+
--odt-border-strong: rgba(255, 255, 255, 0.22);
|
|
23
|
+
--odt-surface: rgba(255, 255, 255, 0.06);
|
|
24
|
+
--odt-subtle: rgba(255, 255, 255, 0.03);
|
|
25
|
+
--odt-inset: rgba(22, 24, 29, 0.55);
|
|
26
|
+
--odt-success: #8ae8ae;
|
|
27
|
+
--odt-warn: #ffc56d;
|
|
28
|
+
--odt-danger: #ff8797;
|
|
29
|
+
--odt-mono: 'SF Mono', SFMono-Regular, ui-monospace, Menlo, Consolas, monospace;
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
height: 100%;
|
|
33
|
+
min-height: 0;
|
|
34
|
+
background: var(--odt-bg);
|
|
35
|
+
color: var(--odt-body);
|
|
36
|
+
font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
37
|
+
font-size: 12px;
|
|
38
|
+
line-height: 1.45;
|
|
39
|
+
}
|
|
40
|
+
.odt-tabs {
|
|
41
|
+
display: flex;
|
|
42
|
+
flex-wrap: nowrap;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: 2px;
|
|
45
|
+
padding: 0 10px;
|
|
46
|
+
background: var(--odt-panel);
|
|
47
|
+
border-bottom: 1px solid var(--odt-border);
|
|
48
|
+
/* Fixed height + flex-basis so the bar can never grow to fill the column
|
|
49
|
+
(the host's flex context resolves an auto basis unpredictably here). */
|
|
50
|
+
flex: 0 0 42px;
|
|
51
|
+
height: 42px;
|
|
52
|
+
box-sizing: border-box;
|
|
53
|
+
}
|
|
54
|
+
.odt-tab {
|
|
55
|
+
appearance: none;
|
|
56
|
+
border: 0;
|
|
57
|
+
background: transparent;
|
|
58
|
+
color: var(--odt-sec);
|
|
59
|
+
font: inherit;
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
letter-spacing: 0.01em;
|
|
62
|
+
padding: 8px 13px;
|
|
63
|
+
border-radius: 8px 8px 0 0;
|
|
64
|
+
cursor: pointer;
|
|
65
|
+
position: relative;
|
|
66
|
+
flex: 0 0 auto;
|
|
67
|
+
align-self: center;
|
|
68
|
+
height: auto;
|
|
69
|
+
line-height: 1.3;
|
|
70
|
+
white-space: nowrap;
|
|
71
|
+
transition: color 0.12s ease, background 0.12s ease;
|
|
72
|
+
}
|
|
73
|
+
.odt-tab:hover {
|
|
74
|
+
color: var(--odt-text);
|
|
75
|
+
background: var(--odt-surface);
|
|
76
|
+
}
|
|
77
|
+
.odt-tab.odt-tab-active {
|
|
78
|
+
color: var(--odt-text);
|
|
79
|
+
}
|
|
80
|
+
.odt-tab.odt-tab-active::after {
|
|
81
|
+
content: '';
|
|
82
|
+
position: absolute;
|
|
83
|
+
left: 10px;
|
|
84
|
+
right: 10px;
|
|
85
|
+
bottom: -1px;
|
|
86
|
+
height: 2px;
|
|
87
|
+
border-radius: 2px 2px 0 0;
|
|
88
|
+
background: var(--odt-accent);
|
|
89
|
+
box-shadow: 0 0 10px rgba(255, 65, 90, 0.55);
|
|
90
|
+
}
|
|
91
|
+
.odt-body {
|
|
92
|
+
flex: 1 1 0;
|
|
93
|
+
min-height: 0;
|
|
94
|
+
overflow: auto;
|
|
95
|
+
padding: 12px 14px;
|
|
96
|
+
}
|
|
97
|
+
.odt-split {
|
|
98
|
+
display: flex;
|
|
99
|
+
gap: 12px;
|
|
100
|
+
height: 100%;
|
|
101
|
+
min-height: 0;
|
|
102
|
+
}
|
|
103
|
+
.odt-col {
|
|
104
|
+
flex: 1 1 0;
|
|
105
|
+
min-width: 0;
|
|
106
|
+
overflow: auto;
|
|
107
|
+
}
|
|
108
|
+
.odt-col-tree {
|
|
109
|
+
flex: 0 0 46%;
|
|
110
|
+
border-right: 1px solid var(--odt-border);
|
|
111
|
+
padding-right: 12px;
|
|
112
|
+
}
|
|
113
|
+
.odt-h {
|
|
114
|
+
margin: 0 0 8px;
|
|
115
|
+
font-size: 10px;
|
|
116
|
+
font-weight: 700;
|
|
117
|
+
text-transform: uppercase;
|
|
118
|
+
letter-spacing: 0.08em;
|
|
119
|
+
color: var(--odt-sec);
|
|
120
|
+
}
|
|
121
|
+
.odt-h + .odt-h {
|
|
122
|
+
margin-top: 14px;
|
|
123
|
+
}
|
|
124
|
+
.odt-empty {
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: 8px;
|
|
128
|
+
color: var(--odt-sec);
|
|
129
|
+
font-style: normal;
|
|
130
|
+
padding: 10px 2px;
|
|
131
|
+
}
|
|
132
|
+
.odt-empty::before {
|
|
133
|
+
content: '';
|
|
134
|
+
width: 7px;
|
|
135
|
+
height: 7px;
|
|
136
|
+
border-radius: 50%;
|
|
137
|
+
background: var(--odt-accent);
|
|
138
|
+
opacity: 0.65;
|
|
139
|
+
flex: 0 0 auto;
|
|
140
|
+
}
|
|
141
|
+
/* Component tree */
|
|
142
|
+
.odt-tree-row {
|
|
143
|
+
appearance: none;
|
|
144
|
+
border: 0;
|
|
145
|
+
width: 100%;
|
|
146
|
+
text-align: left;
|
|
147
|
+
background: transparent;
|
|
148
|
+
color: var(--odt-body);
|
|
149
|
+
font-family: var(--odt-mono);
|
|
150
|
+
font-size: 11.5px;
|
|
151
|
+
padding: 3px 6px;
|
|
152
|
+
border-radius: 6px;
|
|
153
|
+
cursor: pointer;
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
gap: 6px;
|
|
157
|
+
transition: background 0.1s ease, color 0.1s ease;
|
|
158
|
+
}
|
|
159
|
+
.odt-tree-row:hover {
|
|
160
|
+
background: var(--odt-surface);
|
|
161
|
+
color: var(--odt-text);
|
|
162
|
+
}
|
|
163
|
+
.odt-tree-row.odt-selected {
|
|
164
|
+
background: rgba(255, 65, 90, 0.16);
|
|
165
|
+
color: var(--odt-text);
|
|
166
|
+
box-shadow: inset 2px 0 0 var(--odt-accent);
|
|
167
|
+
}
|
|
168
|
+
.odt-kind {
|
|
169
|
+
font-size: 9px;
|
|
170
|
+
font-weight: 700;
|
|
171
|
+
text-transform: uppercase;
|
|
172
|
+
letter-spacing: 0.04em;
|
|
173
|
+
color: var(--odt-sec);
|
|
174
|
+
background: var(--odt-surface);
|
|
175
|
+
border-radius: 4px;
|
|
176
|
+
padding: 1px 5px;
|
|
177
|
+
flex: 0 0 auto;
|
|
178
|
+
}
|
|
179
|
+
.odt-kind-root {
|
|
180
|
+
color: var(--odt-on-accent);
|
|
181
|
+
background: var(--odt-accent);
|
|
182
|
+
}
|
|
183
|
+
.odt-child {
|
|
184
|
+
margin-left: 12px;
|
|
185
|
+
border-left: 1px solid var(--odt-border);
|
|
186
|
+
padding-left: 4px;
|
|
187
|
+
}
|
|
188
|
+
/* Inspector */
|
|
189
|
+
.odt-node-name {
|
|
190
|
+
margin: 0 0 10px;
|
|
191
|
+
font-family: var(--odt-mono);
|
|
192
|
+
font-size: 13px;
|
|
193
|
+
font-weight: 600;
|
|
194
|
+
color: var(--odt-text);
|
|
195
|
+
}
|
|
196
|
+
.odt-badge {
|
|
197
|
+
display: inline-block;
|
|
198
|
+
font-size: 10px;
|
|
199
|
+
font-weight: 600;
|
|
200
|
+
color: var(--odt-sec);
|
|
201
|
+
background: var(--odt-surface);
|
|
202
|
+
border: 1px solid var(--odt-border);
|
|
203
|
+
border-radius: 999px;
|
|
204
|
+
padding: 1px 8px;
|
|
205
|
+
margin-left: 8px;
|
|
206
|
+
}
|
|
207
|
+
.odt-kv {
|
|
208
|
+
font-family: var(--odt-mono);
|
|
209
|
+
font-size: 11.5px;
|
|
210
|
+
background: var(--odt-inset);
|
|
211
|
+
border: 1px solid var(--odt-border);
|
|
212
|
+
border-radius: 7px;
|
|
213
|
+
padding: 6px 9px;
|
|
214
|
+
margin-bottom: 5px;
|
|
215
|
+
overflow-x: auto;
|
|
216
|
+
white-space: pre;
|
|
217
|
+
}
|
|
218
|
+
.odt-kv-key {
|
|
219
|
+
color: var(--odt-accent2);
|
|
220
|
+
font-weight: 600;
|
|
221
|
+
}
|
|
222
|
+
.odt-kv-val {
|
|
223
|
+
color: var(--odt-body);
|
|
224
|
+
}
|
|
225
|
+
/* Tables (profiler / performance) */
|
|
226
|
+
.odt-table {
|
|
227
|
+
width: 100%;
|
|
228
|
+
border-collapse: collapse;
|
|
229
|
+
font-family: var(--odt-mono);
|
|
230
|
+
font-size: 11px;
|
|
231
|
+
margin-bottom: 14px;
|
|
232
|
+
}
|
|
233
|
+
.odt-table th {
|
|
234
|
+
text-align: left;
|
|
235
|
+
font-weight: 600;
|
|
236
|
+
color: var(--odt-sec);
|
|
237
|
+
border-bottom: 1px solid var(--odt-border-strong);
|
|
238
|
+
padding: 4px 8px;
|
|
239
|
+
position: sticky;
|
|
240
|
+
top: 0;
|
|
241
|
+
background: var(--odt-bg);
|
|
242
|
+
}
|
|
243
|
+
.odt-table td {
|
|
244
|
+
padding: 3px 8px;
|
|
245
|
+
border-bottom: 1px solid var(--odt-subtle);
|
|
246
|
+
color: var(--odt-body);
|
|
247
|
+
}
|
|
248
|
+
.odt-table tr:hover td {
|
|
249
|
+
background: var(--odt-surface);
|
|
250
|
+
}
|
|
251
|
+
.odt-num {
|
|
252
|
+
text-align: right;
|
|
253
|
+
font-variant-numeric: tabular-nums;
|
|
254
|
+
color: var(--odt-text);
|
|
255
|
+
}
|
|
256
|
+
.odt-name-cell {
|
|
257
|
+
color: var(--odt-text);
|
|
258
|
+
font-weight: 600;
|
|
259
|
+
}
|
|
260
|
+
.odt-bar {
|
|
261
|
+
display: inline-block;
|
|
262
|
+
height: 6px;
|
|
263
|
+
border-radius: 3px;
|
|
264
|
+
background: linear-gradient(90deg, var(--odt-accent), var(--odt-accent2));
|
|
265
|
+
vertical-align: middle;
|
|
266
|
+
}
|
|
267
|
+
.odt-commit-row {
|
|
268
|
+
display: flex;
|
|
269
|
+
align-items: center;
|
|
270
|
+
gap: 8px;
|
|
271
|
+
font-family: var(--odt-mono);
|
|
272
|
+
font-size: 11px;
|
|
273
|
+
padding: 2px 0;
|
|
274
|
+
color: var(--odt-body);
|
|
275
|
+
}
|
|
276
|
+
/* Transitions & Suspense */
|
|
277
|
+
.odt-stat {
|
|
278
|
+
font-family: var(--odt-mono);
|
|
279
|
+
font-size: 14px;
|
|
280
|
+
font-weight: 600;
|
|
281
|
+
color: var(--odt-accent2);
|
|
282
|
+
margin-bottom: 14px;
|
|
283
|
+
padding: 8px 10px;
|
|
284
|
+
background: var(--odt-inset);
|
|
285
|
+
border: 1px solid var(--odt-border);
|
|
286
|
+
border-radius: 8px;
|
|
287
|
+
}
|
|
288
|
+
.odt-boundary {
|
|
289
|
+
display: flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
gap: 9px;
|
|
292
|
+
padding: 6px 8px;
|
|
293
|
+
border: 1px solid var(--odt-border);
|
|
294
|
+
border-radius: 8px;
|
|
295
|
+
background: var(--odt-subtle);
|
|
296
|
+
margin-bottom: 5px;
|
|
297
|
+
font-family: var(--odt-mono);
|
|
298
|
+
font-size: 11.5px;
|
|
299
|
+
color: var(--odt-text);
|
|
300
|
+
}
|
|
301
|
+
.odt-state {
|
|
302
|
+
font-size: 9.5px;
|
|
303
|
+
font-weight: 700;
|
|
304
|
+
text-transform: uppercase;
|
|
305
|
+
letter-spacing: 0.05em;
|
|
306
|
+
border-radius: 999px;
|
|
307
|
+
padding: 2px 8px;
|
|
308
|
+
flex: 0 0 auto;
|
|
309
|
+
}
|
|
310
|
+
.odt-state-pending {
|
|
311
|
+
color: #3a2c00;
|
|
312
|
+
background: var(--odt-warn);
|
|
313
|
+
}
|
|
314
|
+
.odt-state-resolved {
|
|
315
|
+
color: #05321a;
|
|
316
|
+
background: var(--odt-success);
|
|
317
|
+
}
|
|
318
|
+
.odt-state-catch {
|
|
319
|
+
color: #3a0510;
|
|
320
|
+
background: var(--odt-danger);
|
|
321
|
+
}
|
|
322
|
+
.odt-state-init {
|
|
323
|
+
color: var(--odt-body);
|
|
324
|
+
background: var(--odt-surface);
|
|
325
|
+
}
|
|
326
|
+
.odt-muted {
|
|
327
|
+
color: var(--odt-sec);
|
|
328
|
+
}
|
|
329
|
+
`;
|