@atolljs/devtools 0.1.7 → 0.1.8

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 (48) hide show
  1. package/README.md +408 -71
  2. package/app/api.js +68 -0
  3. package/app/index.html +161 -2
  4. package/app/main.js +877 -127
  5. package/app/panels/actions.js +324 -0
  6. package/app/panels/audit-rules.js +728 -0
  7. package/app/panels/audits.js +438 -0
  8. package/app/panels/inspect-island.js +351 -0
  9. package/app/panels/memory-values.js +287 -0
  10. package/app/panels/performance.js +304 -0
  11. package/app/panels/reactivity.js +271 -0
  12. package/app/panels/recorder.js +344 -0
  13. package/app/panels/recording-format.js +166 -0
  14. package/app/panels/route.d.ts +44 -0
  15. package/app/panels/route.js +264 -0
  16. package/app/panels/shell.js +789 -0
  17. package/app/panels/toast.js +38 -0
  18. package/dist/bin.js +704 -5
  19. package/dist/chunk-57NGHQUL.js +344 -0
  20. package/dist/{chunk-4N3NBFL3.js → chunk-AGT6VNZI.js} +179 -217
  21. package/dist/chunk-WIL2P7HG.js +661 -0
  22. package/dist/client.d.ts +7 -0
  23. package/dist/control.d.ts +4 -0
  24. package/dist/index.js +2 -1
  25. package/dist/node.js +2 -1
  26. package/dist/nodeWs.d.ts +1 -0
  27. package/dist/otel.d.ts +35 -0
  28. package/dist/otel.js +87 -0
  29. package/dist/otelMap.d.ts +232 -0
  30. package/dist/otelTransport.d.ts +69 -0
  31. package/dist/overlay.d.ts +16 -2
  32. package/dist/probes.d.ts +7 -0
  33. package/dist/protocol.d.ts +46 -4
  34. package/dist/server.d.ts +19 -0
  35. package/dist/server.js +61 -3
  36. package/package.json +6 -2
  37. package/src/bin.ts +27 -2
  38. package/src/broadcast.ts +6 -0
  39. package/src/client.ts +26 -2
  40. package/src/control.ts +51 -0
  41. package/src/nodeWs.ts +7 -1
  42. package/src/otel.ts +140 -0
  43. package/src/otelMap.ts +661 -0
  44. package/src/otelTransport.ts +286 -0
  45. package/src/overlay.ts +153 -27
  46. package/src/probes.ts +155 -0
  47. package/src/protocol.ts +52 -4
  48. package/src/server.ts +83 -3
