@celestia-island/hikari 0.55.91 → 0.55.93

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 (79) 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/HkIconButton.tsx +2 -2
  14. package/src/components/HkIconButtonVars.scss +3 -3
  15. package/src/components/HkLogWindow.tsx +1 -1
  16. package/src/components/HkThemeToggle.ghost-chrome.test.ts +32 -18
  17. package/src/components/HkThemeToggle.scss +9 -36
  18. package/src/components/HkThemeToggle.test.ts +59 -3
  19. package/src/components/HkThemeToggle.tsx +34 -37
  20. package/src/components/HkTokenUsagePanel.tsx +1 -1
  21. package/src/components/HkVoiceInputPopup.tsx +1 -1
  22. package/src/errorReporting/HkErrorReportingOverlay.tsx +16 -2
  23. package/src/errorReporting/index.test.tsx +3 -0
  24. package/src/i18n/flatKeyParity.test.ts +28 -13
  25. package/src/i18n/locales/ar/chat.json +2 -2
  26. package/src/i18n/locales/ar/components.json +2 -3
  27. package/src/i18n/locales/ar/components.toml +0 -1
  28. package/src/i18n/locales/ar/errors.json +3 -1
  29. package/src/i18n/locales/ar/platform.json +8 -8
  30. package/src/i18n/locales/de/chat.json +2 -2
  31. package/src/i18n/locales/de/components.json +2 -3
  32. package/src/i18n/locales/de/components.toml +0 -1
  33. package/src/i18n/locales/de/errors.json +3 -1
  34. package/src/i18n/locales/de/platform.json +8 -8
  35. package/src/i18n/locales/en/chat.json +2 -2
  36. package/src/i18n/locales/en/components.json +2 -3
  37. package/src/i18n/locales/en/components.toml +0 -1
  38. package/src/i18n/locales/en/errors.json +3 -1
  39. package/src/i18n/locales/en/platform.json +8 -8
  40. package/src/i18n/locales/es/chat.json +2 -2
  41. package/src/i18n/locales/es/components.json +2 -3
  42. package/src/i18n/locales/es/components.toml +0 -1
  43. package/src/i18n/locales/es/errors.json +3 -1
  44. package/src/i18n/locales/es/platform.json +8 -8
  45. package/src/i18n/locales/fr/chat.json +2 -2
  46. package/src/i18n/locales/fr/components.json +2 -3
  47. package/src/i18n/locales/fr/components.toml +0 -1
  48. package/src/i18n/locales/fr/errors.json +3 -1
  49. package/src/i18n/locales/fr/platform.json +8 -8
  50. package/src/i18n/locales/ja/chat.json +2 -2
  51. package/src/i18n/locales/ja/components.json +2 -3
  52. package/src/i18n/locales/ja/components.toml +0 -1
  53. package/src/i18n/locales/ja/errors.json +3 -1
  54. package/src/i18n/locales/ja/platform.json +8 -8
  55. package/src/i18n/locales/ko/chat.json +2 -2
  56. package/src/i18n/locales/ko/components.json +2 -3
  57. package/src/i18n/locales/ko/components.toml +0 -1
  58. package/src/i18n/locales/ko/errors.json +3 -1
  59. package/src/i18n/locales/ko/platform.json +8 -8
  60. package/src/i18n/locales/pt/chat.json +2 -2
  61. package/src/i18n/locales/pt/components.json +2 -3
  62. package/src/i18n/locales/pt/components.toml +0 -1
  63. package/src/i18n/locales/pt/errors.json +3 -1
  64. package/src/i18n/locales/pt/platform.json +8 -8
  65. package/src/i18n/locales/ru/chat.json +2 -2
  66. package/src/i18n/locales/ru/components.json +2 -3
  67. package/src/i18n/locales/ru/components.toml +0 -1
  68. package/src/i18n/locales/ru/errors.json +3 -1
  69. package/src/i18n/locales/ru/platform.json +8 -8
  70. package/src/i18n/locales/zh-Hans/chat.json +2 -2
  71. package/src/i18n/locales/zh-Hans/components.json +2 -3
  72. package/src/i18n/locales/zh-Hans/components.toml +0 -1
  73. package/src/i18n/locales/zh-Hans/errors.json +3 -1
  74. package/src/i18n/locales/zh-Hans/platform.json +8 -8
  75. package/src/i18n/locales/zh-Hant/chat.json +2 -2
  76. package/src/i18n/locales/zh-Hant/components.json +2 -3
  77. package/src/i18n/locales/zh-Hant/components.toml +0 -1
  78. package/src/i18n/locales/zh-Hant/errors.json +3 -1
  79. 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.91",
