@flamingo-stack/openframe-frontend-core 0.0.498 → 0.0.499-snapshot.20260730180712

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 (175) hide show
  1. package/dist/{chunk-MSB3PEPP.cjs → chunk-4NDGD2YH.cjs} +19 -19
  2. package/dist/{chunk-MSB3PEPP.cjs.map → chunk-4NDGD2YH.cjs.map} +1 -1
  3. package/dist/{chunk-VOOEX4IZ.js → chunk-5CY6YKWY.js} +6 -6
  4. package/dist/{chunk-XSNKSMB3.js → chunk-6TJJFQ74.js} +6 -6
  5. package/dist/{chunk-TTYJAZ7P.cjs → chunk-C5R2OX7A.cjs} +9 -4
  6. package/dist/chunk-C5R2OX7A.cjs.map +1 -0
  7. package/dist/{chunk-ZBTIN7RC.cjs → chunk-ETU6VFOL.cjs} +40 -40
  8. package/dist/{chunk-ZBTIN7RC.cjs.map → chunk-ETU6VFOL.cjs.map} +1 -1
  9. package/dist/{chunk-ZTE32A7C.cjs → chunk-GDUWGCPP.cjs} +12 -12
  10. package/dist/{chunk-ZTE32A7C.cjs.map → chunk-GDUWGCPP.cjs.map} +1 -1
  11. package/dist/{chunk-P47NN7RY.js → chunk-GSONSIWG.js} +28 -3
  12. package/dist/chunk-GSONSIWG.js.map +1 -0
  13. package/dist/{chunk-THC3K262.cjs → chunk-I64ZGPA6.cjs} +3 -3
  14. package/dist/{chunk-THC3K262.cjs.map → chunk-I64ZGPA6.cjs.map} +1 -1
  15. package/dist/{chunk-4NIBKEED.cjs → chunk-IDRKW6MS.cjs} +12 -12
  16. package/dist/{chunk-4NIBKEED.cjs.map → chunk-IDRKW6MS.cjs.map} +1 -1
  17. package/dist/{chunk-W24U7ZUV.cjs → chunk-KQKU4VXB.cjs} +40 -15
  18. package/dist/chunk-KQKU4VXB.cjs.map +1 -0
  19. package/dist/{chunk-SGL6URIJ.cjs → chunk-L5CI57RM.cjs} +9 -5
  20. package/dist/chunk-L5CI57RM.cjs.map +1 -0
  21. package/dist/{chunk-4EHQEMN4.cjs → chunk-LN2X4XAB.cjs} +18 -18
  22. package/dist/{chunk-4EHQEMN4.cjs.map → chunk-LN2X4XAB.cjs.map} +1 -1
  23. package/dist/{chunk-VLWVM6ZS.js → chunk-P3B65FBX.js} +4 -4
  24. package/dist/{chunk-2OPD6KRB.js → chunk-P5UMIPKR.js} +5 -5
  25. package/dist/{chunk-WEUNPE3S.js → chunk-P7IOCYGD.js} +53 -48
  26. package/dist/chunk-P7IOCYGD.js.map +1 -0
  27. package/dist/{chunk-ZEZTITQZ.cjs → chunk-PAO45LBQ.cjs} +1162 -931
  28. package/dist/chunk-PAO45LBQ.cjs.map +1 -0
  29. package/dist/{chunk-G6ARA2HD.js → chunk-QQAKDJPE.js} +3 -3
  30. package/dist/{chunk-KK4M4URN.js → chunk-R2IGOYHU.js} +3 -3
  31. package/dist/{chunk-A3Z63ZL5.cjs → chunk-S3COVC7H.cjs} +73 -73
  32. package/dist/{chunk-A3Z63ZL5.cjs.map → chunk-S3COVC7H.cjs.map} +1 -1
  33. package/dist/{chunk-OYPUQJJ2.js → chunk-SOX7CGFX.js} +342 -111
  34. package/dist/{chunk-OYPUQJJ2.js.map → chunk-SOX7CGFX.js.map} +1 -1
  35. package/dist/{chunk-T67MZAQJ.cjs → chunk-TJCUGL7S.cjs} +33 -33
  36. package/dist/{chunk-T67MZAQJ.cjs.map → chunk-TJCUGL7S.cjs.map} +1 -1
  37. package/dist/{chunk-K5HHLTSG.js → chunk-UM2YFCPE.js} +3 -3
  38. package/dist/{chunk-MVORKLWX.cjs → chunk-V5ALIJZ6.cjs} +32 -32
  39. package/dist/{chunk-MVORKLWX.cjs.map → chunk-V5ALIJZ6.cjs.map} +1 -1
  40. package/dist/{chunk-2R25CGSO.cjs → chunk-VLI6ESJN.cjs} +93 -93
  41. package/dist/{chunk-2R25CGSO.cjs.map → chunk-VLI6ESJN.cjs.map} +1 -1
  42. package/dist/{chunk-MF3VJKHT.js → chunk-VMDQDD5Y.js} +2 -2
  43. package/dist/{chunk-6WTCJI5R.js → chunk-VPQBQJ4S.js} +2 -2
  44. package/dist/{chunk-5ZL7PA63.js → chunk-W6Y66G4K.js} +3 -3
  45. package/dist/{chunk-QKWOBV55.js → chunk-WHJGRRK2.js} +9 -5
  46. package/dist/chunk-WHJGRRK2.js.map +1 -0
  47. package/dist/{chunk-6GAJETAP.js → chunk-XBLRT3MX.js} +4 -4
  48. package/dist/{chunk-ZHNWWFIM.cjs → chunk-YSN2AZJR.cjs} +5 -5
  49. package/dist/{chunk-ZHNWWFIM.cjs.map → chunk-YSN2AZJR.cjs.map} +1 -1
  50. package/dist/{chunk-XI44QDCD.cjs → chunk-ZLJCE75C.cjs} +40 -40
  51. package/dist/{chunk-XI44QDCD.cjs.map → chunk-ZLJCE75C.cjs.map} +1 -1
  52. package/dist/{chunk-W4XZ3FBZ.js → chunk-ZU27I5NZ.js} +6 -6
  53. package/dist/components/case-studies/index.cjs +11 -11
  54. package/dist/components/case-studies/index.js +5 -5
  55. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts +5 -2
  56. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
  57. package/dist/components/chat/index.cjs +5 -5
  58. package/dist/components/chat/index.js +4 -4
  59. package/dist/components/chat/types/api.types.d.ts +9 -2
  60. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  61. package/dist/components/contact/index.cjs +6 -6
  62. package/dist/components/contact/index.js +5 -5
  63. package/dist/components/docs/index.cjs +8 -8
  64. package/dist/components/docs/index.js +7 -7
  65. package/dist/components/embeds/index.cjs +6 -6
  66. package/dist/components/embeds/index.js +5 -5
  67. package/dist/components/faq/index.cjs +7 -7
  68. package/dist/components/faq/index.js +6 -6
  69. package/dist/components/features/filters-dropdown.d.ts.map +1 -1
  70. package/dist/components/features/index.cjs +5 -5
  71. package/dist/components/features/index.js +4 -4
  72. package/dist/components/help-center-pages/index.cjs +24 -24
  73. package/dist/components/help-center-pages/index.js +15 -15
  74. package/dist/components/index.cjs +166 -162
  75. package/dist/components/index.cjs.map +1 -1
  76. package/dist/components/index.js +17 -13
  77. package/dist/components/index.js.map +1 -1
  78. package/dist/components/navigation/app-header.d.ts +48 -0
  79. package/dist/components/navigation/app-header.d.ts.map +1 -1
  80. package/dist/components/navigation/header-mingo-button.d.ts.map +1 -1
  81. package/dist/components/navigation/index.cjs +7 -5
  82. package/dist/components/navigation/index.cjs.map +1 -1
  83. package/dist/components/navigation/index.d.ts +2 -2
  84. package/dist/components/navigation/index.d.ts.map +1 -1
  85. package/dist/components/navigation/index.js +6 -4
  86. package/dist/components/navigation/navigation-sidebar-item.d.ts +14 -0
  87. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  88. package/dist/components/navigation/navigation-sidebar-toggle.d.ts.map +1 -1
  89. package/dist/components/navigation/navigation-sidebar.d.ts +18 -0
  90. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  91. package/dist/components/onboarding-guides/index.cjs +9 -9
  92. package/dist/components/onboarding-guides/index.js +8 -8
  93. package/dist/components/related-content/index.cjs +7 -7
  94. package/dist/components/related-content/index.js +6 -6
  95. package/dist/components/tickets/index.cjs +8 -8
  96. package/dist/components/tickets/index.js +7 -7
  97. package/dist/components/ui/button/button-styles.d.ts +1 -0
  98. package/dist/components/ui/button/button-styles.d.ts.map +1 -1
  99. package/dist/components/ui/button/button.d.ts.map +1 -1
  100. package/dist/components/ui/data-table/data-table-body.d.ts.map +1 -1
  101. package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
  102. package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
  103. package/dist/components/ui/data-table/data-table-row.d.ts.map +1 -1
  104. package/dist/components/ui/data-table/data-table-skeleton.d.ts +12 -0
  105. package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
  106. package/dist/components/ui/data-table/index.d.ts +1 -1
  107. package/dist/components/ui/data-table/index.d.ts.map +1 -1
  108. package/dist/components/ui/data-table/utils.d.ts +18 -0
  109. package/dist/components/ui/data-table/utils.d.ts.map +1 -1
  110. package/dist/components/ui/file-manager/index.cjs +50 -50
  111. package/dist/components/ui/file-manager/index.js +1 -1
  112. package/dist/components/ui/index.cjs +7 -5
  113. package/dist/components/ui/index.cjs.map +1 -1
  114. package/dist/components/ui/index.js +6 -4
  115. package/dist/hooks/index.cjs +5 -3
  116. package/dist/hooks/index.cjs.map +1 -1
  117. package/dist/hooks/index.js +4 -2
  118. package/dist/hooks/ui/index.d.ts +1 -0
  119. package/dist/hooks/ui/index.d.ts.map +1 -1
  120. package/dist/hooks/ui/use-isomorphic-layout-effect.d.ts +17 -0
  121. package/dist/hooks/ui/use-isomorphic-layout-effect.d.ts.map +1 -0
  122. package/dist/index.cjs +11 -5
  123. package/dist/index.cjs.map +1 -1
  124. package/dist/index.js +10 -4
  125. package/dist/nats/index.cjs +2 -2
  126. package/dist/nats/index.js +1 -1
  127. package/dist/nats/nats.d.ts +18 -1
  128. package/dist/nats/nats.d.ts.map +1 -1
  129. package/dist/types/navigation.d.ts +24 -0
  130. package/dist/types/navigation.d.ts.map +1 -1
  131. package/package.json +1 -1
  132. package/src/components/chat/hooks/.use-jetstream-dialog-subscription.md +7 -3
  133. package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +77 -5
  134. package/src/components/chat/types/api.types.ts +9 -2
  135. package/src/components/features/filters-dropdown.tsx +17 -5
  136. package/src/components/navigation/app-header.tsx +178 -0
  137. package/src/components/navigation/header-mingo-button.tsx +5 -0
  138. package/src/components/navigation/index.ts +2 -2
  139. package/src/components/navigation/navigation-sidebar-item.tsx +22 -0
  140. package/src/components/navigation/navigation-sidebar-toggle.tsx +10 -6
  141. package/src/components/navigation/navigation-sidebar.tsx +187 -44
  142. package/src/components/ui/button/button-styles.ts +11 -0
  143. package/src/components/ui/button/button.tsx +6 -3
  144. package/src/components/ui/button/split-button.tsx +2 -2
  145. package/src/components/ui/data-table/data-table-body.tsx +5 -2
  146. package/src/components/ui/data-table/data-table-column-filter.tsx +54 -25
  147. package/src/components/ui/data-table/data-table-header.tsx +134 -45
  148. package/src/components/ui/data-table/data-table-row.tsx +3 -2
  149. package/src/components/ui/data-table/data-table-skeleton.tsx +17 -2
  150. package/src/components/ui/data-table/index.ts +1 -1
  151. package/src/components/ui/data-table/utils.ts +49 -3
  152. package/src/hooks/ui/index.ts +1 -0
  153. package/src/hooks/ui/use-isomorphic-layout-effect.ts +20 -0
  154. package/src/nats/.nats.md +2 -2
  155. package/src/nats/nats.ts +70 -5
  156. package/src/types/navigation.ts +21 -0
  157. package/dist/chunk-P47NN7RY.js.map +0 -1
  158. package/dist/chunk-QKWOBV55.js.map +0 -1
  159. package/dist/chunk-SGL6URIJ.cjs.map +0 -1
  160. package/dist/chunk-TTYJAZ7P.cjs.map +0 -1
  161. package/dist/chunk-W24U7ZUV.cjs.map +0 -1
  162. package/dist/chunk-WEUNPE3S.js.map +0 -1
  163. package/dist/chunk-ZEZTITQZ.cjs.map +0 -1
  164. /package/dist/{chunk-VOOEX4IZ.js.map → chunk-5CY6YKWY.js.map} +0 -0
  165. /package/dist/{chunk-XSNKSMB3.js.map → chunk-6TJJFQ74.js.map} +0 -0
  166. /package/dist/{chunk-VLWVM6ZS.js.map → chunk-P3B65FBX.js.map} +0 -0
  167. /package/dist/{chunk-2OPD6KRB.js.map → chunk-P5UMIPKR.js.map} +0 -0
  168. /package/dist/{chunk-G6ARA2HD.js.map → chunk-QQAKDJPE.js.map} +0 -0
  169. /package/dist/{chunk-KK4M4URN.js.map → chunk-R2IGOYHU.js.map} +0 -0
  170. /package/dist/{chunk-K5HHLTSG.js.map → chunk-UM2YFCPE.js.map} +0 -0
  171. /package/dist/{chunk-MF3VJKHT.js.map → chunk-VMDQDD5Y.js.map} +0 -0
  172. /package/dist/{chunk-6WTCJI5R.js.map → chunk-VPQBQJ4S.js.map} +0 -0
  173. /package/dist/{chunk-5ZL7PA63.js.map → chunk-W6Y66G4K.js.map} +0 -0
  174. /package/dist/{chunk-6GAJETAP.js.map → chunk-XBLRT3MX.js.map} +0 -0
  175. /package/dist/{chunk-W4XZ3FBZ.js.map → chunk-ZU27I5NZ.js.map} +0 -0
