@celestia-island/hikari 0.55.85 → 0.55.86

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.86",
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).
@@ -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
  <>
@@ -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;