coplan-engine 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 129983ca7890cb3869a1c594ebde86720d3ba80e14861b9ad613c3311eec30b0
4
- data.tar.gz: 93ee7fa05f6f3676bc414a9f9794a3a04c80cee7181efcab89d484816f72cd79
3
+ metadata.gz: 3e4bc136761076f3c7f12bf4e025f5bfd0c89831c718f50cd88a4e650bb402e8
4
+ data.tar.gz: a7739fea373261118928c4c488043ca9d2547eac95d4ceeee91f004585e3b317
5
5
  SHA512:
6
- metadata.gz: 291848526406f7b918dd2cf86e161ccec3e85eecf73277325af84fb320ea78b4fd64c260c0f539ba6bf0e59b747906c2d173fdf39c1920c72522537ae62fe0ae
7
- data.tar.gz: 8f5af631ce15ede7498305751f51a89dfc71136448196a959e4aa3223e5c00fde1955645aefa507db117f72269d8ab256d7bad1053c56cb34a218b509e9cd6e6
6
+ metadata.gz: 0d984b75c02ce889706c1cf1080f61106f5e355d0626d69c502b7f29ec28ef99300ad249d0c57cd1823f46d064f62e641a2c733b60a3df6cab266fc17fef453d
7
+ data.tar.gz: f70083d0f8ce612a2608e7863fe7606aecefa40e402c582596eccc68576f9cf60e546efd5f98d30c2a59a180d81a2c97e1b831ad9277ec21f54569df3ebf2325
@@ -31,7 +31,7 @@
31
31
  --space-2xl: 3rem;
32
32
 
33
33
  /* Typography */
34
- --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
34
+ --font-sans: 'Lexend', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
35
35
  --font-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace;
36
36
  --text-sm: 0.875rem;
37
37
  --text-base: 1rem;
