@celestia-island/hikari 0.55.85 → 0.55.87
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 +1 -1
- package/src/animation/registerAnimations.ts +4 -0
- package/src/components/HkAdminHeader.test.tsx +19 -0
- package/src/components/HkAdminHeader.tsx +13 -0
- package/src/components/HkIconButton.tsx +6 -1
- package/src/components/HkIconButtonVars.scss +5 -0
- package/src/components/HkStatusBar.test.tsx +102 -1
- package/src/components/HkStatusBar.tsx +38 -4
- package/src/components/adminHeaderChrome.test.ts +79 -0
- package/src/styles/admin-tokens.scss +61 -3
package/package.json
CHANGED
|
@@ -66,6 +66,10 @@ export function isCssAnimationsEnabled(): boolean {
|
|
|
66
66
|
registerCssAnimation("s-nav-item-badge-pulse", { infinite: true });
|
|
67
67
|
// styles/admin-tokens.scss — auth card entrance (plays once).
|
|
68
68
|
registerCssAnimation("s-auth-card-in");
|
|
69
|
+
// styles/admin-tokens.scss — status-bar recovery flash (plays once):
|
|
70
|
+
// the traffic light's green background blink when a dropped connection
|
|
71
|
+
// comes back.
|
|
72
|
+
registerCssAnimation("s-status-bar-recover-flash");
|
|
69
73
|
// components/HkStatusPill.scss — live-dot pulse (loops forever).
|
|
70
74
|
registerCssAnimation("hk-status-pill-pulse", { infinite: true });
|
|
71
75
|
// components/HkRollingNumber.scss — digit roll-up (plays once, forwards).
|
|
@@ -247,4 +247,23 @@ describe("HkAdminHeader", () => {
|
|
|
247
247
|
const c = mount(headerNode({ username: "alice" }));
|
|
248
248
|
expect(c.textContent.toLowerCase()).not.toContain("emergency");
|
|
249
249
|
});
|
|
250
|
+
|
|
251
|
+
it("renders the page centre group between the identity and the actions when provided", () => {
|
|
252
|
+
const c = mount(headerNode({ title: "My tasks" }, {
|
|
253
|
+
center: () => h("nav", { class: "centre-stub" }, "filter group"),
|
|
254
|
+
}));
|
|
255
|
+
const centre = c.querySelector(".s-admin-header-center");
|
|
256
|
+
expect(centre, "the centre wrapper must render when the slot is provided").not.toBeNull();
|
|
257
|
+
expect(centre?.querySelector(".centre-stub")?.textContent).toBe("filter group");
|
|
258
|
+
// Wing order: identity (left) → centre → actions (right).
|
|
259
|
+
const header = c.querySelector("header")!;
|
|
260
|
+
const kids = [...header.children].map((el) => el.className);
|
|
261
|
+
expect(kids.indexOf("s-admin-header-user")).toBeLessThan(kids.indexOf("s-admin-header-center"));
|
|
262
|
+
expect(kids.indexOf("s-admin-header-center")).toBeLessThan(kids.indexOf("s-admin-header-actions"));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("renders no centre wrapper without the slot (DOM unchanged for hosts that skip it)", () => {
|
|
266
|
+
const c = mount(headerNode({ title: "Providers" }));
|
|
267
|
+
expect(c.querySelector(".s-admin-header-center")).toBeNull();
|
|
268
|
+
});
|
|
250
269
|
});
|
|
@@ -283,6 +283,19 @@ export const HkAdminHeader = defineComponent({
|
|
|
283
283
|
}}
|
|
284
284
|
</HkMenu>
|
|
285
285
|
|
|
286
|
+
{/* Page-owned centre group (page filter tab strips, segmented
|
|
287
|
+
* option pickers) — the console face of the balanced-wing
|
|
288
|
+
* header the chat frontend's top bar already uses: the left
|
|
289
|
+
* and right wings grow from the same flex basis so this group
|
|
290
|
+
* stays pinned to the true viewport middle. Rendered only when
|
|
291
|
+
* the host provides it, so headers without a centre keep the
|
|
292
|
+
* exact DOM they always had. */}
|
|
293
|
+
{slots.center && (
|
|
294
|
+
<div class="s-admin-header-center">
|
|
295
|
+
{slots.center()}
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
|
|
286
299
|
<div class="s-admin-header-actions">
|
|
287
300
|
{props.showEmergencyStop && (
|
|
288
301
|
<button
|
|
@@ -9,7 +9,12 @@ export default defineComponent({
|
|
|
9
9
|
props: {
|
|
10
10
|
icon: { type: String, default: "" },
|
|
11
11
|
variant: { type: String as PropType<"ghost" | "primary" | "secondary" | "danger" | "success">, default: "ghost" },
|
|
12
|
-
|
|
12
|
+
// 28 is the header chrome step: HkThemeToggle's trigger buttons are
|
|
13
|
+
// 28px boxes with 16px glyphs, so a header action that must read as
|
|
14
|
+
// "the same kind of button" (global search, the console pages'
|
|
15
|
+
// refresh) composes at 28 — 24 read visibly smaller beside the toggle
|
|
16
|
+
// (user report 2026-09-28: 前台全局搜索与主题切换按钮不同尺寸).
|
|
17
|
+
size: { type: Number as PropType<16 | 24 | 28 | 32 | 36 | 40>, default: 32 },
|
|
13
18
|
disabled: { type: Boolean, default: false },
|
|
14
19
|
},
|
|
15
20
|
emits: {
|
|
@@ -72,6 +72,11 @@
|
|
|
72
72
|
--hi-icon-button-icon-size: var(--hi-icon-size-xs);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
.hk-icon-button-28 {
|
|
76
|
+
--hi-icon-button-size: 28px;
|
|
77
|
+
--hi-icon-button-icon-size: var(--hi-icon-size-xs);
|
|
78
|
+
}
|
|
79
|
+
|
|
75
80
|
.hk-icon-button-32 {
|
|
76
81
|
--hi-icon-button-size: 32px;
|
|
77
82
|
--hi-icon-button-icon-size: var(--hi-icon-size-sm);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { createApp, h, nextTick } from "vue";
|
|
2
|
+
import { createApp, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import { HkStatusBar } from "./HkStatusBar";
|
|
5
5
|
import type { HkConnectionInfo } from "./HkConnectionInfo";
|
|
@@ -15,6 +15,31 @@ function mountBar(props: Record<string, unknown> = {}) {
|
|
|
15
15
|
return container;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Mount with a reactive connectionStatus the test can drive through
|
|
19
|
+
* state transitions (the fixed-props mountBar cannot re-render). */
|
|
20
|
+
type BarStatus = "connected" | "disconnected" | "connecting" | "reconnecting";
|
|
21
|
+
|
|
22
|
+
function mountReactiveStatus(initial: BarStatus) {
|
|
23
|
+
const status = ref(initial);
|
|
24
|
+
const container = document.createElement("div");
|
|
25
|
+
document.body.appendChild(container);
|
|
26
|
+
const app = createApp({
|
|
27
|
+
render: () =>
|
|
28
|
+
h(HkStatusBar, {
|
|
29
|
+
version: "1.2.3",
|
|
30
|
+
connectionStatus: status.value,
|
|
31
|
+
connectionInfo: INFO,
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
app.mount(container);
|
|
35
|
+
mounts.push({ app, container });
|
|
36
|
+
return { container, status };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function tagOf(container: HTMLElement) {
|
|
40
|
+
return container.querySelector<HTMLElement>(".s-status-bar-tag")!;
|
|
41
|
+
}
|
|
42
|
+
|
|
18
43
|
const INFO: HkConnectionInfo = {
|
|
19
44
|
state: "connected",
|
|
20
45
|
tier: "poll",
|
|
@@ -221,4 +246,80 @@ describe("HkStatusBar", () => {
|
|
|
221
246
|
expect(onRetry, "flick is not a tap").not.toHaveBeenCalled();
|
|
222
247
|
expect(document.body.querySelector(".hk-popover-panel")).toBeNull();
|
|
223
248
|
});
|
|
249
|
+
|
|
250
|
+
it("stays quiet on the first connect: no recovery class on open", async () => {
|
|
251
|
+
vi.useFakeTimers();
|
|
252
|
+
try {
|
|
253
|
+
// The page-open sequence: connecting → connected for the first
|
|
254
|
+
// time in this component's life. No flash — the pill goes green
|
|
255
|
+
// without announcing itself (hosts used to toast here).
|
|
256
|
+
const { container, status } = mountReactiveStatus("connecting");
|
|
257
|
+
await nextTick();
|
|
258
|
+
expect(tagOf(container).className).toContain("s-status-bar-tag-reconnecting");
|
|
259
|
+
status.value = "connected";
|
|
260
|
+
await nextTick();
|
|
261
|
+
expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
|
|
262
|
+
vi.advanceTimersByTime(1500);
|
|
263
|
+
await nextTick();
|
|
264
|
+
expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
|
|
265
|
+
} finally {
|
|
266
|
+
vi.useRealTimers();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("flashes the background once when a dropped connection recovers", async () => {
|
|
271
|
+
vi.useFakeTimers();
|
|
272
|
+
try {
|
|
273
|
+
// Mounted while connected (established), drops, then recovers:
|
|
274
|
+
// the recovering class rides the pill for the flash window and
|
|
275
|
+
// is gone after it.
|
|
276
|
+
const { container, status } = mountReactiveStatus("connected");
|
|
277
|
+
await nextTick();
|
|
278
|
+
status.value = "reconnecting";
|
|
279
|
+
await nextTick();
|
|
280
|
+
expect(tagOf(container).className).toContain("s-status-bar-tag-reconnecting");
|
|
281
|
+
status.value = "connected";
|
|
282
|
+
await nextTick();
|
|
283
|
+
expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
|
|
284
|
+
// Mid-window (t=600ms): the class must still ride the pill — the JS
|
|
285
|
+
// window (1200ms) has to outlive the 1.1s CSS animation, or the class
|
|
286
|
+
// would truncate the decay's tail and snap to the resting color.
|
|
287
|
+
vi.advanceTimersByTime(600);
|
|
288
|
+
await nextTick();
|
|
289
|
+
expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
|
|
290
|
+
vi.advanceTimersByTime(700);
|
|
291
|
+
await nextTick();
|
|
292
|
+
expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
|
|
293
|
+
} finally {
|
|
294
|
+
vi.useRealTimers();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("re-arms the flash on a later drop/recover cycle after the window closes", async () => {
|
|
299
|
+
vi.useFakeTimers();
|
|
300
|
+
try {
|
|
301
|
+
const { container, status } = mountReactiveStatus("connected");
|
|
302
|
+
await nextTick();
|
|
303
|
+
status.value = "disconnected";
|
|
304
|
+
await nextTick();
|
|
305
|
+
status.value = "connected";
|
|
306
|
+
await nextTick();
|
|
307
|
+
expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
|
|
308
|
+
vi.advanceTimersByTime(1300);
|
|
309
|
+
await nextTick();
|
|
310
|
+
expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
|
|
311
|
+
// Second cycle AFTER the window closed: the class must come back —
|
|
312
|
+
// a one-shot sticky flag or a dead timer would leave it dark.
|
|
313
|
+
status.value = "reconnecting";
|
|
314
|
+
await nextTick();
|
|
315
|
+
status.value = "connected";
|
|
316
|
+
await nextTick();
|
|
317
|
+
expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
|
|
318
|
+
vi.advanceTimersByTime(1300);
|
|
319
|
+
await nextTick();
|
|
320
|
+
expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
|
|
321
|
+
} finally {
|
|
322
|
+
vi.useRealTimers();
|
|
323
|
+
}
|
|
324
|
+
});
|
|
224
325
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineComponent, ref, type PropType } from "vue";
|
|
1
|
+
import { defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
|
|
2
2
|
import { Wifi, WifiOff, Globe, Cable, Monitor, Cog, Plug } from "lucide-vue-next";
|
|
3
3
|
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
@@ -133,6 +133,38 @@ export const HkStatusBar = defineComponent({
|
|
|
133
133
|
disconnected: "rgb(var(--color-error))",
|
|
134
134
|
};
|
|
135
135
|
|
|
136
|
+
// ── Recovery flash ─────────────────────────────────────────────
|
|
137
|
+
// One soft green background blink when the light RETURNS to green
|
|
138
|
+
// after a drop (reconnecting/connecting/disconnected → connected).
|
|
139
|
+
// The FIRST connect of the component's life stays quiet: the flash
|
|
140
|
+
// acknowledges a connection the user saw go down, it is not an
|
|
141
|
+
// open-time announcement (hosts used to fire a toast for exactly
|
|
142
|
+
// this moment; the light now owns that feedback, quietly).
|
|
143
|
+
// `seenConnected` seeds from the initial prop so a component that
|
|
144
|
+
// mounts while already connected still counts as established and a
|
|
145
|
+
// LATER drop/recover pair flashes.
|
|
146
|
+
const RECOVER_FLASH_MS = 1200;
|
|
147
|
+
const seenConnected = ref(props.connectionStatus === "connected");
|
|
148
|
+
const recovering = ref(false);
|
|
149
|
+
let recoverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
150
|
+
|
|
151
|
+
watch(() => props.connectionStatus, (next) => {
|
|
152
|
+
if (next !== "connected") return;
|
|
153
|
+
const wasEstablished = seenConnected.value;
|
|
154
|
+
seenConnected.value = true;
|
|
155
|
+
if (!wasEstablished) return;
|
|
156
|
+
if (recoverTimer) clearTimeout(recoverTimer);
|
|
157
|
+
recovering.value = true;
|
|
158
|
+
recoverTimer = setTimeout(() => {
|
|
159
|
+
recovering.value = false;
|
|
160
|
+
recoverTimer = null;
|
|
161
|
+
}, RECOVER_FLASH_MS);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
onBeforeUnmount(() => {
|
|
165
|
+
if (recoverTimer) clearTimeout(recoverTimer);
|
|
166
|
+
});
|
|
167
|
+
|
|
136
168
|
function onTagEnter() {
|
|
137
169
|
if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
|
|
138
170
|
popupOpen.value = true;
|
|
@@ -244,9 +276,11 @@ export const HkStatusBar = defineComponent({
|
|
|
244
276
|
// + value column), so both rows share one true left edge — no
|
|
245
277
|
// separator, no mid-token wrapping, at any footer width.
|
|
246
278
|
|
|
247
|
-
const tagClass =
|
|
248
|
-
|
|
249
|
-
|
|
279
|
+
const tagClass = [
|
|
280
|
+
"s-status-bar-tag",
|
|
281
|
+
connecting ? "s-status-bar-tag-reconnecting" : "",
|
|
282
|
+
recovering.value ? "s-status-bar-tag-recovered" : "",
|
|
283
|
+
].filter(Boolean).join(" ");
|
|
250
284
|
|
|
251
285
|
const inner = (
|
|
252
286
|
<>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Static pins for the admin header's chrome geometry (2026-09-28 user
|
|
8
|
+
* report, R1 findings b/c: reverting either the avatar→title gap or the
|
|
9
|
+
* 28px icon-button box kept the whole suite green — the two values the
|
|
10
|
+
* user explicitly asked for had no red-if-broken evidence). happy-dom
|
|
11
|
+
* has no layout engine, so — headerAvatarGeometry-style — these read the
|
|
12
|
+
* declarations themselves; they fail when the values move, disappear or
|
|
13
|
+
* stop reading the way they read here, and a deliberate rewrite is
|
|
14
|
+
* expected to update them.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
/** Declarations of the rule whose prelude IS `selector` (whole-prelude
|
|
20
|
+
* match, comments stripped), as [prop, value] pairs; null when absent. */
|
|
21
|
+
function ruleDeclarations(source: string, selector: string): Array<[string, string]> | null {
|
|
22
|
+
const code = source.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
23
|
+
for (let at = code.indexOf(selector); at >= 0; at = code.indexOf(selector, at + 1)) {
|
|
24
|
+
const open = code.indexOf("{", at + selector.length);
|
|
25
|
+
if (open < 0) continue;
|
|
26
|
+
if (code.slice(at + selector.length, open).trim() !== "") continue;
|
|
27
|
+
let boundary = at - 1;
|
|
28
|
+
while (boundary >= 0 && !"}{;".includes(code[boundary])) boundary -= 1;
|
|
29
|
+
if (code.slice(boundary + 1, at).replace(/\s+/g, " ").trim() !== "") continue;
|
|
30
|
+
const body = code.slice(open + 1, code.indexOf("}", open));
|
|
31
|
+
const out: Array<[string, string]> = [];
|
|
32
|
+
for (const part of body.split(";")) {
|
|
33
|
+
const m = /^\s*([-a-zA-Z]+)\s*:\s*([\s\S]+?)\s*$/.exec(part);
|
|
34
|
+
if (m) out.push([m[1], m[2].replace(/\s+/g, " ")]);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const adminTokens = readFileSync(join(here, "..", "styles", "admin-tokens.scss"), "utf8");
|
|
42
|
+
const iconButtonVars = readFileSync(join(here, "HkIconButtonVars.scss"), "utf8");
|
|
43
|
+
const iconButton = readFileSync(join(here, "HkIconButton.tsx"), "utf8");
|
|
44
|
+
|
|
45
|
+
describe("admin header chrome geometry (the two user-requested fixes)", () => {
|
|
46
|
+
it("keeps the console title at the panel's visual distance from the avatar (16px wing gap)", () => {
|
|
47
|
+
// The frontend's workspace button sits a flex gap PLUS its own 8px
|
|
48
|
+
// inline padding from the avatar (16px visual); the console title
|
|
49
|
+
// carried only the 8px flex gap and visibly hugged the avatar.
|
|
50
|
+
const rule = ruleDeclarations(adminTokens, ".s-admin-header-user");
|
|
51
|
+
expect(rule, ".s-admin-header-user must be declared").not.toBeNull();
|
|
52
|
+
expect(rule).toContainEqual(["gap", "var(--space-16)"]);
|
|
53
|
+
// Balanced left wing (the centre group stays viewport-centred).
|
|
54
|
+
expect(rule).toContainEqual(["flex", "1 1 0"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("keeps the actions wing balanced (no margin-left:auto push) and the centre wing shrinkable", () => {
|
|
58
|
+
const actions = ruleDeclarations(adminTokens, ".s-admin-header-actions");
|
|
59
|
+
expect(actions, ".s-admin-header-actions must be declared").not.toBeNull();
|
|
60
|
+
expect(actions).toContainEqual(["flex", "1 1 0"]);
|
|
61
|
+
expect(actions).toContainEqual(["justify-content", "flex-end"]);
|
|
62
|
+
// The old push would fight the centre wing for the middle.
|
|
63
|
+
expect(actions).not.toContainEqual(["margin-left", "auto"]);
|
|
64
|
+
const centre = ruleDeclarations(adminTokens, ".s-admin-header-center");
|
|
65
|
+
expect(centre, ".s-admin-header-center must be declared").not.toBeNull();
|
|
66
|
+
expect(centre).toContainEqual(["flex", "0 1 auto"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps the 28px icon-button step at the theme toggle's own box", () => {
|
|
70
|
+
// HkThemeToggle's trigger buttons are 1.75rem (28px) boxes with 16px
|
|
71
|
+
// glyphs — the size any header action that must read as the same
|
|
72
|
+
// kind of button composes at.
|
|
73
|
+
const rule = ruleDeclarations(iconButtonVars, ".hk-icon-button-28");
|
|
74
|
+
expect(rule, ".hk-icon-button-28 must be declared").not.toBeNull();
|
|
75
|
+
expect(rule).toContainEqual(["--hi-icon-button-size", "28px"]);
|
|
76
|
+
// The component accepts the step.
|
|
77
|
+
expect(iconButton).toMatch(/PropType<16 \| 24 \| 28 \| 32 \| 36 \| 40>/);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -225,6 +225,37 @@
|
|
|
225
225
|
transition: background var(--duration-short), width var(--duration-short) var(--ease-standard);
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/* One soft green blink when the light recovers: the pill background
|
|
229
|
+
pulses from a success tint down to the resting surface colour
|
|
230
|
+
exactly once, then hands the property back. The transient peak sits
|
|
231
|
+
slightly above the reconnecting tint's resting intensity so both
|
|
232
|
+
states read as one family; first connects never get it (see
|
|
233
|
+
HkStatusBar's seenConnected guard). Reduced-motion users get the
|
|
234
|
+
plain state switch instead. */
|
|
235
|
+
.s-status-bar-tag-recovered {
|
|
236
|
+
animation: s-status-bar-recover-flash 1.1s var(--ease-standard) 1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
@keyframes s-status-bar-recover-flash {
|
|
240
|
+
0% {
|
|
241
|
+
background: rgb(var(--color-success) / 22%);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
55% {
|
|
245
|
+
background: rgb(var(--color-success) / 10%);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
100% {
|
|
249
|
+
background: rgb(var(--color-surface));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@media (prefers-reduced-motion: reduce) {
|
|
254
|
+
.s-status-bar-tag-recovered {
|
|
255
|
+
animation: none;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
228
259
|
.s-status-bar-retry {
|
|
229
260
|
display: inline-flex;
|
|
230
261
|
align-items: center;
|
|
@@ -347,7 +378,28 @@
|
|
|
347
378
|
.s-admin-header-user {
|
|
348
379
|
display: flex;
|
|
349
380
|
align-items: center;
|
|
350
|
-
|
|
381
|
+
/* Avatar → title distance mirrors the chat frontend's header left
|
|
382
|
+
* wing: the panel's workspace button sits a flex gap PLUS its own
|
|
383
|
+
* 8px inline padding away from the avatar (16px visual), while the
|
|
384
|
+
* console title had only the flex gap — the console title visibly
|
|
385
|
+
* hugged the avatar (user report 2026-09-28: 后台标题离头像太近).
|
|
386
|
+
* The wing gap here carries the whole distance so the title text
|
|
387
|
+
* lands on the same rhythm as the panel's button content. */
|
|
388
|
+
gap: var(--space-16);
|
|
389
|
+
/* Balanced left wing (same grammar as ChatLayout's .s-header-bar-left):
|
|
390
|
+
* grows from the same flex basis as the actions wing so a centre
|
|
391
|
+
* group, when a page renders one, stays pinned to the true middle. */
|
|
392
|
+
flex: 1 1 0;
|
|
393
|
+
min-width: 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* The page-owned centre group (filter tab strips): keeps its natural
|
|
397
|
+
* width and hands overflow to the HTabs scroller inside it. */
|
|
398
|
+
.s-admin-header-center {
|
|
399
|
+
flex: 0 1 auto;
|
|
400
|
+
display: flex;
|
|
401
|
+
align-items: center;
|
|
402
|
+
justify-content: center;
|
|
351
403
|
min-width: 0;
|
|
352
404
|
}
|
|
353
405
|
|
|
@@ -399,8 +451,14 @@
|
|
|
399
451
|
display: flex;
|
|
400
452
|
align-items: center;
|
|
401
453
|
gap: var(--space-6);
|
|
402
|
-
|
|
403
|
-
|
|
454
|
+
/* Balanced right wing: with the user wing at flex 1 1 0 the pair
|
|
455
|
+
* grows symmetrically, so the centre group stays centred and the
|
|
456
|
+
* actions still hug the right edge (justify-end replaces the old
|
|
457
|
+
* margin-left:auto push, which would fight the centre wing). The
|
|
458
|
+
* automatic min-content floor keeps the icon buttons from being
|
|
459
|
+
* squeezed below their own width on narrow viewports. */
|
|
460
|
+
flex: 1 1 0;
|
|
461
|
+
justify-content: flex-end;
|
|
404
462
|
}
|
|
405
463
|
|
|
406
464
|
.s-admin-header-action {
|