@groundmail/groundhog 0.0.0-stage → 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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +130 -0
  5. package/dist/client.d.ts +55 -0
  6. package/dist/client.js +90 -0
  7. package/dist/extract.d.ts +22 -0
  8. package/dist/extract.js +116 -0
  9. package/dist/faults.d.ts +24 -0
  10. package/dist/faults.js +64 -0
  11. package/dist/groundmail-account.d.ts +50 -0
  12. package/dist/groundmail-account.js +233 -0
  13. package/dist/http.d.ts +58 -0
  14. package/dist/http.js +152 -0
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.js +2 -0
  17. package/dist/mcp.d.ts +11 -0
  18. package/dist/mcp.js +156 -0
  19. package/dist/mime.d.ts +52 -0
  20. package/dist/mime.js +182 -0
  21. package/dist/providers/common.d.ts +14 -0
  22. package/dist/providers/common.js +68 -0
  23. package/dist/providers/groundmail.d.ts +31 -0
  24. package/dist/providers/groundmail.js +169 -0
  25. package/dist/providers/index.d.ts +12 -0
  26. package/dist/providers/index.js +10 -0
  27. package/dist/providers/mailgun.d.ts +3 -0
  28. package/dist/providers/mailgun.js +150 -0
  29. package/dist/providers/postmark.d.ts +3 -0
  30. package/dist/providers/postmark.js +147 -0
  31. package/dist/providers/resend.d.ts +8 -0
  32. package/dist/providers/resend.js +121 -0
  33. package/dist/providers/sendgrid.d.ts +3 -0
  34. package/dist/providers/sendgrid.js +128 -0
  35. package/dist/providers/ses.d.ts +3 -0
  36. package/dist/providers/ses.js +244 -0
  37. package/dist/providers/types.d.ts +35 -0
  38. package/dist/providers/types.js +1 -0
  39. package/dist/server.d.ts +38 -0
  40. package/dist/server.js +450 -0
  41. package/dist/signing.d.ts +25 -0
  42. package/dist/signing.js +47 -0
  43. package/dist/smtp.d.ts +13 -0
  44. package/dist/smtp.js +70 -0
  45. package/dist/store.d.ts +35 -0
  46. package/dist/store.js +171 -0
  47. package/dist/types.d.ts +48 -0
  48. package/dist/types.js +12 -0
  49. package/dist/ui/app.css +214 -0
  50. package/dist/ui/app.js +621 -0
  51. package/dist/ui/favicon.svg +1 -0
  52. package/dist/ui/index.html +108 -0
  53. package/dist/webhooks.d.ts +72 -0
  54. package/dist/webhooks.js +102 -0
  55. package/package.json +88 -4
