@mdbase-dev/ui 0.1.0-beta.147 → 0.1.0-beta.149

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.
package/README.md CHANGED
@@ -102,6 +102,15 @@ import "@mdbase-dev/ui/collections.css";
102
102
  switcher: a popover, and a bottom sheet below 640px. `icon` puts the app's
103
103
  icon before the name, or `icon="mark"` the open collection's initials. In
104
104
  the `icon` variant it replaces the folder glyph.
105
+ - Pass `open` and `onOpenChange` to control the switcher; `onOpenChange` is
106
+ called for every open and close it makes itself, controlled or not.
107
+ `variant="none"` renders no trigger, for an app that opens the switcher from
108
+ its own control, such as an item in an overflow menu. It requires
109
+ `anchorRef`, an element that stays mounted while the switcher is open (the
110
+ menu's button, say): the popover is placed against it, and focus returns to
111
+ it when the switcher or a row dialog opened from it closes. Close your menu
112
+ without moving focus back to that button; the switcher takes focus as it
113
+ opens.
105
114
  - `rowDetail(row)` adds a second line to each collection row, such as where it
106
115
  lives or which account it belongs to. `row.entry` is the SDK's entry.
107
116
  - `sections` lists collections the app knows about and the SDK doesn't (local
package/css/controls.css CHANGED
@@ -786,3 +786,10 @@ textarea.mdbase-field {
786
786
  transition: none;
787
787
  }
788
788
  }
