@celestia-island/hikari 0.55.85 → 0.55.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.85",
3
+ "version": "0.55.87",
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",
@@ -66,6 +66,10 @@ export function isCssAnimationsEnabled(): boolean {
66
66
  registerCssAnimation("s-nav-item-badge-pulse", { infinite: true });
67
67
  // styles/admin-tokens.scss — auth card entrance (plays once).
68
68
  registerCssAnimation("s-auth-card-in");
69
+ // styles/admin-tokens.scss — status-bar recovery flash (plays once):
70
+ // the traffic light's green background blink when a dropped connection
71
+ // comes back.
72
+ registerCssAnimation("s-status-bar-recover-flash");
69
73
  // components/HkStatusPill.scss — live-dot pulse (loops forever).
70
74
  registerCssAnimation("hk-status-pill-pulse", { infinite: true });
71
75
  // components/HkRollingNumber.scss — digit roll-up (plays once, forwards).
@@ -247,4 +247,23 @@ describe("HkAdminHeader", () => {
247
247
  const c = mount(headerNode({ username: "alice" }));
248
248
  expect(c.textContent.toLowerCase()).not.toContain("emergency");
249
249
  });
250
+
251
+ it("renders the page centre group between the identity and the actions when provided", () => {
252
+ const c = mount(headerNode({ title: "My tasks" }, {
253
+ center: () => h("nav", { class: "centre-stub" }, "filter group"),
254
+ }));
255
+ const centre = c.querySelector(".s-admin-header-center");
256
+ expect(centre, "the centre wrapper must render when the slot is provided").not.toBeNull();
257
+ expect(centre?.querySelector(".centre-stub")?.textContent).toBe("filter group");
258
+ // Wing order: identity (left) → centre → actions (right).
259
+ const header = c.querySelector("header")!;
260
+ const kids = [...header.children].map((el) => el.className);
261
+ expect(kids.indexOf("s-admin-header-user")).toBeLessThan(kids.indexOf("s-admin-header-center"));
262
+ expect(kids.indexOf("s-admin-header-center")).toBeLessThan(kids.indexOf("s-admin-header-actions"));
263
+ });
264
+
265
+ it("renders no centre wrapper without the slot (DOM unchanged for hosts that skip it)", () => {
266
+ const c = mount(headerNode({ title: "Providers" }));
267
+ expect(c.querySelector(".s-admin-header-center")).toBeNull();
268
+ });
250
269
  });
@@ -283,6 +283,19 @@ export const HkAdminHeader = defineComponent({
283
283
  }}
284
284
  </HkMenu>
285
285
 
286
+ {/* Page-owned centre group (page filter tab strips, segmented
287
+ * option pickers) — the console face of the balanced-wing
288
+ * header the chat frontend's top bar already uses: the left
289
+ * and right wings grow from the same flex basis so this group
290
+ * stays pinned to the true viewport middle. Rendered only when
291
+ * the host provides it, so headers without a centre keep the
292
+ * exact DOM they always had. */}
293
+ {slots.center && (
294
+ <div class="s-admin-header-center">
295
+ {slots.center()}
296
+ </div>
297
+ )}
298
+
286
299
  <div class="s-admin-header-actions">
