@tai42/studio-sdk 0.11.0 → 0.13.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 (80) hide show
  1. package/dist/components/code-block.d.ts.map +1 -1
  2. package/dist/components/code-block.js +26 -13
  3. package/dist/components/code-block.js.map +1 -1
  4. package/dist/components/components.css +20 -0
  5. package/dist/components/copy-field.d.ts.map +1 -1
  6. package/dist/components/copy-field.js +8 -73
  7. package/dist/components/copy-field.js.map +1 -1
  8. package/dist/components/date-range-picker.d.ts +73 -0
  9. package/dist/components/date-range-picker.d.ts.map +1 -0
  10. package/dist/components/date-range-picker.js +146 -0
  11. package/dist/components/date-range-picker.js.map +1 -0
  12. package/dist/components/dialog.d.ts +19 -1
  13. package/dist/components/dialog.d.ts.map +1 -1
  14. package/dist/components/dialog.js +16 -13
  15. package/dist/components/dialog.js.map +1 -1
  16. package/dist/components/dirty-guard.d.ts +28 -0
  17. package/dist/components/dirty-guard.d.ts.map +1 -0
  18. package/dist/components/dirty-guard.js +93 -0
  19. package/dist/components/dirty-guard.js.map +1 -0
  20. package/dist/components/extension-picker.d.ts.map +1 -1
  21. package/dist/components/extension-picker.js +7 -0
  22. package/dist/components/extension-picker.js.map +1 -1
  23. package/dist/components/form-dialog.d.ts +45 -0
  24. package/dist/components/form-dialog.d.ts.map +1 -0
  25. package/dist/components/form-dialog.js +71 -0
  26. package/dist/components/form-dialog.js.map +1 -0
  27. package/dist/components/json-tree.d.ts +7 -1
  28. package/dist/components/json-tree.d.ts.map +1 -1
  29. package/dist/components/json-tree.js +300 -13
  30. package/dist/components/json-tree.js.map +1 -1
  31. package/dist/components/markdown.d.ts +60 -0
  32. package/dist/components/markdown.d.ts.map +1 -0
  33. package/dist/components/markdown.js +435 -0
  34. package/dist/components/markdown.js.map +1 -0
  35. package/dist/components/tabs.d.ts +3 -1
  36. package/dist/components/tabs.d.ts.map +1 -1
  37. package/dist/components/tabs.js +2 -2
  38. package/dist/components/tabs.js.map +1 -1
  39. package/dist/hooks/useApi.d.ts +56 -28
  40. package/dist/hooks/useApi.d.ts.map +1 -1
  41. package/dist/hooks/useClipboardCopy.d.ts +26 -0
  42. package/dist/hooks/useClipboardCopy.d.ts.map +1 -0
  43. package/dist/hooks/useClipboardCopy.js +72 -0
  44. package/dist/hooks/useClipboardCopy.js.map +1 -0
  45. package/dist/hooks/useSystemKinds.d.ts +55 -0
  46. package/dist/hooks/useSystemKinds.d.ts.map +1 -0
  47. package/dist/hooks/useSystemKinds.js +78 -0
  48. package/dist/hooks/useSystemKinds.js.map +1 -0
  49. package/dist/index.d.ts +11 -2
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +6 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/navigation/context.d.ts +13 -1
  54. package/dist/navigation/context.d.ts.map +1 -1
  55. package/dist/navigation/context.js +205 -7
  56. package/dist/navigation/context.js.map +1 -1
  57. package/dist/navigation/index.d.ts +2 -2
  58. package/dist/navigation/index.d.ts.map +1 -1
  59. package/dist/navigation/index.js +1 -1
  60. package/dist/navigation/index.js.map +1 -1
  61. package/dist/navigation/types.d.ts +11 -0
  62. package/dist/navigation/types.d.ts.map +1 -1
  63. package/package.json +2 -2
  64. package/src/components/code-block.tsx +72 -9
  65. package/src/components/components.css +20 -0
  66. package/src/components/copy-field.tsx +25 -81
  67. package/src/components/date-range-picker.tsx +256 -0
  68. package/src/components/dialog.tsx +59 -18
  69. package/src/components/dirty-guard.tsx +153 -0
  70. package/src/components/extension-picker.tsx +12 -0
  71. package/src/components/form-dialog.tsx +133 -0
  72. package/src/components/json-tree.tsx +477 -42
  73. package/src/components/markdown.tsx +552 -0
  74. package/src/components/tabs.tsx +10 -1
  75. package/src/hooks/useClipboardCopy.ts +105 -0
  76. package/src/hooks/useSystemKinds.tsx +97 -0
  77. package/src/index.ts +20 -0
  78. package/src/navigation/context.tsx +245 -7
  79. package/src/navigation/index.ts +2 -0
  80. package/src/navigation/types.ts +12 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `useFeatureOff` — the PROACTIVE read of a DB-backed feature's OFF state, off the
