@templatical/template-tools 0.42.0 → 0.43.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.
package/live/index.html CHANGED
@@ -92,6 +92,26 @@
92
92
  --scrim: oklch(10% 0.01 60 / 0.6);
93
93
  color-scheme: dark;
94
94
  }
95
+ :root:not([data-theme="light"]) .btn.primary,
96
+ :root:not([data-theme="light"]) .btn.primary:hover {
97
+ background: var(--text);
98
+ color: var(--canvas);
99
+ }
100
+ :root:not([data-theme="light"]) .btn.primary:hover {
101
+ background: color-mix(in oklch, var(--text) 86%, var(--canvas));
102
+ }
103
+ :root:not([data-theme="light"]) #annotate-toggle[aria-pressed="true"],
104
+ :root:not([data-theme="light"])
105
+ #annotate-toggle[aria-pressed="true"]:hover {
106
+ color: var(--text);
107
+ }
108
+ :root:not([data-theme="light"]) #annotate-count,
109
+ :root:not([data-theme="light"])
110
+ #annotate-toggle[aria-pressed="true"]
111
+ #annotate-count {
112
+ background: var(--text);
113
+ color: var(--canvas);
114
+ }
95
115
  }
96
116
  :root[data-theme="dark"] {
97
117
  --canvas: oklch(16% 0.008 60);
@@ -110,7 +130,25 @@
110
130
  --scrim: oklch(10% 0.01 60 / 0.6);
111
131
  color-scheme: dark;
112
132
  }
113
-
133
+ :root[data-theme="dark"] .btn.primary,
134
+ :root[data-theme="dark"] .btn.primary:hover {
135
+ background: var(--text);
136
+ color: var(--canvas);
137
+ }
138
+ :root[data-theme="dark"] .btn.primary:hover {
139
+ background: color-mix(in oklch, var(--text) 86%, var(--canvas));
140
+ }
141
+ :root[data-theme="dark"] #annotate-toggle[aria-pressed="true"],
142
+ :root[data-theme="dark"] #annotate-toggle[aria-pressed="true"]:hover {
143
+ color: var(--text);
144
+ }
145
+ :root[data-theme="dark"] #annotate-count,
146
+ :root[data-theme="dark"]
147
+ #annotate-toggle[aria-pressed="true"]
148
+ #annotate-count {
149
+ background: var(--text);
150
+ color: var(--canvas);
151
+ }
114
152
  * {
115
153
  box-sizing: border-box;
116
154
  }
@@ -139,17 +177,15 @@
139
177
  align-items: center;
140
178
  justify-content: space-between;
141
179
  gap: 12px;
142
- height: 52px;
180
+ height: 56px;
143
181
  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;
182
+ padding: 0 16px;
183
+ position: relative;
184
+ background: var(--canvas);
185
+ z-index: 20;
150
186
  }
151
- .bar::-webkit-scrollbar {
152
- display: none;
187
+ #settings {
188
+ position: relative;
153
189
  }
154
190
  .bar .group {
155
191
  display: flex;
@@ -182,6 +218,7 @@
182
218
  }
183
219
 
184
220
  .status {
221
+ position: relative;
185
222
  display: inline-flex;
186
223
  align-items: center;
187
224
  gap: 7px;
@@ -194,6 +231,30 @@
194
231
  color: var(--muted);
195
232
  white-space: nowrap;
196
233
  }
234
+ .status-tip {
235
+ position: absolute;
236
+ top: calc(100% + 8px);
237
+ left: 0;
238
+ z-index: 2;
239
+ width: max-content;
240
+ max-width: 260px;
241
+ padding: 8px 10px;
242
+ border: 1px solid var(--border);
243
+ border-radius: 8px;
244
+ background: var(--surface);
245
+ color: var(--text);
246
+ font-size: 12px;
247
+ font-weight: 500;
248
+ line-height: 1.4;
249
+ white-space: normal;
250
+ box-shadow: 0 12px 32px oklch(18% 0.01 60 / 0.14);
251
+ opacity: 0;
252
+ pointer-events: none;
253
+ }
254
+ .status:hover .status-tip,
255
+ .status:focus-visible .status-tip {
256
+ opacity: 1;
257
+ }
197
258
  .status .dot {
198
259
  width: 7px;
199
260
  height: 7px;
@@ -289,24 +350,164 @@
289
350
  justify-content: center;
290
351
  }
291
352
  .btn.primary {
292
- background: var(--primary);
353
+ background: var(--n-900);
293
354
  border-color: transparent;
294
355
  color: var(--paper);
295
356
  }
296
357
  .btn.primary:hover {
297
- background: var(--primary-hover);
358
+ background: var(--n-700);
298
359
  color: var(--paper);
299
360
  }
