viteboard 0.1.0 → 0.1.1

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.
Files changed (37) hide show
  1. package/README.md +110 -61
  2. package/content/docs/guide/boards-in-markdown.md +28 -26
  3. package/content/docs/guide/boards.md +44 -82
  4. package/content/docs/guide/getting-started.md +36 -48
  5. package/content/docs/index.md +21 -32
  6. package/dist/assets/index-C30zRPsW.js +60 -0
  7. package/dist/assets/index-e5aqtN8W.css +1 -0
  8. package/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/app/AppController.ts +7 -0
  11. package/src/elements/renderElement.ts +89 -0
  12. package/src/elements/types.ts +1 -0
  13. package/src/shell/Shell.ts +4 -6
  14. package/src/shell/Sidebar.ts +382 -83
  15. package/src/styles.css +75 -0
  16. package/src/templates/handoffTemplate.ts +34 -0
  17. package/src/templates/helpers.ts +10 -2
  18. package/src/templates/index.ts +14 -0
  19. package/src/templates/systemDiagramTemplate.ts +6 -6
  20. package/src/templates/workflowTemplate.ts +35 -0
  21. package/src/tools/ArrowTool.ts +11 -1
  22. package/src/tools/SelectTool.ts +1 -1
  23. package/src/tools/ShapeTool.ts +2 -2
  24. package/src/tools/ToolContext.ts +7 -0
  25. package/src/ui/TextEditorOverlay.ts +136 -11
  26. package/src/ui/Toolbar.ts +39 -1
  27. package/src/ui/icons.ts +6 -0
  28. package/vite.config.ts +16 -10
  29. package/content/docs/capabilities.md +0 -89
  30. package/content/docs/docs.md +0 -84
  31. package/content/docs/guide/test.md +0 -6
  32. package/content/docs/test.md +0 -219
  33. package/content/docs/tetst-dir/test-board.board.json +0 -15
  34. package/content/docs/tetst-dir/test-test.md +0 -1
  35. package/content/docs/workspace.md +0 -73
  36. package/dist/assets/index-CEpxLM2o.css +0 -1
  37. package/dist/assets/index-D4xvJdEQ.js +0 -60
