@flamingo-stack/openframe-frontend-core 0.0.847 → 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 (188) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{case-study-card-DJOHIHYU.cjs → case-study-card-X2HKPKT7.cjs} +5 -5
  3. package/dist/{chunk-CNMXP77I.cjs → chunk-36DUCHI7.cjs} +4 -4
  4. package/dist/{chunk-MPVOPSU6.cjs → chunk-36XWCVSM.cjs} +96 -96
  5. package/dist/{chunk-J2IXYCEH.cjs → chunk-3W4ASAXN.cjs} +15 -15
  6. package/dist/{chunk-6LDL3UZE.cjs → chunk-4YBS4HIO.cjs} +20 -20
  7. package/dist/{chunk-ABL5OBSF.cjs → chunk-533QK4R6.cjs} +296 -250
  8. package/dist/{chunk-QK2A334V.cjs → chunk-5CBI2ZYV.cjs} +12 -12
  9. package/dist/{chunk-D5DMWXF2.cjs → chunk-5PIR77V5.cjs} +4 -4
  10. package/dist/{chunk-HPXET3LH.cjs → chunk-6AJYGXXS.cjs} +27 -27
  11. package/dist/{chunk-4R4HAFXI.cjs → chunk-ALRYVZ3E.cjs} +6 -6
  12. package/dist/{chunk-FLW4WULE.cjs → chunk-BU2PSRAS.cjs} +17 -61
  13. package/dist/{chunk-HBDLJUI3.cjs → chunk-C3P7IUTU.cjs} +39 -14
  14. package/dist/{chunk-SSSI2RBV.cjs → chunk-FNQWL4W4.cjs} +14 -14
  15. package/dist/{chunk-TBOWFTEN.cjs → chunk-FRG724DU.cjs} +22 -22
  16. package/dist/{chunk-SIFRIUPW.cjs → chunk-FXLROQUT.cjs} +345 -356
  17. package/dist/{chunk-3V5JWG27.cjs → chunk-GG466XFQ.cjs} +2 -2
  18. package/dist/{chunk-XCVH5BCZ.cjs → chunk-GRCOEICK.cjs} +6 -6
  19. package/dist/{chunk-5GBLXY5F.cjs → chunk-HTYJUMFB.cjs} +7 -7
  20. package/dist/{chunk-URP6GYTD.cjs → chunk-IG6ROHPS.cjs} +56 -56
  21. package/dist/{chunk-DGQDGCQH.cjs → chunk-IHY7E4EX.cjs} +2 -2
  22. package/dist/{chunk-WCK5T3UD.cjs → chunk-IKOL6NJY.cjs} +101 -69
  23. package/dist/{chunk-RAJ5FYFL.cjs → chunk-IN3LA2FU.cjs} +14 -14
  24. package/dist/{chunk-ZTO7KNYV.cjs → chunk-KXBZCUDW.cjs} +35 -35
  25. package/dist/{chunk-57JYB3E2.cjs → chunk-MC3L525K.cjs} +3 -3
  26. package/dist/{chunk-BIOSHDXQ.cjs → chunk-MDLT6D46.cjs} +61 -61
  27. package/dist/{chunk-3TOWCFIK.cjs → chunk-PMUCEUFQ.cjs} +13 -1
  28. package/dist/{chunk-PV3NKXWS.cjs → chunk-PV2XQJ2R.cjs} +99 -178
  29. package/dist/{chunk-J2ZSGOUC.cjs → chunk-R5JPZ6TY.cjs} +27 -27
  30. package/dist/{chunk-744JXTDP.cjs → chunk-SA2NNWIJ.cjs} +23 -23
  31. package/dist/{chunk-DEEQSVLX.cjs → chunk-SHQY4GOE.cjs} +90 -90
  32. package/dist/{chunk-BHOVF4KP.cjs → chunk-SOSIXRQ3.cjs} +2 -2
  33. package/dist/{chunk-Y2OEC7UW.cjs → chunk-TEKN6TJ5.cjs} +40 -40
  34. package/dist/{chunk-PZNWIJRZ.cjs → chunk-TIVPOVIM.cjs} +2 -2
  35. package/dist/{chunk-BZZE4KIV.cjs → chunk-TX4NTH22.cjs} +155 -136
  36. package/dist/{chunk-NFNSPLGC.cjs → chunk-W7OFAROY.cjs} +14 -9
  37. package/dist/{chunk-N2A24FDF.cjs → chunk-YLETC7LP.cjs} +994 -953
  38. package/dist/{chunk-YC52LSPP.cjs → chunk-YYHQY3K6.cjs} +40 -40
  39. package/dist/{chunk-2FUTZKLB.cjs → chunk-Z5UCDEKZ.cjs} +6 -6
  40. package/dist/{chunk-SWF6TNZ3.cjs → chunk-ZGEWT3SF.cjs} +2 -2
  41. package/dist/{chunk-PQDCSZ3W.cjs → chunk-ZY34R37A.cjs} +85 -85
  42. package/dist/{chunk-5SZ4ILU3.cjs → chunk-ZZDFMUEU.cjs} +3 -3
  43. package/dist/{cloud-tenants-XEUUMABT.cjs → cloud-tenants-HKUUEUAY.cjs} +21 -21
  44. package/dist/components/case-studies/index.cjs +36 -36
  45. package/dist/components/chat/chat-composer.d.ts.map +1 -1
  46. package/dist/components/chat/chat-composer.js +4 -3
  47. package/dist/components/chat/chat-container.d.ts.map +1 -1
  48. package/dist/components/chat/chat-container.js +2 -2
  49. package/dist/components/chat/entity-cards/design-doc-card.d.ts.map +1 -1
  50. package/dist/components/chat/entity-cards/design-doc-card.js +5 -17
  51. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  52. package/dist/components/chat/entity-cards/dispatch.js +5 -7
  53. package/dist/components/chat/index.cjs +15 -15
  54. package/dist/components/contact/index.cjs +22 -22
  55. package/dist/components/docs/doc-viewer.d.ts +7 -7
  56. package/dist/components/docs/doc-viewer.d.ts.map +1 -1
  57. package/dist/components/docs/doc-viewer.js +29 -16
  58. package/dist/components/docs/docs-hub-page.d.ts +1 -1
  59. package/dist/components/docs/docs-hub-page.d.ts.map +1 -1
  60. package/dist/components/docs/docs-hub-page.js +4 -11
  61. package/dist/components/docs/index.cjs +25 -25
  62. package/dist/components/docs/use-document-tree.d.ts.map +1 -1
  63. package/dist/components/docs/use-document-tree.js +24 -24
  64. package/dist/components/embeds/claude-embed.d.ts +2 -2
  65. package/dist/components/embeds/claude-embed.d.ts.map +1 -1
  66. package/dist/components/embeds/claude-embed.js +3 -3
  67. package/dist/components/embeds/embed-iframe.d.ts +5 -3
  68. package/dist/components/embeds/embed-iframe.d.ts.map +1 -1
  69. package/dist/components/embeds/embed-iframe.js +4 -3
  70. package/dist/components/embeds/embed-viewer-frame.d.ts +12 -9
  71. package/dist/components/embeds/embed-viewer-frame.d.ts.map +1 -1
  72. package/dist/components/embeds/embed-viewer-frame.js +21 -7
  73. package/dist/components/embeds/figma-embed.d.ts +4 -2
  74. package/dist/components/embeds/figma-embed.d.ts.map +1 -1
  75. package/dist/components/embeds/figma-embed.js +4 -3
  76. package/dist/components/embeds/file-download-card.d.ts +3 -1
  77. package/dist/components/embeds/file-download-card.d.ts.map +1 -1
  78. package/dist/components/embeds/file-download-card.js +18 -3
  79. package/dist/components/embeds/google-sheets-viewer.d.ts +3 -1
  80. package/dist/components/embeds/google-sheets-viewer.d.ts.map +1 -1
  81. package/dist/components/embeds/google-sheets-viewer.js +7 -4
  82. package/dist/components/embeds/index.cjs +22 -22
  83. package/dist/components/embeds/pdf-viewer.d.ts +3 -1
  84. package/dist/components/embeds/pdf-viewer.d.ts.map +1 -1
  85. package/dist/components/embeds/pdf-viewer.js +6 -2
  86. package/dist/components/faq/index.cjs +23 -23
  87. package/dist/components/features/index.cjs +21 -21
  88. package/dist/components/features/status-filter-component.d.ts +7 -1
  89. package/dist/components/features/status-filter-component.d.ts.map +1 -1
  90. package/dist/components/features/status-filter-component.js +9 -3
  91. package/dist/components/help-center-pages/index.cjs +140 -140
  92. package/dist/components/index.cjs +137 -135
  93. package/dist/components/layout/back-button.d.ts +7 -1
  94. package/dist/components/layout/back-button.d.ts.map +1 -1
  95. package/dist/components/layout/back-button.js +34 -2
  96. package/dist/components/loading/unified-skeleton.d.ts +6 -1
  97. package/dist/components/loading/unified-skeleton.d.ts.map +1 -1
  98. package/dist/components/loading/unified-skeleton.js +13 -1
  99. package/dist/components/meeting-scheduler/index.cjs +119 -119
  100. package/dist/components/navigation/index.cjs +24 -24
  101. package/dist/components/onboarding-guides/index.cjs +24 -24
  102. package/dist/components/product-demo/index.cjs +33 -33
  103. package/dist/components/related-content/index.cjs +11 -11
  104. package/dist/components/tickets/index.cjs +28 -28
  105. package/dist/components/ui/file-manager/file-manager-table.d.ts.map +1 -1
  106. package/dist/components/ui/file-manager/file-manager-table.js +59 -50
  107. package/dist/components/ui/file-manager/index.cjs +71 -62
  108. package/dist/components/ui/index.cjs +23 -21
  109. package/dist/components/ui/markdown/engine.d.ts +5 -4
  110. package/dist/components/ui/markdown/engine.d.ts.map +1 -1
  111. package/dist/components/ui/markdown/engine.js +15 -15
  112. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -1
  113. package/dist/components/ui/markdown/rich/rich-markdown-renderer.js +7 -3
  114. package/dist/components/ui/people-cell.d.ts +9 -2
  115. package/dist/components/ui/people-cell.d.ts.map +1 -1
  116. package/dist/components/ui/people-cell.js +39 -23
  117. package/dist/components/ui/progress-bar.d.ts +6 -0
  118. package/dist/components/ui/progress-bar.d.ts.map +1 -1
  119. package/dist/components/ui/progress-bar.js +2 -0
  120. package/dist/components/ui/tag.d.ts +8 -0
  121. package/dist/components/ui/tag.d.ts.map +1 -1
  122. package/dist/components/ui/tag.js +5 -0
  123. package/dist/{devices-7YDIHVVP.cjs → devices-P7M4Z7VP.cjs} +22 -22
  124. package/dist/embed-shims/index.cjs +3 -3
  125. package/dist/embed-shims/next-image.cjs +3 -3
  126. package/dist/{embeddable-chat-Z3S37SSM.cjs → embeddable-chat-322NDFP5.cjs} +94 -94
  127. package/dist/hooks/index.cjs +6 -6
  128. package/dist/index.cjs +32 -30
  129. package/dist/{knowledge-SQVPQSKM.cjs → knowledge-TC7JLOWI.cjs} +22 -22
  130. package/dist/{logs-M5DFGUBC.cjs → logs-GPOYCP4B.cjs} +28 -28
  131. package/dist/{policies-T3VQEJUD.cjs → policies-ZUHOUMDD.cjs} +25 -25
  132. package/dist/{remote-session-EE6477O3.cjs → remote-session-NT4NG7EF.cjs} +10 -10
  133. package/dist/{scripts-JFRFJKPH.cjs → scripts-FOEVDGTH.cjs} +28 -28
  134. package/dist/{software-update-E7W3AAIY.cjs → software-update-XGTMNFST.cjs} +25 -25
  135. package/dist/{tickets-board-44HO3LR4.cjs → tickets-board-7FLGQEHF.cjs} +10 -10
  136. package/dist/types/design-doc.d.ts +45 -8
  137. package/dist/types/design-doc.d.ts.map +1 -1
  138. package/dist/types/doc-source.d.ts +2 -2
  139. package/dist/types/doc-source.d.ts.map +1 -1
  140. package/dist/utils/design-doc-readiness.d.ts +17 -15
  141. package/dist/utils/design-doc-readiness.d.ts.map +1 -1
  142. package/dist/utils/design-doc-readiness.js +5 -11
  143. package/dist/utils/doc-tree-nav.d.ts +7 -0
  144. package/dist/utils/doc-tree-nav.d.ts.map +1 -1
  145. package/dist/utils/doc-tree-nav.js +19 -0
  146. package/dist/utils/index.cjs +30 -13
  147. package/dist/utils/list-url.d.ts.map +1 -1
  148. package/dist/utils/list-url.js +2 -1
  149. package/dist/utils/source-grouping.d.ts +2 -2
  150. package/dist/utils/source-grouping.d.ts.map +1 -1
  151. package/dist/utils/source-grouping.js +1 -1
  152. package/dist/utils/source-icons.d.ts.map +1 -1
  153. package/dist/utils/source-icons.js +3 -0
  154. package/package.json +1 -1
  155. package/src/components/chat/chat-composer.tsx +10 -3
  156. package/src/components/chat/chat-container.tsx +6 -4
  157. package/src/components/chat/entity-cards/design-doc-card.tsx +12 -17
  158. package/src/components/chat/entity-cards/dispatch.tsx +10 -10
  159. package/src/components/docs/doc-viewer.tsx +56 -35
  160. package/src/components/docs/docs-hub-page.tsx +13 -37
  161. package/src/components/docs/use-document-tree.ts +33 -23
  162. package/src/components/embeds/claude-embed.tsx +4 -4
  163. package/src/components/embeds/embed-iframe.tsx +8 -5
  164. package/src/components/embeds/embed-viewer-frame.tsx +35 -15
  165. package/src/components/embeds/figma-embed.tsx +9 -4
  166. package/src/components/embeds/file-download-card.tsx +28 -4
  167. package/src/components/embeds/google-sheets-viewer.tsx +9 -3
  168. package/src/components/embeds/pdf-viewer.tsx +9 -2
  169. package/src/components/features/status-filter-component.tsx +15 -0
  170. package/src/components/layout/back-button.tsx +54 -2
  171. package/src/components/loading/unified-skeleton.tsx +18 -2
  172. package/src/components/ui/file-manager/file-manager-table.tsx +8 -3
  173. package/src/components/ui/markdown/engine.tsx +21 -30
  174. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +4 -0
  175. package/src/components/ui/people-cell.tsx +53 -17
  176. package/src/components/ui/progress-bar.tsx +8 -0
  177. package/src/components/ui/tag.tsx +14 -0
  178. package/src/types/design-doc.ts +59 -10
  179. package/src/types/doc-source.ts +2 -2
  180. package/src/utils/design-doc-readiness.ts +22 -23
  181. package/src/utils/doc-tree-nav.ts +27 -0
  182. package/src/utils/list-url.ts +2 -1
  183. package/src/utils/source-grouping.ts +2 -2
  184. package/src/utils/source-icons.ts +3 -0
  185. package/dist/components/docs/skeletons.d.ts +0 -25
  186. package/dist/components/docs/skeletons.d.ts.map +0 -1
  187. package/dist/components/docs/skeletons.js +0 -83
  188. package/src/components/docs/skeletons.tsx +0 -131