361
+ /* Pressed Annotate is a tint, not a second amber fill. The editor
362
+ toolbar under this bar already uses amber for its own active state. */
363
+ #annotate-toggle[aria-pressed="true"] {
364
+ background: var(--primary-tint);
365
+ border-color: var(--primary);
366
+ color: var(--n-900);
367
+ }
368
+ #annotate-toggle[aria-pressed="true"]:hover {
369
+ background: var(--primary-tint);
370
+ border-color: var(--primary);
371
+ color: var(--n-900);
372
+ }
373
+ #annotate-count[hidden] {
374
+ display: none;
375
+ }
376
+ #annotate-count {
377
+ display: inline-flex;
378
+ align-items: center;
379
+ justify-content: center;
380
+ min-width: 16px;
381
+ height: 16px;
382
+ padding: 0 4px;
383
+ border-radius: 999px;
384
+ background: var(--n-900);
385
+ color: var(--paper);
386
+ font-size: 11px;
387
+ font-weight: 600;
388
+ line-height: 16px;
389
+ }
390
+ #annotate-toggle[aria-pressed="true"] #annotate-count {
391
+ background: var(--n-900);
392
+ color: var(--paper);
393
+ }
394
+
395
+ .settings-panel {
396
+ position: absolute;
397
+ top: calc(100% + 8px);
398
+ right: 0;
399
+ z-index: 1;
400
+ width: 240px;
401
+ padding: 12px;
402
+ border: 1px solid var(--border);
403
+ border-radius: 8px;
404
+ background: var(--surface);
405
+ color: var(--text);
406
+ box-shadow:
407
+ 0 10px 15px -3px oklch(18% 0.01 60 / 0.12),
408
+ 0 4px 6px -4px oklch(18% 0.01 60 / 0.08);
409
+ transform-origin: top right;
410
+ }
411
+ .settings-panel[hidden] {
412
+ display: none;
413
+ }
414
+ .settings-label {
415
+ display: block;
416
+ margin: 0 0 6px;
417
+ padding: 0;
418
+ font-size: 11px;
419
+ font-weight: 500;
420
+ letter-spacing: 0.04em;
421
+ text-transform: uppercase;
422
+ color: var(--muted);
423
+ }
424
+ .settings-label.spaced {
425
+ margin-top: 12px;
426
+ }
427
+ .theme-field {
428
+ margin: 0;
429
+ padding: 0;
430
+ border: 0;
431
+ }
432
+ .theme-options {
433
+ display: grid;
434
+ grid-template-columns: repeat(3, 1fr);
435
+ gap: 4px;
436
+ margin: 0;
437
+ padding: 4px;
438
+ border-radius: 6px;
439
+ background: var(--canvas);
440
+ }
441
+ .theme-options label {
442
+ position: relative;
443
+ display: flex;
444
+ align-items: center;
445
+ justify-content: center;
446
+ gap: 6px;
447
+ height: 32px;
448
+ border: 0;
449
+ border-radius: 4px;
450
+ background: transparent;
451
+ color: var(--muted);
452
+ font-size: 12px;
453
+ font-weight: 500;
454
+ cursor: pointer;
455
+ }
456
+ .theme-options label:hover {
457
+ color: var(--text);
458
+ }
459
+ .theme-options svg {
460
+ width: 14px;
461
+ height: 14px;
462
+ flex: none;
463
+ }
464
+ .theme-options input {
465
+ position: absolute;
466
+ opacity: 0;
467
+ pointer-events: none;
468
+ }
469
+ .theme-options label:has(input:checked) {
470
+ background: var(--surface);
471
+ color: var(--text);
472
+ box-shadow: 0 1px 2px oklch(18% 0.01 60 / 0.08);
473
+ }
474
+ #live-locale {
475
+ width: 100%;
476
+ height: 36px;
477
+ padding: 0 28px 0 10px;
478
+ border: 1px solid var(--border);
479
+ border-radius: 8px;
480
+ background-color: var(--surface);
481
+ color: var(--text);
482
+ font: inherit;
483
+ font-size: 13px;
484
+ appearance: none;
485
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2378706a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
486
+ background-repeat: no-repeat;
487
+ background-position: right 10px center;
488
+ }
489
+ #live-locale:focus-visible {
490
+ border-color: var(--primary);
491
+ box-shadow: 0 0 0 3px var(--ring);
492
+ }
300
493
  :focus-visible {
301
494
  outline: none;
302
495
  box-shadow: 0 0 0 3px var(--ring);
303
496
  }
304
497
 
305
498
  /* ---- Editor + loading ------------------------------------------- */
499
+ /* The editor fills this card. The 15px well is the margin outside the border. */
306
500
  .stage {
307
501
  position: relative;
502
+ z-index: 0;
308
503
  flex: 1;
309
504
  min-height: 0;
505
+ margin: 0 15px 15px;
506
+ border: 1px solid var(--border);
507
+ border-radius: 8px;
508
+ overflow: hidden;
509
+ background: var(--surface);
510
+ box-shadow: 0 1px 2px oklch(18% 0.01 60 / 0.06);
310
511
  }
311
512
  #editor {
312
513
  position: absolute;
@@ -321,7 +522,7 @@
321
522
  align-items: center;
322
523
  justify-content: center;
323
524
  gap: 18px;
324
- background: var(--canvas);
525
+ background: var(--surface);
325
526
  z-index: 5;
326
527
  transition: opacity 260ms var(--spring);
327
528
  }
@@ -366,7 +567,7 @@
366
567
  width: min(840px, 92vw);
367
568
  max-height: 86vh;
368
569
  border: 1px solid var(--border);
369
- border-radius: 14px;
570
+ border-radius: 12px;
370
571
  padding: 0;
371
572
  background: var(--raised);
372
573
  color: var(--text);
@@ -486,23 +687,22 @@
486
687
  }
487
688
 
488
689
  /* ---- 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. */
690
+ /* Opened from the Annotate toggle. The target id comes from
691
+ data-block-id on the click path. z-index is well above the editor's
692
+ popover layer (literal 10000) so a note stays reachable over any
693
+ editor dialog. */
494
694
  #note-panel {
495
695
  position: fixed;
496
696
  right: 16px;
497
697
  bottom: 16px;
498
698
  z-index: 20000;
499
- width: 280px;
500
- padding: 12px;
699
+ width: 300px;
700
+ padding: 14px;
501
701
  border: 1px solid var(--border);
