@celestia-island/hikari 0.55.136 → 0.55.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.136",
3
+ "version": "0.55.138",
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",
@@ -85,4 +85,96 @@ describe("HSelect mobile sheet", () => {
85
85
 
86
86
  app.unmount();
87
87
  });
88
+
89
+ it("consumes Escape while the panel is open so hosting dialogs stay put", async () => {
90
+ // The trigger commonly renders inside a dialog that closes on
91
+ // bubbled Escape (HkModal/HkDrawer). One press must close ONLY the
92
+ // dropdown; with the dropdown already closed the key bubbles on so
93
+ // the dialog's own Escape semantics are untouched.
94
+ setViewportWidth(1280);
95
+ const host = document.createElement("div");
96
+ document.body.appendChild(host);
97
+ const dialogKeydowns: KeyboardEvent[] = [];
98
+ host.addEventListener("keydown", (ev) => dialogKeydowns.push(ev as KeyboardEvent));
99
+
100
+ const app = createApp({
101
+ render: () =>
102
+ h("div", { onKeydown: () => {} }, [
103
+ h(HSelect, {
104
+ modelValue: "",
105
+ "onUpdate:modelValue": () => {},
106
+ options: [
107
+ { value: "a", label: "Option A" },
108
+ { value: "b", label: "Option B" },
109
+ ],
110
+ }),
111
+ ]),
112
+ });
113
+ app.mount(host);
114
+ await nextTick();
115
+
116
+ const trigger = host.querySelector<HTMLButtonElement>(".hk-select-trigger")!;
117
+
118
+ // Panel open: Escape closes it AND is consumed.
119
+ trigger.click();
120
+ await nextTick();
121
+ const escapeOpen = new KeyboardEvent("keydown", {
122
+ key: "Escape", bubbles: true, cancelable: true,
123
+ });
124
+ trigger.dispatchEvent(escapeOpen);
125
+ await nextTick();
126
+ expect(escapeOpen.defaultPrevented).toBe(true);
127
+ expect(dialogKeydowns).toHaveLength(0);
128
+
129
+ // Panel closed: the same key bubbles to the hosting dialog.
130
+ const escapeClosed = new KeyboardEvent("keydown", {
131
+ key: "Escape", bubbles: true, cancelable: true,
132
+ });
133
+ trigger.dispatchEvent(escapeClosed);
134
+ await nextTick();
135
+ expect(escapeClosed.defaultPrevented).toBe(false);
136
+ expect(dialogKeydowns).toHaveLength(1);
137
+
138
+ app.unmount();
139
+ host.remove();
140
+ });
141
+
142
+ it("titles the opened surface from panelTitle without a visible label", async () => {
143
+ // Consumers whose visible label lives outside the field (a titled
144
+ // row) still need the mobile sheet header / popout a11y name; an
145
+ // untitled surface registers an unnamed blocking layer on phones.
146
+ setViewportWidth(375);
147
+ const el = document.createElement("div");
148
+ document.body.appendChild(el);
149
+ const app = createApp({
150
+ render: () =>
151
+ h(HSelect, {
152
+ modelValue: "",
153
+ "onUpdate:modelValue": () => {},
154
+ panelTitle: "What this screen shows",
155
+ options: [
156
+ { value: "a", label: "Option A" },
157
+ { value: "b", label: "Option B" },
158
+ ],
159
+ }),
160
+ });
161
+ app.mount(el);
162
+ await nextTick();
163
+
164
+ // No visible field label rendered above the trigger.
165
+ expect(document.querySelector(".hk-select-label")).toBeNull();
166
+
167
+ const trigger = document.querySelector<HTMLButtonElement>(".hk-select-trigger")!;
168
+ trigger.click();
169
+ await nextTick();
170
+
171
+ const title = document.querySelector<HTMLElement>(".hk-select-sheet-title");
172
+ expect(title?.textContent?.trim()).toBe("What this screen shows");
173
+ // The sheet panel is the a11y-named dialog for the surface.
174
+ const panel = document.querySelector<HTMLElement>(".hk-select-sheet-panel");
175
+ expect(panel?.getAttribute("aria-label")).toBe("What this screen shows");
176
+
177
+ app.unmount();
178
+ el.remove();
179
+ });
88
180
  });
