@firedrill-tools/resend 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 +198 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +1687 -0
  5. package/firedrill/conformance.suite.json +19 -0
  6. package/firedrill/contacts-outage.scenario.json +11 -0
  7. package/firedrill/quota-exhausted.scenario.json +17 -0
  8. package/firedrill/rate-limited.scenario.json +11 -0
  9. package/firedrill/resend-audience.drill.json +407 -0
  10. package/firedrill/resend-contacts-outage.drill.json +63 -0
  11. package/firedrill/resend-denied.drill.json +68 -0
  12. package/firedrill/resend-domains-and-keys.drill.json +307 -0
  13. package/firedrill/resend-email-flow.drill.json +286 -0
  14. package/firedrill/resend-fresh-install.drill.json +73 -0
  15. package/firedrill/resend-invalid-key.drill.json +803 -0
  16. package/firedrill/resend-large-page.drill.json +53 -0
  17. package/firedrill/resend-quota-exhausted.drill.json +82 -0
  18. package/firedrill/resend-rate-limited.drill.json +81 -0
  19. package/firedrill/resend-restricted-key.drill.json +441 -0
  20. package/firedrill/resend-tight-limits.drill.json +163 -0
  21. package/firedrill/tight-limits.scenario.json +16 -0
  22. package/firedrill/tools/resend/app/assets/ATTRIBUTION.md +30 -0
  23. package/firedrill/tools/resend/app/assets/fonts/Inter-OFL.txt +93 -0
  24. package/firedrill/tools/resend/app/assets/fonts/JetBrainsMono-OFL.txt +93 -0
  25. package/firedrill/tools/resend/app/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
  26. package/firedrill/tools/resend/app/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  27. package/firedrill/tools/resend/app/assets/resend-icon-white.svg +3 -0
  28. package/firedrill/tools/resend/app/assets/resend-wordmark-white.svg +3 -0
  29. package/firedrill/tools/resend/app/assets/resend.svg +3 -0
  30. package/firedrill/tools/resend/app/site/app.js +90 -0
  31. package/firedrill/tools/resend/app/site/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
  32. package/firedrill/tools/resend/app/site/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  33. package/firedrill/tools/resend/app/site/assets/resend-icon-white.svg +3 -0
  34. package/firedrill/tools/resend/app/site/assets/resend-wordmark-white.svg +3 -0
  35. package/firedrill/tools/resend/app/site/assets/resend.svg +3 -0
  36. package/firedrill/tools/resend/app/site/contact-panel.js +86 -0
  37. package/firedrill/tools/resend/app/site/icons.js +70 -0
  38. package/firedrill/tools/resend/app/site/index.html +56 -0
  39. package/firedrill/tools/resend/app/site/list.js +41 -0
  40. package/firedrill/tools/resend/app/site/styles.css +67 -0
  41. package/firedrill/tools/resend/app/site/ui.js +259 -0
  42. package/firedrill/tools/resend/app/site/view-audience.js +160 -0
  43. package/firedrill/tools/resend/app/site/view-domains.js +106 -0
  44. package/firedrill/tools/resend/app/site/view-email-detail.js +126 -0
  45. package/firedrill/tools/resend/app/site/view-emails.js +83 -0
  46. package/firedrill/tools/resend/app/site/view-keys.js +91 -0
  47. package/firedrill/tools/resend/app/site/views.css +141 -0
  48. package/firedrill/tools/resend/behavior.mjs +114 -0
  49. package/firedrill/tools/resend/lib/check.mjs +75 -0
  50. package/firedrill/tools/resend/lib/core.mjs +112 -0
  51. package/firedrill/tools/resend/lib/json-depth.mjs +45 -0
  52. package/firedrill/tools/resend/lib/page.mjs +66 -0
  53. package/firedrill/tools/resend/lib/time.mjs +60 -0
  54. package/firedrill/tools/resend/lib/wire.mjs +98 -0
  55. package/firedrill/tools/resend/ops/contacts.mjs +199 -0
  56. package/firedrill/tools/resend/ops/domains.mjs +125 -0
  57. package/firedrill/tools/resend/ops/email-model.mjs +100 -0
  58. package/firedrill/tools/resend/ops/emails.mjs +139 -0
  59. package/firedrill/tools/resend/ops/keys-segments.mjs +97 -0
  60. package/firedrill/tools/resend/resend.tool.json +4804 -0
  61. package/firedrill/world.json +2148 -0
  62. package/firedrill.json +5 -0
  63. package/package.json +62 -0
  64. package/starter.json +1686 -0
  65. package/test/conformance.mjs +480 -0