@@ -50,8 +50,57 @@ export interface AppHeaderProps {
50
50
  * EXCEPT the mobile burger menu toggle, which remains interactive.
51
51
  */
52
52
  disabled?: boolean;
53
+ /**
54
+ * Draw placeholder cells instead of the live controls.
55
+ *
56
+ * Solves a problem this header cannot solve on its own: its mobile/desktop split
57
+ * is decided by `useMdUp()`, which answers `undefined` until an effect has run —
58
+ * and `?? false` turns that into "mobile". The first render is therefore always
59
+ * the phone header (burger + wordmark, no side actions), which on a desktop load
60
+ * is a visible flash before the real layout appears. It is also what a host has
61
+ * to show while the user and the action flags are still loading.
62
+ *
63
+ * The placeholder decides mobile vs desktop in CSS instead, so it is correct at
64
+ * every width on the very first paint, server-rendered included.
65
+ */
66
+ loading?: boolean;
67
+ /**
68
+ * Shape of the trailing action cells `loading` reserves.
69
+ *
70
+ * Needed because the `show*` props are usually the very thing the host is still
71
+ * waiting on — they are typically driven by feature flags or permissions, so during
72
+ * `loading` they all read `false` and the placeholder collapses to whatever is
73
+ * hardcoded on (often just the avatar), which looks nothing like the loaded header.
74
+ *
75
+ * Pass the cells the header settles on, in order — see `HeaderLoadingCell` for the
76
+ * footprints. A bare number is accepted as shorthand for all-`'icon'`.
77
+ *
78
+ * Unlike the sidebar's rows, a cell that turns out not to exist is cheap here: the
79
+ * cells are a right-aligned cluster in otherwise empty space, so one disappearing
80
+ * shifts nothing else on the page.
81
+ *
82
+ * Defaults to the shape implied by the `show*` props, which is right for a host whose
83
+ * header composition is known up front.
84
+ */
85
+ loadingActionCells?: number | ReadonlyArray<HeaderLoadingCell>;
53
86
  }