502
702
  border-radius: 8px;
503
703
  background: var(--surface);
504
704
  color: var(--text);
505
- box-shadow: 0 8px 24px oklch(18% 0.01 60 / 0.16);
705
+ box-shadow: 0 12px 32px oklch(18% 0.01 60 / 0.14);
506
706
  font-size: 13px;
507
707
  line-height: 1.4;
508
708
  }
@@ -533,6 +733,54 @@
533
733
  gap: 8px;
534
734
  justify-content: flex-end;
535
735
  }
736
+ #note-list {
737
+ list-style: none;
738
+ margin: 0 0 8px;
739
+ padding: 0;
740
+ max-height: 200px;
741
+ overflow-y: auto;
742
+ }
743
+ #note-list li {
744
+ display: grid;
745
+ grid-template-columns: auto 1fr auto;
746
+ gap: 6px;
747
+ align-items: start;
748
+ padding: 6px 0;
749
+ border-bottom: 1px solid var(--border);
750
+ }
751
+ #note-list button.note-row {
752
+ display: contents;
753
+ font: inherit;
754
+ color: inherit;
755
+ background: none;
756
+ border: 0;
757
+ text-align: left;
758
+ cursor: pointer;
759
+ }
760
+ #note-footer {
761
+ margin: 8px 0 0;
762
+ color: var(--muted);
763
+ font-size: 12px;
764
+ }
765
+ #note-badges {
766
+ position: fixed;
767
+ inset: 0;
768
+ z-index: 20;
769
+ pointer-events: none;
770
+ }
771
+ .note-badge {
772
+ position: fixed;
773
+ height: 18px;
774
+ min-width: 18px;
775
+ padding: 0 5px;
776
+ border: 0;
777
+ border-radius: 999px;
778
+ background: var(--primary);
779
+ color: var(--n-900);
780
+ font: 600 11px/18px Geist, ui-sans-serif, system-ui, sans-serif;
781
+ pointer-events: auto;
782
+ cursor: pointer;
783
+ }
536
784
 
