@object-ui/react 16.1.0 → 17.2.0

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 (45) hide show
  1. package/CHANGELOG.md +790 -1
  2. package/README.md +41 -0
  3. package/dist/SchemaRenderer.js +12 -2
  4. package/dist/context/AppShellContext.js +51 -23
  5. package/dist/context/InlineEditContext.d.ts +64 -0
  6. package/dist/context/InlineEditContext.js +8 -2
  7. package/dist/context/NotificationContext.d.ts +245 -17
  8. package/dist/context/NotificationContext.js +222 -20
  9. package/dist/context/ThemeContext.js +4 -8
  10. package/dist/hooks/index.d.ts +2 -1
  11. package/dist/hooks/index.js +3 -1
  12. package/dist/hooks/useAnimation.d.ts +40 -4
  13. package/dist/hooks/useAnimation.js +41 -10
  14. package/dist/hooks/useCapabilityGate.d.ts +16 -0
  15. package/dist/hooks/useCapabilityGate.js +60 -0
  16. package/dist/hooks/useClientNotifications.d.ts +0 -6
  17. package/dist/hooks/useClientNotifications.js +7 -53
  18. package/dist/hooks/useFilterScope.d.ts +30 -0
  19. package/dist/hooks/useFilterScope.js +55 -0
  20. package/dist/hooks/useNavigationOverlay.d.ts +34 -10
  21. package/dist/hooks/useNavigationOverlay.js +35 -1
  22. package/dist/hooks/useOffline.d.ts +51 -33
  23. package/dist/hooks/useOffline.js +3 -3
  24. package/dist/hooks/usePageVariables.js +23 -11
  25. package/dist/hooks/usePerformance.d.ts +3 -3
  26. package/dist/hooks/usePerformance.js +2 -2
  27. package/dist/hooks/useRecordSearch.js +98 -4
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.js +1 -1
  30. package/dist/spec-bridge/SpecBridge.d.ts +0 -4
  31. package/dist/spec-bridge/SpecBridge.js +0 -12
  32. package/dist/spec-bridge/bridges/list-view.js +5 -11
  33. package/dist/spec-bridge/index.d.ts +0 -2
  34. package/dist/spec-bridge/index.js +0 -2
  35. package/dist/spec-input.d.ts +37 -0
  36. package/dist/spec-input.js +8 -0
  37. package/dist/utils/error-message.d.ts +31 -0
  38. package/dist/utils/error-message.js +49 -0
  39. package/package.json +8 -8
  40. package/dist/hooks/useColumnSummary.d.ts +0 -38
  41. package/dist/hooks/useColumnSummary.js +0 -54
  42. package/dist/spec-bridge/bridges/dashboard.d.ts +0 -81
  43. package/dist/spec-bridge/bridges/dashboard.js +0 -121
  44. package/dist/spec-bridge/bridges/page.d.ts +0 -58
  45. package/dist/spec-bridge/bridges/page.js +0 -101
@@ -13,55 +13,192 @@ import { jsx as _jsx } from "react/jsx-runtime";
13
13
  * Implements NotificationSchema, NotificationConfigSchema, and
14
14
  * NotificationActionSchema from @objectstack/spec v2.0.7.
15
15
  */
