@atolljs/devtools 0.1.7 → 0.1.9

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 (50) hide show
  1. package/README.md +408 -71
  2. package/app/api.js +68 -0
  3. package/app/fw-logos.js +14 -0
  4. package/app/index.html +161 -2
  5. package/app/main.js +897 -222
  6. package/app/panels/actions.js +324 -0
  7. package/app/panels/audit-rules.js +728 -0
  8. package/app/panels/audits.js +438 -0
  9. package/app/panels/inspect-island.js +351 -0
  10. package/app/panels/memory-values.js +287 -0
  11. package/app/panels/performance.js +304 -0
  12. package/app/panels/reactivity.js +271 -0
  13. package/app/panels/recorder.js +344 -0
  14. package/app/panels/recording-format.js +166 -0
  15. package/app/panels/route.d.ts +44 -0
  16. package/app/panels/route.js +264 -0
  17. package/app/panels/shell.js +789 -0
  18. package/app/panels/toast.js +38 -0
  19. package/dist/bin.js +704 -5
  20. package/dist/chunk-57NGHQUL.js +344 -0
  21. package/dist/chunk-6E54FXKS.js +495 -0
  22. package/dist/chunk-WIL2P7HG.js +661 -0
  23. package/dist/client.d.ts +7 -0
  24. package/dist/control.d.ts +4 -0
  25. package/dist/index.js +2 -1
  26. package/dist/node.js +2 -1
  27. package/dist/nodeWs.d.ts +1 -0
  28. package/dist/otel.d.ts +35 -0
  29. package/dist/otel.js +87 -0
  30. package/dist/otelMap.d.ts +232 -0
  31. package/dist/otelTransport.d.ts +69 -0
  32. package/dist/overlay.d.ts +16 -2
  33. package/dist/probes.d.ts +7 -0
  34. package/dist/protocol.d.ts +46 -4
  35. package/dist/server.d.ts +19 -0
  36. package/dist/server.js +61 -3
  37. package/package.json +6 -2
  38. package/src/bin.ts +27 -2
  39. package/src/broadcast.ts +6 -0
  40. package/src/client.ts +26 -2
  41. package/src/control.ts +51 -0
  42. package/src/nodeWs.ts +7 -1
  43. package/src/otel.ts +140 -0
  44. package/src/otelMap.ts +661 -0
  45. package/src/otelTransport.ts +286 -0
  46. package/src/overlay.ts +239 -47
  47. package/src/probes.ts +155 -0
  48. package/src/protocol.ts +52 -4
  49. package/src/server.ts +83 -3
  50. package/dist/chunk-4N3NBFL3.js +0 -474
@@ -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;' +
@@ -46,7 +66,21 @@ const BTN_STYLE =
46
66
  const PANEL_STYLE =
47
67
  'position:fixed;z-index:2147483646;display:flex;flex-direction:column;' +
48
68
  'background:#0d1117;border:1px solid #30363d;border-radius:8px;' +
49
- 'box-shadow:0 12px 40px #000c;overflow:hidden;min-width:340px;min-height:220px';
69
+ 'box-shadow:0 12px 40px #000c;overflow:hidden;min-width:340px;min-height:220px;' +
70
+ 'max-width:100vw;max-height:100vh;box-sizing:border-box';
71
+
72
+ type Edge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
73
+ const EDGE_CSS: Record<Edge, string> = {
74
+ n: 'top:0;left:10px;right:10px;height:4px;cursor:ns-resize',
75
+ s: 'bottom:0;left:10px;right:16px;height:5px;cursor:ns-resize',
76
+ e: 'right:0;top:10px;bottom:16px;width:5px;cursor:ew-resize',
77
+ w: 'left:0;top:10px;bottom:10px;width:5px;cursor:ew-resize',
78
+ ne: 'right:0;top:0;width:10px;height:10px;cursor:nesw-resize',
79
+ nw: 'left:0;top:0;width:10px;height:10px;cursor:nwse-resize',
80
+ sw: 'left:0;bottom:0;width:10px;height:10px;cursor:nesw-resize',
81
+ se: 'right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;' +
82
+ 'background:linear-gradient(135deg,transparent 50%,#30363d 50%);border-radius:0 0 7px',
83
+ };
50
84
 