537
785
  @media (max-width: 720px) {
538
786
  .btn .label,
@@ -577,12 +825,32 @@
577
825
  id="status"
578
826
  class="status"
579
827
  data-state="connecting"
580
- title="Connecting to the live preview…"
828
+ tabindex="0"
829
+ aria-describedby="status-tip"
581
830
  >
582
831
  <span class="dot"></span><span id="status-text">Connecting…</span>
832
+ <span id="status-tip" class="status-tip" role="tooltip">
833
+ Connecting to the live preview…
834
+ </span>
583
835
  </span>
584
836
  <span class="divider"></span>
585
- <button id="btn-export" class="btn" title="Export the template">
837
+ <button
838
+ id="annotate-toggle"
839
+ type="button"
840
+ class="btn"
841
+ aria-pressed="false"
842
+ title="Leave notes for your agent"
843
+ >
844
+ <span class="label">Annotate</span>
845
+ <span id="annotate-count" hidden></span>
846
+ </button>
847
+ <button
848
+ id="btn-export"
849
+ type="button"
850
+ class="btn icon-only"
851
+ title="Export the template"
852
+ aria-label="Export the template"
853
+ >
586
854
  <svg
587
855
  viewBox="0 0 24 24"
588
856
  fill="none"
@@ -595,20 +863,108 @@
595
863
  <polyline points="7 10 12 15 17 10" />
596
864
  <line x1="12" y1="15" x2="12" y2="3" />
597
865
  </svg>
598
- <span class="label">Export</span>
599
866
  </button>
600
867
  <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>
868
+ <div id="settings">
869
+ <button
870
+ id="btn-settings"
871
+ type="button"
872
+ class="btn icon-only"
873
+ title="Settings"
874
+ aria-label="Settings"
875
+ aria-haspopup="dialog"
876
+ aria-expanded="false"
877
+ aria-controls="settings-panel"
878
+ >
879
+ <svg
880
+ viewBox="0 0 24 24"
881
+ fill="none"
882
+ stroke="currentColor"
883
+ stroke-width="1.8"
884
+ stroke-linecap="round"
885
+ stroke-linejoin="round"
886
+ aria-hidden="true"
887
+ >
888
+ <path d="M20 7h-9" />
889
+ <path d="M14 17H5" />
890
+ <circle cx="17" cy="17" r="3" />
891
+ <circle cx="7" cy="7" r="3" />
892
+ </svg>
893
+ </button>
894
+ <div
895
+ id="settings-panel"
896
+ class="settings-panel"
897
+ role="dialog"
898
+ aria-label="Settings"
899
+ hidden
900
+ >
901
+ <fieldset class="theme-field">
902
+ <legend class="settings-label">Theme</legend>
903
+ <div class="theme-options">
904
+ <label>
905
+ <input type="radio" name="live-theme" value="auto" />
906
+ <svg
907
+ viewBox="0 0 24 24"
908
+ fill="none"
909
+ stroke="currentColor"
910
+ stroke-width="1.5"
911
+ stroke-linecap="round"
912
+ stroke-linejoin="round"
913
+ aria-hidden="true"
914
+ >
915
+ <rect width="20" height="14" x="2" y="3" rx="2" />
916
+ <path d="M8 21h8" />
917
+ <path d="M12 17v4" />
918
+ </svg>
919
+ System
920
+ </label>
921
+ <label>
922
+ <input type="radio" name="live-theme" value="light" />
923
+ <svg
924
+ viewBox="0 0 24 24"
925
+ fill="none"
926
+ stroke="currentColor"
927
+ stroke-width="1.5"
928
+ stroke-linecap="round"
929
+ stroke-linejoin="round"
930
+ aria-hidden="true"
931
+ >
932
+ <circle cx="12" cy="12" r="4" />
933
+ <path
934
+ 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"
935
+ />
936
+ </svg>
937
+ Light
938
+ </label>
939
+ <label>
940
+ <input type="radio" name="live-theme" value="dark" />
941
+ <svg
942
+ viewBox="0 0 24 24"
943
+ fill="none"
944
+ stroke="currentColor"
945
+ stroke-width="1.5"
946
+ stroke-linecap="round"
947
+ stroke-linejoin="round"
948
+ aria-hidden="true"
949
+ >
950
+ <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
951
+ </svg>
952
+ Dark
953
+ </label>
954
+ </div>
955
+ </fieldset>
956
+ <label class="settings-label spaced" for="live-locale">
957
+ Language
958
+ </label>
959
+ <select id="live-locale"></select>
960
+ </div>
961
+ </div>
607
962
  </div>
608
963
  </header>
609
964
 
610
965
  <div class="stage">
611
966
  <div id="editor"></div>
967
+ <div id="note-badges"></div>
612
968
  <div id="loading" class="loading">
613
969
  <img src="https://templatical.com/logo.svg" alt="Templatical" />
614
970
  <div class="spinner" aria-hidden="true"></div>
@@ -617,6 +973,7 @@
617
973
  </div>
618
974
 
619
975
  <div id="note-panel" hidden>
976
+ <ol id="note-list"></ol>
620
977
  <div class="note-target">
621
978
  Note on <strong id="note-target-label">the template</strong>
622
979
  </div>
@@ -632,6 +989,7 @@
632
989
  </button>
633
990
  </div>
634
991
  <p id="note-status" role="status"></p>
992
+ <p id="note-footer">Ask your agent to apply these.</p>
635
993
  </div>
636
994
 
637
995
  <dialog id="export">
@@ -658,7 +1016,10 @@
658
1016
  <div id="toast" class="toast"></div>
659
1017
 
660
1018
  <script type="module">
661
- import { init } from "https://cdn.jsdelivr.net/npm/@templatical/editor@{{EDITOR_VERSION}}/dist/cdn/editor.js";
1019
+ import {
1020
+ getSupportedLocales,
1021
+ init,
1022
+ } from "https://cdn.jsdelivr.net/npm/@templatical/editor@{{EDITOR_VERSION}}/dist/cdn/editor.js";
662
1023
 
663
1024
  const $ = (id) => document.getElementById(id);
664
1025
  const statusEl = $("status");
@@ -672,11 +1033,11 @@
672
1033
  let applyingRemote = false;
673
1034
  let debounceTimer = null;
674
1035
 
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>';
1036
+ // ---- Settings: theme (auto / light / dark) and editor UI locale ----
1037
+ // Locale is read once at init. There is no setLocale, so a language
1038
+ // change remounts with the current template. The page's own labels stay
1039
+ // English. This does not write settings.locale — that is the email's
1040
+ // language, owned by Template Settings.
680
1041
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
681
1042
  const readTheme = () => {
682
1043
  try {
@@ -685,19 +1046,27 @@
685
1046
  return "auto";
686
1047
  }
687
1048
  };
688
- const effective = (stored) =>
689
- stored === "auto" ? (prefersDark.matches ? "dark" : "light") : stored;
1049
+ function readLocale() {
1050
+ try {
1051
+ const stored = localStorage.getItem("tpl-live-locale");
1052
+ if (stored) return stored;
1053
+ } catch {
1054
+ /* private mode */
1055
+ }
1056
+ // The browser that opens this harness belongs to whoever is
1057
+ // hand-editing in it. Optional-chained: a runtime with no navigator
1058
+ // must not abort the mount. The editor falls back to English for a
1059
+ // tag it has no bundle for.
1060
+ return navigator?.language || undefined;
1061
+ }
690
1062
  function applyTheme(stored) {
691
1063
  if (stored === "auto")
692
1064
  document.documentElement.removeAttribute("data-theme");
693
1065
  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",
1066
+ const input = document.querySelector(
1067
+ `input[name="live-theme"][value="${stored}"]`,
700
1068
  );
1069
+ if (input) input.checked = true;
701
1070
  if (editor) {
702
1071
  try {
703
1072
  editor.setTheme(stored);
@@ -706,35 +1075,104 @@
706
1075
  }
707
1076
  }
708
1077
  }
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
- };
1078
+ document.querySelectorAll('input[name="live-theme"]').forEach((input) => {
1079
+ input.addEventListener("change", () => {
1080
+ if (!input.checked) return;
1081
+ try {
1082
+ localStorage.setItem("tpl-live-theme", input.value);
1083
+ } catch {
1084
+ /* private mode */
1085
+ }
1086
+ applyTheme(input.value);
1087
+ });
1088
+ });
716
1089
  prefersDark.addEventListener("change", () => {
717
1090
  if (readTheme() === "auto") applyTheme("auto");
718
1091
  });
719
1092
  applyTheme(readTheme());
720
1093
 
