@shardworks/clerk-apparatus 0.1.249 → 0.1.251

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.249",
3
+ "version": "0.1.251",
4
4
  "license": "ISC",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,12 +20,12 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "zod": "4.3.6",
23
- "@shardworks/stacks-apparatus": "0.1.249",
24
- "@shardworks/tools-apparatus": "0.1.249"
23
+ "@shardworks/stacks-apparatus": "0.1.251",
24
+ "@shardworks/tools-apparatus": "0.1.251"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "25.5.0",
28
- "@shardworks/nexus-core": "0.1.249"
28
+ "@shardworks/nexus-core": "0.1.251"
29
29
  },
30
30
  "files": [
31
31
  "dist",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "scripts": {
35
35
  "build": "tsc",
36
- "test": "node --disable-warning=ExperimentalWarning --experimental-transform-types --test 'src/**/*.test.ts'",
36
+ "test": "node --disable-warning=ExperimentalWarning --experimental-transform-types --test 'src/**/*.test.ts' 'pages/**/*.test.js'",
37
37
  "typecheck": "tsc --noEmit"
38
38
  }
39
39
  }
@@ -235,6 +235,73 @@
235
235
  el.style.display = 'none';
236
236
  }
237
237
 
238
+ // ── Descendant tree helpers ────────────────────────────────────────
239
+
240
+ /**
241
+ * WritTree node shape: { writ: WritDoc, children: WritTreeNode[] }.
242
+ *
243
+ * Recursively fetches descendants of `rootId` via /api/writ/list, producing a
244
+ * tree of `WritTreeNode` entries. Guards against cycles with `visited` and
245
+ * bounds recursion at `maxDepth` (default 10) to keep the UI responsive on
246
+ * pathological data. Children at each level are sorted by createdAt ascending
247
+ * (oldest first), matching fetchChildrenForRoots behavior.
248
+ *
249
+ * Returns `WritTreeNode[]` — the direct children of `rootId`, each with their
250
+ * own nested `children` list.
251
+ */
252
+ async function buildDescendantTree(rootId, maxDepth = 10, visited = null) {
253
+ if (visited === null) visited = new Set();
254
+ if (maxDepth <= 0) return [];
255
+ if (visited.has(rootId)) return [];
256
+ visited.add(rootId);
257
+
258
+ let children;
259
+ try {
260
+ children = await api('GET',
261
+ '/api/writ/list?parentId=' + encodeURIComponent(rootId) + '&limit=1000');
262
+ } catch (e) {
263
+ console.error('Failed to fetch children for ' + rootId, e);
264
+ return [];
265
+ }
266
+
267
+ children.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
268
+
269
+ // Fetch grandchildren in parallel for each child
270
+ const nodes = await Promise.all(children.map(async (child) => {
271
+ const subChildren = await buildDescendantTree(child.id, maxDepth - 1, visited);
272
+ return { writ: child, children: subChildren };
273
+ }));
274
+ return nodes;
275
+ }
276
+
277
+ /**
278
+ * Flattens a WritTree forest into a depth-first list of `{ writ, depth }`.
279
+ * `depth` is 0-based — direct children of the root get depth 0, their
280
+ * children get depth 1, etc. Order preserves the pre-order traversal
281
+ * of the tree (parent before children, children in createdAt order).
282
+ */
283
+ function flattenTree(nodes, depth = 0, acc = null) {
284
+ if (acc === null) acc = [];
285
+ for (const node of nodes) {
286
+ acc.push({ writ: node.writ, depth });
287
+ if (node.children && node.children.length > 0) {
288
+ flattenTree(node.children, depth + 1, acc);
289
+ }
290
+ }
291
+ return acc;
292
+ }
293
+
294
+ /**
295
+ * Returns inline style for indenting a depth-aware row's title cell.
296
+ * Depth 0 is the first indent step, matching the existing child-row
297
+ * padding-left: 2rem in the main list; each additional depth adds
298
+ * 1.5rem so nested descendants are visually distinguishable.
299
+ */
300
+ function depthIndentStyle(depth) {
301
+ const rem = 2 + depth * 1.5;
302
+ return `padding-left:${rem}rem`;
303
+ }
304
+
238
305
  // ── Row actions ────────────────────────────────────────────────────
239
306
 
240
307
  /** Returns the HTML for the context-aware Actions cell for a writ row. */
@@ -439,13 +506,27 @@
439
506
  html += renderLinksSection(writ);
440
507
  html += `</div>`;
441
508
 
