@flamingo-stack/openframe-frontend-core 0.0.305 → 0.0.306-snapshot.20260622221324

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 (84) hide show
  1. package/dist/{chunk-DVVQY4VI.cjs → chunk-2YYAKVL7.cjs} +12 -12
  2. package/dist/{chunk-DVVQY4VI.cjs.map → chunk-2YYAKVL7.cjs.map} +1 -1
  3. package/dist/{chunk-AGJPN5RS.js → chunk-46KRPHHL.js} +2 -2
  4. package/dist/{chunk-TFEXHBJE.cjs → chunk-4RHOLPFU.cjs} +14 -14
  5. package/dist/{chunk-TFEXHBJE.cjs.map → chunk-4RHOLPFU.cjs.map} +1 -1
  6. package/dist/{chunk-SR6ZSDOP.cjs → chunk-4RI7S6ZD.cjs} +9 -9
  7. package/dist/{chunk-SR6ZSDOP.cjs.map → chunk-4RI7S6ZD.cjs.map} +1 -1
  8. package/dist/{chunk-EEVOKQGR.js → chunk-4YV5U7GA.js} +48 -28
  9. package/dist/chunk-4YV5U7GA.js.map +1 -0
  10. package/dist/{chunk-KCID66IW.js → chunk-5OFBD6EQ.js} +2 -2
  11. package/dist/{chunk-3NDFB23M.cjs → chunk-6TRTIHGW.cjs} +27 -2
  12. package/dist/chunk-6TRTIHGW.cjs.map +1 -0
  13. package/dist/{chunk-UN7RFOZI.cjs → chunk-AIHM4TT7.cjs} +5 -5
  14. package/dist/{chunk-UN7RFOZI.cjs.map → chunk-AIHM4TT7.cjs.map} +1 -1
  15. package/dist/{chunk-TKGSUJZF.cjs → chunk-CKFHYXSJ.cjs} +7 -7
  16. package/dist/{chunk-TKGSUJZF.cjs.map → chunk-CKFHYXSJ.cjs.map} +1 -1
  17. package/dist/{chunk-QHOX4JWP.js → chunk-E7FIV5LH.js} +2 -2
  18. package/dist/{chunk-UEGSCIAW.js → chunk-ER4CMF47.js} +26 -1
  19. package/dist/chunk-ER4CMF47.js.map +1 -0
  20. package/dist/{chunk-L3JKIAQX.cjs → chunk-J3YKVLQ5.cjs} +26 -26
  21. package/dist/{chunk-L3JKIAQX.cjs.map → chunk-J3YKVLQ5.cjs.map} +1 -1
  22. package/dist/{chunk-NG6YEOHW.js → chunk-N3YPIZBH.js} +2 -2
  23. package/dist/{chunk-VP4VKYR2.cjs → chunk-PLFQJ5E7.cjs} +37 -37
  24. package/dist/{chunk-VP4VKYR2.cjs.map → chunk-PLFQJ5E7.cjs.map} +1 -1
  25. package/dist/{chunk-KKSZQLGC.js → chunk-QPTJOLAP.js} +2 -2
  26. package/dist/{chunk-GC4CNBTS.js → chunk-QWMYOUGP.js} +2 -2
  27. package/dist/{chunk-7QHSLXTB.js → chunk-WSEK6W4B.js} +2 -2
  28. package/dist/{chunk-OYAALXG6.cjs → chunk-XXFATUTH.cjs} +70 -50
  29. package/dist/chunk-XXFATUTH.cjs.map +1 -0
  30. package/dist/components/case-studies/index.cjs +8 -8
  31. package/dist/components/case-studies/index.js +2 -2
  32. package/dist/components/chat/index.cjs +2 -2
  33. package/dist/components/chat/index.js +1 -1
  34. package/dist/components/contact/index.cjs +3 -3
  35. package/dist/components/contact/index.js +2 -2
  36. package/dist/components/docs/index.cjs +5 -5
  37. package/dist/components/docs/index.js +4 -4
  38. package/dist/components/docs/use-document-tree.d.ts.map +1 -1
  39. package/dist/components/embeds/index.cjs +3 -3
  40. package/dist/components/embeds/index.js +2 -2
  41. package/dist/components/faq/index.cjs +3 -3
  42. package/dist/components/faq/index.js +2 -2
  43. package/dist/components/features/index.cjs +2 -2
  44. package/dist/components/features/index.js +1 -1
  45. package/dist/components/features/seo-editor-preview.d.ts.map +1 -1
  46. package/dist/components/index.cjs +172 -172
  47. package/dist/components/index.js +8 -8
  48. package/dist/components/navigation/index.cjs +2 -2
  49. package/dist/components/navigation/index.js +1 -1
  50. package/dist/components/onboarding-guides/index.cjs +23 -23
  51. package/dist/components/onboarding-guides/index.js +3 -3
  52. package/dist/components/related-content/index.cjs +3 -3
  53. package/dist/components/related-content/index.js +2 -2
  54. package/dist/components/tickets/index.cjs +60 -60
  55. package/dist/components/tickets/index.js +3 -3
  56. package/dist/components/ui/index.cjs +2 -2
  57. package/dist/components/ui/index.js +1 -1
  58. package/dist/index.cjs +4 -2
  59. package/dist/index.cjs.map +1 -1
  60. package/dist/index.js +3 -1
  61. package/dist/utils/index.cjs +4 -0
  62. package/dist/utils/index.cjs.map +1 -1
  63. package/dist/utils/index.d.ts +1 -0
  64. package/dist/utils/index.d.ts.map +1 -1
  65. package/dist/utils/index.js +4 -1
  66. package/dist/utils/index.js.map +1 -1
  67. package/dist/utils/seo-title.d.ts +15 -0
  68. package/dist/utils/seo-title.d.ts.map +1 -0
  69. package/package.json +1 -1
  70. package/src/components/docs/use-document-tree.ts +73 -28
  71. package/src/components/features/seo-editor-preview.tsx +24 -0
  72. package/src/utils/index.ts +3 -0
  73. package/src/utils/seo-title.ts +14 -0
  74. package/dist/chunk-3NDFB23M.cjs.map +0 -1
  75. package/dist/chunk-EEVOKQGR.js.map +0 -1
  76. package/dist/chunk-OYAALXG6.cjs.map +0 -1
  77. package/dist/chunk-UEGSCIAW.js.map +0 -1
  78. /package/dist/{chunk-AGJPN5RS.js.map → chunk-46KRPHHL.js.map} +0 -0
  79. /package/dist/{chunk-KCID66IW.js.map → chunk-5OFBD6EQ.js.map} +0 -0
  80. /package/dist/{chunk-QHOX4JWP.js.map → chunk-E7FIV5LH.js.map} +0 -0
  81. /package/dist/{chunk-NG6YEOHW.js.map → chunk-N3YPIZBH.js.map} +0 -0
  82. /package/dist/{chunk-KKSZQLGC.js.map → chunk-QPTJOLAP.js.map} +0 -0
  83. /package/dist/{chunk-GC4CNBTS.js.map → chunk-QWMYOUGP.js.map} +0 -0
  84. /package/dist/{chunk-7QHSLXTB.js.map → chunk-WSEK6W4B.js.map} +0 -0
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Single source of truth for the `seo_title` length budget.
3
+ *
4
+ * The `seo_title` is rendered as the page `<title>` VERBATIM — no brand suffix is
5
+ * appended to it (the `| <Brand>` suffix is kept only on the default/actual-title
6
+ * fallback). So this is the full rendered-title budget; search engines truncate
7
+ * page titles around 60 chars.
8
+ *
9
+ * This number lives in exactly ONE place. Consumers:
10
+ * - `SEOEditorPreview` — input `maxLength` + the too-long alert.
11
+ * - the hub — imports it (via `@flamingo-stack/openframe-frontend-core/utils`)
12
+ * for the AI generation prompt guidance and the DB CHECK value.
13
+ */
14
+ export declare const SEO_TITLE_MAX_LENGTH = 60;
15
+ //# sourceMappingURL=seo-title.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo-title.d.ts","sourceRoot":"","sources":["../../src/utils/seo-title.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.305",
3
+ "version": "0.0.306-snapshot.20260622221324",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,6 +22,29 @@ function scrollToContent() {
22
22
  }