@@ -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
@@ -1,14 +1,23 @@
1
1
  import { FileText, Download } from 'lucide-react';
2
2
  import { formatFileSize } from '../../utils';
3
+ import { InteractiveSkeleton, TextLineSkeleton } from '../loading/unified-skeleton';
3
4
  import { Button } from '../ui';
5
+ import { useLoadingScope } from '../ui/loading-scope';
4
6
 
5
7
  export interface FileDownloadCardProps {
6
8
  fileName?: string;
7
9
  mimeType?: string;
8
10
  fileSize?: number;
9
11
  fileUrl?: string;
12
+ /** The file is being read: the card draws its loading form. A loading scope does the same. */
13
+ loading?: boolean;
10
14
  }
11
15
 
16
+ /** The card's boxes, shared by the card and its loading form. */
17
+ const FILE_CARD_OUTER_CLASS = 'flex flex-col items-center justify-center py-16';
18
+ const FILE_CARD_CLASS = 'w-full max-w-md space-y-4 rounded-xl border border-ods-border bg-ods-card p-8 text-center';
19
+ const FILE_ICON_CLASS = 'mx-auto h-16 w-16 text-ods-text-secondary';
20
+
12
21
  /**
13
22
  * Generic downloadable-file card for the `file` document type. Used by
14
23
  * `<DocsHubPage>`'s default `documentTypeRenderers.file`. Embedders can
@@ -18,11 +27,26 @@ export interface FileDownloadCardProps {
18
27
  * renders the filename + type + size so the user knows what they were
19
28
  * about to download).
20
29
  */
