@fluixi/devtools 0.2.0-alpha.1 → 0.2.0-alpha.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.
Files changed (96) hide show
  1. package/README.md +80 -2
  2. package/dist/callsite.d.ts +22 -0
  3. package/dist/callsite.d.ts.map +1 -0
  4. package/dist/callsite.js +82 -0
  5. package/dist/change-view.d.ts +10 -0
  6. package/dist/change-view.d.ts.map +1 -0
  7. package/dist/change-view.js +102 -0
  8. package/dist/copy.d.ts +9 -0
  9. package/dist/copy.d.ts.map +1 -0
  10. package/dist/copy.js +52 -0
  11. package/dist/describe.d.ts +3 -0
  12. package/dist/describe.d.ts.map +1 -0
  13. package/dist/describe.js +47 -0
  14. package/dist/detail-panel.d.ts +17 -0
  15. package/dist/detail-panel.d.ts.map +1 -0
  16. package/dist/detail-panel.js +217 -0
  17. package/dist/diff.d.ts +31 -0
  18. package/dist/diff.d.ts.map +1 -0
  19. package/dist/diff.js +132 -0
  20. package/dist/dom.d.ts +81 -0
  21. package/dist/dom.d.ts.map +1 -0
  22. package/dist/dom.js +269 -0
  23. package/dist/export.d.ts +55 -0
  24. package/dist/export.d.ts.map +1 -0
  25. package/dist/export.js +90 -0
  26. package/dist/float.d.ts +54 -0
  27. package/dist/float.d.ts.map +1 -0
  28. package/dist/float.js +256 -0
  29. package/dist/format.d.ts +16 -0
  30. package/dist/format.d.ts.map +1 -0
  31. package/dist/format.js +188 -0
  32. package/dist/graph-view.d.ts +79 -2
  33. package/dist/graph-view.d.ts.map +1 -1
  34. package/dist/graph-view.js +866 -50
  35. package/dist/highlight.d.ts +56 -0
  36. package/dist/highlight.d.ts.map +1 -0
  37. package/dist/highlight.js +154 -0
  38. package/dist/history.d.ts +59 -0
  39. package/dist/history.d.ts.map +1 -0
  40. package/dist/history.js +85 -0
  41. package/dist/hook.d.ts +60 -0
  42. package/dist/hook.d.ts.map +1 -0
  43. package/dist/hook.js +142 -0
  44. package/dist/index.d.ts +44 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +25 -1
  47. package/dist/inspector.d.ts +60 -0
  48. package/dist/inspector.d.ts.map +1 -0
  49. package/dist/inspector.js +415 -0
  50. package/dist/instrument.d.ts +147 -5
  51. package/dist/instrument.d.ts.map +1 -1
  52. package/dist/instrument.js +164 -44
  53. package/dist/kind-filter.d.ts +39 -0
  54. package/dist/kind-filter.d.ts.map +1 -0
  55. package/dist/kind-filter.js +151 -0
  56. package/dist/menu.d.ts +37 -0
  57. package/dist/menu.d.ts.map +1 -0
  58. package/dist/menu.js +125 -0
  59. package/dist/observe.d.ts +80 -0
  60. package/dist/observe.d.ts.map +1 -0
  61. package/dist/observe.js +893 -0
  62. package/dist/palette.d.ts +11 -0
  63. package/dist/palette.d.ts.map +1 -0
  64. package/dist/palette.js +27 -0
  65. package/dist/path.d.ts +19 -0
  66. package/dist/path.d.ts.map +1 -0
  67. package/dist/path.js +49 -0
  68. package/dist/plugin.d.ts +121 -0
  69. package/dist/plugin.d.ts.map +1 -0
  70. package/dist/plugin.js +74 -0
  71. package/dist/plugins/context.d.ts +10 -0
  72. package/dist/plugins/context.d.ts.map +1 -0
  73. package/dist/plugins/context.js +80 -0
  74. package/dist/plugins/directives.d.ts +25 -0
  75. package/dist/plugins/directives.d.ts.map +1 -0
  76. package/dist/plugins/directives.js +78 -0
  77. package/dist/plugins/index.d.ts +7 -0
  78. package/dist/plugins/index.d.ts.map +1 -0
  79. package/dist/plugins/index.js +9 -0
  80. package/dist/source.d.ts +72 -0
  81. package/dist/source.d.ts.map +1 -0
  82. package/dist/source.js +153 -0
  83. package/dist/timeline.d.ts +30 -0
  84. package/dist/timeline.d.ts.map +1 -0
  85. package/dist/timeline.js +271 -0
  86. package/dist/tree.d.ts +128 -0
  87. package/dist/tree.d.ts.map +1 -0
  88. package/dist/tree.js +645 -0
  89. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  90. package/dist/value-pane.d.ts +10 -0
  91. package/dist/value-pane.d.ts.map +1 -0
  92. package/dist/value-pane.js +61 -0
  93. package/dist/wire.d.ts +100 -0
  94. package/dist/wire.d.ts.map +1 -0
  95. package/dist/wire.js +93 -0
  96. package/package.json +10 -3
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // @fluixi/devtools live inspector for the Fluixi reactive graph.
1
+ // @fluixi/devtools: live inspector for the Fluixi reactive graph.
2
2
  // Instrumented signal/memo/effect register into a graph; createGraphView renders + animates the