1094
+ const settingsPanel = $("settings-panel");
1095
+ const settingsButton = $("btn-settings");
1096
+ function closeSettings() {
1097
+ settingsPanel.hidden = true;
1098
+ settingsButton.setAttribute("aria-expanded", "false");
1099
+ }
1100
+ function openSettings() {
1101
+ settingsPanel.hidden = false;
1102
+ settingsButton.setAttribute("aria-expanded", "true");
1103
+ }
1104
+ settingsButton.onclick = () => {
1105
+ if (settingsPanel.hidden) openSettings();
1106
+ else closeSettings();
1107
+ };
1108
+ document.addEventListener(
1109
+ "mousedown",
1110
+ (event) => {
1111
+ if (settingsPanel.hidden) return;
1112
+ if ($("settings").contains(event.target)) return;
1113
+ closeSettings();
1114
+ },
1115
+ true,
1116
+ );
1117
+ document.addEventListener(
1118
+ "keydown",
1119
+ (event) => {
1120
+ if (event.key !== "Escape" || settingsPanel.hidden) return;
1121
+ event.preventDefault();
1122
+ event.stopPropagation();
1123
+ closeSettings();
1124
+ settingsButton.focus();
1125
+ },
1126
+ true,
1127
+ );
1128
+
1129
+ function localeLabel(code) {
1130
+ try {
1131
+ const name = new Intl.DisplayNames([code], { type: "language" }).of(
1132
+ code,
1133
+ );
1134
+ if (name && name.toLowerCase() !== code.toLowerCase()) {
1135
+ return name.charAt(0).toLocaleUpperCase(code) + name.slice(1);
1136
+ }
1137
+ } catch {
1138
+ /* a runtime without DisplayNames keeps the code */
1139
+ }
1140
+ return code;
1141
+ }
1142
+ const localeSelect = $("live-locale");
1143
+ const supportedLocales = getSupportedLocales()
1144
+ .slice()
1145
+ .sort((a, b) => localeLabel(a).localeCompare(localeLabel(b)));
1146
+ for (const code of supportedLocales) {
1147
+ const option = document.createElement("option");
1148
+ option.value = code;
1149
+ option.textContent = localeLabel(code);
1150
+ localeSelect.appendChild(option);
1151
+ }
1152
+ function localeChoice(tag) {
1153
+ if (tag && supportedLocales.includes(tag)) return tag;
1154
+ const base = tag ? tag.split("-")[0] : "";
1155
+ if (base && supportedLocales.includes(base)) return base;
1156
+ return supportedLocales.includes("en") ? "en" : supportedLocales[0];
1157
+ }
1158
+ localeSelect.value = localeChoice(readLocale());
1159
+
721
1160
  // ---- 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).
1161
+ // The status pill is the connection to this preview server. "Live" means
1162
+ // the page can receive the agent's updates and report hand-edits back.
1163
+ // The green dot pulses only in that state.
726
1164
  const STATUS_TITLE = {
727
1165
  connecting: "Connecting to the live preview…",
728
- live: "Connected — your edits and the agent's changes sync here in real time.",
1166
+ live: "Connected. Edits here and the agent's changes both show up on this page.",
729
1167
  reconnecting:
730
- "Reconnecting to the live server — changes won't sync until it's back.",
1168
+ "Reconnecting to the live server. Changes won't sync until it's back.",
731
1169
  error:
732
- "Disconnected — changes won't sync until the connection is restored.",
1170
+ "Disconnected. Changes won't sync until the connection is restored.",
733
1171
  };
734
1172
  function setStatus(state, text) {
735
1173
  statusEl.dataset.state = state;
736
1174
  statusText.textContent = text;
737
- statusEl.title = STATUS_TITLE[state] ?? "";
1175
+ $("status-tip").textContent = STATUS_TITLE[state] ?? "";
738
1176
  }
739
1177
  let toastTimer = null;
740
1178
  function toast(msg) {
@@ -780,6 +1218,9 @@
780
1218
  await new Promise((r) => setTimeout(r, 0)); // let Vue flush setContent
781
1219
  await postContent(true);
782
1220
  applyingRemote = false;
1221
+ // The reload may have consumed the note queue. The template frame does
1222
+ // not carry it, so re-read it or the badges stay until a manual refresh.
1223
+ await refreshNotes();
783
1224
  toast("Updated from agent");
784
1225
  }
785
1226
 
@@ -851,7 +1292,41 @@
851
1292
  toast(ok ? "Copied to clipboard" : "Select the text to copy");
852
1293
  };
853
1294
 
1295
+ localeSelect.onchange = async () => {
1296
+ const next = localeSelect.value;
1297
+ try {
1298
+ localStorage.setItem("tpl-live-locale", next);
1299
+ } catch {
1300
+ /* private mode */
1301
+ }
1302
+ const content = editor ? editor.getContent() : undefined;
1303
+ await mountEditor(content);
1304
+ };
1305
+
854
1306
  // ---- Boot ----
1307
+ async function mountEditor(content) {
1308
+ applyingRemote = true;
1309
+ try {
1310
+ if (editor) {
1311
+ editor.unmount();
1312
+ editor = null;
1313
+ }
1314
+ editor = await init({
1315
+ container: "#editor",
1316
+ locale: readLocale(),
1317
+ content, // undefined → editor starts from its default empty template
1318
+ uiTheme: readTheme(),
1319
+ onChange: () => {
1320
+ if (!applyingRemote) scheduleContentPost();
1321
+ },
1322
+ });
1323
+ await postContent(true); // seed the baseline with the normalized view
1324
+ await refreshNotes();
1325
+ } finally {
1326
+ applyingRemote = false;
1327
+ }
1328
+ }
1329
+
855
1330
  async function boot() {
856
1331
  let content;
857
1332
  try {
@@ -861,23 +1336,8 @@
861
1336
  /* fall through to an empty editor */
862
1337
  }
863
1338
 
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
-
1339
+ await mountEditor(content);
879
1340
  document.body.classList.add("ready");
880
- await postContent(true); // seed the baseline with the normalized view
881
1341
  setStatus("live", "Live");
882
1342
 
883
1343
  const es = new EventSource("/events");
@@ -901,49 +1361,360 @@
901
1361
  });
