@open-mercato/ui 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (214) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -2
  3. package/agentic/standalone-guide.md +1 -1
  4. package/build.mjs +2 -54
  5. package/dist/ai/AiChat.js +26 -7
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/LoopDisabledBanner.js +1 -1
  8. package/dist/ai/LoopDisabledBanner.js.map +2 -2
  9. package/dist/ai/parts/ConfirmationCard.js +4 -4
  10. package/dist/ai/parts/ConfirmationCard.js.map +2 -2
  11. package/dist/ai/parts/FieldDiffCard.js +2 -2
  12. package/dist/ai/parts/FieldDiffCard.js.map +2 -2
  13. package/dist/ai/parts/MutationResultCard.js +3 -3
  14. package/dist/ai/parts/MutationResultCard.js.map +2 -2
  15. package/dist/ai/ui-parts/pending-phase3-placeholder.js +1 -1
  16. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
  17. package/dist/backend/AppShell.js +43 -4
  18. package/dist/backend/AppShell.js.map +2 -2
  19. package/dist/backend/AuthSessionGuard.js +14 -0
  20. package/dist/backend/AuthSessionGuard.js.map +2 -2
  21. package/dist/backend/BackendChromeProvider.js +6 -1
  22. package/dist/backend/BackendChromeProvider.js.map +2 -2
  23. package/dist/backend/CrudForm.js +6 -4
  24. package/dist/backend/CrudForm.js.map +2 -2
  25. package/dist/backend/DataTable.js +20 -18
  26. package/dist/backend/DataTable.js.map +2 -2
  27. package/dist/backend/JsonBuilder.js +3 -3
  28. package/dist/backend/JsonBuilder.js.map +2 -2
  29. package/dist/backend/NextStepCallout.js +4 -4
  30. package/dist/backend/NextStepCallout.js.map +1 -1
  31. package/dist/backend/PerspectiveSidebar.js +1 -1
  32. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  33. package/dist/backend/ProfileDropdown.js +2 -1
  34. package/dist/backend/ProfileDropdown.js.map +2 -2
  35. package/dist/backend/RowActions.js +2 -2
  36. package/dist/backend/RowActions.js.map +2 -2
  37. package/dist/backend/confirm-dialog/ConfirmDialog.js +13 -1
  38. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  39. package/dist/backend/crud/useGroupOrder.js +14 -21
  40. package/dist/backend/crud/useGroupOrder.js.map +2 -2
  41. package/dist/backend/custom-fields/FieldDefinitionsManager.js +1 -1
  42. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +2 -2
  43. package/dist/backend/dashboard/DashboardScreen.js +3 -3
  44. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  45. package/dist/backend/detail/AddressTiles.js +4 -5
  46. package/dist/backend/detail/AddressTiles.js.map +2 -2
  47. package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
  48. package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
  49. package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
  50. package/dist/backend/detail/AttachmentsSection.js +1 -1
  51. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  52. package/dist/backend/detail/NotesSection.js +1 -1
  53. package/dist/backend/detail/NotesSection.js.map +2 -2
  54. package/dist/backend/detail/TagsSection.js +1 -1
  55. package/dist/backend/detail/TagsSection.js.map +2 -2
  56. package/dist/backend/filters/AdvancedFilterPanel.js +1 -1
  57. package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
  58. package/dist/backend/forms/FormHeader.js +1 -2
  59. package/dist/backend/forms/FormHeader.js.map +2 -2
  60. package/dist/backend/icons/lucideRegistry.generated.js +1 -25
  61. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  62. package/dist/backend/icons/lucideRegistry.js +3 -3
  63. package/dist/backend/icons/lucideRegistry.js.map +2 -2
  64. package/dist/backend/icons/lucideRegistryRuntime.js +28 -0
  65. package/dist/backend/icons/lucideRegistryRuntime.js.map +7 -0
  66. package/dist/backend/injection/spotIds.js +23 -18
  67. package/dist/backend/injection/spotIds.js.map +2 -2
  68. package/dist/backend/inputs/ComboboxInput.js.map +1 -1
  69. package/dist/backend/messages/EmailThreadsPanel.js +1 -1
  70. package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
  71. package/dist/backend/perspectiveState.js +34 -0
  72. package/dist/backend/perspectiveState.js.map +7 -0
  73. package/dist/backend/schedule/ScheduleAgenda.js +3 -3
  74. package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
  75. package/dist/backend/schedule/ScheduleGrid.js +3 -3
  76. package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
  77. package/dist/backend/schedule/ScheduleToolbar.js +87 -70
  78. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  79. package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -2
  80. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  81. package/dist/backend/sidebar/customization-helpers.js +30 -0
  82. package/dist/backend/sidebar/customization-helpers.js.map +2 -2
  83. package/dist/backend/utils/apiCall.js +21 -1
  84. package/dist/backend/utils/apiCall.js.map +2 -2
  85. package/dist/backend/utils/customFieldDefs.js.map +2 -2
  86. package/dist/backend/utils/customFieldForms.js +10 -9
  87. package/dist/backend/utils/customFieldForms.js.map +2 -2
  88. package/dist/backend/utils/fetchAllPages.js +57 -0
  89. package/dist/backend/utils/fetchAllPages.js.map +7 -0
  90. package/dist/backend/utils/nav.js +5 -2
  91. package/dist/backend/utils/nav.js.map +2 -2
  92. package/dist/backend/version-history/VersionHistoryPanel.js +1 -1
  93. package/dist/backend/version-history/VersionHistoryPanel.js.map +2 -2
  94. package/dist/frontend/LanguageSwitcher.js +2 -1
  95. package/dist/frontend/LanguageSwitcher.js.map +2 -2
  96. package/dist/portal/PortalContext.js +1 -1
  97. package/dist/portal/PortalContext.js.map +2 -2
  98. package/dist/portal/hooks/portalBridgeStatus.js +25 -0
  99. package/dist/portal/hooks/portalBridgeStatus.js.map +7 -0
  100. package/dist/portal/hooks/usePortalEventBridge.js +6 -0
  101. package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
  102. package/dist/portal/hooks/usePortalNotifications.js +42 -4
  103. package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
  104. package/dist/portal/utils/nav.js +2 -3
  105. package/dist/portal/utils/nav.js.map +2 -2
  106. package/dist/primitives/ErrorNotice.js +1 -1
  107. package/dist/primitives/ErrorNotice.js.map +2 -2
  108. package/dist/primitives/Notice.js +1 -1
  109. package/dist/primitives/Notice.js.map +1 -1
  110. package/dist/primitives/button.js +9 -4
  111. package/dist/primitives/button.js.map +2 -2
  112. package/dist/theme/ThemeProvider.js +1 -1
  113. package/dist/theme/ThemeProvider.js.map +2 -2
  114. package/dist/theme/index.js +3 -0
  115. package/dist/theme/index.js.map +2 -2
  116. package/dist/theme/theme-init-script.js +17 -0
  117. package/dist/theme/theme-init-script.js.map +7 -0
  118. package/figma/alert.figma.tsx +35 -0
  119. package/figma/badge.figma.tsx +33 -0
  120. package/figma/button.figma.tsx +33 -0
  121. package/figma/checkbox.figma.tsx +28 -0
  122. package/figma/drawer.figma.tsx +40 -0
  123. package/figma/input-variants.figma.tsx +58 -0
  124. package/figma/radio.figma.tsx +16 -0
  125. package/figma/select.figma.tsx +24 -0
  126. package/figma/status-badge.figma.tsx +24 -0
  127. package/figma/switch.figma.tsx +28 -0
  128. package/figma/tabs.figma.tsx +22 -0
  129. package/figma/tag.figma.tsx +27 -0
  130. package/figma.config.json +6 -0
  131. package/jest.config.cjs +2 -1
  132. package/package.json +21 -18
  133. package/scripts/lucideRegistrySource.cjs +133 -0
  134. package/src/ai/AiChat.tsx +36 -7
  135. package/src/ai/LoopDisabledBanner.tsx +1 -1
  136. package/src/ai/__tests__/AiChat.test.tsx +31 -0
  137. package/src/ai/parts/ConfirmationCard.tsx +4 -4
  138. package/src/ai/parts/FieldDiffCard.tsx +2 -2
  139. package/src/ai/parts/MutationResultCard.tsx +3 -3
  140. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +2 -2
  141. package/src/backend/AppShell.tsx +55 -4
  142. package/src/backend/AuthSessionGuard.tsx +29 -0
  143. package/src/backend/BackendChromeProvider.tsx +19 -1
  144. package/src/backend/CrudForm.tsx +18 -4
  145. package/src/backend/DataTable.tsx +36 -19
  146. package/src/backend/JsonBuilder.tsx +3 -3
  147. package/src/backend/NextStepCallout.tsx +4 -4
  148. package/src/backend/PerspectiveSidebar.tsx +1 -1
  149. package/src/backend/ProfileDropdown.tsx +1 -0
  150. package/src/backend/RowActions.tsx +2 -2
  151. package/src/backend/__tests__/AppShell.test.tsx +65 -7
  152. package/src/backend/__tests__/AuthSessionGuard.test.tsx +52 -0
  153. package/src/backend/__tests__/BackendChromeProvider.test.tsx +98 -2
  154. package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +158 -0
  155. package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +98 -0
  156. package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +105 -0
  157. package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +62 -1
  158. package/src/backend/__tests__/nav-utils.test.ts +139 -0
  159. package/src/backend/confirm-dialog/ConfirmDialog.tsx +16 -1
  160. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +68 -1
  161. package/src/backend/crud/__tests__/useGroupOrder.test.ts +39 -1
  162. package/src/backend/crud/useGroupOrder.ts +30 -25
  163. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +1 -1
  164. package/src/backend/dashboard/DashboardScreen.tsx +3 -3
  165. package/src/backend/detail/AddressTiles.tsx +3 -6
  166. package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
  167. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  168. package/src/backend/detail/AttachmentsSection.tsx +1 -1
  169. package/src/backend/detail/NotesSection.tsx +1 -1
  170. package/src/backend/detail/TagsSection.tsx +1 -1
  171. package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +21 -0
  172. package/src/backend/filters/AdvancedFilterPanel.tsx +1 -1
  173. package/src/backend/forms/FormHeader.tsx +1 -2
  174. package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +177 -0
  175. package/src/backend/icons/lucideRegistry.generated.tsx +3 -37
  176. package/src/backend/icons/lucideRegistry.ts +2 -2
  177. package/src/backend/icons/lucideRegistryRuntime.tsx +36 -0
  178. package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +38 -0
  179. package/src/backend/injection/spotIds.ts +23 -18
  180. package/src/backend/inputs/ComboboxInput.tsx +1 -1
  181. package/src/backend/messages/EmailThreadsPanel.tsx +1 -1
  182. package/src/backend/perspectiveState.ts +44 -0
  183. package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
  184. package/src/backend/schedule/ScheduleGrid.tsx +3 -3
  185. package/src/backend/schedule/ScheduleToolbar.tsx +89 -68
  186. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +42 -3
  187. package/src/backend/sidebar/__tests__/group-visibility.test.ts +131 -0
  188. package/src/backend/sidebar/customization-helpers.ts +47 -0
  189. package/src/backend/utils/__tests__/apiCall.test.ts +61 -0
  190. package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +95 -0
  191. package/src/backend/utils/__tests__/fetchAllPages.test.ts +103 -0
  192. package/src/backend/utils/apiCall.ts +29 -1
  193. package/src/backend/utils/customFieldDefs.ts +1 -0
  194. package/src/backend/utils/customFieldForms.ts +15 -11
  195. package/src/backend/utils/fetchAllPages.ts +93 -0
  196. package/src/backend/utils/nav.ts +22 -2
  197. package/src/backend/version-history/VersionHistoryPanel.tsx +1 -1
  198. package/src/frontend/LanguageSwitcher.tsx +1 -0
  199. package/src/portal/PortalContext.tsx +1 -1
  200. package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +66 -0
  201. package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +155 -0
  202. package/src/portal/hooks/portalBridgeStatus.ts +29 -0
  203. package/src/portal/hooks/usePortalEventBridge.ts +6 -0
  204. package/src/portal/hooks/usePortalNotifications.ts +55 -12
  205. package/src/portal/utils/__tests__/nav.test.ts +2 -2
  206. package/src/portal/utils/nav.ts +3 -4
  207. package/src/primitives/ErrorNotice.tsx +1 -1
  208. package/src/primitives/Notice.tsx +5 -5
  209. package/src/primitives/__tests__/button.test.tsx +70 -0
  210. package/src/primitives/button.tsx +10 -4
  211. package/src/theme/ThemeProvider.tsx +1 -2
  212. package/src/theme/__tests__/theme-init-script.test.ts +70 -0
  213. package/src/theme/index.ts +1 -0
  214. package/src/theme/theme-init-script.ts +23 -0