16
- import { createContext, useCallback, useContext, useMemo, useState } from 'react';
16
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
17
+ /**
18
+ * The presentation table — one entry per spec `NotificationTypeSchema` member.
19
+ *
20
+ * Typed as `Record<NotificationPresentation, …>`, so adding a member to the
21
+ * spec enum fails type-check here until its presentation is decided, rather
22
+ * than silently falling back to a toast.
23
+ */
24
+ export const NOTIFICATION_PRESENTATIONS = {
25
+ toast: { surface: 'delegate', transient: true },
26
+ snackbar: { surface: 'snackbar', transient: true },
27
+ banner: { surface: 'banner', transient: false },
28
+ alert: { surface: 'alert', transient: false },
29
+ inline: { surface: 'inline', transient: false },
30
+ };
31
+ /**
32
+ * Resolve an authored `displayType` to the presentation that renders it:
33
+ * the spec default (`toast`) when absent, and `alert` for the deprecated
34
+ * `modal` dialect.
35
+ */
36
+ export function resolveNotificationPresentation(displayType) {
37
+ if (displayType === 'modal')
38
+ return 'alert';
39
+ if (displayType && displayType in NOTIFICATION_PRESENTATIONS)
40
+ return displayType;
41
+ return 'toast';
42
+ }
43
+ /**
44
+ * The spec vocabularies this context implements — exported for the parity
45
+ * tests (#2942), which fail the moment `NotificationTypeSchema` /
46
+ * `NotificationPositionSchema` and these sets drift in either direction.
47
+ */
48
+ export const SUPPORTED_NOTIFICATION_DISPLAY_TYPES = new Set(Object.keys(NOTIFICATION_PRESENTATIONS));
49
+ export const SUPPORTED_NOTIFICATION_POSITIONS = new Set([
50
+ 'top_left', 'top_center', 'top_right', 'bottom_left', 'bottom_center', 'bottom_right',
51
+ ]);
52
+ /** The spec vocabulary this context implements — exported for the parity test. */
53
+ export const SUPPORTED_NOTIFICATION_ACTION_VARIANTS = new Set([
54
+ 'primary', 'secondary', 'link',
55
+ ]);
56
+ /**
57
+ * Fold the deprecated spellings into the spec ones and apply the spec defaults
58
+ * (`NotificationConfigSchema`: 5000ms / 5 visible / stack down / pause on
59
+ * hover). `defaultPosition` gets no fabricated default — see the field docs.
60
+ */
61
+ export function resolveNotificationConfig(config = {}) {
62
+ return {
63
+ defaultPosition: config.defaultPosition ?? config.position,
64
+ defaultDuration: config.defaultDuration ?? 5000,
65
+ // `stacking: false` means "don't stack" — one visible item.
66
+ maxVisible: config.stacking === false ? 1 : config.maxVisible ?? 5,
67
+ stackDirection: config.stackDirection ?? 'down',
68
+ pauseOnHover: config.pauseOnHover ?? true,
69
+ };
70
+ }
17
71
  let notificationCounter = 0;
72
+ /** Dev-build check — same idiom as `SchemaRenderer`'s `__DEV__`. */
73
+ const __DEV__ = (() => {
74
+ try {
75
+ return globalThis.process?.env?.NODE_ENV
76
+ !== 'production';
77
+ }
78
+ catch {
79
+ return true;
80
+ }
81
+ })();
82
+ /** Dev-only guidance for a presentation raised with no surface mounted. */
83
+ const SURFACE_HINTS = {
84
+ delegate: 'pass `onToast` to <NotificationProvider>',
85
+ snackbar: 'mount <NotificationSnackbar /> from @object-ui/components',
86
+ banner: 'mount <NotificationBanners /> from @object-ui/components',
87
+ alert: 'mount <NotificationAlerts /> from @object-ui/components',
88
+ inline: 'mount <NotificationInline /> from @object-ui/components at the raising surface',
89
+ };
18
90
  const NotificationCtx = createContext(null);
19
91
  /**
20
92
  * NotificationProvider — Provides a spec-driven notification system.
21
93
  *
94
+ * Each spec display type is presented by its own surface. `toast` is delegated
95
+ * to the host (`onToast`); the other four are rendered by surface components
96
+ * that subscribe to this context — mount them where they belong:
97
+ * `NotificationSnackbar` / `NotificationAlerts` anywhere inside the provider,
98
+ * `NotificationBanners` at the top of the content area, and
99
+ * `NotificationInline` in the surface that raises the notification.
100
+ *
22
101
  * @example
23
102
  * ```tsx
24
103
  * <NotificationProvider
25
- * config={{ position: 'top-right', defaultDuration: 5000, maxVisible: 5 }}
104
+ * config={{ position: 'top_right', defaultDuration: 5000, maxVisible: 5 }}
26
105
  * onToast={(n) => toast[n.severity](n.title, { description: n.message })}
27
106
  * >
107
+ * <NotificationBanners />
28
108
  * <App />
109
+ * <NotificationSnackbar />
110
+ * <NotificationAlerts />
29
111
  * </NotificationProvider>
30
112
  * ```
31
113
  */
