@owlmeans/web-panel 0.1.18-rc.56 → 0.1.18-rc.58

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
@@ -11,7 +11,7 @@ but never started.
11
11
  ## Installation
12
12
 
13
13
  ```sh
14
- bun add @owlmeans/web-panel@^0.1.18-rc.55
14
+ bun add @owlmeans/web-panel@^0.1.18-rc.58
15
15
  ```
16
16
 
17
17
  Peer requirements (the consuming app provides these): `react`, `react-dom`,
@@ -86,7 +86,7 @@ import { commonConfig, MY_APP_WEB } from 'my-app-common'
86
86
  import type { Config } from './types.js'
87
87
 
88
88
  const cfg: Config = config(MY_APP_WEB, commonConfig as Config)
89
- // Block the screen with a "reload the page" prompt once every socket has given up reconnecting.
89
+ // Block the screen with a "try again / reload the page" prompt once a socket has given up reconnecting.
90
90
  cfg.socket = { ...cfg.socket, reloadDialog: true }
91
91
 
92
92
  export default cfg
@@ -496,7 +496,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
496
496
  your project's skill store (`.agents/skills/`):
497
497
 
498
498
  ```sh
499
- npx @owlmeans/agent-skills@^0.1.18-rc.33
499
+ npx @owlmeans/agent-skills@^0.1.18-rc.35
500
500
  ```
501
501
 
502
502
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/web-panel",
4
- "version": "0.1.18-rc.56",
5
- "generatedAt": "2026-09-22T18:27:08.247Z",
4
+ "version": "0.1.18-rc.58",
5
+ "generatedAt": "2026-09-22T21:57:18.836Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/web-panel
9
9
 
10
10
  **Layer:** Web (React)
11
- **Install:** `"@owlmeans/web-panel": "^0.1.18-rc.56"` in `dependencies`
11
+ **Install:** `"@owlmeans/web-panel": "^0.1.18-rc.58"` in `dependencies`
12
12
 
13
13
  ## Key Exports
14
14
 
@@ -19,7 +19,7 @@ user-invocable: false
19
19
  | `TopNav` / `SideNav` / `MobileNav` / `Footer` | The shell's pieces, mountable on their own |
20
20
  | `Toaster` | The application's toast surface — mounted once, in the layout |
21
21
  | `ThemeToggle` / `useColorScheme` | The light/dark switcher (`ThemeToggleProps`, `ThemeToggleLabels`) and the hook behind it (`ColorSchemeModel`) — see *Light and dark* below |
22
- | `SocketReloadDialog` | The global "reload the page" prompt (opt in via `cfg.socket.reloadDialog`) `PanelApp` mounts automatically — see below. The hook it reads, `useSocketStatus`, is imported from `@owlmeans/client-socket`, not from this package |
22
+ | `SocketReloadDialog` | The global "connection lost — try again / reload" prompt (opt in via `cfg.socket.reloadDialog`) `PanelApp` mounts automatically — see below. The hooks it reads, `useSocketStatus` and `useSocketRetry`, are imported from `@owlmeans/client-socket`, not from this package |
23
23
  | `Link` | An `<a>` addressing an entrypoint alias (or a literal `src`), with the label taken from i18n |
24
24
  | `LoginScreen` / `LocalizedLoginScreen` / `appendLoginScreen` | The identity-provider choice screen — see `login-methods`. `LoginScreen` is pure w.r.t. `locale` too, exactly like `translate`: it is a prop, defaulting to nothing, never an implicit `useLanguage()` read, because a component that reaches for i18n context directly crashes an app mounted without one. `LocalizedLoginScreen` supplies `useLanguage()`'s value when the caller does not pass its own |
25
25
  | `render(context, opts?)` | Mounts the tree inside `PanelApp`, with the browser language detector installed on the i18n instance. `opts` is `RenderOptions` plus `rootClassName` |
@@ -341,12 +341,22 @@ on every navigation. Neither does anything unless an app opts in:
341
341
  cfg.socket = { reloadDialog: true }
