@flamingo-stack/openframe-frontend-core 0.0.848 → 0.0.849

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 (117) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{chunk-Y4BG7SPX.cjs → chunk-36XWCVSM.cjs} +63 -63
  3. package/dist/{chunk-YXKAHQYS.cjs → chunk-533QK4R6.cjs} +2 -1
  4. package/dist/{chunk-5HOWR4TG.cjs → chunk-5CBI2ZYV.cjs} +5 -5
  5. package/dist/{chunk-NYT6IBIX.cjs → chunk-5PIR77V5.cjs} +4 -4
  6. package/dist/{chunk-IIUZE73M.cjs → chunk-6AJYGXXS.cjs} +14 -14
  7. package/dist/{chunk-U7UBYJEZ.cjs → chunk-BU2PSRAS.cjs} +5 -5
  8. package/dist/{chunk-I3YRYS23.cjs → chunk-C3P7IUTU.cjs} +37 -12
  9. package/dist/{chunk-O4BXHCMK.cjs → chunk-FRG724DU.cjs} +2 -2
  10. package/dist/{chunk-YAW7CHLV.cjs → chunk-FXLROQUT.cjs} +41 -38
  11. package/dist/{chunk-7YJQTQPV.cjs → chunk-IG6ROHPS.cjs} +46 -46
  12. package/dist/{chunk-INA2FX2N.cjs → chunk-IHY7E4EX.cjs} +2 -2
  13. package/dist/{chunk-Y2ZWWIUA.cjs → chunk-IN3LA2FU.cjs} +2 -2
  14. package/dist/{chunk-7LE4HWHA.cjs → chunk-KXBZCUDW.cjs} +11 -11
  15. package/dist/{chunk-C3WZXOZJ.cjs → chunk-MC3L525K.cjs} +3 -3
  16. package/dist/{chunk-7DW3LZ52.cjs → chunk-PV2XQJ2R.cjs} +92 -171
  17. package/dist/{chunk-GASFYPGH.cjs → chunk-R5JPZ6TY.cjs} +14 -14
  18. package/dist/{chunk-MWYEROAP.cjs → chunk-SHQY4GOE.cjs} +55 -55
  19. package/dist/{chunk-VW4446JR.cjs → chunk-TEKN6TJ5.cjs} +14 -14
  20. package/dist/{chunk-QUSCSYHJ.cjs → chunk-TX4NTH22.cjs} +45 -26
  21. package/dist/{chunk-2V6FCVUL.cjs → chunk-YLETC7LP.cjs} +187 -169
  22. package/dist/{chunk-OCJXOSIH.cjs → chunk-YYHQY3K6.cjs} +30 -30
  23. package/dist/{chunk-74Z34I2C.cjs → chunk-Z5UCDEKZ.cjs} +4 -4
  24. package/dist/{chunk-IUZVOQXP.cjs → chunk-ZY34R37A.cjs} +45 -45
  25. package/dist/{cloud-tenants-YAZHLPOQ.cjs → cloud-tenants-HKUUEUAY.cjs} +7 -7
  26. package/dist/components/case-studies/index.cjs +21 -21
  27. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  28. package/dist/components/chat/entity-cards/dispatch.js +1 -0
  29. package/dist/components/chat/index.cjs +5 -5
  30. package/dist/components/contact/index.cjs +8 -8
  31. package/dist/components/docs/doc-viewer.d.ts +7 -7
  32. package/dist/components/docs/doc-viewer.d.ts.map +1 -1
  33. package/dist/components/docs/doc-viewer.js +29 -16
  34. package/dist/components/docs/docs-hub-page.d.ts +1 -1
  35. package/dist/components/docs/docs-hub-page.d.ts.map +1 -1
  36. package/dist/components/docs/docs-hub-page.js +4 -11
  37. package/dist/components/docs/index.cjs +11 -11
  38. package/dist/components/docs/use-document-tree.d.ts.map +1 -1
  39. package/dist/components/docs/use-document-tree.js +24 -24
  40. package/dist/components/embeds/claude-embed.d.ts +2 -2
  41. package/dist/components/embeds/claude-embed.d.ts.map +1 -1
  42. package/dist/components/embeds/claude-embed.js +3 -3
  43. package/dist/components/embeds/embed-iframe.d.ts +5 -3
  44. package/dist/components/embeds/embed-iframe.d.ts.map +1 -1
  45. package/dist/components/embeds/embed-iframe.js +4 -3
  46. package/dist/components/embeds/embed-viewer-frame.d.ts +12 -9
  47. package/dist/components/embeds/embed-viewer-frame.d.ts.map +1 -1
  48. package/dist/components/embeds/embed-viewer-frame.js +21 -7
  49. package/dist/components/embeds/figma-embed.d.ts +4 -2
  50. package/dist/components/embeds/figma-embed.d.ts.map +1 -1
  51. package/dist/components/embeds/figma-embed.js +4 -3
  52. package/dist/components/embeds/file-download-card.d.ts +3 -1
  53. package/dist/components/embeds/file-download-card.d.ts.map +1 -1
  54. package/dist/components/embeds/file-download-card.js +18 -3
  55. package/dist/components/embeds/google-sheets-viewer.d.ts +3 -1
  56. package/dist/components/embeds/google-sheets-viewer.d.ts.map +1 -1
  57. package/dist/components/embeds/google-sheets-viewer.js +7 -4
  58. package/dist/components/embeds/index.cjs +8 -8
  59. package/dist/components/embeds/pdf-viewer.d.ts +3 -1
  60. package/dist/components/embeds/pdf-viewer.d.ts.map +1 -1
  61. package/dist/components/embeds/pdf-viewer.js +6 -2
  62. package/dist/components/faq/index.cjs +9 -9
  63. package/dist/components/features/index.cjs +7 -7
  64. package/dist/components/help-center-pages/index.cjs +81 -81
  65. package/dist/components/index.cjs +49 -49
  66. package/dist/components/meeting-scheduler/index.cjs +32 -32
  67. package/dist/components/navigation/index.cjs +10 -10
  68. package/dist/components/onboarding-guides/index.cjs +10 -10
  69. package/dist/components/product-demo/index.cjs +16 -16
  70. package/dist/components/related-content/index.cjs +4 -4
  71. package/dist/components/tickets/index.cjs +12 -12
  72. package/dist/components/ui/index.cjs +7 -7
  73. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -1
  74. package/dist/components/ui/markdown/rich/rich-markdown-renderer.js +7 -3
  75. package/dist/{devices-IZROCLFK.cjs → devices-P7M4Z7VP.cjs} +8 -8
  76. package/dist/{embeddable-chat-ETW6S22E.cjs → embeddable-chat-322NDFP5.cjs} +74 -74
  77. package/dist/index.cjs +15 -11
  78. package/dist/{knowledge-4IRWLTNW.cjs → knowledge-TC7JLOWI.cjs} +8 -8
  79. package/dist/{logs-EFDGY5IO.cjs → logs-GPOYCP4B.cjs} +14 -14
  80. package/dist/{policies-3KEXZ5BP.cjs → policies-ZUHOUMDD.cjs} +11 -11
  81. package/dist/{scripts-PCGTZLZL.cjs → scripts-FOEVDGTH.cjs} +12 -12
  82. package/dist/{software-update-YCDNVFJE.cjs → software-update-XGTMNFST.cjs} +10 -10
  83. package/dist/types/doc-source.d.ts +2 -2
  84. package/dist/types/doc-source.d.ts.map +1 -1
  85. package/dist/utils/doc-tree-nav.d.ts +7 -0
  86. package/dist/utils/doc-tree-nav.d.ts.map +1 -1
  87. package/dist/utils/doc-tree-nav.js +19 -0
  88. package/dist/utils/index.cjs +25 -2
  89. package/dist/utils/list-url.d.ts.map +1 -1
  90. package/dist/utils/list-url.js +2 -1
  91. package/dist/utils/source-grouping.d.ts +2 -2
  92. package/dist/utils/source-grouping.d.ts.map +1 -1
  93. package/dist/utils/source-grouping.js +1 -1
  94. package/dist/utils/source-icons.d.ts.map +1 -1
  95. package/dist/utils/source-icons.js +3 -0
  96. package/package.json +1 -1
  97. package/src/components/chat/entity-cards/dispatch.tsx +3 -0
  98. package/src/components/docs/doc-viewer.tsx +56 -35
  99. package/src/components/docs/docs-hub-page.tsx +13 -37
  100. package/src/components/docs/use-document-tree.ts +33 -23
  101. package/src/components/embeds/claude-embed.tsx +4 -4
  102. package/src/components/embeds/embed-iframe.tsx +8 -5
  103. package/src/components/embeds/embed-viewer-frame.tsx +35 -15
  104. package/src/components/embeds/figma-embed.tsx +9 -4
  105. package/src/components/embeds/file-download-card.tsx +28 -4
  106. package/src/components/embeds/google-sheets-viewer.tsx +9 -3
  107. package/src/components/embeds/pdf-viewer.tsx +9 -2
  108. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +4 -0
  109. package/src/types/doc-source.ts +2 -2
  110. package/src/utils/doc-tree-nav.ts +27 -0
  111. package/src/utils/list-url.ts +2 -1
  112. package/src/utils/source-grouping.ts +2 -2
  113. package/src/utils/source-icons.ts +3 -0
  114. package/dist/components/docs/skeletons.d.ts +0 -25
  115. package/dist/components/docs/skeletons.d.ts.map +0 -1
  116. package/dist/components/docs/skeletons.js +0 -83
  117. package/src/components/docs/skeletons.tsx +0 -131