3
+ * system kind-status table (`GET /api/system/kinds`), fetched once per auth flip and
4
+ * held as SDK state.
5
+ *
6
+ * A deployment leaves a database-backed feature cleanly OFF by configuring no store
7
+ * for it; the kind-status table then reports that kind's `state` as `off` (rather
8
+ * than `active` / `default`). A write affordance for an OFF feature reads this and
9
+ * hides itself BEFORE the user acts, instead of learning OFF only from a write's 501
10
+ * refusal — the reactive `isFeatureDisabled` swallow stays as the backstop for a
11
+ * stale table.
12
+ *
13
+ * A PLAIN fetch state machine (`loading` / `ready` / `failed`), NOT TanStack Query:
14
+ * the SDK is the shared leaf across the plugin boundary and holds no query context,
15
+ * exactly as `useCapabilities` does. It is DELIBERATELY non-blocking — a kind counts
16
+ * as not-off until the table is `ready`, and a failed fetch leaves every kind not-off
17
+ * so the surface falls back to its reactive 501 handling, never a hard-failed page.
18
+ */
19
+ import {
20
+ createContext,
21
+ createElement,
22
+ useContext,
23
+ useEffect,
24
+ useState,
25
+ type ReactNode,
26
+ } from 'react';
27
+ import type { KindStatus } from '@tai42/api-client';
28
+
29
+ import { useApi } from './useApi';
30
+ import { useAuth } from './useAuth';
31
+
32
+ export type SystemKindsState =
33
+ | { readonly status: 'loading' }
34
+ | { readonly status: 'ready'; readonly kinds: readonly KindStatus[] }
35
+ | { readonly status: 'failed'; readonly error: unknown };
36
+
37
+ const SystemKindsContext = createContext<SystemKindsState | null>(null);
38
+
39
+ /**
40
+ * Fetch the kind-status table once the caller is authenticated and hold it as SDK
41
+ * state. Gated on `isAuthenticated` (the table is an authed read), so the login
42
+ * screen never fires a doomed request; a fetch failure settles into `failed`
43
+ * QUIETLY — no unauthorized routing, no loud error — because the consuming
44
+ * affordance falls back to its reactive OFF handling.
45
+ */
46
+ export function SystemKindsProvider({ children }: { children: ReactNode }) {
47
+ const { isAuthenticated } = useAuth();
48
+ const api = useApi();
49
+ const [state, setState] = useState<SystemKindsState>({ status: 'loading' });
50
+
51
+ useEffect(() => {
52
+ if (!isAuthenticated) {
53
+ // No credential → nothing to read. Reset to the neutral `loading` state so the
54
+ // next sign-in re-fetches cleanly; every kind reads not-off meanwhile.
55
+ setState({ status: 'loading' });
56
+ return;
57
+ }
58
+ const controller = new AbortController();
59
+ setState({ status: 'loading' });
60
+ api.getSystemKinds(controller.signal).then(
61
+ (kinds) => {
62
+ if (!controller.signal.aborted) setState({ status: 'ready', kinds });
63
+ },
64
+ (error: unknown) => {
65
+ if (!controller.signal.aborted) setState({ status: 'failed', error });
66
+ },
67
+ );
68
+ return () => {
69
+ controller.abort();
70
+ };
71
+ }, [api, isAuthenticated]);
72
+
73
+ return createElement(SystemKindsContext.Provider, { value: state }, children);
74
+ }
75
+
76
+ /**
77
+ * The live kind-status state. Falls back to `loading` when no provider is mounted, so
78
+ * a consumer rendered outside the shell reads every kind as not-off rather than
79
+ * throwing — the fail-open default this proactive-hiding idiom relies on.
80
+ */
81
+ export function useSystemKinds(): SystemKindsState {
82
+ return useContext(SystemKindsContext) ?? { status: 'loading' };
83
+ }
84
+
85
+ /**
86
+ * Whether `kind` is reported OFF by the kind-status table — the single proactive
87
+ * predicate a write affordance folds in to hide itself before a doomed write. It is
88
+ * `true` ONLY once the table is `ready` AND that kind's row carries `state: 'off'`;
89
+ * while the table is loading/failed, no provider is mounted, or the kind is unknown,
90
+ * it is `false` (treat as not-off until known — under-hiding is safe, the reactive
91
+ * 501 backstop still catches a genuinely-off feature).
92
+ */
93
+ export function useFeatureOff(kind: string): boolean {
94
+ const state = useSystemKinds();
95
+ if (state.status !== 'ready') return false;
96
+ return state.kinds.some((row) => row.kind === kind && row.state === 'off');
97
+ }
package/src/index.ts CHANGED
@@ -64,6 +64,8 @@ export {
64
64
  coversWrite,
65
65
  } from './hooks/useCapabilities';