51
85
  const ANCHOR: Record<DevtoolsOverlayPosition, (m: number) => string> = {
52
86
  topleft: (m) => `left:${m}px;top:${m}px`,
@@ -57,18 +91,53 @@ const ANCHOR: Record<DevtoolsOverlayPosition, (m: number) => string> = {
57
91
  bottomright: (m) => `right:${m}px;bottom:${m}px`,
58
92
  };
59
93
 
94
+ const MIN_W = 340;
95
+ const MIN_H = 220;
96
+ let seq = 0;
97
+
98
+ /** 'Alt+Shift+D' → a keydown predicate. Letters/digits match on `code`, so Alt/Option layouts still hit. */
99
+ export function parseHotkey(spec: string): (e: KeyboardEvent) => boolean {
100
+ const parts = spec.split('+').map((p) => p.trim().toLowerCase()).filter(Boolean);
101
+ const key = parts.pop() ?? '';
102
+ const mods = new Set(parts.map((m) => (m === 'option' ? 'alt' : m === 'cmd' || m === 'meta' ? 'meta' : m === 'control' ? 'ctrl' : m)));
103
+ const code = /^[a-z]$/.test(key) ? `Key${key.toUpperCase()}` : /^[0-9]$/.test(key) ? `Digit${key}` : null;
104
+ return (e) =>
105
+ e.altKey === mods.has('alt') &&
106
+ e.shiftKey === mods.has('shift') &&
107
+ e.ctrlKey === mods.has('ctrl') &&
108
+ e.metaKey === mods.has('meta') &&
109
+ (code ? e.code === code : e.key.toLowerCase() === key);
110
+ }
111
+
60
112
  export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): DevtoolsOverlay {
61
113
  const src = opts.src ?? '/__atoll/?mini=1';
62
- const W = opts.width ?? 760;
63
- const H = opts.height ?? 580;
114
+ const storeKey = opts.persist === false ? null : typeof opts.persist === 'string' ? opts.persist : 'atoll-devtools:overlay';
115
+ const load = (): Saved => {
116
+ if (!storeKey) return {};
117
+ try { return JSON.parse(localStorage.getItem(storeKey) ?? '{}') as Saved; } catch { return {}; }
118
+ };
119
+ const saved = load();
120
+ const W = Math.max(MIN_W, saved.width ?? opts.width ?? 760);
121
+ const H = Math.max(MIN_H, saved.height ?? opts.height ?? 580);
64
122
  const anchor = ANCHOR[opts.position ?? 'bottomright'];
123
+ const hotkey = opts.hotkey === false ? null : opts.hotkey ?? 'Alt+Shift+D';
124
+ const isHotkey = hotkey ? parseHotkey(hotkey) : null;
125
+ const hint = hotkey ? ` (${hotkey})` : '';
126
+ const id = `atoll-devtools-overlay-${++seq}`;
65
127
  let open = false;
66
128
 
67
129
  const btn = document.createElement('button');
130
+ btn.type = 'button';
68
131
  btn.textContent = 'atoll devtools';
132
+ btn.title = `Open atoll devtools${hint}`;
133
+ btn.setAttribute('aria-controls', id);
134
+ btn.setAttribute('aria-expanded', 'false');
69
135
  btn.style.cssText = `${BTN_STYLE};${anchor(12)}`;
70
136
 
71
137
  const panel = document.createElement('div');
138
+ panel.id = id;
139
+ panel.setAttribute('role', 'dialog');
140
+ panel.setAttribute('aria-label', 'atoll devtools');
72
141
  panel.style.cssText =
73
142
  `${PANEL_STYLE};width:${W}px;height:${H}px;display:none;${anchor(16)}`;
74
143
 
@@ -77,44 +146,123 @@ export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): Devtool
77
146
  'display:flex;align-items:center;gap:8px;padding:5px 10px;flex:none;' +
78
147
  'border-bottom:1px solid #21262d;font:11px ui-monospace,monospace;' +
