@autono/open-pages 0.7.0 → 0.9.0

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 (47) hide show
  1. package/dist/{build-CCoxDxL-.js → build-Cpod-K9R.js} +9 -4
  2. package/dist/cli/bin.js +7 -7
  3. package/dist/{config-D_U_aCQz.js → config-CYLBzMMb.js} +1077 -18
  4. package/dist/{config-p-TWNXbv.d.ts → config-IkwICvOe.d.ts} +2 -1
  5. package/dist/{dev-CTrB263u.js → dev-B7x-G-wk.js} +1 -1
  6. package/dist/{export-enKL9R7x.js → export-BxMksWEc.js} +74 -12
  7. package/dist/index.d.ts +23 -3
  8. package/dist/locale/index.d.ts +1 -1
  9. package/dist/locale/index.js +96 -0
  10. package/dist/{preview-Dmzyd1cf.js → preview-CMv1lGOC.js} +1 -1
  11. package/dist/{types-DDdUFUIS.d.ts → types-B-9WXL93.d.ts} +24 -0
  12. package/dist/vite/index.d.ts +1 -1
  13. package/dist/vite/index.js +1 -1
  14. package/package.json +1 -1
  15. package/skills/apply-comments/SKILL.md +6 -3
  16. package/skills/create-email/SKILL.md +166 -0
  17. package/skills/create-page/SKILL.md +5 -1
  18. package/skills/create-theme/SKILL.md +33 -7
  19. package/skills/create-theme/references/email-theme-from-css.mjs +185 -0
  20. package/skills/create-theme/references/email-theme.md +48 -0
  21. package/skills/current-page/SKILL.md +3 -3
  22. package/skills/page-authoring/SKILL.md +3 -0
  23. package/skills/web-design-guidelines/SKILL.md +41 -0
  24. package/src/app/app.tsx +4 -0
  25. package/src/app/components/command/home-command-menu.tsx +19 -2
  26. package/src/app/components/selection-note.tsx +71 -0
  27. package/src/app/components/sidebar/folder-item.tsx +9 -2
  28. package/src/app/components/sidebar/sidebar.tsx +10 -0
  29. package/src/app/frame/email-inspect.ts +17 -0
  30. package/src/app/lib/emails.ts +17 -0
  31. package/src/app/lib/frame.ts +15 -4
  32. package/src/app/lib/page-thumb.tsx +11 -3
  33. package/src/app/lib/sdk.ts +22 -0
  34. package/src/app/routes/email.tsx +424 -0
  35. package/src/app/routes/emails.tsx +94 -0
  36. package/src/app/routes/home-shell.tsx +21 -2
  37. package/src/app/routes/page.tsx +21 -57
  38. package/src/app/virtual.d.ts +10 -0
  39. package/src/locale/en.ts +25 -0
  40. package/src/locale/ja.ts +25 -0
  41. package/src/locale/types.ts +24 -0
  42. package/src/locale/zh-cn.ts +25 -0
  43. package/src/locale/zh-tw.ts +25 -0
  44. package/workspace/components.json +3 -1
  45. package/workspace/styles/globals.css +8 -2
  46. package/dist/open-pages-plugin-C8cFhMB9.js +0 -662
  47. package/dist/version-DZrXLoAD.js +0 -42
@@ -15,6 +15,7 @@ export const ALL_DOCS_ID = '__all__';
15
15
  export const DRAFT_ID = 'draft';
16
16
  export const THEMES_ID = '__themes__';
17
17
  export const ASSETS_ID = '__assets__';
18
+ export const EMAILS_ID = '__emails__';
18
19
 
19
20
  export const FOLDER_DND_MIME = 'application/x-folder-id';
20
21
 
