@open-mercato/ui 0.6.7-develop.6860.1.f796b22e80 → 0.6.7

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 (167) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -4
  3. package/agentic/standalone-guide.md +1 -1
  4. package/build.mjs +54 -2
  5. package/dist/ai/AiChat.js +2 -21
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/backend/AppShell.js +4 -43
  8. package/dist/backend/AppShell.js.map +2 -2
  9. package/dist/backend/AuthSessionGuard.js +0 -14
  10. package/dist/backend/AuthSessionGuard.js.map +2 -2
  11. package/dist/backend/BackendChromeProvider.js +1 -6
  12. package/dist/backend/BackendChromeProvider.js.map +2 -2
  13. package/dist/backend/CrudForm.js +4 -6
  14. package/dist/backend/CrudForm.js.map +2 -2
  15. package/dist/backend/DataTable.js +18 -20
  16. package/dist/backend/DataTable.js.map +2 -2
  17. package/dist/backend/JsonBuilder.js +3 -3
  18. package/dist/backend/JsonBuilder.js.map +2 -2
  19. package/dist/backend/NextStepCallout.js +4 -4
  20. package/dist/backend/NextStepCallout.js.map +1 -1
  21. package/dist/backend/ProfileDropdown.js +1 -2
  22. package/dist/backend/ProfileDropdown.js.map +2 -2
  23. package/dist/backend/RowActions.js +2 -2
  24. package/dist/backend/RowActions.js.map +2 -2
  25. package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -13
  26. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  27. package/dist/backend/crud/useGroupOrder.js +21 -14
  28. package/dist/backend/crud/useGroupOrder.js.map +2 -2
  29. package/dist/backend/detail/AddressTiles.js +5 -4
  30. package/dist/backend/detail/AddressTiles.js.map +2 -2
  31. package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
  32. package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
  33. package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
  34. package/dist/backend/detail/AttachmentsSection.js +1 -1
  35. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  36. package/dist/backend/detail/NotesSection.js +1 -1
  37. package/dist/backend/detail/NotesSection.js.map +2 -2
  38. package/dist/backend/detail/TagsSection.js +1 -1
  39. package/dist/backend/detail/TagsSection.js.map +2 -2
  40. package/dist/backend/forms/FormHeader.js +2 -1
  41. package/dist/backend/forms/FormHeader.js.map +2 -2
  42. package/dist/backend/icons/lucideRegistry.generated.js +25 -1
  43. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  44. package/dist/backend/icons/lucideRegistry.js +3 -3
  45. package/dist/backend/icons/lucideRegistry.js.map +2 -2
  46. package/dist/backend/injection/spotIds.js +18 -23
  47. package/dist/backend/injection/spotIds.js.map +2 -2
  48. package/dist/backend/schedule/ScheduleAgenda.js +3 -3
  49. package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
  50. package/dist/backend/schedule/ScheduleGrid.js +3 -3
  51. package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
  52. package/dist/backend/schedule/ScheduleToolbar.js +70 -87
  53. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  54. package/dist/backend/sidebar/SidebarCustomizationEditor.js +2 -30
  55. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  56. package/dist/backend/sidebar/customization-helpers.js +0 -30
  57. package/dist/backend/sidebar/customization-helpers.js.map +2 -2
  58. package/dist/backend/utils/api.js +13 -3
  59. package/dist/backend/utils/api.js.map +2 -2
  60. package/dist/backend/utils/apiCall.js +1 -21
  61. package/dist/backend/utils/apiCall.js.map +2 -2
  62. package/dist/backend/utils/customFieldDefs.js.map +2 -2
  63. package/dist/backend/utils/customFieldForms.js +9 -10
  64. package/dist/backend/utils/customFieldForms.js.map +2 -2
  65. package/dist/backend/utils/nav.js +2 -5
  66. package/dist/backend/utils/nav.js.map +2 -2
  67. package/dist/frontend/LanguageSwitcher.js +1 -2
  68. package/dist/frontend/LanguageSwitcher.js.map +2 -2
  69. package/dist/portal/PortalContext.js +1 -1
  70. package/dist/portal/PortalContext.js.map +2 -2
  71. package/dist/portal/hooks/usePortalEventBridge.js +0 -6
  72. package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
  73. package/dist/portal/hooks/usePortalNotifications.js +4 -42
  74. package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
  75. package/dist/portal/utils/nav.js +3 -2
  76. package/dist/portal/utils/nav.js.map +2 -2
  77. package/dist/primitives/button.js +4 -9
  78. package/dist/primitives/button.js.map +2 -2
  79. package/jest.config.cjs +1 -2
  80. package/package.json +18 -21
  81. package/src/ai/AiChat.tsx +1 -30
  82. package/src/ai/__tests__/AiChat.test.tsx +0 -31
  83. package/src/backend/AppShell.tsx +4 -55
  84. package/src/backend/AuthSessionGuard.tsx +0 -29
  85. package/src/backend/BackendChromeProvider.tsx +1 -19
  86. package/src/backend/CrudForm.tsx +4 -18
  87. package/src/backend/DataTable.tsx +19 -36
  88. package/src/backend/JsonBuilder.tsx +3 -3
  89. package/src/backend/NextStepCallout.tsx +4 -4
  90. package/src/backend/ProfileDropdown.tsx +0 -1
  91. package/src/backend/RowActions.tsx +2 -2
  92. package/src/backend/__tests__/AppShell.test.tsx +7 -65
  93. package/src/backend/__tests__/AuthSessionGuard.test.tsx +0 -52
  94. package/src/backend/__tests__/BackendChromeProvider.test.tsx +2 -98
  95. package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +1 -62
  96. package/src/backend/__tests__/nav-utils.test.ts +0 -139
  97. package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -16
  98. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +1 -68
  99. package/src/backend/crud/__tests__/useGroupOrder.test.ts +1 -39
  100. package/src/backend/crud/useGroupOrder.ts +25 -30
  101. package/src/backend/detail/AddressTiles.tsx +6 -3
  102. package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
  103. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  104. package/src/backend/detail/AttachmentsSection.tsx +1 -1
  105. package/src/backend/detail/NotesSection.tsx +1 -1
  106. package/src/backend/detail/TagsSection.tsx +1 -1
  107. package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +0 -21
  108. package/src/backend/forms/FormHeader.tsx +2 -1
  109. package/src/backend/icons/lucideRegistry.generated.tsx +37 -3
  110. package/src/backend/icons/lucideRegistry.ts +2 -2
  111. package/src/backend/injection/spotIds.ts +18 -23
  112. package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
  113. package/src/backend/schedule/ScheduleGrid.tsx +3 -3
  114. package/src/backend/schedule/ScheduleToolbar.tsx +68 -89
  115. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +3 -42
  116. package/src/backend/sidebar/customization-helpers.ts +0 -47
  117. package/src/backend/utils/__tests__/api.test.ts +27 -0
  118. package/src/backend/utils/__tests__/apiCall.test.ts +0 -61
  119. package/src/backend/utils/api.ts +20 -3
  120. package/src/backend/utils/apiCall.ts +1 -29
  121. package/src/backend/utils/customFieldDefs.ts +0 -1
  122. package/src/backend/utils/customFieldForms.ts +11 -15
  123. package/src/backend/utils/nav.ts +2 -22
  124. package/src/frontend/LanguageSwitcher.tsx +0 -1
  125. package/src/portal/PortalContext.tsx +1 -1
  126. package/src/portal/hooks/usePortalEventBridge.ts +0 -6
  127. package/src/portal/hooks/usePortalNotifications.ts +12 -55
  128. package/src/portal/utils/__tests__/nav.test.ts +2 -2
  129. package/src/portal/utils/nav.ts +4 -3
  130. package/src/primitives/button.tsx +4 -10
  131. package/dist/backend/icons/lucideRegistryRuntime.js +0 -28
  132. package/dist/backend/icons/lucideRegistryRuntime.js.map +0 -7
  133. package/dist/backend/perspectiveState.js +0 -34
  134. package/dist/backend/perspectiveState.js.map +0 -7
  135. package/dist/backend/utils/fetchAllPages.js +0 -57
  136. package/dist/backend/utils/fetchAllPages.js.map +0 -7
  137. package/dist/portal/hooks/portalBridgeStatus.js +0 -25
  138. package/dist/portal/hooks/portalBridgeStatus.js.map +0 -7
  139. package/figma/alert.figma.tsx +0 -35
  140. package/figma/badge.figma.tsx +0 -33
  141. package/figma/button.figma.tsx +0 -33
  142. package/figma/checkbox.figma.tsx +0 -28
  143. package/figma/drawer.figma.tsx +0 -40
  144. package/figma/input-variants.figma.tsx +0 -58
  145. package/figma/radio.figma.tsx +0 -16
  146. package/figma/select.figma.tsx +0 -24
  147. package/figma/status-badge.figma.tsx +0 -24
  148. package/figma/switch.figma.tsx +0 -28
  149. package/figma/tabs.figma.tsx +0 -22
  150. package/figma/tag.figma.tsx +0 -27
  151. package/figma.config.json +0 -6
  152. package/scripts/lucideRegistrySource.cjs +0 -133
  153. package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +0 -158
  154. package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +0 -98
  155. package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +0 -105
  156. package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +0 -177
  157. package/src/backend/icons/lucideRegistryRuntime.tsx +0 -36
  158. package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +0 -38
  159. package/src/backend/perspectiveState.ts +0 -44
  160. package/src/backend/sidebar/__tests__/group-visibility.test.ts +0 -131
  161. package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +0 -95
  162. package/src/backend/utils/__tests__/fetchAllPages.test.ts +0 -103
  163. package/src/backend/utils/fetchAllPages.ts +0 -93
  164. package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +0 -66
  165. package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +0 -155
  166. package/src/portal/hooks/portalBridgeStatus.ts +0 -29
  167. package/src/primitives/__tests__/button.test.tsx +0 -70
