@remit/ui 0.0.21 → 0.0.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,86 @@
1
+ import { Download, ExternalLink } from "lucide-react";
2
+ import { Button, ButtonLink } from "./button.js";
3
+ import { Dialog } from "./dialog.js";
4
+ import { formatReleaseDate, type ReleaseInfo } from "./self-update.js";
5
+
6
+ export interface SelfUpdateConfirmDialogProps {
7
+ open: boolean;
8
+ currentVersion: string;
9
+ release: ReleaseInfo;
10
+ onClose: () => void;
11
+ onConfirm: () => void;
12
+ }
13
+
14
+ const consequences = [
15
+ "Remit restarts. Mail stops loading for about a minute and this page will lose its connection while that happens.",
16
+ "Nothing is sent, deleted or moved. Your mail stays at your provider.",
17
+ "If the new version does not come back up, the version you are running now is restored automatically.",
18
+ ];
19
+
20
+ /**
21
+ * Consent before the server replaces itself. Reflects what will happen in the
22
+ * user's terms, in the order they will feel it, and leaves the way back open
23
+ * until the moment they commit.
24
+ */
25
+ export function SelfUpdateConfirmDialog({
26
+ open,
27
+ currentVersion,
28
+ release,
29
+ onClose,
30
+ onConfirm,
31
+ }: SelfUpdateConfirmDialogProps) {
32
+ if (!open) return null;
33
+
34
+ return (
35
+ <Dialog open onClose={onClose} title={`Install Remit ${release.version}`}>
36
+ <div className="flex max-h-[80vh] flex-col">
37
+ <header className="space-y-1 border-b border-line px-4 py-3">
38
+ <h3 className="text-sm font-semibold text-fg">
39
+ Install Remit {release.version}?
40
+ </h3>
41
+ <p className="text-xs text-fg-muted">
42
+ You are on {currentVersion}. {release.version} was released{" "}
43
+ {formatReleaseDate(release.releasedAt)}.
44
+ </p>
45
+ </header>
46
+
47
+ <div className="flex-1 space-y-3 overflow-auto px-4 py-3">
48
+ <p className="text-sm text-fg-muted">{release.summary}</p>
49
+ <ul className="space-y-2">
50
+ {consequences.map((line) => (
51
+ <li key={line} className="flex gap-2 text-sm text-fg-muted">
52
+ <span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-subtle" />
53
+ <span>{line}</span>
54
+ </li>
55
+ ))}
56
+ </ul>
57
+ <p className="text-xs text-fg-subtle">
58
+ Good moment for this: when you are not waiting on a message.
59
+ </p>
60
+ </div>
61
+
62
+ <footer className="flex flex-wrap items-center justify-end gap-2 border-t border-line px-4 py-3">
63
+ <Button variant="ghost" size="sm" onClick={onClose}>
64
+ Not now
65
+ </Button>
66
+ <ButtonLink
67
+ variant="secondary"
68
+ size="sm"
69
+ external
70
+ href={release.releaseNotesUrl}
71
+ icon={<ExternalLink className="size-3.5" />}
72
+ >
73
+ Release notes
74
+ </ButtonLink>
75
+ <Button
76
+ size="sm"
77
+ icon={<Download className="size-3.5" />}
78
+ onClick={onConfirm}
79
+ >
80
+ Install and restart
81
+ </Button>
82
+ </footer>
83
+ </div>
84
+ </Dialog>
85
+ );
86
+ }
@@ -0,0 +1,32 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface UpdateAvailableDotProps {
4
+ /** The icon or control the hint is attached to. */
5
+ children?: ReactNode;
6
+ show: boolean;
7
+ /** Read out to assistive tech in place of the bare dot. */
8
+ label?: string;
9
+ }
10
+
11
+ /**
12
+ * The only place an available update is allowed to reach outside settings: a
13
+ * dot on the way there. It carries no count, no copy and no action, so it can
14
+ * never grow into a prompt over someone's mail.
15
+ */
16
+ export function UpdateAvailableDot({
17
+ children,
18
+ show,
19
+ label = "Update available",
20
+ }: UpdateAvailableDotProps) {
21
+ return (
22
+ <span className="relative inline-flex">
23
+ {children}
24
+ {show && (
25
+ <>
26
+ <span className="absolute -right-1 -top-1 size-2 rounded-full bg-accent-2" />
27
+ <span className="sr-only">{label}</span>
28
+ </>
29
+ )}
30
+ </span>
31
+ );
32
+ }
@@ -0,0 +1,80 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { demoLogsCommand, demoRelease } from "./self-update.js";
3
+ import {
4
+ SelfUpdateProgressOverlay,
5
+ SelfUpdateUnreachableScreen,
6
+ } from "./self-update-progress-overlay.js";
7
+
8
+ const meta: Meta<typeof SelfUpdateProgressOverlay> = {
9
+ title: "Settings/Self-update restart",
10
+ component: SelfUpdateProgressOverlay,
11
+ parameters: { layout: "fullscreen" },
12
+ args: { target: demoRelease.version },
13
+ decorators: [
14
+ (Story) => (
15
+ <div className="h-dvh w-full bg-canvas p-6">
16
+ <p className="text-sm text-fg-subtle">
17
+ Settings sits here. The overlay is fixed to the window, so this stays
18
+ covered and out of tab order.
19
+ </p>
20
+ <Story />
21
+ </div>
22
+ ),
23
+ ],
24
+ };
25
+ export default meta;
26
+
27
+ type Story = StoryObj<typeof SelfUpdateProgressOverlay>;
28
+
29
+ /**
30
+ * The new version is being put in place; the running server has not gone away
31
+ * yet.
32
+ */
33
+ export const Preparing: Story = {
34
+ args: { phase: "preparing", elapsedSeconds: 6 },
35
+ };
36
+
37
+ /** The server is going down. From here the page has nothing to talk to. */
38
+ export const Restarting: Story = {
39
+ args: { phase: "restarting", elapsedSeconds: 24 },
40
+ };
41
+
42
+ /**
43
+ * Polling for a server that is not answering yet. This is the normal middle of
44
+ * an update, so it reads as waiting, not as failure.
45
+ */
46
+ export const Reconnecting: Story = {
47
+ args: { phase: "reconnecting", elapsedSeconds: 48 },
48
+ };
49
+
50
+ /**
51
+ * Past the point where "about a minute" is still true. The copy stops making
52
+ * that promise rather than repeating it.
53
+ */
54
+ export const ReconnectingTakingLong: Story = {
55
+ args: { phase: "reconnecting", elapsedSeconds: 200 },
56
+ };
57
+
58
+ /**
59
+ * Long silence. The copy still describes only what the client can observe —
60
+ * how long it has been quiet — and never what the server is doing about it.
61
+ */
62
+ export const ReconnectingStillSilent: Story = {
63
+ args: { phase: "reconnecting", elapsedSeconds: 300 },
64
+ };
65
+
66
+ /**
67
+ * The server never came back. The client cannot see the rollback from here, so
68
+ * it says what it knows and points at the machine that can answer.
69
+ */
70
+ export const NeverCameBack: StoryObj<typeof SelfUpdateUnreachableScreen> = {
71
+ render: () => (
72
+ <SelfUpdateUnreachableScreen
73
+ attemptedVersion={demoRelease.version}
74
+ previousVersion="0.9.3"
75
+ elapsedSeconds={420}
76
+ logsCommand={demoLogsCommand}
77
+ onRetryConnection={() => {}}
78
+ />
79
+ ),
80
+ };
@@ -0,0 +1,203 @@
1
+ import { AlertOctagon, Check, Loader2 } from "lucide-react";
2
+ import { type RefObject, useEffect, useRef } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { Button } from "./button.js";
5
+ import {
6
+ type UpdatePhase,
7
+ updatePhaseLabel,
8
+ updateWaitNote,
9
+ } from "./self-update.js";
10
+
11
+ const phaseOrder: UpdatePhase[] = ["preparing", "restarting", "reconnecting"];
12
+
13
+ const FOCUSABLE =
14
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
15
+
16
+ /**
17
+ * A screen whose purpose is to stop interaction has to stop it for the
18
+ * keyboard too. Focus moves in on mount and Tab cycles inside, so nothing
19
+ * behind the overlay can be reached while the server is gone.
20
+ */
21
+ function useBlockingFocus(ref: RefObject<HTMLDivElement | null>) {
22
+ useEffect(() => {
23
+ const container = ref.current;
24
+ if (!container) return;
25
+
26
+ const focusables = () =>
27
+ Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE));
28
+
29
+ (focusables()[0] ?? container).focus();
30
+
31
+ const onKeyDown = (event: KeyboardEvent) => {
32
+ if (event.key !== "Tab") return;
33
+ const items = focusables();
34
+ if (items.length === 0) {
35
+ event.preventDefault();
36
+ container.focus();
37
+ return;
38
+ }
39
+ const first = items[0];
40
+ const last = items[items.length - 1];
41
+ const active = document.activeElement;
42
+ if (event.shiftKey && (active === first || !container.contains(active))) {
43
+ event.preventDefault();
44
+ last.focus();
45
+ return;
46
+ }
47
+ if (!event.shiftKey && (active === last || !container.contains(active))) {
48
+ event.preventDefault();
49
+ first.focus();
50
+ }
51
+ };
52
+
53
+ window.addEventListener("keydown", onKeyDown, true);
54
+ return () => window.removeEventListener("keydown", onKeyDown, true);
55
+ }, [ref]);
56
+ }
57
+
58
+ const overlayShell =
59
+ "fixed inset-0 z-50 flex flex-col items-center justify-center gap-6 bg-canvas p-6 text-center outline-none";
60
+
61
+ export interface SelfUpdateProgressOverlayProps {
62
+ target: string;
63
+ phase: UpdatePhase;
64
+ elapsedSeconds: number;
65
+ }
66
+
67
+ /**
68
+ * Applying an update takes the server away, so the app genuinely cannot be
69
+ * used while it runs. Blocking here is the honest state — a background spinner
70
+ * over a dead mailbox would let a broken system look healthy.
71
+ */
72
+ export function SelfUpdateProgressOverlay({
73
+ target,
74
+ phase,
75
+ elapsedSeconds,
76
+ }: SelfUpdateProgressOverlayProps) {
77
+ const ref = useRef<HTMLDivElement>(null);
78
+ useBlockingFocus(ref);
79
+ const activeIndex = phaseOrder.indexOf(phase);
80
+
81
+ return (
82
+ <div
83
+ ref={ref}
84
+ role="dialog"
85
+ aria-modal="true"
86
+ aria-labelledby="self-update-progress-title"
87
+ tabIndex={-1}
88
+ className={overlayShell}
89
+ >
90
+ <div className="max-w-md space-y-2">
91
+ <h1
92
+ id="self-update-progress-title"
93
+ className="text-lg font-semibold text-fg"
94
+ >
95
+ Installing Remit {target}
96
+ </h1>
97
+ <p className="text-sm text-fg-muted">
98
+ Remit is restarting, so this page has no server to talk to for a
99
+ moment. Leave this open — it reconnects on its own.
100
+ </p>
101
+ </div>
102
+
103
+ <ol className="w-full max-w-sm space-y-2 text-left">
104
+ {phaseOrder.map((step, index) => {
105
+ const done = index < activeIndex;
106
+ const active = index === activeIndex;
107
+ return (
108
+ <li
109
+ key={step}
110
+ className={cn(
111
+ "flex items-center gap-3 rounded-sm border px-row-inset py-2 text-sm",
112
+ active
113
+ ? "border-line-strong bg-surface text-fg"
114
+ : "border-line bg-surface-sunken text-fg-subtle",
115
+ )}
116
+ >
117
+ {done ? (
118
+ <Check className="size-4 shrink-0 text-positive" aria-hidden />
119
+ ) : active ? (
120
+ <Loader2
121
+ className="size-4 shrink-0 animate-spin text-accent-2"
122
+ aria-hidden
123
+ />
124
+ ) : (
125
+ <span className="size-4 shrink-0 rounded-full border border-line-strong" />
126
+ )}
127
+ {updatePhaseLabel(step)}
128
+ </li>
129
+ );
130
+ })}
131
+ </ol>
132
+
133
+ <p aria-live="polite" className="max-w-md text-xs text-fg-subtle">
134
+ <span className="sr-only">{`${updatePhaseLabel(phase)}. `}</span>
135
+ {updateWaitNote(elapsedSeconds)}
136
+ </p>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ export interface SelfUpdateUnreachableScreenProps {
142
+ attemptedVersion: string;
143
+ previousVersion: string;
144
+ elapsedSeconds: number;
145
+ logsCommand: string;
146
+ onRetryConnection: () => void;
147
+ }
148
+
149
+ /**
150
+ * The server never came back. The client cannot tell whether the rollback ran,
151
+ * so it must not claim that it did — it says what it knows, what it does not,
152
+ * and where to look on the machine that is still running.
153
+ */
154
+ export function SelfUpdateUnreachableScreen({
155
+ attemptedVersion,
156
+ previousVersion,
157
+ elapsedSeconds,
158
+ logsCommand,
159
+ onRetryConnection,
160
+ }: SelfUpdateUnreachableScreenProps) {
161
+ const ref = useRef<HTMLDivElement>(null);
162
+ useBlockingFocus(ref);
163
+ const minutes = Math.max(1, Math.round(elapsedSeconds / 60));
164
+
165
+ return (
166
+ <div
167
+ ref={ref}
168
+ role="alertdialog"
169
+ aria-modal="true"
170
+ aria-labelledby="self-update-unreachable-title"
171
+ tabIndex={-1}
172
+ className={overlayShell}
173
+ >
174
+ <AlertOctagon className="size-12 shrink-0 text-danger" aria-hidden />
175
+ <div className="max-w-lg space-y-3">
176
+ <h1
177
+ id="self-update-unreachable-title"
178
+ className="text-lg font-semibold text-fg"
179
+ >
180
+ Remit has not answered since the restart
181
+ </h1>
182
+ <p className="text-sm text-fg-muted">
183
+ Installing {attemptedVersion} started {minutes} minute
184
+ {minutes === 1 ? "" : "s"} ago and the server has been silent since.
185
+ Your mail is safe at your provider — it was never part of the update.
186
+ </p>
187
+ <p className="text-sm text-fg-muted">
188
+ Remit rolls back to {previousVersion} on its own when the new version
189
+ fails to start, but this page cannot confirm that from here. Check the
190
+ server directly:
191
+ </p>
192
+ <code className="block rounded-xs bg-surface-sunken px-3 py-2 text-left text-xs text-fg-muted">
193
+ {logsCommand}
194
+ </code>
195
+ </div>
196
+ <div className="flex flex-wrap items-center justify-center gap-2">
197
+ <Button size="sm" onClick={onRetryConnection}>
198
+ Try connecting again
199
+ </Button>
200
+ </div>
201
+ </div>
202
+ );
203
+ }