@celestia-island/hikari 0.55.178 → 0.55.180
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/README.md +35 -0
- package/package.json +4 -2
- package/src/animation/registerAnimations.test.ts +1 -0
- package/src/animation/registerAnimations.ts +5 -0
- package/src/components/HkConnectionStatus.shell.test.tsx +68 -2
- package/src/components/HkConnectionStatus.tsx +17 -3
- package/src/components/HkStatusBar.test.tsx +104 -7
- package/src/components/HkStatusBar.tsx +134 -127
- package/src/i18n/locales/ar/components.json +0 -1
- package/src/i18n/locales/de/components.json +0 -1
- package/src/i18n/locales/en/components.json +0 -1
- package/src/i18n/locales/es/components.json +0 -1
- package/src/i18n/locales/fr/components.json +0 -1
- package/src/i18n/locales/ja/components.json +0 -1
- package/src/i18n/locales/ko/components.json +0 -1
- package/src/i18n/locales/pt/components.json +0 -1
- package/src/i18n/locales/ru/components.json +0 -1
- package/src/i18n/locales/zh-Hans/components.json +0 -1
- package/src/i18n/locales/zh-Hant/components.json +0 -1
- package/src/styles/admin-tokens.scss +24 -0
- package/src/utils/connectivity.ts +6 -2
- package/src/utils/fetchReceiver.test.ts +94 -0
- package/src/utils/healthProbe.ts +5 -1
- package/src/utils/powNonce.ts +7 -2
package/README.md
CHANGED
|
@@ -9,6 +9,31 @@ npm install @celestia-island/hikari
|
|
|
9
9
|
pnpm add @celestia-island/hikari
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
## Build Requirements
|
|
13
|
+
|
|
14
|
+
This package is a **source distribution**: the tarball ships the TypeScript,
|
|
15
|
+
TSX and SCSS sources under `src/`, and your build pipeline compiles them like
|
|
16
|
+
first-party code. Nothing is pre-bundled, so consuming projects must provide:
|
|
17
|
+
|
|
18
|
+
| Requirement | Why |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `vue` (peer dependency) | Components are written for Vue 3 and import Vue at runtime. |
|
|
21
|
+
| TypeScript-aware bundler | Component and composable entry points resolve to `.ts`/`.tsx` sources (`main`/`types` → `src/index.ts`). |
|
|
22
|
+
| Vue JSX support | Components are written as `.tsx` render functions, not SFCs — enable `@vitejs/plugin-vue-jsx` (or your bundler's equivalent). |
|
|
23
|
+
| `sass` in your toolchain | Styling is distributed as SCSS; `@celestia-island/hikari/styles` resolves to an `.scss` aggregate your bundler compiles. Install `sass` as a devDependency of the consuming project. |
|
|
24
|
+
|
|
25
|
+
Minimal Vite configuration:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// vite.config.ts
|
|
29
|
+
import vue from '@vitejs/plugin-vue'
|
|
30
|
+
import vueJsx from '@vitejs/plugin-vue-jsx'
|
|
31
|
+
|
|
32
|
+
export default {
|
|
33
|
+
plugins: [vue(), vueJsx()],
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
12
37
|
## Usage
|
|
13
38
|
|
|
14
39
|
Import styles once in your app entry point:
|
|
@@ -49,6 +74,16 @@ import { HkButton, HkCard, HkInput } from "@celestia-island/hikari";
|
|
|
49
74
|
- **HkAvatar** — image/fallback avatar
|
|
50
75
|
- And more...
|
|
51
76
|
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
`packages/vue` is its own pnpm workspace. After `corepack pnpm install`:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pnpm test # Vitest suite (colocated *.test.ts(x), happy-dom)
|
|
83
|
+
pnpm test:watch # Vitest in watch mode
|
|
84
|
+
pnpm lint # vue-tsc --noEmit type check
|
|
85
|
+
```
|
|
86
|
+
|
|
52
87
|
## License
|
|
53
88
|
|
|
54
89
|
Licensed under the [Synthetic Source License (SySL), Version 1.0](../../LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celestia-island/hikari",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.180",
|
|
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",
|
|
@@ -47,7 +47,9 @@
|
|
|
47
47
|
"dev": "vite",
|
|
48
48
|
"build": "vue-tsc --noEmit",
|
|
49
49
|
"build:docs": "vite build",
|
|
50
|
-
"lint": "
|
|
50
|
+
"lint": "vue-tsc --noEmit",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:watch": "vitest",
|
|
51
53
|
"prepublishOnly": "vue-tsc --noEmit"
|
|
52
54
|
},
|
|
53
55
|
"peerDependencies": {
|
|
@@ -61,6 +61,7 @@ describe("registerAnimations", () => {
|
|
|
61
61
|
expect(byName.get("hk-status-pill-pulse")?.infinite).toBe(true);
|
|
62
62
|
expect(byName.get("hk-pwd-breathe")?.infinite).toBe(true);
|
|
63
63
|
expect(byName.get("s-nav-item-badge-pulse")?.infinite).toBe(true);
|
|
64
|
+
expect(byName.get("s-status-bar-reconnecting-breathe")?.infinite).toBe(true);
|
|
64
65
|
expect(byName.get("s-auth-card-in")?.infinite).toBe(false);
|
|
65
66
|
expect(byName.get("hk-rolling-number-up")?.infinite).toBe(false);
|
|
66
67
|
expect(byName.get("hk-modal-breadcrumb-in")?.infinite).toBe(false);
|
|
@@ -70,6 +70,11 @@ registerCssAnimation("s-auth-card-in");
|
|
|
70
70
|
// the traffic light's green background blink when a dropped connection
|
|
71
71
|
// comes back.
|
|
72
72
|
registerCssAnimation("s-status-bar-recover-flash");
|
|
73
|
+
// styles/admin-tokens.scss — status-bar in-flight breathe (loops
|
|
74
|
+
// forever): the connecting/reconnecting light's warning tint pulse —
|
|
75
|
+
// a fixed background means a settled state (the gray idle), an
|
|
76
|
+
// in-flight one visibly breathes (2026-10-17 user direction).
|
|
77
|
+
registerCssAnimation("s-status-bar-reconnecting-breathe", { infinite: true });
|
|
73
78
|
// components/HkStatusPill.scss — live-dot pulse (loops forever).
|
|
74
79
|
registerCssAnimation("hk-status-pill-pulse", { infinite: true });
|
|
75
80
|
// components/HkRollingNumber.scss — digit roll-up (plays once, forwards).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { createApp, h } from "vue";
|
|
2
|
+
import { createApp, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
|
-
import { HkConnectionStatus } from "./HkConnectionStatus";
|
|
4
|
+
import { HkConnectionStatus, HK_CONNECTION_PROBE } from "./HkConnectionStatus";
|
|
5
5
|
|
|
6
6
|
/** The shell contract: WITHOUT injections the component never touches
|
|
7
7
|
* the network — the fetch-poll was demoted with the chat kit (the M1
|
|
@@ -31,4 +31,70 @@ describe("HkConnectionStatus (shell)", () => {
|
|
|
31
31
|
globalThis.fetch = holder;
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
it("idle pins the light to the resting gray even when the probe reports a fault", () => {
|
|
36
|
+
// 2026-10-17 user direction: sessionless auth surfaces pin the light
|
|
37
|
+
// to idle — "not yet logged in" is not a fault — through THIS one
|
|
38
|
+
// wiring, not a hand-rolled HkStatusBar fork (the fork was what left
|
|
39
|
+
// the auth popover stuck on its fetching placeholder with no
|
|
40
|
+
// versions). The probe underneath reports red; the override wins.
|
|
41
|
+
const probe = {
|
|
42
|
+
result: ref({
|
|
43
|
+
connected: false,
|
|
44
|
+
state: "disconnected" as const,
|
|
45
|
+
transportTier: "poll",
|
|
46
|
+
tier: "poll",
|
|
47
|
+
latencyMs: null,
|
|
48
|
+
attemptNumber: 1,
|
|
49
|
+
retryCount: 1,
|
|
50
|
+
retryTotal: 3,
|
|
51
|
+
countdown: 0,
|
|
52
|
+
}),
|
|
53
|
+
retryNow: vi.fn(),
|
|
54
|
+
};
|
|
55
|
+
const container = document.createElement("div");
|
|
56
|
+
document.body.appendChild(container);
|
|
57
|
+
const app = createApp({
|
|
58
|
+
render: () =>
|
|
59
|
+
h(HkConnectionStatus, { standalone: true, version: "1.2.3", idle: true, compact: true }),
|
|
60
|
+
});
|
|
61
|
+
app.provide(HK_CONNECTION_PROBE, probe);
|
|
62
|
+
app.mount(container);
|
|
63
|
+
mounts.push({ app, container });
|
|
64
|
+
const dot = container.querySelector<HTMLElement>(".s-status-bar-dot")!;
|
|
65
|
+
expect(dot, "light renders").toBeTruthy();
|
|
66
|
+
// happy-dom drops inline styles whose value carries var() fallbacks,
|
|
67
|
+
// so the gray pin rides the compact aria contract: the idle override
|
|
68
|
+
// turns a faulting probe into "Ready", never "Disconnected".
|
|
69
|
+
expect(container.querySelector<HTMLElement>(".s-status-bar-tag")!.getAttribute("aria-label"))
|
|
70
|
+
.toBe("Ready · 1.2.3");
|
|
71
|
+
// Idle is a settled resting state — clicking the gray light must not
|
|
72
|
+
// fire a silent probe retry (R3 catch).
|
|
73
|
+
container.querySelector<HTMLElement>(".s-status-bar-tag")!.click();
|
|
74
|
+
expect(probe.retryNow).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("the inert shell shows a definite status with versions but no probe rows", async () => {
|
|
78
|
+
// No injection: INERT_PROBE's sentinel transportTier is "unknown" —
|
|
79
|
+
// it must not leak a bare "Protocol unknown" row (which has no
|
|
80
|
+
// locale key in ANY language) into the popover (R3 catch). The card
|
|
81
|
+
// keeps the definite status heading + the known versions only.
|
|
82
|
+
const container = document.createElement("div");
|
|
83
|
+
document.body.appendChild(container);
|
|
84
|
+
const app = createApp({
|
|
85
|
+
render: () => h(HkConnectionStatus, { standalone: false, version: "1.2.3" }),
|
|
86
|
+
});
|
|
87
|
+
app.mount(container);
|
|
88
|
+
mounts.push({ app, container });
|
|
89
|
+
await nextTick();
|
|
90
|
+
container
|
|
91
|
+
.querySelector<HTMLElement>(".s-status-bar-tag")!
|
|
92
|
+
.dispatchEvent(new MouseEvent("mouseenter"));
|
|
93
|
+
await nextTick();
|
|
94
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
95
|
+
expect(panel.textContent).toContain("Disconnected");
|
|
96
|
+
expect(panel.textContent).toContain("1.2.3");
|
|
97
|
+
expect(panel.textContent).not.toContain("Protocol");
|
|
98
|
+
expect(panel.textContent).not.toContain("unknown");
|
|
99
|
+
});
|
|
34
100
|
});
|
|
@@ -65,8 +65,10 @@ const backendStateColor: Record<HkBackendStatus["state"], string> = {
|
|
|
65
65
|
* HK_CONNECTION_PROBE, that probe (and its retryNow) is used verbatim;
|
|
66
66
|
* without an injection the shell renders an inert unknown state and
|
|
67
67
|
* NEVER touches the network (pinned). Slots:
|
|
68
|
-
* - `left`
|
|
69
|
-
* - `right`
|
|
68
|
+
* - `left` — extras after the status tags (e.g. a mock badge)
|
|
69
|
+
* - `right` — extras on the far right (e.g. an engine version tag)
|
|
70
|
+
* - `actions` — forwarded to HkStatusBar's status-row cluster (e.g. the
|
|
71
|
+
* hosts' single manual page refresh, 2026-10-11 direction)
|
|
70
72
|
*/
|
|
71
73
|
export const HkConnectionStatus = defineComponent({
|
|
72
74
|
name: "HkConnectionStatus",
|
|
@@ -77,6 +79,15 @@ export const HkConnectionStatus = defineComponent({
|
|
|
77
79
|
standalone: { type: Boolean, default: true },
|
|
78
80
|
/** Forwarded to HkStatusBar — collapse to the traffic-light dot. */
|
|
79
81
|
compact: { type: Boolean, default: false },
|
|
82
|
+
/** Resting override (2026-10-17 user direction): pins the light and
|
|
83
|
+
* the status line to the neutral-gray idle state — "the shell is
|
|
84
|
+
* alive, no session yet" — regardless of what the injected probe
|
|
85
|
+
* reports. Sessionless auth surfaces set it; logged-in surfaces
|
|
86
|
+
* never do. Everything else (versions, info rows, actions) keeps
|
|
87
|
+
* the ONE wiring — idle hosts no longer hand-roll an HkStatusBar
|
|
88
|
+
* copy (that fork is what left the auth popover stuck on its
|
|
89
|
+
* fetching placeholder). */
|
|
90
|
+
idle: { type: Boolean, default: false },
|
|
80
91
|
},
|
|
81
92
|
setup(props, { slots }) {
|
|
82
93
|
// SHELL-ONLY: the network side (WHO polls /api/health and HOW)
|
|
@@ -119,7 +130,10 @@ export const HkConnectionStatus = defineComponent({
|
|
|
119
130
|
};
|
|
120
131
|
});
|
|
121
132
|
|
|
122
|
-
const connectionStatus = computed<"connected" | "reconnecting" | "disconnected">(() => {
|
|
133
|
+
const connectionStatus = computed<"connected" | "reconnecting" | "disconnected" | "idle">(() => {
|
|
134
|
+
// The idle override wins over the probe: the resting gray light is
|
|
135
|
+
// a definite statement about the SHELL, not a verdict on the link.
|
|
136
|
+
if (props.idle) return "idle";
|
|
123
137
|
const s = probe.value.state;
|
|
124
138
|
if (s === "connected") return "connected";
|
|
125
139
|
if (s === "connecting" || s === "reconnecting") return "reconnecting";
|
|
@@ -173,7 +173,9 @@ describe("HkStatusBar", () => {
|
|
|
173
173
|
const row = panel!.querySelector('[data-extra-detail="gateway"]');
|
|
174
174
|
expect(row).not.toBeNull();
|
|
175
175
|
expect((row as HTMLElement).style.maxWidth).toBe("400px");
|
|
176
|
-
|
|
176
|
+
// Scoped to the extra-detail row: the version cells (always in the
|
|
177
|
+
// popover since the 2026-10-17 wave) are title-bearing spans too.
|
|
178
|
+
const value = row!.querySelector("span[title]") as HTMLElement;
|
|
177
179
|
expect(value.textContent).toBe("gateway.celestia.world");
|
|
178
180
|
expect(value.title).toBe("gateway.celestia.world");
|
|
179
181
|
expect(value.style.textOverflow).toBe("ellipsis");
|
|
@@ -559,7 +561,12 @@ describe("HkStatusBar", () => {
|
|
|
559
561
|
expect(panel!.querySelector("[data-status-actions]")).toBeNull();
|
|
560
562
|
});
|
|
561
563
|
|
|
562
|
-
it("
|
|
564
|
+
it("without info the popover states a definite status with versions, never a fetching placeholder", async () => {
|
|
565
|
+
// 2026-10-17 user report: the idle auth pill's popover printed
|
|
566
|
+
// "Fetching connection info..." forever while its steady gray light
|
|
567
|
+
// meant a settled state. The info-null branch is RETIRED: the title
|
|
568
|
+
// row states the definite state and the known versions render up
|
|
569
|
+
// front; the recovery action stays on that title row.
|
|
563
570
|
const onRefresh = vi.fn();
|
|
564
571
|
const container = document.createElement("div");
|
|
565
572
|
document.body.appendChild(container);
|
|
@@ -581,18 +588,108 @@ describe("HkStatusBar", () => {
|
|
|
581
588
|
.dispatchEvent(new MouseEvent("mouseenter"));
|
|
582
589
|
await nextTick();
|
|
583
590
|
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
584
|
-
// The cluster must live OUTSIDE the info/fetching ternary: an outage
|
|
585
|
-
// that drops connectionInfo to null is exactly when the recovery
|
|
586
|
-
// action is most needed. It rides the fetching line's right edge.
|
|
587
591
|
const cluster = panel.querySelector<HTMLElement>("[data-status-actions]");
|
|
588
592
|
expect(cluster, "actions cluster renders without info").toBeTruthy();
|
|
589
|
-
//
|
|
590
|
-
expect(cluster!.closest("div")!.textContent).toContain("
|
|
593
|
+
// The title row is the DEFINITE state, not a fetching placeholder.
|
|
594
|
+
expect(cluster!.closest("div")!.textContent).toContain("Disconnected");
|
|
595
|
+
expect(panel.textContent).not.toContain("Fetching connection info");
|
|
596
|
+
// Versions are known facts — up front, info or not.
|
|
597
|
+
expect(panel.querySelectorAll(".s-status-bar-popover-value").length).toBeGreaterThanOrEqual(1);
|
|
598
|
+
expect(panel.textContent).toContain("1.2.3");
|
|
599
|
+
// Without probe facts the protocol/network rows stay honest (absent).
|
|
600
|
+
expect(panel.textContent).not.toContain("Protocol");
|
|
591
601
|
const button = cluster!.querySelector<HTMLButtonElement>("button")!;
|
|
592
602
|
button.click();
|
|
593
603
|
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
594
604
|
});
|
|
595
605
|
|
|
606
|
+
it("renders the version rows in the popover regardless of compact", async () => {
|
|
607
|
+
// 2026-10-17 user direction: the popover is the details card — the
|
|
608
|
+
// versions (panel + engine, merged once) show there up front even in
|
|
609
|
+
// full (non-compact) mode, no longer gated behind `compact`.
|
|
610
|
+
const container = mountBar({
|
|
611
|
+
version: "1.2.3 master:49fe7ae",
|
|
612
|
+
panelBuildHash: "master:49fe7ae",
|
|
613
|
+
engineVersion: "9.8.7",
|
|
614
|
+
engineBuildHash: "engine:abcdef0",
|
|
615
|
+
connectionStatus: "connected",
|
|
616
|
+
connectionInfo: INFO,
|
|
617
|
+
});
|
|
618
|
+
await nextTick();
|
|
619
|
+
tagOf(container).dispatchEvent(new MouseEvent("mouseenter"));
|
|
620
|
+
await nextTick();
|
|
621
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
622
|
+
const cells = [...panel.querySelectorAll<HTMLElement>(".s-status-bar-popover-value .s-status-bar-version")]
|
|
623
|
+
.map((el) => el.querySelector(".s-status-bar-version__static")!.textContent ?? "");
|
|
624
|
+
// Set-merged once: the build hash the version line already names is
|
|
625
|
+
// not printed twice in the popover either.
|
|
626
|
+
expect(cells).toContain("1.2.3 master:49fe7ae");
|
|
627
|
+
expect(cells).toContain("9.8.7 engine:abcdef0");
|
|
628
|
+
expect(cells.filter((v) => v.includes("49fe7ae")).length).toBe(1);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("idle renders the resting gray light, the Ready title row, and no reconnecting pulse", async () => {
|
|
632
|
+
const container = mountBar({
|
|
633
|
+
version: "1.2.3",
|
|
634
|
+
connectionStatus: "idle",
|
|
635
|
+
connectionInfo: null,
|
|
636
|
+
});
|
|
637
|
+
await nextTick();
|
|
638
|
+
const tag = tagOf(container);
|
|
639
|
+
// Steady state: idle must NOT carry the in-flight warning pulse.
|
|
640
|
+
expect(tag.className).not.toContain("s-status-bar-tag-reconnecting");
|
|
641
|
+
// happy-dom drops inline styles whose value carries var() fallbacks,
|
|
642
|
+
// so the gray pin is a source-contract assertion: the idle entry in
|
|
643
|
+
// the dot color map must name the tertiary (resting gray) token —
|
|
644
|
+
// the same contract the 2026-10-17 user direction fixed (#815).
|
|
645
|
+
const source = readFileSync(resolve(__dirname, "./HkStatusBar.tsx"), "utf8");
|
|
646
|
+
expect(source).toMatch(/idle:\s*"rgb\(var\(--color-text-tertiary/);
|
|
647
|
+
// The popover states the definite Ready state (the hikari i18n
|
|
648
|
+
// fallback) with the version row, and offers no protocol/network
|
|
649
|
+
// facts the idle shell does not have.
|
|
650
|
+
tag.dispatchEvent(new MouseEvent("mouseenter"));
|
|
651
|
+
await nextTick();
|
|
652
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
653
|
+
expect(panel.textContent).toContain("Ready");
|
|
654
|
+
expect(panel.textContent).toContain("1.2.3");
|
|
655
|
+
expect(panel.textContent).not.toContain("Protocol");
|
|
656
|
+
// Idle is a SETTLED state — it must not offer the disconnected-only
|
|
657
|
+
// "Click to retry" hint (R1 mutation catch: the gate survived, the
|
|
658
|
+
// pin was missing).
|
|
659
|
+
expect(panel.textContent).not.toContain("Click to retry");
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("pins the in-flight breathe pulse contract in admin-tokens.scss", () => {
|
|
663
|
+
// 2026-10-17 user direction: a genuinely in-flight (connecting/
|
|
664
|
+
// reconnecting) light must visibly breathe — a FIXED background
|
|
665
|
+
// means a settled state, which is the gray idle's job now.
|
|
666
|
+
// Reduced-motion users keep the steady tint.
|
|
667
|
+
const scss = readFileSync(resolve(__dirname, "../styles/admin-tokens.scss"), "utf8");
|
|
668
|
+
const rule = /\.s-status-bar-tag-reconnecting\s*\{[^}]*\}/.exec(scss)?.[0] ?? "";
|
|
669
|
+
expect(rule, "reconnecting rule carries the breathe animation").toContain("animation:");
|
|
670
|
+
expect(scss).toContain("@keyframes s-status-bar-reconnecting-breathe");
|
|
671
|
+
expect(scss).toMatch(
|
|
672
|
+
/@media[^{]*prefers-reduced-motion[^{]*\{[^@]*?\.s-status-bar-tag-reconnecting\s*\{[^}]*animation:\s*none/s,
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("connecting carries the in-flight pulse class; idle does not", async () => {
|
|
677
|
+
const connecting = mountBar({
|
|
678
|
+
version: "1.2.3",
|
|
679
|
+
connectionStatus: "connecting",
|
|
680
|
+
connectionInfo: null,
|
|
681
|
+
});
|
|
682
|
+
await nextTick();
|
|
683
|
+
expect(tagOf(connecting).className).toContain("s-status-bar-tag-reconnecting");
|
|
684
|
+
const idle = mountBar({
|
|
685
|
+
version: "1.2.3",
|
|
686
|
+
connectionStatus: "idle",
|
|
687
|
+
connectionInfo: null,
|
|
688
|
+
});
|
|
689
|
+
await nextTick();
|
|
690
|
+
expect(tagOf(idle).className).not.toContain("s-status-bar-tag-reconnecting");
|
|
691
|
+
});
|
|
692
|
+
|
|
596
693
|
it("no actions row when the slot renders nothing (wrapper forwards an empty slot)", async () => {
|
|
597
694
|
const container = document.createElement("div");
|
|
598
695
|
document.body.appendChild(container);
|
|
@@ -14,8 +14,13 @@ import { HkPlaceholderMarquee } from "./HkPlaceholderMarquee";
|
|
|
14
14
|
* (Upstreamed from shittim-chest's plana-legacy layer.)
|
|
15
15
|
*
|
|
16
16
|
* A traffic-light dot plus a two-column-grid version block (panel row,
|
|
17
|
-
* engine row). Hovering/tapping the pill opens an HPopover
|
|
18
|
-
*
|
|
17
|
+
* engine row). Hovering/tapping the pill opens an HPopover whose title
|
|
18
|
+
* row is ALWAYS a definite state (Ready/Connected/Connecting/Disconnected)
|
|
19
|
+
* followed by the version rows — both render with or without probe facts
|
|
20
|
+
* (2026-10-17 user direction: the popover must never sit on a perpetual
|
|
21
|
+
* "fetching" placeholder, and the versions show up front). The quality
|
|
22
|
+
* icon, latency, retry countdown and protocol/network rows join once the
|
|
23
|
+
* probe supplies them.
|
|
19
24
|
* With `compact` the pill collapses to the BARE DOT only (mobile
|
|
20
25
|
* logged-in footers) — no inline status text next to the light; that
|
|
21
26
|
* label is a standing user directive (asked to be removed repeatedly,
|
|
@@ -303,7 +308,10 @@ export const HkStatusBar = defineComponent({
|
|
|
303
308
|
let touchEchoGuardUntil = 0;
|
|
304
309
|
function onTagClick() {
|
|
305
310
|
if (Date.now() < touchEchoGuardUntil) return;
|
|
306
|
-
|
|
311
|
+
// Idle is a SETTLED resting state ("shell alive, no session"), not
|
|
312
|
+
// a fault — clicking the gray light must not fire a silent retry
|
|
313
|
+
// (R3 catch: it looked like a dead control doing invisible work).
|
|
314
|
+
if (props.connectionStatus !== "connected" && props.connectionStatus !== "idle") {
|
|
307
315
|
props.onRetry?.();
|
|
308
316
|
}
|
|
309
317
|
}
|
|
@@ -485,140 +493,139 @@ export const HkStatusBar = defineComponent({
|
|
|
485
493
|
// 300px default floor (2026-10-11 user direction: the
|
|
486
494
|
// version/protocol rows read cramped at the old 220px),
|
|
487
495
|
// clamped to the popover's own viewport budget so a
|
|
488
|
-
// 320px-class phone never spills past the
|
|
496
|
+
// 320px-class phone never spills past the viewport (the
|
|
497
|
+
// glass chrome eats a few px of the card's inner width
|
|
498
|
+
// at the very narrow end — cosmetic, bounded, R3-note).
|
|
489
499
|
minWidth: "min(300px, calc(100vw - 2 * var(--viewport-gutter, 16px)))",
|
|
490
500
|
padding: "10px 14px",
|
|
491
501
|
fontSize: "0.75rem", lineHeight: 1.6,
|
|
492
502
|
color: "rgb(var(--color-text))",
|
|
493
503
|
}}
|
|
494
504
|
>
|
|
495
|
-
{
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
)}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
// control this time: the original hint was retired
|
|
540
|
-
// because its clicks went nowhere (the popover body
|
|
541
|
-
// teleports to <body>), so the text now carries the
|
|
542
|
-
// retry itself, echo guard included.
|
|
543
|
-
<div
|
|
544
|
-
role="button"
|
|
545
|
-
tabindex={0}
|
|
546
|
-
onClick={onRetryButtonClick}
|
|
547
|
-
onKeydown={(e: KeyboardEvent) => {
|
|
548
|
-
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRetryButtonClick(); }
|
|
549
|
-
}}
|
|
550
|
-
style={{ fontStyle: "italic", fontSize: "0.6875rem", marginBottom: "4px", opacity: 0.7, cursor: "pointer" }}
|
|
551
|
-
>
|
|
552
|
-
{t("hikari::statusBar.clickReconnect", "Click to retry")}
|
|
553
|
-
</div>
|
|
554
|
-
)}
|
|
555
|
-
{props.compact && (
|
|
556
|
-
<>
|
|
557
|
-
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
558
|
-
<Monitor size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
|
|
559
|
-
<span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.panel", "Panel")}</span>
|
|
560
|
-
<span
|
|
561
|
-
class="s-status-bar-popover-value"
|
|
562
|
-
style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})` }}
|
|
563
|
-
>
|
|
564
|
-
<HkVersionValue text={pv} />
|
|
565
|
-
</span>
|
|
566
|
-
</div>
|
|
567
|
-
{ev && (
|
|
568
|
-
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
569
|
-
<Cog size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
|
|
570
|
-
<span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.engine", "Engine")}</span>
|
|
571
|
-
<span
|
|
572
|
-
class="s-status-bar-popover-value"
|
|
573
|
-
style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})` }}
|
|
574
|
-
>
|
|
575
|
-
<HkVersionValue text={hkMergeVersionDisplay(ev, props.engineBuildHash)} />
|
|
576
|
-
</span>
|
|
577
|
-
</div>
|
|
578
|
-
)}
|
|
579
|
-
</>
|
|
580
|
-
)}
|
|
581
|
-
{tier !== "none" && (
|
|
582
|
-
<>
|
|
583
|
-
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
584
|
-
<Plug size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
|
|
585
|
-
<span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.protocol", "Protocol")}</span>
|
|
586
|
-
{connecting ? (
|
|
587
|
-
<span style={{ color: "rgb(var(--color-warning))" }}>
|
|
588
|
-
{t("hikari::statusBar.probing", "Probing...")}
|
|
589
|
-
</span>
|
|
590
|
-
) : (
|
|
591
|
-
<span>{t(tierLabelKey, tier)}</span>
|
|
592
|
-
)}
|
|
593
|
-
</div>
|
|
594
|
-
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
595
|
-
<Globe size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
|
|
596
|
-
<span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.network", "Network")}</span>
|
|
597
|
-
<span>{regionDisplayName(info.region, locale, t)}{info.asn != null ? ` · AS${info.asn}` : ""}{info.isLocalhost ? " · " + t("hikari::statusBar.local", "Local") : ""}</span>
|
|
598
|
-
</div>
|
|
599
|
-
</>
|
|
600
|
-
)}
|
|
601
|
-
{(props.extraDetails ?? []).map((row) => (
|
|
602
|
-
<div key={row.key} data-extra-detail={row.key} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "6px", maxWidth: "400px" }}>
|
|
603
|
-
<span style={{ opacity: 0.5, flexShrink: 0, display: "inline-flex" }}>{row.icon}</span>
|
|
604
|
-
<span style={{ opacity: 0.5, flexShrink: 0, minWidth: "72px", marginRight: "auto" }}>{row.label}</span>
|
|
605
|
-
<span title={row.value} style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.value}</span>
|
|
606
|
-
</div>
|
|
607
|
-
))}
|
|
608
|
-
</>
|
|
609
|
-
) : (
|
|
610
|
-
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
|
|
611
|
-
<span style={{ opacity: 0.5 }}>{t("hikari::statusBar.fetching", "Fetching connection info...")}</span>
|
|
612
|
-
{hasActionsRow && (
|
|
613
|
-
// An outage that drops connectionInfo to null is exactly
|
|
614
|
-
// when the recovery action is most needed — the cluster
|
|
615
|
-
// rides the fetching line's right edge.
|
|
616
|
-
<span data-status-actions style={{ display: "inline-flex", alignItems: "center", gap: "8px", marginLeft: "auto" }}>
|
|
617
|
-
{actionVnodes}
|
|
505
|
+
{/* The status heading is the popover's title row and it is
|
|
506
|
+
ALWAYS a definite state (2026-10-17 user report: the idle
|
|
507
|
+
auth pill's popover printed "Fetching connection info..."
|
|
508
|
+
forever while its steady gray light meant a settled state —
|
|
509
|
+
a genuinely in-flight light pulses the warning tint, it
|
|
510
|
+
never rests gray). The old info-null fallback is RETIRED;
|
|
511
|
+
the info-dependent rows below simply don't render until
|
|
512
|
+
the probe has facts to show. */}
|
|
513
|
+
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px", fontWeight: 600, fontSize: "0.8125rem" }}>
|
|
514
|
+
{qualityIcon(info?.quality || (mode === "connected" ? "good" : "unknown"), tier, info?.isLocalhost ?? false, 14)}
|
|
515
|
+
<span style={{ color: dotColorMap[mode] ?? dotColorMap.disconnected }}>
|
|
516
|
+
{statusText}
|
|
517
|
+
</span>
|
|
518
|
+
{latency !== null && (
|
|
519
|
+
<span style={{ marginLeft: "auto", color: latencyColor(latency), fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, fontWeight: 600, fontSize: "0.6875rem" }}>
|
|
520
|
+
{latency} ms
|
|
521
|
+
</span>
|
|
522
|
+
)}
|
|
523
|
+
{hasActionsRow && (
|
|
524
|
+
<span
|
|
525
|
+
data-status-actions
|
|
526
|
+
style={{
|
|
527
|
+
display: "inline-flex",
|
|
528
|
+
alignItems: "center",
|
|
529
|
+
gap: "8px",
|
|
530
|
+
// No latency chip to push against: the cluster
|
|
531
|
+
// still lands on the row's right edge.
|
|
532
|
+
marginLeft: latency !== null ? undefined : "auto",
|
|
533
|
+
}}
|
|
534
|
+
>
|
|
535
|
+
{actionVnodes}
|
|
536
|
+
</span>
|
|
537
|
+
)}
|
|
538
|
+
</div>
|
|
539
|
+
{connecting && attempt > 0 && (
|
|
540
|
+
<div style={{ display: "flex", alignItems: "center", gap: "4px", color: "rgb(var(--color-warning))", fontSize: "0.6875rem", marginBottom: "4px" }}>
|
|
541
|
+
<span>
|
|
542
|
+
{t("hikari::statusBar.retrying", "Retrying {retryCount} / {maxRetries}")
|
|
543
|
+
.replace("{retryCount}", String(attempt))
|
|
544
|
+
.replace("{maxRetries}", String(info?.maxRetries && info.maxRetries > 0 ? info.maxRetries : 3))}
|
|
545
|
+
</span>
|
|
546
|
+
{countdown > 0 && (
|
|
547
|
+
<span style={{ display: "inline-flex", alignItems: "center", gap: "4px", fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, marginLeft: "8px" }}>
|
|
548
|
+
<HkCountdownDigit value={countdown} />
|
|
618
549
|
</span>
|
|
619
550
|
)}
|
|
620
551
|
</div>
|
|
621
552
|
)}
|
|
553
|
+
{mode === "disconnected" && !hasActionsRow && (
|
|
554
|
+
// Fallback for hosts without a slot action — and a REAL
|
|
555
|
+
// control this time: the original hint was retired
|
|
556
|
+
// because its clicks went nowhere (the popover body
|
|
557
|
+
// teleports to <body>), so the text now carries the
|
|
558
|
+
// retry itself, echo guard included.
|
|
559
|
+
<div
|
|
560
|
+
role="button"
|
|
561
|
+
tabindex={0}
|
|
562
|
+
onClick={onRetryButtonClick}
|
|
563
|
+
onKeydown={(e: KeyboardEvent) => {
|
|
564
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRetryButtonClick(); }
|
|
565
|
+
}}
|
|
566
|
+
style={{ fontStyle: "italic", fontSize: "0.6875rem", marginBottom: "4px", opacity: 0.7, cursor: "pointer" }}
|
|
567
|
+
>
|
|
568
|
+
{t("hikari::statusBar.clickReconnect", "Click to retry")}
|
|
569
|
+
</div>
|
|
570
|
+
)}
|
|
571
|
+
{/* Version rows: ALWAYS in the popover (2026-10-17 user
|
|
572
|
+
direction — versions are known facts, show them up front
|
|
573
|
+
independent of the probe state; the old gate hid them
|
|
574
|
+
behind `compact` inside the info branch, which left the
|
|
575
|
+
idle auth popover versionless). The inline tag keeps its
|
|
576
|
+
own copy; this card is the details surface. */}
|
|
577
|
+
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
578
|
+
<Monitor size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
|
|
579
|
+
<span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.panel", "Panel")}</span>
|
|
580
|
+
<span
|
|
581
|
+
class="s-status-bar-popover-value"
|
|
582
|
+
style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})` }}
|
|
583
|
+
>
|
|
584
|
+
<HkVersionValue text={pv} />
|
|
585
|
+
</span>
|
|
586
|
+
</div>
|
|
587
|
+
{ev && (
|
|
588
|
+
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
589
|
+
<Cog size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
|
|
590
|
+
<span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.engine", "Engine")}</span>
|
|
591
|
+
<span
|
|
592
|
+
class="s-status-bar-popover-value"
|
|
593
|
+
style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})` }}
|
|
594
|
+
>
|
|
595
|
+
<HkVersionValue text={hkMergeVersionDisplay(ev, props.engineBuildHash)} />
|
|
596
|
+
</span>
|
|
597
|
+
</div>
|
|
598
|
+
)}
|
|
599
|
+
{info && tier !== "none" && tier !== "unknown" && (
|
|
600
|
+
// "unknown" is the inert shell's sentinel tier — no probe
|
|
601
|
+
// is wired, so there are no transport facts to show and a
|
|
602
|
+
// bare "Protocol unknown" row would leak (R3 catch).
|
|
603
|
+
<>
|
|
604
|
+
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
605
|
+
<Plug size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
|
|
606
|
+
<span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.protocol", "Protocol")}</span>
|
|
607
|
+
{connecting ? (
|
|
608
|
+
<span style={{ color: "rgb(var(--color-warning))" }}>
|
|
609
|
+
{t("hikari::statusBar.probing", "Probing...")}
|
|
610
|
+
</span>
|
|
611
|
+
) : (
|
|
612
|
+
<span>{t(tierLabelKey, tier)}</span>
|
|
613
|
+
)}
|
|
614
|
+
</div>
|
|
615
|
+
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
616
|
+
<Globe size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
|
|
617
|
+
<span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.network", "Network")}</span>
|
|
618
|
+
<span>{regionDisplayName(info.region, locale, t)}{info.asn != null ? ` · AS${info.asn}` : ""}{info.isLocalhost ? " · " + t("hikari::statusBar.local", "Local") : ""}</span>
|
|
619
|
+
</div>
|
|
620
|
+
</>
|
|
621
|
+
)}
|
|
622
|
+
{info && (props.extraDetails ?? []).map((row) => (
|
|
623
|
+
<div key={row.key} data-extra-detail={row.key} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "6px", maxWidth: "400px" }}>
|
|
624
|
+
<span style={{ opacity: 0.5, flexShrink: 0, display: "inline-flex" }}>{row.icon}</span>
|
|
625
|
+
<span style={{ opacity: 0.5, flexShrink: 0, minWidth: "72px", marginRight: "auto" }}>{row.label}</span>
|
|
626
|
+
<span title={row.value} style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.value}</span>
|
|
627
|
+
</div>
|
|
628
|
+
))}
|
|
622
629
|
</div>
|
|
623
630
|
</HPopover>
|
|
624
631
|
</>
|
|
@@ -155,7 +155,6 @@
|
|
|
155
155
|
"hikari::statusBar.connecting": "جارٍ الاتصال...",
|
|
156
156
|
"hikari::statusBar.disconnected": "غير متصل",
|
|
157
157
|
"hikari::statusBar.engine": "المحرك",
|
|
158
|
-
"hikari::statusBar.fetching": "جارٍ جلب معلومات الاتصال...",
|
|
159
158
|
"hikari::statusBar.latency": "زمن الاستجابة",
|
|
160
159
|
"hikari::statusBar.local": "محلي",
|
|
161
160
|
"hikari::statusBar.network": "الشبكة",
|
|
@@ -155,7 +155,6 @@
|
|
|
155
155
|
"hikari::statusBar.connecting": "Verbinde...",
|
|
156
156
|
"hikari::statusBar.disconnected": "Getrennt",
|
|
157
157
|
"hikari::statusBar.engine": "Engine",
|
|
158
|
-
"hikari::statusBar.fetching": "Verbindungsinformationen werden geladen...",
|
|
159
158
|
"hikari::statusBar.latency": "Latenz",
|
|
160
159
|
"hikari::statusBar.local": "Lokal",
|
|
161
160
|
"hikari::statusBar.network": "Netzwerk",
|
|
@@ -155,7 +155,6 @@
|
|
|
155
155
|
"hikari::statusBar.connecting": "Connecting...",
|
|
156
156
|
"hikari::statusBar.disconnected": "Disconnected",
|
|
157
157
|
"hikari::statusBar.engine": "Engine",
|
|
158
|
-
"hikari::statusBar.fetching": "Fetching connection info...",
|
|
159
158
|
"hikari::statusBar.latency": "Latency",
|
|
160
159
|
"hikari::statusBar.local": "Local",
|
|
161
160
|
"hikari::statusBar.network": "Network",
|
|
@@ -155,7 +155,6 @@
|
|
|
155
155
|
"hikari::statusBar.connecting": "Conectando...",
|
|
156
156
|
"hikari::statusBar.disconnected": "Desconectado",
|
|
157
157
|
"hikari::statusBar.engine": "Motor",
|
|
158
|
-
"hikari::statusBar.fetching": "Obteniendo información de conexión...",
|
|
159
158
|
"hikari::statusBar.latency": "Latencia",
|
|
160
159
|
"hikari::statusBar.local": "Local",
|
|
161
160
|
"hikari::statusBar.network": "Red",
|
|
@@ -155,7 +155,6 @@
|
|
|
155
155
|
"hikari::statusBar.connecting": "Connexion...",
|
|
156
156
|
"hikari::statusBar.disconnected": "Déconnecté",
|
|
157
157
|
"hikari::statusBar.engine": "Moteur",
|
|
158
|
-
"hikari::statusBar.fetching": "Récupération des informations de connexion...",
|
|
159
158
|
"hikari::statusBar.latency": "Latence",
|
|
160
159
|
"hikari::statusBar.local": "Local",
|
|
161
160
|
"hikari::statusBar.network": "Réseau",
|
|
@@ -155,7 +155,6 @@
|
|
|
155
155
|
"hikari::statusBar.connecting": "接続中...",
|
|
156
156
|
"hikari::statusBar.disconnected": "切断",
|
|
157
157
|
"hikari::statusBar.engine": "エンジン",
|
|
158
|
-
"hikari::statusBar.fetching": "接続情報を取得中...",
|
|
159
158
|
"hikari::statusBar.latency": "レイテンシ",
|
|
160
159
|
"hikari::statusBar.local": "ローカル",
|
|
161
160
|
"hikari::statusBar.network": "ネットワーク地域",
|
|
@@ -155,7 +155,6 @@
|
|
|
155
155
|
"hikari::statusBar.connecting": "연결 중...",
|
|
156
156
|
"hikari::statusBar.disconnected": "연결 끊김",
|
|
157
157
|
"hikari::statusBar.engine": "엔진",
|
|
158
|
-
"hikari::statusBar.fetching": "연결 정보를 가져오는 중...",
|
|
159
158
|
"hikari::statusBar.latency": "지연 시간",
|
|
160
159
|
"hikari::statusBar.local": "로컬",
|
|
161
160
|
"hikari::statusBar.network": "네트워크 지역",
|
|
@@ -155,7 +155,6 @@
|
|
|
155
155
|
"hikari::statusBar.connecting": "Conectando...",
|
|
156
156
|
"hikari::statusBar.disconnected": "Desconectado",
|
|
157
157
|
"hikari::statusBar.engine": "Motor",
|
|
158
|
-
"hikari::statusBar.fetching": "Buscando informações de conexão...",
|
|
159
158
|
"hikari::statusBar.latency": "Latência",
|
|
160
159
|
"hikari::statusBar.local": "Local",
|
|
161
160
|
"hikari::statusBar.network": "Rede",
|
|
@@ -155,7 +155,6 @@
|
|
|
155
155
|
"hikari::statusBar.connecting": "Подключение...",
|
|
156
156
|
"hikari::statusBar.disconnected": "Отключено",
|
|
157
157
|
"hikari::statusBar.engine": "Движок",
|
|
158
|
-
"hikari::statusBar.fetching": "Получение сведений о подключении...",
|
|
159
158
|
"hikari::statusBar.latency": "Задержка",
|
|
160
159
|
"hikari::statusBar.local": "Локально",
|
|
161
160
|
"hikari::statusBar.network": "Сеть",
|
|
@@ -155,7 +155,6 @@
|
|
|
155
155
|
"hikari::statusBar.connecting": "连接中...",
|
|
156
156
|
"hikari::statusBar.disconnected": "断开",
|
|
157
157
|
"hikari::statusBar.engine": "引擎",
|
|
158
|
-
"hikari::statusBar.fetching": "获取连接信息中...",
|
|
159
158
|
"hikari::statusBar.latency": "延迟",
|
|
160
159
|
"hikari::statusBar.local": "本地",
|
|
161
160
|
"hikari::statusBar.network": "网络区域",
|
|
@@ -155,7 +155,6 @@
|
|
|
155
155
|
"hikari::statusBar.connecting": "連線中...",
|
|
156
156
|
"hikari::statusBar.disconnected": "斷開",
|
|
157
157
|
"hikari::statusBar.engine": "引擎",
|
|
158
|
-
"hikari::statusBar.fetching": "取得連線資訊中...",
|
|
159
158
|
"hikari::statusBar.latency": "延遲",
|
|
160
159
|
"hikari::statusBar.local": "本機",
|
|
161
160
|
"hikari::statusBar.network": "網路區域",
|
|
@@ -267,9 +267,33 @@
|
|
|
267
267
|
opacity: 0.5;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
/* In-flight (connecting/reconnecting): the warning tint BREATHES —
|
|
271
|
+
2026-10-17 user direction, a fixed background must mean a SETTLED
|
|
272
|
+
state (that is the gray idle's job now), so a genuinely in-flight
|
|
273
|
+
light visibly pulses between a faint and a stronger tint. Pure-CSS
|
|
274
|
+
compositor motion; reduced-motion users keep the steady resting
|
|
275
|
+
tint (the class's original value). */
|
|
270
276
|
.s-status-bar-tag-reconnecting {
|
|
271
277
|
background: rgb(var(--color-warning) / 14%);
|
|
272
278
|
transition: background var(--duration-short), width var(--duration-short) var(--ease-standard);
|
|
279
|
+
animation: s-status-bar-reconnecting-breathe 1.8s var(--ease-standard) infinite;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
@keyframes s-status-bar-reconnecting-breathe {
|
|
283
|
+
0%,
|
|
284
|
+
100% {
|
|
285
|
+
background: rgb(var(--color-warning) / 8%);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
50% {
|
|
289
|
+
background: rgb(var(--color-warning) / 22%);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
@media (prefers-reduced-motion: reduce) {
|
|
294
|
+
.s-status-bar-tag-reconnecting {
|
|
295
|
+
animation: none;
|
|
296
|
+
}
|
|
273
297
|
}
|
|
274
298
|
|
|
275
299
|
/* One soft green blink when the light recovers: the pill background
|
|
@@ -14,7 +14,10 @@ export async function probeOrigin(
|
|
|
14
14
|
url: string,
|
|
15
15
|
opts?: { fetchFn?: typeof fetch },
|
|
16
16
|
): Promise<OriginProbe> {
|
|
17
|
-
|
|
17
|
+
// Bare `?? fetch` detaches Window's fetch receiver — the default path
|
|
18
|
+
// throws "Illegal invocation" in a browser (2026-10-17 chest live
|
|
19
|
+
// report family). bind(globalThis) re-attaches it, re-resolved per call.
|
|
20
|
+
const fetchFn = opts?.fetchFn ?? fetch.bind(globalThis);
|
|
18
21
|
try {
|
|
19
22
|
// A successful credentialed fetch means reachable + CORS-whitelisted,
|
|
20
23
|
// regardless of the HTTP status code (4xx/5xx still prove the origin).
|
|
@@ -41,7 +44,8 @@ export async function probeOriginWithBody(
|
|
|
41
44
|
url: string,
|
|
42
45
|
opts?: { fetchFn?: typeof fetch },
|
|
43
46
|
): Promise<{ ok: boolean; body?: string }> {
|
|
44
|
-
|
|
47
|
+
// Same receiver rule as probeOrigin: never capture Window's fetch bare.
|
|
48
|
+
const fetchFn = opts?.fetchFn ?? fetch.bind(globalThis);
|
|
45
49
|
try {
|
|
46
50
|
const resp = await fetchFn(url, { credentials: "include", mode: "cors" });
|
|
47
51
|
return { ok: true, body: await resp.text().catch(() => undefined) };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { probeOrigin, probeOriginWithBody } from "./connectivity";
|
|
4
|
+
import { probeHealthEndpoint } from "./healthProbe";
|
|
5
|
+
import { fetchChallenge, negotiateNonce } from "./powNonce";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The browser's `fetch` is a Window method: capturing it as a bare value
|
|
9
|
+
* (`opts?.fetchFn ?? fetch`) detaches the receiver, and every default call
|
|
10
|
+
* throws "Failed to execute 'fetch' on 'Window': Illegal invocation"
|
|
11
|
+
* (2026-10-17 chest live report: branding silently fell back and health
|
|
12
|
+
* probes reported every panel unreachable — the TypeError was swallowed
|
|
13
|
+
* into `{ok:false}` results). Node's fetch does not check its receiver,
|
|
14
|
+
* so these tests stub the browser's exact contract — `this !== globalThis`
|
|
15
|
+
* rejects with the same TypeError — making a detached capture red here.
|
|
16
|
+
*/
|
|
17
|
+
function stubWindowFetch(): void {
|
|
18
|
+
vi.stubGlobal(
|
|
19
|
+
"fetch",
|
|
20
|
+
vi.fn(function (this: unknown, input: RequestInfo | URL): Promise<Response> {
|
|
21
|
+
if (this !== globalThis) {
|
|
22
|
+
return Promise.reject(
|
|
23
|
+
new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation"),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const url = String(input);
|
|
27
|
+
if (url.endsWith("/health")) {
|
|
28
|
+
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
|
|
29
|
+
}
|
|
30
|
+
if (url.endsWith("/auth/nonce")) {
|
|
31
|
+
return Promise.resolve(
|
|
32
|
+
new Response(JSON.stringify({ nonce: "nonce-1" }), { status: 200 }),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
// probeOrigin / probeOriginWithBody / health probe targets.
|
|
36
|
+
return Promise.resolve(new Response("pong", { status: 200 }));
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("connectivity / healthProbe / powNonce — default fetches keep the Window receiver", () => {
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.unstubAllGlobals();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("probeOrigin's default fetchFn probes instead of throwing illegal invocation", async () => {
|
|
47
|
+
stubWindowFetch();
|
|
48
|
+
|
|
49
|
+
await expect(probeOrigin("https://panel.example/health")).resolves.toEqual({ ok: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("probeOriginWithBody's default fetchFn captures the body", async () => {
|
|
53
|
+
stubWindowFetch();
|
|
54
|
+
|
|
55
|
+
// Non-/health URL: the stub's default branch answers the body "pong".
|
|
56
|
+
await expect(probeOriginWithBody("https://panel.example/ping")).resolves.toEqual({
|
|
57
|
+
ok: true,
|
|
58
|
+
body: "pong",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("probeHealthEndpoint's default fetchFn reads a healthy body", async () => {
|
|
63
|
+
stubWindowFetch();
|
|
64
|
+
|
|
65
|
+
const result = await probeHealthEndpoint("https://panel.example/health");
|
|
66
|
+
expect(result.ok).toBe(true);
|
|
67
|
+
expect(result.body).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("powNonce defaults complete the challenge/nonce exchange", async () => {
|
|
71
|
+
stubWindowFetch();
|
|
72
|
+
|
|
73
|
+
// `undefined` (fetch failed) would mean the default threw — the exact
|
|
74
|
+
// silent regression that dropped the X-Nonce on chest's live login.
|
|
75
|
+
await expect(fetchChallenge("/api")).resolves.toBe(null);
|
|
76
|
+
await expect(negotiateNonce("/api")).resolves.toBe("nonce-1");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("an explicit fetchFn still wins over every default", async () => {
|
|
80
|
+
const guard = vi.fn(() => Promise.reject(new Error("global fetch must not be called")));
|
|
81
|
+
vi.stubGlobal("fetch", guard);
|
|
82
|
+
const explicit = vi.fn(async () => new Response("{}", { status: 200 }));
|
|
83
|
+
|
|
84
|
+
await probeOrigin("https://panel.example/", { fetchFn: explicit });
|
|
85
|
+
await probeOriginWithBody("https://panel.example/", { fetchFn: explicit });
|
|
86
|
+
await probeHealthEndpoint("https://panel.example/", { fetchFn: explicit });
|
|
87
|
+
await fetchChallenge("/api", explicit);
|
|
88
|
+
await negotiateNonce("/api", { fetchFn: explicit });
|
|
89
|
+
|
|
90
|
+
// negotiateNonce rides the explicit fn twice (challenge + exchange).
|
|
91
|
+
expect(explicit).toHaveBeenCalledTimes(6);
|
|
92
|
+
expect(guard).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/utils/healthProbe.ts
CHANGED
|
@@ -40,7 +40,11 @@ export async function probeHealthEndpoint(
|
|
|
40
40
|
url: string,
|
|
41
41
|
opts: { timeoutMs?: number; fetchFn?: typeof fetch } = {},
|
|
42
42
|
): Promise<HealthProbeResult> {
|
|
43
|
-
|
|
43
|
+
// Bare `?? fetch` captures Window's fetch detached — every default
|
|
44
|
+
// call throws "Illegal invocation" in a browser (2026-10-17 chest live
|
|
45
|
+
// report; the probe then silently reported every panel unreachable).
|
|
46
|
+
// bind(globalThis) re-attaches the receiver and re-resolves per call.
|
|
47
|
+
const fetchFn = opts.fetchFn ?? fetch.bind(globalThis);
|
|
44
48
|
const started = performance.now();
|
|
45
49
|
try {
|
|
46
50
|
const resp = await fetchFn(url, {
|
package/src/utils/powNonce.ts
CHANGED
|
@@ -29,7 +29,11 @@ function normalizeBase(baseUrl: string): string {
|
|
|
29
29
|
*/
|
|
30
30
|
export async function fetchChallenge(
|
|
31
31
|
baseUrl: string,
|
|
32
|
-
|
|
32
|
+
// Bare `= fetch` captures Window's fetch detached — the default throws
|
|
33
|
+
// "Illegal invocation" in a browser (2026-10-17 chest live report
|
|
34
|
+
// family). bind(globalThis) re-attaches the receiver, re-resolved per
|
|
35
|
+
// call so vi.stubGlobal stubs stay visible.
|
|
36
|
+
fetchFn: typeof fetch = fetch.bind(globalThis),
|
|
33
37
|
): Promise<ChallengeDescriptor | undefined> {
|
|
34
38
|
let resp: Response;
|
|
35
39
|
try {
|
|
@@ -59,7 +63,8 @@ export async function negotiateNonce(
|
|
|
59
63
|
},
|
|
60
64
|
): Promise<string | undefined> {
|
|
61
65
|
try {
|
|
62
|
-
|
|
66
|
+
// Same receiver rule as fetchChallenge's default: never bare `?? fetch`.
|
|
67
|
+
const fetchFn = opts?.fetchFn ?? fetch.bind(globalThis);
|
|
63
68
|
let body: Record<string, unknown> | undefined;
|
|
64
69
|
if (opts?.captchaToken) {
|
|
65
70
|
body = { type: "captcha", token: opts.captchaToken };
|