@@ -4,15 +4,15 @@ import type React from 'react';
4
4
  import { useMemo } from 'react';
5
5
  import { useRouter } from '../../embed-shims/next-navigation';
6
6
  import type { DocContent, DocNode, DocRenderHandlers, DocSourceId } from '../../types/doc-source';
7
- import { findDocNodeByPath } from '../../utils/doc-tree-nav';
7
+ import { findDocNodeByPath, rootDocumentPath } from '../../utils/doc-tree-nav';
8
8
  import { PageShell } from '../layout/article-detail-layout';
9
9
  import { PageLayout } from '../layout/page-layout';
10
- import { UnifiedSkeleton } from '../loading/unified-skeleton';
11
10
  import { MultiLevelNavigation, MobileNavigationDropdown } from '../navigation/multi-level-navigation';
12
11
  import { StickySectionNav } from '../navigation/sticky-section-nav';
13
12
  import { PersistentSidebar, PersistentMobileDropdown } from '../persistent-filter-controls';
14
13
  import { DocSearchBar, useDocSearch } from '../shared/doc-search';
15
14
  import { DataSyncLine } from '../ui/data-attribution';
15
+ import { LoadingScope } from '../ui/loading-scope';
16
16
  import { useDocNavigation } from './doc-navigation-context';
17
17
  import { useDocsResolveLink } from './use-docs-resolve-link';
18
18
  import { useDocumentTree } from './use-document-tree';