789
+
790
+ /* Mode changes stay inline; progress is text, never an optimistic toggle. */
791
+ .mdbase-collection-mode { display: grid; gap: var(--space-3); min-inline-size: 0; }
792
+ .mdbase-collection-mode :is(h3, p, ul) { margin-block: 0; }
793
+ .mdbase-collection-mode h3 { font-size: var(--font-size-section); font-weight: var(--font-weight-medium); }
794
+ .mdbase-collection-mode > div:last-child { display: flex; flex-wrap: wrap; gap: var(--space-2); }
795
+ .mdbase-collection-mode code { overflow-wrap: anywhere; }
@@ -0,0 +1,11 @@
1
+ type Mode = "private" | "cloud_copy";
2
+ /** Owner controls only. Visibility follows actual executor registration, not a
3
+ * mode flag. Browser intent cannot produce native key/source consent. */
4
+ export declare function CollectionModeControl({ collectionId, load, change, cancel, native }: {
5
+ collectionId: string;
6
+ load(): Promise<unknown>;
7
+ change(target: Mode, requestId: string): Promise<unknown>;
8
+ native?: boolean;
9
+ cancel?(target: Mode, requestId: string): Promise<unknown>;
10
+ }): import("react").JSX.Element | null;
11
+ export {};
@@ -0,0 +1,290 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { i18n, useLocaleRevision } from "./i18n.js";
4
+ function read(value) {
5
+ if (!value || typeof value !== "object") return null;
6
+ const v = value;
7
+ if (v.execution_available !== true || v.current_mode !== "private" && v.current_mode !== "cloud_copy") return null;
8
+ if (v.change !== null) {
9
+ if (!v.change || typeof v.change !== "object") return null;
10
+ const c = v.change;
11
+ if (c.target_mode !== "private" && c.target_mode !== "cloud_copy" || typeof c.phase !== "string") return null;
12
+ }
13
+ return v;
14
+ }
15
+ /** Owner controls only. Visibility follows actual executor registration, not a
16
+ * mode flag. Browser intent cannot produce native key/source consent. */
17
+ export function CollectionModeControl({
18
+ collectionId,
19
+ load,
20
+ change,
21
+ cancel,
22
+ native = false
23
+ }) {
24
+ useLocaleRevision();
25
+ const [status, setStatus] = useState(null);
26
+ const [busy, setBusy] = useState(false);
27
+ const [failure, setFailure] = useState(false);
28
+ const [step, setStep] = useState(null);
29
+ const intent = useRef(null);
30
+ const generation = useRef(0);
31
+ // Callback identity changes with parent renders; it must not restart I/O.
32
+ const callbacks = useRef({
33
+ load,
34
+ change,
35
+ cancel
36
+ });
37
+ callbacks.current = {
38
+ load,
39
+ change,
40
+ cancel
41
+ };
42
+ useEffect(() => {
43
+ const active = ++generation.current;
44
+ setStatus(null);
45
+ setStep(null);
46
+ setFailure(false);
47
+ setBusy(false);
48
+ intent.current = null;
49
+ void callbacks.current.load().then(value => {
50
+ if (generation.current === active) setStatus(read(value));
51
+ }).catch(() => {});
52
+ return () => {
53
+ ++generation.current;
54
+ };
55
+ }, [collectionId]);
56
+ async function refresh() {
57
+ const active = generation.current;
58
+ setBusy(true);
59
+ setFailure(false);
60
+ try {
61
+ const next = read(await callbacks.current.load());
62
+ if (active !== generation.current) return;
63
+ setStatus(next);
64
+ if (next && !next.change) {
65
+ setStep(null);
66
+ intent.current = null;
67
+ }
68
+ } catch {
69
+ if (active === generation.current) setFailure(true);
70
+ } finally {
71
+ if (active === generation.current) setBusy(false);
72
+ }
73
+ }
74
+ async function commit(target) {
75
+ const active = generation.current;
76
+ if (intent.current?.target !== target) intent.current = {
77
+ target,
78
+ id: crypto.randomUUID()
79
+ };
80
+ setBusy(true);
81
+ setFailure(false);
82
+ try {
83
+ const result = await callbacks.current.change(target, intent.current.id);
84
+ if (active !== generation.current) return;
85
+ const next = read(await callbacks.current.load());
86
+ if (active !== generation.current) return;
87
+ setStatus(next);
88
+ if (next && !next.change) intent.current = null;
89
+ setStep(result && typeof result === "object" ? result : null);
90
+ } catch {
91
+ if (active === generation.current) setFailure(true);
92
+ } finally {
93
+ if (active === generation.current) setBusy(false);
94
+ }
95
+ }
96
+ async function cancelPending() {
97
+ if (!status?.change?.request_id || !callbacks.current.cancel) return;
98
+ const active = generation.current;
99
+ setBusy(true);
100
+ setFailure(false);
101
+ try {
102
+ await callbacks.current.cancel(status.change.target_mode, status.change.request_id);
103
+ const next = read(await callbacks.current.load());
104
+ if (active !== generation.current) return;
105
+ setStatus(next);
106
+ setStep(null);
107
+ intent.current = null;
108
+ } catch {
109
+ if (active === generation.current) setFailure(true);
110
+ } finally {
111
+ if (active === generation.current) setBusy(false);
112
+ }
113
+ }
114
+ if (!status) return null;
115
+ const pending = status.change;
116
+ const target = pending?.target_mode ?? (status.current_mode === "private" ? "cloud_copy" : "private");
117
+ const blocked = step?.phase === "waiting_for_devices" && Array.isArray(step.devices) ? step.devices.filter(v => typeof v === "string") : [];
118
+ const retryAt = typeof step?.retry_at === "string" ? step.retry_at : typeof pending?.retry_at === "string" ? pending.retry_at : undefined;
119
+ return _jsxs("section", {
120
+ className: "mdbase-collection-mode",
121
+ "aria-label": i18n._(
122
+ /** i18n */
123
+ {
124
+ id: "4GiuPT",
125
+ message: "Collection storage mode",
126
+ context: "Collection storage mode"
127
+ }),
128
+ "aria-busy": busy,
129
+ children: [_jsx("h3", {
130
+ children: i18n._(
131
+ /** i18n */
132
+ {
133
+ id: "vAr5Dp",
134
+ message: "Storage mode",
135
+ context: "Collection storage mode"
136
+ })
137
+ }), _jsx("p", {
138
+ children: status.current_mode === "private" ? i18n._(
139
+ /** i18n */
140
+ {
141
+ id: "EIDHh1",
142
+ message: "Private",
143
+ context: "Collection storage mode"
144
+ }) : i18n._(
145
+ /** i18n */
146
+ {
147
+ id: "oKQdii",
148
+ message: "Cloud copy",
149
+ context: "Collection storage mode"
150
+ })
151
+ }), _jsx("p", {
152
+ children: target === "private" ? i18n._(
153
+ /** i18n */
154
+ {
155
+ id: "FHz4md",
156
+ message: "Only your devices can read new content after the switch finishes. mdbase may still be able to read older content.",
157
+ context: "Collection storage mode"
158
+ }) : i18n._(
159
+ /** i18n */
160
+ {
161
+ id: "bcDV8T",
162
+ message: "mdbase will be able to read the collection, including its existing history, and serve it while your devices are off.",
163
+ context: "Collection storage mode"
164
+ })
165
+ }), pending && _jsx("p", {
166
+ role: "status",
167
+ children: blocked.length > 0 ? i18n._(
168
+ /** i18n */
169
+ {
170
+ id: "3w1RWH",
171
+ message: "Waiting for devices to update.",
172
+ context: "Collection storage mode"
173
+ }) : pending.phase === "waiting_for_token_expiry" ? i18n._(
174
+ /** i18n */
175
+ {
176
+ id: "lx5IrT",
177
+ message: "Waiting for older device connections to expire. Cloud copy remains on until the switch finishes.",
178
+ context: "Collection storage mode"
179
+ }) : pending.phase === "waiting_for_device" ? i18n._(
180
+ /** i18n */
181
+ {
182
+ id: "3FYCb9",
183
+ message: "Open mdbase connect on one of the owner's devices to finish the switch.",
184
+ context: "Collection storage mode"
185
+ }) : i18n._(
186
+ /** i18n */
187
+ {
188
+ id: "13DGsZ",
189
+ message: "Finishing the switch. Keep the owner device connected.",
190
+ context: "Collection storage mode"
191
+ })
192
+ }), retryAt && Number.isFinite(Date.parse(retryAt)) && _jsx("p", {
193
+ children: _jsx("time", {
194
+ dateTime: retryAt,
195
+ children: i18n._(
196
+ /** i18n */
197
+ {
198
+ id: "ejJPcY",
199
+ message: "Continue after {0}.",
200
+ context: "Collection storage mode",
201
+ values: {
202
+ 0: new Date(retryAt).toLocaleString()
203
+ }
204
+ })
205
+ })
206
+ }), blocked.length > 0 && _jsxs("div", {
207
+ role: "status",
208
+ children: [_jsx("p", {
209
+ children: i18n._(
210
+ /** i18n */
211
+ {
212
+ id: "YQ2gus",
213
+ message: "Update or revoke these devices before continuing:",
214
+ context: "Collection storage mode"
215
+ })
216
+ }), _jsx("ul", {
217
+ children: blocked.map(id => _jsx("li", {
218
+ children: _jsx("code", {
219
+ children: id
220
+ })
221
+ }, id))
222
+ })]
223
+ }), failure && _jsx("p", {
224
+ role: "alert",
225
+ children: i18n._(
226
+ /** i18n */
227
+ {
228
+ id: "Iu3ZPh",
229
+ message: "We couldn\u2019t confirm progress. Refresh, then retry. An interrupted switch may already have started.",
230
+ context: "Collection storage mode"
231
+ })
232
+ }), _jsxs("div", {
233
+ children: [(!pending || native) && _jsx("button", {
234
+ type: "button",
235
+ className: "mdbase-button",
236
+ disabled: busy,
237
+ onClick: () => void commit(target),
238
+ children: busy ? i18n._(
239
+ /** i18n */
240
+ {
241
+ id: "3xE-Bk",
242
+ message: "Working\u2026",
243
+ context: "Collection storage mode"
244
+ }) : pending ? i18n._(
245
+ /** i18n */
246
+ {
247
+ id: "JKLDUc",
248
+ message: "Continue on this device",
249
+ context: "Collection storage mode"
250
+ }) : target === "private" ? i18n._(
251
+ /** i18n */
252
+ {
253
+ id: "rO1a9-",
254
+ message: "Make private",
255
+ context: "Collection storage mode"
256
+ }) : i18n._(
257
+ /** i18n */
258
+ {
259
+ id: "nVYD2-",
260
+ message: "Turn on Cloud copy",
261
+ context: "Collection storage mode"
262
+ })
263
+ }), cancel && pending?.request_id && ["waiting_for_device", "waiting_for_token_expiry"].includes(pending.phase) && _jsx("button", {
264
+ type: "button",
265
+ className: "mdbase-button is-tertiary",
266
+ disabled: busy,
267
+ onClick: () => void cancelPending(),
268
+ children: i18n._(
269
+ /** i18n */
270
+ {
271
+ id: "VON31G",
272
+ message: "Cancel switch",
273
+ context: "Collection storage mode"
274
+ })
275
+ }), _jsx("button", {
276
+ type: "button",
277
+ className: "mdbase-button is-tertiary",
278
+ disabled: busy,
279
+ onClick: () => void refresh(),
280
+ children: i18n._(
281
+ /** i18n */
282
+ {
283
+ id: "tHHSXx",
284
+ message: "Refresh status",
285
+ context: "Collection storage mode"
286
+ })
287
+ })]
288
+ })]
289
+ });
290
+ }
@@ -1,22 +1,47 @@
1
- import { type JSX, type ReactNode } from "react";
1
+ import { type JSX, type ReactNode, type RefObject } from "react";
2
2
  import { type RowDialog } from "./parts.js";