21
- export function FileDownloadCard({ fileName, mimeType, fileSize, fileUrl }: FileDownloadCardProps) {
30
+ export function FileDownloadCard({ fileName, mimeType, fileSize, fileUrl, loading = false }: FileDownloadCardProps) {
31
+ // In a loading scope the file is being read: the card draws its own loading form, in its own boxes.
32
+ if (useLoadingScope() || loading) {
33
+ return (
34
+ <div className={FILE_CARD_OUTER_CLASS}>
35
+ <div className={FILE_CARD_CLASS}>
36
+ <FileText className={FILE_ICON_CLASS} />
37
+ <div>
38
+ <TextLineSkeleton type="h3" className="mx-auto w-2/3" />
39
+ <TextLineSkeleton type="h6" className="mx-auto mt-2 w-1/2" />
40
+ </div>
41
+ <InteractiveSkeleton.Button className="mx-auto" />
42
+ </div>
43
+ </div>
44
+ );
45
+ }
22
46
  return (
23
- <div className="flex flex-col items-center justify-center py-16">
24
- <div className="w-full max-w-md space-y-4 rounded-xl border border-ods-border bg-ods-card p-8 text-center">
25
- <FileText className="mx-auto h-16 w-16 text-ods-text-secondary" />
47
+ <div className={FILE_CARD_OUTER_CLASS}>
48
+ <div className={FILE_CARD_CLASS}>
49
+ <FileText className={FILE_ICON_CLASS} />
26
50
  <div>
27
51
  <h3 className="text-ods-text-primary text-h3">{fileName || 'File'}</h3>
28
52
  <div className="mt-2 flex items-center justify-center gap-3 text-ods-text-secondary text-h6">
@@ -4,20 +4,25 @@ import { ExternalLink } from 'lucide-react';
4
4
  import { toGoogleSheetsEmbedUrl, toGoogleSheetsOriginalUrl } from '../../utils/embed-url-converters';
5
5
  import { GoogleSheetsIcon } from '../icons-v2-generated';
6
6
  import { Button } from '../ui';
7
+ import { useLoadingScope } from '../ui/loading-scope';
7
8
  import { EmbedViewerFrame } from './embed-viewer-frame';
8
9
 
9
10
  export interface GoogleSheetsViewerProps {
10
11
  externalUrl: string;
11
12
  fileName?: string;
12
13
  height?: string;
14
+ /** The document is being read: the viewer draws its loading form. A loading scope does the same. */
15
+ loading?: boolean;
13
16
  }
14
17
 
15
- export function GoogleSheetsViewer({ externalUrl, fileName, height }: GoogleSheetsViewerProps) {
18
+ export function GoogleSheetsViewer({ externalUrl, fileName, height, loading = false }: GoogleSheetsViewerProps) {
16
19
  const displayName = fileName || 'Google Sheet';
20
+ // In a loading scope the document is being read: the frame draws its loading form, never "not configured".
21
+ const isLoading = useLoadingScope() || loading;
17
22
 
18
23
  // Historical shape: no URL at all → standalone empty state with NO header
19
24
  // (the frame's empty body keeps the header — that's the figma behavior).
20
- if (!externalUrl) {
25
+ if (!externalUrl && !isLoading) {
21
26
  return (
22
27
  <div className="flex flex-col items-center justify-center py-16 text-center">
23
28
  <GoogleSheetsIcon className="mb-4 h-16 w-16 text-ods-text-secondary" />
@@ -43,8 +48,9 @@ export function GoogleSheetsViewer({ externalUrl, fileName, height }: GoogleShee
43
48
  Open in Google Sheets
44
49
  </Button>
45
50
  }
46
- src={toGoogleSheetsEmbedUrl(externalUrl)}
51
+ src={externalUrl ? toGoogleSheetsEmbedUrl(externalUrl) : null}
47
52
  height={height}
53
+ loading={loading}
48
54
  />
49
55
  );
50
56
  }
@@ -3,6 +3,7 @@
3
3
  import { Download, Eye } from 'lucide-react';
4
4
  import { AdobePdfIcon } from '../icons-v2-generated';
5
5
  import { Button } from '../ui';
6
+ import { useLoadingScope } from '../ui/loading-scope';
6
7
  import { EmbedViewerFrame } from './embed-viewer-frame';
7
8
 
8
9
  export interface PdfViewerProps {
@@ -11,13 +12,17 @@ export interface PdfViewerProps {
11
12
  onPreview?: () => void;
12
13
  onDownload?: () => void;
13
14
  height?: string;
15
+ /** The document is being read: the viewer draws its loading form. A loading scope does the same. */
16
+ loading?: boolean;
14
17
  }
15
18
 
16
- export function PdfViewer({ src, fileName, onPreview, onDownload, height }: PdfViewerProps) {
19
+ export function PdfViewer({ src, fileName, onPreview, onDownload, height, loading = false }: PdfViewerProps) {
17
20
  const displayName = fileName || 'PDF Document';
21
+ // In a loading scope the document is being read: the frame draws its loading form, never "not available".
22
+ const isLoading = useLoadingScope() || loading;
18
23
 
19
24
  // Historical shape: no src at all → standalone empty state with NO header.
20
- if (!src) {
25
+ if (!src && !isLoading) {
21
26
  return (
22
27
  <div className="flex flex-col items-center justify-center py-16 text-center">
23
28
  <AdobePdfIcon className="mb-4 h-16 w-16 text-ods-text-secondary" />
@@ -57,6 +62,8 @@ export function PdfViewer({ src, fileName, onPreview, onDownload, height }: PdfV
57
62
  </div>
58
63
  }
59
64
  src={src}
65
+ loadingActionCount={2}
66
+ loading={loading}
60
67
  height={height}
61
68
  />
62
69
  );
@@ -43,6 +43,12 @@ export interface StatusFilterComponentProps {
43
43
  showAll?: boolean;
44
44
  /** The options are read on the client and have not arrived: the row draws its skeleton, as in a loading scope. */
45
45
  loading?: boolean;
46
+ /**
47
+ * The options' labels are known but their counts are not ("Active" now, "Active (33)" once read): while loading,
48
+ * each option is drawn as its real button with a bar where the count goes, so the row keeps its width and, on a
49
+ * narrow screen, wraps onto the same lines as the loaded row.
50
+ */
51
+ countsPending?: boolean;
46
52
  }
47
53
 
48
54
  /** The row's frame, label and controls, read by the row and by its skeleton, so the skeleton is the row's size. */
@@ -74,6 +80,7 @@ export function StatusFilterComponent({
74
80
  label = 'Status',
75
81
  showAll = true,
76
82
  loading = false,
83
+ countsPending = false,
77
84
  }: StatusFilterComponentProps) {
78
85
  // Filter out 'all' from options since we render it separately
79
86
  const filteredOptions = statusOptions.filter(option => option.value !== 'all');
@@ -84,6 +91,7 @@ export function StatusFilterComponent({
84
91
  label={label}
85
92
  // Options the page states itself are its own words: drawn for real. Read ones arrive empty: boxes.
86
93
  options={filteredOptions.length > 0 ? filteredOptions.map(option => option.label) : undefined}
94
+ countsPending={countsPending}
87
95
  showAll={showAll}
88
96
  showCount={showCount}
89
97
  className={className}
@@ -142,6 +150,7 @@ function StatusFilterComponentSkeleton({
142
150
  label = 'Status',
143
151
  options,
144
152
  pills = STATUS_FILTER_SKELETON_PILLS,
153
+ countsPending = false,
145
154
  showAll = true,
146
155
  showCount = false,
147
156
  className,
@@ -150,6 +159,8 @@ function StatusFilterComponentSkeleton({
150
159
  /** Option labels the page states itself: drawn as real buttons (in place of `pills` boxes). */
151
160
  options?: string[];
152
161
  pills?: number;
162
+ /** Each option's count is still being read: a bar the width of " (00)" follows its label. */
163
+ countsPending?: boolean;
153
164
  showAll?: boolean;
154
165
  showCount?: boolean;
155
166
  className?: string;
@@ -182,6 +193,10 @@ function StatusFilterComponentSkeleton({
182
193
  tabIndex={-1}
183
194
  >
184
195
  {option}
196
+ {/* With the button's gap before it, a 2ch bar is the width of the " (00)" the count adds. */}
197
+ {countsPending && (
198
+ <TextLineSkeleton type="inherit" as="span" className="inline-block w-[2ch] align-top" />
199
+ )}
185
200
  </Button>
186
201
  ))
187
202
  : Array.from({ length: pills }, (_, index) => (
@@ -14,6 +14,28 @@ export interface BackButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLBut
14
14
  * prefetch, right-click); without it, a button that runs `onClick`.
15
15
  */
16
16
  href?: string;
17
+ /**
18
+ * Go back the way the browser's own back button does: when this tab has an earlier page of the same site, the
19
+ * click returns to it (with the address it had: its filters, page and sort), and `href` is only where a visitor
20
+ * who arrived from outside (a shared link, a new tab) goes. Needs `href`.
21
+ */
22
+ historyBack?: boolean;
23
+ }
24
+
25
+ /** The earlier page of this tab, when it is a page of the same site (the Navigation API, else the referrer). */
26
+ function hasSameSiteHistory(): boolean {
27
+ if (typeof window === 'undefined') return false;
28
+ const nav = (window as Window & { navigation?: { canGoBack?: boolean; currentEntry?: { index: number } | null } })
29
+ .navigation;
30
+ // The Navigation API lists only this tab's same-origin entries, so an earlier one is a page of this site.
31
+ if (nav && typeof nav.canGoBack === 'boolean') return nav.canGoBack && (nav.currentEntry?.index ?? 0) > 0;
32
+ try {
33
+ return (
34
+ window.history.length > 1 && !!document.referrer && new URL(document.referrer).origin === window.location.origin
35
+ );
36
+ } catch {
37
+ return false;
38
+ }
17
39
  }
18
40
 
19
41
  /** Its place in the page: the start of the column, above the title. The box is the `link` button's at `default` size. */
@@ -30,7 +52,14 @@ export const BACK_BUTTON_SKELETON_CLASS = 'h-11 w-40 rounded-md md:h-12';
30
52
  * Every detail page renders this one (through `PageLayout`'s `backButton` or
31
53
  * directly), never a hand-built link.
32
54
  */
33
- export function BackButton({ label = 'Back', className, type = 'button', href, ...props }: BackButtonProps) {
55
+ export function BackButton({
56
+ label = 'Back',
57
+ className,
58
+ type = 'button',
59
+ href,
60
+ historyBack = false,
61
+ ...props
62
+ }: BackButtonProps) {
34
63
  const content = (
35
64
  <>
36
65
  <Chevron02LeftIcon className="size-6 shrink-0" />
@@ -54,7 +83,30 @@ export function BackButton({ label = 'Back', className, type = 'button', href, .
54
83
  } = props;
55
84
  return (
56
85
  <Button asChild variant="link" noPaddingX className={cn(BACK_BUTTON_LAYOUT_CLASS, className)}>
57
- <Link {...(shared as unknown as React.AnchorHTMLAttributes<HTMLAnchorElement>)} href={href}>
86
+ <Link
87
+ {...(shared as unknown as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
88
+ href={href}
89
+ onClick={
90
+ historyBack
91
+ ? (event: React.MouseEvent<HTMLAnchorElement>) => {
92
+ (shared.onClick as unknown as React.MouseEventHandler<HTMLAnchorElement> | undefined)?.(event);
93
+ // A modified click (new tab, new window) keeps the link's own behaviour.
94
+ if (
95
+ event.defaultPrevented ||
96
+ event.button !== 0 ||
97
+ event.metaKey ||
98
+ event.ctrlKey ||
99
+ event.shiftKey ||
100
+ event.altKey
101
+ )
102
+ return;
103
+ if (!hasSameSiteHistory()) return;
104
+ event.preventDefault();
105
+ window.history.back();
106
+ }
107
+ : (shared.onClick as unknown as React.MouseEventHandler<HTMLAnchorElement> | undefined)
108
+ }
109
+ >
58
110
  {content}
59
111
  </Link>
60
112
  </Button>
@@ -179,11 +179,16 @@ export function TextLineSkeleton({ type, lines = 1, as = 'div', width, className
179
179
  );
180
180
  }
181
181
 
182
- /** The lines a text run takes per breakpoint: on a phone, from `content-md`, and from `content-lg`. */
182
+ /**
183
+ * THE lines a text run takes per width: on a phone, from `content-md`, from `content-lg`, and from `content-xl` where
184
+ * a wide screen wraps it differently (`lg` when not given). Every loading text whose length changes with the width
185
+ * states its lines in this one shape.
186
+ */
183
187
  export interface TextLinesByBreakpoint {
184
188
  phone: number;
185
189
  md: number;
186
190
  lg: number;
191
+ xl?: number;
187
192
  }
188
193
 
189
194
  /**
@@ -215,8 +220,19 @@ export function TextLinesByBreakpointSkeleton({
215
220
  type={type}
216
221
  as={as}
217
222
  lines={lines.lg}
218
- className={cn('hidden w-full content-lg:flex', className)}
223
+ className={cn(
224
+ lines.xl === undefined ? 'hidden w-full content-lg:flex' : 'hidden w-full content-lg:flex content-xl:hidden',
225
+ className,
226
+ )}
219
227
  />
228
+ {lines.xl === undefined ? null : (
229
+ <TextLineSkeleton
230
+ type={type}
231
+ as={as}
232
+ lines={lines.xl}
233
+ className={cn('hidden w-full content-xl:flex', className)}
234
+ />
235
+ )}
220
236
  </>
221
237
  );
222
238
  }
@@ -57,10 +57,15 @@ export function FileManagerTable({
57
57
  // The height its parent gives it, both forms: the rows scroll inside, under the header. Not
58
58
  // the window's: whatever is below the parent (a docked composer, a page's padding) would
59
59
  // hide the last rows under it. A parent of no definite height lets the table grow with its rows.
60
- <div className={cn('flex h-full flex-col rounded-lg border border-ods-border bg-ods-bg', className)}>
60
+ // `overflow-hidden` rounds the header and the rows at the border's inner curve: a child rounded
61
+ // to the frame's own radius sits 1px inside it and leaves a sliver of the frame's fill at each
62
+ // corner. Nothing floats out of the table (the row menu is portalled).
63
+ <div
64
+ className={cn('flex h-full flex-col overflow-hidden rounded-lg border border-ods-border bg-ods-bg', className)}
65
+ >
61
66
  {/* ONE header in both forms: the column labels are copy, and while the folder is read the select-all box is the
62
67
  real checkbox, inert. */}
63
- <div className="flex h-12 items-center rounded-t-lg border-b border-ods-border bg-ods-bg-surface px-4">
68
+ <div className="flex h-12 items-center border-b border-ods-border bg-ods-bg-surface px-4">
64
69
  {showCheckboxes && (
65
70
  <div
66
71
  className={FILE_MANAGER_CHECKBOX_CELL_CLASS}
@@ -84,7 +89,7 @@ export function FileManagerTable({
84
89
  <div className={FILE_MANAGER_ACTIONS_CELL_BOX_CLASS}>{/* Space for action buttons */}</div>
85
90
  </div>
86
91
 
87
- <div className="flex-1 divide-y divide-ods-border overflow-auto rounded-b-lg">
92
+ <div className="flex-1 divide-y divide-ods-border overflow-auto">
88
93
  {loading ? (
89
94
  // A folder listing has no page size: a typical folder's rows, each the row's own loading form.
90
95
  <LoadingItems count={FILE_MANAGER_SKELETON_ROWS}>