54
87
 
88
+ /**
89
+ * A trailing header cell's footprint while loading — see `loadingActionCells`.
90
+ *
91
+ * The breakpoint-scoped members exist because several live cells are themselves
92
+ * responsive, and a placeholder that ignores that is wider than the header it hands
93
+ * off to on exactly the viewports where the cluster is tightest:
94
+ *
95
+ * - `'icon'` — a fixed 48/56px cell at every width (time tracker, notifications).
96
+ * - `'icon-md'` — the same cell, from md only (the avatar: `isMdUp && showUser`).
97
+ * - `'icon-lg'` — the same cell, from lg only (the organization filter, which the
98
+ * live header renders `hidden lg:flex`).
99
+ * - `'wide'` — the Mingo launcher: a 48px square below md, where the live button is
100
+ * `iconOnly`, and the fixed 140px labelled cell from md.
101
+ */
102
+ export type HeaderLoadingCell = 'icon' | 'icon-md' | 'icon-lg' | 'wide';
103
+
55
104
  export const AppHeader = React.memo(function AppHeader({
56
105
  showSearch,
57
106
  onSearch,
@@ -75,9 +124,36 @@ export const AppHeader = React.memo(function AppHeader({
75
124
  isMobileMenuOpen,
76
125
  onToggleMobileMenu,
77
126
  disabled = false,
127
+ loading = false,
128
+ loadingActionCells,
78
129
  }: AppHeaderProps) {
79
130
  const isMdUp = useMdUp() ?? false;
80
131
 
132
+ // After the only hook above, so the hook order is identical in both branches.
133
+ if (loading) {
134
+ return (
135
+ <AppHeaderSkeleton
136
+ showSearch={showSearch}
137
+ actionCells={
138
+ loadingActionCells ??
139
+ // Fallback: what the `show*` props imply, in the order the live cells are
140
+ // mounted below. Only correct for a host that knows its header composition
141
+ // before it starts loading. The cell types carry each control's own
142
+ // breakpoint — the organization filter is `hidden lg:flex`, the avatar is
143
+ // gated on `isMdUp`, and Mingo is `iconOnly` below md.
144
+ ([
145
+ showOrganizations && 'icon-lg',
146
+ showTimeTracker && 'icon',
147
+ showNotifications && 'icon',
148
+ showUser && 'icon-md',
149
+ showMingoAI && 'wide',
150
+ ].filter(Boolean) as HeaderLoadingCell[])
151
+ }
152
+ className={className}
153
+ />
154
+ );
155
+ }
156
+
81
157
  const dimmedClass = disabled ? 'pointer-events-none opacity-50' : '';
82
158
  // Cells carry their own dividers in the unified TopNavigation model
83
159
  // (the shell no longer applies divide-x).
@@ -268,3 +344,105 @@ function NotificationsHeaderButton({ fallbackUnreadCount, disabled, dimmedClass
268
344
  }
269
345
 
270
346
  export default AppHeader;
347
+
348
+ // Literal class strings — Tailwind's scanner needs to see them spelled out.
349
+ const CELL_VISIBILITY: Record<Exclude<HeaderLoadingCell, 'wide'>, string | undefined> = {
350
+ icon: undefined,
351
+ 'icon-md': 'hidden md:flex',
352
+ 'icon-lg': 'hidden lg:flex',
353
+ };
354
+
355
+ /** One trailing action cell placeholder — mirrors the live cells' 48/56px box. */
356
+ function HeaderCellSkeleton({ className }: { className?: string }) {
357
+ return (
358
+ <div
359
+ className={cn(
360
+ 'flex h-full w-12 shrink-0 items-center justify-center border-l border-ods-border md:w-14',
361
+ className,
362
+ )}
363
+ >
364
+ <div className="h-4 w-4 animate-pulse rounded bg-ods-border md:h-6 md:w-6" />
365
+ </div>
366
+ );
367
+ }
368
+
369
+ /**
370
+ * Wide cell placeholder — mirrors `HeaderMingoButton` at both widths, which are
371
+ * two different shapes: the live button gets `iconOnly={!isMdUp}`, so below md it
372
+ * drops its wordmark and collapses to the same 48px square as any other cell, and
373
+ * from md it is the fixed 140px labelled cell. Reserving 140px-worth of icon,
374
+ * label, gap and padding on a phone would make the placeholder cluster ~70px
375
+ * wider than the header it hands off to.
376
+ */
377
+ function HeaderWideCellSkeleton() {
378
+ return (
379
+ <div className="flex h-full w-12 shrink-0 items-center justify-center gap-2 border-l border-ods-border md:w-[140px] md:px-4">
380
+ <div className="h-4 w-4 shrink-0 animate-pulse rounded bg-ods-border md:h-6 md:w-6" />
381
+ <div className="hidden h-5 animate-pulse rounded bg-ods-border md:block md:w-[72px]" />
382
+ </div>
383
+ );
384
+ }
385
+
386
+ /**
387
+ * `AppHeader`'s loading state — see `AppHeaderProps.loading`.
388
+ *
389
+ * Keep in sync with `AppHeader` above; a diverging placeholder makes the handoff
390
+ * jump. Every breakpoint decision here is a Tailwind variant on purpose: this is the
391
+ * one thing the live header cannot do (its split runs through `useMdUp()`), and it
392
+ * is the whole reason this branch exists.
393
+ */
394
+ function AppHeaderSkeleton({
395
+ showSearch,
396
+ actionCells,
397
+ className,
398
+ }: {
399
+ showSearch?: boolean;
400
+ actionCells: number | ReadonlyArray<HeaderLoadingCell>;
401
+ className?: string;
402
+ }) {
403
+ const cells: ReadonlyArray<HeaderLoadingCell> =
404
+ typeof actionCells === 'number'
405
+ ? Array.from({ length: Math.max(0, actionCells) }, () => 'icon' as const)
406
+ : actionCells;
407
+ return (
408
+ <TopNavigation
409
+ className={cn('sticky top-0 z-40', className)}
410
+ centerBreakpoint="md"
411
+ aria-busy="true"
412
+ leading={
413
+ // Burger cell: mobile only, in CSS.
414
+ <div className="flex h-full w-12 shrink-0 items-center justify-center border-r border-ods-border md:hidden">
415
+ <div className="h-4 w-4 animate-pulse rounded bg-ods-border" />
416
+ </div>
417
+ }
418
+ logo={
419
+ <>
420
+ <div className="h-6 w-6 shrink-0 animate-pulse rounded bg-ods-border" />
421
+ <div className="h-4 w-24 animate-pulse rounded bg-ods-border" />
422
+ </>
423
+ }
424
+ // `md:hidden` belongs on the WRAPPER, not the placeholder inside it:
425
+ // `TopNavigation` builds its padded logo zone whenever `logo` is truthy, and
426
+ // that padding runs to 24px at md and 80px at lg. The live header passes
427
+ // `false` here from md up, so the zone does not exist at all — leaving the
428
+ // wrapper visible would inset the search field by 80px on desktop and shift
429
+ // it back on handoff, which is the exact jump this branch removes.
430
+ logoClassName="gap-2 md:hidden"
431
+ // `TopNavigation` already hides the center zone below `centerBreakpoint`.
432
+ center={showSearch ? <div className="h-10 w-full animate-pulse rounded-md bg-ods-border" /> : undefined}
433
+ sideActions={
434
+ <>
435
+ {/* Mobile-only search trigger; from md the search lives in the center zone. */}
436
+ {showSearch && <HeaderCellSkeleton className="md:hidden" />}
437
+ {cells.map((cell, i) =>
438
+ cell === 'wide' ? (
439
+ <HeaderWideCellSkeleton key={i} />
440
+ ) : (
441
+ <HeaderCellSkeleton key={i} className={CELL_VISIBILITY[cell]} />
442
+ ),
443
+ )}
444
+ </>
445
+ }
446
+ />
447
+ );
448
+ }
@@ -39,6 +39,11 @@ export function HeaderMingoButton({
39
39
  aria-pressed={isActive}
40
40
  className={cn(
41
41
  'flex items-center shrink-0 h-full gap-2 px-4',
42
+ // Fixed 140px cell from md, so the header's right-hand cluster has a
43
+ // known width and a loading placeholder can reserve it exactly
44
+ // (`AppHeaderProps.loadingActionCells` → `'wide'`). Content-width below
45
+ // md, and in `iconOnly` mode, where the control is a square affordance.
46
+ !iconOnly && 'md:w-[140px] md:justify-center',
42
47
  'transition-colors duration-200',
43
48
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent',
44
49
  // Transparent at rest so the cell inherits the bar's background
@@ -22,11 +22,11 @@ export type { SlidingSidebarProps } from './sliding-sidebar'
22
22
  export { StickySectionNav, useSectionNavigation } from './sticky-section-nav'
23
23
  export type { StickyNavSection } from './sticky-section-nav'
24
24
 
25
- export { NavigationSidebar } from './navigation-sidebar'
25
+ export { NAVIGATION_SIDEBAR_WIDTH_VAR, NavigationSidebar } from './navigation-sidebar'
26
26
  export type { NavigationSidebarProps } from './navigation-sidebar'
27
27
 
28
28
  export { AppHeader } from './app-header'
29
- export type { AppHeaderProps } from './app-header'
29
+ export type { AppHeaderProps, HeaderLoadingCell } from './app-header'
30
30
 
31
31
  export { AppLayout, useAppLayoutDrawerContainer } from './app-layout'
32
32
  export type { AppLayoutProps } from './app-layout'
@@ -130,3 +130,25 @@ export const NavigationSidebarItemButton = memo(function NavigationSidebarItemBu
130
130
  </Link>
131
131
  )
132
132
  })
133
+
134
+ /**
135
+ * One placeholder entry, for `NavigationSidebarConfig.loading`.
136
+ *
137
+ * Deliberately in this file, beside `NavigationSidebarItemButton`: the two must
138
+ * agree on `h-14`, the `p-[var(--spacing-system-m)]` inset, the 24px icon box and
139
+ * the label column, or the nav resizes on the handoff. Same reason
140
+ * `header-skeleton.tsx` sits beside `header.tsx`.
141
+ *
142
+ * `showLabel` is the sidebar's own minimized state, so the caller passes what it
143
+ * already passes the real row — no separate width handling.
144
+ */
145
+ export function NavigationSidebarItemSkeleton({ showLabel }: { showLabel: boolean }) {
146
+ return (
147
+ <div className="w-full flex items-center justify-start h-14 p-[var(--spacing-system-m)]" aria-hidden="true">
148
+ <div className="relative flex items-center justify-center flex-shrink-0">
149
+ <div className="h-6 w-6 animate-pulse rounded bg-ods-border" />
150
+ </div>
151
+ {showLabel && <div className="h-4 flex-1 ml-[var(--spacing-system-xs)] animate-pulse rounded bg-ods-border" />}
152
+ </div>
153
+ )
154
+ }
@@ -30,12 +30,16 @@ export function NavigationSidebarToggle({
30
30
  aria-label={"Hide Menu"}
31
31
  >
32
32
  <div className="flex items-center justify-center flex-shrink-0">
33
- <Chevrons03LeftIcon
34
- className={cn(
35
- "text-ods-text-secondary w-6 h-6 transition-transform duration-300",
36
- minimized && "rotate-180",
37
- )}
38
- />
33
+ {/* Direction follows the rail's measured width (the `of-nav-sidebar`
34
+ container declared in `navigation-sidebar.tsx`), not `minimized`:
35
+ before hydration that prop can only carry the server's answer, and
36
+ a chevron pointing the wrong way in a 56px rail is the one piece of
37
+ sidebar state a user actually notices arriving late.
38
+
39
+ Collapsed is the default because container queries are min-width —
40
+ the rail is narrower than every threshold, so it needs no query of
41
+ its own. */}
42
+ <Chevrons03LeftIcon className="rotate-180 @[140px]/of-nav-sidebar:rotate-0 text-ods-text-secondary w-6 h-6 transition-transform duration-300" />
39
43
  </div>
40
44
 
41
45
  <span
@@ -1,18 +1,115 @@
1
1
  "use client"
2
2
 
3
- import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
+ import { useIsomorphicLayoutEffect } from '../../hooks/ui/use-isomorphic-layout-effect'
4
5
  import { useLocalStorage } from '../../hooks/ui/use-local-storage'
5
6
  import { useLgUp, useMdUp } from '../../hooks/ui/use-media-query'
6
7
  import { NavigationSidebarConfig, NavigationSidebarItem } from '../../types/navigation'
7
8
  import { cn } from '../../utils'
8
9
  import { NavigationSidebarHeader } from './navigation-sidebar-header'
9
- import { NavigationSidebarItemButton } from './navigation-sidebar-item'
10
+ import { NavigationSidebarItemButton, NavigationSidebarItemSkeleton } from './navigation-sidebar-item'
10
11
  import { NavigationSidebarToggle } from './navigation-sidebar-toggle'
11
12
 
12
13
  const MINIMIZED_WIDTH = 56 // 3.5rem = 56px
13
14
  const EXPANDED_WIDTH = 224 // 14rem = 224px
14
15
  const STORAGE_KEY = 'of.navigationSidebar.minimized'
15
16
 
17
+ /**
18
+ * The desktop width, as a custom property on `:root`. Read by the `lg:` rule in
19
+ * {@link SIDEBAR_GEOMETRY_CLASSES} and written by this component whenever the
20
+ * user toggles — but NEVER during render.
21
+ *
22
+ * It has to be a global variable rather than an inline style because of WHEN the
23
+ * value is knowable. The width follows a preference in `localStorage`, which the
24
+ * server cannot read: any markup derived from it differs between the server's
25
+ * HTML and the client's hydration render, and React tears the tree down and
26
+ * regenerates it. Seeding this property from a tiny script in `<head>` moves the
27
+ * preference OUT of React's rendered output — the markup becomes identical on
28
+ * both sides, while the width is already correct on the very first paint.
29
+ *
30
+ * Consumers are expected to seed it before first paint; unseeded, the fallback
31
+ * in the class below paints the expanded width until this component's effect
32
+ * catches up, which is the flash the seed exists to remove.
33
+ */
34
+ export const NAVIGATION_SIDEBAR_WIDTH_VAR = '--of-navigation-sidebar-width'
35
+
36
+ /**
37
+ * The rail as a query container, so the parts of the sidebar that follow its
38
+ * collapsed state can read that state off the rail's own width — currently the
39
+ * collapse chevron's direction, in `navigation-sidebar-toggle.tsx`.
40
+ *
41
+ * There is no second source of truth for "is it collapsed", and that is the
42
+ * point: the width already has to be correct on the first paint and at every
43
+ * breakpoint, so anything derived from it is too — the seeded desktop
44
+ * preference, the tablet rail and an overlay opened on tablet all come along for
45
+ * free. A parallel copy would have to be kept in step with each of the three
46
+ * separately, and the custom property that used to live here fell out of step on
47
+ * the last one: it could not see the tablet rail, so that case got its own media
48
+ * query, which in turn could not see an open overlay and pointed the chevron
49
+ * backwards under it.
50
+ *
51
+ * Queried as `@[140px]/of-nav-sidebar:` — 140px is the midpoint between the 56px
52
+ * rail and the 224px expanded sidebar, so the width only crosses it mid-toggle.
53
+ * Container queries are min-width, so a follower styles its COLLAPSED look as the
54
+ * default and lets the query take it back. Written out literally at each use
55
+ * rather than interpolated from a constant: Tailwind scans source text, and a
56
+ * class it cannot see spelled out is a class it never generates.
57
+ */
58
+ const SIDEBAR_CONTAINER = '@container/of-nav-sidebar'
59
+
60
+ /**
61
+ * Where the sidebar sits and how wide it is, per breakpoint — as literal classes,
62
+ * because this cannot be decided in JavaScript in time.
63
+ *
64
+ * `useMdUp`/`useLgUp` answer `undefined` until an effect has run, so `isTablet`
65
+ * is false on the first render — the SERVER's render included. A tablet
66
+ * therefore received the desktop branch in its HTML (`relative`, `width:224px`,
67
+ * an inline style no class could outrank) and only snapped to the 56px rail once
68
+ * hydration and an effect had both completed. That is the wide sidebar that
69
+ * flashed on every load, and no amount of JS could fix it: the server has no
70
+ * viewport to consult, so the answer has to be deferred to the one consumer that
71
+ * always knows — the browser's own media evaluation.
72
+ *
73
+ * The state that a viewport CANNOT determine still lives outside the render: the
74
+ * persisted desktop preference reaches the `lg:` rule through
75
+ * {@link NAVIGATION_SIDEBAR_WIDTH_VAR}. An inline width would be simpler than a
76
+ * custom property, but it would also override the tablet rail on the very first
77
+ * paint, which is the bug being fixed.
78
+ *
79
+ * The tablet WIDTH is deliberately not here — see {@link SIDEBAR_TABLET_WIDTH}.
80
+ *
81
+ * Widths are `w-14`/`w-56` — the class equivalents of `MINIMIZED_WIDTH` and
82
+ * `EXPANDED_WIDTH`, as is the `14rem` fallback below. Keep them in step; a class
83
+ * string cannot interpolate a constant.
84
+ */
85
+ const SIDEBAR_GEOMETRY_CLASSES = [
86
+ // Tablet: float over the content, anchored to the layout row (`absolute`
87
+ // within AppLayout's `relative` row) — NOT the viewport — so an optional
88
+ // `topBar` above the row is not overlapped. With no topBar the row spans the
89
+ // full viewport, so this is visually identical to a viewport-fixed sidebar.
90
+ 'md:absolute md:inset-y-0 md:left-0 md:z-[45]',
91
+ // Desktop: back in the flex flow, width from the persisted preference. The
92
+ // fallback IS the default, so the property needs no global declaration and an
93
+ // unseeded consumer gets the full sidebar rather than a rail with no labels.
94
+ 'lg:relative lg:inset-auto lg:z-auto lg:h-full lg:w-[var(--of-navigation-sidebar-width,14rem)]',
95
+ ].join(' ')
96
+
97
+ /**
98
+ * The tablet width, as ONE `md:` class rather than a rail plus an override.
99
+ *
100
+ * Tablet is a 56px rail by design and only widens for an overlay the user has
101
+ * opened — state no viewport can imply, so it has to come from JS. Written as
102
+ * `md:w-14` + `max-lg:w-56` the two rules both match between md and lg at equal
103
+ * specificity, and Tailwind emits the `max-lg` block BEFORE the `md` one, so
104
+ * source order hands the tablet straight back to the rail. Picking one class
105
+ * removes the contest instead of trying to win it — and keeps the `!important`
106
+ * that winning it would need out of the tree.
107
+ *
108
+ * `lg:` still governs desktop either way: it is emitted after `md:`, so an
109
+ * overlay left open while the window widens does not leak into the desktop width.
110
+ */
111
+ const SIDEBAR_TABLET_WIDTH = { rail: 'md:w-14', overlay: 'md:w-56' } as const
112
+
16
113
  /** A click the browser will resolve itself — new tab, new window, middle button. */
17
114
  const isModifiedClick = (event?: React.MouseEvent): boolean =>
18
115
  !!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
@@ -47,7 +144,22 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
47
144
  if (isTablet) setTabletMinimized(true)
48
145
  }, [isTablet])
49
146
 
50
- const minimized = isTablet ? tabletMinimized : desktopMinimized
147
+ // `useLocalStorage` reads the store SYNCHRONOUSLY in its initializer, so on the
148
+ // hydration render it already knows a preference the server could not. Anything
149
+ // derived from it — labels, `aria-hidden`, the chevron, the placeholder rows —
150
+ // then differs from the server's HTML, and React throws out the whole tree and
151
+ // regenerates it. So until hydration is done, this renders the server's answer
152
+ // and nothing else.
153
+ //
154
+ // That costs nothing visually. The width is already correct at that point (it
155
+ // comes from the seeded CSS var, not from here), and at the minimized width
156
+ // every label is a `flex-1` in a rail with no room to give — zero-wide whatever
157
+ // this says. What flips on the next commit is opacity and margin on boxes that
158
+ // were never visible.
159
+ const [hydrated, setHydrated] = useState(false)
160
+ useEffect(() => setHydrated(true), [])
161
+
162
+ const minimized = hydrated ? (isTablet ? tabletMinimized : desktopMinimized) : (config.minimized ?? false)
51
163
 
52
164
  // Enable transitions only after the correct width is painted
53
165
  const [transitionsEnabled, setTransitionsEnabled] = useState(false)
@@ -147,11 +259,35 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
147
259
  secondaryItems: config.items.filter(item => item.section === 'secondary'),
148
260
  }), [config.items])
149
261
 
262
+ // Placeholder rows while the host cannot know its entries yet — see
263
+ // `NavigationSidebarConfig.loading`. Only the ROWS are stood in for: the header,
264
+ // the collapse toggle, the widths and the tablet overlay all behave exactly as
265
+ // when loaded, so this is the loaded rail with unknown contents rather than a
266
+ // separate skeleton sidebar to keep in sync.
267
+ const loadingRows = useMemo(() => {
268
+ if (!config.loading) return null
269
+ const primary = config.loadingRows?.primary ?? 7
270
+ const secondary = config.loadingRows?.secondary ?? 2
271
+ return {
272
+ primary: Array.from({ length: Math.max(0, primary) }, (_, i) => `primary-${i}`),
273
+ secondary: Array.from({ length: Math.max(0, secondary) }, (_, i) => `secondary-${i}`),
274
+ }
275
+ }, [config.loading, config.loadingRows?.primary, config.loadingRows?.secondary])
276
+
150
277
  const sidebarWidth = useMemo(
151
278
  () => (minimized ? `${MINIMIZED_WIDTH}px` : `${EXPANDED_WIDTH}px`),
152
279
  [minimized],
153
280
  )
154
281
 
282
+ // Published to CSS from an EFFECT, never from render. Before hydration the
283
+ // width belongs to whatever seeded `NAVIGATION_SIDEBAR_WIDTH_VAR` in `<head>`
284
+ // — put it in the markup instead and it becomes part of what React hydrates,
285
+ // which is precisely what cannot agree across the server boundary. Afterwards
286
+ // this is what a toggle moves.
287
+ useIsomorphicLayoutEffect(() => {
288
+ document.documentElement.style.setProperty(NAVIGATION_SIDEBAR_WIDTH_VAR, sidebarWidth)
289
+ }, [sidebarWidth])
290
+
155
291
  // There used to be an `isHydrated` gate here — `isMdUp !== undefined && ...`
156
292
  // — meant to hold the sidebar's contents back until the media queries
157
293
  // resolved. It could never be false: the `?? false` above had already
@@ -159,7 +295,7 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
159
295
  // because repairing it would be the regression: the contents are server-
160
296
  // rendered today, and gating them behind a client-only media query would
161
297
  // trade a first paint of the real navigation for an empty rail.
162
- useLayoutEffect(() => {
298
+ useIsomorphicLayoutEffect(() => {
163
299
  if (!transitionsEnabled) {
164
300
  const id = requestAnimationFrame(() => {
165
301
  setTransitionsEnabled(true)
@@ -183,61 +319,68 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
183
319
  />
184
320
 
185
321
  {/* Flex-flow placeholder — reserves the collapsed 56px slot on tablet so
186
- the main content keeps its position while the sidebar floats above it */}
187
- {isTablet && (
188
- <div
189
- className="h-full hidden md:block flex-shrink-0"
190
- style={{ width: `${MINIMIZED_WIDTH}px` }}
191
- aria-hidden="true"
192
- />
193
- )}
322
+ the main content keeps its position while the sidebar floats above it.
323
+ Rendered unconditionally and scoped by classes for the same reason the
324
+ geometry is: gated on `isTablet`, it was missing from the server HTML
325
+ and from the first client paint — exactly when the sidebar it
326
+ compensates for has already gone `absolute`. */}
327
+ <div className="h-full hidden md:block lg:hidden w-14 flex-shrink-0" aria-hidden="true" />
194
328
 
195
329
  <aside
196
330
  className={cn(
331
+ SIDEBAR_CONTAINER,
197
332
  "flex-col hidden md:flex flex-shrink-0",
198
333
  "bg-ods-card border-r border-ods-border",
199
- // Tablet: float the sidebar over content. Anchored to the layout row
200
- // (`absolute` within AppLayout's `relative` row) — NOT the viewport —
201
- // so an optional `topBar` above the row is not overlapped. With no
202
- // topBar the row spans the full viewport, so this is visually
203
- // identical to a viewport-fixed sidebar.
204
- isTablet ? "absolute inset-y-0 left-0 z-[45]" : "relative h-full",
334
+ SIDEBAR_GEOMETRY_CLASSES,
335
+ // The one width a viewport cannot imply: an overlay the user opened on
336
+ // tablet. Only ever true after a click, so the first paint always gets
337
+ // the rail.
338
+ isOverlayOpen ? SIDEBAR_TABLET_WIDTH.overlay : SIDEBAR_TABLET_WIDTH.rail,
205
339
  transitionsEnabled && "transition-[width] duration-300",
206
340
  config.className,
207
341
  )}
208
- style={{ width: sidebarWidth }}
209
342
  aria-label="Main navigation sidebar"
210
343
  >
211
344
  <NavigationSidebarHeader minimized={minimized} />
212
345
 
213
346
  <div className="flex-1 flex flex-col justify-between py-4 overflow-y-auto">
214
- <nav className="flex flex-col" aria-label="Primary navigation">
215
- {primaryItems.map(item => (
216
- <NavigationSidebarItemButton
217
- key={item.id}
218
- item={item}
219
- isActive={item.id === committedActiveId}
220
- isPending={item.id === pendingItemId}
221
- showLabel={showLabel}
222
- disabled={disabled}
223
- onClick={handleItemClick}
224
- />
225
- ))}
347
+ {/* `aria-busy` on the nav, not the rows: the region is what is loading,
348
+ and each placeholder row is already `aria-hidden`. */}
349
+ <nav className="flex flex-col" aria-label="Primary navigation" aria-busy={!!loadingRows}>
350
+ {loadingRows
351
+ ? loadingRows.primary.map(key => (
352
+ <NavigationSidebarItemSkeleton key={key} showLabel={showLabel} />
353
+ ))
354
+ : primaryItems.map(item => (
355
+ <NavigationSidebarItemButton
356
+ key={item.id}
357
+ item={item}
358
+ isActive={item.id === committedActiveId}
359
+ isPending={item.id === pendingItemId}
360
+ showLabel={showLabel}
361
+ disabled={disabled}
362
+ onClick={handleItemClick}
363
+ />
364
+ ))}
226
365
  </nav>
227
366
 
228
- {secondaryItems.length > 0 && (
229
- <nav className="flex flex-col" aria-label="Secondary navigation">
230
- {secondaryItems.map(item => (
231
- <NavigationSidebarItemButton
232
- key={item.id}
233
- item={item}
234
- isActive={item.id === committedActiveId}
235
- isPending={item.id === pendingItemId}
236
- showLabel={showLabel}
237
- disabled={disabled}
238
- onClick={handleItemClick}
239
- />
240
- ))}
367
+ {(loadingRows ? loadingRows.secondary.length > 0 : secondaryItems.length > 0) && (
368
+ <nav className="flex flex-col" aria-label="Secondary navigation" aria-busy={!!loadingRows}>
369
+ {loadingRows
370
+ ? loadingRows.secondary.map(key => (
371
+ <NavigationSidebarItemSkeleton key={key} showLabel={showLabel} />
372
+ ))
373
+ : secondaryItems.map(item => (
374
+ <NavigationSidebarItemButton
375
+ key={item.id}
376
+ item={item}
377
+ isActive={item.id === committedActiveId}
378
+ isPending={item.id === pendingItemId}
379
+ showLabel={showLabel}
380
+ disabled={disabled}
381
+ onClick={handleItemClick}
382
+ />
383
+ ))}
241
384
  </nav>
242
385
  )}
243
386
  </div>
@@ -32,6 +32,17 @@ export const buttonSurfaceClasses = {
32
32
  export const outlineBorderClasses =
33
33
  "border border-ods-border hover:border-ods-border-hover active:border-ods-border-active disabled:border-ods-border aria-disabled:border-ods-border"
34
34
 
35
+ // Glyph scale for the split-layout button family: 16px below md, 24px from md.
36
+ //
37
+ // Shared because the same page-header action renders through EITHER of the two
38
+ // split layouts depending on its shape — `SplitButton` for two click targets, and
39
+ // `Button` + `splitIcon` for one with a decorative trailing glyph — and the two sit
40
+ // side by side in the same row. They used to disagree: `SplitButton` carried this
41
+ // responsive scale while the `Button` split slots re-declared a flat 20px, so a
42
+ // "submenu" action's icons rendered 4px smaller than its neighbour's on desktop.
43
+ // `Button`'s own flat `[&_svg]:h-5` stays as it is for ordinary (non-split) buttons.
44
+ export const splitGlyphSizeClasses = "[&_svg]:h-4 [&_svg]:w-4 md:[&_svg]:h-6 md:[&_svg]:w-6"
45
+
35
46
  // Color of the vertical seam between the main and icon halves (split layouts).
36
47
  export const splitDividerColorClasses = {
37
48
  accent: "border-ods-accent-active",
@@ -6,7 +6,7 @@ import Link from "../../../embed-shims/next-link"
6
6
  import React from "react"
7
7
 
8
8
  import { cn } from "../../../utils/cn"
9
- import { buttonSurfaceClasses, outlineBorderClasses, splitDividerColorClasses } from "./button-styles"
9
+ import { buttonSurfaceClasses, outlineBorderClasses, splitDividerColorClasses, splitGlyphSizeClasses } from "./button-styles"
10
10
 
11
11
  // Default layout: centered single content area, padding/gap on the button itself.
12
12
  const buttonVariants = cva(
@@ -104,14 +104,17 @@ const splitShellVariants = cva(
104
104
  )
105
105
 
106
106
  const splitSlotVariants = cva(
107
- ["inline-flex items-center justify-center", "[&_svg]:shrink-0 [&_svg]:h-5 [&_svg]:w-5"],
107
+ // Glyph size lives per-size, not in the base: cva concatenates base + variant
108
+ // without tailwind-merge, so a base `[&_svg]:h-5` and a variant `[&_svg]:h-4`
109
+ // would both ship and the winner would come down to stylesheet order.
110
+ ["inline-flex items-center justify-center", "[&_svg]:shrink-0"],
108
111
  {
109
112
  variants: {
110
113
  slot: {
111
114
  main: "gap-[var(--spacing-system-xsf)]",
112
115
  icon: "border-l",
113
116
  },
114
- size: { default: "", small: "[&_svg]:h-4 [&_svg]:w-4" },
117
+ size: { default: splitGlyphSizeClasses, small: "[&_svg]:h-4 [&_svg]:w-4" },
115
118
  variant: { accent: "", outline: "", transparent: "", destructive: "", warning: "" },
116
119
  },
117
120
  compoundVariants: [
@@ -5,7 +5,7 @@ import Link from "../../../embed-shims/next-link"
5
5
  import React from "react"
6
6
 
7
7
  import { cn } from "../../../utils/cn"
8
- import { buttonSurfaceClasses, splitDividerColorClasses } from "./button-styles"
8
+ import { buttonSurfaceClasses, splitDividerColorClasses, splitGlyphSizeClasses } from "./button-styles"
9
9
 
10
10
  // Two independent interactive halves: each a `<button>` or `<a>`. The seam is
11
11
  // a 1px border on the left edge of the icon half, colored per variant. For a
@@ -29,7 +29,7 @@ const splitHalfVariants = cva(splitHalfBase, {
29
29
  warning: buttonSurfaceClasses.warning,
30
30
  },
31
31
  size: {
32
- default: "h-10 md:h-12 px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3 [&_svg]:h-4 [&_svg]:w-4 md:[&_svg]:h-6 md:[&_svg]:w-6",
32
+ default: `h-10 md:h-12 px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3 ${splitGlyphSizeClasses}`,
33
33
  small: "h-6 md:h-8 px-[var(--spacing-system-xs)] text-h5 [&_svg]:h-3 [&_svg]:w-3 md:[&_svg]:h-4 md:[&_svg]:w-4",
34
34
  },
35
35
  side: { main: "", icon: "" },