coplan-engine 0.2.0 → 0.4.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: 3e4bc136761076f3c7f12bf4e025f5bfd0c89831c718f50cd88a4e650bb402e8
4
- data.tar.gz: a7739fea373261118928c4c488043ca9d2547eac95d4ceeee91f004585e3b317
3
+ metadata.gz: fc0c571fae079ecc474da55dea08af61a198ca761f6f3c9b5c3b672afe3b838c
4
+ data.tar.gz: 791565182e62dab38561ae29eeb9794cf0f0087222449b4ef5842525b5e28424
5
5
  SHA512:
6
- metadata.gz: 0d984b75c02ce889706c1cf1080f61106f5e355d0626d69c502b7f29ec28ef99300ad249d0c57cd1823f46d064f62e641a2c733b60a3df6cab266fc17fef453d
7
- data.tar.gz: f70083d0f8ce612a2608e7863fe7606aecefa40e402c582596eccc68576f9cf60e546efd5f98d30c2a59a180d81a2c97e1b831ad9277ec21f54569df3ebf2325
6
+ metadata.gz: 973c44900f288a2ba876c9ee2df96e47e13668f271c3b5e1de187ddf4d935b4fef63de2dac2753cf1acc82628a4a4e3987c8e8e3ea8cd3e38a9ecd123e7b734e
7
+ data.tar.gz: e7866646881f7c217cb5b23e0adac89651e6fa47facfe9f4f33049a4e2a7362f8eb209f40dc161dbd43bdd689e643248c050be0044821efeae6abcb97c13aa08
@@ -119,6 +119,17 @@ img, svg {
119
119
  border-radius: 6px;
120
120
  }
121
121
 