@@ -2,6 +2,11 @@
2
2
  import * as React from 'react'
3
3
  import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
4
4
  import { apiCall } from '../../backend/utils/apiCall'
5
+ import {
6
+ PORTAL_BRIDGE_STATUS_DOM_NAME,
7
+ readPortalBridgeHealth,
8
+ type PortalBridgeStatusDetail,
9
+ } from './portalBridgeStatus'
5
10
 
6
11
  export type UsePortalNotificationsResult = {
7
12
  notifications: NotificationDto[]
@@ -28,13 +33,11 @@ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T | null>
28
33
  }
29
34
 
30
35
  /**
31
- * Portal notification hook polls customer notification endpoints.
36
+ * Portal notification hook with SSE-first delivery and polling fallback.
32
37
  *
33
- * Fetches notifications from `/api/customer_accounts/portal/notifications`
34
- * and unread count from `.../unread-count`. Polls every 8 seconds.
35
- *
36
- * Also listens for portal SSE events (`notifications.notification.created`)
37
- * to trigger immediate refresh.
38
+ * Performs an initial list/count reconciliation, refreshes on notification,
39
+ * reconnect, and focus events, and polls every 8 seconds until the portal
40
+ * event bridge explicitly reports a healthy connection.
38
41
  */
39
42
  export function usePortalNotifications(): UsePortalNotificationsResult {
40
43
  const [notifications, setNotifications] = React.useState<NotificationDto[]>([])
@@ -66,18 +69,50 @@ export function usePortalNotifications(): UsePortalNotificationsResult {
66
69
  setIsLoading(false)
67
70
  }, [])