342
342
  ```
343
343
 
344
- Once every `ws()`/`useWs()` connection in the app has exhausted its own retry budget
344
+ Once any `ws()`/`useWs()` connection in the app has exhausted its own retry budget
345
345
  (`useSocketStatus() === 'lost'`), a global, blocking `AlertDialog` covers the screen — no Escape,
346
- no outside click, one action ("Reload page") that calls `window.location.reload()`. There is
347
- nothing else the budget-exhausted state can resolve into: the aggregate is not released on that
348
- path (see the `client-socket` skill), so the only way out is the reload. Strings are lib-tier
349
- (`useI18nLib('socket', 'reload')`), 7 languages, under `src/components/socket/i18n/`.
346
+ no outside click, two actions:
347
+
348
+ - **"Try again"** (`data-socket-retry-action`) — `useSocketRetry().retry()` revives every lost
349
+ connection (see the `client-socket` skill). The dialog stays up with the button disabled and
350
+ reading "Reconnecting…" while they retry, closes once the aggregate is `'online'`, and offers
351
+ both buttons again if it falls back to `'lost'`.
352
+ - **"Reload page"** (`data-socket-reload-action`) — `window.location.reload()`, the fallback that
353
+ always works.
354
+
355
+ The status service also retries by itself when the tab becomes visible, the window gains focus or
356
+ the browser comes back online, so a socket that died in a background tab is usually back before
357
+ anyone presses anything — an open dialog shows "Reconnecting…" through that too. Strings are
358
+ lib-tier (`useI18nLib('socket', 'reload')`: `title`, `description`, `action`, `retry`,
359
+ `retrying`), 8 languages, under `src/components/socket/i18n/`.
350
360
 
351
361
  Leave `cfg.socket.reloadDialog` unset (or `false`) for an app that would rather show its own
352
362
  inline "reconnecting…" state — `useSocketStatus()` from `@owlmeans/client-socket` serves that,
@@ -536,7 +546,7 @@ in a linked workspace, so it is the reliable scan target in both modes.
536
546
  ## Depends On
537
547
 
538
548
  - `@owlmeans/web-client`, `@owlmeans/client-panel`, `@owlmeans/client-i18n`, `@owlmeans/web-router`
539
- - `@owlmeans/client-socket` — `appendSocketStatus`, `useSocketStatus`, behind `SocketReloadDialog`
549
+ - `@owlmeans/client-socket` — `appendSocketStatus`, `useSocketStatus`, `useSocketRetry`, behind `SocketReloadDialog`
540
550
  - Peers (app-provided): `react`, `react-dom`, `react-hook-form`, `tailwindcss`, `tailwind-merge`,
541
551
  `clsx`, `class-variance-authority`, `lucide-react`, `ajv`, and the `@radix-ui/react-*` primitives
542
552
  (`alert-dialog`, `dialog`, `label`, `navigation-menu`, `progress`, `separator`, `slot`). No MUI,
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Злучэнне страчана",
4
- "description": "Не ўдалося аднавіць злучэнне пасля некалькіх спробаў. Перазагрузіце старонку, каб працягнуць.",
5
- "action": "Перазагрузіць старонку"
4
+ "description": "Не ўдалося аднавіць злучэнне пасля некалькіх спробаў. Паспрабуйце яшчэ раз або перазагрузіце старонку, каб працягнуць.",
5
+ "action": "Перазагрузіць старонку",
6
+ "retry": "Паспрабаваць яшчэ раз",
7
+ "retrying": "Паўторнае падключэнне…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Verbindung verloren",
4
- "description": "Die Verbindung konnte nach mehreren Versuchen nicht wiederhergestellt werden. Lade die Seite neu, um fortzufahren.",
5
- "action": "Seite neu laden"
4
+ "description": "Die Verbindung konnte nach mehreren Versuchen nicht wiederhergestellt werden. Versuche es erneut oder lade die Seite neu, um fortzufahren.",
5
+ "action": "Seite neu laden",
6
+ "retry": "Erneut versuchen",
7
+ "retrying": "Verbindung wird wiederhergestellt…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Connection lost",
4
- "description": "We couldn't restore the connection after several attempts. Reload the page to continue.",
5
- "action": "Reload page"
4
+ "description": "We couldn't restore the connection after several attempts. Try again, or reload the page to continue.",
5
+ "action": "Reload page",
6
+ "retry": "Try again",
7
+ "retrying": "Reconnecting…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Conexión perdida",
4
- "description": "No se pudo restablecer la conexión tras varios intentos. Recarga la página para continuar.",
5
- "action": "Recargar página"
4
+ "description": "No se pudo restablecer la conexión tras varios intentos. Vuelve a intentarlo o recarga la página para continuar.",
5
+ "action": "Recargar página",
6
+ "retry": "Reintentar",
7
+ "retrying": "Reconectando…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Connexion perdue",
4
- "description": "Nous n’avons pas pu rétablir la connexion après plusieurs tentatives. Actualisez la page pour continuer.",
5
- "action": "Actualiser la page"
4
+ "description": "Nous n’avons pas pu rétablir la connexion après plusieurs tentatives. Réessayez ou actualisez la page pour continuer.",
5
+ "action": "Actualiser la page",
6
+ "retry": "Réessayer",
7
+ "retrying": "Reconnexion…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Utracono połączenie",
4
- "description": "Nie udało się przywrócić połączenia mimo kilku prób. Odśwież stronę, aby kontynuować.",
5
- "action": "Odśwież stronę"
4
+ "description": "Nie udało się przywrócić połączenia mimo kilku prób. Spróbuj ponownie lub odśwież stronę, aby kontynuować.",
5
+ "action": "Odśwież stronę",
6
+ "retry": "Spróbuj ponownie",
7
+ "retrying": "Ponowne łączenie…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Соединение потеряно",
4
- "description": "Не удалось восстановить соединение после нескольких попыток. Перезагрузите страницу, чтобы продолжить.",
5
- "action": "Перезагрузить страницу"
4
+ "description": "Не удалось восстановить соединение после нескольких попыток. Попробуйте снова или перезагрузите страницу, чтобы продолжить.",
5
+ "action": "Перезагрузить страницу",
6
+ "retry": "Попробовать снова",
7
+ "retrying": "Переподключение…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "З'єднання втрачено",
4
- "description": "Не вдалося відновити з'єднання після кількох спроб. Перезавантажте сторінку, щоб продовжити.",
5
- "action": "Перезавантажити сторінку"
4
+ "description": "Не вдалося відновити з'єднання після кількох спроб. Спробуйте ще раз або перезавантажте сторінку, щоб продовжити.",
5
+ "action": "Перезавантажити сторінку",
6
+ "retry": "Спробувати ще раз",
7
+ "retrying": "Перепідключення…"
6
8
  }
7
9
  }
@@ -1,13 +1,17 @@
1
1
  import type { FC } from 'react';
2
2
  /**
3
- * A global, blocking "reload the page" prompt — opt in per app with `cfg.socket.reloadDialog`.
3
+ * A global, blocking "connection lost" prompt — opt in per app with `cfg.socket.reloadDialog`.
4
4
  *
5
5
  * Mount it once, above the router (`PanelApp` does), exactly like `PanelCookieConsent`: a dialog
6
6
  * mounted inside a route is torn down on every navigation, and this one is meant to survive them
7
7
  * all. It opens the moment `useSocketStatus()` reports `'lost'` — every `ws()`/`useWs()`
8
- * connection in the app has exhausted its own retry budget (10 minutes by default) — and it never
9
- * closes on its own: there is nothing left retrying that a countdown or a poll could wait out, so
10
- * the only exit this dialog offers is the one that actually fixes it.
8
+ * connection in the app has exhausted its own retry budget (10 minutes by default).
9
+ *
10
+ * Two exits: "Try again" revives every lost connection (`useSocketRetry()`), and the dialog stays
11
+ * up showing progress until they are back online — or offers both buttons again if they are lost
12
+ * again. "Reload page" is the fallback that always works. The status service also retries by
13
+ * itself when the tab or window becomes active, so a socket that died in a background tab is
14
+ * usually back before anyone has to press anything.
11
15
  *
12
16
  * Non-dismissible on two levels: Radix's `AlertDialogContent` (unlike its plain `Dialog` sibling)
13
17
  * never treats an outside click as a request to close, so there is no `onPointerDownOutside` to
@@ -1 +1 @@
1
- {"version":3,"file":"reload-dialog.d.ts","sourceRoot":"","sources":["../../../src/components/socket/reload-dialog.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,OAAO,CAAA;AAQ/B;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,kBAAkB,EAAE,EAqBhC,CAAA"}
1
+ {"version":3,"file":"reload-dialog.d.ts","sourceRoot":"","sources":["../../../src/components/socket/reload-dialog.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,OAAO,CAAA;AAS/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,kBAAkB,EAAE,EAgChC,CAAA"}
@@ -1,16 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useSocketStatus } from '@owlmeans/client-socket';
2
+ import { useSocketRetry, useSocketStatus } from '@owlmeans/client-socket';
3
3
  import { useI18nLib } from '@owlmeans/client-i18n';
4
4
  import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '../../@/components/ui/alert-dialog.js';
5
+ import { Button } from '../../@/components/ui/button.js';
5
6
  /**
6
- * A global, blocking "reload the page" prompt — opt in per app with `cfg.socket.reloadDialog`.
7
+ * A global, blocking "connection lost" prompt — opt in per app with `cfg.socket.reloadDialog`.
7
8
  *
8
9
  * Mount it once, above the router (`PanelApp` does), exactly like `PanelCookieConsent`: a dialog
9
10
  * mounted inside a route is torn down on every navigation, and this one is meant to survive them
10
11
  * all. It opens the moment `useSocketStatus()` reports `'lost'` — every `ws()`/`useWs()`
11
- * connection in the app has exhausted its own retry budget (10 minutes by default) — and it never
12
- * closes on its own: there is nothing left retrying that a countdown or a poll could wait out, so
13
- * the only exit this dialog offers is the one that actually fixes it.
12
+ * connection in the app has exhausted its own retry budget (10 minutes by default).
13
+ *
14
+ * Two exits: "Try again" revives every lost connection (`useSocketRetry()`), and the dialog stays
15
+ * up showing progress until they are back online — or offers both buttons again if they are lost
16
+ * again. "Reload page" is the fallback that always works. The status service also retries by
17
+ * itself when the tab or window becomes active, so a socket that died in a background tab is
18
+ * usually back before anyone has to press anything.
14
19
  *
15
20
  * Non-dismissible on two levels: Radix's `AlertDialogContent` (unlike its plain `Dialog` sibling)
16
21
  * never treats an outside click as a request to close, so there is no `onPointerDownOutside` to
@@ -19,8 +24,11 @@ import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescript
19
24
  */
20
25
  export const SocketReloadDialog = () => {
21
26
  const status = useSocketStatus();
27
+ const { retry, retrying } = useSocketRetry();
22
28
  const t = useI18nLib('socket', 'reload');
23
- const open = status === 'lost';
24
- return _jsx(AlertDialog, { open: open, children: _jsxs(AlertDialogContent, { "data-socket-reload-dialog": true, onEscapeKeyDown: event => event.preventDefault(), children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: t('title') }), _jsx(AlertDialogDescription, { children: t('description') })] }), _jsx(AlertDialogFooter, { children: _jsx(AlertDialogAction, { "data-socket-reload-action": true, onClick: () => window.location.reload(), children: t('action') }) })] }) });
29
+ const open = status === 'lost' || (retrying && status === 'reconnecting');
30
+ // Closing still animates the content out — keep the retry in its busy state until it is gone.
31
+ const busy = retrying || !open;
32
+ return _jsx(AlertDialog, { open: open, children: _jsxs(AlertDialogContent, { "data-socket-reload-dialog": true, onEscapeKeyDown: event => event.preventDefault(), children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: t('title') }), _jsx(AlertDialogDescription, { children: t('description') })] }), _jsxs(AlertDialogFooter, { children: [_jsx(Button, { variant: "outline", "data-socket-reload-action": true, onClick: () => window.location.reload(), children: t('action') }), _jsx(AlertDialogAction, { "data-socket-retry-action": true, disabled: busy, "aria-busy": busy, onClick: retry, children: busy ? t('retrying') : t('retry') })] })] }) });
25
33
  };
26
34
  //# sourceMappingURL=reload-dialog.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"reload-dialog.js","sourceRoot":"","sources":["../../../src/components/socket/reload-dialog.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAA;AAClD,OAAO,EACL,WAAW,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,sBAAsB,EAC1E,iBAAiB,EAAE,iBAAiB,EAAE,gBAAgB,EACvD,MAAM,uCAAuC,CAAA;AAE9C;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAO,GAAG,EAAE;IACzC,MAAM,MAAM,GAAG,eAAe,EAAE,CAAA;IAChC,MAAM,CAAC,GAAG,UAAU,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAA;IACxC,MAAM,IAAI,GAAG,MAAM,KAAK,MAAM,CAAA;IAE9B,OAAO,KAAC,WAAW,IAAC,IAAI,EAAE,IAAI,YAC5B,MAAC,kBAAkB,uCAEjB,eAAe,EAAE,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,cAAc,EAAE,aAEhD,MAAC,iBAAiB,eAChB,KAAC,gBAAgB,cAAE,CAAC,CAAC,OAAO,CAAC,GAAoB,EACjD,KAAC,sBAAsB,cAAE,CAAC,CAAC,aAAa,CAAC,GAA0B,IACjD,EACpB,KAAC,iBAAiB,cAChB,KAAC,iBAAiB,uCAA2B,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,YACjF,CAAC,CAAC,QAAQ,CAAC,GACM,GACF,IACD,GACT,CAAA;AAChB,CAAC,CAAA"}
1
+ {"version":3,"file":"reload-dialog.js","sourceRoot":"","sources":["../../../src/components/socket/reload-dialog.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AACzE,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAA;AAClD,OAAO,EACL,WAAW,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,sBAAsB,EAC1E,iBAAiB,EAAE,iBAAiB,EAAE,gBAAgB,EACvD,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EAAE,MAAM,EAAE,MAAM,iCAAiC,CAAA;AAExD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAO,GAAG,EAAE;IACzC,MAAM,MAAM,GAAG,eAAe,EAAE,CAAA;IAChC,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,cAAc,EAAE,CAAA;IAC5C,MAAM,CAAC,GAAG,UAAU,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAA;IACxC,MAAM,IAAI,GAAG,MAAM,KAAK,MAAM,IAAI,CAAC,QAAQ,IAAI,MAAM,KAAK,cAAc,CAAC,CAAA;IACzE,8FAA8F;IAC9F,MAAM,IAAI,GAAG,QAAQ,IAAI,CAAC,IAAI,CAAA;IAE9B,OAAO,KAAC,WAAW,IAAC,IAAI,EAAE,IAAI,YAC5B,MAAC,kBAAkB,uCAEjB,eAAe,EAAE,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,cAAc,EAAE,aAEhD,MAAC,iBAAiB,eAChB,KAAC,gBAAgB,cAAE,CAAC,CAAC,OAAO,CAAC,GAAoB,EACjD,KAAC,sBAAsB,cAAE,CAAC,CAAC,aAAa,CAAC,GAA0B,IACjD,EACpB,MAAC,iBAAiB,eAChB,KAAC,MAAM,IAAC,OAAO,EAAC,SAAS,qCAA2B,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,YACxF,CAAC,CAAC,QAAQ,CAAC,GACL,EACT,KAAC,iBAAiB,sCAEhB,QAAQ,EAAE,IAAI,eACH,IAAI,EACf,OAAO,EAAE,KAAK,YAEb,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,GAChB,IACF,IACD,GACT,CAAA;AAChB,CAAC,CAAA"}
package/build/types.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { SocketClientSettings, SocketStatusServiceAppend } from '@owlmeans/
5
5
  export interface AppConfig extends Config, WithFlowConfig {
6
6
  socket?: SocketClientSettings & {
7
7
  /**
8
- * Show `SocketReloadDialog` — a global, blocking "reload the page" prompt — once every
8
+ * Show `SocketReloadDialog` — a global, blocking "try again / reload the page" prompt — once every
9
9
  * socket in the app has given up reconnecting (`useSocketStatus() === 'lost'`). Off by
10
10
  * default: an app opts in once it has decided that outcome deserves a full-page prompt
11
11
  * rather than the caller's own fallback UI.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/web-panel",
3
- "version": "0.1.18-rc.56",
3
+ "version": "0.1.18-rc.58",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -51,33 +51,33 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@hookform/resolvers": "^5.2.2",
54
- "@owlmeans/api-config-client": "^0.1.18-rc.31",
55
- "@owlmeans/auth": "^0.1.18-rc.31",
56
- "@owlmeans/auth-common": "^0.1.18-rc.33",
57
- "@owlmeans/basic-envelope": "^0.1.18-rc.32",
58
- "@owlmeans/client": "^0.1.18-rc.36",
59
- "@owlmeans/client-auth": "^0.1.18-rc.43",
60
- "@owlmeans/client-config": "^0.1.18-rc.33",
61
- "@owlmeans/client-context": "^0.1.18-rc.34",
62
- "@owlmeans/client-entrypoint": "^0.1.18-rc.33",
63
- "@owlmeans/client-flow": "^0.1.18-rc.39",
64
- "@owlmeans/client-i18n": "^0.1.18-rc.35",
65
- "@owlmeans/client-panel": "^0.1.18-rc.43",
66
- "@owlmeans/client-route": "^0.1.18-rc.34",
67
- "@owlmeans/client-socket": "^0.1.18-rc.34",
68
- "@owlmeans/config": "^0.1.18-rc.33",
69
- "@owlmeans/context": "^0.1.18-rc.29",
70
- "@owlmeans/entrypoint": "^0.1.18-rc.32",
71
- "@owlmeans/error": "^0.1.18-rc.31",
72
- "@owlmeans/flow": "^0.1.18-rc.34",
73
- "@owlmeans/i18n": "^0.1.18-rc.32",
74
- "@owlmeans/route": "^0.1.18-rc.30",
75
- "@owlmeans/state": "^0.1.18-rc.30",
76
- "@owlmeans/web-client": "^0.1.18-rc.46",
77
- "@owlmeans/web-consent": "^0.1.18-rc.27",
78
- "@owlmeans/web-db": "^0.1.18-rc.34",
79
- "@owlmeans/web-flow": "^0.1.18-rc.38",
80
- "@owlmeans/web-router": "^0.1.18-rc.34",
54
+ "@owlmeans/api-config-client": "^0.1.18-rc.33",
55
+ "@owlmeans/auth": "^0.1.18-rc.33",
56
+ "@owlmeans/auth-common": "^0.1.18-rc.35",
57
+ "@owlmeans/basic-envelope": "^0.1.18-rc.34",
58
+ "@owlmeans/client": "^0.1.18-rc.38",
59
+ "@owlmeans/client-auth": "^0.1.18-rc.45",
60
+ "@owlmeans/client-config": "^0.1.18-rc.35",
61
+ "@owlmeans/client-context": "^0.1.18-rc.36",
62
+ "@owlmeans/client-entrypoint": "^0.1.18-rc.35",
63
+ "@owlmeans/client-flow": "^0.1.18-rc.41",
64
+ "@owlmeans/client-i18n": "^0.1.18-rc.37",
65
+ "@owlmeans/client-panel": "^0.1.18-rc.45",
66
+ "@owlmeans/client-route": "^0.1.18-rc.36",
67
+ "@owlmeans/client-socket": "^0.1.18-rc.36",
68
+ "@owlmeans/config": "^0.1.18-rc.35",
69
+ "@owlmeans/context": "^0.1.18-rc.31",
70
+ "@owlmeans/entrypoint": "^0.1.18-rc.34",
71
+ "@owlmeans/error": "^0.1.18-rc.33",
72
+ "@owlmeans/flow": "^0.1.18-rc.36",
73
+ "@owlmeans/i18n": "^0.1.18-rc.34",
74
+ "@owlmeans/route": "^0.1.18-rc.32",
75
+ "@owlmeans/state": "^0.1.18-rc.32",
76
+ "@owlmeans/web-client": "^0.1.18-rc.48",
77
+ "@owlmeans/web-consent": "^0.1.18-rc.29",
78
+ "@owlmeans/web-db": "^0.1.18-rc.36",
79
+ "@owlmeans/web-flow": "^0.1.18-rc.40",
80
+ "@owlmeans/web-router": "^0.1.18-rc.36",
81
81
  "i18next-browser-languagedetector": "^8.0.0",
82
82
  "qrcode.react": "^4.2.0",
83
83
  "react-google-recaptcha": "^3.1.0",
@@ -103,7 +103,7 @@
103
103
  },
104
104
  "devDependencies": {
105
105
  "@owlmeans/dep-config": "workspace:*",
106
- "@owlmeans/test-ui": "^0.1.18-rc.34",
106
+ "@owlmeans/test-ui": "^0.1.18-rc.36",
107
107
  "@radix-ui/react-alert-dialog": "^1.1.15",
108
108
  "@radix-ui/react-dialog": "^1.1.15",
109
109
  "@radix-ui/react-label": "^2.1.0",
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Злучэнне страчана",
4
- "description": "Не ўдалося аднавіць злучэнне пасля некалькіх спробаў. Перазагрузіце старонку, каб працягнуць.",
5
- "action": "Перазагрузіць старонку"
4
+ "description": "Не ўдалося аднавіць злучэнне пасля некалькіх спробаў. Паспрабуйце яшчэ раз або перазагрузіце старонку, каб працягнуць.",
5
+ "action": "Перазагрузіць старонку",
6
+ "retry": "Паспрабаваць яшчэ раз",
7
+ "retrying": "Паўторнае падключэнне…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Verbindung verloren",
4
- "description": "Die Verbindung konnte nach mehreren Versuchen nicht wiederhergestellt werden. Lade die Seite neu, um fortzufahren.",
5
- "action": "Seite neu laden"
4
+ "description": "Die Verbindung konnte nach mehreren Versuchen nicht wiederhergestellt werden. Versuche es erneut oder lade die Seite neu, um fortzufahren.",
5
+ "action": "Seite neu laden",
6
+ "retry": "Erneut versuchen",
7
+ "retrying": "Verbindung wird wiederhergestellt…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Connection lost",
4
- "description": "We couldn't restore the connection after several attempts. Reload the page to continue.",
5
- "action": "Reload page"
4
+ "description": "We couldn't restore the connection after several attempts. Try again, or reload the page to continue.",
5
+ "action": "Reload page",
6
+ "retry": "Try again",
7
+ "retrying": "Reconnecting…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Conexión perdida",
4
- "description": "No se pudo restablecer la conexión tras varios intentos. Recarga la página para continuar.",
5
- "action": "Recargar página"
4
+ "description": "No se pudo restablecer la conexión tras varios intentos. Vuelve a intentarlo o recarga la página para continuar.",
5
+ "action": "Recargar página",
6
+ "retry": "Reintentar",
7
+ "retrying": "Reconectando…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Connexion perdue",
4
- "description": "Nous n’avons pas pu rétablir la connexion après plusieurs tentatives. Actualisez la page pour continuer.",
5
- "action": "Actualiser la page"
4
+ "description": "Nous n’avons pas pu rétablir la connexion après plusieurs tentatives. Réessayez ou actualisez la page pour continuer.",
5
+ "action": "Actualiser la page",
6
+ "retry": "Réessayer",
7
+ "retrying": "Reconnexion…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Utracono połączenie",
4
- "description": "Nie udało się przywrócić połączenia mimo kilku prób. Odśwież stronę, aby kontynuować.",
5
- "action": "Odśwież stronę"
4
+ "description": "Nie udało się przywrócić połączenia mimo kilku prób. Spróbuj ponownie lub odśwież stronę, aby kontynuować.",
5
+ "action": "Odśwież stronę",
6
+ "retry": "Spróbuj ponownie",
7
+ "retrying": "Ponowne łączenie…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "Соединение потеряно",
4
- "description": "Не удалось восстановить соединение после нескольких попыток. Перезагрузите страницу, чтобы продолжить.",
5
- "action": "Перезагрузить страницу"
4
+ "description": "Не удалось восстановить соединение после нескольких попыток. Попробуйте снова или перезагрузите страницу, чтобы продолжить.",
5
+ "action": "Перезагрузить страницу",
6
+ "retry": "Попробовать снова",
7
+ "retrying": "Переподключение…"
6
8
  }
7
9
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "reload": {
3
3
  "title": "З'єднання втрачено",
4
- "description": "Не вдалося відновити з'єднання після кількох спроб. Перезавантажте сторінку, щоб продовжити.",
5
- "action": "Перезавантажити сторінку"
4
+ "description": "Не вдалося відновити з'єднання після кількох спроб. Спробуйте ще раз або перезавантажте сторінку, щоб продовжити.",
5
+ "action": "Перезавантажити сторінку",
6
+ "retry": "Спробувати ще раз",
7
+ "retrying": "Перепідключення…"
6
8
  }
7
9
  }
@@ -1,20 +1,25 @@
1
1
  import type { FC } from 'react'
2
- import { useSocketStatus } from '@owlmeans/client-socket'
2
+ import { useSocketRetry, useSocketStatus } from '@owlmeans/client-socket'
3
3
  import { useI18nLib } from '@owlmeans/client-i18n'
4
4
  import {
5
5
  AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription,
6
6
  AlertDialogFooter, AlertDialogHeader, AlertDialogTitle
7
7
  } from '../../@/components/ui/alert-dialog.js'
8
+ import { Button } from '../../@/components/ui/button.js'
8
9
 
9
10
  /**
10
- * A global, blocking "reload the page" prompt — opt in per app with `cfg.socket.reloadDialog`.
11
+ * A global, blocking "connection lost" prompt — opt in per app with `cfg.socket.reloadDialog`.
11
12
  *
12
13
  * Mount it once, above the router (`PanelApp` does), exactly like `PanelCookieConsent`: a dialog
13
14
  * mounted inside a route is torn down on every navigation, and this one is meant to survive them
14
15
  * all. It opens the moment `useSocketStatus()` reports `'lost'` — every `ws()`/`useWs()`
15
- * connection in the app has exhausted its own retry budget (10 minutes by default) — and it never
16
- * closes on its own: there is nothing left retrying that a countdown or a poll could wait out, so
17
- * the only exit this dialog offers is the one that actually fixes it.
16
+ * connection in the app has exhausted its own retry budget (10 minutes by default).
17
+ *
18
+ * Two exits: "Try again" revives every lost connection (`useSocketRetry()`), and the dialog stays
19
+ * up showing progress until they are back online — or offers both buttons again if they are lost
20
+ * again. "Reload page" is the fallback that always works. The status service also retries by
21
+ * itself when the tab or window becomes active, so a socket that died in a background tab is
22
+ * usually back before anyone has to press anything.
18
23
  *
19
24
  * Non-dismissible on two levels: Radix's `AlertDialogContent` (unlike its plain `Dialog` sibling)
20
25
  * never treats an outside click as a request to close, so there is no `onPointerDownOutside` to
@@ -23,8 +28,11 @@ import {
23
28
  */
24
29
  export const SocketReloadDialog: FC = () => {
25
30
  const status = useSocketStatus()
31
+ const { retry, retrying } = useSocketRetry()
26
32
  const t = useI18nLib('socket', 'reload')
27
- const open = status === 'lost'
33
+ const open = status === 'lost' || (retrying && status === 'reconnecting')
34
+ // Closing still animates the content out — keep the retry in its busy state until it is gone.
35
+ const busy = retrying || !open
28
36
 
29
37
  return <AlertDialog open={open}>
30
38
  <AlertDialogContent
@@ -36,8 +44,16 @@ export const SocketReloadDialog: FC = () => {
36
44
  <AlertDialogDescription>{t('description')}</AlertDialogDescription>
37
45
  </AlertDialogHeader>
38
46
  <AlertDialogFooter>
39
- <AlertDialogAction data-socket-reload-action onClick={() => window.location.reload()}>
47
+ <Button variant="outline" data-socket-reload-action onClick={() => window.location.reload()}>
40
48
  {t('action')}
49
+ </Button>
50
+ <AlertDialogAction
51
+ data-socket-retry-action
52
+ disabled={busy}
53
+ aria-busy={busy}
54
+ onClick={retry}
55
+ >
56
+ {busy ? t('retrying') : t('retry')}
41
57
  </AlertDialogAction>
42
58
  </AlertDialogFooter>
43
59
  </AlertDialogContent>
package/src/types.ts CHANGED
@@ -7,7 +7,7 @@ import type { SocketClientSettings, SocketStatusServiceAppend } from '@owlmeans/
7
7
  export interface AppConfig extends Config, WithFlowConfig {
8
8
  socket?: SocketClientSettings & {
9
9
  /**
10
- * Show `SocketReloadDialog` — a global, blocking "reload the page" prompt — once every
10
+ * Show `SocketReloadDialog` — a global, blocking "try again / reload the page" prompt — once every
11
11
  * socket in the app has given up reconnecting (`useSocketStatus() === 'lost'`). Off by
12
12
  * default: an app opts in once it has decided that outcome deserves a full-page prompt
13
13
  * rather than the caller's own fallback UI.
@@ -1,6 +1,7 @@
1
1
  import '../../src/@/globals.css'
2
2
 
3
3
  import type { FC, PropsWithChildren } from 'react'
4
+ import { useState } from 'react'
4
5
  import { createRoot } from 'react-dom/client'
5
6
  import { config } from '@owlmeans/client-context'
6
7
  import { AppType, service } from '@owlmeans/config'
@@ -184,12 +185,23 @@ const ReportsGroup: FC<PropsWithChildren> = ({ children }) => <div id="reports-g
184
185
  */
185
186
  const SocketStatusScreen: FC = () => {
186
187
  const context = useContext()
188
+ // A revive the way a real carrier's goes: the connection is retrying again the moment it is
189
+ // asked to. How that retry ends is up to the test — `#report-online` or `#report-lost`.
190
+ const [revived, setRevived] = useState(0)
191
+ const reportLost = () => context.socketStatus().report(HARNESS_SOCKET_ID, 'lost', () => {
192
+ setRevived(n => n + 1)
193
+ context.socketStatus().report(HARNESS_SOCKET_ID, 'reconnecting')
194
+ })
187
195
 