@@ -30,6 +30,15 @@ export default defineComponent({
30
30
  props: {
31
31
  modelValue: { type: String, default: "" },
32
32
  label: { type: String, default: undefined },
33
+ /**
34
+ * Title for the OPENED surface only (mobile sheet header, desktop
35
+ * popout's a11y name, popup-manager breadcrumb layer) — without
36
+ * rendering the visible field `label`. Consumers whose visible label
37
+ * lives elsewhere (a titled row around the field) pass this so the
38
+ * surface is not registered untitled; resolution order is
39
+ * panelTitle, then `label`, then empty (the historic behaviour).
40
+ */
41
+ panelTitle: { type: String, default: undefined },
33
42
  placeholder: { type: String, default: "" },
34
43
  error: { type: String, default: undefined },
35
44
  disabled: { type: Boolean, default: false },
@@ -100,6 +109,18 @@ export default defineComponent({
100
109
  normalizedOptions.value.length;
101
110
  }
102
111
  } else if (e.key === "Escape") {
112
+ // Consume the key ONLY when it actually closed something: the
113
+ // trigger commonly renders inside a dialog surface (HkModal and
114
+ // HkDrawer close on bubbled Escape), and one press must mean one
115
+ // thing — close the dropdown, not the dropdown AND the hosting
116
+ // dialog. The panel surface itself stops propagation for the
117
+ // same reason (HkSelectPanel's onSurfaceEscape); with the panel
118
+ // closed the key bubbles on as before, so the hosting dialog's
119
+ // own Escape semantics are untouched.
120
+ if (isOpen.value) {
121
+ e.preventDefault();
122
+ e.stopPropagation();
123
+ }
103
124
  isOpen.value = false;
104
125
  }
105
126
  }
@@ -182,7 +203,7 @@ export default defineComponent({
182
203
  open={isOpen.value}
183
204
  onUpdate:open={(v: boolean) => { isOpen.value = v; }}
184
205
  anchorRef={triggerRef.value ?? null}
185
- title={props.label ?? ""}
206
+ title={props.panelTitle ?? props.label ?? ""}
186
207
  placement="bottom-start"
187
208
  offset={4}
188
209
  onKeydown={onPopoutKeydown}
@@ -1,7 +1,9 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, h, nextTick, ref } from "vue";
3
+ import { readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
3
5
 
4
- import { HkStatusBar } from "./HkStatusBar";
6
+ import { HkStatusBar, hkMergeVersionDisplay, hkParseIdentity } from "./HkStatusBar";
5
7
  import type { HkConnectionInfo } from "./HkConnectionInfo";
6
8
 
7
9
  const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
@@ -55,6 +57,7 @@ const INFO: HkConnectionInfo = {
55
57
  };
56
58
 
57
59
  afterEach(() => {
60
+ vi.restoreAllMocks();
58
61
  for (const { app, container } of mounts.splice(0)) {
59
62
  app.unmount();
60
63
  container.remove();
@@ -64,17 +67,32 @@ afterEach(() => {
64
67
 
65
68
  describe("HkStatusBar", () => {
66
69
  it("suppresses a build hash the version line already shows, keeps drift visible", async () => {
67
- // Display-layer dedup (2026-10-10): the family version line ends with
68
- // `::<hash7>`, so appending the SAME commit — bare hash7 or a
69
- // `<branch>::<hash7>` stamp — printed the commit twice (the engine-row
70
- // bug). A DIFFERENT commit stays visible: that IS the drift signal.
70
+ // Display-layer dedup, set-based since 2026-10-11: the version line
71
+ // and the build stamp are two INDEPENDENT facts, and the merge keys
72
+ // every token by its commit (separator- and case-agnostic) through a
73
+ // set — a token whose key is already shown cannot render again, BY
74
+ // CONSTRUCTION, in either separator generation. A DIFFERENT commit
75
+ // stays visible: that IS the drift signal. The last case is the
76
+ // live 2026-10-11 production pair (single-colon health line against
77
+ // the legacy `::` webui stamp) that printed the panel identity
78
+ // twice on demo.dev.cw.
71
79
  const cases: Array<{ version: string; hash: string; expect: string }> = [
80
+ { version: "0.1.0 master:d423747", hash: "d423747", expect: "0.1.0 master:d423747" },
81
+ { version: "0.1.0 master:d423747", hash: "master:d423747", expect: "0.1.0 master:d423747" },
82
+ { version: "0.1.0 master:d423747", hash: "master::d423747", expect: "0.1.0 master:d423747" },
72
83
  { version: "0.1 master::d423747", hash: "d423747", expect: "0.1 master::d423747" },
73
84
  { version: "0.1 master::d423747", hash: "master::d423747", expect: "0.1 master::d423747" },
85
+ { version: "0.1 master::d423747", hash: "master:d423747", expect: "0.1 master::d423747" },
74
86
  { version: "0.1 master::d423747", hash: "badd902", expect: "0.1 master::d423747 badd902" },
75
87
  { version: "0.1 master::d423747", hash: "feat/x::badd902", expect: "0.1 master::d423747 feat/x::badd902" },
88
+ { version: "0.1.0 master:d423747", hash: "master:badd902", expect: "0.1.0 master:d423747 master:badd902" },
76
89
  { version: "0.1.284", hash: "d423747", expect: "0.1.284 d423747" },
77
90
  { version: "0.1.52", hash: "EDW62Q", expect: "0.1.52 EDW62Q" },
91
+ {
92
+ version: "0.1.0 feat/model-usage-selfhosted:770f625",
93
+ hash: "feat/model-usage-selfhosted::770f625",
94
+ expect: "0.1.0 feat/model-usage-selfhosted:770f625",
95
+ },
78
96
  ];
79
97
  for (const { version, hash, expect: expected } of cases) {
80
98
  const container = document.createElement("div");
@@ -90,13 +108,51 @@ describe("HkStatusBar", () => {
90
108
  });
91
109
  app.mount(container);
92
110
  await nextTick();
93
- const row = container.querySelector<HTMLElement>(".s-status-bar-version")!;
111
+ // The static layer carries the merged text exactly once; the
112
+ // marquee overlay copies are the scrolling mechanism, not content.
113
+ const row = container.querySelector<HTMLElement>(".s-status-bar-version__static")!;
94
114
  expect(row.textContent, `${version} + ${hash}`).toBe(expected);
95
115
  app.unmount();
96
116
  container.remove();
97
117
  }
98
118
  });
99
119
 
120
+ it("merges version identities duplicate-free by construction (hkMergeVersionDisplay)", () => {
121
+ // The set guarantee, exercised at the unit level: whatever the
122
+ // inputs look like — repeated tokens inside one line, mixed
123
+ // separators, case drift — a key already seen never renders again.
124
+ expect(hkMergeVersionDisplay("0.1.0 master:abc1234", undefined)).toBe("0.1.0 master:abc1234");
125
+ expect(hkMergeVersionDisplay("0.1.0", "master:abc1234")).toBe("0.1.0 master:abc1234");
126
+ // The same commit twice inside ONE line (defensive: a chatty server
127
+ // concatenating its own stamp).
128
+ expect(hkMergeVersionDisplay("0.1.0 master:abc1234 master:abc1234", undefined))
129
+ .toBe("0.1.0 master:abc1234");
130
+ // Cross-separator + case-folded: same commit, either generation.
131
+ expect(hkMergeVersionDisplay("0.1.0 a:1b2c3d4", "a::1B2C3D4")).toBe("0.1.0 a:1b2c3d4");
132
+ // Same commit under a different branch label: the commit IS the
133
+ // identity — the second label names the same atom and adds nothing.
134
+ expect(hkMergeVersionDisplay("0.1.0 feat/x:1b2c3d4", "master::1b2c3d4"))
135
+ .toBe("0.1.0 feat/x:1b2c3d4");
136
+ // Non-git tokens key by raw text and still cannot duplicate.
137
+ expect(hkMergeVersionDisplay("0.1.52 EDW62Q", "EDW62Q")).toBe("0.1.52 EDW62Q");
138
+ expect(hkMergeVersionDisplay("0.1.52", "EDW62Q")).toBe("0.1.52 EDW62Q");
139
+ // Drift stays visible (the stale-embed signal).
140
+ expect(hkMergeVersionDisplay("0.1.0 master:1b2c3d4", "badd902"))
141
+ .toBe("0.1.0 master:1b2c3d4 badd902");
142
+ // Blank stamps never append a phantom token.
143
+ expect(hkMergeVersionDisplay("0.1.0", " ")).toBe("0.1.0");
144
+ expect(hkMergeVersionDisplay(" 0.1.0 ", "")).toBe("0.1.0");
145
+ });
146
+
147
+ it("parses identity atoms in either separator generation (hkParseIdentity)", () => {
148
+ expect(hkParseIdentity("feat/x:770f625")).toEqual({ branch: "feat/x", commit: "770f625" });
149
+ expect(hkParseIdentity("feat/x::770f625")).toEqual({ branch: "feat/x", commit: "770f625" });
150
+ expect(hkParseIdentity("770F625")).toEqual({ commit: "770f625" });
151
+ expect(hkParseIdentity("0.1.0")).toBeUndefined();
152
+ expect(hkParseIdentity("EDW62Q")).toBeUndefined();
153
+ expect(hkParseIdentity(undefined)).toBeUndefined();
154
+ });
155
+
100
156
  it("renders extraDetails rows capped at 400px with an ellipsized, title-backed value", async () => {
101
157
  const container = mountBar({
102
158
  version: "1.2.3",
@@ -170,6 +226,15 @@ describe("HkStatusBar", () => {
170
226
  expect(panel, "popover opens on hover").toBeTruthy();
171
227
  expect(panel!.textContent).toContain("Protocol");
172
228
  expect(panel!.textContent).toContain("HTTP poll");
229
+ // The card's DEFAULT width is a deliberate 300px (2026-10-11 user
230
+ // direction: the version/protocol rows read cramped at the old 220px
231
+ // floor), clamped to the popover viewport budget on narrow phones.
232
+ // Hooked via data-status-bar-card — firstElementChild would couple
233
+ // the test to HkPopover's internal panel structure.
234
+ const card = panel!.querySelector<HTMLElement>("[data-status-bar-card]")!;
235
+ expect(card.style.minWidth).toBe(
236
+ "min(300px, calc(100vw - 2 * var(--viewport-gutter, 16px)))",
237
+ );
173
238
  });
174
239
 
175
240
  it("compact mode collapses the tag to the bare dot with no inline status text", async () => {
@@ -500,4 +565,108 @@ describe("HkStatusBar", () => {
500
565
  const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel");
501
566
  expect(panel!.querySelector("[data-status-actions]")).toBeNull();
502
567
  });
568
+
569
+ it("version value cells scroll like a marquee only when the identity overflows", async () => {
570
+ // 2026-10-11 user direction: long branch identities must not wrap or
571
+ // stretch the footer — the two version positions become marquee
572
+ // windows riding the SHARED HkPlaceholderMarquee (the input
573
+ // placeholder's overflow strategy, consumed as-is). Static layer
574
+ // while the text fits; ghost + scrolling strip once it overflows.
575
+ const LONG = "0.1.0 feat/model-usage-selfhosted:770f625";
576
+ const container = mountBar({
577
+ version: LONG,
578
+ engineVersion: "9.8.7",
579
+ connectionStatus: "connected",
580
+ connectionInfo: INFO,
581
+ });
582
+ await nextTick();
583
+ // Fitting state (jsdom lays out nothing): the static layer shows the
584
+ // merged text, the marquee stays a hidden single-copy probe, no
585
+ // overflow flag, and the title keeps the hover copy.
586
+ const cell = container.querySelector<HTMLElement>(".s-status-bar-version")!;
587
+ expect(cell.querySelector(".s-status-bar-version__static")!.textContent).toBe(LONG);
588
+ expect(cell.querySelector(".hk-placeholder-marquee--hidden"), "hidden probe").toBeTruthy();
589
+ expect(cell.hasAttribute("data-overflowing")).toBe(false);
590
+ expect(cell.title).toBe(LONG);
591
+
592
+ // Overflow: the copy (500px) outgrows the window (200px) — the cell
593
+ // flips to the marquee: overflow flag on, strip scrolls with the
594
+ // measured loop geometry, three copies for the seamless wrap.
595
+ const host = cell.querySelector<HTMLElement>(".hk-placeholder-marquee")!;
596
+ const copy = cell.querySelector<HTMLElement>(".hk-placeholder-marquee__copy")!;
597
+ Object.defineProperty(host, "clientWidth", { configurable: true, get: () => 200 });
598
+ vi.spyOn(copy, "getBoundingClientRect").mockReturnValue({ width: 500 } as DOMRect);
599
+ (host as HTMLElement & {
600
+ __vueParentComponent?: { exposed?: { measure?(): void } };
601
+ }).__vueParentComponent?.exposed?.measure?.();
602
+ await nextTick();
603
+ expect(cell.hasAttribute("data-overflowing")).toBe(true);
604
+ const strip = cell.querySelector<HTMLElement>(".hk-placeholder-marquee__strip")!;
605
+ expect(strip.className).toContain("hk-placeholder-marquee__strip--scroll");
606
+ expect(strip.style.getPropertyValue("--hk-marquee-shift")).toBe("-500px");
607
+ expect(cell.querySelectorAll(".hk-placeholder-marquee__copy").length).toBe(3);
608
+
609
+ // Fit again (a shorter identity arrives from the server): the cell
610
+ // flips back — overflow flag off, probe hidden again.
611
+ vi.spyOn(copy, "getBoundingClientRect").mockReturnValue({ width: 100 } as DOMRect);
612
+ (host as HTMLElement & {
613
+ __vueParentComponent?: { exposed?: { measure?(): void } };
614
+ }).__vueParentComponent?.exposed?.measure?.();
615
+ await nextTick();
616
+ expect(cell.hasAttribute("data-overflowing")).toBe(false);
617
+ expect(cell.querySelector(".hk-placeholder-marquee--hidden")).toBeTruthy();
618
+ });
619
+
620
+ it("compact popover version rows are bounded marquee cells with merged-once text", async () => {
621
+ const container = mountBar({
622
+ version: "0.1.0 feat/model-usage-selfhosted:770f625",
623
+ panelBuildHash: "feat/model-usage-selfhosted::770f625",
624
+ engineVersion: "0.1.0 feat/model-usage-selfhosted:770f625",
625
+ connectionStatus: "connected",
626
+ connectionInfo: INFO,
627
+ compact: true,
628
+ });
629
+ await nextTick();
630
+ container
631
+ .querySelector<HTMLElement>(".s-status-bar-tag")!
632
+ .dispatchEvent(new MouseEvent("mouseenter"));
633
+ await nextTick();
634
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
635
+ // Both the panel and the engine row ride the bounded popover value
636
+ // cell; the panel pair is the live duplicate fixture — the merged
637
+ // text renders the identity exactly once in each row.
638
+ const cells = panel.querySelectorAll<HTMLElement>(".s-status-bar-popover-value .s-status-bar-version");
639
+ expect(cells.length).toBe(2);
640
+ for (const c of cells) {
641
+ expect(c.querySelector(".s-status-bar-version__static")!.textContent)
642
+ .toBe("0.1.0 feat/model-usage-selfhosted:770f625");
643
+ expect(c.querySelector(".hk-placeholder-marquee"), "marquee overlay wired").toBeTruthy();
644
+ }
645
+ });
646
+
647
+ it("pins the status-bar marquee window contract in admin-tokens.scss", () => {
648
+ // Extraction self-check: the selector list form
649
+ // (`.s-status-bar-version, .s-status-bar-version-sep {`) must NOT be
650
+ // the match — the rule asserted here is the dedicated block.
651
+ const scss = readFileSync(resolve(__dirname, "../styles/admin-tokens.scss"), "utf8");
652
+ const versionRule = /\.s-status-bar-version\s*\{[^}]*\}/.exec(scss)?.[0] ?? "";
653
+ expect(versionRule, "dedicated .s-status-bar-version rule exists").toContain("max-width: min(40vw, 24rem)");
654
+ // inline-flex keeps a text baseline for the grid's align-items:
655
+ // baseline — an overflow:hidden block would sink the row.
656
+ expect(versionRule).toContain("display: inline-flex");
657
+ expect(versionRule).toContain("overflow: hidden");
658
+ // Ghost handover: the static layer keeps layout + a11y, only the
659
+ // paint moves to the scrolling overlay.
660
+ expect(scss).toContain(".s-status-bar-version[data-overflowing] .s-status-bar-version__static");
661
+ expect(scss).toMatch(/\.s-status-bar-version\[data-overflowing\][^{]*\{[^}]*opacity:\s*0/s);
662
+ // Measurement parity: the overlay must draw in the cell's own
663
+ // typography, or the overflow flip fires at the wrong width.
664
+ expect(scss).toContain(".s-status-bar-version .hk-placeholder-marquee");
665
+ expect(scss).toMatch(/\.s-status-bar-version \.hk-placeholder-marquee\s*\{[^}]*font-size:\s*inherit/s);
666
+ // Popover bound: a long identity scrolls inside the ~300px card
667
+ // instead of stretching it.
668
+ const popoverRule = /\.s-status-bar-popover-value\s*\{[^}]*\}/.exec(scss)?.[0] ?? "";
669
+ expect(popoverRule, "popover value cell bound exists").toContain("max-width: min(16rem, 60vw)");
670
+ expect(scss).toMatch(/\.s-status-bar-popover-value \.s-status-bar-version\s*\{[^}]*max-width:\s*100%/s);
671
+ });
503
672
  });
@@ -8,6 +8,7 @@ import HPopover from "./HkPopover";
8
8
  import HkButton from "./HkButton";
9
9
  import type { HkConnectionInfo } from "./HkConnectionInfo";
10
10
  import { HkCountdownDigit } from "./HkCountdownDigit";
11
+ import { HkPlaceholderMarquee } from "./HkPlaceholderMarquee";
11
12
 
12
13
  /**
13
14
  * HkStatusBar — connection + version status pill for app footers.
@@ -27,6 +28,13 @@ import { HkCountdownDigit } from "./HkCountdownDigit";
27
28
  * Styling rides the shared `s-status-bar*` classes from
28
29
  * `styles/admin-tokens.scss` (the `[data-compact]` rules hide the inline
29
30
  * version block); everything else is inline.
31
+ *
32
+ * The two version values (panel row, engine row) are merged ONCE from
33
+ * the raw facts via hkMergeVersionDisplay — the single set-based,
34
+ * duplicate-free-by-construction rule for the whole family — and each
35
+ * renders in an HkVersionValue cell: static text that hands its paint
36
+ * over to the shared HkPlaceholderMarquee scrolling window when a long
37
+ * branch identity outgrows the cell (2026-10-11 user direction).
30
38
  */
31
39
  /** Locale-aware region name: the political-name i18n keys first (they
32
40
  * carry the deliberate political naming per language, e.g. zh-Hans
@@ -66,37 +74,105 @@ function qualityIcon(quality: string, tier: string, isLocalhost: boolean, size:
66
74
  }
67
75
 
68
76
  /**
69
- * The commit a family version line ends with (`0.1 master::d423747`),
70
- * when it carries one. Lowercased for comparison.
77
+ * One git identity atom: a commit, optionally labeled by its branch.
78
+ * `feat/x:770f625`, the legacy `feat/x::770f625`, and a bare `770f625`
79
+ * all carry the SAME commit — the commit IS the identity (2026-10-08
80
+ * family direction); branch labels are names for it.
71
81
  */
72
- function versionLineCommit(v: string): string | undefined {
73
- return v.match(/::([0-9a-f]{7,40})$/i)?.[1]?.toLowerCase();
82
+ export interface HkVersionIdentity {
83
+ branch?: string;
84
+ commit: string;
74
85
  }
75
86
 
76
- /** The commit a build stamp carries — a bare hash7 or a `<branch>::<hash7>`
77
- * stamp. Non-git stamps (retired Crockford tokens) carry none and always
78
- * stay visible; they can never be shown "already". */
79
- function stampCommit(hash: string): string | undefined {
80
- const stamped = hash.match(/^(\S+)::([0-9a-f]{7,40})$/i)?.[2];
81
- return stamped?.toLowerCase() ?? (/^[0-9a-f]{7,40}$/i.test(hash) ? hash.toLowerCase() : undefined);
87
+ /** `<branch>:<hash7..40>` / legacy `<branch>::<hash>` / bare `<hash>`.
88
+ * Both separators count: the family narrowed `::` → `:` on 2026-10-11
89
+ * and transition-window binaries still speak either shape. Non-git
90
+ * tokens (retired Crockford chunk stamps like `EDW62Q`) carry no commit
91
+ * and return undefined — they can never be "already shown". */
92
+ const STAMP_RE = /^(\S+?):+([0-9a-f]{7,40})$/i;
93
+ const BARE_COMMIT_RE = /^[0-9a-f]{7,40}$/i;
94
+
95
+ export function hkParseIdentity(token: string | undefined): HkVersionIdentity | undefined {
96
+ if (!token) return undefined;
97
+ const labeled = token.match(STAMP_RE);
98
+ if (labeled) return { branch: labeled[1], commit: labeled[2].toLowerCase() };
99
+ if (BARE_COMMIT_RE.test(token)) return { commit: token.toLowerCase() };
100
+ return undefined;
82
101
  }
83
102
 
84
- function fmtVer(v: string, hash?: string): string {
85
- // Display-layer dedup (2026-10-10 family direction): a hash whose commit
86
- // the version line already names says nothing new — printing it twice was
87
- // the status-bar engine-row bug. Drift (a different commit) stays visible:
88
- // that IS the stale-embed signal.
89
- if (hash) {
90
- const shown = versionLineCommit(v);
91
- const stamp = stampCommit(hash);
92
- if (shown === undefined || stamp === undefined || shown !== stamp) {
93
- return `${v} ${hash}`;
94
- }
95
- return v;
103
+ /**
104
+ * Canonical display merge for a `<version line> + <build stamp>` pair —
105
+ * the ONE rule every identity surface consumes (status bar rows, the
106
+ * hosts' About dialogs; import this instead of re-deriving it). The two
107
+ * inputs are independent facts (the served version line and the
108
+ * client/stamp), so a plain concatenation can print the same commit
109
+ * twice.
110
+ *
111
+ * The merge is SET-BASED and therefore duplicate-free BY CONSTRUCTION:
112
+ * every whitespace token of the version line, then the stamp, is keyed —
113
+ * git tokens by their commit (case-folded, separator-agnostic), non-git
114
+ * tokens by their raw text — and a key already in the set is dropped.
115
+ * No pair of inputs, in either separator generation, can render one
116
+ * identity twice. A stamp whose commit the version line already names
117
+ * adds nothing (the 2026-10-11 seam: single-colon line against legacy
118
+ * `::` stamp and vice versa); a DIFFERENT commit always stays visible,
119
+ * because that drift IS the stale-embed signal.
120
+ */
121
+ export function hkMergeVersionDisplay(version: string, hash?: string): string {
122
+ const seen = new Set<string>();
123
+ const out: string[] = [];
124
+ const keyOf = (token: string): string => {
125
+ const id = hkParseIdentity(token);
126
+ return id ? `c:${id.commit}` : `t:${token.toLowerCase()}`;
127
+ };
128
+ for (const token of version.trim().split(/\s+/)) {
129
+ if (!token) continue;
130
+ const key = keyOf(token);
131
+ if (seen.has(key)) continue;
132
+ seen.add(key);
133
+ out.push(token);
134
+ }
135
+ const stamp = hash?.trim();
136
+ if (stamp) {
137
+ const key = keyOf(stamp);
138
+ if (!seen.has(key)) out.push(stamp);
96
139
  }
97
- return v;
140
+ return out.join(" ");
98
141
  }
99
142
 
143
+ /**
144
+ * One version value cell: the static text plus the shared
145
+ * HkPlaceholderMarquee layered over it (the upstream overflow strategy,
146
+ * consumed as-is — nothing is re-invented here). While the text fits,
147
+ * the static layer shows and the marquee stays a hidden measuring
148
+ * probe; once it overflows the window (long branch names), the static
149
+ * layer turns into a transparent layout ghost (opacity, so the text
150
+ * stays in the a11y tree; `title` keeps the hover copy) and the marquee
151
+ * scrolls the text like a storefront sign. Pure-CSS compositor motion;
152
+ * reduced-motion users get a parked first copy (the component's own
153
+ * media handling).
154
+ */
155
+ const HkVersionValue = defineComponent({
156
+ name: "HkVersionValue",
157
+ props: { text: { type: String, required: true } },
158
+ setup(props) {
159
+ const overflowing = ref(false);
160
+ return () => (
161
+ <span
162
+ class="s-status-bar-version"
163
+ data-overflowing={overflowing.value || undefined}
164
+ title={props.text}
165
+ >
166
+ <span class="s-status-bar-version__static">{props.text}</span>
167
+ <HkPlaceholderMarquee
168
+ text={props.text}
169
+ onOverflowChange={(v: boolean) => { overflowing.value = v; }}
170
+ />
171
+ </span>
172
+ );
173
+ },
174
+ });
175
+
100
176
  export const HkStatusBar = defineComponent({
101
177
  name: "HkStatusBar",
102
178
  props: {
@@ -323,12 +399,15 @@ export const HkStatusBar = defineComponent({
323
399
  : Boolean(actionVnodes);
324
400
  const hasActionsRow = showRetryButton || hasHostActions;
325
401
 
326
- const pv = fmtVer(props.version, props.panelBuildHash);
402
+ const pv = hkMergeVersionDisplay(props.version, props.panelBuildHash);
327
403
  const ev = props.engineVersion;
328
404
  // Version block: panel version on the first row, engine version on
329
405
  // the second. The value container is a two-column grid (label column
330
406
  // + value column), so both rows share one true left edge — no
331
- // separator, no mid-token wrapping, at any footer width.
407
+ // separator, no mid-token wrapping, at any footer width. Each value
408
+ // cell is an HkVersionValue: merged ONCE above (set semantics — see
409
+ // hkMergeVersionDisplay), rendered once, marquee-scrolled only when
410
+ // the identity outgrows the window.
332
411
 
333
412
  const tagClass = [
334
413
  "s-status-bar-tag",
@@ -345,7 +424,7 @@ export const HkStatusBar = defineComponent({
345
424
  role="button"
346
425
  tabindex={0}
347
426
  aria-label={props.compact
348
- ? `${statusText} · ${pv}${ev ? ` · ${fmtVer(ev, props.engineBuildHash)}` : ""}`
427
+ ? `${statusText} · ${pv}${ev ? ` · ${hkMergeVersionDisplay(ev, props.engineBuildHash)}` : ""}`
349
428
  : undefined}
350
429
  onMouseenter={onTagEnter}
351
430
  onMouseleave={onTagLeave}
@@ -365,11 +444,11 @@ export const HkStatusBar = defineComponent({
365
444
  }} />
366
445
  <span class="s-status-bar-tag-value">
367
446
  <span class="s-status-bar-tag-label">{t("hikari::statusBar.panel", "Panel")}</span>
368
- <span class="s-status-bar-version">{pv}</span>
447
+ <HkVersionValue text={pv} />
369
448
  {ev && (
370
449
  <>
371
450
  <span class="s-status-bar-tag-label">{t("hikari::statusBar.engine", "Engine")}</span>
372
- <span class="s-status-bar-version">{fmtVer(ev, props.engineBuildHash)}</span>
451
+ <HkVersionValue text={hkMergeVersionDisplay(ev, props.engineBuildHash)} />
373
452
  </>
374
453
  )}
375
454
  </span>
@@ -389,10 +468,16 @@ export const HkStatusBar = defineComponent({
389
468
  title={t("hikari::statusBar.panel")}
390
469
  >
391
470
  <div
471
+ data-status-bar-card
392
472
  onMouseenter={onPopupEnter}
393
473
  onMouseleave={onPopupLeave}
394
474
  style={{
395
- minWidth: "220px", padding: "10px 14px",
475
+ // 300px default floor (2026-10-11 user direction: the
476
+ // version/protocol rows read cramped at the old 220px),
477
+ // clamped to the popover's own viewport budget so a
478
+ // 320px-class phone never spills past the glass.
479
+ minWidth: "min(300px, calc(100vw - 2 * var(--viewport-gutter, 16px)))",
480
+ padding: "10px 14px",
396
481
  fontSize: "0.75rem", lineHeight: 1.6,
397
482
  color: "rgb(var(--color-text))",
398
483
  }}
@@ -434,13 +519,23 @@ export const HkStatusBar = defineComponent({
434
519
  <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
435
520
  <Monitor size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
436
521
  <span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.panel", "Panel")}</span>
437
- <span style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})` }}>{pv}</span>
522
+ <span
523
+ class="s-status-bar-popover-value"
524
+ style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})` }}
525
+ >
526
+ <HkVersionValue text={pv} />
527
+ </span>
438
528
  </div>
439
529
  {ev && (
440
530
  <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
441
531
  <Cog size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
442
532
  <span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.engine", "Engine")}</span>
443
- <span style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})` }}>{fmtVer(ev, props.engineBuildHash)}</span>
533
+ <span
534
+ class="s-status-bar-popover-value"
535
+ style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})` }}
536
+ >
537
+ <HkVersionValue text={hkMergeVersionDisplay(ev, props.engineBuildHash)} />
538
+ </span>
444
539
  </div>
445
540
  )}
446
541
  </>
package/src/index.ts CHANGED
@@ -853,7 +853,8 @@ export { HkSettingsSub as HkSettingsSub } from "./components/HkSettingsDialog";
853
853
  export { HkSettingsHint as HkSettingsHint } from "./components/HkSettingsDialog";
854
854
  export { HkCookieConsent as HkCookieConsent } from "./components/HkCookieConsent";
855
855
  export { HkAttachmentModal as HkAttachmentModal } from "./components/HkAttachmentModal";
856
- export { HkStatusBar as HkStatusBar } from "./components/HkStatusBar";
856
+ export { HkStatusBar as HkStatusBar, hkMergeVersionDisplay, hkParseIdentity } from "./components/HkStatusBar";
857
+ export type { HkVersionIdentity } from "./components/HkStatusBar";
857
858
  export { HkCountdownDigit as HkCountdownDigit } from "./components/HkCountdownDigit";
858
859
  export { HkConnectionStatus as HkConnectionStatus } from "./components/HkConnectionStatus";
859
860
  export { HkRichInput as HkRichInput } from "./components/HkRichInput";
@@ -182,6 +182,53 @@
182
182
 
183
183
  .s-status-bar-version {
184
184
  white-space: nowrap;
185
+ /* Version cell = marquee window (2026-10-11 user direction): long
186
+ branch identities scroll inside the cell (HkPlaceholderMarquee
187
+ layered by HkStatusBar's HkVersionValue) instead of stretching the
188
+ footer or wrapping. inline-flex (not block) so the static text
189
+ layer keeps contributing a text baseline to the parent grid's
190
+ align-items: baseline — an overflow:hidden block would synthesize
191
+ its baseline from the bottom edge and visibly sink the row. */
192
+ display: inline-flex;
193
+ align-items: baseline;
194
+ justify-content: flex-start;
195
+ position: relative;
196
+ min-width: 0;
197
+ overflow: hidden;
198
+ max-width: min(40vw, 24rem);
199
+ }
200
+
201
+ .s-status-bar-version__static {
202
+ /* The ghost state (marquee owns the window): opacity keeps the text
203
+ in the layout and the a11y tree; only the paint is handed over. */
204
+ min-width: 0;
205
+ }
206
+
207
+ .s-status-bar-version[data-overflowing] .s-status-bar-version__static {
208
+ opacity: 0;
209
+ }
210
+
211
+ /* The marquee overlay must measure and draw in the cell's own
212
+ typography — the placeholder defaults (base size, muted color, 1.5
213
+ line box) are input-context styling, overridden here. */
214
+ .s-status-bar-version .hk-placeholder-marquee {
215
+ font-size: inherit;
216
+ line-height: inherit;
217
+ color: inherit;
218
+ }
219
+
220
+ /* Compact-popover value cell: bounded so a long branch identity scrolls
221
+ inside the ~300px card instead of stretching it; the label keeps its
222
+ natural width on the left (marginRight:auto on the row). */
223
+ .s-status-bar-popover-value {
224
+ display: block;
225
+ min-width: 0;
226
+ overflow: hidden;
227
+ max-width: min(16rem, 60vw);
228
+ }
229
+
230
+ .s-status-bar-popover-value .s-status-bar-version {
231
+ max-width: 100%;
185
232
  }
186
233
 
187
234
  /* Compact traffic light (logged-in mobile footers): drop the version