@firedrill-tools/notion 0.1.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.
Files changed (65) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +402 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/bounded.scenario.json +19 -0
  6. package/firedrill/conformance.suite.json +23 -0
  7. package/firedrill/notion-bounded.drill.json +318 -0
  8. package/firedrill/notion-byte-budget.drill.json +116 -0
  9. package/firedrill/notion-mcp-aliases.drill.json +150 -0
  10. package/firedrill/notion-page-authoring.drill.json +254 -0
  11. package/firedrill/notion-rate-limited.drill.json +118 -0
  12. package/firedrill/notion-schema-growth.drill.json +88 -0
  13. package/firedrill/notion-scope-agent-only.drill.json +131 -0
  14. package/firedrill/notion-scope-auditor.drill.json +86 -0
  15. package/firedrill/notion-scope-board-bot.drill.json +128 -0
  16. package/firedrill/notion-scope-notes-bot.drill.json +303 -0
  17. package/firedrill/notion-scope-stranger.drill.json +773 -0
  18. package/firedrill/notion-task-triage.drill.json +277 -0
  19. package/firedrill/notion-trash-and-restore.drill.json +186 -0
  20. package/firedrill/notion-update-lost.drill.json +88 -0
  21. package/firedrill/notion-workspace-read.drill.json +258 -0
  22. package/firedrill/notion-write-unavailable.drill.json +161 -0
  23. package/firedrill/rate-limited.scenario.json +11 -0
  24. package/firedrill/tools/notion/app/assets/ATTRIBUTION.md +35 -0
  25. package/firedrill/tools/notion/app/assets/fonts/OFL.txt +93 -0
  26. package/firedrill/tools/notion/app/assets/fonts/inter-latin.woff2 +0 -0
  27. package/firedrill/tools/notion/app/assets/notion-wordmark.svg +1 -0
  28. package/firedrill/tools/notion/app/assets/notion.svg +1 -0
  29. package/firedrill/tools/notion/app/site/app.js +797 -0
  30. package/firedrill/tools/notion/app/site/assets/fonts/inter-latin.woff2 +0 -0
  31. package/firedrill/tools/notion/app/site/assets/notion-wordmark.svg +1 -0
  32. package/firedrill/tools/notion/app/site/assets/notion.svg +1 -0
  33. package/firedrill/tools/notion/app/site/chrome.js +104 -0
  34. package/firedrill/tools/notion/app/site/cover-picker.js +83 -0
  35. package/firedrill/tools/notion/app/site/database.js +648 -0
  36. package/firedrill/tools/notion/app/site/editors.js +320 -0
  37. package/firedrill/tools/notion/app/site/format-bar.js +97 -0
  38. package/firedrill/tools/notion/app/site/icons.js +131 -0
  39. package/firedrill/tools/notion/app/site/index.html +125 -0
  40. package/firedrill/tools/notion/app/site/page.js +826 -0
  41. package/firedrill/tools/notion/app/site/rich.js +159 -0
  42. package/firedrill/tools/notion/app/site/state.js +170 -0
  43. package/firedrill/tools/notion/app/site/styles.css +826 -0
  44. package/firedrill/tools/notion/app/site/ui.js +418 -0
  45. package/firedrill/tools/notion/behavior.mjs +1123 -0
  46. package/firedrill/tools/notion/lib/blocks.mjs +371 -0
  47. package/firedrill/tools/notion/lib/identity.mjs +123 -0
  48. package/firedrill/tools/notion/lib/ids.mjs +63 -0
  49. package/firedrill/tools/notion/lib/json-depth.mjs +26 -0
  50. package/firedrill/tools/notion/lib/markdown.mjs +381 -0
  51. package/firedrill/tools/notion/lib/properties.mjs +513 -0
  52. package/firedrill/tools/notion/lib/query.mjs +272 -0
  53. package/firedrill/tools/notion/lib/render.mjs +137 -0
  54. package/firedrill/tools/notion/lib/rich-text.mjs +134 -0
  55. package/firedrill/tools/notion/lib/size.mjs +44 -0
  56. package/firedrill/tools/notion/lib/state.mjs +192 -0
  57. package/firedrill/tools/notion/lib/wire.mjs +89 -0
  58. package/firedrill/tools/notion/notion.tool.json +9837 -0
  59. package/firedrill/update-lost.scenario.json +11 -0
  60. package/firedrill/world.json +7039 -0
  61. package/firedrill/write-unavailable.scenario.json +11 -0
  62. package/firedrill.json +5 -0
  63. package/package.json +63 -0
  64. package/starter.json +6482 -0
  65. package/test/conformance.mjs +1186 -0