package/dist/store.js ADDED
@@ -0,0 +1,171 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { parseEmail } from './mime.js';
5
+ export function publicEmail(email) {
6
+ const { raw: _raw, attachmentContent: _content, ...rest } = email;
7
+ return rest;
8
+ }
9
+ function lower(value) {
10
+ return (value ?? '').toLowerCase();
11
+ }
12
+ export function matches(email, filter) {
13
+ if (filter.to) {
14
+ const want = lower(filter.to);
15
+ const all = [...email.to, ...email.cc, ...email.bcc].map((a) => lower(a.address));
16
+ if (!all.some((address) => address === want || (want.startsWith('@') && address.endsWith(want)) || (!want.includes('@') && address.includes(want))))
17
+ return false;
18
+ }
19
+ if (filter.from && !lower(email.from?.address).includes(lower(filter.from)))
20
+ return false;
21
+ if (filter.subject && !lower(email.subject).includes(lower(filter.subject)))
22
+ return false;
23
+ if (filter.via && email.via !== filter.via)
24
+ return false;
25
+ if (filter.since) {
26
+ const since = new Date(filter.since).getTime();
27
+ if (!Number.isNaN(since) && new Date(email.createdAt).getTime() < since)
28
+ return false;
29
+ }
30
+ if (filter.q) {
31
+ const q = lower(filter.q);
32
+ const hay = [
33
+ email.subject,
34
+ email.from?.address,
35
+ email.from?.name,
36
+ ...email.to.map((a) => `${a.name} ${a.address}`),
37
+ ...email.cc.map((a) => a.address),
38
+ email.text,
39
+ email.code,
40
+ ]
41
+ .map(lower)
42
+ .join('\n');
43
+ if (!hay.includes(q))
44
+ return false;
45
+ }
46
+ return true;
47
+ }
48
+ /** In-memory inbox, newest first. Optionally saved to a JSON file. */
49
+ export class EmailStore extends EventEmitter {
50
+ options;
51
+ emails = [];
52
+ saveTimer = null;
53
+ constructor(options = {}) {
54
+ super();
55
+ this.options = options;
56
+ this.setMaxListeners(0);
57
+ }
58
+ get max() {
59
+ return this.options.max ?? 1000;
60
+ }
61
+ async load() {
62
+ const file = this.options.file;
63
+ if (!file || !existsSync(file))
64
+ return;
65
+ const data = JSON.parse(readFileSync(file, 'utf8'));
66
+ const loaded = [];
67
+ for (const entry of data.emails ?? []) {
68
+ loaded.push(await parseEmail(Buffer.from(entry.raw, 'base64'), {
69
+ id: entry.id,
70
+ via: entry.via,
71
+ providerMessageId: entry.providerMessageId,
72
+ createdAt: entry.createdAt,
73
+ tags: entry.tags,
74
+ metadata: entry.metadata,
75
+ bcc: entry.bcc,
76
+ }));
77
+ }
78
+ this.emails = loaded;
79
+ }
80
+ scheduleSave() {
81
+ const file = this.options.file;
82
+ if (!file)
83
+ return;
84
+ if (this.saveTimer)
85
+ clearTimeout(this.saveTimer);
86
+ this.saveTimer = setTimeout(() => this.saveNow(), 200);
87
+ this.saveTimer.unref?.();
88
+ }
89
+ saveNow() {
90
+ const file = this.options.file;
91
+ if (!file)
92
+ return;
93
+ if (this.saveTimer)
94
+ clearTimeout(this.saveTimer);
95
+ this.saveTimer = null;
96
+ const data = {
97
+ version: 1,
98
+ emails: this.emails.map((email) => ({
99
+ id: email.id,
100
+ via: email.via,
101
+ providerMessageId: email.providerMessageId,
102
+ createdAt: email.createdAt,
103
+ tags: email.tags,
104
+ metadata: email.metadata,
105
+ bcc: email.bcc,
106
+ raw: email.raw.toString('base64'),
107
+ })),
108
+ };
109
+ mkdirSync(dirname(file), { recursive: true });
110
+ writeFileSync(`${file}.tmp`, JSON.stringify(data));
111
+ renameSync(`${file}.tmp`, file);
112
+ }
113
+ add(email) {
114
+ this.emails.unshift(email);
115
+ if (this.emails.length > this.max)
116
+ this.emails.length = this.max;
117
+ this.scheduleSave();
118
+ this.emit('email', email);
119
+ return email;
120
+ }
121
+ list(filter = {}, limit = 100) {
122
+ return this.emails.filter((email) => matches(email, filter)).slice(0, limit);
123
+ }
124
+ count() {
125
+ return this.emails.length;
126
+ }
127
+ get(id) {
128
+ return this.emails.find((email) => email.id === id || email.providerMessageId === id);
129
+ }
130
+ latest(filter = {}) {
131
+ return this.emails.find((email) => matches(email, filter));
132
+ }
133
+ delete(id) {
134
+ const before = this.emails.length;
135
+ this.emails = this.emails.filter((email) => email.id !== id);
136
+ const removed = this.emails.length !== before;
137
+ if (removed) {
138
+ this.scheduleSave();
139
+ this.emit('delete', id);
140
+ }
141
+ return removed;
142
+ }
143
+ clear() {
144
+ this.emails = [];
145
+ this.scheduleSave();
146
+ this.emit('clear');
147
+ }
148
+ /** Resolve with the first email (already caught after `since`, or arriving later) that matches. */
149
+ waitFor(filter, timeoutMs, signal) {
150
+ const existing = this.latest(filter);
151
+ if (existing)
152
+ return Promise.resolve(existing);
153
+ return new Promise((resolve) => {
154
+ const onEmail = (email) => {
155
+ if (!matches(email, filter))
156
+ return;
157
+ done(email);
158
+ };
159
+ const timer = setTimeout(() => done(null), Math.max(0, timeoutMs));
160
+ const onAbort = () => done(null);
161
+ const done = (email) => {
162
+ clearTimeout(timer);
163
+ this.off('email', onEmail);
164
+ signal?.removeEventListener('abort', onAbort);
165
+ resolve(email);
166
+ };
167
+ this.on('email', onEmail);
168
+ signal?.addEventListener('abort', onAbort);
169
+ });
170
+ }
171
+ }
@@ -0,0 +1,48 @@
1
+ export type Provider = 'smtp' | 'groundmail' | 'resend' | 'sendgrid' | 'postmark' | 'mailgun' | 'ses';
2
+ export declare const PROVIDERS: Provider[];
3
+ export declare const PROVIDER_LABELS: Record<Provider, string>;
4
+ export type Address = {
5
+ name: string;
6
+ address: string;
7
+ };
8
+ export type AttachmentInfo = {
9
+ id: string;
10
+ filename: string;
11
+ contentType: string;
12
+ size: number;
13
+ contentId?: string;
14
+ inline: boolean;
15
+ };
16
+ /** An email Groundhog caught, as the JSON API returns it. */
17
+ export type Email = {
18
+ id: string;
19
+ /** The id the sending API returned to the app (a Postmark MessageID, a SendGrid X-Message-Id, …). */
20
+ providerMessageId: string;
21
+ via: Provider;
22
+ from: Address | null;
23
+ to: Address[];
24
+ cc: Address[];
25
+ bcc: Address[];
26
+ replyTo: Address[];
27
+ subject: string;
28
+ html: string | null;
29
+ text: string | null;
30
+ headers: Array<[string, string]>;
31
+ attachments: AttachmentInfo[];
32
+ tags: string[];
33
+ metadata: Record<string, string>;
34
+ createdAt: string;
35
+ size: number;
36
+ /** A one-time code found in the email, if any. */
37
+ code: string | null;
38
+ /** Every http(s) link in the email, in order. */
39
+ links: string[];
40
+ };
41
+ export type StoredEmail = Email & {
42
+ raw: Buffer;
43
+ attachmentContent: Map<string, Buffer>;
44
+ };
45
+ export type WebhookEvent = 'delivered' | 'bounced' | 'complained' | 'opened' | 'clicked';
46
+ export declare const WEBHOOK_EVENTS: WebhookEvent[];
47
+ export type FaultKind = 'rate_limit' | 'invalid_recipient' | 'unauthorized' | 'outage' | 'timeout';
48
+ export declare const FAULT_KINDS: FaultKind[];
package/dist/types.js ADDED
@@ -0,0 +1,12 @@
1
+ export const PROVIDERS = ['smtp', 'groundmail', 'sendgrid', 'postmark', 'mailgun', 'resend', 'ses'];
2
+ export const PROVIDER_LABELS = {
3
+ smtp: 'SMTP',
4
+ groundmail: 'Groundmail API',
5
+ sendgrid: 'SendGrid API',
6
+ postmark: 'Postmark API',
7
+ mailgun: 'Mailgun API',
8
+ resend: 'Resend API',
9
+ ses: 'Amazon SES API',
10
+ };
11
+ export const WEBHOOK_EVENTS = ['delivered', 'bounced', 'complained', 'opened', 'clicked'];
12
+ export const FAULT_KINDS = ['rate_limit', 'invalid_recipient', 'unauthorized', 'outage', 'timeout'];
@@ -0,0 +1,214 @@
1
+ :root {
2
+ --bg: #14100c;
3
+ --panel: #1c1712;
4
+ --raised: #2a221a;
5
+ --well: #100c09;
6
+ --code: #0d0a07;
7
+ --line: #33291f;
8
+ --line-strong: #4a3d30;
9
+ --ink: #eee5d4;
10
+ --ink-soft: #c9bba8;
11
+ --muted: #a8988a;
12
+ --faint: #7d6e5f;
13
+ --accent: #e27d4e;
14
+ --accent-hover: #ec9168;
15
+ --accent-ink: #1a110a;
16
+ --terracotta: #a3461f;
17
+ --fur: #8b5a33;
18
+ --belly: #e8c9a0;
19
+ --moss: #9cb67f;
20
+ --moss-bg: #1e2617;
21
+ --chip: #2a241d;
22
+ --amber: #e0a84a;
23
+ --amber-bg: #2e2210;
24
+ --amber-line: #5a4316;
25
+ --amber-ink: #f0d6a8;
26
+ --red: #ef7a6e;
27
+ --red-bg: #2c1714;
28
+ --mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
29
+ --sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', Roboto, system-ui, sans-serif;
30
+ color-scheme: dark;
31
+ }
32
+
33
+ * { box-sizing: border-box; }
34
+ html, body { margin: 0; height: 100%; }
35
+ body { background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
36
+ a { color: var(--accent); }
37
+ a:hover { color: var(--accent-hover); }
38
+ button, input, select, textarea { font: inherit; color: inherit; }
39
+ button { cursor: pointer; }
40
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
41
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
42
+ .mono { font-family: var(--mono); }
43
+ [hidden] { display: none !important; }
44
+
45
+ .app { min-height: 100vh; display: flex; flex-direction: column; }
46
+
47
+ /* Header */
48
+ .top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; padding: 12px 24px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 5; }
49
+ .brand { display: flex; align-items: center; gap: 10px; }
50
+ .brand-name { font-size: 16px; font-weight: 700; letter-spacing: -0.2px; }
51
+ .brand-from { font-size: 12px; color: var(--muted); }
52
+ .brand-from a { color: var(--muted); }
53
+ .top-right { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
54
+ .pill { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 12px; color: var(--muted); background: var(--well); border: 1px solid var(--line); border-radius: 6px; padding: 6px 10px; }
55
+ .dot { width: 7px; height: 7px; border-radius: 9999px; background: var(--moss); flex-shrink: 0; }
56
+ .dot.off { background: var(--faint); }
57
+ .dot.warn { background: var(--amber); }
58
+
59
+ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 36px; padding: 0 14px; border: 1px solid var(--line-strong); border-radius: 6px; background: transparent; color: var(--ink); text-decoration: none; font-size: 14px; font-weight: 500; white-space: nowrap; }
60
+ .btn:hover { background: var(--raised); color: var(--ink); }
61
+ .btn.small { min-height: 30px; padding: 0 10px; font-size: 13px; }
62
+ .btn.solid { background: var(--raised); }
63
+ .btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
64
+ .btn.primary:hover { background: var(--accent-hover); color: var(--accent-ink); }
65
+ .btn.warn { border-color: var(--amber-line); color: var(--amber-ink); }
66
+ .btn.danger:hover { border-color: var(--red); color: var(--red); background: transparent; }
67
+ .btn[disabled] { opacity: 0.5; cursor: default; }
68
+ .btn.on { border-color: var(--accent); background: var(--raised); }
69
+
70
+ /* Failure banner */
71
+ .banner { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; padding: 10px 24px; background: var(--amber-bg); border-bottom: 1px solid var(--amber-line); color: var(--amber-ink); font-size: 13px; }
72
+ .banner .dot { background: var(--amber); width: 8px; height: 8px; }
73
+ .banner strong { font-family: var(--mono); }
74
+ .banner .btn { border-color: var(--amber-line); color: var(--amber-ink); }
75
+
76
+ /* Layout */
77
+ .main { flex: 1 1 auto; display: flex; min-height: 0; }
78
+ .list { flex: 0 0 380px; max-width: 420px; border-right: 1px solid var(--line); display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
79
+ .list-tools { display: flex; gap: 8px; padding: 14px 16px; border-bottom: 1px solid var(--line); }
80
+ .input { height: 36px; background: var(--well); border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink); padding: 0 12px; font-size: 14px; min-width: 0; }
81
+ .input::placeholder { color: var(--faint); }
82
+ .list-tools .input { flex: 1 1 auto; }
83
+ .list-count { padding: 10px 16px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--line); }
84
+ .rows { overflow-y: auto; flex: 1 1 auto; }
85
+ .row { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; width: 100%; text-align: left; padding: 12px 16px; border: 0; border-bottom: 1px solid var(--line); background: transparent; }
86
+ .row:hover { background: #1f1913; }
87
+ .row.active { background: var(--raised); box-shadow: inset 3px 0 0 var(--accent); }
88
+ .row.new { animation: arrive 1.2s ease-out; }
89
+ @keyframes arrive { from { background: #3a2a1c; } to { background: transparent; } }
90
+ .row-top { display: flex; justify-content: space-between; gap: 8px; width: 100%; }
91
+ .row-subject { font-size: 14px; font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
92
+ .row-time { font-size: 12px; color: var(--muted); white-space: nowrap; }
93
+ .row-to { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%; }
94
+ .tags { display: flex; flex-wrap: wrap; gap: 6px; }
95
+ .tag { font-family: var(--mono); font-size: 11px; color: var(--ink-soft); background: var(--chip); border-radius: 4px; padding: 2px 6px; }
96
+ .tag.code { color: var(--moss); background: var(--moss-bg); }
97
+ .tag.att { color: var(--muted); }
98
+
99
+ .view { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
100
+ .view-head { padding: 20px 28px 16px; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; }
101
+ .view-title { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: flex-start; }
102
+ .view-title h1 { margin: 0; font-size: 20px; line-height: 28px; font-weight: 600; overflow-wrap: anywhere; }
103
+ .view-actions { display: flex; flex-wrap: wrap; gap: 6px; }
104
+ .meta { display: grid; grid-template-columns: 72px minmax(0, 1fr); row-gap: 4px; font-size: 13px; }
105
+ .meta dt { color: var(--muted); }
106
+ .meta dd { margin: 0; font-family: var(--mono); overflow-wrap: anywhere; }
107
+ .meta dd.plain { font-family: var(--sans); }
108
+ .chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
109
+ .chip { text-decoration: none; display: inline-flex; align-items: center; gap: 8px; border-radius: 6px; padding: 6px 10px; font-size: 13px; background: var(--chip); color: var(--ink-soft); border: 0; max-width: 100%; }
110
+ .chip.code { background: var(--moss-bg); color: var(--moss); }
111
+ .chip strong { font-family: var(--mono); letter-spacing: 1px; }
112
+ .chip .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
113
+ button.chip:hover { filter: brightness(1.15); }
114
+
115
+ .view-bar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; padding: 10px 28px; border-bottom: 1px solid var(--line); }
116
+ .seg { display: flex; flex-wrap: wrap; gap: 4px; }
117
+ .seg button { min-height: 32px; padding: 0 10px; border-radius: 6px; font-size: 13px; font-weight: 500; background: transparent; color: var(--muted); border: 1px solid var(--line-strong); }
118
+ .seg button[aria-selected='true'], .seg button[aria-pressed='true'] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
119
+
120
+ .stage { flex: 1 1 auto; padding: 28px; display: flex; justify-content: center; align-items: flex-start; background: var(--well); overflow: auto; min-height: 420px; }
121
+ .frame-wrap { width: 100%; max-width: 760px; border-radius: 10px; overflow: hidden; background: #fff; transition: max-width .2s; }
122
+ .frame-wrap.mobile { max-width: 375px; }
123
+ .frame-wrap.dark { background: #1f1f22; }
124
+ .frame-wrap iframe { display: block; width: 100%; border: 0; min-height: 520px; background: transparent; }
125
+ pre.raw { margin: 0; width: 100%; max-width: 900px; background: var(--code); color: #eadfcc; border-radius: 8px; padding: 18px; font-family: var(--mono); font-size: 12px; line-height: 19px; white-space: pre-wrap; overflow-wrap: anywhere; }
126
+
127
+ .panel { width: 100%; max-width: 900px; display: flex; flex-direction: column; gap: 16px; }
128
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px; display: flex; flex-direction: column; gap: 12px; }
129
+ .card h2 { margin: 0; font-size: 15px; font-weight: 650; }
130
+ .card p { margin: 0; color: var(--muted); font-size: 13px; }
131
+ .field { display: flex; flex-direction: column; gap: 6px; }
132
+ .field label, .label { font-size: 12px; color: var(--muted); }
133
+ .field-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
134
+ .field-row .input { flex: 1 1 240px; }
135
+ select.input { padding-right: 28px; }
136
+ .events { display: flex; flex-wrap: wrap; gap: 6px; }
137
+ .log { display: flex; flex-direction: column; gap: 6px; }
138
+ .log-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; padding: 10px 12px; background: var(--well); border: 1px solid var(--line); border-radius: 8px; font-size: 13px; }
139
+ .log-row .ok { color: var(--moss); font-family: var(--mono); }
140
+ .log-row .bad { color: var(--red); font-family: var(--mono); }
141
+ .log-row details { grid-column: 1 / -1; }
142
+ .log-row summary { color: var(--muted); font-size: 12px; cursor: pointer; }
143
+ .log-row pre { margin: 6px 0 0; background: var(--code); border-radius: 6px; padding: 10px; font-family: var(--mono); font-size: 11px; line-height: 17px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 260px; overflow: auto; }
144
+ .kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 12px; font-size: 12px; }
145
+ .kv code { font-family: var(--mono); overflow-wrap: anywhere; color: var(--ink-soft); }
146
+ table.headers { width: 100%; max-width: 900px; border-collapse: collapse; font-size: 12px; background: var(--code); border-radius: 8px; overflow: hidden; }
147
+ table.headers td { padding: 6px 12px; border-bottom: 1px solid var(--line); vertical-align: top; font-family: var(--mono); overflow-wrap: anywhere; }
148
+ table.headers td:first-child { color: var(--muted); white-space: nowrap; width: 1%; }
149
+ .attachments { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 900px; }
150
+ .attachment { display: flex; justify-content: space-between; gap: 12px; align-items: center; padding: 12px 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
151
+ .attachment span.muted { color: var(--muted); font-size: 12px; }
152
+ .note { font-size: 12px; color: var(--muted); }
153
+ .ok-text { color: var(--moss); }
154
+ .bad-text { color: var(--red); }
155
+
156
+ /* Empty state */
157
+ .empty { flex: 1 1 auto; display: flex; flex-direction: column; align-items: center; padding: 48px 24px 40px; gap: 26px; overflow: auto; }
158
+ .empty h1 { margin: 0; font-size: 30px; line-height: 36px; font-weight: 650; letter-spacing: -0.4px; text-align: center; }
159
+ .empty .lede { margin: 0; font-size: 16px; line-height: 24px; color: var(--muted); max-width: 560px; text-align: center; }
160
+ .connect { width: 100%; max-width: 760px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
161
+ .connect-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 10px 10px 0; border-bottom: 1px solid var(--line); }
162
+ .connect-tabs button { min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px 6px 0 0; font-size: 13px; font-weight: 500; background: transparent; color: var(--muted); }
163
+ .connect-tabs button[aria-selected='true'] { background: var(--code); color: var(--ink); }
164
+ .connect-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
165
+ .connect-body .hint { font-size: 13px; color: var(--muted); }
166
+ .codeblock { position: relative; }
167
+ .codeblock pre { margin: 0; background: var(--code); color: #eadfcc; border-radius: 6px; padding: 14px 16px; font-family: var(--mono); font-size: 13px; line-height: 20px; white-space: pre-wrap; overflow-x: auto; }
168
+ .codeblock .copy { position: absolute; top: 8px; right: 8px; }
169
+ .empty-foot { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px 24px; font-size: 13px; color: var(--muted); }
170
+
171
+ /* Footer */
172
+ .foot { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 16px; padding: 10px 24px; border-top: 1px solid var(--line); font-size: 12px; color: var(--muted); background: var(--panel); }
173
+ .foot a { color: var(--muted); }
174
+
175
+ /* Toasts */
176
+ .toasts { position: fixed; right: 24px; bottom: 24px; display: flex; flex-direction: column; gap: 10px; z-index: 30; max-width: calc(100vw - 32px); }
177
+ .toast { display: flex; align-items: center; gap: 12px; background: var(--raised); border: 1px solid var(--line-strong); border-radius: 10px; padding: 10px 16px 10px 10px; box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.6); animation: pop .35s cubic-bezier(.2, 1.4, .4, 1); max-width: 420px; }
178
+ .toast.warn { border-color: var(--amber-line); background: var(--amber-bg); }
179
+ .toast-text { display: flex; flex-direction: column; min-width: 0; }
180
+ .toast-title { font-size: 13px; font-weight: 600; }
181
+ .toast-sub { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
182
+ @keyframes pop { from { transform: translateY(16px) scale(.96); opacity: 0; } to { transform: none; opacity: 1; } }
183
+ @media (prefers-reduced-motion: reduce) { .toast, .row.new { animation: none; } }
184
+
185
+ /* Dialogs */
186
+ dialog { padding: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); color: var(--ink); box-shadow: 0 24px 48px -28px rgba(0, 0, 0, 0.7); width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 32px); }
187
+ dialog::backdrop { background: rgba(13, 10, 7, 0.82); }
188
+ .dialog-art { display: block; width: 100%; height: auto; background: var(--well); }
189
+ .dialog-body { padding: 26px 30px 30px; display: flex; flex-direction: column; gap: 20px; }
190
+ .dialog-body h1 { margin: 0; font-size: 24px; line-height: 30px; font-weight: 650; letter-spacing: -0.3px; }
191
+ .dialog-body > p, .dialog-lede { margin: 0; font-size: 15px; line-height: 22px; color: var(--muted); }
192
+ .steps { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 14px; }
193
+ .steps li { display: grid; grid-template-columns: 32px minmax(0, 1fr); gap: 14px; align-items: start; }
194
+ .step-n { width: 28px; height: 28px; border-radius: 9999px; background: var(--raised); border: 1px solid var(--line-strong); font-size: 13px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
195
+ .step-t { display: flex; flex-direction: column; gap: 4px; }
196
+ .step-t strong { font-size: 15px; }
197
+ .step-t span { font-size: 14px; color: var(--muted); }
198
+ .dialog-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; border-top: 1px solid var(--line); }
199
+ .linkish { background: none; border: 0; padding: 0; color: var(--muted); text-decoration: underline; font-size: 14px; }
200
+ .faults { display: flex; flex-direction: column; gap: 6px; }
201
+ .fault { display: flex; align-items: center; gap: 12px; min-height: 40px; padding: 0 12px; border-radius: 6px; text-align: left; background: transparent; border: 1px solid var(--line); }
202
+ .fault[aria-checked='true'] { background: var(--raised); border-color: var(--accent); }
203
+ .fault .code { font-family: var(--mono); font-size: 12px; width: 40px; }
204
+ .fault .desc { color: var(--muted); font-size: 12px; margin-left: auto; text-align: right; }
205
+
206
+ @media (max-width: 860px) {
207
+ .main { flex-direction: column; }
208
+ .list { flex: 0 0 auto; max-width: none; border-right: 0; border-bottom: 1px solid var(--line); max-height: 42vh; }
209
+ .top, .banner, .foot { padding-left: 16px; padding-right: 16px; }
210
+ .view-head, .view-bar { padding-left: 16px; padding-right: 16px; }
211
+ .stage { padding: 16px; }
212
+ .empty h1 { font-size: 24px; line-height: 30px; }
213
+ .empty-art { width: 100%; max-width: 360px; height: auto; }
214
+ }