287
300
  {props.showEmergencyStop && (
288
301
  <button
@@ -9,7 +9,12 @@ 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
- size: { type: Number as PropType<16 | 24 | 32 | 36 | 40>, default: 32 },
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
14
+ // "the same kind of button" (global search, the console pages'
15
+ // refresh) composes at 28 — 24 read visibly smaller beside the toggle
16
+ // (user report 2026-09-28: 前台全局搜索与主题切换按钮不同尺寸).
17
+ size: { type: Number as PropType<16 | 24 | 28 | 32 | 36 | 40>, default: 32 },
13
18
  disabled: { type: Boolean, default: false },
14
19
  },
15
20
  emits: {
@@ -72,6 +72,11 @@
72
72
  --hi-icon-button-icon-size: var(--hi-icon-size-xs);
73
73
  }
74
74
 
75
+ .hk-icon-button-28 {
76
+ --hi-icon-button-size: 28px;
77
+ --hi-icon-button-icon-size: var(--hi-icon-size-xs);
78
+ }
79
+
75
80
  .hk-icon-button-32 {
76
81
  --hi-icon-button-size: 32px;
77
82
  --hi-icon-button-icon-size: var(--hi-icon-size-sm);
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { createApp, h, nextTick } from "vue";
2
+ import { createApp, h, nextTick, ref } from "vue";
3
3
 
4
4
  import { HkStatusBar } from "./HkStatusBar";
5
5
  import type { HkConnectionInfo } from "./HkConnectionInfo";
@@ -15,6 +15,31 @@ function mountBar(props: Record<string, unknown> = {}) {
15
15
  return container;
16
16
  }
17
17
 
18
+ /** Mount with a reactive connectionStatus the test can drive through
19
+ * state transitions (the fixed-props mountBar cannot re-render). */
20
+ type BarStatus = "connected" | "disconnected" | "connecting" | "reconnecting";
21
+
22
+ function mountReactiveStatus(initial: BarStatus) {
23
+ const status = ref(initial);
24
+ const container = document.createElement("div");
25
+ document.body.appendChild(container);
26
+ const app = createApp({
27
+ render: () =>
28
+ h(HkStatusBar, {
29
+ version: "1.2.3",
30
+ connectionStatus: status.value,
31
+ connectionInfo: INFO,
32
+ }),
33
+ });
34
+ app.mount(container);
35
+ mounts.push({ app, container });
36
+ return { container, status };
37
+ }
38
+
39
+ function tagOf(container: HTMLElement) {
40
+ return container.querySelector<HTMLElement>(".s-status-bar-tag")!;
41
+ }
42
+
18
43
  const INFO: HkConnectionInfo = {
19
44
  state: "connected",
20
45
  tier: "poll",
@@ -221,4 +246,80 @@ describe("HkStatusBar", () => {
221
246
  expect(onRetry, "flick is not a tap").not.toHaveBeenCalled();
222
247
  expect(document.body.querySelector(".hk-popover-panel")).toBeNull();
223
248
  });
249
+
250
+ it("stays quiet on the first connect: no recovery class on open", async () => {
251
+ vi.useFakeTimers();
252
+ try {
253
+ // The page-open sequence: connecting → connected for the first
254
+ // time in this component's life. No flash — the pill goes green
255
+ // without announcing itself (hosts used to toast here).
256
+ const { container, status } = mountReactiveStatus("connecting");
257
+ await nextTick();
258
+ expect(tagOf(container).className).toContain("s-status-bar-tag-reconnecting");
259
+ status.value = "connected";
260
+ await nextTick();
261
+ expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
262
+ vi.advanceTimersByTime(1500);
263
+ await nextTick();
264
+ expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
265
+ } finally {
266
+ vi.useRealTimers();
267
+ }
268
+ });
269
+
270
+ it("flashes the background once when a dropped connection recovers", async () => {
271
+ vi.useFakeTimers();
272
+ try {
273
+ // Mounted while connected (established), drops, then recovers:
274
+ // the recovering class rides the pill for the flash window and
275
+ // is gone after it.
276
+ const { container, status } = mountReactiveStatus("connected");
277
+ await nextTick();
278
+ status.value = "reconnecting";
279
+ await nextTick();
280
+ expect(tagOf(container).className).toContain("s-status-bar-tag-reconnecting");
281
+ status.value = "connected";
282
+ await nextTick();
283
+ expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
284
+ // Mid-window (t=600ms): the class must still ride the pill — the JS
285
+ // window (1200ms) has to outlive the 1.1s CSS animation, or the class
286
+ // would truncate the decay's tail and snap to the resting color.
287
+ vi.advanceTimersByTime(600);
288
+ await nextTick();
289
+ expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
290
+ vi.advanceTimersByTime(700);
291
+ await nextTick();
292
+ expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
293
+ } finally {
294
+ vi.useRealTimers();
295
+ }
296
+ });
297
+
298
+ it("re-arms the flash on a later drop/recover cycle after the window closes", async () => {
299
+ vi.useFakeTimers();
300
+ try {
301
+ const { container, status } = mountReactiveStatus("connected");
302
+ await nextTick();
303
+ status.value = "disconnected";
304
+ await nextTick();
305
+ status.value = "connected";
306
+ await nextTick();
307
+ expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
308
+ vi.advanceTimersByTime(1300);
309
+ await nextTick();
310
+ expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
311
+ // Second cycle AFTER the window closed: the class must come back —
312
+ // a one-shot sticky flag or a dead timer would leave it dark.
313
+ status.value = "reconnecting";
314
+ await nextTick();
315
+ status.value = "connected";
316
+ await nextTick();
317
+ expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
318
+ vi.advanceTimersByTime(1300);
319
+ await nextTick();
320
+ expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
321
+ } finally {
322
+ vi.useRealTimers();
323
+ }
324
+ });
224
325
  });
@@ -1,4 +1,4 @@
1
- import { defineComponent, ref, type PropType } from "vue";
1
+ import { defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
2
2
  import { Wifi, WifiOff, Globe, Cable, Monitor, Cog, Plug } from "lucide-vue-next";
3
3
 
4
4
  import { useI18n } from "../i18n/context";
@@ -133,6 +133,38 @@ export const HkStatusBar = defineComponent({
133
133
  disconnected: "rgb(var(--color-error))",
134
134
  };
135
135
 
136
+ // ── Recovery flash ─────────────────────────────────────────────
137
+ // One soft green background blink when the light RETURNS to green
138
+ // after a drop (reconnecting/connecting/disconnected → connected).
139
+ // The FIRST connect of the component's life stays quiet: the flash
140
+ // acknowledges a connection the user saw go down, it is not an
141
+ // open-time announcement (hosts used to fire a toast for exactly
142
+ // this moment; the light now owns that feedback, quietly).
143
+ // `seenConnected` seeds from the initial prop so a component that
144
+ // mounts while already connected still counts as established and a
145
+ // LATER drop/recover pair flashes.
146
+ const RECOVER_FLASH_MS = 1200;
147
+ const seenConnected = ref(props.connectionStatus === "connected");
148
+ const recovering = ref(false);
149
+ let recoverTimer: ReturnType<typeof setTimeout> | null = null;
150
+
151
+ watch(() => props.connectionStatus, (next) => {
152
+ if (next !== "connected") return;
153
+ const wasEstablished = seenConnected.value;
154
+ seenConnected.value = true;
155
+ if (!wasEstablished) return;
156
+ if (recoverTimer) clearTimeout(recoverTimer);
157
+ recovering.value = true;
158
+ recoverTimer = setTimeout(() => {
159
+ recovering.value = false;
160
+ recoverTimer = null;
161
+ }, RECOVER_FLASH_MS);
162
+ });
163
+
164
+ onBeforeUnmount(() => {
165
+ if (recoverTimer) clearTimeout(recoverTimer);
166
+ });
167
+
136
168
  function onTagEnter() {
137
169
  if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
138
170
  popupOpen.value = true;
@@ -244,9 +276,11 @@ export const HkStatusBar = defineComponent({
244
276
  // + value column), so both rows share one true left edge — no
245
277
  // separator, no mid-token wrapping, at any footer width.
246
278
 
247
- const tagClass = connecting
248
- ? "s-status-bar-tag s-status-bar-tag-reconnecting"
249
- : "s-status-bar-tag";
279
+ const tagClass = [
280
+ "s-status-bar-tag",
281
+ connecting ? "s-status-bar-tag-reconnecting" : "",
282
+ recovering.value ? "s-status-bar-tag-recovered" : "",
283
+ ].filter(Boolean).join(" ");
250
284
 
251
285
  const inner = (
252
286
  <>
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Static pins for the admin header's chrome geometry (2026-09-28 user
8
+ * report, R1 findings b/c: reverting either the avatar→title gap or the
9
+ * 28px icon-button box kept the whole suite green — the two values the
10
+ * user explicitly asked for had no red-if-broken evidence). happy-dom
11
+ * has no layout engine, so — headerAvatarGeometry-style — these read the
12
+ * declarations themselves; they fail when the values move, disappear or
13
+ * stop reading the way they read here, and a deliberate rewrite is
14
+ * expected to update them.
15
+ */
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+
19
+ /** Declarations of the rule whose prelude IS `selector` (whole-prelude
20
+ * match, comments stripped), as [prop, value] pairs; null when absent. */
21
+ function ruleDeclarations(source: string, selector: string): Array<[string, string]> | null {
22
+ const code = source.replace(/\/\*[\s\S]*?\*\//g, "");
23
+ for (let at = code.indexOf(selector); at >= 0; at = code.indexOf(selector, at + 1)) {
24
+ const open = code.indexOf("{", at + selector.length);
25
+ if (open < 0) continue;
26
+ if (code.slice(at + selector.length, open).trim() !== "") continue;
27
+ let boundary = at - 1;
28
+ while (boundary >= 0 && !"}{;".includes(code[boundary])) boundary -= 1;
29
+ if (code.slice(boundary + 1, at).replace(/\s+/g, " ").trim() !== "") continue;
30
+ const body = code.slice(open + 1, code.indexOf("}", open));
31
+ const out: Array<[string, string]> = [];
32
+ for (const part of body.split(";")) {
33
+ const m = /^\s*([-a-zA-Z]+)\s*:\s*([\s\S]+?)\s*$/.exec(part);
34
+ if (m) out.push([m[1], m[2].replace(/\s+/g, " ")]);
35
+ }
36
+ return out;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ const adminTokens = readFileSync(join(here, "..", "styles", "admin-tokens.scss"), "utf8");
42
+ const iconButtonVars = readFileSync(join(here, "HkIconButtonVars.scss"), "utf8");
43
+ const iconButton = readFileSync(join(here, "HkIconButton.tsx"), "utf8");
44
+
45
+ describe("admin header chrome geometry (the two user-requested fixes)", () => {
46
+ it("keeps the console title at the panel's visual distance from the avatar (16px wing gap)", () => {
47
+ // The frontend's workspace button sits a flex gap PLUS its own 8px
48
+ // inline padding from the avatar (16px visual); the console title
49
+ // carried only the 8px flex gap and visibly hugged the avatar.
50
+ const rule = ruleDeclarations(adminTokens, ".s-admin-header-user");
51
+ expect(rule, ".s-admin-header-user must be declared").not.toBeNull();
52
+ expect(rule).toContainEqual(["gap", "var(--space-16)"]);
53
+ // Balanced left wing (the centre group stays viewport-centred).
54
+ expect(rule).toContainEqual(["flex", "1 1 0"]);
55
+ });
56
+
57
+ it("keeps the actions wing balanced (no margin-left:auto push) and the centre wing shrinkable", () => {
58
+ const actions = ruleDeclarations(adminTokens, ".s-admin-header-actions");
59
+ expect(actions, ".s-admin-header-actions must be declared").not.toBeNull();
60
+ expect(actions).toContainEqual(["flex", "1 1 0"]);
61
+ expect(actions).toContainEqual(["justify-content", "flex-end"]);
62
+ // The old push would fight the centre wing for the middle.
63
+ expect(actions).not.toContainEqual(["margin-left", "auto"]);
64
+ const centre = ruleDeclarations(adminTokens, ".s-admin-header-center");
65
+ expect(centre, ".s-admin-header-center must be declared").not.toBeNull();
66
+ expect(centre).toContainEqual(["flex", "0 1 auto"]);
67
+ });
68
+
69
+ it("keeps the 28px icon-button step at the theme toggle's own box", () => {
70
+ // HkThemeToggle's trigger buttons are 1.75rem (28px) boxes with 16px
71
+ // glyphs — the size any header action that must read as the same
72
+ // kind of button composes at.
73
+ const rule = ruleDeclarations(iconButtonVars, ".hk-icon-button-28");
74
+ expect(rule, ".hk-icon-button-28 must be declared").not.toBeNull();
75
+ expect(rule).toContainEqual(["--hi-icon-button-size", "28px"]);
76
+ // The component accepts the step.
77
+ expect(iconButton).toMatch(/PropType<16 \| 24 \| 28 \| 32 \| 36 \| 40>/);
78
+ });
79
+ });
@@ -225,6 +225,37 @@
225
225
  transition: background var(--duration-short), width var(--duration-short) var(--ease-standard);
226
226
  }
227
227
 
228
+ /* One soft green blink when the light recovers: the pill background
229
+ pulses from a success tint down to the resting surface colour
230
+ exactly once, then hands the property back. The transient peak sits
231
+ slightly above the reconnecting tint's resting intensity so both
232
+ states read as one family; first connects never get it (see
233
+ HkStatusBar's seenConnected guard). Reduced-motion users get the
234
+ plain state switch instead. */
235
+ .s-status-bar-tag-recovered {
236
+ animation: s-status-bar-recover-flash 1.1s var(--ease-standard) 1;
237
+ }
238
+
239
+ @keyframes s-status-bar-recover-flash {
240
+ 0% {
241
+ background: rgb(var(--color-success) / 22%);
242
+ }
243
+
244
+ 55% {
245
+ background: rgb(var(--color-success) / 10%);
246
+ }
247
+
248
+ 100% {
249
+ background: rgb(var(--color-surface));
250
+ }
251
+ }
252
+
253
+ @media (prefers-reduced-motion: reduce) {
254
+ .s-status-bar-tag-recovered {
255
+ animation: none;
256
+ }
257
+ }
258
+
228
259
  .s-status-bar-retry {
229
260
  display: inline-flex;
230
261
  align-items: center;
@@ -347,7 +378,28 @@
347
378
  .s-admin-header-user {
348
379
  display: flex;
349
380
  align-items: center;
350
- gap: var(--space-8);
381
+ /* Avatar → title distance mirrors the chat frontend's header left
382
+ * wing: the panel's workspace button sits a flex gap PLUS its own
383
+ * 8px inline padding away from the avatar (16px visual), while the
384
+ * console title had only the flex gap — the console title visibly
385
+ * hugged the avatar (user report 2026-09-28: 后台标题离头像太近).
386
+ * The wing gap here carries the whole distance so the title text
387
+ * lands on the same rhythm as the panel's button content. */
388
+ gap: var(--space-16);
389
+ /* Balanced left wing (same grammar as ChatLayout's .s-header-bar-left):
390
+ * grows from the same flex basis as the actions wing so a centre
391
+ * group, when a page renders one, stays pinned to the true middle. */
392
+ flex: 1 1 0;
393
+ min-width: 0;
394
+ }
395
+
396
+ /* The page-owned centre group (filter tab strips): keeps its natural
397
+ * width and hands overflow to the HTabs scroller inside it. */
398
+ .s-admin-header-center {
399
+ flex: 0 1 auto;
400
+ display: flex;
401
+ align-items: center;
402
+ justify-content: center;
351
403
  min-width: 0;
352
404
  }
353
405
 
@@ -399,8 +451,14 @@
399
451
  display: flex;
400
452
  align-items: center;
401
453
  gap: var(--space-6);
402
- margin-left: auto;
403
- flex-shrink: 0;
454
+ /* Balanced right wing: with the user wing at flex 1 1 0 the pair
455
+ * grows symmetrically, so the centre group stays centred and the
456
+ * actions still hug the right edge (justify-end replaces the old
457
+ * margin-left:auto push, which would fight the centre wing). The
458
+ * automatic min-content floor keeps the icon buttons from being
459
+ * squeezed below their own width on narrow viewports. */
460
+ flex: 1 1 0;
461
+ justify-content: flex-end;
404
462
  }
405
463
 
406
464
  .s-admin-header-action {