@celestia-island/hikari 0.40.12 → 0.40.13

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": "@celestia-island/hikari",
3
- "version": "0.40.12",
3
+ "version": "0.40.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,103 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, type Component } from "vue";
3
+
4
+ import HkErrorBoundary from "./HkErrorBoundary";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ function mountWith(child: Component, props: Record<string, unknown> = {}) {
9
+ const container = document.createElement("div");
10
+ document.body.appendChild(container);
11
+ const app = createApp({
12
+ render: () => h(HkErrorBoundary, props, { default: () => h(child) }),
13
+ });
14
+ app.config.errorHandler = () => {}; // Silence Vue's own duplicate logging.
15
+ app.mount(container);
16
+ mounts.push({ app, container });
17
+ return container;
18
+ }
19
+
20
+ const Boom = defineComponent({
21
+ name: "Boom",
22
+ setup() {
23
+ throw new TypeError("Cannot read properties of undefined (reading 'length')");
24
+ },
25
+ render: () => h("div", "never"),
26
+ });
27
+
28
+ const Fine = defineComponent({
29
+ name: "Fine",
30
+ render: () => h("div", { class: "fine-child" }, "fine"),
31
+ });
32
+
33
+ afterEach(() => {
34
+ for (const { app, container } of mounts) {
35
+ app.unmount();
36
+ container.remove();
37
+ }
38
+ mounts.length = 0;
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ describe("HkErrorBoundary", () => {
43
+ it("renders children while nothing throws", () => {
44
+ const el = mountWith(Fine);
45
+ expect(el.querySelector(".fine-child")).not.toBeNull();
46
+ expect(el.querySelector(".hk-error-landing")).toBeNull();
47
+ });
48
+
49
+ it("captures a crash and renders the inline error landing", async () => {
50
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
51
+ const el = mountWith(Boom);
52
+ await nextTick();
53
+
54
+ const landing = el.querySelector(".hk-error-landing");
55
+ expect(landing).not.toBeNull();
56
+ expect(landing!.classList.contains("is-inline")).toBe(true);
57
+ expect(el.querySelector(".hk-error-landing__code")!.textContent).toBe("TypeError");
58
+ expect(el.querySelector(".hk-error-landing__desc")!.textContent)
59
+ .toBe("Cannot read properties of undefined (reading 'length')");
60
+ // Raw details ride in the JSON tree.
61
+ expect(el.querySelector(".s-tool-json-tree")).not.toBeNull();
62
+ expect(errorSpy).toHaveBeenCalled();
63
+ });
64
+
65
+ it("falls back to the default headline when no title is given", async () => {
66
+ vi.spyOn(console, "error").mockImplementation(() => {});
67
+ const el = mountWith(Boom);
68
+ await nextTick();
69
+ expect(el.querySelector(".hk-error-landing__title")!.textContent).toBe("Something went wrong");
70
+ });
71
+
72
+ it("honours a custom title override", async () => {
73
+ vi.spyOn(console, "error").mockImplementation(() => {});
74
+ const el = mountWith(Boom, { errorTitle: "发生了错误" });
75
+ await nextTick();
76
+ expect(el.querySelector(".hk-error-landing__title")!.textContent).toBe("发生了错误");
77
+ });
78
+
79
+ it("recovers via the retry action", async () => {
80
+ vi.spyOn(console, "error").mockImplementation(() => {});
81
+ const el = mountWith(Boom, { retryLabel: "重试" });
82
+ await nextTick();
83
+
84
+ const buttons = Array.from(el.querySelectorAll<HTMLButtonElement>("button"));
85
+ const retry = buttons.find((b) => b.textContent === "重试");
86
+ expect(retry).toBeDefined();
87
+ retry!.click();
88
+ await nextTick();
89
+ // The slot rerender throws again, so the landing stays — but the error
90
+ // ref was cleared and recaptured (boundary proven by a fresh record).
91
+ expect(el.querySelector(".hk-error-landing")).not.toBeNull();
92
+ });
93
+
94
+ it("keeps the custom fallback render prop contract", async () => {
95
+ vi.spyOn(console, "error").mockImplementation(() => {});
96
+ const el = mountWith(Boom, {
97
+ fallback: (err: string, retry: () => void) => h("div", { class: "custom-fallback" }, [err.slice(0, 10), h("button", { onClick: retry }, "go")]),
98
+ });
99
+ await nextTick();
100
+ expect(el.querySelector(".custom-fallback")).not.toBeNull();
101
+ expect(el.querySelector(".hk-error-landing")).toBeNull();
102
+ });
103
+ });
@@ -1,14 +1,38 @@
1
- import { Copy, RefreshCw, TriangleAlert as AlertTriangle } from "lucide-vue-next";
2
- import { defineComponent, onErrorCaptured, ref, type PropType, type VNode } from "vue";
3
-
4
-
1
+ import { Copy, RefreshCw } from "lucide-vue-next";
2
+ import { computed, defineComponent, onErrorCaptured, ref, type PropType, type VNode } from "vue";
5
3
 
6
4
  import { useClipboard } from "../runtime/useClipboard";
7
5
  import { useI18n } from "../i18n/context";
8
6
  import HButton from "./HkButton";
9
- import HScrollContainer from "./HkScrollContainer";
10
- import "./HkErrorBoundary.scss";
7
+ import { HkErrorLanding } from "./HkErrorLanding";
8
+ import { HkJsonTree } from "./HkJsonTree";
9
+
10
+ interface CapturedError {
11
+ name: string;
12
+ message: string;
13
+ stack: string;
14
+ }
11
15
 
16
+ function captureError(err: unknown): CapturedError {
17
+ if (err instanceof Error) {
18
+ return { name: err.name || "Error", message: err.message, stack: err.stack || "" };
19
+ }
20
+ return { name: "Error", message: String(err), stack: "" };
21
+ }
22
+
23
+ function formatError(err: CapturedError): string {
24
+ return err.stack ? `${err.name}: ${err.message}\n\n${err.stack}` : `${err.name}: ${err.message}`;
25
+ }
26
+
27
+ /**
28
+ * HkErrorBoundary — inline crash guard rendering the shared error landing.
29
+ *
30
+ * Captures descendant errors via `onErrorCaptured` and stops propagation.
31
+ * The built-in fallback is the same HkErrorLanding card the family's
32
+ * full-page takeovers use (inline variant): tone icon, headline, the error
33
+ * name as the code chip, the message as the description, the raw
34
+ * name/message/stack in a collapsible JSON tree, plus retry / copy actions.
35
+ */
12
36
  export default defineComponent({
13
37
  name: "HkErrorBoundary",
14
38
  props: {
@@ -21,15 +45,12 @@ export default defineComponent({
21
45
  setup(props, { slots }) {
22
46
  const clipboard = useClipboard();
23
47
  const { t } = useI18n();
24
- const error = ref<string | null>(null);
48
+ const error = ref<CapturedError | null>(null);
25
49
 
26
50
  onErrorCaptured((err) => {
27
- const msg =
28
- err instanceof Error
29
- ? `${err.name}: ${err.message}\n\n${err.stack || ""}`
30
- : String(err);
31
- console.error(`[ErrorBoundary:${props.name}]`, msg);
32
- error.value = msg;
51
+ const captured = captureError(err);
52
+ console.error(`[ErrorBoundary:${props.name}]`, err);
53
+ error.value = captured;
33
54
  return false;
34
55
  });
35
56
 
@@ -37,48 +58,49 @@ export default defineComponent({
37
58
  error.value = null;
38
59
  }
39
60
 
40
- function copyError() {
41
- clipboard.copy(error.value!);
42
- }
61
+ const detailsValue = computed(() => {
62
+ if (!error.value) return undefined;
63
+ const record: Record<string, unknown> = {
64
+ name: error.value.name,
65
+ message: error.value.message,
66
+ };
67
+ if (error.value.stack) record.stack = error.value.stack;
68
+ if (props.name !== "unknown") record.boundary = props.name;
69
+ return record;
70
+ });
43
71
 
44
72
  return () => {
45
73
  if (error.value === null) {
46
74
  return slots.default?.();
47
75
  }
48
76
 
77
+ const err = error.value;
78
+
49
79
  if (props.fallback) {
50
- return props.fallback(error.value, retry);
80
+ return props.fallback(formatError(err), retry);
51
81
  }
52
82
 
53
83
  return (
54
- <div class="hk-error-boundary">
55
- <div class="hk-error-boundary-card">
56
- <div class="hk-error-boundary-header">
57
- <AlertTriangle size={16} class="hk-error-boundary-icon" />
58
- <span class="hk-error-boundary-label">{props.errorTitle || t("hikari::errorBoundary.title", "Component Error")}</span>
59
- {props.name !== "unknown" && (
60
- <span class="hk-error-boundary-tag">{props.name}</span>
61
- )}
62
- </div>
63
- <div class="hk-error-boundary-msg">
64
- <div style={{ maxHeight: "12rem" }}>
65
- <HScrollContainer>
66
- {error.value}
67
- </HScrollContainer>
68
- </div>
69
- </div>
70
- <div class="hk-error-boundary-actions">
71
- <HButton variant="ghost" size="sm" onClick={copyError}>
72
- <Copy size={12} />
73
- {props.copyErrorLabel || t("hikari::errorBoundary.copyError", "Copy Error")}
74
- </HButton>
75
- <HButton variant="outline" size="sm" onClick={retry}>
84
+ <HkErrorLanding
85
+ variant="inline"
86
+ title={props.errorTitle || t("hikari::errors.defaultTitle", "Something went wrong")}
87
+ description={err.message}
88
+ code={err.name}
89
+ >
90
+ {{
91
+ default: () => <HkJsonTree value={detailsValue.value} ariaLabel="stack trace" />,
92
+ actions: () => [
93
+ <HButton variant="primary" size="sm" onClick={retry}>
76
94
  <RefreshCw size={12} />
77
95
  {props.retryLabel || t("hikari::errorBoundary.retry", "Retry")}
78
- </HButton>
79
- </div>
80
- </div>
81
- </div>
96
+ </HButton>,
97
+ <HButton variant="ghost" size="sm" onClick={() => clipboard.copy(formatError(err))}>
98
+ <Copy size={12} />
99
+ {props.copyErrorLabel || t("hikari::errorBoundary.copyError", "Copy Error")}
100
+ </HButton>,
101
+ ],
102
+ }}
103
+ </HkErrorLanding>
82
104
  );
83
105
  };
84
106
  },
@@ -181,6 +181,15 @@
181
181
  margin-top: var(--space-16, 1rem);
182
182
  }
183
183
 
184
+ // Inline variant: the same card without the viewport takeover — used when
185
+ // HkErrorBoundary captures a crash inside a pane and swaps in the landing
186
+ // in-place instead of covering the whole screen.
187
+ .hk-error-landing.is-inline {
188
+ min-height: 0;
189
+ padding: var(--space-20, 1.25rem) var(--space-12, 0.75rem);
190
+ background: transparent;
191
+ }
192
+
184
193
  @media (max-width: 480px) {
185
194
  .hk-error-landing__card {
186
195
  padding: 1.5rem 1.125rem;
@@ -11,6 +11,7 @@ interface MountOptions {
11
11
  code?: string;
12
12
  status?: number;
13
13
  tone?: "error" | "warning" | "info";
14
+ variant?: "page" | "inline";
14
15
  detailsOpen?: boolean;
15
16
  details?: () => ReturnType<typeof h>;
16
17
  actions?: () => ReturnType<typeof h>;
@@ -28,6 +29,7 @@ function mountLanding(opts: MountOptions = {}) {
28
29
  code: opts.code ?? "",
29
30
  status: opts.status,
30
31
  tone: opts.tone ?? "error",
32
+ variant: opts.variant ?? "page",
31
33
  detailsOpen: opts.detailsOpen ?? true,
32
34
  }, {
33
35
  ...(opts.details ? { default: opts.details } : {}),
@@ -99,4 +101,10 @@ describe("HkErrorLanding", () => {
99
101
  expect(el.querySelector(".fake-action")).not.toBeNull();
100
102
  expect(el.querySelector(".fake-brand")).not.toBeNull();
101
103
  });
104
+
105
+ it("applies the inline variant class without the page backdrop", () => {
106
+ const el = mountLanding({ variant: "inline", title: "Boom" });
107
+ const root = el.querySelector(".hk-error-landing")!;
108
+ expect(root.classList.contains("is-inline")).toBe(true);
109
+ });
102
110
  });
@@ -7,6 +7,15 @@ import "./HkErrorLanding.scss";
7
7
  /** Visual severity of the landing icon and accents. */
8
8
  export type HErrorTone = "error" | "warning" | "info";
9
9
 
10
+ /**
11
+ * Layout variant of the landing.
12
+ * - `page` (default): owns a full-viewport backdrop — for overlays and
13
+ * standalone error pages.
14
+ * - `inline`: drops the backdrop and viewport height so the same card can
15
+ * live inside a pane captured by HkErrorBoundary.
16
+ */
17
+ export type HErrorLandingVariant = "page" | "inline";
18
+
10
19
  /**
11
20
  * HkErrorLanding — the shared full-page error landing.
12
21
  *
@@ -33,6 +42,8 @@ export const HkErrorLanding = defineComponent({
33
42
  /** HTTP status chip, e.g. 400. */
34
43
  status: { type: Number, default: undefined },
35
44
  tone: { type: String as PropType<HErrorTone>, default: "error" },
45
+ /** Layout variant: `page` (viewport backdrop) or `inline` (in-flow card). */
46
+ variant: { type: String as PropType<HErrorLandingVariant>, default: "page" },
36
47
  /** Initial expansion of the raw-details section. */
37
48
  detailsOpen: { type: Boolean, default: true },
38
49
  },
@@ -48,7 +59,7 @@ export const HkErrorLanding = defineComponent({
48
59
  }
49
60
 
50
61
  return () => (
51
- <div class={`hk-error-landing is-${props.tone}`}>
62
+ <div class={`hk-error-landing is-${props.tone}${props.variant === "inline" ? " is-inline" : ""}`}>
52
63
  <div class="hk-error-landing__card">
53
64
  {slots.brand?.()}
54
65
 
@@ -0,0 +1,9 @@
1
+ // HkErrorReportingOverlay — fixed full-viewport host for the error landing.
2
+ // Sits above every popup band (modal 1000 / dropdown 2000 / tooltip 3000 /
3
+ // toast 4000): an uncaught-error takeover must never be covered.
4
+
5
+ .hk-error-reporting-overlay {
6
+ position: fixed;
7
+ inset: 0;
8
+ z-index: 5000;
9
+ }
@@ -0,0 +1,103 @@
1
+ import { computed, defineComponent } from "vue";
2
+
3
+ import { useI18n } from "../i18n/context";
4
+ import HButton from "../components/HkButton";
5
+ import { HkErrorLanding } from "../components/HkErrorLanding";
6
+ import { HkJsonTree } from "../components/HkJsonTree";
7
+ import {
8
+ getErrorReportingOptions,
9
+ useErrorReportingState,
10
+ } from "./state";
11
+
12
+ import "./HkErrorReportingOverlay.scss";
13
+
14
+ /**
15
+ * HkErrorReportingOverlay — the full-viewport takeover for uncaught errors.
16
+ *
17
+ * Renders nothing until the error-reporting state is raised. The card is
18
+ * the family-wide HkErrorLanding (same design language as every unified
19
+ * error surface): tone icon, headline, the error name as the code chip,
20
+ * the message as the description, the raw error record in a collapsible
21
+ * JSON tree, and Home / Retry actions.
22
+ *
23
+ * Mounted by `createErrorReporting` on a dedicated root appended to
24
+ * `document.body` via its own tiny app instance, so it keeps working even
25
+ * when the host component tree is the thing that crashed.
26
+ */
27
+ export const HkErrorReportingOverlay = defineComponent({
28
+ name: "HkErrorReportingOverlay",
29
+ setup() {
30
+ const { t } = useI18n();
31
+ const state = useErrorReportingState();
32
+
33
+ const detailsValue = computed<Record<string, unknown> | undefined>(() => {
34
+ const err = state.value;
35
+ if (!err) return undefined;
36
+ const record: Record<string, unknown> = {
37
+ name: err.name,
38
+ message: err.message,
39
+ source: err.source,
40
+ };
41
+ if (err.stack) record.stack = err.stack;
42
+ if (err.info) record.info = err.info;
43
+ return record;
44
+ });
45
+
46
+ function goHome() {
47
+ const options = getErrorReportingOptions();
48
+ if (options.onHome) {
49
+ options.onHome();
50
+ return;
51
+ }
52
+ const href = options.homeHref === undefined ? "/" : options.homeHref;
53
+ if (href !== false) window.location.assign(href);
54
+ }
55
+
56
+ function retry() {
57
+ const options = getErrorReportingOptions();
58
+ if (options.onRetry) {
59
+ options.onRetry();
60
+ return;
61
+ }
62
+ window.location.reload();
63
+ }
64
+
65
+ return () => {
66
+ const err = state.value;
67
+ if (!err) return null;
68
+ const options = getErrorReportingOptions();
69
+
70
+ const description = options.describe
71
+ ? options.describe(err)
72
+ : err.message || t("hikari::errors.unexpectedDesc", "An unhandled error occurred.");
73
+
74
+ const showHome = Boolean(options.onHome) || options.homeHref !== false;
75
+
76
+ return (
77
+ <div class="hk-error-reporting-overlay" role="alertdialog" aria-live="assertive" aria-modal="true">
78
+ <HkErrorLanding
79
+ title={options.title || t("hikari::errors.defaultTitle", "Something went wrong")}
80
+ description={description}
81
+ code={err.name}
82
+ >
83
+ {{
84
+ default: () => <HkJsonTree value={detailsValue.value} ariaLabel="stack trace" />,
85
+ actions: () => [
86
+ ...(showHome
87
+ ? [(
88
+ <HButton key="home" size="sm" onClick={goHome}>
89
+ {t("hikari::errors.backHome", "Back to home")}
90
+ </HButton>
91
+ )]
92
+ : []),
93
+ <HButton key="retry" size="sm" variant="secondary" onClick={retry}>
94
+ {t("hikari::errorBoundary.retry", "Retry")}
95
+ </HButton>,
96
+ ],
97
+ }}
98
+ </HkErrorLanding>
99
+ </div>
100
+ );
101
+ };
102
+ },
103
+ });
@@ -0,0 +1,165 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick } from "vue";
3
+
4
+ import {
5
+ clearGlobalError,
6
+ createErrorReporting,
7
+ reportGlobalError,
8
+ resetErrorReportingForTests,
9
+ type HkErrorReportingOptions,
10
+ } from "./index";
11
+
12
+ const Boom = defineComponent({
13
+ name: "Boom",
14
+ setup() {
15
+ throw new TypeError("Cannot read properties of undefined (reading 'length')");
16
+ },
17
+ render: () => h("div", "never"),
18
+ });
19
+
20
+ function installPlugin(options?: HkErrorReportingOptions) {
21
+ // A throwaway app: the plugin's window hooks and module state outlive it,
22
+ // exactly like the real host app installing it at bootstrap.
23
+ const container = document.createElement("div");
24
+ document.body.appendChild(container);
25
+ const app = createApp({ render: () => null });
26
+ app.use(createErrorReporting(options));
27
+ app.mount(container);
28
+ app.unmount();
29
+ container.remove();
30
+ }
31
+
32
+ function mountThrowingApp() {
33
+ const container = document.createElement("div");
34
+ document.body.appendChild(container);
35
+ const app = createApp(Boom);
36
+ app.use(createErrorReporting());
37
+ app.mount(container);
38
+ return { app, container };
39
+ }
40
+
41
+ function overlayHost(): HTMLElement | null {
42
+ return document.querySelector<HTMLElement>("[data-hikari-error-reporting]");
43
+ }
44
+
45
+ afterEach(() => {
46
+ resetErrorReportingForTests();
47
+ document.querySelectorAll("[data-hikari-error-reporting]").forEach((el) => el.remove());
48
+ });
49
+
50
+ describe("createErrorReporting", () => {
51
+ it("shows the unified error landing for uncaught render errors", async () => {
52
+ vi.spyOn(console, "error").mockImplementation(() => {});
53
+ const { app, container } = mountThrowingApp();
54
+ await nextTick();
55
+
56
+ const host = overlayHost();
57
+ expect(host).not.toBeNull();
58
+ expect(host!.querySelector(".hk-error-landing")).not.toBeNull();
59
+ expect(host!.querySelector(".hk-error-landing__code")!.textContent).toBe("TypeError");
60
+ expect(host!.querySelector(".hk-error-landing__desc")!.textContent)
61
+ .toBe("Cannot read properties of undefined (reading 'length')");
62
+ // Raw details ride in the JSON tree.
63
+ expect(host!.querySelector(".s-tool-json-tree")).not.toBeNull();
64
+
65
+ app.unmount();
66
+ container.remove();
67
+ });
68
+
69
+ it("renders Home and Retry actions and hides Home when disabled", async () => {
70
+ vi.spyOn(console, "error").mockImplementation(() => {});
71
+ installPlugin();
72
+ reportGlobalError(new Error("boom"));
73
+ await nextTick();
74
+
75
+ const host = overlayHost()!;
76
+ const labels = Array.from(host.querySelectorAll("button")).map((b) => b.textContent);
77
+ expect(labels).toContain("Back to home");
78
+ expect(labels).toContain("Retry");
79
+
80
+ clearGlobalError();
81
+
82
+ installPlugin({ homeHref: false });
83
+ reportGlobalError(new Error("boom"));
84
+ await nextTick();
85
+ const labels2 = Array.from(overlayHost()!.querySelectorAll("button")).map((b) => b.textContent);
86
+ expect(labels2).not.toContain("Back to home");
87
+ expect(labels2).toContain("Retry");
88
+ });
89
+
90
+ it("takes over for unhandled window errors and rejections", async () => {
91
+ vi.spyOn(console, "error").mockImplementation(() => {});
92
+ installPlugin();
93
+
94
+ window.dispatchEvent(new ErrorEvent("error", { error: new RangeError("out of range") }));
95
+ await nextTick();
96
+ expect(overlayHost()!.querySelector(".hk-error-landing__code")!.textContent).toBe("RangeError");
97
+
98
+ clearGlobalError();
99
+
100
+ // happy-dom has no PromiseRejectionEvent constructor; a plain event with
101
+ // a `reason` property exercises the same listener path.
102
+ const rejection = new Event("unhandledrejection");
103
+ Object.defineProperty(rejection, "reason", { value: new Error("async boom") });
104
+ window.dispatchEvent(rejection);
105
+ await nextTick();
106
+ expect(overlayHost()!.querySelector(".hk-error-landing__desc")!.textContent).toBe("async boom");
107
+ });
108
+
109
+ it("keeps the first error and still fires onError for later ones", () => {
110
+ const onError = vi.fn();
111
+ vi.spyOn(console, "error").mockImplementation(() => {});
112
+ installPlugin({ onError });
113
+
114
+ reportGlobalError(new Error("first"));
115
+ reportGlobalError(new Error("second"));
116
+ expect(overlayHost()!.querySelector(".hk-error-landing__desc")!.textContent).toBe("first");
117
+ expect(onError).toHaveBeenCalledTimes(2);
118
+ });
119
+
120
+ it("drops errors rejected by shouldReport", () => {
121
+ vi.spyOn(console, "error").mockImplementation(() => {});
122
+ installPlugin({ shouldReport: (_err, source) => source === "manual" });
123
+
124
+ window.dispatchEvent(new ErrorEvent("error", { error: new Error("filtered away") }));
125
+ expect(overlayHost()).toBeNull();
126
+ });
127
+
128
+ it("chains a pre-existing app errorHandler", () => {
129
+ vi.spyOn(console, "error").mockImplementation(() => {});
130
+ const previous = vi.fn();
131
+ const container = document.createElement("div");
132
+ document.body.appendChild(container);
133
+ const app = createApp({ render: () => null });
134
+ app.config.errorHandler = previous;
135
+ app.use(createErrorReporting());
136
+ app.mount(container);
137
+ app.config.errorHandler!(new Error("routed"), null, "setup function");
138
+ expect(previous).toHaveBeenCalledTimes(1);
139
+ expect(overlayHost()).not.toBeNull();
140
+ app.unmount();
141
+ container.remove();
142
+ });
143
+
144
+ it("uses custom onRetry instead of reloading", async () => {
145
+ vi.spyOn(console, "error").mockImplementation(() => {});
146
+ const onRetry = vi.fn();
147
+ installPlugin({ onRetry });
148
+ reportGlobalError(new Error("boom"));
149
+ await nextTick();
150
+
151
+ const retry = Array.from(overlayHost()!.querySelectorAll<HTMLButtonElement>("button"))
152
+ .find((b) => b.textContent === "Retry")!;
153
+ retry.click();
154
+ expect(onRetry).toHaveBeenCalledTimes(1);
155
+ });
156
+
157
+ it("clearGlobalError tears the overlay down", async () => {
158
+ vi.spyOn(console, "error").mockImplementation(() => {});
159
+ reportGlobalError(new Error("boom"));
160
+ await nextTick();
161
+ expect(overlayHost()).not.toBeNull();
162
+ clearGlobalError();
163
+ expect(overlayHost()).toBeNull();
164
+ });
165
+ });
@@ -0,0 +1,158 @@
1
+ import { createApp, type App, type Plugin } from "vue";
2
+
3
+ import { HkErrorReportingOverlay } from "./HkErrorReportingOverlay";
4
+ import {
5
+ clearErrorReportingState,
6
+ reportError,
7
+ setErrorReportingOptions,
8
+ type HkErrorReportingOptions,
9
+ type HkErrorSource,
10
+ } from "./state";
11
+
12
+ export type {
13
+ HkErrorReportingOptions,
14
+ HkErrorSource,
15
+ HkReportedError,
16
+ } from "./state";
17
+ export { HkErrorReportingOverlay } from "./HkErrorReportingOverlay";
18
+
19
+ /**
20
+ * createErrorReporting — the install-and-forget global error reporting hook.
21
+ *
22
+ * `app.use(createErrorReporting())` is all a host needs to get the
23
+ * family-wide unified error landing for every error nothing else caught:
24
+ *
25
+ * - `app.config.errorHandler` (render trees without an HErrorBoundary,
26
+ * watchers, lifecycle hooks, async component failures, …);
27
+ * - uncaught `window` error events;
28
+ * - `unhandledrejection` events.
29
+ *
30
+ * On the first accepted error the plugin mounts a dedicated overlay app on
31
+ * `document.body` (z-index above every popup band) rendering HkErrorLanding
32
+ * with Home / Retry actions. Because the overlay lives on its own tiny app
33
+ * instance, it still shows when the host tree itself is what crashed.
34
+ *
35
+ * Any pre-existing `app.config.errorHandler` is chained, not discarded.
36
+ * All capture channels are optional; `shouldReport` filters, `onError` is
37
+ * the telemetry hook.
38
+ */
39
+ export function createErrorReporting(options: HkErrorReportingOptions = {}): Plugin {
40
+ return {
41
+ install(app: App) {
42
+ setErrorReportingOptions(options);
43
+
44
+ const previous = app.config.errorHandler;
45
+ app.config.errorHandler = (err, instance, info) => {
46
+ if (previous) {
47
+ try {
48
+ previous(err, instance, info);
49
+ } catch {
50
+ // A broken custom handler must not swallow the report.
51
+ }
52
+ }
53
+ if (reportError(err, "vue", info)) ensureOverlayMounted();
54
+ };
55
+
56
+ installWindowHooks(options);
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Raise the global error landing programmatically — the runtime-error
63
+ * counterpart of the old `window.__appFatal` handoff: fatal-fallback
64
+ * surfaces can route post-mount reports here once the SPA is up.
65
+ */
66
+ export function reportGlobalError(err: unknown, source: HkErrorSource = "manual"): void {
67
+ if (reportError(err, source)) ensureOverlayMounted();
68
+ }
69
+
70
+ /** Dismiss the landing and tear the overlay app down (programmatic reset). */
71
+ export function clearGlobalError(): void {
72
+ clearErrorReportingState();
73
+ unmountOverlay();
74
+ }
75
+
76
+ // ── window hooks ────────────────────────────────────────────────────────
77
+
78
+ let windowCleanup: (() => void) | null = null;
79
+
80
+ function installWindowHooks(options: HkErrorReportingOptions): void {
81
+ if (typeof window === "undefined") return;
82
+ if (windowCleanup) return; // One set of listeners per document, ever.
83
+
84
+ const disposers: Array<() => void> = [];
85
+
86
+ if (options.captureWindow !== false) {
87
+ const onError = (event: ErrorEvent) => {
88
+ // Resource-load failures never reach here (no capture phase); script
89
+ // errors always carry `error` or at least a `message`.
90
+ const err = event.error ?? new Error(event.message || "Unknown script error");
91
+ if (reportError(err, "window")) ensureOverlayMounted();
92
+ };
93
+ window.addEventListener("error", onError);
94
+ disposers.push(() => window.removeEventListener("error", onError));
95
+ }
96
+
97
+ if (options.captureRejection !== false) {
98
+ const onRejection = (event: PromiseRejectionEvent) => {
99
+ if (reportError(event.reason, "rejection")) ensureOverlayMounted();
100
+ };
101
+ window.addEventListener("unhandledrejection", onRejection);
102
+ disposers.push(() => window.removeEventListener("unhandledrejection", onRejection));
103
+ }
104
+
105
+ if (disposers.length === 0) return;
106
+ windowCleanup = () => {
107
+ for (const dispose of disposers) dispose();
108
+ windowCleanup = null;
109
+ };
110
+ }
111
+
112
+ // ── dedicated overlay app ───────────────────────────────────────────────
113
+
114
+ let overlayApp: App | null = null;
115
+ let overlayHost: HTMLElement | null = null;
116
+ let overlayMounting = false;
117
+
118
+ function ensureOverlayMounted(): void {
119
+ if (overlayApp || overlayMounting) return;
120
+ if (typeof document === "undefined") return;
121
+ overlayMounting = true;
122
+ try {
123
+ overlayHost = document.createElement("div");
124
+ overlayHost.dataset.hikariErrorReporting = "";
125
+ document.body.appendChild(overlayHost);
126
+ overlayApp = createApp(HkErrorReportingOverlay);
127
+ overlayApp.mount(overlayHost);
128
+ } catch (err) {
129
+ // Never let overlay mounting recurse into another report.
130
+ console.error("[hikari:error-reporting] failed to mount the overlay", err);
131
+ overlayHost?.remove();
132
+ overlayHost = null;
133
+ overlayApp = null;
134
+ } finally {
135
+ overlayMounting = false;
136
+ }
137
+ }
138
+
139
+ function unmountOverlay(): void {
140
+ if (overlayApp) {
141
+ try {
142
+ overlayApp.unmount();
143
+ } catch {
144
+ // Already gone.
145
+ }
146
+ overlayApp = null;
147
+ }
148
+ overlayHost?.remove();
149
+ overlayHost = null;
150
+ }
151
+
152
+ /** Test-only: reset singleton state, listeners and the overlay app. */
153
+ export function resetErrorReportingForTests(): void {
154
+ clearErrorReportingState();
155
+ windowCleanup?.();
156
+ windowCleanup = null;
157
+ unmountOverlay();
158
+ }
@@ -0,0 +1,104 @@
1
+ import { ref, type Ref } from "vue";
2
+
3
+ /** Where a reported error came from. */
4
+ export type HkErrorSource = "vue" | "window" | "rejection" | "manual";
5
+
6
+ /** A normalized error record carried by the global error landing. */
7
+ export interface HkReportedError {
8
+ source: HkErrorSource;
9
+ error: unknown;
10
+ name: string;
11
+ message: string;
12
+ stack: string;
13
+ /** Vue's `info` string for errors surfaced through `app.config.errorHandler`. */
14
+ info?: string;
15
+ }
16
+
17
+ export interface HkErrorReportingOptions {
18
+ /** Headline override; defaults to `hikari::errors.defaultTitle`. */
19
+ title?: string;
20
+ /** Description builder; defaults to the error's message, falling back to
21
+ * `hikari::errors.unexpectedDesc` when the error carries none. */
22
+ describe?: (err: HkReportedError) => string;
23
+ /** Target of the "back to home" action. `false` hides the button.
24
+ * Ignored when `onHome` is set. Default `/`. */
25
+ homeHref?: string | false;
26
+ /** Overrides the default `window.location.assign(homeHref)` home action. */
27
+ onHome?: () => void;
28
+ /** Overrides the default `window.location.reload()` retry action. */
29
+ onRetry?: () => void;
30
+ /** Listen for uncaught `window` error events. Default `true`. */
31
+ captureWindow?: boolean;
32
+ /** Listen for `unhandledrejection` events. Default `true`. */
33
+ captureRejection?: boolean;
34
+ /** Drop an error entirely (no overlay, no `onError` callback). */
35
+ shouldReport?: (err: unknown, source: HkErrorSource) => boolean;
36
+ /** Telemetry hook: logging / reporting pipeline. Fires for every accepted
37
+ * error, including ones arriving while the landing is already up. */
38
+ onError?: (err: unknown, source: HkErrorSource) => void;
39
+ }
40
+
41
+ export function normalizeError(err: unknown): { name: string; message: string; stack: string } {
42
+ if (err instanceof Error) {
43
+ return {
44
+ name: err.name || "Error",
45
+ message: err.message || String(err),
46
+ stack: err.stack || "",
47
+ };
48
+ }
49
+ return { name: "Error", message: String(err), stack: "" };
50
+ }
51
+
52
+ /**
53
+ * Module-level singleton state for the global error reporting overlay.
54
+ * Module scope (not app provide/inject) is deliberate: window hooks and the
55
+ * dedicated overlay app must reach it without a host component tree — the
56
+ * host tree is exactly what is broken when this fires.
57
+ */
58
+ const currentError: Ref<HkReportedError | null> = ref(null);
59
+
60
+ /** Options of the most recent `createErrorReporting` install. */
61
+ let activeOptions: HkErrorReportingOptions = {};
62
+
63
+ export function getErrorReportingOptions(): HkErrorReportingOptions {
64
+ return activeOptions;
65
+ }
66
+
67
+ export function setErrorReportingOptions(options: HkErrorReportingOptions): void {
68
+ activeOptions = options;
69
+ }
70
+
71
+ export function useErrorReportingState(): Ref<HkReportedError | null> {
72
+ return currentError;
73
+ }
74
+
75
+ /**
76
+ * Raise the global error landing. The first error wins while the landing is
77
+ * up; later errors still log and fire `onError` but never swap the card.
78
+ * Returns the record when accepted, `null` when filtered or already showing.
79
+ */
80
+ export function reportError(err: unknown, source: HkErrorSource, info?: string): HkReportedError | null {
81
+ const options = activeOptions;
82
+ if (options.shouldReport && !options.shouldReport(err, source)) return null;
83
+
84
+ console.error(`[hikari:error-reporting:${source}]`, err);
85
+ options.onError?.(err, source);
86
+
87
+ if (currentError.value !== null) return null;
88
+
89
+ const normalized = normalizeError(err);
90
+ const record: HkReportedError = {
91
+ source,
92
+ error: err,
93
+ name: normalized.name,
94
+ message: normalized.message,
95
+ stack: normalized.stack,
96
+ info,
97
+ };
98
+ currentError.value = record;
99
+ return record;
100
+ }
101
+
102
+ export function clearErrorReportingState(): void {
103
+ currentError.value = null;
104
+ }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "errors": {
3
3
  "hikari::errors.defaultTitle": "حدث خطأ ما",
4
- "hikari::errors.rawDetails": "تفاصيل الخطأ الأصلية"
4
+ "hikari::errors.rawDetails": "تفاصيل الخطأ الأصلية",
5
+ "hikari::errors.backHome": "العودة إلى الرئيسية",
6
+ "hikari::errors.unexpectedDesc": "واجه التطبيق خطأً غير معالج. يمكنك العودة إلى الصفحة الرئيسية أو إعادة المحاولة."
5
7
  }
6
8
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "errors": {
3
3
  "hikari::errors.defaultTitle": "Etwas ist schiefgelaufen",
4
- "hikari::errors.rawDetails": "Fehlerdetails im Original"
4
+ "hikari::errors.rawDetails": "Fehlerdetails im Original",
5
+ "hikari::errors.backHome": "Zur Startseite",
6
+ "hikari::errors.unexpectedDesc": "In der Anwendung ist ein unbehandelter Fehler aufgetreten. Sie können zur Startseite zurückkehren oder es erneut versuchen."
5
7
  }
6
8
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "errors": {
3
3
  "hikari::errors.defaultTitle": "Something went wrong",
4
- "hikari::errors.rawDetails": "Raw error details"
4
+ "hikari::errors.rawDetails": "Raw error details",
5
+ "hikari::errors.backHome": "Back to home",
6
+ "hikari::errors.unexpectedDesc": "The application hit an unhandled error. You can go back to the home page or retry."
5
7
  }
6
8
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "errors": {
3
3
  "hikari::errors.defaultTitle": "Algo salió mal",
4
- "hikari::errors.rawDetails": "Detalles del error original"
4
+ "hikari::errors.rawDetails": "Detalles del error original",
5
+ "hikari::errors.backHome": "Volver al inicio",
6
+ "hikari::errors.unexpectedDesc": "La aplicación encontró un error no controlado. Puede volver al inicio o reintentar."
5
7
  }
6
8
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "errors": {
3
3
  "hikari::errors.defaultTitle": "Une erreur est survenue",
4
- "hikari::errors.rawDetails": "Détails bruts de l'erreur"
4
+ "hikari::errors.rawDetails": "Détails bruts de l'erreur",
5
+ "hikari::errors.backHome": "Retour à l'accueil",
6
+ "hikari::errors.unexpectedDesc": "L'application a rencontré une erreur non gérée. Vous pouvez revenir à l'accueil ou réessayer."
5
7
  }
6
8
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "errors": {
3
3
  "hikari::errors.defaultTitle": "問題が発生しました",
4
- "hikari::errors.rawDetails": "エラーの詳細(原文)"
4
+ "hikari::errors.rawDetails": "エラーの詳細(原文)",
5
+ "hikari::errors.backHome": "ホームに戻る",
6
+ "hikari::errors.unexpectedDesc": "アプリケーションで未処理のエラーが発生しました。ホームに戻るか、再試行できます。"
5
7
  }
6
8
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "errors": {
3
3
  "hikari::errors.defaultTitle": "문제가 발생했습니다",
4
- "hikari::errors.rawDetails": "원본 오류 세부 정보"
4
+ "hikari::errors.rawDetails": "원본 오류 세부 정보",
5
+ "hikari::errors.backHome": "홈으로 돌아가기",
6
+ "hikari::errors.unexpectedDesc": "애플리케이션에서 처리되지 않은 오류가 발생했습니다. 홈으로 돌아가거나 다시 시도할 수 있습니다."
5
7
  }
6
8
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "errors": {
3
3
  "hikari::errors.defaultTitle": "Algo deu errado",
4
- "hikari::errors.rawDetails": "Detalhes do erro original"
4
+ "hikari::errors.rawDetails": "Detalhes do erro original",
5
+ "hikari::errors.backHome": "Voltar ao início",
6
+ "hikari::errors.unexpectedDesc": "O aplicativo encontrou um erro não tratado. Você pode voltar ao início ou tentar novamente."
5
7
  }
6
8
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "errors": {
3
3
  "hikari::errors.defaultTitle": "Что-то пошло не так",
4
- "hikari::errors.rawDetails": "Исходные детали ошибки"
4
+ "hikari::errors.rawDetails": "Исходные детали ошибки",
5
+ "hikari::errors.backHome": "На главную",
6
+ "hikari::errors.unexpectedDesc": "В приложении произошла необработанная ошибка. Вы можете вернуться на главную страницу или повторить попытку."
5
7
  }
6
8
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "errors": {
3
3
  "hikari::errors.defaultTitle": "出错了",
4
- "hikari::errors.rawDetails": "原始错误详情"
4
+ "hikari::errors.rawDetails": "原始错误详情",
5
+ "hikari::errors.backHome": "返回首页",
6
+ "hikari::errors.unexpectedDesc": "应用遇到了未处理的错误,您可以返回首页或重试。"
5
7
  }
6
8
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "errors": {
3
3
  "hikari::errors.defaultTitle": "出錯了",
4
- "hikari::errors.rawDetails": "原始錯誤詳情"
4
+ "hikari::errors.rawDetails": "原始錯誤詳情",
5
+ "hikari::errors.backHome": "返回首頁",
6
+ "hikari::errors.unexpectedDesc": "應用程式遇到未處理的錯誤,您可以返回首頁或重試。"
5
7
  }
6
8
  }
package/src/index.ts CHANGED
@@ -111,7 +111,17 @@ export { default as HErrorBoundary } from "./components/HkErrorBoundary";
111
111
  export {
112
112
  HkErrorLanding as HErrorLanding,
113
113
  type HErrorTone,
114
+ type HErrorLandingVariant,
114
115
  } from "./components/HkErrorLanding";
116
+ export { HkErrorReportingOverlay as HErrorReportingOverlay } from "./errorReporting";
117
+ export {
118
+ createErrorReporting,
119
+ reportGlobalError,
120
+ clearGlobalError,
121
+ type HkErrorReportingOptions,
122
+ type HkErrorSource,
123
+ type HkReportedError,
124
+ } from "./errorReporting";
115
125
  export { default as HDraggableList } from "./components/HkDraggableList";
116
126
  export { default as HDraggableGrid } from "./components/HkDraggableGrid";
117
127
  export { default as HSelectionGrid } from "./components/HkSelectionGrid";
@@ -1,67 +0,0 @@
1
- // Use theme variables with namespace to avoid conflicts
2
- @use "./hikari-vars" as vars;
3
-
4
- .hk-error-boundary {
5
- display: flex;
6
- align-items: center;
7
- justify-content: center;
8
- padding: 2rem;
9
- }
10
-
11
- .hk-error-boundary-card {
12
- max-width: 28rem;
13
- width: 100%;
14
- background: rgb(from var(--hi-color-error, #f85149) r g b / 0.06);
15
- border: 1px solid rgb(from var(--hi-color-error, #f85149) r g b / 0.18);
16
- border-radius: 8px;
17
- padding: 1.25rem;
18
- display: flex;
19
- flex-direction: column;
20
- gap: 0.75rem;
21
- }
22
-
23
- .hk-error-boundary-header {
24
- display: flex;
25
- align-items: center;
26
- gap: 0.5rem;
27
- }
28
-
29
- .hk-error-boundary-icon {
30
- color: var(--hi-color-error, #f85149);
31
- flex-shrink: 0;
32
- }
33
-
34
- .hk-error-boundary-label {
35
- font-size: 0.75rem;
36
- font-weight: 600;
37
- color: var(--hi-color-error, #f85149);
38
- letter-spacing: 0.02em;
39
- text-transform: uppercase;
40
- }
41
-
42
- .hk-error-boundary-tag {
43
- font-size: 0.75rem;
44
- font-weight: 500;
45
- padding: 0.0625rem 0.375rem;
46
- border-radius: 4px;
47
- background: rgb(from var(--hi-color-error, #f85149) r g b / 0.12);
48
- color: var(--hi-color-error, #f85149);
49
- font-family: var(--font-mono, vars.$hikari-font-family-mono);
50
- }
51
-
52
- .hk-error-boundary-msg {
53
- font-size: 0.8125rem;
54
- line-height: 1.5;
55
- color: var(--hi-color-text-primary, #c9d1d9);
56
- opacity: 0.85;
57
- word-break: break-word;
58
- white-space: pre-wrap;
59
- font-family: var(--font-mono, vars.$hikari-font-family-mono);
60
- }
61
-
62
- .hk-error-boundary-actions {
63
- display: flex;
64
- align-items: center;
65
- justify-content: flex-end;
66
- gap: 0.5rem;
67
- }