@flamingo-stack/openframe-frontend-core 0.0.490 → 0.0.491-snapshot.20260729083908

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 (168) hide show
  1. package/dist/{chunk-FY5XAW3H.cjs → chunk-2XAAFAQT.cjs} +18 -18
  2. package/dist/{chunk-FY5XAW3H.cjs.map → chunk-2XAAFAQT.cjs.map} +1 -1
  3. package/dist/{chunk-AIRNG27G.js → chunk-4AWRPTKE.js} +6 -6
  4. package/dist/{chunk-6CFFISUC.js → chunk-4J3CB4KO.js} +3 -3
  5. package/dist/{chunk-VUXNXWOQ.cjs → chunk-4MLOMMDX.cjs} +1900 -1600
  6. package/dist/chunk-4MLOMMDX.cjs.map +1 -0
  7. package/dist/{chunk-OQIMH2WQ.js → chunk-56J7RLMM.js} +1514 -1214
  8. package/dist/{chunk-OQIMH2WQ.js.map → chunk-56J7RLMM.js.map} +1 -1
  9. package/dist/{chunk-7I42V2OD.cjs → chunk-5ICPQAUI.cjs} +73 -73
  10. package/dist/{chunk-7I42V2OD.cjs.map → chunk-5ICPQAUI.cjs.map} +1 -1
  11. package/dist/{chunk-BZMOEWWS.js → chunk-5RHJVPL7.js} +6 -6
  12. package/dist/{chunk-RW2LPL72.js → chunk-6WTCJI5R.js} +2 -2
  13. package/dist/{chunk-VDZO6IDR.js → chunk-BTIRYGDT.js} +5 -5
  14. package/dist/{chunk-D5LQMRPU.cjs → chunk-DY6AJNVK.cjs} +5 -5
  15. package/dist/{chunk-D5LQMRPU.cjs.map → chunk-DY6AJNVK.cjs.map} +1 -1
  16. package/dist/{chunk-MPYDX5WX.js → chunk-FG5ALORQ.js} +4 -4
  17. package/dist/{chunk-2MKUHJ3O.cjs → chunk-J2J36PEN.cjs} +40 -40
  18. package/dist/{chunk-2MKUHJ3O.cjs.map → chunk-J2J36PEN.cjs.map} +1 -1
  19. package/dist/{chunk-AW63ET2A.cjs → chunk-LIRXKEOE.cjs} +19 -19
  20. package/dist/{chunk-AW63ET2A.cjs.map → chunk-LIRXKEOE.cjs.map} +1 -1
  21. package/dist/{chunk-UZZT65PQ.cjs → chunk-LVDI7R7T.cjs} +40 -40
  22. package/dist/{chunk-UZZT65PQ.cjs.map → chunk-LVDI7R7T.cjs.map} +1 -1
  23. package/dist/{chunk-GUTYUMGV.cjs → chunk-M4SCYPCR.cjs} +12 -12
  24. package/dist/{chunk-GUTYUMGV.cjs.map → chunk-M4SCYPCR.cjs.map} +1 -1
  25. package/dist/{chunk-GMPYR3MX.cjs → chunk-O6S6Q52W.cjs} +33 -33
  26. package/dist/{chunk-GMPYR3MX.cjs.map → chunk-O6S6Q52W.cjs.map} +1 -1
  27. package/dist/{chunk-IEZ3YBAP.cjs → chunk-OFFYASUY.cjs} +32 -32
  28. package/dist/{chunk-IEZ3YBAP.cjs.map → chunk-OFFYASUY.cjs.map} +1 -1
  29. package/dist/{chunk-NGEFYPY7.js → chunk-P3ITKTUE.js} +4 -4
  30. package/dist/{chunk-3IEVA5AO.cjs → chunk-PI5BMKV3.cjs} +93 -93
  31. package/dist/{chunk-3IEVA5AO.cjs.map → chunk-PI5BMKV3.cjs.map} +1 -1
  32. package/dist/{chunk-WLM3F3WU.js → chunk-QHRNQTW5.js} +3 -3
  33. package/dist/{chunk-GZK7FWJJ.js → chunk-QKWOBV55.js} +12 -12
  34. package/dist/chunk-QKWOBV55.js.map +1 -0
  35. package/dist/{chunk-STYA77PH.cjs → chunk-SGL6URIJ.cjs} +12 -12
  36. package/dist/chunk-SGL6URIJ.cjs.map +1 -0
  37. package/dist/{chunk-6QK3ONDD.js → chunk-SZWMJIH3.js} +2 -2
  38. package/dist/{chunk-KP5KRRD6.cjs → chunk-THC3K262.cjs} +3 -3
  39. package/dist/{chunk-KP5KRRD6.cjs.map → chunk-THC3K262.cjs.map} +1 -1
  40. package/dist/{chunk-BIKOJ4FH.cjs → chunk-TTYJAZ7P.cjs} +58 -32
  41. package/dist/chunk-TTYJAZ7P.cjs.map +1 -0
  42. package/dist/{chunk-6CJ2K7BD.js → chunk-U5LU56JE.js} +3 -3
  43. package/dist/{chunk-EJQUYZXF.cjs → chunk-VML66FJQ.cjs} +12 -12
  44. package/dist/{chunk-EJQUYZXF.cjs.map → chunk-VML66FJQ.cjs.map} +1 -1
  45. package/dist/{chunk-PB3BTZDU.js → chunk-WEUNPE3S.js} +139 -113
  46. package/dist/chunk-WEUNPE3S.js.map +1 -0
  47. package/dist/{chunk-EGXCWZYP.js → chunk-WKWZ7LVC.js} +3 -3
  48. package/dist/{chunk-VL2YMDPH.js → chunk-WPFLV4QJ.js} +6 -6
  49. package/dist/components/case-studies/index.cjs +10 -10
  50. package/dist/components/case-studies/index.js +4 -4
  51. package/dist/components/chat/index.cjs +4 -4
  52. package/dist/components/chat/index.js +3 -3
  53. package/dist/components/contact/index.cjs +5 -5
  54. package/dist/components/contact/index.js +4 -4
  55. package/dist/components/docs/index.cjs +7 -7
  56. package/dist/components/docs/index.js +6 -6
  57. package/dist/components/embeds/index.cjs +5 -5
  58. package/dist/components/embeds/index.js +4 -4
  59. package/dist/components/faq/index.cjs +6 -6
  60. package/dist/components/faq/index.js +5 -5
  61. package/dist/components/features/floating-walkthrough-video.d.ts.map +1 -1
  62. package/dist/components/features/index.cjs +4 -4
  63. package/dist/components/features/index.js +3 -3
  64. package/dist/components/features/select-button.d.ts.map +1 -1
  65. package/dist/components/help-center-pages/index.cjs +23 -23
  66. package/dist/components/help-center-pages/index.js +14 -14
  67. package/dist/components/index.cjs +161 -161
  68. package/dist/components/index.js +12 -12
  69. package/dist/components/layout/page-layout.d.ts +7 -1
  70. package/dist/components/layout/page-layout.d.ts.map +1 -1
  71. package/dist/components/layout/title-block.d.ts +23 -1
  72. package/dist/components/layout/title-block.d.ts.map +1 -1
  73. package/dist/components/navigation/index.cjs +4 -4
  74. package/dist/components/navigation/index.js +3 -3
  75. package/dist/components/navigation/navigation-sidebar-item.d.ts +23 -1
  76. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  77. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  78. package/dist/components/onboarding-guides/index.cjs +8 -8
  79. package/dist/components/onboarding-guides/index.js +7 -7
  80. package/dist/components/related-content/index.cjs +6 -6
  81. package/dist/components/related-content/index.js +5 -5
  82. package/dist/components/tickets/index.cjs +7 -7
  83. package/dist/components/tickets/index.js +6 -6
  84. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  85. package/dist/components/ui/checkbox-block.d.ts.map +1 -1
  86. package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
  87. package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
  88. package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
  89. package/dist/components/ui/date-picker.d.ts.map +1 -1
  90. package/dist/components/ui/field-wrapper.d.ts +10 -1
  91. package/dist/components/ui/field-wrapper.d.ts.map +1 -1
  92. package/dist/components/ui/file-manager/index.cjs +50 -50
  93. package/dist/components/ui/file-manager/index.js +1 -1
  94. package/dist/components/ui/floating-tooltip.d.ts +8 -1
  95. package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
  96. package/dist/components/ui/index.cjs +4 -4
  97. package/dist/components/ui/index.js +3 -3
  98. package/dist/components/ui/input-trigger.d.ts.map +1 -1
  99. package/dist/components/ui/input.d.ts.map +1 -1
  100. package/dist/components/ui/page-actions.d.ts +7 -1
  101. package/dist/components/ui/page-actions.d.ts.map +1 -1
  102. package/dist/components/ui/radio-group.d.ts.map +1 -1
  103. package/dist/components/ui/search-input.d.ts.map +1 -1
  104. package/dist/components/ui/select.d.ts.map +1 -1
  105. package/dist/components/ui/tab-navigation.d.ts +10 -1
  106. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  107. package/dist/components/ui/tag-search-input.d.ts.map +1 -1
  108. package/dist/components/ui/textarea.d.ts.map +1 -1
  109. package/dist/components/ui/truncate-text.d.ts +2 -2
  110. package/dist/components/ui/truncate-text.d.ts.map +1 -1
  111. package/dist/hooks/index.cjs +4 -2
  112. package/dist/hooks/index.cjs.map +1 -1
  113. package/dist/hooks/index.js +3 -1
  114. package/dist/hooks/ui/index.d.ts +1 -0
  115. package/dist/hooks/ui/index.d.ts.map +1 -1
  116. package/dist/hooks/ui/use-is-truncated.d.ts +33 -0
  117. package/dist/hooks/ui/use-is-truncated.d.ts.map +1 -0
  118. package/dist/index.cjs +6 -4
  119. package/dist/index.cjs.map +1 -1
  120. package/dist/index.js +5 -3
  121. package/package.json +1 -1
  122. package/src/components/features/floating-walkthrough-video.tsx +3 -1
  123. package/src/components/features/select-button.tsx +4 -1
  124. package/src/components/layout/page-layout.tsx +16 -0
  125. package/src/components/layout/title-block.tsx +106 -7
  126. package/src/components/navigation/navigation-sidebar-item.tsx +80 -30
  127. package/src/components/navigation/navigation-sidebar.tsx +106 -46
  128. package/src/components/ui/.field-wrapper.md +5 -4
  129. package/src/components/ui/.tab-navigation.md +14 -1
  130. package/src/components/ui/__tests__/tab-navigation.test.tsx +159 -0
  131. package/src/components/ui/autocomplete.tsx +13 -1
  132. package/src/components/ui/button/button.tsx +1 -1
  133. package/src/components/ui/checkbox-block.tsx +27 -3
  134. package/src/components/ui/data-table/data-table-column-filter.tsx +7 -1
  135. package/src/components/ui/data-table/data-table-header.tsx +4 -1
  136. package/src/components/ui/data-table/data-table-skeleton.tsx +7 -0
  137. package/src/components/ui/date-picker.tsx +15 -1
  138. package/src/components/ui/field-wrapper.tsx +12 -6
  139. package/src/components/ui/floating-tooltip.tsx +9 -1
  140. package/src/components/ui/input-trigger.tsx +8 -1
  141. package/src/components/ui/input.tsx +8 -1
  142. package/src/components/ui/page-actions.tsx +142 -30
  143. package/src/components/ui/radio-group.tsx +28 -3
  144. package/src/components/ui/search-input.tsx +2 -1
  145. package/src/components/ui/select.tsx +7 -0
  146. package/src/components/ui/tab-navigation.tsx +379 -158
  147. package/src/components/ui/tag-search-input.tsx +9 -2
  148. package/src/components/ui/textarea.tsx +8 -1
  149. package/src/components/ui/truncate-text.tsx +3 -18
  150. package/src/hooks/ui/index.ts +1 -0
  151. package/src/hooks/ui/use-is-truncated.ts +66 -0
  152. package/dist/chunk-BIKOJ4FH.cjs.map +0 -1
  153. package/dist/chunk-GZK7FWJJ.js.map +0 -1
  154. package/dist/chunk-PB3BTZDU.js.map +0 -1
  155. package/dist/chunk-STYA77PH.cjs.map +0 -1
  156. package/dist/chunk-VUXNXWOQ.cjs.map +0 -1
  157. /package/dist/{chunk-AIRNG27G.js.map → chunk-4AWRPTKE.js.map} +0 -0
  158. /package/dist/{chunk-6CFFISUC.js.map → chunk-4J3CB4KO.js.map} +0 -0
  159. /package/dist/{chunk-BZMOEWWS.js.map → chunk-5RHJVPL7.js.map} +0 -0
  160. /package/dist/{chunk-RW2LPL72.js.map → chunk-6WTCJI5R.js.map} +0 -0
  161. /package/dist/{chunk-VDZO6IDR.js.map → chunk-BTIRYGDT.js.map} +0 -0
  162. /package/dist/{chunk-MPYDX5WX.js.map → chunk-FG5ALORQ.js.map} +0 -0
  163. /package/dist/{chunk-NGEFYPY7.js.map → chunk-P3ITKTUE.js.map} +0 -0
  164. /package/dist/{chunk-WLM3F3WU.js.map → chunk-QHRNQTW5.js.map} +0 -0
  165. /package/dist/{chunk-6QK3ONDD.js.map → chunk-SZWMJIH3.js.map} +0 -0
  166. /package/dist/{chunk-6CJ2K7BD.js.map → chunk-U5LU56JE.js.map} +0 -0
  167. /package/dist/{chunk-EGXCWZYP.js.map → chunk-WKWZ7LVC.js.map} +0 -0
  168. /package/dist/{chunk-VL2YMDPH.js.map → chunk-WPFLV4QJ.js.map} +0 -0
