@templatical/template-tools 0.38.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.
@@ -0,0 +1,978 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Templatical — Live preview</title>
7
+ <link rel="icon" href="https://templatical.com/logo-small.svg" />
8
+ <!-- Geist, matching the playground (fonts.bunny.net). -->
9
+ <link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
10
+ <link
11
+ href="https://fonts.bunny.net/css?family=geist:400,500,600&display=swap"
12
+ rel="stylesheet"
13
+ />
14
+ <!--
15
+ The real Templatical editor, loaded from the CDN. {{EDITOR_VERSION}} is
16
+ injected by src/live/index.ts (pinned to this package's schema version).
17
+ In shadow-DOM mode the editor adopts its own CSS; the stylesheet link is
18
+ kept for parity with the documented consumer setup.
19
+ -->
20
+ <link
21
+ rel="stylesheet"
22
+ href="https://cdn.jsdelivr.net/npm/@templatical/editor@{{EDITOR_VERSION}}/dist/cdn/editor.css"
23
+ />
24
+ <!-- Set the stored theme before paint to avoid a flash. -->
25
+ <script>
26
+ (function () {
27
+ try {
28
+ var t = localStorage.getItem("tpl-live-theme");
29
+ if (t === "light" || t === "dark")
30
+ document.documentElement.setAttribute("data-theme", t);
31
+ } catch (e) {}
32
+ })();
33
+ </script>
34
+ <style>
35
+ /*
36
+ Design language borrowed from the playground + the SDK design system
37
+ (DESIGN.md): warm neutrals tinted toward hue 60, Signal Amber as the one
38
+ signal color, Geist, and the 120ms house spring on every transition.
39
+ */
40
+ :root {
41
+ --spring: cubic-bezier(0.16, 1, 0.3, 1);
42
+
43
+ --paper: oklch(99.5% 0.002 60);
44
+ --n-50: oklch(97% 0.004 60);
45
+ --n-100: oklch(96% 0.006 60);
46
+ --n-200: oklch(92% 0.006 60);
47
+ --n-300: oklch(86% 0.01 60);
48
+ --n-400: oklch(68% 0.012 60);
49
+ --n-500: oklch(50% 0.015 60);
50
+ --n-700: oklch(30% 0.012 60);
51
+ --n-900: oklch(18% 0.01 60);
52
+
53
+ --primary: oklch(70% 0.16 55);
54
+ --primary-hover: oklch(63% 0.17 55);
55
+ --primary-tint: oklch(95% 0.04 55);
56
+ --ring: oklch(70% 0.16 55 / 0.32);
57
+
58
+ --success: oklch(62.8% 0.194 155.1);
59
+ --warning: oklch(76.9% 0.168 70.08);
60
+ --danger: oklch(63.7% 0.237 25.33);
61
+
62
+ --canvas: var(--n-100);
63
+ --bar: color-mix(in oklch, var(--paper) 82%, var(--n-100));
64
+ --surface: var(--paper);
65
+ --raised: var(--paper);
66
+ --border: var(--n-200);
67
+ --text: var(--n-900);
68
+ --muted: var(--n-500);
69
+ --hover: var(--n-100);
70
+ --shadow-modal: 0 24px 64px oklch(18% 0.01 60 / 0.18);
71
+ --scrim: oklch(24% 0.02 60 / 0.5);
72
+ color-scheme: light;
73
+ }
74
+
75
+ /* Dark tokens — applied on system-dark (unless explicitly overridden to
76
+ light) and whenever the toggle sets data-theme="dark". */
77
+ @media (prefers-color-scheme: dark) {
78
+ :root:not([data-theme="light"]) {
79
+ --canvas: oklch(16% 0.008 60);
80
+ --bar: oklch(22% 0.011 60);
81
+ --surface: oklch(24% 0.012 60);
82
+ --raised: oklch(27% 0.013 60);
83
+ --border: oklch(32% 0.012 60);
84
+ --text: oklch(96% 0.006 60);
85
+ --muted: oklch(70% 0.012 60);
86
+ --hover: oklch(30% 0.013 60);
87
+ --primary: oklch(78% 0.14 55);
88
+ --primary-hover: oklch(84% 0.12 55);
89
+ --primary-tint: oklch(40% 0.09 55);
90
+ --ring: oklch(78% 0.14 55 / 0.4);
91
+ --shadow-modal: 0 24px 64px oklch(0% 0 0 / 0.5);
92
+ --scrim: oklch(10% 0.01 60 / 0.6);
93
+ color-scheme: dark;
94
+ }
95
+ }
96
+ :root[data-theme="dark"] {
97
+ --canvas: oklch(16% 0.008 60);
98
+ --bar: oklch(22% 0.011 60);
99
+ --surface: oklch(24% 0.012 60);
100
+ --raised: oklch(27% 0.013 60);
101
+ --border: oklch(32% 0.012 60);
102
+ --text: oklch(96% 0.006 60);
103
+ --muted: oklch(70% 0.012 60);
104
+ --hover: oklch(30% 0.013 60);
105
+ --primary: oklch(78% 0.14 55);
106
+ --primary-hover: oklch(84% 0.12 55);
107
+ --primary-tint: oklch(40% 0.09 55);
108
+ --ring: oklch(78% 0.14 55 / 0.4);
109
+ --shadow-modal: 0 24px 64px oklch(0% 0 0 / 0.5);
110
+ --scrim: oklch(10% 0.01 60 / 0.6);
111
+ color-scheme: dark;
112
+ }
113
+
114
+ * {
115
+ box-sizing: border-box;
116
+ }
117
+ html,
118
+ body {
119
+ margin: 0;
120
+ height: 100%;
121
+ }
122
+ body {
123
+ display: flex;
124
+ flex-direction: column;
125
+ background: var(--canvas);
126
+ color: var(--text);
127
+ font-family:
128
+ Geist,
129
+ ui-sans-serif,
130
+ system-ui,
131
+ -apple-system,
132
+ sans-serif;
133
+ -webkit-font-smoothing: antialiased;
134
+ }
135
+
136
+ /* ---- Top bar ---------------------------------------------------- */
137
+ .bar {
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: space-between;
141
+ gap: 12px;
142
+ height: 52px;
143
+ flex: none;
144
+ padding: 0 14px 0 16px;
145
+ background: var(--bar);
146
+ border-bottom: 1px solid var(--border);
147
+ overflow-x: auto;
148
+ scrollbar-width: none;
149
+ z-index: 10;
150
+ }
151
+ .bar::-webkit-scrollbar {
152
+ display: none;
153
+ }
154
+ .bar .group {
155
+ display: flex;
156
+ align-items: center;
157
+ gap: 8px;
158
+ flex: none;
159
+ }
160
+ .brand {
161
+ display: flex;
162
+ align-items: center;
163
+ gap: 9px;
164
+ font-size: 15px;
165
+ font-weight: 600;
166
+ letter-spacing: -0.01em;
167
+ color: var(--text);
168
+ }
169
+ .brand img {
170
+ width: 22px;
171
+ height: 22px;
172
+ display: block;
173
+ }
174
+ .brand .sub {
175
+ font-weight: 500;
176
+ font-size: 13px;
177
+ color: var(--muted);
178
+ }
179
+ .brand .sep {
180
+ color: var(--n-300);
181
+ font-weight: 400;
182
+ }
183
+
184
+ .status {
185
+ display: inline-flex;
186
+ align-items: center;
187
+ gap: 7px;
188
+ padding: 4px 11px 4px 9px;
189
+ border: 1px solid var(--border);
190
+ border-radius: 999px;
191
+ background: var(--surface);
192
+ font-size: 12px;
193
+ font-weight: 500;
194
+ color: var(--muted);
195
+ white-space: nowrap;
196
+ }
197
+ .status .dot {
198
+ width: 7px;
199
+ height: 7px;
200
+ border-radius: 50%;
201
+ background: var(--n-400);
202
+ position: relative;
203
+ }
204
+ .status[data-state="live"] {
205
+ color: color-mix(in oklch, var(--success) 72%, var(--text));
206
+ }
207
+ .status[data-state="live"] .dot {
208
+ background: var(--success);
209
+ box-shadow: 0 0 0 3px
210
+ color-mix(in oklch, var(--success) 22%, transparent);
211
+ }
212
+ .status[data-state="live"] .dot::after {
213
+ content: "";
214
+ position: absolute;
215
+ inset: 0;
216
+ border-radius: 50%;
217
+ background: var(--success);
218
+ animation: pulse 2s var(--spring) infinite;
219
+ }
220
+ .status[data-state="reconnecting"] {
221
+ color: color-mix(in oklch, var(--warning) 66%, var(--text));
222
+ }
223
+ .status[data-state="reconnecting"] .dot {
224
+ background: var(--warning);
225
+ }
226
+ .status[data-state="error"] {
227
+ color: color-mix(in oklch, var(--danger) 72%, var(--text));
228
+ }
229
+ .status[data-state="error"] .dot {
230
+ background: var(--danger);
231
+ }
232
+ @keyframes pulse {
233
+ 0% {
234
+ transform: scale(1);
235
+ opacity: 0.6;
236
+ }
237
+ 70%,
238
+ 100% {
239
+ transform: scale(2.6);
240
+ opacity: 0;
241
+ }
242
+ }
243
+
244
+ .divider {
245
+ width: 1px;
246
+ height: 20px;
247
+ background: var(--border);
248
+ flex: none;
249
+ }
250
+
251
+ /* ---- Buttons ---------------------------------------------------- */
252
+ .btn {
253
+ display: inline-flex;
254
+ align-items: center;
255
+ gap: 6px;
256
+ height: 32px;
257
+ padding: 0 10px;
258
+ border: 1px solid var(--border);
259
+ border-radius: 8px;
260
+ background: var(--surface);
261
+ color: var(--muted);
262
+ font: inherit;
263
+ font-size: 13px;
264
+ font-weight: 500;
265
+ white-space: nowrap;
266
+ cursor: pointer;
267
+ transition:
268
+ color 120ms var(--spring),
269
+ background-color 120ms var(--spring),
270
+ border-color 120ms var(--spring),
271
+ box-shadow 120ms var(--spring),
272
+ transform 120ms var(--spring);
273
+ }
274
+ .btn:hover {
275
+ color: var(--text);
276
+ background: var(--hover);
277
+ }
278
+ .btn:active {
279
+ transform: translateY(0.5px);
280
+ }
281
+ .btn svg {
282
+ width: 14px;
283
+ height: 14px;
284
+ flex: none;
285
+ }
286
+ .btn.icon-only {
287
+ width: 32px;
288
+ padding: 0;
289
+ justify-content: center;
290
+ }
291
+ .btn.primary {
292
+ background: var(--primary);
293
+ border-color: transparent;
294
+ color: var(--paper);
295
+ }
296
+ .btn.primary:hover {
297
+ background: var(--primary-hover);
298
+ color: var(--paper);
299
+ }
300
+ :focus-visible {
301
+ outline: none;
302
+ box-shadow: 0 0 0 3px var(--ring);
303
+ }
304
+
305
+ /* ---- Editor + loading ------------------------------------------- */
306
+ .stage {
307
+ position: relative;
308
+ flex: 1;
309
+ min-height: 0;
310
+ }
311
+ #editor {
312
+ position: absolute;
313
+ inset: 0;
314
+ overflow: auto;
315
+ }
316
+ .loading {
317
+ position: absolute;
318
+ inset: 0;
319
+ display: flex;
320
+ flex-direction: column;
321
+ align-items: center;
322
+ justify-content: center;
323
+ gap: 18px;
324
+ background: var(--canvas);
325
+ z-index: 5;
326
+ transition: opacity 260ms var(--spring);
327
+ }
328
+ body.ready .loading {
329
+ opacity: 0;
330
+ pointer-events: none;
331
+ }
332
+ .loading img {
333
+ width: 40px;
334
+ height: 40px;
335
+ opacity: 0.9;
336
+ }
337
+ .loading .spinner {
338
+ width: 22px;
339
+ height: 22px;
340
+ border: 2px solid var(--border);
341
+ border-top-color: var(--primary);
342
+ border-radius: 50%;
343
+ animation: spin 0.7s linear infinite;
344
+ }
345
+ .loading .msg {
346
+ font-size: 13px;
347
+ color: var(--muted);
348
+ }
349
+ .loading.error .spinner {
350
+ display: none;
351
+ }
352
+ .loading.error .msg {
353
+ color: color-mix(in oklch, var(--danger) 70%, var(--text));
354
+ max-width: 460px;
355
+ text-align: center;
356
+ line-height: 1.5;
357
+ }
358
+ @keyframes spin {
359
+ to {
360
+ transform: rotate(360deg);
361
+ }
362
+ }
363
+
364
+ /* ---- Export dialog ---------------------------------------------- */
365
+ dialog {
366
+ width: min(840px, 92vw);
367
+ max-height: 86vh;
368
+ border: 1px solid var(--border);
369
+ border-radius: 14px;
370
+ padding: 0;
371
+ background: var(--raised);
372
+ color: var(--text);
373
+ box-shadow: var(--shadow-modal);
374
+ overflow: hidden;
375
+ }
376
+ dialog::backdrop {
377
+ background: var(--scrim);
378
+ backdrop-filter: blur(4px);
379
+ }
380
+ dialog[open] {
381
+ animation: dialog-in 200ms var(--spring);
382
+ }
383
+ dialog[open]::backdrop {
384
+ animation: backdrop-in 200ms var(--spring);
385
+ }
386
+ @keyframes dialog-in {
387
+ from {
388
+ opacity: 0;
389
+ transform: scale(0.97) translateY(6px);
390
+ }
391
+ }
392
+ @keyframes backdrop-in {
393
+ from {
394
+ opacity: 0;
395
+ }
396
+ }
397
+ .dlg-head {
398
+ display: flex;
399
+ align-items: center;
400
+ gap: 10px;
401
+ padding: 13px 14px 13px 18px;
402
+ border-bottom: 1px solid var(--border);
403
+ }
404
+ .dlg-head strong {
405
+ font-size: 14px;
406
+ font-weight: 600;
407
+ }
408
+ .dlg-head .dlg-spacer {
409
+ flex: 1;
410
+ }
411
+ .tabs {
412
+ display: inline-flex;
413
+ gap: 4px;
414
+ }
415
+ .tab {
416
+ height: 28px;
417
+ padding: 0 12px;
418
+ border: none;
419
+ border-radius: 7px;
420
+ background: transparent;
421
+ color: var(--muted);
422
+ font: inherit;
423
+ font-size: 13px;
424
+ font-weight: 500;
425
+ cursor: pointer;
426
+ transition:
427
+ color 120ms var(--spring),
428
+ background-color 120ms var(--spring);
429
+ }
430
+ .tab:hover {
431
+ color: var(--text);
432
+ }
433
+ .tab[aria-selected="true"] {
434
+ background: var(--hover);
435
+ color: var(--text);
436
+ font-weight: 600;
437
+ }
438
+ dialog textarea {
439
+ display: block;
440
+ width: 100%;
441
+ height: 56vh;
442
+ border: none;
443
+ padding: 16px 18px;
444
+ /* Must be a semantic token: the --n-* / --paper primitives are declared
445
+ only in the light :root, so pairing one with `color: var(--text)`
446
+ rendered the export near-white on near-white in dark mode. */
447
+ background: var(--canvas);
448
+ color: var(--text);
449
+ font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
450
+ font-size: 12.5px;
451
+ line-height: 1.55;
452
+ resize: none;
453
+ outline: none;
454
+ }
455
+
456
+ /* ---- Toast ------------------------------------------------------ */
457
+ .toast {
458
+ position: fixed;
459
+ bottom: 20px;
460
+ left: 50%;
461
+ transform: translate(-50%, 8px);
462
+ display: inline-flex;
463
+ align-items: center;
464
+ gap: 8px;
465
+ background: var(--n-900);
466
+ color: var(--paper);
467
+ padding: 9px 16px;
468
+ border-radius: 999px;
469
+ font-size: 13px;
470
+ font-weight: 500;
471
+ box-shadow: 0 8px 28px oklch(18% 0.01 60 / 0.28);
472
+ opacity: 0;
473
+ pointer-events: none;
474
+ transition:
475
+ opacity 200ms var(--spring),
476
+ transform 200ms var(--spring);
477
+ z-index: 50;
478
+ }
479
+ :root[data-theme="dark"] .toast,
480
+ .toast {
481
+ /* keep the toast readable in both themes */
482
+ }
483
+ .toast.show {
484
+ opacity: 1;
485
+ transform: translate(-50%, 0);
486
+ }
487
+
488
+ /* ---- Note panel ------------------------------------------------- */
489
+ /* Alt-click a block to open this — see the capture script below for
490
+ why the target id comes from data-block-id rather than an editor
491
+ API. z-index is well above --z-modal (10000, the editor's own
492
+ popover/dialog layer) so a note stays reachable over any editor
493
+ dialog. */
494
+ #note-panel {
495
+ position: fixed;
496
+ right: 16px;
497
+ bottom: 16px;
498
+ z-index: 20000;
499
+ width: 280px;
500
+ padding: 12px;
501
+ border: 1px solid var(--border);
502
+ border-radius: 8px;
503
+ background: var(--surface);
504
+ color: var(--text);
505
+ box-shadow: 0 8px 24px oklch(18% 0.01 60 / 0.16);
506
+ font-size: 13px;
507
+ line-height: 1.4;
508
+ }
509
+ #note-panel .note-target {
510
+ color: var(--muted);
511
+ }
512
+ #note-panel textarea {
513
+ display: block;
514
+ width: 100%;
515
+ box-sizing: border-box;
516
+ margin: 8px 0;
517
+ padding: 6px 8px;
518
+ border: 1px solid var(--border);
519
+ border-radius: 6px;
520
+ background: var(--canvas);
521
+ color: var(--text);
522
+ font: inherit;
523
+ resize: vertical;
524
+ }
525
+ #note-panel #note-status {
526
+ margin: 8px 0 0;
527
+ min-height: 1.4em;
528
+ font-size: 12px;
529
+ color: var(--muted);
530
+ }
531
+ .note-actions {
532
+ display: flex;
533
+ gap: 8px;
534
+ justify-content: flex-end;
535
+ }
536
+
537
+ @media (max-width: 720px) {
538
+ .btn .label,
539
+ .brand .sub,
540
+ .brand .sep {
541
+ display: none;
542
+ }
543
+ .btn {
544
+ width: 32px;
545
+ padding: 0;
546
+ justify-content: center;
547
+ }
548
+ }
549
+
550
+ @media (prefers-reduced-motion: reduce) {
551
+ *,
552
+ *::before,
553
+ *::after {
554
+ animation-duration: 0.01ms !important;
555
+ animation-iteration-count: 1 !important;
556
+ transition-duration: 0.01ms !important;
557
+ }
558
+ }
559
+ </style>
560
+ </head>
561
+ <body>
562
+ <header class="bar">
563
+ <div class="group">
564
+ <span class="brand">
565
+ <img
566
+ src="https://templatical.com/logo.svg"
567
+ alt=""
568
+ aria-hidden="true"
569
+ />
570
+ Templatical <span class="sep">·</span>
571
+ <span class="sub">Live preview</span>
572
+ </span>
573
+ </div>
574
+
575
+ <div class="group">
576
+ <span
577
+ id="status"
578
+ class="status"
579
+ data-state="connecting"
580
+ title="Connecting to the live preview…"
581
+ >
582
+ <span class="dot"></span><span id="status-text">Connecting…</span>
583
+ </span>
584
+ <span class="divider"></span>
585
+ <button id="btn-export" class="btn" title="Export the template">
586
+ <svg
587
+ viewBox="0 0 24 24"
588
+ fill="none"
589
+ stroke="currentColor"
590
+ stroke-width="1.8"
591
+ stroke-linecap="round"
592
+ stroke-linejoin="round"
593
+ >
594
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
595
+ <polyline points="7 10 12 15 17 10" />
596
+ <line x1="12" y1="15" x2="12" y2="3" />
597
+ </svg>
598
+ <span class="label">Export</span>
599
+ </button>
600
+ <span class="divider"></span>
601
+ <button
602
+ id="btn-theme"
603
+ class="btn icon-only"
604
+ title="Toggle theme"
605
+ aria-label="Toggle theme"
606
+ ></button>
607
+ </div>
608
+ </header>
609
+
610
+ <div class="stage">
611
+ <div id="editor"></div>
612
+ <div id="loading" class="loading">
613
+ <img src="https://templatical.com/logo.svg" alt="Templatical" />
614
+ <div class="spinner" aria-hidden="true"></div>
615
+ <div class="msg" id="loading-msg">Loading the editor…</div>
616
+ </div>
617
+ </div>
618
+
619
+ <div id="note-panel" hidden>
620
+ <div class="note-target">
621
+ Note on <strong id="note-target-label">the template</strong>
622
+ </div>
623
+ <textarea
624
+ id="note-text"
625
+ rows="3"
626
+ placeholder="What should change here?"
627
+ ></textarea>
628
+ <div class="note-actions">
629
+ <button type="button" id="note-cancel" class="btn">Cancel</button>
630
+ <button type="button" id="note-save" class="btn primary">
631
+ Leave note
632
+ </button>
633
+ </div>
634
+ <p id="note-status" role="status"></p>
635
+ </div>
636
+
637
+ <dialog id="export">
638
+ <div class="dlg-head">
639
+ <strong>Export</strong>
640
+ <div class="tabs" role="tablist">
641
+ <button class="tab" data-format="json" aria-selected="true">
642
+ JSON
643
+ </button>
644
+ <button class="tab" data-format="mjml" aria-selected="false">
645
+ MJML
646
+ </button>
647
+ <button class="tab" data-format="html" aria-selected="false">
648
+ HTML
649
+ </button>
650
+ </div>
651
+ <span class="dlg-spacer"></span>
652
+ <button id="export-copy" class="btn">Copy</button>
653
+ <button id="export-close" class="btn">Close</button>
654
+ </div>
655
+ <textarea id="export-body" readonly spellcheck="false"></textarea>
656
+ </dialog>
657
+
658
+ <div id="toast" class="toast"></div>
659
+
660
+ <script type="module">
661
+ import { init } from "https://cdn.jsdelivr.net/npm/@templatical/editor@{{EDITOR_VERSION}}/dist/cdn/editor.js";
662
+
663
+ const $ = (id) => document.getElementById(id);
664
+ const statusEl = $("status");
665
+ const statusText = $("status-text");
666
+ const toastEl = $("toast");
667
+ const loadingEl = $("loading");
668
+
669
+ let editor = null;
670
+ // Guards the setContent() we trigger from an agent update so its onChange
671
+ // isn't mistaken for (and reported back as) a user hand-edit.
672
+ let applyingRemote = false;
673
+ let debounceTimer = null;
674
+
675
+ // ---- Theme (auto default, manual light/dark override) ----
676
+ const SUN =
677
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
678
+ const MOON =
679
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>';
680
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
681
+ const readTheme = () => {
682
+ try {
683
+ return localStorage.getItem("tpl-live-theme") || "auto";
684
+ } catch {
685
+ return "auto";
686
+ }
687
+ };
688
+ const effective = (stored) =>
689
+ stored === "auto" ? (prefersDark.matches ? "dark" : "light") : stored;
690
+ function applyTheme(stored) {
691
+ if (stored === "auto")
692
+ document.documentElement.removeAttribute("data-theme");
693
+ else document.documentElement.setAttribute("data-theme", stored);
694
+ const eff = effective(stored);
695
+ const btn = $("btn-theme");
696
+ btn.innerHTML = eff === "dark" ? SUN : MOON;
697
+ btn.setAttribute(
698
+ "aria-label",
699
+ eff === "dark" ? "Switch to light theme" : "Switch to dark theme",
700
+ );
701
+ if (editor) {
702
+ try {
703
+ editor.setTheme(stored);
704
+ } catch {
705
+ /* older editor build without setTheme */
706
+ }
707
+ }
708
+ }
709
+ $("btn-theme").onclick = () => {
710
+ const next = effective(readTheme()) === "dark" ? "light" : "dark";
711
+ try {
712
+ localStorage.setItem("tpl-live-theme", next);
713
+ } catch {}
714
+ applyTheme(next);
715
+ };
716
+ prefersDark.addEventListener("change", () => {
717
+ if (readTheme() === "auto") applyTheme("auto");
718
+ });
719
+ applyTheme(readTheme());
720
+
721
+ // ---- Status + toast ----
722
+ // The status pill is a connection-state signal, not decoration: when it's
723
+ // "live" the SSE link is up, so agent updates land here and your in-browser
724
+ // edits are reported back for the divergence check. The hover title spells
725
+ // that out (a native tooltip — the bar's overflow would clip a styled one).
726
+ const STATUS_TITLE = {
727
+ connecting: "Connecting to the live preview…",
728
+ live: "Connected — your edits and the agent's changes sync here in real time.",
729
+ reconnecting:
730
+ "Reconnecting to the live server — changes won't sync until it's back.",
731
+ error:
732
+ "Disconnected — changes won't sync until the connection is restored.",
733
+ };
734
+ function setStatus(state, text) {
735
+ statusEl.dataset.state = state;
736
+ statusText.textContent = text;
737
+ statusEl.title = STATUS_TITLE[state] ?? "";
738
+ }
739
+ let toastTimer = null;
740
+ function toast(msg) {
741
+ toastEl.textContent = msg;
742
+ toastEl.classList.add("show");
743
+ clearTimeout(toastTimer);
744
+ toastTimer = setTimeout(() => toastEl.classList.remove("show"), 1700);
745
+ }
746
+
747
+ async function copy(text) {
748
+ try {
749
+ await navigator.clipboard.writeText(text);
750
+ return true;
751
+ } catch {
752
+ return false;
753
+ }
754
+ }
755
+
756
+ // ---- Sync with the bridge ----
757
+ // Report the editor's current content. `baseline: true` marks a post-apply
758
+ // snapshot (the normalized view of the agent's content), which resets the
759
+ // divergence tracker rather than counting as an edit.
760
+ async function postContent(baseline = false) {
761
+ if (!editor) return;
762
+ try {
763
+ await fetch("/content", {
764
+ method: "POST",
765
+ headers: { "content-type": "application/json" },
766
+ body: JSON.stringify({ content: editor.getContent(), baseline }),
767
+ });
768
+ } catch {
769
+ /* bridge gone; ignore */
770
+ }
771
+ }
772
+ function scheduleContentPost() {
773
+ clearTimeout(debounceTimer);
774
+ debounceTimer = setTimeout(() => postContent(false), 500);
775
+ }
776
+ async function applyRemote(content) {
777
+ if (!editor) return;
778
+ applyingRemote = true;
779
+ editor.setContent(content);
780
+ await new Promise((r) => setTimeout(r, 0)); // let Vue flush setContent
781
+ await postContent(true);
782
+ applyingRemote = false;
783
+ toast("Updated from agent");
784
+ }
785
+
786
+ // ---- Export ----
787
+ // One "Export" button opens a modal with JSON / MJML / HTML tabs. JSON is
788
+ // instant; MJML calls editor.toMjml() (bundled in the CDN editor); HTML
789
+ // compiles that MJML in-browser via mjml-browser.
790
+ //
791
+ // mjml-browser loads from jsDelivr's /+esm (NOT unpkg) on purpose: it's a
792
+ // CommonJS package, and unpkg's ?module only rewrites import specifiers —
793
+ // it does not transpile CJS to ESM, so `import()` from unpkg yields an
794
+ // empty module. jsDelivr's /+esm does the CJS→ESM conversion and exposes a
795
+ // callable default.
796
+ //
797
+ // The editor bundle is on jsDelivr too, so the whole harness rides one
798
+ // CDN. It used to load from unpkg, which intermittently served the
799
+ // editor's LAZY chunks as text/plain with a failed CORS preflight — the
800
+ // entry resolved, the editor booted, and only the deferred chunk died. For
801
+ // the renderer chunk that surfaced as toMjml()'s "install @templatical/
802
+ // renderer" error (its catch can't tell a missing package from a failed
803
+ // fetch), even though the CDN build bundles the renderer.
804
+ const exportDialog = $("export");
805
+ const tabs = [...document.querySelectorAll("#export .tab")];
806
+ let mjml2html = null;
807
+
808
+ async function renderFormat(format) {
809
+ tabs.forEach((t) =>
810
+ t.setAttribute("aria-selected", String(t.dataset.format === format)),
811
+ );
812
+ const body = $("export-body");
813
+ if (!editor) return;
814
+ if (format === "json") {
815
+ body.value = JSON.stringify(editor.getContent(), null, 2);
816
+ return;
817
+ }
818
+ body.value = format === "html" ? "Compiling HTML…" : "Rendering MJML…";
819
+ try {
820
+ const mjml = await editor.toMjml();
821
+ if (format === "mjml") {
822
+ body.value = mjml;
823
+ return;
824
+ }
825
+ if (!mjml2html) {
826
+ // The major here must track the mjml that @templatical/renderer's
827
+ // round-trip tests compile against, or this preview renders
828
+ // through a different compiler than the one the renderer's
829
+ // output is actually verified with. Guarded by
830
+ // tests/cdn-pin.test.ts — a version pinned inside a CDN URL in an
831
+ // HTML file is invisible to Renovate, so nothing else here flags
832
+ // a drift.
833
+ const mod =
834
+ await import("https://cdn.jsdelivr.net/npm/mjml-browser@5/+esm");
835
+ mjml2html = mod.default ?? mod.mjml2html ?? mod;
836
+ }
837
+ body.value = mjml2html(mjml).html;
838
+ } catch (err) {
839
+ body.value = String(err?.message ?? err);
840
+ }
841
+ }
842
+
843
+ $("btn-export").onclick = () => {
844
+ exportDialog.showModal();
845
+ renderFormat("json");
846
+ };
847
+ tabs.forEach((t) => (t.onclick = () => renderFormat(t.dataset.format)));
848
+ $("export-close").onclick = () => exportDialog.close();
849
+ $("export-copy").onclick = async () => {
850
+ const ok = await copy($("export-body").value);
851
+ toast(ok ? "Copied to clipboard" : "Select the text to copy");
852
+ };
853
+
854
+ // ---- Boot ----
855
+ async function boot() {
856
+ let content;
857
+ try {
858
+ const res = await fetch("/template");
859
+ if (res.status === 200) content = await res.json();
860
+ } catch {
861
+ /* fall through to an empty editor */
862
+ }
863
+
864
+ editor = await init({
865
+ container: "#editor",
866
+ // The browser that opens this harness belongs to whoever is
867
+ // hand-editing in it, so its language is the right one for the
868
+ // chrome. Optional-chained: a runtime that exposes no `navigator`
869
+ // must not abort the mount, and the editor falls back to English for
870
+ // any tag it has no bundle for.
871
+ locale: navigator?.language || undefined,
872
+ content, // undefined → editor starts from its default empty template
873
+ uiTheme: readTheme(),
874
+ onChange: () => {
875
+ if (!applyingRemote) scheduleContentPost();
876
+ },
877
+ });
878
+
879
+ document.body.classList.add("ready");
880
+ await postContent(true); // seed the baseline with the normalized view
881
+ setStatus("live", "Live");
882
+
883
+ const es = new EventSource("/events");
884
+ es.addEventListener("template", (e) => {
885
+ try {
886
+ applyRemote(JSON.parse(e.data));
887
+ } catch {
888
+ /* malformed frame; ignore */
889
+ }
890
+ });
891
+ es.onopen = () => setStatus("live", "Live");
892
+ es.onerror = () => setStatus("reconnecting", "Reconnecting…");
893
+ }
894
+
895
+ boot().catch((err) => {
896
+ setStatus("error", "Editor failed to load");
897
+ loadingEl.classList.add("error");
898
+ $("loading-msg").textContent =
899
+ "The editor couldn't load from the CDN. Check your connection and reload.\n\n" +
900
+ String(err?.message ?? err);
901
+ });
902
+
903
+ // ---- Notes ----
904
+ // Notes are attached by clicking a block. The editor exposes no
905
+ // selection accessor, so the id comes from data-block-id on the
906
+ // click's composedPath - an attribute the editor's own e2e suite
907
+ // locks, and documented in-repo as a hook for consumers. composedPath
908
+ // (not event.target) is required because the editor mounts in shadow
909
+ // DOM and clicks retarget at the boundary.
910
+ const notePanel = $("note-panel");
911
+ const noteText = $("note-text");
912
+ const noteLabel = $("note-target-label");
913
+ const noteStatus = $("note-status");
914
+ let noteBlockId = null;
915
+
916
+ function blockIdFromEvent(event) {
917
+ for (const el of event.composedPath()) {
918
+ const id = el instanceof HTMLElement ? el.dataset?.blockId : undefined;
919
+ if (id) return id;
920
+ }
921
+ return null;
922
+ }
923
+
924
+ // Capture phase, not bubble: the editor's own BlockWrapper.handleClick
925
+ // calls stopPropagation() (so a click inside a section picks the
926
+ // section, not a stray column child) - a bubble listener on document
927
+ // never sees a click on a block. Capture runs root-to-target, before
928
+ // that stopPropagation is reached, so it is unaffected.
929
+ document.addEventListener(
930
+ "click",
931
+ (event) => {
932
+ if (notePanel.contains(event.target)) return;
933
+ // Alt-click opens the note panel; a plain click is the editor's own.
934
+ if (!event.altKey) return;
935
+ noteBlockId = blockIdFromEvent(event);
936
+ noteLabel.textContent = noteBlockId ? noteBlockId : "the template";
937
+ noteStatus.textContent = "";
938
+ noteText.value = "";
939
+ notePanel.hidden = false;
940
+ noteText.focus();
941
+ },
942
+ true,
943
+ );
944
+
945
+ $("note-cancel").onclick = () => {
946
+ notePanel.hidden = true;
947
+ };
948
+
949
+ $("note-save").onclick = async () => {
950
+ const text = noteText.value.trim();
951
+ if (!text) {
952
+ noteStatus.textContent = "Write something first.";
953
+ return;
954
+ }
955
+ try {
956
+ const res = await fetch("/annotations", {
957
+ method: "POST",
958
+ headers: { "content-type": "application/json" },
959
+ body: JSON.stringify({ blockId: noteBlockId, text }),
960
+ });
961
+ if (res.ok) {
962
+ notePanel.hidden = true;
963
+ // Say plainly when it will be acted on: nothing can wake the
964
+ // agent from this page, so the note is picked up on its next
965
+ // turn. Reuses the existing toast() rather than a second
966
+ // notification affordance.
967
+ noteStatus.textContent = "";
968
+ toast("Note saved — your agent will see it on its next change.");
969
+ } else {
970
+ noteStatus.textContent = "Could not save that note.";
971
+ }
972
+ } catch {
973
+ noteStatus.textContent = "Could not reach the bridge.";
974
+ }
975
+ };
976
+ </script>
977
+ </body>
978
+ </html>