902
1362
 
903
1363
  // ---- 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.
1364
+ // Annotate mode takes canvas clicks. BlockWrapper.handleClick calls
1365
+ // stopPropagation() on bubble, so the listeners stay on the capture phase.
1366
+ // mousedown is stopped too: that is the event that focuses contenteditable.
1367
+ // The editor's header, sidebars, and properties panel are outside .tpl-canvas
1368
+ // and still receive clicks. composedPath (not event.target) is required
1369
+ // because the editor mounts in shadow DOM and clicks retarget at the boundary.
910
1370
  const notePanel = $("note-panel");
911
1371
  const noteText = $("note-text");
912
1372
  const noteLabel = $("note-target-label");
913
1373
  const noteStatus = $("note-status");
914
- let noteBlockId = null;
1374
+ const annotateToggle = $("annotate-toggle");
1375
+ let annotating = false;
1376
+ let panelDismissed = false;
1377
+ let draftTarget = null;
1378
+ let notes = [];
1379
+ let editingId = null;
1380
+
1381
+ function canvasRoot() {
1382
+ const host = document.querySelector("#editor");
1383
+ return host && host.shadowRoot;
1384
+ }
1385
+
1386
+ function blockEl(id) {
1387
+ const root = canvasRoot();
1388
+ if (!root || !id) return null;
1389
+ return root.querySelector(
1390
+ ".tpl-canvas [data-block-id='" + CSS.escape(id) + "']",
1391
+ );
1392
+ }
1393
+
1394
+ function labelFromElement(el) {
1395
+ return (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80);
1396
+ }
915
1397
 