@@ -6,6 +6,7 @@ import { Chevron02DownIcon } from '../icons-v2-generated'
6
6
  import { ActionsMenuDropdown, type ActionsMenuGroup, type ActionsMenuItem } from './actions-menu'
7
7
  import type { ButtonProps, SplitButtonIconAction } from './button'
8
8
  import { Button, SplitButton } from './button'
9
+ import { Skeleton } from './skeleton'
9
10
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'
10
11
 
11
12
  export type PageActionButton = {
@@ -206,26 +207,104 @@ export interface PageActionsProps {
206
207
  */
207
208
  selector?: React.ReactNode
208
209
  className?: string
210
+ /**
211
+ * Render placeholders instead of the actions — for pages whose action SET
212
+ * depends on data still in flight. Opt-in: a page whose actions are already
213
+ * final while its title loads keeps rendering them.
214
+ */
215
+ loading?: boolean
209
216
  }
210
217
 
211
218
  const ACTIONS_GAP = 'gap-[var(--spacing-system-xs)]'
212
219
 
220
+ /** Matches `Button` size="icon" / size="default" exactly, so a placeholder and the
221
+ * button that replaces it occupy the same box at both breakpoints. `rounded-md`
222
+ * is the same token `Button` itself uses — a raw `6px` would agree today and
223
+ * drift the moment a platform overrides `--radius`. */
224
+ const SKELETON_HEIGHT = 'h-11 md:h-12'
225
+ const SKELETON_ICON_WIDTH = 'w-11 md:w-12'
226
+ const SKELETON_LABEL_WIDTH = 'w-[120px] md:w-[140px]'
227
+ const SKELETON_RADIUS = 'rounded-md'
228
+
229
+ /**
230
+ * Placeholders for actions whose SHAPE isn't known yet — the page is still
231
+ * loading the record that decides which actions exist (e.g. an archived entity
232
+ * offers "Unarchive" where an active one offers "Edit").
233
+ *
234
+ * Why not render the actions anyway: a button is a promise. Painting the
235
+ * optimistic set means a flash of the wrong header AND a live click target that
236
+ * can act on the wrong assumption before the data lands. A grey box promises
237
+ * nothing and can't be clicked.
238
+ *
239
+ * The placeholders are derived from the actions the caller passed — one per
240
+ * action, each sized like the button that action WILL render — so the common
241
+ * case (the data confirms that set) settles with no layout shift at all. Sizing
242
+ * per action matters: `renderRawActionButton` collapses a label-less action (or
243
+ * one flagged `iconOnlyOnDesktop`) to a square `size="icon"` button, and a
244
+ * 140px-wide placeholder snapping to 48px is exactly the shift this is meant to
245
+ * avoid.
246
+ *
247
+ * An empty list still yields one placeholder: `loading` means the action set is
248
+ * unknown, not known-empty. Its shape comes from `emptyFallback`, because what
249
+ * an empty header settles into differs per variant — `icon-buttons` and
250
+ * `menu-primary` fall back to the square "…" overflow trigger, `primary-buttons`
251
+ * has no overflow trigger at all and settles into a labelled button.
252
+ */
253
+ function ActionSkeletons({
254
+ actions,
255
+ fullWidth,
256
+ emptyFallback = 'icon',
257
+ }: {
258
+ actions: PageActionButton[]
259
+ fullWidth?: boolean
260
+ emptyFallback?: 'icon' | 'label'
261
+ }) {
262
+ const items = actions.length > 0 ? actions : [null]
263
+ return (
264
+ <>
265
+ {items.map((action, idx) => {
266
+ // Mirrors `renderRawActionButton`'s `isIconOnly`. The mobile bottom bar
267
+ // stretches only labelled buttons (`fullWidth: !!action.label`), so an
268
+ // icon action keeps its square box there too.
269
+ const isIconOnly = action ? !action.label || !!action.iconOnlyOnDesktop : emptyFallback === 'icon'
270
+ return (
271
+ <Skeleton
272
+ // Static placeholder list — index IS the identity here.
273
+ key={`action-skeleton-${idx}`}
274
+ className={cn(
275
+ SKELETON_HEIGHT,
276
+ SKELETON_RADIUS,
277
+ isIconOnly ? SKELETON_ICON_WIDTH : fullWidth ? 'flex-1' : SKELETON_LABEL_WIDTH,
278
+ )}
279
+ />
280
+ )
281
+ })}
282
+ </>
283
+ )
284
+ }
285
+
286
+ /** Mobile placeholder for the variants that collapse to ONE trigger (icon button or "…"). */
287
+ function MobileTriggerSkeleton() {
288
+ return <Skeleton className={cn(SKELETON_HEIGHT, SKELETON_ICON_WIDTH, SKELETON_RADIUS)} />
289
+ }
290
+
213
291
  export function PageActions({
214
292
  variant = 'icon-buttons',
215
293
  actions,
216
294
  menuActions,
217
295
  selector,
218
296
  className,
297
+ loading,
219
298
  }: PageActionsProps) {
220
299
  if (variant === 'icon-buttons') {
221
- return <IconButtonsVariant actions={actions} menuActions={menuActions} selector={selector} className={className} />
300
+ return <IconButtonsVariant actions={actions} menuActions={menuActions} selector={selector} className={className} loading={loading} />
222
301
  }
223
302
 
224
303
  if (variant === 'menu-primary') {
225
- return <MenuPrimaryVariant actions={actions} menuActions={menuActions || []} selector={selector} className={className} />
304
+ return <MenuPrimaryVariant actions={actions} menuActions={menuActions || []} selector={selector} className={className} loading={loading} />
226
305
  }
227
306
 
228
- return <PrimaryButtonsVariant actions={actions} className={className} />
307
+ return <PrimaryButtonsVariant actions={actions} className={className} loading={loading} />
229
308
  }
230
309
 
231
310
  function IconButtonsVariant({
@@ -233,11 +312,13 @@ function IconButtonsVariant({
233
312
  menuActions,
234
313
  selector,
235
314
  className,
315
+ loading,
236
316
  }: {
237
317
  actions: PageActionButton[]
238
318
  menuActions?: ActionsMenuGroup[]
239
319
  selector?: React.ReactNode
240
320
  className?: string
321
+ loading?: boolean
241
322
  }) {
242
323
  const desktopActions = actions.filter(a => !a.showOnlyMobile)
243
324
  const hasMenuActions = !!menuActions && menuActions.some(g => g.items.length > 0)
@@ -257,17 +338,25 @@ function IconButtonsVariant({
257
338
  {/* Desktop: every action as an icon button + optional overflow menu */}
258
339
  <div className={cn('hidden md:flex items-center', ACTIONS_GAP, className)}>
259
340
  {selector}
260
- {desktopActions.map((action, idx) => (
261
- <React.Fragment key={actionKey(action, idx)}>
262
- {renderActionButton(action)}
263
- </React.Fragment>
264
- ))}
265
- {hasMenuActions && <ActionsMenuDropdown groups={menuActions} />}
341
+ {loading ? (
342
+ <ActionSkeletons actions={desktopActions} />
343
+ ) : (
344
+ <>
345
+ {desktopActions.map((action, idx) => (
346
+ <React.Fragment key={actionKey(action, idx)}>
347
+ {renderActionButton(action)}
348
+ </React.Fragment>
349
+ ))}
350
+ {hasMenuActions && <ActionsMenuDropdown groups={menuActions} />}
351
+ </>
352
+ )}
266
353
  </div>
267
354
 
268
355
  {/* Mobile: single icon button OR all actions merged into one "..." menu */}
269
356
  <div className={cn('flex md:hidden', className)}>
270
- {useSingleActionMobile && singleAction ? (
357
+ {loading ? (
358
+ <MobileTriggerSkeleton />
359
+ ) : useSingleActionMobile && singleAction ? (
271
360
  renderActionButton(singleAction, { iconOnly: true })
272
361
  ) : hasMobileMenuItems ? (
273
362
  <ActionsMenuDropdown groups={mobileMenuGroups} />
@@ -284,9 +373,11 @@ function IconButtonsVariant({
284
373
  function PrimaryButtonsVariant({
285
374
  actions,
286
375
  className,
376
+ loading,
287
377
  }: {
288
378
  actions: PageActionButton[]
289
379
  className?: string
380
+ loading?: boolean
290
381
  }) {
291
382
  // Sort: outline first, accent last (rightmost on desktop).
292
383
  const sortedActions = [...actions].sort((a, b) => {
@@ -300,14 +391,20 @@ function PrimaryButtonsVariant({
300
391
  return (
301
392
  <>
302
393
  <div className={cn('hidden md:flex items-center', ACTIONS_GAP, className)}>
303
- {desktopActions.map((action, idx) => (
304
- <React.Fragment key={`desktop-${actionKey(action, idx)}`}>
305
- {renderActionButton(action)}
306
- </React.Fragment>
307
- ))}
394
+ {loading ? (
395
+ // No overflow trigger in this variant — an unknown action set settles
396
+ // into a labelled button, not a square one.
397
+ <ActionSkeletons actions={desktopActions} emptyFallback="label" />
398
+ ) : (
399
+ desktopActions.map((action, idx) => (
400
+ <React.Fragment key={`desktop-${actionKey(action, idx)}`}>
401
+ {renderActionButton(action)}
402
+ </React.Fragment>
403
+ ))
404
+ )}
308
405
  </div>
309
406
 
310
- <MobileBottomActions actions={sortedActions} />
407
+ <MobileBottomActions actions={sortedActions} loading={loading} />
311
408
  </>
312
409
  )
313
410
  }
@@ -321,11 +418,13 @@ function MenuPrimaryVariant({
321
418
  menuActions,
322
419
  selector,
323
420
  className,
421
+ loading,
324
422
  }: {
325
423
  actions: PageActionButton[]
326
424
  menuActions: ActionsMenuGroup[]
327
425
  selector?: React.ReactNode
328
426
  className?: string
427
+ loading?: boolean
329
428
  }) {
330
429
  const desktopActions = actions.filter(a => !a.showOnlyMobile)
331
430
  const hasMenuActions = menuActions.some(g => g.items.length > 0)
@@ -340,34 +439,47 @@ function MenuPrimaryVariant({
340
439
  <>
341
440
  <div className={cn('hidden md:flex items-center', ACTIONS_GAP, className)}>
342
441
  {selector}
343
- {hasMenuActions && <ActionsMenuDropdown groups={menuActions} />}
344
- {desktopActions.map((action, idx) => (
345
- <React.Fragment key={`desktop-${actionKey(action, idx)}`}>
346
- {renderActionButton({ ...action, variant: action.variant || 'accent' })}
347
- </React.Fragment>
348
- ))}
442
+ {loading ? (
443
+ <ActionSkeletons actions={desktopActions} />
444
+ ) : (
445
+ <>
446
+ {hasMenuActions && <ActionsMenuDropdown groups={menuActions} />}
447
+ {desktopActions.map((action, idx) => (
448
+ <React.Fragment key={`desktop-${actionKey(action, idx)}`}>
449
+ {renderActionButton({ ...action, variant: action.variant || 'accent' })}
450
+ </React.Fragment>
451
+ ))}
452
+ </>
453
+ )}
349
454
  </div>
350
455
 
351
456
  <div className={cn('flex md:hidden', className)}>
352
- {hasMobileMenuItems ? <ActionsMenuDropdown groups={mobileMenuGroups} /> : null}
457
+ {loading ? <MobileTriggerSkeleton /> : hasMobileMenuItems ? <ActionsMenuDropdown groups={mobileMenuGroups} /> : null}
353
458
  </div>
354
459
  </>
355
460
  )
356
461
  }
357
462
 
358
- function MobileBottomActions({ actions }: { actions: PageActionButton[] }) {
463
+ function MobileBottomActions({ actions, loading }: { actions: PageActionButton[]; loading?: boolean }) {
359
464
  return (
360
465
  <div className={cn(
361
466
  'fixed md:hidden bottom-0 left-0 right-0 z-50',
362
467
  'bg-ods-card border-t border-ods-border',
363
- 'flex items-start pt-6 pb-6 px-6',
468
+ // `lf` (24px at every breakpoint), NOT `l` — this bar is `md:hidden`, so the
469
+ // only value it ever uses is the MOBILE one, and `l` is 16px there. The
470
+ // fixed variant is what keeps the 24px the bar has always had.
471
+ 'flex items-start p-[var(--spacing-system-lf)]',
364
472
  ACTIONS_GAP,
365
473
  )}>
366
- {actions.map((action, idx) => (
367
- <React.Fragment key={`mobile-${actionKey(action, idx)}`}>
368
- {renderActionButton(action, { fullWidth: !!action.label })}
369
- </React.Fragment>
370
- ))}
474
+ {loading ? (
475
+ <ActionSkeletons actions={actions} fullWidth emptyFallback="label" />
476
+ ) : (
477
+ actions.map((action, idx) => (
478
+ <React.Fragment key={`mobile-${actionKey(action, idx)}`}>
479
+ {renderActionButton(action, { fullWidth: !!action.label })}
480
+ </React.Fragment>
481
+ ))
482
+ )}
371
483
  </div>
372
484
  )
373
485
  }
@@ -81,6 +81,10 @@ const RadioGroupBlock = React.forwardRef<
81
81
  <div className={cn("relative flex w-full flex-col", className)}>
82
82
  <RadioGroupPrimitive.Root
83
83
  ref={ref}
84
+ // Same marker every field exposes — it is how a form finds (and
85
+ // scrolls to) the first control that failed validation. The GROUP is
86
+ // the field here; the individual options are not separately invalid.
87
+ data-invalid={error ? true : undefined}
84
88
  className={cn(
85
89
  "w-full",
86
90
  isGrouped
@@ -106,6 +110,10 @@ const RadioGroupBlock = React.forwardRef<
106
110
  "flex w-full items-center gap-3",
107
111
  "transition-colors duration-200",
108
112
  isDisabled ? "cursor-not-allowed" : "cursor-pointer",
113
+ // Hover / active move the BACKGROUND only, like Input and
114
+ // CheckboxBlock — the border stays put so an unselected row
115
+ // never flashes "selected" under the cursor.
116
+ !isDisabled && "hover:bg-ods-bg-hover active:bg-ods-bg-active",
109
117
  isGrouped
110
118
  ? cn(
111
119
  "bg-ods-card px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs,8px)]",
@@ -114,12 +122,17 @@ const RadioGroupBlock = React.forwardRef<
114
122
  )
115
123
  : cn(
116
124
  "rounded-[6px] border bg-ods-card p-[var(--spacing-system-sf)]",
117
- !isDisabled && "hover:border-ods-accent/30",
118
125
  !isDisabled &&
119
126
  "has-[[data-state=checked]]:bg-[var(--ods-system-greys-soft-grey)]/30 has-[[data-state=checked]]:border-ods-accent/40",
120
127
  error ? "border-ods-error" : "border-ods-border"
121
128
  ),
122
- isDisabled && "opacity-50",
129
+ // Disabled matches a disabled field: flat `ods-bg` fill and
130
+ // greyed text, not a fade of the whole row. The greying lives
131
+ // on the TEXT COLUMN below, not here: as a descendant rule on
132
+ // the row it also repainted whatever `trailing` renders — a
133
+ // `Tag`'s label is a span too, and it went invisible against
134
+ // its own fill.
135
+ isDisabled && "bg-ods-bg",
123
136
  itemClassName
124
137
  )}
125
138
  >
@@ -132,7 +145,10 @@ const RadioGroupBlock = React.forwardRef<
132
145
  error ? "border-ods-error" : "border-[var(--color-border-strong)]",
133
146
  "transition-colors duration-150",
134
147
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-2 focus-visible:ring-offset-ods-card",
148
+ // Disabled: the EMPTY dot dims with the text; a selected one
149
+ // keeps its accent so "on but locked" stays readable.
135
150
  "disabled:cursor-not-allowed",
151
+ "disabled:data-[state=unchecked]:bg-ods-bg disabled:data-[state=unchecked]:border-ods-border",
136
152
  "data-[state=checked]:border-ods-accent"
137
153
  )}
138
154
  >
@@ -140,7 +156,16 @@ const RadioGroupBlock = React.forwardRef<
140
156
  <span className="block h-1/2 w-1/2 rounded-full bg-ods-accent" />
141
157
  </RadioGroupPrimitive.Indicator>
142
158
  </RadioGroupPrimitive.Item>
143
- <div className="flex min-w-0 flex-1 flex-col justify-center">
159
+ <div
160
+ className={cn(
161
+ "flex min-w-0 flex-1 flex-col justify-center",
162
+ // Direct children only — these are the two component-owned
163
+ // wrappers. `option.label` / `option.description` are
164
+ // ReactNode, so a descendant rule would repaint content the
165
+ // caller styled itself.
166
+ isDisabled && "[&>span]:text-ods-text-disabled",
167
+ )}
168
+ >
144
169
  <span
145
170
  className={cn(
146
171
  "font-[family-name:var(--font-h4-family)] font-[number:var(--font-h4-weight)] text-[length:var(--font-size-h4-body)] leading-[24px]",
@@ -106,7 +106,8 @@ const innerInputStyles = cn(
106
106
  "flex-1 min-w-[60px] bg-transparent border-none outline-none",
107
107
  "text-h4",
108
108
  "text-ods-text-primary placeholder:text-ods-text-secondary",
109
- "disabled:cursor-not-allowed",
109
+ // Disabled - match Input exactly (value greys out, placeholder dims further)
110
+ "disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
110
111
  "touch-manipulation"
111
112
  )
112
113
 
@@ -42,7 +42,14 @@ const SelectTrigger = React.forwardRef<
42
42
  "enabled:hover:bg-ods-bg-hover enabled:hover:border-ods-border-hover enabled:active:bg-ods-bg-active enabled:active:border-ods-border-active",
43
43
  !isInvalid && "data-[state=open]:border-ods-accent data-[state=open]:hover:border-ods-accent",
44
44
  "group",
45
+ // Disabled - match Input exactly: value greys out, placeholder dims
46
+ // further. The `data-[placeholder]` rule above is a class+attribute
47
+ // selector, so the disabled placeholder rule stacks `:disabled` on top
48
+ // of it to win on specificity rather than on source order.
45
49
  "disabled:!cursor-not-allowed disabled:bg-ods-bg",
50
+ "disabled:text-ods-text-disabled disabled:data-[placeholder]:text-ods-border",
51
+ // The chevron sets its own colour — grey it with the value.
52
+ "disabled:[&_svg]:text-ods-text-disabled",
46
53
  "transition-colors duration-200 cursor-pointer",
47
54
  "[&>span]:line-clamp-1",
48
55
  isInvalid && "border-ods-error enabled:hover:border-ods-error enabled:active:border-ods-error data-[state=open]:border-ods-error",