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

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 (125) 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-N2A24FDF.cjs → chunk-2V6FCVUL.cjs} +955 -932
  4. package/dist/{chunk-CNMXP77I.cjs → chunk-36DUCHI7.cjs} +4 -4
  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-QK2A334V.cjs → chunk-5HOWR4TG.cjs} +12 -12
  8. package/dist/{chunk-2FUTZKLB.cjs → chunk-74Z34I2C.cjs} +6 -6
  9. package/dist/{chunk-PV3NKXWS.cjs → chunk-7DW3LZ52.cjs} +57 -57
  10. package/dist/{chunk-ZTO7KNYV.cjs → chunk-7LE4HWHA.cjs} +35 -35
  11. package/dist/{chunk-URP6GYTD.cjs → chunk-7YJQTQPV.cjs} +56 -56
  12. package/dist/{chunk-4R4HAFXI.cjs → chunk-ALRYVZ3E.cjs} +6 -6
  13. package/dist/{chunk-57JYB3E2.cjs → chunk-C3WZXOZJ.cjs} +3 -3
  14. package/dist/{chunk-SSSI2RBV.cjs → chunk-FNQWL4W4.cjs} +14 -14
  15. package/dist/{chunk-J2ZSGOUC.cjs → chunk-GASFYPGH.cjs} +27 -27
  16. package/dist/{chunk-3V5JWG27.cjs → chunk-GG466XFQ.cjs} +2 -2
  17. package/dist/{chunk-XCVH5BCZ.cjs → chunk-GRCOEICK.cjs} +6 -6
  18. package/dist/{chunk-5GBLXY5F.cjs → chunk-HTYJUMFB.cjs} +7 -7
  19. package/dist/{chunk-HBDLJUI3.cjs → chunk-I3YRYS23.cjs} +5 -5
  20. package/dist/{chunk-HPXET3LH.cjs → chunk-IIUZE73M.cjs} +27 -27
  21. package/dist/{chunk-WCK5T3UD.cjs → chunk-IKOL6NJY.cjs} +101 -69
  22. package/dist/{chunk-DGQDGCQH.cjs → chunk-INA2FX2N.cjs} +2 -2
  23. package/dist/{chunk-PQDCSZ3W.cjs → chunk-IUZVOQXP.cjs} +85 -85
  24. package/dist/{chunk-BIOSHDXQ.cjs → chunk-MDLT6D46.cjs} +61 -61
  25. package/dist/{chunk-DEEQSVLX.cjs → chunk-MWYEROAP.cjs} +90 -90
  26. package/dist/{chunk-D5DMWXF2.cjs → chunk-NYT6IBIX.cjs} +4 -4
  27. package/dist/{chunk-TBOWFTEN.cjs → chunk-O4BXHCMK.cjs} +22 -22
  28. package/dist/{chunk-YC52LSPP.cjs → chunk-OCJXOSIH.cjs} +40 -40
  29. package/dist/{chunk-3TOWCFIK.cjs → chunk-PMUCEUFQ.cjs} +13 -1
  30. package/dist/{chunk-BZZE4KIV.cjs → chunk-QUSCSYHJ.cjs} +135 -135
  31. package/dist/{chunk-744JXTDP.cjs → chunk-SA2NNWIJ.cjs} +23 -23
  32. package/dist/{chunk-BHOVF4KP.cjs → chunk-SOSIXRQ3.cjs} +2 -2
  33. package/dist/{chunk-PZNWIJRZ.cjs → chunk-TIVPOVIM.cjs} +2 -2
  34. package/dist/{chunk-FLW4WULE.cjs → chunk-U7UBYJEZ.cjs} +17 -61
  35. package/dist/{chunk-Y2OEC7UW.cjs → chunk-VW4446JR.cjs} +40 -40
  36. package/dist/{chunk-NFNSPLGC.cjs → chunk-W7OFAROY.cjs} +14 -9
  37. package/dist/{chunk-RAJ5FYFL.cjs → chunk-Y2ZWWIUA.cjs} +14 -14
  38. package/dist/{chunk-MPVOPSU6.cjs → chunk-Y4BG7SPX.cjs} +96 -96
  39. package/dist/{chunk-SIFRIUPW.cjs → chunk-YAW7CHLV.cjs} +340 -354
  40. package/dist/{chunk-ABL5OBSF.cjs → chunk-YXKAHQYS.cjs} +294 -249
  41. package/dist/{chunk-SWF6TNZ3.cjs → chunk-ZGEWT3SF.cjs} +2 -2
  42. package/dist/{chunk-5SZ4ILU3.cjs → chunk-ZZDFMUEU.cjs} +3 -3
  43. package/dist/{cloud-tenants-XEUUMABT.cjs → cloud-tenants-YAZHLPOQ.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 +4 -7
  53. package/dist/components/chat/index.cjs +15 -15
  54. package/dist/components/contact/index.cjs +22 -22
  55. package/dist/components/docs/index.cjs +25 -25
  56. package/dist/components/embeds/index.cjs +22 -22
  57. package/dist/components/faq/index.cjs +23 -23
  58. package/dist/components/features/index.cjs +21 -21
  59. package/dist/components/features/status-filter-component.d.ts +7 -1
  60. package/dist/components/features/status-filter-component.d.ts.map +1 -1
  61. package/dist/components/features/status-filter-component.js +9 -3
  62. package/dist/components/help-center-pages/index.cjs +140 -140
  63. package/dist/components/index.cjs +137 -135
  64. package/dist/components/layout/back-button.d.ts +7 -1
  65. package/dist/components/layout/back-button.d.ts.map +1 -1
  66. package/dist/components/layout/back-button.js +34 -2
  67. package/dist/components/loading/unified-skeleton.d.ts +6 -1
  68. package/dist/components/loading/unified-skeleton.d.ts.map +1 -1
  69. package/dist/components/loading/unified-skeleton.js +13 -1
  70. package/dist/components/meeting-scheduler/index.cjs +119 -119
  71. package/dist/components/navigation/index.cjs +24 -24
  72. package/dist/components/onboarding-guides/index.cjs +24 -24
  73. package/dist/components/product-demo/index.cjs +33 -33
  74. package/dist/components/related-content/index.cjs +11 -11
  75. package/dist/components/tickets/index.cjs +28 -28
  76. package/dist/components/ui/file-manager/file-manager-table.d.ts.map +1 -1
  77. package/dist/components/ui/file-manager/file-manager-table.js +63 -85
  78. package/dist/components/ui/file-manager/index.cjs +87 -109
  79. package/dist/components/ui/index.cjs +23 -21
  80. package/dist/components/ui/markdown/engine.d.ts +5 -4
  81. package/dist/components/ui/markdown/engine.d.ts.map +1 -1
  82. package/dist/components/ui/markdown/engine.js +15 -15
  83. package/dist/components/ui/people-cell.d.ts +9 -2
  84. package/dist/components/ui/people-cell.d.ts.map +1 -1
  85. package/dist/components/ui/people-cell.js +39 -23
  86. package/dist/components/ui/progress-bar.d.ts +6 -0
  87. package/dist/components/ui/progress-bar.d.ts.map +1 -1
  88. package/dist/components/ui/progress-bar.js +2 -0
  89. package/dist/components/ui/tag.d.ts +8 -0
  90. package/dist/components/ui/tag.d.ts.map +1 -1
  91. package/dist/components/ui/tag.js +5 -0
  92. package/dist/{devices-7YDIHVVP.cjs → devices-IZROCLFK.cjs} +22 -22
  93. package/dist/embed-shims/index.cjs +3 -3
  94. package/dist/embed-shims/next-image.cjs +3 -3
  95. package/dist/{embeddable-chat-Z3S37SSM.cjs → embeddable-chat-ETW6S22E.cjs} +94 -94
  96. package/dist/hooks/index.cjs +6 -6
  97. package/dist/index.cjs +30 -32
  98. package/dist/{knowledge-SQVPQSKM.cjs → knowledge-4IRWLTNW.cjs} +22 -22
  99. package/dist/{logs-M5DFGUBC.cjs → logs-EFDGY5IO.cjs} +28 -28
  100. package/dist/{policies-T3VQEJUD.cjs → policies-3KEXZ5BP.cjs} +25 -25
  101. package/dist/{remote-session-EE6477O3.cjs → remote-session-NT4NG7EF.cjs} +10 -10
  102. package/dist/{scripts-JFRFJKPH.cjs → scripts-PCGTZLZL.cjs} +28 -28
  103. package/dist/{software-update-E7W3AAIY.cjs → software-update-YCDNVFJE.cjs} +25 -25
  104. package/dist/{tickets-board-44HO3LR4.cjs → tickets-board-7FLGQEHF.cjs} +10 -10
  105. package/dist/types/design-doc.d.ts +45 -8
  106. package/dist/types/design-doc.d.ts.map +1 -1
  107. package/dist/utils/design-doc-readiness.d.ts +17 -15
  108. package/dist/utils/design-doc-readiness.d.ts.map +1 -1
  109. package/dist/utils/design-doc-readiness.js +5 -11
  110. package/dist/utils/index.cjs +5 -11
  111. package/package.json +1 -1
  112. package/src/components/chat/chat-composer.tsx +10 -3
  113. package/src/components/chat/chat-container.tsx +6 -4
  114. package/src/components/chat/entity-cards/design-doc-card.tsx +12 -17
  115. package/src/components/chat/entity-cards/dispatch.tsx +7 -10
  116. package/src/components/features/status-filter-component.tsx +15 -0
  117. package/src/components/layout/back-button.tsx +54 -2
  118. package/src/components/loading/unified-skeleton.tsx +18 -2
  119. package/src/components/ui/file-manager/file-manager-table.tsx +10 -45
  120. package/src/components/ui/markdown/engine.tsx +21 -30
  121. package/src/components/ui/people-cell.tsx +53 -17
  122. package/src/components/ui/progress-bar.tsx +8 -0
  123. package/src/components/ui/tag.tsx +14 -0
  124. package/src/types/design-doc.ts +59 -10
  125. package/src/utils/design-doc-readiness.ts +22 -23
@@ -48,11 +48,7 @@ import {
48
48
  } from '../../../types/trust-center';
49
49
  import { isAppOriginUrl } from '../../../utils/app-origins';
50
50
  import { formatDateShort } from '../../../utils/date-formatters';
51
- import {
52
- DESIGN_DOC_READINESS_DISPLAY,
53
- designDocReadiness,
54
- formatCompletionLabel,
55
- } from '../../../utils/design-doc-readiness';
51
+ import { designDocStatusDisplay, formatCompletionLabel } from '../../../utils/design-doc-readiness';
56
52
  import { faqItemAnchor } from '../../../utils/faq-anchor';
57
53
  import { formatDateUTC as formatDate } from '../../../utils/format';
58
54
  import { programMetaFormatters, programMetaLine } from '../../../utils/program-instant';
@@ -88,6 +84,7 @@ import type { LoadedProductReleaseCardProps } from '../../shared/product-release
88
84
  import { ProductReleaseCard } from '../../shared/product-release/product-release-card';
89
85
  import type { ActionsMenuGroup } from '../../ui/actions-menu';
90
86
  import { ExternalMediaNotice } from '../../ui/markdown/external-media-notice';
87
+ import { tagVariantForBadgeColor } from '../../ui/tag';
91
88
  import { VendorIcon } from '../../vendor-icon';
92
89
  import { useChatPanel } from '../chat-panel-context';
93
90
  import type { ChatRef } from '../chat-ref.types';
@@ -1046,8 +1043,8 @@ function CustomerInterviewChatCard({
1046
1043
  }
1047
1044
 
1048
1045
  /**
1049
- * Design doc → the doc glyph, its readiness as the pill (the hub's own wording: "Ready to build" / "Not ready",
1050
- * `design-doc-readiness`) and "DRI · updated · n/m reviews signed off" under the title. The hub's card route attaches the doc's list row as
1046
+ * Design doc → the doc glyph, its status as the pill (the status a person set, else its readiness, both vocabulary rows the hub resolves onto the
1047
+ * doc: `designDocStatusDisplay`) and "DRI · updated · n/m signed off" under the title. The hub's card route attaches the doc's list row as
1051
1048
  * `item.doc`; a row without it (an older hub) renders the ref's own title and preview.
1052
1049
  */
1053
1050
  function DesignDocChatCard({
@@ -1063,7 +1060,7 @@ function DesignDocChatCard({
1063
1060
  }) {
1064
1061
  const displayRef = fetchedItemDisplayRef(item, chatRef);
1065
1062
  const doc = item?.doc;
1066
- const readiness = doc ? DESIGN_DOC_READINESS_DISPLAY[designDocReadiness(doc.completion)] : null;
1063
+ const status = doc ? designDocStatusDisplay(doc) : null;
1067
1064
  return (
1068
1065
  <MingoInfoCard
1069
1066
  title={doc?.title ?? displayRef.title}
@@ -1074,8 +1071,8 @@ function DesignDocChatCard({
1074
1071
  }
1075
1072
  icon={<FileContentIcon size={24} />}
1076
1073
  status={
1077
- readiness
1078
- ? { label: readiness.label, variant: readiness.scheme === 'success' ? 'success' : 'grey' }
1074
+ status
1075
+ ? { label: status.label, variant: tagVariantForBadgeColor(status.color_scheme) }
1079
1076
  : { label: 'Design doc', variant: 'grey' }
1080
1077
  }
1081
1078
  anchorProps={buildAnchorProps(displayRef.url, isNewTab, displayRef.targetPlatform)}
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { useMemo } from 'react';
4
4
  import { cn } from '../../../utils/cn';
5
5
  import { Checkbox } from '../checkbox';
6
6
  import { LoadingItems, useLoadingScope } from '../loading-scope';
@@ -45,45 +45,6 @@ export function FileManagerTable({
45
45
  return selectedFiles.length > 0 && selectedFiles.length < files.length;
46
46
  }, [files.length, selectedFiles.length]);
47
47
 
48
- const containerRef = useRef<HTMLDivElement>(null);
49
- const [measuredHeight, setTableHeight] = useState<number | null>(null);
50
- // There is nothing to size while the skeleton or the empty state is showing,
51
- // and that is knowable during render — masking it here means the effect no
52
- // longer has to write `null` back into state (a second render pass) every
53
- // time a fetch starts or a filter empties the list.
54
- const tableHeight = loading || files.length === 0 ? null : measuredHeight;
55
-
56
- useLayoutEffect(() => {
57
- if (loading || files.length === 0) return undefined;
58
-
59
- const handleResize = () => {
60
- const node = containerRef.current;
61
- if (!node) return;
62
- const rect = node.getBoundingClientRect();
63
- const availableHeight = window.innerHeight - rect.top;
64
- setTableHeight(availableHeight > 0 ? availableHeight : null);
65
- };
66
-
67
- handleResize();
68
-
69
- window.addEventListener('resize', handleResize);
70
- window.addEventListener('scroll', handleResize, true);
71
-
72
- const resizeObserver = new ResizeObserver(handleResize);
73
- const parent = containerRef.current?.parentElement;
74
- if (parent) {
75
- resizeObserver.observe(parent);
76
- } else if (containerRef.current) {
77
- resizeObserver.observe(containerRef.current);
78
- }
79
-
80
- return () => {
81
- window.removeEventListener('resize', handleResize);
82
- window.removeEventListener('scroll', handleResize, true);
83
- resizeObserver.disconnect();
84
- };
85
- }, [loading, files.length]);
86
-
87
48
  const handleSelectAll = (checked: boolean) => {
88
49
  onSelectAll?.(checked);
89
50
  };
@@ -93,14 +54,18 @@ export function FileManagerTable({
93
54
  }
94
55
 
95
56
  return (
57
+ // The height its parent gives it, both forms: the rows scroll inside, under the header. Not
58
+ // the window's: whatever is below the parent (a docked composer, a page's padding) would
59
+ // hide the last rows under it. A parent of no definite height lets the table grow with its rows.
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).
96
63
  <div
97
- ref={containerRef}
98
- className={cn('flex flex-col rounded-lg border border-ods-border bg-ods-bg', className)}
99
- style={tableHeight ? { height: `${tableHeight}px` } : undefined}
64
+ className={cn('flex h-full flex-col overflow-hidden rounded-lg border border-ods-border bg-ods-bg', className)}
100
65
  >
101
66
  {/* ONE header in both forms: the column labels are copy, and while the folder is read the select-all box is the
102
67
  real checkbox, inert. */}
103
- <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">
104
69
  {showCheckboxes && (
105
70
  <div
106
71
  className={FILE_MANAGER_CHECKBOX_CELL_CLASS}
@@ -124,7 +89,7 @@ export function FileManagerTable({
124
89
  <div className={FILE_MANAGER_ACTIONS_CELL_BOX_CLASS}>{/* Space for action buttons */}</div>
125
90
  </div>
126
91
 
127
- <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">
128
93
  {loading ? (
129
94
  // A folder listing has no page size: a typical folder's rows, each the row's own loading form.
130
95
  <LoadingItems count={FILE_MANAGER_SKELETON_ROWS}>
@@ -35,7 +35,11 @@ import type { PluggableList } from 'unified';
35
35
  import type { ResolveLinkResult } from '../../../types/doc-source';
36
36
  import { readAppOrigins, SERVER_APP_ORIGINS, subscribeAppOrigins } from '../../../utils/app-origins';
37
37
  import { cn } from '../../../utils/cn';
38
- import { TextLineSkeleton } from '../../loading/unified-skeleton';
38
+ import {
39
+ TextLineSkeleton,
40
+ TextLinesByBreakpointSkeleton,
41
+ type TextLinesByBreakpoint,
42
+ } from '../../loading/unified-skeleton';
39
43
  import { LoadingItems, useLoadingScope } from '../loading-scope';
40
44
  import { buildBaseComponents, MARKDOWN_H1_CLASS, MARKDOWN_H2_CLASS, MARKDOWN_PARAGRAPH_CLASS } from './base-components';
41
45
  import { MarkdownMediaOriginsContext } from './external-media-notice';
@@ -193,8 +197,8 @@ export interface MarkdownEngineProps {
193
197
  * sections, each a heading and its paragraphs. Unset, one paragraph of `skeletonLines` lines.
194
198
  */
195
199
  skeletonSections?: number;
196
- /** Lines of the one paragraph a loading body draws when it names no sections. Default 3. */
197
- skeletonLines?: number;
200
+ /** Lines of the one paragraph a loading body draws when it names no sections, per width. Default 3 at every width. */
201
+ skeletonLines?: TextLinesByBreakpoint;
198
202
  /** A loading body opens with its own `# ` title (a legal document's): its title row is drawn first. */
199
203
  skeletonTitle?: boolean;
200
204
  /** The content is still being read: the body draws its skeleton (the `skeleton*` shape). A loading scope does the
@@ -203,16 +207,19 @@ export interface MarkdownEngineProps {
203
207
  }
204
208
 
205
209
  /** Lines a loading body draws when its caller names no shape. */
206
- export const MARKDOWN_SKELETON_LINES = 3;
210
+ export const MARKDOWN_SKELETON_LINES: TextLinesByBreakpoint = { phone: 3, md: 3, lg: 3 };
207
211
  /** Paragraphs under each heading of a loading body, and their lines. */
208
- const SKELETON_SECTION_PARAGRAPHS = [3, 2] as const;
212
+ const SKELETON_SECTION_PARAGRAPHS: readonly TextLinesByBreakpoint[] = [
213
+ { phone: 3, md: 3, lg: 3 },
214
+ { phone: 2, md: 2, lg: 2 },
215
+ ];
209
216
  /**
210
- * Lines a typical body's opening line set takes, on a phone (below `content-md`) and from `content-md`: a document's
217
+ * Lines a typical body's opening line set takes, per width: a document's
211
218
  * `# ` title wraps on a phone, and the opening paragraph every post and legal document starts with runs a few lines.
212
219
  * Typical lengths, never one record's: a longer record growing past them is a settle below its own lines.
213
220
  */
214
- const SKELETON_TITLE_LINES = { phone: 2, wide: 1 } as const;
215
- const SKELETON_LEAD_LINES = { phone: 5, wide: 3 } as const;
221
+ const SKELETON_TITLE_LINES: TextLinesByBreakpoint = { phone: 2, md: 1, lg: 1 };
222
+ const SKELETON_LEAD_LINES: TextLinesByBreakpoint = { phone: 5, md: 3, lg: 3 };
216
223
  /** The renderer's own frame: read by the engine and by its skeleton. */
217
224
  /** The renderer's root class, on the rendered document and on every skeleton of one: it names a markdown body. */
218
225
  export const MARKDOWN_RENDERER_CLASS = 'simple-markdown-renderer';
@@ -467,31 +474,15 @@ export const MarkdownEngine = memo(function MarkdownEngineGate(props: MarkdownEn
467
474
  return <MarkdownEngineImpl {...props} />;
468
475
  });
469
476
 
470
- /** A paragraph of `lines` lines at the body's own paragraph type, as one bar of its line height. */
471
- function ParagraphBar({ lines, className }: { lines: number; className: string }) {
477
+ /** A paragraph of its `lines` per width at the body's own paragraph type. */
478
+ function ParagraphBar({ lines, className }: { lines: TextLinesByBreakpoint; className: string }) {
472
479
  return (
473
480
  <div className={className}>
474
- <TextLineSkeleton type="inherit" lines={lines} className="w-full" />
481
+ <TextLinesByBreakpointSkeleton lines={lines} />
475
482
  </div>
476
483
  );
477
484
  }
478
485
 
479
- /** Lines of a text run per breakpoint: on a phone, and from `content-md`. */
480
- interface SkeletonLinesByBreakpoint {
481
- phone: number;
482
- wide: number;
483
- }
484
-
485
- /** A text run of the lines it takes on a phone and from `content-md` (the content area's own steps). */
486
- function LinesByBreakpoint({ lines, as = 'div' }: { lines: SkeletonLinesByBreakpoint; as?: 'div' | 'span' }) {
487
- return (
488
- <>
489
- <TextLineSkeleton type="inherit" as={as} lines={lines.phone} className="w-full content-md:hidden" />
490
- <TextLineSkeleton type="inherit" as={as} lines={lines.wide} className="hidden w-full content-md:flex" />
491
- </>
492
- );
493
- }
494
-
495
486
  /**
496
487
  * A markdown body while it is read: the renderer's own frame and type. With `sections`, the body's opening paragraph
497
488
  * (a typical length per breakpoint), then each section a REAL `<h2>` (the renderer's heading classes) holding a bar,
@@ -506,7 +497,7 @@ export function MarkdownEngineSkeleton({
506
497
  className = '',
507
498
  }: {
508
499
  sections?: number;
509
- lines?: number;
500
+ lines?: TextLinesByBreakpoint;
510
501
  textSize?: TextSizeConfig;
511
502
  /** The body opens with its own `# ` title (a legal document's, demoted to an `h2`): its H1 row is drawn first. */
512
503
  title?: boolean;
@@ -522,12 +513,12 @@ export function MarkdownEngineSkeleton({
522
513
  <div className={ARTICLE_CLASS}>
523
514
  {title && (
524
515
  <h2 className={cn(MARKDOWN_H1_CLASS, textSizes.h1)}>
525
- <LinesByBreakpoint lines={SKELETON_TITLE_LINES} as="span" />
516
+ <TextLinesByBreakpointSkeleton lines={SKELETON_TITLE_LINES} as="span" />
526
517
  </h2>
527
518
  )}
528
519
  {sections ? (
529
520
  <div className={paragraphClass}>
530
- <LinesByBreakpoint lines={SKELETON_LEAD_LINES} />
521
+ <TextLinesByBreakpointSkeleton lines={SKELETON_LEAD_LINES} />
531
522
  </div>
532
523
  ) : null}
533
524
  {sections ? (
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { cn } from '../../utils/cn';
3
4
  import { personFirstName } from '../../utils/format';
4
5
  import { TextLineSkeleton } from '../loading/unified-skeleton';
5
6
  import { AvatarStack } from './avatar-stack';
@@ -48,6 +49,11 @@ export interface PersonCellProps {
48
49
  size?: 'sm' | 'lg';
49
50
  /** The person is still being read: the cell draws its skeleton at its size. A loading scope does the same. */
50
51
  loading?: boolean;
52
+ /**
53
+ * `2` (default): the name and its second line. `1`: the name alone, one line, with the second line in a tooltip:
54
+ * for a table whose rows are one line in every column but the first.
55
+ */
56
+ lines?: 1 | 2;
51
57
  }
52
58
 
53
59
  const PERSON_CELL_SIZE = {
@@ -67,29 +73,35 @@ export function PersonCell({
67
73
  actionTitle,
68
74
  size = 'sm',
69
75
  loading,
76
+ lines = 2,
70
77
  }: PersonCellProps) {
71
78
  // In a page's loading state (or while the host reads the person) the cell is its skeleton.
72
- if (useLoadingScope() || loading) return <PersonCellSkeleton size={size} />;
79
+ if (useLoadingScope() || loading) return <PersonCellSkeleton size={size} lines={lines} />;
73
80
  const displayName = name || fallbackName;
74
81
  const scale = PERSON_CELL_SIZE[size];
82
+ const nameLine = (
83
+ <TruncateText
84
+ variant={scale.primary}
85
+ className={onClick ? 'text-ods-accent group-hover/person-cell:underline' : undefined}
86
+ >
87
+ {displayName}
88
+ </TruncateText>
89
+ );
75
90
  const row = (
76
91
  <div className={`group/person-cell ${PERSON_ROW_CLASS} ${scale.gap}`}>
77
92
  <SquareAvatar size={scale.avatar} src={avatarUrl ?? undefined} alt={displayName} fallback={displayName} />
78
93
  <div className="min-w-0 flex-1">
79
- <TruncateText
80
- variant={scale.primary}
81
- className={onClick ? 'text-ods-accent group-hover/person-cell:underline' : undefined}
82
- >
83
- {displayName}
84
- </TruncateText>
85
- <TruncateText variant="h6" tone="secondary">
86
- {secondary === undefined ? ' ' : secondary || '·'}
87
- </TruncateText>
94
+ {nameLine}
95
+ {lines === 2 ? (
96
+ <TruncateText variant="h6" tone="secondary">
97
+ {secondary === undefined ? ' ' : secondary || '·'}
98
+ </TruncateText>
99
+ ) : null}
88
100
  </div>
89
101
  </div>
90
102
  );
91
- if (!onClick) return row;
92
- return (
103
+ // A clickable cell is a button in both layouts, so `onClick` and `actionTitle` are never dropped by `lines`.
104
+ const cell = onClick ? (
93
105
  <Button
94
106
  variant="transparent"
95
107
  onClick={onClick}
@@ -100,18 +112,34 @@ export function PersonCell({
100
112
  >
101
113
  {row}
102
114
  </Button>
115
+ ) : (
116
+ row
117
+ );
118
+ // One line: the second line is not drawn, so it is the cell's tooltip.
119
+ return lines === 1 && secondary ? (
120
+ <FloatingTooltip content={`${displayName} · ${secondary}`} triggerClassName="min-w-0 max-w-full">
121
+ {cell}
122
+ </FloatingTooltip>
123
+ ) : (
124
+ cell
103
125
  );
104
126
  }
105
127
 
106
128
  /** The cell while it loads: the SAME avatar box, gap and two lines (the name's type step, then the caption's). */
107
- function PersonCellSkeleton({ size = 'sm' }: { size?: PersonCellProps['size'] }) {
129
+ function PersonCellSkeleton({
130
+ size = 'sm',
131
+ lines = 2,
132
+ }: {
133
+ size?: PersonCellProps['size'];
134
+ lines?: PersonCellProps['lines'];
135
+ }) {
108
136
  const scale = PERSON_CELL_SIZE[size];
109
137
  return (
110
138
  <div className={`${PERSON_ROW_CLASS} ${scale.gap}`} aria-hidden>
111
139
  <SquareAvatar loading size={scale.avatar} />
112
140
  <div className="flex min-w-0 flex-1 flex-col">
113
141
  <TextLineSkeleton type={scale.primary} className="w-3/4" />
114
- <TextLineSkeleton type="h6" className="w-1/2" />
142
+ {lines === 2 ? <TextLineSkeleton type="h6" className="w-1/2" /> : null}
115
143
  </div>
116
144
  </div>
117
145
  );
@@ -130,14 +158,16 @@ export interface PeopleCellProps {
130
158
  people: PeopleCellPerson[];
131
159
  /** Shown when the list is empty — the same slot `PersonCell` fills. */
132
160
  fallbackName?: string;
161
+ /** `1`: one line for one person or several (see `PersonCellProps.lines`). */
162
+ lines?: 1 | 2;
133
163
  }
134
164
 
135
165
  /** Positions the multi-person stack reserves: two faces, or one face and "+N". */
136
166
  const PEOPLE_CELL_STACK_SLOTS = 2;
137
167
 
138
- export function PeopleCell({ people, fallbackName = 'Unassigned' }: PeopleCellProps) {
168
+ export function PeopleCell({ people, fallbackName = 'Unassigned', lines = 2 }: PeopleCellProps) {
139
169
  // In a page's loading state the people are read: the cell is a person cell's skeleton.
140
- if (useLoadingScope()) return <PersonCellSkeleton />;
170
+ if (useLoadingScope()) return <PersonCellSkeleton lines={lines} />;
141
171
  if (people.length <= 1) {
142
172
  const [person] = people;
143
173
  return (
@@ -146,6 +176,7 @@ export function PeopleCell({ people, fallbackName = 'Unassigned' }: PeopleCellPr
146
176
  avatarUrl={person?.avatarUrl}
147
177
  secondary={person ? person.secondary : undefined}
148
178
  fallbackName={fallbackName}
179
+ lines={lines}
149
180
  />
150
181
  );
151
182
  }
@@ -163,7 +194,12 @@ export function PeopleCell({ people, fallbackName = 'Unassigned' }: PeopleCellPr
163
194
  people={people.map(p => ({ key: p.key, name: p.name || 'Unknown', avatarUrl: p.avatarUrl }))}
164
195
  />
165
196
  </div>
166
- <p className="line-clamp-2 min-w-0 flex-1 break-words text-ods-text-primary text-h6">
197
+ <p
198
+ className={cn(
199
+ 'min-w-0 flex-1 break-words text-ods-text-primary text-h6',
200
+ lines === 1 ? 'line-clamp-1' : 'line-clamp-2',
201
+ )}
202
+ >
167
203
  <span aria-hidden>{people.map(p => personFirstName(p.name, 'Unknown')).join(' & ')}</span>
168
204
  <span className="sr-only">{fullNames}</span>
169
205
  </p>
@@ -27,6 +27,12 @@ type ProgressBarProps = {
27
27
  height?: number; // desktop height (px)
28
28
  mobileHeight?: number; // mobile height (px)
29
29
  inverted?: boolean; // if true, high values are good (green), low values are bad (red)
30
+ /**
31
+ * `threshold` (default): the fill takes the success, warning or error colour by the thresholds. `neutral`: the fill
32
+ * is the secondary text colour at every value, for a count that is progress, not a health reading (a sign-off
33
+ * count), so a partial bar never reads as an error.
34
+ */
35
+ tone?: 'threshold' | 'neutral';
30
36
  /** The value is still being read: the bar draws its `ProgressBarSkeleton` at its own height. A loading scope does
31
37
  * the same. */
32
38
  loading?: boolean;
@@ -42,6 +48,7 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({
42
48
  height = PROGRESS_BAR_HEIGHT.desktop,
43
49
  mobileHeight = PROGRESS_BAR_HEIGHT.mobile,
44
50
  inverted = false,
51
+ tone = 'threshold',
45
52
  loading: loadingProp,
46
53
  }) => {
47
54
  // ONE switch: in a page's loading state (or while the host reads the value) the bar is its skeleton.
@@ -76,6 +83,7 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({
76
83
 
77
84
  // Pick color based on thresholds using ODS design tokens
78
85
  const getColor = () => {
86
+ if (tone === 'neutral') return 'var(--color-text-secondary)';
79
87
  if (inverted) {
80
88
  // Inverted: high values = good (green), low values = bad (red)
81
89
  // For battery health: 100% = green, <30% = red
@@ -201,6 +201,20 @@ function isEmptyTagLabel(label: React.ReactNode): boolean {
201
201
  return label == null || label === false || (typeof label === 'string' && label.trim() === '');
202
202
  }
203
203
 
204
+ /** The tones a badge colour keeps when it is drawn as a tag. */
205
+ const BADGE_COLOR_TAG_TONES = ['success', 'warning', 'error'] as const;
206
+
207
+ /**
208
+ * THE tag variant for a badge colour that is data (a `StatusBadge` colour scheme a vocabulary row stores): the three
209
+ * semantic tones carry over, every other colour reads grey. Every card that draws such a colour as a tag reads it
210
+ * here, so a status is the same tone in a badge and in a chat card.
211
+ */
212
+ export function tagVariantForBadgeColor(
213
+ color: string | null | undefined,
214
+ ): (typeof BADGE_COLOR_TAG_TONES)[number] | 'grey' {
215
+ return BADGE_COLOR_TAG_TONES.find(tone => tone === color) ?? 'grey';
216
+ }
217
+
204
218
  export { Tag, tagVariants };
205
219
 
206
220
  /**