@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.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkAttachmentModal.tsx +1 -1
  3. package/src/components/HkCaptchaModal.tsx +1 -1
  4. package/src/components/HkCaptchaWidget.tsx +1 -1
  5. package/src/components/HkCookieConsent.test.tsx +1 -1
  6. package/src/components/HkCookieConsent.tsx +1 -1
  7. package/src/components/HkErrorBoundary.test.tsx +11 -0
  8. package/src/components/HkErrorBoundary.tsx +9 -9
  9. package/src/components/HkErrorLanding.inline.contract.test.ts +12 -0
  10. package/src/components/HkErrorLanding.scss +5 -0
  11. package/src/components/HkErrorLanding.test.tsx +70 -2
  12. package/src/components/HkErrorLanding.tsx +59 -2
  13. package/src/components/HkLogWindow.tsx +1 -1
  14. package/src/components/HkNodeCanvas.test.tsx +139 -0
  15. package/src/components/HkNodeCanvas.tsx +32 -3
  16. package/src/components/HkTokenUsagePanel.tsx +1 -1
  17. package/src/components/HkVoiceInputPopup.tsx +1 -1
  18. package/src/components/nodeCanvasTypes.ts +68 -0
  19. package/src/errorReporting/HkErrorReportingOverlay.tsx +16 -2
  20. package/src/errorReporting/index.test.tsx +3 -0
  21. package/src/i18n/flatKeyParity.test.ts +28 -13
  22. package/src/i18n/locales/ar/chat.json +2 -2
  23. package/src/i18n/locales/ar/components.json +2 -3
  24. package/src/i18n/locales/ar/components.toml +0 -1
  25. package/src/i18n/locales/ar/errors.json +3 -1
  26. package/src/i18n/locales/ar/platform.json +8 -8
  27. package/src/i18n/locales/de/chat.json +2 -2
  28. package/src/i18n/locales/de/components.json +2 -3
  29. package/src/i18n/locales/de/components.toml +0 -1
  30. package/src/i18n/locales/de/errors.json +3 -1
  31. package/src/i18n/locales/de/platform.json +8 -8
  32. package/src/i18n/locales/en/chat.json +2 -2
  33. package/src/i18n/locales/en/components.json +2 -3
  34. package/src/i18n/locales/en/components.toml +0 -1
  35. package/src/i18n/locales/en/errors.json +3 -1
  36. package/src/i18n/locales/en/platform.json +8 -8
  37. package/src/i18n/locales/es/chat.json +2 -2
  38. package/src/i18n/locales/es/components.json +2 -3
  39. package/src/i18n/locales/es/components.toml +0 -1
  40. package/src/i18n/locales/es/errors.json +3 -1
  41. package/src/i18n/locales/es/platform.json +8 -8
  42. package/src/i18n/locales/fr/chat.json +2 -2
  43. package/src/i18n/locales/fr/components.json +2 -3
  44. package/src/i18n/locales/fr/components.toml +0 -1
  45. package/src/i18n/locales/fr/errors.json +3 -1
  46. package/src/i18n/locales/fr/platform.json +8 -8
  47. package/src/i18n/locales/ja/chat.json +2 -2
  48. package/src/i18n/locales/ja/components.json +2 -3
  49. package/src/i18n/locales/ja/components.toml +0 -1
  50. package/src/i18n/locales/ja/errors.json +3 -1
  51. package/src/i18n/locales/ja/platform.json +8 -8
  52. package/src/i18n/locales/ko/chat.json +2 -2
  53. package/src/i18n/locales/ko/components.json +2 -3
  54. package/src/i18n/locales/ko/components.toml +0 -1
  55. package/src/i18n/locales/ko/errors.json +3 -1
  56. package/src/i18n/locales/ko/platform.json +8 -8
  57. package/src/i18n/locales/pt/chat.json +2 -2
  58. package/src/i18n/locales/pt/components.json +2 -3
  59. package/src/i18n/locales/pt/components.toml +0 -1
  60. package/src/i18n/locales/pt/errors.json +3 -1
  61. package/src/i18n/locales/pt/platform.json +8 -8
  62. package/src/i18n/locales/ru/chat.json +2 -2
  63. package/src/i18n/locales/ru/components.json +2 -3
  64. package/src/i18n/locales/ru/components.toml +0 -1
  65. package/src/i18n/locales/ru/errors.json +3 -1
  66. package/src/i18n/locales/ru/platform.json +8 -8
  67. package/src/i18n/locales/zh-Hans/chat.json +2 -2
  68. package/src/i18n/locales/zh-Hans/components.json +2 -3
  69. package/src/i18n/locales/zh-Hans/components.toml +0 -1
  70. package/src/i18n/locales/zh-Hans/errors.json +3 -1
  71. package/src/i18n/locales/zh-Hans/platform.json +8 -8
  72. package/src/i18n/locales/zh-Hant/chat.json +2 -2
  73. package/src/i18n/locales/zh-Hant/components.json +2 -3
  74. package/src/i18n/locales/zh-Hant/components.toml +0 -1
  75. package/src/i18n/locales/zh-Hant/errors.json +3 -1
  76. package/src/i18n/locales/zh-Hant/platform.json +8 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.92",
3
+ "version": "0.55.94",
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",
@@ -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.")}</p>
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).")}</span>
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 { Copy, RefreshCw } from "lucide-vue-next";
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 / copy actions.
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
- {slots.actions && <div class="hk-error-landing__actions">{slots.actions()}</div>}
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.")}</div>
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, used for fit and clamping. */
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({ k, x: next.x, y: next.y });
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 →")}