68
71
 
69
- // Poll
72
+ const [usePolling, setUsePolling] = React.useState(() => {
73
+ if (typeof window === 'undefined' || !('EventSource' in window)) {
74
+ return true
75
+ }
76
+ return readPortalBridgeHealth() !== true
77
+ })
78
+
79
+ React.useEffect(() => {
80
+ if (typeof window === 'undefined' || !('EventSource' in window)) {
81
+ return
82
+ }
83
+ const handleStatusChange = (event: Event) => {
84
+ const detail = (event as CustomEvent<PortalBridgeStatusDetail>).detail
85
+ if (detail && typeof detail.healthy === 'boolean') {
86
+ setUsePolling(!detail.healthy)
87
+ if (!detail.healthy) {
88
+ fetchAll()
89
+ }
90
+ }
91
+ }
92
+ window.addEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange)
93
+ return () => {
94
+ window.removeEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange)
95
+ }
96
+ }, [fetchAll])
97
+
70
98
  React.useEffect(() => {
71
99
  fetchAll()
100
+ }, [fetchAll])
101
+
102
+ React.useEffect(() => {
103
+ if (!usePolling) return
72
104
  const interval = setInterval(fetchAll, POLL_INTERVAL)
73
105
  return () => clearInterval(interval)
74
- }, [fetchAll])
106
+ }, [fetchAll, usePolling])
75
107
 