916
- function blockIdFromEvent(event) {
1398
+ function blockFromPath(event) {
1399
+ let blockId = null;
1400
+ let parentBlockId = null;
1401
+ let blockType = null;
1402
+ let label = "";
917
1403
  for (const el of event.composedPath()) {
918
- const id = el instanceof HTMLElement ? el.dataset?.blockId : undefined;
919
- if (id) return id;
1404
+ if (!(el instanceof HTMLElement)) continue;
1405
+ if (!el.closest(".tpl-canvas")) continue;
1406
+ const id = el.dataset.blockId;
1407
+ if (!id) continue;
1408
+ if (!blockId) {
1409
+ blockId = id;
1410
+ blockType = el.dataset.blockType || null;
1411
+ label = labelFromElement(el);
1412
+ } else {
1413
+ parentBlockId = id;
1414
+ break;
1415
+ }
920
1416
  }
921
- return null;
1417
+ if (!blockId) return null;
1418
+ return { blockId, parentBlockId, blockType, label };
922
1419
  }
923
1420
 
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";
1421
+ function pathHitsCanvas(event) {
1422
+ return event.composedPath().some(
1423
+ (el) => el instanceof Element && el.closest(".tpl-canvas"),
1424
+ );
1425
+ }
1426
+
1427
+ function syncChrome() {
1428
+ annotateToggle.setAttribute("aria-pressed", String(annotating));
1429
+ const count = $("annotate-count");
1430
+ count.hidden = notes.length === 0;
1431
+ count.textContent = String(notes.length);
1432
+ notePanel.hidden =
1433
+ panelDismissed || (!annotating && notes.length === 0);
1434
+ }
1435
+
1436
+ function setAnnotating(next) {
1437
+ annotating = next;
1438
+ if (next) panelDismissed = false;
1439
+ if (!next) {
1440
+ editingId = null;
1441
+ draftTarget = null;
1442
+ noteText.value = "";
937
1443
  noteStatus.textContent = "";
1444
+ $("note-save").textContent = "Leave note";
1445
+ }
1446
+ syncChrome();
1447
+ }
1448
+
1449
+ annotateToggle.onclick = () => setAnnotating(!annotating);
1450
+
1451
+ function showComposer(target, id) {
1452
+ panelDismissed = false;
1453
+ editingId = id;
1454
+ const note = id ? notes.find((item) => item.id === id) : null;
1455
+ if (note) {
1456
+ noteLabel.textContent = note.blockId
1457
+ ? (note.blockType || "Block") + (note.label ? " · " + note.label : "")
1458
+ : "the template";
1459
+ noteText.value = note.text;
1460
+ } else if (target) {
1461
+ noteLabel.textContent =
1462
+ (target.blockType || "Block") + (target.label ? " · " + target.label : "");
938
1463
  noteText.value = "";
939
- notePanel.hidden = false;
940
- noteText.focus();
941
- },
1464
+ } else {
1465
+ noteLabel.textContent = "the template";
1466
+ noteText.value = "";
1467
+ }
1468
+ $("note-save").textContent = id ? "Save" : "Leave note";
1469
+ noteStatus.textContent = "";
1470
+ notePanel.hidden = false;
1471
+ noteText.focus();
1472
+ }
1473
+
1474
+ function renderNotes() {
1475
+ const list = $("note-list");
1476
+ list.replaceChildren();
1477
+ notes.forEach((note, index) => {
1478
+ const li = document.createElement("li");
1479
+ const row = document.createElement("button");
1480
+ row.type = "button";
1481
+ row.className = "note-row";
1482
+ const num = document.createElement("span");
1483
+ num.textContent = String(index + 1);
1484
+ const body = document.createElement("span");
1485
+ const title = note.blockId
1486
+ ? (note.blockType || "Block") + (note.label ? " · " + note.label : "")
1487
+ : "Whole template";
1488
+ const gone =
1489
+ note.blockId && !blockEl(note.blockId)
1490
+ ? " This block is no longer on the canvas."
1491
+ : "";
1492
+ body.textContent = title + " — " + note.text + gone;
1493
+ row.append(num, body);
1494
+ row.onclick = () => showComposer(null, note.id);
1495
+ const remove = document.createElement("button");
1496
+ remove.type = "button";
1497
+ remove.className = "btn";
1498
+ remove.textContent = "Delete";
1499
+ remove.onclick = async () => {
1500
+ const res = await fetch(
1501
+ "/annotations/" + encodeURIComponent(note.id),
1502
+ { method: "DELETE" },
1503
+ );
1504
+ if (res.status === 204) {
1505
+ if (editingId === note.id) editingId = null;
1506
+ await refreshNotes();
1507
+ } else {
1508
+ noteStatus.textContent = "Could not delete that note.";
1509
+ }
1510
+ };
1511
+ li.append(row, remove);
1512
+ list.appendChild(li);
1513
+ });
1514
+ syncChrome();
1515
+ }
1516
+
1517
+ function syncOutlines() {
1518
+ const host = document.querySelector("#editor");
1519
+ if (!host || !host.shadowRoot) return;
1520
+ let style = host.shadowRoot.querySelector("#note-outlines");
1521
+ if (!style) {
1522
+ style = document.createElement("style");
1523
+ style.id = "note-outlines";
1524
+ host.shadowRoot.appendChild(style);
1525
+ }
1526
+ const ids = [
1527
+ ...new Set(notes.map((note) => note.blockId).filter((id) => id)),
1528
+ ];
1529
+ style.textContent = ids
1530
+ .map(
1531
+ (id) =>
1532
+ `.tpl-canvas [data-block-id="${CSS.escape(id)}"] { outline: 2px solid var(--tpl-primary); outline-offset: 2px; }`,
1533
+ )
1534
+ .join("\n");
1535
+ }
1536
+
1537
+ const NUDGE_GAP = 18;
1538
+ const NUDGE_DROP = 22;
1539
+ let badgeFrame = 0;
1540
+ let badgeScroller = null;
1541
+ let badgeObserver = null;
1542
+
1543
+ function schedulePlace() {
1544
+ if (notes.length === 0) return;
1545
+ if (badgeFrame) return;
1546
+ badgeFrame = requestAnimationFrame(() => {
1547
+ badgeFrame = 0;
1548
+ placeBadges();
1549
+ });
1550
+ }
1551
+
1552
+ function bindBadgeTracking() {
1553
+ if (notes.length === 0) {
1554
+ if (badgeScroller) {
1555
+ badgeScroller.removeEventListener("scroll", schedulePlace);
1556
+ }
1557
+ badgeScroller = null;
1558
+ if (badgeObserver) badgeObserver.disconnect();
1559
+ badgeObserver = null;
1560
+ return;
1561
+ }
1562
+ const root = canvasRoot();
1563
+ const scroller = root && root.querySelector(".tpl-body");
1564
+ if (scroller && scroller !== badgeScroller) {
1565
+ if (badgeScroller) {
1566
+ badgeScroller.removeEventListener("scroll", schedulePlace);
1567
+ }
1568
+ badgeScroller = scroller;
1569
+ badgeScroller.addEventListener("scroll", schedulePlace, {
1570
+ passive: true,
1571
+ });
1572
+ }
1573
+ const host = document.querySelector("#editor");
1574
+ if (host && !badgeObserver) {
1575
+ badgeObserver = new ResizeObserver(() => schedulePlace());
1576
+ badgeObserver.observe(host);
1577
+ }
1578
+ }
1579
+
1580
+ function placeBadges() {
1581
+ const layer = $("note-badges");
1582
+ layer.replaceChildren();
1583
+ if (notes.length === 0) return;
1584
+ const host = document.querySelector("#editor");
1585
+ if (!host) return;
1586
+ const hostBox = host.getBoundingClientRect();
1587
+ const root = canvasRoot();
1588
+ const canvas = root && root.querySelector(".tpl-canvas");
1589
+ const rtl = !!(canvas && canvas.getAttribute("dir") === "rtl");
1590
+ const groups = new Map();
1591
+ notes.forEach((note, index) => {
1592
+ if (!note.blockId) return;
1593
+ const existing = groups.get(note.blockId);
1594
+ if (existing) existing.extra += 1;
1595
+ else groups.set(note.blockId, { note, number: index + 1, extra: 0 });
1596
+ });
1597
+ // Nudge against anchors already placed, so a section noted before its
1598
+ // child is the anchor that wins the shared corner.
1599
+ const placed = [];
1600
+ for (const group of groups.values()) {
1601
+ const el = blockEl(group.note.blockId);
1602
+ if (!el) continue;
1603
+ const rect = el.getBoundingClientRect();
1604
+ const visible =
1605
+ rect.width > 0 &&
1606
+ rect.height > 0 &&
1607
+ rect.bottom > hostBox.top &&
1608
+ rect.top < hostBox.bottom &&
1609
+ rect.right > hostBox.left &&
1610
+ rect.left < hostBox.right;
1611
+ if (!visible) continue;
1612
+ let x = rtl ? rect.left : rect.right;
1613
+ let y = rect.top;
1614
+ for (const other of placed) {
1615
+ if (Math.hypot(other.x - x, other.y - y) < NUDGE_GAP) y += NUDGE_DROP;
1616
+ }
1617
+ placed.push({ x, y });
1618
+ const btn = document.createElement("button");
1619
+ btn.type = "button";
1620
+ btn.className = "note-badge";
1621
+ btn.textContent = group.extra
1622
+ ? group.number + "+" + group.extra
1623
+ : String(group.number);
1624
+ btn.setAttribute(
1625
+ "aria-label",
1626
+ group.extra
1627
+ ? group.extra +
1628
+ 1 +
1629
+ " notes on " +
1630
+ (group.note.label || group.note.blockType || "block")
1631
+ : "Note " +
1632
+ group.number +
1633
+ " on " +
1634
+ (group.note.label || group.note.blockType || "block"),
1635
+ );
1636
+ btn.style.left = x + "px";
1637
+ btn.style.top = y + "px";
1638
+ btn.style.transform = rtl
1639
+ ? "translate(calc(-100% - 2px), -100%)"
1640
+ : "translate(2px, -100%)";
1641
+ btn.addEventListener("click", (event) => {
1642
+ event.stopPropagation();
1643
+ showComposer(null, group.note.id);
1644
+ });
1645
+ layer.appendChild(btn);
1646
+ }
1647
+ }
1648
+
1649
+ async function refreshNotes() {
1650
+ const res = await fetch("/content");
1651
+ if (!res.ok) return;
1652
+ const body = await res.json();
1653
+ notes = Array.isArray(body.annotations) ? body.annotations : [];
1654
+ if (editingId && !notes.some((note) => note.id === editingId)) {
1655
+ editingId = null;
1656
+ draftTarget = null;
1657
+ noteText.value = "";
1658
+ noteStatus.textContent = "";
1659
+ noteLabel.textContent = "the template";
1660
+ $("note-save").textContent = "Leave note";
1661
+ }
1662
+ renderNotes();
1663
+ syncOutlines();
1664
+ bindBadgeTracking();
1665
+ placeBadges();
1666
+ }
1667
+
1668
+ function onAnnotatePointer(event) {
1669
+ if (!annotating) return;
1670
+ if (event.target instanceof Element && event.target.closest("#note-badges")) {
1671
+ return;
1672
+ }
1673
+ if (notePanel.contains(event.target)) return;
1674
+ if (!pathHitsCanvas(event)) return;
1675
+ event.preventDefault();
1676
+ event.stopPropagation();
1677
+ if (event.type !== "click") return;
1678
+ draftTarget = blockFromPath(event);
1679
+ showComposer(draftTarget, null);
1680
+ }
1681
+
1682
+ document.addEventListener(
1683
+ "mousedown",
1684
+ onAnnotatePointer,
942
1685
  true,
943
1686
  );
