@celestia-island/hikari 0.55.177 → 0.55.179

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 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.177",
3
+ "version": "0.55.179",
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": "echo ok",
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` — extras after the status tags (e.g. a mock badge)
69
- * - `right` — extras on the far right (e.g. an engine version tag)
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
- const value = panel!.querySelector("span[title]") as HTMLElement;
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("keeps host actions available while connection info is still fetching", async () => {
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
- // Same line as the fetching text: label left, actions right.
590
- expect(cluster!.closest("div")!.textContent).toContain("Fetching connection info");
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 with the
18
- * quality icon, latency, retry countdown and protocol/network rows.
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,
@@ -180,7 +185,7 @@ export const HkStatusBar = defineComponent({
180
185
  panelBuildHash: { type: String as PropType<string | undefined>, default: undefined },
181
186
  engineBuildHash: { type: String as PropType<string | undefined>, default: undefined },
182
187
  connectionStatus: {
183
- type: String as PropType<"connected" | "reconnecting" | "disconnected" | "connecting">,
188
+ type: String as PropType<"connected" | "reconnecting" | "disconnected" | "connecting" | "idle">,
184
189
  default: "disconnected",
185
190
  },
186
191
  connectionInfo: {
@@ -238,6 +243,10 @@ export const HkStatusBar = defineComponent({
238
243
  connecting: "rgb(var(--color-warning))",
239
244
  reconnecting: "rgb(var(--color-warning))",
240
245
  disconnected: "rgb(var(--color-error))",
246
+ // Idle (2026-10-17 user direction): the shell is alive but has no
247
+ // session — the login page's resting light. Neutral gray, not red:
248
+ // red means FAULT, and "not yet logged in" is not a fault.
249
+ idle: "rgb(var(--color-text-tertiary, var(--color-text-secondary, 128 128 128)))",
241
250
  };
242
251
 
243
252
  // ── Recovery flash ─────────────────────────────────────────────
@@ -299,7 +308,10 @@ export const HkStatusBar = defineComponent({
299
308
  let touchEchoGuardUntil = 0;
300
309
  function onTagClick() {
301
310
  if (Date.now() < touchEchoGuardUntil) return;
302
- if (props.connectionStatus !== "connected") {
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") {
303
315
  props.onRetry?.();
304
316
  }
305
317
  }
@@ -380,7 +392,8 @@ export const HkStatusBar = defineComponent({
380
392
  const countdown = props.countdown ?? info?.countdown ?? 0;
381
393
 
382
394
  const tierLabelKey = `hikari::statusBar.tier.${tier}`;
383
- const statusText = mode === "connected" ? t("hikari::statusBar.connected", "Connected")
395
+ const statusText = mode === "idle" ? t("hikari::statusBar.idle", "Ready")
396
+ : mode === "connected" ? t("hikari::statusBar.connected", "Connected")
384
397
  : mode === "reconnecting" || mode === "connecting" ? t("hikari::statusBar.connecting", "Connecting...")
385
398
  : t("hikari::statusBar.disconnected", "Disconnected");
386
399
 
@@ -480,140 +493,139 @@ export const HkStatusBar = defineComponent({
480
493
  // 300px default floor (2026-10-11 user direction: the
481
494
  // version/protocol rows read cramped at the old 220px),
482
495
  // clamped to the popover's own viewport budget so a
483
- // 320px-class phone never spills past the glass.
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).
484
499
  minWidth: "min(300px, calc(100vw - 2 * var(--viewport-gutter, 16px)))",
485
500
  padding: "10px 14px",
486
501
  fontSize: "0.75rem", lineHeight: 1.6,
487
502
  color: "rgb(var(--color-text))",
488
503
  }}
489
504
  >
490
- {info ? (
491
- <>
492
- <div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px", fontWeight: 600, fontSize: "0.8125rem" }}>
493
- {qualityIcon(info.quality || (mode === "connected" ? "good" : "unknown"), tier, info.isLocalhost, 14)}
494
- <span style={{ color: dotColorMap[mode] ?? dotColorMap.disconnected }}>
495
- {statusText}
496
- </span>
497
- {latency !== null && (
498
- <span style={{ marginLeft: "auto", color: latencyColor(latency), fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, fontWeight: 600, fontSize: "0.6875rem" }}>
499
- {latency} ms
500
- </span>
501
- )}
502
- {hasActionsRow && (
503
- <span
504
- data-status-actions
505
- style={{
506
- display: "inline-flex",
507
- alignItems: "center",
508
- gap: "8px",
509
- // No latency chip to push against: the cluster
510
- // still lands on the row's right edge.
511
- marginLeft: latency !== null ? undefined : "auto",
512
- }}
513
- >
514
- {actionVnodes}
515
- </span>
516
- )}
517
- </div>
518
- {connecting && attempt > 0 && (
519
- <div style={{ display: "flex", alignItems: "center", gap: "4px", color: "rgb(var(--color-warning))", fontSize: "0.6875rem", marginBottom: "4px" }}>
520
- <span>
521
- {t("hikari::statusBar.retrying", "Retrying {retryCount} / {maxRetries}")
522
- .replace("{retryCount}", String(attempt))
523
- .replace("{maxRetries}", String(info.maxRetries > 0 ? info.maxRetries : 3))}
524
- </span>
525
- {countdown > 0 && (
526
- <span style={{ display: "inline-flex", alignItems: "center", gap: "4px", fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, marginLeft: "8px" }}>
527
- <HkCountdownDigit value={countdown} />
528
- </span>
529
- )}
530
- </div>
531
- )}
532
- {mode === "disconnected" && !hasActionsRow && (
533
- // Fallback for hosts without a slot action — and a REAL
534
- // control this time: the original hint was retired
535
- // because its clicks went nowhere (the popover body
536
- // teleports to <body>), so the text now carries the
537
- // retry itself, echo guard included.
538
- <div
539
- role="button"
540
- tabindex={0}
541
- onClick={onRetryButtonClick}
542
- onKeydown={(e: KeyboardEvent) => {
543
- if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRetryButtonClick(); }
544
- }}
545
- style={{ fontStyle: "italic", fontSize: "0.6875rem", marginBottom: "4px", opacity: 0.7, cursor: "pointer" }}
546
- >
547
- {t("hikari::statusBar.clickReconnect", "Click to retry")}
548
- </div>
549
- )}
550
- {props.compact && (
551
- <>
552
- <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
553
- <Monitor size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
554
- <span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.panel", "Panel")}</span>
555
- <span
556
- class="s-status-bar-popover-value"
557
- style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})` }}
558
- >
559
- <HkVersionValue text={pv} />
560
- </span>
561
- </div>
562
- {ev && (
563
- <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
564
- <Cog size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
565
- <span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.engine", "Engine")}</span>
566
- <span
567
- class="s-status-bar-popover-value"
568
- style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})` }}
569
- >
570
- <HkVersionValue text={hkMergeVersionDisplay(ev, props.engineBuildHash)} />
571
- </span>
572
- </div>
573
- )}
574
- </>
575
- )}
576
- {tier !== "none" && (
577
- <>
578
- <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
579
- <Plug size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
580
- <span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.protocol", "Protocol")}</span>
581
- {connecting ? (
582
- <span style={{ color: "rgb(var(--color-warning))" }}>
583
- {t("hikari::statusBar.probing", "Probing...")}
584
- </span>
585
- ) : (
586
- <span>{t(tierLabelKey, tier)}</span>
587
- )}
588
- </div>
589
- <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
590
- <Globe size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
591
- <span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.network", "Network")}</span>
592
- <span>{regionDisplayName(info.region, locale, t)}{info.asn != null ? ` · AS${info.asn}` : ""}{info.isLocalhost ? " · " + t("hikari::statusBar.local", "Local") : ""}</span>
593
- </div>
594
- </>
595
- )}
596
- {(props.extraDetails ?? []).map((row) => (
597
- <div key={row.key} data-extra-detail={row.key} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "6px", maxWidth: "400px" }}>
598
- <span style={{ opacity: 0.5, flexShrink: 0, display: "inline-flex" }}>{row.icon}</span>
599
- <span style={{ opacity: 0.5, flexShrink: 0, minWidth: "72px", marginRight: "auto" }}>{row.label}</span>
600
- <span title={row.value} style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.value}</span>
601
- </div>
602
- ))}
603
- </>
604
- ) : (
605
- <div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
606
- <span style={{ opacity: 0.5 }}>{t("hikari::statusBar.fetching", "Fetching connection info...")}</span>
607
- {hasActionsRow && (
608
- // An outage that drops connectionInfo to null is exactly
609
- // when the recovery action is most needed — the cluster
610
- // rides the fetching line's right edge.
611
- <span data-status-actions style={{ display: "inline-flex", alignItems: "center", gap: "8px", marginLeft: "auto" }}>
612
- {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} />
613
549
  </span>
614
550
  )}
615
551
  </div>
616
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
+ ))}
617
629
  </div>
618
630
  </HPopover>
619
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": "الشبكة",
@@ -283,5 +282,6 @@
283
282
  "hikari::diff.loadFailed": "فشل التحميل، انقر لإعادة المحاولة",
284
283
  "hikari::search.trigger": "بحث",
285
284
  "hikari::search.clear": "مسح البحث",
286
- "hikari::search.apply": "تطبيق"
285
+ "hikari::search.apply": "تطبيق",
286
+ "hikari::statusBar.idle": "جاهز"
287
287
  }
@@ -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",
@@ -283,5 +282,6 @@
283
282
  "hikari::diff.loadFailed": "Laden fehlgeschlagen, zum Wiederholen klicken",
284
283
  "hikari::search.trigger": "Suchen",
285
284
  "hikari::search.clear": "Suche löschen",
286
- "hikari::search.apply": "Anwenden"
285
+ "hikari::search.apply": "Anwenden",
286
+ "hikari::statusBar.idle": "Bereit"
287
287
  }
@@ -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",
@@ -283,5 +282,6 @@
283
282
  "hikari::diff.loadFailed": "Load failed, click to retry",
284
283
  "hikari::search.trigger": "Search",
285
284
  "hikari::search.clear": "Clear search",
286
- "hikari::search.apply": "Apply"
285
+ "hikari::search.apply": "Apply",
286
+ "hikari::statusBar.idle": "Ready"
287
287
  }
@@ -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",
@@ -283,5 +282,6 @@
283
282
  "hikari::diff.loadFailed": "Error al cargar, haz clic para reintentar",
284
283
  "hikari::search.trigger": "Buscar",
285
284
  "hikari::search.clear": "Borrar búsqueda",
286
- "hikari::search.apply": "Aplicar"
285
+ "hikari::search.apply": "Aplicar",
286
+ "hikari::statusBar.idle": "Listo"
287
287
  }
@@ -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",
@@ -283,5 +282,6 @@
283
282
  "hikari::diff.loadFailed": "Échec du chargement, cliquez pour réessayer",
284
283
  "hikari::search.trigger": "Rechercher",
285
284
  "hikari::search.clear": "Effacer la recherche",
286
- "hikari::search.apply": "Appliquer"
285
+ "hikari::search.apply": "Appliquer",
286
+ "hikari::statusBar.idle": "Prêt"
287
287
  }
@@ -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": "ネットワーク地域",
@@ -283,5 +282,6 @@
283
282
  "hikari::diff.loadFailed": "読み込みに失敗しました。クリックで再試行",
284
283
  "hikari::search.trigger": "検索",
285
284
  "hikari::search.clear": "検索をクリア",
286
- "hikari::search.apply": "適用"
285
+ "hikari::search.apply": "適用",
286
+ "hikari::statusBar.idle": "準備完了"
287
287
  }
@@ -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": "네트워크 지역",
@@ -283,5 +282,6 @@
283
282
  "hikari::diff.loadFailed": "불러오기 실패, 클릭하여 재시도",
284
283
  "hikari::search.trigger": "검색",
285
284
  "hikari::search.clear": "검색 지우기",
286
- "hikari::search.apply": "적용"
285
+ "hikari::search.apply": "적용",
286
+ "hikari::statusBar.idle": "준비됨"
287
287
  }
@@ -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",
@@ -283,5 +282,6 @@
283
282
  "hikari::diff.loadFailed": "Falha ao carregar, clique para tentar novamente",
284
283
  "hikari::search.trigger": "Pesquisar",
285
284
  "hikari::search.clear": "Limpar pesquisa",
286
- "hikari::search.apply": "Aplicar"
285
+ "hikari::search.apply": "Aplicar",
286
+ "hikari::statusBar.idle": "Pronto"
287
287
  }
@@ -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": "Сеть",
@@ -283,5 +282,6 @@
283
282
  "hikari::diff.loadFailed": "Не удалось загрузить, нажмите для повтора",
284
283
  "hikari::search.trigger": "Поиск",
285
284
  "hikari::search.clear": "Очистить поиск",
286
- "hikari::search.apply": "Применить"
285
+ "hikari::search.apply": "Применить",
286
+ "hikari::statusBar.idle": "Готово"
287
287
  }
@@ -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": "网络区域",
@@ -287,5 +286,6 @@
287
286
  "hikari::diff.loadFailed": "加载失败,点击重试",
288
287
  "hikari::search.trigger": "搜索",
289
288
  "hikari::search.clear": "清除搜索",
290
- "hikari::search.apply": "应用"
289
+ "hikari::search.apply": "应用",
290
+ "hikari::statusBar.idle": "就绪"
291
291
  }
@@ -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": "網路區域",
@@ -287,5 +286,6 @@
287
286
  "hikari::diff.loadFailed": "載入失敗,點擊重試",
288
287
  "hikari::search.trigger": "搜尋",
289
288
  "hikari::search.clear": "清除搜尋",
290
- "hikari::search.apply": "套用"
289
+ "hikari::search.apply": "套用",
290
+ "hikari::statusBar.idle": "就緒"
291
291
  }
@@ -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