@celestia-island/hikari 0.55.178 → 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.178",
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,
@@ -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
- 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") {
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 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).
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
- {info ? (
496
- <>
497
- <div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px", fontWeight: 600, fontSize: "0.8125rem" }}>
498
- {qualityIcon(info.quality || (mode === "connected" ? "good" : "unknown"), tier, info.isLocalhost, 14)}
499
- <span style={{ color: dotColorMap[mode] ?? dotColorMap.disconnected }}>
500
- {statusText}
501
- </span>
502
- {latency !== null && (
503
- <span style={{ marginLeft: "auto", color: latencyColor(latency), fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, fontWeight: 600, fontSize: "0.6875rem" }}>
504
- {latency} ms
505
- </span>
506
- )}
507
- {hasActionsRow && (
508
- <span
509
- data-status-actions
510
- style={{
511
- display: "inline-flex",
512
- alignItems: "center",
513
- gap: "8px",
514
- // No latency chip to push against: the cluster
515
- // still lands on the row's right edge.
516
- marginLeft: latency !== null ? undefined : "auto",
517
- }}
518
- >
519
- {actionVnodes}
520
- </span>
521
- )}
522
- </div>
523
- {connecting && attempt > 0 && (
524
- <div style={{ display: "flex", alignItems: "center", gap: "4px", color: "rgb(var(--color-warning))", fontSize: "0.6875rem", marginBottom: "4px" }}>
525
- <span>
526
- {t("hikari::statusBar.retrying", "Retrying {retryCount} / {maxRetries}")
527
- .replace("{retryCount}", String(attempt))
528
- .replace("{maxRetries}", String(info.maxRetries > 0 ? info.maxRetries : 3))}
529
- </span>
530
- {countdown > 0 && (
531
- <span style={{ display: "inline-flex", alignItems: "center", gap: "4px", fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, marginLeft: "8px" }}>
532
- <HkCountdownDigit value={countdown} />
533
- </span>
534
- )}
535
- </div>
536
- )}
537
- {mode === "disconnected" && !hasActionsRow && (
538
- // Fallback for hosts without a slot action — and a REAL
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