3
+ "version": "0.55.93",
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
  );
@@ -9,8 +9,8 @@ export default defineComponent({
9
9
  props: {
10
10
  icon: { type: String, default: "" },
11
11
  variant: { type: String as PropType<"ghost" | "primary" | "secondary" | "danger" | "success">, default: "ghost" },
12
- // 28 is the header chrome step: HkThemeToggle's trigger buttons are
13
- // 28px boxes with 16px glyphs, so a header action that must read as
12
+ // 28 is the header chrome step: HkThemeToggle's trigger is a 28px box
13
+ // with a 16px glyph, so a header action that must read as
14
14
  // "the same kind of button" (global search, the console pages'
15
15
  // refresh) composes at 28 — 24 read visibly smaller beside the toggle
16
16
  // (user report 2026-09-28: 前台全局搜索与主题切换按钮不同尺寸).
@@ -76,9 +76,9 @@
76
76
  --hi-icon-button-size: 28px;
77
77
  /* The header-chrome step carries the sm glyph (16px): its consumers are
78
78
  * the header clusters — global search, the pages' refresh, the filter
79
- * trigger — whose visual sibling is HkThemeToggle's own trigger buttons
80
- * (28px boxes with direct 16px lucide glyphs). The xs mapping (12px)
81
- * rendered those glyphs visibly smaller than the sun beside them
79
+ * trigger — whose visual sibling is HkThemeToggle's own trigger (one
80
+ * 28px box with a direct 16px lucide glyph). The xs mapping (12px)
81
+ * rendered those glyphs visibly smaller than the glyphs beside them
82
82
  * (user report 2026-09-29: 不像一个尺寸的图标按钮) — the `.svg` rule
83
83
  * in HkIconButton.scss forces ANY slotted glyph to this var, so the
84
84
  * slot's size-16 attribute never reached the screen. */
@@ -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)}`]}>
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Source contract for the theme-toggle trigger chrome (2026-09-08 user
3
3
  * report: the bordered surface box read as a mystery outline in the
4
- * header and fought the host themes — the faint border, the host
5
- * override layer and the `--hk-theme-toggle-btn-border` var never
6
- * agreed). The trigger is now the STANDARD ghost icon-button chrome
7
- * (same as `.hk-btn-ghost` / the HkIconButton ghost variant beside it):
8
- * transparent at rest, primary wash on hover, no border, no surface,
9
- * no shadow. Pinned here so the bordered-box chrome cannot silently
10
- * return.
4
+ * header and fought the host themes). That round standardized the trigger
5
+ * onto ghost chrome via a bespoke scss block; 2026-09-30 (user direction)
6
+ * went one step further: the trigger IS an HkIconButton (ghost/28 — the
7
+ * header chrome step, user report 2026-09-28) now — the retired
8
+ * main/arrow button pair, whose primary hover wash disagreed with the
9
+ * ghost gray of the buttons beside it, is merged into ONE palette-glyph
10
+ * trigger, and every byte of trigger chrome comes from the shared
11
+ * HkIconButtonVars contract. Pinned here so neither the bespoke block
12
+ * nor the two-button pair can silently return.
11
13
  */
12
14
  import { beforeAll, describe, expect, it } from "vitest";
13
15
  import { readFileSync } from "node:fs";
@@ -15,27 +17,39 @@ import { dirname, join } from "node:path";
15
17
  import { fileURLToPath } from "node:url";
16
18
 
17
19
  const here = dirname(fileURLToPath(import.meta.url));
18
- const src = readFileSync(join(here, "HkThemeToggle.scss"), "utf-8");
20
+ const scss = readFileSync(join(here, "HkThemeToggle.scss"), "utf-8");
21
+ const tsx = readFileSync(join(here, "HkThemeToggle.tsx"), "utf-8");
19
22
 
20
- describe("HkThemeToggle ghost trigger chrome contract", () => {
23
+ describe("HkThemeToggle trigger chrome contract", () => {
21
24
  let block = "";
22
25
  beforeAll(() => {
23
- block = src.match(/\.s-theme-toggle-btn\s*{[^}]*}/)?.[0] ?? "";
26
+ // Line-anchored: prose comments may NAME the hook class — only a real
27
+ // top-level rule block for it counts as chrome coming back.
28
+ block = scss.match(/^\.s-theme-toggle-btn[^{]*\{[^}]*\}/m)?.[0] ?? "";
24
29
  });
25
30
 
26
- it("paints no border on the trigger (border: none)", () => {
27
- expect(block).toContain("border: none;");
31
+ it("carries no bespoke chrome block for the trigger", () => {
32
+ // The class survives ONLY as a stable hook on the HkIconButton —
33
+ // any rule painting it would fork the chrome away from the shared
34
+ // ghost contract again.
35
+ expect(block).toBe("");
28
36
  });
29
37
 
30
- it("keeps the rest state transparent (no surface box)", () => {
31
- expect(block).toContain("background: transparent;");
38
+ it("removes the per-theme border var entirely", () => {
39
+ expect(scss).not.toContain("--hk-theme-toggle-btn-border");
32
40
  });
33
41
 
34
- it("removes the per-theme border var entirely", () => {
35
- expect(src).not.toContain("--hk-theme-toggle-btn-border");
42
+ it("renders the trigger through the standard HkIconButton", () => {
43
+ expect(tsx).toContain("HkIconButton");
44
+ expect(tsx).toMatch(/variant="ghost"/);
36
45
  });
37
46
 
38
- it("retains a visible focus ring via :focus-visible", () => {
39
- expect(src).toMatch(/\.s-theme-toggle-btn:focus-visible\s*{[^}]*}/);
47
+ it("merges the retired main/arrow pair into one trigger", () => {
48
+ // One s-theme-toggle-btn element, and the retired data hooks are gone.
49
+ const triggers = tsx.match(/class="s-theme-toggle-btn"/g) ?? [];
50
+ expect(triggers).toHaveLength(1);
51
+ expect(tsx).not.toContain('data-variant="arrow"');
52
+ expect(tsx).not.toContain('data-variant="main"');
53
+ expect(tsx).not.toContain("toggleMode");
40
54
  });
41
55
  });
@@ -3,35 +3,17 @@
3
3
  .s-theme-toggle {
4
4
  display: inline-flex;
5
5
  align-items: center;
6
- gap: var(--space-2, 2px);
7
6
  }
8
7
 
9
- .s-theme-toggle-btn {
10
- display: inline-flex;
11
- align-items: center;
12
- justify-content: center;
13
- height: 1.75rem;
14
- min-width: 1.75rem;
15
- padding: 0 var(--space-6, 0.375rem);
16
- /* Standard ghost icon-button chrome (2026-09-08 user report: the
17
- * bordered surface box read as a mystery outline in the header and
18
- * fought the host themes — the faint border, the host override layer
19
- * and the border-var never agreed). The trigger now matches the
20
- * standard ghost buttons beside it (same chrome as .hk-btn-ghost and
21
- * the HkIconButton ghost variant): transparent at rest, primary wash
22
- * on hover, no border, no surface, no shadow, no per-theme border var. */
23
- border: none;
24
- border-radius: var(--radius-sm, 6px);
25
- background: transparent;
26
- color: rgb(var(--color-text, var(--color-text-secondary, var(--color-muted))));
27
- cursor: pointer;
28
- transition: background 0.15s ease, color 0.15s ease;
29
- }
30
-
31
- .s-theme-toggle-btn:focus-visible {
32
- outline: 2px solid rgb(var(--color-primary));
33
- outline-offset: 1px;
34
- }
8
+ /* The trigger carries NO css of its own (2026-09-30 user direction): the
9
+ * retired bespoke trigger block — whose primary hover wash disagreed with
10
+ * the ghost gray of the HkIconButtons beside it and made the header
11
+ * cluster read as two different button families — is gone. The trigger IS
12
+ * an HkIconButton (ghost/28, the header chrome step) now, so its
13
+ * rest/hover/press chrome, radius and focus ring come verbatim from the
14
+ * shared HkIconButtonVars contract; the s-theme-toggle-btn class survives
15
+ * only as a stable hook on that button (chest's chrome guard asserts
16
+ * chest never re-paints it). Do not reintroduce a chrome rule for it. */
35
17
 
36
18
  /* Host strip under the mode group (mode-extra slot). */
37
19
  .s-theme-mode-extra {
@@ -39,15 +21,6 @@
39
21
  width: 100%;
40
22
  }
41
23
 
42
- .s-theme-toggle-btn:hover {
43
- background: rgb(var(--color-primary) / 12%);
44
- color: rgb(var(--color-text));
45
- }
46
-
47
- .s-theme-toggle-btn:active {
48
- background: rgb(var(--color-primary) / 16%);
49
- }
50
-
51
24
  .s-theme-menu {
52
25
  display: flex;
53
26
  flex-direction: column;
@@ -60,10 +60,18 @@ async function settle(): Promise<void> {
60
60
  }
61
61
 
62
62
  function openMenu(container: HTMLElement): void {
63
- const arrow = container.querySelector(
64
- '.s-theme-toggle-btn[data-variant="arrow"]',
63
+ // The single merged trigger (user direction 2026-09-30): ONE ghost
64
+ // HkIconButton opens the menu — the retired main/arrow pair is gone.
65
+ const trigger = container.querySelector(
66
+ ".s-theme-toggle-btn",
65
67
  ) as HTMLButtonElement | null;
66
- arrow!.click();
68
+ trigger!.click();
69
+ }
70
+
71
+ function triggerButton(container: HTMLElement): HTMLButtonElement {
72
+ const trigger = container.querySelector<HTMLButtonElement>(".s-theme-toggle-btn");
73
+ expect(trigger).toBeTruthy();
74
+ return trigger!;
67
75
  }
68
76
 
69
77
  function paletteButton(): HTMLButtonElement {
@@ -96,6 +104,54 @@ afterEach(() => {
96
104
  vi.unstubAllGlobals();
97
105
  });
98
106
 
107
+ describe("HkThemeToggle trigger", () => {
108
+ it("renders ONE standard ghost icon-button trigger and no retired pair", async () => {
109
+ const { container } = mountToggle(true);
110
+ await settle();
111
+
112
+ // Exactly one trigger, and it IS the shared standard part: the merged
113
+ // single button (user direction 2026-09-30) must be chrome-identical
114
+ // to the functional HkIconButtons hosts place beside it — the ghost
115
+ // variant's shared rest/hover/press paint, not a bespoke block. The
116
+ // 28 step is the header chrome step (user report 2026-09-28: the
117
+ // cluster reads as one row only at ghost/28, sm 16px glyph).
118
+ const triggers = container.querySelectorAll(".s-theme-toggle-btn");
119
+ expect(triggers).toHaveLength(1);
120
+ const trigger = triggers[0] as HTMLElement;
121
+ expect(trigger.classList.contains("hk-icon-button")).toBe(true);
122
+ expect(trigger.classList.contains("hk-icon-button-28")).toBe(true);
123
+ expect(trigger.classList.contains("hk-icon-button-ghost")).toBe(true);
124
+ // The retired main/arrow data hooks must never come back.
125
+ expect(trigger.hasAttribute("data-variant")).toBe(false);
126
+ // A palette glyph, not the retired mode glyphs (the mode state lives
127
+ // in the menu's segmented group now).
128
+ expect(trigger.querySelector("svg")).toBeTruthy();
129
+ });
130
+
131
+ it("opens the theme menu from the single trigger with menu aria wiring", async () => {
132
+ const { container } = mountToggle(true);
133
+ await settle();
134
+
135
+ const trigger = triggerButton(container);
136
+ expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
137
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
138
+ // A click opens the menu; no mode switch fires (the retired direct
139
+ // mode-cycle press is gone — mode switching lives in the strip).
140
+ const modeBefore = useTheme().currentMode.value;
141
+ trigger.click();
142
+ await settle();
143
+ expect(document.body.querySelector(".s-theme-menu")).toBeTruthy();
144
+ expect(useTheme().currentMode.value).toBe(modeBefore);
145
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
146
+
147
+ // Second press closes it again (plain toggle, same as before).
148
+ trigger.click();
149
+ await settle();
150
+ expect(document.body.querySelector(".s-theme-menu")).toBeNull();
151
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
152
+ });
153
+ });
154
+
99
155
  describe("HkThemeToggle external customization", () => {
100
156
  it("emits open-customize and keeps the dialog closed when externalCustomize is true", async () => {
101
157
  const { container, openCustomize } = mountToggle(true);