@@ -24,6 +25,7 @@ export function Sidebar({
24
25
  allCount,
25
26
  themesCount,
26
27
  assetsCount,
28
+ emailsCount,
27
29
  selectedId,
28
30
  onSelect,
29
31
  onCreate,
@@ -40,6 +42,7 @@ export function Sidebar({
40
42
  allCount: number;
41
43
  themesCount: number;
42
44
  assetsCount: number;
45
+ emailsCount: number;
43
46
  selectedId: string;
44
47
  onSelect: (id: string) => void;
45
48
  onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | undefined;
@@ -178,6 +181,13 @@ export function Sidebar({
178
181
  onSelect={() => onSelect(THEMES_ID)}
179
182
  onDropDoc={() => {}}
180
183
  />
184
+ <FolderItem
185
+ row={{ kind: 'emails' }}
186
+ count={emailsCount}
187
+ selected={selectedId === EMAILS_ID}
188
+ onSelect={() => onSelect(EMAILS_ID)}
189
+ onDropDoc={() => {}}
190
+ />
181
191
  {import.meta.env.DEV && (
182
192
  <FolderItem
183
193
  row={{ kind: 'assets' }}
@@ -0,0 +1,17 @@
1
+ // Attached to a rendered email when the workspace embeds it: the inspector
2
+ // alone, since the document is static HTML and needs no React mount.
3
+ import type { FrameMessage, WorkspaceMessage } from '../lib/frame';
4
+ import { installInspector } from './inspect';
5
+
6
+ if (window.parent !== window) {
7
+ const post = (msg: FrameMessage) => window.parent.postMessage(msg, '*');
8
+ const inspector = installInspector(post);
9
+ window.addEventListener('message', (event: MessageEvent<WorkspaceMessage>) => {
10
+ if (event.source !== window.parent) return;
11
+ const msg = event.data;
12
+ if (!msg || typeof msg !== 'object') return;
13
+ if (msg.type === 'op:inspect') inspector.setInspecting(msg.on);
14
+ else if (msg.type === 'op:select') inspector.setSelected(msg.loc);
15
+ });
16
+ post({ type: 'op:ready', title: document.title });
17
+ }
@@ -0,0 +1,17 @@
1
+ import {
2
+ emailCreatedAt as createdAt,
3
+ emailIds as ids,
4
+ emailMeta as meta,
5
+ } from 'virtual:open-pages/emails';
6
+
7
+ export type EmailInfo = { title: string; subject: string | null; description: string | null };
8
+
9
+ export const emailIds: string[] = ids;
10
+ export const emailMeta: Record<string, EmailInfo> = meta;
11
+ export const emailCreatedAt: Record<string, number> = createdAt;
12
+
13
+ export function emailChangeIncludes(data: unknown, emailId: string): boolean {
14
+ if (!data || typeof data !== 'object') return false;
15
+ const payload = data as { emailIds?: unknown };
16
+ return Array.isArray(payload.emailIds) && payload.emailIds.includes(emailId);
17
+ }
@@ -12,22 +12,33 @@ export type WorkspaceMessage =
12
12
  | { type: 'op:inspect'; on: boolean }
13
13
  | { type: 'op:select'; loc: string | null };
14
14
 
15
- export type FrameSource = { pageId: string; kind?: PageKind } | { themeId: string };
15
+ export type FrameSource =
16
+ | { pageId: string; kind?: PageKind }
17
+ | { themeId: string }
18
+ | { emailId: string };
16
19
 
17
20
  const BASE = import.meta.env.BASE_URL;
18
21
 
19
22
  /**
20
- * URL the workspace loads into an iframe for a page or a theme demo. React
21
- * pages mount through `frame.html`; `index.html` pages are served verbatim
22
- * (dev middleware, or the per-page build under `__page/` in a static build).
23
+ * URL the workspace loads into an iframe for a page, a theme demo, or an
24
+ * email. React pages mount through `frame.html`; `index.html` pages are served
25
+ * verbatim (dev middleware, or the per-page build under `__page/` in a static
26
+ * build); emails are rendered on the server under `__email/`.
23
27
  */
24
28
  export function frameUrl(source: FrameSource): string {
25
29
  if ('themeId' in source) return `${BASE}frame.html?theme=${encodeURIComponent(source.themeId)}`;
30
+ if ('emailId' in source) return emailUrl(source.emailId, 'html');
26
31
  if (source.kind === 'html')
27
32
  return `${BASE}__page/${encodeURIComponent(source.pageId)}/index.html`;
28
33
  return `${BASE}frame.html?page=${encodeURIComponent(source.pageId)}`;
29
34
  }
30
35
 
36
+ /** The rendered email document, its plain-text alternative, or its render report. */
37
+ export function emailUrl(emailId: string, format: 'html' | 'text' | 'json'): string {
38
+ const ext = format === 'text' ? 'txt' : format;
39
+ return `${BASE}__email/${encodeURIComponent(emailId)}/index.${ext}`;
40
+ }
41
+
31
42
  export function isFrameMessage(data: unknown): data is FrameMessage {
32
43
  return (
33
44
  !!data &&
@@ -8,7 +8,15 @@ const THUMB_VIEWPORT = 1280;
8
8
  * desktop width and scaled down to fit. Inert — pointer events go to the
9
9
  * card, and the frame never takes focus.
10
10
  */
11
- export function PageThumb({ source, title }: { source: FrameSource; title: string }) {
11
+ export function PageThumb({
12
+ source,
13
+ title,
14
+ viewport = THUMB_VIEWPORT,
15
+ }: {
16
+ source: FrameSource;
17
+ title: string;
18
+ viewport?: number;
19
+ }) {
12
20
  const ref = useRef<HTMLDivElement | null>(null);
13
21
  const [size, setSize] = useState({ width: 0, height: 0 });
14
22
 
@@ -22,7 +30,7 @@ export function PageThumb({ source, title }: { source: FrameSource; title: strin
22
30
  return () => ro.disconnect();
23
31
  }, []);
24
32
 
25
- const scale = size.width > 0 ? size.width / THUMB_VIEWPORT : 0;
33
+ const scale = size.width > 0 ? size.width / viewport : 0;
26
34
 
27
35
  return (
28
36
  <div ref={ref} className="h-full w-full overflow-hidden bg-white">
@@ -35,7 +43,7 @@ export function PageThumb({ source, title }: { source: FrameSource; title: strin
35
43
  aria-hidden
36
44
  className="pointer-events-none origin-top-left border-0"
37
45
  style={{
38
- width: THUMB_VIEWPORT,
46
+ width: viewport,
39
47
  height: size.height / scale,
40
48
  transform: `scale(${scale})`,
41
49
  }}
@@ -29,6 +29,28 @@ export type PageModule = {
29
29
  design?: DesignSystem;
30
30
  };
31
31
 
32
+ export type EmailMeta = {
33
+ /** Display name in the workspace. Default: the folder name. */
34
+ title?: string;
35
+ /** Subject line the email is meant to be sent with. */
36
+ subject?: string;
37
+ /** What the email is for; shown on the workspace card. */
38
+ description?: string;
39
+ /** ISO 8601 timestamp. Set once at scaffold time; used to sort the email list. */
40
+ createdAt?: string;
41
+ };
42
+
43
+ /**
44
+ * An email is one react-email component rendered on the server to a single
45
+ * HTML document with inlined styles, plus a plain-text alternative derived
46
+ * from it. It never runs in a browser: no hooks with effects, no state, no
47
+ * browser APIs.
48
+ */
49
+ export type EmailModule = {
50
+ default: ComponentType;
51
+ meta?: EmailMeta;
52
+ };
53
+
32
54
  export type FolderIcon = { type: 'emoji'; value: string } | { type: 'color'; value: string };
33
55
 
34
56
  export type Folder = {
@@ -0,0 +1,424 @@
1
+ import config from 'virtual:open-pages/config';
2
+ import {
3
+ ChevronLeft,
4
+ Code2,
5
+ Copy,
6
+ Crosshair,
7
+ ExternalLink,
8
+ FileText,
9
+ Loader2,
10
+ Monitor,
11
+ RotateCw,
12
+ Smartphone,
13
+ TriangleAlert,
14
+ } from 'lucide-react';
15
+ import { useCallback, useEffect, useMemo, useState } from 'react';
16
+ import { useNavigate, useParams } from 'react-router-dom';
17
+ import { toast } from 'sonner';
18
+ import { type Selection, SelectionNote } from '~/components/selection-note';
19
+ import { Button, buttonVariants } from '~/components/ui/button';
20
+ import { readLastHomeLocation } from '~/lib/last-home-location';
21
+ import { format, useLocale } from '~/lib/use-locale';
22
+ import { cn } from '~/lib/utils';
23
+ import { emailChangeIncludes, emailIds, emailMeta } from '../lib/emails';
24
+ import { emailUrl, type FrameMessage, isFrameMessage, type WorkspaceMessage } from '../lib/frame';
25
+
26
+ const { showPageUi, showPageBrowser } = config.build;
27
+
28
+ type View = 'html' | 'text';
29
+ type Viewport = { id: 'desktop' | 'mobile'; width: number | null };
30
+
31
+ const VIEWPORTS: Viewport[] = [
32
+ { id: 'desktop', width: null },
33
+ { id: 'mobile', width: 375 },
34
+ ];
35
+ const VIEWPORT_STORAGE_KEY = 'open-pages:email-viewport';
36
+ const VIEWPORT_ICONS = { desktop: Monitor, mobile: Smartphone } as const;
37
+
38
+ function readViewportPref(): Viewport {
39
+ try {
40
+ const raw = window.localStorage.getItem(VIEWPORT_STORAGE_KEY);
41
+ return VIEWPORTS.find((v) => v.id === raw) ?? VIEWPORTS[0];
42
+ } catch {
43
+ return VIEWPORTS[0];
44
+ }
45
+ }
46
+
47
+ export function EmailView() {
48
+ const { emailId = '' } = useParams();
49
+ const known = emailIds.includes(emailId);
50
+ const info = emailMeta[emailId];
51
+ const title = info?.title ?? emailId;
52
+ const t = useLocale();
53
+
54
+ useEffect(() => {
55
+ document.title = `${title} — Autono`;
56
+ return () => {
57
+ document.title = 'Autono';
58
+ };
59
+ }, [title]);
60
+
61
+ const navigate = useNavigate();
62
+ const goBack = useCallback(() => {
63
+ const idx = (window.history.state as { idx?: number } | null)?.idx ?? 0;
64
+ if (idx > 0) navigate(-1);
65
+ else navigate(readLastHomeLocation(), { replace: true });
66
+ }, [navigate]);
67
+
68
+ const [view, setView] = useState<View>('html');
69
+ const [viewport, setViewport] = useState<Viewport>(readViewportPref);
70
+ const pickViewport = (v: Viewport) => {
71
+ setViewport(v);
72
+ try {
73
+ window.localStorage.setItem(VIEWPORT_STORAGE_KEY, v.id);
74
+ } catch {}
75
+ };
76
+
77
+ // Bumped on every change to the email or its imports; the frame and the
78
+ // text alternative both re-fetch from the server.
79
+ const [version, setVersion] = useState(0);
80
+ const [frameEl, setFrameEl] = useState<HTMLIFrameElement | null>(null);
81
+ const [ready, setReady] = useState(false);
82
+ const [text, setText] = useState<string | null>(null);
83
+
84
+ useEffect(() => {
85
+ if (!import.meta.hot) return;
86
+ const handler = (data: unknown) => {
87
+ if (emailChangeIncludes(data, emailId)) setVersion((v) => v + 1);
88
+ };
89
+ import.meta.hot.on('open-pages:email-changed', handler);
90
+ return () => import.meta.hot?.off('open-pages:email-changed', handler);
91
+ }, [emailId]);
92
+
93
+ const [unresolved, setUnresolved] = useState<string[]>([]);
94
+ useEffect(() => {
95
+ if (!known) return;
96
+ let cancelled = false;
97
+ fetch(`${emailUrl(emailId, 'json')}?v=${version}`)
98
+ .then((res) => (res.ok ? res.json() : null))
99
+ .then((body: { unresolved?: unknown } | null) => {
100
+ if (cancelled) return;
101
+ setUnresolved(Array.isArray(body?.unresolved) ? (body.unresolved as string[]) : []);
102
+ })
103
+ .catch(() => {
104
+ if (!cancelled) setUnresolved([]);
105
+ });
106
+ return () => {
107
+ cancelled = true;
108
+ };
109
+ }, [known, emailId, version]);
110
+
111
+ useEffect(() => {
112
+ if (view !== 'text' || !known) return;
113
+ let cancelled = false;
114
+ setText(null);
115
+ fetch(`${emailUrl(emailId, 'text')}?v=${version}`)
116
+ .then((res) => res.text())
117
+ .then((body) => {
118
+ if (!cancelled) setText(body);
119
+ })
120
+ .catch((e) => {
121
+ if (!cancelled) setText(e instanceof Error ? e.message : String(e));
122
+ });
123
+ return () => {
124
+ cancelled = true;
125
+ };
126
+ }, [view, known, emailId, version]);
127
+
128
+ const [inspecting, setInspecting] = useState(false);
129
+ const [selection, setSelection] = useState<Selection | null>(null);
130
+ const [note, setNote] = useState('');
131
+ const [savingNote, setSavingNote] = useState(false);
132
+
133
+ const send = useCallback(
134
+ (msg: WorkspaceMessage) => {
135
+ frameEl?.contentWindow?.postMessage(msg, '*');
136
+ },
137
+ [frameEl],
138
+ );
139
+
140
+ useEffect(() => {
141
+ send({ type: 'op:inspect', on: inspecting });
142
+ if (!inspecting) {
143
+ setSelection(null);
144
+ send({ type: 'op:select', loc: null });
145
+ }
146
+ }, [inspecting, send]);
147
+
148
+ useEffect(() => {
149
+ const onMessage = (event: MessageEvent) => {
150
+ if (event.source !== frameEl?.contentWindow) return;
151
+ if (!isFrameMessage(event.data)) return;
152
+ const msg: FrameMessage = event.data;
153
+ if (msg.type === 'op:ready') {
154
+ setReady(true);
155
+ send({ type: 'op:inspect', on: inspecting });
156
+ } else if (msg.type === 'op:select') {
157
+ setSelection({ loc: msg.loc, tag: msg.tag, text: msg.text });
158
+ setNote('');
159
+ } else if (msg.type === 'op:key') {
160
+ if (msg.key === 'i') setInspecting((v) => !v);
161
+ if (msg.key === 'Escape') setSelection(null);
162
+ }
163
+ };
164
+ window.addEventListener('message', onMessage);
165
+ return () => window.removeEventListener('message', onMessage);
166
+ }, [frameEl, inspecting, send]);
167
+
168
+ useEffect(() => {
169
+ const onKey = (e: KeyboardEvent) => {
170
+ const tag = (e.target as HTMLElement | null)?.tagName;
171
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
172
+ if (e.key === 'i') setInspecting((v) => !v);
173
+ if (e.key === 'Escape') setSelection(null);
174
+ };
175
+ window.addEventListener('keydown', onKey);
176
+ return () => window.removeEventListener('keydown', onKey);
177
+ }, []);
178
+
179
+ useEffect(() => {
180
+ if (!import.meta.hot || !emailId || !known) return;
181
+ import.meta.hot.send('open-pages:current', {
182
+ pageId: emailId,
183
+ pageTitle: title,
184
+ view: 'emails',
185
+ });
186
+ }, [emailId, known, title]);
187
+
188
+ useEffect(() => {
189
+ if (!import.meta.hot) return;
190
+ const [line, column] = selection
191
+ ? selection.loc.split(':').map(Number)
192
+ : [undefined, undefined];
193
+ import.meta.hot.send('open-pages:current', {
194
+ selection: selection ? { line, column, tagName: selection.tag, text: selection.text } : null,
195
+ });
196
+ send({ type: 'op:select', loc: selection?.loc ?? null });
197
+ }, [selection, send]);
198
+
199
+ const submitComment = useCallback(async () => {
200
+ if (!selection || !note.trim()) return;
201
+ const [line, column] = selection.loc.split(':').map(Number);
202
+ setSavingNote(true);
203
+ try {
204
+ const res = await fetch('/__comments/add', {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/json' },
207
+ body: JSON.stringify({ pageId: emailId, target: 'email', line, column, text: note.trim() }),
208
+ });
209
+ if (!res.ok) {
210
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
211
+ throw new Error(body.error ?? `POST /__comments/add → ${res.status}`);
212
+ }
213
+ toast.success('Comment saved — run /apply-comments to apply it');
214
+ setSelection(null);
215
+ setNote('');
216
+ } catch (e) {
217
+ toast.error(e instanceof Error ? e.message : String(e));
218
+ } finally {
219
+ setSavingNote(false);
220
+ }
221
+ }, [selection, note, emailId]);
222
+
223
+ const copyHtml = useCallback(async () => {
224
+ try {
225
+ const res = await fetch(`${emailUrl(emailId, 'html')}?raw`);
226
+ if (!res.ok) throw new Error(`${res.status}`);
227
+ await navigator.clipboard.writeText(await res.text());
228
+ toast.success(t.emails.copiedHtml);
229
+ } catch {
230
+ toast.error(t.emails.copyHtmlFailed);
231
+ }
232
+ }, [emailId, t]);
233
+
234
+ const frameSrc = useMemo(
235
+ () => (known ? `${emailUrl(emailId, 'html')}?v=${version}` : ''),
236
+ [known, emailId, version],
237
+ );
238
+ const rawSrc = known ? `${emailUrl(emailId, 'html')}?raw` : undefined;
239
+ const canInspect = view === 'html' && import.meta.env.DEV;
240
+
241
+ return (
242
+ <div className="flex h-screen flex-col bg-muted/40 text-foreground">
243
+ {showPageUi && (
244
+ <header className="flex h-12 shrink-0 items-center gap-2 border-b bg-background px-3">
245
+ {showPageBrowser && (
246
+ <Button variant="ghost" size="icon" aria-label={t.emails.backToEmails} onClick={goBack}>
247
+ <ChevronLeft className="size-4" />
248
+ </Button>
249
+ )}
250
+ <h1 className="min-w-0 truncate text-sm font-medium">{title}</h1>
251
+ <span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
252
+ email
253
+ </span>
254
+ {known && (
255
+ <span
256
+ className="hidden min-w-0 truncate text-xs text-muted-foreground md:inline"
257
+ title={t.emails.subjectLabel}
258
+ >
259
+ {info?.subject ?? t.emails.noSubject}
260
+ </span>
261
+ )}
262
+ <div className="ml-auto flex items-center gap-2">
263
+ {unresolved.length > 0 && (
264
+ <span
265
+ className="flex items-center gap-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300"
266
+ title={`${t.emails.unresolvedTitle}\n${unresolved.join(' ')}`}
267
+ >
268
+ <TriangleAlert className="size-3.5" aria-hidden />
269
+ {format(t.emails.unresolvedCount, { count: String(unresolved.length) })}
270
+ </span>
271
+ )}
272
+ {!ready && known && view === 'html' && (
273
+ <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
274
+ <Loader2 className="size-3.5 animate-spin" />
275
+ {t.page.loadingEyebrow}
276
+ </span>
277
+ )}
278
+ <fieldset className="flex items-center rounded-md border p-0.5" aria-label="View">
279
+ <Button
280
+ variant={view === 'html' ? 'secondary' : 'ghost'}
281
+ size="sm"
282
+ aria-pressed={view === 'html'}
283
+ onClick={() => setView('html')}
284
+ >
285
+ <Code2 className="size-3.5" />
286
+ {t.emails.viewHtml}
287
+ </Button>
288
+ <Button
289
+ variant={view === 'text' ? 'secondary' : 'ghost'}
290
+ size="sm"
291
+ aria-pressed={view === 'text'}
292
+ onClick={() => setView('text')}
293
+ >
294
+ <FileText className="size-3.5" />
295
+ {t.emails.viewText}
296
+ </Button>
297
+ </fieldset>
298
+ {view === 'html' && (
299
+ <fieldset className="flex items-center rounded-md border p-0.5" aria-label="Viewport">
300
+ {VIEWPORTS.map((v) => {
301
+ const Icon = VIEWPORT_ICONS[v.id];
302
+ const label =
303
+ v.id === 'desktop' ? t.emails.viewportDesktop : t.emails.viewportMobile;
304
+ return (
305
+ <Button
306
+ key={v.id}
307
+ variant={viewport.id === v.id ? 'secondary' : 'ghost'}
308
+ size="icon-sm"
309
+ aria-label={label}
310
+ aria-pressed={viewport.id === v.id}
311
+ title={v.width ? `${label} · ${v.width}px` : label}
312
+ onClick={() => pickViewport(v)}
313
+ >
314
+ <Icon className="size-3.5" />
315
+ </Button>
316
+ );
317
+ })}
318
+ </fieldset>
319
+ )}
320
+ <Button
321
+ variant="outline"
322
+ size="icon-sm"
323
+ aria-label={t.emails.reload}
324
+ title={t.emails.reload}
325
+ onClick={() => {
326
+ setReady(false);
327
+ setVersion((v) => v + 1);
328
+ }}
329
+ >
330
+ <RotateCw className="size-3.5" />
331
+ </Button>
332
+ {canInspect && (
333
+ <Button
334
+ variant={inspecting ? 'default' : 'outline'}
335
+ size="sm"
336
+ onClick={() => setInspecting((v) => !v)}
337
+ disabled={!ready}
338
+ aria-pressed={inspecting}
339
+ title="Inspect elements (i) — click any element to select it or leave a comment"
340
+ >
341
+ <Crosshair className="size-4" />
342
+ Inspect
343
+ </Button>
344
+ )}
345
+ <Button
346
+ variant="outline"
347
+ size="sm"
348
+ onClick={copyHtml}
349
+ disabled={!known}
350
+ title={t.emails.copyHtml}
351
+ >
352
+ <Copy className="size-4" />
353
+ {t.emails.copyHtml}
354
+ </Button>
355
+ <a
356
+ href={rawSrc}
357
+ target="_blank"
358
+ rel="noreferrer"
359
+ aria-label={t.emails.openInTab}
360
+ aria-disabled={!known || undefined}
361
+ tabIndex={known ? undefined : -1}
362
+ title={t.emails.openInTab}
363
+ className={cn(
364
+ buttonVariants({ variant: 'outline', size: 'sm' }),
365
+ !known && 'pointer-events-none opacity-50',
366
+ )}
367
+ >
368
+ <ExternalLink className="size-4" />
369
+ Open
370
+ </a>
371
+ </div>
372
+ </header>
373
+ )}
374
+
375
+ {!known && (
376
+ <div className="border-b border-destructive/30 bg-destructive/10 px-4 py-2 font-mono text-xs text-destructive whitespace-pre-wrap">
377
+ {format(t.emails.notFound, { id: emailId })}
378
+ </div>
379
+ )}
380
+
381
+ <main className="relative flex min-h-0 flex-1 justify-center overflow-hidden">
382
+ {known && view === 'html' && (
383
+ <iframe
384
+ key={frameSrc}
385
+ ref={setFrameEl}
386
+ title={title}
387
+ src={frameSrc}
388
+ className={cn(
389
+ 'h-full border-0 bg-white',
390
+ viewport.width !== null && 'border-x shadow-md',
391
+ inspecting && 'cursor-crosshair',
392
+ )}
393
+ style={{ width: viewport.width ?? '100%', maxWidth: '100%' }}
394
+ />
395
+ )}
396
+ {known && view === 'text' && (
397
+ <div className="h-full w-full overflow-auto bg-background">
398
+ {text === null ? (
399
+ <div className="flex items-center gap-1.5 px-6 py-6 text-xs text-muted-foreground">
400
+ <Loader2 className="size-3.5 animate-spin" />
401
+ {t.page.loadingEyebrow}
402
+ </div>
403
+ ) : (
404
+ <pre className="mx-auto w-full max-w-[72ch] whitespace-pre-wrap px-6 py-8 font-mono text-[13px] leading-6 text-foreground">
405
+ {text}
406
+ </pre>
407
+ )}
408
+ </div>
409
+ )}
410
+
411
+ {selection && (
412
+ <SelectionNote
413
+ selection={selection}
414
+ note={note}
415
+ saving={savingNote}
416
+ onNoteChange={setNote}
417
+ onSubmit={submitComment}
418
+ onClear={() => setSelection(null)}
419
+ />
420
+ )}
421
+ </main>
422
+ </div>
423
+ );
424
+ }