@@ -87,7 +87,6 @@ function isBooleanRecord(value: unknown): value is Record<string, boolean> {
87
87
  export type ShellLogo = {
88
88
  src: string
89
89
  alt?: string
90
- preserveAspectRatio?: boolean
91
90
  }
92
91
 
93
92
  export type AppShellProps = {
@@ -187,56 +186,6 @@ function shouldBypassLogoOptimization(src?: string | null): boolean {
187
186
  return /^https?:\/\//.test(value) || /^\/api\/attachments\/(?:image|file)\//.test(value)
188
187
  }
189
188
 
190
- function ShellBrandLogo({
191
- logo,
192
- brandName,
193
- unoptimized,
194
- compact = false,
195
- mobile = false,
196
- }: {
197
- logo?: ShellLogo
198
- brandName: string
199
- unoptimized?: boolean
200
- compact?: boolean
201
- mobile?: boolean
202
- }) {
203
- const src = logo?.src ?? '/open-mercato.svg'
204
- const alt = logo?.alt ?? brandName
205
- const isCustomLogo = Boolean(logo?.src)
206
- const preserveAspectRatio = Boolean(logo?.preserveAspectRatio)
207
- if (!isCustomLogo || !preserveAspectRatio) {
208
- return (
209
- <Image
210
- src={src}
211
- alt={alt}
212
- width={mobile ? 28 : 40}
213
- height={mobile ? 28 : 40}
214
- className={`${mobile ? 'rounded' : 'rounded-full'} shrink-0 object-cover`}
215
- unoptimized={unoptimized ? true : undefined}
216
- />
217
- )
218
- }
219
-
220
- const width = compact ? 40 : mobile ? 96 : 120
221
- const height = mobile ? 28 : 40
222
- const className = compact
223
- ? 'h-10 max-w-10 w-auto shrink-0 object-contain'
224
- : mobile
225
- ? 'h-7 max-w-24 w-auto shrink-0 object-contain'
226
- : 'h-10 max-w-[120px] w-auto shrink-0 object-contain'
227
-
228
- return (
229
- <Image
230
- src={src}
231
- alt={alt}
232
- width={width}
233
- height={height}
234
- className={className}
235
- unoptimized={unoptimized ? true : undefined}
236
- />
237
- )
238
- }
239
-
240
189
  function mergeSidebarItemsWithInjected(
241
190
  items: SidebarItem[],
242
191
  injectedItems: InjectionMenuItem[],
@@ -811,7 +760,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
811
760
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
812
761
  aria-label={t('appShell.goToDashboard')}
813
762
  >
814
- <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} compact={compact} unoptimized={resolvedLogoBypassesOptimization} />
763
+ <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
815
764
  {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
816
765
  </Link>
817
766
  </div>
@@ -944,7 +893,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
944
893
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
945
894
  aria-label={t('appShell.goToDashboard')}
946
895
  >
947
- <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} compact={compact} unoptimized={resolvedLogoBypassesOptimization} />
896
+ <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
948
897
  {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
949
898
  </Link>
950
899
  </div>
@@ -1010,7 +959,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1010
959
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
1011
960
  aria-label={t('appShell.goToDashboard')}
1012
961
  >
1013
- <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} compact={compact} unoptimized={resolvedLogoBypassesOptimization} />
962
+ <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
1014
963
  {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
1015
964
  </Link>
1016
965
  </div>
@@ -1509,7 +1458,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1509
1458
  <aside className="absolute left-0 top-0 flex h-full w-[280px] max-w-[85vw] flex-col bg-background border-r shadow-lg overflow-hidden">
1510
1459
  <div className="shrink-0 flex items-center justify-between gap-2 border-b px-4 py-3">
1511
1460
  <Link href="/backend" className="flex items-center gap-2 min-w-0 text-sm font-semibold" onClick={() => setMobileOpen(false)} aria-label={t('appShell.goToDashboard')}>
1512
- <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} mobile unoptimized={resolvedLogoBypassesOptimization} />
1461
+ <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={28} height={28} className="rounded shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
1513
1462
  <span className="truncate">{resolvedBrandName}</span>
1514
1463
  </Link>
1515
1464
  <IconButton variant="ghost" size="sm" onClick={() => setMobileOpen(false)} aria-label={t('appShell.closeMenu')}>
@@ -1,37 +1,9 @@
1
1
  'use client'
2
2
  import * as React from 'react'
3
3
  import { apiCall } from './utils/apiCall'
4
- import { clearAllPerspectiveState } from './perspectiveState'
5
4
 
6
5
  export const AUTH_IDENTITY_STORAGE_KEY = 'om:auth:identity'
7
6
  export const AUTH_IDENTITY_BROADCAST_CHANNEL = 'om-auth-identity'
8
- export const AUTH_IDENTITY_USER_STORAGE_KEY = 'om:auth:identity:user'
9
-
10
- /**
11
- * Purge browser-local DataTable perspective state whenever the backend renders for a
12
- * different user than the one it last rendered for (#4185).
13
- *
14
- * The login form also purges on submit, but that only fires when its client handler
15
- * runs: a form submitted before hydration, an SSO/magic-link return, or any other
16
- * entry point into the backend bypasses it entirely and the previous account's unsaved
17
- * column widths carry over. Anchoring the purge to the observed server identity covers
18
- * every route into the backend instead of one form.
19
- *
20
- * Missing identity metadata is treated as a legacy browser state and purged once:
21
- * snapshots may predate this marker and belong to a different account. After the
22
- * marker is recorded, an account's own unsaved widths survive a plain reload.
23
- */
24
- function purgePerspectiveStateOnIdentityChange(serverUserId: string | null): void {
25
- if (typeof window === 'undefined' || !serverUserId) return
26
- try {
27
- const lastSeenUserId = window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)
28
- if (lastSeenUserId === serverUserId) return
29
- clearAllPerspectiveState()
30
- window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, serverUserId)
31
- } catch {
32
- // private mode / quota errors are non-fatal
33
- }
34
- }
35
7
 