3
3
  import { type CollectionUi, type CollectionUiOptions } from "./use-collection-ui.js";
4
- export interface CollectionSwitcherProps extends CollectionUiOptions {
5
- /** "full": name and role; "compact": name only; "icon": a glyph for toolbars. */
6
- readonly variant?: "full" | "compact" | "icon";
4
+ export type CollectionSwitcherProps = CollectionUiOptions & {
7
5
  readonly className?: string;
8
6
  /**
9
7
  * Leading content in the trigger: the app's icon, or "mark" for the open
10
8
  * collection's initials. In the "icon" variant it replaces the folder glyph.
11
9
  */
12
10
  readonly icon?: ReactNode | "mark";
13
- }
11
+ /**
12
+ * Whether the switcher is open. Leave it out and the switcher keeps its own
13
+ * state; pass it and the switcher only asks through `onOpenChange`.
14
+ */
15
+ readonly open?: boolean;
16
+ /** Called whenever the switcher opens or closes itself, controlled or not. */
17
+ readonly onOpenChange?: (open: boolean) => void;
18
+ } & ({
19
+ /** "full": name and role; "compact": name only; "icon": a glyph for toolbars; "none": no trigger. */
20
+ readonly variant?: "full" | "compact" | "icon";
21
+ readonly anchorRef?: never;
22
+ } | {
23
+ /**
24
+ * No trigger of its own: the app opens the switcher with `open` and
25
+ * `onOpenChange`, for instance from an item in its own menu.
26
+ */
27
+ readonly variant: "none";
28
+ /**
29
+ * The app's element the switcher belongs to, such as the button of the menu
30
+ * that offered it. The wide-screen popover is placed against it and focus
31
+ * returns to it on close. It must stay mounted while the switcher is open.
32
+ */
33
+ readonly anchorRef: RefObject<HTMLElement | null>;
34
+ });
14
35
  /**
15
36
  * The in-app collection switcher: a trigger that opens the approved collections
16
37
  * (one click switches, no Connect window), "Add a collection…", "Manage access"
17
38
  * and the account. A popover on wide screens, a bottom sheet on narrow ones.
39
+ *
40
+ * Pass `open` and `onOpenChange` to control it. With `variant="none"` it has no
41
+ * trigger, so an app can open it from its own control and pass that control's
42
+ * element (or the menu button that held it) as `anchorRef`.
18
43
  */
