@chan.run/ui 0.3.1 → 0.3.2

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/dist/index.d.mts CHANGED
@@ -1,6 +1,31 @@
1
1
  import { FlexProps } from "@chakra-ui/react";
2
2
  import { ReactNode } from "react";
3
+ interface ErrorContent {
4
+ /** Small orange label above the code, e.g. "// not found". */
5
+ eyebrow: string;
6
+ /** Big display code: the HTTP status or "err". */
7
+ code: string;
8
+ /** The page heading (rendered as the h1). */
9
+ title: string;
10
+ /** One sentence on what happened and what to do. */
11
+ detail: string;
12
+ /** Raw error text, shown in a box below the detail. */
13
+ diagnostic?: string;
14
+ /** Whether to offer a "retry" action (reloads the page). */
15
+ showRetry?: boolean;
16
+ }
17
+ /** Any value caught by an error boundary → the content for <ErrorView>. */
18
+ export declare const classifyError: (err: unknown) => ErrorContent;
3
19
  export declare function ColorModeButton(props: FlexProps): import("react").JSX.Element;
20
+ interface ErrorViewProps extends ErrorContent {
21
+ /** Where the "home" action goes. */
22
+ homeHref?: string;
23
+ }
24
+ export declare function ErrorView({ eyebrow, code, title, detail, diagnostic, showRetry, homeHref }: ErrorViewProps): import("react").JSX.Element;
25
+ /** Route error boundary (React Router's `ErrorBoundary` export): caught error → <ErrorView>. */
26
+ export declare function ErrorBoundary({ error }: {
27
+ error: unknown;
28
+ }): import("react").JSX.Element;
4
29
  interface SplashProps {
5
30
  /** While true, the splash covers the screen. Flip to false to reveal. */
6
31
  open: boolean;
@@ -21,4 +46,4 @@ interface TopbarProps {
21
46
  onMenuOpen?: () => void;
22
47
  }
23
48
  export declare function Topbar({ onMenuOpen }: TopbarProps): import("react").JSX.Element;
24
- export type { SplashProps, TopbarProps };
49
+ export type { ErrorContent, ErrorViewProps, SplashProps, TopbarProps };
package/dist/index.mjs CHANGED
@@ -1,9 +1,81 @@
1
- import { Box, Flex, useSlotRecipe } from "@chakra-ui/react";
1
+ import { allPass, has, includes, is, isEmpty, isNil, pipe, propIs, replace, test, toLower, toUpper } from "ramda";
2
+ import { Alert, Box, Button, ButtonGroup, EmptyState, Flex, chakra, useRecipe, useSlotRecipe } from "@chakra-ui/react";
2
3
  import { useTheme } from "next-themes";
3
4
  import { useEffect, useState, useSyncExternalStore } from "react";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { ArrowLeft, RotateCw } from "lucide-react";
5
7
  import { useTimeout } from "@mantine/hooks";
6
8
  import { Wordmark } from "@chan.run/brand";
9
+ // src/classifyError.ts
10
+ const UNREACHABLE_STATUSES = [
11
+ 502,
12
+ 503,
13
+ 504
14
+ ];
15
+ /** Browser / runtime messages of a rejected fetch (Firefox, Chrome, Node, Safari, …). */
16
+ const NETWORK_FAILURE = /networkerror|failed to fetch|fetch failed|load failed|err_connection|connection refused/i;
17
+ /** { status: 404, statusText: "Not Found", data: … } → true; new Error() → false */
18
+ const isHttpErrorResponse = allPass([
19
+ propIs(Number, "status"),
20
+ propIs(String, "statusText"),
21
+ has("data")
22
+ ]);
23
+ /** "text" → "text"; { a: 1 } → '{"a":1}'; null → undefined */
24
+ const dataText = (data) => isNil(data) ? void 0 : is(String, data) ? data : JSON.stringify(data);
25
+ /** new Error("boom") → "boom"; "boom" → "boom" */
26
+ const messageOf = (err) => err instanceof Error ? err.message : String(err);
27
+ /** "Failed to fetch" → true; "x is undefined" → false */
28
+ const isNetworkFailure = test(NETWORK_FAILURE);
29
+ /** 503 → true; 500 → false */
30
+ const isUnreachableStatus = (status) => includes(status, UNREACHABLE_STATUSES);
31
+ /** "Not Found" → "Not found"; "" → "Something went wrong" */
32
+ const sentenceCase = (text) => isEmpty(text) ? "Something went wrong" : pipe(toLower, replace(/^./, toUpper))(text);
33
+ const unreachable = (diagnostic) => ({
34
+ eyebrow: "// signal lost",
35
+ code: "502",
36
+ title: "Server unreachable",
37
+ detail: "The server isn't responding. It may be starting up or offline. Wait a moment and retry.",
38
+ diagnostic,
39
+ showRetry: true
40
+ });
41
+ const notFound = (diagnostic) => ({
42
+ eyebrow: "// not found",
43
+ code: "404",
44
+ title: "Page not found",
45
+ detail: "This page doesn't exist or has moved. Check the address, or go back home.",
46
+ diagnostic,
47
+ showRetry: false
48
+ });
49
+ const httpError = ({ status, statusText }, diagnostic) => ({
50
+ eyebrow: "// error",
51
+ code: String(status),
52
+ title: sentenceCase(statusText),
53
+ detail: "The request failed. Retry, or go back home.",
54
+ diagnostic,
55
+ showRetry: true
56
+ });
57
+ const crash = (diagnostic) => ({
58
+ eyebrow: "// error",
59
+ code: "err",
60
+ title: "Something went wrong",
61
+ detail: "An unexpected error occurred. Retry, or go back home.",
62
+ diagnostic,
63
+ showRetry: true
64
+ });
65
+ /** { status: 404 } → not found; { status: 503 } → unreachable; other statuses → their own page */
66
+ function classifyResponse(res) {
67
+ const data = dataText(res.data);
68
+ if (res.status === 404) return notFound(data);
69
+ if (isUnreachableStatus(res.status)) return unreachable(data ?? `${res.status} ${res.statusText}`);
70
+ return httpError(res, data);
71
+ }
72
+ /** TypeError("Failed to fetch") → unreachable; Error("boom") → crash */
73
+ const classifyThrown = (err) => {
74
+ const message = messageOf(err);
75
+ return isNetworkFailure(message) ? unreachable(message) : crash(message);
76
+ };
77
+ /** Any value caught by an error boundary → the content for <ErrorView>. */
78
+ const classifyError = (err) => isHttpErrorResponse(err) ? classifyResponse(err) : classifyThrown(err);
7
79
  // src/ColorModeButton.tsx
8
80
  const noopSubscribe = () => () => void 0;
9
81
  function ColorModeButton(props) {
@@ -81,6 +153,63 @@ function MoonIcon() {
81
153
  children: /* @__PURE__ */ jsx("path", { d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" })
82
154
  });
83
155
  }
156
+ // src/ErrorView.tsx
157
+ /** Reload the current page (the "retry" action). */
158
+ const reload = () => window.location.reload();
159
+ function ErrorView({ eyebrow, code, title, detail, diagnostic, showRetry = false, homeHref = "/" }) {
160
+ const lbl = useRecipe({ key: "lbl" });
161
+ return /* @__PURE__ */ jsx(EmptyState.Root, {
162
+ variant: "chan",
163
+ "data-testid": "error-view",
164
+ children: /* @__PURE__ */ jsxs(EmptyState.Content, { children: [
165
+ /* @__PURE__ */ jsxs(chakra.span, {
166
+ css: lbl({ tone: "dim" }),
167
+ children: [
168
+ "[",
169
+ eyebrow,
170
+ "]"
171
+ ]
172
+ }),
173
+ /* @__PURE__ */ jsx(EmptyState.Indicator, {
174
+ "aria-hidden": "true",
175
+ children: code
176
+ }),
177
+ /* @__PURE__ */ jsx(EmptyState.Title, {
178
+ as: "h1",
179
+ children: title
180
+ }),
181
+ /* @__PURE__ */ jsx(EmptyState.Description, { children: detail }),
182
+ diagnostic ? /* @__PURE__ */ jsx(Alert.Root, {
183
+ variant: "chan",
184
+ status: "error",
185
+ "data-testid": "error-diagnostic",
186
+ children: /* @__PURE__ */ jsxs(Alert.Content, { children: [/* @__PURE__ */ jsx(Alert.Title, { children: "Diagnostic" }), /* @__PURE__ */ jsx(Alert.Description, { children: diagnostic })] })
187
+ }) : null,
188
+ /* @__PURE__ */ jsxs(ButtonGroup, {
189
+ size: "sm",
190
+ gap: "2.5",
191
+ children: [showRetry ? /* @__PURE__ */ jsxs(Button, {
192
+ variant: "chanGhost",
193
+ onClick: reload,
194
+ "data-testid": "error-retry",
195
+ children: [/* @__PURE__ */ jsx(RotateCw, {}), " Retry"]
196
+ }) : null, /* @__PURE__ */ jsx(Button, {
197
+ asChild: true,
198
+ variant: "chan",
199
+ children: /* @__PURE__ */ jsxs("a", {
200
+ href: homeHref,
201
+ "data-testid": "error-home",
202
+ children: [/* @__PURE__ */ jsx(ArrowLeft, {}), " Home"]
203
+ })
204
+ })]
205
+ })
206
+ ] })
207
+ });
208
+ }
209
+ /** Route error boundary (React Router's `ErrorBoundary` export): caught error → <ErrorView>. */
210
+ function ErrorBoundary({ error }) {
211
+ return /* @__PURE__ */ jsx(ErrorView, { ...classifyError(error) });
212
+ }
84
213
  // src/Splash.tsx
85
214
  function Splash({ open, children, orientation = "vertical", size = "md", holdMs = 0, revealMs = 1e3 }) {
86
215
  const recipe = useSlotRecipe({ key: "splash" });
@@ -202,4 +331,4 @@ function Topbar({ onMenuOpen }) {
202
331
  })]
203
332
  });