36
8
  type FeatureCheckResponse = {
37
9
  ok: boolean
@@ -52,7 +24,6 @@ export const __reload = {
52
24
  export function AuthSessionGuard({ serverUserId }: AuthSessionGuardProps) {
53
25
  React.useEffect(() => {
54
26
  if (typeof window === 'undefined') return
55
- purgePerspectiveStateOnIdentityChange(serverUserId)
56
27
  let cancelled = false
57
28
  let reloadScheduled = false
58
29
 
@@ -3,10 +3,7 @@
3
3
  import * as React from 'react'
4
4
  import { apiCall } from './utils/apiCall'
5
5
  import { subscribeOrganizationScopeChanged, getCurrentOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/organizationEvents'
6
- import type {
7
- BackendChromeCurrentOrganization,
8
- BackendChromePayload,
9
- } from '@open-mercato/shared/modules/navigation/backendChrome'
6
+ import type { BackendChromePayload } from '@open-mercato/shared/modules/navigation/backendChrome'
10
7
 
11
8
  type BackendChromeContextValue = {
12
9
  payload: BackendChromePayload | null
@@ -102,18 +99,3 @@ export function useBackendChrome(): BackendChromeContextValue {
102
99
  refresh: async () => {},
103
100
  }
104
101
  }
105
-
106
- /**
107
- * The organization the backend chrome is currently scoped to, for UI that needs to label
108
- * "you are viewing: <name>".
109
- *
110
- * Reads the payload this provider already holds, so it costs no additional request. Returns `null`
111
- * under an all-organizations selection, outside a provider, or before the payload has arrived — treat
112
- * it as "unknown", not as "no organization".
113
- *
114
- * Prefer this over `payload.brand`, which only populates when the organization has a logo configured.
115
- */
116
- export function useCurrentOrganization(): BackendChromeCurrentOrganization | null {
117
- const { payload } = useBackendChrome()
118
- return payload?.currentOrganization ?? null
119
- }
@@ -107,7 +107,6 @@ import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/share
107
107
  import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
108
108
  import { evaluateInjectedVisibility } from './injection/visibility-utils'
109
109
  import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
110
- import { crudFormExtensionSpotId, extensionSpotChildId } from '@open-mercato/shared/modules/widgets/extension-points'
111
110
  import { RichEditor, type RichEditorLabels } from '../primitives/rich-editor'
112
111
  import MarkdownField from './inputs/MarkdownField'
113
112
 
@@ -817,7 +816,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
817
816
  if (injectionSpotId) return injectionSpotId
818
817
  if (resolvedEntityIds.length) {
819
818
  const normalized = resolvedEntityIds[0].replace(/[:]+/g, '.')
820
- return crudFormExtensionSpotId(normalized)
819
+ return `crud-form:${normalized}`
821
820
  }
822
821
  return undefined
823
822
  }, [injectionSpotId, resolvedEntityIds])
@@ -826,9 +825,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
826
825
  if (resolvedEntityIds.length) return ComponentReplacementHandles.crudForm(resolvedEntityIds[0].replace(/[:]+/g, '.'))
827
826
  return ComponentReplacementHandles.crudForm('unknown')
828
827
  }, [replacementHandle, resolvedEntityIds])
829
- const headerInjectionSpotId = resolvedInjectionSpotId
830
- ? extensionSpotChildId(resolvedInjectionSpotId, 'header')
831
- : undefined
828
+ const headerInjectionSpotId = resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:header` : undefined
832
829
 
833
830
  const recordId = React.useMemo(() => {
834
831
  const raw = values.id
@@ -1086,9 +1083,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1086
1083
  triggerOnLoad: true,
1087
1084
  })
1088
1085
  const { widgets: injectedFieldWidgets } = useInjectionDataWidgets(
1089
- resolvedInjectionSpotId
1090
- ? extensionSpotChildId(resolvedInjectionSpotId, 'fields')
1091
- : '__disabled__:fields'
1086
+ resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:fields` : '__disabled__:fields'
1092
1087
  )
1093
1088
 
1094
1089
  const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? '', injectionWidgets)
