gantry-web 0.3.5 → 0.4.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.
package/package.json CHANGED
@@ -1,43 +1,43 @@
1
- {
2
- "name": "gantry-web",
3
- "version": "0.3.5",
4
- "description": "Frontend half of the Gantry desktop app framework: window chrome, native bridge, Tea runtime, Vite plugin",
5
- "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/B-Commissions/Gantry.git",
9
- "directory": "web"
10
- },
11
- "homepage": "https://github.com/B-Commissions/Gantry",
12
- "keywords": [
13
- "gantry",
14
- "desktop",
15
- "webview",
16
- "go",
17
- "react",
18
- "vite-plugin"
19
- ],
20
- "type": "module",
21
- "exports": {
22
- ".": "./src/index.ts",
23
- "./tea": "./src/tea/index.ts",
24
- "./vite": "./src/vite/index.js",
25
- "./styles.css": "./src/styles.css",
26
- "./types": "./types/index.d.ts"
27
- },
28
- "files": [
29
- "src",
30
- "types"
31
- ],
32
- "peerDependencies": {
33
- "lucide-react": "*",
34
- "react": ">=18",
35
- "react-dom": ">=18",
36
- "vite": ">=5"
37
- },
38
- "peerDependenciesMeta": {
39
- "vite": {
40
- "optional": true
41
- }
42
- }
43
- }
1
+ {
2
+ "name": "gantry-web",
3
+ "version": "0.4.0",
4
+ "description": "Frontend half of the Gantry desktop app framework: window chrome, native bridge, Tea runtime, Vite plugin",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/B-Commissions/Gantry.git",
9
+ "directory": "web"
10
+ },
11
+ "homepage": "https://github.com/B-Commissions/Gantry",
12
+ "keywords": [
13
+ "gantry",
14
+ "desktop",
15
+ "webview",
16
+ "go",
17
+ "react",
18
+ "vite-plugin"
19
+ ],
20
+ "type": "module",
21
+ "exports": {
22
+ ".": "./src/index.ts",
23
+ "./tea": "./src/tea/index.ts",
24
+ "./vite": "./src/vite/index.js",
25
+ "./styles.css": "./src/styles.css",
26
+ "./types": "./types/index.d.ts"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "types"
31
+ ],
32
+ "peerDependencies": {
33
+ "lucide-react": "*",
34
+ "react": ">=18",
35
+ "react-dom": ">=18",
36
+ "vite": ">=5"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "vite": {
40
+ "optional": true
41
+ }
42
+ }
43
+ }
package/src/Await.tsx ADDED
@@ -0,0 +1,85 @@
1
+ // Loading states for Go calls. useCall() already exposes { loading,
2
+ // error, data }; Await is the declarative wrapper: show a developer-
3
+ // defined fallback (a spinner, <Skeleton/>, anything) while the call
4
+ // is in flight, an error card with retry when it fails, and the
5
+ // children once data is there.
6
+ //
7
+ // const users = useCall<User[]>("api", "listUsers");
8
+ // return (
9
+ // <Await call={users} fallback={<Skeleton lines={4} />}>
10
+ // {(list) => <ul>{list.map(...)}</ul>}
11
+ // </Await>
12
+ // );
13
+
14
+ import type { CSSProperties, ReactNode } from "react";
15
+ import type { CallResult } from "./service";
16
+
17
+ export interface AwaitProps<T> {
18
+ /** The in-flight call (from useCall, or anything CallResult-shaped). */
19
+ call: CallResult<T>;
20
+ /** What to show while loading: a spinner, <Skeleton/>, custom JSX.
21
+ * Default: <Skeleton lines={3}/>. */
22
+ fallback?: ReactNode;
23
+ /** Custom error rendering; default is a small card with a Retry
24
+ * button. */
25
+ renderError?: (error: string, code: string | null, reload: () => void) => ReactNode;
26
+ /** Renders the loaded data. */
27
+ children: (data: T) => ReactNode;
28
+ }
29
+
30
+ export function Await<T>({ call, fallback, renderError, children }: AwaitProps<T>) {
31
+ if (call.loading) {
32
+ return <>{fallback ?? <Skeleton lines={3} />}</>;
33
+ }
34
+ if (call.error !== null) {
35
+ if (renderError) return <>{renderError(call.error, call.code, call.reload)}</>;
36
+ return (
37
+ <div className="gantry-await-error">
38
+ <span className="gantry-await-error-message">{call.error}</span>
39
+ {call.code && <span className="gantry-await-error-code">{call.code}</span>}
40
+ <button className="gantry-await-retry" onClick={call.reload}>
41
+ Retry
42
+ </button>
43
+ </div>
44
+ );
45
+ }
46
+ return <>{children(call.data as T)}</>;
47
+ }
48
+
49
+ export interface SkeletonProps {
50
+ /** Placeholder text lines (the last one renders shorter). */
51
+ lines?: number;
52
+ /** Explicit size for a single block (a chart area, an avatar). */
53
+ width?: number | string;
54
+ height?: number | string;
55
+ /** Round the block fully (avatars). */
56
+ circle?: boolean;
57
+ className?: string;
58
+ style?: CSSProperties;
59
+ }
60
+
61
+ /**
62
+ * Skeleton renders shimmering placeholder blocks sized like the
63
+ * content they stand in for: <Skeleton lines={4}/> for text,
64
+ * <Skeleton width={240} height={120}/> for a block, circle for
65
+ * avatars.
66
+ */
67
+ export function Skeleton({ lines, width, height, circle, className, style }: SkeletonProps) {
68
+ if (lines === undefined && (width !== undefined || height !== undefined || circle)) {
69
+ const s: CSSProperties = {
70
+ width: width ?? (circle ? 40 : "100%"),
71
+ height: height ?? (circle ? 40 : 16),
72
+ borderRadius: circle ? "50%" : undefined,
73
+ ...style,
74
+ };
75
+ return <div className={"gantry-skeleton" + (className ? " " + className : "")} style={s} />;
76
+ }
77
+ const n = Math.max(1, lines ?? 3);
78
+ return (
79
+ <div className={"gantry-skeleton-lines" + (className ? " " + className : "")} style={style}>
80
+ {Array.from({ length: n }, (_, i) => (
81
+ <div key={i} className="gantry-skeleton" style={{ width: i === n - 1 && n > 1 ? "60%" : "100%" }} />
82
+ ))}
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,98 @@
1
+ // The built-in error UI: a full-screen overlay for fatal render
2
+ // crashes and dismissible banners for non-fatal errors (Go panics,
3
+ // unhandled rejections). Development shows the full story - message,
4
+ // stack, the page, and the "what led here" action trail; production
5
+ // shows a friendly minimal card. Replace it per app via
6
+ // createApp({ errors: { screen: MyScreen } }).
7
+
8
+ import type { FC } from "react";
9
+ import type { GantryCrumb, GantryErrorInfo } from "./errors";
10
+
11
+ export interface ErrorScreenProps {
12
+ error: GantryErrorInfo;
13
+ /** Resolved app mode; null while unknown (treated as development). */
14
+ mode: "development" | "production" | null;
15
+ /** "fatal" = render crash overlay, "notice" = dismissible banner. */
16
+ variant: "fatal" | "notice";
17
+ onDismiss: () => void;
18
+ }
19
+
20
+ function ago(iso: string, now: number): string {
21
+ const t = Date.parse(iso);
22
+ if (Number.isNaN(t)) return "";
23
+ const s = Math.max(0, Math.round((now - t) / 1000));
24
+ if (s < 1) return "now";
25
+ if (s < 60) return `${s}s ago`;
26
+ return `${Math.floor(s / 60)}m${s % 60 ? ` ${s % 60}s` : ""} ago`;
27
+ }
28
+
29
+ function Trail({ trail }: { trail: GantryCrumb[] }) {
30
+ const now = Date.now();
31
+ return (
32
+ <div className="gantry-error-trail">
33
+ <div className="gantry-error-heading">What led here</div>
34
+ {trail.map((c, i) => (
35
+ <div key={i} className={"gantry-error-crumb" + (c.ok ? "" : " gantry-error-crumb-failed")}>
36
+ <span className="gantry-error-crumb-time">{ago(c.time, now)}</span>
37
+ <span className="gantry-error-crumb-type">{c.type}</span>
38
+ <span className="gantry-error-crumb-detail">{c.detail}</span>
39
+ </div>
40
+ ))}
41
+ </div>
42
+ );
43
+ }
44
+
45
+ function Detail({ error }: { error: GantryErrorInfo }) {
46
+ return (
47
+ <>
48
+ <div className="gantry-error-meta">
49
+ <span className="gantry-error-kind">{error.kind}</span>
50
+ {error.code && <span className="gantry-error-code">{error.code}</span>}
51
+ {error.page && <span className="gantry-error-page">on {error.page}</span>}
52
+ {error.source && <span className="gantry-error-source">{error.source}</span>}
53
+ <span className="gantry-error-origin">{error.origin === "go" ? "Go" : "JS"}</span>
54
+ </div>
55
+ {error.stack && <pre className="gantry-error-stack">{error.stack}</pre>}
56
+ {error.componentStack && (
57
+ <>
58
+ <div className="gantry-error-heading">Component stack</div>
59
+ <pre className="gantry-error-stack">{error.componentStack}</pre>
60
+ </>
61
+ )}
62
+ {error.trail && error.trail.length > 0 && <Trail trail={error.trail} />}
63
+ </>
64
+ );
65
+ }
66
+
67
+ export const ErrorScreen: FC<ErrorScreenProps> = ({ error, mode, variant, onDismiss }) => {
68
+ const dev = mode !== "production";
69
+ if (variant === "fatal") {
70
+ return (
71
+ <div className="gantry-error-overlay">
72
+ <div className="gantry-error-card">
73
+ <div className="gantry-error-title">{dev ? "The page crashed" : "Something went wrong"}</div>
74
+ {dev ? <div className="gantry-error-message">{error.message}</div> : <div className="gantry-error-message">The app hit an unexpected error. Reloading usually fixes it.</div>}
75
+ {dev && <Detail error={error} />}
76
+ <div className="gantry-error-actions">
77
+ <button className="gantry-error-button" onClick={() => location.reload()}>
78
+ Reload
79
+ </button>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+ return (
86
+ <div className="gantry-error-banner">
87
+ <div className="gantry-error-banner-head">
88
+ <span className="gantry-error-banner-title">
89
+ {error.origin === "go" ? "Go error" : "Error"}: {error.message}
90
+ </span>
91
+ <button className="gantry-error-dismiss" onClick={onDismiss} aria-label="Dismiss">
92
+ ×
93
+ </button>
94
+ </div>
95
+ {dev && <Detail error={error} />}
96
+ </div>
97
+ );
98
+ };
@@ -1,40 +1,43 @@
1
- import { useShell, useShellCaps } from "./hooks";
2
-
3
- const EDGES = [
4
- { edge: "n", style: { top: 0, left: 8, right: 8, height: 5, cursor: "ns-resize" } },
5
- { edge: "s", style: { bottom: 0, left: 8, right: 8, height: 5, cursor: "ns-resize" } },
6
- { edge: "w", style: { left: 0, top: 8, bottom: 8, width: 5, cursor: "ew-resize" } },
7
- { edge: "e", style: { right: 0, top: 8, bottom: 8, width: 5, cursor: "ew-resize" } },
8
- { edge: "nw", style: { top: 0, left: 0, width: 10, height: 10, cursor: "nwse-resize" } },
9
- { edge: "ne", style: { top: 0, right: 0, width: 10, height: 10, cursor: "nesw-resize" } },
10
- { edge: "sw", style: { bottom: 0, left: 0, width: 10, height: 10, cursor: "nesw-resize" } },
11
- { edge: "se", style: { bottom: 0, right: 0, width: 10, height: 10, cursor: "nwse-resize" } },
12
- ] as const;
13
-
14
- /**
15
- * ResizeFrame renders invisible edge strips that start a native
16
- * interactive resize - needed on Linux, where a frameless window has
17
- * no OS hit-test to re-implement edges (Windows handles this natively,
18
- * so there the component renders nothing). createApp adds one
19
- * automatically on frameless Linux windows.
20
- */
21
- export function ResizeFrame({ prefix }: { prefix?: string }) {
22
- const shell = useShell(prefix);
23
- const caps = useShellCaps(prefix);
24
- if (caps?.platform !== "linux" || !caps.frameless) return null;
25
- return (
26
- <>
27
- {EDGES.map(({ edge, style }) => (
28
- <div
29
- key={edge}
30
- style={{ position: "fixed", zIndex: 40, ...style }}
31
- onMouseDown={(e) => {
32
- if (e.button !== 0) return;
33
- e.preventDefault();
34
- shell.resizeEdge(edge);
35
- }}
36
- />
37
- ))}
38
- </>
39
- );
40
- }
1
+ import { useShell, useShellCaps } from "./hooks";
2
+
3
+ const EDGES = [
4
+ { edge: "n", style: { top: 0, left: 8, right: 8, height: 5, cursor: "ns-resize" } },
5
+ { edge: "s", style: { bottom: 0, left: 8, right: 8, height: 5, cursor: "ns-resize" } },
6
+ { edge: "w", style: { left: 0, top: 8, bottom: 8, width: 5, cursor: "ew-resize" } },
7
+ { edge: "e", style: { right: 0, top: 8, bottom: 8, width: 5, cursor: "ew-resize" } },
8
+ { edge: "nw", style: { top: 0, left: 0, width: 10, height: 10, cursor: "nwse-resize" } },
9
+ { edge: "ne", style: { top: 0, right: 0, width: 10, height: 10, cursor: "nesw-resize" } },
10
+ { edge: "sw", style: { bottom: 0, left: 0, width: 10, height: 10, cursor: "nesw-resize" } },
11
+ { edge: "se", style: { bottom: 0, right: 0, width: 10, height: 10, cursor: "nwse-resize" } },
12
+ ] as const;
13
+
14
+ /**
15
+ * ResizeFrame renders invisible edge strips that start a native
16
+ * interactive resize and carry the resize cursor. A frameless window has
17
+ * no draggable OS frame: on Linux the compositor offers no hit-test, and
18
+ * on Windows the WebView2 child covers the client area and swallows the
19
+ * parent's WM_NCHITTEST resize margin - so on both the frontend draws the
20
+ * strips and calls the ResizeEdge binding on mousedown. Framed windows
21
+ * (and the browser) render nothing.
22
+ */
23
+ export function ResizeFrame({ prefix }: { prefix?: string }) {
24
+ const shell = useShell(prefix);
25
+ const caps = useShellCaps(prefix);
26
+ const native = caps?.platform === "linux" || caps?.platform === "windows";
27
+ if (!native || !caps?.frameless) return null;
28
+ return (
29
+ <>
30
+ {EDGES.map(({ edge, style }) => (
31
+ <div
32
+ key={edge}
33
+ style={{ position: "fixed", zIndex: 40, ...style }}
34
+ onMouseDown={(e) => {
35
+ if (e.button !== 0) return;
36
+ e.preventDefault();
37
+ shell.resizeEdge(edge);
38
+ }}
39
+ />
40
+ ))}
41
+ </>
42
+ );
43
+ }