@starlove/orbit 1.2.1 → 1.3.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.
@@ -4,7 +4,14 @@
4
4
  // related cards. Also owns the small render helpers (status history,
5
5
  // comments, detail-card) that are only used inside the drawer.
6
6
 
7
- import { state, ticketsForProject, selectableStatesForProject, boardLabelCatalog, currentBoard, upsertTicket } from "./state.js";
7
+ import {
8
+ state,
9
+ ticketsForProject,
10
+ selectableStatesForProject,
11
+ boardLabelCatalog,
12
+ currentBoard,
13
+ upsertTicket,
14
+ } from "./state.js";
8
15
  import { drawerInner, drawer, $ } from "./dom.js";
9
16
  import {
10
17
  escapeHtml,
@@ -18,7 +25,7 @@ import {
18
25
  priorityKeyFor,
19
26
  renderPreservedText,
20
27
  stateClassFor,
21
- cleanText
28
+ cleanText,
22
29
  } from "./format.js";
23
30
  import { renderTypeIcon, renderPriorityPill, renderBoard } from "./kanban.js";
24
31
  import { renderDrawerShell, openDrawer, closeDrawer } from "./drawer.js";
@@ -38,17 +45,25 @@ import { formatActorLabel, formatCommentAuthor } from "./actor-labels.js";
38
45
  * board card itself changed, also rebuild the board from the patched local
39
46
  * cache; otherwise leave the board DOM alone.
40
47
  */
41
- async function refreshTicketDetail(ticketId, { renderBoardAfter = false } = {}) {
48
+ async function refreshTicketDetail(
49
+ ticketId,
50
+ { renderBoardAfter = false } = {},
51
+ ) {
42
52
  const [context, statusHistory, commentsResult] = await Promise.all([
43
53
  api(withBoardQuery(`/api/tickets/${ticketId}/context?depth=1`)),
44
54
  api(withBoardQuery(`/api/tickets/${ticketId}/history`)),
45
- api(withBoardQuery(`/api/tickets/${ticketId}/comments`))
55
+ api(withBoardQuery(`/api/tickets/${ticketId}/comments`)),
46
56
  ]);
47
57
  if (context.ticket?.id !== ticketId) return;
48
58
  upsertTicket(context.ticket);
49
59
  if (renderBoardAfter) renderBoard();
50
- if (state.detailMode !== "ticket" || state.selectedTicketId !== ticketId) return;
51
- await renderDetail({ context, statusHistory, comments: commentsResult.comments || [] });
60
+ if (state.detailMode !== "ticket" || state.selectedTicketId !== ticketId)
61
+ return;
62
+ await renderDetail({
63
+ context,
64
+ statusHistory,
65
+ comments: commentsResult.comments || [],
66
+ });
52
67
  }
53
68
 
54
69
  /**
@@ -57,7 +72,10 @@ async function refreshTicketDetail(ticketId, { renderBoardAfter = false } = {})
57
72
  * every drawer edit.
58
73
  */
59
74
  async function patchTicket(ticketId, body) {
60
- await api(withBoardQuery(`/api/tickets/${ticketId}`), { method: "PATCH", body });
75
+ await api(withBoardQuery(`/api/tickets/${ticketId}`), {
76
+ method: "PATCH",
77
+ body,
78
+ });
61
79
  await refreshTicketDetail(ticketId, { renderBoardAfter: true });
62
80
  }
63
81
 
@@ -78,19 +96,38 @@ async function patchTicket(ticketId, body) {
78
96
  * trimming, cleanText, or PATCH wiring.
79
97
  */
80
98
  function inlineTextOffsetFromPoint(node, sourceEvent, maxLength) {
81
- if (!node || !sourceEvent || typeof sourceEvent.clientX !== "number" || typeof sourceEvent.clientY !== "number") return null;
99
+ if (
100
+ !node ||
101
+ !sourceEvent ||
102
+ typeof sourceEvent.clientX !== "number" ||
103
+ typeof sourceEvent.clientY !== "number"
104
+ )
105
+ return null;
82
106
 
83
107
  let range = null;
84
108
  if (document.caretPositionFromPoint) {
85
- const position = document.caretPositionFromPoint(sourceEvent.clientX, sourceEvent.clientY);
86
- if (position?.offsetNode && (position.offsetNode === node || node.contains(position.offsetNode))) {
109
+ const position = document.caretPositionFromPoint(
110
+ sourceEvent.clientX,
111
+ sourceEvent.clientY,
112
+ );
113
+ if (
114
+ position?.offsetNode &&
115
+ (position.offsetNode === node || node.contains(position.offsetNode))
116
+ ) {
87
117
  range = document.createRange();
88
118
  range.setStart(node, 0);
89
119
  range.setEnd(position.offsetNode, position.offset);
90
120
  }
91
121
  } else if (document.caretRangeFromPoint) {
92
- const caretRange = document.caretRangeFromPoint(sourceEvent.clientX, sourceEvent.clientY);
93
- if (caretRange?.startContainer && (caretRange.startContainer === node || node.contains(caretRange.startContainer))) {
122
+ const caretRange = document.caretRangeFromPoint(
123
+ sourceEvent.clientX,
124
+ sourceEvent.clientY,
125
+ );
126
+ if (
127
+ caretRange?.startContainer &&
128
+ (caretRange.startContainer === node ||
129
+ node.contains(caretRange.startContainer))
130
+ ) {
94
131
  range = document.createRange();
95
132
  range.setStart(node, 0);
96
133
  range.setEnd(caretRange.startContainer, caretRange.startOffset);
@@ -107,15 +144,20 @@ function captureInlineEditScrollState(node) {
107
144
  return {
108
145
  scrollContainer,
109
146
  nodeRect: node.getBoundingClientRect(),
110
- scrollTop: scrollContainer.scrollTop
147
+ scrollTop: scrollContainer.scrollTop,
111
148
  };
112
149
  }
113
150
 
114
151
  function restoreInlineEditScroll(scrollState, editor) {
115
152
  if (!scrollState?.scrollContainer || !editor?.isConnected) return;
116
153
  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;
154
+ const nextScrollTop = Math.max(
155
+ 0,
156
+ scrollState.scrollContainer.scrollTop +
157
+ (editorRect.top - scrollState.nodeRect.top),
158
+ );
159
+ if (Number.isFinite(nextScrollTop))
160
+ scrollState.scrollContainer.scrollTop = nextScrollTop;
119
161
  else scrollState.scrollContainer.scrollTop = scrollState.scrollTop;
120
162
  }
121
163
 
@@ -137,12 +179,14 @@ export function startInlineEdit(node, opts) {
137
179
  emptyMessage,
138
180
  sourceEvent,
139
181
  rerender,
140
- commit: commitHandler
182
+ commit: commitHandler,
141
183
  } = opts;
142
184
 
143
185
  const scrollState = multiline ? captureInlineEditScrollState(node) : null;
144
186
  const initialString = String(initialValue);
145
- const clickedCaretOffset = multiline ? inlineTextOffsetFromPoint(node, sourceEvent, initialString.length) : null;
187
+ const clickedCaretOffset = multiline
188
+ ? inlineTextOffsetFromPoint(node, sourceEvent, initialString.length)
189
+ : null;
146
190
 
147
191
  node.dataset.editing = "true";
148
192
 
@@ -195,7 +239,8 @@ export function startInlineEdit(node, opts) {
195
239
  };
196
240
 
197
241
  function handleOutsidePointerDown(event) {
198
- if (done || event.target === editor || editor.contains(event.target)) return;
242
+ if (done || event.target === editor || editor.contains(event.target))
243
+ return;
199
244
  // Some drawer/header surfaces are not focusable, so clicking them does not
200
245
  // reliably blur the input in every browser. Force the blur path used on
201
246
  // commit so the edit styling cannot get stuck.
@@ -257,7 +302,7 @@ function wireTicketDetailEditors(ticket) {
257
302
  ariaLabel: "Edit ticket title",
258
303
  emptyMessage: "Title cannot be empty",
259
304
  rerender: () => renderDetail(),
260
- commit: (next) => patchTicket(ticketId, { title: next })
305
+ commit: (next) => patchTicket(ticketId, { title: next }),
261
306
  });
262
307
 
263
308
  const titleEl = drawer.querySelector('[data-edit-field="title"]');
@@ -280,7 +325,7 @@ function wireTicketDetailEditors(ticket) {
280
325
  ariaLabel: "Edit ticket description",
281
326
  sourceEvent,
282
327
  rerender: () => renderDetail(),
283
- commit: (next) => patchTicket(ticketId, { description: cleanText(next) })
328
+ commit: (next) => patchTicket(ticketId, { description: cleanText(next) }),
284
329
  });
285
330
 
286
331
  if (descEl) {
@@ -304,7 +349,10 @@ function wireTicketDetailEditors(ticket) {
304
349
  const AI_FIELDS = [
305
350
  { key: "ai_plan", label: "AI plan" },
306
351
  { key: "implementation_summary", label: "Implementation summary" },
307
- { key: "implementation_updates", label: "Implementation updates / lessons" }
352
+ {
353
+ key: "implementation_updates",
354
+ label: "Implementation updates / lessons",
355
+ },
308
356
  ];
309
357
  for (const { key, label } of AI_FIELDS) {
310
358
  const fieldEl = drawerInner.querySelector(`[data-edit-field="${key}"]`);
@@ -317,7 +365,7 @@ function wireTicketDetailEditors(ticket) {
317
365
  ariaLabel: `Edit ${label}`,
318
366
  sourceEvent,
319
367
  rerender: () => renderDetail(),
320
- commit: (next) => patchTicket(ticketId, { [key]: cleanText(next) })
368
+ commit: (next) => patchTicket(ticketId, { [key]: cleanText(next) }),
321
369
  });
322
370
  fieldEl.addEventListener("click", (event) => {
323
371
  event.preventDefault();
@@ -359,7 +407,9 @@ function wireTicketDetailEditors(ticket) {
359
407
  drawerInner.querySelectorAll("[data-remove-label]").forEach((btn) => {
360
408
  btn.addEventListener("click", async () => {
361
409
  const removeName = btn.getAttribute("data-remove-label");
362
- const names = (ticket.labels || []).map((l) => l.name).filter((n) => n !== removeName);
410
+ const names = (ticket.labels || [])
411
+ .map((l) => l.name)
412
+ .filter((n) => n !== removeName);
363
413
  await applyLabelPatch(names);
364
414
  });
365
415
  });
@@ -379,15 +429,19 @@ function wireTicketDetailEditors(ticket) {
379
429
  await applyLabelPatch(names);
380
430
  };
381
431
 
382
- drawerInner.querySelector("[data-label-add]")?.addEventListener("click", () => {
383
- void addLabel();
384
- });
385
- drawerInner.querySelector("[data-label-input]")?.addEventListener("keydown", (event) => {
386
- if (event.key === "Enter") {
387
- event.preventDefault();
432
+ drawerInner
433
+ .querySelector("[data-label-add]")
434
+ ?.addEventListener("click", () => {
388
435
  void addLabel();
389
- }
390
- });
436
+ });
437
+ drawerInner
438
+ .querySelector("[data-label-input]")
439
+ ?.addEventListener("keydown", (event) => {
440
+ if (event.key === "Enter") {
441
+ event.preventDefault();
442
+ void addLabel();
443
+ }
444
+ });
391
445
  }
392
446
 
393
447
  export async function renderDetail(options = {}) {
@@ -407,27 +461,41 @@ export async function renderDetail(options = {}) {
407
461
  const [context, statusHistory, comments, attachmentList] = await Promise.all([
408
462
  hasOption("context")
409
463
  ? Promise.resolve(options.context)
410
- : api(withBoardQuery(`/api/tickets/${requestedTicketId}/context?depth=1`)),
464
+ : api(
465
+ withBoardQuery(`/api/tickets/${requestedTicketId}/context?depth=1`),
466
+ ),
411
467
  hasOption("statusHistory")
412
468
  ? Promise.resolve(options.statusHistory)
413
469
  : api(withBoardQuery(`/api/tickets/${requestedTicketId}/history`)),
414
470
  hasOption("comments")
415
471
  ? Promise.resolve(options.comments)
416
- : api(withBoardQuery(`/api/tickets/${requestedTicketId}/comments`)).then((result) => result.comments || []),
472
+ : api(withBoardQuery(`/api/tickets/${requestedTicketId}/comments`)).then(
473
+ (result) => result.comments || [],
474
+ ),
417
475
  hasOption("attachmentList")
418
476
  ? Promise.resolve(options.attachmentList)
419
477
  : features.attachments
420
- ? api(withBoardQuery(`/api/tickets/${requestedTicketId}/attachments`)).catch(() => ({ attachments: [] }))
421
- : Promise.resolve({ attachments: [] })
478
+ ? api(
479
+ withBoardQuery(`/api/tickets/${requestedTicketId}/attachments`),
480
+ ).catch(() => ({ attachments: [] }))
481
+ : Promise.resolve({ attachments: [] }),
422
482
  ]);
423
- if (state.detailMode !== "ticket" || state.selectedTicketId !== requestedTicketId) return;
483
+ if (
484
+ state.detailMode !== "ticket" ||
485
+ state.selectedTicketId !== requestedTicketId
486
+ )
487
+ return;
424
488
  if (context.ticket?.id !== requestedTicketId) return;
425
489
  const ticket = context.ticket;
426
490
  // Acknowledge the read receipt: clear the unread dot on this card.
427
491
  // Also wipe the dot from the already-rendered card in the board so the
428
492
  // user doesn't see it lingering until the next full re-render.
429
493
  markRead(ticket.id, ticket.updated_at);
430
- document.querySelector(`.card[data-ticket-id="${CSS.escape(ticket.id)}"] .card-unread-dot`)?.remove();
494
+ document
495
+ .querySelector(
496
+ `.card[data-ticket-id="${CSS.escape(ticket.id)}"] .card-unread-dot`,
497
+ )
498
+ ?.remove();
431
499
  const states = selectableStatesForProject(ticket.state_id);
432
500
 
433
501
  // Ordered by the canonical hierarchy so the dropdown visually reinforces scale:
@@ -435,21 +503,21 @@ export async function renderDetail(options = {}) {
435
503
  const typeOptions = ["epic", "feature", "task", "bug"]
436
504
  .map(
437
505
  (value) =>
438
- `<option value="${escapeHtml(value)}" ${canonicalTicketType(ticket.type) === value ? "selected" : ""}>${escapeHtml(typeLabelLong(value))}</option>`
506
+ `<option value="${escapeHtml(value)}" ${canonicalTicketType(ticket.type) === value ? "selected" : ""}>${escapeHtml(typeLabelLong(value))}</option>`,
439
507
  )
440
508
  .join("");
441
509
 
442
510
  const stateOptions = states
443
511
  .map(
444
512
  (s) =>
445
- `<option value="${escapeHtml(s.id)}" ${s.id === ticket.state_id ? "selected" : ""}>${escapeHtml(s.name)}</option>`
513
+ `<option value="${escapeHtml(s.id)}" ${s.id === ticket.state_id ? "selected" : ""}>${escapeHtml(s.name)}</option>`,
446
514
  )
447
515
  .join("");
448
516
 
449
517
  const priorityOptions = [0, 1, 2, 3, 4]
450
518
  .map(
451
519
  (p) =>
452
- `<option value="${p}" ${Number(ticket.priority) === p ? "selected" : ""}>${escapeHtml(priorityLabel(p))}</option>`
520
+ `<option value="${p}" ${Number(ticket.priority) === p ? "selected" : ""}>${escapeHtml(priorityLabel(p))}</option>`,
453
521
  )
454
522
  .join("");
455
523
 
@@ -459,7 +527,7 @@ export async function renderDetail(options = {}) {
459
527
  <span class="label-pill-removable" style="--label-color: ${escapeHtml(label.color)}">
460
528
  ${escapeHtml(label.name)}
461
529
  <button type="button" class="label-pill-remove" data-remove-label="${escapeHtml(label.name)}" title="Remove label">×</button>
462
- </span>`
530
+ </span>`,
463
531
  )
464
532
  .join("");
465
533
 
@@ -467,13 +535,17 @@ export async function renderDetail(options = {}) {
467
535
  .map((l) => l.name)
468
536
  .filter((name) => !ticket.labels?.some((tl) => tl.name === name));
469
537
 
470
- const datalistOptions = catalogNames.map((name) => `<option value="${escapeHtml(name)}"></option>`).join("");
538
+ const datalistOptions = catalogNames
539
+ .map((name) => `<option value="${escapeHtml(name)}"></option>`)
540
+ .join("");
471
541
 
472
542
  const detailCanonicalType = canonicalTicketType(ticket.type);
473
543
  const priorityKey = priorityKeyFor(ticket.priority);
474
544
  const currentState = states.find((s) => s.id === ticket.state_id);
475
545
  const stateClass = stateClassFor(currentState);
476
- const descriptionHtml = ticket.description ? renderPreservedText(ticket.description) : escapeHtml("No description yet.");
546
+ const descriptionHtml = ticket.description
547
+ ? renderPreservedText(ticket.description)
548
+ : escapeHtml("No description yet.");
477
549
  const descriptionClass = ticket.description
478
550
  ? "description preserved-text-body editable-field"
479
551
  : "description editable-field is-placeholder";
@@ -495,7 +567,7 @@ export async function renderDetail(options = {}) {
495
567
  role: "button",
496
568
  tabindex: "0",
497
569
  title: "Click or press Enter to edit ticket title",
498
- "aria-label": "Edit ticket title"
570
+ "aria-label": "Edit ticket title",
499
571
  },
500
572
  subtitleHtml: detailSubtitleHtml,
501
573
  body: `
@@ -528,7 +600,9 @@ export async function renderDetail(options = {}) {
528
600
  ${renderRelated(context.relations, ticket)}
529
601
  </div>
530
602
 
531
- ${currentBoard()?.ai_enabled !== 0 ? `
603
+ ${
604
+ currentBoard()?.ai_enabled !== 0
605
+ ? `
532
606
  <details class="section ai-fields" data-ai-plan-toggle data-ticket-id="${escapeHtml(ticket.id)}"${aiPlanOpen(ticket.id) ? " open" : ""}>
533
607
  <summary><h3>AI Plan / Implementation Record</h3></summary>
534
608
  <div class="ai-fields-grid">
@@ -536,22 +610,24 @@ export async function renderDetail(options = {}) {
536
610
  fieldName: "ai_plan",
537
611
  label: "AI-Written Plan",
538
612
  value: ticket.ai_plan,
539
- placeholder: "Paste or let an AI write the plan..."
613
+ placeholder: "Paste or let an AI write the plan...",
540
614
  })}
541
615
  ${renderInlinePreservedTextField({
542
616
  fieldName: "implementation_summary",
543
617
  label: "Implementation Summary",
544
618
  value: ticket.implementation_summary,
545
- placeholder: "What changed, what shipped, what remains..."
619
+ placeholder: "What changed, what shipped, what remains...",
546
620
  })}
547
621
  ${renderInlinePreservedTextField({
548
622
  fieldName: "implementation_updates",
549
623
  label: "Implementation Updates / Lessons",
550
624
  value: ticket.implementation_updates,
551
- placeholder: "Progress notes, mistakes to avoid, discoveries..."
625
+ placeholder: "Progress notes, mistakes to avoid, discoveries...",
552
626
  })}
553
627
  </div>
554
- </details>` : ""}
628
+ </details>`
629
+ : ""
630
+ }
555
631
 
556
632
  <div class="section">
557
633
  <h3>Status History</h3>
@@ -566,7 +642,7 @@ export async function renderDetail(options = {}) {
566
642
  <button type="submit">Comment</button>
567
643
  </form>
568
644
  </div>
569
- `
645
+ `,
570
646
  });
571
647
 
572
648
  wireTicketDetailEditors(ticket);
@@ -574,22 +650,29 @@ export async function renderDetail(options = {}) {
574
650
 
575
651
  $("#detailCommentForm").addEventListener("submit", async (event) => {
576
652
  event.preventDefault();
577
- const body = cleanText(new FormData(event.currentTarget).get("body")).trim();
653
+ const body = cleanText(
654
+ new FormData(event.currentTarget).get("body"),
655
+ ).trim();
578
656
  if (!body) return;
579
657
  await api(withBoardQuery(`/api/tickets/${ticket.id}/comments`), {
580
658
  method: "POST",
581
- body: { body }
659
+ body: { body },
582
660
  });
583
661
  await refreshTicketDetail(ticket.id);
584
662
  });
585
663
 
586
664
  drawerInner.querySelectorAll("[data-open-ticket]").forEach((card) => {
587
665
  card.addEventListener("click", async (event) => {
588
- if (event.target.closest("[data-detach-child],[data-detach-parent],[data-detach-relation]")) return;
666
+ if (
667
+ event.target.closest(
668
+ "[data-detach-child],[data-detach-parent],[data-detach-relation]",
669
+ )
670
+ )
671
+ return;
589
672
  navigate({
590
673
  boardId: state.boardId,
591
674
  view: "ticket",
592
- ticketId: card.dataset.openTicket
675
+ ticketId: card.dataset.openTicket,
593
676
  });
594
677
  });
595
678
  });
@@ -601,7 +684,7 @@ export async function renderDetail(options = {}) {
601
684
  try {
602
685
  await api(withBoardQuery(`/api/tickets/${childId}`), {
603
686
  method: "PATCH",
604
- body: { parent_ticket_id: null }
687
+ body: { parent_ticket_id: null },
605
688
  });
606
689
  } catch (err) {
607
690
  toast.error(err.message || "Failed to remove child");
@@ -618,7 +701,7 @@ export async function renderDetail(options = {}) {
618
701
  try {
619
702
  await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
620
703
  method: "PATCH",
621
- body: { parent_ticket_id: null }
704
+ body: { parent_ticket_id: null },
622
705
  });
623
706
  } catch (err) {
624
707
  toast.error(err.message || "Failed to remove parent");
@@ -634,7 +717,9 @@ export async function renderDetail(options = {}) {
634
717
  event.stopPropagation();
635
718
  const relationId = btn.dataset.detachRelation;
636
719
  try {
637
- await api(withBoardQuery(`/api/relations/${relationId}`), { method: "DELETE" });
720
+ await api(withBoardQuery(`/api/relations/${relationId}`), {
721
+ method: "DELETE",
722
+ });
638
723
  } catch (err) {
639
724
  toast.error(err.message || "Failed to remove relation");
640
725
  return;
@@ -656,9 +741,14 @@ export async function renderDetail(options = {}) {
656
741
  const parentEpicForm = drawerInner.querySelector("#parentEpicForm");
657
742
  if (parentEpicForm) {
658
743
  const parentInput = parentEpicForm.querySelector("[name=target]");
659
- const parentClear = parentEpicForm.querySelector("[data-parent-epic-clear]");
744
+ const parentClear = parentEpicForm.querySelector(
745
+ "[data-parent-epic-clear]",
746
+ );
660
747
  const syncParentClear = () => {
661
- parentEpicForm.classList.toggle("has-value", parentInput.value.length > 0);
748
+ parentEpicForm.classList.toggle(
749
+ "has-value",
750
+ parentInput.value.length > 0,
751
+ );
662
752
  };
663
753
  parentInput.addEventListener("input", syncParentClear);
664
754
  syncParentClear();
@@ -669,7 +759,9 @@ export async function renderDetail(options = {}) {
669
759
  });
670
760
  parentEpicForm.addEventListener("submit", async (event) => {
671
761
  event.preventDefault();
672
- const targetLabel = String(new FormData(event.currentTarget).get("target") || "").trim();
762
+ const targetLabel = String(
763
+ new FormData(event.currentTarget).get("target") || "",
764
+ ).trim();
673
765
  if (!targetLabel) return;
674
766
  const targetId = resolveTicketIdFromLabel(targetLabel, ticket.id);
675
767
  if (!targetId) {
@@ -684,7 +776,7 @@ export async function renderDetail(options = {}) {
684
776
  try {
685
777
  await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
686
778
  method: "PATCH",
687
- body: { parent_ticket_id: targetId }
779
+ body: { parent_ticket_id: targetId },
688
780
  });
689
781
  } catch (err) {
690
782
  toast.error(err.message || "Failed to set parent epic");
@@ -700,7 +792,10 @@ export async function renderDetail(options = {}) {
700
792
  const relatedInput = relatedAddForm.querySelector("[name=target]");
701
793
  const relatedClear = relatedAddForm.querySelector("[data-related-clear]");
702
794
  const syncRelatedClear = () => {
703
- relatedAddForm.classList.toggle("has-value", relatedInput.value.length > 0);
795
+ relatedAddForm.classList.toggle(
796
+ "has-value",
797
+ relatedInput.value.length > 0,
798
+ );
704
799
  };
705
800
  relatedInput.addEventListener("input", syncRelatedClear);
706
801
  syncRelatedClear();
@@ -723,7 +818,11 @@ export async function renderDetail(options = {}) {
723
818
  try {
724
819
  await api(withBoardQuery(`/api/relations`), {
725
820
  method: "POST",
726
- body: { source_ticket_id: ticket.id, target_ticket_id: targetId, type: relationType }
821
+ body: {
822
+ source_ticket_id: ticket.id,
823
+ target_ticket_id: targetId,
824
+ type: relationType,
825
+ },
727
826
  });
728
827
  } catch (err) {
729
828
  toast.error(err.message || "Failed to add relation");
@@ -774,7 +873,10 @@ function renderAttachmentCard(ticket, attachment) {
774
873
  </article>
775
874
  `;
776
875
  }
777
- const src = withBoardQuery(attachment.content_url || `/api/tickets/${ticket.id}/attachments/${attachment.id}/content`);
876
+ const src = withBoardQuery(
877
+ attachment.content_url ||
878
+ `/api/tickets/${ticket.id}/attachments/${attachment.id}/content`,
879
+ );
778
880
  return `
779
881
  <article class="attachment-card">
780
882
  <button type="button" class="attachment-thumb-button" data-open-lightbox="${escapeHtml(src)}" data-lightbox-title="${name}">
@@ -794,7 +896,9 @@ function formatBytes(value) {
794
896
  }
795
897
 
796
898
  function imageFilesFromList(files) {
797
- return [...(files || [])].filter((file) => file && String(file.type || "").startsWith("image/"));
899
+ return [...(files || [])].filter(
900
+ (file) => file && String(file.type || "").startsWith("image/"),
901
+ );
798
902
  }
799
903
 
800
904
  async function uploadAttachmentFiles(ticket, files) {
@@ -804,22 +908,33 @@ async function uploadAttachmentFiles(ticket, files) {
804
908
  return;
805
909
  }
806
910
  for (const file of imageFiles) {
807
- const response = await fetch(withBoardQuery(`/api/tickets/${ticket.id}/attachments?filename=${encodeURIComponent(file.name || "image")}`), {
808
- method: "POST",
809
- headers: {
810
- "Content-Type": file.type || "application/octet-stream",
811
- "X-File-Name": file.name || "image"
911
+ const response = await fetch(
912
+ withBoardQuery(
913
+ `/api/tickets/${ticket.id}/attachments?filename=${encodeURIComponent(file.name || "image")}`,
914
+ ),
915
+ {
916
+ method: "POST",
917
+ headers: {
918
+ "Content-Type": file.type || "application/octet-stream",
919
+ "X-File-Name": file.name || "image",
920
+ },
921
+ body: file,
812
922
  },
813
- body: file
814
- });
923
+ );
815
924
  if (!response.ok) {
816
925
  let payload = {};
817
- try { payload = await response.json(); } catch {}
926
+ try {
927
+ payload = await response.json();
928
+ } catch {}
818
929
  throw new Error(payload.error || `Upload failed (${response.status})`);
819
930
  }
820
931
  }
821
932
  await refreshTicketDetail(ticket.id);
822
- toast.success(imageFiles.length === 1 ? "Image attached" : `${imageFiles.length} images attached`);
933
+ toast.success(
934
+ imageFiles.length === 1
935
+ ? "Image attached"
936
+ : `${imageFiles.length} images attached`,
937
+ );
823
938
  }
824
939
 
825
940
  function wireAttachmentControls(ticket) {
@@ -840,7 +955,9 @@ function wireAttachmentControls(ticket) {
840
955
  event.preventDefault();
841
956
  dropzone.classList.add("is-dragover");
842
957
  });
843
- dropzone?.addEventListener("dragleave", () => dropzone.classList.remove("is-dragover"));
958
+ dropzone?.addEventListener("dragleave", () =>
959
+ dropzone.classList.remove("is-dragover"),
960
+ );
844
961
  dropzone?.addEventListener("drop", async (event) => {
845
962
  event.preventDefault();
846
963
  dropzone.classList.remove("is-dragover");
@@ -852,7 +969,8 @@ function wireAttachmentControls(ticket) {
852
969
  });
853
970
 
854
971
  drawerInner.onpaste = async (event) => {
855
- if (event.target.closest("input, textarea, [contenteditable='true']")) return;
972
+ if (event.target.closest("input, textarea, [contenteditable='true']"))
973
+ return;
856
974
  const files = imageFilesFromList(event.clipboardData?.files || []);
857
975
  if (!files.length) return;
858
976
  event.preventDefault();
@@ -866,7 +984,12 @@ function wireAttachmentControls(ticket) {
866
984
  section.querySelectorAll("[data-delete-attachment]").forEach((btn) => {
867
985
  btn.addEventListener("click", async () => {
868
986
  try {
869
- await api(withBoardQuery(`/api/tickets/${ticket.id}/attachments/${btn.dataset.deleteAttachment}`), { method: "DELETE" });
987
+ await api(
988
+ withBoardQuery(
989
+ `/api/tickets/${ticket.id}/attachments/${btn.dataset.deleteAttachment}`,
990
+ ),
991
+ { method: "DELETE" },
992
+ );
870
993
  await refreshTicketDetail(ticket.id);
871
994
  toast.success("Image removed");
872
995
  } catch (err) {
@@ -876,7 +999,12 @@ function wireAttachmentControls(ticket) {
876
999
  });
877
1000
 
878
1001
  section.querySelectorAll("[data-open-lightbox]").forEach((btn) => {
879
- btn.addEventListener("click", () => openAttachmentLightbox(btn.dataset.openLightbox, btn.dataset.lightboxTitle || "Image"));
1002
+ btn.addEventListener("click", () =>
1003
+ openAttachmentLightbox(
1004
+ btn.dataset.openLightbox,
1005
+ btn.dataset.lightboxTitle || "Image",
1006
+ ),
1007
+ );
880
1008
  });
881
1009
  }
882
1010
 
@@ -900,7 +1028,11 @@ function openAttachmentLightbox(src, title) {
900
1028
  if (event.key === "Escape") close();
901
1029
  }
902
1030
  overlay.addEventListener("click", (event) => {
903
- if (event.target === overlay || event.target.closest(".attachment-lightbox-close")) close();
1031
+ if (
1032
+ event.target === overlay ||
1033
+ event.target.closest(".attachment-lightbox-close")
1034
+ )
1035
+ close();
904
1036
  });
905
1037
  document.addEventListener("keydown", onKeyDown);
906
1038
  document.body.append(overlay);
@@ -932,8 +1064,8 @@ function renderStatusHistory(history) {
932
1064
  formatActorLabel({
933
1065
  actor: event.actor,
934
1066
  actorType: body.actor_type || "human",
935
- actorId: body.actor_id
936
- })
1067
+ actorId: body.actor_id,
1068
+ }),
937
1069
  );
938
1070
  return `<div class="comment status-change">
939
1071
  <div class="comment-meta">
@@ -963,10 +1095,17 @@ function renderComment(comment) {
963
1095
  * a textarea on click. The wiring (event handlers + commit) lives in
964
1096
  * wireTicketDetailEditors so this helper stays a pure template.
965
1097
  */
966
- export function renderInlinePreservedTextField({ fieldName, label, value, placeholder }) {
1098
+ export function renderInlinePreservedTextField({
1099
+ fieldName,
1100
+ label,
1101
+ value,
1102
+ placeholder,
1103
+ }) {
967
1104
  const text = value || "";
968
1105
  const hasValue = Boolean(text.trim());
969
- const inner = hasValue ? renderPreservedText(text) : escapeHtml(placeholder || "");
1106
+ const inner = hasValue
1107
+ ? renderPreservedText(text)
1108
+ : escapeHtml(placeholder || "");
970
1109
  const placeholderClass = hasValue ? "" : "is-placeholder";
971
1110
  return `
972
1111
  <div class="inline-md-field">
@@ -993,10 +1132,13 @@ function renderRelated(relations = [], ticket) {
993
1132
  }
994
1133
  }
995
1134
  const candidates = ticketsForProject().filter(
996
- (t) => t.id !== ticket?.id && !relatedIds.has(t.id) && !hierarchyIds.has(t.id)
1135
+ (t) =>
1136
+ t.id !== ticket?.id && !relatedIds.has(t.id) && !hierarchyIds.has(t.id),
997
1137
  );
998
1138
  const datalistOptions = candidates
999
- .map((t) => `<option value="#${t.number} — ${escapeHtml(t.title)}"></option>`)
1139
+ .map(
1140
+ (t) => `<option value="#${t.number} — ${escapeHtml(t.title)}"></option>`,
1141
+ )
1000
1142
  .join("");
1001
1143
 
1002
1144
  const list = relations.length
@@ -1042,9 +1184,13 @@ function renderHierarchySection(ticket, context) {
1042
1184
  function renderParentEpicSection(ticket, context) {
1043
1185
  if (canonicalTicketType(ticket.type) === "epic") return "";
1044
1186
 
1045
- const epics = ticketsForProject().filter((t) => t.type === "epic" && t.id !== ticket.id);
1187
+ const epics = ticketsForProject().filter(
1188
+ (t) => t.type === "epic" && t.id !== ticket.id,
1189
+ );
1046
1190
  const datalistOptions = epics
1047
- .map((t) => `<option value="#${t.number} — ${escapeHtml(t.title)}"></option>`)
1191
+ .map(
1192
+ (t) => `<option value="#${t.number} — ${escapeHtml(t.title)}"></option>`,
1193
+ )
1048
1194
  .join("");
1049
1195
 
1050
1196
  const body = context.parent_ticket
@@ -1104,11 +1250,12 @@ export function renderDetailCard(ticket, options = {}) {
1104
1250
  `;
1105
1251
  }
1106
1252
  const unreadN = unreadCount(ticket);
1107
- const unreadDot = unreadN === 0
1108
- ? ""
1109
- : unreadN === 1
1110
- ? `<span class="card-unread-dot" role="status" aria-label="1 unread update"></span>`
1111
- : `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
1253
+ const unreadDot =
1254
+ unreadN === 0
1255
+ ? ""
1256
+ : unreadN === 1
1257
+ ? `<span class="card-unread-dot" role="status" aria-label="1 unread update"></span>`
1258
+ : `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
1112
1259
  return `
1113
1260
  <article class="card card--detail type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}" data-variant="${escapeHtml(canonicalType)}"${options.disableOpen ? "" : ` data-open-ticket="${escapeHtml(ticket.id)}"`}>
1114
1261
  ${unreadDot}
@@ -1135,6 +1282,6 @@ function renderRelatedCard(relation) {
1135
1282
  const direction = relation.direction === "incoming" ? "from" : "to";
1136
1283
  return renderDetailCard(relation.other_ticket, {
1137
1284
  eyebrow: `${verb} ${direction}`,
1138
- detach: { mode: "relation", id: relation.id }
1285
+ detach: { mode: "relation", id: relation.id },
1139
1286
  });
1140
1287
  }