@starlove/orbit 1.1.2 → 1.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.
@@ -165,8 +165,12 @@ function renderProjectAiTab(context) {
165
165
  }
166
166
 
167
167
  function renderAgentContextSection(project) {
168
- const ph =
168
+ const instructionsPlaceholder =
169
169
  "This project is about…\n\nHow any AI agent should use this board and work in this repo.";
170
+ const instructions = project.agent_instructions || "";
171
+ const hasInstructions = Boolean(instructions.trim());
172
+ const placeholderClass = hasInstructions ? "" : "is-placeholder";
173
+ const inner = hasInstructions ? renderPreservedText(instructions) : escapeHtml(instructionsPlaceholder);
170
174
  return `
171
175
  <div class="section ai-subsection ai-context-setup">
172
176
  <div class="ai-context-title-row">
@@ -174,10 +178,16 @@ function renderAgentContextSection(project) {
174
178
  <h3>Agent Instructions</h3>
175
179
  </div>
176
180
  <p class="description">This project-level context is provided to agents when they work on tickets.</p>
177
- <form id="agentInstructionsForm" class="field-form">
178
- <textarea name="agent_instructions" placeholder="${escapeHtml(ph)}">${escapeHtml(project.agent_instructions || "")}</textarea>
179
- <button type="submit">Save</button>
180
- </form>
181
+ <div class="inline-md-field">
182
+ <div
183
+ class="inline-md-field-body preserved-text-body editable-field settings-agent-instructions-body ${placeholderClass}"
184
+ data-edit-field="agent_instructions"
185
+ tabindex="0"
186
+ title="Click to edit"
187
+ role="button"
188
+ aria-label="Edit Agent Instructions"
189
+ >${inner}</div>
190
+ </div>
181
191
  </div>
182
192
  `;
183
193
  }