79
148
  'color:#8b949e;background:#161b22;cursor:move;user-select:none';
80
- head.textContent = 'atoll devtools';
149
+ const title = document.createElement('span');
150
+ title.textContent = 'atoll devtools';
81
151
 
152
+ // Full-page dashboard = src minus its query/hash — stays relative when the
153
+ // caller passes a relative src (e.g. '__atoll/?mini=1' under a mounted
154
+ // base path where absolute /__atoll/ would escape the mount) — plus the
155
+ // iframe's current hash route, so the tab opens on the same view.
156
+ const base = src.split(/[?#]/)[0];
157
+ const frame = document.createElement('iframe');
158
+ let route = '';
159
+ const currentRoute = (): string => {
160
+ try {
161
+ const h = frame.contentWindow?.location.hash;
162
+ if (typeof h === 'string' && h.startsWith('#/')) return h;
163
+ } catch { /* cross-origin src: rely on posted routes */ }
164
+ return route;
165
+ };
82
166
  const pop = document.createElement('a');
83
- pop.href = '/__atoll/';
167
+ pop.href = base;
84
168
  pop.target = '_blank';
85
169
  pop.rel = 'noopener';
86
170
  pop.textContent = 'full page ↗';
171
+ pop.title = 'Open this view in the full-page dashboard';
87
172
  pop.style.cssText = 'color:#58a6ff;text-decoration:none;margin-left:auto';
173
+ const syncPop = () => { pop.href = base + currentRoute(); };
174
+ pop.addEventListener('pointerenter', syncPop);
175
+ pop.addEventListener('focus', syncPop);
176
+ pop.addEventListener('click', syncPop); // runs before the default navigation
88
177
  const x = document.createElement('button');
89
- x.textContent = '—';
90
- x.title = 'collapse';
178
+ x.type = 'button';
179
+ x.textContent = '×';
180
+ x.title = `Close${hint}`;
181
+ x.setAttribute('aria-label', 'Close atoll devtools');
91
182
  x.style.cssText =
92
- 'color:#8b949e;background:none;border:none;cursor:pointer;font:inherit;padding:0 2px';
93
- head.append(pop, x);
183
+ 'color:#8b949e;background:none;border:none;cursor:pointer;font:14px/1 ui-monospace,monospace;padding:0 2px';
184
+ head.append(title, pop, x);
94
185
 
95
- const frame = document.createElement('iframe');
96
186
  frame.src = src;
187
+ frame.title = 'atoll devtools dashboard';
97
188
  frame.style.cssText = 'flex:1;border:0;background:#0d1117;min-height:0';
98
189
 
99
- // manual resize grip — CSS `resize` doesn't work over an iframe
100
- const grip = document.createElement('div');
101
- grip.style.cssText =
102
- 'position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;' +
103
- 'background:linear-gradient(135deg,transparent 50%,#30363d 50%);border-radius:0 0 7px';
104
- panel.append(head, frame, grip);
190
+ // Manual resize handles on every edge and corner (CSS `resize` doesn't work
191
+ // over an iframe). The visible se grip stays the panel's last child.
192
+ const handles = (['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se'] as const).map((edge) => {
193
+ const h = document.createElement('div');
194
+ h.setAttribute('aria-hidden', 'true');
195
+ h.dataset.edge = edge;
196
+ h.style.cssText = `position:absolute;z-index:1;${EDGE_CSS[edge]}`;
197
+ return h;
198
+ });
199
+ panel.append(head, frame, ...handles);
105
200
  document.body.append(panel, btn);
106
201
 
107
- /** Pointer-drag helper: run `move` on pointermove until pointerup. */
108
- const drag = (e: PointerEvent, move: (dx: number, dy: number) => void) => {
202
+ const px = (v: string) => parseFloat(v) || 0;
203
+ const vw = () => document.documentElement.clientWidth || innerWidth;
204
+ const vh = () => document.documentElement.clientHeight || innerHeight;
205
+ const fitW = (w: number) => Math.max(MIN_W, Math.min(w, vw()));
206
+ const fitH = (h: number) => Math.max(MIN_H, Math.min(h, vh()));
207
+
208
+ /** Explicit left/top, clamped so the whole panel (close button included) stays in the viewport. */
209
+ const placeAt = (left: number, top: number) => {
210
+ const w = px(panel.style.width), h = px(panel.style.height);
211
+ panel.style.left = `${Math.min(Math.max(0, left), Math.max(0, vw() - w))}px`;
212
+ panel.style.top = `${Math.min(Math.max(0, top), Math.max(0, vh() - h))}px`;
213
+ panel.style.right = 'auto';
214
+ panel.style.bottom = 'auto';
215
+ panel.style.transform = 'none'; // drop the center anchor's translateX
216
+ };
217
+ const pinned = () => panel.style.right === 'auto';
218
+ /** Swap a corner/center anchor for explicit left/top at the panel's current spot. */
219
+ const pin = () => {
220
+ if (pinned()) return;
221
+ const r = panel.getBoundingClientRect();
222
+ placeAt(r.left, r.top);
223
+ };
224
+ /** Shrink to the viewport and pull back on screen (window resized, or reopened smaller). */
225
+ const refit = () => {
226
+ panel.style.width = `${fitW(px(panel.style.width))}px`;
227
+ panel.style.height = `${fitH(px(panel.style.height))}px`;
228
+ if (pinned()) placeAt(px(panel.style.left), px(panel.style.top));
229
+ };
230
+ if (typeof saved.left === 'number' && typeof saved.top === 'number') {
231
+ panel.style.width = `${fitW(W)}px`;
232
+ panel.style.height = `${fitH(H)}px`;
233
+ placeAt(saved.left, saved.top);
234
+ }
235
+
236
+ // Only what the user changed is remembered: an untouched size/position
237
+ // keeps following the options.
238
+ const mem: Saved = { ...saved };
239
+ const save = (what: 'open' | 'move' | 'size') => {
240
+ if (!storeKey) return;
241
+ if (what === 'open') mem.open = open;
242
+ if (what === 'size') {
243
+ mem.width = parseFloat(panel.style.width);
244
+ mem.height = parseFloat(panel.style.height);
245
+ }
246
+ // resizing pins the panel and a w/n edge moves it, so size saves position too
247
+ if (what === 'move' || what === 'size') {
248
+ mem.left = parseFloat(panel.style.left);
249
+ mem.top = parseFloat(panel.style.top);
250
+ }
251
+ try { localStorage.setItem(storeKey, JSON.stringify(mem)); } catch { /* storage off */ }
252
+ };
253
+
254
+ /** Pointer-drag helper: run `move` with the total offset from the press until pointerup. */
255
+ const drag = (e: PointerEvent, what: 'move' | 'size', move: (dx: number, dy: number) => void) => {
109
256
  e.preventDefault();
110
- let lx = e.clientX, ly = e.clientY;
111
- const mm = (ev: PointerEvent) => {
112
- move(ev.clientX - lx, ev.clientY - ly);
113
- lx = ev.clientX; ly = ev.clientY;
114
- };
257
+ const sx = e.clientX, sy = e.clientY;
258
+ // the iframe would swallow pointermove while the cursor is over it
259
+ frame.style.pointerEvents = 'none';
260
+ const mm = (ev: PointerEvent) => move(ev.clientX - sx, ev.clientY - sy);
115
261
  const up = () => {
116
262
  removeEventListener('pointermove', mm);
117
263
  removeEventListener('pointerup', up);
264
+ frame.style.pointerEvents = '';
265
+ save(what);
118
266
  };
119
267
  addEventListener('pointermove', mm);
120
268
  addEventListener('pointerup', up);
@@ -122,42 +270,86 @@ export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): Devtool
122
270
 
123
271
  head.onpointerdown = (e) => {
124
272
  if ((e.target as HTMLElement).closest('a,button')) return;
125
- drag(e, (dx, dy) => {
126
- 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
135
- });
273
+ pin();
274
+ refit();
275
+ const l0 = px(panel.style.left), t0 = px(panel.style.top);
276
+ drag(e, 'move', (dx, dy) => placeAt(l0 + dx, t0 + dy));
136
277
  };
137
278
 
138
- grip.onpointerdown = (e) => {
139
- e.stopPropagation();
140
- drag(e, (dx, dy) => {
141
- 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`;
144
- });
145
- };
279
+ for (const h of handles) {
280
+ const edge = h.dataset.edge as Edge;
281
+ h.onpointerdown = (e) => {
282
+ e.stopPropagation();
283
+ pin();
284
+ refit();
285
+ const l0 = px(panel.style.left), t0 = px(panel.style.top);
286
+ const w0 = px(panel.style.width), h0 = px(panel.style.height);
287
+ drag(e, 'size', (dx, dy) => {
288
+ let left = l0, top = t0, w = w0, hh = h0;
289
+ if (edge.includes('e')) w = Math.max(MIN_W, Math.min(w0 + dx, vw() - l0));
290
+ if (edge.includes('s')) hh = Math.max(MIN_H, Math.min(h0 + dy, vh() - t0));
291
+ if (edge.includes('w')) {
292
+ left = Math.max(0, Math.min(l0 + dx, l0 + w0 - MIN_W));
293
+ w = l0 + w0 - left;
294
+ }
295
+ if (edge.includes('n')) {
296
+ top = Math.max(0, Math.min(t0 + dy, t0 + h0 - MIN_H));
297
+ hh = t0 + h0 - top;
298
+ }
299
+ panel.style.width = `${w}px`;
300
+ panel.style.height = `${hh}px`;
301
+ panel.style.left = `${left}px`;
302
+ panel.style.top = `${top}px`;
303
+ });
304
+ };
305
+ }
146
306
 
147
- const set = (v: boolean) => {
307
+ const set = (v: boolean, focus = false, persist = true) => {
308
+ const hadFocus = panel.contains(document.activeElement);
148
309
  open = v;
149
310
  panel.style.display = v ? 'flex' : 'none';
311
+ if (v) refit();
150
312
  btn.style.display = v ? 'none' : 'block';
313
+ btn.setAttribute('aria-expanded', String(v));
314
+ if (v && focus) frame.focus();
315
+ if (!v && hadFocus) btn.focus();
316
+ if (persist) save('open');
151
317
  };
152
- btn.onclick = () => set(true);
318
+ btn.onclick = () => set(true, true);
153
319
  x.onclick = () => set(false);
154
- set(opts.startOpen === true);
320
+
321
+ const onKey = (e: KeyboardEvent) => {
322
+ if (!isHotkey?.(e)) return;
323
+ e.preventDefault();
324
+ set(!open, true);
325
+ };
326
+ const onMessage = (e: MessageEvent) => {
327
+ if (e.source !== frame.contentWindow || !e.data || typeof e.data !== 'object') return;
328
+ const d = e.data as { type?: string; hash?: unknown };
329
+ if (d.type === 'atoll-devtools:toggle') set(!open);
330
+ else if (d.type === 'atoll-devtools:route' && typeof d.hash === 'string' && d.hash.startsWith('#/')) {
331
+ route = d.hash;
332
+ }
333
+ };
334
+ const onResize = () => { if (open) refit(); };
335
+ if (isHotkey) addEventListener('keydown', onKey);
336
+ addEventListener('message', onMessage);
337
+ addEventListener('resize', onResize);
338
+ // a page scrollbar appearing shrinks the visible width without a window resize event
339
+ const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(onResize) : undefined;
340
+ ro?.observe(document.documentElement);
341
+
342
+ set(opts.startOpen ?? saved.open === true, false, false);
155
343
 
156
344
  return {
157
345
  open: () => set(true),
158
346
  close: () => set(false),
159
347
  toggle: () => set(!open),
160
348
  unmount: () => {
349
+ removeEventListener('keydown', onKey);
350
+ removeEventListener('message', onMessage);
351
+ removeEventListener('resize', onResize);
352
+ ro?.disconnect();
161
353
  panel.remove();
162
354
  btn.remove();
163
355
  },