122
+ .env-badge {
123
+ font-size: 10px;
124
+ font-weight: 600;
125
+ color: #fff;
126
+ padding: 1px 6px;
127
+ border-radius: 4px;
128
+ text-transform: uppercase;
129
+ letter-spacing: 0.5px;
130
+ line-height: 1.4;
131
+ }
132
+
122
133
  .site-nav__brand:hover {
123
134
  text-decoration: none;
124
135
  color: var(--color-primary);
@@ -268,6 +279,10 @@ img, svg {
268
279
  .badge--developing { background: #dbeafe; color: var(--color-status-developing); }
269
280
  .badge--live { background: #d1fae5; color: var(--color-status-live); }
270
281
  .badge--abandoned { background: #f3f4f6; color: var(--color-status-abandoned); }
282
+ .badge--pending { background: #fef3c7; color: var(--color-status-considering); }
283
+ .badge--todo { background: #dbeafe; color: var(--color-status-developing); }
284
+ .badge--discarded { background: #f3f4f6; color: var(--color-status-abandoned); }
285
+ .badge--resolved { background: #d1fae5; color: var(--color-status-live); }
271
286
 
272
287
  /* Forms */
273
288
  .form-group {
@@ -619,6 +634,24 @@ img, svg {
619
634
  background: rgba(108, 140, 255, 0.22);
620
635
  }
621
636
 
637
+ .anchor-highlight--pending {
638
+ background: rgba(245, 158, 11, 0.12);
639
+ border-bottom: 2px solid rgba(245, 158, 11, 0.6);
640
+ }
641
+
642
+ .anchor-highlight--pending:hover {
643
+ background: rgba(245, 158, 11, 0.22);
644
+ }
645
+
646
+ .anchor-highlight--todo {
647
+ background: rgba(59, 130, 246, 0.12);
648
+ border-bottom: 2px solid rgba(59, 130, 246, 0.6);
649
+ }
650
+
651
+ .anchor-highlight--todo:hover {
652
+ background: rgba(59, 130, 246, 0.22);
653
+ }
654
+
622
655
  .anchor-highlight--resolved {
623
656
  background: none;
624
657
  border-bottom: none;
@@ -1004,24 +1037,47 @@ img, svg {
1004
1037
  /* Margin dots */
1005
1038
  .margin-dot {
1006
1039
  position: absolute;
1007
- width: 12px;
1008
- height: 12px;
1040
+ width: 10px;
1041
+ height: 10px;
1009
1042
  border-radius: 50%;
1043
+ border: 2px solid transparent;
1044
+ background-clip: padding-box;
1010
1045
  cursor: pointer;
1011
1046
  left: 14px;
1012
- transition: transform 0.15s, box-shadow 0.15s;
1047
+ transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
1013
1048
  display: flex;
1014
1049
  align-items: center;
1015
1050
  justify-content: center;
1051
+ padding: 0;
1052
+ outline: none;
1053
+ }
1054
+
1055
+ .margin-dot::after {
1056
+ content: "";
1057
+ position: absolute;
1058
+ inset: -8px;
1059
+ border-radius: 50%;
1016
1060
  }
1017
1061
 
1018
1062
  .margin-dot:hover {
1019
- transform: scale(1.3);
1063
+ transform: scale(1.4);
1020
1064
  }
1021
1065
 
1022
- .margin-dot--open {
1023
- background: var(--color-primary);
1024
- box-shadow: 0 0 6px rgba(37, 99, 235, 0.3);
1066
+ .margin-dot:focus-visible {
1067
+ outline: 2px solid var(--color-primary);
1068
+ outline-offset: 2px;
1069
+ }
1070
+
1071
+ .margin-dot--pending {
1072
+ background: var(--color-status-considering);
1073
+ border-color: rgba(245, 158, 11, 0.3);
1074
+ box-shadow: 0 0 6px rgba(245, 158, 11, 0.25);
1075
+ }
1076
+
1077
+ .margin-dot--todo {
1078
+ background: var(--color-status-developing);
1079
+ border-color: rgba(59, 130, 246, 0.3);
1080
+ box-shadow: 0 0 6px rgba(59, 130, 246, 0.25);
1025
1081
  }
1026
1082
 
1027
1083
  .margin-dot--resolved {
@@ -281,6 +281,44 @@ module CoPlan
281
281
  return
282
282
  end
283
283
  end
284
+ when "replace_section"
285
+ return unless op["heading"]
286
+ include_heading = op.fetch("include_heading", true)
287
+ include_heading = include_heading != false && include_heading != "false"
288
+
289
+ transformed_ranges.each do |tr|
290
+ if include_heading
291
+ # Verify the heading is the first line of the section range
292
+ first_line_end = content.index("\n", tr[0]) || tr[1]
293
+ first_line = content[tr[0]...[first_line_end, tr[1]].min]
294
+ unless first_line&.rstrip == op["heading"]&.rstrip
295
+ render json: {
296
+ error: "Conflict: section at target position has changed",
297
+ current_revision: @plan.current_revision,
298
+ expected_heading: op["heading"],
299
+ found: content[tr[0]...tr[1]]&.slice(0, 200)
300
+ }, status: :conflict
301
+ return
302
+ end
303
+ else
304
+ # Body-only: verify the heading appears on the line before tr[0].
305
+ # Walk backwards past any blank lines to find the heading text.
306
+ search_pos = tr[0]
307
+ search_pos -= 1 while search_pos > 0 && content[search_pos - 1] == "\n"
308
+ heading_line_end = search_pos
309
+ heading_line_start = search_pos > 0 ? (content.rindex("\n", search_pos - 1) || -1) + 1 : 0
310
+ heading_text = content[heading_line_start...heading_line_end]
311
+ unless heading_text == op["heading"]
312
+ render json: {
313
+ error: "Conflict: section heading before target position has changed",
314
+ current_revision: @plan.current_revision,
315
+ expected_heading: op["heading"],
316
+ found: heading_text
317
+ }, status: :conflict
318
+ return
319
+ end
320
+ end
321
+ end
284
322
  end
285
323
  end
286
324
 
@@ -1,4 +1,48 @@
1
1
  module CoPlan
2
2
  module ApplicationHelper
3
+ FAVICON_COLORS = {
4
+ "production" => { start: "#3B82F6", stop: "#1E40AF" },
5
+ "staging" => { start: "#F59E0B", stop: "#D97706" },
6
+ "development" => { start: "#10B981", stop: "#047857" },
7
+ }.freeze
8
+
9
+ def coplan_favicon_tag
10
+ colors = FAVICON_COLORS.fetch(Rails.env, FAVICON_COLORS["development"])
11
+ svg = <<~SVG
12
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
13
+ <defs>
14
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
15
+ <stop offset="0%" stop-color="#{colors[:start]}"/>
16
+ <stop offset="100%" stop-color="#{colors[:stop]}"/>
17
+ </linearGradient>
18
+ </defs>
19
+ <rect width="100" height="100" rx="22" fill="url(#g)"/>
20
+ <g opacity=".25" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round">
21
+ <line x1="16" y1="18" x2="84" y2="18"/>
22
+ <line x1="16" y1="28" x2="84" y2="28"/>
23
+ <line x1="16" y1="38" x2="84" y2="38"/>
24
+ <line x1="16" y1="48" x2="84" y2="48"/>
25
+ <line x1="16" y1="58" x2="84" y2="58"/>
26
+ <line x1="16" y1="68" x2="84" y2="68"/>
27
+ <line x1="16" y1="78" x2="84" y2="78"/>
28
+ </g>
29
+ <circle cx="40" cy="46" r="22" fill="#fff" opacity=".55"/>
30
+ <path d="M26,62 L18,78 L36,66 Z" fill="#fff" opacity=".55"/>
31
+ <circle cx="62" cy="54" r="22" fill="#fff" opacity=".55"/>
32
+ <path d="M76,70 L84,84 L68,72 Z" fill="#fff" opacity=".55"/>
33
+ </svg>
34
+ SVG
35
+
36
+ data_uri = "data:image/svg+xml,#{ERB::Util.url_encode(svg.strip)}"
37
+ tag.link(rel: "icon", type: "image/svg+xml", href: data_uri)
38
+ end
39
+
40
+ def coplan_environment_badge
41
+ return if Rails.env.production?
42
+
43
+ label = Rails.env.capitalize
44
+ colors = FAVICON_COLORS.fetch(Rails.env, FAVICON_COLORS["development"])
45
+ tag.span(label, class: "env-badge", style: "background: #{colors[:start]};")
46
+ end
3
47
  end
4
48
  end
@@ -11,6 +11,7 @@ module CoPlan
11
11
  blockquote hr br
12
12
  dd dt dl
13
13
  sup sub
14
+ details summary
14
15
  ].freeze
15
16
 
16
17
  ALLOWED_ATTRIBUTES = %w[id class href src alt title].freeze
@@ -6,13 +6,18 @@ export default class extends Controller {
6
6
 
7
7
  connect() {
8
8
  this.currentIndex = -1
9
+ this.activeMark = null
10
+ this.activePopover = null
9
11
  this.updatePosition()
10
12
  this.handleKeydown = this.handleKeydown.bind(this)
13
+ this.handleScroll = this.handleScroll.bind(this)
11
14
  document.addEventListener("keydown", this.handleKeydown)
15
+ window.addEventListener("scroll", this.handleScroll, { passive: true })
12
16
  }
13
17
 
14
18
  disconnect() {
15
19
  document.removeEventListener("keydown", this.handleKeydown)
20
+ window.removeEventListener("scroll", this.handleScroll)
16
21
  }
17
22
 
18
23
  handleKeydown(event) {
@@ -36,6 +41,14 @@ export default class extends Controller {
36
41
  event.preventDefault()
37
42
  this.focusReply()
38
43
  break
44
+ case "a":
45
+ event.preventDefault()
46
+ this.acceptCurrent()
47
+ break
48
+ case "d":
49
+ event.preventDefault()
50
+ this.discardCurrent()
51
+ break
39
52
  }
40
53
  }
41
54
 
@@ -69,42 +82,142 @@ export default class extends Controller {
69
82
  el.classList.remove("anchor-highlight--active")
70
83
  })
71
84
 
85
+ // Close any currently open popover first — showPopover() throws
86
+ // InvalidStateError if the same popover is already open, and
87
+ // auto-dismiss only works when showing a *different* popover.
88
+ const openPopover = this.findOpenPopover()
89
+ if (openPopover) {
90
+ try { openPopover.hidePopover() } catch {}
91
+ }
92
+
72
93
  // Add active class and scroll into view
73
94
  mark.classList.add("anchor-highlight--active")
74
- mark.scrollIntoView({ behavior: "smooth", block: "center" })
95
+ mark.scrollIntoView({ behavior: "instant", block: "center" })
75
96
 
76
97
  // Open the thread popover if there's one
77
98
  const threadId = mark.dataset.threadId
78
99
  if (threadId) {
79
100
  const popover = document.getElementById(`${threadId}_popover`)
80
101
  if (popover) {
81
- const markRect = mark.getBoundingClientRect()
102
+ popover.style.visibility = "hidden"
82
103
  popover.showPopover()
83
- popover.style.top = `${markRect.top}px`
84
- popover.style.left = `${markRect.right + 12}px`
104
+ this.positionPopoverAtMark(popover, mark)
105
+ popover.style.visibility = "visible"
106
+ this.activeMark = mark
107
+ this.activePopover = popover
85
108
  }
86
109
  }
87
110
 
88
111
  this.updatePosition()
89
112
  }
90
113
 
91
- focusReply() {
92
- let openPopover
114
+ handleScroll() {
115
+ if (!this.activeMark || !this.activePopover) return
93
116
  try {
94
- openPopover = document.querySelector(".thread-popover:popover-open")
95
- } catch {
96
- // :popover-open not supported — find the visible popover manually
97
- openPopover = Array.from(document.querySelectorAll(".thread-popover[popover]"))
98
- .find(el => el.checkVisibility?.())
117
+ if (!this.activePopover.matches(":popover-open")) {
118
+ this.activeMark = null
119
+ this.activePopover = null
120
+ return
121
+ }
122
+ } catch { return }
123
+ this.positionPopoverAtMark(this.activePopover, this.activeMark)
124
+ }
125
+
126
+ positionPopoverAtMark(popover, mark) {
127
+ const markRect = mark.getBoundingClientRect()
128
+ const popoverRect = popover.getBoundingClientRect()
129
+ const viewportWidth = window.innerWidth
130
+ const viewportHeight = window.innerHeight
131
+
132
+ let top = markRect.top
133
+ let left = markRect.right + 12
134
+
135
+ if (left + popoverRect.width > viewportWidth - 16) {
136
+ left = markRect.left - popoverRect.width - 12
137
+ }
138
+ if (top + popoverRect.height > viewportHeight - 16) {
139
+ top = viewportHeight - popoverRect.height - 16
99
140
  }
100
- if (!openPopover) return
141
+ if (top < 16) top = 16
142
+ if (left < 16) left = 16
101
143
 
102
- const textarea = openPopover.querySelector(".thread-popover__reply textarea")
144
+ popover.style.top = `${top}px`
145
+ popover.style.left = `${left}px`
146
+ }
147
+
148
+ focusReply() {
149
+ const popover = this.findOpenPopover()
150
+ if (!popover) return
151
+
152
+ const textarea = popover.querySelector(".thread-popover__reply textarea")
103
153
  if (textarea) {
104
154
  textarea.focus({ preventScroll: true })
105
155
  }
106
156
  }
107
157
 
158
+ acceptCurrent() {
159
+ this.submitPopoverAction("accept")
160
+ }
161
+
162
+ discardCurrent() {
163
+ this.submitPopoverAction("discard")
164
+ }
165
+
166
+ submitPopoverAction(action) {
167
+ const popover = this.findOpenPopover()
168
+ if (!popover) return
169
+
170
+ const form = popover.querySelector(`form[data-action-name='${action}']`)
171
+ if (!form) return
172
+
173
+ // Normalize currentIndex if popover was opened via mouse (not j/k)
174
+ if (this.currentIndex < 0) {
175
+ this.currentIndex = 0
176
+ }
177
+
178
+ // For accept (pending→todo), the thread stays open so we need to
179
+ // explicitly advance. For discard, the thread leaves openHighlights
180
+ // and the current index naturally points to the next one.
181
+ const shouldAdvance = action === "accept"
182
+
183
+ // Watch for the broadcast DOM update that replaces the thread data,
184
+ // then advance to the next thread once the highlights have changed.
185
+ const threadsContainer = document.getElementById("plan-threads")
186
+ if (threadsContainer) {
187
+ const observer = new MutationObserver(() => {
188
+ observer.disconnect()
189
+ this.advanceAfterAction(shouldAdvance)
190
+ })
191
+ observer.observe(threadsContainer, { childList: true, subtree: true })
192
+ }
193
+
194
+ form.requestSubmit()
195
+ }
196
+
197
+ advanceAfterAction(shouldAdvance) {
198
+ const highlights = this.openHighlights
199
+ if (highlights.length === 0) {
200
+ this.currentIndex = -1
201
+ this.updatePosition()
202
+ return
203
+ }
204
+ if (shouldAdvance) {
205
+ this.currentIndex = (this.currentIndex + 1) % highlights.length
206
+ } else if (this.currentIndex >= highlights.length) {
207
+ this.currentIndex = 0
208
+ }
209
+ this.navigateTo(highlights[this.currentIndex])
210
+ }
211
+
212
+ findOpenPopover() {
213
+ try {
214
+ return document.querySelector(".thread-popover:popover-open")
215
+ } catch {
216
+ return Array.from(document.querySelectorAll(".thread-popover[popover]"))
217
+ .find(el => el.checkVisibility?.())
218
+ }
219
+ }
220
+
108
221
  toggleResolved() {
109
222
  const planLayout = document.querySelector(".plan-layout")
110
223
  if (!planLayout) return
@@ -6,8 +6,12 @@ export default class extends Controller {
6
6
 
7
7
  connect() {
8
8
  this.selectedText = null
9
+ this._activeMark = null
10
+ this._activePopover = null
9
11
  this.contentTarget.addEventListener("mouseup", this.handleMouseUp.bind(this))
10
12
  document.addEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
13
+ this._handleScroll = this._handleScroll.bind(this)
14
+ window.addEventListener("scroll", this._handleScroll, { passive: true })
11
15
  this.highlightAnchors()
12
16
 
13
17
  // Watch for broadcast-appended threads and re-highlight
@@ -20,6 +24,7 @@ export default class extends Controller {
20
24
  disconnect() {
21
25
  this.contentTarget.removeEventListener("mouseup", this.handleMouseUp.bind(this))
22
26
  document.removeEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
27
+ window.removeEventListener("scroll", this._handleScroll)
23
28
  if (this._threadsObserver) {
24
29
  this._threadsObserver.disconnect()
25
30
  this._threadsObserver = null
@@ -40,17 +45,33 @@ export default class extends Controller {
40
45
 
41
46
  checkSelection(event) {
42
47
  const selection = window.getSelection()
43
- const text = selection.toString().trim()
44
48
 
45
- if (text.length < 3) {
46
- this.popoverTarget.style.display = "none"
49
+ if (!selection.rangeCount) return
50
+ const range = selection.getRangeAt(0)
51
+
52
+ // Make sure at least part of the selection is within the content area.
53
+ // Whole-line selections (e.g. triple-click) can set commonAncestorContainer
54
+ // to a parent element above contentTarget, so we check start/end individually.
55
+ const startInContent = this.contentTarget.contains(range.startContainer)
56
+ const endInContent = this.contentTarget.contains(range.endContainer)
57
+ if (!startInContent) {
47
58
  return
48
59
  }
49
60
 
50
- // Make sure selection is within the content area
51
- if (!selection.rangeCount) return
52
- const range = selection.getRangeAt(0)
53
- if (!this.contentTarget.contains(range.commonAncestorContainer)) {
61
+ // Clamp the range to the last rendered markdown element, not the
62
+ // content wrapper's lastChild (which is a hidden popover/form control).
63
+ if (startInContent && !endInContent) {
64
+ const clampTarget = this.hasPopoverTarget
65
+ ? this.popoverTarget.previousElementSibling || this.popoverTarget.previousSibling
66
+ : this.contentTarget.lastChild
67
+ if (clampTarget) range.setEndAfter(clampTarget)
68
+ }
69
+
70
+ // Extract text after clamping so it only contains content-area text
71
+ const text = selection.toString().trim()
72
+
73
+ if (text.length < 3) {
74
+ this.popoverTarget.style.display = "none"
54
75
  return
55
76
  }
56
77
 
@@ -111,7 +132,10 @@ export default class extends Controller {
111
132
  if (event.detail.success) {
112
133
  const form = event.target
113
134
  const textarea = form.querySelector("textarea")
114
- if (textarea) textarea.value = ""
135
+ if (textarea) {
136
+ textarea.value = ""
137
+ textarea.blur()
138
+ }
115
139
  }
116
140
  }
117
141
 
@@ -155,40 +179,49 @@ export default class extends Controller {
155
179
  const popover = document.getElementById(`${threadId}_popover`)
156
180
  if (!popover) return
157
181
 
158
- // Position the popover near the clicked element
159
182
  const trigger = event.currentTarget
160
- const triggerRect = trigger.getBoundingClientRect()
161
183
 
162
- // Hide visually while positioning to prevent flash
163
184
  popover.style.visibility = "hidden"
164
185
  popover.showPopover()
186
+ this._positionPopoverAtMark(popover, trigger)
187
+ popover.style.visibility = "visible"
188
+
189
+ this._activeMark = trigger
190
+ this._activePopover = popover
191
+ }
165
192
 
166
- // Position after showing (popover needs to be in top layer first)
193
+ _handleScroll() {
194
+ if (!this._activeMark || !this._activePopover) return
195
+ try {
196
+ if (!this._activePopover.matches(":popover-open")) {
197
+ this._activeMark = null
198
+ this._activePopover = null
199
+ return
200
+ }
201
+ } catch { return }
202
+ this._positionPopoverAtMark(this._activePopover, this._activeMark)
203
+ }
204
+
205
+ _positionPopoverAtMark(popover, mark) {
206
+ const markRect = mark.getBoundingClientRect()
167
207
  const popoverRect = popover.getBoundingClientRect()
168
208
  const viewportWidth = window.innerWidth
169
209
  const viewportHeight = window.innerHeight
170
210
 
171
- // Default: right of the content area, aligned with the trigger
172
- let top = triggerRect.top
173
- let left = triggerRect.right + 12
211
+ let top = markRect.top
212
+ let left = markRect.right + 12
174
213
 
175
- // If it would overflow right, position to the left
176
214
  if (left + popoverRect.width > viewportWidth - 16) {
177
- left = triggerRect.left - popoverRect.width - 12
215
+ left = markRect.left - popoverRect.width - 12
178
216
  }
179
-
180
- // If it would overflow bottom, shift up
181
217
  if (top + popoverRect.height > viewportHeight - 16) {
182
218
  top = viewportHeight - popoverRect.height - 16
183
219
  }
184
-
185
- // Ensure it doesn't go outside viewport
186
220
  if (top < 16) top = 16
187
221
  if (left < 16) left = 16
188
222
 
189
223
  popover.style.top = `${top}px`
190
224
  popover.style.left = `${left}px`
191
- popover.style.visibility = "visible"
192
225
  }
193
226
 
194
227
  extractContext(range, selectedText) {
@@ -276,7 +309,9 @@ export default class extends Controller {
276
309
  if (anchor && anchor.length > 0) {
277
310
  const isOpen = status === "pending" || status === "todo"
278
311
  const statusClass = isOpen ? "anchor-highlight--open" : "anchor-highlight--resolved"
279
- const mark = this.findAndHighlight(anchor, occurrence, `anchor-highlight ${statusClass}`)
312
+ const specificClass = isOpen ? `anchor-highlight--${status}` : ""
313
+ const classes = `anchor-highlight ${statusClass} ${specificClass}`.trim()
314
+ const mark = this.findAndHighlight(anchor, occurrence, classes)
280
315
 
281
316
  if (mark && threadId) {
282
317
  // Make highlight clickable to open popover
@@ -300,11 +335,12 @@ export default class extends Controller {
300
335
 
301
336
  const dot = document.createElement("button")
302
337
  const isOpen = status === "pending" || status === "todo"
303
- dot.className = `margin-dot margin-dot--${isOpen ? "open" : "resolved"}`
338
+ const openClass = isOpen ? `margin-dot--open margin-dot--${status}` : "margin-dot--resolved"
339
+ dot.className = `margin-dot ${openClass}`
304
340
  dot.style.top = `${markRect.top - marginRect.top}px`
305
341
  dot.dataset.threadId = threadId
306
342
  dot.addEventListener("click", (e) => this.openThreadPopover(e))
307
- dot.title = `${status} comment`
343
+ dot.setAttribute("aria-label", `${status} comment`)
308
344
 
309
345
  this.marginTarget.appendChild(dot)
310
346
  }
@@ -21,6 +21,8 @@ module CoPlan
21
21
  apply_insert_under_heading(op, index)
22
22
  when "delete_paragraph_containing"
23
23
  apply_delete_paragraph_containing(op, index)
24
+ when "replace_section"
25
+ apply_replace_section(op, index)
24
26
  else
25
27
  raise OperationError, "Operation #{index}: unknown op '#{op["op"]}'"
26
28
  end
@@ -103,6 +105,47 @@ module CoPlan
103
105
  applied_data
104
106
  end
105
107
 
108
+ def apply_replace_section(op, index)
109
+ heading = op["heading"]
110
+ new_content = op["new_content"]
111
+
112
+ raise OperationError, "Operation #{index}: replace_section requires 'heading'" if heading.blank?
113
+ raise OperationError, "Operation #{index}: replace_section requires 'new_content'" if new_content.nil?
114
+
115
+ range = if op.key?("_pre_resolved_ranges")
116
+ op["_pre_resolved_ranges"][0]
117
+ else
118
+ Plans::PositionResolver.call(content: @content, operation: op).ranges[0]
119
+ end
120
+
121
+ # For body-only replacements (include_heading: false), ensure
122
+ # newlines separate the heading from new content and new content
123
+ # from the next section.
124
+ include_heading = op.fetch("include_heading", true)
125
+ include_heading = include_heading != false && include_heading != "false"
126
+ effective_content = new_content
127
+ if !include_heading && range[0] == range[1]
128
+ # Prepend newline if heading line doesn't end with one
129
+ if range[0] > 0 && @content[range[0] - 1] != "\n"
130
+ effective_content = "\n#{effective_content}"
131
+ end
132
+ # Append newline if next content starts without one
133
+ after = @content[range[1]]
134
+ if after && after != "\n" && !effective_content.end_with?("\n")
135
+ effective_content = "#{effective_content}\n"
136
+ end
137
+ end
138
+
139
+ @content = @content[0...range[0]] + effective_content + @content[range[1]..]
140
+
141
+ delta = effective_content.length - (range[1] - range[0])
142
+ applied_data = op.except("_pre_resolved_ranges")
143
+ applied_data["resolved_range"] = range
144
+ applied_data["new_range"] = [range[0], range[0] + effective_content.length]
145
+ applied_data["delta"] = delta
146
+ applied_data
147
+ end
148
+
106
149
  def apply_delete_paragraph_containing(op, index)
107
150
  needle = op["needle"]
108
151
 
@@ -65,7 +65,7 @@ module CoPlan
65
65
  rebased_ops = []
66
66
  @session.operations_json.each do |op_data|
67
67
  op_data = op_data.transform_keys(&:to_s)
68
- semantic_keys = %w[op old_text new_text heading content needle occurrence replace_all count]
68
+ semantic_keys = %w[op old_text new_text heading content needle occurrence replace_all count new_content include_heading]
69
69
  semantic_op = op_data.slice(*semantic_keys)
70
70
 
71
71
  if op_data["resolved_range"]
@@ -179,6 +179,31 @@ module CoPlan
179
179
 
180
180
  raise SessionConflictError,
181
181
  "Paragraph at target position no longer contains '#{op_data["needle"]}'"
182
+ when "replace_section"
183
+ return unless op_data["heading"]
184
+ include_heading = op_data.fetch("include_heading", true)
185
+ include_heading = include_heading != false && include_heading != "false"
186
+
187
+ if include_heading
188
+ first_line_end = content.index("\n", range[0]) || range[1]
189
+ first_line = content[range[0]...[first_line_end, range[1]].min]
190
+ return if first_line&.rstrip == op_data["heading"]&.rstrip
191
+
192
+ raise SessionConflictError,
193
+ "Section heading changed at target position. Expected '#{op_data["heading"]}' " \
194
+ "but found '#{first_line}'"
195
+ else
196
+ search_pos = range[0]
197
+ search_pos -= 1 while search_pos > 0 && content[search_pos - 1] == "\n"
198
+ heading_line_end = search_pos
199
+ heading_line_start = search_pos > 0 ? (content.rindex("\n", search_pos - 1) || -1) + 1 : 0
200
+ heading_text = content[heading_line_start...heading_line_end]
201
+ return if heading_text == op_data["heading"]
202
+
203
+ raise SessionConflictError,
204
+ "Section heading before target position changed. Expected '#{op_data["heading"]}' " \
205
+ "but found '#{heading_text}'"
206
+ end
182
207
  end
183
208
  end
184
209
  end
@@ -20,6 +20,8 @@ module CoPlan
20
20
  resolve_insert_under_heading
21
21
  when "delete_paragraph_containing"
22
22
  resolve_delete_paragraph_containing
23
+ when "replace_section"
24
+ resolve_replace_section
23
25
  else
24
26
  raise OperationError, "Unknown operation: #{@op["op"]}"
25
27
  end
@@ -153,6 +155,115 @@ module CoPlan
153
155
  paragraphs
154
156
  end
155
157
 
158
+ def resolve_replace_section
159
+ heading = @op["heading"]
160
+ raise OperationError, "replace_section requires 'heading'" if heading.blank?
161
+ raise OperationError, "replace_section requires 'new_content'" if @op["new_content"].nil?
162
+
163
+ include_heading = @op.fetch("include_heading", true)
164
+ # Normalize: accept both string and boolean
165
+ include_heading = include_heading != false && include_heading != "false"
166
+
167
+ headings = parse_headings(@content)
168
+ matches = headings.select { |h| h[:text] == heading }
169
+
170
+ if matches.empty?
171
+ raise OperationError, "replace_section: heading_not_found — no heading matching '#{heading}'"
172
+ end
173
+
174
+ if matches.length > 1
175
+ match_details = matches.map { |m| { heading: m[:text], line: m[:line] } }
176
+ raise OperationError, "replace_section: ambiguous_heading — found #{matches.length} headings matching '#{heading}': #{match_details.inspect}"
177
+ end
178
+
179
+ match = matches.first
180
+ target_level = match[:level]
181
+
182
+ # Section starts at the heading line start
183
+ section_start = match[:line_start]
184
+
185
+ # Section ends at the next heading of equal or higher level, or EOF
186
+ next_heading = headings.find { |h| h[:line_start] > match[:line_start] && h[:level] <= target_level }
187
+ section_end = next_heading ? next_heading[:line_start] : @content.length
188
+
189
+ # Strip all trailing newlines from the section range so the separator
190
+ # between sections falls outside the replaced range. This ensures
191
+ # replacement content won't merge into the next heading.
192
+ section_end = section_end.to_i
193
+ while section_end > section_start && @content[section_end - 1] == "\n"
194
+ section_end -= 1
195
+ end
196
+
197
+ range = if include_heading
198
+ [section_start, section_end]
199
+ else
200
+ # Skip past the heading line itself
201
+ heading_line_end = @content.index("\n", section_start)
202
+ if heading_line_end
203
+ body_start = heading_line_end + 1
204
+ # Skip blank line after heading
205
+ while body_start < section_end && @content[body_start] == "\n"
206
+ body_start += 1
207
+ end
208
+ # When trailing newlines are stripped, section_end can retreat
209
+ # behind body_start. Use an empty range at body_start to avoid
210
+ # an inverted range and keep the insertion point after the heading newline.
211
+ [body_start, [body_start, section_end].max]
212
+ else
213
+ # Heading is the only line — body is empty
214
+ [section_end, section_end]
215
+ end
216
+ end
217
+
218
+ Resolution.new(op: "replace_section", ranges: [range])
219
+ end
220
+
221
+ # Parse markdown headings, respecting code fences (``` blocks).
222
+ # Returns an array of hashes: { text:, level:, line:, line_start:, line_end: }
223
+ def parse_headings(content)
224
+ headings = []
225
+ in_code_fence = false
226
+ fence_char = nil
227
+ fence_length = 0
228
+ line_number = 0
229
+ pos = 0
230
+
231
+ content.each_line do |line|
232
+ line_number += 1
233
+ line_start = pos
234
+ line_end = pos + line.length
235
+ stripped = line.chomp
236
+
237
+ fence_match = stripped.match(/\A(`{3,}|~{3,})(.*)\z/)
238
+ if fence_match
239
+ fence_chars = fence_match[1]
240
+ info_string = fence_match[2]
241
+ if in_code_fence
242
+ # Close only if fence char and length match, and no info string
243
+ if fence_chars[0] == fence_char && fence_chars.length >= fence_length && info_string.strip.empty?
244
+ in_code_fence = false
245
+ end
246
+ else
247
+ in_code_fence = true
248
+ fence_char = fence_chars[0]
249
+ fence_length = fence_chars.length
250
+ end
251
+ elsif !in_code_fence && (m = stripped.match(/\A(\#{1,6})\s+(.+)/))
252
+ headings << {
253
+ level: m[1].length,
254
+ text: stripped,
255
+ line: line_number,
256
+ line_start: line_start,
257
+ line_end: line_end
258
+ }
259
+ end
260
+
261
+ pos = line_end
262
+ end
263
+
264
+ headings
265
+ end
266
+
156
267
  # Determine the character range to delete so that removing
157
268
  # content[range[0]...range[1]] produces clean output with
158
269
  # correct paragraph spacing.
@@ -1,7 +1,7 @@
1
1
  <div class="comment-thread__reply" id="<%= dom_id(thread, :reply_form) %>">
2
2
  <%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
3
3
  <div class="form-group">
4
- <textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required
4
+ <textarea name="comment[body_markdown]" rows="2" placeholder="Press r to reply" required
5
5
  data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
6
6
  </div>
7
7
  <button type="submit" class="btn btn--secondary btn--sm">Reply</button>
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div popover="auto" id="<%= dom_id(thread) %>_popover" class="thread-popover">
9
9
  <div class="thread-popover__header">
10
- <span class="badge badge--<%= thread.open? ? 'live' : 'abandoned' %>"><%= thread.status %></span>
10
+ <span class="badge badge--<%= thread.status %>"><%= thread.status %></span>
11
11
  <% if thread.out_of_date? %>
12
12
  <span class="badge badge--abandoned">out of date</span>
13
13
  <% end %>
@@ -32,7 +32,7 @@
32
32
  <div class="thread-popover__reply">
33
33
  <%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
34
34
  <div class="form-group">
35
- <textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required
35
+ <textarea name="comment[body_markdown]" rows="2" placeholder="Press r to reply" required
36
36
  data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
37
37
  </div>
38
38
  <button type="submit" class="btn btn--secondary btn--sm">Reply</button>
@@ -42,9 +42,9 @@
42
42
  <div class="thread-popover__actions">
43
43
  <% if is_plan_author %>
44
44
  <% if thread.status == "pending" %>
45
- <%= button_to "Accept", accept_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
45
+ <%= button_to "Accept (a)", accept_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm", form: { "data-action-name": "accept" } %>
46
46
  <% end %>
47
- <%= button_to "Discard", discard_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
47
+ <%= button_to "Discard (d)", discard_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm", form: { "data-action-name": "discard" } %>
48
48
  <% end %>
49
49
  </div>
50
50
  <% else %>
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <%= csrf_meta_tags %>
7
7
  <%= csp_meta_tag %>
8
+ <%= coplan_favicon_tag %>
8
9
  <%= yield :head %>
9
10
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -18,6 +19,7 @@
18
19
  <%= link_to coplan.root_path, class: "site-nav__brand" do %>
19
20
  <%= image_tag "coplan/coplan-logo-sm.png", alt: "CoPlan", class: "site-nav__logo" %>
20
21
  CoPlan
22
+ <%= coplan_environment_badge %>
21
23
  <% end %>
22
24
  <ul class="site-nav__links">
23
25
  <% if signed_in? %>
@@ -1,3 +1,3 @@
1
1
  module CoPlan
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coplan-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Block