@flamingo-stack/openframe-frontend-core 0.0.285 → 0.0.286-snapshot.20260618154650

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 (224) hide show
  1. package/dist/chunk-3MCHAFHB.js +89 -0
  2. package/dist/chunk-3MCHAFHB.js.map +1 -0
  3. package/dist/chunk-3SDBXXDP.cjs +1594 -0
  4. package/dist/chunk-3SDBXXDP.cjs.map +1 -0
  5. package/dist/{chunk-IMNGGLKL.js → chunk-3ZXUQQL4.js} +2 -2
  6. package/dist/chunk-5KD3S25X.cjs +312 -0
  7. package/dist/chunk-5KD3S25X.cjs.map +1 -0
  8. package/dist/{chunk-44ZFLMYC.cjs → chunk-6AW25OS6.cjs} +30 -30
  9. package/dist/{chunk-44ZFLMYC.cjs.map → chunk-6AW25OS6.cjs.map} +1 -1
  10. package/dist/{chunk-XDI5JSWB.cjs → chunk-6CSW5TMS.cjs} +45 -41
  11. package/dist/chunk-6CSW5TMS.cjs.map +1 -0
  12. package/dist/chunk-7EYWERFT.js +1594 -0
  13. package/dist/chunk-7EYWERFT.js.map +1 -0
  14. package/dist/{chunk-XX7P4IL2.js → chunk-7NM7DEUK.js} +4 -143
  15. package/dist/chunk-7NM7DEUK.js.map +1 -0
  16. package/dist/{chunk-GVSTRN36.cjs → chunk-D3LEFMOA.cjs} +3 -3
  17. package/dist/{chunk-GVSTRN36.cjs.map → chunk-D3LEFMOA.cjs.map} +1 -1
  18. package/dist/chunk-D6RK5YXX.cjs +2439 -0
  19. package/dist/chunk-D6RK5YXX.cjs.map +1 -0
  20. package/dist/{chunk-VQW7CEGS.js → chunk-EFYXPR43.js} +1831 -3672
  21. package/dist/chunk-EFYXPR43.js.map +1 -0
  22. package/dist/{chunk-PVXCVPJD.cjs → chunk-EI4WALN2.cjs} +67 -1626
  23. package/dist/chunk-EI4WALN2.cjs.map +1 -0
  24. package/dist/{chunk-ZIODAAFY.cjs → chunk-EL5GCMPU.cjs} +20 -159
  25. package/dist/chunk-EL5GCMPU.cjs.map +1 -0
  26. package/dist/{chunk-53CFL7PH.js → chunk-GJDXIVEQ.js} +11 -7
  27. package/dist/{chunk-53CFL7PH.js.map → chunk-GJDXIVEQ.js.map} +1 -1
  28. package/dist/chunk-I6ZPGKZ2.cjs +151 -0
  29. package/dist/chunk-I6ZPGKZ2.cjs.map +1 -0
  30. package/dist/{chunk-P45Q4QCU.cjs → chunk-JQ4I743L.cjs} +19 -15
  31. package/dist/chunk-JQ4I743L.cjs.map +1 -0
  32. package/dist/{chunk-J66U3LPZ.cjs → chunk-KBKZYJRI.cjs} +33 -332
  33. package/dist/chunk-KBKZYJRI.cjs.map +1 -0
  34. package/dist/chunk-MV67MBV3.js +290 -0
  35. package/dist/chunk-MV67MBV3.js.map +1 -0
  36. package/dist/chunk-MWS25U4U.cjs +290 -0
  37. package/dist/chunk-MWS25U4U.cjs.map +1 -0
  38. package/dist/{chunk-USPATRSC.js → chunk-N6ZM5PYZ.js} +45 -344
  39. package/dist/chunk-N6ZM5PYZ.js.map +1 -0
  40. package/dist/chunk-ODR6A6FC.js +2439 -0
  41. package/dist/chunk-ODR6A6FC.js.map +1 -0
  42. package/dist/{chunk-DPKUSBQH.cjs → chunk-OXC72UIP.cjs} +1073 -2914
  43. package/dist/chunk-OXC72UIP.cjs.map +1 -0
  44. package/dist/{chunk-RGALV4UX.js → chunk-POKKCWKF.js} +3 -82
  45. package/dist/chunk-POKKCWKF.js.map +1 -0
  46. package/dist/{chunk-Z3V2SGQZ.js → chunk-R2KT5GDD.js} +324 -1883
  47. package/dist/chunk-R2KT5GDD.js.map +1 -0
  48. package/dist/{chunk-RJUGNL2A.cjs → chunk-RG6FNZUA.cjs} +73 -31
  49. package/dist/chunk-RG6FNZUA.cjs.map +1 -0
  50. package/dist/{chunk-23UOJUDF.js → chunk-RWCA2ZQK.js} +4 -4
  51. package/dist/chunk-TFSYSWPS.cjs +89 -0
  52. package/dist/chunk-TFSYSWPS.cjs.map +1 -0
  53. package/dist/{chunk-KNCDUWY2.js → chunk-TY2EB7VK.js} +65 -23
  54. package/dist/chunk-TY2EB7VK.js.map +1 -0
  55. package/dist/chunk-TYZEMPPH.js +312 -0
  56. package/dist/chunk-TYZEMPPH.js.map +1 -0
  57. package/dist/chunk-VRSXJ5QJ.js +1599 -0
  58. package/dist/chunk-VRSXJ5QJ.js.map +1 -0
  59. package/dist/chunk-Y4JNA4W6.cjs +1599 -0
  60. package/dist/chunk-Y4JNA4W6.cjs.map +1 -0
  61. package/dist/{chunk-ZSUNQJRO.cjs → chunk-YETA25JW.cjs} +2 -81
  62. package/dist/chunk-YETA25JW.cjs.map +1 -0
  63. package/dist/chunk-ZLN6SM2U.js +151 -0
  64. package/dist/chunk-ZLN6SM2U.js.map +1 -0
  65. package/dist/{chunk-O5N57CRV.js → chunk-ZYLQMCHW.js} +12 -8
  66. package/dist/{chunk-O5N57CRV.js.map → chunk-ZYLQMCHW.js.map} +1 -1
  67. package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
  68. package/dist/components/chat/index.cjs +12 -6
  69. package/dist/components/chat/index.cjs.map +1 -1
  70. package/dist/components/chat/index.js +45 -39
  71. package/dist/components/contact/index.cjs +11 -7
  72. package/dist/components/contact/index.cjs.map +1 -1
  73. package/dist/components/contact/index.js +10 -6
  74. package/dist/components/docs/doc-navigation-context.d.ts +39 -0
  75. package/dist/components/docs/doc-navigation-context.d.ts.map +1 -0
  76. package/dist/components/docs/doc-viewer.d.ts +66 -0
  77. package/dist/components/docs/doc-viewer.d.ts.map +1 -0
  78. package/dist/components/docs/index.cjs +34 -0
  79. package/dist/components/docs/index.cjs.map +1 -0
  80. package/dist/components/docs/index.d.ts +8 -0
  81. package/dist/components/docs/index.d.ts.map +1 -0
  82. package/dist/components/docs/index.js +34 -0
  83. package/dist/components/docs/index.js.map +1 -0
  84. package/dist/components/docs/use-document-tree.d.ts +41 -0
  85. package/dist/components/docs/use-document-tree.d.ts.map +1 -0
  86. package/dist/components/docs/use-scroll-spy.d.ts +16 -0
  87. package/dist/components/docs/use-scroll-spy.d.ts.map +1 -0
  88. package/dist/components/embeds/embed-iframe.d.ts +29 -0
  89. package/dist/components/embeds/embed-iframe.d.ts.map +1 -0
  90. package/dist/components/embeds/figma-embed.d.ts +27 -0
  91. package/dist/components/embeds/figma-embed.d.ts.map +1 -0
  92. package/dist/components/embeds/google-sheets-viewer.d.ts +8 -0
  93. package/dist/components/embeds/google-sheets-viewer.d.ts.map +1 -0
  94. package/dist/components/embeds/index.cjs +37 -0
  95. package/dist/components/embeds/index.cjs.map +1 -0
  96. package/dist/components/embeds/index.d.ts +9 -0
  97. package/dist/components/embeds/index.d.ts.map +1 -0
  98. package/dist/components/embeds/index.js +37 -0
  99. package/dist/components/embeds/index.js.map +1 -0
  100. package/dist/components/embeds/pdf-viewer.d.ts +10 -0
  101. package/dist/components/embeds/pdf-viewer.d.ts.map +1 -0
  102. package/dist/components/faq/faq-section.d.ts.map +1 -1
  103. package/dist/components/faq/index.cjs +12 -8
  104. package/dist/components/faq/index.cjs.map +1 -1
  105. package/dist/components/faq/index.js +11 -7
  106. package/dist/components/faq-accordion.d.ts.map +1 -1
  107. package/dist/components/features/index.cjs +10 -6
  108. package/dist/components/features/index.cjs.map +1 -1
  109. package/dist/components/features/index.js +9 -5
  110. package/dist/components/index.cjs +247 -1120
  111. package/dist/components/index.cjs.map +1 -1
  112. package/dist/components/index.d.ts +2 -0
  113. package/dist/components/index.d.ts.map +1 -1
  114. package/dist/components/index.js +645 -1518
  115. package/dist/components/index.js.map +1 -1
  116. package/dist/components/navigation/index.cjs +15 -6
  117. package/dist/components/navigation/index.cjs.map +1 -1
  118. package/dist/components/navigation/index.d.ts +2 -0
  119. package/dist/components/navigation/index.d.ts.map +1 -1
  120. package/dist/components/navigation/index.js +15 -6
  121. package/dist/components/navigation/multi-level-navigation.d.ts +28 -0
  122. package/dist/components/navigation/multi-level-navigation.d.ts.map +1 -0
  123. package/dist/components/onboarding-guides/index.cjs +34 -29
  124. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  125. package/dist/components/onboarding-guides/index.js +13 -8
  126. package/dist/components/onboarding-guides/index.js.map +1 -1
  127. package/dist/components/related-content/index.cjs +12 -8
  128. package/dist/components/related-content/index.cjs.map +1 -1
  129. package/dist/components/related-content/index.js +11 -7
  130. package/dist/components/tickets/index.cjs +75 -70
  131. package/dist/components/tickets/index.cjs.map +1 -1
  132. package/dist/components/tickets/index.js +16 -11
  133. package/dist/components/tickets/index.js.map +1 -1
  134. package/dist/components/ui/index.cjs +12 -6
  135. package/dist/components/ui/index.cjs.map +1 -1
  136. package/dist/components/ui/index.js +65 -59
  137. package/dist/embed-shims/index.cjs +3 -3
  138. package/dist/embed-shims/index.cjs.map +1 -1
  139. package/dist/embed-shims/index.js +8 -8
  140. package/dist/hooks/index.cjs +6 -3
  141. package/dist/hooks/index.cjs.map +1 -1
  142. package/dist/hooks/index.js +7 -4
  143. package/dist/index.cjs +68 -6
  144. package/dist/index.cjs.map +1 -1
  145. package/dist/index.js +263 -201
  146. package/dist/types/doc-source.cjs +4 -0
  147. package/dist/types/doc-source.cjs.map +1 -0
  148. package/dist/types/doc-source.d.ts +86 -0
  149. package/dist/types/doc-source.d.ts.map +1 -0
  150. package/dist/types/doc-source.js +3 -0
  151. package/dist/types/doc-source.js.map +1 -0
  152. package/dist/types/index.d.ts +1 -0
  153. package/dist/types/index.d.ts.map +1 -1
  154. package/dist/types/marketing.d.ts +1 -1
  155. package/dist/types/marketing.d.ts.map +1 -1
  156. package/dist/utils/doc-path-utils.d.ts +24 -0
  157. package/dist/utils/doc-path-utils.d.ts.map +1 -0
  158. package/dist/utils/doc-tree-nav.d.ts +35 -0
  159. package/dist/utils/doc-tree-nav.d.ts.map +1 -0
  160. package/dist/utils/embed-url-converters.d.ts +16 -0
  161. package/dist/utils/embed-url-converters.d.ts.map +1 -0
  162. package/dist/utils/faq-anchor.d.ts +51 -0
  163. package/dist/utils/faq-anchor.d.ts.map +1 -0
  164. package/dist/utils/format.d.ts +9 -0
  165. package/dist/utils/format.d.ts.map +1 -1
  166. package/dist/utils/index.cjs +320 -1
  167. package/dist/utils/index.cjs.map +1 -1
  168. package/dist/utils/index.d.ts +9 -1
  169. package/dist/utils/index.d.ts.map +1 -1
  170. package/dist/utils/index.js +296 -2
  171. package/dist/utils/index.js.map +1 -1
  172. package/dist/utils/list-url.d.ts.map +1 -1
  173. package/dist/utils/markdown-section-extractor.d.ts +23 -0
  174. package/dist/utils/markdown-section-extractor.d.ts.map +1 -0
  175. package/dist/utils/markdown-to-plain.d.ts +32 -0
  176. package/dist/utils/markdown-to-plain.d.ts.map +1 -0
  177. package/dist/utils/tree-builder.d.ts +30 -0
  178. package/dist/utils/tree-builder.d.ts.map +1 -0
  179. package/package.json +19 -1
  180. package/src/components/chat/chat-attachment-bar.tsx +6 -11
  181. package/src/components/docs/doc-navigation-context.tsx +123 -0
  182. package/src/components/docs/doc-viewer.tsx +345 -0
  183. package/src/components/docs/index.ts +12 -0
  184. package/src/components/docs/use-document-tree.ts +427 -0
  185. package/src/components/docs/use-scroll-spy.ts +93 -0
  186. package/src/components/embeds/embed-iframe.tsx +103 -0
  187. package/src/components/embeds/figma-embed.tsx +147 -0
  188. package/src/components/embeds/google-sheets-viewer.tsx +54 -0
  189. package/src/components/embeds/index.ts +11 -0
  190. package/src/components/embeds/pdf-viewer.tsx +64 -0
  191. package/src/components/faq/faq-section.tsx +78 -26
  192. package/src/components/faq-accordion.tsx +8 -1
  193. package/src/components/index.ts +2 -0
  194. package/src/components/navigation/index.ts +5 -0
  195. package/src/components/navigation/multi-level-navigation.tsx +358 -0
  196. package/src/types/doc-source.ts +88 -0
  197. package/src/types/index.ts +4 -0
  198. package/src/types/marketing.ts +1 -0
  199. package/src/utils/doc-path-utils.ts +47 -0
  200. package/src/utils/doc-tree-nav.ts +111 -0
  201. package/src/utils/embed-url-converters.ts +77 -0
  202. package/src/utils/faq-anchor.ts +70 -0
  203. package/src/utils/format.ts +16 -0
  204. package/src/utils/index.ts +18 -0
  205. package/src/utils/list-url.ts +1 -0
  206. package/src/utils/markdown-section-extractor.ts +106 -0
  207. package/src/utils/markdown-to-plain.ts +69 -0
  208. package/src/utils/tree-builder.ts +114 -0
  209. package/dist/chunk-DPKUSBQH.cjs.map +0 -1
  210. package/dist/chunk-J66U3LPZ.cjs.map +0 -1
  211. package/dist/chunk-KNCDUWY2.js.map +0 -1
  212. package/dist/chunk-P45Q4QCU.cjs.map +0 -1
  213. package/dist/chunk-PVXCVPJD.cjs.map +0 -1
  214. package/dist/chunk-RGALV4UX.js.map +0 -1
  215. package/dist/chunk-RJUGNL2A.cjs.map +0 -1
  216. package/dist/chunk-USPATRSC.js.map +0 -1
  217. package/dist/chunk-VQW7CEGS.js.map +0 -1
  218. package/dist/chunk-XDI5JSWB.cjs.map +0 -1
  219. package/dist/chunk-XX7P4IL2.js.map +0 -1
  220. package/dist/chunk-Z3V2SGQZ.js.map +0 -1
  221. package/dist/chunk-ZIODAAFY.cjs.map +0 -1
  222. package/dist/chunk-ZSUNQJRO.cjs.map +0 -1
  223. /package/dist/{chunk-IMNGGLKL.js.map → chunk-3ZXUQQL4.js.map} +0 -0
  224. /package/dist/{chunk-23UOJUDF.js.map → chunk-RWCA2ZQK.js.map} +0 -0