944
1687
 
1688
+ document.addEventListener(
1689
+ "click",
1690
+ onAnnotatePointer,
1691
+ true,
1692
+ );
1693
+
1694
+ document.addEventListener("keydown", (event) => {
1695
+ if (event.key !== "Escape" || !annotating) return;
1696
+ event.preventDefault();
1697
+ event.stopPropagation();
1698
+ setAnnotating(false);
1699
+ });
1700
+
1701
+ function clearComposer() {
1702
+ editingId = null;
1703
+ draftTarget = null;
1704
+ noteText.value = "";
1705
+ noteStatus.textContent = "";
1706
+ noteLabel.textContent = "the template";
1707
+ $("note-save").textContent = "Leave note";
1708
+ }
1709
+
945
1710
  $("note-cancel").onclick = () => {
946
- notePanel.hidden = true;
1711
+ const pending = noteText.value.trim() || editingId;
1712
+ if (pending) {
1713
+ clearComposer();
1714
+ return;
1715
+ }
1716
+ panelDismissed = true;
1717
+ setAnnotating(false);
947
1718
  };
948
1719
 
949
1720
  $("note-save").onclick = async () => {
@@ -953,26 +1724,40 @@
953
1724
  return;
954
1725
  }
955
1726
  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.";
1727
+ const res = editingId
1728
+ ? await fetch("/annotations/" + encodeURIComponent(editingId), {
1729
+ method: "PUT",
1730
+ headers: { "content-type": "application/json" },
1731
+ body: JSON.stringify({ text }),
1732
+ })
1733
+ : await fetch("/annotations", {
1734
+ method: "POST",
1735
+ headers: { "content-type": "application/json" },
1736
+ body: JSON.stringify({
1737
+ text,
1738
+ blockId: (draftTarget && draftTarget.blockId) || null,
1739
+ parentBlockId: (draftTarget && draftTarget.parentBlockId) || null,
1740
+ blockType: (draftTarget && draftTarget.blockType) || null,
1741
+ label: (draftTarget && draftTarget.label) || "",
1742
+ }),
1743
+ });
1744
+ if (!res.ok) {
1745
+ noteStatus.textContent =
1746
+ (await res.text()) || "Could not save that note.";
1747
+ return;
971
1748
  }
1749
+ editingId = null;
1750
+ draftTarget = null;
1751
+ noteText.value = "";
1752
+ $("note-save").textContent = "Leave note";
1753
+ noteStatus.textContent = "";
1754
+ toast("Saved. Ask your agent to apply your notes.");
1755
+ await refreshNotes();
972
1756
  } catch {
973
1757
  noteStatus.textContent = "Could not reach the bridge.";
974
1758
  }
975
1759
  };
1760
+
976
1761
  </script>
977
1762
  </body>
978
1763
  </html>