76
- // Listen for portal SSE notification events
77
108
  React.useEffect(() => {
78
- const handler = (e: Event) => {
79
- const detail = (e as CustomEvent).detail
80
- if (detail?.id === 'notifications.notification.created' || detail?.id === 'notifications.notification.batch_created') {
109
+ const handler = (event: Event) => {
110
+ const detail = (event as CustomEvent<{ id?: string }>).detail
111
+ if (
112
+ detail?.id === 'notifications.notification.created' ||
113
+ detail?.id === 'notifications.notification.batch_created' ||
114
+ detail?.id === 'om:portal-bridge:reconnected'
115
+ ) {
81
116
  fetchAll()
82
117
  }
83
118
  }
@@ -85,6 +120,14 @@ export function usePortalNotifications(): UsePortalNotificationsResult {
85
120
  return () => window.removeEventListener('om:portal-event', handler)
86
121
  }, [fetchAll])
87
122
 
123
+ React.useEffect(() => {
124
+ const onFocus = () => {
125
+ fetchAll()
126
+ }
127
+ window.addEventListener('focus', onFocus)
128
+ return () => window.removeEventListener('focus', onFocus)
129
+ }, [fetchAll])
130
+
88
131
  const markAsRead = React.useCallback(async (id: string) => {
89
132
  await fetchJson(`${BASE}/${id}/read`, { method: 'PUT' })
90
133
  setNotifications((prev) =>
@@ -97,7 +97,7 @@ describe('buildPortalNav', () => {
97
97
  expect(groups).toEqual([{ id: 'main', items: [expect.objectContaining({ label: 'Orders' })] }])
98
98
  })
99
99
 
100
- it('bypasses feature checks when isPortalAdmin is true', () => {
100
+ it('does not let the deprecated portal-admin flag bypass effective features', () => {
101
101
  const routes: FrontendRouteManifestEntry[] = [
102
102
  makeRoute({
103
103
  pattern: '/[orgSlug]/portal/orders',
@@ -107,7 +107,7 @@ describe('buildPortalNav', () => {
107
107
  ]
108
108
 
109
109
  const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [], isPortalAdmin: true })
110
- expect(groups[0].items[0].label).toBe('Orders')
110
+ expect(groups).toEqual([])
111
111
  })
112
112
 
113
113
  it('ignores navHidden pages even when nav is declared', () => {
@@ -22,9 +22,9 @@ export type BuildPortalNavOptions = {
22
22
  routes: readonly FrontendRouteManifestEntry[]
23
23
  /** Current customer org slug — substituted into `[orgSlug]` patterns. */
24
24
  orgSlug: string
25
- /** Feature strings granted to the current customer (may include wildcards). */
25
+ /** Concrete effective features granted to the current customer. */
26
26
  grantedFeatures: readonly string[]
27
- /** If true, bypass feature checks (portal admin). Defaults to false. */
27
+ /** @deprecated Portal admins must receive concrete effective features. */
28
28
  isPortalAdmin?: boolean
29
29
  }
30
30
 
@@ -75,7 +75,6 @@ export function buildPortalNav({
75
75
  routes,
76
76
  orgSlug,
77
77
  grantedFeatures,
78
- isPortalAdmin = false,
79
78
  }: BuildPortalNavOptions): PortalNavGroup[] {
80
79
  const mainItems: PortalNavItem[] = []
81
80
  const accountItems: PortalNavItem[] = []
@@ -96,7 +95,7 @@ export function buildPortalNav({
96
95
  const nav = route.nav!
97
96
 
98
97
  const requireFeatures = route.requireCustomerFeatures ?? []
99
- if (!isPortalAdmin && requireFeatures.length) {
98
+ if (requireFeatures.length) {
100
99
  if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue
101
100
  }
102
101
 
@@ -12,7 +12,7 @@ export function ErrorNotice({ title, message, action }: {
12
12
  const resolvedTitle = title ?? t('ui.errors.defaultTitle', 'Something went wrong')
13
13
  const resolvedMessage = message ?? t('ui.errors.defaultMessage', 'Unable to load data. Please try again.')
14
14
  return (
15
- <Alert variant="destructive">
15
+ <Alert status="error">
16
16
  <AlertTitle>{resolvedTitle}</AlertTitle>
17
17
  <AlertDescription>{resolvedMessage}</AlertDescription>
18
18
  {action ? <div className="mt-2">{action}</div> : null}
@@ -6,12 +6,12 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
6
6
  const logger = createLogger('ui')
7
7
 
8
8
  /**
9
- * @deprecated Use <Alert variant="destructive|warning|info"> instead.
9
+ * @deprecated Use <Alert status="error|warning|information"> instead.
10
10
  * Migration guide: docs/design-system/migration-tables.md#j3-component-mapping
11
11
  *
12
- * Notice variant="error" → Alert variant="destructive"
13
- * Notice variant="warning" → Alert variant="warning"
14
- * Notice variant="info" → Alert variant="info"
12
+ * Notice variant="error" → Alert status="error"
13
+ * Notice variant="warning" → Alert status="warning"
14
+ * Notice variant="info" → Alert status="information"
15
15
  */
16
16
 
17
17
  const variantStyles = {
@@ -51,7 +51,7 @@ export function Notice({
51
51
  compact = false,
52
52
  }: NoticeProps) {
53
53
  if (process.env.NODE_ENV === 'development') {
54
- logger.warn('<Notice> is deprecated. Use <Alert variant="destructive|warning|info"> instead. See: docs/design-system/migration-tables.md')
54
+ logger.warn('<Notice> is deprecated. Use <Alert status="error|warning|information"> instead. See: docs/design-system/migration-tables.md')
55
55
  }
56
56
 
57
57
  const styles = variantStyles[variant]
@@ -0,0 +1,70 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render } from '@testing-library/react'
5
+ import { Button, buttonVariants } from '../button'
6
+
7
+ type ButtonVariant = NonNullable<Parameters<typeof buttonVariants>[0]>['variant']
8
+
9
+ function classesOf(variant: ButtonVariant) {
10
+ return buttonVariants({ variant }).split(/\s+/).filter(Boolean)
11
+ }
12
+
13
+ describe('Button destructive variants', () => {
14
+ // The DS policy: `destructive` is the quiet trigger (red text on a calm
15
+ // surface) so a page full of delete buttons does not train users to ignore
16
+ // red; `destructive-solid` is the filled form, reserved for the single
17
+ // point-of-no-return confirmation inside a dialog. These tests pin both
18
+ // halves of that contract — swapping them back is a visual regression that
19
+ // no other test would catch.
20
+
21
+ it('destructive is quiet: destructive border and text on the page surface', () => {
22
+ const classes = classesOf('destructive')
23
+ expect(classes).toContain('border')
24
+ expect(classes).toContain('border-destructive')
25
+ expect(classes).toContain('bg-background')
26
+ expect(classes).toContain('text-destructive')
27
+ })
28
+
29
+ it('destructive does not fill with the destructive surface', () => {
30
+ const classes = classesOf('destructive')
31
+ expect(classes).not.toContain('bg-destructive')
32
+ expect(classes).not.toContain('text-white')
33
+ })
34
+
35
+ it('destructive-solid fills with the destructive surface and white glyph', () => {
36
+ const classes = classesOf('destructive-solid')
37
+ expect(classes).toContain('bg-destructive')
38
+ expect(classes).toContain('text-white')
39
+ expect(classes).not.toContain('border-destructive')
40
+ })
41
+
42
+ it('destructive-solid ships no dark background override', () => {
43
+ // `--destructive` is a semantic token that already resolves per theme; a
44
+ // `dark:bg-*` override on top of it is what broke IconButton in #3507.
45
+ const classes = classesOf('destructive-solid')
46
+ expect(classes.some((cls) => cls.startsWith('dark:bg-'))).toBe(false)
47
+ })
48
+
49
+ it('renders the quiet treatment on the element for variant="destructive"', () => {
50
+ const { getByRole } = render(<Button variant="destructive">Delete</Button>)
51
+ const className = getByRole('button').className
52
+ expect(className).toContain('text-destructive')
53
+ expect(className).toContain('border-destructive')
54
+ expect(className.split(/\s+/)).not.toContain('bg-destructive')
55
+ })
56
+
57
+ it('renders the filled treatment on the element for variant="destructive-solid"', () => {
58
+ const { getByRole } = render(<Button variant="destructive-solid">Delete permanently</Button>)
59
+ const classNames = getByRole('button').className.split(/\s+/)
60
+ expect(classNames).toContain('bg-destructive')
61
+ expect(classNames).toContain('text-white')
62
+ })
63
+
64
+ it('defaults to the primary variant when none is given', () => {
65
+ const { getByRole } = render(<Button>Save</Button>)
66
+ const classNames = getByRole('button').className.split(/\s+/)
67
+ expect(classNames).toContain('bg-primary')
68
+ expect(classNames).not.toContain('text-destructive')
69
+ })
70
+ })
@@ -9,14 +9,20 @@ const buttonVariants = cva(
9
9
  variants: {
10
10
  variant: {
11
11
  default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary-hover',
12
+ /* Destructive is quiet by design: red text on a calm surface. A wall
13
+ of solid red buttons trains users to ignore red. The filled form is
14
+ `destructive-solid`, reserved for the single point-of-no-return
15
+ confirmation inside a dialog — never for the button that opens it. */
12
16
  destructive:
13
- 'bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive dark:bg-destructive/10',
17
+ 'border border-destructive bg-background text-destructive shadow-xs hover:bg-destructive/10 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive',
18
+ 'destructive-solid':
19
+ 'bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive',
14
20
  'destructive-outline':
15
- 'border border-destructive/30 bg-background text-destructive shadow-xs hover:bg-destructive/10 dark:border-destructive/40 dark:hover:bg-destructive/15',
21
+ 'border border-destructive bg-background text-destructive shadow-xs hover:bg-destructive/10',
16
22
  'destructive-soft':
17
- 'bg-destructive/10 text-destructive hover:bg-destructive/15 dark:bg-destructive/15 dark:hover:bg-destructive/20',
23
+ 'bg-destructive/10 text-destructive hover:bg-destructive/15',
18
24
  'destructive-ghost':
19
- 'text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/15',
25
+ 'text-destructive hover:bg-destructive/10',
20
26
  outline:
21
27
  'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
22
28
  secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react'
4
4
  import { createContext, useContext } from 'react'
5
5
  import { createLogger } from '@open-mercato/shared/lib/logger'
6
+ import { THEME_STORAGE_KEY } from './theme-init-script'
6
7
 
7
8
  const logger = createLogger('ui').child({ component: 'ThemeProvider' })
8
9
 
@@ -16,8 +17,6 @@ type ThemeContextValue = {
16
17
 
17
18
  const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
18
19
 
19
- const THEME_STORAGE_KEY = 'om-theme'
20
-
21
20
  function getSystemTheme(): 'light' | 'dark' {
22
21
  if (typeof window === 'undefined') return 'light'
23
22
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
@@ -0,0 +1,70 @@
1
+ import { THEME_INIT_SCRIPT, THEME_STORAGE_KEY } from '../theme-init-script'
2
+
3
+ function mockSystemTheme(prefersDark: boolean) {
4
+ Object.defineProperty(window, 'matchMedia', {
5
+ writable: true,
6
+ configurable: true,
7
+ value: (query: string) => ({
8
+ matches: query.includes('prefers-color-scheme: dark') ? prefersDark : false,
9
+ media: query,
10
+ addEventListener: () => {},
11
+ removeEventListener: () => {},
12
+ }),
13
+ })
14
+ }
15
+
16
+ function runThemeInitScript() {
17
+ new Function(THEME_INIT_SCRIPT)()
18
+ }
19
+
20
+ describe('THEME_INIT_SCRIPT', () => {
21
+ beforeEach(() => {
22
+ document.documentElement.classList.remove('dark')
23
+ window.localStorage.clear()
24
+ jest.restoreAllMocks()
25
+ })
26
+
27
+ it('applies the dark class when the stored preference is dark', () => {
28
+ window.localStorage.setItem(THEME_STORAGE_KEY, 'dark')
29
+ mockSystemTheme(false)
30
+
31
+ runThemeInitScript()
32
+
33
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
34
+ })
35
+
36
+ it('keeps the light theme when the stored preference is light, even if the system prefers dark', () => {
37
+ window.localStorage.setItem(THEME_STORAGE_KEY, 'light')
38
+ mockSystemTheme(true)
39
+
40
+ runThemeInitScript()
41
+
42
+ expect(document.documentElement.classList.contains('dark')).toBe(false)
43
+ })
44
+
45
+ it('falls back to the system preference when nothing is stored', () => {
46
+ mockSystemTheme(true)
47
+
48
+ runThemeInitScript()
49
+
50
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
51
+ })
52
+
53
+ it('stays on the light theme when nothing is stored and the system prefers light', () => {
54
+ mockSystemTheme(false)
55
+
56
+ runThemeInitScript()
57
+
58
+ expect(document.documentElement.classList.contains('dark')).toBe(false)
59
+ })
60
+
61
+ it('degrades gracefully when localStorage is unavailable', () => {
62
+ jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
63
+ throw new Error('localStorage is blocked')
64
+ })
65
+ mockSystemTheme(true)
66
+
67
+ expect(() => runThemeInitScript()).not.toThrow()
68
+ expect(document.documentElement.classList.contains('dark')).toBe(false)
69
+ })
70
+ })
@@ -1,3 +1,4 @@
1
1
  export { ThemeProvider, useTheme } from './ThemeProvider'
2
+ export { THEME_INIT_SCRIPT, THEME_STORAGE_KEY } from './theme-init-script'
2
3
  export { ThemeToggle } from './ThemeToggle'
3
4
  export { QueryProvider } from './QueryProvider'
@@ -0,0 +1,23 @@
1
+ export const THEME_STORAGE_KEY = 'om-theme'
2
+
3
+ /**
4
+ * Inline source for the root layout's theme initializer.
5
+ *
6
+ * It MUST be rendered as a plain, non-deferred `<script>` element so the browser
7
+ * executes it while parsing the document, before the first paint. Deferring it —
8
+ * for example through `next/script` with `strategy="beforeInteractive"`, which in
9
+ * the App Router only queues the code onto `self.__next_s` for the client runtime
10
+ * to replay once hydration starts — makes the page paint in light theme first and
11
+ * flash to dark afterwards.
12
+ */
13
+ export const THEME_INIT_SCRIPT = `
14
+ (function () {
15
+ try {
16
+ var stored = localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
17
+ var theme = stored === 'dark' ? 'dark'
18
+ : stored === 'light' ? 'light'
19
+ : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
20
+ if (theme === 'dark') document.documentElement.classList.add('dark');
21
+ } catch (error) {}
22
+ })();
23
+ `