@@ -2407,19 +2402,10 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2407
2402
 
2408
2403
  const handleFieldsetSelectionChange = React.useCallback(
2409
2404
  (entityId: string, nextCode: string | null) => {
2410
- // The fieldset selector never renders an empty option, so a user can never
2411
- // pick "no fieldset". An empty/null value here only comes from Radix's
2412
- // hidden native <select>, which fires a spurious onChange when the
2413
- // controlled value is changed programmatically after mount (e.g. when the
2414
- // persisted customFieldsetCode arrives via async initialValues and the
2415
- // binding hydrates the selection). Honoring that reset would wipe both the
2416
- // selection and the bound value, collapsing the form back to the default
2417
- // fieldset (#2646). Ignore it.
2418
- if (!nextCode) return
2419
2405
  setCfFieldsetSelections((prev) => ({ ...prev, [entityId]: nextCode }))
2420
2406
  const bindingKey = customFieldsetBindings?.[entityId]?.valueKey
2421
2407
  if (bindingKey) {
2422
- setValue(bindingKey, nextCode)
2408
+ setValue(bindingKey, nextCode ?? undefined)
2423
2409
  }
2424
2410
  },
2425
2411
  [customFieldsetBindings, setValue],
@@ -59,7 +59,6 @@ import type {
59
59
  InjectionRowActionDefinition,
60
60
  } from '@open-mercato/shared/modules/widgets/injection'
61
61
  import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
62
- import { dataTableExtensionSpotId, extensionSpotChildId } from '@open-mercato/shared/modules/widgets/extension-points'
63
62
  import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
64
63
  import { useVirtualizer } from '@tanstack/react-virtual'
65
64
  import type {
@@ -95,11 +94,6 @@ import {
95
94
  } from '@dnd-kit/sortable'
96
95
  import { CSS } from '@dnd-kit/utilities'
97
96
  import { createLogger } from '@open-mercato/shared/lib/logger'
98
- import { clearAllPerspectiveState, PERSPECTIVE_COOKIE_PREFIX, PERSPECTIVE_STORAGE_PREFIX } from './perspectiveState'
99
-
100
- // Re-exported so `@open-mercato/ui/backend/DataTable` stays the published import
101
- // path for the purge (BACKWARD_COMPATIBILITY: import paths are a contract surface).
102
- export { clearAllPerspectiveState }
103
97
 
104
98
  const logger = createLogger('ui').child({ component: 'DataTable' })
105
99
 
@@ -475,6 +469,8 @@ function resolveExportSections(config: DataTableExportConfig | null | undefined)
475
469
  return sections
476
470
  }
477
471
 
472
+ const PERSPECTIVE_COOKIE_PREFIX = 'om_table_perspective'
473
+ const PERSPECTIVE_STORAGE_PREFIX = 'om_table_perspective_snapshot'
478
474
 
479
475
  // Bounds for user-driven column resizing (#1835). Widths outside this range are
480
476
  // clamped so a persisted/dragged value can never collapse a column to nothing or
@@ -1356,8 +1352,8 @@ export function DataTable<T>({
1356
1352
  }, [injectionSpotId, perspective?.tableId, extensionTableIdProp])
1357
1353
  const resolvedInjectionSpotId =
1358
1354
  injectionSpotId
1359
- ?? (perspective?.tableId ? dataTableExtensionSpotId(perspective.tableId) : null)
1360
- ?? (extensionTableIdProp ? dataTableExtensionSpotId(extensionTableIdProp) : null)
1355
+ ?? (perspective?.tableId ? `data-table:${perspective.tableId}` : null)
1356
+ ?? (extensionTableIdProp ? `data-table:${extensionTableIdProp}` : null)
1361
1357
  const resolvedReplacementHandle = replacementHandle ?? ComponentReplacementHandles.dataTable(extensionTableId ?? 'unknown')
1362
1358
  const baseInjectionContext = React.useMemo(
1363
1359
  () => {
@@ -1380,32 +1376,32 @@ export function DataTable<T>({
1380
1376
  [injectionContext, perspective?.tableId, extensionTableId, title]
1381
1377
  )
1382
1378
  const headerInjectionSpotId = React.useMemo(
1383
- () => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'header') : null),
1379
+ () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:header` : null),
1384
1380
  [resolvedInjectionSpotId]
1385
1381
  )
1386
1382
  const toolbarInjectionSpotId = React.useMemo(
1387
- () => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'toolbar') : null),
1383
+ () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:toolbar` : null),
1388
1384
  [resolvedInjectionSpotId]
1389
1385
  )
1390
1386
  const searchTrailingInjectionSpotId = React.useMemo(
1391
- () => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'search-trailing') : null),
1387
+ () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:search-trailing` : null),
1392
1388
  [resolvedInjectionSpotId]
1393
1389
  )
1394
1390
  const footerInjectionSpotId = React.useMemo(
1395
- () => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'footer') : null),
1391
+ () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:footer` : null),
1396
1392
  [resolvedInjectionSpotId]