@@ -41,9 +41,22 @@ export type SidebarCallbacks = {
41
41
 
42
42
  type SidebarDragItem =
43
43
  | { kind: "doc"; rootId: string; path: string }
44
+ | { kind: "capability"; rootId: string; path: string }
44
45
  | { kind: "board"; rootId: string; boardId: string }
45
46
  | { kind: "dir"; rootId: string; path: string };
46
47
 
48
+ type CanonicalNode = {
49
+ id: string;
50
+ kind: "folder" | "docsRoot" | "doc" | "board" | "capability" | "file";
51
+ name: string;
52
+ projectPath: string;
53
+ root?: DocsRootSummary;
54
+ contentPath?: string;
55
+ /** Docs mode promotes a docs root's children into this physical parent. */
56
+ docsContainerRoot?: DocsRootSummary;
57
+ children: CanonicalNode[];
58
+ };
59
+
47
60
  // ---------------------------------------------------------------------------
48
61
  // Path-segment tree used to group docs roots into a nested hierarchy.
49
62
  // ---------------------------------------------------------------------------
@@ -120,13 +133,15 @@ export class Sidebar {
120
133
  private callbacks: SidebarCallbacks;
121
134
  private contextMenu?: HTMLElement;
122
135
  private draggedItem?: SidebarDragItem;
136
+ private dropExpandTimer?: number;
137
+ private dropExpandTarget?: HTMLElement;
123
138
 
124
139
  /** Last full workspace state — kept so we can re-render on mode toggle. */
125
140
  private lastWorkspace: WorkspaceState = { projects: [] };
126
141
  private lastTreesByRoot = new Map<string, TreeNode[]>();
127
142
  private lastTreesByProject = new Map<string, TreeNode[]>();
128
- /** Track which projects have "browse mode" enabled (shows full filesystem) */
129
- private browseModeProjects = new Set<string>();
143
+ /** Raw mode adds muted project files and folders to the docs-focused tree. */
144
+ private rawModeProjects = new Set<string>();
130
145
 
131
146
  constructor(parent: HTMLElement, callbacks: SidebarCallbacks) {
132
147
  this.callbacks = callbacks;
@@ -214,22 +229,6 @@ export class Sidebar {
214
229
 
215
230
 
216
231
 
217
- /** Exit browse mode for all projects (called after content refresh). */
218
- clearBrowseMode(): void {
219
- if (this.browseModeProjects.size === 0) return;
220
- this.browseModeProjects.clear();
221
- this.render();
222
- }
223
-
224
- private toggleBrowseMode(projectId: string): void {
225
- if (this.browseModeProjects.has(projectId)) {
226
- this.browseModeProjects.delete(projectId);
227
- } else {
228
- this.browseModeProjects.add(projectId);
229
- }
230
- this.render();
231
- }
232
-
233
232
  setActive(hash: string): void {
234
233
  // 1. Clear previous active state.
235
234
  for (const a of new Set(this.links.values())) a.classList.remove("active");
@@ -254,6 +253,12 @@ export class Sidebar {
254
253
  this.syncCollapsed(hash);
255
254
  }
256
255
 
256
+ private toggleRawMode(projectId: string): void {
257
+ if (this.rawModeProjects.has(projectId)) this.rawModeProjects.delete(projectId);
258
+ else this.rawModeProjects.add(projectId);
259
+ this.render();
260
+ }
261
+
257
262
  /**
258
263
  * Open every collapsible that is an ancestor of the active page, while
259
264
  * preserving the user's existing open/close state elsewhere.
@@ -303,18 +308,21 @@ export class Sidebar {
303
308
 
304
309
  // Project header.
305
310
  open.add(projectId);
311
+ // The docs-root folder is a real, collapsible tree row.
312
+ open.add(rootId);
306
313
 
307
- // Path-segment keys: auto-open the filesystem scaffolding leading to /docs/
314
+ // Open real project-folder ancestors leading to the docs root. The docs
315
+ // root itself is represented by its parent's tagged row in Docs mode.
308
316
  const displayParts = displayPath.split("/").filter(Boolean).slice(0, -1);
309
317
  let acc = "";
310
318
  for (const part of displayParts) {
311
319
  acc = acc ? `${acc}/${part}` : part;
312
- const candidate = `pathseg:${projectId}:${acc}`;
313
- if (this.collapsibles.has(candidate)) open.add(candidate);
320
+ const folderKey = `folder:${projectId}:${acc}`;
321
+ if (this.collapsibles.has(folderKey)) open.add(folderKey);
314
322
  }
315
323
 
316
324
  // Doc directory keys: every parent dir of the current file path.
317
- if (section === "docs" && rest) {
325
+ if ((section === "docs" || section === "capabilities") && rest) {
318
326
  const parts = rest.split("/");
319
327
  for (let i = 1; i < parts.length; i++) {
320
328
  const dirPath = parts.slice(0, i).join("/");
@@ -333,7 +341,7 @@ export class Sidebar {
333
341
  treesByRoot: Map<string, TreeNode[]>
334
342
  ): HTMLElement {
335
343
  const { id: projectId, label, docsRoots: roots } = project;
336
- const isRaw = this.browseModeProjects.has(projectId);
344
+ const isRaw = this.rawModeProjects.has(projectId);
337
345
 
338
346
  const body = document.createElement("div");
339
347
  body.className = "nav-repo-body";
@@ -351,8 +359,13 @@ export class Sidebar {
351
359
  const text = document.createElement("span");
352
360
  text.className = "nav-repo-label-text";
353
361
  text.textContent = label;
362
+ const projectDocsRoot = !isRaw && roots.find((root) => normalizeDirPath(root.displayPath) === "docs");
363
+ const docsBadge = document.createElement("span");
364
+ docsBadge.className = "nav-tree-badge";
365
+ docsBadge.textContent = "DOCS";
366
+ docsBadge.hidden = !projectDocsRoot;
354
367
 
355
- // Three-dot menu button (replaces wiki link + toggle)
368
+ // Project-level actions never alter the navigation model.
356
369
  const moreBtn = document.createElement("button");
357
370
  moreBtn.className = "nav-repo-more";
358
371
  moreBtn.type = "button";
@@ -366,7 +379,7 @@ export class Sidebar {
366
379
  icon: isRaw ? DOC_ICON_HTML : DIRECTORY_ICON_HTML,
367
380
  iconClass: isRaw ? "nav-ctx-doc" : "nav-ctx-dir",
368
381
  label: isRaw ? "View: Docs" : "View: Raw",
369
- action: () => this.toggleBrowseMode(projectId),
382
+ action: () => this.toggleRawMode(projectId),
370
383
  },
371
384
  {
372
385
  icon: EDIT_ICON_HTML, iconClass: "nav-ctx-edit", label: "Wiki",
@@ -394,7 +407,7 @@ export class Sidebar {
394
407
  ]);
395
408
  });
396
409
 
397
- header.append(chevron, text, moreBtn);
410
+ header.append(chevron, text, docsBadge, moreBtn);
398
411
 
399
412
  this.registerCollapsible(projectId, body, group, header as HTMLButtonElement, true);
400
413
  header.addEventListener("click", () => this.toggleCollapsible(projectId));
@@ -406,67 +419,137 @@ export class Sidebar {
406
419
  moreBtn.click();
407
420
  });
408
421
 
409
- const projectDropRoot = roots[0];
410
- if (projectDropRoot) this.enableDirectoryDropTarget(header, projectDropRoot.id, "");
422
+ const tree = buildCanonicalTree(project, this.lastTreesByProject.get(projectId) ?? [], treesByRoot);
423
+ const visibleTree = isRaw
424
+ ? tree
425
+ : collapseDocsRootRows(visibleCanonicalNodes(tree, false));
426
+ if (visibleTree.length === 0) {
427
+ const note = document.createElement("div");
428
+ note.className = "nav-label nav-empty-copy";
429
+ note.textContent = "No folders found.";
430
+ body.appendChild(note);
431
+ } else {
432
+ body.appendChild(this.renderCanonicalNodes(project, visibleTree, 0, isRaw));
433
+ }
411
434
 
412
- // ── Render tree: one unified view, same structure in both modes ──
413
- if (isRaw) {
414
- // Raw mode: full project filesystem. /docs/ folders render full content.
415
- // Non-docs dirs are subtly muted. Right-click non-docs → create /docs/.
416
- const projectTree = this.lastTreesByProject.get(projectId) ?? [];
417
- for (const node of projectTree) {
418
- if (node.type === "dir") {
419
- body.appendChild(this.renderBrowseNode(project, node, treesByRoot));
420
- }
435
+ group.append(header, body);
436
+ return group;
437
+ }
438
+
439
+ /** Render the one project-relative tree used by the sidebar. */
440
+ private renderCanonicalNodes(project: WorkspaceProject, nodes: CanonicalNode[], depth: number, isRaw: boolean): HTMLElement {
441
+ const fragment = document.createElement("div");
442
+ for (const node of sortCanonicalNodes(nodes)) {
443
+ if (node.kind === "doc" || node.kind === "board" || node.kind === "capability" || node.kind === "file") {
444
+ fragment.appendChild(this.renderCanonicalFile(node, depth));
445
+ continue;
421
446
  }
422
- } else {
423
- // Docs mode: only /docs/ roots and their content.
424
- if (roots.length === 0) {
425
- const note = document.createElement("div");
426
- note.className = "nav-label nav-empty-copy";
427
- note.textContent = "No docs folders found.";
428
- body.appendChild(note);
447
+
448
+ const group = document.createElement("div");
449
+ group.className = "nav-tree-group";
450
+ group.style.setProperty("--tree-depth", String(depth));
451
+ const body = document.createElement("div");
452
+ body.className = "nav-tree-children";
453
+ const toggle = document.createElement("button");
454
+ toggle.className = "nav-tree-row nav-tree-folder" +
455
+ (node.kind === "docsRoot" ? " nav-tree-docs-root" : "") +
456
+ (node.docsContainerRoot ? " nav-tree-docs-container" : "") +
457
+ (!node.root ? " nav-tree-scaffold" : "");
458
+ toggle.type = "button";
459
+ toggle.style.setProperty("--tree-depth", String(depth));
460
+ const chevron = document.createElement("span");
461
+ chevron.className = "nav-repo-chevron";
462
+ chevron.textContent = "›";
463
+ const label = document.createElement("span");
464
+ label.textContent = node.name;
465
+ toggle.append(chevron, label);
466
+ if (node.kind === "docsRoot" || node.docsContainerRoot) {
467
+ const badge = document.createElement("span");
468
+ badge.className = "nav-tree-badge";
469
+ badge.textContent = "DOCS";
470
+ toggle.appendChild(badge);
471
+ }
472
+
473
+ const key = node.kind === "docsRoot" || node.docsContainerRoot
474
+ ? (node.docsContainerRoot ?? node.root)!.id
475
+ : node.root
476
+ ? `doc:${node.root.id}:${node.contentPath ?? ""}`
477
+ : `folder:${project.id}:${node.projectPath}`;
478
+ this.registerCollapsible(key, body, group, toggle, true);
479
+ toggle.addEventListener("click", () => this.toggleCollapsible(key));
480
+
481
+ if (node.kind === "docsRoot" || node.docsContainerRoot) {
482
+ const root = node.docsContainerRoot ?? node.root!;
483
+ toggle.addEventListener("contextmenu", (e) => {
484
+ e.preventDefault();
485
+ this.openDirContextMenu(e.clientX, e.clientY, root, "");
486
+ });
487
+ this.enableDirectoryDropTarget(group, root.id, "");
488
+ } else if (node.root) {
489
+ const root = node.root;
490
+ const contentPath = node.contentPath ?? "";
491
+ this.enableDragSource(toggle, { kind: "dir", rootId: root.id, path: contentPath });
492
+ this.enableDirectoryDropTarget(group, root.id, contentPath);
493
+ group.addEventListener("contextmenu", (e) => {
494
+ e.preventDefault();
495
+ this.openDirContextMenu(e.clientX, e.clientY, root, contentPath);
496
+ });
429
497
  } else {
430
- const pathTree = buildPathTree(roots);
431
- body.appendChild(this.renderPathNodes(pathTree, treesByRoot, projectId));
498
+ toggle.addEventListener("contextmenu", (e) => {
499
+ e.preventDefault();
500
+ this.showContextMenu(e.clientX, e.clientY, node.name, [{
501
+ icon: DIRECTORY_ICON_HTML,
502
+ iconClass: "nav-ctx-dir",
503
+ label: "Create docs folder here",
504
+ action: () => this.callbacks.onCreateDocsFolder(project.id, node.projectPath),
505
+ }]);
506
+ });
432
507
  }
433
- }
434
508
 
435
- // ── Footer links (always visible when docs exist) ──
436
- if (roots.length > 0) {
437
- const boardsRow = document.createElement("div");
438
- boardsRow.className = "nav-project-boards-row";
439
- const allBoardsLink = this.link(
440
- projectBoardsHref(projectId),
441
- "All Boards",
442
- `boards:${projectId}`
443
- );
444
- allBoardsLink.classList.add("nav-link-board");
445
- allBoardsLink.insertAdjacentHTML("afterbegin", BOARD_ICON_HTML);
446
- boardsRow.appendChild(allBoardsLink);
447
- body.appendChild(boardsRow);
448
-
449
- const firstRootWithCaps = roots.find((r) => {
450
- const tree = treesByRoot.get(r.id) ?? [];
451
- const capsDir = tree.find((n) => n.type === "dir" && n.name === "capabilities");
452
- return capsDir?.type === "dir" &&
453
- capsDir.children.some((n) => n.type === "file" && n.name.endsWith(".md"));
454
- }) ?? roots[0];
455
- const capsRow = document.createElement("div");
456
- capsRow.className = "nav-project-boards-row";
457
- const allCapsLink = this.link(
458
- rootCapabilitiesHref(firstRootWithCaps.id),
459
- "All Capabilities",
460
- `caps:${projectId}`
461
- );
462
- allCapsLink.classList.add("nav-link-capability");
463
- allCapsLink.insertAdjacentHTML("afterbegin", CAPABILITY_ICON_HTML);
464
- capsRow.appendChild(allCapsLink);
465
- body.appendChild(capsRow);
509
+ body.appendChild(this.renderCanonicalNodes(project, node.children, depth + 1, isRaw));
510
+ group.append(toggle, body);
511
+ fragment.appendChild(group);
466
512
  }
513
+ return fragment;
514
+ }
467
515
 
468
- group.append(header, body);
469
- return group;
516
+ private renderCanonicalFile(node: CanonicalNode, depth: number): HTMLElement {
517
+ if (node.kind === "file") {
518
+ const file = document.createElement("div");
519
+ file.className = "nav-tree-row nav-tree-file";
520
+ file.style.setProperty("--tree-depth", String(depth));
521
+ file.textContent = node.name;
522
+ return file;
523
+ }
524
+ const root = node.root!;
525
+ const path = node.contentPath!;
526
+ const href = node.kind === "doc"
527
+ ? rootDocHref(root.id, path)
528
+ : node.kind === "board"
529
+ ? rootBoardHref(root.id, boardIdFromFilePath(path))
530
+ : rootCapabilityHref(root.id, path);
531
+ const label = node.kind === "doc" && node.name === "index.md"
532
+ ? "Overview"
533
+ : prettify(node.kind === "board" ? boardIdFromFilePath(node.name) : node.name);
534
+ const link = this.link(href, label, `${node.kind}:${root.id}:${path}`);
535
+ link.classList.add("nav-tree-row", `nav-tree-${node.kind}`);
536
+ link.style.setProperty("--tree-depth", String(depth));
537
+ link.insertAdjacentHTML("afterbegin", node.kind === "board"
538
+ ? BOARD_ICON_HTML
539
+ : node.kind === "capability"
540
+ ? CAPABILITY_ICON_HTML
541
+ : DOC_ICON_HTML);
542
+ this.enableDragSource(link, node.kind === "board"
543
+ ? { kind: "board", rootId: root.id, boardId: boardIdFromFilePath(path) }
544
+ : node.kind === "capability"
545
+ ? { kind: "capability", rootId: root.id, path }
546
+ : { kind: "doc", rootId: root.id, path });
547
+ link.addEventListener("contextmenu", (e) => {
548
+ e.preventDefault();
549
+ if (node.kind === "board") this.openBoardContextMenu(e.clientX, e.clientY, root, boardIdFromFilePath(path));
550
+ else if (node.kind === "doc") this.openDocContextMenu(e.clientX, e.clientY, root, path);
551
+ });
552
+ return link;
470
553
  }
471
554
 
472
555
  // ---------- Browse mode (filesystem view) ----------
@@ -828,21 +911,45 @@ export class Sidebar {
828
911
  e.stopPropagation();
829
912
  if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
830
913
  el.classList.add("nav-drop-target");
914
+ this.scheduleDropExpand(el);
831
915
  });
832
916
  el.addEventListener("dragleave", (e) => {
833
917
  if (el.contains(e.relatedTarget as Node | null)) return;
834
918
  el.classList.remove("nav-drop-target");
919
+ this.clearDropExpand(el);
835
920
  });
836
921
  el.addEventListener("drop", (e) => {
837
922
  const item = this.currentDragItem(e);
838
923
  if (!item || !this.canDropItem(item, rootId, targetDir)) return;
839
924
  e.preventDefault();
840
925
  e.stopPropagation();
926
+ this.clearDropExpand(el);
841
927
  this.clearDropTargets();
842
928
  this.moveDraggedItem(item, targetDir);
843
929
  });
844
930
  }
845
931
 
932
+ /** Match IDE tree behavior: pause over a valid collapsed folder to open it. */
933
+ private scheduleDropExpand(el: HTMLElement): void {
934
+ if (this.dropExpandTarget === el || !el.dataset.collapsibleKey) return;
935
+ this.clearDropExpand();
936
+ this.dropExpandTarget = el;
937
+ this.dropExpandTimer = window.setTimeout(() => {
938
+ const key = el.dataset.collapsibleKey;
939
+ if (key) this.applyCollapsed(key, false);
940
+ el.classList.add("nav-drop-auto-expanded");
941
+ this.dropExpandTimer = undefined;
942
+ }, 650);
943
+ }
944
+
945
+ private clearDropExpand(target?: HTMLElement): void {
946
+ if (target && this.dropExpandTarget !== target) return;
947
+ if (this.dropExpandTimer !== undefined) window.clearTimeout(this.dropExpandTimer);
948
+ this.dropExpandTimer = undefined;
949
+ this.dropExpandTarget?.classList.remove("nav-drop-auto-expanded");
950
+ this.dropExpandTarget = undefined;
951
+ }
952
+
846
953
  private currentDragItem(e: DragEvent): SidebarDragItem | undefined {
847
954
  if (this.draggedItem) return this.draggedItem;
848
955
  const raw = e.dataTransfer?.getData("application/x-docs-nav-item");
@@ -851,7 +958,7 @@ export class Sidebar {
851
958
  const parsed = JSON.parse(raw) as SidebarDragItem;
852
959
  if (
853
960
  parsed &&
854
- (parsed.kind === "doc" || parsed.kind === "board" || parsed.kind === "dir") &&
961
+ (parsed.kind === "doc" || parsed.kind === "capability" || parsed.kind === "board" || parsed.kind === "dir") &&
855
962
  typeof parsed.rootId === "string"
856
963
  ) {
857
964
  return parsed;
@@ -866,6 +973,10 @@ export class Sidebar {
866
973
  if (item.rootId !== rootId) return false;
867
974
  const normalizedTarget = normalizeDirPath(targetDir);
868
975
  if (item.kind === "doc") return parentDir(item.path) !== normalizedTarget;
976
+ if (item.kind === "capability") {
977
+ return normalizedTarget === "capabilities" ||
978
+ (normalizedTarget.startsWith("capabilities/") && parentDir(item.path) !== normalizedTarget);
979
+ }
869
980
  if (item.kind === "board") return parentDir(item.boardId) !== normalizedTarget;
870
981
 
871
982
  const source = normalizeDirPath(item.path);
@@ -877,7 +988,7 @@ export class Sidebar {
877
988
 
878
989
  private moveDraggedItem(item: SidebarDragItem, targetDir: string): void {
879
990
  const normalizedTarget = normalizeDirPath(targetDir);
880
- if (item.kind === "doc") {
991
+ if (item.kind === "doc" || item.kind === "capability") {
881
992
  this.callbacks.onMoveDoc(item.rootId, item.path, normalizedTarget);
882
993
  } else if (item.kind === "board") {
883
994
  this.callbacks.onMoveBoard(item.rootId, item.boardId, normalizedTarget);
@@ -887,6 +998,7 @@ export class Sidebar {
887
998
  }
888
999
 
889
1000
  private clearDropTargets(): void {
1001
+ this.clearDropExpand();
890
1002
  this.element.querySelectorAll(".nav-drop-target").forEach((node) => {
891
1003
  node.classList.remove("nav-drop-target");
892
1004
  });
@@ -984,6 +1096,7 @@ export class Sidebar {
984
1096
  defaultCollapsed: boolean
985
1097
  ): void {
986
1098
  this.collapsibles.set(key, { body, group, btn });
1099
+ group.dataset.collapsibleKey = key;
987
1100
  this.applyCollapsed(key, this.collapsedState.get(key) ?? defaultCollapsed);
988
1101
  }
989
1102
 
@@ -1310,6 +1423,192 @@ function rootCapabilitiesHref(rootId: string): string {
1310
1423
  return `#/root/${encodeURIComponent(rootId)}/capabilities`;
1311
1424
  }
1312
1425
 
1426
+ function rootCapabilityHref(rootId: string, path: string): string {
1427
+ return `#/root/${encodeURIComponent(rootId)}/capabilities/${encodeURIComponent(path)}`;
1428
+ }
1429
+
1430
+ /**
1431
+ * Merge the project directory tree and each mounted docs-root tree by their
1432
+ * project-relative path. A physical folder is represented exactly once.
1433
+ */
1434
+ function buildCanonicalTree(
1435
+ project: WorkspaceProject,
1436
+ projectTree: TreeNode[],
1437
+ treesByRoot: Map<string, TreeNode[]>
1438
+ ): CanonicalNode[] {
1439
+ const byPath = new Map<string, CanonicalNode>();
1440
+ const roots: CanonicalNode[] = [];
1441
+
1442
+ const ensureFolder = (projectPath: string): CanonicalNode => {
1443
+ const normalized = normalizeDirPath(projectPath);
1444
+ const existing = byPath.get(normalized);
1445
+ if (existing) return existing;
1446
+ const parentPath = parentDir(normalized);
1447
+ const node: CanonicalNode = {
1448
+ id: `folder:${project.id}:${normalized}`,
1449
+ kind: "folder",
1450
+ name: normalized.split("/").pop() ?? project.label,
1451
+ projectPath: normalized,
1452
+ children: [],
1453
+ };
1454
+ byPath.set(normalized, node);
1455
+ // The project itself is the implicit root. Never create a blank tree row
1456
+ // just to parent top-level folders.
1457
+ if (normalized && parentPath) ensureFolder(parentPath).children.push(node);
1458
+ else roots.push(node);
1459
+ return node;
1460
+ };
1461
+
1462
+ const addProjectFolders = (nodes: TreeNode[]) => {
1463
+ for (const node of nodes) {
1464
+ if (node.type === "dir") {
1465
+ ensureFolder(node.path);
1466
+ addProjectFolders(node.children);
1467
+ } else {
1468
+ const file: CanonicalNode = {
1469
+ id: `file:${project.id}:${node.path}`,
1470
+ kind: "file",
1471
+ name: node.name,
1472
+ projectPath: node.path,
1473
+ children: [],
1474
+ };
1475
+ byPath.set(node.path, file);
1476
+ const parentPath = parentDir(node.path);
1477
+ if (parentPath) ensureFolder(parentPath).children.push(file);
1478
+ else roots.push(file);
1479
+ }
1480
+ }
1481
+ };
1482
+ addProjectFolders(projectTree);
1483
+
1484
+ for (const root of project.docsRoots) {
1485
+ const rootPath = normalizeDirPath(root.displayPath);
1486
+ const rootNode = ensureFolder(rootPath);
1487
+ rootNode.kind = "docsRoot";
1488
+ rootNode.id = `root:${root.id}`;
1489
+ rootNode.root = root;
1490
+ rootNode.contentPath = "";
1491
+
1492
+ const addDocsNodes = (nodes: TreeNode[]) => {
1493
+ for (const node of nodes) {
1494
+ const projectPath = [rootPath, node.path].filter(Boolean).join("/");
1495
+ if (node.type === "dir") {
1496
+ const folder = ensureFolder(projectPath);
1497
+ // A nested docs root is assigned when its own root is processed.
1498
+ if (folder.kind !== "docsRoot") {
1499
+ folder.root = root;
1500
+ folder.contentPath = node.path;
1501
+ }
1502
+ addDocsNodes(node.children);
1503
+ continue;
1504
+ }
1505
+ if (!node.name.endsWith(".md") && !node.name.endsWith(".board.json")) continue;
1506
+ const parentPath = parentDir(projectPath);
1507
+ const isBoard = node.name.endsWith(".board.json");
1508
+ const isCapability = node.path.startsWith("capabilities/");
1509
+ const existing = byPath.get(projectPath);
1510
+ const content = existing ?? {
1511
+ id: "",
1512
+ kind: "file" as const,
1513
+ name: node.name,
1514
+ projectPath,
1515
+ children: [],
1516
+ };
1517
+ content.id = `${isBoard ? "board" : isCapability ? "capability" : "doc"}:${root.id}:${node.path}`;
1518
+ content.kind = isBoard ? "board" : isCapability ? "capability" : "doc";
1519
+ content.root = root;
1520
+ content.contentPath = node.path;
1521
+ if (!existing) {
1522
+ byPath.set(projectPath, content);
1523
+ if (parentPath) ensureFolder(parentPath).children.push(content);
1524
+ else roots.push(content);
1525
+ }
1526
+ }
1527
+ };
1528
+ addDocsNodes(treesByRoot.get(root.id) ?? []);
1529
+ }
1530
+
1531
+ const assignRoot = (node: CanonicalNode, inherited?: DocsRootSummary): void => {
1532
+ const owner = node.kind === "docsRoot" ? node.root : node.root ?? inherited;
1533
+ if (node.kind === "folder" && owner) {
1534
+ node.root = owner;
1535
+ const rootPath = normalizeDirPath(owner.displayPath);
1536
+ node.contentPath = node.projectPath.slice(rootPath.length).replace(/^\//, "");
1537
+ }
1538
+ for (const child of node.children) assignRoot(child, owner);
1539
+ };
1540
+ for (const node of roots) assignRoot(node);
1541
+ return roots;
1542
+ }
1543
+
1544
+ function sortCanonicalNodes(nodes: CanonicalNode[]): CanonicalNode[] {
1545
+ return [...nodes].sort((a, b) => {
1546
+ const rank = (node: CanonicalNode) => {
1547
+ if (node.kind === "doc" && node.name === "index.md") return 0;
1548
+ // Content is the primary navigation surface. Folders follow it as
1549
+ // structural organization, matching the focused docs workflow.
1550
+ if (node.kind === "doc") return 1;
1551
+ if (node.kind === "capability") return 2;
1552
+ if (node.kind === "board") return 3;
1553
+ if (node.kind === "folder" || node.kind === "docsRoot") return 4;
1554
+ return 5;
1555
+ };
1556
+ return rank(a) - rank(b) || a.name.localeCompare(b.name, undefined, { sensitivity: "accent" });
1557
+ });
1558
+ }
1559
+
1560
+ /** Docs mode keeps docs roots and their physical ancestor paths only. */
1561
+ function visibleCanonicalNodes(nodes: CanonicalNode[], isRaw: boolean, insideDocsRoot = false): CanonicalNode[] {
1562
+ if (isRaw) return nodes;
1563
+ const visible: CanonicalNode[] = [];
1564
+ for (const node of nodes) {
1565
+ if (node.kind === "docsRoot") {
1566
+ visible.push({ ...node, children: visibleCanonicalNodes(node.children, false, true) });
1567
+ continue;
1568
+ }
1569
+ if (insideDocsRoot) {
1570
+ if (node.kind === "file") continue;
1571
+ if (node.kind === "folder") {
1572
+ const children = visibleCanonicalNodes(node.children, false, true);
1573
+ if (node.name !== "assets" || children.length > 0) visible.push({ ...node, children });
1574
+ } else {
1575
+ visible.push(node);
1576
+ }
1577
+ continue;
1578
+ }
1579
+ if (node.kind === "folder") {
1580
+ const children = visibleCanonicalNodes(node.children, false);
1581
+ if (children.length > 0) visible.push({ ...node, children });
1582
+ }
1583
+ }
1584
+ return visible;
1585
+ }
1586
+
1587
+ /**
1588
+ * Docs mode keeps the physical path to a docs root but removes the redundant
1589
+ * docs row itself. Its parent owns the DOCS badge and root-level actions.
1590
+ */
1591
+ function collapseDocsRootRows(nodes: CanonicalNode[]): CanonicalNode[] {
1592
+ const out: CanonicalNode[] = [];
1593
+ for (const node of nodes) {
1594
+ if (node.kind === "docsRoot") {
1595
+ out.push(...collapseDocsRootRows(node.children));
1596
+ continue;
1597
+ }
1598
+ if (node.kind !== "folder") {
1599
+ out.push(node);
1600
+ continue;
1601
+ }
1602
+ const directRoot = node.children.find((child) => child.kind === "docsRoot")?.root;
1603
+ out.push({
1604
+ ...node,
1605
+ docsContainerRoot: directRoot,
1606
+ children: collapseDocsRootRows(node.children),
1607
+ });
1608
+ }
1609
+ return out;
1610
+ }
1611
+
1313
1612
  function prettify(name: string): string {
1314
1613
  const base = name.replace(/\.md$/, "").replace(/[-_]/g, " ");
1315
1614
  return base.charAt(0).toUpperCase() + base.slice(1);
@@ -1336,7 +1635,7 @@ function parentDir(path: string): string {
1336
1635
  }
1337
1636
 
1338
1637
  function dragItemLabel(item: SidebarDragItem): string {
1339
- if (item.kind === "doc") return item.path;
1638
+ if (item.kind === "doc" || item.kind === "capability") return item.path;
1340
1639
  if (item.kind === "board") return item.boardId;
1341
1640
  return item.path;
1342
1641
  }