@@ -232,7 +242,7 @@ function renderMcpSetupSection(context) {
232
242
  <strong data-mcp-output-title>${escapeHtml(client.label)} setup</strong>
233
243
  <p class="description" data-mcp-helper>${escapeHtml(client.helper)}</p>
234
244
  </div>
235
- <button type="button" class="ghost" data-copy-mcp="config">Copy setup</button>
245
+ <button type="button" data-variant="secondary" data-copy-mcp="config">Copy setup</button>
236
246
  </div>
237
247
  <div class="mcp-terminal">
238
248
  <div class="mcp-terminal-chrome" aria-hidden="true"><span></span><span></span><span></span></div>
@@ -248,7 +258,7 @@ function renderMcpSetupSection(context) {
248
258
  <strong>Manual run command</strong>
249
259
  <p class="description">Use this only when an app asks you to start the MCP server yourself.</p>
250
260
  </div>
251
- <button type="button" class="ghost" data-copy-mcp="command">Copy command</button>
261
+ <button type="button" data-variant="secondary" data-copy-mcp="command">Copy command</button>
252
262
  </div>
253
263
  <div class="mcp-terminal">
254
264
  <div class="mcp-terminal-chrome" aria-hidden="true"><span></span><span></span><span></span></div>
@@ -381,6 +391,7 @@ function renderProjectRepositoryTab(context) {
381
391
 
382
392
  <div class="section">
383
393
  <h3>Board Snapshot</h3>
394
+ <p class="description">Import accepts Orbit snapshots or Trello board JSON exports. The default import creates a separate board and leaves this board untouched.</p>
384
395
  ${features.attachments ? `
385
396
  <label class="orbit-check">
386
397
  <input type="checkbox" id="exportProjectImages" />
@@ -392,11 +403,22 @@ function renderProjectRepositoryTab(context) {
392
403
  <p class="description">Image exports are embedded in the .orbit.json snapshot and may be much larger.</p>
393
404
  ` : ""}
394
405
  <div class="deployment-actions">
395
- <button type="button" class="ghost" id="exportProject">Export</button>
396
- <label class="import-button">
397
- <input type="file" id="importSnapshotFile" accept=".orbit.json,.json,application/json" />
398
- <span>Import</span>
399
- </label>
406
+ <button type="button" data-variant="secondary" id="exportProject">Export</button>
407
+ <input type="file" id="importSnapshotAsNewFile" class="import-file-input" accept=".orbit.json,.json,application/json" />
408
+ <button type="button" data-variant="secondary" id="importSnapshotAsNewButton">Import as New Board</button>
409
+ </div>
410
+ </div>
411
+
412
+ <div class="section repository-danger-zone">
413
+ <h3>Replace Current Board</h3>
414
+ <p class="description">Advanced: replace this board from an Orbit snapshot or Trello export after making the normal pre-import backup. Type <strong>${escapeHtml(project.slug || "")}</strong> to enable replacement.</p>
415
+ <label class="repository-confirm-field">
416
+ <span>Board slug</span>
417
+ <input id="replaceImportConfirmInput" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(project.slug || "")}" />
418
+ </label>
419
+ <div class="deployment-actions">
420
+ <input type="file" id="replaceCurrentBoardImportFile" class="import-file-input" accept=".orbit.json,.json,application/json" disabled />
421
+ <button type="button" data-variant="secondary" id="replaceCurrentBoardImportButton" disabled>Replace Current Board</button>
400
422
  </div>
401
423
  </div>
402
424
 
@@ -414,7 +436,7 @@ function renderBoardRenameSection(project) {
414
436
  <span>Display name</span>
415
437
  <input name="name" value="${escapeHtml(project.name || "")}" maxlength="120" required />
416
438
  </label>
417
- <button type="submit">Rename Board</button>
439
+ <button type="submit" data-variant="secondary">Rename Board</button>
418
440
  </form>
419
441
  </div>
420
442
  `;
@@ -428,9 +450,9 @@ function renderDeleteBoardSection(project) {
428
450
  <h3>Delete Board</h3>
429
451
  <p class="description">Delete this board from Orbit and remove its repo-local Orbit files. Export a snapshot first if you may need this board again.</p>
430
452
  <div class="repository-delete-actions">
431
- <button type="button" class="ghost" id="deleteBoardExportFirst">Export first</button>
432
- <button type="button" class="ghost danger-button" id="deleteBoardSkipExport">Delete without exporting</button>
433
- <button type="button" class="ghost" id="deleteBoardCancel">Cancel</button>
453
+ <button type="button" data-variant="secondary" id="deleteBoardExportFirst">Export first</button>
454
+ <button type="button" data-variant="ghost" class="danger-button" id="deleteBoardSkipExport">Delete without exporting</button>
455
+ <button type="button" data-variant="ghost" id="deleteBoardCancel">Cancel</button>
434
456
  </div>
435
457
  </div>
436
458
  `;
@@ -450,8 +472,8 @@ function renderDeleteBoardSection(project) {
450
472
  <input id="deleteBoardConfirmInput" autocomplete="off" spellcheck="false" placeholder="${slug}" autofocus />
451
473
  </label>
452
474
  <div class="repository-delete-actions">
453
- <button type="button" class="danger-button" id="deleteBoardConfirm" disabled>Delete board permanently</button>
454
- <button type="button" class="ghost" id="deleteBoardCancel">Cancel</button>
475
+ <button type="button" data-variant="primary" class="danger-button" id="deleteBoardConfirm" disabled>Delete board permanently</button>
476
+ <button type="button" data-variant="ghost" id="deleteBoardCancel">Cancel</button>
455
477
  </div>
456
478
  </div>
457
479
  `;
@@ -461,7 +483,7 @@ function renderDeleteBoardSection(project) {
461
483
  <div class="section repository-danger-zone">
462
484
  <h3>Delete Board</h3>
463
485
  <p class="description">Remove this board from Orbit and delete its repo-local Orbit files. This cannot be undone without an exported snapshot.</p>
464
- <button type="button" class="ghost danger-button" id="deleteBoardStart">Delete Board</button>
486
+ <button type="button" data-variant="ghost" class="danger-button" id="deleteBoardStart">Delete Board</button>
465
487
  </div>
466
488
  `;
467
489
  }
@@ -510,7 +532,7 @@ function renderProjectLanesTab() {
510
532
  ${renderLaneManager()}
511
533
  <form id="laneCreateForm" class="lane-create-form">
512
534
  <input name="name" placeholder="New lane name..." required />
513
- <button type="submit">Add Lane</button>
535
+ <button type="submit" data-variant="primary">Add Lane</button>
514
536
  </form>
515
537
  </div>
516
538
  `;
@@ -593,12 +615,24 @@ function renderProjectJournalTab(context) {
593
615
  </select>
594
616
  <input name="title" placeholder="Entry title..." required />
595
617
  <textarea name="body" placeholder="Decision rationale or lesson: do X instead of Y when working with Z." required></textarea>
596
- <button type="submit">Add Entry</button>
618
+ <button type="submit" data-variant="primary">Add Entry</button>
597
619
  </form>
598
620
  </div>
599
621
  `;
600
622
  }
601
623
 
624
+ function parseImportSnapshotText(text) {
625
+ const trimmed = String(text || "").trimStart();
626
+ if (trimmed.startsWith("<")) {
627
+ throw new Error("That file looks like an HTML page instead of JSON. In Trello, use Export JSON/download the board JSON, not Save Page As.");
628
+ }
629
+ try {
630
+ return JSON.parse(text);
631
+ } catch (error) {
632
+ throw new Error(error?.message || "invalid JSON");
633
+ }
634
+ }
635
+
602
636
  function bindProjectTabHandlers(context, tab) {
603
637
  const project = context.board;
604
638
 
@@ -628,19 +662,76 @@ function bindProjectTabHandlers(context, tab) {
628
662
  toast.success(`Renamed board to ${nextName}. Canonical slug unchanged: ${project.slug}`);
629
663
  });
630
664
 
631
- $("#importSnapshotFile")?.addEventListener("change", async (event) => {
632
- const file = event.currentTarget.files?.[0];
633
- if (!file) return;
665
+ const importSnapshotFile = async (file, bodyForSnapshot) => {
634
666
  const text = await file.text();
635
- const snapshot = JSON.parse(text);
636
- await api("/api/admin/import", {
667
+ const snapshot = parseImportSnapshotText(text);
668
+ return api("/api/admin/import", {
637
669
  method: "POST",
638
- body: { snapshot, replace_existing: true, board_id: project.id }
670
+ body: bodyForSnapshot(snapshot)
639
671
  });
640
- await load();
641
- state.detailMode = "settings";
642
- toast.success("Board imported");
643
- event.currentTarget.value = "";
672
+ };
673
+
674
+ const importSnapshotAsNewFile = $("#importSnapshotAsNewFile");
675
+ $("#importSnapshotAsNewButton")?.addEventListener("click", () => {
676
+ importSnapshotAsNewFile?.click();
677
+ });
678
+
679
+ importSnapshotAsNewFile?.addEventListener("change", async (event) => {
680
+ const input = event.currentTarget;
681
+ const file = input.files?.[0];
682
+ if (!file) return;
683
+ try {
684
+ const result = await importSnapshotFile(file, (snapshot) => ({ snapshot, create_new: true, board_id: project.id }));
685
+ state.boardId = result.imported_board_id;
686
+ state.boardSlug = result.imported_board_slug;
687
+ state.detailMode = "empty";
688
+ await load();
689
+ await navigate({ boardId: result.imported_board_id, view: "board" });
690
+ const counts = result.imported_counts || {};
691
+ const cardCount = Number.isFinite(Number(counts.tickets)) ? ` (${counts.tickets} cards)` : "";
692
+ toast.success(result.source_format === "trello-board-export" ? `Trello board imported as new board${cardCount}` : "Board imported as new board");
693
+ } catch (err) {
694
+ toast.error(`Import failed: ${err?.message || "invalid JSON"}`);
695
+ } finally {
696
+ input.value = "";
697
+ }
698
+ });
699
+
700
+ const replaceImportConfirmInput = $("#replaceImportConfirmInput");
701
+ const replaceCurrentBoardImportFile = $("#replaceCurrentBoardImportFile");
702
+ const replaceCurrentBoardImportButton = $("#replaceCurrentBoardImportButton");
703
+ replaceImportConfirmInput?.addEventListener("input", () => {
704
+ const disabled = replaceImportConfirmInput.value.trim() !== project.slug;
705
+ if (replaceCurrentBoardImportFile) replaceCurrentBoardImportFile.disabled = disabled;
706
+ if (replaceCurrentBoardImportButton) replaceCurrentBoardImportButton.disabled = disabled;
707
+ });
708
+
709
+ replaceCurrentBoardImportButton?.addEventListener("click", () => {
710
+ if (replaceCurrentBoardImportButton.disabled || replaceCurrentBoardImportFile?.disabled) return;
711
+ replaceCurrentBoardImportFile?.click();
712
+ });
713
+
714
+ replaceCurrentBoardImportFile?.addEventListener("change", async (event) => {
715
+ const input = event.currentTarget;
716
+ const file = input.files?.[0];
717
+ if (!file) return;
718
+ if (replaceImportConfirmInput.value.trim() !== project.slug) {
719
+ toast.error("Type the board slug before replacing this board.");
720
+ input.value = "";
721
+ return;
722
+ }
723
+ try {
724
+ const result = await importSnapshotFile(file, (snapshot) => ({ snapshot, replace_existing: true, board_id: project.id }));
725
+ await load();
726
+ state.detailMode = "settings";
727
+ const counts = result.imported_counts || {};
728
+ const cardCount = Number.isFinite(Number(counts.tickets)) ? ` (${counts.tickets} cards)` : "";
729
+ toast.success(result.source_format === "trello-board-export" ? `Trello board replaced from Trello import${cardCount}` : "Board replaced from snapshot");
730
+ } catch (err) {
731
+ toast.error(`Import failed: ${err?.message || "invalid JSON"}`);
732
+ } finally {
733
+ input.value = "";
734
+ }
644
735
  });
645
736
 
646
737
  $("#deleteBoardStart")?.addEventListener("click", async () => {
@@ -817,17 +908,39 @@ function bindProjectTabHandlers(context, tab) {
817
908
  });
818
909
  });
819
910
 
820
- $("#agentInstructionsForm")?.addEventListener("submit", async (event) => {
821
- event.preventDefault();
822
- const form = new FormData(event.currentTarget);
823
- await api(`/api/boards/${project.id}`, {
824
- method: "PATCH",
825
- body: Object.fromEntries(form.entries())
911
+ const instructionsEl = drawerInner.querySelector('[data-edit-field="agent_instructions"]');
912
+ if (instructionsEl) {
913
+ const edit = () =>
914
+ startInlineEdit(instructionsEl, {
915
+ fieldName: "agent_instructions",
916
+ multiline: true,
917
+ initialValue: project.agent_instructions || "",
918
+ ariaLabel: "Edit Agent Instructions",
919
+ rerender: async () => {
920
+ state.detailMode = "settings";
921
+ await renderProjectDetail();
922
+ },
923
+ commit: async (next) => {
924
+ await api(`/api/boards/${project.id}`, {
925
+ method: "PATCH",
926
+ body: { agent_instructions: cleanText(next) }
927
+ });
928
+ await load();
929
+ state.detailMode = "settings";
930
+ toast.success("Saved");
931
+ }
932
+ });
933
+ instructionsEl.addEventListener("click", (event) => {
934
+ event.preventDefault();
935
+ edit();
826
936
  });
827
- await load();
828
- state.detailMode = "settings";
829
- toast.success("Saved");
830
- });
937
+ instructionsEl.addEventListener("keydown", (event) => {
938
+ if (event.key === "Enter" || event.key === " ") {
939
+ event.preventDefault();
940
+ edit();
941
+ }
942
+ });
943
+ }
831
944
 
832
945
  $("#aiEnabledToggle")?.addEventListener("change", async (event) => {
833
946
  const enabled = !!event.currentTarget.checked;
@@ -950,7 +1063,8 @@ function renderProjectEntries(entries = []) {
950
1063
  <span>${escapeHtml(entry.created_by)} - ${formatDate(entry.created_at)}</span>
951
1064
  <button
952
1065
  type="button"
953
- class="ghost project-entry-strike"
1066
+ data-variant="ghost"
1067
+ class="project-entry-strike"
954
1068
  data-entry-action="toggle-struck"
955
1069
  data-entry-id="${escapeHtml(entry.id)}"
956
1070
  data-struck="${isStruck ? "true" : "false"}"
@@ -1018,14 +1132,14 @@ function renderLaneManager() {
1018
1132
  : "";
1019
1133
  return `
1020
1134
  <div class="lane-row${isAiReady ? " lane-row--locked" : ""}" data-lane-id="${lane.id}">
1021
- <button type="button" class="lane-icon-btn" data-lane-action="up" ${index === 0 ? "disabled" : ""} title="Move lane left" aria-label="Move lane left">↑</button>
1022
- <button type="button" class="lane-icon-btn" data-lane-action="down" ${index === lanes.length - 1 ? "disabled" : ""} title="Move lane right" aria-label="Move lane right">↓</button>
1135
+ <button type="button" data-variant="ghost" class="settings-lane-icon-btn" data-lane-action="up" ${index === 0 ? "disabled" : ""} title="Move lane left" aria-label="Move lane left">↑</button>
1136
+ <button type="button" data-variant="ghost" class="settings-lane-icon-btn" data-lane-action="down" ${index === lanes.length - 1 ? "disabled" : ""} title="Move lane right" aria-label="Move lane right">↓</button>
1023
1137
  <input name="name" value="${escapeHtml(lane.name)}" aria-label="Lane name" ${isAiReady ? `readonly title="${escapeHtml(nameTitle)}"` : ""} />
1024
- <button type="button" class="lane-new-toggle ${lane.is_default ? "is-on" : ""}" data-lane-action="toggle-default" aria-pressed="${lane.is_default ? "true" : "false"}" title="${lane.is_default ? "Default lane for new cards — click another lane to move it" : "Make this the default lane for new cards"}" aria-label="Default lane for new cards">
1138
+ <button type="button" data-variant="${lane.is_default ? "secondary" : "ghost"}" class="settings-lane-new-toggle ${lane.is_default ? "is-on" : ""}" data-lane-action="toggle-default" aria-pressed="${lane.is_default ? "true" : "false"}" title="${lane.is_default ? "Default lane for new cards — click another lane to move it" : "Make this the default lane for new cards"}" aria-label="Default lane for new cards">
1025
1139
  <span class="lane-new-sun" aria-hidden="true"></span>
1026
1140
  <span class="lane-new-label">New</span>
1027
1141
  </button>
1028
- <button type="button" class="lane-delete-btn" data-lane-action="delete" ${deleteDisabled(lane, counts, aiEnabled) ? "disabled" : ""} title="${deleteTitle(lane, counts, aiEnabled)}" aria-label="Delete lane">×</button>
1142
+ <button type="button" data-variant="ghost" class="settings-lane-delete-btn danger-button" data-lane-action="delete" ${deleteDisabled(lane, counts, aiEnabled) ? "disabled" : ""} title="${deleteTitle(lane, counts, aiEnabled)}" aria-label="Delete lane">×</button>
1029
1143
  </div>
1030
1144
  `;
1031
1145
  })
@@ -1054,9 +1168,11 @@ async function handleLaneAction(button) {
1054
1168
  if (wasOn) return;
1055
1169
  drawerInner.querySelectorAll('[data-lane-action="toggle-default"]').forEach((btn) => {
1056
1170
  btn.setAttribute("aria-pressed", "false");
1171
+ btn.setAttribute("data-variant", "ghost");
1057
1172
  btn.classList.remove("is-on");
1058
1173
  });
1059
1174
  button.setAttribute("aria-pressed", "true");
1175
+ button.setAttribute("data-variant", "secondary");
1060
1176
  button.classList.add("is-on");
1061
1177
  await updateLane(row, { silent: true });
1062
1178
  toast.success("Default lane updated");
@@ -39,15 +39,15 @@ import { formatActorLabel, formatCommentAuthor } from "./actor-labels.js";
39
39
  * cache; otherwise leave the board DOM alone.
40
40
  */
41
41
  async function refreshTicketDetail(ticketId, { renderBoardAfter = false } = {}) {
42
- state.selectedTicketId = ticketId;
43
- state.detailMode = "ticket";
44
42
  const [context, statusHistory, commentsResult] = await Promise.all([
45
43
  api(withBoardQuery(`/api/tickets/${ticketId}/context?depth=1`)),
46
44
  api(withBoardQuery(`/api/tickets/${ticketId}/history`)),
47
45
  api(withBoardQuery(`/api/tickets/${ticketId}/comments`))
48
46
  ]);
47
+ if (context.ticket?.id !== ticketId) return;
49
48
  upsertTicket(context.ticket);
50
49
  if (renderBoardAfter) renderBoard();
50
+ if (state.detailMode !== "ticket" || state.selectedTicketId !== ticketId) return;
51
51
  await renderDetail({ context, statusHistory, comments: commentsResult.comments || [] });
52
52
  }
53
53
 
@@ -71,11 +71,62 @@ async function patchTicket(ticketId, body) {
71
71
  * - initialValue: text shown in the editor on open.
72
72
  * - ariaLabel: full label for screen readers.
73
73
  * - emptyMessage: if set, an empty trimmed value triggers a warning toast and cancels.
74
+ * - sourceEvent: optional pointer/click event used to seed the textarea caret near the clicked text.
74
75
  * - rerender: async fn called on cancel / no-change to put the static node back.
75
76
  * - commit: async fn called with the new value when the user accepts a real change.
76
77
  * Receives the *raw* editor value; the caller is responsible for any
77
78
  * trimming, cleanText, or PATCH wiring.
78
79
  */
80
+ function inlineTextOffsetFromPoint(node, sourceEvent, maxLength) {
81
+ if (!node || !sourceEvent || typeof sourceEvent.clientX !== "number" || typeof sourceEvent.clientY !== "number") return null;
82
+
83
+ let range = null;
84
+ if (document.caretPositionFromPoint) {
85
+ const position = document.caretPositionFromPoint(sourceEvent.clientX, sourceEvent.clientY);
86
+ if (position?.offsetNode && (position.offsetNode === node || node.contains(position.offsetNode))) {
87
+ range = document.createRange();
88
+ range.setStart(node, 0);
89
+ range.setEnd(position.offsetNode, position.offset);
90
+ }
91
+ } else if (document.caretRangeFromPoint) {
92
+ const caretRange = document.caretRangeFromPoint(sourceEvent.clientX, sourceEvent.clientY);
93
+ if (caretRange?.startContainer && (caretRange.startContainer === node || node.contains(caretRange.startContainer))) {
94
+ range = document.createRange();
95
+ range.setStart(node, 0);
96
+ range.setEnd(caretRange.startContainer, caretRange.startOffset);
97
+ }
98
+ }
99
+
100
+ if (!range) return null;
101
+ return Math.max(0, Math.min(maxLength, range.toString().length));
102
+ }
103
+
104
+ function captureInlineEditScrollState(node) {
105
+ const scrollContainer = node?.closest?.(".drawer-inner");
106
+ if (!scrollContainer) return null;
107
+ return {
108
+ scrollContainer,
109
+ nodeRect: node.getBoundingClientRect(),
110
+ scrollTop: scrollContainer.scrollTop
111
+ };
112
+ }
113
+
114
+ function restoreInlineEditScroll(scrollState, editor) {
115
+ if (!scrollState?.scrollContainer || !editor?.isConnected) return;
116
+ const editorRect = editor.getBoundingClientRect();
117
+ const nextScrollTop = Math.max(0, scrollState.scrollContainer.scrollTop + (editorRect.top - scrollState.nodeRect.top));
118
+ if (Number.isFinite(nextScrollTop)) scrollState.scrollContainer.scrollTop = nextScrollTop;
119
+ else scrollState.scrollContainer.scrollTop = scrollState.scrollTop;
120
+ }
121
+
122
+ function focusWithoutScrolling(editor) {
123
+ try {
124
+ editor.focus({ preventScroll: true });
125
+ } catch {
126
+ editor.focus();
127
+ }
128
+ }
129
+
79
130
  export function startInlineEdit(node, opts) {
80
131
  if (!node || node.dataset.editing === "true") return;
81
132
  const {
@@ -84,15 +135,20 @@ export function startInlineEdit(node, opts) {
84
135
  initialValue = "",
85
136
  ariaLabel,
86
137
  emptyMessage,
138
+ sourceEvent,
87
139
  rerender,
88
140
  commit: commitHandler
89
141
  } = opts;
90
142
 
143
+ const scrollState = multiline ? captureInlineEditScrollState(node) : null;
144
+ const initialString = String(initialValue);
145
+ const clickedCaretOffset = multiline ? inlineTextOffsetFromPoint(node, sourceEvent, initialString.length) : null;
146
+
91
147
  node.dataset.editing = "true";
92
148
 
93
149
  const editor = document.createElement(multiline ? "textarea" : "input");
94
150
  editor.className = multiline ? "inline-desc-editor" : "inline-title-editor";
95
- editor.value = initialValue;
151
+ editor.value = initialString;
96
152
  if (fieldName) editor.setAttribute("data-editing-field", fieldName);
97
153
  if (ariaLabel) editor.setAttribute("aria-label", ariaLabel);
98
154
  if (!multiline) {
@@ -101,13 +157,22 @@ export function startInlineEdit(node, opts) {
101
157
  editor.autocomplete = "off";
102
158
  editor.spellcheck = true;
103
159
  } else {
104
- editor.rows = Math.max(3, String(initialValue).split("\n").length + 1);
160
+ editor.rows = Math.max(3, initialString.split("\n").length + 1);
161
+ const sourceHeight = node.getBoundingClientRect().height;
162
+ if (Number.isFinite(sourceHeight) && sourceHeight > 0) {
163
+ editor.style.minHeight = `${Math.ceil(sourceHeight)}px`;
164
+ }
105
165
  }
106
166
 
107
167
  node.replaceWith(editor);
108
- editor.focus();
109
- if (!multiline) editor.select();
110
- else editor.setSelectionRange(editor.value.length, editor.value.length);
168
+ focusWithoutScrolling(editor);
169
+ if (!multiline) {
170
+ editor.select();
171
+ } else {
172
+ const selectionStart = clickedCaretOffset ?? 0;
173
+ editor.setSelectionRange(selectionStart, selectionStart);
174
+ restoreInlineEditScroll(scrollState, editor);
175
+ }
111
176
 
112
177
  let done = false;
113
178
 
@@ -115,11 +180,9 @@ export function startInlineEdit(node, opts) {
115
180
  document.removeEventListener("pointerdown", handleOutsidePointerDown, true);
116
181
  };
117
182
 
118
- // The title row uses a persistent shell element (#drawerTitle) that
119
- // renderDrawerShell re-queries by id on every render. Leaving the editor in
120
- // its place means the next render finds nothing, throws, and the input is
121
- // stranded on screen until a full reload. Put the original node back before
122
- // any downstream render runs.
183
+ // If a downstream render rebuilds the title block while the editor is open,
184
+ // the editor is dropped along with everything else; only restore the saved
185
+ // node when our editor is still connected to the DOM.
123
186
  const restoreNode = () => {
124
187
  if (editor.isConnected) editor.replaceWith(node);
125
188
  delete node.dataset.editing;
@@ -209,12 +272,13 @@ function wireTicketDetailEditors(ticket) {
209
272
  }
210
273
 
211
274
  const descEl = drawerInner.querySelector('[data-edit-field="description"]');
212
- const editDescription = () =>
275
+ const editDescription = (sourceEvent) =>
213
276
  startInlineEdit(descEl, {
214
277
  fieldName: "description",
215
278
  multiline: true,
216
279
  initialValue: ticket.description || "",
217
280
  ariaLabel: "Edit ticket description",
281
+ sourceEvent,
218
282
  rerender: () => renderDetail(),
219
283
  commit: (next) => patchTicket(ticketId, { description: cleanText(next) })
220
284
  });
@@ -222,7 +286,7 @@ function wireTicketDetailEditors(ticket) {
222
286
  if (descEl) {
223
287
  descEl.addEventListener("click", (event) => {
224
288
  event.preventDefault();
225
- editDescription();
289
+ editDescription(event);
226
290
  });
227
291
  descEl.addEventListener("keydown", (event) => {
228
292
  if (event.key === "Enter" || event.key === " ") {
@@ -245,18 +309,19 @@ function wireTicketDetailEditors(ticket) {
245
309
  for (const { key, label } of AI_FIELDS) {
246
310
  const fieldEl = drawerInner.querySelector(`[data-edit-field="${key}"]`);
247
311
  if (!fieldEl) continue;
248
- const edit = () =>
312
+ const edit = (sourceEvent) =>
249
313
  startInlineEdit(fieldEl, {
250
314
  fieldName: key,
251
315
  multiline: true,
252
316
  initialValue: ticket[key] || "",
253
317
  ariaLabel: `Edit ${label}`,
318
+ sourceEvent,
254
319
  rerender: () => renderDetail(),
255
320
  commit: (next) => patchTicket(ticketId, { [key]: cleanText(next) })
256
321
  });
257
322
  fieldEl.addEventListener("click", (event) => {
258
323
  event.preventDefault();
259
- edit();
324
+ edit(event);
260
325
  });
261
326
  fieldEl.addEventListener("keydown", (event) => {
262
327
  if (event.key === "Enter" || event.key === " ") {
@@ -337,23 +402,26 @@ export async function renderDetail(options = {}) {
337
402
  return;
338
403
  }
339
404
 
405
+ const requestedTicketId = state.selectedTicketId;
340
406
  const hasOption = (key) => Object.prototype.hasOwnProperty.call(options, key);
341
407
  const [context, statusHistory, comments, attachmentList] = await Promise.all([
342
408
  hasOption("context")
343
409
  ? Promise.resolve(options.context)
344
- : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/context?depth=1`)),
410
+ : api(withBoardQuery(`/api/tickets/${requestedTicketId}/context?depth=1`)),
345
411
  hasOption("statusHistory")
346
412
  ? Promise.resolve(options.statusHistory)
347
- : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/history`)),
413
+ : api(withBoardQuery(`/api/tickets/${requestedTicketId}/history`)),
348
414
  hasOption("comments")
349
415
  ? Promise.resolve(options.comments)
350
- : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/comments`)).then((result) => result.comments || []),
416
+ : api(withBoardQuery(`/api/tickets/${requestedTicketId}/comments`)).then((result) => result.comments || []),
351
417
  hasOption("attachmentList")
352
418
  ? Promise.resolve(options.attachmentList)
353
419
  : features.attachments
354
- ? api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/attachments`)).catch(() => ({ attachments: [] }))
420
+ ? api(withBoardQuery(`/api/tickets/${requestedTicketId}/attachments`)).catch(() => ({ attachments: [] }))
355
421
  : Promise.resolve({ attachments: [] })
356
422
  ]);
423
+ if (state.detailMode !== "ticket" || state.selectedTicketId !== requestedTicketId) return;
424
+ if (context.ticket?.id !== requestedTicketId) return;
357
425
  const ticket = context.ticket;
358
426
  // Acknowledge the read receipt: clear the unread dot on this card.
359
427
  // Also wipe the dot from the already-rendered card in the board so the
@@ -412,9 +480,9 @@ export async function renderDetail(options = {}) {
412
480
  const detailSubtitleHtml = `
413
481
  <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
414
482
  <div class="detail-meta-badge-row" aria-label="Ticket metadata controls">
415
- <select class="detail-meta-badge detail-state-badge state-pill-${escapeHtml(stateClass)} meta-select" data-meta-field="state_id" aria-label="Ticket state">${stateOptions}</select>
483
+ <select class="detail-meta-badge detail-state-badge meta-select" data-variant="${escapeHtml(stateClass)}" data-meta-field="state_id" aria-label="Ticket state">${stateOptions}</select>
416
484
  <select class="detail-meta-badge detail-type-badge type-pill-${escapeHtml(detailCanonicalType)} meta-select" data-meta-field="type" aria-label="Ticket type">${typeOptions}</select>
417
- <select class="detail-meta-badge detail-priority-badge priority-pill priority-pill-${escapeHtml(priorityKey)} meta-select" data-meta-field="priority" aria-label="Ticket priority">${priorityOptions}</select>
485
+ <select class="detail-meta-badge detail-priority-badge priority-pill meta-select" data-variant="${escapeHtml(priorityKey)}" data-meta-field="priority" aria-label="Ticket priority">${priorityOptions}</select>
418
486
  </div>
419
487
  `;
420
488
 
@@ -1042,7 +1110,7 @@ export function renderDetailCard(ticket, options = {}) {
1042
1110
  ? `<span class="card-unread-dot" role="status" aria-label="1 unread update"></span>`
1043
1111
  : `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
1044
1112
  return `
1045
- <article class="card card--detail type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}"${options.disableOpen ? "" : ` data-open-ticket="${escapeHtml(ticket.id)}"`}>
1113
+ <article class="card card--detail type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}" data-variant="${escapeHtml(canonicalType)}"${options.disableOpen ? "" : ` data-open-ticket="${escapeHtml(ticket.id)}"`}>
1046
1114
  ${unreadDot}
1047
1115
  ${detachBtn}
1048
1116
  ${eyebrow}
@@ -1054,7 +1122,7 @@ export function renderDetailCard(ticket, options = {}) {
1054
1122
  ${renderTypeIcon(ticket.type)}
1055
1123
  <span class="card-type-label">${escapeHtml(typeText)}</span>
1056
1124
  </div>
1057
- <span class="detail-card-state state-pill-${escapeHtml(stateClass)}">${escapeHtml(ticket.state_name || "State")}</span>
1125
+ <span class="detail-card-state" data-variant="${escapeHtml(stateClass)}">${escapeHtml(ticket.state_name || "State")}</span>
1058
1126
  ${state.showPriority ? renderPriorityPill(ticket.priority) : ""}
1059
1127
  </div>
1060
1128
  </div>