188
196
  return <div id="socket-status">
189
197
  socket-status-screen
190
- <button id="report-lost" onClick={() => context.socketStatus().report(HARNESS_SOCKET_ID, 'lost')}>
198
+ <span id="revived">{revived}</span>
199
+ <button id="report-lost" onClick={reportLost}>
191
200
  lose connection
192
201
  </button>
202
+ <button id="report-online" onClick={() => context.socketStatus().report(HARNESS_SOCKET_ID, 'online')}>
203
+ reconnected
204
+ </button>
193
205
  <button id="release-lost" onClick={() => context.socketStatus().release(HARNESS_SOCKET_ID)}>
194
206
  restore connection
195
207
  </button>
@@ -11,6 +11,19 @@ afterAll(async () => {
11
11
 
12
12
  const open = async (path: string) => mountComponent({ url: `${HARNESS_URL.replace(/\/$/, '')}${path}` })
13
13
 
14
+ type Page = Awaited<ReturnType<typeof open>>['page']
15
+
16
+ // The modal overlay covers the harness buttons and would take a pointer click itself.
17
+ const clickBehindDialog = async (page: Page, selector: string) =>
18
+ page.evaluate(sel => document.querySelector<HTMLButtonElement>(sel)?.click(), selector)
19
+
20
+ const revivedCount = async (page: Page, count: number) =>
21
+ page.waitForFunction(n => document.querySelector('#revived')?.textContent === String(n), count)
22
+
23
+ const retryDisabled = async (page: Page, disabled: boolean) => page.waitForFunction(
24
+ d => document.querySelector<HTMLButtonElement>('[data-socket-retry-action]')?.disabled === d, disabled
25
+ )
26
+
14
27
  describe('@owlmeans/web-panel — the socket reload dialog', () => {
15
28
  test('stays unmounted until the connection is reported lost', async () => {
16
29
  const { page, close } = await open('/socket')
@@ -38,6 +51,13 @@ describe('@owlmeans/web-panel — the socket reload dialog', () => {
38
51
  await page.mouse.click(2, 2)
39
52
  expect(await dialog.isVisible()).toBe(true)
40
53
 
54
+ // The secondary button's label must be readable, not the primary's text on an outline fill.
55
+ const reloadColors = await page.locator('[data-socket-reload-action]').evaluate(el => {
56
+ const style = getComputedStyle(el)
57
+ return { color: style.color, background: style.backgroundColor }
58
+ })
59
+ expect(reloadColors.color).not.toBe(reloadColors.background)
60
+
41
61
  const [navigated] = await Promise.all([
42
62
  page.waitForEvent('load'),
43
63
  page.locator('[data-socket-reload-action]').click(),
@@ -53,6 +73,71 @@ describe('@owlmeans/web-panel — the socket reload dialog', () => {
53
73
  }
54
74
  }, TIMEOUT)
55
75
 
76
+ test('Try again revives the lost connection, shows progress, and closes once it is back', async () => {
77
+ const { page, close } = await open('/socket')
78
+ try {
79
+ await page.waitForSelector('#socket-status')
80
+ await page.locator('#report-lost').click()
81
+
82
+ const dialog = page.locator('[data-socket-reload-dialog]')
83
+ const retry = page.locator('[data-socket-retry-action]')
84
+ await dialog.waitFor({ state: 'visible' })
85
+ expect(await retry.isEnabled()).toBe(true)
86
+
87
+ await retry.click()
88
+ await revivedCount(page, 1)
89
+ await retryDisabled(page, true)
90
+ // Still up while the revived connection is retrying — closing now would read as "fixed".
91
+ expect(await dialog.isVisible()).toBe(true)
92
+ expect(await retry.getAttribute('aria-busy')).toBe('true')
93
+
94
+ await clickBehindDialog(page, '#report-online')
95
+ await dialog.waitFor({ state: 'detached' })
96
+ } finally {
97
+ await close()
98
+ }
99
+ }, TIMEOUT)
100
+
101
+ test('a retry that fails again offers both buttons again', async () => {
102
+ const { page, close } = await open('/socket')
103
+ try {
104
+ await page.waitForSelector('#socket-status')
105
+ await page.locator('#report-lost').click()
106
+ const retry = page.locator('[data-socket-retry-action]')
107
+ await retry.click()
108
+ await retryDisabled(page, true)
109
+
110
+ await clickBehindDialog(page, '#report-lost')
111
+ await retryDisabled(page, false)
112
+ expect(await page.locator('[data-socket-reload-dialog]').isVisible()).toBe(true)
113
+ } finally {
114
+ await close()
115
+ }
116
+ }, TIMEOUT)
117
+
118
+ test('retries on its own when the tab or window becomes active again', async () => {
119
+ const { page, close } = await open('/socket')
120
+ try {
121
+ await page.waitForSelector('#socket-status')
122
+ await page.locator('#report-lost').click()
123
+ await page.locator('[data-socket-reload-dialog]').waitFor({ state: 'visible' })
124
+
125
+ await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')))
126
+ await revivedCount(page, 1)
127
+
128
+ // Nothing is lost any more, so a second activation has nothing to revive.
129
+ await page.evaluate(() => window.dispatchEvent(new Event('focus')))
130
+ await page.waitForTimeout(100)
131
+ expect(await page.locator('#revived').textContent()).toBe('1')
132
+
133
+ await clickBehindDialog(page, '#report-lost')
134
+ await page.evaluate(() => window.dispatchEvent(new Event('focus')))
135
+ await revivedCount(page, 2)
136
+ } finally {
137
+ await close()
138
+ }
139
+ }, TIMEOUT)
140
+
56
141
  test('never mounts when the app has not opted in via cfg.socket.reloadDialog', async () => {
57
142
  const { page, close } = await open('/socket?reloadDialog=0')
58
143
  try {