@@ -514,7 +514,7 @@ img, svg {
514
514
 
515
515
  .markdown-rendered p {
516
516
  margin-bottom: var(--space-md);
517
- line-height: 1.7;
517
+ line-height: 1.9;
518
518
  }
519
519
 
520
520
  .markdown-rendered ul,
@@ -525,7 +525,7 @@ img, svg {
525
525
 
526
526
  .markdown-rendered li {
527
527
  margin-bottom: var(--space-xs);
528
- line-height: 1.7;
528
+ line-height: 1.9;
529
529
  }
530
530
 
531
531
  .markdown-rendered pre {
@@ -605,20 +605,37 @@ img, svg {
605
605
 
606
606
  /* Anchor text highlights */
607
607
  .anchor-highlight {
608
- background: rgba(255, 213, 79, 0.3);
609
- border-bottom: 2px solid rgba(255, 179, 0, 0.5);
610
608
  cursor: pointer;
611
609
  transition: background 0.2s;
612
610
  }
613
611
 
614
- .anchor-highlight:hover {
615
- background: rgba(255, 213, 79, 0.5);
612
+ .anchor-highlight--open {
613
+ background: rgba(108, 140, 255, 0.12);
614
+ border-bottom: 2px solid rgba(108, 140, 255, 0.6);
615
+ cursor: pointer;
616
+ }
617
+
618
+ .anchor-highlight--open:hover {
619
+ background: rgba(108, 140, 255, 0.22);
620
+ }
621
+
622
+ .anchor-highlight--resolved {
623
+ background: none;
624
+ border-bottom: none;
625
+ cursor: default;
626
+ pointer-events: none;
627
+ }
628
+
629
+ .plan-layout--show-resolved .anchor-highlight--resolved {
630
+ border-bottom: 1px dashed var(--color-text-muted);
631
+ cursor: pointer;
632
+ pointer-events: auto;
616
633
  }
617
634
 
618
635
  .anchor-highlight--active {
619
- background: rgba(255, 179, 0, 0.4);
620
- border-bottom: 2px solid var(--color-warning);
621
- outline: 2px solid rgba(255, 179, 0, 0.3);
636
+ background: rgba(108, 140, 255, 0.25);
637
+ border-bottom: 2px solid var(--color-primary);
638
+ outline: 2px solid rgba(108, 140, 255, 0.3);
622
639
  outline-offset: 1px;
623
640
  }
624
641
 
@@ -653,87 +670,35 @@ img, svg {
653
670
  border-radius: 0 var(--radius) var(--radius) 0;
654
671
  }
655
672
 
656
- /* Plan layout (content + sidebar) */
673
+ /* Plan layout */
657
674
  .plan-layout {
658
675
  position: relative;
659
- }
660
-
661
- .plan-layout__content {
662
- max-width: 60%;
663
- position: relative;
664
- }
665
-
666
- .plan-layout__sidebar {
667
- position: absolute;
668
- top: 0;
669
- right: 0;
670
- width: 22rem;
671
- }
672
-
673
- @media (max-width: 900px) {
674
- .plan-layout__content {
675
- max-width: 100%;
676
- }
677
-
678
- .plan-layout__sidebar {
679
- position: static;
680
- width: 100%;
681
- margin-top: var(--space-lg);
682
- }
683
- }
684
- .sidebar-heading {
685
- font-size: var(--text-lg);
686
- font-weight: 700;
687
- margin-bottom: var(--space-md);
688
- }
689
-
690
- /* Comment tabs */
691
- .comment-tabs__nav {
692
676
  display: flex;
693
677
  gap: 0;
694
- border-bottom: 2px solid var(--color-border);
695
- margin-bottom: var(--space-md);
696
- }
697
-
698
- .comment-tab {
699
- padding: var(--space-xs) var(--space-md);
700
- font-size: var(--text-sm);
701
- font-weight: 600;
702
- color: var(--color-text-muted);
703
- background: none;
704
- border: none;
705
- border-bottom: 2px solid transparent;
706
- margin-bottom: -2px;
707
- cursor: pointer;
708
678
  }
709
679
 
710
- .comment-tab:hover {
711
- color: var(--color-text);
712
- }
713
-
714
- .comment-tab--active {
715
- color: var(--color-primary);
716
- border-bottom-color: var(--color-primary);
717
- }
718
-
719
- .comment-tab__count {
720
- background: var(--color-border);
721
- color: var(--color-text-muted);
722
- font-size: 0.7rem;
723
- padding: 1px 6px;
724
- border-radius: 10px;
725
- margin-left: 4px;
680
+ .plan-layout__margin {
681
+ width: 40px;
682
+ flex-shrink: 0;
683
+ position: relative;
726
684
  }
727
685
 
728
- .comment-tab--active .comment-tab__count {
729
- background: var(--color-primary);
730
- color: white;
686
+ .plan-layout__content {
687
+ position: relative;
688
+ flex: 1;
689
+ min-width: 0;
731
690
  }
732
691
 
733
692
  /* Comment form */
734
693
  .comment-form {
735
- margin-bottom: var(--space-md);
694
+ position: absolute;
695
+ z-index: 100;
696
+ width: 360px;
736
697
  padding: var(--space-md);
698
+ background: var(--color-surface);
699
+ border: 1px solid var(--color-border);
700
+ border-radius: var(--radius);
701
+ box-shadow: var(--shadow-lg);
737
702
  }
738
703
 
739
704
  .comment-form .form-group {
@@ -757,13 +722,6 @@ img, svg {
757
722
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
758
723
  }
759
724
 
760
- /* Comment threads list */
761
- .comment-threads-list {
762
- display: flex;
763
- flex-direction: column;
764
- gap: var(--space-md);
765
- }
766
-
767
725
  /* Comment thread */
768
726
  .comment-thread {
769
727
  padding: var(--space-md);
@@ -960,3 +918,172 @@ img, svg {
960
918
  color: var(--color-text-muted);
961
919
  margin-top: var(--space-xs);
962
920
  }
921
+
922
+ /* Thread popover data containers (hidden, provide data for JS) */
923
+ .thread-popover-data {
924
+ display: contents;
925
+ }
926
+
927
+ /* Thread popover (native HTML Popover API) */
928
+ .thread-popover {
929
+ position: fixed;
930
+ margin: 0;
931
+ padding: var(--space-md);
932
+ background: var(--color-surface);
933
+ border: 1px solid var(--color-border);
934
+ border-radius: var(--radius);
935
+ box-shadow: var(--shadow-lg);
936
+ width: 380px;
937
+ max-height: 60vh;
938
+ overflow-y: auto;
939
+ z-index: 200;
940
+ }
941
+
942
+ .thread-popover::backdrop {
943
+ background: transparent;
944
+ }
945
+
946
+ .thread-popover__header {
947
+ display: flex;
948
+ gap: var(--space-xs);
949
+ align-items: center;
950
+ margin-bottom: var(--space-sm);
951
+ }
952
+
953
+ .thread-popover__quote {
954
+ border-left: 3px solid rgba(108, 140, 255, 0.6);
955
+ padding: var(--space-xs) var(--space-sm);
956
+ margin: 0 0 var(--space-sm) 0;
957
+ font-size: var(--text-sm);
958
+ color: var(--color-text-muted);
959
+ background: rgba(108, 140, 255, 0.05);
960
+ border-radius: 0 var(--radius) var(--radius) 0;
961
+ }
962
+
963
+ .thread-popover__comments {
964
+ display: flex;
965
+ flex-direction: column;
966
+ gap: var(--space-xs);
967
+ }
968
+
969
+ .thread-popover__reply {
970
+ margin-top: var(--space-sm);
971
+ padding-top: var(--space-sm);
972
+ border-top: 1px solid var(--color-border);
973
+ }
974
+
975
+ .thread-popover__reply textarea {
976
+ width: 100%;
977
+ padding: var(--space-sm);
978
+ font-family: var(--font-sans);
979
+ font-size: var(--text-sm);
980
+ border: 1px solid var(--color-border);
981
+ border-radius: var(--radius);
982
+ resize: vertical;
983
+ min-height: 3rem;
984
+ }
985
+
986
+ .thread-popover__reply textarea:focus {
987
+ outline: none;
988
+ border-color: var(--color-primary);
989
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
990
+ }
991
+
992
+ .thread-popover__reply .form-group {
993
+ margin-bottom: var(--space-xs);
994
+ }
995
+
996
+ .thread-popover__actions {
997
+ display: flex;
998
+ gap: var(--space-xs);
999
+ margin-top: var(--space-sm);
1000
+ padding-top: var(--space-sm);
1001
+ border-top: 1px solid var(--color-border);
1002
+ }
1003
+
1004
+ /* Margin dots */
1005
+ .margin-dot {
1006
+ position: absolute;
1007
+ width: 12px;
1008
+ height: 12px;
1009
+ border-radius: 50%;
1010
+ cursor: pointer;
1011
+ left: 14px;
1012
+ transition: transform 0.15s, box-shadow 0.15s;
1013
+ display: flex;
1014
+ align-items: center;
1015
+ justify-content: center;
1016
+ }
1017
+
1018
+ .margin-dot:hover {
1019
+ transform: scale(1.3);
1020
+ }
1021
+
1022
+ .margin-dot--open {
1023
+ background: var(--color-primary);
1024
+ box-shadow: 0 0 6px rgba(37, 99, 235, 0.3);
1025
+ }
1026
+
1027
+ .margin-dot--resolved {
1028
+ background: var(--color-text-muted);
1029
+ opacity: 0.4;
1030
+ display: none;
1031
+ }
1032
+
1033
+ .plan-layout--show-resolved .margin-dot--resolved {
1034
+ display: flex;
1035
+ }
1036
+
1037
+ /* Ensure content doesn't get hidden behind fixed toolbar */
1038
+ body:has(.comment-toolbar) .main-content {
1039
+ padding-bottom: 3.5rem;
1040
+ }
1041
+
1042
+ /* Comment toolbar (bottom-pinned) */
1043
+ .comment-toolbar {
1044
+ position: fixed;
1045
+ bottom: 0;
1046
+ left: 0;
1047
+ right: 0;
1048
+ background: var(--color-surface);
1049
+ border-top: 1px solid var(--color-border);
1050
+ padding: var(--space-sm) var(--space-lg);
1051
+ display: flex;
1052
+ align-items: center;
1053
+ gap: var(--space-lg);
1054
+ z-index: 50;
1055
+ justify-content: center;
1056
+ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
1057
+ }
1058
+
1059
+ .comment-toolbar__count {
1060
+ font-size: var(--text-sm);
1061
+ font-weight: 600;
1062
+ color: var(--color-text);
1063
+ }
1064
+
1065
+ .comment-toolbar__nav {
1066
+ display: flex;
1067
+ align-items: center;
1068
+ gap: var(--space-xs);
1069
+ }
1070
+
1071
+ .comment-toolbar__position {
1072
+ font-size: var(--text-sm);
1073
+ color: var(--color-text-muted);
1074
+ min-width: 4rem;
1075
+ text-align: center;
1076
+ }
1077
+
1078
+ .comment-toolbar__toggle {
1079
+ font-size: var(--text-sm);
1080
+ color: var(--color-text-muted);
1081
+ cursor: pointer;
1082
+ display: flex;
1083
+ align-items: center;
1084
+ gap: var(--space-xs);
1085
+ }
1086
+
1087
+ .comment-toolbar__toggle input[type="checkbox"] {
1088
+ cursor: pointer;
1089
+ }
@@ -56,7 +56,7 @@ module CoPlan
56
56
  render json: { thread_id: thread.id, status: thread.status }
57
57
  end
58
58
 
59
- def dismiss
59
+ def discard
60
60
  thread = @plan.comment_threads.find_by(id: params[:id])
61
61
  unless thread
62
62
  render json: { error: "Comment thread not found" }, status: :not_found
@@ -64,12 +64,12 @@ module CoPlan
64
64
  end
65
65
 
66
66
  policy = CommentThreadPolicy.new(current_user, thread)
67
- unless policy.dismiss?
67
+ unless policy.discard?
68
68
  render json: { error: "Not authorized" }, status: :forbidden
69
69
  return
70
70
  end
71
71
 
72
- thread.dismiss!(current_user)
72
+ thread.discard!(current_user)
73
73
  broadcast_thread_update(thread)
74
74
 
75
75
  render json: { thread_id: thread.id, status: thread.status }
@@ -104,10 +104,10 @@ module CoPlan
104
104
  private
105
105
 
106
106
  def broadcast_new_thread(thread)
107
- Broadcaster.prepend_to(
107
+ Broadcaster.append_to(
108
108
  @plan,
109
- target: "comment-threads",
110
- partial: "coplan/comment_threads/thread",
109
+ target: "plan-threads",
110
+ partial: "coplan/comment_threads/thread_popover",
111
111
  locals: { thread: thread, plan: @plan }
112
112
  )
113
113
  end
@@ -116,7 +116,7 @@ module CoPlan
116
116
  Broadcaster.replace_to(
117
117
  @plan,
118
118
  target: ActionView::RecordIdentifier.dom_id(thread),
119
- partial: "coplan/comment_threads/thread",
119
+ partial: "coplan/comment_threads/thread_popover",
120
120
  locals: { thread: thread, plan: @plan }
121
121
  )
122
122
  end
@@ -3,11 +3,15 @@ module CoPlan
3
3
  include ActionView::RecordIdentifier
4
4
 
5
5
  before_action :set_plan
6
- before_action :set_thread, only: [:resolve, :accept, :dismiss, :reopen]
6
+ before_action :set_thread, only: [:resolve, :accept, :discard, :reopen]
7
7
 
8
8
  def create
9
9
  authorize!(@plan, :show?)
10
10
 
11
+ # Author's own comments start as "todo" (self-assigned work item);
12
+ # non-author comments start as "pending" (awaiting author triage).
13
+ initial_status = current_user.id == @plan.created_by_user_id ? "todo" : "pending"
14
+
11
15
  thread = @plan.comment_threads.new(
12
16
  plan_version: @plan.current_plan_version,
13
17
  anchor_text: params[:comment_thread][:anchor_text].presence,
@@ -15,7 +19,8 @@ module CoPlan
15
19
  anchor_occurrence: params[:comment_thread][:anchor_occurrence].presence&.to_i,
16
20
  start_line: params[:comment_thread][:start_line].presence,
17
21
  end_line: params[:comment_thread][:end_line].presence,
18
- created_by_user: current_user
22
+ created_by_user: current_user,
23
+ status: initial_status
19
24
  )
20
25
 
21
26
  thread.save!
@@ -26,8 +31,14 @@ module CoPlan
26
31
  body_markdown: params[:comment_thread][:body_markdown]
27
32
  )
28
33
 
29
- broadcast_new_thread(thread)
30
- broadcast_tab_counts
34
+ if thread.anchored?
35
+ Broadcaster.append_to(
36
+ @plan,
37
+ target: "plan-threads",
38
+ partial: "coplan/comment_threads/thread_popover",
39
+ locals: { thread: thread, plan: @plan }
40
+ )
41
+ end
31
42
 
32
43
  respond_with_stream_or_redirect("Comment added.")
33
44
  end
@@ -35,34 +46,28 @@ module CoPlan
35
46
  def resolve
36
47
  authorize!(@thread, :resolve?)
37
48
  @thread.resolve!(current_user)
38
- broadcast_thread_move(@thread, from: "comment-threads", to: "resolved-comment-threads")
49
+ broadcast_thread_replace(@thread)
39
50
  respond_with_stream_or_redirect("Thread resolved.")
40
51
  end
41
52
 
42
53
  def accept
43
54
  authorize!(@thread, :accept?)
44
55
  @thread.accept!(current_user)
45
- broadcast_thread_move(@thread, from: "comment-threads", to: "resolved-comment-threads")
56
+ broadcast_thread_replace(@thread)
46
57
  respond_with_stream_or_redirect("Thread accepted.")
47
58
  end
48
59
 
49
- def dismiss
50
- authorize!(@thread, :dismiss?)
51
- @thread.dismiss!(current_user)
52
- broadcast_thread_move(@thread, from: "comment-threads", to: "resolved-comment-threads")
53
- respond_with_stream_or_redirect("Thread dismissed.")
60
+ def discard
61
+ authorize!(@thread, :discard?)
62
+ @thread.discard!(current_user)
63
+ broadcast_thread_replace(@thread)
64
+ respond_with_stream_or_redirect("Thread discarded.")
54
65
  end
55
66
 
56
67
  def reopen
57
68
  authorize!(@thread, :reopen?)
58
- @thread.update!(status: "open", resolved_by_user: nil)
59
- # Out-of-date threads stay in the archived list even when reopened,
60
- # since the active scope excludes out_of_date rows.
61
- if @thread.out_of_date?
62
- broadcast_thread_replace(@thread)
63
- else
64
- broadcast_thread_move(@thread, from: "resolved-comment-threads", to: "comment-threads")
65
- end
69
+ @thread.update!(status: "pending", resolved_by_user: nil)
70
+ broadcast_thread_replace(@thread)
66
71
  respond_with_stream_or_redirect("Thread reopened.")
67
72
  end
68
73
 
@@ -76,15 +81,6 @@ module CoPlan
76
81
  @thread = @plan.comment_threads.find(params[:id])
77
82
  end
78
83
 
79
- def broadcast_new_thread(thread)
80
- Broadcaster.prepend_to(
81
- @plan,
82
- target: "comment-threads",
83
- partial: "coplan/comment_threads/thread",
84
- locals: { thread: thread, plan: @plan }
85
- )
86
- end
87
-
88
84
  # Broadcasts update all clients (including the submitter) via WebSocket.
89
85
  # The empty turbo_stream response prevents Turbo from navigating (which causes scroll-to-top).
90
86
  def respond_with_stream_or_redirect(message)
@@ -94,56 +90,14 @@ module CoPlan
94
90
  end
95
91
  end
96
92
 
97
- # Replaces a thread in place (status changed but stays in the same list).
93
+ # Replaces a thread in place (status changed).
98
94
  def broadcast_thread_replace(thread)
99
95
  Broadcaster.replace_to(
100
96
  @plan,
101
97
  target: dom_id(thread),
102
- partial: "coplan/comment_threads/thread",
98
+ partial: "coplan/comment_threads/thread_popover",
103
99
  locals: { thread: thread, plan: @plan }
104
100
  )
105
- broadcast_tab_counts
106
- end
107
-
108
- # Moves a thread between Open/Resolved lists and updates tab counts.
109
- def broadcast_thread_move(thread, from:, to:)
110
- Broadcaster.remove_to(@plan, target: dom_id(thread))
111
- Broadcaster.append_to(
112
- @plan,
113
- target: to,
114
- partial: "coplan/comment_threads/thread",
115
- locals: { thread: thread, plan: @plan }
116
- )
117
- broadcast_tab_counts
118
- end
119
-
120
- def broadcast_tab_counts
121
- threads = @plan.comment_threads
122
- open_count = threads.active.count
123
- resolved_count = threads.archived.count
124
-
125
- Broadcaster.update_to(
126
- @plan,
127
- target: "open-thread-count",
128
- html: open_count > 0 ? open_count.to_s : ""
129
- )
130
- Broadcaster.update_to(
131
- @plan,
132
- target: "resolved-thread-count",
133
- html: resolved_count > 0 ? resolved_count.to_s : ""
134
- )
135
-
136
- # Toggle empty-state placeholders
137
- Broadcaster.replace_to(
138
- @plan,
139
- target: "open-threads-empty",
140
- html: %(<p class="text-sm text-muted" id="open-threads-empty" #{'style="display: none;"' if open_count > 0}>No open comments.</p>)
141
- )
142
- Broadcaster.replace_to(
143
- @plan,
144
- target: "resolved-threads-empty",
145
- html: %(<p class="text-sm text-muted" id="resolved-threads-empty" #{'style="display: none;"' if resolved_count > 0}>No resolved comments.</p>)
146
- )
147
101
  end
148
102
  end
149
103
  end
@@ -10,9 +10,7 @@ module CoPlan
10
10
 
11
11
  def show
12
12
  authorize!(@plan, :show?)
13
- threads = @plan.comment_threads.includes(:comments, :created_by_user, :plan_version).order(created_at: :asc)
14
- @active_threads = threads.active
15
- @archived_threads = threads.archived
13
+ @threads = @plan.comment_threads.includes(:comments, :created_by_user).order(:created_at)
16
14
  end
17
15
 
18
16
  def edit
@@ -0,0 +1,14 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ submitOnEnter(event) {
5
+ if (event.key !== "Enter") return
6
+ if (event.shiftKey || event.isComposing) return
7
+
8
+ const form = event.target.closest("form")
9
+ if (!form) return
10
+
11
+ event.preventDefault()
12
+ form.requestSubmit()
13
+ }
14
+ }