1397
1393
  )
1398
1394
  const { widgets: columnWidgets } = useInjectionDataWidgets(
1399
- extensionTableId ? dataTableExtensionSpotId(extensionTableId, 'columns') : '__disabled__:columns',
1395
+ extensionTableId ? `data-table:${extensionTableId}:columns` : '__disabled__:columns',
1400
1396
  )
1401
1397
  const { widgets: rowActionWidgets } = useInjectionDataWidgets(
1402
- extensionTableId ? dataTableExtensionSpotId(extensionTableId, 'row-actions') : '__disabled__:row-actions',
1398
+ extensionTableId ? `data-table:${extensionTableId}:row-actions` : '__disabled__:row-actions',
1403
1399
  )
1404
1400
  const { widgets: bulkActionWidgets } = useInjectionDataWidgets(
1405
- extensionTableId ? dataTableExtensionSpotId(extensionTableId, 'bulk-actions') : '__disabled__:bulk-actions',
1401
+ extensionTableId ? `data-table:${extensionTableId}:bulk-actions` : '__disabled__:bulk-actions',
1406
1402
  )
1407
1403
  const { widgets: filterWidgets } = useInjectionDataWidgets(
1408
- extensionTableId ? dataTableExtensionSpotId(extensionTableId, 'filters') : '__disabled__:filters',
1404
+ extensionTableId ? `data-table:${extensionTableId}:filters` : '__disabled__:filters',
1409
1405
  )