@@ -0,0 +1,70 @@
1
+ // Monochrome 16px line icons in the dashboard's thin-stroke style (1.5px strokes, round joins). Built with DOM APIs only.
2
+ const NS = "http://www.w3.org/2000/svg";
3
+ const P = {
4
+ mail: ["M3 5.5h18v13H3z", "m3.5 6 8.5 7 8.5-7"],
5
+ broadcast: ["M4 10v4", "M7.5 8.5v7", "M11 6v12", "M14.5 8.5v7", "M18 10v4"],
6
+ template: ["M4 4h16v16H4z", "M4 9h16", "M9 9v11"],
7
+ audience: ["M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z", "M2.5 20c.6-3.4 3.2-5.5 6.5-5.5s5.9 2.1 6.5 5.5", "M16 4.3a3.5 3.5 0 0 1 0 6.4", "M18 14.8c2 .7 3.2 2.5 3.5 5.2"],
8
+ metrics: ["M4 20V10", "M10 20V4", "M16 20v-7", "M22 20H2"],
9
+ globe: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z", "M3 12h18", "M12 3c2.5 2.6 3.7 5.6 3.7 9s-1.2 6.4-3.7 9c-2.5-2.6-3.7-5.6-3.7-9S9.5 5.6 12 3Z"],
10
+ logs: ["M8 6h13", "M8 12h13", "M8 18h13", "M3.5 6h.01", "M3.5 12h.01", "M3.5 18h.01"],
11
+ key: ["M14.5 9.5a2 2 0 1 0 0-.01", "M15 15a6 6 0 1 0-5.6-3.9L3 17.5V21h3.5v-2h2v-2h2l1.9-1.9A6 6 0 0 0 15 15Z"],
12
+ webhook: ["M9 7.5a3.5 3.5 0 1 1 5.3 3L17 15", "M6.5 13a3.5 3.5 0 1 0 3.5 4h7", "M17.5 20.5a3.5 3.5 0 1 0-1.5-6.7L12 7"],
13
+ settings: ["M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z", "M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"],
14
+ search: ["M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z", "m20 20-3.5-3.5"],
15
+ chevronDown: ["m6 9 6 6 6-6"],
16
+ chevronRight: ["m9 6 6 6-6 6"],
17
+ chevronLeft: ["m15 6-6 6 6 6"],
18
+ chevronsUpDown: ["m7 15 5 5 5-5", "m7 9 5-5 5 5"],
19
+ plus: ["M12 5v14", "M5 12h14"],
20
+ copy: ["M9 9h11v11H9z", "M5 15H4V4h11v1"],
21
+ check: ["m5 12.5 4.5 4.5L19 7"],
22
+ x: ["M6 6l12 12", "M18 6 6 18"],
23
+ more: ["M5 12h.01", "M12 12h.01", "M19 12h.01"],
24
+ trash: ["M4 7h16", "M10 11v6", "M14 11v6", "M6 7l1 13h10l1-13", "M9 7V4h6v3"],
25
+ book: ["M4 19.5V5a2 2 0 0 1 2-2h14v16H6a2 2 0 0 0-2 2Z", "M4 19.5A2 2 0 0 0 6 21h14"],
26
+ help: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z", "M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6", "M12 17h.01"],
27
+ calendar: ["M4 5h16v16H4z", "M4 10h16", "M8 3v4", "M16 3v4"],
28
+ download: ["M12 4v11", "m7 10 5 5 5-5", "M4 20h16"],
29
+ refresh: ["M20 11a8 8 0 0 0-14.8-4L4 8.5", "M4 4v4.5h4.5", "M4 13a8 8 0 0 0 14.8 4l1.2-1.5", "M20 20v-4.5h-4.5"],
30
+ clock: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z", "M12 7v5l3 2"],
31
+ ban: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z", "m5.7 5.7 12.6 12.6"],
32
+ alert: ["M12 3 2 20h20L12 3Z", "M12 10v4", "M12 17h.01"],
33
+ lock: ["M5 11h14v10H5z", "M8 11V7a4 4 0 1 1 8 0v4"],
34
+ users: ["M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z", "M2.5 20c.6-3.4 3.2-5.5 6.5-5.5s5.9 2.1 6.5 5.5"],
35
+ filter: ["M4 5h16", "M7 12h10", "M10 19h4"],
36
+ send: ["m4 12 16-8-6 16-2.5-6.5L4 12Z"],
37
+ logout: ["M15 4h4v16h-4", "M10 16l4-4-4-4", "M14 12H4"],
38
+ menu: ["M4 7h16", "M4 12h16", "M4 17h16"],
39
+ inbox: ["M3 13h5l1.5 3h5l1.5-3h5", "M5.5 5h13L21 13v6H3v-6l2.5-8Z"],
40
+ tag: ["M3 12V4h8l10 10-8 8L3 12Z", "M7.5 8h.01"],
41
+ topic: ["M4 6h16", "M4 12h10", "M4 18h6"],
42
+ property: ["M5 4h14v16H5z", "M9 9h6", "M9 13h6", "M9 17h3"],
43
+ };
44
+
45
+ export function icon(name, size = 16) {
46
+ const svg = document.createElementNS(NS, "svg");
47
+ svg.setAttribute("viewBox", "0 0 24 24");
48
+ svg.setAttribute("width", String(size));
49
+ svg.setAttribute("height", String(size));
50
+ svg.setAttribute("fill", "none");
51
+ svg.setAttribute("stroke", "currentColor");
52
+ svg.setAttribute("stroke-width", "1.6");
53
+ svg.setAttribute("stroke-linecap", "round");
54
+ svg.setAttribute("stroke-linejoin", "round");
55
+ svg.setAttribute("aria-hidden", "true");
56
+ svg.classList.add("i");
57
+ for (const d of P[name] ?? P.more) {
58
+ const path = document.createElementNS(NS, "path");
59
+ path.setAttribute("d", d);
60
+ svg.append(path);
61
+ }
62
+ return svg;
63
+ }
64
+
65
+ export function hydrateIcons(root = document) {
66
+ for (const node of root.querySelectorAll("[data-icon]")) {
67
+ if (node.firstChild) continue;
68
+ node.append(icon(node.dataset.icon, Number(node.dataset.size ?? 16)));
69
+ }
70
+ }
@@ -0,0 +1,56 @@
1
+ <!doctype html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Emails · Resend (synthetic)</title>
7
+ <link rel="icon" type="image/svg+xml" href="./assets/resend-icon-white.svg" />
8
+ <link rel="stylesheet" href="./styles.css" />
9
+ <link rel="stylesheet" href="./views.css" />
10
+ <script type="module" src="./app.js"></script>
11
+ </head>
12
+ <body>
13
+ <div class="shell">
14
+ <aside class="sidebar" id="sidebar" aria-label="Dashboard navigation">
15
+ <div class="sidebar-top">
16
+ <a class="brand" href="#/emails" aria-label="Resend home"><img src="./assets/resend-wordmark-white.svg" alt="" width="72" height="16" /></a>
17
+ <button type="button" class="team" id="team-switcher" aria-label="Switch team" title="Switch team">
18
+ <span class="team-avatar" id="team-avatar" aria-hidden="true">N</span>
19
+ <span class="team-name" id="team-name">Loading…</span>
20
+ <span data-icon="chevronsUpDown" data-size="14" class="team-chevron"></span>
21
+ </button>
22
+ </div>
23
+ <nav class="nav" id="nav">
24
+ <a href="#/emails" data-route="emails"><span data-icon="mail"></span><span>Emails</span></a>
25
+ <button type="button" data-soon="Broadcasts"><span data-icon="broadcast"></span><span>Broadcasts</span></button>
26
+ <button type="button" data-soon="Templates"><span data-icon="template"></span><span>Templates</span></button>
27
+ <a href="#/audience/contacts" data-route="audience"><span data-icon="audience"></span><span>Audience</span></a>
28
+ <div class="nav-sub" id="audience-sub">
29
+ <a href="#/audience/contacts" data-route="contacts"><span>Contacts</span></a>
30
+ <a href="#/audience/segments" data-route="segments"><span>Segments</span></a>
31
+ <button type="button" data-soon="Topics"><span>Topics</span></button>
32
+ <button type="button" data-soon="Properties"><span>Properties</span></button>
33
+ </div>
34
+ <button type="button" data-soon="Metrics"><span data-icon="metrics"></span><span>Metrics</span></button>
35
+ <a href="#/domains" data-route="domains"><span data-icon="globe"></span><span>Domains</span></a>
36
+ <button type="button" data-soon="Logs"><span data-icon="logs"></span><span>Logs</span></button>
37
+ <a href="#/api-keys" data-route="api-keys"><span data-icon="key"></span><span>API Keys</span></a>
38
+ <button type="button" data-soon="Webhooks"><span data-icon="webhook"></span><span>Webhooks</span></button>
39
+ <button type="button" data-soon="Settings"><span data-icon="settings"></span><span>Settings</span></button>
40
+ </nav>
41
+ <div class="sidebar-foot">
42
+ <div class="usage" id="usage" aria-live="polite"></div>
43
+ <button type="button" class="foot-link" data-soon="Docs"><span data-icon="book"></span><span>Docs</span></button>
44
+ <button type="button" class="foot-link" data-soon="Help"><span data-icon="help"></span><span>Help</span></button>
45
+ <button type="button" class="user" id="user-menu" data-soon="Account"><span class="user-avatar" id="user-avatar" aria-hidden="true">P</span><span class="user-name" id="user-name">Production</span><span data-icon="more" data-size="14"></span></button>
46
+ </div>
47
+ </aside>
48
+ <main class="main" id="main" tabindex="-1">
49
+ <button type="button" class="icon-btn mobile-menu" id="mobile-menu" aria-label="Open navigation" title="Open navigation"><span data-icon="menu"></span></button>
50
+ <div id="view"></div>
51
+ </main>
52
+ </div>
53
+ <div id="dialogs"></div>
54
+ <div class="toasts" id="toasts" aria-live="polite"></div>
55
+ </body>
56
+ </html>
@@ -0,0 +1,41 @@
1
+ // Cursor paging shared by every list screen: forward with `after` = last id, back through a stack of earlier cursors.
2
+ import { button, el } from "./ui.js";
3
+
4
+ export class Cursor {
5
+ constructor() { this.reset(); }
6
+ reset() { this.stack = []; this.after = undefined; }
7
+ get page() { return this.stack.length + 1; }
8
+ args() { return this.after === undefined ? {} : { after: this.after }; }
9
+ next(lastId) { this.stack.push(this.after); this.after = lastId; }
10
+ prev() { this.after = this.stack.pop(); }
11
+ }
12
+
13
+ /** Renders "Page N" with Previous / Next controls; `onChange` re-reads the list. */
14
+ export function pagerBar(cursor, list, onChange, noun = "items") {
15
+ const prev = button("Previous", { size: "sm", iconName: "chevronLeft", attrs: { disabled: cursor.page === 1, "aria-label": `Previous page of ${noun}` }, onClick: () => { cursor.prev(); onChange(); } });
16
+ const next = button("Next", { size: "sm", attrs: { disabled: !list.has_more, "aria-label": `Next page of ${noun}` }, onClick: () => { cursor.next(list.data[list.data.length - 1].id); onChange(); } });
17
+ const count = list.data.length;
18
+ return el("div", { class: "pager" }, [
19
+ el("span", { text: count === 0 ? `Page ${cursor.page}` : `Page ${cursor.page} · ${count} ${noun}` }),
20
+ el("div", { class: "btns" }, [prev, next]),
21
+ ]);
22
+ }
23
+
24
+ /** A table with a header row; `body` is a tbody. */
25
+ export function table(headers, body, label) {
26
+ const head = el("thead", {}, [el("tr", {}, headers.map((h) => el("th", { text: h, attrs: { scope: "col" } })))]);
27
+ return el("div", { class: "table-wrap" }, [el("table", { class: "table", attrs: { "aria-label": label } }, [head, body])]);
28
+ }
29
+
30
+ /** Makes a clickable row keyboard accessible. */
31
+ export function linkRow(cells, href, label) {
32
+ const row = el("tr", { class: "row", attrs: { tabindex: "0", "aria-label": label } }, cells);
33
+ row.addEventListener("click", (event) => { if (!event.target.closest("button, a, input")) location.hash = href; });
34
+ row.addEventListener("keydown", (event) => { if (event.key === "Enter" && event.target === row) location.hash = href; });
35
+ return row;
36
+ }
37
+
38
+ export function debounce(fn, ms = 250) {
39
+ let timer;
40
+ return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
41
+ }
@@ -0,0 +1,67 @@
1
+ @font-face { font-family: "Inter"; src: url("./assets/fonts/inter-latin-wght-normal.woff2") format("woff2"); font-weight: 100 900; font-style: normal; font-display: swap; }
2
+ @font-face { font-family: "JetBrains Mono"; src: url("./assets/fonts/jetbrains-mono-latin-400-normal.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; }
3
+
4
+ :root {
5
+ --bg: #000000;
6
+ --panel: #0a0a0a;
7
+ --elev: #111113;
8
+ --hover: rgba(255, 255, 255, 0.05);
9
+ --active: rgba(255, 255, 255, 0.08);
10
+ --border: rgba(255, 255, 255, 0.08);
11
+ --border-strong: rgba(255, 255, 255, 0.14);
12
+ --text: #eeeef0;
13
+ --muted: #a1a1aa;
14
+ --faint: #6e6e76;
15
+ --green: #3dd68c; --green-bg: rgba(61, 214, 140, 0.1);
16
+ --red: #ff6369; --red-bg: rgba(255, 99, 105, 0.1);
17
+ --amber: #ffca16; --amber-bg: rgba(255, 202, 22, 0.1);
18
+ --blue: #70b8ff; --blue-bg: rgba(112, 184, 255, 0.1);
19
+ --gray-bg: rgba(255, 255, 255, 0.07);
20
+ --radius: 8px;
21
+ --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
22
+ color-scheme: dark;
23
+ }
24
+ * { box-sizing: border-box; }
25
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); }
26
+ body { font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.45; -webkit-font-smoothing: antialiased; font-feature-settings: "cv11", "ss01"; }
27
+ a { color: inherit; text-decoration: none; }
28
+ button, input, select, textarea { font: inherit; color: inherit; }
29
+ .i { flex: none; display: block; }
30
+ [hidden] { display: none !important; }
31
+ :focus-visible { outline: 2px solid rgba(255, 255, 255, 0.5); outline-offset: 1px; }
32
+ .mono { font-family: var(--mono); font-size: 12.5px; }
33
+ .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
34
+
35
+ .shell { display: grid; grid-template-columns: 240px 1fr; height: 100vh; }
36
+ .sidebar { display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--bg); padding: 14px 10px 10px; min-height: 0; }
37
+ .sidebar-top { display: flex; flex-direction: column; gap: 14px; padding: 4px 6px 12px; }
38
+ .brand { display: inline-flex; align-items: center; height: 24px; padding-left: 2px; }
39
+ .brand img { height: 18px; width: auto; }
40
+ .team { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; margin-left: -2px; border: 0; border-radius: 6px; background: transparent; cursor: pointer; text-align: left; }
41
+ .team:hover { background: var(--hover); }
42
+ .team-avatar, .user-avatar { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 5px; background: linear-gradient(135deg, #3a3a40, #1c1c20); border: 1px solid var(--border-strong); font-size: 11px; font-weight: 600; flex: none; }
43
+ .user-avatar { border-radius: 999px; }
44
+ .team-name { flex: 1; min-width: 0; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
45
+ .team-plan { font-size: 11px; color: var(--muted); border: 1px solid var(--border-strong); border-radius: 999px; padding: 0 6px; line-height: 17px; }
46
+ .team-chevron { margin-left: auto; color: var(--muted); }
47
+
48
+ .nav { display: flex; flex-direction: column; gap: 1px; overflow-y: auto; }
49
+ .nav > a, .nav > button, .nav-sub > a, .nav-sub > button, .foot-link { display: flex; align-items: center; gap: 10px; height: 32px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 14px; cursor: pointer; text-align: left; width: 100%; }
50
+ .nav > a:hover, .nav > button:hover, .nav-sub > a:hover, .nav-sub > button:hover, .foot-link:hover { background: var(--hover); color: var(--text); }
51
+ .nav a.active { background: var(--active); color: var(--text); }
52
+ .nav-sub { display: flex; flex-direction: column; gap: 1px; margin: 1px 0 2px 18px; padding-left: 9px; border-left: 1px solid var(--border); }
53
+ .nav-sub > a, .nav-sub > button { height: 30px; font-size: 13.5px; }
54
+ .sidebar-foot { margin-top: auto; display: flex; flex-direction: column; gap: 1px; padding-top: 10px; }
55
+ .usage { margin: 0 4px 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 12px; color: var(--muted); }
56
+ .usage:empty { display: none; }
57
+ .usage strong { color: var(--text); font-weight: 500; }
58
+ .meter { height: 4px; border-radius: 99px; background: var(--gray-bg); margin-top: 8px; overflow: hidden; }
59
+ .meter span { display: block; height: 100%; background: var(--text); border-radius: 99px; }
60
+ .user { display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 8px; margin-top: 6px; border: 0; border-top: 1px solid var(--border); border-radius: 0; background: transparent; cursor: pointer; color: var(--muted); }
61
+ .user:hover { color: var(--text); }
62
+ .user-name { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
63
+
64
+ .main { overflow-y: auto; min-width: 0; position: relative; }
65
+ .icon-btn.mobile-menu { display: none; }
66
+ #view { max-width: 1200px; margin: 0 auto; padding: 36px 40px 64px; }
67
+ .usage-plan { color: var(--text); font-weight: 500; margin-bottom: 4px; }
@@ -0,0 +1,259 @@
1
+ // Shared helpers: safe DOM building, operation calls, dialogs, toasts, virtual-time formatting and revision watching.
2
+ import { getContext, invoke } from "/_firedrill/client.js";
3
+ import { icon } from "./icons.js";
4
+
5
+ export const $ = (selector, root = document) => root.querySelector(selector);
6
+
7
+ /** el("div", { class, text, attrs, on }, children) — record text only ever goes through textContent. */
8
+ export function el(tag, options = {}, children = []) {
9
+ const node = document.createElement(tag);
10
+ if (options.class) node.className = options.class;
11
+ if (options.text !== undefined && options.text !== null) node.textContent = String(options.text);
12
+ for (const [name, value] of Object.entries(options.attrs ?? {})) {
13
+ if (value === false || value === undefined || value === null) continue;
14
+ node.setAttribute(name, value === true ? "" : String(value));
15
+ }
16
+ for (const [type, listener] of Object.entries(options.on ?? {})) node.addEventListener(type, listener);
17
+ for (const child of [].concat(children)) {
18
+ if (child === null || child === undefined || child === false) continue;
19
+ node.append(typeof child === "string" ? document.createTextNode(child) : child);
20
+ }
21
+ return node;
22
+ }
23
+
24
+ export function button(label, { variant = "secondary", iconName, onClick, attrs = {}, size = "" } = {}) {
25
+ const node = el("button", { class: `btn btn-${variant} ${size}`.trim(), attrs: { type: "button", ...attrs }, on: onClick ? { click: onClick } : {} });
26
+ if (iconName) node.append(icon(iconName, 14));
27
+ if (label) node.append(el("span", { text: label }));
28
+ return node;
29
+ }
30
+
31
+ export class ToolError extends Error {
32
+ constructor(status, code, message) {
33
+ super(message);
34
+ this.status = status;
35
+ this.code = String(code ?? "").replace(/^tool\./, "");
36
+ }
37
+ get denied() { return this.status === "denied" || this.status === "permission_denied" || this.code === "PERMISSION_DENIED"; }
38
+ }
39
+
40
+ export async function call(operationId, args = {}, idempotencyKey) {
41
+ const result = await invoke(operationId, args, idempotencyKey ? { idempotencyKey } : {});
42
+ const outcome = result?.outcome ?? {};
43
+ if (outcome.status !== "ok") {
44
+ const error = outcome.error ?? {};
45
+ throw new ToolError(outcome.status, error.code ?? outcome.status, error.message ?? `The request was ${outcome.status}.`);
46
+ }
47
+ return outcome.value;
48
+ }
49
+
50
+ /** A mutation: fresh idempotency key per user action; callers reuse `run.key` only to retry the same action. */
51
+ export function mutate(operationId, args = {}) {
52
+ const idempotencyKey = crypto.randomUUID();
53
+ return call(operationId, { ...args, idempotencyKey }, idempotencyKey);
54
+ }
55
+
56
+ export function describe(error) {
57
+ if (error instanceof ToolError) {
58
+ if (error.denied) return "You don't have permission to perform this action with the current actor.";
59
+ if (error.code === "RATE_LIMIT_EXCEEDED") return "Too many requests. Please slow down and try again in a second.";
60
+ if (error.code === "RESTRICTED_API_KEY") return "This API key is restricted to only send emails.";
61
+ if (error.code === "INVALID_API_KEY") return "API key is invalid.";
62
+ if (error.code === "APPLICATION_ERROR") return "An unexpected error occurred. Please try again.";
63
+ return error.message;
64
+ }
65
+ return error?.message ?? "Something went wrong.";
66
+ }
67
+
68
+ // ---- toasts --------------------------------------------------------------------------------
69
+ export function toast(text, { error = false } = {}) {
70
+ const host = $("#toasts");
71
+ const node = el("div", { class: `toast${error ? " toast-error" : ""}`, attrs: { role: error ? "alert" : "status" } }, [icon(error ? "alert" : "check", 14), el("span", { text })]);
72
+ host.append(node);
73
+ setTimeout(() => node.remove(), 5000);
74
+ }
75
+
76
+ export async function copyText(text) {
77
+ try {
78
+ await navigator.clipboard.writeText(text);
79
+ toast("Copied to clipboard");
80
+ } catch {
81
+ toast("Copy is not available in this browser", { error: true });
82
+ }
83
+ }
84
+
85
+ export function copyButton(text, label = "Copy") {
86
+ const node = el("button", { class: "icon-btn copy", attrs: { type: "button", "aria-label": label, title: label }, on: { click: (event) => { event.stopPropagation(); void copyText(text); } } });
87
+ node.append(icon("copy", 14));
88
+ return node;
89
+ }
90
+
91
+ // ---- dialogs -------------------------------------------------------------------------------
92
+ /** Opens a modal. `build(close)` returns the body nodes; resolves with the value passed to close(). */
93
+ export function modal(title, build, { description, wide = false } = {}) {
94
+ return new Promise((resolve) => {
95
+ const previous = document.activeElement;
96
+ const titleId = `dlg-${crypto.randomUUID()}`;
97
+ const overlay = el("div", { class: "overlay" });
98
+ const box = el("div", { class: `dialog${wide ? " dialog-wide" : ""}`, attrs: { role: "dialog", "aria-modal": "true", "aria-labelledby": titleId } });
99
+ const close = (value) => {
100
+ overlay.remove();
101
+ document.removeEventListener("keydown", onKey, true);
102
+ previous?.focus?.();
103
+ resolve(value);
104
+ };
105
+ const onKey = (event) => { if (event.key === "Escape") { event.preventDefault(); close(undefined); } };
106
+ const head = el("div", { class: "dialog-head" }, [el("h2", { text: title, attrs: { id: titleId } })]);
107
+ const x = el("button", { class: "icon-btn", attrs: { type: "button", "aria-label": "Close", title: "Close" }, on: { click: () => close(undefined) } });
108
+ x.append(icon("x", 16));
109
+ head.append(x);
110
+ box.append(head);
111
+ if (description) box.append(el("p", { class: "dialog-desc", text: description }));
112
+ box.append(...[].concat(build(close)));
113
+ overlay.append(box);
114
+ overlay.addEventListener("mousedown", (event) => { if (event.target === overlay) close(undefined); });
115
+ document.addEventListener("keydown", onKey, true);
116
+ $("#dialogs").append(overlay);
117
+ (box.querySelector("input, select, textarea, .btn-primary, .btn-danger") ?? x).focus();
118
+ });
119
+ }
120
+
121
+ export function confirmDialog(title, text, okLabel, { danger = true } = {}) {
122
+ return modal(title, (close) => [
123
+ el("p", { class: "dialog-text", text }),
124
+ el("div", { class: "dialog-actions" }, [
125
+ button("Cancel", { onClick: () => close(false) }),
126
+ button(okLabel, { variant: danger ? "danger" : "primary", onClick: () => close(true) }),
127
+ ]),
128
+ ]);
129
+ }
130
+
131
+ export function field(labelText, control, hint) {
132
+ const id = control.id || `f-${crypto.randomUUID()}`;
133
+ control.id = id;
134
+ return el("div", { class: "field" }, [el("label", { text: labelText, attrs: { for: id } }), control, hint ? el("p", { class: "hint", text: hint }) : null]);
135
+ }
136
+
137
+ // ---- virtual time (never the browser clock) -------------------------------------------------
138
+ export const clock = { nowMs: 0, set(iso) { const ms = Date.parse(iso); if (Number.isFinite(ms)) this.nowMs = ms; } };
139
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
140
+
141
+ function parse(value) {
142
+ if (typeof value !== "string" || value.length === 0) return NaN;
143
+ // Resend timestamps carry an offset ("+00"); zone-less text is treated as UTC.
144
+ const text = /(Z|[+-]\d\d(:?\d\d)?)$/.test(value) ? value.replace(/([+-]\d\d)$/, "$1:00") : `${value}Z`;
145
+ return Date.parse(text.replace(" ", "T"));
146
+ }
147
+
148
+ export function relative(value) {
149
+ const ms = parse(value);
150
+ if (!Number.isFinite(ms)) return "—";
151
+ const diff = clock.nowMs - ms;
152
+ const future = diff < 0;
153
+ const s = Math.abs(diff) / 1000;
154
+ let text;
155
+ if (s < 45) text = "less than a minute";
156
+ else if (s < 90) text = "1 minute";
157
+ else if (s < 2700) text = `${Math.round(s / 60)} minutes`;
158
+ else if (s < 5400) text = "about 1 hour";
159
+ else if (s < 86400) text = `about ${Math.round(s / 3600)} hours`;
160
+ else if (s < 172800) text = "1 day";
161
+ else if (s < 2592000) text = `${Math.round(s / 86400)} days`;
162
+ else if (s < 5184000) text = "about 1 month";
163
+ else if (s < 31536000) text = `${Math.round(s / 2592000)} months`;
164
+ else text = `about ${Math.round(s / 31536000)} years`;
165
+ return future ? `in ${text}` : `${text} ago`;
166
+ }
167
+
168
+ export function formatDate(value, { time = true } = {}) {
169
+ const ms = parse(value);
170
+ if (!Number.isFinite(ms)) return "—";
171
+ const d = new Date(ms);
172
+ const day = `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}${d.getUTCFullYear() !== new Date(clock.nowMs).getUTCFullYear() ? `, ${d.getUTCFullYear()}` : ""}`;
173
+ if (!time) return day;
174
+ const h = d.getUTCHours();
175
+ const m = String(d.getUTCMinutes()).padStart(2, "0");
176
+ return `${day}, ${((h + 11) % 12) + 1}:${m} ${h < 12 ? "AM" : "PM"} UTC`;
177
+ }
178
+
179
+ export function timeCell(value) {
180
+ return el("time", { text: relative(value), attrs: { datetime: value ?? "", title: formatDate(value) } });
181
+ }
182
+
183
+ // ---- badges ----------------------------------------------------------------------------------
184
+ const TONES = {
185
+ delivered: "green", verified: "green", subscribed: "green", full_access: "gray", sending_access: "gray",
186
+ bounced: "red", failed: "red", complained: "red", temporary_failure: "amber", suppressed: "red",
187
+ scheduled: "blue", queued: "gray", sent: "gray", canceled: "gray", not_started: "gray", pending: "amber",
188
+ temporary_failure_domain: "amber", unsubscribed: "gray", opened: "green", clicked: "green", delivery_delayed: "amber",
189
+ };
190
+ export const labelFor = (value) => {
191
+ const text = String(value ?? "unknown").replace(/_/g, " ");
192
+ return text.charAt(0).toUpperCase() + text.slice(1);
193
+ };
194
+ export function badge(value, text) {
195
+ return el("span", { class: `badge tone-${TONES[value] ?? "gray"}`, text: text ?? labelFor(value) });
196
+ }
197
+
198
+ // ---- states ----------------------------------------------------------------------------------
199
+ export function emptyState(title, text, action) {
200
+ return el("div", { class: "empty" }, [el("h3", { text: title }), text ? el("p", { text }) : null, action ?? null]);
201
+ }
202
+
203
+ export function errorState(error, retry) {
204
+ const denied = error instanceof ToolError && (error.denied || error.code === "RESTRICTED_API_KEY" || error.code === "INVALID_API_KEY");
205
+ const box = el("div", { class: `callout ${denied ? "callout-warn" : "callout-error"}`, attrs: { role: "alert" } }, [
206
+ icon(denied ? "lock" : "alert", 16),
207
+ el("div", {}, [el("strong", { text: denied ? "Access denied" : "Something went wrong" }), el("p", { text: describe(error) })]),
208
+ ]);
209
+ if (retry) box.append(button("Try again", { size: "sm", iconName: "refresh", onClick: retry }));
210
+ return box;
211
+ }
212
+
213
+ export function skeletonRows(columns, rows = 8) {
214
+ const body = el("tbody", { attrs: { "aria-busy": "true" } });
215
+ for (let r = 0; r < rows; r += 1) {
216
+ body.append(el("tr", { class: "skeleton" }, Array.from({ length: columns }, () => el("td", {}, [el("span", { class: "sk" })]))));
217
+ }
218
+ return body;
219
+ }
220
+
221
+ export function notSimulated(name, detail) {
222
+ return modal(name, (close) => [
223
+ el("p", { class: "dialog-text", text: detail ?? `${name} is part of the dashboard but is not simulated by this Tool. Nothing here reads or changes the synthetic team.` }),
224
+ el("div", { class: "dialog-actions" }, [button("Close", { variant: "primary", onClick: () => close() })]),
225
+ ]);
226
+ }
227
+
228
+ // ---- world revision --------------------------------------------------------------------------
229
+ let busy = 0;
230
+ export async function guard(task) {
231
+ busy += 1;
232
+ try { return await task(); } finally { busy -= 1; }
233
+ }
234
+
235
+ /** Polls getContext(); calls refresh() when the revision moves and no action or open dialog would be disturbed. */
236
+ export function watchWorld(refresh) {
237
+ let stamp;
238
+ let checking = false;
239
+ const check = async () => {
240
+ if (checking || busy > 0 || document.visibilityState !== "visible") return;
241
+ checking = true;
242
+ try {
243
+ const context = await getContext();
244
+ const next = JSON.stringify(context.revision);
245
+ if (stamp !== next) {
246
+ const first = stamp === undefined;
247
+ stamp = next;
248
+ if (!first && !$("#dialogs").hasChildNodes()) await refresh();
249
+ }
250
+ } catch {
251
+ // The local environment went away; the next successful poll resumes.
252
+ } finally {
253
+ checking = false;
254
+ }
255
+ };
256
+ const timer = setInterval(() => void check(), 2000);
257
+ window.addEventListener("pagehide", () => clearInterval(timer), { once: true });
258
+ void check();
259
+ }