32
114
  export const NotificationProvider = ({ children, config: userConfig = {}, onToast, }) => {
33
- const config = useMemo(() => ({
34
- position: 'top-right',
35
- defaultDuration: 5000,
36
- maxVisible: 5,
37
- stacking: true,
38
- ...userConfig,
39
- }),
115
+ const config = useMemo(() => resolveNotificationConfig(userConfig),
40
116
  // eslint-disable-next-line react-hooks/exhaustive-deps
41
117
  [JSON.stringify(userConfig)]);
42
118
  const [notifications, setNotifications] = useState([]);
119
+ // Surfaces currently mounted, by presentation surface. A ref (not state) so
120
+ // `notify` can read the live set without re-creating itself on every mount.
121
+ const mountedSurfaces = useRef(new Map());
122
+ // Live auto-dismiss timers, so `pauseOnHover` can hold one and resume it with
123
+ // the time it had left. A plain `setTimeout` could only ever be cancelled,
124
+ // which is why the spec's `pauseOnHover` had nothing to attach to.
125
+ const timers = useRef(new Map());
126
+ const scheduleAutoDismiss = useCallback((id, ms) => {
127
+ const handle = setTimeout(() => {
128
+ timers.current.delete(id);
129
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
130
+ }, ms);
131
+ timers.current.set(id, { handle, endsAt: Date.now() + ms, left: ms });
132
+ }, []);
133
+ const pauseAutoDismiss = useCallback((id) => {
134
+ const timer = timers.current.get(id);
135
+ if (!timer)
136
+ return;
137
+ clearTimeout(timer.handle);
138
+ timers.current.set(id, { ...timer, left: Math.max(0, timer.endsAt - Date.now()) });
139
+ }, []);
140
+ const resumeAutoDismiss = useCallback((id) => {
141
+ const timer = timers.current.get(id);
142
+ if (!timer)
143
+ return;
144
+ clearTimeout(timer.handle);
145
+ scheduleAutoDismiss(id, timer.left);
146
+ }, [scheduleAutoDismiss]);
147
+ // Never leave a timer running past unmount.
148
+ useEffect(() => () => {
149
+ for (const { handle } of timers.current.values())
150
+ clearTimeout(handle);
151
+ timers.current.clear();
152
+ }, []);
153
+ const registerSurface = useCallback((surface) => {
154
+ const counts = mountedSurfaces.current;
155
+ counts.set(surface, (counts.get(surface) ?? 0) + 1);
156
+ return () => {
157
+ const next = (counts.get(surface) ?? 1) - 1;
158
+ if (next > 0)
159
+ counts.set(surface, next);
160
+ else
161
+ counts.delete(surface);
162
+ };
163
+ }, []);
43
164
  const notify = useCallback((input) => {
44
165
  const id = `notification-${++notificationCounter}`;
166
+ // Materialize the declared presentation (spec default: toast; the legacy
167
+ // `modal` dialect presents as its nearest spec family, alert) so every
168
+ // stored item carries the presentation that will render it (#2942).
169
+ const presentation = resolveNotificationPresentation(input.displayType);
170
+ const { surface, transient } = NOTIFICATION_PRESENTATIONS[presentation];
45
171
  const notification = {
46
172
  ...input,
173
+ displayType: presentation,
47
174
  id,
48
175
  createdAt: new Date(),
49
176
  read: false,
50
177
  };
51
178
  setNotifications((prev) => [notification, ...prev]);
52
- // Delegate to external toast handler if provided
53
- if (onToast) {
54
- onToast(notification);
55
- }
56
- // Auto-dismiss non-persistent notifications
57
- const duration = input.duration ?? config.defaultDuration ?? 5000;
58
- if (duration > 0) {
59
- setTimeout(() => {
60
- setNotifications((prev) => prev.filter((n) => n.id !== id));
61
- }, duration);
179
+ // Route to the presentation's surface. `onToast` is the `toast` surface —
180
+ // it used to receive EVERY type, so a banner/alert/inline notification
181
+ // presented as a toast (#3014). The other four are rendered by the
182
+ // surface components subscribing through the context below.
183
+ if (surface === 'delegate')
184
+ onToast?.(notification, config);
185
+ // A surface-rendered presentation with no surface mounted is invisible —
186
+ // the "silently absent" shape this whole issue is about. Say so loudly in
187
+ // dev. `toast` is exempt: a provider with no `onToast` is the supported
188
+ // store-only mode (a notification centre with no overlay).
189
+ if (__DEV__ && surface !== 'delegate' && (mountedSurfaces.current.get(surface) ?? 0) === 0) {
190
+ console.warn(`[NotificationProvider] "${notification.title}" declares displayType ` +
191
+ `'${presentation}' but no ${presentation} surface is mounted, so it will ` +
192
+ `not be shown. To present it, ${SURFACE_HINTS[surface]}.`);
62
193
  }
194
+ // Auto-dismiss transient presentations. A banner/alert/inline stays until
195
+ // it is dismissed unless the raiser asked for a duration explicitly —
196
+ // sharing the toast timer made "persistent" presentations evaporate.
197
+ const duration = input.duration ?? (transient ? config.defaultDuration : 0);
198
+ if (duration > 0)
199
+ scheduleAutoDismiss(id, duration);
63
200
  return id;
64
- }, [config.defaultDuration, onToast]);
201
+ }, [config, onToast, scheduleAutoDismiss]);
65
202
  const info = useCallback((title, message) => notify({ title, message, severity: 'info' }), [notify]);
66
203
  const success = useCallback((title, message) => notify({ title, message, severity: 'success' }), [notify]);
67
204
  const warning = useCallback((title, message) => notify({ title, message, severity: 'warning' }), [notify]);
@@ -73,6 +210,13 @@ export const NotificationProvider = ({ children, config: userConfig = {}, onToas
73
210
  setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
74
211
  }, []);
75
212
  const dismiss = useCallback((id) => {
213
+ // Drop any pending auto-dismiss with it — a held (hovered) timer would
214
+ // otherwise sit in the map until unmount.
215
+ const timer = timers.current.get(id);
216
+ if (timer) {
217
+ clearTimeout(timer.handle);
218
+ timers.current.delete(id);
219
+ }
76
220
  setNotifications((prev) => prev.filter((n) => n.id !== id));
77
221
  }, []);
78
222
  const clearAll = useCallback(() => {
@@ -92,6 +236,9 @@ export const NotificationProvider = ({ children, config: userConfig = {}, onToas
92
236
  dismiss,
93
237
  clearAll,
94
238
  config,
239
+ pauseAutoDismiss,
240
+ resumeAutoDismiss,
241
+ registerSurface,
95
242
  }), [
96
243
  notifications,
97
244
  unreadCount,
@@ -105,6 +252,9 @@ export const NotificationProvider = ({ children, config: userConfig = {}, onToas
105
252
  dismiss,
106
253
  clearAll,
107
254
  config,
255
+ pauseAutoDismiss,
256
+ resumeAutoDismiss,
257
+ registerSurface,
108
258
  ]);
109
259
  return (_jsx(NotificationCtx.Provider, { value: value, children: children }));
110
260
  };
@@ -128,3 +278,55 @@ export function useNotifications() {
128
278
  export function useHasNotificationProvider() {
129
279
  return useContext(NotificationCtx) !== null;
130
280
  }
281
+ /**
282
+ * Apply the config's stack rules to a surface's items: keep the newest
283
+ * `maxVisible`, then order them so the stack GROWS in `stackDirection`
284
+ * (`down`, the spec default, puts the newest below the older ones).
285
+ *
286
+ * Shared by the stacking surfaces (banner, inline) so `maxVisible` means one
287
+ * thing. It used to mean nothing at all — the config carried it and no surface
288
+ * read it, while `NotificationBanners` capped at a hard-coded 3 of its own.
289
+ *
290
+ * @param items the surface's notifications, newest-first
291
+ */
292
+ export function visibleNotificationStack(items, config) {
293
+ // Cap BEFORE ordering — an over-long stack drops its oldest, never its newest.
294
+ const capped = config.maxVisible > 0 ? items.slice(0, config.maxVisible) : items;
295
+ return config.stackDirection === 'up' ? capped : [...capped].reverse();
296
+ }
297
+ /**
298
+ * The screen position a FLOATING notification (toast, snackbar) should take:
299
+ * its own `position`, else the configured `defaultPosition`, else `undefined`.
300
+ *
301
+ * `undefined` is a meaningful answer, not a missing one — it means "nothing was
302
+ * declared", and the surface should keep its own anchor (a snackbar's bottom
303
+ * edge) or defer to the host's toast chrome, rather than being moved by a
304
+ * default nobody asked for. Always returns the spec (underscore) spelling.
305
+ */
306
+ export function resolveNotificationPosition(notification, config) {
307
+ const declared = notification.position ?? config?.defaultPosition;
308
+ if (!declared)
309
+ return undefined;
310
+ const underscored = declared.replace(/-/g, '_');
311
+ return SUPPORTED_NOTIFICATION_POSITIONS.has(underscored) ? underscored : undefined;
312
+ }
313
+ /**
314
+ * Subscribe to the live notifications for ONE presentation — the contract a
315
+ * surface component implements (`@object-ui/components` ships one per spec
316
+ * type). Registering also tells the provider the surface exists, so raising a
317
+ * `banner` with no banner surface mounted warns in dev instead of vanishing.
318
+ *
319
+ * Items come back newest-first, matching `notifications`. A surface that wants
320
+ * FIFO order (an `alert` queue) reverses them itself.
321
+ *
322
+ * @param presentation the spec display type this surface renders
323
+ * @param scope optional `inline` routing key — when set, only items raised with
324
+ * the same `scope` are returned; when omitted, only unscoped items are.
325
+ */
326
+ export function useNotificationsByPresentation(presentation, scope) {
327
+ const { notifications, registerSurface } = useNotifications();
328
+ const { surface } = NOTIFICATION_PRESENTATIONS[presentation];
329
+ useEffect(() => registerSurface(surface), [registerSurface, surface]);
330
+ return useMemo(() => notifications.filter((n) => resolveNotificationPresentation(n.displayType) === presentation &&
331
+ (presentation !== 'inline' || (n.scope ?? undefined) === scope)), [notifications, presentation, scope]);
332
+ }
@@ -131,14 +131,10 @@ export function ThemeProvider({ themes: themesInput = [], defaultTheme, defaultM
131
131
  // Apply mode class
132
132
  el.classList.remove('light', 'dark');
133
133
  el.classList.add(resolvedMode);
134
- // Apply logo / favicon
135
- if (resolvedTheme?.logo?.favicon && typeof document !== 'undefined') {
136
- const link = document.querySelector('link[rel="icon"]')
137
- ?? document.querySelector('#favicon');
138
- if (link) {
139
- link.href = resolvedTheme.logo.favicon;
140
- }
141
- }
134
+ // No favicon here: spec v17 (#3494) pruned `Theme.logo`, so the key is
135
+ // stripped at parse and could never arrive. The live favicon comes from
136
+ // operator branding (`getFaviconUrl()`), applied in the console's
137
+ // index.html, main.tsx, and on route change.
142
138
  return () => {
143
139
  // Cleanup: remove injected vars
144
140
  for (const prop of Object.keys(cssVars)) {
@@ -6,6 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  export * from './useExpression';
9
+ export * from './useFilterScope';
9
10
  export * from './useActionRunner';
10
11
  export * from './useNavigationOverlay';
11
12
  export * from './usePageVariables';
@@ -19,7 +20,6 @@ export * from './useKeyboardShortcuts';
19
20
  export * from './useCrudShortcuts';
20
21
  export * from './useReducedMotion';
21
22
  export * from './useAnimation';
22
- export * from './useColumnSummary';
23
23
  export * from './useDensityMode';
24
24
  export * from './useViewSharing';
25
25
  export * from './useClientNotifications';
@@ -33,6 +33,7 @@ export * from './useSchemaPersistence';
33
33
  export * from './useGlobalUndo';
34
34
  export * from './useDebugMode';
35
35
  export * from './useActionEngine';
36
+ export * from './useCapabilityGate';
36
37
  export * from './useDataRefresh';
37
38
  export * from './usePageAssignment';
38
39
  export * from './useRecordSearch';
@@ -6,6 +6,8 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  export * from './useExpression';
9
+ // Session scope for filter placeholders ({current_user_id}/{current_org_id}).
10
+ export * from './useFilterScope';
9
11
  export * from './useActionRunner';
10
12
  export * from './useNavigationOverlay';
11
13
  export * from './usePageVariables';
@@ -19,7 +21,6 @@ export * from './useKeyboardShortcuts';
19
21
  export * from './useCrudShortcuts';
20
22
  export * from './useReducedMotion';
21
23
  export * from './useAnimation';
22
- export * from './useColumnSummary';
23
24
  export * from './useDensityMode';
24
25
  export * from './useViewSharing';
25
26
  export * from './useClientNotifications';
@@ -33,6 +34,7 @@ export * from './useSchemaPersistence';
33
34
  export * from './useGlobalUndo';
34
35
  export * from './useDebugMode';
35
36
  export * from './useActionEngine';
37
+ export * from './useCapabilityGate';
36
38
  export * from './useDataRefresh';
37
39
  export * from './usePageAssignment';
38
40
  export * from './useRecordSearch';
@@ -7,10 +7,39 @@
7
7
  */
8
8
  /** Animation trigger type */
9
9
  export type AnimationTriggerType = 'enter' | 'exit' | 'hover' | 'focus' | 'click';
10
- /** Easing presets aligned with EasingFunctionSchema */
11
- export type EasingPreset = 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'spring';
12
- /** Transition preset aligned with TransitionPresetSchema */
13
- export type TransitionPresetType = 'fade' | 'slide-up' | 'slide-down' | 'slide-left' | 'slide-right' | 'scale' | 'scale-fade' | 'none';
10
+ /**
11
+ * Easing presets aligned with the spec `EasingFunctionSchema`
12
+ * (`ui/animation.zod.ts` — underscore spellings). The hyphen spellings are
13
+ * this hook's historical dialect, still accepted at runtime (normalized
14
+ * before lookup) so existing configs keep animating.
15
+ */
16
+ export type EasingPreset = 'linear' | 'ease' | 'ease_in' | 'ease_out' | 'ease_in_out' | 'spring'
17
+ /** @deprecated legacy spelling — use `ease_in` */
18
+ | 'ease-in'
19
+ /** @deprecated legacy spelling — use `ease_out` */
20
+ | 'ease-out'
21
+ /** @deprecated legacy spelling — use `ease_in_out` */
22
+ | 'ease-in-out';
23
+ /**
24
+ * Transition presets aligned with the spec `TransitionPresetSchema`
25
+ * (`ui/animation.zod.ts` — underscore spellings, including `rotate` and
26
+ * `flip`). The hyphen spellings and `scale-fade` are this hook's historical
27
+ * dialect, still accepted at runtime so existing configs keep animating —
28
+ * but the spec's own spellings used to look up NOTHING here (#2942): the
29
+ * map was keyed in hyphens, so `preset: 'slide_up'` validated and rendered
30
+ * no animation at all.
31
+ */
32
+ export type TransitionPresetType = 'fade' | 'slide_up' | 'slide_down' | 'slide_left' | 'slide_right' | 'scale' | 'rotate' | 'flip' | 'none'
33
+ /** @deprecated legacy spelling — use `slide_up` */
34
+ | 'slide-up'
35
+ /** @deprecated legacy spelling — use `slide_down` */
36
+ | 'slide-down'
37
+ /** @deprecated legacy spelling — use `slide_left` */
38
+ | 'slide-left'
39
+ /** @deprecated legacy spelling — use `slide_right` */
40
+ | 'slide-right'
41
+ /** @deprecated legacy dialect (never in the spec) — use `flip` */
42
+ | 'scale-fade';
14
43
  export interface AnimationConfig {
15
44
  /** The animation/transition preset */
16
45
  preset?: TransitionPresetType;
@@ -30,6 +59,13 @@ export interface AnimationResult {
30
59
  /** Inline style for custom durations/delays (only when needed) */
31
60
  style: React.CSSProperties;
32
61
  }
62
+ /**
63
+ * The spec vocabularies this hook implements — exported for the parity tests,
64
+ * which fail the moment `TransitionPresetSchema` / `EasingFunctionSchema` and
65
+ * these sets drift in either direction (#2942).
66
+ */
67
+ export declare const SUPPORTED_TRANSITION_PRESETS: ReadonlySet<string>;
68
+ export declare const SUPPORTED_EASING_FUNCTIONS: ReadonlySet<string>;
33
69
  /**
34
70
  * Hook for applying spec-driven animations to components.
35
71
  * Implements ComponentAnimationSchema and TransitionPresetSchema
@@ -6,22 +6,48 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { useMemo } from 'react';
9
+ /**
10
+ * The spec vocabularies this hook implements — exported for the parity tests,
11
+ * which fail the moment `TransitionPresetSchema` / `EasingFunctionSchema` and
12
+ * these sets drift in either direction (#2942).
13
+ */
14
+ export const SUPPORTED_TRANSITION_PRESETS = new Set([
15
+ 'fade', 'slide_up', 'slide_down', 'slide_left', 'slide_right', 'scale', 'rotate', 'flip', 'none',
16
+ ]);
17
+ export const SUPPORTED_EASING_FUNCTIONS = new Set([
18
+ 'linear', 'ease', 'ease_in', 'ease_out', 'ease_in_out', 'spring',
19
+ ]);
20
+ /**
21
+ * Normalize a legacy hyphen spelling (this hook's pre-#2942 dialect) onto the
22
+ * spec's underscore vocabulary. `scale-fade` never existed in the spec; its
23
+ * classes were identical to `flip`'s, so it maps there.
24
+ */
25
+ function normalizePreset(preset) {
26
+ if (preset === 'scale-fade')
27
+ return 'flip';
28
+ return preset.replace(/-/g, '_');
29
+ }
30
+ // Keyed by the SPEC spellings (`ui/animation.zod.ts`). Class values for the
31
+ // shared presets are unchanged; `rotate`/`flip` follow `usePageTransition`'s
32
+ // enter classes (spin-in / fade+zoom), the sibling hook that always carried
33
+ // the full spec vocabulary.
9
34
  const EASING_MAP = {
10
35
  linear: 'linear',
11
36
  ease: 'ease',
12
- 'ease-in': 'ease-in',
13
- 'ease-out': 'ease-out',
14
- 'ease-in-out': 'ease-in-out',
37
+ ease_in: 'ease-in',
38
+ ease_out: 'ease-out',
39
+ ease_in_out: 'ease-in-out',
15
40
  spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
16
41
  };
17
42
  const PRESET_CLASSES = {
18
43
  fade: 'animate-in fade-in',
19
- 'slide-up': 'animate-in slide-in-from-bottom-2',
20
- 'slide-down': 'animate-in slide-in-from-top-2',
21
- 'slide-left': 'animate-in slide-in-from-right-2',
22
- 'slide-right': 'animate-in slide-in-from-left-2',
44
+ slide_up: 'animate-in slide-in-from-bottom-2',
45
+ slide_down: 'animate-in slide-in-from-top-2',
46
+ slide_left: 'animate-in slide-in-from-right-2',
47
+ slide_right: 'animate-in slide-in-from-left-2',
23
48
  scale: 'animate-in zoom-in-95',
24
- 'scale-fade': 'animate-in fade-in zoom-in-95',
49
+ rotate: 'animate-in spin-in-90',
50
+ flip: 'animate-in fade-in zoom-in-95',
25
51
  none: '',
26
52
  };
27
53
  /**
@@ -43,7 +69,7 @@ export function useAnimation(config = {}) {
43
69
  if (!enabled || preset === 'none') {
44
70
  return { className: '', style: {} };
45
71
  }
46
- const className = PRESET_CLASSES[preset] || '';
72
+ const className = PRESET_CLASSES[normalizePreset(preset)] || '';
47
73
  const style = {};
48
74
  if (duration !== undefined) {
49
75
  style.animationDuration = `${duration}ms`;
@@ -52,7 +78,12 @@ export function useAnimation(config = {}) {
52
78
  style.animationDelay = `${delay}ms`;
53
79
  }
54
80
  if (easing) {
55
- style.animationTimingFunction = EASING_MAP[easing] || easing;
81
+ // Spec spellings (and legacy hyphen ones) resolve through the map; an
82
+ // out-of-vocabulary string only passes through when it is plausible raw
83
+ // CSS (`cubic-bezier(…)`, `steps(…)`) — `ease_in_out` reaching the DOM
84
+ // verbatim was an invalid declaration the browser silently dropped.
85
+ style.animationTimingFunction =
86
+ EASING_MAP[normalizePreset(easing)] ?? (easing.includes('(') ? easing : undefined);
56
87
  }
57
88
  return { className, style };
58
89
  }, [preset, duration, delay, easing, enabled]);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * The caller's system capabilities, or `undefined` when the host never supplied
10
+ * them (→ callers must fail open).
11
+ */
12
+ export declare function useHeldCapabilities(): string[] | undefined;
13
+ /**
14
+ * `(requiredPermissions) => mayInvoke`. Actions declaring nothing always pass.
15
+ */
16
+ export declare function useCapabilityGate(): (required: unknown) => boolean;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * useCapabilityGate — the UI half of ADR-0066 D4's `requiredPermissions`
10
+ * contract, for surfaces that filter actions THEMSELVES.
11
+ *
12
+ * `ActionEngine.getActionsForLocation` applies this gate for everything routed
13
+ * through the engine. But several renderers filter their own action lists —
14
+ * `page:header` (record_header / record_more) and the grid's row-action menu
15
+ * (list_item) — and each of those bypassed the engine entirely, so a button
16
+ * declaring a capability nobody holds still rendered (framework#3923). One hook
17
+ * so all three surfaces gate identically and can't drift apart.
18
+ *
19
+ * Source of truth for what the caller holds: the ActionProvider's runner
20
+ * context (`user.systemPermissions`) — the same object the engine reads — with
21
+ * the ambient predicate scope as fallback for hosts that mount an
22
+ * ExpressionProvider but no action runtime.
23
+ *
24
+ * **Fail-OPEN when unknown.** No runner, no user, no `systemPermissions` array:
25
+ * the action shows. Unknown is not denied, the server is the authority
26
+ * (ADR-0057 D10), and hiding a permitted user's button on missing client data
27
+ * is the worse failure. An EMPTY array is not unknown — it means "holds
28
+ * nothing" and gates normally.
29
+ */
30
+ import { useCallback, useContext } from 'react';
31
+ import { ActionCtxReact } from '../context/ActionContext';
32
+ import { usePredicateScope } from './useExpression';
33
+ /**
34
+ * The caller's system capabilities, or `undefined` when the host never supplied
35
+ * them (→ callers must fail open).
36
+ */
37
+ export function useHeldCapabilities() {
38
+ const actionCtx = useContext(ActionCtxReact);
39
+ const predicateScope = usePredicateScope();
40
+ const runnerUser = actionCtx?.runner?.getContext?.()?.user;
41
+ if (Array.isArray(runnerUser?.systemPermissions))
42
+ return runnerUser.systemPermissions;
43
+ const scopeUser = predicateScope?.user;
44
+ if (Array.isArray(scopeUser?.systemPermissions))
45
+ return scopeUser.systemPermissions;
46
+ return undefined;
47
+ }
48
+ /**
49
+ * `(requiredPermissions) => mayInvoke`. Actions declaring nothing always pass.
50
+ */
51
+ export function useCapabilityGate() {
52
+ const held = useHeldCapabilities();
53
+ return useCallback((required) => {
54
+ if (!Array.isArray(required) || required.length === 0)
55
+ return true;
56
+ if (!Array.isArray(held))
57
+ return true; // unknown → fail open
58
+ return required.every((p) => typeof p === 'string' && held.includes(p));
59
+ }, [held]);
60
+ }
@@ -16,12 +16,6 @@ export interface UseClientNotificationsOptions {
16
16
  export interface UseClientNotificationsResult {
17
17
  /** Fetch notifications from server */
18
18
  fetchNotifications: () => Promise<void>;
19
- /** Register device for push notifications */
20
- registerDevice: (token: string, platform: string) => Promise<void>;
21
- /** Get notification preferences */
22
- getPreferences: () => Promise<any>;
23
- /** Update notification preferences */
24
- updatePreferences: (prefs: any) => Promise<void>;
25
19
  /** Mark a notification as read on the server */
26
20
  markAsRead: (id: string) => Promise<void>;
27
21
  /** Loading state */