@@ -0,0 +1,418 @@
1
+ // DOM, Firedrill-client and interaction helpers for the Notion Tool app. Every record string goes through
2
+ // textContent; nothing on this page is authoritative — records are re-read after writes and when the world revision moves.
3
+ import { getContext, invoke } from "/_firedrill/client.js";
4
+ import { icon } from "./icons.js";
5
+
6
+ export const $ = (selector, root = document) => root.querySelector(selector);
7
+ export const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
8
+
9
+ /** Create an element. `options`: { class, text, title, attrs: {…} }. Text always goes through textContent. */
10
+ export function el(tag, options = {}, children = []) {
11
+ const element = document.createElement(tag);
12
+ if (options.class) element.className = options.class;
13
+ if (options.text !== undefined) element.textContent = options.text;
14
+ if (options.title !== undefined) element.title = options.title;
15
+ if (options.attrs) for (const [name, value] of Object.entries(options.attrs)) if (value !== undefined && value !== null) element.setAttribute(name, String(value));
16
+ for (const child of Array.isArray(children) ? children : [children]) if (child !== undefined && child !== null && child !== false) element.append(child);
17
+ return element;
18
+ }
19
+
20
+ export function iconButton(name, label, options = {}) {
21
+ const button = el("button", { class: `icon-btn ${options.class ?? ""}`.trim(), title: options.tooltip === false ? undefined : label, attrs: { type: "button", "aria-label": label } });
22
+ button.append(icon(name));
23
+ if (options.onClick) button.addEventListener("click", options.onClick);
24
+ return button;
25
+ }
26
+
27
+ export function textButton(label, options = {}) {
28
+ const button = el("button", { class: `btn ${options.class ?? ""}`.trim(), attrs: { type: "button" } });
29
+ if (options.icon) button.append(icon(options.icon));
30
+ button.append(el("span", { text: label }));
31
+ if (options.onClick) button.addEventListener("click", options.onClick);
32
+ return button;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------------------------
36
+ // Tool calls
37
+ // ---------------------------------------------------------------------------------------------
38
+
39
+ export class ToolError extends Error {
40
+ constructor(status, code, message) {
41
+ super(message);
42
+ this.status = status;
43
+ this.code = code;
44
+ }
45
+ get denied() {
46
+ return this.status === "denied";
47
+ }
48
+ is(code) {
49
+ return this.code === `tool.${code}` || this.code === code;
50
+ }
51
+ }
52
+
53
+ /** Invoke one Tool operation; throws ToolError for every non-ok outcome. */
54
+ export async function call(operationId, args = {}, idempotencyKey) {
55
+ const result = await invoke(operationId, args, idempotencyKey ? { idempotencyKey } : {});
56
+ if (result.outcome.status !== "ok") {
57
+ const error = result.outcome.error ?? {};
58
+ throw new ToolError(result.outcome.status, error.code ?? result.outcome.status, error.message ?? `The operation was ${result.outcome.status}.`);
59
+ }
60
+ return result.outcome.value;
61
+ }
62
+
63
+ export const key = () => crypto.randomUUID();
64
+
65
+ /** Human wording for a failed call, in the product's tone. */
66
+ export function describe(error) {
67
+ if (error instanceof ToolError) {
68
+ if (error.denied) return "You don't have permission to do that in this workspace.";
69
+ if (error.is("RESTRICTED_RESOURCE")) return `This integration can't do that: ${error.message}`;
70
+ if (error.is("OBJECT_NOT_FOUND")) return "This page doesn't exist or isn't shared with the integration.";
71
+ if (error.is("RATE_LIMITED")) return "You're being rate limited. Try again in a few seconds.";
72
+ if (error.is("SERVICE_UNAVAILABLE")) return "Notion is unavailable right now. Nothing was saved — try again later.";
73
+ if (error.is("CONFLICT_ERROR")) return "Conflict occurred while saving. The page was reloaded — check before retrying.";
74
+ if (error.is("UNAUTHORIZED")) return "The integration token is not valid for this workspace.";
75
+ return error.message;
76
+ }
77
+ return error?.message ?? "Something went wrong. Refresh and try again.";
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------------------------
81
+ // One user action at a time
82
+ // ---------------------------------------------------------------------------------------------
83
+
84
+ let pending = 0;
85
+ const busyListeners = new Set();
86
+ export const isPending = () => pending > 0;
87
+ export function onBusy(listener) {
88
+ busyListeners.add(listener);
89
+ }
90
+ function setBusy(delta) {
91
+ pending += delta;
92
+ document.documentElement.toggleAttribute("data-busy", pending > 0);
93
+ for (const listener of busyListeners) listener(pending > 0);
94
+ }
95
+
96
+ /** Run a user action; a second exclusive action is ignored while one is in flight so a write cannot be submitted twice. */
97
+ export async function action(task, { exclusive = true, onError } = {}) {
98
+ if (exclusive && pending > 0) return undefined;
99
+ setBusy(1);
100
+ try {
101
+ return await task();
102
+ } catch (error) {
103
+ if (onError) onError(error);
104
+ else snackbar(describe(error), { error: true });
105
+ return undefined;
106
+ } finally {
107
+ setBusy(-1);
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------------------------
112
+ // Snackbar (bottom-left toast, the product's dark pill)
113
+ // ---------------------------------------------------------------------------------------------
114
+
115
+ let snackbarTimer;
116
+ export function snackbar(text, { actionLabel, onAction, timeout = 6000, error = false } = {}) {
117
+ const host = $("#snackbar");
118
+ $("#snackbar-text").textContent = text;
119
+ const button = $("#snackbar-action");
120
+ host.dataset.error = String(error);
121
+ button.hidden = !actionLabel;
122
+ button.textContent = actionLabel ?? "";
123
+ button.onclick = () => {
124
+ hideSnackbar();
125
+ if (onAction) void action(onAction);
126
+ };
127
+ host.hidden = false;
128
+ clearTimeout(snackbarTimer);
129
+ snackbarTimer = setTimeout(hideSnackbar, timeout);
130
+ }
131
+ export function hideSnackbar() {
132
+ clearTimeout(snackbarTimer);
133
+ $("#snackbar").hidden = true;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------------------------
137
+ // Popovers (menus, pickers) — one open at a time, anchored below or beside an element
138
+ // ---------------------------------------------------------------------------------------------
139
+
140
+ let openPopover;
141
+ export function closePopovers() {
142
+ if (!openPopover) return;
143
+ const { element, anchor, onClose } = openPopover;
144
+ openPopover = undefined;
145
+ element.remove();
146
+ if (anchor?.isConnected) anchor.setAttribute("aria-expanded", "false");
147
+ onClose?.();
148
+ }
149
+ export const popoverOpen = () => openPopover !== undefined;
150
+
151
+ /**
152
+ * Open a popover next to `anchor`. `content` is an element or an array of menu items
153
+ * ({ label, icon?, description?, danger?, disabled?, checked?, onSelect, keepOpen? } | "divider" | { header }).
154
+ */
155
+ export function openPopover_(anchor, content, { align = "start", side = "bottom", className = "", width, onClose, focusFirst = true } = {}) {
156
+ closePopovers();
157
+ const element = el("div", { class: `popover ${className}`.trim(), attrs: { role: Array.isArray(content) ? "menu" : "dialog" } });
158
+ if (width) element.style.width = `${width}px`;
159
+ if (Array.isArray(content)) element.append(menuList(content));
160
+ else element.append(content);
161
+ $("#popover-host").append(element);
162
+ openPopover = { element, anchor, onClose };
163
+ anchor.setAttribute("aria-expanded", "true");
164
+ position(element, anchor, align, side);
165
+ if (focusFirst) {
166
+ const first = element.querySelector("input, textarea, [contenteditable], button:not(:disabled)");
167
+ first?.focus();
168
+ }
169
+ return element;
170
+ }
171
+ export { openPopover_ as openPopover };
172
+
173
+ export function menuList(items) {
174
+ const list = el("div", { class: "menu" });
175
+ for (const item of items) {
176
+ if (item === "divider") {
177
+ list.append(el("div", { class: "menu-divider", attrs: { role: "separator" } }));
178
+ continue;
179
+ }
180
+ if (item.header !== undefined) {
181
+ list.append(el("div", { class: "menu-header", text: item.header }));
182
+ continue;
183
+ }
184
+ const button = el("button", { class: `menu-item ${item.danger ? "danger" : ""} ${item.checked ? "checked" : ""}`.trim(), attrs: { type: "button", role: item.checked === undefined ? "menuitem" : "menuitemcheckbox", "aria-checked": item.checked === undefined ? undefined : String(item.checked) } });
185
+ if (item.icon) button.append(icon(item.icon, "menu-icon"));
186
+ else if (item.emoji !== undefined) button.append(el("span", { class: "menu-emoji", text: item.emoji }));
187
+ else if (item.swatch) {
188
+ const swatch = el("span", { class: "menu-swatch" });
189
+ swatch.dataset.color = item.swatch;
190
+ button.append(swatch);
191
+ }
192
+ const labels = el("span", { class: "menu-labels" }, [el("span", { class: "menu-label", text: item.label }), item.description ? el("span", { class: "menu-description", text: item.description }) : null]);
193
+ button.append(labels);
194
+ if (item.checked) button.append(icon("check", "menu-check"));
195
+ if (item.shortcut) button.append(el("span", { class: "menu-shortcut", text: item.shortcut }));
196
+ if (item.disabled) button.disabled = true;
197
+ button.addEventListener("click", () => {
198
+ if (!item.keepOpen) closePopovers();
199
+ item.onSelect?.();
200
+ });
201
+ list.append(button);
202
+ }
203
+ return list;
204
+ }
205
+
206
+ function position(element, anchor, align, side) {
207
+ const rect = anchor.getBoundingClientRect();
208
+ const width = element.offsetWidth;
209
+ const height = element.offsetHeight;
210
+ let left;
211
+ let top;
212
+ if (side === "right") {
213
+ left = rect.right + 6;
214
+ top = rect.top;
215
+ } else {
216
+ left = align === "end" ? rect.right - width : rect.left;
217
+ top = rect.bottom + 4;
218
+ }
219
+ left = Math.max(8, Math.min(left, window.innerWidth - width - 8));
220
+ if (top + height > window.innerHeight - 8) top = Math.max(8, side === "right" ? window.innerHeight - height - 8 : rect.top - height - 4);
221
+ element.style.left = `${left}px`;
222
+ element.style.top = `${top}px`;
223
+ }
224
+
225
+ document.addEventListener("pointerdown", (event) => {
226
+ if (openPopover && !openPopover.element.contains(event.target) && !openPopover.anchor?.contains(event.target)) closePopovers();
227
+ });
228
+ document.addEventListener("keydown", (event) => {
229
+ if (!openPopover) return;
230
+ if (event.key === "Escape") {
231
+ event.preventDefault();
232
+ closePopovers();
233
+ } else if ((event.key === "ArrowDown" || event.key === "ArrowUp") && !(event.target instanceof HTMLTextAreaElement)) {
234
+ const items = $$(".menu-item:not(:disabled), .picker-item:not(:disabled)", openPopover.element);
235
+ if (items.length === 0) return;
236
+ const index = items.indexOf(document.activeElement);
237
+ const next = event.key === "ArrowDown" ? (index + 1) % items.length : (index - 1 + items.length) % items.length;
238
+ items[next].focus();
239
+ event.preventDefault();
240
+ }
241
+ });
242
+
243
+ // ---------------------------------------------------------------------------------------------
244
+ // Confirmation dialog
245
+ // ---------------------------------------------------------------------------------------------
246
+
247
+ export function confirmDialog(title, text, okLabel = "Delete", { danger = true } = {}) {
248
+ const dialog = $("#confirm-dialog");
249
+ $("#confirm-title").textContent = title;
250
+ $("#confirm-text").textContent = text;
251
+ const ok = $("#confirm-ok");
252
+ ok.textContent = okLabel;
253
+ ok.classList.toggle("danger", danger);
254
+ ok.classList.toggle("primary", !danger);
255
+ dialog.returnValue = "cancel";
256
+ dialog.showModal();
257
+ return new Promise((resolve) => dialog.addEventListener("close", () => resolve(dialog.returnValue === "ok"), { once: true }));
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------------------------
261
+ // Dates — "today" is always the world's virtual now, never the browser clock
262
+ // ---------------------------------------------------------------------------------------------
263
+
264
+ const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
265
+ const utcDay = (date) => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
266
+
267
+ /** Property date value: "September 18, 2026" (date only) or with a time; ranges join with →. */
268
+ export function propertyDate(value) {
269
+ if (!value || !value.start) return "";
270
+ const format = (iso) => {
271
+ const date = new Date(iso);
272
+ if (Number.isNaN(date.getTime())) return iso;
273
+ const base = `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}`;
274
+ return iso.length > 10 ? `${base} ${time(date)}` : base;
275
+ };
276
+ return value.end ? `${format(value.start)} → ${format(value.end)}` : format(value.start);
277
+ }
278
+
279
+ function time(date) {
280
+ const hours = date.getUTCHours();
281
+ const minutes = String(date.getUTCMinutes()).padStart(2, "0");
282
+ return `${((hours + 11) % 12) + 1}:${minutes} ${hours < 12 ? "AM" : "PM"}`;
283
+ }
284
+
285
+ /** Compact stamp for lists: "9:00 AM" today, "Sep 12" this year, "Mar 3, 2025" otherwise. */
286
+ export function shortDate(iso, nowMs) {
287
+ const date = new Date(iso);
288
+ if (Number.isNaN(date.getTime())) return "";
289
+ const short = `${MONTHS[date.getUTCMonth()].slice(0, 3)} ${date.getUTCDate()}`;
290
+ if (nowMs === undefined) return `${short}, ${date.getUTCFullYear()}`;
291
+ const now = new Date(nowMs);
292
+ if (utcDay(date) === utcDay(now)) return time(date);
293
+ if (date.getUTCFullYear() === now.getUTCFullYear()) return short;
294
+ return `${short}, ${date.getUTCFullYear()}`;
295
+ }
296
+
297
+ /** "Edited 2 hours ago" style relative time against the virtual now. */
298
+ export function relativeTime(iso, nowMs) {
299
+ const ms = Date.parse(iso);
300
+ if (Number.isNaN(ms) || nowMs === undefined) return shortDate(iso, nowMs);
301
+ const minutes = Math.round((nowMs - ms) / 60000);
302
+ if (minutes < 1) return "just now";
303
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
304
+ const hours = Math.round(minutes / 60);
305
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
306
+ const days = Math.round(hours / 24);
307
+ if (days < 7) return days === 1 ? "yesterday" : `${days} days ago`;
308
+ return shortDate(iso, nowMs);
309
+ }
310
+
311
+ /** Greeting for the Home tab from the virtual hour. */
312
+ export function greeting(nowMs) {
313
+ const hours = new Date(nowMs).getUTCHours();
314
+ if (hours < 12) return "Good morning";
315
+ if (hours < 18) return "Good afternoon";
316
+ return "Good evening";
317
+ }
318
+
319
+ /** Long form with weekday, for the Home tab header. */
320
+ export function longDate(nowMs) {
321
+ const date = new Date(nowMs);
322
+ const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
323
+ return `${days[date.getUTCDay()]}, ${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}`;
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------------------------
327
+ // People
328
+ // ---------------------------------------------------------------------------------------------
329
+
330
+ const AVATAR_COLORS = ["#d44c47", "#cb912f", "#448361", "#337ea9", "#9065b0", "#c14c8a", "#9f6b53", "#787774"];
331
+ export function avatarColor(seed) {
332
+ let hash = 0;
333
+ for (const character of String(seed)) hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
334
+ return AVATAR_COLORS[hash % AVATAR_COLORS.length];
335
+ }
336
+
337
+ /** Round letter avatar the way the product shows people without a photo. */
338
+ export function avatar(user, size = "") {
339
+ const name = user?.name ?? "?";
340
+ const element = el("span", { class: `avatar ${size}`.trim(), text: name.trim().charAt(0).toUpperCase() || "?", attrs: { "aria-hidden": "true" } });
341
+ element.style.background = avatarColor(user?.id ?? name);
342
+ return element;
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------------------------
346
+ // Per-viewer preferences (sidebar width, last tab) — a convenience, never authority over records
347
+ // ---------------------------------------------------------------------------------------------
348
+
349
+ export function readPreference(name, fallback) {
350
+ try {
351
+ return localStorage.getItem(`notion-tool:${name}`) ?? fallback;
352
+ } catch {
353
+ return fallback;
354
+ }
355
+ }
356
+ export function writePreference(name, value) {
357
+ try {
358
+ localStorage.setItem(`notion-tool:${name}`, value);
359
+ } catch {
360
+ /* private mode or blocked storage: the setting simply does not persist */
361
+ }
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------------------------
365
+ // World revision polling
366
+ // ---------------------------------------------------------------------------------------------
367
+
368
+ /** Poll the world revision and refresh when something changed (agent activity, reset, other tabs). */
369
+ export function watchWorld(refresh, mayRefresh = () => true, onContext) {
370
+ let revision;
371
+ let checking = false;
372
+ const check = async () => {
373
+ if (checking || pending > 0 || document.visibilityState !== "visible") return;
374
+ checking = true;
375
+ try {
376
+ const context = await getContext();
377
+ onContext?.(context);
378
+ const stamp = JSON.stringify(context.revision);
379
+ if (revision !== stamp) {
380
+ const first = revision === undefined;
381
+ revision = stamp;
382
+ if (first || mayRefresh()) await refresh(first);
383
+ }
384
+ } catch (error) {
385
+ snackbar(error?.message ?? "The local environment is unavailable.", { error: true });
386
+ } finally {
387
+ checking = false;
388
+ }
389
+ };
390
+ const timer = setInterval(() => void check(), 2000);
391
+ window.addEventListener("pagehide", () => clearInterval(timer), { once: true });
392
+ void check();
393
+ }
394
+
395
+ /** True while the viewer is typing somewhere that a re-render would disturb. */
396
+ export function isEditing() {
397
+ const active = document.activeElement;
398
+ if (!active || active === document.body) return false;
399
+ if (active.isContentEditable) return true;
400
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) return !active.closest("#search-dialog");
401
+ return popoverOpen();
402
+ }
403
+
404
+ /** A control that exists in the product's chrome but is outside this Tool's scope: a short explanatory panel. */
405
+ export function notSimulated(anchor, title, text, { align = "start", side = "bottom" } = {}) {
406
+ const box = el("div", { class: "not-simulated", attrs: { "aria-label": title } });
407
+ box.append(el("div", { class: "panel-head plain" }, [el("span", { class: "panel-title", text: title })]));
408
+ box.append(el("p", { class: "panel-note", text: text ?? "Not simulated by this Tool." }));
409
+ box.append(el("p", { class: "not-simulated-tag", text: "Not simulated by this Tool" }));
410
+ return openPopover_(anchor, box, { width: 300, align, side, className: "sidebar-panel", focusFirst: false });
411
+ }
412
+
413
+ /** Icon button for a control outside the Tool's scope (renders in place with hover and tooltip). */
414
+ export function unsimulatedButton(name, label, text, options = {}) {
415
+ const button = iconButton(name, label, { class: `unsimulated ${options.class ?? ""}`.trim() });
416
+ button.addEventListener("click", (event) => notSimulated(event.currentTarget, label, text, options));
417
+ return button;
418
+ }