66
66
  export type { CapabilityState, CapabilityContextValue } from './hooks/useCapabilities';
67
+ export { SystemKindsProvider, useSystemKinds, useFeatureOff } from './hooks/useSystemKinds';
68
+ export type { SystemKindsState } from './hooks/useSystemKinds';
67
69
  export { ThemeProvider, useTheme } from './hooks/useTheme';
68
70
  export type { Theme, ThemePreference, ThemeState } from './hooks/useTheme';
69
71
  export { useBreakpoint } from './hooks/useBreakpoint';
@@ -78,6 +80,7 @@ export {
78
80
  useAppNavigate,
79
81
  useResolvePath,
80
82
  usePluginNavigation,
83
+ useNavigationGuard,
81
84
  } from './navigation';
82
85
  export type {
83
86
  AppLinkProps,
@@ -87,6 +90,7 @@ export type {
87
90
  PageProps,
88
91
  NavigationContextValue,
89
92
  PluginSearch,
93
+ NavigationGuardHandler,
90
94
  } from './navigation';
91
95
 
92
96
  // -- Utilities ---------------------------------------------------------------
@@ -146,6 +150,9 @@ export { Dialog } from './components/dialog';
146
150
  export type { DialogProps } from './components/dialog';
147
151
  export { ConfirmDialog } from './components/confirm-dialog';
148
152
  export type { ConfirmDialogProps } from './components/confirm-dialog';
153
+ export { GuardedTabs, useRegisterDirty } from './components/dirty-guard';
154
+ export { FormDialog } from './components/form-dialog';
155
+ export type { FormDialogProps } from './components/form-dialog';
149
156
  export { Drawer } from './components/drawer';
150
157
  export type { DrawerProps } from './components/drawer';
151
158
  export { Tooltip } from './components/tooltip';
@@ -210,6 +217,19 @@ export type {
210
217
  } from './components/folder-nav';
211
218
  export { CodeBlock } from './components/code-block';
212
219
  export type { CodeBlockProps } from './components/code-block';
220
+ export { Markdown, parseMarkdown } from './components/markdown';
221
+ export type { MarkdownProps, MarkdownBlock, HeadingLevel } from './components/markdown';
222
+ export {
223
+ DateRangePicker,
224
+ formatRangeLabel,
225
+ normalizeCustomRange,
226
+ DEFAULT_DATE_RANGE_PRESETS,
227
+ } from './components/date-range-picker';
228
+ export type {
229
+ DateRangePickerProps,
230
+ DateRangePreset,
231
+ DateRangeValue,
232
+ } from './components/date-range-picker';
213
233
  export { FleetReport } from './components/fleet-report';
214
234
  export type { FleetReportProps } from './components/fleet-report';
215
235
  export { ExternalLinkButton, isSafeHttpUrl } from './components/external-link';
@@ -3,20 +3,105 @@ import {
3
3
  createElement,
4
4
  useCallback,
5
5
  useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
6
10
  type AriaAttributes,
7
11
  type MouseEvent,
8
12
  type ReactNode,
9
13
  } from 'react';
10
14
 
11
- import type { NavigationContextValue, RouteSearch, RouteToken } from './types';
15
+ import type {
16
+ NavigationContextValue,
17
+ NavigationGuardHandler,
18
+ RouteSearch,
19
+ RouteToken,
20
+ } from './types';
21
+
22
+ /** Shell-provided navigation; no default, so use outside {@link NavigationProvider} throws. */
23
+ const NavigationContext = createContext<NavigationContextValue | null>(null);
12
24
 
13
25
  /**
14
- * Shell-provided navigation. The context has no default: a feature rendered
15
- * outside the shell's {@link NavigationProvider} is a wiring bug, so
16
- * {@link useNavigation} raises loudly rather than silently no-op'ing a
17
- * transition.
26
+ * One armed navigation guard. The registry holds this exact object (not a
27
+ * snapshot), so flipping `when` or swapping `handler` on re-render takes effect
28
+ * without re-registering.
18
29
  */
19
- const NavigationContext = createContext<NavigationContextValue | null>(null);
30
+ interface GuardEntry {
31
+ when: boolean;
32
+ handler: NavigationGuardHandler;
33
+ }
34
+
35
+ /**
36
+ * The guards active under one {@link NavigationProvider} — the single registry
37
+ * every SDK-controlled navigation consults, so feature and plugin-page guards
38
+ * compose against the same set. `run` stops at the first veto (at most one confirm
39
+ * dialog); `subscribe` re-evaluates browser-level listeners when the armed set changes.
40
+ */
41
+ class NavigationGuardRegistry {
42
+ private readonly guards = new Set<GuardEntry>();
43
+ private readonly listeners = new Set<() => void>();
44
+
45
+ /** Register a guard; returns the unregister function. */
46
+ add(entry: GuardEntry): () => void {
47
+ this.guards.add(entry);
48
+ this.emit();
49
+ return () => {
50
+ this.guards.delete(entry);
51
+ this.emit();
52
+ };
53
+ }
54
+
55
+ /** Notify subscribers that a guard's `when` may have changed. */
56
+ touch(): void {
57
+ this.emit();
58
+ }
59
+
60
+ subscribe(listener: () => void): () => void {
61
+ this.listeners.add(listener);
62
+ return () => {
63
+ this.listeners.delete(listener);
64
+ };
65
+ }
66
+
67
+ hasArmedGuards(): boolean {
68
+ for (const entry of this.guards) {
69
+ if (entry.when) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /** Resolve `true` only if every armed guard allows the navigation. */
75
+ async run(): Promise<boolean> {
76
+ for (const entry of this.guards) {
77
+ if (!entry.when) continue;
78
+ const allowed = await entry.handler();
79
+ if (!allowed) return false;
80
+ }
81
+ return true;
82
+ }
83
+
84
+ private emit(): void {
85
+ for (const listener of this.listeners) listener();
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Carries the provider's guard registry to {@link useNavigationGuard}. Separate from
91
+ * {@link NavigationContext} so the shell-supplied value stays exactly the shell's
92
+ * contract — the registry is SDK-owned.
93
+ */
94
+ const GuardRegistryContext = createContext<NavigationGuardRegistry | null>(null);
95
+
96
+ function useGuardRegistry(): NavigationGuardRegistry {
97
+ const registry = useContext(GuardRegistryContext);
98
+ if (registry === null) {
99
+ throw new Error(
100
+ 'useNavigationGuard must be used within a <NavigationProvider> (the shell provides it).',
101
+ );
102
+ }
103
+ return registry;
104
+ }
20
105
 
21
106
  export function NavigationProvider({
22
107
  value,
@@ -25,7 +110,136 @@ export function NavigationProvider({
25
110
  value: NavigationContextValue;
26
111
  children: ReactNode;
27
112
  }): ReactNode {
28
- return createElement(NavigationContext.Provider, { value }, children);
113
+ const registryRef = useRef<NavigationGuardRegistry | null>(null);
114
+ registryRef.current ??= new NavigationGuardRegistry();
115
+ const registry = registryRef.current;
116
+
117
+ // The last committed history entry the SDK observed (URL + `history.state`): what a
118
+ // canceled back/forward restores to. URL changes made outside the SDK's navigate
119
+ // entry points are unobservable by design at this layer.
120
+ const committedRef = useRef<{ href: string; state: unknown } | null>(null);
121
+
122
+ // Mirror the registry's armed state into React so the browser listeners attach only
123
+ // while at least one guard is armed.
124
+ const [armed, setArmed] = useState(false);
125
+ useEffect(() => {
126
+ setArmed(registry.hasArmedGuards());
127
+ return registry.subscribe(() => {
128
+ setArmed(registry.hasArmedGuards());
129
+ });
130
+ }, [registry]);
131
+
132
+ useEffect(() => {
133
+ if (!armed) return;
134
+
135
+ // The protected entry (URL + state); a back/forward landing elsewhere is first
136
+ // canceled back to here while the guard is consulted.
137
+ committedRef.current = { href: window.location.href, state: window.history.state };
138
+ // Set while replaying a guard-approved back/forward, so its `popstate` passes
139
+ // through instead of being re-intercepted.
140
+ let bypass = false;
141
+ // Latched while a decision is pending, so a second Back mid-decision keeps canceling
142
+ // without starting a concurrent `registry.run()` — one gesture, one dialog.
143
+ let pending = false;
144
+ // Restore pushes stacked for the pending decision, so an approved replay unwinds
145
+ // exactly that far in one `go(-depth)`. Reset per decision.
146
+ let restores = 0;
147
+
148
+ const onPopState = () => {
149
+ if (bypass) {
150
+ bypass = false;
151
+ committedRef.current = { href: window.location.href, state: window.history.state };
152
+ return;
153
+ }
154
+ if (!registry.hasArmedGuards()) {
155
+ committedRef.current = { href: window.location.href, state: window.history.state };
156
+ return;
157
+ }
158
+ const committed = committedRef.current;
159
+ if (committed === null) return;
160
+ // Cancel the browser's move by pushing the committed entry on top of wherever it
161
+ // landed. popstate exposes no direction/distance and the router owns
162
+ // `history.state`, so restore can only push, not delete. A vetoed forward thus
163
+ // strands a stub entry — an accepted limit the next pushState truncates.
164
+ window.history.pushState(committed.state, '', committed.href);
165
+ restores += 1;
166
+ if (pending) return;
167
+ pending = true;
168
+ void registry.run().then((allowed) => {
169
+ pending = false;
170
+ const depth = restores;
171
+ restores = 0;
172
+ if (!allowed) return;
173
+ bypass = true;
174
+ window.history.go(-depth);
175
+ });
176
+ };
177
+
178
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
179
+ event.preventDefault();
180
+ // Browsers below the floor (Chrome 108) gate the prompt on a set `returnValue`,
181
+ // not `preventDefault` alone; the string is ignored. Typed deprecated, still required.
182
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- required beforeunload trigger for the supported browser range.
183
+ event.returnValue = '';
184
+ };
185
+
186
+ window.addEventListener('popstate', onPopState);
187
+ window.addEventListener('beforeunload', onBeforeUnload);
188
+ return () => {
189
+ committedRef.current = null;
190
+ window.removeEventListener('popstate', onPopState);
191
+ window.removeEventListener('beforeunload', onBeforeUnload);
192
+ };
193
+ }, [armed, registry]);
194
+
195
+ // Wrap the shell's transitions so an armed guard is consulted before they commit.
196
+ // With no guard armed the call stays synchronous — identical to the raw shell value.
197
+ const guarded = useMemo<NavigationContextValue>(() => {
198
+ const navigate: NavigationContextValue['navigate'] = (token, search) => {
199
+ if (!registry.hasArmedGuards()) {
200
+ value.navigate(token, search);
201
+ return;
202
+ }
203
+ void registry.run().then((allowed) => {
204
+ if (!allowed) return;
205
+ value.navigate(token, search);
206
+ // Advance the committed entry to the resolved destination (the router may commit
207
+ // async, so `window.location` would be stale). Its router state is unobservable
208
+ // here, hence `state: null`.
209
+ if (committedRef.current !== null) {
210
+ committedRef.current = { href: value.resolvePath(token, search), state: null };
211
+ }
212
+ });
213
+ };
214
+ const navigatePlugin: NavigationContextValue['navigatePlugin'] = (
215
+ pluginId,
216
+ pagePath,
217
+ params,
218
+ search,
219
+ ) => {
220
+ if (!registry.hasArmedGuards()) {
221
+ value.navigatePlugin(pluginId, pagePath, params, search);
222
+ return;
223
+ }
224
+ void registry.run().then((allowed) => {
225
+ if (!allowed) return;
226
+ value.navigatePlugin(pluginId, pagePath, params, search);
227
+ if (committedRef.current !== null) {
228
+ committedRef.current = {
229
+ href: value.resolvePluginPath(pluginId, pagePath, params, search),
230
+ state: null,
231
+ };
232
+ }
233
+ });
234
+ };
235
+ return { ...value, navigate, navigatePlugin };
236
+ }, [value, registry]);
237
+
238
+ return createElement(
239
+ GuardRegistryContext.Provider,
240
+ { value: registry },
241
+ createElement(NavigationContext.Provider, { value: guarded }, children),
242
+ );
29
243
  }
30
244
 
31
245
  function useNavigation(): NavigationContextValue {
@@ -48,6 +262,30 @@ export function useResolvePath(): NavigationContextValue['resolvePath'] {
48
262
  return useNavigation().resolvePath;
49
263
  }
50
264
 
265
+ /**
266
+ * Block navigation away from the current page while `when` is true. `handler` is
267
+ * consulted before any SDK-controlled navigation commits — a route-token navigate,
268
+ * a plugin navigate, or a browser back/forward — and the navigation proceeds only if
269
+ * it resolves `true`; a promise lets the handler drive a confirm dialog. A full-page
270
+ * unload (tab close / refresh) is additionally covered by a native `beforeunload`
271
+ * prompt that fires whenever any guard is armed.
272
+ *
273
+ * Guards compose against the provider's shared registry: a feature and a plugin page
274
+ * may each arm one, and any veto blocks.
275
+ */
276
+ export function useNavigationGuard(when: boolean, handler: NavigationGuardHandler): void {
277
+ const registry = useGuardRegistry();
278
+ const entryRef = useRef<GuardEntry>({ when, handler });
279
+ // Keep the registered entry pointing at the latest inputs without re-registering.
280
+ entryRef.current.when = when;
281
+ entryRef.current.handler = handler;
282
+
283
+ useEffect(() => registry.add(entryRef.current), [registry]);
284
+ useEffect(() => {
285
+ registry.touch();
286
+ }, [registry, when]);
287
+ }
288
+
51
289
  /**
52
290
  * Plugin-page navigation: `navigatePlugin` drives a client-side transition to a
53
291
  * runtime plugin path, `resolvePluginPath` yields its href. These are the ONLY
@@ -4,6 +4,7 @@ export {
4
4
  useAppNavigate,
5
5
  useResolvePath,
6
6
  usePluginNavigation,
7
+ useNavigationGuard,
7
8
  } from './context';
8
9
  export type { AppLinkProps } from './context';
9
10
  export type {
@@ -13,4 +14,5 @@ export type {
13
14
  PageProps,
14
15
  NavigationContextValue,
15
16
  PluginSearch,
17
+ NavigationGuardHandler,
16
18
  } from './types';
@@ -113,3 +113,15 @@ export interface NavigationContextValue {
113
113
  search?: PluginSearch,
114
114
  ) => string;
115
115
  }
116
+
117
+ /**
118
+ * A navigation guard's decision function, run while armed before any SDK-controlled
119
+ * navigation commits ({@link NavigationContextValue.navigate},
120
+ * {@link NavigationContextValue.navigatePlugin}, or a browser back/forward). Returns
121
+ * `true` to proceed, `false` to veto; a promise lets it await a confirm dialog.
122
+ *
123
+ * Guards compose — navigation proceeds only if every armed guard allows, and the first
124
+ * veto blocks (at most one dialog). A full-page unload can't await, so it is covered by
125
+ * a `beforeunload` prompt independent of this handler.
126
+ */
127
+ export type NavigationGuardHandler = () => boolean | Promise<boolean>;