@celestia-island/hikari 0.55.78 → 0.55.80
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/components/HkAdminTablePage.test.tsx +93 -0
- package/src/components/HkAdminTablePage.tsx +35 -4
- package/src/components/HkBoard.tsx +6 -0
- package/src/components/HkCardList.scss +68 -0
- package/src/components/HkCardList.test.tsx +141 -0
- package/src/components/HkCardList.tsx +113 -0
- package/src/components/HkIconChip.scss +27 -0
- package/src/components/HkIconChip.test.tsx +56 -0
- package/src/components/HkIconChip.tsx +42 -0
- package/src/components/HkMinimap.tsx +3 -0
- package/src/components/HkNodeCanvas.tsx +30 -21
- package/src/components/HkPageHeader.scss +19 -0
- package/src/components/HkPageHeader.test.ts +17 -0
- package/src/components/HkPageHeader.tsx +2 -0
- package/src/components/HkSectionHeader.scss +81 -0
- package/src/components/HkSectionHeader.test.tsx +70 -0
- package/src/components/HkSectionHeader.tsx +76 -0
- package/src/components/HkStatCard.scss +10 -0
- package/src/components/HkStatCard.tsx +6 -0
- package/src/components/HkStatCard.variants.test.tsx +32 -0
- package/src/components/HkSvgVarPresentation.test.tsx +179 -0
- package/src/index.ts +6 -0
|
@@ -1293,27 +1293,35 @@ export default defineComponent({
|
|
|
1293
1293
|
data-edge-id={edge.id}
|
|
1294
1294
|
data-edge-type={edge.type}
|
|
1295
1295
|
>
|
|
1296
|
-
{props.interactiveEdges && strokes.map((sp, i) =>
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1296
|
+
{props.interactiveEdges && strokes.map((sp, i) => {
|
|
1297
|
+
const edgeInk = edge.color ?? "var(--hk-border-3, #555)";
|
|
1298
|
+
return (
|
|
1299
|
+
<path
|
|
1300
|
+
key={`halo-${i}`}
|
|
1301
|
+
class="hk-node-canvas-edge-halo"
|
|
1302
|
+
d={sp.d}
|
|
1303
|
+
fill="none"
|
|
1304
|
+
stroke={edgeInk}
|
|
1305
|
+
style={{ stroke: edgeInk }}
|
|
1306
|
+
stroke-width={sp.width * 3}
|
|
1307
|
+
stroke-dasharray={sp.dashed ? "6 4" : undefined}
|
|
1308
|
+
/>
|
|
1309
|
+
);
|
|
1310
|
+
})}
|
|
1311
|
+
{strokes.map((sp, i) => {
|
|
1312
|
+
const edgeInk = edge.color ?? "var(--hk-border-3, #555)";
|
|
1313
|
+
return (
|
|
1314
|
+
<path
|
|
1315
|
+
key={`ink-${i}`}
|
|
1316
|
+
d={sp.d}
|
|
1317
|
+
fill="none"
|
|
1318
|
+
stroke={edgeInk}
|
|
1319
|
+
style={{ stroke: edgeInk }}
|
|
1320
|
+
stroke-width={sp.width}
|
|
1321
|
+
stroke-dasharray={sp.dashed ? "6 4" : undefined}
|
|
1322
|
+
/>
|
|
1323
|
+
);
|
|
1324
|
+
})}
|
|
1317
1325
|
{edge.label && (
|
|
1318
1326
|
<text
|
|
1319
1327
|
x={mid.x}
|
|
@@ -1321,6 +1329,7 @@ export default defineComponent({
|
|
|
1321
1329
|
text-anchor="middle"
|
|
1322
1330
|
font-size="11"
|
|
1323
1331
|
fill="var(--hk-text-2, #aaa)"
|
|
1332
|
+
style={{ fill: "var(--hk-text-2, #aaa)" }}
|
|
1324
1333
|
>
|
|
1325
1334
|
{edge.label}
|
|
1326
1335
|
</text>
|
|
@@ -78,3 +78,22 @@
|
|
|
78
78
|
height: var(--space-24, 1.5rem);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
/* lead — a back affordance (or any leading control) riding before the
|
|
83
|
+
* title inside the h1 box, keeping the heading semantics intact. */
|
|
84
|
+
.hk-page-header-lead {
|
|
85
|
+
display: inline-flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
flex-shrink: 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* titleExtra — inline chips (status badges, mono ids) after the title
|
|
91
|
+
* text; baseline-aligned with the title, wrapping with it. */
|
|
92
|
+
.hk-page-header-title-extra {
|
|
93
|
+
display: inline-flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
gap: var(--space-8, 0.5rem);
|
|
96
|
+
font-size: var(--text-xs, 0.75rem);
|
|
97
|
+
font-weight: 400;
|
|
98
|
+
min-width: 0;
|
|
99
|
+
}
|
|
@@ -64,4 +64,21 @@ describe("HkPageHeader", () => {
|
|
|
64
64
|
"hk-page-header-dense",
|
|
65
65
|
);
|
|
66
66
|
});
|
|
67
|
+
it("renders the lead slot before the title icon", () => {
|
|
68
|
+
const c = mountHeader({ title: "T" }, { lead: () => h("button", { class: "probe-back" }, "Back") });
|
|
69
|
+
const title = c.querySelector(".hk-page-header-title")!;
|
|
70
|
+
expect(title.querySelector(".hk-page-header-lead .probe-back")).toBeTruthy();
|
|
71
|
+
const lead = c.querySelector(".hk-page-header-lead")!;
|
|
72
|
+
const icon = c.querySelector(".hk-page-header-icon");
|
|
73
|
+
if (icon) {
|
|
74
|
+
expect(lead.compareDocumentPosition(icon) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders titleExtra inline after the title text", () => {
|
|
79
|
+
const c = mountHeader({ title: "T" }, { titleExtra: () => h("span", { class: "probe-chip" }, "stable") });
|
|
80
|
+
const extra = c.querySelector(".hk-page-header-title-text + .hk-page-header-title-extra") ??
|
|
81
|
+
c.querySelector(".hk-page-header-title-extra");
|
|
82
|
+
expect(extra?.textContent).toContain("stable");
|
|
83
|
+
});
|
|
67
84
|
});
|
|
@@ -31,12 +31,14 @@ export const HkPageHeader = defineComponent({
|
|
|
31
31
|
<div class={["hk-page-header", props.dense && "hk-page-header-dense"]}>
|
|
32
32
|
<div class="hk-page-header-main">
|
|
33
33
|
<h1 class="hk-page-header-title">
|
|
34
|
+
{slots.lead ? <span class="hk-page-header-lead">{slots.lead()}</span> : null}
|
|
34
35
|
{slots.icon
|
|
35
36
|
? <span class="hk-page-header-icon">{slots.icon()}</span>
|
|
36
37
|
: props.icon
|
|
37
38
|
? <span class="hk-page-header-icon">{h(props.icon)}</span>
|
|
38
39
|
: null}
|
|
39
40
|
<span class="hk-page-header-title-text">{props.title}</span>
|
|
41
|
+
{slots.titleExtra ? <span class="hk-page-header-title-extra">{slots.titleExtra()}</span> : null}
|
|
40
42
|
</h1>
|
|
41
43
|
{props.subtitle && (
|
|
42
44
|
<p class="hk-page-header-subtitle">{props.subtitle}</p>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* HkSectionHeader — the standard in-card / in-page section header. */
|
|
2
|
+
|
|
3
|
+
.hk-section-header {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: var(--space-4, 0.25rem);
|
|
7
|
+
min-width: 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.hk-section-header-row {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: var(--space-8, 0.5rem);
|
|
14
|
+
min-width: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.hk-section-header-icon {
|
|
18
|
+
display: inline-flex;
|
|
19
|
+
color: rgb(var(--color-muted));
|
|
20
|
+
flex-shrink: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.hk-section-header-title {
|
|
24
|
+
margin: 0;
|
|
25
|
+
font-size: var(--text-sm, 0.875rem);
|
|
26
|
+
font-weight: 600;
|
|
27
|
+
color: rgb(var(--color-text));
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: baseline;
|
|
30
|
+
gap: var(--space-8, 0.5rem);
|
|
31
|
+
min-width: 0;
|
|
32
|
+
overflow-wrap: anywhere;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.hk-section-header-count {
|
|
36
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
37
|
+
font-weight: 400;
|
|
38
|
+
color: rgb(var(--color-muted));
|
|
39
|
+
white-space: nowrap;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.hk-section-header-actions {
|
|
43
|
+
margin-left: auto;
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: var(--space-8, 0.5rem);
|
|
47
|
+
flex-shrink: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.hk-section-header-description {
|
|
51
|
+
margin: 0;
|
|
52
|
+
font-size: var(--text-xs, 0.75rem);
|
|
53
|
+
color: rgb(var(--color-muted));
|
|
54
|
+
line-height: 1.4;
|
|
55
|
+
overflow-wrap: anywhere;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ── dense: tighter rhythm for dense panels ─────────────────────────── */
|
|
59
|
+
.hk-section-header-dense {
|
|
60
|
+
gap: var(--space-2, 0.125rem);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ── micro: tiny uppercase eyebrow label ────────────────────────────── */
|
|
64
|
+
.hk-section-header-micro .hk-section-header-title {
|
|
65
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
66
|
+
font-weight: 600;
|
|
67
|
+
text-transform: uppercase;
|
|
68
|
+
letter-spacing: 0.05em;
|
|
69
|
+
color: rgb(var(--color-muted));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ── mono: mono-caption idiom for ledgers/detail rails ──────────────── */
|
|
73
|
+
.hk-section-header-mono .hk-section-header-title {
|
|
74
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
75
|
+
font-size: var(--text-xs, 0.75rem);
|
|
76
|
+
font-weight: 500;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.hk-section-header-mono .hk-section-header-count {
|
|
80
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
81
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkSectionHeader } from "./HkSectionHeader";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(node: ReturnType<typeof h>) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => node });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const FakeIcon = defineComponent({
|
|
25
|
+
name: "FakeIcon",
|
|
26
|
+
props: { size: { type: [Number, String], default: 24 } },
|
|
27
|
+
setup: (props) => () => h("svg", { "data-fake-icon": "", width: props.size, height: props.size }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("HkSectionHeader", () => {
|
|
31
|
+
it("renders an h3 title by default with the count beside it", () => {
|
|
32
|
+
const c = mount(h(HkSectionHeader, { title: "Token Usage", count: "12 total" }));
|
|
33
|
+
const title = c.querySelector("h3.hk-section-header-title") as HTMLElement;
|
|
34
|
+
expect(title).toBeTruthy();
|
|
35
|
+
expect(title.textContent).toContain("Token Usage");
|
|
36
|
+
expect(c.querySelector(".hk-section-header-count")?.textContent).toBe("12 total");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("honors the level prop for the document outline", () => {
|
|
40
|
+
const c = mount(h(HkSectionHeader, { title: "T", level: "h2" }));
|
|
41
|
+
expect(c.querySelector("h2.hk-section-header-title")).toBeTruthy();
|
|
42
|
+
const c2 = mount(h(HkSectionHeader, { title: "T", level: "div" }));
|
|
43
|
+
expect(c2.querySelector("h2, h3")).toBeNull();
|
|
44
|
+
expect(c2.querySelector(".hk-section-header-title")).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders description under the row and actions right-aligned in the row", () => {
|
|
48
|
+
const c = mount(
|
|
49
|
+
h(HkSectionHeader, { title: "T", description: "one-liner" }, {
|
|
50
|
+
actions: () => h("button", { class: "probe-action" }, "Export"),
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
expect(c.querySelector(".hk-section-header-description")?.textContent).toBe("one-liner");
|
|
54
|
+
expect(c.querySelector(".hk-section-header-row .hk-section-header-actions .probe-action")).toBeTruthy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders the leading icon inside the row", () => {
|
|
58
|
+
const c = mount(h(HkSectionHeader, { title: "T", icon: FakeIcon }));
|
|
59
|
+
expect(c.querySelector(".hk-section-header-row .hk-section-header-icon svg[data-fake-icon]")).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("applies variant and dense classes", () => {
|
|
63
|
+
const c = mount(h(HkSectionHeader, { title: "T", variant: "micro", dense: true }));
|
|
64
|
+
const root = c.querySelector(".hk-section-header") as HTMLElement;
|
|
65
|
+
expect(root.className).toContain("hk-section-header-micro");
|
|
66
|
+
expect(root.className).toContain("hk-section-header-dense");
|
|
67
|
+
const c2 = mount(h(HkSectionHeader, { title: "T", variant: "mono" }));
|
|
68
|
+
expect((c2.querySelector(".hk-section-header") as HTMLElement).className).toContain("hk-section-header-mono");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { defineComponent, h, type Component, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import "./HkSectionHeader.scss";
|
|
4
|
+
|
|
5
|
+
export type SectionHeaderVariant = "default" | "micro" | "mono";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HkSectionHeader — the standard in-card / in-page section header.
|
|
9
|
+
*
|
|
10
|
+
* The audit that introduced this counted ~89 distinct hand-rolled
|
|
11
|
+
* treatments for the same anatomy across the consumers: a small
|
|
12
|
+
* semibold heading, an optional one-line description under (or beside)
|
|
13
|
+
* it, an optional right-aligned actions cluster, and occasionally a
|
|
14
|
+
* leading icon or a trailing count badge. One component replaces the
|
|
15
|
+
* family.
|
|
16
|
+
*
|
|
17
|
+
* Variants:
|
|
18
|
+
* - `default` — `text-sm font-semibold` heading (the dominant idiom).
|
|
19
|
+
* - `micro` — tiny uppercase eyebrow label for dense panels.
|
|
20
|
+
* - `mono` — mono-caption idiom used by ledgers/detail rails.
|
|
21
|
+
*
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <HkSectionHeader title="Token Usage" description="Per model this range">
|
|
24
|
+
* {{ actions: () => <HkButton size="sm">Export</HkButton> }}
|
|
25
|
+
* </HkSectionHeader>
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export const HkSectionHeader = defineComponent({
|
|
29
|
+
name: "HkSectionHeader",
|
|
30
|
+
props: {
|
|
31
|
+
title: { type: String, required: true },
|
|
32
|
+
/** Heading element: h2/h3 keep document outline, div for visual-only. */
|
|
33
|
+
level: { type: String as PropType<"h2" | "h3" | "div">, default: "h3" },
|
|
34
|
+
/** One-line description rendered under the heading. */
|
|
35
|
+
description: { type: String, default: undefined },
|
|
36
|
+
/** Leading icon component. */
|
|
37
|
+
icon: { type: Object as PropType<Component>, default: undefined },
|
|
38
|
+
/** Trailing count badge (e.g. "12 total") rendered beside the title. */
|
|
39
|
+
count: { type: String, default: undefined },
|
|
40
|
+
variant: { type: String as PropType<SectionHeaderVariant>, default: "default" },
|
|
41
|
+
/** Tighter spacing for dense panels. */
|
|
42
|
+
dense: { type: Boolean, default: false },
|
|
43
|
+
},
|
|
44
|
+
setup(props, { slots }) {
|
|
45
|
+
return () => {
|
|
46
|
+
const heading = h(
|
|
47
|
+
props.level,
|
|
48
|
+
{ class: "hk-section-header-title" },
|
|
49
|
+
[
|
|
50
|
+
props.title,
|
|
51
|
+
props.count != null ? <span class="hk-section-header-count">{props.count}</span> : null,
|
|
52
|
+
],
|
|
53
|
+
);
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
class={[
|
|
57
|
+
"hk-section-header",
|
|
58
|
+
`hk-section-header-${props.variant}`,
|
|
59
|
+
props.dense && "hk-section-header-dense",
|
|
60
|
+
]}
|
|
61
|
+
>
|
|
62
|
+
<div class="hk-section-header-row">
|
|
63
|
+
{props.icon ? <span class="hk-section-header-icon">{h(props.icon, { size: 15 })}</span> : null}
|
|
64
|
+
{heading}
|
|
65
|
+
{slots.actions ? <div class="hk-section-header-actions">{slots.actions()}</div> : null}
|
|
66
|
+
</div>
|
|
67
|
+
{props.description ? (
|
|
68
|
+
<p class="hk-section-header-description">{props.description}</p>
|
|
69
|
+
) : slots.description ? (
|
|
70
|
+
<p class="hk-section-header-description">{slots.description()}</p>
|
|
71
|
+
) : null}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -187,3 +187,13 @@
|
|
|
187
187
|
.hk-stat-card-bar-detail {
|
|
188
188
|
max-width: 100%;
|
|
189
189
|
}
|
|
190
|
+
|
|
191
|
+
/* footer slot — free-form content row under the value/hint (badge rows,
|
|
192
|
+
* deltas, sparklines). Wraps; does not collapse like the hint. */
|
|
193
|
+
.hk-stat-card-footer {
|
|
194
|
+
display: flex;
|
|
195
|
+
flex-wrap: wrap;
|
|
196
|
+
align-items: center;
|
|
197
|
+
gap: var(--space-8, 0.5rem);
|
|
198
|
+
max-width: 100%;
|
|
199
|
+
}
|
|
@@ -15,6 +15,8 @@ export type StatTone = "success" | "warning" | "error" | "info" | "primary" | "m
|
|
|
15
15
|
* hint (the original anatomy, still the default).
|
|
16
16
|
* - `chip` — horizontal: a tone-tinted icon chip beside value + label,
|
|
17
17
|
* with an optional trailing `aside` slot (tier badge, delta, …).
|
|
18
|
+
* Every variant also takes a `footer` slot — a free-form content row
|
|
19
|
+
* under the value/hint for badge rows and deltas.
|
|
18
20
|
* - `ring` — a single utilization ring with the value inside, the label
|
|
19
21
|
* (with the icon) underneath, and the hint as the detail line.
|
|
20
22
|
* - `bar` — label + value on one row over a tone-colored progress bar,
|
|
@@ -126,6 +128,7 @@ export const HkStatCard = defineComponent({
|
|
|
126
128
|
{slots.aside && <div class="hk-stat-card-aside">{slots.aside()}</div>}
|
|
127
129
|
</div>
|
|
128
130
|
{props.hint && <div class="hk-stat-card-hint">{props.hint}</div>}
|
|
131
|
+
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
129
132
|
</div>
|
|
130
133
|
);
|
|
131
134
|
}
|
|
@@ -157,6 +160,7 @@ export const HkStatCard = defineComponent({
|
|
|
157
160
|
{props.hint}
|
|
158
161
|
</div>
|
|
159
162
|
)}
|
|
163
|
+
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
160
164
|
</div>
|
|
161
165
|
);
|
|
162
166
|
}
|
|
@@ -178,6 +182,7 @@ export const HkStatCard = defineComponent({
|
|
|
178
182
|
{props.hint}
|
|
179
183
|
</div>
|
|
180
184
|
)}
|
|
185
|
+
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
181
186
|
</div>
|
|
182
187
|
);
|
|
183
188
|
}
|
|
@@ -191,6 +196,7 @@ export const HkStatCard = defineComponent({
|
|
|
191
196
|
</div>
|
|
192
197
|
<div class="hk-stat-card-value">{props.value}</div>
|
|
193
198
|
{props.hint && <div class="hk-stat-card-hint" title={props.hint}>{props.hint}</div>}
|
|
199
|
+
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
194
200
|
</div>
|
|
195
201
|
);
|
|
196
202
|
};
|
|
@@ -196,6 +196,38 @@ describe("HkStatCard bar", () => {
|
|
|
196
196
|
});
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
describe("HkStatCard footer slot (all variants)", () => {
|
|
200
|
+
it("renders the footer content row under the value on plain", () => {
|
|
201
|
+
const c = mount(
|
|
202
|
+
h(HkStatCard, { label: "Total", value: "1.2M" }, { footer: () => h("span", { class: "probe-badge" }, "3 dims") }),
|
|
203
|
+
);
|
|
204
|
+
expect(c.querySelector(".hk-stat-card-footer .probe-badge")?.textContent).toBe("3 dims");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("renders the footer row after the detail line on ring and bar", () => {
|
|
208
|
+
const ring = mount(
|
|
209
|
+
h(HkStatCard, { variant: "ring", value: "40%", label: "L", pct: 40 }, { footer: () => h("i", { class: "probe-r" }) }),
|
|
210
|
+
);
|
|
211
|
+
expect(ring.querySelector(".hk-stat-card-footer .probe-r")).toBeTruthy();
|
|
212
|
+
const bar = mount(
|
|
213
|
+
h(HkStatCard, { variant: "bar", value: "72%", label: "L", pct: 72 }, { footer: () => h("i", { class: "probe-b" }) }),
|
|
214
|
+
);
|
|
215
|
+
expect(bar.querySelector(".hk-stat-card-footer .probe-b")).toBeTruthy();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("renders the footer row on the chip variant too", () => {
|
|
219
|
+
const c = mount(
|
|
220
|
+
h(HkStatCard, { variant: "chip", value: 3, label: "Running" }, { footer: () => h("i", { class: "probe-c" }) }),
|
|
221
|
+
);
|
|
222
|
+
expect(c.querySelector(".hk-stat-card-footer .probe-c")).toBeTruthy();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("omits the footer node when the slot is absent", () => {
|
|
226
|
+
const c = mount(h(HkStatCard, { label: "L", value: 1 }));
|
|
227
|
+
expect(c.querySelector(".hk-stat-card-footer")).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
199
231
|
describe("statToneColor", () => {
|
|
200
232
|
it("maps every tone to the theme-variable palette the card paints with", () => {
|
|
201
233
|
expect(statToneColor("success")).toBe("rgb(var(--color-success))");
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { createApp, h } from "vue";
|
|
7
|
+
|
|
8
|
+
import * as Hk from "../index";
|
|
9
|
+
import HkBoard from "./HkBoard";
|
|
10
|
+
import HkMinimap from "./HkMinimap";
|
|
11
|
+
|
|
12
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
13
|
+
|
|
14
|
+
function mount(node: ReturnType<typeof h>) {
|
|
15
|
+
const container = document.createElement("div");
|
|
16
|
+
document.body.appendChild(container);
|
|
17
|
+
const app = createApp({ render: () => node });
|
|
18
|
+
app.mount(container);
|
|
19
|
+
mounts.push({ app, container });
|
|
20
|
+
return container;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
25
|
+
app.unmount();
|
|
26
|
+
container.remove();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* SVG presentation attributes carrying `var()` colors are NOT substituted
|
|
32
|
+
* on every engine — Firefox drops the whole declaration (stroke:none / fill:
|
|
33
|
+
* none → invisible ink; svgwg#1031, browsers still disagree). The house
|
|
34
|
+
* contract is therefore: paint through an inline `style` channel BESIDE the
|
|
35
|
+
* attribute (inline always wins, so literal colors behave identically, while
|
|
36
|
+
* var() colors still resolve everywhere). HkGaugeRing documents the same
|
|
37
|
+
* contract for its ring colors; these tests pin the remaining SVG painters.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
describe("HkBoard edge ink dual channel", () => {
|
|
41
|
+
it("paints edge strokes through the inline style beside the attribute", () => {
|
|
42
|
+
const c = mount(
|
|
43
|
+
h(HkBoard, {
|
|
44
|
+
nodes: [
|
|
45
|
+
{ id: "a", x: 40, y: 40, w: 120, h: 60, label: "A" },
|
|
46
|
+
{ id: "b", x: 320, y: 40, w: 120, h: 60, label: "B" },
|
|
47
|
+
],
|
|
48
|
+
edges: [{ id: "e1", from: "a", to: "b" }],
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
const edge = c.querySelector(".hk-board-edge") as SVGPathElement;
|
|
52
|
+
expect(edge).toBeTruthy();
|
|
53
|
+
const attr = edge.getAttribute("stroke");
|
|
54
|
+
const inline = edge.style.stroke;
|
|
55
|
+
// the attribute keeps the literal value…
|
|
56
|
+
expect(attr).toBe("rgb(var(--color-primary) / 38%)");
|
|
57
|
+
// …and the inline channel carries the SAME var() form so Firefox resolves it
|
|
58
|
+
expect(inline).toBe("rgb(var(--color-primary) / 38%)");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("HkMinimap paint dual channel", () => {
|
|
63
|
+
it("paints box fill/stroke through the inline style beside the attributes", () => {
|
|
64
|
+
const c = mount(
|
|
65
|
+
h(HkMinimap, {
|
|
66
|
+
boxes: [{ id: "b1", bounds: { x: 0, y: 0, w: 100, h: 80 }, color: "rgb(var(--color-primary))" }],
|
|
67
|
+
hubPos: { x: 600, y: 400 },
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
const box = c.querySelector(".hk-minimap-svg rect[fill]") as SVGRectElement;
|
|
71
|
+
expect(box).toBeTruthy();
|
|
72
|
+
expect(box.getAttribute("fill")).toBe("rgb(var(--color-primary))");
|
|
73
|
+
expect(box.style.fill).toBe("rgb(var(--color-primary))");
|
|
74
|
+
expect(box.style.stroke).toBe("rgb(var(--color-primary))");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("paints the hub dot and viewport frame through the inline style", () => {
|
|
78
|
+
const c = mount(
|
|
79
|
+
h(HkMinimap, {
|
|
80
|
+
// an empty roster renders null (documented early return) — the frame
|
|
81
|
+
// and hub ride along with any painted box
|
|
82
|
+
boxes: [{ id: "b1", bounds: { x: 0, y: 0, w: 100, h: 80 }, color: "rgb(var(--color-info, #5b8def))" }],
|
|
83
|
+
hubPos: { x: 600, y: 400 },
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
const svg = c.querySelector(".hk-minimap-svg") as SVGSVGElement;
|
|
87
|
+
expect(svg).toBeTruthy();
|
|
88
|
+
const hub = svg.querySelector("circle") as SVGCircleElement;
|
|
89
|
+
expect(hub.getAttribute("fill")).toBe("rgb(var(--color-primary))");
|
|
90
|
+
expect(hub.style.fill).toBe("rgb(var(--color-primary))");
|
|
91
|
+
const frame = svg.querySelector('rect[stroke-dasharray="3 2"]') as SVGRectElement;
|
|
92
|
+
expect(frame.getAttribute("stroke")).toBe("rgb(var(--color-primary))");
|
|
93
|
+
expect(frame.style.stroke).toBe("rgb(var(--color-primary))");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("static sweep — literal var() presentation attributes must ride an inline style", () => {
|
|
98
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
99
|
+
|
|
100
|
+
/** Returns offending `stroke="…var(…"` / `fill="…var(…"` literal lines that
|
|
101
|
+
* have no compensating `style={{` within the following 3 lines. */
|
|
102
|
+
async function violations(file: string): Promise<string[]> {
|
|
103
|
+
const text = await readFile(join(dir, file), "utf-8");
|
|
104
|
+
const lines = text.split("\n");
|
|
105
|
+
const bad: string[] = [];
|
|
106
|
+
for (let i = 0; i < lines.length; i++) {
|
|
107
|
+
if (/\b(stroke|fill)="[^"]*var\(--/.test(lines[i])) {
|
|
108
|
+
const window = lines.slice(i + 1, i + 4).join("\n");
|
|
109
|
+
const prop = /stroke=/.test(lines[i]) ? "stroke" : "fill";
|
|
110
|
+
if (!window.includes(`style={{`) || !window.includes(`${prop}:`)) {
|
|
111
|
+
bad.push(`${file}:${i + 1}: ${lines[i].trim()}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return bad;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
it("the sweep self-certifies on a known-bad sample", async () => {
|
|
119
|
+
// Zero-产出防呆:the detector must fire on a constructed violation, or the
|
|
120
|
+
// sweep below proves nothing.
|
|
121
|
+
const badSample = [
|
|
122
|
+
`<circle`,
|
|
123
|
+
` cx={1}`,
|
|
124
|
+
` fill="rgb(var(--color-x))"`,
|
|
125
|
+
` r={2}`,
|
|
126
|
+
`/>`,
|
|
127
|
+
].join("\n");
|
|
128
|
+
const lines = badSample.split("\n");
|
|
129
|
+
let fired = false;
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
if (/\b(stroke|fill)="[^"]*var\(--/.test(lines[i])) {
|
|
132
|
+
const window = lines.slice(i + 1, i + 4).join("\n");
|
|
133
|
+
if (!window.includes(`style={{`)) fired = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
expect(fired).toBe(true);
|
|
137
|
+
void violations;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("every painted component file is clean", async () => {
|
|
141
|
+
const files = (await readdir(dir)).filter((f) => /^Hk.+\.tsx$/.test(f) && !f.includes(".test."));
|
|
142
|
+
const offenders: string[] = [];
|
|
143
|
+
for (const f of files) {
|
|
144
|
+
offenders.push(...(await violations(f)));
|
|
145
|
+
}
|
|
146
|
+
expect(offenders).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("index export surface smoke", () => {
|
|
151
|
+
it("exposes the standard KPI/filter/painter surfaces under both names", () => {
|
|
152
|
+
for (const name of [
|
|
153
|
+
"HkStatCard",
|
|
154
|
+
"HStatCard",
|
|
155
|
+
"HkFilterBar",
|
|
156
|
+
"HFilterBar",
|
|
157
|
+
"HkGaugeRing",
|
|
158
|
+
"HGaugeRing",
|
|
159
|
+
"HkBoard",
|
|
160
|
+
"HkMinimap",
|
|
161
|
+
"HkNodeCanvas",
|
|
162
|
+
"HkEmptyState",
|
|
163
|
+
"HkPageHeader",
|
|
164
|
+
"HkTable",
|
|
165
|
+
"HkIconChip",
|
|
166
|
+
"HIconChip",
|
|
167
|
+
"HkCardList",
|
|
168
|
+
"HCardList",
|
|
169
|
+
"HkListRow",
|
|
170
|
+
"HListRow",
|
|
171
|
+
"HkSectionHeader",
|
|
172
|
+
"HSectionHeader",
|
|
173
|
+
"HkAdminTablePage",
|
|
174
|
+
] as const) {
|
|
175
|
+
expect(Hk, `missing export: ${name}`).toHaveProperty(name);
|
|
176
|
+
}
|
|
177
|
+
expect(typeof Hk.statToneColor).toBe("function");
|
|
178
|
+
});
|
|
179
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -754,6 +754,12 @@ export { HkStatCard as HkStatCard } from "./components/HkStatCard";
|
|
|
754
754
|
export { statToneColor, type StatTone as StatTone, type StatVariant as StatVariant } from "./components/HkStatCard";
|
|
755
755
|
export { HkStatusPill as HkStatusPill } from "./components/HkStatusPill";
|
|
756
756
|
export { default as HkFilterBar } from "./components/HkFilterBar";
|
|
757
|
+
export { HkIconChip as HkIconChip } from "./components/HkIconChip";
|
|
758
|
+
export { HkIconChip as HIconChip } from "./components/HkIconChip";
|
|
759
|
+
export { HkCardList, HkListRow } from "./components/HkCardList";
|
|
760
|
+
export { HkCardList as HCardList, HkListRow as HListRow } from "./components/HkCardList";
|
|
761
|
+
export { HkSectionHeader, type SectionHeaderVariant } from "./components/HkSectionHeader";
|
|
762
|
+
export { HkSectionHeader as HSectionHeader } from "./components/HkSectionHeader";
|
|
757
763
|
export { default as HFilterBar } from "./components/HkFilterBar";
|
|
758
764
|
export { HkShareBar as HkShareBar } from "./components/HkShareBar";
|
|
759
765
|
export { HkSecretRevealModal as HkSecretRevealModal } from "./components/HkSecretRevealModal";
|