1410
1406
  const injectedColumnDefs = React.useMemo<{ def: ColumnDef<T, unknown>; placement: InjectionColumnDefinition['placement'] }[]>(() => {
1411
1407
  const entries: InjectionColumnDefinition[] = []
@@ -1699,31 +1695,18 @@ export function DataTable<T>({
1699
1695
  })
1700
1696
  }, [table, mergedColumns])
1701
1697
 
1702
- // A stored perspective seeds `columnVisibility` at mount and wins outright over the
1703
- // `meta.hidden` defaults, so the auto-hide pass is skipped entirely in that case.
1704
- const visibilitySeededByStoredSettings = React.useRef(Boolean(mergedInitialSettings?.columnVisibility))
1705
- // Auto-hiding is a per-column default, applied once per column — not an enforcement.
1706
- // It cannot be latched by a single has-run boolean: columns arrive in waves, because
1707
- // custom-field columns are built from definitions fetched asynchronously. The first
1708
- // render carries no `cf_*` column at all, so a run-once pass found nothing to hide and
1709
- // still burned the latch, leaving fields declared `listVisible: false` visible for the
1710
- // rest of the session after a hard page load (#4859).
1711
- // It also cannot lean on `columnVisibility` as the record of what has been decided:
1712
- // `handleColumnChooserToggle` *deletes* a column's entry when the user turns it back on,
1713
- // so a re-shown column is indistinguishable from one never seen and would be hidden again
1714
- // on the next wave. Hence an explicit per-column record of what this pass has applied.
1715
- const autoHiddenColumnIds = React.useRef<Set<string>>(new Set())
1698
+ const initialVisibilityApplied = React.useRef(Boolean(mergedInitialSettings?.columnVisibility))
1716
1699
  React.useEffect(() => {
1717
- if (visibilitySeededByStoredSettings.current) return
1700
+ if (initialVisibilityApplied.current) return
1718
1701
  const hidden: VisibilityState = {}
1719
1702
  table.getAllLeafColumns().forEach((column) => {
1720
1703
  const hiddenMeta = (column.columnDef as any)?.meta?.hidden
1721
- if (!hiddenMeta || autoHiddenColumnIds.current.has(column.id)) return
1722
- hidden[column.id] = false
1723
- autoHiddenColumnIds.current.add(column.id)
1704
+ if (hiddenMeta) hidden[column.id] = false
1724
1705
  })
1725
- if (!Object.keys(hidden).length) return
1726
- setColumnVisibility((prev) => ({ ...hidden, ...prev }))
1706
+ if (Object.keys(hidden).length) {
1707
+ setColumnVisibility((prev) => ({ ...hidden, ...prev }))
1708
+ }
1709
+ initialVisibilityApplied.current = true
1727
1710
  }, [table, mergedColumns])
1728
1711
 