3
3
  // State → Memo → Effect propagation.
4
4
  //
@@ -16,4 +16,28 @@
16
16
  // const tick = () => { reactor.sample(); view.update(reactor.graph); requestAnimationFrame(tick); };
17
17
  // tick();
18
18
  export { createReactor, nodeVersion, nodeState } from './instrument.js';
19
+ export { observeGraph } from './observe.js';
20
+ export { installDevtoolsHook } from './hook.js';
21
+ export { formatValue, shorten } from './format.js';
22
+ export { callSite, currentCallSite } from './callsite.js';
23
+ export { serializeGraph, snapshotToRenderMap, applySnapshot } from './wire.js';
24
+ export { toCommits } from './history.js';
25
+ export { markReactiveSource } from './source.js';
19
26
  export { createGraphView, DEVTOOLS_CSS } from './graph-view.js';
27
+ export { createInspector, INSPECTOR_CSS } from './inspector.js';
28
+ export { createValuePane, VALUE_PANE_CSS } from './value-pane.js';
29
+ export { indentValue } from './format.js';
30
+ export { createTimeline, TIMELINE_CSS } from './timeline.js';
31
+ export { shortenPath } from './path.js';
32
+ export { registerDevtoolsPlugin, devtoolsPlugins, annotationOf, pluginRows, pluginBadge } from './plugin.js';
33
+ export { createComponentTree, componentTree, TREE_KINDS, TREE_CSS } from './tree.js';
34
+ export { showHighlight, hideHighlight, removeHighlight, startPicking } from './highlight.js';
35
+ export { makeFloatable, FLOAT_CSS } from './float.js';
36
+ export { createKindFilter, FILTERABLE_KINDS, DROPDOWN_FILTER_CSS, KIND_FILTER_CSS } from './kind-filter.js';
37
+ export { createMenu, MENU_CSS } from './menu.js';
38
+ export { createDetailPanel, DETAIL_PANEL_CSS } from './detail-panel.js';
39
+ export { changeRows, handleCopyClick, CHANGE_CSS } from './change-view.js';
40
+ export { copyText } from './copy.js';
41
+ export { exportGraph, graphToJson } from './export.js';
42
+ export { diffValues } from './diff.js';
43
+ export { describeNode } from './describe.js';
@@ -0,0 +1,60 @@
1
+ import { type RNode } from './instrument.js';
2
+ import type { ReactiveSourceLocation } from './source.js';
3
+ export interface Inspector {
4
+ /** Draw the node with this id, or the empty state when it is undefined. */
5
+ show(graph: Map<number, RNode>, id: number | null | undefined): void;
6
+ /**
7
+ * Draw an element instead of a node.
8
+ *
9
+ * An element is not in the graph — nothing computes it — but it is the part of the page a
10
+ * reader recognises, and the graph knows which tag rendered it and which values write into
11
+ * it. Those, and the line each binding was written on, are what there is to say.
12
+ */
13
+ showElement(graph: Map<number, RNode>, pick: ElementInspection | null | undefined): void;
14
+ clear(): void;
15
+ }
16
+ /** An element to inspect. The shape `createComponentTree` hands to its `onPick`. */
17
+ export interface ElementInspection {
18
+ key: string;
19
+ label: string;
20
+ owner?: number;
21
+ writes?: number[];
22
+ /** What plugins attached to this element. */
23
+ extra?: Record<string, unknown>;
24
+ /** Where the element itself was written, when the build recorded it. */
25
+ at?: ReactiveSourceLocation;
26
+ }
27
+ export interface InspectorOptions {
28
+ /** What to say when nothing is selected. */
29
+ empty?: string;
30
+ /** How much of a value to show. */
31
+ limit?: number;
32
+ /**
33
+ * Clicking a location. Omitted leaves them as plain text, which is what a host with
34
+ * nowhere to open a file wants.
35
+ */
36
+ onReveal?: (location: ReactiveSourceLocation) => void;
37
+ /**
38
+ * The project a path is shown relative to. Without it, a path is shortened to the package
39
+ * it names, which is as much as a graph can work out on its own.
40
+ */
41
+ root?: string;
42
+ /**
43
+ * Write a value from the panel. Given the text as typed: the host parses it, since what
44
+ * counts as a value is the application's business, and returns whether it took.
45
+ *
46
+ * Omitted leaves values read-only, which is what a panel reading a snapshot over a wire
47
+ * wants — there is nothing on the other end to write to.
48
+ */
49
+ onEdit?: (id: number, raw: string) => boolean;
50
+ /**
51
+ * Start with framework locations shown. Off by default: for someone writing an
52
+ * application, where the framework instantiated a component is noise. For someone working
53
+ * on the framework it is the whole point, so it is a toggle rather than a rule.
54
+ */
55
+ internals?: boolean;
56
+ }
57
+ export declare function createInspector(host: HTMLElement, options?: InspectorOptions): Inspector;
58
+ /** Default styling for what createInspector writes, in the shape DEVTOOLS_CSS is used. */
59
+ export declare const INSPECTOR_CSS: string;
60
+ //# sourceMappingURL=inspector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inspector.d.ts","sourceRoot":"","sources":["../src/inspector.ts"],"names":[],"mappings":"AASA,OAAO,EAA0B,KAAK,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAErE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAO1D,MAAM,WAAW,SAAS;IACxB,2EAA2E;IAC3E,IAAI,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CAAC;IACrE;;;;;;OAMG;IACH,WAAW,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,IAAI,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CAAC;IACzF,KAAK,IAAI,IAAI,CAAC;CACf;AAED,oFAAoF;AACpF,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,6CAA6C;IAC7C,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,wEAAwE;IACxE,EAAE,CAAC,EAAE,sBAAsB,CAAC;CAC7B;AAED,MAAM,WAAW,gBAAgB;IAC/B,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,sBAAsB,KAAK,IAAI,CAAC;IACtD;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAC9C;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAuFD,wBAAgB,eAAe,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS,CA2S5F;AAED,0FAA0F;AAC1F,eAAO,MAAM,aAAa,QAuCzB,CAAC"}
@@ -0,0 +1,415 @@
1
+ // What one node is, in words.
2
+ //
3
+ // The graph draws a node in the space of a card: a name, a kind, a clipped value, a file and
4
+ // a line. Everything else about it — the version, the run count, what it reads, what reads
5
+ // it, where it was declared as against where it was used — needs room, and every host that
6
+ // draws the graph wants the same answer. So it lives here rather than in each of them.
7
+ //
8
+ // Rendered as html into an element the host owns, and re-rendered only when what it says
9
+ // would change: a panel repainting on every frame makes text impossible to select.
10
+ import { nodeState, nodeVersion } from './instrument.js';
11
+ import { formatValue } from './format.js';
12
+ import { shortenPath } from './path.js';
13
+ import { pluginRows, pluginElementRows } from './plugin.js';
14
+ import { kindRules } from './palette.js';
15
+ import { createValuePane, VALUE_PANE_CSS } from './value-pane.js';
16
+ const escape = (text) => text.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
17
+ const clip = (text, n = 140) => (text.length > n ? `${text.slice(0, n - 1)}…` : text);
18
+ /**
19
+ * A location the host can open, carrying enough to find it again on the click.
20
+ *
21
+ * The position travels in the markup rather than in a closure because the panel is written
22
+ * as a string of html and replaced wholesale on each change.
23
+ */
24
+ const place = (text, source, live) => {
25
+ if (!source || !live || !source.file)
26
+ return escape(text);
27
+ return (`<button type="button" class="ins-at" data-file="${escape(source.file)}"` +
28
+ ` data-line="${source.line ?? ''}" data-column="${source.column ?? ''}"` +
29
+ ` title="Open ${escape(source.file)}">${escape(text)}</button>`);
30
+ };
31
+ const at = (source, root) => source ? `${source.file ? shortenPath(source.file, root) : ''}:${source.line ?? ''}:${source.column ?? ''}` : '';
32
+ /**
33
+ * The location, and where it is in the compiled file when the two differ.
34
+ *
35
+ * A template's holes all map back to the line the template opens at, so the original position
36
+ * names the component and nothing finer. The compiled one is what separates this binding from
37
+ * the three beside it.
38
+ */
39
+ const bothAt = (source, root) => {
40
+ if (!source)
41
+ return '';
42
+ const out = source.compiled;
43
+ const same = out?.line === source.line && out?.column === source.column;
44
+ return out && !same
45
+ ? `${at(source, root)} · out ${out.line ?? ''}:${out.column ?? ''}`
46
+ : at(source, root);
47
+ };
48
+ /**
49
+ * The line worth opening for a tag.
50
+ *
51
+ * A component the framework instantiated — a lazy route, a layout the router mounted — was
52
+ * created inside the framework, so `source` is a line in a package. `declaredAt` is where it
53
+ * was actually written. Only one line is shown for an element, so it had better be that one.
54
+ */
55
+ function authoredPlace(node) {
56
+ for (const place of [node.source, node.declaredAt, node.usedAt]) {
57
+ if (place && !place.external)
58
+ return place;
59
+ }
60
+ return node.declaredAt ?? node.source ?? node.usedAt;
61
+ }
62
+ /**
63
+ * The tags this value writes inside, and how many times each.
64
+ *
65
+ * A signal handed to a component through a prop is read by the render effect that binds it,
66
+ * which is machinery the graph does not draw — so there is no edge from the signal to the
67
+ * component, and a card for it looks connected only to whatever declared it. The
68
+ * descriptions already say it exactly: each element records the values writing into it, and
69
+ * it sits in the tree of the tag that rendered it.
70
+ */
71
+ function reaches(graph, id) {
72
+ const count = new Map();
73
+ const writesHere = (nodes) => nodes.some((n) => n.writes?.includes(id) || writesHere(n.children));
74
+ for (const node of graph.values()) {
75
+ if (node.id === id || !node.dom?.length)
76
+ continue;
77
+ if (writesHere(node.dom))
78
+ count.set(node.label, (count.get(node.label) ?? 0) + 1);
79
+ }
80
+ return [...count].map(([label, n]) => (n > 1 ? `${label} ×${n}` : label));
81
+ }
82
+ /** The live value when the node can be read, the last one it reported otherwise. */
83
+ function valueOf(node, limit) {
84
+ if (node.read) {
85
+ try {
86
+ return formatValue(node.read(), limit);
87
+ }
88
+ catch {
89
+ // A reader that throws is still a node worth inspecting.
90
+ }
91
+ }
92
+ return node.detail ?? node.value ?? '';
93
+ }
94
+ export function createInspector(host, options = {}) {
95
+ const empty = options.empty ?? 'click a node to inspect';
96
+ const limit = options.limit ?? 600;
97
+ const reveals = typeof options.onReveal === 'function';
98
+ const root = options.root;
99
+ const edits = typeof options.onEdit === 'function';
100
+ /** While the reader is typing, nothing redraws: the input would go with it. */
101
+ let editing = null;
102
+ let field = null;
103
+ let internals = options.internals === true;
104
+ /** Built on first use: most sessions never open one. */
105
+ let pane = null;
106
+ const doc = host.ownerDocument;
107
+ // Anything cut to fit carries the whole of it, and opens a pane rather than a tooltip.
108
+ host.addEventListener('click', (event) => {
109
+ const cell = event.target?.closest?.('[data-full]');
110
+ if (!cell)
111
+ return;
112
+ pane ??= createValuePane(host);
113
+ pane.open(cell.dataset.label || 'value', cell.dataset.full || '');
114
+ });
115
+ // Kept out of the redrawn markup: the panel replaces its content wholesale, and a control
116
+ // inside it would be rebuilt under the pointer every time something changed.
117
+ const header = doc.createElement('div');
118
+ header.className = 'ins-header';
119
+ const bar = doc.createElement('label');
120
+ bar.className = 'ins-bar';
121
+ const box = doc.createElement('input');
122
+ box.type = 'checkbox';
123
+ box.checked = internals;
124
+ bar.append(box, doc.createTextNode('framework'));
125
+ bar.title = 'Also show where the framework created this, not only where it was written';
126
+ const body = doc.createElement('div');
127
+ body.className = 'ins-body';
128
+ header.appendChild(bar);
129
+ host.replaceChildren(header, body);
130
+ box.addEventListener('change', () => {
131
+ internals = box.checked;
132
+ signature = '';
133
+ });
134
+ if (edits) {
135
+ host.addEventListener('click', (event) => {
136
+ const cell = event.target?.closest?.('.ins-edit');
137
+ if (!cell || editing !== null)
138
+ return;
139
+ const id = Number(cell.dataset.id);
140
+ editing = id;
141
+ const input = host.ownerDocument.createElement('input');
142
+ field = input;
143
+ input.className = 'ins-input';
144
+ input.value = cell.dataset.raw ?? '';
145
+ cell.replaceWith(input);
146
+ input.focus();
147
+ input.select();
148
+ const done = (commit) => {
149
+ if (editing === null)
150
+ return;
151
+ editing = null;
152
+ field = null;
153
+ // Whatever it was is redrawn from the graph on the next frame.
154
+ signature = '';
155
+ if (commit)
156
+ options.onEdit(id, input.value);
157
+ };
158
+ input.addEventListener('keydown', (e) => {
159
+ if (e.key === 'Enter')
160
+ done(true);
161
+ else if (e.key === 'Escape')
162
+ done(false);
163
+ });
164
+ input.addEventListener('blur', () => done(true));
165
+ });
166
+ }
167
+ if (reveals) {
168
+ host.addEventListener('click', (event) => {
169
+ const button = event.target?.closest?.('.ins-at');
170
+ if (!button)
171
+ return;
172
+ const line = Number(button.dataset.line);
173
+ const column = Number(button.dataset.column);
174
+ options.onReveal({
175
+ file: button.dataset.file,
176
+ ...(Number.isFinite(line) && button.dataset.line ? { line } : {}),
177
+ ...(Number.isFinite(column) && button.dataset.column ? { column } : {}),
178
+ });
179
+ });
180
+ }
181
+ let signature = '';
182
+ return {
183
+ show(graph, id) {
184
+ const node = id == null ? undefined : graph.get(id);
185
+ if (!node) {
186
+ if (signature === 'empty')
187
+ return;
188
+ signature = 'empty';
189
+ body.innerHTML = `<span class="ins-empty">${escape(empty)}</span>`;
190
+ return;
191
+ }
192
+ const labelOf = (other) => graph.get(other)?.label ?? '?';
193
+ const reads = [...node.deps].map(labelOf);
194
+ // Binding cards are left out: `bound` below lists the same places, and says where
195
+ // each one is. With both, every binding was named twice on the one line.
196
+ const feeds = [...graph.values()]
197
+ .filter((n) => n.deps.has(node.id) && n.kind !== 'binding')
198
+ .map((n) => n.label);
199
+ const writes = [...node.writes].map(labelOf);
200
+ const value = valueOf(node, limit);
201
+ const state = nodeState(node);
202
+ // A card the view invented, not a node: version and run count are the signal's, and
203
+ // repeating them here would read as facts about a thing that does not compute.
204
+ const synthetic = node.kind === 'binding' || node.kind === 'setter' || node.kind === 'group';
205
+ const owner = node.createdIn !== undefined ? graph.get(node.createdIn)?.label : undefined;
206
+ const reaching = reaches(graph, node.id);
207
+ const made = [...graph.values()].filter((n) => n.createdIn === node.id).map((n) => n.label);
208
+ // Bindings are not nodes, so a signal read only by the dom shows no readers at all.
209
+ // Named when the dom layer said what they write, counted when it did not.
210
+ const bound = node.targets?.length
211
+ ? node.targets
212
+ .map((t) => {
213
+ const where = bothAt(t.source, root);
214
+ const what = escape(t.name ? `${t.name} of ${t.element}` : t.element);
215
+ return where ? `${what} (${place(where, t.source, reveals)})` : what;
216
+ })
217
+ .join(', ')
218
+ : node.bindings
219
+ ? `${node.bindings} dom binding${node.bindings > 1 ? 's' : ''}`
220
+ : '';
221
+ // A node on screen is part of the graph, so every line says something: "nothing yet" is
222
+ // an answer, and leaving the row out reads as the inspector having failed to look.
223
+ const relations = [
224
+ `<span>↤ reads ${reads.length ? `<b>${escape(reads.join(', '))}</b>` : '<i>nothing</i>'}</span>`,
225
+ // `bound` already carries markup for the places a value reaches, so only the
226
+ // labels are escaped here.
227
+ `<span>↦ feeds ${feeds.length || bound
228
+ ? `<b>${[...feeds.map(escape), bound].filter(Boolean).join(', ')}</b>`
229
+ : '<i>nothing yet</i>'}</span>`,
230
+ writes.length ? `<span>✎ writes <b>${escape(writes.join(', '))}</b></span>` : '',
231
+ // Where the value is set from code the graph has no node for.
232
+ node.setAt?.length
233
+ ? `<span>⌁ set at <b>${node.setAt.map((w) => place(bothAt(w, root), w, reveals)).join(', ')}</b></span>`
234
+ : '',
235
+ owner ? `<span>◧ made in <b>${escape(owner)}</b></span>` : '',
236
+ reaching.length ? `<span>◨ reaches <b>${escape(reaching.join(', '))}</b></span>` : '',
237
+ ...pluginRows(node, graph).map((row) => {
238
+ const where = row.at && { ...row.at, file: row.at.file ?? node.source?.file };
239
+ const text = where?.file ? bothAt(where, root) : '';
240
+ const more = row.value !== undefined && row.value.length > 140;
241
+ return `<span${more
242
+ ? ` class="ins-more" data-full="${escape(row.value)}" data-label="${escape(row.label)}"`
243
+ : ''} title="${escape(row.value ?? row.label)}">⌘ ${escape(row.label)}${row.value ? `=<b>${escape(clip(row.value))}</b>` : ''}${text ? ` ${place(text, where, reveals)}` : ''}</span>`;
244
+ }),
245
+ // What the tag was written with. A prop's position has no file of its own: it was
246
+ // written on the tag, so it belongs to whichever file that is.
247
+ ...(node.props ?? []).map((p) => {
248
+ const where = p.at && { ...p.at, file: p.at.file ?? node.source?.file };
249
+ const at = where?.file ? bothAt(where, root) : '';
250
+ return `<span>⇥ ${escape(p.name)}=<b>${escape(clip(p.value ?? '', 60))}</b>${at ? ` ${place(at, where, reveals)}` : ''}</span>`;
251
+ }),
252
+ made.length ? `<span>▸ made <b>${escape(made.join(', '))}</b></span>` : '',
253
+ ]
254
+ .filter(Boolean)
255
+ .join('');
256
+ const next = `${node.id}|${node.kind}|${value}|${node.runs}|${state ?? ''}|${relations}|${at(node.source, root)}|${at(node.usedAt, root)}|${bound}`;
257
+ // An input that has left the page is not one anybody is typing into. Without this a
258
+ // commit that never arrived — the panel redrew, the tab changed — would hold the
259
+ // inspector on whatever it last wrote, for good.
260
+ if (editing !== null && field?.isConnected !== true) {
261
+ editing = null;
262
+ field = null;
263
+ }
264
+ if (editing !== null)
265
+ return;
266
+ if (next === signature)
267
+ return;
268
+ signature = next;
269
+ body.innerHTML =
270
+ `<div class="ins-head"><span class="ins-kind n-${node.kind}">${node.kind}</span>` +
271
+ `<b>${escape(node.label)}</b>` +
272
+ (synthetic
273
+ ? '<span class="ins-meta">not a node</span></div>'
274
+ : `<span class="ins-meta">v${nodeVersion(node)} · ${node.runs} runs${state ? ` · ${state}` : ''}</span></div>`) +
275
+ (edits && node.kind === 'state'
276
+ ? `<div class="ins-val ins-edit" data-id="${node.id}" data-raw="${escape(value)}"` +
277
+ ` title="Click to set ${escape(node.label)}">${escape(clip(value))}</div>`
278
+ : `<div class="ins-val${value.length > 140 ? ' ins-more' : ''}"${value.length > 140 ? ` data-full="${escape(value)}" data-label="${escape(node.label)}"` : ''} title="${escape(value)}">${escape(clip(value))}</div>`) +
279
+ `<div class="ins-rel">${relations}</div>` +
280
+ // Only when the graph carries one. A snapshot from a build that was not marked has
281
+ // no locations at all, and an empty line there reads as missing data.
282
+ (node.source
283
+ ? `<div class="ins-source">${node.source.external ? '📦' : '📍'} ${place(bothAt(node.source, root), node.source, reveals)}</div>`
284
+ : '') +
285
+ // A component is created where its tag is written; where it is declared is somewhere
286
+ // else, and only worth a line when it is.
287
+ (node.declaredAt && at(node.declaredAt, root) !== at(node.source, root)
288
+ ? `<div class="ins-source">◧ declared ${place(at(node.declaredAt, root), node.declaredAt, reveals)}</div>`
289
+ : '') +
290
+ // A node a package made on the application's behalf: the package's line is where it
291
+ // was created, and this is the line that asked for it.
292
+ (node.usedAt
293
+ ? `<div class="ins-source">↗ used at ${place(bothAt(node.usedAt, root), node.usedAt, reveals)}</div>`
294
+ : '') +
295
+ // Nothing said where it came from at all: the compiler did not mark this project and
296
+ // the stack had nothing to offer either. Saying so beats a card that just sits there.
297
+ (!node.source && !node.usedAt && !node.declaredAt
298
+ ? '<div class="ins-source">— no source: build without sourceLocations</div>'
299
+ : '');
300
+ },
301
+ showElement(graph, pick) {
302
+ if (!pick) {
303
+ if (signature === 'empty')
304
+ return;
305
+ signature = 'empty';
306
+ body.innerHTML = `<span class="ins-empty">${escape(empty)}</span>`;
307
+ return;
308
+ }
309
+ const owner = pick.owner === undefined ? undefined : graph.get(pick.owner);
310
+ const writers = (pick.writes ?? [])
311
+ .map((id) => graph.get(id))
312
+ .filter((n) => n !== undefined);
313
+ // Where the binding into this element was written. A value can write to several
314
+ // places, so it is the one naming this element that matters, not the first.
315
+ const bindingAt = (node) => node.targets?.find((t) => t.element === pick.label || t.element.endsWith(` in ${pick.label}`))
316
+ ?.source;
317
+ const rows = writers
318
+ .map((node) => {
319
+ const where = bindingAt(node);
320
+ const text = where ? bothAt(where, root) : '';
321
+ return `<span>⌁ <b>${escape(node.label)}</b>${text ? ` ${place(text, where, reveals)}` : ''}</span>`;
322
+ })
323
+ .join('');
324
+ const relations = [
325
+ owner
326
+ ? `<span>◧ rendered by <b>${escape(owner.label)}</b></span>`
327
+ : '<span>◧ rendered by <i>nothing in the graph</i></span>',
328
+ writers.length ? rows : '<span>⌁ <i>nothing writes here</i></span>',
329
+ ...pluginElementRows(pick.extra).map((row) => {
330
+ const where = row.at && { ...row.at, file: row.at.file ?? owner?.source?.file };
331
+ const text = where?.file ? bothAt(where, root) : '';
332
+ const more = row.value !== undefined && row.value.length > 140;
333
+ return `<span${more
334
+ ? ` class="ins-more" data-full="${escape(row.value)}" data-label="${escape(row.label)}"`
335
+ : ''} title="${escape(row.value ?? row.label)}">⌘ ${escape(row.label)}${row.value ? `=<b>${escape(clip(row.value))}</b>` : ''}${text ? ` ${place(text, where, reveals)}` : ''}</span>`;
336
+ }),
337
+ ].join('');
338
+ // The element's own line when the build recorded one. Failing that, where the tag was
339
+ // declared rather than where it was used: an element sits in the component's body, so
340
+ // the line `<App/>` was written on says nothing about it.
341
+ const source = pick.at ?? (owner && (owner.declaredAt ?? authoredPlace(owner)));
342
+ // Where the framework built it, when that is somewhere else. Only on request: for an
343
+ // application it is a line in a package nobody asked about.
344
+ const madeIn = internals && owner?.source && owner.source !== source ? owner.source : undefined;
345
+ const next = `el|${pick.key}|${pick.label}|${relations}|${at(source, root)}|${at(madeIn, root)}`;
346
+ if (editing !== null && field?.isConnected !== true) {
347
+ editing = null;
348
+ field = null;
349
+ }
350
+ if (editing !== null)
351
+ return;
352
+ if (next === signature)
353
+ return;
354
+ signature = next;
355
+ body.innerHTML =
356
+ `<div class="ins-head"><span class="ins-kind n-element">element</span>` +
357
+ `<b>${escape(pick.label)}</b>` +
358
+ `<span class="ins-meta">not a node</span></div>` +
359
+ `<div class="ins-rel">${relations}</div>` +
360
+ // The tag's own line. An element has no position of its own in the graph — only the
361
+ // component that rendered it does — so this is as close as the source gets.
362
+ (source
363
+ ? `<div class="ins-source">${source.external ? '📦' : '📍'} ${place(bothAt(source, root), source, reveals)}</div>`
364
+ : '') +
365
+ (madeIn
366
+ ? `<div class="ins-source">📦 made in ${place(bothAt(madeIn, root), madeIn, reveals)}</div>`
367
+ : '');
368
+ },
369
+ clear() {
370
+ signature = '';
371
+ body.innerHTML = `<span class="ins-empty">${escape(empty)}</span>`;
372
+ },
373
+ };
374
+ }
375
+ /** Default styling for what createInspector writes, in the shape DEVTOOLS_CSS is used. */
376
+ export const INSPECTOR_CSS = `
377
+ ${VALUE_PANE_CSS}
378
+ /* Anything cut to fit says so, and opens the whole of it. */
379
+ .ins-more { cursor: zoom-in; }
380
+ .ins-header{ display: flex; align-items: center; gap: 8px; padding: .3rem .5rem; }
381
+ .ins-body { padding: .5rem; }
382
+ .ins-more:hover { text-decoration: underline dotted; }
383
+ .ins-empty { color: var(--fx-muted, #8a90a2); font-size: 12px; }
384
+ .ins-bar {
385
+ display: flex; align-items: center; gap: 5px; margin-bottom: 6px;
386
+ font-size: 10.5px; color: var(--fx-muted, #8a90a2); cursor: pointer; user-select: none;
387
+ }
388
+ .ins-bar:hover { color: var(--fx-label, #e7e9ee); }
389
+ .ins-bar input { width: 11px; height: 11px; margin: 0; accent-color: var(--fx-accent, #5eead4); }
390
+ .ins-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
391
+ .ins-head b { font-size: 13px; color: var(--fx-label, #e7e9ee); }
392
+ .ins-kind { font-family: ui-monospace, monospace; font-size: 9.5px; text-transform: uppercase; letter-spacing: 1px; }
393
+ ${kindRules((k) => `.ins-kind.n-${k}`, 'color')}
394
+ .ins-meta { margin-left: auto; color: var(--fx-muted, #8a90a2); font-family: ui-monospace, monospace; font-size: 10.5px; }
395
+ .ins-val { font-family: ui-monospace, monospace; font-size: 12px; color: var(--fx-label, #e7e9ee); word-break: break-word; }
396
+ .ins-rel { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; color: var(--fx-muted, #8a90a2); font-size: 11.5px; }
397
+ .ins-rel b { color: var(--fx-label, #e7e9ee); font-weight: 600; }
398
+ .ins-rel i { font-style: normal; opacity: .6; }
399
+ .ins-edit { cursor: text; border-bottom: 1px dashed var(--fx-line, #262a35); }
400
+ .ins-edit:hover { border-bottom-color: var(--fx-accent, #5eead4); }
401
+ .ins-input {
402
+ width: 100%; box-sizing: border-box; padding: 1px 4px;
403
+ font: inherit; font-family: ui-monospace, monospace;
404
+ color: var(--fx-label, #e7e9ee); background: var(--fx-bg, #0f1115);
405
+ border: 1px solid var(--fx-accent, #5eead4); border-radius: 3px;
406
+ }
407
+ .ins-at {
408
+ font: inherit; color: inherit; background: none; border: 0; padding: 0;
409
+ cursor: pointer; text-decoration: underline; text-decoration-style: dotted;
410
+ text-underline-offset: 2px;
411
+ }
412
+ .ins-at:hover { color: var(--fx-accent, #5eead4); }
413
+ .ins-at:focus-visible { outline: 1px solid var(--fx-accent, #5eead4); outline-offset: 1px; }
414
+ .ins-source { margin-top: 6px; font-family: ui-monospace, monospace; font-size: 10.5px; color: var(--fx-muted, #8a90a2); }
415
+ `;