@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
|
@@ -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
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
73
|
-
|
|
82
|
+
export interface HkVersionIdentity {
|
|
83
|
+
branch?: string;
|
|
84
|
+
commit: string;
|
|
74
85
|
}
|
|
75
86
|
|
|
76
|
-
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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 =
|
|
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 ? ` · ${
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|