@@ -0,0 +1,286 @@
1
+ /**
2
+ * OTLP/HTTP JSON transport: a retrying `fetch` sender and the batching
3
+ * pipeline that drains `OtelMapper`s into it. No @atolljs/core runtime
4
+ * imports, so the aggregate server can forward without bundling the SDK.
5
+ */
6
+ import type { EmittedDevtoolsEvent } from '@atolljs/core';
7
+ import {
8
+ encodeLogs,
9
+ encodeMetrics,
10
+ encodeTraces,
11
+ toAttributes,
12
+ type KeyValue,
13
+ type OtelMapper,
14
+ type OtelSignals,
15
+ } from './otelMap';
16
+ import type { SessionInfo } from './protocol';
17
+
18
+ export type OtlpSignal = 'traces' | 'metrics' | 'logs';
19
+
20
+ export interface OtlpSenderOptions {
21
+ /** OTLP/HTTP base URL; `/v1/<signal>` is appended (default http://localhost:4318). */
22
+ endpoint?: string;
23
+ /** Extra request headers (auth: `x-honeycomb-team`, `authorization`, …). */
24
+ headers?: Record<string, string>;
25
+ /** fetch implementation (default: the global fetch captured at creation). */
26
+ fetch?: typeof fetch;
27
+ /** Retries per request on 429/502/503/504 or network failure (default 3). */
28
+ maxRetries?: number;
29
+ /** Exponential backoff base when no Retry-After is sent (default 500ms). */
30
+ retryBaseMs?: number;
31
+ /** Cap on any single retry delay (default 30s). */
32
+ retryMaxMs?: number;
33
+ /**
34
+ * Export failures (non-retryable status, retries exhausted). Never thrown
35
+ * into app code. Don't route this through atoll's `log()`: that tees back
36
+ * into the devtools stream and becomes the next export.
37
+ */
38
+ onError?: (error: Error, signal: OtlpSignal) => void;
39
+ }
40
+
41
+ export interface OtlpSender {
42
+ readonly endpoint: string;
43
+ /** POST one request body; resolves true when accepted. Never rejects. */
44
+ send(signal: OtlpSignal, body: unknown, opts?: { keepalive?: boolean }): Promise<boolean>;
45
+ }
46
+
47
+ /** Retryable per the OTLP/HTTP spec; every other 4xx/5xx is final. */
48
+ const RETRYABLE = new Set([429, 502, 503, 504]);
49
+ /** fetch keepalive bodies share a 64KiB in-flight budget. */
50
+ const KEEPALIVE_MAX = 60_000;
51
+
52
+ const sleep = (ms: number) => new Promise<void>((r) => {
53
+ const t = setTimeout(r, ms);
54
+ (t as unknown as { unref?: () => void }).unref?.();
55
+ });
56
+
57
+ /** Retry-After: delta-seconds or an HTTP-date. */
58
+ const retryAfterMs = (h: string | null): number | undefined => {
59
+ if (!h) return undefined;
60
+ const secs = Number(h);
61
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
62
+ const at = Date.parse(h);
63
+ return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
64
+ };
65
+
66
+ export const normalizeEndpoint = (endpoint = 'http://localhost:4318'): string => endpoint.replace(/\/+$/, '');
67
+
68
+ export function createOtlpSender(opts: OtlpSenderOptions = {}): OtlpSender {
69
+ const endpoint = normalizeEndpoint(opts.endpoint);
70
+ const doFetch = opts.fetch ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : undefined);
71
+ const maxRetries = opts.maxRetries ?? 3;
72
+ const base = opts.retryBaseMs ?? 500;
73
+ const cap = opts.retryMaxMs ?? 30_000;
74
+ const fail = (signal: OtlpSignal, message: string) => {
75
+ try {
76
+ opts.onError?.(new Error(message), signal);
77
+ } catch {
78
+ /* a throwing callback must not escape */
79
+ }
80
+ };
81
+
82
+ return {
83
+ endpoint,
84
+ async send(signal, body, sendOpts) {
85
+ if (!doFetch) {
86
+ fail(signal, 'otlp: no fetch available');
87
+ return false;
88
+ }
89
+ const text = JSON.stringify(body);
90
+ const keepalive = !!sendOpts?.keepalive && text.length < KEEPALIVE_MAX;
91
+ for (let attempt = 0; ; attempt++) {
92
+ let delay: number | undefined;
93
+ let reason: string;
94
+ try {
95
+ const res = await doFetch(`${endpoint}/v1/${signal}`, {
96
+ method: 'POST',
97
+ headers: { ...opts.headers, 'content-type': 'application/json' },
98
+ body: text,
99
+ keepalive,
100
+ });
101
+ if (res.ok) return true;
102
+ reason = `otlp ${signal}: HTTP ${res.status}`;
103
+ if (!RETRYABLE.has(res.status)) {
104
+ fail(signal, reason);
105
+ return false;
106
+ }
107
+ delay = retryAfterMs(res.headers.get('retry-after'));
108
+ } catch (err) {
109
+ reason = `otlp ${signal}: ${err instanceof Error ? err.message : String(err)}`;
110
+ }
111
+ if (attempt >= maxRetries) {
112
+ fail(signal, `${reason} (gave up after ${attempt + 1} attempts)`);
113
+ return false;
114
+ }
115
+ const backoff = base * 2 ** attempt;
116
+ await sleep(Math.min(cap, delay ?? backoff * (0.5 + Math.random() / 2)));
117
+ }
118
+ },
119
+ };
120
+ }
121
+
122
+ export interface OtelPipelineOptions {
123
+ sender: OtlpSender;
124
+ /** Builds the mapper for a key on first use ('' for a single-app exporter). */
125
+ newMapper: (key: string) => OtelMapper;
126
+ /** Periodic flush interval (default 5000ms). 0 disables the timer. */
127
+ flushIntervalMs?: number;
128
+ /** Spans / log records per request; reaching it triggers an early flush (default 512). */
129
+ maxBatch?: number;
130
+ signals?: OtelSignals;
131
+ /** Epoch-ms clock for metric collection (default Date.now). */
132
+ now?: () => number;
133
+ }
134
+
135
+ export interface OtelPipeline {
136
+ ingest(ev: EmittedDevtoolsEvent, recvEpochMs: number, key?: string): void;
137
+ /** Stop tracking a key after its queued data ships on the next flush. */
138
+ release(key: string): void;
139
+ /** Serialized: concurrent calls queue behind the in-flight flush. Never rejects. */
140
+ flush(opts?: { keepalive?: boolean }): Promise<void>;
141
+ close(): Promise<void>;
142
+ }
143
+
144
+ export function createOtelPipeline(opts: OtelPipelineOptions): OtelPipeline {
145
+ const { sender, newMapper } = opts;
146
+ const maxBatch = Math.max(1, opts.maxBatch ?? 512);
147
+ const now = opts.now ?? Date.now;
148
+ const traces = opts.signals?.traces !== false;
149
+ const metrics = opts.signals?.metrics !== false;
150
+ const logs = opts.signals?.logs !== false;
151
+ const mappers = new Map<string, OtelMapper>();
152
+ const released = new Set<string>();
153
+ let chain: Promise<void> = Promise.resolve();
154
+ let flushing = false;
155
+ let closed = false;
156
+
157
+ const sendQueued = async (signal: 'traces' | 'logs', keepalive: boolean): Promise<void> => {
158
+ for (;;) {
159
+ let budget = maxBatch;
160
+ const groups: { resource: KeyValue[]; items: unknown[] }[] = [];
161
+ for (const m of mappers.values()) {
162
+ if (budget === 0) break;
163
+ const items = signal === 'traces' ? m.takeSpans(budget) : m.takeLogs(budget);
164
+ if (items.length) {
165
+ groups.push({ resource: m.resource, items });
166
+ budget -= items.length;
167
+ }
168
+ }
169
+ if (groups.length === 0) return;
170
+ const body =
171
+ signal === 'traces'
172
+ ? encodeTraces(groups.map((g) => ({ resource: g.resource, spans: g.items as never })))
173
+ : encodeLogs(groups.map((g) => ({ resource: g.resource, logs: g.items as never })));
174
+ await sender.send(signal, body, { keepalive });
175
+ }
176
+ };
177
+
178
+ const doFlush = async (keepalive: boolean): Promise<void> => {
179
+ flushing = true;
180
+ try {
181
+ if (traces) await sendQueued('traces', keepalive);
182
+ if (logs) await sendQueued('logs', keepalive);
183
+ if (metrics) {
184
+ const t = now();
185
+ const groups = [...mappers.values()]
186
+ .map((m) => ({ resource: m.resource, metrics: m.collectMetrics(t) }))
187
+ .filter((g) => g.metrics.length > 0);
188
+ if (groups.length) await sender.send('metrics', encodeMetrics(groups), { keepalive });
189
+ }
190
+ for (const key of released) {
191
+ const m = mappers.get(key);
192
+ if (!m || (m.spans.length === 0 && m.logs.length === 0)) {
193
+ mappers.delete(key);
194
+ released.delete(key);
195
+ }
196
+ }
197
+ } finally {
198
+ flushing = false;
199
+ }
200
+ };
201
+
202
+ const flush = (f?: { keepalive?: boolean }): Promise<void> => {
203
+ chain = chain.then(() => doFlush(!!f?.keepalive)).catch(() => {});
204
+ return chain;
205
+ };
206
+
207
+ const interval = opts.flushIntervalMs ?? 5000;
208
+ const timer = interval > 0 ? setInterval(() => void flush(), interval) : undefined;
209
+ (timer as unknown as { unref?: () => void } | undefined)?.unref?.();
210
+
211
+ return {
212
+ ingest(ev, recvEpochMs, key = '') {
213
+ if (closed) return;
214
+ let m = mappers.get(key);
215
+ if (!m) {
216
+ m = newMapper(key);
217
+ mappers.set(key, m);
218
+ }
219
+ released.delete(key);
220
+ m.ingest(ev, recvEpochMs);
221
+ if (!flushing && (m.spans.length >= maxBatch || m.logs.length >= maxBatch)) void flush();
222
+ },
223
+ release(key) {
224
+ if (mappers.has(key)) released.add(key);
225
+ },
226
+ flush,
227
+ async close() {
228
+ if (closed) return chain;
229
+ closed = true;
230
+ if (timer) clearInterval(timer);
231
+ return flush();
232
+ },
233
+ };
234
+ }
235
+
236
+ /* ── resource ────────────────────────────────────────────────────────────── */
237
+
238
+ export type ResourceAttributes = Record<string, string | number | boolean | undefined>;
239
+
240
+ /** Resource attributes for this process: service.name, telemetry.sdk.*, runtime hints. */
241
+ export function localResource(serviceName?: string, extra: ResourceAttributes = {}): KeyValue[] {
242
+ const g = globalThis as {
243
+ window?: { document?: unknown };
244
+ navigator?: { userAgent?: string };
245
+ process?: { pid?: number; versions?: { node?: string } };
246
+ };
247
+ const browser = typeof g.window !== 'undefined' && typeof g.window.document !== 'undefined';
248
+ const node = !browser && g.process?.versions?.node !== undefined;
249
+ return toAttributes({
250
+ 'service.name': serviceName ?? 'unknown_service',
251
+ 'telemetry.sdk.name': 'atoll',
252
+ 'telemetry.sdk.language': node ? 'nodejs' : 'webjs',
253
+ ...(node
254
+ ? {
255
+ 'process.pid': g.process?.pid,
256
+ 'process.runtime.name': 'nodejs',
257
+ 'process.runtime.version': g.process?.versions?.node,
258
+ }
259
+ : { 'user_agent.original': g.navigator?.userAgent }),
260
+ ...extra,
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Resource attributes for a remote app session (aggregate-server
266
+ * forwarding): the session name is the service unless a fixed one is given.
267
+ */
268
+ export function sessionResource(
269
+ session: SessionInfo,
270
+ serviceName?: string,
271
+ extra: ResourceAttributes = {},
272
+ ): KeyValue[] {
273
+ const node = session.runtime === 'node';
274
+ return toAttributes({
275
+ 'service.name': serviceName ?? session.name ?? 'unknown_service',
276
+ 'service.instance.id': session.id,
277
+ 'telemetry.sdk.name': 'atoll',
278
+ 'telemetry.sdk.language': node ? 'nodejs' : 'webjs',
279
+ ...(node
280
+ ? { 'process.runtime.name': 'nodejs', 'process.runtime.version': session.env?.node?.replace(/^v/, '') }
281
+ : { 'user_agent.original': session.env?.userAgent }),
282
+ 'atoll.session.hint': session.hint,
283
+ 'atoll.framework': session.framework,
284
+ ...extra,
285
+ });
286
+ }
package/src/overlay.ts CHANGED
@@ -9,6 +9,11 @@
9
9
  * connectDevtools();
10
10
  * mountDevtoolsOverlay();
11
11
  * }
12
+ *
13
+ * Alt+Shift+D toggles the flyout from the host page (`hotkey` option). The
14
+ * dashboard inside the iframe forwards the same chord via postMessage, so
15
+ * it works wherever focus is. "full page ↗" opens the dashboard on the
16
+ * view the flyout is showing (its `#/<view>…` hash route).
12
17
  */
13
18
 
14
19
  export type DevtoolsOverlayPosition =
@@ -22,13 +27,20 @@ export type DevtoolsOverlayPosition =
22
27
  export interface DevtoolsOverlayOptions {
23
28
  /** Dashboard URL — default '/__atoll/?mini=1' (vite-plugin mount, mini layout). */
24
29
  src?: string;
25
- /** Start expanded (default false — a small corner toggle shows first). */
30
+ /** Start expanded (default: the remembered state, else collapsed to a corner toggle). */
26
31
  startOpen?: boolean;
27
- /** Initial size (default 760×580). */
32
+ /** Initial size (default 760×580; a remembered resize wins). */
28
33
  width?: number;
29
34
  height?: number;
30
35
  /** Where the flyout (and its collapsed toggle) anchors — default 'bottomright'. */
31
36
  position?: DevtoolsOverlayPosition;
37
+ /**
38
+ * Remember position, size and open state in localStorage across reloads —
39
+ * default true; a string is the storage key (default 'atoll-devtools:overlay').
40
+ */
41
+ persist?: boolean | string;
42
+ /** Host-page shortcut that toggles the flyout — default 'Alt+Shift+D'; false disables. */
43
+ hotkey?: string | false;
32
44
  }
33
45
 
34
46
  export interface DevtoolsOverlay {
@@ -38,6 +50,14 @@ export interface DevtoolsOverlay {
38
50
  unmount(): void;
39
51
  }
40
52
 
53
+ interface Saved {
54
+ left?: number;
55
+ top?: number;
56
+ width?: number;
57
+ height?: number;
58
+ open?: boolean;
59
+ }
60
+
41
61
  const BTN_STYLE =
42
62
  'position:fixed;z-index:2147483646;' +
43
63
  'font:11px ui-monospace,monospace;color:#c9d1d9;background:#161b22;' +
@@ -57,18 +77,53 @@ const ANCHOR: Record<DevtoolsOverlayPosition, (m: number) => string> = {
57
77
  bottomright: (m) => `right:${m}px;bottom:${m}px`,
58
78
  };
59
79
 
80
+ const MIN_W = 340;
81
+ const MIN_H = 220;
82
+ let seq = 0;
83
+
84
+ /** 'Alt+Shift+D' → a keydown predicate. Letters/digits match on `code`, so Alt/Option layouts still hit. */
85
+ export function parseHotkey(spec: string): (e: KeyboardEvent) => boolean {
86
+ const parts = spec.split('+').map((p) => p.trim().toLowerCase()).filter(Boolean);
87
+ const key = parts.pop() ?? '';
88
+ const mods = new Set(parts.map((m) => (m === 'option' ? 'alt' : m === 'cmd' || m === 'meta' ? 'meta' : m === 'control' ? 'ctrl' : m)));
89
+ const code = /^[a-z]$/.test(key) ? `Key${key.toUpperCase()}` : /^[0-9]$/.test(key) ? `Digit${key}` : null;
90
+ return (e) =>
91
+ e.altKey === mods.has('alt') &&
92
+ e.shiftKey === mods.has('shift') &&
93
+ e.ctrlKey === mods.has('ctrl') &&
94
+ e.metaKey === mods.has('meta') &&
95
+ (code ? e.code === code : e.key.toLowerCase() === key);
96
+ }
97
+
60
98
  export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): DevtoolsOverlay {
61
99
  const src = opts.src ?? '/__atoll/?mini=1';
62
- const W = opts.width ?? 760;
63
- const H = opts.height ?? 580;
100
+ const storeKey = opts.persist === false ? null : typeof opts.persist === 'string' ? opts.persist : 'atoll-devtools:overlay';
101
+ const load = (): Saved => {
102
+ if (!storeKey) return {};
103
+ try { return JSON.parse(localStorage.getItem(storeKey) ?? '{}') as Saved; } catch { return {}; }
104
+ };
105
+ const saved = load();
106
+ const W = Math.max(MIN_W, saved.width ?? opts.width ?? 760);
107
+ const H = Math.max(MIN_H, saved.height ?? opts.height ?? 580);
64
108
  const anchor = ANCHOR[opts.position ?? 'bottomright'];
109
+ const hotkey = opts.hotkey === false ? null : opts.hotkey ?? 'Alt+Shift+D';
110
+ const isHotkey = hotkey ? parseHotkey(hotkey) : null;
111
+ const hint = hotkey ? ` (${hotkey})` : '';
112
+ const id = `atoll-devtools-overlay-${++seq}`;
65
113
  let open = false;
66
114
 
67
115
  const btn = document.createElement('button');
116
+ btn.type = 'button';
68
117
  btn.textContent = 'atoll devtools';
118
+ btn.title = `Open atoll devtools${hint}`;
119
+ btn.setAttribute('aria-controls', id);
120
+ btn.setAttribute('aria-expanded', 'false');
69
121
  btn.style.cssText = `${BTN_STYLE};${anchor(12)}`;
70
122
 
71
123
  const panel = document.createElement('div');
124
+ panel.id = id;
125
+ panel.setAttribute('role', 'dialog');
126
+ panel.setAttribute('aria-label', 'atoll devtools');
72
127
  panel.style.cssText =
73
128
  `${PANEL_STYLE};width:${W}px;height:${H}px;display:none;${anchor(16)}`;
74
129
 
@@ -77,37 +132,89 @@ export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): Devtool
77
132
  'display:flex;align-items:center;gap:8px;padding:5px 10px;flex:none;' +
78
133
  'border-bottom:1px solid #21262d;font:11px ui-monospace,monospace;' +
79
134
  'color:#8b949e;background:#161b22;cursor:move;user-select:none';
80
- head.textContent = 'atoll devtools';
135
+ const title = document.createElement('span');
136
+ title.textContent = 'atoll devtools';
81
137
 
138
+ // Full-page dashboard = src minus its query/hash — stays relative when the
139
+ // caller passes a relative src (e.g. '__atoll/?mini=1' under a mounted
140
+ // base path where absolute /__atoll/ would escape the mount) — plus the
141
+ // iframe's current hash route, so the tab opens on the same view.
142
+ const base = src.split(/[?#]/)[0];
143
+ const frame = document.createElement('iframe');
144
+ let route = '';
145
+ const currentRoute = (): string => {
146
+ try {
147
+ const h = frame.contentWindow?.location.hash;
148
+ if (typeof h === 'string' && h.startsWith('#/')) return h;
149
+ } catch { /* cross-origin src: rely on posted routes */ }
150
+ return route;
151
+ };
82
152
  const pop = document.createElement('a');
83
- pop.href = '/__atoll/';
153
+ pop.href = base;
84
154
  pop.target = '_blank';
85
155
  pop.rel = 'noopener';
86
156
  pop.textContent = 'full page ↗';
157
+ pop.title = 'Open this view in the full-page dashboard';
87
158
  pop.style.cssText = 'color:#58a6ff;text-decoration:none;margin-left:auto';
159
+ const syncPop = () => { pop.href = base + currentRoute(); };
160
+ pop.addEventListener('pointerenter', syncPop);
161
+ pop.addEventListener('focus', syncPop);
162
+ pop.addEventListener('click', syncPop); // runs before the default navigation
88
163
  const x = document.createElement('button');
89
- x.textContent = '—';
90
- x.title = 'collapse';
164
+ x.type = 'button';
165
+ x.textContent = '×';
166
+ x.title = `Close${hint}`;
167
+ x.setAttribute('aria-label', 'Close atoll devtools');
91
168
  x.style.cssText =
92
- 'color:#8b949e;background:none;border:none;cursor:pointer;font:inherit;padding:0 2px';
93
- head.append(pop, x);
169
+ 'color:#8b949e;background:none;border:none;cursor:pointer;font:14px/1 ui-monospace,monospace;padding:0 2px';
170
+ head.append(title, pop, x);
94
171
 
95
- const frame = document.createElement('iframe');
96
172
  frame.src = src;
173
+ frame.title = 'atoll devtools dashboard';
97
174
  frame.style.cssText = 'flex:1;border:0;background:#0d1117;min-height:0';
98
175
 
99
176
  // manual resize grip — CSS `resize` doesn't work over an iframe
100
177
  const grip = document.createElement('div');
178
+ grip.setAttribute('aria-hidden', 'true');
101
179
  grip.style.cssText =
102
180
  'position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;' +
103
181
  'background:linear-gradient(135deg,transparent 50%,#30363d 50%);border-radius:0 0 7px';
104
182
  panel.append(head, frame, grip);
105
183
  document.body.append(panel, btn);
106
184
 
185
+ const placeAt = (left: number, top: number) => {
186
+ // keep at least 40px of the title bar on screen
187
+ panel.style.left = `${Math.min(Math.max(0, left), Math.max(0, innerWidth - 60))}px`;
188
+ panel.style.top = `${Math.min(Math.max(0, top), Math.max(0, innerHeight - 40))}px`;
189
+ panel.style.right = 'auto';
190
+ panel.style.bottom = 'auto';
191
+ panel.style.transform = 'none'; // drop the center anchor's translateX
192
+ };
193
+ if (typeof saved.left === 'number' && typeof saved.top === 'number') placeAt(saved.left, saved.top);
194
+
195
+ // Only what the user changed is remembered: an untouched size/position
196
+ // keeps following the options.
197
+ const mem: Saved = { ...saved };
198
+ const save = (what: 'open' | 'move' | 'size') => {
199
+ if (!storeKey) return;
200
+ if (what === 'open') mem.open = open;
201
+ if (what === 'size') {
202
+ mem.width = parseFloat(panel.style.width);
203
+ mem.height = parseFloat(panel.style.height);
204
+ }
205
+ if (what === 'move') {
206
+ mem.left = parseFloat(panel.style.left);
207
+ mem.top = parseFloat(panel.style.top);
208
+ }
209
+ try { localStorage.setItem(storeKey, JSON.stringify(mem)); } catch { /* storage off */ }
210
+ };
211
+
107
212
  /** Pointer-drag helper: run `move` on pointermove until pointerup. */
108
- const drag = (e: PointerEvent, move: (dx: number, dy: number) => void) => {
213
+ const drag = (e: PointerEvent, what: 'move' | 'size', move: (dx: number, dy: number) => void) => {
109
214
  e.preventDefault();
110
215
  let lx = e.clientX, ly = e.clientY;
216
+ // the iframe would swallow pointermove while the cursor is over it
217
+ frame.style.pointerEvents = 'none';
111
218
  const mm = (ev: PointerEvent) => {
112
219
  move(ev.clientX - lx, ev.clientY - ly);
113
220
  lx = ev.clientX; ly = ev.clientY;
@@ -115,6 +222,8 @@ export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): Devtool
115
222
  const up = () => {
116
223
  removeEventListener('pointermove', mm);
117
224
  removeEventListener('pointerup', up);
225
+ frame.style.pointerEvents = '';
226
+ save(what);
118
227
  };
119
228
  addEventListener('pointermove', mm);
120
229
  addEventListener('pointerup', up);
@@ -122,42 +231,59 @@ export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): Devtool
122
231
 
123
232
  head.onpointerdown = (e) => {
124
233
  if ((e.target as HTMLElement).closest('a,button')) return;
125
- drag(e, (dx, dy) => {
234
+ drag(e, 'move', (dx, dy) => {
126
235
  const r = panel.getBoundingClientRect();
127
- // keep at least 40px of the title bar on screen
128
- const left = Math.min(Math.max(0, r.left + dx), innerWidth - 60);
129
- const top = Math.min(Math.max(0, r.top + dy), innerHeight - 40);
130
- panel.style.left = `${left}px`;
131
- panel.style.top = `${top}px`;
132
- panel.style.right = 'auto';
133
- panel.style.bottom = 'auto';
134
- panel.style.transform = 'none'; // drop the center anchor's translateX
236
+ placeAt(r.left + dx, r.top + dy);
135
237
  });
136
238
  };
137
239
 
138
240
  grip.onpointerdown = (e) => {
139
241
  e.stopPropagation();
140
- drag(e, (dx, dy) => {
242
+ drag(e, 'size', (dx, dy) => {
141
243
  const r = panel.getBoundingClientRect();
142
- panel.style.width = `${Math.max(340, r.width + dx)}px`;
143
- panel.style.height = `${Math.max(220, r.height + dy)}px`;
244
+ panel.style.width = `${Math.max(MIN_W, r.width + dx)}px`;
245
+ panel.style.height = `${Math.max(MIN_H, r.height + dy)}px`;
144
246
  });
145
247
  };
146
248
 
147
- const set = (v: boolean) => {
249
+ const set = (v: boolean, focus = false, persist = true) => {
250
+ const hadFocus = panel.contains(document.activeElement);
148
251
  open = v;
149
252
  panel.style.display = v ? 'flex' : 'none';
150
253
  btn.style.display = v ? 'none' : 'block';
254
+ btn.setAttribute('aria-expanded', String(v));
255
+ if (v && focus) frame.focus();
256
+ if (!v && hadFocus) btn.focus();
257
+ if (persist) save('open');
151
258
  };
152
- btn.onclick = () => set(true);
259
+ btn.onclick = () => set(true, true);
153
260
  x.onclick = () => set(false);
154
- set(opts.startOpen === true);
261
+
262
+ const onKey = (e: KeyboardEvent) => {
263
+ if (!isHotkey?.(e)) return;
264
+ e.preventDefault();
265
+ set(!open, true);
266
+ };
267
+ const onMessage = (e: MessageEvent) => {
268
+ if (e.source !== frame.contentWindow || !e.data || typeof e.data !== 'object') return;
269
+ const d = e.data as { type?: string; hash?: unknown };
270
+ if (d.type === 'atoll-devtools:toggle') set(!open);
271
+ else if (d.type === 'atoll-devtools:route' && typeof d.hash === 'string' && d.hash.startsWith('#/')) {
272
+ route = d.hash;
273
+ }
274
+ };
275
+ if (isHotkey) addEventListener('keydown', onKey);
276
+ addEventListener('message', onMessage);
277
+
278
+ set(opts.startOpen ?? saved.open === true, false, false);
155
279
 
156
280
  return {
157
281
  open: () => set(true),
158
282
  close: () => set(false),
159
283
  toggle: () => set(!open),
160
284
  unmount: () => {
285
+ removeEventListener('keydown', onKey);
286
+ removeEventListener('message', onMessage);
161
287
  panel.remove();
162
288
  btn.remove();
163
289
  },