@@ -0,0 +1,358 @@
1
+ "use client"
2
+
3
+ import { ChevronRight, ChevronDown, ChevronUp, FileText, Folder, FolderOpen } from 'lucide-react'
4
+ import { cn } from '../../utils/cn'
5
+ import { DEFAULT_FOLDER_INDEX_FILE as CANONICAL_FOLDER_INDEX_FILE } from '../../utils/doc-tree-nav'
6
+
7
+ export interface NavigationNode {
8
+ id: string
9
+ name: string
10
+ path: string
11
+ type: 'file' | 'folder'
12
+ hasReadme?: boolean
13
+ children?: NavigationNode[]
14
+ slug?: string
15
+ // Mirror DocNode's extra optional fields so a DocNode tree is structurally
16
+ // assignable to NavigationNode[] without an `as` cast. NavigationNode
17
+ // intentionally never reads these — they're carried for type-compat only.
18
+ documentType?: 'markdown' | 'pdf' | 'google_sheet' | 'figma' | 'file'
19
+ sortOrder?: number
20
+ }
21
+
22
+ interface MultiLevelNavigationProps {
23
+ nodes: NavigationNode[]
24
+ selectedPath: string
25
+ expandedNodes: Set<string>
26
+ onNodeClick: (node: NavigationNode) => void
27
+ onToggleExpand?: (nodeId: string) => void
28
+ isLoading?: boolean
29
+ className?: string
30
+ /** Folder-index filename (default `'README.md'`, case-insensitive). When the
31
+ * selectedPath is a folder with this file, the visual ribbon moves to the
32
+ * child file. */
33
+ folderIndexFile?: string
34
+ }
35
+
36
+ // SSOT lives in `doc-tree-nav` (canonical case `'README.md'`). The visual
37
+ // comparator below lowercases both sides, so re-exporting the canonical
38
+ // constant under the same local name preserves call-site syntax without
39
+ // drifting from the SSOT.
40
+ const DEFAULT_FOLDER_INDEX_FILE = CANONICAL_FOLDER_INDEX_FILE
41
+
42
+ /**
43
+ * Compute the visual "selected" state for a navigation node.
44
+ *
45
+ * Folder-with-README convention: when `selectedPath` is a folder that has a
46
+ * README, the FOLDER doesn't get the ribbon — its README child does. The folder
47
+ * is just a container; the README is the actual document being viewed.
48
+ *
49
+ * Mirror case: a README file whose parent folder is the `selectedPath` gets the
50
+ * visual selection ribbon (implicit). This handles both the auto-select-first-
51
+ * folder landing path AND the user-clicks-folder case.
52
+ */
53
+ function isNodeVisuallySelected(
54
+ node: NavigationNode,
55
+ selectedPath: string,
56
+ folderIndexFile: string = DEFAULT_FOLDER_INDEX_FILE,
57
+ ): boolean {
58
+ const FOLDER_INDEX_FILE = folderIndexFile.toLowerCase()
59
+ // Path comparisons keyed on `node.path` (raw, includes `.md`). Do NOT use
60
+ // `node.name` — the tree-builder runs it through `formatDocName` which strips
61
+ // the `.md` extension, so `node.name.toLowerCase() === 'readme.md'` is always
62
+ // false.
63
+ if (selectedPath === node.path) {
64
+ // Explicit match — but folder-with-README defers to its README child.
65
+ return !(node.type === 'folder' && node.hasReadme)
66
+ }
67
+ if (node.type !== 'file') return false
68
+ const pathLower = node.path.toLowerCase()
69
+ // Implicit: README child whose parent folder is the selectedPath.
70
+ if (
71
+ selectedPath !== '' &&
72
+ pathLower === `${selectedPath.toLowerCase()}/${FOLDER_INDEX_FILE}`
73
+ ) {
74
+ return true
75
+ }
76
+ // Implicit: ROOT README on the landing page (selectedPath === '' means
77
+ // "no explicit doc selected" — the viewer falls back to the root folder-
78
+ // index file, so that file IS the active document).
79
+ if (selectedPath === '' && pathLower === FOLDER_INDEX_FILE) {
80
+ return true
81
+ }
82
+ return false
83
+ }
84
+
85
+ export function MultiLevelNavigation({
86
+ nodes,
87
+ selectedPath,
88
+ expandedNodes,
89
+ onNodeClick,
90
+ onToggleExpand,
91
+ isLoading,
92
+ className,
93
+ folderIndexFile = DEFAULT_FOLDER_INDEX_FILE,
94
+ }: MultiLevelNavigationProps) {
95
+ if (isLoading) {
96
+ return (
97
+ <div className={cn("space-y-2", className)}>
98
+ {[1, 2, 3, 4].map((i) => (
99
+ <div key={i} className="p-3 bg-ods-skeleton rounded-lg animate-pulse h-12" />
100
+ ))}
101
+ </div>
102
+ )
103
+ }
104
+
105
+ return (
106
+ <div className={cn("space-y-2", className)} role="list" aria-label="Navigation list">
107
+ {nodes.map(node => (
108
+ <NavigationItem
109
+ key={node.id}
110
+ node={node}
111
+ selectedPath={selectedPath}
112
+ expandedNodes={expandedNodes}
113
+ onNodeClick={onNodeClick}
114
+ onToggleExpand={onToggleExpand}
115
+ level={0}
116
+ folderIndexFile={folderIndexFile}
117
+ />
118
+ ))}
119
+ </div>
120
+ )
121
+ }
122
+
123
+ interface NavigationItemProps {
124
+ node: NavigationNode
125
+ selectedPath: string
126
+ expandedNodes: Set<string>
127
+ onNodeClick: (node: NavigationNode) => void
128
+ onToggleExpand?: (nodeId: string) => void
129
+ level: number
130
+ folderIndexFile: string
131
+ }
132
+
133
+ function NavigationItem({
134
+ node,
135
+ selectedPath,
136
+ expandedNodes,
137
+ onNodeClick,
138
+ onToggleExpand,
139
+ level,
140
+ folderIndexFile,
141
+ }: NavigationItemProps) {
142
+ const isExpanded = expandedNodes.has(node.id)
143
+ const isSelected = isNodeVisuallySelected(node, selectedPath, folderIndexFile)
144
+ const hasChildren = node.children && node.children.length > 0
145
+
146
+ return (
147
+ <div className="space-y-1" role="listitem">
148
+ <div className={cn("px-2", level > 0 && "ml-4")}>
149
+ <div
150
+ className={cn(
151
+ "w-full rounded-lg transition-all duration-150 border relative",
152
+ isSelected
153
+ ? "bg-ods-border border-ods-border"
154
+ : "bg-ods-card border-ods-border"
155
+ )}
156
+ >
157
+ <div className="flex items-center relative">
158
+ <button
159
+ className={cn(
160
+ "w-full flex items-center h-12 px-2 rounded-lg text-[16px] font-medium font-['DM_Sans'] transition-all duration-150 leading-[1.33em] text-ods-text-primary",
161
+ !isSelected && "hover:bg-ods-bg-secondary",
162
+ hasChildren && "pr-12"
163
+ )}
164
+ onClick={() => onNodeClick(node)}
165
+ aria-label={`${isSelected ? 'Selected' : 'Select'} ${node.name}`}
166
+ >
167
+ <span className="flex-shrink-0 mr-2">
168
+ {node.type === 'folder' ? (
169
+ isExpanded ? <FolderOpen className="h-4 w-4" /> : <Folder className="h-4 w-4" />
170
+ ) : (
171
+ <FileText className="h-4 w-4" />
172
+ )}
173
+ </span>
174
+ <span className="text-left truncate flex-1 min-w-0">
175
+ {node.name.endsWith('.md') ? node.name.replace('.md', '') : node.name}
176
+ </span>
177
+ {node.type === 'folder' && node.hasReadme && (
178
+ <span className="text-[10px] bg-ods-bg-secondary text-ods-text-tertiary px-1.5 py-0.5 rounded mr-2">
179
+ README
180
+ </span>
181
+ )}
182
+ </button>
183
+
184
+ {hasChildren && (
185
+ <button
186
+ className="absolute right-0 top-0 flex items-center justify-center w-12 h-12 text-ods-text-secondary hover:text-ods-text-primary transition-colors duration-150"
187
+ onClick={(e) => {
188
+ e.stopPropagation()
189
+ if (onToggleExpand) {
190
+ onToggleExpand(node.id)
191
+ } else {
192
+ onNodeClick(node)
193
+ }
194
+ }}
195
+ aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${node.name}`}
196
+ >
197
+ {isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
198
+ </button>
199
+ )}
200
+ </div>
201
+
202
+ {isSelected && (
203
+ <div className="absolute right-0 top-1/2 -translate-y-1/2 w-1 h-[calc(100%-8px)] bg-ods-accent rounded-l" />
204
+ )}
205
+ </div>
206
+ </div>
207
+
208
+ {hasChildren && isExpanded && (
209
+ <div className="space-y-1">
210
+ {node.children!.map(child => (
211
+ <NavigationItem
212
+ key={child.id}
213
+ node={child}
214
+ selectedPath={selectedPath}
215
+ expandedNodes={expandedNodes}
216
+ onNodeClick={onNodeClick}
217
+ onToggleExpand={onToggleExpand}
218
+ level={level + 1}
219
+ folderIndexFile={folderIndexFile}
220
+ />
221
+ ))}
222
+ </div>
223
+ )}
224
+ </div>
225
+ )
226
+ }
227
+
228
+ export function MobileNavigationDropdown({
229
+ nodes,
230
+ selectedPath,
231
+ expandedNodes,
232
+ onNodeClick,
233
+ onToggleExpand,
234
+ isLoading,
235
+ className,
236
+ folderIndexFile = DEFAULT_FOLDER_INDEX_FILE,
237
+ }: MultiLevelNavigationProps) {
238
+ if (isLoading) {
239
+ return (
240
+ <div className={cn("space-y-2", className)}>
241
+ {[1, 2, 3].map((i) => (
242
+ <div key={i} className="p-3 bg-ods-skeleton rounded-lg animate-pulse h-10" />
243
+ ))}
244
+ </div>
245
+ )
246
+ }
247
+
248
+ return (
249
+ <div className={cn("space-y-2", className)}>
250
+ {nodes.map(node => (
251
+ <MobileNavigationItem
252
+ key={node.id}
253
+ node={node}
254
+ selectedPath={selectedPath}
255
+ expandedNodes={expandedNodes}
256
+ onNodeClick={onNodeClick}
257
+ onToggleExpand={onToggleExpand}
258
+ level={0}
259
+ folderIndexFile={folderIndexFile}
260
+ />
261
+ ))}
262
+ </div>
263
+ )
264
+ }
265
+
266
+ function MobileNavigationItem({
267
+ node,
268
+ selectedPath,
269
+ expandedNodes,
270
+ onNodeClick,
271
+ onToggleExpand,
272
+ level,
273
+ folderIndexFile,
274
+ }: NavigationItemProps) {
275
+ const isExpanded = expandedNodes.has(node.id)
276
+ const isSelected = isNodeVisuallySelected(node, selectedPath, folderIndexFile)
277
+ const hasChildren = node.children && node.children.length > 0
278
+
279
+ return (
280
+ <div className="space-y-1">
281
+ <div className={cn("px-2", level > 0 && "ml-3")}>
282
+ <div
283
+ className={cn(
284
+ "w-full rounded-lg transition-all duration-150 border relative",
285
+ isSelected
286
+ ? "bg-ods-border border-ods-border"
287
+ : "bg-ods-card border-ods-border"
288
+ )}
289
+ >
290
+ <div className="flex items-center relative">
291
+ <button
292
+ className={cn(
293
+ "w-full flex items-center h-11 px-2 rounded-lg text-[15px] font-medium font-['DM_Sans'] transition-all duration-150 leading-[1.33em] text-ods-text-primary",
294
+ !isSelected && "hover:bg-ods-bg-secondary",
295
+ hasChildren && "pr-11"
296
+ )}
297
+ onClick={() => onNodeClick(node)}
298
+ >
299
+ <span className="flex-shrink-0 mr-2">
300
+ {node.type === 'folder' ? (
301
+ isExpanded ? <FolderOpen className="h-4 w-4" /> : <Folder className="h-4 w-4" />
302
+ ) : (
303
+ <FileText className="h-4 w-4" />
304
+ )}
305
+ </span>
306
+ <span className="text-left truncate flex-1 min-w-0 text-sm">
307
+ {node.name.endsWith('.md') ? node.name.replace('.md', '') : node.name}
308
+ </span>
309
+ {node.type === 'folder' && node.hasReadme && (
310
+ <span className="text-[10px] bg-ods-bg-secondary text-ods-text-tertiary px-1.5 py-0.5 rounded mr-2">
311
+ README
312
+ </span>
313
+ )}
314
+ </button>
315
+
316
+ {hasChildren && (
317
+ <button
318
+ className="absolute right-0 top-0 flex items-center justify-center w-11 h-11 text-ods-text-secondary hover:text-ods-text-primary transition-colors duration-150"
319
+ onClick={(e) => {
320
+ e.stopPropagation()
321
+ if (onToggleExpand) {
322
+ onToggleExpand(node.id)
323
+ } else {
324
+ onNodeClick(node)
325
+ }
326
+ }}
327
+ aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${node.name}`}
328
+ >
329
+ {isExpanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
330
+ </button>
331
+ )}
332
+ </div>
333
+
334
+ {isSelected && (
335
+ <div className="absolute right-0 top-1/2 -translate-y-1/2 w-1 h-[calc(100%-8px)] bg-ods-accent rounded-l" />
336
+ )}
337
+ </div>
338
+ </div>
339
+
340
+ {hasChildren && isExpanded && (
341
+ <div className="space-y-1">
342
+ {node.children!.map(child => (
343
+ <MobileNavigationItem
344
+ key={child.id}
345
+ node={child}
346
+ selectedPath={selectedPath}
347
+ expandedNodes={expandedNodes}
348
+ onNodeClick={onNodeClick}
349
+ onToggleExpand={onToggleExpand}
350
+ level={level + 1}
351
+ folderIndexFile={folderIndexFile}
352
+ />
353
+ ))}
354
+ </div>
355
+ )}
356
+ </div>
357
+ )
358
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Single home for doc-source tree + content types.
3
+ *
4
+ * Consumed by every doc-viewer consumer (knowledge base, data room, future
5
+ * sources). Replaces the per-source `OpenFrameDocNode` / `OpenFrameDocContent`
6
+ * / `DataRoomDocNode` / `DocNavNode` types that previously lived in the hub.
7
+ */
8
+
9
+ /**
10
+ * A single node in a doc-source tree. The `documentType` discriminator
11
+ * defaults to `'markdown'` when undefined; rich-content viewers handle
12
+ * `'pdf' | 'google_sheet' | 'figma' | 'file'`.
13
+ */
14
+ export interface DocNode {
15
+ id: string
16
+ name: string
17
+ slug: string
18
+ path: string
19
+ type: 'file' | 'folder'
20
+ hasReadme?: boolean
21
+ /** Per-row sort key — populated by data-room rows, undefined elsewhere. */
22
+ sortOrder?: number
23
+ /** Discriminator for the rich renderer. `undefined` is treated as `'markdown'`. */
24
+ documentType?: 'markdown' | 'pdf' | 'google_sheet' | 'figma' | 'file'
25
+ children?: DocNode[]
26
+ }
27
+
28
+ /**
29
+ * Content payload returned by a doc-source DAL's `getContent` call. Carries
30
+ * everything any consumer's renderer might need; markdown-only fields and
31
+ * rich-only fields are both optional.
32
+ */
33
+ export interface DocContent {
34
+ content: string
35
+ sections: Array<{ id: string; title: string; level: number }>
36
+ path: string
37
+ /** Defaults to `'markdown'` when undefined. */
38
+ documentType?: DocNode['documentType']
39
+ // Markdown-only
40
+ brokenLinks?: string[]
41
+ // Rich-content (data-room) — optional
42
+ fileUrl?: string
43
+ externalUrl?: string
44
+ mimeType?: string
45
+ fileName?: string
46
+ fileSize?: number
47
+ /** ISO timestamps for SEO `datePublished` / `dateModified` — DAL populates
48
+ * from the underlying row's sync/update timestamp. Undefined when unknown. */
49
+ publishedAt?: string
50
+ updatedAt?: string
51
+ }
52
+
53
+ /**
54
+ * Doc-source data-access contract. Each doc source's hub-side DAL conforms
55
+ * to this. Generic over the Supabase-client type so the lib doesn't pull in
56
+ * `@supabase/supabase-js`; hub callers tighten `Client` to `SupabaseClient`.
57
+ */
58
+ export interface DocSourceDal<Client = unknown> {
59
+ getStructure(client: Client): Promise<DocNode[]>
60
+ getContent(client: Client, path: string): Promise<DocContent | null>
61
+ }
62
+
63
+ /**
64
+ * Doc-source registry id. The lib's `<DocViewer>` is generic over this union
65
+ * — the hub's `DOC_SOURCES` registry defines the actual ids. Listed here as a
66
+ * type-only union so the lib can flow the id into `DocRenderHandlers.sourceId`
67
+ * without callers having to cast `string` → `'openframe-docs' | 'data-room-docs'`.
68
+ *
69
+ * Adding a new source = add the id here AND register it in the hub. The lib
70
+ * never reaches into the registry — this is purely a type-narrowing handle.
71
+ */
72
+ export type DocSourceId = 'openframe-docs' | 'data-room-docs'
73
+
74
+ /**
75
+ * Handlers the viewer passes to a consumer's `renderContent` callback.
76
+ * The page shell wires `renderContent` directly — no `DocContentRenderer`
77
+ * interface layer between the viewer and the consumer's render logic.
78
+ */
79
+ export interface DocRenderHandlers {
80
+ onInternalLinkClick: (
81
+ path: string,
82
+ options?: { expandFolder?: boolean; fromInternalLink?: boolean },
83
+ ) => void
84
+ currentPath: string
85
+ /** Registry source id (e.g. `'openframe-docs'`, `'data-room-docs'`) — used by
86
+ * the consumer's `/api/resolve-link` POST to disambiguate the doc source. */
87
+ sourceId: DocSourceId
88
+ }
@@ -3,6 +3,10 @@
3
3
 
4
4
  // Core platform types
5
5
  export * from './announcement'
6
+ // Doc-source viewer types — DocNode / DocContent / DocSourceDal /
7
+ // DocRenderHandlers. Consumed by knowledge-base, data-room, and future
8
+ // doc-source viewers.
9
+ export * from './doc-source'
6
10
  export type { Category, CategoryCardProps, RealCategoryCardProps } from './categories'
7
11
  export * from './category'
8
12
  export * from './media'
@@ -347,6 +347,7 @@ export type ContentSourceType =
347
347
  | 'investor_update' // Investor updates
348
348
  | 'onboarding_guide' // Onboarding guides (lives on openframe platform)
349
349
  | 'what_i_shipped' // What I Shipped employee check-ins (lives on people-hub)
350
+ | 'faq' // FAQ Q&A pair (single-page /faqs index; deep-link by category anchor)
350
351
  | 'from_scratch';
351
352
 
352
353
  export type URLInjectionPreference = 'none' | 'in_post' | 'as_comment';
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared document path utilities.
3
+ * Single source of truth for path/slug operations used by all doc-source viewers
4
+ * (knowledge base, data room, future).
5
+ */
6
+
7
+ import { DEFAULT_FOLDER_INDEX_FILE } from './doc-tree-nav'
8
+
9
+ /**
10
+ * Convert a document name to a URL-safe path slug.
11
+ * Used when creating, moving, or renaming documents.
12
+ * e.g., "Investor Thesis" → "investor-thesis"
13
+ */
14
+ export function toDocSlug(name: string): string {
15
+ return name.trim().toLowerCase().replace(/\s+/g, '-')
16
+ }
17
+
18
+ /**
19
+ * Convert a full document path to a DOM-safe node ID.
20
+ * Used for tree node IDs and sidebar expand/collapse state.
21
+ * e.g., "legal/cap-table/safe.md" → "legal-cap-table-safe.md"
22
+ */
23
+ export function pathToNodeId(path: string): string {
24
+ return path.replace(/[\/\s]/g, '-').toLowerCase()
25
+ }
26
+
27
+ /**
28
+ * Normalize a raw URL path from a route handler.
29
+ * Strips folder-index suffixes (default README.md) and lowercases segments.
30
+ * Used by both data-room and knowledge-base [...path] route handlers.
31
+ */
32
+ export function normalizeDocPath(
33
+ segments: string[],
34
+ folderIndexFile: string = DEFAULT_FOLDER_INDEX_FILE,
35
+ ): string {
36
+ // Lowercase BOTH sides BEFORE comparison so paths from URL segments
37
+ // (`readme.md`) and the canonical folder-index constant (`README.md`)
38
+ // strip uniformly. Lowercasing only at the end (the previous shape) left
39
+ // lowercase URL inputs unstripped → bogus content lookups.
40
+ const lowerPath = (segments?.join('/') || '').toLowerCase()
41
+ const lowerIndex = folderIndexFile.toLowerCase()
42
+
43
+ const suffix = `/${lowerIndex}`
44
+ if (lowerPath.endsWith(suffix)) return lowerPath.slice(0, -suffix.length)
45
+ if (lowerPath === lowerIndex) return ''
46
+ return lowerPath
47
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Pure helpers for doc-source tree navigation — shared by client hook and RSC SSR.
3
+ * README convention (folder-index file) is configurable but defaults to 'README.md'.
4
+ */
5
+
6
+ import type { DocNode } from '../types/doc-source'
7
+
8
+ /**
9
+ * Canonical folder-index filename. Single SSOT consumed by `doc-tree-nav`
10
+ * pure helpers, `useDocumentTree` (default config), `DocSeoContent` (fallback
11
+ * fetch path), and `multi-level-navigation`'s visual-selection logic. Stays
12
+ * in canonical case here; consumers that do case-insensitive comparison
13
+ * (the navigation) lowercase locally.
14
+ */
15
+ export const DEFAULT_FOLDER_INDEX_FILE = 'README.md'
16
+
17
+ /** @deprecated Re-exported under the new name; remove after consumers migrate. */
18
+ const DEFAULT_FOLDER_INDEX = DEFAULT_FOLDER_INDEX_FILE
19
+
20
+ /**
21
+ * Strip the folder-index filename from a path, returning the folder path.
22
+ * For default 'README.md': 'folder/README.md' → 'folder', 'README.md' → '', 'folder/file.md' → 'folder/file.md'
23
+ */
24
+ export function stripFolderIndexFromPath(
25
+ path: string,
26
+ folderIndexFile: string = DEFAULT_FOLDER_INDEX,
27
+ ): string {
28
+ const suffix = `/${folderIndexFile}`
29
+ if (path.endsWith(suffix)) return path.slice(0, -suffix.length)
30
+ if (path === folderIndexFile) return ''
31
+ return path
32
+ }
33
+
34
+ export function findDocNodeByPath(path: string, nodes: DocNode[]): DocNode | null {
35
+ for (const node of nodes) {
36
+ if (node.path === path) return node
37
+ if (node.children) {
38
+ const found = findDocNodeByPath(path, node.children)
39
+ if (found) return found
40
+ }
41
+ }
42
+ return null
43
+ }
44
+
45
+ /**
46
+ * Given a node path like "folder1/subfolder2", return DOM-style node IDs for each segment
47
+ * (accordion sidebar — matches use-document-tree).
48
+ */
49
+ export function getDocAncestorNodeIds(nodePath: string): string[] {
50
+ const parts = nodePath.split('/')
51
+ const ids: string[] = []
52
+ let current = ''
53
+ for (const part of parts) {
54
+ current = current ? `${current}-${part}` : part
55
+ ids.push(current.toLowerCase())
56
+ }
57
+ return ids
58
+ }
59
+
60
+ /**
61
+ * Resolve which storage path to load (matches use-document-tree content effect).
62
+ */
63
+ export function resolveContentFetchPath(
64
+ selectedPath: string,
65
+ structure: DocNode[],
66
+ folderIndexFile: string = DEFAULT_FOLDER_INDEX,
67
+ ): string | null {
68
+ if (selectedPath === '') {
69
+ return folderIndexFile
70
+ }
71
+ const node = findDocNodeByPath(selectedPath, structure)
72
+ if (node && node.type === 'folder' && !node.hasReadme) {
73
+ return null
74
+ }
75
+ let pathToFetch = selectedPath
76
+ if (node && node.type === 'folder' && node.hasReadme) {
77
+ pathToFetch = `${selectedPath}/${folderIndexFile}`
78
+ }
79
+ return pathToFetch
80
+ }
81
+
82
+ export function getInitialExpandedNodeIds(cleanInitialPath: string): string[] {
83
+ if (!cleanInitialPath) return []
84
+ const pathForExpansion = cleanInitialPath.includes('.')
85
+ ? cleanInitialPath.substring(0, cleanInitialPath.lastIndexOf('/'))
86
+ : cleanInitialPath
87
+ if (!pathForExpansion) return []
88
+ return getDocAncestorNodeIds(pathForExpansion)
89
+ }
90
+
91
+ /**
92
+ * When a doc source's root has no top-level folder-index file, navigate to the first
93
+ * folder that does. Matches the historical knowledge-base default.
94
+ */
95
+ export function getDocSourceDefaultPath(
96
+ structure: DocNode[],
97
+ folderIndexFile: string = DEFAULT_FOLDER_INDEX,
98
+ ): string | null {
99
+ if (!structure.length) return null
100
+ const hasRootIndex = structure.some(
101
+ (node) => node.type === 'file' && node.path === folderIndexFile
102
+ )
103
+ if (hasRootIndex) return null
104
+ // Scan ALL root nodes for the first folder with a README — not just
105
+ // structure[0]. Otherwise a leading non-folder root (file / folder without
106
+ // README) silently returns null even when a later root folder has one.
107
+ const firstFolderWithReadme = structure.find(
108
+ (node) => node.type === 'folder' && node.hasReadme && !!node.path,
109
+ )
110
+ return firstFolderWithReadme?.path ?? null
111
+ }