1729
1712
  const getCurrentSettings = React.useCallback((): PerspectiveSettings => {
@@ -164,7 +164,7 @@ export function JsonBuilder({
164
164
  disabled={disabled}
165
165
  />
166
166
  {parseError && (
167
- <div className="text-xs text-status-error-text">Invalid JSON format</div>
167
+ <div className="text-xs text-red-600">Invalid JSON format</div>
168
168
  )}
169
169
  </div>
170
170
  ) : (
@@ -185,7 +185,7 @@ export function JsonBuilder({
185
185
  </div>
186
186
  )}
187
187
 
188
- {error && <div className="text-xs text-status-error-text">{error}</div>}
188
+ {error && <div className="text-xs text-red-600">{error}</div>}
189
189
  {ConfirmDialogElement}
190
190
  </div>
191
191
  )
@@ -363,7 +363,7 @@ function JsonNode({ data, onChange, onDelete, readOnly, label, isRoot, confirm }
363
363
  type="button"
364
364
  variant="ghost"
365
365
  size="xs"
366
- className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
366
+ className="text-muted-foreground hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
367
367
  title="Remove item"
368
368
  onClick={onDelete}
369
369
  >
@@ -42,16 +42,16 @@ type NextStepCalloutProps = {
42
42
  const STEP_STYLES: Record<NonNullable<NextStepCalloutStep['state']>, string> = {
43
43
  pending: '',
44
44
  active: 'border-primary/40 bg-primary/10 text-primary',
45
- completed: 'border-status-success-border bg-status-success-bg text-status-success-text',
45
+ completed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200',
46
46
  }
47
47
 
48
48
  const STATUS_STYLES: Record<NextStepCalloutStatusTone, string> = {
49
49
  default: 'border-border bg-background/80 text-foreground',
50
50
  info: 'border-primary/20 bg-primary/5 text-primary',
51
- success: 'border-status-success-border bg-status-success-bg text-status-success-text',
52
- warning: 'border-status-warning-border bg-status-warning-bg text-status-warning-text',
51
+ success: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-200',
52
+ warning: 'border-amber-500/20 bg-amber-500/5 text-amber-200',
53
53
  danger: 'border-destructive/20 bg-destructive/5 text-destructive',
54
- muted: 'border-status-neutral-border bg-status-neutral-bg text-status-neutral-text',
54
+ muted: 'border-zinc-500/20 bg-zinc-500/5 text-zinc-200',
55
55
  }
56
56
 
57
57
  export function NextStepCallout({
@@ -29,7 +29,6 @@ const localeLabels: Record<Locale, string> = {
29
29
  de: 'Deutsch',
30
30
  es: 'Español',
31
31
  pl: 'Polski',
32
- ko: '한국어',
33
32
  }
34
33
 
35
34
  export function ProfileDropdown({
@@ -123,7 +123,7 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
123
123
  <a
124
124
  key={idx}
125
125
  href={it.href}
126
- className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent ${it.destructive ? 'text-destructive' : ''}`}
126
+ className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent ${it.destructive ? 'text-red-600' : ''}`}
127
127
  role="menuitem"
128
128
  onClick={(event) => {
129
129
  event.stopPropagation()
@@ -138,7 +138,7 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
138
138
  type="button"
139
139
  variant="ghost"
140
140
  size="sm"
141
- className={`w-full justify-start rounded-none font-normal ${it.destructive ? 'text-destructive' : ''}`}
141
+ className={`w-full justify-start rounded-none font-normal ${it.destructive ? 'text-red-600' : ''}`}
142
142
  role="menuitem"
143
143
  onClick={(event) => {
144
144
  event.stopPropagation()
@@ -226,11 +226,7 @@ describe('AppShell', () => {
226
226
  expect(screen.getByTestId('injection-spot:backend:layout:footer')).toBeInTheDocument()
227
227
  })
228
228
 
229
- it.each([
230
- ['internal-file', '/api/attachments/file/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
231
- ['internal-image-query', '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.svg?width=320&height=320'],
232
- ['external-webp', 'https://example.com/acme-wide-logo.webp'],
233
- ])('uses an aspect-ratio-preserving backend chrome brand logo when enabled for %s', async (variant, logoSrc) => {
229
+ it('uses backend chrome brand logo when the selected organization has one', async () => {
234
230
  const previousFetch = global.fetch
235
231
  const previousWindowFetch = window.fetch
236
232
  const previousOriginalFetch = (window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch
@@ -239,9 +235,8 @@ describe('AppShell', () => {
239
235
  brand: {
240
236
  name: 'Acme',
241
237
  logo: {
242
- src: logoSrc,
238
+ src: '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
243
239
  alt: 'Acme logo',
244
- preserveAspectRatio: true,
245
240
  },
246
241
  },
247
242
  groups,
@@ -262,7 +257,7 @@ describe('AppShell', () => {
262
257
  <AppShell
263
258
  email="demo@example.com"
264
259
  groups={[]}
265
- adminNavApi={`/api/auth/admin/nav-brand-logo-${variant}`}
260
+ adminNavApi="/api/auth/admin/nav-brand-logo"
266
261
  >
267
262
  <div>Child content</div>
268
263
  </AppShell>,
@@ -271,10 +266,11 @@ describe('AppShell', () => {
271
266
 
272
267
  await waitFor(() => {
273
268
  const logo = screen.getByAltText('Acme logo')
274
- expect(logo).toHaveAttribute('src', logoSrc)
269
+ expect(logo).toHaveAttribute(
270
+ 'src',
271
+ '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
272
+ )
275
273
  expect(logo).toHaveAttribute('data-unoptimized', 'true')
276
- expect(logo).toHaveClass('object-contain')
277
- expect(logo).not.toHaveClass('rounded-full')
278
274
  })
279
275
  expect(screen.getByText('Acme')).toBeInTheDocument()
280
276
  } finally {
@@ -284,60 +280,6 @@ describe('AppShell', () => {
284
280
  }
285
281
  })
286
282
 
287
- it('uses the cropped icon treatment for backend chrome brand logos by default', async () => {
288
- const previousFetch = global.fetch
289
- const previousWindowFetch = window.fetch
290
- const previousOriginalFetch = (window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch
291
- const logoSrc = 'https://example.com/acme-wide-logo.webp'
292
- const fetchMock = jest.fn().mockResolvedValue(
293
- new Response(JSON.stringify({
294
- brand: {
295
- name: 'Acme',
296
- logo: {
297
- src: logoSrc,
298
- alt: 'Acme logo',
299
- },
300
- },
301
- groups,
302
- settingsSections: [],
303
- settingsPathPrefixes: [],
304
- profileSections: [],
305
- profilePathPrefixes: [],
306
- grantedFeatures: [],
307
- roles: [],
308
- }), { status: 200, headers: { 'content-type': 'application/json' } }),
309
- ) as typeof fetch
310
- global.fetch = fetchMock
311
- window.fetch = fetchMock
312
- ;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = fetchMock
313
-
314
- try {
315
- renderWithProviders(
316
- <AppShell
317
- email="demo@example.com"
318
- groups={[]}
319
- adminNavApi="/api/auth/admin/nav-brand-logo-cropped"
320
- >
321
- <div>Child content</div>
322
- </AppShell>,
323
- { dict },
324
- )
325
-
326
- await waitFor(() => {
327
- const logo = screen.getByAltText('Acme logo')
328
- expect(logo).toHaveAttribute('src', logoSrc)
329
- expect(logo).toHaveAttribute('data-unoptimized', 'true')
330
- expect(logo).toHaveClass('object-cover')
331
- expect(logo).toHaveClass('rounded-full')
332
- expect(logo).not.toHaveClass('object-contain')
333
- })
334
- } finally {
335
- global.fetch = previousFetch
336
- window.fetch = previousWindowFetch
337
- ;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = previousOriginalFetch
338
- }
339
- })
340
-
341
283
  it('renders nested settings links when settings parent route is active', async () => {
342
284
  mockPathname = '/backend/entities/user'
343
285
 
@@ -8,7 +8,6 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
8
8
  import {
9
9
  __reload,
10
10
  AUTH_IDENTITY_STORAGE_KEY,
11
- AUTH_IDENTITY_USER_STORAGE_KEY,
12
11
  AuthSessionGuard,
13
12
  notifyAuthIdentityChange,
14
13
  } from '../AuthSessionGuard'
@@ -149,54 +148,3 @@ describe('notifyAuthIdentityChange', () => {
149
148
  setItemSpy.mockRestore()
150
149
  })
151
150
  })
152
-
153
- describe('perspective purge at the rendered identity boundary (#4185)', () => {
154
- const snapshotKey = 'om_table_perspective_snapshot:customers-companies'
155
-
156
- beforeEach(() => {
157
- window.localStorage.clear()
158
- apiCallMock.mockResolvedValue({ ok: true, status: 200, result: { ok: true } } as never)
159
- })
160
-
161
- afterEach(() => {
162
- window.localStorage.clear()
163
- })
164
-
165
- it('purges legacy table state when no previous identity marker exists', () => {
166
- window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
167
-
168
- render(<AuthSessionGuard serverUserId="user-1" />)
169
-
170
- expect(window.localStorage.getItem(snapshotKey)).toBeNull()
171
- expect(window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)).toBe('user-1')
172
- })
173
-
174
- it('keeps a returning user\'s unsaved widths across a reload', () => {
175
- window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, 'user-1')
176
- window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
177
-
178
- render(<AuthSessionGuard serverUserId="user-1" />)
179
-
180
- expect(window.localStorage.getItem(snapshotKey)).toBe('{"columnWidths":{"name":240}}')
181
- })
182
-
183
- it('purges perspective state when the backend renders for a different user', () => {
184
- window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, 'user-1')
185
- window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
186
-
187
- render(<AuthSessionGuard serverUserId="user-2" />)
188
-
189
- expect(window.localStorage.getItem(snapshotKey)).toBeNull()
190
- expect(window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)).toBe('user-2')
191
- })
192
-
193
- it('does nothing when the shell renders without an authenticated user', () => {
194
- window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, 'user-1')
195
- window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
196
-
197
- render(<AuthSessionGuard serverUserId={null} />)
198
-
199
- expect(window.localStorage.getItem(snapshotKey)).toBe('{"columnWidths":{"name":240}}')
200
- expect(window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)).toBe('user-1')
201
- })
202
- })