@comty.io/core 0.1.3 → 0.1.5

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,421 @@ 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 I(t){return Promise.resolve().then(()=>new XMLSerializer().serializeToString(t)).then(encodeURIComponent).then(e=>`data:image/svg+xml;charset=utf-8,${e}`)}async function U(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),I(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 V(t){let e=t.getPropertyValue("content");return`${t.cssText} content: '${e.replace(/'|"/g,"")}';`}function M(t,e){return y(e).map(r=>{let n=t.getPropertyValue(r),i=t.getPropertyPriority(r);return`${r}: ${n}${i?" !important":""};`}).join(" ")}function F(t,e,r,n){let i=`.${t}:${e}`,o=r.cssText?V(r):M(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(F(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",H={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 O(t){let e=/\.([^./]*?)$/g.exec(t);return e?e[1]:""}function m(t){let e=O(t).toLowerCase();return H[e]||""}function k(t){return t.split(/,/)[1]}function p(t){return t.search(/^(data:)/)!==-1}function x(t,e){return`data:${e};base64,${t}`}async function q(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 q(t,r.fetchRequestInit,({res:c,result:s})=>(e||(e=c.headers.get("Content-Type")||""),k(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 _(t){let e=t.toDataURL();return e==="data:,"?t.cloneNode(!1):w(e)}async function v(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 B(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 G(t,e){return l(t,HTMLCanvasElement)?_(t).catch(()=>t.cloneNode(!1)):l(t,HTMLVideoElement)?v(t,e).catch(()=>document.createElement("div")):l(t,HTMLIFrameElement)?B(t,e):t.cloneNode(!1)}var j=t=>t.tagName!=null&&t.tagName.toUpperCase()==="SLOT";async function z(t,e,r){var n,i;let o=[];return j(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 W(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 X(t,e){l(t,HTMLTextAreaElement)&&(e.textContent=t.value),l(t,HTMLInputElement)&&e.setAttribute("value",t.value)}function K(t,e){if(l(t,HTMLSelectElement)){let n=Array.from(e.children).find(i=>t.value===i.getAttribute("value"));n&&n.setAttribute("selected","")}}function J(t,e,r){return l(e,Element)&&(W(t,e,r),C(t,e,r),X(t,e),K(t,e)),e}async function Q(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=>G(i,e)).then(i=>z(t,i,e)).then(async i=>(await e.beforeClone?.(t),J(t,i,e))).then(i=>e.afterClone?.(t,i)??i).then(i=>Q(i,e))}var A=/url\((['"]?)([^'"]+?)\1\)/g,L=/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(A,(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(L.lastIndex=0;;){let[n,,i]=L.exec(r)||[];if(!i)return"";if(i===e)return`src: ${n};`}}):t}function rt(t){return t.search(A)!==-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=>D(i,e));await Promise.all(n).then(()=>t)}async function D(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:D,embedResources:E,nodeToDataURL:U}; })();
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
+ // Transparent page backgrounds reveal the browser canvas, not the image viewer.
2225
+ const viewport = document.createElement("div");
2226
+ viewport.style.backgroundColor = "Canvas";
2227
+ viewport.style.colorScheme = rootStyle.colorScheme;
2228
+ viewport.append(stage);
2229
+ const svg = await captureEngine.nodeToDataURL(viewport, context.width, context.height);
2230
+ const image = new Image();
2231
+ image.src = svg;
2232
+ await untilAborted(image.decode());
2233
+ checkContext();
2234
+ const scale = Math.min(2, devicePixelRatio || 1, 2560 / context.width, 2560 / context.height);
2235
+ const canvas = document.createElement("canvas");
2236
+ canvas.width = Math.round(context.width * scale);
2237
+ canvas.height = Math.round(context.height * scale);
2238
+ const ctx = canvas.getContext("2d");
2239
+ ctx.scale(scale, scale);
2240
+ ctx.drawImage(image, 0, 0);
2241
+ let blob = await untilAborted(new Promise(resolve => canvas.toBlob(resolve, "image/png")));
2242
+ if (!blob) throw new Error("Screenshot encoding failed");
2243
+ if (blob.size > 2800000) {
2244
+ blob = await untilAborted(new Promise(resolve => canvas.toBlob(resolve, "image/jpeg", 0.9)));
2245
+ if (!blob) throw new Error("Screenshot encoding failed");
2246
+ }
2247
+ // CSS dimensions keep the eyedropper's coordinate system independent of DPR.
2248
+ return { blob, width: canvas.width, height: canvas.height, cssWidth: context.width, cssHeight: context.height };
2249
+ } finally {
2250
+ clearTimeout(timeout);
2251
+ controller.abort();
2152
2252
  }
2153
-
2154
- return { waitForPaint, waitForCustomElements, captureViewportScreenshot };
2253
+ }
2254
+ return { captureViewportScreenshot };
2155
2255
  }
2156
2256
 
2157
2257
  // Page-local counterpart of the native eyedropper; never captures outside the page.
@@ -2199,7 +2299,9 @@ function createColorMagnifier({ root, isComtyElement, capture }) {
2199
2299
  sampledHex = null;
2200
2300
  if (!enabled) return;
2201
2301
  try {
2202
- const { blob } = await capture();
2302
+ const screenshot = await capture();
2303
+ if (!screenshot) return;
2304
+ const { blob, cssWidth, cssHeight } = screenshot;
2203
2305
  if (!enabled || version !== revision) return;
2204
2306
  const dataUrl = await new Promise((resolve, reject) => {
2205
2307
  const reader = new FileReader();
@@ -2215,7 +2317,11 @@ function createColorMagnifier({ root, isComtyElement, capture }) {
2215
2317
  const probe = document.createElement('canvas').getContext('2d');
2216
2318
  probe.drawImage(next, 0, 0);
2217
2319
  probe.getImageData(0, 0, 1, 1);
2218
- image = next;
2320
+ const scaled = document.createElement('canvas');
2321
+ scaled.width = cssWidth || next.width;
2322
+ scaled.height = cssHeight || next.height;
2323
+ scaled.getContext('2d').drawImage(next, 0, 0, scaled.width, scaled.height);
2324
+ image = scaled;
2219
2325
  paint();
2220
2326
  } catch { /* The CSS-colour fallback remains available without screen permission. */ }
2221
2327
  }
@@ -2262,7 +2368,7 @@ function createCommentThread(context) {
2262
2368
  const {
2263
2369
  root, apiUrl, projectKey, commentStatuses, selectedStatusFilters, placedPins,
2264
2370
  closeBubbles, getCommentStatus, updateCommentStatus, applyStatusDot,
2265
- renderSidepanelComments, applyStatusFilters, createThreadItem,
2371
+ renderSidepanelComments, applyStatusFilters, createThreadItem, renderCommentContent,
2266
2372
  isOwnCommentEntry, updateCommentContent, deleteSdkComment, getCommentId,
2267
2373
  updateReplyContent, deleteSdkReply, createCommentAvatar, getSdkHeaders,
2268
2374
  showAuthPrompt,
@@ -2290,14 +2396,6 @@ function createCommentThread(context) {
2290
2396
  const header = document.createElement("div");
2291
2397
  header.className = "comty-bubble-header";
2292
2398
 
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
2399
  const statusMenuButton = document.createElement("button");
2302
2400
  statusMenuButton.className = "comty-status-trigger";
2303
2401
  statusMenuButton.type = "button";
@@ -2336,7 +2434,7 @@ function createCommentThread(context) {
2336
2434
  };
2337
2435
 
2338
2436
  const saveStatus = async (nextStatus, control) => {
2339
- if (!context.canWrite) return false;
2437
+ if (!context.canManage) return false;
2340
2438
  control.disabled = true;
2341
2439
  const savedStatus = await updateCommentStatus(comment, nextStatus);
2342
2440
  control.disabled = false;
@@ -2361,7 +2459,7 @@ function createCommentThread(context) {
2361
2459
  return true;
2362
2460
  };
2363
2461
 
2364
- statusMenuButton.disabled = !context.canWrite;
2462
+ statusMenuButton.disabled = !context.canManage;
2365
2463
  statusMenuButton.onclick = (event) => {
2366
2464
  event.stopPropagation();
2367
2465
  const existingPopover = document.querySelector(
@@ -2373,10 +2471,11 @@ function createCommentThread(context) {
2373
2471
  if (existingPopover) return;
2374
2472
 
2375
2473
  const popover = document.createElement("div");
2376
- popover.className = "comty-popover comty-status-popover";
2474
+ popover.className = "comty-popover comty-comment-popover comty-status-popover";
2377
2475
  const buttonRect = statusMenuButton.getBoundingClientRect();
2378
2476
  popover.style.top = `${buttonRect.bottom + 4}px`;
2379
- popover.style.left = `${buttonRect.right - 140}px`;
2477
+ popover.style.left = `${buttonRect.left}px`;
2478
+ popover.style.transformOrigin = "top left";
2380
2479
 
2381
2480
  for (const status of commentStatuses) {
2382
2481
  const item = document.createElement("button");
@@ -2414,6 +2513,13 @@ function createCommentThread(context) {
2414
2513
  }
2415
2514
 
2416
2515
  root.appendChild(popover);
2516
+ const popoverWidth = popover.offsetWidth;
2517
+ const popoverHeight = popover.offsetHeight;
2518
+ popover.style.left = `${Math.max(8, Math.min(buttonRect.left, innerWidth - popoverWidth - 8))}px`;
2519
+ if (buttonRect.bottom + 4 + popoverHeight > innerHeight - 8) {
2520
+ popover.style.top = `${Math.max(8, buttonRect.top - popoverHeight - 4)}px`;
2521
+ popover.style.transformOrigin = "bottom left";
2522
+ }
2417
2523
 
2418
2524
  const closeStatusPopover = (closeEvent) => {
2419
2525
  if (
@@ -2430,9 +2536,7 @@ function createCommentThread(context) {
2430
2536
  );
2431
2537
  };
2432
2538
 
2433
- headerActions.appendChild(statusMenuButton);
2434
- headerActions.appendChild(closeButton);
2435
- header.appendChild(headerActions);
2539
+ header.append(statusMenuButton, closeButton);
2436
2540
  bubble.appendChild(header);
2437
2541
 
2438
2542
  const thread = document.createElement("div");
@@ -2451,7 +2555,7 @@ function createCommentThread(context) {
2451
2555
 
2452
2556
  const popover = document.createElement("div");
2453
2557
  popover.className =
2454
- "comty-popover comty-entry-actions-popover";
2558
+ "comty-popover comty-comment-popover comty-entry-actions-popover";
2455
2559
 
2456
2560
  const editItem = document.createElement("button");
2457
2561
  editItem.className = "comty-popover-item";
@@ -2537,7 +2641,7 @@ function createCommentThread(context) {
2537
2641
 
2538
2642
  const previousContent = entry.content;
2539
2643
  entry.content = nextContent;
2540
- item.content.innerText = nextContent;
2644
+ renderCommentContent(item.content, nextContent);
2541
2645
  editor.disabled = true;
2542
2646
  editor.replaceWith(item.content);
2543
2647
  renderSidepanelComments();
@@ -2545,11 +2649,11 @@ function createCommentThread(context) {
2545
2649
  const savedContent = await persist(nextContent);
2546
2650
  if (!savedContent) {
2547
2651
  entry.content = previousContent;
2548
- item.content.innerText = previousContent || "";
2652
+ renderCommentContent(item.content, previousContent || "");
2549
2653
  renderSidepanelComments();
2550
2654
  return;
2551
2655
  }
2552
- item.content.innerText = savedContent;
2656
+ renderCommentContent(item.content, savedContent);
2553
2657
  renderSidepanelComments();
2554
2658
  };
2555
2659
 
@@ -2655,9 +2759,11 @@ function createCommentThread(context) {
2655
2759
  replyForm.appendChild(createCommentAvatar(context.currentUser || { name: "You" }));
2656
2760
  const replyField = document.createElement("div");
2657
2761
  replyField.className = "comty-reply-field";
2658
- const replyInput = document.createElement("input");
2762
+ const replyInput = document.createElement("textarea");
2659
2763
  replyInput.className = "comty-reply-input";
2660
2764
  replyInput.placeholder = "Reply";
2765
+ replyInput.rows = 1;
2766
+ replyInput.setAttribute("aria-label", "Reply");
2661
2767
 
2662
2768
  const replyButton = document.createElement("button");
2663
2769
  replyButton.className = "comty-reply-button";
@@ -2691,6 +2797,7 @@ function createCommentThread(context) {
2691
2797
  comment.replies = replies;
2692
2798
  appendReply(data.reply);
2693
2799
  replyInput.value = "";
2800
+ syncReplySize();
2694
2801
  requestAnimationFrame(() => {
2695
2802
  threadList.scrollTop = threadList.scrollHeight;
2696
2803
  });
@@ -2704,12 +2811,28 @@ function createCommentThread(context) {
2704
2811
  }
2705
2812
  };
2706
2813
 
2814
+ const syncReplySize = () => {
2815
+ const currentHeight = replyInput.getBoundingClientRect().height;
2816
+ replyInput.style.transition = "none";
2817
+ const lineHeight = parseFloat(getComputedStyle(replyInput).lineHeight);
2818
+ replyInput.style.height = `${lineHeight}px`;
2819
+ const contentHeight = replyInput.scrollHeight;
2820
+ replyInput.style.height = `${currentHeight}px`;
2821
+ replyInput.getBoundingClientRect();
2822
+ replyInput.style.transition = "";
2823
+ const nextHeight = Math.min(contentHeight, parseFloat(getComputedStyle(replyInput).maxHeight));
2824
+ replyInput.style.height = `${nextHeight}px`;
2825
+ if (contentHeight <= lineHeight + 1) replyInput.scrollTop = 0;
2826
+ replyField.classList.toggle("comty-reply-expanded", contentHeight > lineHeight + 1);
2827
+ };
2828
+
2707
2829
  replyInput.oninput = () => {
2830
+ syncReplySize();
2708
2831
  replyButton.disabled = replyInput.value.trim().length === 0;
2709
2832
  };
2710
2833
  replyInput.onkeydown = (event) => {
2711
2834
  event.stopPropagation();
2712
- if (event.key === "Enter" && !event.shiftKey) {
2835
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
2713
2836
  event.preventDefault();
2714
2837
  void submitReply();
2715
2838
  }
@@ -2727,10 +2850,10 @@ function createCommentThread(context) {
2727
2850
  const signInButton = document.createElement("button");
2728
2851
  signInButton.className = "comty-sign-in-reply";
2729
2852
  signInButton.type = "button";
2730
- signInButton.innerText = "Sign in to reply";
2853
+ signInButton.innerText = context.allowGuestComments ? "Leave a reply" : "Sign in to reply";
2731
2854
  signInButton.onclick = (event) => {
2732
2855
  event.stopPropagation();
2733
- showAuthPrompt();
2856
+ showAuthPrompt(false, () => openCommentThread(comment, pin, pinStatusDot, x, y, removeHoverBubble));
2734
2857
  };
2735
2858
  replyForm.appendChild(signInButton);
2736
2859
  }
@@ -3020,11 +3143,14 @@ function initComty(options = {}) {
3020
3143
  const observedCommentTargets = new WeakSet();
3021
3144
  let sidepanelOpen = false;
3022
3145
  let canWrite = false;
3146
+ let canManage = false;
3147
+ let allowGuestComments = false;
3023
3148
  let loginUrl = "";
3024
3149
  let currentUser = null;
3025
3150
  let authStatus = "";
3026
3151
  let didCompleteSignIn = false;
3027
3152
  const tokenStorageKey = `comty:${projectKey}:token`;
3153
+ const authReturnStorageKey = `comty:${projectKey}:auth-return`;
3028
3154
  let sdkToken = "";
3029
3155
  const activeHoverCommentIds = new Set();
3030
3156
  /** @type {null | 'edit' | 'browse'} */
@@ -3033,7 +3159,6 @@ function initComty(options = {}) {
3033
3159
  const placementDiagnostics = new Map();
3034
3160
  let inspectedElement = null;
3035
3161
  let selectedElement = null;
3036
- let inspectExpanded = false;
3037
3162
  let measureAlt = false;
3038
3163
  let inspectDrag = null;
3039
3164
  let measuredRegion = null;
@@ -3715,6 +3840,21 @@ function initComty(options = {}) {
3715
3840
  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
3841
  const checkIcon = icon('<path d="m5 12 4 4 10-10"/>');
3717
3842
  const copyFeedbackTimers = new WeakMap();
3843
+ async function copyPropertyValue(button, feedback) {
3844
+ clearTimeout(copyFeedbackTimers.get(button));
3845
+ try {
3846
+ await navigator.clipboard.writeText(button.dataset.copy);
3847
+ if (!button.isConnected) return;
3848
+ button.dataset.copied = "";
3849
+ feedback.textContent = "Copied";
3850
+ clearTimeout(copyFeedbackTimers.get(button));
3851
+ copyFeedbackTimers.set(button, setTimeout(() => { delete button.dataset.copied; }, 1200));
3852
+ } catch {
3853
+ delete button.dataset.copied;
3854
+ if (button.isConnected) feedback.textContent = "Clipboard unavailable — select the value to copy";
3855
+ }
3856
+ }
3857
+
3718
3858
  function propertyRow(label, value, display = value) {
3719
3859
  const unavailable = value === "—";
3720
3860
  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 +3881,7 @@ function initComty(options = {}) {
3741
3881
  propertyPanel.setAttribute("aria-label", propertyKind === "color" ? "Recent colours" : "Recent typography");
3742
3882
  const recent = propertyKind === "color" ? recentColors : recentFonts;
3743
3883
  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>`
3884
+ ? `<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
3885
  : propertyKind === "font" && expanded
3746
3886
  ? `<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
3887
  : "";
@@ -3857,18 +3997,7 @@ function initComty(options = {}) {
3857
3997
  renderPropertyPanel(true);
3858
3998
  propertyPanel.querySelector(`button[data-recent="${button.dataset.recent}"]`)?.focus({ preventScroll: true });
3859
3999
  } 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
- }
4000
+ await copyPropertyValue(button, propertyPanel.querySelector(".comty-property-feedback"));
3872
4001
  }
3873
4002
  };
3874
4003
 
@@ -4365,7 +4494,8 @@ function initComty(options = {}) {
4365
4494
  : headers;
4366
4495
  }
4367
4496
 
4368
- function showAuthPrompt(forbidden = false) {
4497
+ function showAuthPrompt(forbidden = false, onReady = startEditSession) {
4498
+ const guestAllowed = allowGuestComments;
4369
4499
  document.querySelector(".comty-auth-backdrop")?.remove();
4370
4500
  const opener = document.activeElement;
4371
4501
  const backdrop = document.createElement("div");
@@ -4376,12 +4506,23 @@ function initComty(options = {}) {
4376
4506
  <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
4507
  <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
4508
  </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>
4509
+ <h2 id="comty-auth-title">${guestAllowed ? "Leave a comment" : forbidden ? "Project access required" : "Sign in to comment"}</h2>
4510
+ <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>
4511
+ ${guestAllowed ? `
4512
+ <form class="comty-guest-form">
4513
+ <label for="comty-guest-name">Your name</label>
4514
+ <input id="comty-guest-name" name="name" autocomplete="name" placeholder="Your name" maxlength="80" required aria-describedby="comty-guest-error" />
4515
+ <p id="comty-guest-error" role="alert" hidden></p>
4516
+ <div class="comty-auth-actions comty-guest-actions">
4517
+ <button type="submit" class="comty-auth-primary" data-comty-guest>Continue</button>
4518
+ <span class="comty-auth-or">or</span>
4519
+ <button type="button" data-comty-login>Continue with Google</button>
4520
+ </div>
4521
+ </form>` : `
4522
+ <div class="comty-auth-actions">
4523
+ <button type="button" data-comty-close>${forbidden ? "Close" : "Cancel"}</button>
4524
+ ${forbidden ? "" : '<button type="button" class="comty-auth-primary" data-comty-login>Continue to sign in</button>'}
4525
+ </div>`}
4385
4526
  </div>`;
4386
4527
  const close = () => {
4387
4528
  backdrop.remove();
@@ -4392,11 +4533,12 @@ function initComty(options = {}) {
4392
4533
  event.stopPropagation();
4393
4534
  if (event.key === "Escape") { event.preventDefault(); close(); }
4394
4535
  if (event.key === "Tab") {
4395
- const buttons = [...backdrop.querySelectorAll("button:not(:disabled)")];
4536
+ const buttons = [...backdrop.querySelectorAll("button:not(:disabled), input:not(:disabled)")];
4396
4537
  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(); }
4538
+ event.preventDefault();
4539
+ const index = buttons.indexOf(document.activeElement);
4540
+ const next = (index + (event.shiftKey ? -1 : 1) + buttons.length) % buttons.length;
4541
+ buttons[next].focus();
4400
4542
  }
4401
4543
  });
4402
4544
  const loginButton = backdrop.querySelector("[data-comty-login]");
@@ -4404,15 +4546,62 @@ function initComty(options = {}) {
4404
4546
  loginButton.onclick = () => {
4405
4547
  loginButton.disabled = true;
4406
4548
  loginButton.innerText = "Connecting…";
4407
- backdrop.querySelectorAll("[data-comty-close]").forEach(button => { button.disabled = true; });
4408
- setTimeout(() => window.location.assign(loginUrl), 50);
4549
+ backdrop.querySelectorAll("button, input").forEach(control => { control.disabled = true; });
4550
+ try {
4551
+ sessionStorage.setItem(authReturnStorageKey, JSON.stringify({
4552
+ mode: onReady === startEditSession || session.mode === "off" ? "edit" : session.mode,
4553
+ entry: sessionEntry,
4554
+ sidepanelOpen,
4555
+ }));
4556
+ } catch { /* Resume in comment mode if browser storage is unavailable. */ }
4557
+ const destination = new URL("/api/auth/sign-in/google", apiUrl);
4558
+ destination.searchParams.set("callbackURL", loginUrl);
4559
+ setTimeout(() => window.location.assign(guestAllowed ? destination.toString() : loginUrl), 50);
4409
4560
  };
4410
4561
  }
4562
+ const guestForm = backdrop.querySelector(".comty-guest-form");
4563
+ if (guestForm) guestForm.onsubmit = async event => {
4564
+ event.preventDefault();
4565
+ const nameInput = guestForm.querySelector("input");
4566
+ const error = guestForm.querySelector("[role=alert]");
4567
+ const name = nameInput.value.trim();
4568
+ if (!name) { nameInput.focus(); return; }
4569
+ const controls = [...backdrop.querySelectorAll("button, input")];
4570
+ controls.forEach(control => { control.disabled = true; });
4571
+ error.hidden = true;
4572
+ const continueButton = guestForm.querySelector("[data-comty-guest]");
4573
+ continueButton.textContent = "Connecting…";
4574
+ try {
4575
+ const response = await fetch(`${apiUrl}/api/sdk/guest`, {
4576
+ method: "POST",
4577
+ headers: { "Content-Type": "application/json" },
4578
+ body: JSON.stringify({ projectKey, name }),
4579
+ });
4580
+ const data = await response.json();
4581
+ if (!response.ok) throw new Error(data.error || "Unable to continue. Please try again.");
4582
+ sdkToken = data.token;
4583
+ try { sessionStorage.setItem(tokenStorageKey, sdkToken); } catch { /* Keep the session in memory when browser storage is unavailable. */ }
4584
+ currentUser = data.user;
4585
+ canWrite = true;
4586
+ canManage = false;
4587
+ if (backdrop.isConnected) {
4588
+ close();
4589
+ onReady();
4590
+ }
4591
+ } catch (failure) {
4592
+ if (!backdrop.isConnected) return;
4593
+ error.textContent = failure.message || "Unable to connect. Please try again.";
4594
+ error.hidden = false;
4595
+ controls.forEach(control => { control.disabled = false; });
4596
+ continueButton.textContent = "Continue";
4597
+ nameInput.focus();
4598
+ }
4599
+ };
4411
4600
  backdrop.onclick = (event) => {
4412
4601
  if (event.target === backdrop) close();
4413
4602
  };
4414
4603
  root.appendChild(backdrop);
4415
- (loginButton || backdrop.querySelector("[data-comty-close]")).focus({ preventScroll: true });
4604
+ (backdrop.querySelector("#comty-guest-name") || loginButton || backdrop.querySelector("[data-comty-close]")).focus({ preventScroll: true });
4416
4605
  }
4417
4606
 
4418
4607
  function formatBoxValue(value) {
@@ -4609,7 +4798,8 @@ function initComty(options = {}) {
4609
4798
  add("background", css.backgroundColor, (tokens.background.length > 0 || !["rgba(0, 0, 0, 0)", "rgb(255, 255, 255)"].includes(css.backgroundColor)));
4610
4799
  add("color", css.color);
4611
4800
  if (getElementOwnTextSignature(el)) {
4612
- add("font", `${css.fontSize} / ${css.fontWeight}`);
4801
+ add("font-size", css.fontSize);
4802
+ add("font-weight", css.fontWeight);
4613
4803
  add("font-family", css.fontFamily);
4614
4804
  add("line-height", css.lineHeight);
4615
4805
  add("letter-spacing", css.letterSpacing, css.letterSpacing !== "normal");
@@ -4793,7 +4983,7 @@ function initComty(options = {}) {
4793
4983
  height: draftBubble.offsetHeight,
4794
4984
  };
4795
4985
  const pinBounds = draftPin?.getBoundingClientRect();
4796
- const signature = JSON.stringify([selector, rect, hoverRect, rows, !!selected, inspectExpanded, measureAlt, draftBounds, pinBounds, spacing]);
4986
+ const signature = JSON.stringify([selector, rect, hoverRect, rows, !!selected, measureAlt, draftBounds, pinBounds, spacing, innerWidth, innerHeight]);
4797
4987
  if (signature === inspectionSignature) return;
4798
4988
  inspectionSignature = signature;
4799
4989
  inspectionLayer.replaceChildren();
@@ -4819,36 +5009,34 @@ function initComty(options = {}) {
4819
5009
  const panel = document.createElement("div");
4820
5010
  panel.className = "comty-inspector";
4821
5011
  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"];
5012
+ if (selected || draftPreview) panel.dataset.selected = "";
5013
+ const priority = ["background", "color", "font-family", "font-size", "font-weight", "margin", "line-height", "letter-spacing"];
5014
+ 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))];
5015
+ const labels = { "font-family": "Font", "font-size": "Size", "font-weight": "Weight", "line-height": "Line height", "letter-spacing": "Spacing", "background": "Background", "color": "Color", "margin": "Margin" };
5016
+ const cssProperty = key => ({ radius: "border-radius", background: "background-color" })[key] || key;
5017
+ const copyValue = (value, tokens) => tokens?.length ? `var(${tokens[0]})` : value;
5018
+ const allProperties = `${selector} {\n${rows.map(([key, value, tokens]) => ` ${cssProperty(key)}: ${copyValue(value, tokens)};`).join("\n")}\n}`;
5019
+ 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>`;
5020
+ 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]) => {
5021
+ const label = labels[key] || key.replaceAll("-", " ").replace(/^./, c => c.toUpperCase());
5022
+ const isColor = key === "background" || key === "color";
5023
+ const picker = isColor ? "color" : key.startsWith("font-") || ["line-height", "letter-spacing"].includes(key) ? "font" : "";
5024
+ const displayValue = isColor ? colorDetails(value)?.hex || value : value;
5025
+ 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>`;
5026
+ const valueElement = picker && selected
5027
+ ? `<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>`
5028
+ : `<span class="comty-inspector-value" data-property="${key}">${valueMarkup}</span>`;
5029
+ return `<div class="comty-inspector-row"><span class="comty-inspector-label">${label}</span>${valueElement}${copyButton(tokens?.length ? tokens[0].replace(/^--/, "") : value, `Copy ${label}`)}</div>`;
5030
+ }).join("")}</div><span class="comty-property-feedback" role="status"></span>`;
5031
+ for (const swatch of panel.querySelectorAll('.comty-inspector-swatch')) {
5032
+ const key = swatch.parentElement.dataset.property;
5033
+ swatch.style.backgroundColor = rows.find(([name]) => name === key)?.[1];
4835
5034
  }
4836
5035
  panel.onclick = async (event) => {
4837
5036
  const button = event.target.closest("button");
4838
5037
  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
- }
5038
+ if (button.dataset.picker) openPropertyPanel(button.dataset.picker, target, button.dataset.property, button);
5039
+ else await copyPropertyValue(button, panel.querySelector(".comty-property-feedback"));
4852
5040
  };
4853
5041
  inspectionLayer.appendChild(panel);
4854
5042
  const bounds = panel.getBoundingClientRect();
@@ -4866,8 +5054,24 @@ function initComty(options = {}) {
4866
5054
  }
4867
5055
  return;
4868
5056
  }
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`;
5057
+ const inset = 8, gap = 12;
5058
+ const clampX = x => Math.max(inset, Math.min(x, innerWidth - bounds.width - inset));
5059
+ const clampY = y => Math.max(inset, Math.min(y, innerHeight - bounds.height - inset));
5060
+ const x = clampX(rect.left), y = clampY(rect.top);
5061
+ const candidates = [
5062
+ { left: x, top: rect.bottom + gap },
5063
+ { left: rect.right + gap, top: y },
5064
+ { left: rect.left - bounds.width - gap, top: y },
5065
+ { left: x, top: rect.top - bounds.height - gap },
5066
+ ];
5067
+ let position = candidates.find(({ left, top }) => left >= inset && top >= inset && left + bounds.width <= innerWidth - inset && top + bounds.height <= innerHeight - inset);
5068
+ if (!position) {
5069
+ // A viewport-sized target leaves no outside space; minimize the covered area.
5070
+ 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));
5071
+ position = candidates.map(({ left, top }) => ({ left: clampX(left), top: clampY(top) })).sort((a, b) => overlap(a) - overlap(b))[0];
5072
+ }
5073
+ panel.style.left = `${position.left}px`;
5074
+ panel.style.top = `${position.top}px`;
4871
5075
  }
4872
5076
 
4873
5077
  function endSession() {
@@ -5256,15 +5460,6 @@ function initComty(options = {}) {
5256
5460
  return element;
5257
5461
  }
5258
5462
 
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
5463
  function getDeviceContext() {
5269
5464
  const width = window.innerWidth;
5270
5465
  const height = window.innerHeight;
@@ -5339,125 +5534,44 @@ function initComty(options = {}) {
5339
5534
  if (!session.edit || hadDraft) hideInspector();
5340
5535
  document
5341
5536
  .querySelectorAll(
5342
- ".comty-bubble, .comty-create-bubble, .comty-temp-pin, .comty-popover",
5537
+ ".comty-bubble:not([data-comty-capture-keep]), .comty-create-bubble, .comty-temp-pin, .comty-popover",
5343
5538
  )
5344
5539
  .forEach((x) => x.remove());
5345
5540
  }
5346
5541
 
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
- }
5542
+ const { captureViewportScreenshot } = createScreenshotCapture({ apiUrl, projectKey, getHeaders: getSdkHeaders });
5383
5543
 
5384
- async function uploadCommentScreenshot(
5385
- commentId,
5386
- payload,
5387
- captureNodes = [],
5388
- ) {
5544
+ async function uploadCommentScreenshot(comment, context, point) {
5389
5545
  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
-
5546
+ const screenshot = await captureViewportScreenshot({ context, annotation: () => mountCaptureAnnotation(comment, point) });
5547
+ if (!screenshot) return;
5548
+ const formData = new FormData();
5549
+ formData.append("projectKey", projectKey);
5550
+ formData.append("screenshot", screenshot.blob, screenshot.blob.type === "image/jpeg" ? "comment.jpg" : "comment.png");
5551
+ formData.append("screenshotWidth", String(screenshot.width));
5552
+ formData.append("screenshotHeight", String(screenshot.height));
5400
5553
  const response = await fetch(
5401
- `${apiUrl}/api/comments/${encodeURIComponent(commentId)}/screenshot`,
5402
- {
5403
- method: "POST",
5404
- headers: getSdkHeaders(),
5405
- body: formData,
5406
- },
5554
+ `${apiUrl}/api/comments/${encodeURIComponent(comment.id)}/screenshot`,
5555
+ { method: "POST", headers: getSdkHeaders(), body: formData },
5407
5556
  );
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 });
5557
+ if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
5558
+ // Updating metadata must not redraw the page or interrupt a new draft.
5559
+ const { screenshotUrl } = await response.json();
5560
+ const cached = cachedComments.find(item => String(item.id) === String(comment.id));
5561
+ if (cached) Object.assign(cached, {
5562
+ screenshot_url: screenshotUrl,
5563
+ screenshot_width: screenshot.width,
5564
+ screenshot_height: screenshot.height,
5565
+ });
5418
5566
  } 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];
5567
+ console.warn("Comty: comment saved, screenshot unavailable", error);
5568
+ }
5456
5569
  }
5457
5570
 
5458
5571
  function addLocalComment(commentId, payload) {
5459
5572
  const comment = {
5460
5573
  id: commentId,
5574
+ pending: commentId.startsWith("pending-"),
5461
5575
  project_id: projectId,
5462
5576
  domain: payload.host || window.location.host,
5463
5577
  page_path: payload.pagePath || window.location.pathname,
@@ -5465,6 +5579,7 @@ function initComty(options = {}) {
5465
5579
  rel_x: payload.relX,
5466
5580
  rel_y: payload.relY,
5467
5581
  content: payload.content || "",
5582
+ link_url: payload.linkUrl || null,
5468
5583
  created_at: new Date().toISOString(),
5469
5584
  dom_element: payload.domElement || null,
5470
5585
  viewport: payload.viewport || null,
@@ -5476,7 +5591,9 @@ function initComty(options = {}) {
5476
5591
  screenshot_width: null,
5477
5592
  screenshot_height: null,
5478
5593
  anchor_context: payload.anchorContext || null,
5479
- author_user_id: currentUser?.id || null,
5594
+ author_user_id: currentUser?.isGuest ? null : currentUser?.id || null,
5595
+ guest_author_id: currentUser?.isGuest ? currentUser.id : null,
5596
+ guest_author_name: currentUser?.isGuest ? currentUser.name : null,
5480
5597
  author_name: currentUser?.name || null,
5481
5598
  author_avatar_url: currentUser?.avatarUrl || null,
5482
5599
  replies: [],
@@ -5491,6 +5608,7 @@ function initComty(options = {}) {
5491
5608
  ];
5492
5609
  renderSidepanelComments();
5493
5610
  void renderComments(cachedComments, { retry: false });
5611
+ return comment;
5494
5612
  }
5495
5613
 
5496
5614
  function getCommentAuthorName(comment) {
@@ -5500,8 +5618,7 @@ function initComty(options = {}) {
5500
5618
  function isOwnCommentEntry(entry) {
5501
5619
  return Boolean(
5502
5620
  currentUser?.id &&
5503
- entry?.author_user_id &&
5504
- entry.author_user_id === currentUser.id,
5621
+ (currentUser.isGuest ? entry?.guest_author_id : entry?.author_user_id) === currentUser.id,
5505
5622
  );
5506
5623
  }
5507
5624
 
@@ -5577,7 +5694,7 @@ function initComty(options = {}) {
5577
5694
 
5578
5695
  function createThreadItem(
5579
5696
  entry,
5580
- { showMore = isOwnCommentEntry(entry) } = {},
5697
+ { showMore = isOwnCommentEntry(entry), showLink = true } = {},
5581
5698
  ) {
5582
5699
  const item = document.createElement("div");
5583
5700
  item.className = "comty-thread-item";
@@ -5612,15 +5729,51 @@ function initComty(options = {}) {
5612
5729
 
5613
5730
  const content = document.createElement("div");
5614
5731
  content.className = "comty-bubble-content";
5615
- content.innerText = entry?.content || "";
5732
+ renderCommentContent(content, entry?.content || "");
5616
5733
 
5617
5734
  body.appendChild(author);
5618
5735
  body.appendChild(content);
5736
+ if (showLink && entry?.link_url) body.appendChild(createCommentLink(entry.link_url));
5619
5737
  item.appendChild(body);
5620
5738
 
5621
5739
  return { item, body, content, moreButton };
5622
5740
  }
5623
5741
 
5742
+ function renderCommentContent(element, value) {
5743
+ const content = String(value ?? "");
5744
+ const nodes = [];
5745
+ let previousEnd = 0;
5746
+ for (const match of content.matchAll(/https?:\/\/[^\s<>"']+/gi)) {
5747
+ const url = match[0].replace(/[.,!?;:]+$/, "");
5748
+ if (!url) continue;
5749
+ let parsed;
5750
+ try { parsed = new URL(url); } catch { continue; }
5751
+ if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) continue;
5752
+ nodes.push(document.createTextNode(content.slice(previousEnd, match.index)));
5753
+ const link = document.createElement("a");
5754
+ link.href = url;
5755
+ link.target = "_blank";
5756
+ link.rel = "noopener noreferrer";
5757
+ link.textContent = url;
5758
+ nodes.push(link);
5759
+ previousEnd = match.index + url.length;
5760
+ }
5761
+ nodes.push(document.createTextNode(content.slice(previousEnd)));
5762
+ element.replaceChildren(...nodes);
5763
+ }
5764
+
5765
+ function createCommentLink(url) {
5766
+ const link = document.createElement("a");
5767
+ link.className = "comty-comment-link";
5768
+ link.href = url;
5769
+ link.target = "_blank";
5770
+ link.rel = "noopener noreferrer";
5771
+ link.title = url;
5772
+ link.setAttribute("aria-label", `Open attached link: ${url}`);
5773
+ link.textContent = url;
5774
+ return link;
5775
+ }
5776
+
5624
5777
  function setPinIcon(pin, comment = null) {
5625
5778
  if (comment) {
5626
5779
  pin.classList.add("comty-pin-with-avatar");
@@ -5637,6 +5790,54 @@ function initComty(options = {}) {
5637
5790
  `;
5638
5791
  }
5639
5792
 
5793
+ function createCommentPin(comment, x, y) {
5794
+ const pin = document.createElement("button");
5795
+ pin.className = "comty-pin";
5796
+ pin.type = "button";
5797
+ pin.style.left = `${x}px`;
5798
+ pin.style.top = `${y}px`;
5799
+ setPinIcon(pin, comment);
5800
+ const statusDot = document.createElement("span");
5801
+ statusDot.className = "comty-pin-status-dot";
5802
+ applyStatusDot(statusDot, comment.status);
5803
+ pin.appendChild(statusDot);
5804
+ return pin;
5805
+ }
5806
+
5807
+ function createCommentPreview(comment) {
5808
+ const bubble = document.createElement("div");
5809
+ bubble.className = "comty-bubble comty-hover-bubble";
5810
+ const thread = document.createElement("div");
5811
+ thread.className = "comty-thread";
5812
+ thread.appendChild(createThreadItem(comment, { showMore: false, showLink: false }).item);
5813
+ bubble.appendChild(thread);
5814
+ return bubble;
5815
+ }
5816
+
5817
+ function mountCaptureAnnotation(comment, { x, y }) {
5818
+ const host = document.createElement("div");
5819
+ host.dataset.comtyCaptureExclude = "";
5820
+ host.setAttribute("aria-hidden", "true");
5821
+ host.inert = true;
5822
+ // Ancestor opacity hides measurement without changing the components'
5823
+ // computed opacity. Nothing flashes or intercepts input during cloning.
5824
+ Object.assign(host.style, { position: "fixed", inset: "0", opacity: "0", pointerEvents: "none" });
5825
+ const pin = createCommentPin(comment, x, y);
5826
+ const bubble = createCommentPreview(comment);
5827
+ for (const element of [pin, bubble]) {
5828
+ element.dataset.comtyCaptureKeep = "";
5829
+ element.style.setProperty("animation", "none", "important");
5830
+ element.style.setProperty("transition", "none", "important");
5831
+ element.style.setProperty("pointer-events", "none", "important");
5832
+ }
5833
+ pin.style.display = "grid";
5834
+ bubble.style.display = "block";
5835
+ host.append(pin, bubble);
5836
+ root.appendChild(host);
5837
+ positionCommentBubble(bubble, x, y);
5838
+ return { elements: [pin, bubble], dispose: () => host.remove() };
5839
+ }
5840
+
5640
5841
  function createCommentAuthor(comment) {
5641
5842
  const author = document.createElement("div");
5642
5843
  author.className = "comty-comment-author";
@@ -5687,25 +5888,86 @@ function initComty(options = {}) {
5687
5888
  const actions = document.createElement("div");
5688
5889
  actions.className = "comty-create-actions";
5689
5890
 
5891
+ const linkBtn = document.createElement("button");
5892
+ linkBtn.type = "button";
5893
+ linkBtn.className = "comty-create-link-button";
5894
+ linkBtn.setAttribute("aria-label", "Add link");
5895
+ linkBtn.innerHTML = `
5896
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
5897
+ <path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
5898
+ <path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
5899
+ </svg>
5900
+ `;
5901
+
5902
+ const linkEditor = document.createElement("div");
5903
+ linkEditor.className = "comty-create-link-editor";
5904
+ linkEditor.inert = true;
5905
+ linkEditor.setAttribute("aria-hidden", "true");
5906
+ const linkInput = document.createElement("input");
5907
+ linkInput.type = "url";
5908
+ linkInput.placeholder = "Paste a link";
5909
+ linkInput.setAttribute("aria-label", "Link URL");
5910
+ const linkInsert = document.createElement("button");
5911
+ linkInsert.type = "button";
5912
+ linkInsert.textContent = "Add";
5913
+ const linkCancel = document.createElement("button");
5914
+ linkCancel.type = "button";
5915
+ linkCancel.setAttribute("aria-label", "Cancel link");
5916
+ linkCancel.textContent = "×";
5917
+ const linkField = document.createElement("div");
5918
+ linkField.className = "comty-create-link-field";
5919
+ linkField.append(linkInput, linkCancel);
5920
+ linkEditor.append(linkField, linkInsert);
5921
+
5922
+ const attachment = document.createElement("div");
5923
+ attachment.className = "comty-create-attachment";
5924
+ const attachmentLink = document.createElement("a");
5925
+ attachmentLink.className = "comty-comment-link";
5926
+ attachmentLink.target = "_blank";
5927
+ attachmentLink.rel = "noopener noreferrer";
5928
+ const removeAttachment = document.createElement("button");
5929
+ removeAttachment.type = "button";
5930
+ removeAttachment.setAttribute("aria-label", "Remove attached link");
5931
+ removeAttachment.textContent = "×";
5932
+ const attachmentChip = document.createElement("div");
5933
+ attachmentChip.className = "comty-create-attachment-chip";
5934
+ attachmentChip.append(attachmentLink, removeAttachment);
5935
+ attachment.appendChild(attachmentChip);
5936
+ let attachedLink = "";
5937
+
5690
5938
  const submitBtn = document.createElement("button");
5939
+ submitBtn.type = "button";
5691
5940
  submitBtn.className = "comty-btn-submit";
5692
5941
  submitBtn.setAttribute("aria-label", "Post comment");
5942
+ submitBtn.disabled = true;
5693
5943
  submitBtn.innerHTML = `
5694
5944
  <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
5945
  <path d="M12 19V5M5 12l7-7 7 7"/>
5696
5946
  </svg>
5697
5947
  `;
5698
5948
 
5949
+ const editorDraft = draftAnchor;
5699
5950
  const submit = async () => {
5700
5951
  const content = textarea.value.trim();
5701
- if (!content) return;
5952
+ if (!content || textarea.disabled) return;
5702
5953
 
5703
5954
  submitBtn.disabled = true;
5704
5955
  textarea.disabled = true;
5956
+ linkBtn.disabled = true;
5705
5957
 
5706
5958
  payload.content = content;
5959
+ payload.linkUrl = attachedLink || null;
5707
5960
  textarea.textContent = content;
5708
5961
 
5962
+ const context = captureContext();
5963
+ const point = getPinPoint(pin);
5964
+ const pendingId = `pending-${crypto.randomUUID()}`;
5965
+ // Paint the comment immediately. The network save never waits for capture work.
5966
+ bubble.remove();
5967
+ pin.remove();
5968
+ if (draftAnchor === editorDraft) draftAnchor = null;
5969
+ hideInspector();
5970
+ addLocalComment(pendingId, payload);
5709
5971
  let post;
5710
5972
  try {
5711
5973
  post = await fetch(`${apiUrl}/api/comments`, {
@@ -5713,35 +5975,32 @@ function initComty(options = {}) {
5713
5975
  headers: getSdkHeaders({ "Content-Type": "application/json" }),
5714
5976
  body: JSON.stringify(payload),
5715
5977
  });
5978
+ if (!post.ok) throw new Error(`Comment save failed (${post.status})`);
5979
+ const data = await post.json();
5980
+ if (!data?.id) throw new Error("Comment save returned no ID");
5981
+ cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
5982
+ if (context.url === location.href) {
5983
+ const comment = addLocalComment(String(data.id), payload);
5984
+ // Start only after the save and immediate UI update; the capture yields to input.
5985
+ void uploadCommentScreenshot(comment, context, point);
5986
+ } else void loadComments({ force: true });
5716
5987
  } catch (error) {
5717
5988
  console.error("Comty: save failed", error);
5989
+ cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
5990
+ renderSidepanelComments();
5991
+ void renderComments(cachedComments, { retry: false });
5992
+ // Keep the original editor and its text available for retry, including attachments.
5993
+ if (!draftAnchor) draftAnchor = editorDraft;
5994
+ root.append(pin, bubble);
5718
5995
  submitBtn.disabled = false;
5719
5996
  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 });
5997
+ linkBtn.disabled = false;
5998
+ const errorMessage = document.createElement("p");
5999
+ errorMessage.setAttribute("role", "alert");
6000
+ errorMessage.textContent = "Comment not saved. Your draft is ready to retry.";
6001
+ bubble.querySelector('[role="alert"]')?.remove();
6002
+ bubble.append(errorMessage);
6003
+ if (bubble.isConnected) textarea.focus();
5745
6004
  }
5746
6005
  };
5747
6006
 
@@ -5750,8 +6009,63 @@ function initComty(options = {}) {
5750
6009
  submit();
5751
6010
  };
5752
6011
 
6012
+ const closeLinkEditor = () => {
6013
+ bubble.classList.remove("comty-create-link-open");
6014
+ linkEditor.inert = true;
6015
+ linkEditor.setAttribute("aria-hidden", "true");
6016
+ linkInput.value = "";
6017
+ linkInput.removeAttribute("aria-invalid");
6018
+ linkInput.setCustomValidity("");
6019
+ textarea.focus();
6020
+ };
6021
+ linkBtn.onclick = () => {
6022
+ if (!bubble.classList.contains("comty-create-expanded")) return;
6023
+ bubble.classList.add("comty-create-link-open");
6024
+ linkEditor.inert = false;
6025
+ linkEditor.setAttribute("aria-hidden", "false");
6026
+ linkInput.focus();
6027
+ };
6028
+ linkCancel.onclick = closeLinkEditor;
6029
+ linkInput.oninput = () => {
6030
+ linkInput.removeAttribute("aria-invalid");
6031
+ linkInput.setCustomValidity("");
6032
+ };
6033
+ linkInput.onkeydown = (event) => {
6034
+ if (event.key === "Enter") { event.preventDefault(); linkInsert.click(); }
6035
+ if (event.key === "Escape") { event.preventDefault(); closeLinkEditor(); }
6036
+ };
6037
+ linkInsert.onclick = () => {
6038
+ const value = linkInput.value.trim();
6039
+ const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`;
6040
+ let url;
6041
+ try { url = new URL(candidate); } catch { /* Report below. */ }
6042
+ if (!url || !["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password || /\s/.test(value) || String(url).length > 2048) {
6043
+ linkInput.setAttribute("aria-invalid", "true");
6044
+ linkInput.setCustomValidity("Enter a valid HTTP(S) URL.");
6045
+ linkInput.reportValidity();
6046
+ linkInput.focus();
6047
+ return;
6048
+ }
6049
+ attachedLink = String(url);
6050
+ attachmentLink.href = attachedLink;
6051
+ attachmentLink.title = attachedLink;
6052
+ attachmentLink.textContent = attachedLink;
6053
+ bubble.classList.add("comty-create-has-attachment");
6054
+ closeLinkEditor();
6055
+ const point = getPinPoint(pin);
6056
+ positionCommentBubble(bubble, point.x, point.y);
6057
+ renderInspector();
6058
+ };
6059
+
6060
+ removeAttachment.onclick = () => {
6061
+ attachedLink = "";
6062
+ bubble.classList.remove("comty-create-has-attachment");
6063
+ textarea.focus();
6064
+ textarea.oninput();
6065
+ };
6066
+
5753
6067
  textarea.onkeydown = (e) => {
5754
- if (e.key === "Enter" && !e.shiftKey) {
6068
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
5755
6069
  e.preventDefault();
5756
6070
  submit();
5757
6071
  }
@@ -5762,31 +6076,20 @@ function initComty(options = {}) {
5762
6076
  };
5763
6077
 
5764
6078
  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
- }
6079
+ const expanded = textarea.value.length > 0 || Boolean(attachedLink);
6080
+ bubble.classList.toggle("comty-create-expanded", expanded);
6081
+ if (!expanded && bubble.classList.contains("comty-create-link-open")) closeLinkEditor();
6082
+ submitBtn.disabled = !textarea.value.trim();
6083
+ submitBtn.classList.toggle("comty-btn-active", !submitBtn.disabled);
5782
6084
  const point = getPinPoint(pin);
5783
6085
  positionCommentBubble(bubble, point.x, point.y);
5784
6086
  renderInspector();
5785
6087
  };
5786
6088
 
5787
- actions.appendChild(submitBtn);
6089
+ actions.append(linkBtn, submitBtn, linkEditor);
5788
6090
 
5789
6091
  bubble.appendChild(textarea);
6092
+ bubble.appendChild(attachment);
5790
6093
  bubble.appendChild(actions);
5791
6094
 
5792
6095
  root.appendChild(bubble);
@@ -5895,6 +6198,7 @@ function initComty(options = {}) {
5895
6198
  const item = document.createElement("button");
5896
6199
  item.type = "button";
5897
6200
  item.className = "comty-sidepanel-item";
6201
+ item.disabled = Boolean(comment.pending);
5898
6202
  item.innerHTML = `
5899
6203
  <span class="comty-sidepanel-item-status" aria-hidden="true"></span>
5900
6204
  <span class="comty-sidepanel-item-body">
@@ -5915,7 +6219,7 @@ function initComty(options = {}) {
5915
6219
 
5916
6220
  const metaEl = item.querySelector(".comty-sidepanel-item-meta");
5917
6221
  if (metaEl instanceof HTMLElement) {
5918
- metaEl.innerText = `${comment.author_name || "Anonymous"} · ${getCommentLabel(comment)}`;
6222
+ metaEl.innerText = `${comment.author_name || "Anonymous"} · ${comment.pending ? "Sending…" : getCommentLabel(comment)}`;
5919
6223
  metaEl.title = getCommentLabel(comment);
5920
6224
  }
5921
6225
 
@@ -6246,11 +6550,13 @@ function initComty(options = {}) {
6246
6550
  const openCommentThread = createCommentThread({
6247
6551
  root, apiUrl, projectKey, commentStatuses, selectedStatusFilters, placedPins,
6248
6552
  closeBubbles, getCommentStatus, updateCommentStatus, applyStatusDot,
6249
- renderSidepanelComments, applyStatusFilters, createThreadItem,
6553
+ renderSidepanelComments, applyStatusFilters, createThreadItem, renderCommentContent,
6250
6554
  isOwnCommentEntry, updateCommentContent, deleteSdkComment, getCommentId,
6251
6555
  updateReplyContent, deleteSdkReply, createCommentAvatar, getSdkHeaders,
6252
6556
  showAuthPrompt,
6253
6557
  get canWrite() { return canWrite; },
6558
+ get canManage() { return canManage; },
6559
+ get allowGuestComments() { return allowGuestComments; },
6254
6560
  get currentUser() { return currentUser; },
6255
6561
  get cachedComments() { return cachedComments; },
6256
6562
  set cachedComments(value) { cachedComments = value; },
@@ -6300,20 +6606,11 @@ function initComty(options = {}) {
6300
6606
  const x = visibility.clientX;
6301
6607
  const y = visibility.clientY;
6302
6608
 
6303
- const pin = document.createElement("button");
6304
- pin.className = "comty-pin";
6305
- pin.type = "button";
6609
+ const pin = createCommentPin(comment, x, y);
6610
+ pin.disabled = Boolean(comment.pending);
6306
6611
  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);
6612
+ if (comment.id) pin.dataset.commentId = String(comment.id);
6613
+ const pinStatusDot = pin.querySelector(".comty-pin-status-dot");
6317
6614
  pin.setAttribute(
6318
6615
  "aria-label",
6319
6616
  `Afficher le commentaire, statut ${getCommentStatus(comment.status).label}`,
@@ -6355,32 +6652,14 @@ function initComty(options = {}) {
6355
6652
  activeHoverCommentIds.add(commentId);
6356
6653
  }
6357
6654
 
6358
- const hoverBubble = document.createElement("div");
6359
- hoverBubble.className = "comty-bubble comty-hover-bubble";
6655
+ const hoverBubble = createCommentPreview(comment);
6360
6656
  hoverBubble.style.left = `${x + 32}px`;
6361
6657
  hoverBubble.style.top = `${y - 17}px`;
6362
- hoverBubble.style.pointerEvents = "none";
6363
6658
  hoverBubble.dataset.hoverId = commentId;
6364
6659
 
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
6660
  root.appendChild(hoverBubble);
6372
6661
 
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
- });
6662
+ requestAnimationFrame(() => positionCommentBubble(hoverBubble, x, y));
6384
6663
  };
6385
6664
 
6386
6665
  pin.onmouseleave = () => {
@@ -6477,6 +6756,9 @@ function initComty(options = {}) {
6477
6756
  cachedComments = comments
6478
6757
  .filter(comment => comment.kind !== "agent" && !comment.local_agent)
6479
6758
  .map(comment => Object.assign(previous.get(String(comment.id)) || {}, comment));
6759
+ for (const comment of previous.values()) {
6760
+ if (comment.pending && !cachedComments.some(item => item.id === comment.id)) cachedComments.push(comment);
6761
+ }
6480
6762
  }
6481
6763
 
6482
6764
  async function loadComments({ force = false } = {}) {
@@ -6697,7 +6979,6 @@ function initComty(options = {}) {
6697
6979
  hideInspector();
6698
6980
  return;
6699
6981
  }
6700
- if (inspectedElement !== target) inspectExpanded = selectedElement ? inspectExpanded : false;
6701
6982
  showInspector(target);
6702
6983
  }, true);
6703
6984
  document.documentElement.addEventListener("pointerleave", () => {
@@ -6754,7 +7035,6 @@ function initComty(options = {}) {
6754
7035
  }
6755
7036
  measuredRegion = null;
6756
7037
  selectedElement = getAnchorElement(target);
6757
- inspectExpanded = false;
6758
7038
  showInspector(target);
6759
7039
  return;
6760
7040
  }
@@ -6912,13 +7192,26 @@ function initComty(options = {}) {
6912
7192
  const data = await res.json();
6913
7193
  projectId = data.projectId;
6914
7194
  canWrite = Boolean(data.canWrite);
7195
+ canManage = Boolean(data.canManage ?? data.canWrite);
7196
+ allowGuestComments = Boolean(data.allowGuestComments);
6915
7197
  currentUser = data.user || null;
6916
7198
  loginUrl = data.loginUrl || "";
6917
7199
 
6918
7200
  if (!canWrite && sdkToken) {
6919
- sessionStorage.removeItem(tokenStorageKey);
7201
+ try { sessionStorage.removeItem(tokenStorageKey); } catch { /* Storage may be unavailable. */ }
6920
7202
  sdkToken = "";
6921
7203
  }
7204
+ if (didCompleteSignIn || authStatus === "forbidden") {
7205
+ let previousSession;
7206
+ try {
7207
+ previousSession = JSON.parse(sessionStorage.getItem(authReturnStorageKey) || "null");
7208
+ sessionStorage.removeItem(authReturnStorageKey);
7209
+ } catch { /* Fall back to the requested comment mode. */ }
7210
+ const mode = canWrite && ["browse", "edit", "inspect"].includes(previousSession?.mode)
7211
+ ? previousSession.mode : canWrite ? "edit" : "browse";
7212
+ setSession(mode, { entry: previousSession?.entry || mode });
7213
+ if (previousSession?.sidepanelOpen) setSidepanelOpen(true);
7214
+ }
6922
7215
  if (authStatus === "forbidden") showAuthPrompt(true);
6923
7216
  if (canWrite && didCompleteSignIn) {
6924
7217
  const toast = document.createElement("div");
@@ -6947,7 +7240,7 @@ function initComty(options = {}) {
6947
7240
  setSession(session.comments ? session.mode : "browse", { entry: sessionEntry, renderComments: true });
6948
7241
  return renderComments(cachedComments, { retry: false });
6949
7242
  },
6950
- setCanWrite(value) { canWrite = value; },
7243
+ setCanWrite(value) { canWrite = value; canManage = value; },
6951
7244
  invalidate() { anchorRevision += 1; },
6952
7245
  });
6953
7246
  }