442
- // Children
443
- const childItems = writ._fullChildren ?? writ.children?.items ?? [];
444
- if (childItems.length > 0) {
509
+ // Children — deep descendant rendering
510
+ //
511
+ // Prefer the descendant tree populated by showWritDetail / refreshDetail
512
+ // (writ._descendantTree: WritTreeNode[]). If the tree hasn't been populated
513
+ // yet (older code path or fetch failure), fall back to the flat children
514
+ // list shape so we still render something. The summary-badges block is
515
+ // driven by writ.children.summary, which is the direct-child summary
516
+ // returned by writ-show — it intentionally covers depth 1 only.
517
+ let rows;
518
+ if (writ._descendantTree && writ._descendantTree.length > 0) {
519
+ rows = flattenTree(writ._descendantTree);
520
+ } else {
521
+ const childItems = writ._fullChildren ?? writ.children?.items ?? [];
522
+ rows = childItems.map(c => ({ writ: c, depth: 0 }));
523
+ }
524
+
525
+ if (rows.length > 0) {
445
526
  html += `<div class="detail-section">`;
446
527
  html += `<h4>Children</h4>`;
447
528
 
448
- // Summary badges
529
+ // Summary badges — direct children only, per writ-show contract.
449
530
  if (writ.children?.summary) {
450
531
  html += `<div style="margin-bottom:0.5rem">`;
451
532
  for (const [phase, count] of Object.entries(writ.children.summary)) {
@@ -454,14 +535,14 @@
454
535
  html += `</div>`;
455
536
  }
456
537
 
457
- // Children table: Phase, Title, Type, ID, Actions
538
+ // Children table: Phase, Title, Type, ID, Actions — rows indent by depth.
458
539
  html += `<table class="data-table"><thead><tr>`;
459
540
  html += `<th>Phase</th><th>Title</th><th>Type</th><th>ID</th><th>Actions</th>`;
460
541
  html += `</tr></thead><tbody>`;
461
- for (const child of childItems) {
462
- html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" style="cursor:pointer">`;
542
+ for (const { writ: child, depth } of rows) {
543
+ html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" data-depth="${depth}" style="cursor:pointer">`;
463
544
  html += `<td>${phaseBadge(child.phase)}</td>`;
464
- html += `<td>${escHtml(child.title ?? '')}</td>`;
545
+ html += `<td style="${depthIndentStyle(depth)}">${escHtml(child.title ?? '')}</td>`;
465
546
  html += `<td>${escHtml(child.type ?? '')}</td>`;
466
547
  html += `<td><code>${child.id}</code></td>`;
467
548
  html += `<td class="row-actions" style="white-space:nowrap">${rowActions(child)}</td>`;
@@ -800,14 +881,13 @@
800
881
  const idx = writs.findIndex(w => w.id === id);
801
882
  if (idx >= 0) writs[idx] = writ;
802
883
 
803
- // Fetch full children if needed
884
+ // Fetch full descendant tree if there are any children.
804
885
  if (writ.children && writ.children.items.length > 0) {
805
886
  try {
806
- const fullChildren = await api('GET',
807
- '/api/writ/list?parentId=' + encodeURIComponent(id) + '&limit=1000');
808
- fullChildren.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
809
- writ._fullChildren = fullChildren;
810
- } catch (e) { writ._fullChildren = null; }
887
+ writ._descendantTree = await buildDescendantTree(id);
888
+ } catch (e) {
889
+ writ._descendantTree = null;
890
+ }
811
891
  }
812
892
 
813
893
  // Re-render detail content
@@ -847,15 +927,14 @@
847
927
  const idx = writs.findIndex(w => w.id === id);
848
928
  if (idx >= 0) writs[idx] = writ;
849
929
 
850
- // Fetch full children data for the detail children table
930
+ // Fetch full descendant tree for the detail children table. The detail
931
+ // view renders deep descendants (not just direct children) so clicking
932
+ // into a writ does not collapse visible depth back to 1.
851
933
  if (writ.children && writ.children.items.length > 0) {
852
934
  try {
853
- const fullChildren = await api('GET',
854
- '/api/writ/list?parentId=' + encodeURIComponent(id) + '&limit=1000');
855
- fullChildren.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
856
- writ._fullChildren = fullChildren;
935
+ writ._descendantTree = await buildDescendantTree(id);
857
936
  } catch (e) {
858
- writ._fullChildren = null;
937
+ writ._descendantTree = null;
859
938
  }
860
939
  }
861
940
 
@@ -155,6 +155,27 @@ function sortedFilteredWrits(writs, childrenMap, showChildren, searchText, sortC
155
155
  return result;
156
156
  }
157
157
 
158
+ /**
159
+ * Flattens a WritTree forest into a depth-first list of `{ writ, depth }`.
160
+ * Mirrors the flattenTree helper in index.html.
161
+ */
162
+ function flattenTree(nodes, depth = 0, acc = null) {
163
+ if (acc === null) acc = [];
164
+ for (const node of nodes) {
165
+ acc.push({ writ: node.writ, depth });
166
+ if (node.children && node.children.length > 0) {
167
+ flattenTree(node.children, depth + 1, acc);
168
+ }
169
+ }
170
+ return acc;
171
+ }
172
+
173
+ /** Mirrors depthIndentStyle in index.html. */
174
+ function depthIndentStyle(depth) {
175
+ const rem = 2 + depth * 1.5;
176
+ return `padding-left:${rem}rem`;
177
+ }
178
+
158
179
  /**
159
180
  * Extracted renderDetail logic for parent link and children table.
160
181
  * Returns the full HTML string, same as the index.html renderDetail.
@@ -202,9 +223,16 @@ function renderDetail(writ) {
202
223
  // Links (simplified)
203
224
  html += `<div class="detail-section" id="links-section-${writ.id}"><h4>Links</h4></div>`;
204
225
 
205
- // Children
206
- const childItems = writ._fullChildren ?? writ.children?.items ?? [];
207
- if (childItems.length > 0) {
226
+ // Children — deep descendant rendering.
227
+ let rows;
228
+ if (writ._descendantTree && writ._descendantTree.length > 0) {
229
+ rows = flattenTree(writ._descendantTree);
230
+ } else {
231
+ const childItems = writ._fullChildren ?? writ.children?.items ?? [];
232
+ rows = childItems.map(c => ({ writ: c, depth: 0 }));
233
+ }
234
+
235
+ if (rows.length > 0) {
208
236
  html += `<div class="detail-section">`;
209
237
  html += `<h4>Children</h4>`;
210
238
 
@@ -219,10 +247,10 @@ function renderDetail(writ) {
219
247
  html += `<table class="data-table"><thead><tr>`;
220
248
  html += `<th>Phase</th><th>Title</th><th>Type</th><th>ID</th><th>Actions</th>`;
221
249
  html += `</tr></thead><tbody>`;
222
- for (const child of childItems) {
223
- html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" style="cursor:pointer">`;
250
+ for (const { writ: child, depth } of rows) {
251
+ html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" data-depth="${depth}" style="cursor:pointer">`;
224
252
  html += `<td>${phaseBadge(child.phase)}</td>`;
225
- html += `<td>${escHtml(child.title ?? '')}</td>`;
253
+ html += `<td style="${depthIndentStyle(depth)}">${escHtml(child.title ?? '')}</td>`;
226
254
  html += `<td>${escHtml(child.type ?? '')}</td>`;
227
255
  html += `<td><code>${child.id}</code></td>`;
228
256
  html += `<td class="row-actions" style="white-space:nowrap">${rowActions(child)}</td>`;
@@ -538,3 +566,237 @@ describe('Parent link in detail view', () => {
538
566
  assert.ok(html.includes('href="?writ=w-parent%20with%20spaces"'), 'Parent id should be URL-encoded');
539
567
  });
540
568
  });
569
+
570
+ describe('flattenTree — depth-first pre-order traversal', () => {
571
+ it('flattens a single-level forest at depth 0', () => {
572
+ const nodes = [
573
+ { writ: { id: 'a', title: 'A' }, children: [] },
574
+ { writ: { id: 'b', title: 'B' }, children: [] },
575
+ ];
576
+ const rows = flattenTree(nodes);
577
+ assert.deepEqual(rows, [
578
+ { writ: { id: 'a', title: 'A' }, depth: 0 },
579
+ { writ: { id: 'b', title: 'B' }, depth: 0 },
580
+ ]);
581
+ });
582
+
583
+ it('flattens a two-level tree — children follow parent with depth+1', () => {
584
+ const nodes = [
585
+ {
586
+ writ: { id: 'a' },
587
+ children: [
588
+ { writ: { id: 'a1' }, children: [] },
589
+ { writ: { id: 'a2' }, children: [] },
590
+ ],
591
+ },
592
+ { writ: { id: 'b' }, children: [] },
593
+ ];
594
+ const rows = flattenTree(nodes);
595
+ assert.deepEqual(rows.map(r => [r.writ.id, r.depth]), [
596
+ ['a', 0],
597
+ ['a1', 1],
598
+ ['a2', 1],
599
+ ['b', 0],
600
+ ]);
601
+ });
602
+
603
+ it('flattens a three-level tree preserving depth per level', () => {
604
+ const nodes = [
605
+ {
606
+ writ: { id: 'a' },
607
+ children: [
608
+ {
609
+ writ: { id: 'a1' },
610
+ children: [
611
+ { writ: { id: 'a1x' }, children: [] },
612
+ { writ: { id: 'a1y' }, children: [] },
613
+ ],
614
+ },
615
+ { writ: { id: 'a2' }, children: [] },
616
+ ],
617
+ },
618
+ ];
619
+ const rows = flattenTree(nodes);
620
+ assert.deepEqual(rows.map(r => [r.writ.id, r.depth]), [
621
+ ['a', 0],
622
+ ['a1', 1],
623
+ ['a1x', 2],
624
+ ['a1y', 2],
625
+ ['a2', 1],
626
+ ]);
627
+ });
628
+
629
+ it('empty forest returns empty', () => {
630
+ assert.deepEqual(flattenTree([]), []);
631
+ });
632
+
633
+ it('accepts an explicit start depth (for sub-tree rendering)', () => {
634
+ const nodes = [
635
+ { writ: { id: 'x' }, children: [{ writ: { id: 'xx' }, children: [] }] },
636
+ ];
637
+ const rows = flattenTree(nodes, 5);
638
+ assert.deepEqual(rows.map(r => [r.writ.id, r.depth]), [
639
+ ['x', 5],
640
+ ['xx', 6],
641
+ ]);
642
+ });
643
+ });
644
+
645
+ describe('depthIndentStyle — depth → padding-left mapping', () => {
646
+ it('depth 0 matches the existing 2rem main-list indent', () => {
647
+ assert.equal(depthIndentStyle(0), 'padding-left:2rem');
648
+ });
649
+
650
+ it('depth 1 adds 1.5rem', () => {
651
+ assert.equal(depthIndentStyle(1), 'padding-left:3.5rem');
652
+ });
653
+
654
+ it('depth 2 adds another 1.5rem', () => {
655
+ assert.equal(depthIndentStyle(2), 'padding-left:5rem');
656
+ });
657
+ });
658
+
659
+ describe('Deep descendant rendering in detail view', () => {
660
+ it('renders all descendants when _descendantTree is populated', () => {
661
+ const writ = {
662
+ id: 'parent-1',
663
+ title: 'Parent',
664
+ phase: 'open',
665
+ body: '',
666
+ children: {
667
+ summary: { open: 1 },
668
+ items: [{ id: 'c1', title: 'Child 1', phase: 'open' }],
669
+ },
670
+ _descendantTree: [
671
+ {
672
+ writ: { id: 'c1', title: 'Child 1', type: 'task', phase: 'open' },
673
+ children: [
674
+ {
675
+ writ: { id: 'g1', title: 'Grandchild 1', type: 'task', phase: 'open' },
676
+ children: [
677
+ {
678
+ writ: { id: 'gg1', title: 'Great-grandchild', type: 'task', phase: 'open' },
679
+ children: [],
680
+ },
681
+ ],
682
+ },
683
+ ],
684
+ },
685
+ ],
686
+ };
687
+
688
+ const html = renderDetail(writ);
689
+
690
+ // All three descendants appear as rows
691
+ assert.ok(html.includes('data-child-id="c1"'), 'Direct child row present');
692
+ assert.ok(html.includes('data-child-id="g1"'), 'Grandchild row present');
693
+ assert.ok(html.includes('data-child-id="gg1"'), 'Great-grandchild row present');
694
+ // Pre-order: parent appears before child
695
+ assert.ok(html.indexOf('data-child-id="c1"') < html.indexOf('data-child-id="g1"'));
696
+ assert.ok(html.indexOf('data-child-id="g1"') < html.indexOf('data-child-id="gg1"'));
697
+ });
698
+
699
+ it('rows carry depth data attribute and depth-based indent', () => {
700
+ const writ = {
701
+ id: 'p',
702
+ title: 'P',
703
+ phase: 'open',
704
+ body: '',
705
+ children: { summary: {}, items: [{ id: 'c', title: 'C', phase: 'open' }] },
706
+ _descendantTree: [
707
+ {
708
+ writ: { id: 'c', title: 'Child', type: 'task', phase: 'open' },
709
+ children: [
710
+ { writ: { id: 'g', title: 'Grand', type: 'task', phase: 'open' }, children: [] },
711
+ ],
712
+ },
713
+ ],
714
+ };
715
+
716
+ const html = renderDetail(writ);
717
+ assert.ok(html.includes('data-child-id="c" data-depth="0"'), 'Direct child has depth 0');
718
+ assert.ok(html.includes('data-child-id="g" data-depth="1"'), 'Grandchild has depth 1');
719
+ assert.ok(html.includes('style="padding-left:2rem"'), 'Depth 0 title cell indents 2rem');
720
+ assert.ok(html.includes('style="padding-left:3.5rem"'), 'Depth 1 title cell indents 3.5rem');
721
+ });
722
+
723
+ it('falls back to flat children when _descendantTree is missing', () => {
724
+ const writ = {
725
+ id: 'p',
726
+ title: 'P',
727
+ phase: 'open',
728
+ body: '',
729
+ _fullChildren: [
730
+ { id: 'c1', title: 'Child 1', type: 'task', phase: 'open' },
731
+ { id: 'c2', title: 'Child 2', type: 'task', phase: 'open' },
732
+ ],
733
+ children: { summary: { open: 2 }, items: [] },
734
+ };
735
+
736
+ const html = renderDetail(writ);
737
+ assert.ok(html.includes('data-child-id="c1" data-depth="0"'));
738
+ assert.ok(html.includes('data-child-id="c2" data-depth="0"'));
739
+ });
740
+
741
+ it('summary badges reflect direct children only (writ.children.summary)', () => {
742
+ const writ = {
743
+ id: 'p',
744
+ title: 'P',
745
+ phase: 'open',
746
+ body: '',
747
+ children: {
748
+ // Direct-child summary from writ-show: 2 open direct children.
749
+ // Grandchildren are NOT counted here by design.
750
+ summary: { open: 2 },
751
+ items: [
752
+ { id: 'c1', title: 'C1', phase: 'open' },
753
+ { id: 'c2', title: 'C2', phase: 'open' },
754
+ ],
755
+ },
756
+ _descendantTree: [
757
+ {
758
+ writ: { id: 'c1', title: 'C1', type: 'task', phase: 'open' },
759
+ children: [
760
+ { writ: { id: 'g1', title: 'G1', type: 'task', phase: 'completed' }, children: [] },
761
+ ],
762
+ },
763
+ { writ: { id: 'c2', title: 'C2', type: 'task', phase: 'open' }, children: [] },
764
+ ],
765
+ };
766
+
767
+ const html = renderDetail(writ);
768
+ // Summary badges section shows "open 2" — not the grandchild's completed phase.
769
+ const summaryOpenMatch = html.match(/badge badge--active">open<\/span>\s*<span[^>]*>2</);
770
+ assert.ok(summaryOpenMatch, 'Summary shows 2 open direct children');
771
+ assert.ok(!html.match(/badge badge--success">completed<\/span>\s*<span[^>]*>1</),
772
+ 'Summary does NOT count the grandchild');
773
+ // But the table still renders the grandchild row.
774
+ assert.ok(html.includes('data-child-id="g1"'), 'Grandchild row is present in the table');
775
+ });
776
+
777
+ it('preserves rowActions on every depth row', () => {
778
+ const writ = {
779
+ id: 'p',
780
+ title: 'P',
781
+ phase: 'open',
782
+ body: '',
783
+ children: { summary: {}, items: [{ id: 'c', title: 'C', phase: 'new' }] },
784
+ _descendantTree: [
785
+ {
786
+ writ: { id: 'c', title: 'C', type: 'task', phase: 'new' },
787
+ children: [
788
+ { writ: { id: 'g', title: 'G', type: 'task', phase: 'open' }, children: [] },
789
+ ],
790
+ },
791
+ ],
792
+ };
793
+
794
+ const html = renderDetail(writ);
795
+ // Depth 0 (new phase) gets Start + Cancel
796
+ assert.ok(html.match(/data-action="row-publish"[^>]*data-id="c"/));
797
+ assert.ok(html.match(/data-action="row-cancel"[^>]*data-id="c"/));
798
+ // Depth 1 (open phase) gets Cancel only
799
+ assert.ok(html.match(/data-action="row-cancel"[^>]*data-id="g"/));
800
+ assert.ok(!html.match(/data-action="row-publish"[^>]*data-id="g"/));
801
+ });
802
+ });