19
- export declare function CollectionSwitcher({ variant, className, icon, ...options }: CollectionSwitcherProps): JSX.Element;
44
+ export declare function CollectionSwitcher({ variant, className, icon, open: openProp, onOpenChange, anchorRef, ...options }: CollectionSwitcherProps): JSX.Element;
20
45
  /** The switcher's list and actions, also shown inline by `CollectionPanel`. */
21
46
  export declare function SwitcherContent({ ui, onDone, onDialog }: {
22
47
  readonly ui: CollectionUi;
@@ -11,17 +11,32 @@ const NARROW = "(max-width: 639.98px)";
11
11
  * The in-app collection switcher: a trigger that opens the approved collections
12
12
  * (one click switches, no Connect window), "Add a collection…", "Manage access"
13
13
  * and the account. A popover on wide screens, a bottom sheet on narrow ones.
14
+ *
15
+ * Pass `open` and `onOpenChange` to control it. With `variant="none"` it has no
16
+ * trigger, so an app can open it from its own control and pass that control's
17
+ * element (or the menu button that held it) as `anchorRef`.
14
18
  */
15
19
  export function CollectionSwitcher({
16
20
  variant = "full",
17
21
  className,
18
22
  icon,
23
+ open: openProp,
24
+ onOpenChange,
25
+ anchorRef,
19
26
  ...options
20
27
  }) {
21
28
  const ui = useCollectionUi(options);
22
- const [open, setOpen] = useState(false);
29
+ const [ownOpen, setOwnOpen] = useState(false);
30
+ const open = openProp ?? ownOpen;
31
+ const setOpen = next => {
32
+ if (next === open) return;
33
+ if (openProp === undefined) setOwnOpen(next);
34
+ onOpenChange?.(next);
35
+ };
23
36
  const [dialog, setDialog] = useState(null);
24
37
  const trigger = useRef(null);
38
+ // The element the panel is placed against and hands focus back to.
39
+ const anchor = anchorRef ?? trigger;
25
40
  const panelId = useId();
26
41
  const narrow = useNarrow();
27
42
  const {
@@ -40,20 +55,20 @@ export function CollectionSwitcher({
40
55
  }) : _jsx(CollectionsGlyph, {}) : icon;
41
56
  const close = refocus => {
42
57
  setOpen(false);
43
- if (refocus) trigger.current?.focus();
58
+ if (refocus) anchor.current?.focus();
44
59
  };
45
60
  const toDialog = next => {
46
61
  setOpen(false);
47
62
  setDialog(next);
48
63
  };
49
- // The popover that held the row is gone: a closed row dialog hands focus to the trigger.
64
+ // The popover that held the row is gone: a closed row dialog hands focus to the anchor.
50
65
  const returnFocus = useRef(false);
51
66
  useEffect(() => {
52
67
  if (dialog || !returnFocus.current) return;
53
68
  returnFocus.current = false;
54
69
  const active = document.activeElement;
55
- if (!active || active === document.body) trigger.current?.focus();
56
- }, [dialog]);
70
+ if (!active || active === document.body) anchor.current?.focus();
71
+ }, [dialog, anchor]);
57
72
  const content = _jsx(SwitcherContent, {
58
73
  ui: ui,
59
74
  onDone: () => close(false),
@@ -62,7 +77,7 @@ export function CollectionSwitcher({
62
77
  return _jsxs("span", {
63
78
  className: ["mdbase-collections", "mdbase-collections-switcher", className].filter(Boolean).join(" "),
64
79
  "data-variant": variant,
65
- children: [_jsxs("button", {
80
+ children: [variant !== "none" && _jsxs("button", {
66
81
  ref: trigger,
67
82
  type: "button",
68
83
  className: "mdbase-collections-trigger",
@@ -73,7 +88,7 @@ export function CollectionSwitcher({
73
88
  collection: currentName
74
89
  }) : copy.switchCollectionNone,
75
90
  title: name,
76
- onClick: () => setOpen(value => !value),
91
+ onClick: () => setOpen(!open),
77
92
  children: [busy === "select" || busy === "add" || view.kind === "opening" ? _jsx("span", {
78
93
  className: "mdbase-collections-spinner",
79
94
  "aria-hidden": "true"
@@ -103,7 +118,7 @@ export function CollectionSwitcher({
103
118
  }) : _jsx(SwitcherPopover, {
104
119
  id: panelId,
105
120
  ui: ui,
106
- triggerRef: trigger,
121
+ anchorRef: anchor,
107
122
  onClose: close,
108
123
  children: content
109
124
  })), _jsx(RowDialogs, {
@@ -430,11 +445,15 @@ function CheckGlyph() {
430
445
  })
431
446
  });
432
447
  }
433
- /** Wide screens: a non-modal dialog anchored to the trigger, in the top layer where supported. */
448
+ /**
449
+ * Wide screens: a non-modal dialog anchored to the trigger (or the app's
450
+ * anchor), in the top layer where supported. Pointer presses and focus on the
451
+ * anchor count as inside, so the trigger can toggle it.
452
+ */
434
453
  function SwitcherPopover({
435
454
  id,
436
455
  ui,
437
- triggerRef,
456
+ anchorRef,
438
457
  onClose,
439
458
  children
440
459
  }) {
@@ -443,10 +462,10 @@ function SwitcherPopover({
443
462
  closeRef.current = onClose;
444
463
  useLayoutEffect(() => {
445
464
  const panel = ref.current;
446
- const trigger = triggerRef.current;
447
- if (!panel || !trigger) return undefined;
465
+ const anchor = anchorRef.current;
466
+ if (!panel || !anchor) return undefined;
448
467
  if (typeof panel.showPopover === "function") panel.showPopover();
449
- const box = trigger.getBoundingClientRect();
468
+ const box = anchor.getBoundingClientRect();
450
469
  const width = Math.min(340, window.innerWidth - 16);
451
470
  panel.style.width = `${width}px`;
452
471
  const place = anchoredPlacement(box, {
@@ -467,11 +486,11 @@ function SwitcherPopover({
467
486
  return () => {
468
487
  if (typeof panel.hidePopover === "function" && panel.matches(":popover-open")) panel.hidePopover();
469
488
  };
470
- }, [triggerRef]);
489
+ }, [anchorRef]);
471
490
  useEffect(() => {
472
491
  const outside = event => {
473
492
  const target = event.target;
474
- if (target && !ref.current?.contains(target) && !triggerRef.current?.contains(target)) closeRef.current(false);
493
+ if (target && !ref.current?.contains(target) && !anchorRef.current?.contains(target)) closeRef.current(false);
475
494
  };
476
495
  const resize = () => closeRef.current(false);
477
496
  document.addEventListener("pointerdown", outside, true);
@@ -480,7 +499,7 @@ function SwitcherPopover({
480
499
  document.removeEventListener("pointerdown", outside, true);
481
500
  window.removeEventListener("resize", resize);
482
501
  };
483
- }, [triggerRef]);
502
+ }, [anchorRef]);
484
503
  return _jsx("div", {
485
504
  ref: ref,
486
505
  id: id,
@@ -497,7 +516,7 @@ function SwitcherPopover({
497
516
  },
498
517
  onBlur: event => {
499
518
  const next = event.relatedTarget;
500
- if (next && !ref.current?.contains(next) && !triggerRef.current?.contains(next)) onClose(false);
519
+ if (next && !ref.current?.contains(next) && !anchorRef.current?.contains(next)) onClose(false);
501
520
  },
502
521
  children: children
503
522
  });
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { describeError } from "@mdbase-dev/sdk/describe";
3
- import { useEffect, useState } from "react";
3
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { actionNotice, problemControl } from "./model.js";
5
5
  import { ActionButton, NoticeBlock } from "./parts.js";
6
6
  /** Quiet re-plans after a transient refusal (about a minute in all). */
@@ -21,9 +21,28 @@ export function SetupReview({
21
21
  });
22
22
  const [request, setRequest] = useState({
23
23
  options: {},
24
- notice: null
24
+ notice: null,
25
+ quiet: false
25
26
  });
26
27
  const [applying, setApplying] = useState(false);
28
+ const applyingNow = useRef(false);
29
+ const shown = useRef(state.status);
30
+ shown.current = state.status;
31
+ // Readiness re-checks whenever the collection changes (in another tab, or in
32
+ // the person's own editor). A review planned before that change can be stale:
33
+ // a restored type may now be upgradable, or a refusal resolved. Plan again,
34
+ // quietly, each time a re-check settles; an apply in flight owns the screen.
35
+ const checking = useSyncExternalStore(mdbase.subscribe, () => mdbase.getSnapshot().readiness.checking);
36
+ const wasChecking = useRef(checking);
37
+ useEffect(() => {
38
+ const settled = wasChecking.current && !checking;
39
+ wasChecking.current = checking;
40
+ // Only a review on screen is kept: planning or a failure plans again plainly.
41
+ if (settled && !applyingNow.current) setRequest(current => ({
42
+ ...current,
43
+ quiet: shown.current === "ready"
44
+ }));
45
+ }, [checking]);
27
46
  useEffect(() => {
28
47
  const controller = new AbortController();
29
48
  let timer = null;
@@ -34,14 +53,16 @@ export function SetupReview({
34
53
  ...request.options,
35
54
  signal: controller.signal
36
55
  }).then(plan => {
37
- if (!controller.signal.aborted) setState({
56
+ if (controller.signal.aborted) return;
57
+ setState(current => ({
38
58
  status: "ready",
39
59
  plan,
40
- notice: request.notice
41
- });
60
+ notice: request.quiet ? current.notice : request.notice
61
+ }));
42
62
  }, cause => {
43
63
  if (controller.signal.aborted) return;
44
64
  const described = describeError(cause);
65
+ if (request.quiet) return;
45
66
  if (described?.transient && n < TRANSIENT_REPLANS) {
46
67
  timer = setTimeout(() => attempt(n + 1), described.retryAfterMs ?? Math.min(500 * 2 ** n, 5000));
47
68
  return;
@@ -65,16 +86,25 @@ export function SetupReview({
65
86
  });
66
87
  setRequest({
67
88
  options,
68
- notice
89
+ notice,
90
+ quiet: false
69
91
  });
70
92
  };
93
+ // Nothing here can change the collection: check it again, then plan again,
94
+ // after the person has fixed it elsewhere. Readiness may open it outright.
95
+ const recheck = () => {
96
+ void ui.actions.track("assess", () => mdbase.assess()).then(() => replan(request.options), () => {});
97
+ };
71
98
  const apply = plan => {
72
99
  setApplying(true);
100
+ applyingNow.current = true;
73
101
  mdbase.setup.apply(plan).then(result => {
74
102
  setApplying(false);
103
+ applyingNow.current = false;
75
104
  if (result.outcome === "changed") replan(request.options, actionNotice(describeError("setup_changed")));
76
105
  }, cause => {
77
106
  setApplying(false);
107
+ applyingNow.current = false;
78
108
  const notice = actionNotice(describeError(cause));
79
109
  setState(current => current.status === "ready" ? {
80
110
  ...current,
@@ -122,6 +152,11 @@ export function SetupReview({
122
152
  } : null;
123
153
  const nothing = plan.status === "current" || plan.changes.length === 0 && plan.conflicts.length === 0;
124
154
  const adoptable = Object.keys(plan.adoptable).length > 0;
155
+ const applicable = !refusal && plan.canApply;
156
+ // NoticeBlock shows a refusal's own control, except `set_up` (this is setup).
157
+ const refusalActs = !!refusalNotice?.control && refusalNotice.control.kind !== "set_up";
158
+ // Never leave the review without a way forward.
159
+ const stuck = !applicable && !adoptable && !refusalActs;
125
160
  return _jsxs("div", {
126
161
  className: "mdbase-collections-setup",
127
162
  children: [state.notice && _jsx(NoticeBlock, {
@@ -156,7 +191,7 @@ export function SetupReview({
156
191
  })]
157
192
  }), _jsxs("div", {
158
193
  className: "mdbase-collections-actions",
159
- children: [!refusal && plan.canApply && _jsx(ActionButton, {
194
+ children: [applicable && _jsx(ActionButton, {
160
195
  tone: "primary",
161
196
  progress: applying ? ui.copy.setupApplying : null,
162
197
  onClick: () => apply(plan),
@@ -169,6 +204,12 @@ export function SetupReview({
169
204
  adopt: plan.adoptable
170
205
  }),
171
206
  children: ui.say("setupUseAppVersion")
207
+ }), stuck && _jsx(ActionButton, {
208
+ tone: "primary",
209
+ inert: ui.busy !== null,
210
+ progress: ui.busy === "assess" ? ui.copy.setupChecking : null,
211
+ onClick: recheck,
212
+ children: ui.copy.tryAgain
172
213
  })]
173
214
  })]
174
215
  });
@@ -4,7 +4,8 @@ export type CollectionUiPart = "panel" | "switcher" | "problem";
4
4
  export interface MountCollectionUiOptions extends CollectionUiOptions {
5
5
  /** "panel": sign-in, choosing and the list (a settings dialog); "switcher": the header trigger; "problem": the current problem with its fix. */
6
6
  readonly part: CollectionUiPart;
7
- readonly variant?: CollectionSwitcherProps["variant"];
7
+ /** "switcher": the trigger's variant. "none" needs an app anchor, so it is not mountable. */
8
+ readonly variant?: Exclude<CollectionSwitcherProps["variant"], "none">;
8
9
  /** "switcher": leading content in the trigger ("mark" for the collection's initials). */
9
10
  readonly icon?: CollectionSwitcherProps["icon"];
10
11
  /** "panel": call `mdbase.start()` when it hasn't started. Default true. */