@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.
@@ -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
- <path
1298
- key={`halo-${i}`}
1299
- class="hk-node-canvas-edge-halo"
1300
- d={sp.d}
1301
- fill="none"
1302
- stroke={edge.color ?? "var(--hk-border-3, #555)"}
1303
- stroke-width={sp.width * 3}
1304
- stroke-dasharray={sp.dashed ? "6 4" : undefined}
1305
- />
1306
- ))}
1307
- {strokes.map((sp, i) => (
1308
- <path
1309
- key={`ink-${i}`}
1310
- d={sp.d}
1311
- fill="none"
1312
- stroke={edge.color ?? "var(--hk-border-3, #555)"}
1313
- stroke-width={sp.width}
1314
- stroke-dasharray={sp.dashed ? "6 4" : undefined}
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";