@@ -46,13 +46,6 @@ export interface DocViewerProps {
46
46
  */
47
47
  renderContent: (content: DocContent, handlers: DocRenderHandlers) => React.ReactNode;
48
48
 
49
- /**
50
- * Render the loading skeleton. Receives the selected node's `documentType`
51
- * (undefined while structure is still loading) so the caller can return a
52
- * markdown-shaped skeleton vs an embed-shaped skeleton.
53
- */
54
- renderSkeleton: (documentType: DocNode['documentType']) => React.ReactNode;
55
-
56
49
  /**
57
50
  * Chat-source identifier — passed in by the page shell from server-side
58
51
  * `currentPlatform()`. Lib has no platform context; the page shell is the
@@ -88,6 +81,13 @@ export interface DocViewerProps {
88
81
  /** Initial doc path (URL `[...path]`). */
89
82
  docPath?: string;
90
83
 
84
+ /**
85
+ * The type of the document at `docPath`, when the page knows it before the tree is read (the host read it on the
86
+ * server). The content's loading state takes its shape from it on a page's first load (a Figma deck draws the embed
87
+ * skeleton, not a text page); once the tree is read, the selected node's own type decides.
88
+ */
89
+ initialDocumentType?: DocNode['documentType'];
90
+
91
91
  /** Sidebar header copy (`'DOCUMENTATION'`, `'DATA ROOM'`). */
92
92
  sidebarLabel?: string;
93
93
 
@@ -113,7 +113,7 @@ export interface DocViewerProps {
113
113
  /** Base route path for URL navigation. */
114
114
  baseRoute: string;
115
115
 
116
- /** Empty state copy when no doc is selected. */
116
+ /** What the viewer says when the source holds no documents yet (default "No documents yet."). */
117
117
  emptyStateText?: string;
118
118
 
119
119
  /** Whether to render the doc-search bar (bound to chat). */
@@ -141,12 +141,13 @@ export function DocViewer(props: DocViewerProps) {
141
141
 
142
142
  /** The "On this page" column's heading: static copy, drawn for real loaded and loading. */
143
143
  const ON_THIS_PAGE_LABEL = 'ON THIS PAGE';
144
+ /** The loading section list's click handler: it has no sections to click. */
145
+ const NO_SECTION_CLICK = () => {};
144
146
  const ON_THIS_PAGE_HEADING_CLASS = 'mb-4 font-semibold text-ods-text-secondary text-h5';
145
147
 
146
148
  function DocViewerContent({
147
149
  sourceId,
148
150
  renderContent,
149
- renderSkeleton,
150
151
  chatSource,
151
152
  title,
152
153
  subtitle,
@@ -154,6 +155,7 @@ function DocViewerContent({
154
155
  className = '',
155
156
  shell = true,
156
157
  docPath,
158
+ initialDocumentType,
157
159
  sidebarLabel = 'DOCUMENTATION',
158
160
  structureEndpoint,
159
161
  contentEndpoint,
@@ -246,11 +248,31 @@ function DocViewerContent({
246
248
  // - the article max-width + sticky-nav visibility (markdown only)
247
249
  // `undefined` documentType is treated as `'markdown'` (per the DocNode
248
250
  // discriminator's documented default).
251
+ // Before the tree is read, the host's `initialDocumentType` (the page's own document) stands in. Once it is read, the
252
+ // root takes the type of the document it shows (`rootDocumentPath`), so a root Figma or PDF never flips to the text
253
+ // layout between the tree arriving and the document arriving.
254
+ const selectedNodePath =
255
+ structure.length > 0 && !selectedPath ? rootDocumentPath(structure, folderIndexFile) : selectedPath;
249
256
  const selectedNodeDocType =
250
- selectedPath && structure.length > 0 ? findDocNodeByPath(selectedPath, structure)?.documentType : undefined;
251
- // During loading, the in-flight content's type isn't known yet — fall back
252
- // to the selected node's type (or markdown if neither is set).
253
- const activeDocType = content?.documentType ?? selectedNodeDocType;
257
+ structure.length > 0
258
+ ? selectedNodePath
259
+ ? findDocNodeByPath(selectedNodePath, structure)?.documentType
260
+ : undefined
261
+ : initialDocumentType;
262
+ // While a document loads, `content` is still the PREVIOUS document's: the layout (the "On this page" column, the
263
+ // article width) follows the document being opened, never the one being left, or opening a Figma deck from a text
264
+ // page draws the text page's column beside the deck's skeleton. Loaded, the content's own type decides.
265
+ const isLoadingDocument = isLoadingContent || isLoadingStructure;
266
+ // While a document loads: its renderer with an EMPTY document of its type (the selected node's, or the host's
267
+ // `initialDocumentType` before the tree is read), drawn in a loading scope, so every renderer shows its own loading
268
+ // form.
269
+ const loadingContent = isLoadingDocument
270
+ ? renderContent(
271
+ { content: '', sections: [], path: selectedPath ?? '', documentType: selectedNodeDocType },
272
+ { onInternalLinkClick: navigateToDoc, currentPath: selectedPath ?? '', sourceId },
273
+ )
274
+ : null;
275
+ const activeDocType = isLoadingDocument ? selectedNodeDocType : (content?.documentType ?? selectedNodeDocType);
254
276
  const isMarkdownContent = !activeDocType || activeDocType === 'markdown';
255
277
  const showStickyNav = isMarkdownContent;
256
278
 
@@ -266,11 +288,10 @@ function DocViewerContent({
266
288
  const containerBgStyle =
267
289
  colorPalette.containerBackground !== 'transparent' ? { backgroundColor: colorPalette.containerBackground } : {};
268
290
 
269
- const defaultEmptyText =
270
- structure.length > 0
271
- ? 'Select a document from the sidebar to view'
272
- : 'No documents yet. Add content from the admin panel.';
273
- const resolvedEmptyText = emptyStateText || defaultEmptyText;
291
+ // A source with documents and none open asks for one; an empty source says so in the host's words (a reader may
292
+ // not be the one who adds documents, so the default names no admin screen).
293
+ const resolvedEmptyText =
294
+ structure.length > 0 ? 'Select a document from the sidebar to view' : emptyStateText || 'No documents yet.';
274
295
 
275
296
  // Unified header: title/subtitle route through the canonical (frozen)
276
297
  // `PageLayout` `TitleBlock` (text-h2) — same as every other help-center page —
@@ -365,8 +386,10 @@ function DocViewerContent({
365
386
  (app-globals.css) so content wraps instead of overflowing. */}
366
387
  <div className={`w-full min-w-0 ${isMarkdownContent ? 'mx-auto max-w-4xl' : ''}`}>
367
388
  <article className="space-y-2">
368
- {isLoadingContent || isLoadingStructure ? (
369
- renderSkeleton(selectedNodeDocType)
389
+ {isLoadingDocument ? (
390
+ // The document being opened draws its own loading form: its renderer, in a loading scope,
391
+ // with an empty document of its type (no words, no address), never a skeleton picked here.
392
+ <LoadingScope>{loadingContent}</LoadingScope>
370
393
  ) : !content ? (
371
394
  <div className="py-16 text-center">
372
395
  <p className="text-ods-text-secondary text-h4">{resolvedEmptyText}</p>
@@ -377,22 +400,20 @@ function DocViewerContent({
377
400
  </article>
378
401
  </div>
379
402
 
380
- {isMarkdownContent && (isLoadingContent || isLoadingStructure) && (
403
+ {isMarkdownContent && isLoadingDocument && (
381
404
  <div className="hidden content-lg:block">
382
405
  <div className="sticky top-24">
383
406
  <h3 className={ON_THIS_PAGE_HEADING_CLASS}>{ON_THIS_PAGE_LABEL}</h3>
384
- <div className="space-y-0">
385
- {[130, 170, 190, 220, 110, 200, 80, 100, 120, 140, 90].map((w, i) => (
386
- <div
387
- key={i}
388
- className={`border-l-2 py-[13px] pl-3 ${
389
- i === 0 ? 'border-ods-accent' : 'border-transparent'
390
- }`}
391
- >
392
- <UnifiedSkeleton className="h-[13px] rounded" width={w} />
393
- </div>
394
- ))}
395
- </div>
407
+ {/* The section list's own loading form (`StickySectionNav` with no sections, in a scope). */}
408
+ <LoadingScope>
409
+ <StickySectionNav
410
+ sections={[]}
411
+ activeSection=""
412
+ onSectionClick={NO_SECTION_CLICK}
413
+ ribbonPosition="left"
414
+ ribbonColor="var(--ods-accent)"
415
+ />
416
+ </LoadingScope>
396
417
  </div>
397
418
  </div>
398
419
  )}
@@ -5,7 +5,6 @@ import { FileDownloadCard } from '../embeds/file-download-card';
5
5
  import { GoogleSheetsViewer } from '../embeds/google-sheets-viewer';
6
6
  import { PdfViewer } from '../embeds/pdf-viewer';
7
7
  import { DocViewer, type DocViewerProps } from './doc-viewer';
8
- import { MarkdownSkeleton, EmbedSkeleton } from './skeletons';
9
8
 
10
9
  type DocRenderer = (content: DocContent, handlers: DocRenderHandlers) => React.ReactNode;
11
10
 
@@ -20,7 +19,7 @@ type DocRenderer = (content: DocContent, handlers: DocRenderHandlers) => React.R
20
19
  */
21
20
  export type DocumentTypeRenderers = { markdown: DocRenderer } & Partial<Record<DocumentType, DocRenderer>>;
22
21
 
23
- export interface DocsHubPageProps extends Omit<DocViewerProps, 'renderContent' | 'renderSkeleton' | 'showAIChat'> {
22
+ export interface DocsHubPageProps extends Omit<DocViewerProps, 'renderContent' | 'showAIChat'> {
24
23
  /** Per-document-type renderer map. `markdown` is REQUIRED. */
25
24
  documentTypeRenderers: DocumentTypeRenderers;
26
25
 
@@ -42,32 +41,20 @@ const defaultFallbackRenderer: DocRenderer = () => (
42
41
  </div>
43
42
  );
44
43
 
45
- // When the DAL hasn't populated the URL field for a rich-content type, the
46
- // embed-viewer components render a broken iframe (empty src). Fall back to
47
- // the same lib-styled "Unsupported document type" panel the explicit
48
- // fallback uses — the surface is honest about the missing data instead of
49
- // pretending to load. `FileDownloadCard` handles its own missing-URL state
50
- // (hides the Download button), so it doesn't need this guard.
51
- const defaultPdfRenderer: DocRenderer = (content, handlers) =>
52
- content.fileUrl ? (
53
- <PdfViewer src={content.fileUrl} fileName={content.fileName} />
54
- ) : (
55
- defaultFallbackRenderer(content, handlers)
56
- );
44
+ // Each viewer owns both its states without a document: in a loading scope (the document is being read) it draws its
45
+ // own loading form, otherwise its "not available" state when the DAL left the URL empty. So the renderers always
46
+ // render the viewer, and the viewer never mounts a frame without a source.
47
+ const defaultPdfRenderer: DocRenderer = content => (
48
+ <PdfViewer src={content.fileUrl ?? ''} fileName={content.fileName} />
49
+ );
57
50
 
58
- const defaultGoogleSheetRenderer: DocRenderer = (content, handlers) =>
59
- content.externalUrl ? (
60
- <GoogleSheetsViewer externalUrl={content.externalUrl} fileName={content.fileName} />
61
- ) : (
62
- defaultFallbackRenderer(content, handlers)
63
- );
51
+ const defaultGoogleSheetRenderer: DocRenderer = content => (
52
+ <GoogleSheetsViewer externalUrl={content.externalUrl ?? ''} fileName={content.fileName} />
53
+ );
64
54
 
65
- const defaultFigmaRenderer: DocRenderer = (content, handlers) =>
66
- content.externalUrl ? (
67
- <FigmaEmbed url={content.externalUrl} title={content.fileName} loading="eager" />
68
- ) : (
69
- defaultFallbackRenderer(content, handlers)
70
- );
55
+ const defaultFigmaRenderer: DocRenderer = content => (
56
+ <FigmaEmbed url={content.externalUrl ?? ''} title={content.fileName} iframeLoading="eager" />
57
+ );
71
58
 
72
59
  const defaultFileRenderer: DocRenderer = content => (
73
60
  <FileDownloadCard
@@ -78,16 +65,6 @@ const defaultFileRenderer: DocRenderer = content => (
78
65
  />
79
66
  );
80
67
 
81
- /** The page's own loading form per document type: the markdown article's, else the embed viewer's. */
82
- const documentLoadingForm = (documentType: DocumentType | undefined) =>
83
- !documentType || documentType === 'markdown' ? (
84
- <MarkdownSkeleton />
85
- ) : (
86
- // Forward the documentType so the embed skeleton renders the right
87
- // shape (PDF=2 buttons, sheets/figma=1 button, file=centered card).
88
- <EmbedSkeleton documentType={documentType} />
89
- );
90
-
91
68
  /**
92
69
  * Embeddable docs-hub page. Bundles `<DocViewer>` with safe defaults so the
93
70
  * minimum embed is a one-line mount (consumer only has to supply
@@ -132,7 +109,6 @@ export function DocsHubPage({
132
109
  className={className}
133
110
  sidebarLabel={sidebarLabel}
134
111
  renderContent={renderContent}
135
- renderSkeleton={documentLoadingForm}
136
112
  />
137
113
  );
138
114
  }
@@ -9,6 +9,8 @@ import {
9
9
  findDocNodeByPath,
10
10
  getDocAncestorNodeIds,
11
11
  DEFAULT_FOLDER_INDEX_FILE,
12
+ findFirstDocPath,
13
+ rootDocumentPath,
12
14
  } from '../../utils/doc-tree-nav';
13
15
  import { contentFetch } from '../../utils/embed-content-fetch';
14
16
  import { navigateSamePageHash, HUB_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav';
@@ -34,21 +36,6 @@ function scrollToContent() {
34
36
  * its README) instead of a blank panel or a redundant in-page listing — the
35
37
  * sidebar tree is the directory browser. Returns null for a folder with no docs.
36
38
  */
37
- function findFirstDocPath(folder: DocNode): string | null {
38
- const children = folder.children ?? [];
39
- for (const child of children) {
40
- if (child.type === 'file' && !child.path.toLowerCase().endsWith('.mmd')) {
41
- return child.path;
42
- }
43
- }
44
- for (const child of children) {
45
- if (child.type === 'folder') {
46
- const nested = findFirstDocPath(child);
47
- if (nested) return nested;
48
- }
49
- }
50
- return null;
51
- }
52
39
 
53
40
  // ---------------------------------------------------------------------------
54
41
  // Wire decoding for the two doc-source endpoints
@@ -220,6 +207,11 @@ export function useDocumentTree(config: UseDocumentTreeConfig, initialPath?: str
220
207
  const cleanInitialPath = stripFolderIndexFromPath(initialPath?.replace(/\/$/, '') || '', folderIndexFile);
221
208
 
222
209
  const [structure, setStructure] = useState<DocNode[]>([]);
210
+ // The latest committed tree, for a request that settles after the tree arrived (its closure holds the old one).
211
+ const structureRef = useRef(structure);
212
+ useEffect(() => {
213
+ structureRef.current = structure;
214
+ }, [structure]);
223
215
  const [selectedPath, setSelectedPath] = useState<string>(cleanInitialPath);
224
216
  const [content, setContent] = useState<DocContent | null>(null);
225
217
  const [isLoadingStructure, setIsLoadingStructure] = useState(true);
@@ -409,12 +401,16 @@ export function useDocumentTree(config: UseDocumentTreeConfig, initialPath?: str
409
401
  // No-README FOLDER → stay silent: the content effect resolves it to
410
402
  // the folder's first child, so the folder-path 404 from the direct-
411
403
  // load speculative fetch is expected, not an error to flash.
412
- const probe = findDocNodeByPath(stripFolderIndexFromPath(path, folderIndexFile), structure);
404
+ // Classified against the LATEST tree (`structureRef`), never the one captured when this request went out: the tree
405
+ // often arrives while the request is in flight, and judged against the empty tree of the call a missing
406
+ // document read as "not classified yet, wait", so nothing settled it and the page stayed a skeleton.
407
+ const latestStructure = structureRef.current;
408
+ const probe = findDocNodeByPath(stripFolderIndexFromPath(path, folderIndexFile), latestStructure);
413
409
  const probeIsNoReadmeFolder = !!probe && probe.type === 'folder' && !probe.hasReadme;
414
410
  // Before the structure has loaded to classify the path (`structure`
415
411
  // is [] in the speculative call's closure), only silence FOLDER-LIKE
416
412
  // paths (no `.md`) — a genuinely missing `*.md` leaf must still error.
417
- const preStructureFolderLike = structure.length === 0 && !path.endsWith('.md');
413
+ const preStructureFolderLike = latestStructure.length === 0 && !path.endsWith('.md');
418
414
  if (probeIsNoReadmeFolder || preStructureFolderLike) {
419
415
  // Superseded by the targeted fetch (first-child / reclassified path)
420
416
  // the structure effect fires. Null the request id so the `finally`
@@ -507,7 +503,15 @@ export function useDocumentTree(config: UseDocumentTreeConfig, initialPath?: str
507
503
  let pathToFetch: string | null = null;
508
504
 
509
505
  if (selectedPath === '') {
510
- pathToFetch = folderIndexFile;
506
+ // The root shows its landing document (the folder index); a source with none shows its FIRST document, the
507
+ // rule a no-README folder follows. Asking for the missing index again would 404 for ever.
508
+ const rootPath = rootDocumentPath(structure, folderIndexFile);
509
+ if (!rootPath) {
510
+ // A tree with folders and no document at all: nothing to fetch; the empty state is derived below.
511
+ lastFetchedPath.current = null;
512
+ return;
513
+ }
514
+ pathToFetch = rootPath;
511
515
  } else {
512
516
  const node = findDocNodeByPath(selectedPath, structure);
513
517
 
@@ -731,19 +735,25 @@ export function useDocumentTree(config: UseDocumentTreeConfig, initialPath?: str
731
735
  // the content effect, which reached the same conclusion one commit later and
732
736
  // meanwhile left the PREVIOUS document's body on screen.
733
737
  const isEmptyFolder = useMemo(() => {
734
- if (!selectedPath || structure.length === 0) return false;
738
+ if (structure.length === 0) return false;
739
+ // The root of a source whose tree holds no document at all is an empty folder too.
740
+ if (!selectedPath) return !rootDocumentPath(structure, folderIndexFile);
735
741
  const node = findDocNodeByPath(selectedPath, structure);
736
742
  return !!node && node.type === 'folder' && !node.hasReadme && !findFirstDocPath(node);
737
- }, [selectedPath, structure]);
743
+ }, [selectedPath, structure, folderIndexFile]);
744
+ // A source with no documents at all (its tree is read and empty): there is nothing to load, so the viewer shows the
745
+ // source's empty state instead of waiting for a landing document that does not exist.
746
+ const isEmptySource = !isLoadingStructure && structure.length === 0;
747
+ const settled = isEmptyFolder || isEmptySource;
738
748
 
739
749
  return {
740
750
  structure,
741
751
  selectedPath,
742
752
  setSelectedPath,
743
- content: isEmptyFolder ? null : content,
753
+ content: settled ? null : content,
744
754
  isLoadingStructure,
745
- isLoadingContent: isEmptyFolder ? false : isLoadingContent,
746
- error: isEmptyFolder ? null : error,
755
+ isLoadingContent: settled ? false : isLoadingContent,
756
+ error: settled ? null : error,
747
757
  expandedNodes,
748
758
  toggleNode,
749
759
  selectNode,
@@ -19,7 +19,7 @@ export interface ClaudeEmbedProps {
19
19
  /** iframe height (CSS value), matching `FigmaEmbed`'s prop. */
20
20
  height?: string;
21
21
  /** iframe loading strategy. Defaults to `lazy`, as `FigmaEmbed` does. */
22
- loading?: 'eager' | 'lazy';
22
+ iframeLoading?: 'eager' | 'lazy';
23
23
  }
24
24
 
25
25
  const KIND_HEADING: Record<ClaudeEmbedKind, string> = {
@@ -51,7 +51,7 @@ const KIND_HEADING: Record<ClaudeEmbedKind, string> = {
51
51
  * a Claude Design url (no embed route), an artifact whose author has not
52
52
  * allow-listed this host, or an http host (mixed content).
53
53
  */
54
- export function ClaudeEmbed({ url, kind = 'artifact', title, height, loading = 'lazy' }: ClaudeEmbedProps) {
54
+ export function ClaudeEmbed({ url, kind = 'artifact', title, height, iframeLoading = 'lazy' }: ClaudeEmbedProps) {
55
55
  // Mirror detection lives in `useClaudeMirrorSrc` (derive from
56
56
  // EndpointsRuntime + 1-byte ranged probe + background revalidation) — this
57
57
  // component stays presentational.
@@ -70,7 +70,7 @@ export function ClaudeEmbed({ url, kind = 'artifact', title, height, loading = '
70
70
  icon={<ClaudeIcon className="h-5 w-5 shrink-0" />}
71
71
  title={title?.trim() || KIND_HEADING[kind]}
72
72
  titleVariant="h6"
73
- isLoading={isProbing}
73
+ resolving={isProbing}
74
74
  actions={
75
75
  <Button
76
76
  variant="outline"
@@ -89,7 +89,7 @@ export function ClaudeEmbed({ url, kind = 'artifact', title, height, loading = '
89
89
  // (unlike Figma's player) — the shell provides the toggle for both
90
90
  // the claude.ai `/embed` route and a mirror.
91
91
  fullscreenControl
92
- loading={loading}
92
+ iframeLoading={iframeLoading}
93
93
  height={height}
94
94
  // Same frame contract Figma gets — an artifact is a real app: it copies
95
95
  // to the clipboard and can go fullscreen.
@@ -43,8 +43,10 @@ export interface EmbedIframeProps {
43
43
  allow?: string;
44
44
  /** iframe `referrerPolicy` attribute */
45
45
  referrerPolicy?: React.IframeHTMLAttributes<HTMLIFrameElement>['referrerPolicy'];
46
- /** iframe `loading` attribute */
47
- loading?: 'eager' | 'lazy';
46
+ /** iframe `loading` attribute (eager or lazy). */
47
+ iframeLoading?: 'eager' | 'lazy';
48
+ /** The frame's source is not known yet: the frame draws its loading form. A loading scope does the same. */
49
+ loading?: boolean;
48
50
  /** iframe `allowFullScreen` attribute */
49
51
  allowFullScreen?: boolean;
50
52
  /**
@@ -74,7 +76,8 @@ export function EmbedIframe({
74
76
  height,
75
77
  allow,
76
78
  referrerPolicy,
77
- loading,
79
+ iframeLoading,
80
+ loading = false,
78
81
  allowFullScreen,
79
82
  sandbox,
80
83
  }: EmbedIframeProps) {
@@ -87,7 +90,7 @@ export function EmbedIframe({
87
90
  const isLoaded = loadedSrc === src;
88
91
  const iframeRef = useRef<HTMLIFrameElement>(null);
89
92
  const handleLoad = useCallback(() => setLoadedSrc(src), [src]);
90
- const held = useLoadingScope();
93
+ const held = useLoadingScope() || loading;
91
94
 
92
95
  useEffect(() => {
93
96
  const iframe = iframeRef.current;
@@ -123,7 +126,7 @@ export function EmbedIframe({
123
126
  onLoad={handleLoad}
124
127
  allow={allow}
125
128
  referrerPolicy={referrerPolicy}
126
- loading={loading}
129
+ loading={iframeLoading}
127
130
  allowFullScreen={allow?.includes('fullscreen') ? undefined : allowFullScreen}
128
131
  sandbox={sandbox}
129
132
  />
@@ -5,6 +5,7 @@ import type React from 'react';
5
5
  import { useEffect, useRef, useState } from 'react';
6
6
  import { cn } from '../../utils/cn';
7
7
  import { FullscreenSwitchController } from '../../utils/fullscreen-switch';
8
+ import { InteractiveSkeleton, TextLineSkeleton } from '../loading/unified-skeleton';
8
9
  import { Button } from '../ui/button/button';
9
10
  import { useLoadingScope } from '../ui/loading-scope';
10
11
  import { EmbedIframe, EmbedLoadingSkeleton } from './embed-iframe';
@@ -40,21 +41,22 @@ export interface EmbedViewerFrameProps {
40
41
  /** Rendered verbatim in the header row — the caller owns its own container/state. */
41
42
  actions?: React.ReactNode;
42
43
  /** Iframe source; falsy renders the inline empty state (or the loading
43
- * state when `isLoading`) under the header. */
44
+ * state when `loading` or `resolving`) under the header. */
44
45
  src: string | null | undefined;
45
- /** While true AND there is no `src` yet, the body shows the shared embed
46
- * loading skeleton (`EmbedLoadingSkeleton`) instead of the empty state —
47
- * for a viewer that is still resolving where its frame points (e.g.
48
- * ClaudeEmbed probing for a self-hosted mirror), so a cold first view
49
- * reads as "loading" (identical to every other embed), not "nothing here". */
50
- isLoading?: boolean;
46
+ /** The document is being read (the standard `loading` prop; a loading scope does the same): the whole frame is its
47
+ * loading form, a skeleton title, its buttons' loading forms and the frame's loading box. */
48
+ loading?: boolean;
49
+ /** The document is known (its title shows) but the viewer is still finding where its frame points (ClaudeEmbed
50
+ * probing for a self-hosted mirror): only the body is its loading box. */
51
+ resolving?: boolean;
51
52
  /** Large icon for the inline empty state (the viewers pass `w-16 h-16`). */
52
53
  emptyIcon?: React.ReactNode;
53
54
  emptyMessage?: string;
54
55
  height?: string;
55
56
  allow?: string;
56
57
  allowFullScreen?: boolean;
57
- loading?: 'eager' | 'lazy';
58
+ /** iframe loading strategy (eager or lazy). */
59
+ iframeLoading?: 'eager' | 'lazy';
58
60
  /** iframe `sandbox` — see `EmbedIframe`. Set it for user-authored contents. */
59
61
  sandbox?: string;
60
62
  /** Wrapper classes; defaults to the sheets/pdf `space-y-4`. */
@@ -66,6 +68,8 @@ export interface EmbedViewerFrameProps {
66
68
  * button (the Figma player) and some cannot (a self-hosted Claude
67
69
  * artifact mirror) — the shell provides the affordance uniformly. */
68
70
  fullscreenControl?: boolean;
71
+ /** How many buttons `actions` draws (PDF: Preview and Download): the header's loading form draws as many. Default 1. */
72
+ loadingActionCount?: number;
69
73
  }
70
74
 
71
75
  export function EmbedViewerFrame({
@@ -74,19 +78,23 @@ export function EmbedViewerFrame({
74
78
  titleVariant = 'h3',
75
79
  actions,
76
80
  src,
77
- isLoading: isLoadingGiven,
81
+ loading: loadingGiven = false,
82
+ resolving = false,
78
83
  emptyIcon,
79
84
  emptyMessage = 'Content not available',
80
85
  height,
81
86
  allow,
82
87
  allowFullScreen,
83
- loading,
88
+ iframeLoading,
84
89
  sandbox,
85
90
  className,
86
91
  fullscreenControl,
92
+ loadingActionCount = 1,
87
93
  }: EmbedViewerFrameProps) {
88
- // ONE switch: a page's loading scope, or the viewer still resolving where its frame points.
89
- const isLoading = useLoadingScope() || !!isLoadingGiven;
94
+ // ONE switch for the whole frame (header included): a page's loading scope, or the standard `loading` prop. The body
95
+ // alone also waits while the viewer resolves where its frame points.
96
+ const loadingForm = useLoadingScope() || loadingGiven;
97
+ const isLoading = loadingForm || resolving;
90
98
  const frameRef = useRef<HTMLDivElement>(null);
91
99
  const [isFullscreen, setIsFullscreen] = useState(false);
92
100
 
@@ -127,7 +135,11 @@ export function EmbedViewerFrame({
127
135
  <div className="flex flex-col gap-3 content-sm:flex-row content-sm:items-center content-sm:justify-between">
128
136
  <div className="flex min-w-0 items-center gap-2">
129
137
  {icon}
130
- {titleVariant === 'h3' ? (
138
+ {loadingForm ? (
139
+ // The document is being read: its name is not known yet. (A viewer still resolving its frame, with a
140
+ // known title, keeps it.)
141
+ <TextLineSkeleton type={titleVariant} className="w-48 max-w-full" />
142
+ ) : titleVariant === 'h3' ? (
131
143
  <h2 className="truncate text-ods-text-primary text-h3">{title}</h2>
132
144
  ) : (
133
145
  <span className="truncate font-semibold text-ods-text-primary text-h6">{title}</span>
@@ -140,7 +152,15 @@ export function EmbedViewerFrame({
140
152
  fullscreen toggle, `actions` renders EXACTLY as before — the
141
153
  wrapper exists only when the toggle joins it, so pre-existing
142
154
  viewers (figma/pdf/sheets) are byte-identical. */}
143
- {fullscreenControl && src ? (
155
+ {loadingForm ? (
156
+ // The actions point at the document being read: each its button's loading form, in the actions' row.
157
+ <div className="flex w-full items-center gap-2 content-sm:w-auto">
158
+ {Array.from({ length: loadingActionCount }, (_, i) => (
159
+ // biome-ignore lint/suspicious/noArrayIndexKey: loading buttons never reorder
160
+ <InteractiveSkeleton.Button key={i} size="small-legacy" className="flex-1 content-sm:flex-initial" />
161
+ ))}
162
+ </div>
163
+ ) : fullscreenControl && src ? (
144
164
  <div className="flex flex-col gap-[var(--spacing-system-xsf)] content-sm:flex-row content-sm:items-center">
145
165
  {actions}
146
166
  <Button
@@ -169,7 +189,7 @@ export function EmbedViewerFrame({
169
189
  height={isFullscreen ? 'calc(100vh - 96px)' : height}
170
190
  allow={allow}
171
191
  allowFullScreen={allowFullScreen}
172
- loading={loading}
192
+ iframeLoading={iframeLoading}
173
193
  sandbox={sandbox}
174
194
  />
175
195
  ) : isLoading ? (
@@ -19,7 +19,9 @@ export interface FigmaEmbedProps {
19
19
  */
20
20
  height?: string;
21
21
  /** iframe loading strategy. Defaults to `"lazy"`; the data-room viewer passes `"eager"`. */
22
- loading?: 'eager' | 'lazy';
22
+ iframeLoading?: 'eager' | 'lazy';
23
+ /** The document is being read: the embed draws its loading form. A loading scope does the same. */
24
+ loading?: boolean;
23
25
  }
24
26
 
25
27
  type SlidesView = 'present' | 'browse';
@@ -42,7 +44,9 @@ function SlidesViewToggle({ view, onChange }: { view: SlidesView; onChange: (v:
42
44
  if (v && v !== view) onChange(v as SlidesView);
43
45
  }}
44
46
  aria-label="Figma slides view mode"
45
- className="flex shrink-0 items-center gap-0.5 rounded-lg border border-ods-border bg-ods-card p-0.5"
47
+ // The whole control is one button tall (40px, border and padding included), so the header row is the same height
48
+ // with or without it, loading and loaded alike.
49
+ className="flex h-10 shrink-0 items-center gap-0.5 rounded-lg border border-ods-border bg-ods-card p-0.5"
46
50
  >
47
51
  {options.map(({ key, label, Icon }) => {
48
52
  const active = view === key;
@@ -51,7 +55,7 @@ function SlidesViewToggle({ view, onChange }: { view: SlidesView; onChange: (v:
51
55
  key={key}
52
56
  value={key}
53
57
  aria-label={label}
54
- className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 transition-colors text-h6 ${
58
+ className={`flex h-full items-center gap-1.5 rounded-md px-2.5 py-1 transition-colors text-h6 ${
55
59
  active
56
60
  ? 'bg-ods-accent text-ods-text-on-accent'
57
61
  : 'text-ods-text-secondary hover:bg-ods-bg-hover hover:text-ods-text-primary'
@@ -80,7 +84,7 @@ function SlidesViewToggle({ view, onChange }: { view: SlidesView; onChange: (v:
80
84
  * plain ReactNode) — flipping it recomputes `embedSrc`, and the frame's
81
85
  * `EmbedIframe` remounts on the new src exactly as before the extraction.
82
86
  */
83
- export function FigmaEmbed({ url, title, height, loading = 'lazy' }: FigmaEmbedProps) {
87
+ export function FigmaEmbed({ url, title, height, iframeLoading = 'lazy', loading = false }: FigmaEmbedProps) {
84
88
  const [view, setView] = useState<SlidesView>('present');
85
89
  const isSlides = url ? isFigmaSlidesUrl(url) : false;
86
90
  const embedSrc = url ? toFigmaEmbedUrl(url, { slidesView: view }) : null;
@@ -124,6 +128,7 @@ export function FigmaEmbed({ url, title, height, loading = 'lazy' }: FigmaEmbedP
124
128
  }
125
129
  src={embedSrc}
126
130
  allow="clipboard-write; clipboard-read; fullscreen"
131
+ iframeLoading={iframeLoading}
127
132
  loading={loading}
128
133
  height={height}
129
134
  allowFullScreen