@shardworks/clerk-apparatus 0.1.164 → 0.1.165

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardworks/clerk-apparatus",
3
- "version": "0.1.164",
3
+ "version": "0.1.165",
4
4
  "license": "ISC",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,9 +17,9 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "zod": "4.3.6",
20
- "@shardworks/nexus-core": "0.1.164",
21
- "@shardworks/stacks-apparatus": "0.1.164",
22
- "@shardworks/tools-apparatus": "0.1.164"
20
+ "@shardworks/nexus-core": "0.1.165",
21
+ "@shardworks/stacks-apparatus": "0.1.165",
22
+ "@shardworks/tools-apparatus": "0.1.165"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "25.5.0"
@@ -25,7 +25,7 @@
25
25
  .confirm-section input { padding: 0.3rem 0.5rem; background: var(--bg, #1a1b26); border: 1px solid var(--border, #414868); border-radius: 4px; color: inherit; min-width: 220px; }
26
26
  tr.writ-row { cursor: pointer; }
27
27
  tr.writ-row:hover { opacity: 0.85; }
28
- tr.writ-row.expanded { background: var(--bg2, #1f2335); }
28
+ tr.child-row { opacity: 0.85; }
29
29
  #post-section { margin-bottom: 16px; display: none; }
30
30
  #post-section.open { display: block; }
31
31
  #post-section h3 { margin: 0 0 0.75rem; }
@@ -47,6 +47,8 @@
47
47
  <body>
48
48
  <main style="padding: 24px;">
49
49
  <h1>Writs</h1>
50
+
51
+ <div id="writ-list-view">
50
52
  <div class="card" style="margin-bottom: 16px;">
51
53
  <div class="toolbar" id="toolbar">
52
54
  <button class="btn btn--primary" id="btn-new-writ">New Writ</button>
@@ -57,6 +59,7 @@
57
59
  <button class="btn filter-btn" data-status="new">new</button>
58
60
  <button class="btn filter-btn" data-status="ready">ready</button>
59
61
  <button class="btn filter-btn" data-status="active">active</button>
62
+ <button class="btn filter-btn" data-status="waiting">waiting</button>
60
63
  <button class="btn filter-btn" data-status="completed">completed</button>
61
64
  <button class="btn filter-btn" data-status="failed">failed</button>
62
65
  <button class="btn filter-btn" data-status="cancelled">cancelled</button>
@@ -66,6 +69,7 @@
66
69
  <button class="btn type-filter-btn active-filter" data-type="">All</button>
67
70
  </span>
68
71
  <input type="text" id="search-input" placeholder="Search title...">
72
+ <button class="btn active-filter" id="btn-toggle-children">Children</button>
69
73
  </div>
70
74
  </div>
71
75
 
@@ -117,6 +121,13 @@
117
121
  <div id="load-more-row" style="display:none; text-align: center; margin-top: 8px;">
118
122
  <button class="btn" id="load-more-btn">Load more</button>
119
123
  </div>
124
+ </div>
125
+
126
+ <div id="writ-detail-view" style="display:none">
127
+ <button id="back-btn" class="btn">&#8592; Back to list</button>
128
+ <h2 id="detail-title"></h2>
129
+ <div id="detail-content"></div>
130
+ </div>
120
131
 
121
132
  <datalist id="link-types">
122
133
  <option value="retries"></option>
@@ -131,14 +142,15 @@
131
142
  <script>
132
143
  (() => {
133
144
  // ── State ──────────────────────────────────────────────────────────
134
- let writs = []; // currently loaded writs array
145
+ let writs = []; // root writs only (no parentId)
135
146
  let offset = 0;
136
147
  let currentStatus = ''; // '' = all
137
148
  let currentType = ''; // '' = all
138
149
  let searchText = '';
139
150
  let sortCol = 'createdAt';
140
151
  let sortDir = 'desc';
141
- let expandedId = null; // currently expanded writ id
152
+ let showChildren = true; // toggle state children shown by default
153
+ let childrenMap = {}; // { parentId: WritDoc[] } — fetched children keyed by parent id
142
154
  let repostSourceId = null;
143
155
 
144
156
  const LIMIT = 20;
@@ -159,6 +171,7 @@
159
171
  new: 'badge badge--draft',
160
172
  ready: 'badge',
161
173
  active: 'badge badge--active',
174
+ waiting: 'badge badge--warning',
162
175
  completed: 'badge badge--success',
163
176
  failed: 'badge badge--error',
164
177
  cancelled: 'badge badge--warning',
@@ -181,18 +194,37 @@
181
194
  }
182
195
 
183
196
  function sortedFilteredWrits() {
184
- let arr = writs.slice();
185
- // Text filter
197
+ let roots = writs.slice();
198
+
199
+ // Text filter on roots
186
200
  if (searchText) {
187
201
  const q = searchText.toLowerCase();
188
- arr = arr.filter(w => (w.title ?? '').toLowerCase().includes(q));
202
+ roots = roots.filter(w => (w.title ?? '').toLowerCase().includes(q));
189
203
  }
190
- // Sort
191
- arr.sort((a, b) => {
204
+
205
+ // Sort roots by current sort column
206
+ roots.sort((a, b) => {
192
207
  const cmp = compareVal(a, b, sortCol);
193
208
  return sortDir === 'asc' ? cmp : -cmp;
194
209
  });
195
- return arr;
210
+
211
+ // Interleave children beneath each root
212
+ const result = [];
213
+ for (const root of roots) {
214
+ result.push({ writ: root, isChild: false });
215
+ if (showChildren) {
216
+ let children = childrenMap[root.id] ?? [];
217
+ // Text filter on children too
218
+ if (searchText) {
219
+ const q = searchText.toLowerCase();
220
+ children = children.filter(w => (w.title ?? '').toLowerCase().includes(q));
221
+ }
222
+ for (const child of children) {
223
+ result.push({ writ: child, isChild: true });
224
+ }
225
+ }
226
+ }
227
+ return result;
196
228
  }
197
229
 
198
230
  function showError(el, msg) {
@@ -236,8 +268,10 @@
236
268
  if (idx >= 0) writs[idx] = writ;
237
269
  updateRowStatus(id);
238
270
  updateRowActions(id);
239
- // Refresh expanded detail if open
240
- if (expandedId === id) await refreshDetail(id);
271
+ // Refresh detail view if open
272
+ if (document.getElementById('writ-detail-view').style.display !== 'none') {
273
+ await refreshDetail(id);
274
+ }
241
275
  } catch (e) {
242
276
  alert(`Action failed: ${e.message}`);
243
277
  btn.disabled = false;
@@ -273,21 +307,19 @@
273
307
  return;
274
308
  }
275
309
 
276
- // Preserve expanded state if row still visible
277
310
  tbody.innerHTML = '';
278
- for (const w of visible) {
311
+ for (const { writ: w, isChild } of visible) {
279
312
  const tr = document.createElement('tr');
280
- tr.className = 'writ-row' + (expandedId === w.id ? ' expanded' : '');
313
+ tr.className = 'writ-row' + (isChild ? ' child-row' : '');
281
314
  tr.dataset.id = w.id;
282
- tr.style.display = '';
283
- tr.innerHTML = `
284
- <td>${statusBadge(w.status)}</td>
285
- <td>${escHtml(w.title ?? '')}</td>
286
- <td>${w.type ?? ''}</td>
287
- <td><code>${w.id}</code></td>
288
- <td>${fmtDate(w.createdAt)}</td>
289
- <td class="row-actions" style="white-space:nowrap">${rowActions(w)}</td>
290
- `;
315
+ tr.innerHTML =
316
+ '<td>' + statusBadge(w.status) + '</td>' +
317
+ '<td' + (isChild ? ' style="padding-left:2rem"' : '') + '>' + escHtml(w.title ?? '') + '</td>' +
318
+ '<td>' + (w.type ?? '') + '</td>' +
319
+ '<td><code>' + w.id + '</code></td>' +
320
+ '<td>' + (isChild ? '' : fmtDate(w.createdAt)) + '</td>' +
321
+ '<td class="row-actions" style="white-space:nowrap">' + rowActions(w) + '</td>';
322
+
291
323
  // Wire row-action buttons (stop row-click propagation)
292
324
  tr.querySelectorAll('.row-action-btn').forEach(btn => {
293
325
  btn.addEventListener('click', e => {
@@ -295,13 +327,9 @@
295
327
  handleRowAction(btn.dataset.action, btn.dataset.id, btn);
296
328
  });
297
329
  });
298
- tr.addEventListener('click', () => toggleExpand(w.id, tr));
330
+ // Click row -> show detail view
331
+ tr.addEventListener('click', () => showWritDetail(w.id));
299
332
  tbody.appendChild(tr);
300
-
301
- if (expandedId === w.id) {
302
- const detailTr = buildDetailRow(w);
303
- tbody.appendChild(detailTr);
304
- }
305
333
  }
306
334
 
307
335
  updateSortIndicators();
@@ -324,21 +352,6 @@
324
352
 
325
353
  // ── Detail row ─────────────────────────────────────────────────────
326
354
 
327
- function buildDetailRow(writ) {
328
- const tr = document.createElement('tr');
329
- tr.className = 'detail-row';
330
- tr.dataset.detailFor = writ.id;
331
-
332
- const td = document.createElement('td');
333
- td.colSpan = 6;
334
- td.innerHTML = renderDetail(writ);
335
-
336
- // Wire up events after inserting
337
- tr.appendChild(td);
338
- requestAnimationFrame(() => wireDetailEvents(tr, writ));
339
- return tr;
340
- }
341
-
342
355
  function renderDetail(writ) {
343
356
  const isTerminal = ['completed', 'failed', 'cancelled'].includes(writ.status);
344
357
  const isDraft = writ.status === 'new';
@@ -366,6 +379,9 @@
366
379
  // Timestamps + metadata
367
380
  html += `<div class="detail-section"><h4>Details</h4><dl class="detail-grid">`;
368
381
  if (writ.codex) html += `<dt>Codex</dt><dd>${escHtml(writ.codex)}</dd>`;
382
+ if (writ.parent) {
383
+ html += `<dt>Parent</dt><dd><a href="?writ=${encodeURIComponent(writ.parent.id)}" style="color:var(--blue,#7aa2f7);text-decoration:underline;cursor:pointer">${escHtml(writ.parent.title)}</a> ${statusBadge(writ.parent.status)}</dd>`;
384
+ }
369
385
  html += `<dt>Created</dt><dd>${fmtDate(writ.createdAt)}</dd>`;
370
386
  html += `<dt>Updated</dt><dd>${fmtDate(writ.updatedAt)}</dd>`;
371
387
  if (writ.acceptedAt) html += `<dt>Accepted</dt><dd>${fmtDate(writ.acceptedAt)}</dd>`;
@@ -402,6 +418,37 @@
402
418
  html += renderLinksSection(writ);
403
419
  html += `</div>`;
404
420
 
421
+ // Children
422
+ const childItems = writ._fullChildren ?? writ.children?.items ?? [];
423
+ if (childItems.length > 0) {
424
+ html += `<div class="detail-section">`;
425
+ html += `<h4>Children</h4>`;
426
+
427
+ // Summary badges
428
+ if (writ.children?.summary) {
429
+ html += `<div style="margin-bottom:0.5rem">`;
430
+ for (const [status, count] of Object.entries(writ.children.summary)) {
431
+ html += statusBadge(status) + ` <span style="margin-right:0.75rem">${count}</span>`;
432
+ }
433
+ html += `</div>`;
434
+ }
435
+
436
+ // Children table: Status, Title, Type, ID, Actions
437
+ html += `<table class="data-table"><thead><tr>`;
438
+ html += `<th>Status</th><th>Title</th><th>Type</th><th>ID</th><th>Actions</th>`;
439
+ html += `</tr></thead><tbody>`;
440
+ for (const child of childItems) {
441
+ html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" style="cursor:pointer">`;
442
+ html += `<td>${statusBadge(child.status)}</td>`;
443
+ html += `<td>${escHtml(child.title ?? '')}</td>`;
444
+ html += `<td>${escHtml(child.type ?? '')}</td>`;
445
+ html += `<td><code>${child.id}</code></td>`;
446
+ html += `<td class="row-actions" style="white-space:nowrap">${rowActions(child)}</td>`;
447
+ html += `</tr>`;
448
+ }
449
+ html += `</tbody></table></div>`;
450
+ }
451
+
405
452
  return html;
406
453
  }
407
454
 
@@ -444,31 +491,44 @@
444
491
  return html;
445
492
  }
446
493
 
447
- function wireDetailEvents(tr, writ) {
448
- const td = tr.querySelector('td');
449
- if (!td) return;
450
-
494
+ function wireDetailEvents(container, writ) {
451
495
  // Populate edit dropdowns for draft writs
452
496
  if (writ.status === 'new') {
453
497
  populateEditDropdowns(writ);
454
498
  }
455
499
 
456
- // Transition action buttons
457
- td.querySelectorAll('[data-action]').forEach(btn => {
500
+ // Transition action buttons + other data-action buttons
501
+ container.querySelectorAll('[data-action]').forEach(btn => {
458
502
  btn.addEventListener('click', e => {
459
503
  e.stopPropagation();
460
504
  const action = btn.dataset.action;
461
505
  const id = btn.dataset.id;
462
- handleDetailAction(action, id, btn, td, writ);
506
+ handleDetailAction(action, id, btn, container, writ);
463
507
  });
464
508
  });
465
509
 
466
- // Link-id clicks
467
- td.querySelectorAll('.link-id').forEach(a => {
510
+ // Link-id clicks (links section)
511
+ container.querySelectorAll('.link-id[data-writ-id]').forEach(a => {
468
512
  a.addEventListener('click', e => {
469
513
  e.stopPropagation();
470
- const targetId = a.dataset.writId;
471
- scrollAndExpand(targetId);
514
+ e.preventDefault();
515
+ showWritDetail(a.dataset.writId);
516
+ });
517
+ });
518
+
519
+ // Child row clicks — navigate to child detail
520
+ container.querySelectorAll('.child-detail-row').forEach(row => {
521
+ row.addEventListener('click', (e) => {
522
+ if (e.target.closest('.row-action-btn')) return;
523
+ showWritDetail(row.dataset.childId);
524
+ });
525
+ });
526
+
527
+ // Wire row-action buttons inside children table
528
+ container.querySelectorAll('.child-detail-row .row-action-btn').forEach(btn => {
529
+ btn.addEventListener('click', e => {
530
+ e.stopPropagation();
531
+ handleRowAction(btn.dataset.action, btn.dataset.id, btn);
472
532
  });
473
533
  });
474
534
  }
@@ -729,13 +789,26 @@
729
789
  const idx = writs.findIndex(w => w.id === id);
730
790
  if (idx >= 0) writs[idx] = writ;
731
791
 
732
- // Re-render detail row
733
- const detailTr = document.querySelector(`tr[data-detail-for="${id}"]`);
734
- if (detailTr) {
735
- const td = detailTr.querySelector('td');
736
- td.innerHTML = renderDetail(writ);
737
- wireDetailEvents(detailTr, writ);
792
+ // Fetch full children if needed
793
+ if (writ.children && writ.children.items.length > 0) {
794
+ try {
795
+ const fullChildren = await api('GET',
796
+ '/api/writ/list?parentId=' + encodeURIComponent(id) + '&limit=1000');
797
+ fullChildren.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
798
+ writ._fullChildren = fullChildren;
799
+ } catch (e) { writ._fullChildren = null; }
800
+ }
801
+
802
+ // Re-render detail content
803
+ const content = document.getElementById('detail-content');
804
+ if (content) {
805
+ content.innerHTML = renderDetail(writ);
806
+ wireDetailEvents(content, writ);
738
807
  }
808
+
809
+ // Also refresh the list view row
810
+ updateRowStatus(id);
811
+ updateRowActions(id);
739
812
  }
740
813
 
741
814
  function updateRowStatus(id) {
@@ -748,63 +821,41 @@
748
821
  if (cells[0]) cells[0].innerHTML = statusBadge(writ.status);
749
822
  }
750
823
 
751
- // ── Expand/Collapse ────────────────────────────────────────────────
824
+ // ── Detail view ──────────────────────────────────────────────────
752
825
 
753
- async function toggleExpand(id, tr) {
754
- const tbody = document.getElementById('writ-tbody');
755
- const existing = document.querySelector(`tr[data-detail-for="${id}"]`);
756
-
757
- if (existing) {
758
- // Collapse
759
- existing.remove();
760
- tr.classList.remove('expanded');
761
- expandedId = null;
762
- return;
763
- }
764
-
765
- // Collapse any other expanded row
766
- if (expandedId) {
767
- const prevDetail = document.querySelector(`tr[data-detail-for="${expandedId}"]`);
768
- if (prevDetail) prevDetail.remove();
769
- const prevRow = document.querySelector(`tr.writ-row[data-id="${expandedId}"]`);
770
- if (prevRow) prevRow.classList.remove('expanded');
771
- }
772
-
773
- expandedId = id;
774
- tr.classList.add('expanded');
775
-
776
- // Fetch fresh writ data
826
+ async function showWritDetail(id) {
777
827
  let writ;
778
828
  try {
779
- writ = await api('GET', `/api/writ/show?id=${encodeURIComponent(id)}`);
829
+ writ = await api('GET', '/api/writ/show?id=' + encodeURIComponent(id));
780
830
  } catch (e) {
781
- expandedId = null;
782
- tr.classList.remove('expanded');
831
+ console.error('Failed to load writ detail:', e);
783
832
  return;
784
833
  }
785
834
 
786
- // Update in writs array
835
+ // Update local data if this writ is already in our arrays
787
836
  const idx = writs.findIndex(w => w.id === id);
788
837
  if (idx >= 0) writs[idx] = writ;
789
838
 
790
- const detailTr = buildDetailRow(writ);
791
- tr.insertAdjacentElement('afterend', detailTr);
792
- }
793
-
794
- function scrollAndExpand(id) {
795
- // Check if writ is in loaded list
796
- const writ = writs.find(w => w.id === id);
797
- if (!writ) return; // not loaded — no-op
798
-
799
- const row = document.querySelector(`tr.writ-row[data-id="${id}"]`);
800
- if (!row) return;
839
+ // Fetch full children data for the detail children table
840
+ if (writ.children && writ.children.items.length > 0) {
841
+ try {
842
+ const fullChildren = await api('GET',
843
+ '/api/writ/list?parentId=' + encodeURIComponent(id) + '&limit=1000');
844
+ fullChildren.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
845
+ writ._fullChildren = fullChildren;
846
+ } catch (e) {
847
+ writ._fullChildren = null;
848
+ }
849
+ }
801
850
 
802
- row.scrollIntoView({ behavior: 'smooth', block: 'center' });
851
+ // Toggle views
852
+ document.getElementById('writ-list-view').style.display = 'none';
853
+ document.getElementById('writ-detail-view').style.display = '';
854
+ document.getElementById('detail-title').textContent = writ.title ?? writ.id;
803
855
 
804
- // Only expand if not already expanded
805
- if (expandedId !== id) {
806
- toggleExpand(id, row);
807
- }
856
+ const content = document.getElementById('detail-content');
857
+ content.innerHTML = renderDetail(writ);
858
+ wireDetailEvents(content, writ);
808
859
  }
809
860
 
810
861
  // ── Data loading ────────────────────────────────────────────────────
@@ -813,7 +864,7 @@
813
864
  if (replace) {
814
865
  offset = 0;
815
866
  writs = [];
816
- expandedId = null;
867
+ childrenMap = {};
817
868
  }
818
869
 
819
870
  const params = new URLSearchParams({ limit: String(LIMIT), offset: String(offset) });
@@ -828,13 +879,20 @@
828
879
  return;
829
880
  }
830
881
 
882
+ // Partition: roots (no parentId) vs children (discarded)
883
+ const roots = result.filter(w => !w.parentId);
884
+
831
885
  if (replace) {
832
- writs = result;
886
+ writs = roots;
833
887
  } else {
834
- writs = writs.concat(result);
888
+ writs = writs.concat(roots);
835
889
  }
836
890
 
837
- offset = writs.length;
891
+ offset += result.length;
892
+
893
+ // Fetch all children for each newly loaded root
894
+ await fetchChildrenForRoots(roots);
895
+
838
896
  renderTable();
839
897
 
840
898
  const loadMoreRow = document.getElementById('load-more-row');
@@ -845,6 +903,23 @@
845
903
  }
846
904
  }
847
905
 
906
+ async function fetchChildrenForRoots(roots) {
907
+ const fetches = roots.map(async (root) => {
908
+ try {
909
+ const children = await api('GET',
910
+ '/api/writ/list?parentId=' + encodeURIComponent(root.id) + '&limit=1000');
911
+ if (children.length > 0) {
912
+ // Sort children by createdAt ascending — oldest first
913
+ children.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
914
+ childrenMap[root.id] = children;
915
+ }
916
+ } catch (e) {
917
+ console.error('Failed to fetch children for ' + root.id, e);
918
+ }
919
+ });
920
+ await Promise.all(fetches);
921
+ }
922
+
848
923
  async function loadWritTypes() {
849
924
  let types;
850
925
  try {
@@ -990,6 +1065,9 @@
990
1065
  }
991
1066
 
992
1067
  function openRepost(writ) {
1068
+ // If in detail view, switch back to list view first
1069
+ document.getElementById('writ-detail-view').style.display = 'none';
1070
+ document.getElementById('writ-list-view').style.display = '';
993
1071
  openPostForm();
994
1072
  document.getElementById('post-title').textContent = 'Repost Writ';
995
1073
  document.getElementById('post-title-input').value = `[Repost] ${writ.title ?? ''}`;
@@ -1101,6 +1179,17 @@
1101
1179
 
1102
1180
  document.getElementById('btn-refresh').addEventListener('click', () => loadWrits(true));
1103
1181
 
1182
+ document.getElementById('back-btn').addEventListener('click', () => {
1183
+ document.getElementById('writ-detail-view').style.display = 'none';
1184
+ document.getElementById('writ-list-view').style.display = '';
1185
+ });
1186
+
1187
+ document.getElementById('btn-toggle-children').addEventListener('click', () => {
1188
+ showChildren = !showChildren;
1189
+ document.getElementById('btn-toggle-children').classList.toggle('active-filter', showChildren);
1190
+ renderTable();
1191
+ });
1192
+
1104
1193
  document.getElementById('post-submit-btn').addEventListener('click', submitPostForm);
1105
1194
 
1106
1195
  document.getElementById('load-more-btn').addEventListener('click', () => loadWrits(false));
@@ -1140,22 +1229,8 @@
1140
1229
 
1141
1230
  await loadWrits(true);
1142
1231
 
1143
- if (!writId) return;
1144
-
1145
- // If writ is in loaded list, scroll to it
1146
- if (writs.find(function (w) { return w.id === writId; })) {
1147
- scrollAndExpand(writId);
1148
- return;
1149
- }
1150
-
1151
- // Otherwise, fetch it and prepend to list
1152
- try {
1153
- var writ = await api('GET', '/api/writ/show?id=' + encodeURIComponent(writId));
1154
- writs.unshift(writ);
1155
- renderTable();
1156
- scrollAndExpand(writId);
1157
- } catch (e) {
1158
- console.error('Deep-link writ not found:', writId);
1232
+ if (writId) {
1233
+ showWritDetail(writId);
1159
1234
  }
1160
1235
  })();
1161
1236
  })();
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Unit tests for parent/child writ hierarchy in writs/index.html.
3
+ *
4
+ * Extracts and tests the pure logic behind:
5
+ * - sortedFilteredWrits — hierarchical ordering, toggle, search
6
+ * - statusBadge — waiting status
7
+ * - Toggle button state
8
+ * - Children table rendering in detail view
9
+ * - Parent link in detail view
10
+ *
11
+ * Uses a minimal DOM shim (same pattern as writs-type-filter.test.js).
12
+ */
13
+
14
+ import { describe, it, beforeEach } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+
17
+ // ── Minimal DOM shim ────────────────────────────────────────────────
18
+
19
+ class FakeElement {
20
+ constructor(tag) {
21
+ this.tagName = tag.toUpperCase();
22
+ this.className = '';
23
+ this.textContent = '';
24
+ this.title = '';
25
+ this.innerHTML = '';
26
+ this.dataset = {};
27
+ this.children = [];
28
+ this._listeners = {};
29
+ this.style = {};
30
+ }
31
+
32
+ appendChild(child) {
33
+ this.children.push(child);
34
+ return child;
35
+ }
36
+
37
+ querySelectorAll(selector) {
38
+ // Minimal support for class selectors
39
+ if (selector.startsWith('.')) {
40
+ const cls = selector.slice(1);
41
+ return this.children.filter(c =>
42
+ (c.className || '').includes(cls),
43
+ );
44
+ }
45
+ return [];
46
+ }
47
+
48
+ addEventListener(event, fn) {
49
+ if (!this._listeners[event]) this._listeners[event] = [];
50
+ this._listeners[event].push(fn);
51
+ }
52
+
53
+ click() {
54
+ for (const fn of this._listeners.click ?? []) fn();
55
+ }
56
+
57
+ get classList() {
58
+ const self = this;
59
+ return {
60
+ toggle(cls, force) {
61
+ const classes = self.className.split(/\s+/).filter(Boolean);
62
+ const idx = classes.indexOf(cls);
63
+ if (force && idx === -1) classes.push(cls);
64
+ if (!force && idx !== -1) classes.splice(idx, 1);
65
+ self.className = classes.join(' ');
66
+ },
67
+ contains(cls) {
68
+ return self.className.split(/\s+/).includes(cls);
69
+ },
70
+ };
71
+ }
72
+ }
73
+
74
+ function createElement(tag) {
75
+ return new FakeElement(tag);
76
+ }
77
+
78
+ // ── Extracted logic (mirrors index.html) ────────────────────────────
79
+
80
+ function statusBadge(status) {
81
+ const map = {
82
+ new: 'badge badge--draft',
83
+ ready: 'badge',
84
+ active: 'badge badge--active',
85
+ waiting: 'badge badge--warning',
86
+ completed: 'badge badge--success',
87
+ failed: 'badge badge--error',
88
+ cancelled: 'badge badge--warning',
89
+ };
90
+ const cls = map[status] ?? 'badge';
91
+ return `<span class="${cls}">${status}</span>`;
92
+ }
93
+
94
+ function escHtml(s) {
95
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
96
+ }
97
+
98
+ function escAttr(s) {
99
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
100
+ }
101
+
102
+ function compareVal(a, b, col) {
103
+ const av = a[col] ?? '';
104
+ const bv = b[col] ?? '';
105
+ if (av < bv) return -1;
106
+ if (av > bv) return 1;
107
+ return 0;
108
+ }
109
+
110
+ function rowActions(w) {
111
+ const isTerminal = ['completed', 'failed', 'cancelled'].includes(w.status);
112
+ if (isTerminal) return '';
113
+ const btns = [];
114
+ if (w.status === 'new') {
115
+ btns.push(`<button class="btn btn--primary row-action-btn" style="padding:0.15rem 0.5rem;font-size:0.8rem" data-action="row-publish" data-id="${w.id}">Start</button>`);
116
+ }
117
+ btns.push(`<button class="btn btn--danger row-action-btn" style="padding:0.15rem 0.5rem;font-size:0.8rem" data-action="row-cancel" data-id="${w.id}">Cancel</button>`);
118
+ return btns.join(' ');
119
+ }
120
+
121
+ /**
122
+ * Extracted sortedFilteredWrits logic — mirrors index.html.
123
+ */
124
+ function sortedFilteredWrits(writs, childrenMap, showChildren, searchText, sortCol, sortDir) {
125
+ let roots = writs.slice();
126
+
127
+ // Text filter on roots
128
+ if (searchText) {
129
+ const q = searchText.toLowerCase();
130
+ roots = roots.filter(w => (w.title ?? '').toLowerCase().includes(q));
131
+ }
132
+
133
+ // Sort roots by current sort column
134
+ roots.sort((a, b) => {
135
+ const cmp = compareVal(a, b, sortCol);
136
+ return sortDir === 'asc' ? cmp : -cmp;
137
+ });
138
+
139
+ // Interleave children beneath each root
140
+ const result = [];
141
+ for (const root of roots) {
142
+ result.push({ writ: root, isChild: false });
143
+ if (showChildren) {
144
+ let children = childrenMap[root.id] ?? [];
145
+ // Text filter on children too
146
+ if (searchText) {
147
+ const q = searchText.toLowerCase();
148
+ children = children.filter(w => (w.title ?? '').toLowerCase().includes(q));
149
+ }
150
+ for (const child of children) {
151
+ result.push({ writ: child, isChild: true });
152
+ }
153
+ }
154
+ }
155
+ return result;
156
+ }
157
+
158
+ /**
159
+ * Extracted renderDetail logic for parent link and children table.
160
+ * Returns the full HTML string, same as the index.html renderDetail.
161
+ */
162
+ function renderDetail(writ) {
163
+ const isTerminal = ['completed', 'failed', 'cancelled'].includes(writ.status);
164
+ const isDraft = writ.status === 'new';
165
+ let html = '';
166
+
167
+ // Edit form
168
+ html += `<div class="detail-section" id="edit-section-${writ.id}">`;
169
+ html += `<h4>${isDraft ? 'Edit Draft' : 'Edit'}</h4>`;
170
+ html += `<div class="form-row"><label>Title</label>`;
171
+ html += `<input type="text" id="edit-title-${writ.id}" value="${escAttr(writ.title ?? '')}"></div>`;
172
+ html += `<div class="form-row"><label>Body</label>`;
173
+ html += `<textarea id="edit-body-${writ.id}" rows="8">${escHtml(writ.body ?? '')}</textarea></div>`;
174
+ if (isDraft) {
175
+ html += `<div class="form-row"><label>Type</label><select id="edit-type-${writ.id}"></select></div>`;
176
+ html += `<div class="form-row"><label>Codex</label><select id="edit-codex-${writ.id}"></select></div>`;
177
+ }
178
+ html += `<div class="action-buttons">`;
179
+ html += `<button class="btn btn--primary" data-action="save-edit" data-id="${writ.id}">Save</button>`;
180
+ html += `</div></div>`;
181
+
182
+ // Details grid
183
+ html += `<div class="detail-section"><h4>Details</h4><dl class="detail-grid">`;
184
+ if (writ.codex) html += `<dt>Codex</dt><dd>${escHtml(writ.codex)}</dd>`;
185
+ if (writ.parent) {
186
+ html += `<dt>Parent</dt><dd><a href="?writ=${encodeURIComponent(writ.parent.id)}" style="color:var(--blue,#7aa2f7);text-decoration:underline;cursor:pointer">${escHtml(writ.parent.title)}</a> ${statusBadge(writ.parent.status)}</dd>`;
187
+ }
188
+ html += `<dt>Created</dt><dd></dd>`;
189
+ html += `</dl></div>`;
190
+
191
+ // Transition actions (simplified)
192
+ if (!isTerminal) {
193
+ html += `<div class="detail-section action-buttons" id="actions-${writ.id}"></div>`;
194
+ }
195
+
196
+ // Repost
197
+ if (writ.status === 'failed' || writ.status === 'cancelled') {
198
+ html += `<div class="detail-section"><button class="btn" data-action="repost" data-id="${writ.id}">Repost</button></div>`;
199
+ }
200
+
201
+ // Links (simplified)
202
+ html += `<div class="detail-section" id="links-section-${writ.id}"><h4>Links</h4></div>`;
203
+
204
+ // Children
205
+ const childItems = writ._fullChildren ?? writ.children?.items ?? [];
206
+ if (childItems.length > 0) {
207
+ html += `<div class="detail-section">`;
208
+ html += `<h4>Children</h4>`;
209
+
210
+ if (writ.children?.summary) {
211
+ html += `<div style="margin-bottom:0.5rem">`;
212
+ for (const [status, count] of Object.entries(writ.children.summary)) {
213
+ html += statusBadge(status) + ` <span style="margin-right:0.75rem">${count}</span>`;
214
+ }
215
+ html += `</div>`;
216
+ }
217
+
218
+ html += `<table class="data-table"><thead><tr>`;
219
+ html += `<th>Status</th><th>Title</th><th>Type</th><th>ID</th><th>Actions</th>`;
220
+ html += `</tr></thead><tbody>`;
221
+ for (const child of childItems) {
222
+ html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" style="cursor:pointer">`;
223
+ html += `<td>${statusBadge(child.status)}</td>`;
224
+ html += `<td>${escHtml(child.title ?? '')}</td>`;
225
+ html += `<td>${escHtml(child.type ?? '')}</td>`;
226
+ html += `<td><code>${child.id}</code></td>`;
227
+ html += `<td class="row-actions" style="white-space:nowrap">${rowActions(child)}</td>`;
228
+ html += `</tr>`;
229
+ }
230
+ html += `</tbody></table></div>`;
231
+ }
232
+
233
+ return html;
234
+ }
235
+
236
+ // ── Tests ────────────────────────────────────────────────────────────
237
+
238
+ describe('sortedFilteredWrits — hierarchy ordering', () => {
239
+ const rootA = { id: 'a', title: 'Alpha Root', type: 'mandate', status: 'active', createdAt: '2025-01-01' };
240
+ const rootB = { id: 'b', title: 'Beta Root', type: 'mandate', status: 'active', createdAt: '2025-01-02' };
241
+ const childA1 = { id: 'a1', title: 'Alpha Child 1', type: 'task', status: 'active', parentId: 'a', createdAt: '2025-01-03' };
242
+ const childA2 = { id: 'a2', title: 'Alpha Child 2', type: 'task', status: 'active', parentId: 'a', createdAt: '2025-01-04' };
243
+ const childB1 = { id: 'b1', title: 'Beta Child 1', type: 'task', status: 'active', parentId: 'b', createdAt: '2025-01-05' };
244
+
245
+ it('happy path — children interleaved beneath parents', () => {
246
+ const writs = [rootA, rootB];
247
+ const childrenMap = { a: [childA1, childA2], b: [childB1] };
248
+ const result = sortedFilteredWrits(writs, childrenMap, true, '', 'createdAt', 'asc');
249
+
250
+ assert.deepEqual(result.map(r => [r.writ.id, r.isChild]), [
251
+ ['a', false],
252
+ ['a1', true],
253
+ ['a2', true],
254
+ ['b', false],
255
+ ['b1', true],
256
+ ]);
257
+ });
258
+
259
+ it('children hidden — only roots returned', () => {
260
+ const writs = [rootA, rootB];
261
+ const childrenMap = { a: [childA1, childA2], b: [childB1] };
262
+ const result = sortedFilteredWrits(writs, childrenMap, false, '', 'createdAt', 'asc');
263
+
264
+ assert.deepEqual(result.map(r => [r.writ.id, r.isChild]), [
265
+ ['a', false],
266
+ ['b', false],
267
+ ]);
268
+ });
269
+
270
+ it('sort changes root order only — children stay beneath parent', () => {
271
+ const writs = [rootA, rootB];
272
+ const childrenMap = { a: [childA1, childA2], b: [childB1] };
273
+ // Sort by title ascending: Alpha < Beta
274
+ const result = sortedFilteredWrits(writs, childrenMap, true, '', 'title', 'asc');
275
+
276
+ assert.deepEqual(result.map(r => [r.writ.id, r.isChild]), [
277
+ ['a', false],
278
+ ['a1', true],
279
+ ['a2', true],
280
+ ['b', false],
281
+ ['b1', true],
282
+ ]);
283
+
284
+ // Sort by title descending: Beta > Alpha
285
+ const result2 = sortedFilteredWrits(writs, childrenMap, true, '', 'title', 'desc');
286
+ assert.deepEqual(result2.map(r => [r.writ.id, r.isChild]), [
287
+ ['b', false],
288
+ ['b1', true],
289
+ ['a', false],
290
+ ['a1', true],
291
+ ['a2', true],
292
+ ]);
293
+ });
294
+
295
+ it('search filters both roots and children', () => {
296
+ const writs = [rootA, rootB];
297
+ const childrenMap = { a: [childA1, childA2], b: [childB1] };
298
+ // Search for 'Alpha' — rootA matches, childA1 and childA2 match, rootB doesn't match
299
+ const result = sortedFilteredWrits(writs, childrenMap, true, 'Alpha', 'createdAt', 'asc');
300
+
301
+ assert.deepEqual(result.map(r => [r.writ.id, r.isChild]), [
302
+ ['a', false],
303
+ ['a1', true],
304
+ ['a2', true],
305
+ ]);
306
+ });
307
+
308
+ it('search respects toggle — hidden children not matched', () => {
309
+ // childA1 title contains 'Child 1' but rootA does not contain 'Child'
310
+ const writs = [rootA, rootB];
311
+ const childrenMap = { a: [childA1], b: [childB1] };
312
+ const result = sortedFilteredWrits(writs, childrenMap, false, 'Child 1', 'createdAt', 'asc');
313
+
314
+ // Neither root matches 'Child 1', and children are hidden
315
+ assert.deepEqual(result, []);
316
+ });
317
+
318
+ it('search matches child title but not root when children visible', () => {
319
+ // Search for 'Child 1': rootA title doesn't match, but childA1 does
320
+ // Since rootA doesn't match the root filter, it's excluded entirely
321
+ const writs = [rootA, rootB];
322
+ const childrenMap = { a: [childA1], b: [childB1] };
323
+ const result = sortedFilteredWrits(writs, childrenMap, true, 'Child 1', 'createdAt', 'asc');
324
+
325
+ // Root filter excludes both roots since neither title contains 'Child 1'
326
+ assert.deepEqual(result, []);
327
+ });
328
+
329
+ it('empty children — root appears alone with no child rows', () => {
330
+ const writs = [rootA];
331
+ const childrenMap = {};
332
+ const result = sortedFilteredWrits(writs, childrenMap, true, '', 'createdAt', 'asc');
333
+
334
+ assert.deepEqual(result.map(r => [r.writ.id, r.isChild]), [
335
+ ['a', false],
336
+ ]);
337
+ });
338
+
339
+ it('empty writs array returns empty', () => {
340
+ const result = sortedFilteredWrits([], {}, true, '', 'createdAt', 'asc');
341
+ assert.deepEqual(result, []);
342
+ });
343
+ });
344
+
345
+ describe('statusBadge — waiting status', () => {
346
+ it('maps waiting to badge badge--warning', () => {
347
+ const result = statusBadge('waiting');
348
+ assert.equal(result, '<span class="badge badge--warning">waiting</span>');
349
+ });
350
+
351
+ it('maps cancelled to badge badge--warning', () => {
352
+ const result = statusBadge('cancelled');
353
+ assert.equal(result, '<span class="badge badge--warning">cancelled</span>');
354
+ });
355
+
356
+ it('maps active to badge badge--active', () => {
357
+ const result = statusBadge('active');
358
+ assert.equal(result, '<span class="badge badge--active">active</span>');
359
+ });
360
+
361
+ it('maps unknown status to plain badge', () => {
362
+ const result = statusBadge('unknown');
363
+ assert.equal(result, '<span class="badge">unknown</span>');
364
+ });
365
+ });
366
+
367
+ describe('Toggle button state', () => {
368
+ it('initial state: showChildren true, button has active-filter', () => {
369
+ let showChildren = true;
370
+ const btn = createElement('button');
371
+ btn.className = 'btn active-filter';
372
+ assert.equal(showChildren, true);
373
+ assert.ok(btn.classList.contains('active-filter'));
374
+ });
375
+
376
+ it('after first click: showChildren false, button loses active-filter', () => {
377
+ let showChildren = true;
378
+ const btn = createElement('button');
379
+ btn.className = 'btn active-filter';
380
+
381
+ // Simulate click
382
+ showChildren = !showChildren;
383
+ btn.classList.toggle('active-filter', showChildren);
384
+
385
+ assert.equal(showChildren, false);
386
+ assert.ok(!btn.classList.contains('active-filter'));
387
+ });
388
+
389
+ it('after second click: showChildren true, button regains active-filter', () => {
390
+ let showChildren = true;
391
+ const btn = createElement('button');
392
+ btn.className = 'btn active-filter';
393
+
394
+ // First click
395
+ showChildren = !showChildren;
396
+ btn.classList.toggle('active-filter', showChildren);
397
+
398
+ // Second click
399
+ showChildren = !showChildren;
400
+ btn.classList.toggle('active-filter', showChildren);
401
+
402
+ assert.equal(showChildren, true);
403
+ assert.ok(btn.classList.contains('active-filter'));
404
+ });
405
+ });
406
+
407
+ describe('Children table rendering in detail view', () => {
408
+ it('renders children table with 3 rows and correct columns', () => {
409
+ const writ = {
410
+ id: 'parent-1',
411
+ title: 'Parent Writ',
412
+ status: 'active',
413
+ body: 'body text',
414
+ _fullChildren: [
415
+ { id: 'c1', title: 'Child 1', type: 'task', status: 'active', createdAt: '2025-01-01' },
416
+ { id: 'c2', title: 'Child 2', type: 'task', status: 'completed', createdAt: '2025-01-02' },
417
+ { id: 'c3', title: 'Child 3', type: 'bug', status: 'new', createdAt: '2025-01-03' },
418
+ ],
419
+ children: {
420
+ summary: { active: 1, completed: 1, new: 1 },
421
+ items: [],
422
+ },
423
+ };
424
+
425
+ const html = renderDetail(writ);
426
+
427
+ // Should contain children section
428
+ assert.ok(html.includes('<h4>Children</h4>'), 'Children header should exist');
429
+
430
+ // Should have 3 child-detail-row entries
431
+ const rowMatches = html.match(/child-detail-row/g);
432
+ assert.equal(rowMatches.length, 3, 'Should have 3 child rows');
433
+
434
+ // Should have Status, Title, Type, ID, Actions columns (no Created)
435
+ assert.ok(html.includes('<th>Status</th><th>Title</th><th>Type</th><th>ID</th><th>Actions</th>'));
436
+
437
+ // Verify child data appears
438
+ assert.ok(html.includes('Child 1'));
439
+ assert.ok(html.includes('Child 2'));
440
+ assert.ok(html.includes('Child 3'));
441
+ assert.ok(html.includes('data-child-id="c1"'));
442
+ assert.ok(html.includes('data-child-id="c2"'));
443
+ assert.ok(html.includes('data-child-id="c3"'));
444
+ });
445
+
446
+ it('does not render children section when no children', () => {
447
+ const writ = {
448
+ id: 'parent-2',
449
+ title: 'No Children',
450
+ status: 'active',
451
+ body: '',
452
+ children: { summary: {}, items: [] },
453
+ };
454
+
455
+ const html = renderDetail(writ);
456
+ assert.ok(!html.includes('<h4>Children</h4>'), 'Children header should not exist');
457
+ assert.ok(!html.includes('child-detail-row'), 'No child rows');
458
+ });
459
+
460
+ it('children in detail table ordered by createdAt ascending', () => {
461
+ const writ = {
462
+ id: 'parent-3',
463
+ title: 'Parent',
464
+ status: 'active',
465
+ body: '',
466
+ _fullChildren: [
467
+ { id: 'c-early', title: 'Early', type: 'task', status: 'active', createdAt: '2025-01-01' },
468
+ { id: 'c-late', title: 'Late', type: 'task', status: 'active', createdAt: '2025-01-10' },
469
+ { id: 'c-mid', title: 'Mid', type: 'task', status: 'active', createdAt: '2025-01-05' },
470
+ ],
471
+ children: { summary: {}, items: [] },
472
+ };
473
+
474
+ const html = renderDetail(writ);
475
+ const earlyIdx = html.indexOf('c-early');
476
+ const midIdx = html.indexOf('c-mid');
477
+ const lateIdx = html.indexOf('c-late');
478
+
479
+ // _fullChildren is pre-sorted by the caller, but we test that the rendering
480
+ // preserves the order they appear in. In real code fetchChildrenForRoots sorts them.
481
+ assert.ok(earlyIdx < lateIdx, 'Early should appear before Late');
482
+ assert.ok(earlyIdx < midIdx, 'Early should appear before Mid');
483
+ });
484
+ });
485
+
486
+ describe('Parent link in detail view', () => {
487
+ it('renders parent row with link and status badge when parent exists', () => {
488
+ const writ = {
489
+ id: 'child-w',
490
+ title: 'Child Writ',
491
+ status: 'active',
492
+ body: '',
493
+ parent: { id: 'w-parent', title: 'Parent Writ', status: 'active' },
494
+ };
495
+
496
+ const html = renderDetail(writ);
497
+
498
+ // Should contain Parent dt/dd
499
+ assert.ok(html.includes('<dt>Parent</dt>'), 'Parent label should exist');
500
+ // Should contain link to parent
501
+ assert.ok(html.includes('href="?writ=w-parent"'), 'Parent link should use ?writ= param');
502
+ assert.ok(html.includes('Parent Writ'), 'Parent title should appear');
503
+ // Should have status badge for parent
504
+ assert.ok(html.includes('badge badge--active'), 'Parent status badge should appear');
505
+ });
506
+
507
+ it('does not render parent row when parent is null', () => {
508
+ const writ = {
509
+ id: 'root-w',
510
+ title: 'Root Writ',
511
+ status: 'active',
512
+ body: '',
513
+ parent: null,
514
+ };
515
+
516
+ const html = renderDetail(writ);
517
+ assert.ok(!html.includes('<dt>Parent</dt>'), 'Parent label should not exist');
518
+ });
519
+
520
+ it('does not render parent row when parent is undefined', () => {
521
+ const writ = {
522
+ id: 'root-w2',
523
+ title: 'Root Writ 2',
524
+ status: 'active',
525
+ body: '',
526
+ };
527
+
528
+ const html = renderDetail(writ);
529
+ assert.ok(!html.includes('<dt>Parent</dt>'), 'Parent label should not exist');
530
+ });
531
+
532
+ it('encodes parent id in URL', () => {
533
+ const writ = {
534
+ id: 'child-w2',
535
+ title: 'Child',
536
+ status: 'active',
537
+ body: '',
538
+ parent: { id: 'w-parent with spaces', title: 'Parent', status: 'ready' },
539
+ };
540
+
541
+ const html = renderDetail(writ);
542
+ assert.ok(html.includes('href="?writ=w-parent%20with%20spaces"'), 'Parent id should be URL-encoded');
543
+ });
544
+ });