@comty.io/core 0.1.3 → 0.1.4

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/comty-sdk.js CHANGED
@@ -13,7 +13,8 @@ function getComtyStyle() {
13
13
  --comty-muted: var(--comty-toolbar-muted);
14
14
  --comty-accent: #55aaf5;
15
15
  --comty-error: #ef7676;
16
- --comty-outline: oklch(0.62 0.18 255);
16
+ --comty-outline: var(--comty-toolbar-muted);
17
+ --comty-comment-focus: var(--comty-toolbar-muted);
17
18
  --comty-paper: #fff;
18
19
  --comty-ink: #171717;
19
20
  --comty-border: #434649;
@@ -37,6 +38,10 @@ function getComtyStyle() {
37
38
  --comty-magnifier-gray: #999;
38
39
  --comty-magnifier-shadow: 0 .0625rem .375rem rgb(0 0 0 / .5);
39
40
  --comty-property-width: 16rem;
41
+ --comty-inspector-width: 18rem;
42
+ --comty-inspector-label-width: 5rem;
43
+ --comty-inspector-row-height: 1.5rem;
44
+ --comty-copy-size: 1.5rem;
40
45
  --comty-property-swatch: 2rem;
41
46
  --comty-property-font-size: .6875rem;
42
47
  --comty-property-row-height: 1.375rem;
@@ -50,7 +55,6 @@ function getComtyStyle() {
50
55
  --comty-dialog-ink: var(--comty-foreground);
51
56
  --comty-dialog-soft: var(--comty-muted);
52
57
  --comty-dialog-field: var(--comty-field-surface);
53
- --comty-dialog-line: var(--comty-border);
54
58
  --comty-dialog-backdrop: rgb(0 0 0 / .4);
55
59
  --comty-dialog-radius: 2rem;
56
60
  --comty-dialog-width: 32rem;
@@ -325,11 +329,11 @@ function getComtyStyle() {
325
329
  .comty-pin {
326
330
  position: fixed;
327
331
  z-index: 2147483645;
328
- width: 34px;
329
- height: 34px;
332
+ width: 28px;
333
+ height: 28px;
330
334
  padding: 0;
331
335
  border: none;
332
- border-radius: 999px 999px 999px 12px;
336
+ border-radius: 999px 999px 999px 10px;
333
337
  background: #0078FF;
334
338
  color: white;
335
339
  display: grid;
@@ -339,17 +343,17 @@ function getComtyStyle() {
339
343
  transform: translate(-50%, -50%);
340
344
  appearance: none;
341
345
  font-family: sans-serif;
342
- filter: drop-shadow(0 8px 18px rgba(0, 120, 255, .28));
346
+ filter: drop-shadow(0 8px 16px rgba(0, 120, 255, .28));
343
347
  display: none;
344
348
  overflow: visible;
345
349
  }
346
350
 
347
- #comty-root > .comty-temp-pin { pointer-events: none; }
351
+ #comty-root > .comty-temp-pin { pointer-events: none; z-index: 2147483647; }
348
352
 
349
353
  .comty-pin-icon {
350
354
  display: block;
351
- width: 18px;
352
- height: 18px;
355
+ width: 16px;
356
+ height: 16px;
353
357
  pointer-events: none;
354
358
  }
355
359
 
@@ -358,11 +362,11 @@ function getComtyStyle() {
358
362
  top: -4px;
359
363
  right: -4px;
360
364
  display: inline-flex;
361
- width: 12px;
362
- height: 12px;
365
+ width: 10px;
366
+ height: 10px;
363
367
  border: 1px solid rgba(255,255,255,.82);
364
368
  border-radius: 999px;
365
- box-shadow: 0 8px 18px rgba(15,23,42,.18);
369
+ box-shadow: 0 8px 16px rgba(15,23,42,.18);
366
370
  pointer-events: none;
367
371
  }
368
372
 
@@ -370,7 +374,7 @@ function getComtyStyle() {
370
374
  background: var(--comty-surface);
371
375
  color: var(--comty-foreground);
372
376
  border: 2px solid var(--comty-border);
373
- box-shadow: 0 2px 6px rgba(0,0,0,.18);
377
+ box-shadow: 0 2px 4px rgba(0,0,0,.18);
374
378
  filter: none;
375
379
  transition:
376
380
  border-color 160ms ease,
@@ -420,14 +424,14 @@ function getComtyStyle() {
420
424
  background: var(--comty-surface);
421
425
  color: var(--comty-foreground);
422
426
  border: 0;
423
- box-shadow: 0 10px 28px rgba(0,0,0,.2);
427
+ box-shadow: 0 8px 24px rgba(0,0,0,.2);
424
428
  padding: 0;
425
- border-radius: 14px;
426
- width: min(360px, calc(100vw - 32px));
429
+ border-radius: 12px;
430
+ width: min(288px, calc(100vw - 24px));
427
431
  overflow: hidden;
428
432
  box-sizing: border-box;
429
433
  font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
430
- font-size: 14px;
434
+ font-size: 12px;
431
435
  display: none;
432
436
  animation: comty-bubble-in 200ms cubic-bezier(.23,1,.32,1) forwards;
433
437
  transform-origin: top left;
@@ -455,37 +459,53 @@ function getComtyStyle() {
455
459
  display: flex;
456
460
  align-items: center;
457
461
  justify-content: space-between;
458
- gap: 12px;
459
- min-height: 40px;
460
- padding: 0 16px;
461
- border-bottom: 1px solid var(--comty-border);
462
- }
463
-
464
- .comty-bubble-title {
465
- margin: 0;
466
- color: var(--comty-foreground);
467
- font-size: 12px;
468
- font-weight: 600;
469
- line-height: 1;
470
- }
471
-
472
- .comty-bubble-header-actions {
473
- display: flex;
474
- align-items: center;
475
- gap: 6px;
476
- margin-left: auto;
462
+ gap: 10px;
463
+ min-height: 1.5rem;
464
+ padding: .625rem .625rem 0;
477
465
  }
478
466
 
479
467
  .comty-bubble-content {
480
468
  flex: 1;
481
469
  color: var(--comty-foreground);
482
- font-size: 14px;
470
+ font-size: .75rem;
483
471
  line-height: 1.45;
484
472
  overflow-wrap: anywhere;
485
473
  cursor: text;
486
474
  white-space: pre-wrap;
487
475
  }
488
476
 
477
+ .comty-bubble-content a {
478
+ color: var(--comty-comment-focus);
479
+ text-decoration: underline;
480
+ text-underline-offset: .15em;
481
+ }
482
+
483
+ .comty-bubble-content a:focus-visible {
484
+ outline: .125rem solid var(--comty-comment-focus);
485
+ outline-offset: .125rem;
486
+ }
487
+
488
+ .comty-comment-link {
489
+ display: inline-block;
490
+ box-sizing: border-box;
491
+ max-width: 100%;
492
+ min-width: 0;
493
+ overflow: hidden;
494
+ padding: .375rem .625rem;
495
+ border-radius: .375rem;
496
+ background: var(--comty-field-surface);
497
+ color: var(--comty-foreground);
498
+ font: .75rem/1.25 var(--comty-font);
499
+ text-decoration: none;
500
+ text-overflow: ellipsis;
501
+ white-space: nowrap;
502
+ }
503
+
504
+ .comty-thread-item .comty-comment-link,
505
+ .comty-capture-annotation .comty-comment-link { margin-top: .5rem; }
506
+ .comty-comment-link:hover { text-decoration: underline; }
507
+ .comty-comment-link:focus-visible { outline: .125rem solid var(--comty-comment-focus); outline-offset: .125rem; }
508
+
489
509
  .comty-bubble-content-editor {
490
510
  display: block;
491
511
  flex: 1;
@@ -497,7 +517,7 @@ function getComtyStyle() {
497
517
  background: transparent;
498
518
  color: var(--comty-foreground);
499
519
  cursor: text;
500
- font: 14px/1.45 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
520
+ font: .75rem/1.45 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
501
521
  outline: none;
502
522
  resize: none;
503
523
  overflow: hidden;
@@ -507,15 +527,15 @@ function getComtyStyle() {
507
527
  }
508
528
 
509
529
  .comty-thread {
510
- padding: 18px 16px 16px;
530
+ padding: .625rem;
511
531
  }
512
532
 
513
533
  .comty-thread-list {
514
534
  display: grid;
515
535
  width: 100%;
516
536
  min-width: 0;
517
- gap: 20px;
518
- max-height: min(360px, calc(100vh - 152px));
537
+ gap: 16px;
538
+ max-height: min(288px, calc(100vh - 120px));
519
539
  overflow-x: hidden;
520
540
  overflow-y: auto;
521
541
  overscroll-behavior: contain;
@@ -528,9 +548,9 @@ function getComtyStyle() {
528
548
  display: grid;
529
549
  width: 100%;
530
550
  min-width: 0;
531
- grid-template-columns: 24px minmax(0, 1fr);
551
+ grid-template-columns: 1.5rem minmax(0, 1fr);
532
552
  align-items: start;
533
- gap: 12px;
553
+ gap: 10px;
534
554
  }
535
555
 
536
556
  .comty-thread-item-body {
@@ -545,15 +565,15 @@ function getComtyStyle() {
545
565
  display: flex;
546
566
  align-items: center;
547
567
  min-width: 0;
548
- min-height: 20px;
549
- gap: 7px;
568
+ min-height: 16px;
569
+ gap: 6px;
550
570
  }
551
571
 
552
572
  .comty-comment-author-name {
553
573
  min-width: 0;
554
574
  overflow: hidden;
555
575
  color: var(--comty-foreground);
556
- font-size: 14px;
576
+ font-size: .75rem;
557
577
  font-weight: 600;
558
578
  line-height: 1.25;
559
579
  text-overflow: ellipsis;
@@ -563,7 +583,7 @@ function getComtyStyle() {
563
583
  .comty-comment-time {
564
584
  flex: 0 0 auto;
565
585
  color: var(--comty-muted);
566
- font-size: 13px;
586
+ font-size: .6875rem;
567
587
  font-weight: 400;
568
588
  line-height: 1.25;
569
589
  }
@@ -571,13 +591,13 @@ function getComtyStyle() {
571
591
  .comty-comment-avatar {
572
592
  display: grid;
573
593
  place-items: center;
574
- width: 24px;
575
- height: 24px;
594
+ width: 1.5rem;
595
+ height: 1.5rem;
576
596
  overflow: hidden;
577
597
  border-radius: 50%;
578
598
  background: var(--comty-field-surface);
579
599
  color: var(--comty-foreground);
580
- font-size: 10px;
600
+ font-size: 8px;
581
601
  font-weight: 600;
582
602
  line-height: 1;
583
603
  }
@@ -589,8 +609,8 @@ function getComtyStyle() {
589
609
  }
590
610
 
591
611
  .comty-pin-avatar {
592
- width: 28px;
593
- height: 28px;
612
+ width: 24px;
613
+ height: 24px;
594
614
  }
595
615
 
596
616
  .comty-thread-item .comty-bubble-content,
@@ -605,12 +625,12 @@ function getComtyStyle() {
605
625
  .comty-icon-button {
606
626
  display: inline-grid;
607
627
  place-items: center;
608
- width: 28px;
609
- height: 28px;
628
+ width: 24px;
629
+ height: 24px;
610
630
  flex: 0 0 auto;
611
631
  padding: 0;
612
632
  border: 0;
613
- border-radius: 6px;
633
+ border-radius: 4px;
614
634
  background: transparent;
615
635
  color: var(--comty-foreground);
616
636
  cursor: pointer;
@@ -640,24 +660,24 @@ function getComtyStyle() {
640
660
  }
641
661
 
642
662
  .comty-icon-button svg {
643
- width: 18px;
644
- height: 18px;
663
+ width: 16px;
664
+ height: 16px;
645
665
  pointer-events: none;
646
666
  }
647
667
 
648
668
  .comty-status-trigger {
649
669
  display: inline-flex;
650
670
  align-items: center;
651
- gap: 6px;
652
- min-height: 28px;
671
+ gap: 4px;
672
+ min-height: 24px;
653
673
  padding: 0 8px;
654
674
  border: 0;
655
- border-radius: 6px;
675
+ border-radius: 4px;
656
676
  background: transparent;
657
677
  color: var(--comty-foreground);
658
678
  cursor: pointer;
659
679
  appearance: none;
660
- font: 600 12px/1 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
680
+ font: 600 .6875rem/1 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
661
681
  transition:
662
682
  background-color 160ms ease,
663
683
  transform 140ms ease-out;
@@ -686,14 +706,14 @@ function getComtyStyle() {
686
706
  }
687
707
 
688
708
  .comty-thread-more {
689
- width: 24px;
690
- height: 24px;
691
- margin: -2px -4px -2px auto;
709
+ width: 20px;
710
+ height: 20px;
711
+ margin: 0 0 0 auto;
692
712
  }
693
713
 
694
714
  .comty-thread-more svg {
695
- width: 16px;
696
- height: 16px;
715
+ width: 12px;
716
+ height: 12px;
697
717
  }
698
718
 
699
719
  .comty-entry-actions-popover {
@@ -712,38 +732,57 @@ function getComtyStyle() {
712
732
 
713
733
  .comty-reply-form {
714
734
  display: grid;
715
- grid-template-columns: 24px minmax(0, 1fr);
735
+ grid-template-columns: 1.5rem minmax(0, 1fr);
716
736
  align-items: center;
717
- gap: 12px;
718
- margin-top: 20px;
737
+ gap: 10px;
738
+ margin-top: 16px;
719
739
  }
720
740
 
721
741
  .comty-reply-field {
722
742
  display: flex;
723
743
  align-items: center;
724
- min-height: 36px;
725
- padding: 4px 8px 4px 16px;
726
- border-radius: 14px;
744
+ min-height: 1.875rem;
745
+ padding: .25rem .25rem .25rem .625rem;
746
+ border-radius: 1.25rem;
727
747
  background: var(--comty-field-surface);
728
748
  box-sizing: border-box;
729
- transition: box-shadow 160ms ease;
749
+ transition: border-radius var(--comty-motion-duration) var(--comty-ease-in-out), box-shadow 160ms ease;
750
+ }
751
+
752
+ .comty-reply-field.comty-reply-expanded { border-radius: .75rem; }
753
+
754
+ .comty-reply-form:has(.comty-reply-expanded) > .comty-comment-avatar {
755
+ align-self: start;
756
+ }
757
+
758
+ .comty-reply-expanded .comty-reply-button {
759
+ align-self: flex-end;
730
760
  }
731
761
 
732
762
  .comty-reply-field:focus-within {
733
- box-shadow: inset 0 0 0 1px #a3a3a3;
763
+ box-shadow: inset 0 0 0 .0625rem var(--comty-comment-focus);
734
764
  }
735
765
 
736
766
  .comty-reply-input {
737
767
  min-width: 0;
768
+ min-height: 0;
738
769
  flex: 1;
739
770
  padding: 0;
740
771
  border: 0;
772
+ height: 1.125rem;
773
+ max-height: 5rem;
741
774
  background: transparent;
742
775
  color: var(--comty-foreground);
743
- font: 14px/1.3 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
776
+ font: .6875rem/1.125rem Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
744
777
  outline: none;
778
+ resize: none;
779
+ overflow-y: hidden;
780
+ box-sizing: border-box;
781
+ transition: height var(--comty-motion-duration) var(--comty-ease-in-out);
745
782
  }
746
783
 
784
+ .comty-reply-expanded .comty-reply-input { overflow-y: auto; }
785
+
747
786
  .comty-reply-input::placeholder {
748
787
  color: var(--comty-muted);
749
788
  }
@@ -751,14 +790,14 @@ function getComtyStyle() {
751
790
  .comty-reply-button {
752
791
  display: inline-grid;
753
792
  place-items: center;
754
- width: 28px;
755
- height: 28px;
793
+ width: 1.375rem;
794
+ height: 1.375rem;
756
795
  flex: 0 0 auto;
757
796
  padding: 0;
758
797
  border: 0;
759
798
  border-radius: 50%;
760
- background: #0d99ff;
761
- color: #fff;
799
+ background: var(--comty-foreground);
800
+ color: var(--comty-surface);
762
801
  cursor: pointer;
763
802
  transition:
764
803
  background-color 160ms ease,
@@ -767,7 +806,7 @@ function getComtyStyle() {
767
806
  }
768
807
 
769
808
  .comty-reply-button:hover:not(:disabled) {
770
- background: #0b87e3;
809
+ background: var(--comty-muted);
771
810
  }
772
811
 
773
812
  .comty-reply-button:active:not(:disabled) {
@@ -775,13 +814,14 @@ function getComtyStyle() {
775
814
  }
776
815
 
777
816
  .comty-reply-button:disabled {
817
+ color: var(--comty-muted);
778
818
  background: var(--comty-hover-surface);
779
819
  cursor: default;
780
820
  }
781
821
 
782
822
  .comty-reply-button svg {
783
- width: 16px;
784
- height: 16px;
823
+ width: 12px;
824
+ height: 12px;
785
825
  }
786
826
 
787
827
  .comty-reply-form-guest { display: block; }
@@ -789,20 +829,20 @@ function getComtyStyle() {
789
829
  .comty-sign-in-reply {
790
830
  display: block;
791
831
  width: 100%;
792
- min-height: 36px;
793
- padding: 4px 16px;
832
+ min-height: 32px;
833
+ padding: 4px 12px;
794
834
  border: 0;
795
- border-radius: 14px;
835
+ border-radius: 12px;
796
836
  background: var(--comty-field-surface);
797
- color: #0d99ff;
837
+ color: var(--comty-foreground);
798
838
  text-align: left;
799
- font: 600 14px/1.3 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
839
+ font: 600 12px/1.3 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
800
840
  cursor: pointer;
801
841
  transition: background-color 160ms ease;
802
842
  }
803
843
 
804
844
  .comty-sign-in-reply:hover { background: var(--comty-hover-surface); }
805
- .comty-sign-in-reply:focus-visible { outline: 2px solid #0d99ff; outline-offset: 2px; }
845
+ .comty-sign-in-reply:focus-visible { outline: .0625rem solid var(--comty-comment-focus); outline-offset: .0625rem; }
806
846
 
807
847
  .comty-hover-bubble {
808
848
  width: min(240px, calc(100vw - 32px));
@@ -828,10 +868,6 @@ function getComtyStyle() {
828
868
  height: 24px;
829
869
  }
830
870
 
831
- .comty-hover-bubble .comty-comment-author-name {
832
- font-size: 12px;
833
- }
834
-
835
871
  .comty-hover-bubble .comty-comment-author {
836
872
  min-height: 0;
837
873
  }
@@ -840,11 +876,6 @@ function getComtyStyle() {
840
876
  font-size: 12px;
841
877
  }
842
878
 
843
- .comty-hover-bubble .comty-thread-item .comty-bubble-content {
844
- margin-top: 0;
845
- font-size: 12px;
846
- }
847
-
848
879
  .comty-auth-backdrop { position: fixed; inset: 0; z-index: 2147483647; display: grid; place-items: center; padding: 1rem; background: var(--comty-dialog-backdrop); font-family: var(--comty-font); }
849
880
  .comty-auth-dialog { box-sizing: border-box; width: min(var(--comty-dialog-width), 100%); max-height: calc(100dvh - 2rem); overflow: auto; padding: 1.75rem; border: 0; border-radius: var(--comty-dialog-radius); background: var(--comty-dialog-surface); color: var(--comty-dialog-ink); box-shadow: var(--comty-dialog-shadow); text-align: left; outline: none; }
850
881
  .comty-auth-dialog *, .comty-auth-dialog *::before, .comty-auth-dialog *::after { box-sizing: border-box; }
@@ -855,13 +886,23 @@ function getComtyStyle() {
855
886
  .comty-auth-header svg { width: 1.25rem; height: 1.25rem; }
856
887
  .comty-auth-dialog h2 { margin: 0; color: inherit; font: 600 1.5rem/1.25 var(--comty-font); letter-spacing: -.025em; }
857
888
  .comty-auth-dialog p { margin: 1rem 0 0; color: var(--comty-dialog-soft); font: 400 .875rem/1.65 var(--comty-font); }
858
- .comty-auth-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .5rem; margin-top: 1.75rem; padding-top: 1.5rem; border-top: .0625rem solid var(--comty-dialog-line); }
889
+ .comty-auth-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .5rem; margin-top: 1.75rem; }
859
890
  .comty-auth-actions button { display: inline-flex; align-items: center; justify-content: center; min-height: 2.5rem; margin: 0; padding: .625rem 1rem; border: 0; border-radius: 999rem; background: transparent; color: var(--comty-dialog-soft); font: 500 .875rem/1.25 var(--comty-font); cursor: pointer; }
860
891
  .comty-auth-actions button:hover, .comty-auth-close:hover { background: var(--comty-dialog-field); color: var(--comty-dialog-ink); }
861
892
  .comty-auth-actions .comty-auth-primary { border-color: transparent; background: var(--comty-dialog-ink); color: var(--comty-dialog-surface); }
862
893
  .comty-auth-actions .comty-auth-primary:hover { background: var(--comty-dialog-soft); color: var(--comty-dialog-surface); }
863
894
  .comty-auth-dialog button:focus-visible { outline: .125rem solid var(--comty-dialog-ink); outline-offset: .1875rem; }
864
895
  .comty-auth-dialog button:disabled { opacity: .5; cursor: wait; }
896
+ .comty-guest-form { display: grid; gap: .5rem; margin-top: 1.5rem; }
897
+ .comty-guest-form label { color: var(--comty-dialog-soft); font: 500 .875rem/1.5 var(--comty-font); }
898
+ .comty-guest-form input { width: 100%; height: 2.75rem; padding: 0 1rem; border: .0625rem solid var(--comty-border); border-radius: .75rem; background: var(--comty-dialog-field); color: var(--comty-dialog-ink); font: 400 .875rem/1.5 var(--comty-font); outline: none; }
899
+ .comty-guest-form input::placeholder { color: var(--comty-dialog-soft); }
900
+ .comty-guest-form input:focus-visible { border-color: var(--comty-dialog-ink); box-shadow: 0 0 0 .0625rem var(--comty-dialog-ink); }
901
+ .comty-guest-form [role="alert"] { margin: 0; color: var(--comty-error); }
902
+ .comty-guest-actions { display: grid; justify-content: stretch; gap: .75rem; margin-top: 1rem; }
903
+ .comty-guest-actions button { width: 100%; }
904
+ .comty-guest-actions [data-comty-login] { background: var(--comty-dialog-field); color: var(--comty-dialog-ink); }
905
+ .comty-auth-or { color: var(--comty-dialog-soft); font: .75rem/1.5 var(--comty-font); text-align: center; }
865
906
  .comty-distance-layer { position: fixed; inset: 0; z-index: 1; width: 100%; height: 100%; overflow: visible; pointer-events: none; color: var(--comty-measure); }
866
907
  .comty-distance-line, .comty-distance-extension { stroke: currentColor; stroke-width: 1; fill: none; shape-rendering: crispEdges; }
867
908
  .comty-distance-extension { stroke-dasharray: 3 3; opacity: .65; }
@@ -927,8 +968,6 @@ function getComtyStyle() {
927
968
  justify-content: space-between;
928
969
  gap: 8px;
929
970
  margin-top: 12px;
930
- padding-top: 10px;
931
- border-top: 1px solid var(--comty-border);
932
971
  }
933
972
 
934
973
  .comty-status-label {
@@ -1010,6 +1049,12 @@ function getComtyStyle() {
1010
1049
  transform-origin: top right;
1011
1050
  }
1012
1051
 
1052
+ .comty-popover.comty-comment-popover {
1053
+ background: var(--comty-field-surface);
1054
+ border: .0625rem solid var(--comty-border);
1055
+ box-shadow: var(--comty-floating-shadow);
1056
+ }
1057
+
1013
1058
  .comty-popover-item {
1014
1059
  display: flex;
1015
1060
  align-items: center;
@@ -1125,11 +1170,11 @@ function getComtyStyle() {
1125
1170
  .comty-property-row button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
1126
1171
  .comty-property-row button[aria-label^="Copy Colour"] > span::before { content: ""; display: inline-block; width: .75rem; height: .75rem; margin-right: .375rem; border: .0625rem solid var(--comty-border); border-radius: .1875rem; background: var(--comty-picked-color); vertical-align: -.0625rem; }
1127
1172
  .comty-property-copy-icon { position: relative; flex: 0 0 .75rem; width: .75rem; height: .75rem; color: var(--comty-muted); opacity: .65; }
1128
- .comty-property-row button:hover .comty-property-copy-icon, .comty-property-row button[data-copied] .comty-property-copy-icon { opacity: 1; }
1173
+ .comty-property-row button:hover .comty-property-copy-icon, .comty-property-row button[data-copied] .comty-property-copy-icon, .comty-inspector-copy:hover .comty-property-copy-icon, .comty-inspector-copy[data-copied] .comty-property-copy-icon { opacity: 1; }
1129
1174
  .comty-property-copy-icon svg { position: absolute; inset: 0; width: 100%; height: 100%; transition: opacity 150ms var(--comty-ease-out), transform 150ms var(--comty-ease-out); }
1130
- .comty-property-copy-icon svg:last-child { opacity: 0; transform: scale(.5); }
1131
- .comty-property-row button[data-copied] svg:first-child { opacity: 0; transform: scale(.5); }
1132
- .comty-property-row button[data-copied] svg:last-child { opacity: 1; transform: scale(1); }
1175
+ .comty-property-copy-icon svg:last-child { opacity: 0; transform: scale(.9); }
1176
+ .comty-property-row button[data-copied] svg:first-child, .comty-inspector-copy[data-copied] svg:first-child { opacity: 0; transform: scale(.9); }
1177
+ .comty-property-row button[data-copied] svg:last-child, .comty-inspector-copy[data-copied] svg:last-child { opacity: 1; transform: scale(1); }
1133
1178
  @media (prefers-reduced-motion: reduce) { .comty-property-copy-icon svg { transition: none; } }
1134
1179
  .comty-property-row button[disabled] { cursor: default; opacity: .5; }
1135
1180
  .comty-property-strip { display: flex; align-items: center; justify-content: center; gap: .375rem; }
@@ -1180,29 +1225,29 @@ function getComtyStyle() {
1180
1225
  .comty-inspect-gap { --comty-spacing-color: var(--comty-gap-fill); }
1181
1226
  .comty-inspect-spacing span { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); padding-inline: .2rem; border-radius: .2rem; background: var(--comty-spacing-color); color: var(--comty-foreground); font: 600 .625rem/1.3 ui-monospace, monospace; white-space: nowrap; }
1182
1227
  .comty-inspector {
1183
- position: fixed; width: 15rem; max-width: calc(100vw - 1rem);
1184
- padding-block: .5rem; border-radius: .5rem;
1185
- color: var(--comty-foreground); background: var(--comty-surface);
1186
- box-shadow: var(--comty-floating-shadow); font: .625rem/1.5 ui-sans-serif, system-ui, sans-serif;
1228
+ position: fixed; width: var(--comty-inspector-width); max-width: calc(100vw - 1rem); max-height: calc(100vh - 1rem);
1229
+ padding: .25rem; border-radius: .75rem; overflow-y: auto;
1230
+ color: var(--comty-foreground); background: var(--comty-toolbar-group);
1231
+ box-shadow: var(--comty-floating-shadow); font: var(--comty-property-font-size)/1.4 var(--comty-font);
1187
1232
  pointer-events: none; box-sizing: border-box;
1188
1233
  }
1189
1234
  .comty-inspector[data-selected] { pointer-events: auto; user-select: text; }
1190
- .comty-inspector-head, .comty-inspector-row { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; padding-inline: .5rem; }
1191
- .comty-inspector-head { margin-bottom: .5rem; }
1192
- .comty-inspector-head button:last-child { flex-shrink: 0; }
1193
- .comty-inspector-head button:first-child { flex: 1; font-family: ui-monospace, monospace; font-weight: 500; text-align: left; }
1194
- .comty-inspector-row span { color: var(--comty-muted); flex-shrink: 0; }
1195
- .comty-inspector button { min-width: 0; padding: 0; border: 0; color: inherit; background: transparent; font: inherit; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; cursor: pointer; }
1196
- .comty-inspector button:hover { text-decoration: underline; }
1235
+ .comty-inspector-head { display: flex; align-items: center; gap: .25rem; padding: 0 .125rem 0 .375rem; min-height: 1.75rem; }
1236
+ .comty-inspector-selector { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; color: var(--comty-muted); font-family: ui-monospace, monospace; }
1237
+ .comty-inspector-details { padding: .25rem .25rem .25rem .5rem; border-radius: .5rem; background: var(--comty-toolbar-surface); }
1238
+ .comty-inspector-row { display: grid; grid-template-columns: var(--comty-inspector-label-width) minmax(0, 1fr) var(--comty-copy-size); align-items: center; gap: .25rem; min-height: var(--comty-inspector-row-height); }
1239
+ .comty-inspector-label, .comty-inspector-secondary { color: var(--comty-muted); }
1240
+ .comty-inspector-label { overflow-wrap: anywhere; }
1241
+ .comty-inspector button { min-width: 0; padding: 0; border: 0; color: inherit; background: transparent; font: inherit; cursor: pointer; }
1197
1242
  .comty-inspector button:focus-visible { outline: .125rem solid var(--comty-accent); outline-offset: .125rem; }
1198
- .comty-inspector-value { display: flex; flex-direction: column; align-items: flex-end; min-width: 0; }
1199
- .comty-inspector-value > button, .comty-inspector-tokens button { max-width: 100%; }
1200
- .comty-inspector-picker { display: inline-flex; align-items: center; gap: .25rem; }
1201
- .comty-inspector-swatch { display: inline-block; flex: 0 0 .625rem; width: .625rem; height: .625rem; border: .0625rem solid var(--comty-border); border-radius: .1875rem; }
1202
- .comty-inspector-tokens { display: flex; flex-direction: column; align-items: flex-end; max-width: 100%; }
1203
- .comty-inspector-tokens button { font-family: ui-monospace, monospace; color: var(--comty-muted); }
1204
- .comty-inspector-details { max-height: 50vh; overflow-y: auto; }
1205
- .comty-inspector .comty-inspector-expand { display: block; margin: .5rem auto 0; color: var(--comty-muted); }
1243
+ .comty-inspector-value { display: flex; align-items: center; gap: .3125rem; min-width: 0; padding-block: .125rem; text-align: left; font-variant-numeric: tabular-nums; }
1244
+ .comty-inspector button.comty-inspector-value { padding-block: .125rem; }
1245
+ .comty-inspector-text { min-width: 0; overflow-wrap: anywhere; }
1246
+ .comty-inspector-text:has(.comty-inspector-tokens) { display: flex; flex-wrap: wrap; gap: 0 .3125rem; }
1247
+ .comty-inspector-swatch { display: inline-block; flex: 0 0 .6875rem; width: .6875rem; height: .6875rem; border-radius: .1875rem; }
1248
+ .comty-inspector .comty-inspector-copy { display: grid; place-items: center; flex: 0 0 var(--comty-copy-size); width: var(--comty-copy-size); height: var(--comty-copy-size); border-radius: .375rem; }
1249
+ @media (hover: hover) and (pointer: fine) { .comty-inspector-copy:hover { background: var(--comty-hover-surface); } }
1250
+ @media (pointer: coarse) { .comty-inspector { --comty-copy-size: 2.75rem; } }
1206
1251
  .comty-measure-line { position: fixed; background: var(--comty-outline); pointer-events: none; }
1207
1252
  .comty-measure-tag { position: fixed; padding: .125rem .375rem; border-radius: .25rem; background: var(--comty-ink); color: var(--comty-paper); font: .625rem/1.4 ui-monospace, monospace; white-space: nowrap; pointer-events: none; }
1208
1253
 
@@ -1211,85 +1256,235 @@ function getComtyStyle() {
1211
1256
  z-index: 2147483647;
1212
1257
  background: var(--comty-surface);
1213
1258
  border: 0;
1214
- box-shadow: 0 4px 20px rgba(0,0,0,.1);
1215
- border-radius: 999px;
1216
- width: 320px;
1217
- font-family: sans-serif;
1218
- display: flex;
1219
- align-items: center;
1220
- padding: 4px 4px 4px 16px;
1221
- gap: 8px;
1259
+ box-shadow: var(--comty-floating-shadow);
1260
+ border-radius: 1.125rem;
1261
+ width: min(16rem, calc(100vw - 1rem));
1262
+ height: 2.25rem;
1263
+ overflow: hidden;
1264
+ font: .75rem/1.25rem var(--comty-font);
1222
1265
  box-sizing: border-box;
1223
- animation: comty-bubble-in 220ms cubic-bezier(.2,.8,.2,1) forwards;
1266
+ animation: comty-bubble-in 200ms var(--comty-ease-out) forwards;
1224
1267
  transform-origin: top left;
1268
+ transition: height var(--comty-motion-duration) var(--comty-ease-in-out), border-radius var(--comty-motion-duration) var(--comty-ease-in-out), box-shadow 160ms var(--comty-ease-out);
1225
1269
  }
1226
1270
 
1227
- .comty-create-bubble.comty-bubble-left {
1228
- animation-name: comty-bubble-in-left;
1229
- transform-origin: top right;
1271
+ .comty-create-bubble.comty-create-expanded {
1272
+ height: 5.75rem;
1273
+ border-radius: .75rem;
1230
1274
  }
1231
1275
 
1276
+ .comty-create-bubble.comty-create-has-attachment { height: 7.5rem; }
1277
+ .comty-create-bubble.comty-bubble-left { transform-origin: top right; }
1278
+
1232
1279
  .comty-create-textarea {
1233
- flex: 1;
1234
- height: 24px;
1235
- min-height: 24px;
1236
- max-height: 150px;
1280
+ position: absolute;
1281
+ top: .5rem;
1282
+ left: .75rem;
1283
+ width: calc(100% - 3.25rem);
1284
+ height: 1.25rem;
1237
1285
  border: none;
1238
1286
  padding: 0;
1239
- font-family: inherit;
1240
- font-size: 14px;
1241
- line-height: 24px;
1287
+ font: inherit;
1242
1288
  resize: none;
1243
1289
  box-sizing: border-box;
1244
1290
  outline: none;
1245
1291
  background: transparent;
1246
- overflow-y: auto;
1292
+ overflow-y: hidden;
1247
1293
  color: var(--comty-foreground);
1248
1294
  scrollbar-width: thin;
1249
- scrollbar-color: gray transparent;
1295
+ scrollbar-color: var(--comty-muted) transparent;
1296
+ transition: width var(--comty-motion-duration) var(--comty-ease-in-out), height var(--comty-motion-duration) var(--comty-ease-in-out);
1297
+ }
1298
+
1299
+ .comty-create-expanded .comty-create-textarea {
1300
+ width: calc(100% - 1.5rem);
1301
+ height: 2.5rem;
1302
+ overflow-y: auto;
1303
+ }
1304
+
1305
+ .comty-create-attachment {
1306
+ position: absolute;
1307
+ top: 3.25rem;
1308
+ left: .75rem;
1309
+ right: .75rem;
1310
+ display: none;
1311
+ min-width: 0;
1250
1312
  }
1251
1313
 
1252
- .comty-create-textarea::placeholder {
1314
+ .comty-create-has-attachment .comty-create-attachment { display: flex; }
1315
+ .comty-create-attachment-chip {
1316
+ display: inline-flex;
1317
+ align-items: center;
1318
+ min-width: 0;
1319
+ max-width: 100%;
1320
+ padding: .125rem .125rem .125rem 0;
1321
+ border-radius: .375rem;
1322
+ background: var(--comty-field-surface);
1323
+ }
1324
+ .comty-create-attachment .comty-comment-link { flex: 0 1 auto; padding: 0 .5rem; line-height: 1.25rem; background: transparent; }
1325
+ .comty-create-attachment button {
1326
+ flex: 0 0 auto;
1327
+ width: 1.25rem;
1328
+ height: 1.25rem;
1329
+ padding: 0;
1330
+ border: 0;
1331
+ border-radius: .25rem;
1332
+ background: transparent;
1253
1333
  color: var(--comty-muted);
1334
+ cursor: pointer;
1254
1335
  }
1255
1336
 
1337
+ .comty-create-textarea::placeholder { color: var(--comty-muted); }
1338
+
1256
1339
  .comty-create-actions {
1340
+ position: absolute;
1341
+ right: 0;
1342
+ bottom: 0;
1343
+ left: 0;
1257
1344
  display: flex;
1258
1345
  align-items: center;
1346
+ justify-content: space-between;
1347
+ height: 2.25rem;
1348
+ padding: .375rem .375rem .375rem .5rem;
1349
+ box-sizing: border-box;
1350
+ pointer-events: none;
1351
+ }
1352
+ .comty-create-actions::before {
1353
+ content: "";
1354
+ position: absolute;
1355
+ inset: 0 0 auto;
1356
+ height: .0625rem;
1357
+ background: var(--comty-border);
1358
+ opacity: 0;
1359
+ transition: opacity 110ms var(--comty-ease-out);
1360
+ }
1361
+ .comty-create-expanded .comty-create-actions::before { opacity: 1; transition-delay: 110ms; }
1362
+ .comty-create-actions > button { pointer-events: auto; }
1363
+
1364
+ .comty-create-link-button {
1365
+ display: grid;
1366
+ place-items: center;
1367
+ width: 1.5rem;
1368
+ height: 1.5rem;
1369
+ padding: 0;
1370
+ border: 0;
1371
+ border-radius: .375rem;
1372
+ background: transparent;
1373
+ color: var(--comty-muted);
1374
+ cursor: pointer;
1375
+ opacity: 0;
1376
+ visibility: hidden;
1377
+ transform: translateY(.25rem);
1378
+ transition: opacity 110ms var(--comty-ease-out), transform 110ms var(--comty-ease-out), visibility 0s linear 110ms;
1379
+ }
1380
+ .comty-create-expanded .comty-create-link-button {
1381
+ opacity: 1;
1382
+ visibility: visible;
1383
+ transform: none;
1384
+ transition-delay: 110ms, 110ms, 0s;
1385
+ }
1386
+ .comty-create-link-button svg { width: .75rem; height: .75rem; }
1387
+
1388
+ @media (hover: hover) and (pointer: fine) {
1389
+ .comty-create-link-button:hover,
1390
+ .comty-create-link-editor button:hover,
1391
+ .comty-create-attachment button:hover {
1392
+ background: var(--comty-hover-surface);
1393
+ color: var(--comty-foreground);
1394
+ }
1395
+ }
1396
+ .comty-create-link-button:focus-visible,
1397
+ .comty-create-link-editor button:focus-visible,
1398
+ .comty-create-attachment button:focus-visible,
1399
+ .comty-btn-submit:focus-visible {
1400
+ outline: .0625rem solid var(--comty-comment-focus);
1401
+ outline-offset: .0625rem;
1402
+ }
1403
+
1404
+ .comty-create-link-editor {
1405
+ position: absolute;
1406
+ inset: .375rem;
1407
+ display: flex;
1408
+ align-items: center;
1409
+ gap: .375rem;
1410
+ background: var(--comty-surface);
1411
+ opacity: 0;
1412
+ visibility: hidden;
1413
+ transform: translateY(.25rem);
1414
+ pointer-events: none;
1415
+ transition: opacity 160ms var(--comty-ease-out), transform 160ms var(--comty-ease-out), visibility 0s linear 160ms;
1416
+ }
1417
+ .comty-create-link-open .comty-create-link-editor {
1418
+ opacity: 1;
1419
+ visibility: visible;
1420
+ transform: none;
1421
+ pointer-events: auto;
1422
+ transition-delay: 0s;
1423
+ }
1424
+ .comty-create-link-field { position: relative; flex: 1; min-width: 0; }
1425
+ .comty-create-link-editor input {
1426
+ display: block;
1427
+ box-sizing: border-box;
1428
+ width: 100%;
1429
+ min-width: 0;
1430
+ height: 1.5rem;
1431
+ padding: 0 1.75rem 0 .5rem;
1432
+ border: 0;
1433
+ border-radius: .375rem;
1434
+ background: var(--comty-field-surface);
1435
+ color: var(--comty-foreground);
1436
+ font: inherit;
1437
+ outline: none;
1438
+ }
1439
+ .comty-create-link-editor input:focus-visible { background: var(--comty-hover-surface); }
1440
+ .comty-create-link-editor input[aria-invalid="true"] { box-shadow: inset 0 0 0 .0625rem var(--comty-error); }
1441
+ .comty-create-link-editor button {
1259
1442
  flex: 0 0 auto;
1443
+ height: 1.5rem;
1444
+ padding: 0 .5rem;
1445
+ border: 0;
1446
+ border-radius: .375rem;
1447
+ background: var(--comty-field-surface);
1448
+ color: var(--comty-foreground);
1449
+ font: inherit;
1450
+ cursor: pointer;
1260
1451
  }
1452
+ .comty-create-link-field button {
1453
+ position: absolute;
1454
+ top: .125rem;
1455
+ right: .125rem;
1456
+ width: 1.25rem;
1457
+ height: 1.25rem;
1458
+ padding: 0;
1459
+ background: transparent;
1460
+ }
1461
+ .comty-create-link-open .comty-create-link-button,
1462
+ .comty-create-link-open .comty-btn-submit { opacity: 0; visibility: hidden; pointer-events: none; }
1261
1463
 
1262
1464
  .comty-btn-submit {
1263
1465
  display: flex;
1264
1466
  align-items: center;
1265
1467
  justify-content: center;
1266
- width: 32px;
1267
- height: 32px;
1468
+ width: 1.5rem;
1469
+ height: 1.5rem;
1268
1470
  padding: 0;
1269
1471
  border-radius: 50%;
1270
1472
  background: var(--comty-border);
1271
- color: white;
1473
+ color: var(--comty-muted);
1272
1474
  border: none;
1273
1475
  cursor: pointer;
1274
- transition: background 150ms ease, opacity 150ms ease;
1476
+ transition: background-color 160ms var(--comty-ease-out), color 160ms var(--comty-ease-out), opacity 160ms var(--comty-ease-out);
1275
1477
  }
1478
+ .comty-btn-submit svg { width: .875rem; height: .875rem; }
1479
+ .comty-btn-submit.comty-btn-active { background: var(--comty-foreground); color: var(--comty-surface); }
1480
+ .comty-btn-submit:disabled { opacity: .6; cursor: not-allowed; }
1276
1481
 
1277
- .comty-btn-submit svg {
1278
- width: 16px;
1279
- height: 16px;
1280
- }
1281
-
1282
- .comty-btn-submit.comty-btn-active {
1283
- background: #0078FF;
1284
- }
1285
-
1286
- .comty-btn-submit.comty-btn-active:hover {
1287
- background: #0062d1;
1288
- }
1289
-
1290
- .comty-btn-submit:disabled {
1291
- opacity: 0.6;
1292
- cursor: not-allowed;
1482
+ @media (prefers-reduced-motion: reduce) {
1483
+ .comty-create-bubble, .comty-create-actions::before, .comty-create-textarea, .comty-create-link-button, .comty-create-link-editor, .comty-reply-field, .comty-reply-input {
1484
+ transition-duration: 0ms;
1485
+ transition-delay: 0ms;
1486
+ }
1487
+ .comty-create-bubble { animation: none; }
1293
1488
  }
1294
1489
  `;
1295
1490
  }
@@ -1642,516 +1837,419 @@ function createAnchorMatcher({
1642
1837
  .filter((candidate) => !isComtyNode(candidate));
1643
1838
  const top = stack[0];
1644
1839
  if (!top) return null;
1645
- if (top === el || el.contains(top) || top.contains(el)) return null;
1646
- return top;
1647
- }
1648
-
1649
- function getVisibleAnchorPoint(el, relX, relY, activeModal) {
1650
- if (!el || !Number.isFinite(relX) || !Number.isFinite(relY) || isElementTreeHidden(el)) return null;
1651
-
1652
- const rect = el.getBoundingClientRect();
1653
- if (rect.width < 0.1 || rect.height < 0.1) {
1654
- return null;
1655
- }
1656
-
1657
- const clientX = Math.min(
1658
- Math.max(rect.left + relX * rect.width, rect.left + 0.01),
1659
- rect.right - 0.01,
1660
- );
1661
- const clientY = Math.min(
1662
- Math.max(rect.top + relY * rect.height, rect.top + 0.01),
1663
- rect.bottom - 0.01,
1664
- );
1665
- const viewport = getViewportBounds();
1666
- if (!pointIsInsideRect(clientX, clientY, viewport)) {
1667
- return null;
1668
- }
1669
-
1670
- if (isPointClipped(el, clientX, clientY)) return null;
1671
-
1672
- if (activeModal && !activeModal.contains(el)) {
1673
- return null;
1674
- }
1675
-
1676
- if (getOccludingElement(el, clientX, clientY)) return null;
1677
-
1678
- return { clientX, clientY };
1679
- }
1680
-
1681
- return { matchesAnchorContext, resolveByAnchorContext, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint };
1682
- }
1683
-
1684
- function createScreenshotCapture() {
1685
- function nextAnimationFrame() {
1686
- return new Promise((resolve) => {
1687
- requestAnimationFrame(() => resolve());
1688
- });
1689
- }
1690
-
1691
- async function waitForPaint() {
1692
- await nextAnimationFrame();
1693
- await nextAnimationFrame();
1694
- }
1695
-
1696
- async function waitForCustomElements(el, timeout = 1600) {
1697
- if (
1698
- !(el instanceof Element) ||
1699
- !window.customElements ||
1700
- typeof window.customElements.whenDefined !== "function"
1701
- ) {
1702
- return;
1703
- }
1704
-
1705
- const names = new Set();
1706
- let current = el;
1707
- while (current && current instanceof Element) {
1708
- const tagName = current.tagName.toLowerCase();
1709
- if (tagName.includes("-")) {
1710
- names.add(tagName);
1711
- }
1712
- current = current.parentElement;
1713
- }
1714
-
1715
- if (names.size === 0) return;
1716
-
1717
- const definitions = [...names].map((name) =>
1718
- window.customElements.whenDefined(name).catch(() => undefined),
1719
- );
1720
- await Promise.race([
1721
- Promise.all(definitions),
1722
- new Promise((resolve) => setTimeout(resolve, timeout)),
1723
- ]);
1724
- }
1725
-
1726
- function syncFormValues(sourceRoot, cloneRoot) {
1727
- const sourceFields = sourceRoot.querySelectorAll(
1728
- "input, textarea, select",
1729
- );
1730
- const clonedFields = cloneRoot.querySelectorAll(
1731
- "input, textarea, select",
1732
- );
1733
-
1734
- sourceFields.forEach((sourceField, index) => {
1735
- const clonedField = clonedFields[index];
1736
- if (!clonedField) return;
1737
-
1738
- if (sourceField instanceof HTMLTextAreaElement) {
1739
- clonedField.textContent = sourceField.value;
1740
- clonedField.setAttribute("value", sourceField.value);
1741
- return;
1742
- }
1743
-
1744
- if (sourceField instanceof HTMLInputElement) {
1745
- clonedField.setAttribute("value", sourceField.value);
1746
- if (sourceField.checked) {
1747
- clonedField.setAttribute("checked", "");
1748
- } else {
1749
- clonedField.removeAttribute("checked");
1750
- }
1751
- return;
1752
- }
1753
-
1754
- if (sourceField instanceof HTMLSelectElement) {
1755
- Array.from(sourceField.options).forEach((option, optionIndex) => {
1756
- const clonedOption = clonedField.options[optionIndex];
1757
- if (!clonedOption) return;
1758
- if (option.selected) {
1759
- clonedOption.setAttribute("selected", "");
1760
- } else {
1761
- clonedOption.removeAttribute("selected");
1762
- }
1763
- });
1764
- }
1765
- });
1766
- }
1767
-
1768
- function toAbsoluteUrl(value) {
1769
- if (!value || value.startsWith("data:") || value.startsWith("blob:")) {
1770
- return value;
1771
- }
1772
-
1773
- try {
1774
- return new URL(value, window.location.href).toString();
1775
- } catch {
1776
- return value;
1777
- }
1778
- }
1779
-
1780
- function isExportSafeUrl(value) {
1781
- if (!value || value.startsWith("data:")) return true;
1782
-
1783
- try {
1784
- const url = new URL(value, window.location.href);
1785
- return url.origin === window.location.origin;
1786
- } catch {
1787
- return false;
1788
- }
1789
- }
1790
-
1791
- function blobToDataUrl(blob) {
1792
- return new Promise((resolve, reject) => {
1793
- const reader = new FileReader();
1794
- reader.onload = () => resolve(String(reader.result ?? ""));
1795
- reader.onerror = reject;
1796
- reader.readAsDataURL(blob);
1797
- });
1798
- }
1799
-
1800
- async function urlToDataUrl(value) {
1801
- const absoluteUrl = toAbsoluteUrl(value);
1802
- if (!absoluteUrl || absoluteUrl.startsWith("data:")) return absoluteUrl;
1803
- if (absoluteUrl.startsWith("blob:")) return "";
1804
-
1805
- try {
1806
- const response = await fetch(absoluteUrl, {
1807
- credentials: isExportSafeUrl(absoluteUrl) ? "same-origin" : "omit",
1808
- mode: "cors",
1809
- });
1810
- if (!response.ok) return "";
1811
- return await blobToDataUrl(await response.blob());
1812
- } catch {
1813
- return "";
1814
- }
1815
- }
1816
-
1817
- async function inlineImageResources(clone) {
1818
- const images = Array.from(clone.querySelectorAll("img"));
1819
- await Promise.all(
1820
- images.map(async (image) => {
1821
- const dataUrl = await urlToDataUrl(image.getAttribute("src") ?? "");
1822
- if (!dataUrl) {
1823
- image.removeAttribute("src");
1824
- image.removeAttribute("srcset");
1825
- image.style.visibility = "hidden";
1826
- return;
1827
- }
1828
- image.setAttribute("src", dataUrl);
1829
- image.removeAttribute("srcset");
1830
- }),
1831
- );
1832
-
1833
- clone.querySelectorAll("source").forEach((source) => {
1834
- source.removeAttribute("src");
1835
- source.removeAttribute("srcset");
1836
- });
1837
- }
1838
-
1839
- function convertViewportUnits(value, viewportWidth, viewportHeight) {
1840
- return value.replace(
1841
- /(-?\d*\.?\d+)(dvh|svh|lvh|vh|dvw|svw|lvw|vw|vmin|vmax)\b/gi,
1842
- (_, amount, unit) => {
1843
- const number = Number(amount);
1844
- if (!Number.isFinite(number)) return `${amount}${unit}`;
1845
- const normalizedUnit = String(unit).toLowerCase();
1846
- const base =
1847
- normalizedUnit === "vw" ||
1848
- normalizedUnit === "dvw" ||
1849
- normalizedUnit === "svw" ||
1850
- normalizedUnit === "lvw"
1851
- ? viewportWidth
1852
- : normalizedUnit === "vmin"
1853
- ? Math.min(viewportWidth, viewportHeight)
1854
- : normalizedUnit === "vmax"
1855
- ? Math.max(viewportWidth, viewportHeight)
1856
- : viewportHeight;
1857
- return `${(number / 100) * base}px`;
1858
- },
1859
- );
1860
- }
1861
-
1862
- function sanitizeCssForExport(css, viewportWidth, viewportHeight) {
1863
- return convertViewportUnits(css, viewportWidth, viewportHeight)
1864
- .replace(/@font-face\s*{[^}]*}/gi, "")
1865
- .replace(/url\((?!['"]?data:)[^)]+\)/gi, "none");
1866
- }
1867
-
1868
- function sanitizeInlineStyles(clone, viewportWidth, viewportHeight) {
1869
- clone.querySelectorAll("[style]").forEach((node) => {
1870
- node.setAttribute(
1871
- "style",
1872
- sanitizeCssForExport(
1873
- node.getAttribute("style") ?? "",
1874
- viewportWidth,
1875
- viewportHeight,
1876
- ),
1877
- );
1878
- });
1879
- }
1880
-
1881
- function removeCaptureLockStyles(clone) {
1882
- clone.querySelectorAll("[style]").forEach((node) => {
1883
- const style = node.getAttribute("style") ?? "";
1884
- const hasCaptureLock =
1885
- /max-width:\s*none/i.test(style) &&
1886
- /max-height:\s*none/i.test(style) &&
1887
- /transform:\s*none/i.test(style) &&
1888
- /margin:\s*0px/i.test(style);
1889
-
1890
- if (!hasCaptureLock) return;
1891
-
1892
- node.style.removeProperty("width");
1893
- node.style.removeProperty("height");
1894
- node.style.removeProperty("max-width");
1895
- node.style.removeProperty("max-height");
1896
- node.style.removeProperty("margin");
1897
- node.style.removeProperty("transform");
1898
- node.style.removeProperty("inset");
1899
- });
1900
- }
1901
-
1902
- function removeExportUnsafeNodes(clone) {
1903
- clone
1904
- .querySelectorAll("iframe, canvas, video, audio, object, embed")
1905
- .forEach((node) => node.remove());
1906
- clone
1907
- .querySelectorAll("link[rel~='stylesheet'], link[rel~='preload']")
1908
- .forEach((node) => node.remove());
1909
- clone
1910
- .querySelectorAll(
1911
- "#comty-root, .comty-sidepanel, .comty-pin:not([data-comty-capture-keep]), .comty-bubble:not([data-comty-capture-keep]), .comty-create-bubble:not([data-comty-capture-keep]), .comty-popover",
1912
- )
1913
- .forEach((node) => node.remove());
1914
- }
1915
-
1916
- function makeCloneUrlsAbsolute(clone) {
1917
- clone.querySelectorAll("[src]").forEach((node) => {
1918
- node.setAttribute("src", toAbsoluteUrl(node.getAttribute("src")));
1919
- });
1920
- clone.querySelectorAll("[href]").forEach((node) => {
1921
- node.setAttribute("href", toAbsoluteUrl(node.getAttribute("href")));
1922
- });
1923
- clone.querySelectorAll("[srcset]").forEach((node) => {
1924
- const srcset = node
1925
- .getAttribute("srcset")
1926
- .split(",")
1927
- .map((entry) => {
1928
- const parts = entry.trim().split(/\s+/);
1929
- if (!parts[0]) return "";
1930
- parts[0] = toAbsoluteUrl(parts[0]);
1931
- return parts.join(" ");
1932
- })
1933
- .filter(Boolean)
1934
- .join(", ");
1935
- node.setAttribute("srcset", srcset);
1936
- });
1840
+ if (top === el || el.contains(top) || top.contains(el)) return null;
1841
+ return top;
1937
1842
  }
1938
1843
 
1939
- function inlineAccessibleStyles(clone, viewportWidth, viewportHeight) {
1940
- const style = clone.ownerDocument.createElement("style");
1941
- let css = "";
1844
+ function getVisibleAnchorPoint(el, relX, relY, activeModal) {
1845
+ if (!el || !Number.isFinite(relX) || !Number.isFinite(relY) || isElementTreeHidden(el)) return null;
1942
1846
 
1943
- Array.from(document.styleSheets).forEach((styleSheet) => {
1944
- try {
1945
- css += Array.from(styleSheet.cssRules)
1946
- .map((rule) => rule.cssText)
1947
- .join("\n");
1948
- } catch {
1949
- // Cross-origin stylesheets cannot be exported safely into canvas.
1950
- }
1951
- });
1847
+ const rect = el.getBoundingClientRect();
1848
+ if (rect.width < 0.1 || rect.height < 0.1) {
1849
+ return null;
1850
+ }
1952
1851
 
1953
- if (!css) return;
1954
- style.textContent = sanitizeCssForExport(
1955
- css,
1956
- viewportWidth,
1957
- viewportHeight,
1852
+ const clientX = Math.min(
1853
+ Math.max(rect.left + relX * rect.width, rect.left + 0.01),
1854
+ rect.right - 0.01,
1958
1855
  );
1959
- clone.querySelector("head")?.appendChild(style);
1960
- }
1961
-
1962
- function markCaptureElements(root, attributeName) {
1963
- return [root, ...Array.from(root.querySelectorAll("*"))].map(
1964
- (element, index) => {
1965
- const previousValue = element.getAttribute(attributeName);
1966
- element.setAttribute(attributeName, String(index));
1967
- return { element, index, previousValue };
1968
- },
1856
+ const clientY = Math.min(
1857
+ Math.max(rect.top + relY * rect.height, rect.top + 0.01),
1858
+ rect.bottom - 0.01,
1969
1859
  );
1970
- }
1860
+ const viewport = getViewportBounds();
1861
+ if (!pointIsInsideRect(clientX, clientY, viewport)) {
1862
+ return null;
1863
+ }
1971
1864
 
1972
- function restoreCaptureElementMarks(markedElements, attributeName) {
1973
- markedElements.forEach(({ element, previousValue }) => {
1974
- if (previousValue === null) {
1975
- element.removeAttribute(attributeName);
1976
- return;
1977
- }
1978
- element.setAttribute(attributeName, previousValue);
1979
- });
1980
- }
1865
+ if (isPointClipped(el, clientX, clientY)) return null;
1981
1866
 
1982
- function removeCaptureElementMarks(clone, attributeName) {
1983
- clone.removeAttribute(attributeName);
1984
- clone
1985
- .querySelectorAll(`[${attributeName}]`)
1986
- .forEach((element) => element.removeAttribute(attributeName));
1987
- }
1867
+ if (activeModal && !activeModal.contains(el)) {
1868
+ return null;
1869
+ }
1988
1870
 
1989
- function pinFixedElementsToViewport(
1990
- clone,
1991
- markedElements,
1992
- attributeName,
1993
- scrollX,
1994
- scrollY,
1995
- viewportWidth,
1996
- viewportHeight,
1997
- ) {
1998
- markedElements.forEach(({ element, index }) => {
1999
- if (window.getComputedStyle(element).position !== "fixed") return;
1871
+ if (getOccludingElement(el, clientX, clientY)) return null;
2000
1872
 
2001
- const clonedElement = clone.querySelector(
2002
- `[${attributeName}="${index}"]`,
2003
- );
2004
- if (!(clonedElement instanceof HTMLElement)) return;
2005
-
2006
- const rect = element.getBoundingClientRect();
2007
- clonedElement.style.position = "absolute";
2008
- clonedElement.style.left = `${rect.left + scrollX}px`;
2009
- clonedElement.style.top = `${rect.top + scrollY}px`;
2010
- clonedElement.style.right = "auto";
2011
- clonedElement.style.bottom = "auto";
2012
- clonedElement.style.boxSizing = "border-box";
2013
- clonedElement.style.width = `${rect.width || viewportWidth}px`;
2014
- clonedElement.style.height = `${rect.height || viewportHeight}px`;
2015
- clonedElement.style.transform = "none";
2016
- });
1873
+ return { clientX, clientY };
2017
1874
  }
2018
1875
 
2019
- function syncScrollableElementOffsets(
2020
- clone,
2021
- markedElements,
2022
- attributeName,
2023
- ) {
2024
- markedElements.forEach(({ element, index }) => {
2025
- if (element === document.documentElement || element === document.body) {
2026
- return;
2027
- }
2028
-
2029
- const scrollLeft = element.scrollLeft;
2030
- const scrollTop = element.scrollTop;
2031
- if (!scrollLeft && !scrollTop) return;
2032
-
2033
- const clonedElement = clone.querySelector(
2034
- `[${attributeName}="${index}"]`,
2035
- );
2036
- if (!(clonedElement instanceof HTMLElement)) return;
2037
-
2038
- const children = Array.from(clonedElement.childNodes);
2039
- if (!children.length) return;
2040
-
2041
- const scrollWrapper = clone.ownerDocument.createElement("div");
2042
- const computedStyle = window.getComputedStyle(element);
2043
- scrollWrapper.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)`;
2044
- scrollWrapper.style.transformOrigin = "top left";
2045
- scrollWrapper.style.width = `${Math.max(
2046
- element.scrollWidth,
2047
- element.clientWidth,
2048
- )}px`;
2049
- scrollWrapper.style.minHeight = `${Math.max(
2050
- element.scrollHeight,
2051
- element.clientHeight,
2052
- )}px`;
1876
+ return { matchesAnchorContext, resolveByAnchorContext, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint };
1877
+ }
2053
1878
 
2054
- if (computedStyle.display.includes("flex")) {
2055
- scrollWrapper.style.display = computedStyle.display;
2056
- scrollWrapper.style.flexDirection = computedStyle.flexDirection;
2057
- scrollWrapper.style.flexWrap = computedStyle.flexWrap;
2058
- scrollWrapper.style.alignItems = computedStyle.alignItems;
2059
- scrollWrapper.style.justifyContent = computedStyle.justifyContent;
2060
- scrollWrapper.style.gap = computedStyle.gap;
2061
- } else if (computedStyle.display.includes("grid")) {
2062
- scrollWrapper.style.display = computedStyle.display;
2063
- scrollWrapper.style.gridTemplateColumns =
2064
- computedStyle.gridTemplateColumns;
2065
- scrollWrapper.style.gridTemplateRows = computedStyle.gridTemplateRows;
2066
- scrollWrapper.style.gap = computedStyle.gap;
1879
+ const captureEngine = (() => { // Generated from html-to-image 1.11.13 by scripts/capture-engine.mjs.
1880
+ /*
1881
+ MIT License
1882
+
1883
+ Copyright (c) 2017-2025 W.Y.
1884
+
1885
+ Permission is hereby granted, free of charge, to any person obtaining a copy
1886
+ of this software and associated documentation files (the "Software"), to deal
1887
+ in the Software without restriction, including without limitation the rights
1888
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1889
+ copies of the Software, and to permit persons to whom the Software is
1890
+ furnished to do so, subject to the following conditions:
1891
+
1892
+ The above copyright notice and this permission notice shall be included in all
1893
+ copies or substantial portions of the Software.
1894
+
1895
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1896
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1897
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1898
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1899
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1900
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1901
+ SOFTWARE.
1902
+
1903
+ */
1904
+ function S(t,e){if(t.match(/^[a-z]+:\/\//i))return t;if(t.match(/^\/\//))return window.location.protocol+t;if(t.match(/^[a-z]+:/i))return t;let r=document.implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),e&&(n.href=e),i.href=t,i.href}var P=(()=>{let t=0,e=()=>`0000${(Math.random()*36**4<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${e()}${t}`)})();function u(t){let e=[];for(let r=0,n=t.length;r<n;r++)e.push(t[r]);return e}var f=null;function y(t={}){return f||(t.includeStyleProperties?(f=t.includeStyleProperties,f):(f=u(window.getComputedStyle(document.documentElement)),f))}function w(t){return new Promise((e,r)=>{let n=new Image;n.onload=()=>{n.decode().then(()=>{requestAnimationFrame(()=>e(n))})},n.onerror=r,n.crossOrigin="anonymous",n.decoding="async",n.src=t})}async function D(t){return Promise.resolve().then(()=>new XMLSerializer().serializeToString(t)).then(encodeURIComponent).then(e=>`data:image/svg+xml;charset=utf-8,${e}`)}async function I(t,e,r){let n="http://www.w3.org/2000/svg",i=document.createElementNS(n,"svg"),o=document.createElementNS(n,"foreignObject");return i.setAttribute("width",`${e}`),i.setAttribute("height",`${r}`),i.setAttribute("viewBox",`0 0 ${e} ${r}`),o.setAttribute("width","100%"),o.setAttribute("height","100%"),o.setAttribute("x","0"),o.setAttribute("y","0"),o.setAttribute("externalResourcesRequired","true"),i.appendChild(o),o.appendChild(t),D(i)}var l=(t,e)=>{if(t instanceof e)return!0;let r=Object.getPrototypeOf(t);return r===null?!1:r.constructor.name===e.name||l(r,e)};function U(t){let e=t.getPropertyValue("content");return`${t.cssText} content: '${e.replace(/'|"/g,"")}';`}function V(t,e){return y(e).map(r=>{let n=t.getPropertyValue(r),i=t.getPropertyPriority(r);return`${r}: ${n}${i?" !important":""};`}).join(" ")}function M(t,e,r,n){let i=`.${t}:${e}`,o=r.cssText?U(r):V(r,n);return document.createTextNode(`${i}{${o}}`)}function R(t,e,r,n){let i=window.getComputedStyle(t,r),o=i.getPropertyValue("content");if(o===""||o==="none")return;let a=P();try{e.className=`${e.className} ${a}`}catch{return}let c=document.createElement("style");c.appendChild(M(a,r,i,n)),e.appendChild(c)}function C(t,e,r){R(t,e,":before",r),R(t,e,":after",r)}var T="application/font-woff",$="image/jpeg",F={woff:T,woff2:T,ttf:"application/font-truetype",eot:"application/vnd.ms-fontobject",png:"image/png",jpg:$,jpeg:$,gif:"image/gif",tiff:"image/tiff",svg:"image/svg+xml",webp:"image/webp"};function H(t){let e=/\.([^./]*?)$/g.exec(t);return e?e[1]:""}function m(t){let e=H(t).toLowerCase();return F[e]||""}function O(t){return t.split(/,/)[1]}function p(t){return t.search(/^(data:)/)!==-1}function x(t,e){return`data:${e};base64,${t}`}async function k(t,e,r){let n=await(e?.captureFetch||fetch)(t,e);if(!n.ok)throw new Error(`Resource "${n.url}" not found`);let i=await n.blob();return new Promise((o,a)=>{let c=new FileReader;c.onerror=a,c.onloadend=()=>{try{o(r({res:n,result:c.result}))}catch(s){a(s)}},c.readAsDataURL(i)})}async function h(t,e,r){let n=r.resourceCache||{},i=t;if(n[i]!=null)return n[i];r.cacheBust&&(t+=(/\?/.test(t)?"&":"?")+new Date().getTime());let o;try{let a=await k(t,r.fetchRequestInit,({res:c,result:s})=>(e||(e=c.headers.get("Content-Type")||""),O(s)));o=x(a,e)}catch(a){o=r.imagePlaceholder||"";let c=`Failed to fetch resource: ${t}`;a&&(c=typeof a=="string"?a:a.message),c&&console.warn(c)}return n[i]=o,o}async function q(t){let e=t.toDataURL();return e==="data:,"?t.cloneNode(!1):w(e)}async function _(t,e){if(t.currentSrc){let o=document.createElement("canvas"),a=o.getContext("2d");o.width=t.clientWidth,o.height=t.clientHeight,a?.drawImage(t,0,0,o.width,o.height);let c=o.toDataURL();return w(c)}let r=t.poster,n=m(r),i=await h(r,n,e);return w(i)}async function v(t,e){var r;try{if(!((r=t?.contentDocument)===null||r===void 0)&&r.body)return await b(t.contentDocument.body,e,!0)}catch{}return t.cloneNode(!1)}async function B(t,e){return l(t,HTMLCanvasElement)?q(t).catch(()=>t.cloneNode(!1)):l(t,HTMLVideoElement)?_(t,e).catch(()=>document.createElement("div")):l(t,HTMLIFrameElement)?v(t,e):t.cloneNode(!1)}var G=t=>t.tagName!=null&&t.tagName.toUpperCase()==="SLOT";async function j(t,e,r){var n,i;let o=[];return G(t)&&t.assignedNodes?o=u(t.assignedNodes()):l(t,HTMLIFrameElement)&&(!((n=t.contentDocument)===null||n===void 0)&&n.body)?o=u(t.contentDocument.body.childNodes):o=u(((i=t.shadowRoot)!==null&&i!==void 0?i:t).childNodes),o.length===0||l(t,HTMLVideoElement)||await o.reduce((a,c)=>a.then(()=>b(c,r)).then(s=>{s&&e.appendChild(s)}),Promise.resolve()),e}function z(t,e,r){let n=e.style;if(!n)return;let i=window.getComputedStyle(t);i.cssText?(n.cssText=i.cssText,n.transformOrigin=i.transformOrigin):y(r).forEach(o=>{let a=i.getPropertyValue(o);o==="font-size"&&a.endsWith("px")&&(a=`${parseFloat(a.substring(0,a.length-2))}px`),l(t,HTMLIFrameElement)&&o==="display"&&a==="inline"&&(a="block"),o==="d"&&e.getAttribute("d")&&(a=`path(${e.getAttribute("d")})`),n.setProperty(o,a,i.getPropertyPriority(o))})}function W(t,e){l(t,HTMLTextAreaElement)&&(e.textContent=t.value),l(t,HTMLInputElement)&&e.setAttribute("value",t.value)}function X(t,e){if(l(t,HTMLSelectElement)){let n=Array.from(e.children).find(i=>t.value===i.getAttribute("value"));n&&n.setAttribute("selected","")}}function K(t,e,r){return l(e,Element)&&(z(t,e,r),C(t,e,r),W(t,e),X(t,e)),e}async function J(t,e){let r=t.querySelectorAll?t.querySelectorAll("use"):[];if(r.length===0)return t;let n={};for(let o=0;o<r.length;o++){let a=r[o],c=a.getAttribute("href")||a.getAttribute("xlink:href");if(c){let s=t.querySelector(c),d=document.querySelector(c);!s&&d&&!n[c]&&(n[c]=await b(d,e,!0))}}let i=Object.values(n);if(i.length){let o="http://www.w3.org/1999/xhtml",a=document.createElementNS(o,"svg");a.setAttribute("xmlns",o),a.style.position="absolute",a.style.width="0",a.style.height="0",a.style.overflow="hidden",a.style.display="none";let c=document.createElementNS(o,"defs");a.appendChild(c);for(let s=0;s<i.length;s++)c.appendChild(i[s]);t.appendChild(a)}return t}async function b(t,e,r){if(await e.beforeClone?.(t),!r&&e.filter&&!e.filter(t))return null;let n=e.placeholder?.(t);return n||Promise.resolve(t).then(i=>B(i,e)).then(i=>j(t,i,e)).then(async i=>(await e.beforeClone?.(t),K(t,i,e))).then(i=>e.afterClone?.(t,i)??i).then(i=>J(i,e))}var L=/url\((['"]?)([^'"]+?)\1\)/g,Q=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,Y=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Z(t){let e=t.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${e})(['"]?\\))`,"g")}function N(t){let e=[];return t.replace(L,(r,n,i)=>(e.push(i),r)),e.filter(r=>!p(r)&&!r.startsWith("#"))}async function tt(t,e,r,n,i){try{let o=r?S(e,r):e,a=m(e),c;if(i){let s=await i(o);c=x(s,a)}else c=await h(o,a,n);return t.replace(Z(e),`$1${c}$3`)}catch{}return t}function et(t,{preferredFontFormat:e}){return e?t.replace(Y,r=>{for(;;){let[n,,i]=Q.exec(r)||[];if(!i)return"";if(i===e)return`src: ${n};`}}):t}function rt(t){return t.search(L)!==-1}async function E(t,e,r){if(!rt(t))return t;let n=et(t,r);return N(n).reduce((o,a)=>o.then(c=>tt(c,a,e,r)),Promise.resolve(n))}async function g(t,e,r){var n;let i=(n=e.style)===null||n===void 0?void 0:n.getPropertyValue(t);if(i){let o=await E(i,null,r);return e.style.setProperty(t,o,e.style.getPropertyPriority(t)),!0}return!1}async function nt(t,e){await g("background",t,e)||await g("background-image",t,e),await g("mask",t,e)||await g("-webkit-mask",t,e)||await g("mask-image",t,e)||await g("-webkit-mask-image",t,e)}async function it(t,e){let r=l(t,HTMLImageElement);if(!(r&&!p(t.src))&&!(l(t,SVGImageElement)&&!p(t.href.baseVal)))return;let n=r?t.src:t.href.baseVal,i=await h(n,m(n),e);await new Promise((o,a)=>{t.onload=o,t.onerror=e.onImageErrorHandler?(...s)=>{try{o(e.onImageErrorHandler(...s))}catch(d){a(d)}}:a;let c=t;c.decode&&(c.decode=o),c.loading==="lazy"&&(c.loading="eager"),r?(t.srcset="",t.src=i):t.href.baseVal=i})}async function ot(t,e){let n=u(t.childNodes).map(i=>A(i,e));await Promise.all(n).then(()=>t)}async function A(t,e){await e.beforeClone?.(t),l(t,Element)&&(await nt(t,e),await it(t,e),await ot(t,e))} return {cloneNode:b,embedImages:A,embedResources:E,nodeToDataURL:I}; })();
1905
+ // Fetches belong to one capture. Never forward SDK credentials to a resource host.
1906
+ function createCaptureResources({ apiUrl, projectKey, getHeaders, signal }) {
1907
+ const cache = new Map();
1908
+ const waiting = [];
1909
+ let active = 0;
1910
+ let totalBytes = 0;
1911
+ async function fetchResource(value) {
1912
+ const url = new URL(value, document.baseURI);
1913
+ if (!["http:", "https:", "blob:", "data:"].includes(url.protocol)) throw new Error("Unsupported capture resource");
1914
+ if (cache.has(url.href)) return (await cache.get(url.href)).clone();
1915
+ if (cache.size >= 128) throw new Error("Too many capture resources");
1916
+ const pending = (async () => {
1917
+ if (active >= 4) await new Promise(resolve => waiting.push(resolve));
1918
+ active++;
1919
+ try {
1920
+ signal.throwIfAborted();
1921
+ let response;
1922
+ try {
1923
+ response = await fetch(url, { signal: AbortSignal.any([signal, AbortSignal.timeout(3500)]), credentials: url.origin === location.origin ? "same-origin" : "omit" });
1924
+ if (!response.ok) throw new Error("Resource unavailable");
1925
+ } catch (error) {
1926
+ if (signal.aborted || !apiUrl || !["http:", "https:"].includes(url.protocol) || url.origin === location.origin) throw error;
1927
+ const proxy = new URL(`${apiUrl}/api/sdk/capture-resource`);
1928
+ proxy.searchParams.set("projectKey", projectKey);
1929
+ proxy.searchParams.set("url", url.href);
1930
+ response = await fetch(proxy, { signal, headers: getHeaders(), credentials: "omit" });
1931
+ if (!response.ok) throw new Error("Capture proxy unavailable");
2067
1932
  }
1933
+ const reader = response.body.getReader();
1934
+ const chunks = [];
1935
+ let size = 0;
1936
+ try {
1937
+ for (;;) {
1938
+ const { done, value } = await reader.read();
1939
+ if (done) break;
1940
+ size += value.byteLength;
1941
+ totalBytes += value.byteLength;
1942
+ if (size > 5 * 1024 * 1024 || totalBytes > 24 * 1024 * 1024) throw new Error("Capture resources too large");
1943
+ chunks.push(value);
1944
+ }
1945
+ } finally { await reader.cancel().catch(() => {}); }
1946
+ const blob = new Blob(chunks, { type: response.headers.get("Content-Type") || "application/octet-stream" });
1947
+ return new Response(blob, { headers: { "Content-Type": blob.type } });
1948
+ } finally {
1949
+ active--;
1950
+ waiting.shift()?.();
1951
+ }
1952
+ })();
1953
+ cache.set(url.href, pending);
1954
+ return (await pending).clone();
1955
+ }
1956
+ return fetchResource;
1957
+ }
2068
1958
 
2069
- children.forEach((child) => scrollWrapper.appendChild(child));
2070
- clonedElement.appendChild(scrollWrapper);
2071
- });
1959
+ // Read font rules without inserting remote CSS into the user's live stylesheets.
1960
+ async function captureFontCSS(sheets, families, fetchResource, embedResources, options) {
1961
+ const visited = new Set();
1962
+ const fonts = [];
1963
+ const normalize = value => value.trim().replace(/^['"]|['"]$/g, "").toLowerCase();
1964
+ async function readRules(rules, base) {
1965
+ for (const rule of rules) {
1966
+ if (rule.type === CSSRule.FONT_FACE_RULE && families.has(normalize(rule.style.getPropertyValue("font-family")))) {
1967
+ const woff2 = /format\(["']?woff2["']?\)/i.test(rule.style.getPropertyValue("src"));
1968
+ fonts.push(embedResources(rule.cssText, base, woff2 ? { ...options, preferredFontFormat: "woff2" } : options));
1969
+ } else if (rule.type === CSSRule.IMPORT_RULE) {
1970
+ await readSheet(rule.styleSheet, new URL(rule.href, base).href);
1971
+ } else if (rule.cssRules) await readRules(rule.cssRules, base);
2072
1972
  }
1973
+ }
1974
+ async function readSheet(sheet, url = sheet?.href) {
1975
+ const key = url || sheet;
1976
+ if (!key || visited.has(key)) return;
1977
+ visited.add(key);
1978
+ try {
1979
+ let rules;
1980
+ try { rules = sheet.cssRules; } catch { /* Remote CSS requires CORS or the resource proxy. */ }
1981
+ if (rules) return await readRules(rules, url || document.baseURI);
1982
+ if (!url) return;
1983
+ const css = await (await fetchResource(url)).text();
1984
+ // Constructed sheets parse nested font rules but intentionally discard @import.
1985
+ // Resolve imports separately, relative to the stylesheet rather than the page.
1986
+ const imports = [...css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/@import\s+(?:url\(\s*(?:"([^"]+)"|'([^']+)'|([^\s)]+))\s*\)|"([^"]+)"|'([^']+)')/gi)];
1987
+ await Promise.all(imports.map(match => readSheet(null, new URL(match.slice(1).find(Boolean), url).href)));
1988
+ const parsed = new CSSStyleSheet();
1989
+ parsed.replaceSync(css);
1990
+ await readRules(parsed.cssRules, url);
1991
+ } catch (error) {
1992
+ if (options.signal.aborted) throw error;
1993
+ console.warn("Comty: a capture font stylesheet was unavailable", url);
1994
+ }
1995
+ }
1996
+ await Promise.all([...sheets].map(sheet => readSheet(sheet)));
1997
+ return (await Promise.all(fonts)).join("\n");
1998
+ }
2073
1999
 
2074
- async function captureViewportScreenshot() {
2075
- await waitForPaint();
2076
2000
 
2077
- const width = window.innerWidth;
2078
- const height = window.innerHeight;
2079
- const scrollX = window.scrollX;
2080
- const scrollY = window.scrollY;
2081
- const captureWidth = Math.max(width + scrollX, width);
2082
- const documentHeight = Math.max(
2083
- document.documentElement.scrollHeight,
2084
- document.body?.scrollHeight ?? 0,
2085
- height,
2086
- );
2087
- const captureIdAttribute = "data-comty-capture-id";
2088
- const markedElements = markCaptureElements(
2089
- document.documentElement,
2090
- captureIdAttribute,
2091
- );
2092
- let clone;
2001
+ function captureContext() {
2002
+ return { url: location.href, width: innerWidth, height: innerHeight, scrollX, scrollY };
2003
+ }
2093
2004
 
2094
- try {
2095
- clone = document.documentElement.cloneNode(true);
2096
- pinFixedElementsToViewport(
2097
- clone,
2098
- markedElements,
2099
- captureIdAttribute,
2100
- scrollX,
2101
- scrollY,
2102
- width,
2103
- height,
2104
- );
2105
- syncScrollableElementOffsets(clone, markedElements, captureIdAttribute);
2106
- } finally {
2107
- restoreCaptureElementMarks(markedElements, captureIdAttribute);
2108
- }
2109
-
2110
- clone.querySelectorAll("script").forEach((node) => node.remove());
2111
- syncFormValues(document, clone);
2112
- makeCloneUrlsAbsolute(clone);
2113
- await inlineImageResources(clone);
2114
- inlineAccessibleStyles(clone, width, height);
2115
- sanitizeInlineStyles(clone, width, height);
2116
- removeCaptureLockStyles(clone);
2117
- removeExportUnsafeNodes(clone);
2118
- removeCaptureElementMarks(clone, captureIdAttribute);
2119
- clone.style.width = `${captureWidth}px`;
2120
- clone.style.height = `${documentHeight}px`;
2121
- clone.style.margin = "0";
2122
- clone.style.overflow = "visible";
2005
+ async function yieldCapture() {
2006
+ if (globalThis.scheduler?.yield) await globalThis.scheduler.yield();
2007
+ else await new Promise(resolve => setTimeout(resolve, 0));
2008
+ }
2123
2009
 
2124
- const clonedBody = clone.querySelector("body");
2125
- if (clonedBody instanceof HTMLElement) {
2126
- clonedBody.style.width = getComputedStyle(document.body).width;
2127
- clonedBody.style.minHeight = `${documentHeight}px`;
2128
- clonedBody.style.margin = clonedBody.style.margin || "0";
2129
- }
2130
-
2131
- const wrapper = document.createElement("div");
2132
- wrapper.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
2133
- wrapper.style.position = "relative";
2134
- wrapper.style.width = `${captureWidth}px`;
2135
- wrapper.style.height = `${documentHeight}px`;
2136
- wrapper.style.overflow = "hidden";
2137
- wrapper.style.transform = `translate(${-scrollX}px, ${-scrollY}px)`;
2138
- wrapper.style.transformOrigin = "top left";
2139
- wrapper.appendChild(clone);
2140
-
2141
- const markup = new XMLSerializer().serializeToString(wrapper);
2142
- const svg = `
2143
- <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
2144
- <foreignObject width="100%" height="100%">${markup}</foreignObject>
2145
- </svg>
2146
- `;
2147
- return {
2148
- blob: new Blob([svg], { type: "image/svg+xml" }),
2149
- width,
2150
- height,
2010
+ const captureExcluded = "#comty-root, .comty-sidepanel, .comty-pin, .comty-bubble, .comty-create-bubble, .comty-popover, .comty-temp-pin, [data-comty-capture-exclude]";
2011
+ const transparentPixel = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
2012
+
2013
+ function createScreenshotCapture({ apiUrl, projectKey, getHeaders = () => ({}) } = {}) {
2014
+ async function bounded(promise, ms) {
2015
+ let timer;
2016
+ try { return await Promise.race([promise, new Promise(resolve => { timer = setTimeout(resolve, ms); })]); }
2017
+ finally { clearTimeout(timer); }
2018
+ }
2019
+
2020
+ async function captureViewportScreenshot({ context = captureContext(), annotation } = {}) {
2021
+ if (document.visibilityState === "hidden") return null;
2022
+ const controller = new AbortController();
2023
+ const timeout = setTimeout(() => controller.abort(), 15000);
2024
+ const { signal } = controller;
2025
+ const untilAborted = promise => new Promise((resolve, reject) => {
2026
+ const aborted = () => reject(new Error("Screenshot timed out"));
2027
+ signal.addEventListener("abort", aborted, { once: true });
2028
+ Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted));
2029
+ if (signal.aborted) aborted();
2030
+ });
2031
+ let frozen = false;
2032
+ let lastYield = performance.now();
2033
+ const checkContext = () => {
2034
+ signal.throwIfAborted();
2035
+ if (!frozen && (context.url !== location.href || context.width !== innerWidth || context.height !== innerHeight || context.scrollX !== scrollX || context.scrollY !== scrollY)) {
2036
+ throw new Error("Page moved before its screenshot was ready");
2037
+ }
2038
+ };
2039
+ const cooperate = async () => {
2040
+ checkContext();
2041
+ if (performance.now() - lastYield >= 6) {
2042
+ await yieldCapture();
2043
+ lastYield = performance.now();
2044
+ checkContext();
2045
+ }
2046
+ };
2047
+ try {
2048
+ await yieldCapture();
2049
+ await bounded(document.fonts?.ready || Promise.resolve(), 1200);
2050
+ checkContext();
2051
+ const resources = createCaptureResources({ apiUrl, projectKey, getHeaders, signal });
2052
+ const fontFamilies = new Set();
2053
+ const sheets = new Set([...document.styleSheets, ...document.adoptedStyleSheets]);
2054
+ const addFonts = value => value.split(",").forEach(family => fontFamilies.add(family.trim().replace(/^['"]|['"]$/g, "").toLowerCase()));
2055
+ const positioned = [];
2056
+ let cloningAnnotation = false;
2057
+ const options = {
2058
+ resourceCache: Object.create(null),
2059
+ includeQueryParams: true,
2060
+ imagePlaceholder: transparentPixel,
2061
+ signal,
2062
+ fetchRequestInit: { captureFetch: resources },
2063
+ beforeClone: cooperate,
2064
+ placeholder(source) {
2065
+ if (cloningAnnotation) return null;
2066
+ // Offscreen leaf boxes still occupy their exact layout space, but need no
2067
+ // text, fonts or hundreds of computed declarations in the exported SVG.
2068
+ if (!(source instanceof HTMLElement)) return null;
2069
+ if (/^(IFRAME|OBJECT|EMBED)$/.test(source.tagName)) {
2070
+ const spacer = document.createElement("div");
2071
+ const style = getComputedStyle(source);
2072
+ for (const property of style) spacer.style.setProperty(property, style.getPropertyValue(property));
2073
+ return spacer;
2074
+ }
2075
+ if (source.children.length || source.shadowRoot) return null;
2076
+ const rect = source.getBoundingClientRect();
2077
+ if (!rect.width || !rect.height || (rect.bottom > 0 && rect.top < context.height && rect.right > 0 && rect.left < context.width)) return null;
2078
+ const style = getComputedStyle(source);
2079
+ if (!["static", "relative"].includes(style.position) || !["block", "flex", "grid"].includes(style.display) || style.transform !== "none") return null;
2080
+ const spacer = document.createElement("div");
2081
+ for (const property of ["display", "margin", "flex", "align-self", "grid-area", "order"]) spacer.style.setProperty(property, style.getPropertyValue(property));
2082
+ Object.assign(spacer.style, { width: `${rect.width}px`, height: `${rect.height}px`, boxSizing: "border-box", visibility: "hidden" });
2083
+ return spacer;
2084
+ },
2085
+ filter(node) {
2086
+ if (!(node instanceof Element)) return true;
2087
+ if ((!cloningAnnotation && node.matches(captureExcluded)) || /^(SCRIPT|STYLE|LINK|HEAD|NOSCRIPT|AUDIO)$/.test(node.tagName)) return false;
2088
+ return getComputedStyle(node).display !== "none";
2089
+ },
2090
+ afterClone(source, clone) {
2091
+ if (!(source instanceof Element) || !clone.style) return clone;
2092
+ const style = getComputedStyle(source);
2093
+ addFonts(style.fontFamily);
2094
+ for (const pseudo of ["::before", "::after"]) {
2095
+ const pseudoStyle = getComputedStyle(source, pseudo);
2096
+ if (pseudoStyle.content && !["none", "normal"].includes(pseudoStyle.content)) addFonts(pseudoStyle.fontFamily);
2097
+ }
2098
+ if (source.shadowRoot) {
2099
+ for (const sheet of [...source.shadowRoot.styleSheets, ...source.shadowRoot.adoptedStyleSheets]) sheets.add(sheet);
2100
+ }
2101
+ clone.style.setProperty("animation", "none", "important");
2102
+ clone.style.setProperty("transition", "none", "important");
2103
+ clone.style.setProperty("caret-color", "transparent", "important");
2104
+ // Chromium 140 exposes these values but omits them from style enumeration.
2105
+ for (const property of ["text-underline-offset", "text-decoration-thickness"]) {
2106
+ clone.style.setProperty(property, style.getPropertyValue(property));
2107
+ }
2108
+ // WebKit scrollbar pseudo-styles are not part of the computed element style.
2109
+ if ([style.overflowX, style.overflowY].some(value => value === "auto" || value === "scroll") && getComputedStyle(source, "::-webkit-scrollbar").display === "none") {
2110
+ clone.style.setProperty("scrollbar-width", "none", "important");
2111
+ }
2112
+ // A cloned document must not re-run handlers or select a different responsive image.
2113
+ for (const attribute of [...clone.attributes]) {
2114
+ if (attribute.name.startsWith("on")) clone.removeAttribute(attribute.name);
2115
+ }
2116
+ if (source instanceof HTMLImageElement) {
2117
+ clone.removeAttribute("srcset");
2118
+ clone.removeAttribute("sizes");
2119
+ clone.src = source.currentSrc || source.src || transparentPixel;
2120
+ clone.loading = "eager";
2121
+ }
2122
+ if (source instanceof HTMLSourceElement) {
2123
+ clone.removeAttribute("srcset");
2124
+ clone.removeAttribute("src");
2125
+ }
2126
+ if (source instanceof HTMLInputElement) {
2127
+ if (source.type === "password") clone.setAttribute("value", "•".repeat(source.value.length));
2128
+ clone.toggleAttribute("checked", source.checked);
2129
+ }
2130
+ if (source instanceof HTMLSelectElement) {
2131
+ [...clone.options].forEach((option, index) => option.toggleAttribute("selected", source.options[index]?.selected));
2132
+ }
2133
+ const rect = source.getBoundingClientRect();
2134
+ if (!cloningAnnotation && ["fixed", "sticky"].includes(style.position)) {
2135
+ // Composite viewport-bound elements independently of the document's scroll.
2136
+ // Keep a spacer for sticky flow; the real source is never moved or restyled.
2137
+ positioned.push({ clone, rect, zIndex: Number.parseInt(style.zIndex, 10) || 0 });
2138
+ const spacer = document.createElement("div");
2139
+ if (style.position === "sticky") {
2140
+ spacer.style.cssText = clone.style.cssText;
2141
+ spacer.style.position = "static";
2142
+ spacer.style.visibility = "hidden";
2143
+ spacer.style.width = `${source.offsetWidth}px`;
2144
+ spacer.style.height = `${source.offsetHeight}px`;
2145
+ } else spacer.style.display = "none";
2146
+ return spacer;
2147
+ }
2148
+ if (source !== document.documentElement && source !== document.body && (source.scrollTop || source.scrollLeft)) {
2149
+ const wrapper = document.createElement("div");
2150
+ for (const property of ["display", "flex-direction", "flex-wrap", "align-items", "justify-content", "gap", "grid-template-columns", "grid-template-rows"]) {
2151
+ wrapper.style.setProperty(property, style.getPropertyValue(property));
2152
+ }
2153
+ const horizontalPadding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
2154
+ wrapper.style.width = `${Math.max(source.scrollWidth, source.clientWidth) - horizontalPadding}px`;
2155
+ wrapper.style.transform = `translate(${-source.scrollLeft}px, ${-source.scrollTop}px)`;
2156
+ wrapper.style.transformOrigin = "top left";
2157
+ wrapper.append(...clone.childNodes);
2158
+ clone.append(wrapper);
2159
+ clone.style.display = "block";
2160
+ }
2161
+ return clone;
2162
+ },
2151
2163
  };
2164
+ const clone = await captureEngine.cloneNode(document.documentElement, options, true);
2165
+ checkContext();
2166
+ frozen = true;
2167
+ const stage = document.createElement("div");
2168
+ stage.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
2169
+ stage.style.position = "relative";
2170
+ stage.style.width = `${context.width}px`;
2171
+ stage.style.height = `${context.height}px`;
2172
+ stage.style.overflow = "hidden";
2173
+ // The browser propagates the root (or body's) background to the viewport;
2174
+ // an HTML clone inside SVG does not, especially with collapsed margins.
2175
+ const rootStyle = getComputedStyle(document.documentElement);
2176
+ stage.style.backgroundColor = rootStyle.backgroundColor === "rgba(0, 0, 0, 0)" && rootStyle.backgroundImage === "none"
2177
+ ? getComputedStyle(document.body).backgroundColor : rootStyle.backgroundColor;
2178
+ const documentLayer = document.createElement("div");
2179
+ documentLayer.style.transform = `translate(${-context.scrollX}px, ${-context.scrollY}px)`;
2180
+ documentLayer.style.transformOrigin = "top left";
2181
+ clone.style.width = `${context.width}px`;
2182
+ clone.style.height = `${Math.max(document.documentElement.scrollHeight, context.height)}px`;
2183
+ clone.style.overflow = "visible";
2184
+ clone.style.margin = "0";
2185
+ documentLayer.append(clone);
2186
+ stage.append(documentLayer);
2187
+ for (const item of positioned.sort((a, b) => a.zIndex - b.zIndex)) {
2188
+ const { clone: node, rect } = item;
2189
+ if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= context.height || rect.left >= context.width) continue;
2190
+ Object.assign(node.style, { position: "absolute", left: `${rect.left}px`, top: `${rect.top}px`, right: "auto", bottom: "auto", width: `${rect.width}px`, height: `${rect.height}px`, boxSizing: "border-box", margin: "0", transform: "none" });
2191
+ stage.append(node);
2192
+ }
2193
+ if (annotation) {
2194
+ // Measure the real Comty components in an invisible host, then include
2195
+ // their styled clones in the same font/image embedding as the page.
2196
+ const mounted = annotation();
2197
+ try {
2198
+ cloningAnnotation = true;
2199
+ for (const source of mounted.elements) {
2200
+ const rect = source.getBoundingClientRect();
2201
+ const node = await captureEngine.cloneNode(source, options, true);
2202
+ Object.assign(node.style, { position: "absolute", left: `${rect.left}px`, top: `${rect.top}px`, right: "auto", bottom: "auto", width: `${rect.width}px`, height: `${rect.height}px`, boxSizing: "border-box", margin: "0", transform: "none" });
2203
+ stage.append(node);
2204
+ }
2205
+ } finally {
2206
+ cloningAnnotation = false;
2207
+ mounted.dispose();
2208
+ }
2209
+ }
2210
+ const fonts = await untilAborted(captureFontCSS(sheets, fontFamilies, resources, captureEngine.embedResources, options));
2211
+ if (fonts) {
2212
+ const style = document.createElement("style");
2213
+ style.textContent = fonts;
2214
+ stage.prepend(style);
2215
+ }
2216
+ // Pseudo-element styles are generated by the library and need their URLs embedded too.
2217
+ for (const style of stage.querySelectorAll("style")) {
2218
+ await cooperate();
2219
+ style.textContent = await captureEngine.embedResources(style.textContent, document.baseURI, options);
2220
+ }
2221
+ await untilAborted(captureEngine.embedImages(stage, options));
2222
+ checkContext();
2223
+ await yieldCapture();
2224
+ const svg = await captureEngine.nodeToDataURL(stage, context.width, context.height);
2225
+ const image = new Image();
2226
+ image.src = svg;
2227
+ await untilAborted(image.decode());
2228
+ checkContext();
2229
+ const scale = Math.min(2, devicePixelRatio || 1, 2560 / context.width, 2560 / context.height);
2230
+ const canvas = document.createElement("canvas");
2231
+ canvas.width = Math.round(context.width * scale);
2232
+ canvas.height = Math.round(context.height * scale);
2233
+ const ctx = canvas.getContext("2d");
2234
+ ctx.scale(scale, scale);
2235
+ ctx.drawImage(image, 0, 0);
2236
+ let blob = await untilAborted(new Promise(resolve => canvas.toBlob(resolve, "image/png")));
2237
+ if (!blob) throw new Error("Screenshot encoding failed");
2238
+ if (blob.size > 2800000) {
2239
+ ctx.globalCompositeOperation = "destination-over";
2240
+ ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("background-color");
2241
+ ctx.fillRect(0, 0, context.width, context.height);
2242
+ blob = await untilAborted(new Promise(resolve => canvas.toBlob(resolve, "image/jpeg", 0.9)));
2243
+ if (!blob) throw new Error("Screenshot encoding failed");
2244
+ }
2245
+ // CSS dimensions keep the eyedropper's coordinate system independent of DPR.
2246
+ return { blob, width: canvas.width, height: canvas.height, cssWidth: context.width, cssHeight: context.height };
2247
+ } finally {
2248
+ clearTimeout(timeout);
2249
+ controller.abort();
2152
2250
  }
2153
-
2154
- return { waitForPaint, waitForCustomElements, captureViewportScreenshot };
2251
+ }
2252
+ return { captureViewportScreenshot };
2155
2253
  }
2156
2254
 
2157
2255
  // Page-local counterpart of the native eyedropper; never captures outside the page.
@@ -2199,7 +2297,9 @@ function createColorMagnifier({ root, isComtyElement, capture }) {
2199
2297
  sampledHex = null;
2200
2298
  if (!enabled) return;
2201
2299
  try {
2202
- const { blob } = await capture();
2300
+ const screenshot = await capture();
2301
+ if (!screenshot) return;
2302
+ const { blob, cssWidth, cssHeight } = screenshot;
2203
2303
  if (!enabled || version !== revision) return;
2204
2304
  const dataUrl = await new Promise((resolve, reject) => {
2205
2305
  const reader = new FileReader();
@@ -2215,7 +2315,11 @@ function createColorMagnifier({ root, isComtyElement, capture }) {
2215
2315
  const probe = document.createElement('canvas').getContext('2d');
2216
2316
  probe.drawImage(next, 0, 0);
2217
2317
  probe.getImageData(0, 0, 1, 1);
2218
- image = next;
2318
+ const scaled = document.createElement('canvas');
2319
+ scaled.width = cssWidth || next.width;
2320
+ scaled.height = cssHeight || next.height;
2321
+ scaled.getContext('2d').drawImage(next, 0, 0, scaled.width, scaled.height);
2322
+ image = scaled;
2219
2323
  paint();
2220
2324
  } catch { /* The CSS-colour fallback remains available without screen permission. */ }
2221
2325
  }
@@ -2262,7 +2366,7 @@ function createCommentThread(context) {
2262
2366
  const {
2263
2367
  root, apiUrl, projectKey, commentStatuses, selectedStatusFilters, placedPins,
2264
2368
  closeBubbles, getCommentStatus, updateCommentStatus, applyStatusDot,
2265
- renderSidepanelComments, applyStatusFilters, createThreadItem,
2369
+ renderSidepanelComments, applyStatusFilters, createThreadItem, renderCommentContent,
2266
2370
  isOwnCommentEntry, updateCommentContent, deleteSdkComment, getCommentId,
2267
2371
  updateReplyContent, deleteSdkReply, createCommentAvatar, getSdkHeaders,
2268
2372
  showAuthPrompt,
@@ -2290,14 +2394,6 @@ function createCommentThread(context) {
2290
2394
  const header = document.createElement("div");
2291
2395
  header.className = "comty-bubble-header";
2292
2396
 
2293
- const title = document.createElement("h2");
2294
- title.className = "comty-bubble-title";
2295
- title.innerText = "Comment";
2296
- header.appendChild(title);
2297
-
2298
- const headerActions = document.createElement("div");
2299
- headerActions.className = "comty-bubble-header-actions";
2300
-
2301
2397
  const statusMenuButton = document.createElement("button");
2302
2398
  statusMenuButton.className = "comty-status-trigger";
2303
2399
  statusMenuButton.type = "button";
@@ -2336,7 +2432,7 @@ function createCommentThread(context) {
2336
2432
  };
2337
2433
 
2338
2434
  const saveStatus = async (nextStatus, control) => {
2339
- if (!context.canWrite) return false;
2435
+ if (!context.canManage) return false;
2340
2436
  control.disabled = true;
2341
2437
  const savedStatus = await updateCommentStatus(comment, nextStatus);
2342
2438
  control.disabled = false;
@@ -2361,7 +2457,7 @@ function createCommentThread(context) {
2361
2457
  return true;
2362
2458
  };
2363
2459
 
2364
- statusMenuButton.disabled = !context.canWrite;
2460
+ statusMenuButton.disabled = !context.canManage;
2365
2461
  statusMenuButton.onclick = (event) => {
2366
2462
  event.stopPropagation();
2367
2463
  const existingPopover = document.querySelector(
@@ -2373,10 +2469,11 @@ function createCommentThread(context) {
2373
2469
  if (existingPopover) return;
2374
2470
 
2375
2471
  const popover = document.createElement("div");
2376
- popover.className = "comty-popover comty-status-popover";
2472
+ popover.className = "comty-popover comty-comment-popover comty-status-popover";
2377
2473
  const buttonRect = statusMenuButton.getBoundingClientRect();
2378
2474
  popover.style.top = `${buttonRect.bottom + 4}px`;
2379
- popover.style.left = `${buttonRect.right - 140}px`;
2475
+ popover.style.left = `${buttonRect.left}px`;
2476
+ popover.style.transformOrigin = "top left";
2380
2477
 
2381
2478
  for (const status of commentStatuses) {
2382
2479
  const item = document.createElement("button");
@@ -2414,6 +2511,13 @@ function createCommentThread(context) {
2414
2511
  }
2415
2512
 
2416
2513
  root.appendChild(popover);
2514
+ const popoverWidth = popover.offsetWidth;
2515
+ const popoverHeight = popover.offsetHeight;
2516
+ popover.style.left = `${Math.max(8, Math.min(buttonRect.left, innerWidth - popoverWidth - 8))}px`;
2517
+ if (buttonRect.bottom + 4 + popoverHeight > innerHeight - 8) {
2518
+ popover.style.top = `${Math.max(8, buttonRect.top - popoverHeight - 4)}px`;
2519
+ popover.style.transformOrigin = "bottom left";
2520
+ }
2417
2521
 
2418
2522
  const closeStatusPopover = (closeEvent) => {
2419
2523
  if (
@@ -2430,9 +2534,7 @@ function createCommentThread(context) {
2430
2534
  );
2431
2535
  };
2432
2536
 
2433
- headerActions.appendChild(statusMenuButton);
2434
- headerActions.appendChild(closeButton);
2435
- header.appendChild(headerActions);
2537
+ header.append(statusMenuButton, closeButton);
2436
2538
  bubble.appendChild(header);
2437
2539
 
2438
2540
  const thread = document.createElement("div");
@@ -2451,7 +2553,7 @@ function createCommentThread(context) {
2451
2553
 
2452
2554
  const popover = document.createElement("div");
2453
2555
  popover.className =
2454
- "comty-popover comty-entry-actions-popover";
2556
+ "comty-popover comty-comment-popover comty-entry-actions-popover";
2455
2557
 
2456
2558
  const editItem = document.createElement("button");
2457
2559
  editItem.className = "comty-popover-item";
@@ -2537,7 +2639,7 @@ function createCommentThread(context) {
2537
2639
 
2538
2640
  const previousContent = entry.content;
2539
2641
  entry.content = nextContent;
2540
- item.content.innerText = nextContent;
2642
+ renderCommentContent(item.content, nextContent);
2541
2643
  editor.disabled = true;
2542
2644
  editor.replaceWith(item.content);
2543
2645
  renderSidepanelComments();
@@ -2545,11 +2647,11 @@ function createCommentThread(context) {
2545
2647
  const savedContent = await persist(nextContent);
2546
2648
  if (!savedContent) {
2547
2649
  entry.content = previousContent;
2548
- item.content.innerText = previousContent || "";
2650
+ renderCommentContent(item.content, previousContent || "");
2549
2651
  renderSidepanelComments();
2550
2652
  return;
2551
2653
  }
2552
- item.content.innerText = savedContent;
2654
+ renderCommentContent(item.content, savedContent);
2553
2655
  renderSidepanelComments();
2554
2656
  };
2555
2657
 
@@ -2655,9 +2757,11 @@ function createCommentThread(context) {
2655
2757
  replyForm.appendChild(createCommentAvatar(context.currentUser || { name: "You" }));
2656
2758
  const replyField = document.createElement("div");
2657
2759
  replyField.className = "comty-reply-field";
2658
- const replyInput = document.createElement("input");
2760
+ const replyInput = document.createElement("textarea");
2659
2761
  replyInput.className = "comty-reply-input";
2660
2762
  replyInput.placeholder = "Reply";
2763
+ replyInput.rows = 1;
2764
+ replyInput.setAttribute("aria-label", "Reply");
2661
2765
 
2662
2766
  const replyButton = document.createElement("button");
2663
2767
  replyButton.className = "comty-reply-button";
@@ -2691,6 +2795,7 @@ function createCommentThread(context) {
2691
2795
  comment.replies = replies;
2692
2796
  appendReply(data.reply);
2693
2797
  replyInput.value = "";
2798
+ syncReplySize();
2694
2799
  requestAnimationFrame(() => {
2695
2800
  threadList.scrollTop = threadList.scrollHeight;
2696
2801
  });
@@ -2704,12 +2809,28 @@ function createCommentThread(context) {
2704
2809
  }
2705
2810
  };
2706
2811
 
2812
+ const syncReplySize = () => {
2813
+ const currentHeight = replyInput.getBoundingClientRect().height;
2814
+ replyInput.style.transition = "none";
2815
+ const lineHeight = parseFloat(getComputedStyle(replyInput).lineHeight);
2816
+ replyInput.style.height = `${lineHeight}px`;
2817
+ const contentHeight = replyInput.scrollHeight;
2818
+ replyInput.style.height = `${currentHeight}px`;
2819
+ replyInput.getBoundingClientRect();
2820
+ replyInput.style.transition = "";
2821
+ const nextHeight = Math.min(contentHeight, parseFloat(getComputedStyle(replyInput).maxHeight));
2822
+ replyInput.style.height = `${nextHeight}px`;
2823
+ if (contentHeight <= lineHeight + 1) replyInput.scrollTop = 0;
2824
+ replyField.classList.toggle("comty-reply-expanded", contentHeight > lineHeight + 1);
2825
+ };
2826
+
2707
2827
  replyInput.oninput = () => {
2828
+ syncReplySize();
2708
2829
  replyButton.disabled = replyInput.value.trim().length === 0;
2709
2830
  };
2710
2831
  replyInput.onkeydown = (event) => {
2711
2832
  event.stopPropagation();
2712
- if (event.key === "Enter" && !event.shiftKey) {
2833
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
2713
2834
  event.preventDefault();
2714
2835
  void submitReply();
2715
2836
  }
@@ -2727,10 +2848,10 @@ function createCommentThread(context) {
2727
2848
  const signInButton = document.createElement("button");
2728
2849
  signInButton.className = "comty-sign-in-reply";
2729
2850
  signInButton.type = "button";
2730
- signInButton.innerText = "Sign in to reply";
2851
+ signInButton.innerText = context.allowGuestComments ? "Leave a reply" : "Sign in to reply";
2731
2852
  signInButton.onclick = (event) => {
2732
2853
  event.stopPropagation();
2733
- showAuthPrompt();
2854
+ showAuthPrompt(false, () => openCommentThread(comment, pin, pinStatusDot, x, y, removeHoverBubble));
2734
2855
  };
2735
2856
  replyForm.appendChild(signInButton);
2736
2857
  }
@@ -3020,11 +3141,14 @@ function initComty(options = {}) {
3020
3141
  const observedCommentTargets = new WeakSet();
3021
3142
  let sidepanelOpen = false;
3022
3143
  let canWrite = false;
3144
+ let canManage = false;
3145
+ let allowGuestComments = false;
3023
3146
  let loginUrl = "";
3024
3147
  let currentUser = null;
3025
3148
  let authStatus = "";
3026
3149
  let didCompleteSignIn = false;
3027
3150
  const tokenStorageKey = `comty:${projectKey}:token`;
3151
+ const authReturnStorageKey = `comty:${projectKey}:auth-return`;
3028
3152
  let sdkToken = "";
3029
3153
  const activeHoverCommentIds = new Set();
3030
3154
  /** @type {null | 'edit' | 'browse'} */
@@ -3033,7 +3157,6 @@ function initComty(options = {}) {
3033
3157
  const placementDiagnostics = new Map();
3034
3158
  let inspectedElement = null;
3035
3159
  let selectedElement = null;
3036
- let inspectExpanded = false;
3037
3160
  let measureAlt = false;
3038
3161
  let inspectDrag = null;
3039
3162
  let measuredRegion = null;
@@ -3715,6 +3838,21 @@ function initComty(options = {}) {
3715
3838
  const copyIcon = icon('<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>');
3716
3839
  const checkIcon = icon('<path d="m5 12 4 4 10-10"/>');
3717
3840
  const copyFeedbackTimers = new WeakMap();
3841
+ async function copyPropertyValue(button, feedback) {
3842
+ clearTimeout(copyFeedbackTimers.get(button));
3843
+ try {
3844
+ await navigator.clipboard.writeText(button.dataset.copy);
3845
+ if (!button.isConnected) return;
3846
+ button.dataset.copied = "";
3847
+ feedback.textContent = "Copied";
3848
+ clearTimeout(copyFeedbackTimers.get(button));
3849
+ copyFeedbackTimers.set(button, setTimeout(() => { delete button.dataset.copied; }, 1200));
3850
+ } catch {
3851
+ delete button.dataset.copied;
3852
+ if (button.isConnected) feedback.textContent = "Clipboard unavailable — select the value to copy";
3853
+ }
3854
+ }
3855
+
3718
3856
  function propertyRow(label, value, display = value) {
3719
3857
  const unavailable = value === "—";
3720
3858
  return `<div class="comty-property-row"><span>${label}</span><button type="button" data-copy="${escapeInspectorText(value)}" aria-label="Copy ${label}: ${escapeInspectorText(display)}" ${unavailable ? "disabled" : ""}><span>${escapeInspectorText(display)}</span>${unavailable ? "" : `<i class="comty-property-copy-icon" aria-hidden="true">${copyIcon}${checkIcon}</i>`}</button></div>`;
@@ -3741,7 +3879,7 @@ function initComty(options = {}) {
3741
3879
  propertyPanel.setAttribute("aria-label", propertyKind === "color" ? "Recent colours" : "Recent typography");
3742
3880
  const recent = propertyKind === "color" ? recentColors : recentFonts;
3743
3881
  const details = propertyKind === "color" && expanded
3744
- ? `<div class="comty-property-detail"><div class="comty-property-rows">${propertyRow("HEX", propertySelection.hex)}${propertyRow("RGB", propertySelection.rgb)}${propertyRow("HSL", propertySelection.hsl)}${propertyRow("OKLCH", propertySelection.oklch)}${propertyRow("Token", propertySelection.token ? `var(${propertySelection.token})` : "—")}</div></div>`
3882
+ ? `<div class="comty-property-detail"><div class="comty-property-rows">${propertyRow("HEX", propertySelection.hex)}${propertyRow("RGB", propertySelection.rgb)}${propertyRow("HSL", propertySelection.hsl)}${propertyRow("OKLCH", propertySelection.oklch)}${propertyRow("Token", propertySelection.token ? propertySelection.token.replace(/^--/, "") : "—", propertySelection.token || "—")}</div></div>`
3745
3883
  : propertyKind === "font" && expanded
3746
3884
  ? `<div class="comty-property-detail"><div class="comty-property-rows">${propertyRow("Family", propertySelection.family)}${propertyRow("Size", propertySelection.size)}${propertyRow("Weight", propertySelection.weight)}${propertyRow("Leading", propertySelection.leading)}${propertyRow("Tracking", propertySelection.tracking)}${propertyRow("Colour", propertySelection.color?.hex || "—")}${propertyRow("CSS", propertySelection.css, "All of the above")}</div></div>`
3747
3885
  : "";
@@ -3857,18 +3995,7 @@ function initComty(options = {}) {
3857
3995
  renderPropertyPanel(true);
3858
3996
  propertyPanel.querySelector(`button[data-recent="${button.dataset.recent}"]`)?.focus({ preventScroll: true });
3859
3997
  } else if (button.dataset.copy != null && button.dataset.copy !== "—") {
3860
- clearTimeout(copyFeedbackTimers.get(button));
3861
- try {
3862
- await navigator.clipboard.writeText(button.dataset.copy);
3863
- if (!button.isConnected || propertyPanel.hidden) return;
3864
- button.dataset.copied = "";
3865
- propertyPanel.querySelector(".comty-property-feedback").textContent = `${button.getAttribute("aria-label").slice(5)} copied`;
3866
- clearTimeout(copyFeedbackTimers.get(button));
3867
- copyFeedbackTimers.set(button, setTimeout(() => { delete button.dataset.copied; }, 1200));
3868
- } catch {
3869
- delete button.dataset.copied;
3870
- if (button.isConnected && !propertyPanel.hidden) propertyPanel.querySelector(".comty-property-feedback").textContent = "Clipboard unavailable";
3871
- }
3998
+ await copyPropertyValue(button, propertyPanel.querySelector(".comty-property-feedback"));
3872
3999
  }
3873
4000
  };
3874
4001
 
@@ -4365,7 +4492,8 @@ function initComty(options = {}) {
4365
4492
  : headers;
4366
4493
  }
4367
4494
 
4368
- function showAuthPrompt(forbidden = false) {
4495
+ function showAuthPrompt(forbidden = false, onReady = startEditSession) {
4496
+ const guestAllowed = allowGuestComments;
4369
4497
  document.querySelector(".comty-auth-backdrop")?.remove();
4370
4498
  const opener = document.activeElement;
4371
4499
  const backdrop = document.createElement("div");
@@ -4376,12 +4504,23 @@ function initComty(options = {}) {
4376
4504
  <span class="comty-auth-icon">${icon('<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.5 8.5 0 1 1 16.1-3.8Z"/>')}</span>
4377
4505
  <button type="button" class="comty-auth-close" data-comty-close aria-label="Close sign-in dialog">${icon('<path d="m6 6 12 12M6 18 18 6"/>')}</button>
4378
4506
  </div>
4379
- <h2 id="comty-auth-title">${forbidden ? "Project access required" : "Sign in to comment"}</h2>
4380
- <p id="comty-auth-description">${forbidden ? "This account is not a member of this project. Ask the project owner for an invite." : "Sign in to share feedback with your team and keep the conversation in one place."}</p>
4381
- <div class="comty-auth-actions">
4382
- <button type="button" data-comty-close>${forbidden ? "Close" : "Cancel"}</button>
4383
- ${forbidden ? "" : '<button type="button" class="comty-auth-primary" data-comty-login>Continue to sign in</button>'}
4384
- </div>
4507
+ <h2 id="comty-auth-title">${guestAllowed ? "Leave a comment" : forbidden ? "Project access required" : "Sign in to comment"}</h2>
4508
+ <p id="comty-auth-description">${guestAllowed ? "Continue with your name or sign in with Google." : forbidden ? "This account is not a member of this project. Ask the project owner for an invite." : "Sign in to share feedback with your team and keep the conversation in one place."}</p>
4509
+ ${guestAllowed ? `
4510
+ <form class="comty-guest-form">
4511
+ <label for="comty-guest-name">Your name</label>
4512
+ <input id="comty-guest-name" name="name" autocomplete="name" placeholder="Your name" maxlength="80" required aria-describedby="comty-guest-error" />
4513
+ <p id="comty-guest-error" role="alert" hidden></p>
4514
+ <div class="comty-auth-actions comty-guest-actions">
4515
+ <button type="submit" class="comty-auth-primary" data-comty-guest>Continue</button>
4516
+ <span class="comty-auth-or">or</span>
4517
+ <button type="button" data-comty-login>Continue with Google</button>
4518
+ </div>
4519
+ </form>` : `
4520
+ <div class="comty-auth-actions">
4521
+ <button type="button" data-comty-close>${forbidden ? "Close" : "Cancel"}</button>
4522
+ ${forbidden ? "" : '<button type="button" class="comty-auth-primary" data-comty-login>Continue to sign in</button>'}
4523
+ </div>`}
4385
4524
  </div>`;
4386
4525
  const close = () => {
4387
4526
  backdrop.remove();
@@ -4392,11 +4531,12 @@ function initComty(options = {}) {
4392
4531
  event.stopPropagation();
4393
4532
  if (event.key === "Escape") { event.preventDefault(); close(); }
4394
4533
  if (event.key === "Tab") {
4395
- const buttons = [...backdrop.querySelectorAll("button:not(:disabled)")];
4534
+ const buttons = [...backdrop.querySelectorAll("button:not(:disabled), input:not(:disabled)")];
4396
4535
  if (!buttons.length) { event.preventDefault(); return; }
4397
- const first = buttons[0], last = buttons.at(-1);
4398
- if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
4399
- else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
4536
+ event.preventDefault();
4537
+ const index = buttons.indexOf(document.activeElement);
4538
+ const next = (index + (event.shiftKey ? -1 : 1) + buttons.length) % buttons.length;
4539
+ buttons[next].focus();
4400
4540
  }
4401
4541
  });
4402
4542
  const loginButton = backdrop.querySelector("[data-comty-login]");
@@ -4404,15 +4544,62 @@ function initComty(options = {}) {
4404
4544
  loginButton.onclick = () => {
4405
4545
  loginButton.disabled = true;
4406
4546
  loginButton.innerText = "Connecting…";
4407
- backdrop.querySelectorAll("[data-comty-close]").forEach(button => { button.disabled = true; });
4408
- setTimeout(() => window.location.assign(loginUrl), 50);
4547
+ backdrop.querySelectorAll("button, input").forEach(control => { control.disabled = true; });
4548
+ try {
4549
+ sessionStorage.setItem(authReturnStorageKey, JSON.stringify({
4550
+ mode: onReady === startEditSession || session.mode === "off" ? "edit" : session.mode,
4551
+ entry: sessionEntry,
4552
+ sidepanelOpen,
4553
+ }));
4554
+ } catch { /* Resume in comment mode if browser storage is unavailable. */ }
4555
+ const destination = new URL("/api/auth/sign-in/google", apiUrl);
4556
+ destination.searchParams.set("callbackURL", loginUrl);
4557
+ setTimeout(() => window.location.assign(guestAllowed ? destination.toString() : loginUrl), 50);
4409
4558
  };
4410
4559
  }
4560
+ const guestForm = backdrop.querySelector(".comty-guest-form");
4561
+ if (guestForm) guestForm.onsubmit = async event => {
4562
+ event.preventDefault();
4563
+ const nameInput = guestForm.querySelector("input");
4564
+ const error = guestForm.querySelector("[role=alert]");
4565
+ const name = nameInput.value.trim();
4566
+ if (!name) { nameInput.focus(); return; }
4567
+ const controls = [...backdrop.querySelectorAll("button, input")];
4568
+ controls.forEach(control => { control.disabled = true; });
4569
+ error.hidden = true;
4570
+ const continueButton = guestForm.querySelector("[data-comty-guest]");
4571
+ continueButton.textContent = "Connecting…";
4572
+ try {
4573
+ const response = await fetch(`${apiUrl}/api/sdk/guest`, {
4574
+ method: "POST",
4575
+ headers: { "Content-Type": "application/json" },
4576
+ body: JSON.stringify({ projectKey, name }),
4577
+ });
4578
+ const data = await response.json();
4579
+ if (!response.ok) throw new Error(data.error || "Unable to continue. Please try again.");
4580
+ sdkToken = data.token;
4581
+ try { sessionStorage.setItem(tokenStorageKey, sdkToken); } catch { /* Keep the session in memory when browser storage is unavailable. */ }
4582
+ currentUser = data.user;
4583
+ canWrite = true;
4584
+ canManage = false;
4585
+ if (backdrop.isConnected) {
4586
+ close();
4587
+ onReady();
4588
+ }
4589
+ } catch (failure) {
4590
+ if (!backdrop.isConnected) return;
4591
+ error.textContent = failure.message || "Unable to connect. Please try again.";
4592
+ error.hidden = false;
4593
+ controls.forEach(control => { control.disabled = false; });
4594
+ continueButton.textContent = "Continue";
4595
+ nameInput.focus();
4596
+ }
4597
+ };
4411
4598
  backdrop.onclick = (event) => {
4412
4599
  if (event.target === backdrop) close();
4413
4600
  };
4414
4601
  root.appendChild(backdrop);
4415
- (loginButton || backdrop.querySelector("[data-comty-close]")).focus({ preventScroll: true });
4602
+ (backdrop.querySelector("#comty-guest-name") || loginButton || backdrop.querySelector("[data-comty-close]")).focus({ preventScroll: true });
4416
4603
  }
4417
4604
 
4418
4605
  function formatBoxValue(value) {
@@ -4609,7 +4796,8 @@ function initComty(options = {}) {
4609
4796
  add("background", css.backgroundColor, (tokens.background.length > 0 || !["rgba(0, 0, 0, 0)", "rgb(255, 255, 255)"].includes(css.backgroundColor)));
4610
4797
  add("color", css.color);
4611
4798
  if (getElementOwnTextSignature(el)) {
4612
- add("font", `${css.fontSize} / ${css.fontWeight}`);
4799
+ add("font-size", css.fontSize);
4800
+ add("font-weight", css.fontWeight);
4613
4801
  add("font-family", css.fontFamily);
4614
4802
  add("line-height", css.lineHeight);
4615
4803
  add("letter-spacing", css.letterSpacing, css.letterSpacing !== "normal");
@@ -4793,7 +4981,7 @@ function initComty(options = {}) {
4793
4981
  height: draftBubble.offsetHeight,
4794
4982
  };
4795
4983
  const pinBounds = draftPin?.getBoundingClientRect();
4796
- const signature = JSON.stringify([selector, rect, hoverRect, rows, !!selected, inspectExpanded, measureAlt, draftBounds, pinBounds, spacing]);
4984
+ const signature = JSON.stringify([selector, rect, hoverRect, rows, !!selected, measureAlt, draftBounds, pinBounds, spacing, innerWidth, innerHeight]);
4797
4985
  if (signature === inspectionSignature) return;
4798
4986
  inspectionSignature = signature;
4799
4987
  inspectionLayer.replaceChildren();
@@ -4819,36 +5007,34 @@ function initComty(options = {}) {
4819
5007
  const panel = document.createElement("div");
4820
5008
  panel.className = "comty-inspector";
4821
5009
  panel.setAttribute("aria-label", "Element properties");
4822
- if (selected) panel.dataset.selected = "";
4823
- const copyable = (value) => `<button type="button" title="Copy ${escapeInspectorText(value)}" data-copy="${escapeInspectorText(value)}">${escapeInspectorText(value)}</button>`;
4824
- const featured = new Set(["background", "color", "font", "font-family"]);
4825
- const visibleRows = inspectExpanded ? rows : selected ? [...rows.filter(([key]) => featured.has(key)), ...rows.filter(([key]) => !featured.has(key))].slice(0, 6) : rows.slice(0, 6);
4826
- panel.innerHTML = `<div class="comty-inspector-head">${copyable(selector)}${copyable(`${formatBoxValue(rect.width)} × ${formatBoxValue(rect.height)}`)}</div><div class="comty-inspector-details">${visibleRows.map(([key, value, tokens]) => {
4827
- const picker = key === "background" || key === "color" ? "color" : key === "font" || key === "font-family" ? "font" : "";
4828
- const valueButton = picker && selected
4829
- ? `<button type="button" class="comty-inspector-picker" data-picker="${picker}" data-property="${key}" aria-label="Open ${picker === "color" ? key + " colour" : "typography"} details">${picker === "color" ? '<span class="comty-inspector-swatch" aria-hidden="true"></span>' : ""}${escapeInspectorText(value)}</button>`
4830
- : copyable(value);
4831
- return `<div class="comty-inspector-row"><span>${key}</span><div class="comty-inspector-value">${tokens?.length ? `<div class="comty-inspector-tokens">${tokens.map(token => copyable(token)).join("")}</div>` : ""}${valueButton}</div></div>`;
4832
- }).join("")}</div>${rows.length > 6 && selected ? `<button type="button" class="comty-inspector-expand">${inspectExpanded ? "Show less" : `Show ${rows.length - 6} more`}</button>` : ""}`;
4833
- for (const button of panel.querySelectorAll('.comty-inspector-picker[data-picker="color"]')) {
4834
- button.querySelector(".comty-inspector-swatch").style.backgroundColor = getComputedStyle(target)[button.dataset.property === "background" ? "backgroundColor" : "color"];
5010
+ if (selected || draftPreview) panel.dataset.selected = "";
5011
+ const priority = ["background", "color", "font-family", "font-size", "font-weight", "margin", "line-height", "letter-spacing"];
5012
+ const orderedRows = [...rows.filter(([key]) => priority.includes(key)).sort((a, b) => priority.indexOf(a[0]) - priority.indexOf(b[0])), ...rows.filter(([key]) => !priority.includes(key))];
5013
+ const labels = { "font-family": "Font", "font-size": "Size", "font-weight": "Weight", "line-height": "Line height", "letter-spacing": "Spacing", "background": "Background", "color": "Color", "margin": "Margin" };
5014
+ const cssProperty = key => ({ radius: "border-radius", background: "background-color" })[key] || key;
5015
+ const copyValue = (value, tokens) => tokens?.length ? `var(${tokens[0]})` : value;
5016
+ const allProperties = `${selector} {\n${rows.map(([key, value, tokens]) => ` ${cssProperty(key)}: ${copyValue(value, tokens)};`).join("\n")}\n}`;
5017
+ const copyButton = (value, label) => `<button type="button" class="comty-inspector-copy" data-copy="${escapeInspectorText(value)}" aria-label="${escapeInspectorText(label)}" title="${escapeInspectorText(label)}"><i class="comty-property-copy-icon" aria-hidden="true">${copyIcon}${checkIcon}</i></button>`;
5018
+ panel.innerHTML = `<div class="comty-inspector-head"><span class="comty-inspector-selector" dir="rtl" title="${escapeInspectorText(selector)}"><bdi dir="ltr">${escapeInspectorText(selector)}</bdi></span>${copyButton(allProperties, "Copy all properties")}</div><div class="comty-inspector-details">${orderedRows.map(([key, value, tokens]) => {
5019
+ const label = labels[key] || key.replaceAll("-", " ").replace(/^./, c => c.toUpperCase());
5020
+ const isColor = key === "background" || key === "color";
5021
+ const picker = isColor ? "color" : key.startsWith("font-") || ["line-height", "letter-spacing"].includes(key) ? "font" : "";
5022
+ const displayValue = isColor ? colorDetails(value)?.hex || value : value;
5023
+ const valueMarkup = `${isColor ? '<span class="comty-inspector-swatch" aria-hidden="true"></span>' : ""}<span class="comty-inspector-text">${tokens?.length ? `<span class="comty-inspector-tokens">${tokens.map(escapeInspectorText).join(", ")}</span><span class="comty-inspector-secondary">${escapeInspectorText(displayValue)}</span>` : escapeInspectorText(displayValue)}</span>`;
5024
+ const valueElement = picker && selected
5025
+ ? `<button type="button" class="comty-inspector-value comty-inspector-picker" data-picker="${picker}" data-property="${key}" aria-label="Open ${isColor ? key + " colour" : "typography"} details">${valueMarkup}</button>`
5026
+ : `<span class="comty-inspector-value" data-property="${key}">${valueMarkup}</span>`;
5027
+ return `<div class="comty-inspector-row"><span class="comty-inspector-label">${label}</span>${valueElement}${copyButton(tokens?.length ? tokens[0].replace(/^--/, "") : value, `Copy ${label}`)}</div>`;
5028
+ }).join("")}</div><span class="comty-property-feedback" role="status"></span>`;
5029
+ for (const swatch of panel.querySelectorAll('.comty-inspector-swatch')) {
5030
+ const key = swatch.parentElement.dataset.property;
5031
+ swatch.style.backgroundColor = rows.find(([name]) => name === key)?.[1];
4835
5032
  }
4836
5033
  panel.onclick = async (event) => {
4837
5034
  const button = event.target.closest("button");
4838
5035
  if (!button) return;
4839
- if (button.classList.contains("comty-inspector-expand")) {
4840
- inspectExpanded = !inspectExpanded;
4841
- renderInspector();
4842
- } else if (button.dataset.picker) {
4843
- openPropertyPanel(button.dataset.picker, target, button.dataset.property, button);
4844
- } else {
4845
- try {
4846
- await navigator.clipboard.writeText(button.dataset.copy);
4847
- button.title = "Copied";
4848
- } catch {
4849
- button.title = "Clipboard unavailable — select the value to copy";
4850
- }
4851
- }
5036
+ if (button.dataset.picker) openPropertyPanel(button.dataset.picker, target, button.dataset.property, button);
5037
+ else await copyPropertyValue(button, panel.querySelector(".comty-property-feedback"));
4852
5038
  };
4853
5039
  inspectionLayer.appendChild(panel);
4854
5040
  const bounds = panel.getBoundingClientRect();
@@ -4866,8 +5052,24 @@ function initComty(options = {}) {
4866
5052
  }
4867
5053
  return;
4868
5054
  }
4869
- panel.style.left = `${Math.max(8, Math.min(rect.left + rect.width / 2 - bounds.width / 2, innerWidth - bounds.width - 8))}px`;
4870
- panel.style.top = `${Math.max(8, Math.min(rect.bottom + 4 + bounds.height <= innerHeight ? rect.bottom + 4 : rect.top - bounds.height - 4, innerHeight - bounds.height - 8))}px`;
5055
+ const inset = 8, gap = 12;
5056
+ const clampX = x => Math.max(inset, Math.min(x, innerWidth - bounds.width - inset));
5057
+ const clampY = y => Math.max(inset, Math.min(y, innerHeight - bounds.height - inset));
5058
+ const x = clampX(rect.left), y = clampY(rect.top);
5059
+ const candidates = [
5060
+ { left: x, top: rect.bottom + gap },
5061
+ { left: rect.right + gap, top: y },
5062
+ { left: rect.left - bounds.width - gap, top: y },
5063
+ { left: x, top: rect.top - bounds.height - gap },
5064
+ ];
5065
+ let position = candidates.find(({ left, top }) => left >= inset && top >= inset && left + bounds.width <= innerWidth - inset && top + bounds.height <= innerHeight - inset);
5066
+ if (!position) {
5067
+ // A viewport-sized target leaves no outside space; minimize the covered area.
5068
+ const overlap = ({ left, top }) => Math.max(0, Math.min(left + bounds.width, rect.right) - Math.max(left, rect.left)) * Math.max(0, Math.min(top + bounds.height, rect.bottom) - Math.max(top, rect.top));
5069
+ position = candidates.map(({ left, top }) => ({ left: clampX(left), top: clampY(top) })).sort((a, b) => overlap(a) - overlap(b))[0];
5070
+ }
5071
+ panel.style.left = `${position.left}px`;
5072
+ panel.style.top = `${position.top}px`;
4871
5073
  }
4872
5074
 
4873
5075
  function endSession() {
@@ -5256,15 +5458,6 @@ function initComty(options = {}) {
5256
5458
  return element;
5257
5459
  }
5258
5460
 
5259
- async function waitForCommentTargetReady(comment) {
5260
- const el = resolveCommentElement(comment);
5261
- if (!el) return false;
5262
-
5263
- await waitForCustomElements(el);
5264
- await waitForPaint();
5265
- return true;
5266
- }
5267
-
5268
5461
  function getDeviceContext() {
5269
5462
  const width = window.innerWidth;
5270
5463
  const height = window.innerHeight;
@@ -5339,125 +5532,44 @@ function initComty(options = {}) {
5339
5532
  if (!session.edit || hadDraft) hideInspector();
5340
5533
  document
5341
5534
  .querySelectorAll(
5342
- ".comty-bubble, .comty-create-bubble, .comty-temp-pin, .comty-popover",
5535
+ ".comty-bubble:not([data-comty-capture-keep]), .comty-create-bubble, .comty-temp-pin, .comty-popover",
5343
5536
  )
5344
5537
  .forEach((x) => x.remove());
5345
5538
  }
5346
5539
 
5347
- const { waitForPaint, waitForCustomElements, captureViewportScreenshot } = createScreenshotCapture();
5348
-
5349
- async function buildCommentScreenshotFormData(payload) {
5350
- const formData = new FormData();
5351
-
5352
- Object.entries(payload).forEach(([key, value]) => {
5353
- if (value === undefined || value === null) return;
5354
- formData.append(key, String(value));
5355
- });
5356
-
5357
- try {
5358
- await waitForCommentTargetReady(payload);
5359
- const screenshot = await captureViewportScreenshot();
5360
- if (screenshot) {
5361
- const extension =
5362
- screenshot.blob.type === "image/svg+xml"
5363
- ? "svg"
5364
- : screenshot.blob.type === "image/jpeg"
5365
- ? "jpg"
5366
- : screenshot.blob.type === "image/png"
5367
- ? "png"
5368
- : "webp";
5369
- formData.append(
5370
- "screenshot",
5371
- screenshot.blob,
5372
- `comment.${extension}`,
5373
- );
5374
- formData.append("screenshotWidth", String(screenshot.width));
5375
- formData.append("screenshotHeight", String(screenshot.height));
5376
- }
5377
- } catch (error) {
5378
- console.error("Comty: screenshot capture failed", error);
5379
- }
5380
-
5381
- return formData;
5382
- }
5540
+ const { captureViewportScreenshot } = createScreenshotCapture({ apiUrl, projectKey, getHeaders: getSdkHeaders });
5383
5541
 
5384
- async function uploadCommentScreenshot(
5385
- commentId,
5386
- payload,
5387
- captureNodes = [],
5388
- ) {
5542
+ async function uploadCommentScreenshot(comment, context, point) {
5389
5543
  try {
5390
- const formData = await buildCommentScreenshotFormData({
5391
- projectKey,
5392
- host: window.location.host,
5393
- ...payload,
5394
- });
5395
-
5396
- captureNodes.forEach((node) => node.remove());
5397
-
5398
- if (!formData.has("screenshot")) return;
5399
-
5544
+ const screenshot = await captureViewportScreenshot({ context, annotation: () => mountCaptureAnnotation(comment, point) });
5545
+ if (!screenshot) return;
5546
+ const formData = new FormData();
5547
+ formData.append("projectKey", projectKey);
5548
+ formData.append("screenshot", screenshot.blob, screenshot.blob.type === "image/jpeg" ? "comment.jpg" : "comment.png");
5549
+ formData.append("screenshotWidth", String(screenshot.width));
5550
+ formData.append("screenshotHeight", String(screenshot.height));
5400
5551
  const response = await fetch(
5401
- `${apiUrl}/api/comments/${encodeURIComponent(commentId)}/screenshot`,
5402
- {
5403
- method: "POST",
5404
- headers: getSdkHeaders(),
5405
- body: formData,
5406
- },
5552
+ `${apiUrl}/api/comments/${encodeURIComponent(comment.id)}/screenshot`,
5553
+ { method: "POST", headers: getSdkHeaders(), body: formData },
5407
5554
  );
5408
-
5409
- if (!response.ok) {
5410
- console.error(
5411
- "Comty: screenshot upload failed",
5412
- await response.text(),
5413
- );
5414
- return;
5415
- }
5416
-
5417
- await loadComments({ force: true });
5555
+ if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
5556
+ // Updating metadata must not redraw the page or interrupt a new draft.
5557
+ const { screenshotUrl } = await response.json();
5558
+ const cached = cachedComments.find(item => String(item.id) === String(comment.id));
5559
+ if (cached) Object.assign(cached, {
5560
+ screenshot_url: screenshotUrl,
5561
+ screenshot_width: screenshot.width,
5562
+ screenshot_height: screenshot.height,
5563
+ });
5418
5564
  } catch (error) {
5419
- console.error("Comty: screenshot upload failed", error);
5420
- captureNodes.forEach((node) => node.remove());
5421
- }
5422
- }
5423
-
5424
- function createCaptureAnnotation(pin, bubble, content) {
5425
- const capturePin = pin.cloneNode(false);
5426
- capturePin.dataset.comtyCaptureKeep = "true";
5427
- capturePin.classList.add("comty-capture-annotation");
5428
- capturePin.classList.remove("comty-temp-pin");
5429
- capturePin.style.display = "grid";
5430
- capturePin.style.pointerEvents = "none";
5431
- capturePin.style.animation = "none";
5432
- capturePin.style.opacity = "1";
5433
- capturePin.style.transform = "translate(-50%, -50%)";
5434
- setPinIcon(capturePin, currentUser || { name: "You" });
5435
-
5436
- const captureBubble = document.createElement("div");
5437
- captureBubble.className =
5438
- "comty-bubble comty-hover-bubble comty-capture-annotation";
5439
- captureBubble.dataset.comtyCaptureKeep = "true";
5440
- captureBubble.style.left = bubble.style.left;
5441
- captureBubble.style.top = bubble.style.top;
5442
- captureBubble.style.display = "block";
5443
- captureBubble.style.pointerEvents = "none";
5444
- captureBubble.style.animation = "none";
5445
- captureBubble.style.opacity = "1";
5446
- captureBubble.style.transform = "none";
5447
-
5448
- const contentEl = document.createElement("div");
5449
- contentEl.className = "comty-bubble-content";
5450
- contentEl.innerText = content;
5451
- captureBubble.appendChild(contentEl);
5452
-
5453
- document.body.appendChild(capturePin);
5454
- document.body.appendChild(captureBubble);
5455
- return [capturePin, captureBubble];
5565
+ console.warn("Comty: comment saved, screenshot unavailable", error);
5566
+ }
5456
5567
  }
5457
5568
 
5458
5569
  function addLocalComment(commentId, payload) {
5459
5570
  const comment = {
5460
5571
  id: commentId,
5572
+ pending: commentId.startsWith("pending-"),
5461
5573
  project_id: projectId,
5462
5574
  domain: payload.host || window.location.host,
5463
5575
  page_path: payload.pagePath || window.location.pathname,
@@ -5465,6 +5577,7 @@ function initComty(options = {}) {
5465
5577
  rel_x: payload.relX,
5466
5578
  rel_y: payload.relY,
5467
5579
  content: payload.content || "",
5580
+ link_url: payload.linkUrl || null,
5468
5581
  created_at: new Date().toISOString(),
5469
5582
  dom_element: payload.domElement || null,
5470
5583
  viewport: payload.viewport || null,
@@ -5476,7 +5589,9 @@ function initComty(options = {}) {
5476
5589
  screenshot_width: null,
5477
5590
  screenshot_height: null,
5478
5591
  anchor_context: payload.anchorContext || null,
5479
- author_user_id: currentUser?.id || null,
5592
+ author_user_id: currentUser?.isGuest ? null : currentUser?.id || null,
5593
+ guest_author_id: currentUser?.isGuest ? currentUser.id : null,
5594
+ guest_author_name: currentUser?.isGuest ? currentUser.name : null,
5480
5595
  author_name: currentUser?.name || null,
5481
5596
  author_avatar_url: currentUser?.avatarUrl || null,
5482
5597
  replies: [],
@@ -5491,6 +5606,7 @@ function initComty(options = {}) {
5491
5606
  ];
5492
5607
  renderSidepanelComments();
5493
5608
  void renderComments(cachedComments, { retry: false });
5609
+ return comment;
5494
5610
  }
5495
5611
 
5496
5612
  function getCommentAuthorName(comment) {
@@ -5500,8 +5616,7 @@ function initComty(options = {}) {
5500
5616
  function isOwnCommentEntry(entry) {
5501
5617
  return Boolean(
5502
5618
  currentUser?.id &&
5503
- entry?.author_user_id &&
5504
- entry.author_user_id === currentUser.id,
5619
+ (currentUser.isGuest ? entry?.guest_author_id : entry?.author_user_id) === currentUser.id,
5505
5620
  );
5506
5621
  }
5507
5622
 
@@ -5577,7 +5692,7 @@ function initComty(options = {}) {
5577
5692
 
5578
5693
  function createThreadItem(
5579
5694
  entry,
5580
- { showMore = isOwnCommentEntry(entry) } = {},
5695
+ { showMore = isOwnCommentEntry(entry), showLink = true } = {},
5581
5696
  ) {
5582
5697
  const item = document.createElement("div");
5583
5698
  item.className = "comty-thread-item";
@@ -5612,15 +5727,51 @@ function initComty(options = {}) {
5612
5727
 
5613
5728
  const content = document.createElement("div");
5614
5729
  content.className = "comty-bubble-content";
5615
- content.innerText = entry?.content || "";
5730
+ renderCommentContent(content, entry?.content || "");
5616
5731
 
5617
5732
  body.appendChild(author);
5618
5733
  body.appendChild(content);
5734
+ if (showLink && entry?.link_url) body.appendChild(createCommentLink(entry.link_url));
5619
5735
  item.appendChild(body);
5620
5736
 
5621
5737
  return { item, body, content, moreButton };
5622
5738
  }
5623
5739
 
5740
+ function renderCommentContent(element, value) {
5741
+ const content = String(value ?? "");
5742
+ const nodes = [];
5743
+ let previousEnd = 0;
5744
+ for (const match of content.matchAll(/https?:\/\/[^\s<>"']+/gi)) {
5745
+ const url = match[0].replace(/[.,!?;:]+$/, "");
5746
+ if (!url) continue;
5747
+ let parsed;
5748
+ try { parsed = new URL(url); } catch { continue; }
5749
+ if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) continue;
5750
+ nodes.push(document.createTextNode(content.slice(previousEnd, match.index)));
5751
+ const link = document.createElement("a");
5752
+ link.href = url;
5753
+ link.target = "_blank";
5754
+ link.rel = "noopener noreferrer";
5755
+ link.textContent = url;
5756
+ nodes.push(link);
5757
+ previousEnd = match.index + url.length;
5758
+ }
5759
+ nodes.push(document.createTextNode(content.slice(previousEnd)));
5760
+ element.replaceChildren(...nodes);
5761
+ }
5762
+
5763
+ function createCommentLink(url) {
5764
+ const link = document.createElement("a");
5765
+ link.className = "comty-comment-link";
5766
+ link.href = url;
5767
+ link.target = "_blank";
5768
+ link.rel = "noopener noreferrer";
5769
+ link.title = url;
5770
+ link.setAttribute("aria-label", `Open attached link: ${url}`);
5771
+ link.textContent = url;
5772
+ return link;
5773
+ }
5774
+
5624
5775
  function setPinIcon(pin, comment = null) {
5625
5776
  if (comment) {
5626
5777
  pin.classList.add("comty-pin-with-avatar");
@@ -5637,6 +5788,54 @@ function initComty(options = {}) {
5637
5788
  `;
5638
5789
  }
5639
5790
 
5791
+ function createCommentPin(comment, x, y) {
5792
+ const pin = document.createElement("button");
5793
+ pin.className = "comty-pin";
5794
+ pin.type = "button";
5795
+ pin.style.left = `${x}px`;
5796
+ pin.style.top = `${y}px`;
5797
+ setPinIcon(pin, comment);
5798
+ const statusDot = document.createElement("span");
5799
+ statusDot.className = "comty-pin-status-dot";
5800
+ applyStatusDot(statusDot, comment.status);
5801
+ pin.appendChild(statusDot);
5802
+ return pin;
5803
+ }
5804
+
5805
+ function createCommentPreview(comment) {
5806
+ const bubble = document.createElement("div");
5807
+ bubble.className = "comty-bubble comty-hover-bubble";
5808
+ const thread = document.createElement("div");
5809
+ thread.className = "comty-thread";
5810
+ thread.appendChild(createThreadItem(comment, { showMore: false, showLink: false }).item);
5811
+ bubble.appendChild(thread);
5812
+ return bubble;
5813
+ }
5814
+
5815
+ function mountCaptureAnnotation(comment, { x, y }) {
5816
+ const host = document.createElement("div");
5817
+ host.dataset.comtyCaptureExclude = "";
5818
+ host.setAttribute("aria-hidden", "true");
5819
+ host.inert = true;
5820
+ // Ancestor opacity hides measurement without changing the components'
5821
+ // computed opacity. Nothing flashes or intercepts input during cloning.
5822
+ Object.assign(host.style, { position: "fixed", inset: "0", opacity: "0", pointerEvents: "none" });
5823
+ const pin = createCommentPin(comment, x, y);
5824
+ const bubble = createCommentPreview(comment);
5825
+ for (const element of [pin, bubble]) {
5826
+ element.dataset.comtyCaptureKeep = "";
5827
+ element.style.setProperty("animation", "none", "important");
5828
+ element.style.setProperty("transition", "none", "important");
5829
+ element.style.setProperty("pointer-events", "none", "important");
5830
+ }
5831
+ pin.style.display = "grid";
5832
+ bubble.style.display = "block";
5833
+ host.append(pin, bubble);
5834
+ root.appendChild(host);
5835
+ positionCommentBubble(bubble, x, y);
5836
+ return { elements: [pin, bubble], dispose: () => host.remove() };
5837
+ }
5838
+
5640
5839
  function createCommentAuthor(comment) {
5641
5840
  const author = document.createElement("div");
5642
5841
  author.className = "comty-comment-author";
@@ -5687,25 +5886,86 @@ function initComty(options = {}) {
5687
5886
  const actions = document.createElement("div");
5688
5887
  actions.className = "comty-create-actions";
5689
5888
 
5889
+ const linkBtn = document.createElement("button");
5890
+ linkBtn.type = "button";
5891
+ linkBtn.className = "comty-create-link-button";
5892
+ linkBtn.setAttribute("aria-label", "Add link");
5893
+ linkBtn.innerHTML = `
5894
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
5895
+ <path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
5896
+ <path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
5897
+ </svg>
5898
+ `;
5899
+
5900
+ const linkEditor = document.createElement("div");
5901
+ linkEditor.className = "comty-create-link-editor";
5902
+ linkEditor.inert = true;
5903
+ linkEditor.setAttribute("aria-hidden", "true");
5904
+ const linkInput = document.createElement("input");
5905
+ linkInput.type = "url";
5906
+ linkInput.placeholder = "Paste a link";
5907
+ linkInput.setAttribute("aria-label", "Link URL");
5908
+ const linkInsert = document.createElement("button");
5909
+ linkInsert.type = "button";
5910
+ linkInsert.textContent = "Add";
5911
+ const linkCancel = document.createElement("button");
5912
+ linkCancel.type = "button";
5913
+ linkCancel.setAttribute("aria-label", "Cancel link");
5914
+ linkCancel.textContent = "×";
5915
+ const linkField = document.createElement("div");
5916
+ linkField.className = "comty-create-link-field";
5917
+ linkField.append(linkInput, linkCancel);
5918
+ linkEditor.append(linkField, linkInsert);
5919
+
5920
+ const attachment = document.createElement("div");
5921
+ attachment.className = "comty-create-attachment";
5922
+ const attachmentLink = document.createElement("a");
5923
+ attachmentLink.className = "comty-comment-link";
5924
+ attachmentLink.target = "_blank";
5925
+ attachmentLink.rel = "noopener noreferrer";
5926
+ const removeAttachment = document.createElement("button");
5927
+ removeAttachment.type = "button";
5928
+ removeAttachment.setAttribute("aria-label", "Remove attached link");
5929
+ removeAttachment.textContent = "×";
5930
+ const attachmentChip = document.createElement("div");
5931
+ attachmentChip.className = "comty-create-attachment-chip";
5932
+ attachmentChip.append(attachmentLink, removeAttachment);
5933
+ attachment.appendChild(attachmentChip);
5934
+ let attachedLink = "";
5935
+
5690
5936
  const submitBtn = document.createElement("button");
5937
+ submitBtn.type = "button";
5691
5938
  submitBtn.className = "comty-btn-submit";
5692
5939
  submitBtn.setAttribute("aria-label", "Post comment");
5940
+ submitBtn.disabled = true;
5693
5941
  submitBtn.innerHTML = `
5694
5942
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5695
5943
  <path d="M12 19V5M5 12l7-7 7 7"/>
5696
5944
  </svg>
5697
5945
  `;
5698
5946
 
5947
+ const editorDraft = draftAnchor;
5699
5948
  const submit = async () => {
5700
5949
  const content = textarea.value.trim();
5701
- if (!content) return;
5950
+ if (!content || textarea.disabled) return;
5702
5951
 
5703
5952
  submitBtn.disabled = true;
5704
5953
  textarea.disabled = true;
5954
+ linkBtn.disabled = true;
5705
5955
 
5706
5956
  payload.content = content;
5957
+ payload.linkUrl = attachedLink || null;
5707
5958
  textarea.textContent = content;
5708
5959
 
5960
+ const context = captureContext();
5961
+ const point = getPinPoint(pin);
5962
+ const pendingId = `pending-${crypto.randomUUID()}`;
5963
+ // Paint the comment immediately. The network save never waits for capture work.
5964
+ bubble.remove();
5965
+ pin.remove();
5966
+ if (draftAnchor === editorDraft) draftAnchor = null;
5967
+ hideInspector();
5968
+ addLocalComment(pendingId, payload);
5709
5969
  let post;
5710
5970
  try {
5711
5971
  post = await fetch(`${apiUrl}/api/comments`, {
@@ -5713,35 +5973,32 @@ function initComty(options = {}) {
5713
5973
  headers: getSdkHeaders({ "Content-Type": "application/json" }),
5714
5974
  body: JSON.stringify(payload),
5715
5975
  });
5976
+ if (!post.ok) throw new Error(`Comment save failed (${post.status})`);
5977
+ const data = await post.json();
5978
+ if (!data?.id) throw new Error("Comment save returned no ID");
5979
+ cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
5980
+ if (context.url === location.href) {
5981
+ const comment = addLocalComment(String(data.id), payload);
5982
+ // Start only after the save and immediate UI update; the capture yields to input.
5983
+ void uploadCommentScreenshot(comment, context, point);
5984
+ } else void loadComments({ force: true });
5716
5985
  } catch (error) {
5717
5986
  console.error("Comty: save failed", error);
5987
+ cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
5988
+ renderSidepanelComments();
5989
+ void renderComments(cachedComments, { retry: false });
5990
+ // Keep the original editor and its text available for retry, including attachments.
5991
+ if (!draftAnchor) draftAnchor = editorDraft;
5992
+ root.append(pin, bubble);
5718
5993
  submitBtn.disabled = false;
5719
5994
  textarea.disabled = false;
5720
- return;
5721
- }
5722
-
5723
- if (!post.ok) {
5724
- console.error("Comty: save failed", await post.text());
5725
- submitBtn.disabled = false;
5726
- textarea.disabled = false;
5727
- return;
5728
- }
5729
-
5730
- const data = await post.json();
5731
- const commentId = data?.id;
5732
- const captureNodes = commentId
5733
- ? createCaptureAnnotation(pin, bubble, content)
5734
- : [];
5735
- bubble.remove();
5736
- pin.remove();
5737
- draftAnchor = null;
5738
- hideInspector();
5739
- if (commentId) {
5740
- addLocalComment(String(commentId), payload);
5741
- void loadComments({ force: true });
5742
- void uploadCommentScreenshot(String(commentId), payload, captureNodes);
5743
- } else {
5744
- void loadComments({ force: true });
5995
+ linkBtn.disabled = false;
5996
+ const errorMessage = document.createElement("p");
5997
+ errorMessage.setAttribute("role", "alert");
5998
+ errorMessage.textContent = "Comment not saved. Your draft is ready to retry.";
5999
+ bubble.querySelector('[role="alert"]')?.remove();
6000
+ bubble.append(errorMessage);
6001
+ if (bubble.isConnected) textarea.focus();
5745
6002
  }
5746
6003
  };
5747
6004
 
@@ -5750,8 +6007,63 @@ function initComty(options = {}) {
5750
6007
  submit();
5751
6008
  };
5752
6009
 
6010
+ const closeLinkEditor = () => {
6011
+ bubble.classList.remove("comty-create-link-open");
6012
+ linkEditor.inert = true;
6013
+ linkEditor.setAttribute("aria-hidden", "true");
6014
+ linkInput.value = "";
6015
+ linkInput.removeAttribute("aria-invalid");
6016
+ linkInput.setCustomValidity("");
6017
+ textarea.focus();
6018
+ };
6019
+ linkBtn.onclick = () => {
6020
+ if (!bubble.classList.contains("comty-create-expanded")) return;
6021
+ bubble.classList.add("comty-create-link-open");
6022
+ linkEditor.inert = false;
6023
+ linkEditor.setAttribute("aria-hidden", "false");
6024
+ linkInput.focus();
6025
+ };
6026
+ linkCancel.onclick = closeLinkEditor;
6027
+ linkInput.oninput = () => {
6028
+ linkInput.removeAttribute("aria-invalid");
6029
+ linkInput.setCustomValidity("");
6030
+ };
6031
+ linkInput.onkeydown = (event) => {
6032
+ if (event.key === "Enter") { event.preventDefault(); linkInsert.click(); }
6033
+ if (event.key === "Escape") { event.preventDefault(); closeLinkEditor(); }
6034
+ };
6035
+ linkInsert.onclick = () => {
6036
+ const value = linkInput.value.trim();
6037
+ const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`;
6038
+ let url;
6039
+ try { url = new URL(candidate); } catch { /* Report below. */ }
6040
+ if (!url || !["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password || /\s/.test(value) || String(url).length > 2048) {
6041
+ linkInput.setAttribute("aria-invalid", "true");
6042
+ linkInput.setCustomValidity("Enter a valid HTTP(S) URL.");
6043
+ linkInput.reportValidity();
6044
+ linkInput.focus();
6045
+ return;
6046
+ }
6047
+ attachedLink = String(url);
6048
+ attachmentLink.href = attachedLink;
6049
+ attachmentLink.title = attachedLink;
6050
+ attachmentLink.textContent = attachedLink;
6051
+ bubble.classList.add("comty-create-has-attachment");
6052
+ closeLinkEditor();
6053
+ const point = getPinPoint(pin);
6054
+ positionCommentBubble(bubble, point.x, point.y);
6055
+ renderInspector();
6056
+ };
6057
+
6058
+ removeAttachment.onclick = () => {
6059
+ attachedLink = "";
6060
+ bubble.classList.remove("comty-create-has-attachment");
6061
+ textarea.focus();
6062
+ textarea.oninput();
6063
+ };
6064
+
5753
6065
  textarea.onkeydown = (e) => {
5754
- if (e.key === "Enter" && !e.shiftKey) {
6066
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
5755
6067
  e.preventDefault();
5756
6068
  submit();
5757
6069
  }
@@ -5762,31 +6074,20 @@ function initComty(options = {}) {
5762
6074
  };
5763
6075
 
5764
6076
  textarea.oninput = () => {
5765
- textarea.style.height = "24px";
5766
- const scrollHeight = textarea.scrollHeight;
5767
- textarea.style.height = scrollHeight + "px";
5768
-
5769
- if (scrollHeight > 24) {
5770
- bubble.style.borderRadius = "16px";
5771
- bubble.style.alignItems = "flex-end";
5772
- } else {
5773
- bubble.style.borderRadius = "999px";
5774
- bubble.style.alignItems = "center";
5775
- }
5776
-
5777
- if (textarea.value.trim().length > 0) {
5778
- submitBtn.classList.add("comty-btn-active");
5779
- } else {
5780
- submitBtn.classList.remove("comty-btn-active");
5781
- }
6077
+ const expanded = textarea.value.length > 0 || Boolean(attachedLink);
6078
+ bubble.classList.toggle("comty-create-expanded", expanded);
6079
+ if (!expanded && bubble.classList.contains("comty-create-link-open")) closeLinkEditor();
6080
+ submitBtn.disabled = !textarea.value.trim();
6081
+ submitBtn.classList.toggle("comty-btn-active", !submitBtn.disabled);
5782
6082
  const point = getPinPoint(pin);
5783
6083
  positionCommentBubble(bubble, point.x, point.y);
5784
6084
  renderInspector();
5785
6085
  };
5786
6086
 
5787
- actions.appendChild(submitBtn);
6087
+ actions.append(linkBtn, submitBtn, linkEditor);
5788
6088
 
5789
6089
  bubble.appendChild(textarea);
6090
+ bubble.appendChild(attachment);
5790
6091
  bubble.appendChild(actions);
5791
6092
 
5792
6093
  root.appendChild(bubble);
@@ -5895,6 +6196,7 @@ function initComty(options = {}) {
5895
6196
  const item = document.createElement("button");
5896
6197
  item.type = "button";
5897
6198
  item.className = "comty-sidepanel-item";
6199
+ item.disabled = Boolean(comment.pending);
5898
6200
  item.innerHTML = `
5899
6201
  <span class="comty-sidepanel-item-status" aria-hidden="true"></span>
5900
6202
  <span class="comty-sidepanel-item-body">
@@ -5915,7 +6217,7 @@ function initComty(options = {}) {
5915
6217
 
5916
6218
  const metaEl = item.querySelector(".comty-sidepanel-item-meta");
5917
6219
  if (metaEl instanceof HTMLElement) {
5918
- metaEl.innerText = `${comment.author_name || "Anonymous"} · ${getCommentLabel(comment)}`;
6220
+ metaEl.innerText = `${comment.author_name || "Anonymous"} · ${comment.pending ? "Sending…" : getCommentLabel(comment)}`;
5919
6221
  metaEl.title = getCommentLabel(comment);
5920
6222
  }
5921
6223
 
@@ -6246,11 +6548,13 @@ function initComty(options = {}) {
6246
6548
  const openCommentThread = createCommentThread({
6247
6549
  root, apiUrl, projectKey, commentStatuses, selectedStatusFilters, placedPins,
6248
6550
  closeBubbles, getCommentStatus, updateCommentStatus, applyStatusDot,
6249
- renderSidepanelComments, applyStatusFilters, createThreadItem,
6551
+ renderSidepanelComments, applyStatusFilters, createThreadItem, renderCommentContent,
6250
6552
  isOwnCommentEntry, updateCommentContent, deleteSdkComment, getCommentId,
6251
6553
  updateReplyContent, deleteSdkReply, createCommentAvatar, getSdkHeaders,
6252
6554
  showAuthPrompt,
6253
6555
  get canWrite() { return canWrite; },
6556
+ get canManage() { return canManage; },
6557
+ get allowGuestComments() { return allowGuestComments; },
6254
6558
  get currentUser() { return currentUser; },
6255
6559
  get cachedComments() { return cachedComments; },
6256
6560
  set cachedComments(value) { cachedComments = value; },
@@ -6300,20 +6604,11 @@ function initComty(options = {}) {
6300
6604
  const x = visibility.clientX;
6301
6605
  const y = visibility.clientY;
6302
6606
 
6303
- const pin = document.createElement("button");
6304
- pin.className = "comty-pin";
6305
- pin.type = "button";
6607
+ const pin = createCommentPin(comment, x, y);
6608
+ pin.disabled = Boolean(comment.pending);
6306
6609
  pin.dataset.status = getCommentStatus(comment.status).value;
6307
- if (comment.id) {
6308
- pin.dataset.commentId = String(comment.id);
6309
- }
6310
- pin.style.left = `${x}px`;
6311
- pin.style.top = `${y}px`;
6312
- setPinIcon(pin, comment);
6313
- const pinStatusDot = document.createElement("span");
6314
- pinStatusDot.className = "comty-pin-status-dot";
6315
- applyStatusDot(pinStatusDot, comment.status);
6316
- pin.appendChild(pinStatusDot);
6610
+ if (comment.id) pin.dataset.commentId = String(comment.id);
6611
+ const pinStatusDot = pin.querySelector(".comty-pin-status-dot");
6317
6612
  pin.setAttribute(
6318
6613
  "aria-label",
6319
6614
  `Afficher le commentaire, statut ${getCommentStatus(comment.status).label}`,
@@ -6355,32 +6650,14 @@ function initComty(options = {}) {
6355
6650
  activeHoverCommentIds.add(commentId);
6356
6651
  }
6357
6652
 
6358
- const hoverBubble = document.createElement("div");
6359
- hoverBubble.className = "comty-bubble comty-hover-bubble";
6653
+ const hoverBubble = createCommentPreview(comment);
6360
6654
  hoverBubble.style.left = `${x + 32}px`;
6361
6655
  hoverBubble.style.top = `${y - 17}px`;
6362
- hoverBubble.style.pointerEvents = "none";
6363
6656
  hoverBubble.dataset.hoverId = commentId;
6364
6657
 
6365
- const hoverThread = document.createElement("div");
6366
- hoverThread.className = "comty-thread";
6367
- const hoverItem = createThreadItem(comment, { showMore: false });
6368
- hoverThread.appendChild(hoverItem.item);
6369
- hoverBubble.appendChild(hoverThread);
6370
-
6371
6658
  root.appendChild(hoverBubble);
6372
6659
 
6373
- requestAnimationFrame(() => {
6374
- if (x + 16 + hoverBubble.offsetWidth > window.innerWidth) {
6375
- hoverBubble.style.left = `${x - hoverBubble.offsetWidth - 24}px`;
6376
- hoverBubble.classList.add("comty-bubble-left");
6377
- }
6378
-
6379
- const rect = hoverBubble.getBoundingClientRect();
6380
- if (rect.bottom > window.innerHeight) {
6381
- hoverBubble.style.top = `${y - rect.height - 16}px`;
6382
- }
6383
- });
6660
+ requestAnimationFrame(() => positionCommentBubble(hoverBubble, x, y));
6384
6661
  };
6385
6662
 
6386
6663
  pin.onmouseleave = () => {
@@ -6477,6 +6754,9 @@ function initComty(options = {}) {
6477
6754
  cachedComments = comments
6478
6755
  .filter(comment => comment.kind !== "agent" && !comment.local_agent)
6479
6756
  .map(comment => Object.assign(previous.get(String(comment.id)) || {}, comment));
6757
+ for (const comment of previous.values()) {
6758
+ if (comment.pending && !cachedComments.some(item => item.id === comment.id)) cachedComments.push(comment);
6759
+ }
6480
6760
  }
6481
6761
 
6482
6762
  async function loadComments({ force = false } = {}) {
@@ -6697,7 +6977,6 @@ function initComty(options = {}) {
6697
6977
  hideInspector();
6698
6978
  return;
6699
6979
  }
6700
- if (inspectedElement !== target) inspectExpanded = selectedElement ? inspectExpanded : false;
6701
6980
  showInspector(target);
6702
6981
  }, true);
6703
6982
  document.documentElement.addEventListener("pointerleave", () => {
@@ -6754,7 +7033,6 @@ function initComty(options = {}) {
6754
7033
  }
6755
7034
  measuredRegion = null;
6756
7035
  selectedElement = getAnchorElement(target);
6757
- inspectExpanded = false;
6758
7036
  showInspector(target);
6759
7037
  return;
6760
7038
  }
@@ -6912,13 +7190,26 @@ function initComty(options = {}) {
6912
7190
  const data = await res.json();
6913
7191
  projectId = data.projectId;
6914
7192
  canWrite = Boolean(data.canWrite);
7193
+ canManage = Boolean(data.canManage ?? data.canWrite);
7194
+ allowGuestComments = Boolean(data.allowGuestComments);
6915
7195
  currentUser = data.user || null;
6916
7196
  loginUrl = data.loginUrl || "";
6917
7197
 
6918
7198
  if (!canWrite && sdkToken) {
6919
- sessionStorage.removeItem(tokenStorageKey);
7199
+ try { sessionStorage.removeItem(tokenStorageKey); } catch { /* Storage may be unavailable. */ }
6920
7200
  sdkToken = "";
6921
7201
  }
7202
+ if (didCompleteSignIn || authStatus === "forbidden") {
7203
+ let previousSession;
7204
+ try {
7205
+ previousSession = JSON.parse(sessionStorage.getItem(authReturnStorageKey) || "null");
7206
+ sessionStorage.removeItem(authReturnStorageKey);
7207
+ } catch { /* Fall back to the requested comment mode. */ }
7208
+ const mode = canWrite && ["browse", "edit", "inspect"].includes(previousSession?.mode)
7209
+ ? previousSession.mode : canWrite ? "edit" : "browse";
7210
+ setSession(mode, { entry: previousSession?.entry || mode });
7211
+ if (previousSession?.sidepanelOpen) setSidepanelOpen(true);
7212
+ }
6922
7213
  if (authStatus === "forbidden") showAuthPrompt(true);
6923
7214
  if (canWrite && didCompleteSignIn) {
6924
7215
  const toast = document.createElement("div");
@@ -6947,7 +7238,7 @@ function initComty(options = {}) {
6947
7238
  setSession(session.comments ? session.mode : "browse", { entry: sessionEntry, renderComments: true });
6948
7239
  return renderComments(cachedComments, { retry: false });
6949
7240
  },
6950
- setCanWrite(value) { canWrite = value; },
7241
+ setCanWrite(value) { canWrite = value; canManage = value; },
6951
7242
  invalidate() { anchorRevision += 1; },
6952
7243
  });
6953
7244
  }