@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
|
@@ -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 =
|
|
248
|
-
|
|
249
|
-
|
|
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;
|