204
333
  }
205
- export { ColorModeButton, Splash, Topbar };
334
+ export { ColorModeButton, ErrorBoundary, ErrorView, Splash, Topbar, classifyError };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chan.run/ui",
3
- "version": "0.3.1",
4
- "description": "Shared chan.run app chrome: Topbar, Splash, ColorModeButton",
3
+ "version": "0.3.2",
4
+ "description": "Shared chan.run app chrome: Topbar, Splash, ColorModeButton, ErrorView",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.mjs",
@@ -19,9 +19,11 @@
19
19
  "dist"
20
20
  ],
21
21
  "dependencies": {
22
- "@chan.run/brand": "^0.3.0",
23
- "@chan.run/theme": "^0.3.1",
24
- "@mantine/hooks": "9.7.3"
22
+ "@chan.run/brand": "^0.3.1",
23
+ "@chan.run/theme": "^0.3.2",
24
+ "@mantine/hooks": "9.7.3",
25
+ "lucide-react": "1.63.0",
26
+ "ramda": "0.32.0"
25
27
  },
26
28
  "peerDependencies": {
27
29
  "@chakra-ui/react": "^3.35.0",
@@ -30,11 +32,17 @@
30
32
  },
31
33
  "devDependencies": {
32
34
  "@chakra-ui/react": "3.37.0",
35
+ "@testing-library/react": "^16.3.3",
36
+ "@types/ramda": "0.32.0",
33
37
  "@types/react": "19.3.0",
38
+ "@types/react-dom": "19.3.0",
39
+ "jsdom": "^30.1.3",
34
40
  "next-themes": "0.4.6",
35
41
  "react": "19.3.1",
42
+ "react-dom": "19.3.1",
36
43
  "tsdown": "0.23.2",
37
- "typescript": "6.0.3"
44
+ "typescript": "6.0.3",
45
+ "vitest": "5.0.5"
38
46
  },
39
47
  "repository": {
40
48
  "type": "git",