23
23
  }
24
24
 
25
+ /**
26
+ * First displayable document inside a folder (depth-first): a direct non-Mermaid
27
+ * file, else the first doc found in a subfolder. Used so a folder WITHOUT a
28
+ * README shows its first child's content (mirroring how a README folder shows
29
+ * its README) instead of a blank panel or a redundant in-page listing — the
30
+ * sidebar tree is the directory browser. Returns null for a folder with no docs.
31
+ */
32
+ function findFirstDocPath(folder: DocNode): string | null {
33
+ const children = folder.children ?? []
34
+ for (const child of children) {
35
+ if (child.type === 'file' && !child.path.toLowerCase().endsWith('.mmd')) {
36
+ return child.path
37
+ }
38
+ }
39
+ for (const child of children) {
40
+ if (child.type === 'folder') {
41
+ const nested = findFirstDocPath(child)
42
+ if (nested) return nested
43
+ }
44
+ }
45
+ return null
46
+ }
47
+
25
48
  export interface UseDocumentTreeConfig {
26
49
  /** API endpoint for fetching the document tree structure */
27
50
  structureEndpoint: string
@@ -155,12 +178,24 @@ export function useDocumentTree(
155
178
  const node = findDocNodeByPath(selectedPath, structure)
156
179
 
157
180
  if (node && node.type === 'folder' && !node.hasReadme) {
158
- return
159
- }
160
-
161
- pathToFetch = selectedPath
162
- if (node && node.type === 'folder' && node.hasReadme) {
181
+ // No-README folder has no body of its own — show its FIRST child doc
182
+ // (mirrors a README folder showing its README). selectedPath stays the
183
+ // folder, so the sidebar keeps it highlighted/expanded; the sidebar is
184
+ // the directory browser, so we render NO separate in-page listing.
185
+ const firstDocPath = findFirstDocPath(node)
186
+ if (!firstDocPath) {
187
+ // Genuinely empty folder — clear to the empty state.
188
+ lastFetchedPath.current = null
189
+ setError(null)
190
+ setContent(null)
191
+ setIsLoadingContent(false)
192
+ return
193
+ }
194
+ pathToFetch = firstDocPath
195
+ } else if (node && node.type === 'folder' && node.hasReadme) {
163
196
  pathToFetch = `${selectedPath}/${folderIndexFile}`
197
+ } else {
198
+ pathToFetch = selectedPath
164
199
  }
165
200
  }
166
201
 
@@ -256,6 +291,25 @@ export function useDocumentTree(
256
291
  setContent(null)
257
292
  return
258
293
  }
294
+ // No-README FOLDER → stay silent: the content effect resolves it to
295
+ // the folder's first child, so the folder-path 404 from the direct-
296
+ // load speculative fetch is expected, not an error to flash.
297
+ const probe = findDocNodeByPath(
298
+ stripFolderIndexFromPath(path, folderIndexFile),
299
+ structure,
300
+ )
301
+ const probeIsNoReadmeFolder =
302
+ !!probe && probe.type === 'folder' && !probe.hasReadme
303
+ // Before the structure has loaded to classify the path (`structure`
304
+ // is [] in the speculative call's closure), only silence FOLDER-LIKE
305
+ // paths (no `.md`) — a genuinely missing `*.md` leaf must still error.
306
+ const preStructureFolderLike =
307
+ structure.length === 0 && !path.endsWith('.md')
308
+ if (probeIsNoReadmeFolder || preStructureFolderLike) {
309
+ setError(null)
310
+ setContent(null)
311
+ return
312
+ }
259
313
  setError(result.error || 'Documentation file not found')
260
314
  setContent(null)
261
315
  return
@@ -310,41 +364,32 @@ export function useDocumentTree(
310
364
  // narrower `NavigationNode` row shape) pass their own node back without the
311
365
  // cross-type `as` cast. Both DocNode and NavigationNode satisfy this.
312
366
  const selectNode = useCallback((node: Pick<DocNode, 'id' | 'path' | 'type' | 'hasReadme'>) => {
367
+ // Expansion only: clicking a folder toggles its own subtree; clicking a file
368
+ // reveals its ancestor chain.
313
369
  if (node.type === 'folder') {
314
370
  setExpandedNodes(prev => {
315
371
  if (prev.has(node.id)) {
316
372
  const ancestorIds = getDocAncestorNodeIds(node.path)
317
- ancestorIds.pop()
373
+ ancestorIds.pop() // collapse self, keep ancestors open
318
374
  return new Set(ancestorIds)
319
- } else {
320
- return new Set(getDocAncestorNodeIds(node.path))
321
375
  }
376
+ return new Set(getDocAncestorNodeIds(node.path))
322
377
  })
323
-
324
- if (node.hasReadme) {
325
- lastFetchedPath.current = null
326
- setSelectedPath(node.path)
327
- window.history.pushState({}, '', `${normalizedBaseRoute}/${node.path}`)
328
- setTimeout(() => {
329
- scrollToContent()
330
- }, 150)
331
- } else {
332
- setSelectedPath(node.path)
333
- }
334
378
  } else {
335
379
  const lastSlash = node.path.lastIndexOf('/')
336
380
  if (lastSlash > 0) {
337
- const parentPath = node.path.substring(0, lastSlash)
338
- setExpandedNodes(new Set(getDocAncestorNodeIds(parentPath)))
381
+ setExpandedNodes(new Set(getDocAncestorNodeIds(node.path.substring(0, lastSlash))))
339
382
  }
340
-
341
- lastFetchedPath.current = null
342
- setSelectedPath(node.path)
343
- window.history.pushState({}, '', `${normalizedBaseRoute}/${node.path}`)
344
- setTimeout(() => {
345
- scrollToContent()
346
- }, 150)
347
383
  }
384
+
385
+ // Every node is a navigable destination — a file shows its body, a README
386
+ // folder its README, a no-README folder its first child doc (the content
387
+ // effect resolves which). So selection + URL + scroll are identical for all
388
+ // node types; no per-type special-casing.
389
+ lastFetchedPath.current = null
390
+ setSelectedPath(node.path)
391
+ window.history.pushState({}, '', `${normalizedBaseRoute}/${node.path}`)
392
+ setTimeout(scrollToContent, 150)
348
393
  }, [normalizedBaseRoute])
349
394
 
350
395
  const navigateToDoc = useCallback((path: string, options?: { expandFolder?: boolean; fromInternalLink?: boolean }) => {
@@ -6,6 +6,9 @@ import { ConfidenceBadge } from '../features';
6
6
  import { Globe, ExternalLink, Upload, X, Loader2, Sparkles } from 'lucide-react';
7
7
  import { cn } from '../../utils';
8
8
  import Image from '../../embed-shims/next-image';
9
+ // SSOT for the field cap (server-safe constant). The seo_title renders as the
10
+ // page <title> verbatim (no brand suffix), so this is the full ~60-char budget.
11
+ import { SEO_TITLE_MAX_LENGTH } from '../../utils/seo-title';
9
12
 
10
13
  export interface SEOEditorPreviewProps {
11
14
  // SEO fields - must be strings (not undefined)
@@ -68,6 +71,10 @@ export function SEOEditorPreview({
68
71
  const [isUploading, setIsUploading] = useState(false);
69
72
  const [fileInputRef, setFileInputRef] = useState<HTMLInputElement | null>(null);
70
73
 
74
+ // SEO title length state — alerts the editor when the title would render too long.
75
+ const seoTitleLength = (seoTitle || '').length;
76
+ const seoTitleTooLong = seoTitleLength > SEO_TITLE_MAX_LENGTH;
77
+
71
78
  // Use fallback values if OG fields are empty
72
79
  const displayTitle = seoTitle.trim() || title || 'Untitled';
73
80
  const displayDescription = seoDescription.trim() || summary || 'No description';
@@ -124,9 +131,26 @@ export function SEOEditorPreview({
124
131
  value={seoTitle || ''}
125
132
  onChange={(e) => onSeoTitleChange(e.target.value)}
126
133
  disabled={disabled}
134
+ maxLength={SEO_TITLE_MAX_LENGTH}
135
+ invalid={seoTitleTooLong}
127
136
  placeholder="Enter SEO meta title..."
128
137
  className="bg-ods-bg border-ods-border text-ods-text-primary"
129
138
  />
139
+ <div className="flex items-center justify-between gap-2">
140
+ <span className="text-[11px] text-ods-error font-['DM_Sans']">
141
+ {seoTitleTooLong
142
+ ? `Too long — search engines may truncate this title (keep it ≤ ${SEO_TITLE_MAX_LENGTH})`
143
+ : ''}
144
+ </span>
145
+ <span
146
+ className={cn(
147
+ "text-[11px] font-['DM_Sans'] tabular-nums shrink-0",
148
+ seoTitleTooLong ? 'text-ods-error font-semibold' : 'text-ods-text-secondary'
149
+ )}
150
+ >
151
+ {seoTitleLength}/{SEO_TITLE_MAX_LENGTH}
152
+ </span>
153
+ </div>
130
154
  {!seoTitle && title && (
131
155
  <p className="text-[11px] text-ods-accent font-['DM_Sans']">
132
156
  Auto-populated from title
@@ -26,6 +26,9 @@ export {
26
26
  } from './ods-color-utils'
27
27
  export { delay, generateRandomString, truncateString, deepClone, getSlackCommunityJoinUrl, serializeJsonLd } from './common'
28
28
  export { getBaseUrl } from '../utils/cn'
29
+ // SEO title length budget — server-safe constant (SSOT). Consumed by the hub
30
+ // (prompt guidance + DB check value) and by SEOEditorPreview (input maxLength).
31
+ export { SEO_TITLE_MAX_LENGTH } from './seo-title'
29
32
 
30
33
  export * from './platform-config'
31
34
  export * from './os-platforms'
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Single source of truth for the `seo_title` length budget.
3
+ *
4
+ * The `seo_title` is rendered as the page `<title>` VERBATIM — no brand suffix is
5
+ * appended to it (the `| <Brand>` suffix is kept only on the default/actual-title
6
+ * fallback). So this is the full rendered-title budget; search engines truncate
7
+ * page titles around 60 chars.
8
+ *
9
+ * This number lives in exactly ONE place. Consumers:
10
+ * - `SEOEditorPreview` — input `maxLength` + the too-long alert.
11
+ * - the hub — imports it (via `@flamingo-stack/openframe-frontend-core/utils`)
12
+ * for the AI generation prompt guidance and the DB CHECK value.
13
+ */
14
+ export const SEO_TITLE_MAX_LENGTH = 60;