@celestia-island/hikari 0.55.92 → 0.55.94
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 +1 -1
- package/src/components/HkAttachmentModal.tsx +1 -1
- package/src/components/HkCaptchaModal.tsx +1 -1
- package/src/components/HkCaptchaWidget.tsx +1 -1
- package/src/components/HkCookieConsent.test.tsx +1 -1
- package/src/components/HkCookieConsent.tsx +1 -1
- package/src/components/HkErrorBoundary.test.tsx +11 -0
- package/src/components/HkErrorBoundary.tsx +9 -9
- package/src/components/HkErrorLanding.inline.contract.test.ts +12 -0
- package/src/components/HkErrorLanding.scss +5 -0
- package/src/components/HkErrorLanding.test.tsx +70 -2
- package/src/components/HkErrorLanding.tsx +59 -2
- package/src/components/HkLogWindow.tsx +1 -1
- package/src/components/HkNodeCanvas.test.tsx +139 -0
- package/src/components/HkNodeCanvas.tsx +32 -3
- package/src/components/HkTokenUsagePanel.tsx +1 -1
- package/src/components/HkVoiceInputPopup.tsx +1 -1
- package/src/components/nodeCanvasTypes.ts +68 -0
- package/src/errorReporting/HkErrorReportingOverlay.tsx +16 -2
- package/src/errorReporting/index.test.tsx +3 -0
- package/src/i18n/flatKeyParity.test.ts +28 -13
- package/src/i18n/locales/ar/chat.json +2 -2
- package/src/i18n/locales/ar/components.json +2 -3
- package/src/i18n/locales/ar/components.toml +0 -1
- package/src/i18n/locales/ar/errors.json +3 -1
- package/src/i18n/locales/ar/platform.json +8 -8
- package/src/i18n/locales/de/chat.json +2 -2
- package/src/i18n/locales/de/components.json +2 -3
- package/src/i18n/locales/de/components.toml +0 -1
- package/src/i18n/locales/de/errors.json +3 -1
- package/src/i18n/locales/de/platform.json +8 -8
- package/src/i18n/locales/en/chat.json +2 -2
- package/src/i18n/locales/en/components.json +2 -3
- package/src/i18n/locales/en/components.toml +0 -1
- package/src/i18n/locales/en/errors.json +3 -1
- package/src/i18n/locales/en/platform.json +8 -8
- package/src/i18n/locales/es/chat.json +2 -2
- package/src/i18n/locales/es/components.json +2 -3
- package/src/i18n/locales/es/components.toml +0 -1
- package/src/i18n/locales/es/errors.json +3 -1
- package/src/i18n/locales/es/platform.json +8 -8
- package/src/i18n/locales/fr/chat.json +2 -2
- package/src/i18n/locales/fr/components.json +2 -3
- package/src/i18n/locales/fr/components.toml +0 -1
- package/src/i18n/locales/fr/errors.json +3 -1
- package/src/i18n/locales/fr/platform.json +8 -8
- package/src/i18n/locales/ja/chat.json +2 -2
- package/src/i18n/locales/ja/components.json +2 -3
- package/src/i18n/locales/ja/components.toml +0 -1
- package/src/i18n/locales/ja/errors.json +3 -1
- package/src/i18n/locales/ja/platform.json +8 -8
- package/src/i18n/locales/ko/chat.json +2 -2
- package/src/i18n/locales/ko/components.json +2 -3
- package/src/i18n/locales/ko/components.toml +0 -1
- package/src/i18n/locales/ko/errors.json +3 -1
- package/src/i18n/locales/ko/platform.json +8 -8
- package/src/i18n/locales/pt/chat.json +2 -2
- package/src/i18n/locales/pt/components.json +2 -3
- package/src/i18n/locales/pt/components.toml +0 -1
- package/src/i18n/locales/pt/errors.json +3 -1
- package/src/i18n/locales/pt/platform.json +8 -8
- package/src/i18n/locales/ru/chat.json +2 -2
- package/src/i18n/locales/ru/components.json +2 -3
- package/src/i18n/locales/ru/components.toml +0 -1
- package/src/i18n/locales/ru/errors.json +3 -1
- package/src/i18n/locales/ru/platform.json +8 -8
- package/src/i18n/locales/zh-Hans/chat.json +2 -2
- package/src/i18n/locales/zh-Hans/components.json +2 -3
- package/src/i18n/locales/zh-Hans/components.toml +0 -1
- package/src/i18n/locales/zh-Hans/errors.json +3 -1
- package/src/i18n/locales/zh-Hans/platform.json +8 -8
- package/src/i18n/locales/zh-Hant/chat.json +2 -2
- package/src/i18n/locales/zh-Hant/components.json +2 -3
- package/src/i18n/locales/zh-Hant/components.toml +0 -1
- package/src/i18n/locales/zh-Hant/errors.json +3 -1
- package/src/i18n/locales/zh-Hant/platform.json +8 -8
package/package.json
CHANGED
|
@@ -286,7 +286,7 @@ export const HkAttachmentModal = defineComponent({
|
|
|
286
286
|
<p class="s-attachment-modal-text-empty">
|
|
287
287
|
{srcError.value
|
|
288
288
|
? srcError.value
|
|
289
|
-
: t("hikari::attachment.noPreview", "No preview available
|
|
289
|
+
: t("hikari::attachment.noPreview", "No preview available")}
|
|
290
290
|
</p>
|
|
291
291
|
)}
|
|
292
292
|
{textError.value && (
|
|
@@ -41,7 +41,7 @@ export const HkCaptchaModal = defineComponent({
|
|
|
41
41
|
width={props.width}
|
|
42
42
|
>
|
|
43
43
|
<div class="s-captcha-modal">
|
|
44
|
-
<p class="s-captcha-modal-prompt">{t("hikari::captcha.prompt", "Complete the verification below to continue
|
|
44
|
+
<p class="s-captcha-modal-prompt">{t("hikari::captcha.prompt", "Complete the verification below to continue")}</p>
|
|
45
45
|
{props.modelValue && (
|
|
46
46
|
<HkCaptchaWidget
|
|
47
47
|
siteKey={props.siteKey}
|
|
@@ -166,7 +166,7 @@ export const HkCaptchaWidget = defineComponent({
|
|
|
166
166
|
return (
|
|
167
167
|
<div class="s-captcha-widget s-captcha-widget-placeholder">
|
|
168
168
|
<ShieldAlert size={16} />
|
|
169
|
-
<span>{t("hikari::captcha.placeholder", "Verification is not configured for this deployment (no site key provided)
|
|
169
|
+
<span>{t("hikari::captcha.placeholder", "Verification is not configured for this deployment (no site key provided)")}</span>
|
|
170
170
|
</div>
|
|
171
171
|
);
|
|
172
172
|
}
|
|
@@ -51,7 +51,7 @@ describe("HkCookieConsent", () => {
|
|
|
51
51
|
await nextTick();
|
|
52
52
|
const notice = container.querySelector<HTMLElement>(".s-cookie-consent");
|
|
53
53
|
expect(notice, "notice span renders").toBeTruthy();
|
|
54
|
-
expect(notice!.textContent).toContain("This site uses cookies
|
|
54
|
+
expect(notice!.textContent).toContain("This site uses cookies");
|
|
55
55
|
const ok = container.querySelector<HTMLButtonElement>(".s-cookie-consent-ok");
|
|
56
56
|
expect(ok, "OK button renders").toBeTruthy();
|
|
57
57
|
expect(ok!.textContent).toBe("OK");
|
|
@@ -45,7 +45,7 @@ export const HkCookieConsent = defineComponent({
|
|
|
45
45
|
if (accepted.value) return <Cookie size={12} class="s-cookie-consent-icon" />;
|
|
46
46
|
return (
|
|
47
47
|
<span class="s-cookie-consent">
|
|
48
|
-
{t("hikari::cookie.text", "This site uses cookies
|
|
48
|
+
{t("hikari::cookie.text", "This site uses cookies")}
|
|
49
49
|
<button type="button" class="s-cookie-consent-ok" onClick={accept}>
|
|
50
50
|
{t("hikari::cookie.ok", "OK")}
|
|
51
51
|
</button>
|
|
@@ -91,6 +91,17 @@ describe("HkErrorBoundary", () => {
|
|
|
91
91
|
expect(el.querySelector(".hk-error-landing")).not.toBeNull();
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
it("feeds the landing's built-in copy action with the captured error", async () => {
|
|
95
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
96
|
+
const el = mountWith(Boom, { copyErrorLabel: "Copy Error" });
|
|
97
|
+
await nextTick();
|
|
98
|
+
|
|
99
|
+
const buttons = Array.from(el.querySelectorAll<HTMLButtonElement>(".hk-error-landing__actions button"));
|
|
100
|
+
// Built-in copy seats first, the boundary's own retry follows.
|
|
101
|
+
expect(buttons[0]!.textContent).toBe("Copy Error");
|
|
102
|
+
expect(buttons.some((b) => b.textContent === "Retry")).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
94
105
|
it("keeps the custom fallback render prop contract", async () => {
|
|
95
106
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
96
107
|
const el = mountWith(Boom, {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { RefreshCw } from "lucide-vue-next";
|
|
2
2
|
import { computed, defineComponent, onErrorCaptured, ref, type PropType, type VNode } from "vue";
|
|
3
3
|
|
|
4
|
-
import { useClipboard } from "../runtime/useClipboard";
|
|
5
4
|
import { useI18n } from "../i18n/context";
|
|
6
5
|
import HButton from "./HkButton";
|
|
7
6
|
import { HkErrorLanding } from "./HkErrorLanding";
|
|
@@ -31,8 +30,9 @@ function formatError(err: CapturedError): string {
|
|
|
31
30
|
* The built-in fallback is the same HkErrorLanding card the family's
|
|
32
31
|
* full-page takeovers use (inline variant): tone icon, headline, the error
|
|
33
32
|
* name as the tone-matched HkBadge chip, the message as the description,
|
|
34
|
-
* the raw name/message/stack in the fixed-height JSON tree pane, plus
|
|
35
|
-
* retry
|
|
33
|
+
* the raw name/message/stack in the fixed-height JSON tree pane, plus the
|
|
34
|
+
* retry action and the landing's built-in copy action (fed with the
|
|
35
|
+
* formatted error text).
|
|
36
36
|
*/
|
|
37
37
|
export default defineComponent({
|
|
38
38
|
name: "HkErrorBoundary",
|
|
@@ -44,7 +44,6 @@ export default defineComponent({
|
|
|
44
44
|
retryLabel: { type: String, default: "" },
|
|
45
45
|
},
|
|
46
46
|
setup(props, { slots }) {
|
|
47
|
-
const clipboard = useClipboard();
|
|
48
47
|
const { t } = useI18n();
|
|
49
48
|
const error = ref<CapturedError | null>(null);
|
|
50
49
|
|
|
@@ -87,6 +86,11 @@ export default defineComponent({
|
|
|
87
86
|
title={props.errorTitle || t("hikari::errors.defaultTitle", "Something went wrong")}
|
|
88
87
|
description={err.message}
|
|
89
88
|
code={err.name}
|
|
89
|
+
// The copy action is the landing's built-in now (seated left of
|
|
90
|
+
// the host actions, one design family-wide); this boundary only
|
|
91
|
+
// feeds it the formatted error and its legacy label override.
|
|
92
|
+
copyText={formatError(err)}
|
|
93
|
+
copyLabel={props.copyErrorLabel}
|
|
90
94
|
>
|
|
91
95
|
{{
|
|
92
96
|
default: () => <HkJsonTree value={detailsValue.value} ariaLabel="stack trace" />,
|
|
@@ -95,10 +99,6 @@ export default defineComponent({
|
|
|
95
99
|
<RefreshCw size={12} />
|
|
96
100
|
{props.retryLabel || t("hikari::errorBoundary.retry", "Retry")}
|
|
97
101
|
</HButton>,
|
|
98
|
-
<HButton variant="ghost" size="sm" onClick={() => clipboard.copy(formatError(err))}>
|
|
99
|
-
<Copy size={12} />
|
|
100
|
-
{props.copyErrorLabel || t("hikari::errorBoundary.copyError", "Copy Error")}
|
|
101
|
-
</HButton>,
|
|
102
102
|
],
|
|
103
103
|
}}
|
|
104
104
|
</HkErrorLanding>
|
|
@@ -51,3 +51,15 @@ describe("HkErrorLanding inline contract", () => {
|
|
|
51
51
|
expect(card).not.toContain("2.25rem");
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
describe("HkErrorLanding multiline headline contract", () => {
|
|
56
|
+
const css = readFileSync(join(here, "HkErrorLanding.scss"), "utf-8");
|
|
57
|
+
const title = ruleBody(css, ".hk-error-landing__title");
|
|
58
|
+
|
|
59
|
+
it("renders authored line breaks in the headline as breaks", () => {
|
|
60
|
+
// Catalogs ship per-line copy (a dash clause moved onto its own
|
|
61
|
+
// line); without pre-line the h1 collapses those breaks to spaces.
|
|
62
|
+
expect(title).toContain("white-space: pre-line");
|
|
63
|
+
expect(title).toContain("overflow-wrap: anywhere");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -173,6 +173,11 @@
|
|
|
173
173
|
letter-spacing: -0.01em;
|
|
174
174
|
line-height: 1.35;
|
|
175
175
|
color: rgb(var(--hel-text));
|
|
176
|
+
// Headlines may carry their own line breaks (hosts ship per-line copy,
|
|
177
|
+
// e.g. a two-clause landing where the dash clause moved to its own
|
|
178
|
+
// line) — render them as breaks, not spaces.
|
|
179
|
+
white-space: pre-line;
|
|
180
|
+
overflow-wrap: anywhere;
|
|
176
181
|
}
|
|
177
182
|
|
|
178
183
|
.hk-error-landing__desc {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { createApp, h } from "vue";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick } from "vue";
|
|
3
3
|
|
|
4
4
|
import { HkErrorLanding } from "./HkErrorLanding";
|
|
5
5
|
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
@@ -11,6 +11,8 @@ interface MountOptions {
|
|
|
11
11
|
status?: number;
|
|
12
12
|
tone?: "error" | "warning" | "info";
|
|
13
13
|
variant?: "page" | "inline";
|
|
14
|
+
copyText?: string;
|
|
15
|
+
copyLabel?: string;
|
|
14
16
|
details?: () => ReturnType<typeof h>;
|
|
15
17
|
actions?: () => ReturnType<typeof h>;
|
|
16
18
|
brand?: () => ReturnType<typeof h>;
|
|
@@ -28,6 +30,8 @@ function mountLanding(opts: MountOptions = {}) {
|
|
|
28
30
|
status: opts.status,
|
|
29
31
|
tone: opts.tone ?? "error",
|
|
30
32
|
variant: opts.variant ?? "page",
|
|
33
|
+
copyText: opts.copyText ?? "",
|
|
34
|
+
copyLabel: opts.copyLabel ?? "",
|
|
31
35
|
}, {
|
|
32
36
|
...(opts.details ? { default: opts.details } : {}),
|
|
33
37
|
...(opts.actions ? { actions: opts.actions } : {}),
|
|
@@ -39,12 +43,29 @@ function mountLanding(opts: MountOptions = {}) {
|
|
|
39
43
|
return container;
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
/** Stub the async clipboard (jsdom ships none) and restore afterwards. */
|
|
47
|
+
function stubClipboard() {
|
|
48
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
49
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
50
|
+
value: { writeText },
|
|
51
|
+
configurable: true,
|
|
52
|
+
writable: true,
|
|
53
|
+
});
|
|
54
|
+
return writeText;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function actionButtons(el: HTMLElement): HTMLButtonElement[] {
|
|
58
|
+
return Array.from(el.querySelectorAll<HTMLButtonElement>(".hk-error-landing__actions button"));
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
afterEach(() => {
|
|
43
62
|
for (const { app, container } of mounts) {
|
|
44
63
|
app.unmount();
|
|
45
64
|
container.remove();
|
|
46
65
|
}
|
|
47
66
|
mounts.length = 0;
|
|
67
|
+
Reflect.deleteProperty(navigator, "clipboard");
|
|
68
|
+
vi.restoreAllMocks();
|
|
48
69
|
});
|
|
49
70
|
|
|
50
71
|
describe("HkErrorLanding", () => {
|
|
@@ -142,4 +163,51 @@ describe("HkErrorLanding", () => {
|
|
|
142
163
|
const root = el.querySelector(".hk-error-landing")!;
|
|
143
164
|
expect(root.classList.contains("is-inline")).toBe(true);
|
|
144
165
|
});
|
|
166
|
+
|
|
167
|
+
it("seats the built-in copy action FIRST, left of the host actions", () => {
|
|
168
|
+
const el = mountLanding({
|
|
169
|
+
copyText: "Boom: bad",
|
|
170
|
+
actions: () => h("button", { class: "fake-action" }, "Retry"),
|
|
171
|
+
});
|
|
172
|
+
const buttons = actionButtons(el);
|
|
173
|
+
expect(buttons).toHaveLength(2);
|
|
174
|
+
expect(buttons[0]!.textContent).toBe("Copy error details");
|
|
175
|
+
expect(buttons[1]!.classList.contains("fake-action")).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("writes the copy payload and flips the label to Copied", async () => {
|
|
179
|
+
const writeText = stubClipboard();
|
|
180
|
+
const el = mountLanding({ copyText: "full error info" });
|
|
181
|
+
const copy = actionButtons(el)[0]!;
|
|
182
|
+
copy.click();
|
|
183
|
+
await vi.waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
|
|
184
|
+
expect(writeText).toHaveBeenCalledWith("full error info");
|
|
185
|
+
await nextTick();
|
|
186
|
+
await nextTick();
|
|
187
|
+
expect(actionButtons(el)[0]!.textContent).toBe("Copied");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("renders the copy action alone when the host passes no actions", () => {
|
|
191
|
+
const el = mountLanding({ copyText: "raw" });
|
|
192
|
+
const buttons = actionButtons(el);
|
|
193
|
+
expect(buttons).toHaveLength(1);
|
|
194
|
+
expect(buttons[0]!.textContent).toBe("Copy error details");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("renders no copy action and no actions row without copyText and slot", () => {
|
|
198
|
+
const el = mountLanding({ title: "Boom" });
|
|
199
|
+
expect(el.querySelector(".hk-error-landing__actions")).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("keeps the actions row host-only when copyText is empty", () => {
|
|
203
|
+
const el = mountLanding({ actions: () => h("button", { class: "fake-action" }, "Retry") });
|
|
204
|
+
const buttons = actionButtons(el);
|
|
205
|
+
expect(buttons).toHaveLength(1);
|
|
206
|
+
expect(buttons[0]!.classList.contains("fake-action")).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("honours a custom copy label override", () => {
|
|
210
|
+
const el = mountLanding({ copyText: "raw", copyLabel: "Copy Error" });
|
|
211
|
+
expect(actionButtons(el)[0]!.textContent).toBe("Copy Error");
|
|
212
|
+
});
|
|
145
213
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Braces, Info, TriangleAlert } from "lucide-vue-next";
|
|
1
|
+
import { Braces, Check, Copy, Info, TriangleAlert } from "lucide-vue-next";
|
|
2
2
|
import {
|
|
3
3
|
computed,
|
|
4
4
|
defineComponent,
|
|
@@ -7,11 +7,14 @@ import {
|
|
|
7
7
|
onUpdated,
|
|
8
8
|
ref,
|
|
9
9
|
type PropType,
|
|
10
|
+
type VNode,
|
|
10
11
|
} from "vue";
|
|
11
12
|
|
|
12
13
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
13
14
|
import { useI18n } from "../i18n/context";
|
|
15
|
+
import { useClipboard } from "../runtime/useClipboard";
|
|
14
16
|
import HkBadge from "./HkBadge";
|
|
17
|
+
import HkButton from "./HkButton";
|
|
15
18
|
|
|
16
19
|
import "./HkErrorLanding.scss";
|
|
17
20
|
|
|
@@ -27,6 +30,13 @@ export type HErrorTone = "error" | "warning" | "info";
|
|
|
27
30
|
*/
|
|
28
31
|
export type HErrorLandingVariant = "page" | "inline";
|
|
29
32
|
|
|
33
|
+
/** Slot renderers may return a single vnode or an array — normalize to an
|
|
34
|
+
* array so the built-in copy action and the host's actions share one row. */
|
|
35
|
+
function normalizeSlotNodes(nodes: VNode | VNode[] | undefined): VNode[] {
|
|
36
|
+
if (!nodes) return [];
|
|
37
|
+
return Array.isArray(nodes) ? nodes : [nodes];
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
/**
|
|
31
41
|
* HkErrorLanding — the shared full-page error landing.
|
|
32
42
|
*
|
|
@@ -46,6 +56,15 @@ export type HErrorLandingVariant = "page" | "inline";
|
|
|
46
56
|
* server-rendered error page alike. All host-facing copy (`title`,
|
|
47
57
|
* `description`, action buttons) arrives pre-translated; the component only
|
|
48
58
|
* translates its own labels via `hikari::errors.*`.
|
|
59
|
+
*
|
|
60
|
+
* The standard copy action: hosts pass `copyText` (the full error info they
|
|
61
|
+
* want on the clipboard — headline, technical context, raw payload, …) and
|
|
62
|
+
* the landing seats its built-in "copy error details" button as the FIRST
|
|
63
|
+
* action, left of whatever the actions slot renders — one design for the
|
|
64
|
+
* whole error-landing family instead of every consumer hand-rolling its
|
|
65
|
+
* own copy row (the boundary used to ship a private one; it now feeds this
|
|
66
|
+
* hook). `copyLabel` overrides the button wording when a host already
|
|
67
|
+
* ships its own.
|
|
49
68
|
*/
|
|
50
69
|
export const HkErrorLanding = defineComponent({
|
|
51
70
|
name: "HkErrorLanding",
|
|
@@ -58,15 +77,29 @@ export const HkErrorLanding = defineComponent({
|
|
|
58
77
|
code: { type: String, default: "" },
|
|
59
78
|
/** HTTP status chip, e.g. 400. */
|
|
60
79
|
status: { type: Number, default: undefined },
|
|
80
|
+
/** Full error info the built-in copy action puts on the clipboard
|
|
81
|
+
* (headline + technical context + raw payload, host-composed).
|
|
82
|
+
* Empty disables the action. */
|
|
83
|
+
copyText: { type: String, default: "" },
|
|
84
|
+
/** Optional wording override for the built-in copy button; empty
|
|
85
|
+
* falls back to `hikari::errors.copyDetails`. */
|
|
86
|
+
copyLabel: { type: String, default: "" },
|
|
61
87
|
tone: { type: String as PropType<HErrorTone>, default: "error" },
|
|
62
88
|
/** Layout variant: `page` (viewport backdrop) or `inline` (in-flow card). */
|
|
63
89
|
variant: { type: String as PropType<HErrorLandingVariant>, default: "page" },
|
|
64
90
|
},
|
|
65
91
|
setup(props, { slots }) {
|
|
66
92
|
const { t } = useI18n();
|
|
93
|
+
const clipboard = useClipboard();
|
|
67
94
|
|
|
68
95
|
const titleText = computed(() => props.title || t("hikari::errors.defaultTitle", "Something went wrong"));
|
|
69
96
|
const hasDetails = computed(() => slots.default != null);
|
|
97
|
+
// The actions row carries the built-in copy action whenever the host
|
|
98
|
+
// feeds it a payload — with or without slot actions beside it.
|
|
99
|
+
// Frozen like hasDetails: the slots object identity is not reactive,
|
|
100
|
+
// so a slot appearing mid-lifetime wouldn't flip this row — every
|
|
101
|
+
// family host mounts the landing with its slot set already settled.
|
|
102
|
+
const hasActions = computed(() => slots.actions != null || props.copyText !== "");
|
|
70
103
|
|
|
71
104
|
// Badge variant follows the landing tone so the chip, the icon and the
|
|
72
105
|
// card wash always speak the same severity language.
|
|
@@ -175,7 +208,31 @@ export const HkErrorLanding = defineComponent({
|
|
|
175
208
|
</div>
|
|
176
209
|
)}
|
|
177
210
|
|
|
178
|
-
{
|
|
211
|
+
{hasActions.value && (
|
|
212
|
+
<div class="hk-error-landing__actions" aria-live="polite">
|
|
213
|
+
{[
|
|
214
|
+
// The standard copy action seats FIRST — left of every
|
|
215
|
+
// host-provided action — so the whole family reads
|
|
216
|
+
// [copy] [retry] [dismiss…] no matter who hosts the card.
|
|
217
|
+
...(props.copyText
|
|
218
|
+
? [(
|
|
219
|
+
<HkButton
|
|
220
|
+
key="hk-error-copy"
|
|
221
|
+
size="sm"
|
|
222
|
+
variant="ghost"
|
|
223
|
+
onClick={() => { void clipboard.copy(props.copyText); }}
|
|
224
|
+
>
|
|
225
|
+
{clipboard.copied.value ? <Check size={12} /> : <Copy size={12} />}
|
|
226
|
+
{clipboard.copied.value
|
|
227
|
+
? t("hikari::errors.copied", "Copied")
|
|
228
|
+
: props.copyLabel || t("hikari::errors.copyDetails", "Copy error details")}
|
|
229
|
+
</HkButton>
|
|
230
|
+
)]
|
|
231
|
+
: []),
|
|
232
|
+
...(normalizeSlotNodes(slots.actions?.())),
|
|
233
|
+
]}
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
179
236
|
</div>
|
|
180
237
|
</div>
|
|
181
238
|
);
|
|
@@ -179,7 +179,7 @@ export const HkLogWindow = defineComponent({
|
|
|
179
179
|
ref={scrollRef}
|
|
180
180
|
>
|
|
181
181
|
{currentLines.value.length === 0 ? (
|
|
182
|
-
<div class="s-log-empty">{t("hikari::log.empty", "No log lines yet
|
|
182
|
+
<div class="s-log-empty">{t("hikari::log.empty", "No log lines yet")}</div>
|
|
183
183
|
) : (
|
|
184
184
|
currentLines.value.map((line, i) => (
|
|
185
185
|
<div key={i} class={["s-log-entry", `s-log-entry-${levelOf(line)}`]}>
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { createApp, defineComponent, h, isRef, nextTick, onUpdated, ref, type App } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkNodeCanvas, { NODE_CANVAS_DEFAULTS } from "./HkNodeCanvas";
|
|
5
|
+
import { CANVAS_BOUNDS_MARGIN, clampCameraToBounds } from "./nodeCanvasTypes";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* HkNodeCanvas tests — the camera protocol the three node surfaces share:
|
|
@@ -1050,4 +1051,142 @@ describe("HkNodeCanvas", () => {
|
|
|
1050
1051
|
// Controlled: the internal camera did not move on its own.
|
|
1051
1052
|
expect(instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
|
|
1052
1053
|
});
|
|
1054
|
+
|
|
1055
|
+
// ── Bounds clamp ─────────────────────────────────────────────────
|
|
1056
|
+
// The camera may never push the content hull farther than half a
|
|
1057
|
+
// viewport past the visible edge, per axis: at the limit the hull sits
|
|
1058
|
+
// exactly CANVAS_BOUNDS_MARGIN of a viewport outside, and past it the
|
|
1059
|
+
// outward drag is refused. Every camera write funnels through
|
|
1060
|
+
// `setCamera`, so a drag, a wheel/pinch zoom, a tween and an imperative
|
|
1061
|
+
// call all stop at the same edge.
|
|
1062
|
+
|
|
1063
|
+
describe("bounds clamp", () => {
|
|
1064
|
+
// BOUNDS = 400×200 at (0,0), viewport 800×600, k = 1 ⇒
|
|
1065
|
+
// x ∈ [-k·(x+w) - f·W, W - k·x + f·W] = [-800, 1200],
|
|
1066
|
+
// y ∈ [-200 - 300, 600 + 300] = [-500, 900].
|
|
1067
|
+
const X_LIMIT_LOW = -800;
|
|
1068
|
+
const Y_LIMIT_LOW = -500;
|
|
1069
|
+
|
|
1070
|
+
it("clamps the pure camera to the expanded hull", () => {
|
|
1071
|
+
const bounds = { x: 0, y: 0, width: 400, height: 200 };
|
|
1072
|
+
const viewport = { width: 800, height: 600 };
|
|
1073
|
+
// A camera already framing the content passes through untouched.
|
|
1074
|
+
const inside = { k: 1, x: 100, y: 100 };
|
|
1075
|
+
expect(clampCameraToBounds(inside, bounds, viewport)).toEqual(inside);
|
|
1076
|
+
// Dragged far off-screen: stopped at the derived limit.
|
|
1077
|
+
expect(clampCameraToBounds({ k: 1, x: -5000, y: -9000 }, bounds, viewport)).toEqual({
|
|
1078
|
+
k: 1,
|
|
1079
|
+
x: X_LIMIT_LOW,
|
|
1080
|
+
y: Y_LIMIT_LOW,
|
|
1081
|
+
});
|
|
1082
|
+
// …and symmetrically for the far corners.
|
|
1083
|
+
expect(clampCameraToBounds({ k: 1, x: 5000, y: 9000 }, bounds, viewport)).toEqual({
|
|
1084
|
+
k: 1,
|
|
1085
|
+
x: 1200,
|
|
1086
|
+
y: 900,
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("holds the margin as a SCREEN distance at any zoom", () => {
|
|
1091
|
+
// At k = 2 the world window halves, so the world-space margin halves
|
|
1092
|
+
// with it — but the on-screen slack stays half a viewport.
|
|
1093
|
+
const bounds = { x: 0, y: 0, width: 400, height: 200 };
|
|
1094
|
+
const viewport = { width: 800, height: 600 };
|
|
1095
|
+
const clamped = clampCameraToBounds({ k: 2, x: -99999, y: 0 }, bounds, viewport);
|
|
1096
|
+
// xLow = -2·400 - 0.5·800 = -1200.
|
|
1097
|
+
expect(clamped.x).toBe(-1200);
|
|
1098
|
+
// At the limit the hull's right edge sits exactly half a viewport
|
|
1099
|
+
// left of the screen's left edge.
|
|
1100
|
+
const hullRightOnScreen = (bounds.width) * clamped.k + clamped.x;
|
|
1101
|
+
expect(hullRightOnScreen).toBe(-viewport.width / 2);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it("passes degenerate inputs through unchanged", () => {
|
|
1105
|
+
const cam = { k: 1, x: 123, y: 456 };
|
|
1106
|
+
expect(clampCameraToBounds(cam, null, { width: 800, height: 600 })).toBe(cam);
|
|
1107
|
+
expect(clampCameraToBounds(cam, { x: 0, y: 0, width: 0, height: 100 }, { width: 800, height: 600 })).toBe(cam);
|
|
1108
|
+
expect(clampCameraToBounds(cam, { x: 0, y: 0, width: 100, height: 0 }, { width: 800, height: 600 })).toBe(cam);
|
|
1109
|
+
expect(clampCameraToBounds(cam, { x: 0, y: 0, width: 100, height: 100 }, { width: 0, height: 0 })).toBe(cam);
|
|
1110
|
+
expect(clampCameraToBounds({ k: 0, x: 1, y: 1 }, { x: 0, y: 0, width: 100, height: 100 }, { width: 800, height: 600 })).toEqual({ k: 0, x: 1, y: 1 });
|
|
1111
|
+
// An astronomically placed hull overflows the clamp range (k·coord
|
|
1112
|
+
// passes the double ceiling): refuse to clamp rather than write a
|
|
1113
|
+
// non-finite translation (the browser would drop the transform).
|
|
1114
|
+
const far = { x: 1e308, y: 1e308, width: 100, height: 100 };
|
|
1115
|
+
expect(clampCameraToBounds({ k: 2, x: 123, y: 456 }, far, { width: 800, height: 600 })).toEqual({ k: 2, x: 123, y: 456 });
|
|
1116
|
+
expect(clampCameraToBounds({ k: 1, x: NaN, y: 0 }, { x: 0, y: 0, width: 100, height: 100 }, { width: 800, height: 600 })).toEqual({ k: 1, x: NaN, y: 0 });
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
it("stops a pan that would drag the hull off-screen", async () => {
|
|
1120
|
+
const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
|
|
1121
|
+
await nextTick();
|
|
1122
|
+
instance.value?.panBy(-5000, -5000);
|
|
1123
|
+
expect(instance.value!.camera).toEqual({ k: 1, x: X_LIMIT_LOW, y: Y_LIMIT_LOW });
|
|
1124
|
+
// The far direction clamps at its own wall.
|
|
1125
|
+
instance.value?.panBy(99999, 99999);
|
|
1126
|
+
expect(instance.value!.camera).toEqual({ k: 1, x: 1200, y: 900 });
|
|
1127
|
+
// Small moves apply on the free axis; the wall axis stays pinned.
|
|
1128
|
+
instance.value?.panBy(-10, 5);
|
|
1129
|
+
expect(instance.value!.camera).toEqual({ k: 1, x: 1190, y: 900 });
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it("stops a pointer drag at the same edge", async () => {
|
|
1133
|
+
const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
|
|
1134
|
+
await nextTick();
|
|
1135
|
+
root.setPointerCapture = () => {};
|
|
1136
|
+
root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 21, clientX: 400, clientY: 300 }));
|
|
1137
|
+
// 400 px of drag left/up, in two moves, is nowhere near the limit —
|
|
1138
|
+
// then one enormous move rides into the wall.
|
|
1139
|
+
root.dispatchEvent(pointerEvent("pointermove", { pointerId: 21, clientX: 300, clientY: 250 }));
|
|
1140
|
+
root.dispatchEvent(pointerEvent("pointermove", { pointerId: 21, clientX: 200, clientY: 200 }));
|
|
1141
|
+
root.dispatchEvent(pointerEvent("pointermove", { pointerId: 21, clientX: -100000, clientY: -100000 }));
|
|
1142
|
+
document.body.dispatchEvent(pointerEvent("pointerup", { pointerId: 21, clientX: -100000, clientY: -100000, buttons: 0 }));
|
|
1143
|
+
expect(instance.value!.camera).toEqual({ k: 1, x: X_LIMIT_LOW, y: Y_LIMIT_LOW });
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it("keeps a wheel zoom anchored without escaping the walls", async () => {
|
|
1147
|
+
const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
|
|
1148
|
+
await nextTick();
|
|
1149
|
+
// Ride to the low-x wall first, then zoom out hard: the anchor keeps
|
|
1150
|
+
// the world point under the cursor, but the translation must stay
|
|
1151
|
+
// inside the clamp interval for the new k.
|
|
1152
|
+
instance.value?.panBy(-5000, 0);
|
|
1153
|
+
for (let step = 0; step < 12; step++) instance.value?.zoomBy(-1);
|
|
1154
|
+
const cam = instance.value!.camera;
|
|
1155
|
+
const xLow = -cam.k * (BOUNDS.x + BOUNDS.width) - CANVAS_BOUNDS_MARGIN * VIEWPORT.width;
|
|
1156
|
+
const xHigh = VIEWPORT.width - cam.k * BOUNDS.x + CANVAS_BOUNDS_MARGIN * VIEWPORT.width;
|
|
1157
|
+
expect(cam.x).toBeGreaterThanOrEqual(xLow - 1e-6);
|
|
1158
|
+
expect(cam.x).toBeLessThanOrEqual(xHigh + 1e-6);
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it("lets a controlled host's gestures clamp before the emit", async () => {
|
|
1162
|
+
const onUpdate = vi.fn();
|
|
1163
|
+
const { instance } = mount({
|
|
1164
|
+
contentBounds: BOUNDS,
|
|
1165
|
+
camera: { k: 1, x: 0, y: 0 },
|
|
1166
|
+
"onUpdate:camera": onUpdate,
|
|
1167
|
+
fitOnLoad: false,
|
|
1168
|
+
});
|
|
1169
|
+
await nextTick();
|
|
1170
|
+
instance.value?.panBy(-5000, -5000);
|
|
1171
|
+
const last = onUpdate.mock.calls.at(-1)?.[0] as { k: number; x: number; y: number };
|
|
1172
|
+
expect(last).toEqual({ k: 1, x: X_LIMIT_LOW, y: Y_LIMIT_LOW });
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it("does nothing the host did not ask for when the clamp is off", async () => {
|
|
1176
|
+
const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: false, clampToBounds: false });
|
|
1177
|
+
await nextTick();
|
|
1178
|
+
instance.value?.panBy(-5000, -5000);
|
|
1179
|
+
expect(instance.value!.camera).toEqual({ k: 1, x: -5000, y: -5000 });
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
it("never fights a fit: framing still centres the hull", async () => {
|
|
1183
|
+
const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
|
|
1184
|
+
await nextTick();
|
|
1185
|
+
instance.value?.panBy(-5000, -5000);
|
|
1186
|
+
expect(instance.value!.fit()).toBe(true);
|
|
1187
|
+
// 400×200 in 800×600 with 40 px padding → k = min(1.8, 2.6) capped at
|
|
1188
|
+
// fitCap 1.25, centred: x = (800-500)/2, y = (600-250)/2.
|
|
1189
|
+
expect(instance.value!.camera).toEqual({ k: 1.25, x: 150, y: 175 });
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1053
1192
|
});
|
|
@@ -13,6 +13,7 @@ import "./HkNodeCanvas.scss";
|
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
computeLod,
|
|
16
|
+
clampCameraToBounds,
|
|
16
17
|
edgeHitWidth,
|
|
17
18
|
edgeMidpoint,
|
|
18
19
|
edgePath,
|
|
@@ -39,7 +40,7 @@ export type {
|
|
|
39
40
|
NodeCanvasPainter,
|
|
40
41
|
PrintPaper,
|
|
41
42
|
} from "./nodeCanvasTypes";
|
|
42
|
-
export { computeLod, edgePath, edgeMidpoint, edgeHitWidth, sideOffset, oppositeSide, LOD_DEFAULTS, PAPER_SIZES } from "./nodeCanvasTypes";
|
|
43
|
+
export { computeLod, edgePath, edgeMidpoint, edgeHitWidth, sideOffset, oppositeSide, LOD_DEFAULTS, PAPER_SIZES, clampCameraToBounds, CANVAS_BOUNDS_MARGIN } from "./nodeCanvasTypes";
|
|
43
44
|
|
|
44
45
|
/** The pointer payload the edge events carry: a real MouseEvent for
|
|
45
46
|
* hover/click/contextmenu, or a synthesized stand-in when a touch
|
|
@@ -143,6 +144,13 @@ interface PendingRequest {
|
|
|
143
144
|
* read to simplify their nodes at low zoom.
|
|
144
145
|
* - a **print mode**: disables gestures, fixes the camera, and exposes
|
|
145
146
|
* `exportSVG()` for vector output.
|
|
147
|
+
* - a **bounds clamp** (default on): every camera write stops where the
|
|
148
|
+
* content hull would leave half a viewport of itself past the visible
|
|
149
|
+
* edge — a drag, a wheel or pinch zoom, a tween, an imperative call.
|
|
150
|
+
* A graph can still be stranded only through the host's own channels:
|
|
151
|
+
* opting out (`clampToBounds: false`), never supplying
|
|
152
|
+
* `contentBounds`, or a controlled `camera` prop the host writes
|
|
153
|
+
* verbatim (emitted gesture results are always pre-clamped).
|
|
146
154
|
*
|
|
147
155
|
* What it still does NOT own: what nodes look like, what data drives
|
|
148
156
|
* them, or how they are laid out. The host draws node content through
|
|
@@ -165,7 +173,10 @@ export default defineComponent({
|
|
|
165
173
|
/** Controlled camera (`v-model:camera`). Without it the component keeps
|
|
166
174
|
* its own, seeded by `fitOnLoad`. */
|
|
167
175
|
camera: { type: Object as PropType<NodeCanvasCamera | undefined>, default: undefined },
|
|
168
|
-
/** Content rectangle, in world units
|
|
176
|
+
/** Content rectangle, in world units — the hull every node on the
|
|
177
|
+
* canvas forms. `fit` frames it, and the bounds clamp (`clampToBounds`,
|
|
178
|
+
* on by default) refuses camera moves that would push it out of
|
|
179
|
+
* reach. */
|
|
169
180
|
contentBounds: { type: Object as PropType<NodeCanvasBounds | null>, default: null },
|
|
170
181
|
/** Smallest zoom the camera will settle on. */
|
|
171
182
|
minZoom: { type: Number, default: NODE_CANVAS_DEFAULTS.minZoom },
|
|
@@ -197,6 +208,15 @@ export default defineComponent({
|
|
|
197
208
|
pannable: { type: Boolean, default: true },
|
|
198
209
|
/** Whether the wheel zooms. */
|
|
199
210
|
zoomable: { type: Boolean, default: true },
|
|
211
|
+
/** Keep the content within reach: the camera may never push the
|
|
212
|
+
* content hull (the `contentBounds` envelope every node on the canvas
|
|
213
|
+
* forms) farther than half a viewport past the visible edge, per
|
|
214
|
+
* axis — the outward drag stops there instead of stranding the graph
|
|
215
|
+
* off-screen. On by default; a canvas with a genuinely unbounded
|
|
216
|
+
* surface (or one whose content the host moves independently) opts
|
|
217
|
+
* out with `false`. Needs `contentBounds` to know the hull — without
|
|
218
|
+
* it there is nothing to clamp against and this is a no-op. */
|
|
219
|
+
clampToBounds: { type: Boolean, default: true },
|
|
200
220
|
/** Mount the minimap in the corner. */
|
|
201
221
|
minimap: { type: Boolean, default: true },
|
|
202
222
|
/** Which corner the minimap takes. */
|
|
@@ -558,12 +578,21 @@ export default defineComponent({
|
|
|
558
578
|
// the surface freezes with no way back. Refuse to propagate it.
|
|
559
579
|
if (!Number.isFinite(k) || k <= 0) return;
|
|
560
580
|
if (!Number.isFinite(next.x) || !Number.isFinite(next.y)) return;
|
|
581
|
+
// Bounds clamp (default on): every write funnels through here, so a
|
|
582
|
+
// drag, a wheel/pinch zoom, a tween and an imperative `setCamera` all
|
|
583
|
+
// stop at the same edge — the content hull never leaves half a
|
|
584
|
+
// viewport of reach. `fit()` frames the hull inside the viewport and
|
|
585
|
+
// therefore always satisfies the clamp; it writes through
|
|
586
|
+
// `writeCamera` directly, so a host framing is never fought.
|
|
587
|
+
const bounded = props.clampToBounds
|
|
588
|
+
? clampCameraToBounds({ k, x: next.x, y: next.y }, props.contentBounds, viewport.value)
|
|
589
|
+
: { k, x: next.x, y: next.y };
|
|
561
590
|
// Any non-automatic camera move is the user's: a late `contentBounds`
|
|
562
591
|
// must not throw that work away. It also means the host is somewhere
|
|
563
592
|
// else, so a later `fit()` may ask for the same camera again.
|
|
564
593
|
movedByHand = true;
|
|
565
594
|
lastRequested = null;
|
|
566
|
-
writeCamera(
|
|
595
|
+
writeCamera(bounded);
|
|
567
596
|
}
|
|
568
597
|
|
|
569
598
|
/** The camera that frames `contentBounds` inside the viewport. */
|
|
@@ -79,7 +79,7 @@ export const HkTokenUsagePanel = defineComponent({
|
|
|
79
79
|
<div class="s-token-panel">
|
|
80
80
|
{props.entries.length === 0 ? (
|
|
81
81
|
<div class="s-token-panel-empty">
|
|
82
|
-
{t("hikari::tokenUsage.noData", "No token usage data available
|
|
82
|
+
{t("hikari::tokenUsage.noData", "No token usage data available")}
|
|
83
83
|
</div>
|
|
84
84
|
) : (
|
|
85
85
|
<>
|
|
@@ -63,7 +63,7 @@ export const HkVoiceInputPopup = defineComponent({
|
|
|
63
63
|
{props.mode === "notConfigured" ? (
|
|
64
64
|
<div class="s-voice-popup-body" data-phase="install">
|
|
65
65
|
<p class="s-voice-popup-text">
|
|
66
|
-
{t("hikari::chat.voice_not_configured", "Voice input requires the Whisper service
|
|
66
|
+
{t("hikari::chat.voice_not_configured", "Voice input requires the Whisper service")}
|
|
67
67
|
</p>
|
|
68
68
|
<button class="s-voice-popup-link" type="button" onClick={openSettings}>
|
|
69
69
|
{t("hikari::chat.voice_go_settings", "Open Voice Settings →")}
|