@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.78",
3
+ "version": "0.55.80",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,93 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import { HkAdminTablePage } from "./HkAdminTablePage";
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 COLS = [{ key: "name", title: "Name" }];
25
+
26
+ describe("HkAdminTablePage", () => {
27
+ it("forwards title/subtitle/dense to the page header", () => {
28
+ const c = mount(
29
+ h(HkAdminTablePage, {
30
+ title: "Webhooks", subtitle: "Inbound endpoints", dense: true,
31
+ rows: [{ name: "a" }], columns: COLS,
32
+ }),
33
+ );
34
+ expect(c.querySelector(".hk-page-header-title-text")?.textContent).toBe("Webhooks");
35
+ expect(c.querySelector(".hk-page-header-subtitle")?.textContent).toBe("Inbound endpoints");
36
+ expect((c.querySelector(".hk-page-header") as HTMLElement).className).toContain("hk-page-header-dense");
37
+ expect(c.querySelector("table")).toBeTruthy();
38
+ });
39
+
40
+ it("renders the filter slot between header and table", () => {
41
+ const c = mount(
42
+ h(HkAdminTablePage, {
43
+ title: "T", rows: [{ name: "a" }], columns: COLS,
44
+ }, {
45
+ filter: () => h("div", { class: "probe-filter" }, "filter-chrome"),
46
+ }),
47
+ );
48
+ const filter = c.querySelector(".hk-admin-table-page-filter .probe-filter");
49
+ expect(filter).toBeTruthy();
50
+ // filter sits above the table card, not inside it
51
+ expect(c.querySelector(".hk-card")?.contains(filter as Node)).toBe(false);
52
+ });
53
+
54
+ it("staleError renders a banner while rows stay on screen", () => {
55
+ const c = mount(
56
+ h(HkAdminTablePage, {
57
+ title: "T", staleError: "refresh failed", rows: [{ name: "a" }], columns: COLS,
58
+ }),
59
+ );
60
+ expect(c.textContent).toContain("refresh failed");
61
+ expect(c.querySelector("table")).toBeTruthy();
62
+ });
63
+
64
+ it("hard error still replaces the table", () => {
65
+ const c = mount(
66
+ h(HkAdminTablePage, {
67
+ title: "T", error: "boom", rows: [], columns: COLS,
68
+ }),
69
+ );
70
+ expect(c.textContent).toContain("boom");
71
+ expect(c.querySelector("table")).toBeNull();
72
+ });
73
+
74
+ it("empty state carries the empty-action slot", () => {
75
+ const c = mount(
76
+ h(HkAdminTablePage, {
77
+ title: "T", rows: [], columns: COLS, emptyTitle: "Nothing here",
78
+ }, {
79
+ "empty-action": () => h("button", { class: "probe-retry" }, "Retry"),
80
+ }),
81
+ );
82
+ expect(c.querySelector(".hk-empty-state")).toBeTruthy();
83
+ expect(c.querySelector(".probe-retry")).toBeTruthy();
84
+ });
85
+
86
+ it("shows the spinner while loading with no rows yet", () => {
87
+ const c = mount(
88
+ h(HkAdminTablePage, { title: "T", loading: true, rows: [], columns: COLS }),
89
+ );
90
+ expect(c.querySelector(".hk-spinner-wrapper")).toBeTruthy();
91
+ expect(c.querySelector("table")).toBeNull();
92
+ });
93
+ });
@@ -13,19 +13,35 @@ export interface HTableColumn {
13
13
  }
14
14
 
15
15
  /**
16
- * CRUD table-page scaffold: HkPageHeader + loading spinner + error alert +
17
- * empty state + HTable. Slots:
16
+ * CRUD table-page scaffold: HkPageHeader + (filter slot) + loading
17
+ * spinner + error alert + empty state + HTable. Slots:
18
18
  * - `actions` — header actions (e.g. a "Create" button)
19
+ * - `filter` — filter chrome (HkFilterBar) between header and table
19
20
  * - `cell-<key>` — per-column cell templates, forwarded to HTable
21
+ * - `empty-action` — action inside the empty state (Retry, …)
20
22
  * - `create-modal` — create dialog, rendered after the table
21
23
  * - `edit-modal` — edit dialog, rendered after the table
24
+ *
25
+ * Error model: `error` is the hard failure (no data — replaces the
26
+ * table), while `staleError` renders a banner ABOVE rows that are still
27
+ * on screen after a failed refresh, so a failed reload never hides data
28
+ * the user already had.
22
29
  */
23
30
  export const HkAdminTablePage = defineComponent({
24
31
  name: "HkAdminTablePage",
25
32
  props: {
26
33
  title: { type: String, default: "" },
34
+ /** Forwarded to the page header. */
35
+ subtitle: { type: String, default: undefined },
36
+ /** Forwarded to the page header. */
37
+ icon: { type: Object, default: undefined },
38
+ /** Compact page header variant. */
39
+ dense: { type: Boolean, default: false },
27
40
  loading: { type: Boolean, default: false },
28
41
  error: { type: String as PropType<string | undefined>, default: undefined },
42
+ /** Refresh failed while rows are still on screen — banner, not a
43
+ * replacement of the table. */
44
+ staleError: { type: String as PropType<string | undefined>, default: undefined },
29
45
  rows: { type: Array as PropType<Record<string, unknown>[]>, required: true },
30
46
  columns: { type: Array as PropType<HTableColumn[]>, required: true },
31
47
  rowKey: { type: String, default: "id" },
@@ -37,10 +53,16 @@ export const HkAdminTablePage = defineComponent({
37
53
  return () => {
38
54
  const { t } = useI18n();
39
55
  const emptyTitle = props.emptyTitle || t("hikari::tablePage.emptyTitle", "No data");
56
+ // Extracted to an identifier: JSX children given as an inline object
57
+ // literal are not normalized to slots the way a bound one is (the
58
+ // chest retrySlots idiom this mirrors relies on the identifier form).
59
+ const emptySlots = slots["empty-action"]
60
+ ? { action: () => slots["empty-action"]?.() }
61
+ : undefined;
40
62
  return (
41
63
  <div>
42
64
  {props.title ? (
43
- <HkPageHeader title={props.title}>
65
+ <HkPageHeader title={props.title} subtitle={props.subtitle} icon={props.icon} dense={props.dense}>
44
66
  {{ actions: () => slots.actions?.() }}
45
67
  </HkPageHeader>
46
68
  ) : slots.actions ? (
@@ -48,12 +70,21 @@ export const HkAdminTablePage = defineComponent({
48
70
  {slots.actions()}
49
71
  </div>
50
72
  ) : null}
73
+ {slots.filter ? <div class="hk-admin-table-page-filter">{slots.filter()}</div> : null}
74
+
75
+ {props.staleError ? <HAlert message={props.staleError} /> : null}
51
76
  {props.error ? (
52
77
  <HAlert message={props.error} />
53
78
  ) : props.loading && !props.rows.length ? (
54
79
  <HSpinner center />
55
80
  ) : !props.rows.length ? (
56
- <HEmptyState boxed title={emptyTitle} description={props.emptyDescription} />
81
+ <HEmptyState
82
+ boxed
83
+ title={emptyTitle}
84
+ description={props.emptyDescription}
85
+ >
86
+ {emptySlots}
87
+ </HEmptyState>
57
88
  ) : (
58
89
  <HCard padded={false}>
59
90
  <HTable columns={props.columns} rows={props.rows} rowKey={props.rowKey}>
@@ -98,6 +98,11 @@ export interface BoardEdgeInput {
98
98
  width?: number;
99
99
  }
100
100
 
101
+ // Ink colors ride INLINE STYLES beside the stroke presentation attribute:
102
+ // var() references in a presentation attribute are not substituted on every
103
+ // engine (Firefox drops the whole declaration → stroke:none → invisible
104
+ // edges; svgwg#1031, 2025-11: browsers still disagree). Same contract as
105
+ // HkGaugeRing/HkProgressRing.
101
106
  const DEFAULT_INK = "rgb(var(--color-primary) / 38%)";
102
107
 
103
108
  export default defineComponent({
@@ -426,6 +431,7 @@ export default defineComponent({
426
431
  class="hk-board-edge"
427
432
  d={e.d}
428
433
  stroke={e.ink}
434
+ style={{ stroke: e.ink }}
429
435
  stroke-width={e.width}
430
436
  stroke-dasharray={e.dashed ? "6 5" : undefined}
431
437
  fill="none"
@@ -0,0 +1,68 @@
1
+ /* HkCardList / HkListRow — the standard card list anatomy. */
2
+
3
+ .hk-card-list {
4
+ display: flex;
5
+ flex-direction: column;
6
+ min-width: 0;
7
+
8
+ /* hairline dividers between rows (the CSS-native divide-y) */
9
+ > .hk-list-row + .hk-list-row {
10
+ border-top: 1px solid rgb(var(--color-border) / 10%);
11
+ }
12
+ }
13
+
14
+ .hk-list-row {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: var(--space-12, 0.75rem);
18
+ padding: var(--space-12, 0.75rem) var(--space-16, 1rem);
19
+ min-width: 0;
20
+ text-align: left;
21
+ }
22
+
23
+ .hk-list-row-clickable {
24
+ cursor: pointer;
25
+ transition: background-color var(--duration-normal, 0.3s) ease;
26
+
27
+ &:hover {
28
+ background: rgb(var(--color-text) / 4%);
29
+ }
30
+
31
+ &:focus-visible {
32
+ outline: 2px solid rgb(var(--color-primary));
33
+ outline-offset: -2px;
34
+ }
35
+ }
36
+
37
+ .hk-list-row-main {
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: 0.125rem;
41
+ min-width: 0;
42
+ flex: 1 1 auto;
43
+ }
44
+
45
+ .hk-list-row-title {
46
+ font-size: var(--text-sm, 0.875rem);
47
+ font-weight: 600;
48
+ color: rgb(var(--color-text));
49
+ overflow: hidden;
50
+ text-overflow: ellipsis;
51
+ white-space: nowrap;
52
+ }
53
+
54
+ .hk-list-row-subtitle {
55
+ font-size: var(--text-xs, 0.75rem);
56
+ color: rgb(var(--color-muted));
57
+ overflow: hidden;
58
+ text-overflow: ellipsis;
59
+ white-space: nowrap;
60
+ }
61
+
62
+ .hk-list-row-trailing {
63
+ margin-left: auto;
64
+ flex-shrink: 0;
65
+ display: flex;
66
+ align-items: center;
67
+ gap: var(--space-8, 0.5rem);
68
+ }
@@ -0,0 +1,141 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, createCommentVNode, defineComponent, h } from "vue";
3
+
4
+ import { HkCardList, HkListRow } from "./HkCardList";
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("HkCardList", () => {
31
+ it("renders rows inside an unpadded card list landmark", () => {
32
+ const c = mount(
33
+ h(HkCardList, () => h(HkListRow, { title: "A" })),
34
+ );
35
+ expect(c.querySelector(".hk-card")).toBeTruthy();
36
+ expect(c.querySelector(".hk-card-body-unpadded")).toBeTruthy();
37
+ const list = c.querySelector(".hk-card-list") as HTMLElement;
38
+ expect(list.getAttribute("role")).toBe("list");
39
+ expect(list.querySelectorAll(".hk-list-row")).toHaveLength(1);
40
+ });
41
+
42
+ it("renders the empty slot instead of the list when provided without rows", () => {
43
+ const c = mount(h(HkCardList, null, { empty: () => h("p", { class: "probe-empty" }, "empty") }));
44
+ expect(c.querySelector(".hk-card-list")).toBeNull();
45
+ expect(c.querySelector(".probe-empty")?.textContent).toBe("empty");
46
+ });
47
+
48
+ it("falls back to the empty slot when the default slot renders no rows (v-for idiom)", () => {
49
+ const rows: Array<{ name: string }> = [];
50
+ const c = mount(
51
+ h(HkCardList, null, {
52
+ default: () => rows.map((r) => h(HkListRow, { title: r.name })),
53
+ empty: () => h("p", { class: "probe-empty" }, "empty"),
54
+ }),
55
+ );
56
+ expect(c.querySelector(".hk-card-list")).toBeNull();
57
+ expect(c.querySelector(".probe-empty")?.textContent).toBe("empty");
58
+ });
59
+
60
+ it("falls back to the empty slot when the default slot renders null (v-if idiom)", () => {
61
+ const c = mount(
62
+ h(HkCardList, null, {
63
+ default: () => null,
64
+ empty: () => h("p", { class: "probe-empty" }, "empty"),
65
+ }),
66
+ );
67
+ expect(c.querySelector(".hk-card-list")).toBeNull();
68
+ expect(c.querySelector(".probe-empty")).toBeTruthy();
69
+ });
70
+
71
+ it("falls back to the empty slot when the default slot is only a comment placeholder", () => {
72
+ const c = mount(
73
+ h(HkCardList, null, {
74
+ default: () => [createCommentVNode("v-if", true)],
75
+ empty: () => h("p", { class: "probe-empty" }, "empty"),
76
+ }),
77
+ );
78
+ expect(c.querySelector(".hk-card-list")).toBeNull();
79
+ expect(c.querySelector(".probe-empty")).toBeTruthy();
80
+ });
81
+
82
+ it("keeps the list when rows exist even with the empty slot present", () => {
83
+ const c = mount(
84
+ h(HkCardList, null, {
85
+ default: () => [h(HkListRow, { title: "A" })],
86
+ empty: () => h("p", { class: "probe-empty" }, "empty"),
87
+ }),
88
+ );
89
+ expect(c.querySelector(".hk-card-list")).toBeTruthy();
90
+ expect(c.querySelectorAll(".hk-list-row")).toHaveLength(1);
91
+ expect(c.querySelector(".probe-empty")).toBeNull();
92
+ });
93
+ });
94
+
95
+ describe("HkListRow", () => {
96
+ it("renders icon chip, title, subtitle and the trailing slot", () => {
97
+ const c = mount(
98
+ h(HkListRow, { icon: FakeIcon, iconTone: "success", title: "gpu-1", subtitle: "192.168.2.10" }, {
99
+ trailing: () => h("span", { class: "probe-badge" }, "online"),
100
+ }),
101
+ );
102
+ const row = c.querySelector(".hk-list-row") as HTMLElement;
103
+ expect(row.getAttribute("role")).toBe("listitem");
104
+ expect(row.querySelector(".hk-icon-chip-tone-success svg[data-fake-icon]")).toBeTruthy();
105
+ expect(row.querySelector(".hk-list-row-title")?.textContent).toBe("gpu-1");
106
+ expect(row.querySelector(".hk-list-row-subtitle")?.textContent).toBe("192.168.2.10");
107
+ expect(row.querySelector(".hk-list-row-trailing .probe-badge")?.textContent).toBe("online");
108
+ });
109
+
110
+ it("keeps the default slot beside title/subtitle inside the main column", () => {
111
+ const c = mount(
112
+ h(HkListRow, { title: "T" }, { default: () => h("div", { class: "probe-extra" }, "extra") }),
113
+ );
114
+ expect(c.querySelector(".hk-list-row-main .probe-extra")).toBeTruthy();
115
+ });
116
+
117
+ it("activates on Enter/Space when clickable, ignoring bubbled keys", async () => {
118
+ const clicks: string[] = [];
119
+ const c = mount(
120
+ h(HkListRow, {
121
+ title: "T",
122
+ clickable: true,
123
+ onClick: (e: MouseEvent | KeyboardEvent) => clicks.push("key" in e ? e.key : "mouse"),
124
+ }),
125
+ );
126
+ const row = c.querySelector(".hk-list-row") as HTMLElement;
127
+ expect(row.getAttribute("tabindex")).toBe("0");
128
+ for (const key of ["Enter", " "]) {
129
+ row.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
130
+ await Promise.resolve();
131
+ }
132
+ expect(clicks).toEqual(["Enter", " "]);
133
+ });
134
+
135
+ it("is not interactive without clickable", () => {
136
+ const c = mount(h(HkListRow, { title: "T" }));
137
+ const row = c.querySelector(".hk-list-row") as HTMLElement;
138
+ expect(row.className).not.toContain("hk-list-row-clickable");
139
+ expect(row.getAttribute("tabindex")).toBeNull();
140
+ });
141
+ });
@@ -0,0 +1,113 @@
1
+ import { Comment, defineComponent, type Component, type PropType, type VNode } from "vue";
2
+
3
+ import HkCard from "./HkCard";
4
+ import { HkIconChip } from "./HkIconChip";
5
+ import type { StatTone } from "./HkStatCard";
6
+ import "./HkCardList.scss";
7
+
8
+ /**
9
+ * Whether a default slot actually rendered any row. Testing
10
+ * `slots.default` for truthiness is not enough: the canonical template
11
+ * idiom (`<HkListRow v-for>` plus `<template #empty>`) always provides a
12
+ * default slot whose v-for renders zero vnodes, and a `v-if` row renders
13
+ * a comment placeholder — both would otherwise show an empty bordered
14
+ * card where the empty state belongs.
15
+ */
16
+ function renderedRows(nodes: unknown): boolean {
17
+ if (nodes == null || nodes === false || nodes === "") return false;
18
+ if (Array.isArray(nodes)) return nodes.some((n) => renderedRows(n));
19
+ if (typeof nodes === "object") return (nodes as VNode).type !== Comment;
20
+ return true; // non-empty text content
21
+ }
22
+
23
+ /**
24
+ * HkCardList — the standard card list: an unpadded HkCard whose body is
25
+ * a `role="list"` column of HkListRow entries separated by hairlines.
26
+ *
27
+ * This anatomy is what the console family calls "card rows, never raw
28
+ * ul/ol" (the host's UnoCSS setup has no preflight, so `<ul>` indents
29
+ * 40px): a bordered card surface, rows at `px-4 py-3` with a 12px gutter,
30
+ * hairline dividers between them. It was copy-pasted across ten views
31
+ * before becoming a component.
32
+ *
33
+ * ```tsx
34
+ * <HkCardList>
35
+ * <HkListRow icon={Server} title={row.name} subtitle={row.url}
36
+ * trailing={() => <HkBadge>{row.status}</HkBadge>} />
37
+ * </HkCardList>
38
+ * ```
39
+ */
40
+ export const HkCardList = defineComponent({
41
+ name: "HkCardList",
42
+ setup(_props, { slots }) {
43
+ return () => {
44
+ const rows = slots.default?.();
45
+ return (
46
+ <HkCard padded={false}>
47
+ {slots.empty && !renderedRows(rows) ? (
48
+ slots.empty()
49
+ ) : (
50
+ <div class="hk-card-list" role="list">
51
+ {rows}
52
+ </div>
53
+ )}
54
+ </HkCard>
55
+ );
56
+ };
57
+ },
58
+ });
59
+
60
+ /**
61
+ * HkListRow — one entry of an HkCardList: an optional tone-tinted icon
62
+ * chip, a main column (title / subtitle / free-form default slot), and a
63
+ * trailing slot pinned to the right edge (badges, switches, actions).
64
+ *
65
+ * The row is a list item, not a button — set `clickable` when the whole
66
+ * row drills through, which adds the cursor/focus/hover contract the
67
+ * stat cards use.
68
+ */
69
+ export const HkListRow = defineComponent({
70
+ name: "HkListRow",
71
+ props: {
72
+ /** Leading icon — rendered through the standard HkIconChip. */
73
+ icon: { type: Object as PropType<Component>, default: undefined },
74
+ iconTone: { type: String as PropType<StatTone>, default: "primary" },
75
+ title: { type: String, default: undefined },
76
+ subtitle: { type: String, default: undefined },
77
+ /** Whole-row click (drill-through). */
78
+ clickable: { type: Boolean, default: false },
79
+ },
80
+ emits: { click: (_e: MouseEvent | KeyboardEvent) => true },
81
+ setup(props, { emit, slots }) {
82
+ return () => (
83
+ <div
84
+ class={["hk-list-row", props.clickable && "hk-list-row-clickable"]}
85
+ role="listitem"
86
+ tabindex={props.clickable ? 0 : undefined}
87
+ onKeydown={
88
+ props.clickable
89
+ ? (e: KeyboardEvent) => {
90
+ // role listitem + tabindex promises keyboard activation
91
+ // only when the row itself is made interactive; keys
92
+ // bubbling from focusable children are left alone.
93
+ if (e.target !== e.currentTarget) return;
94
+ if (e.key !== "Enter" && e.key !== " ") return;
95
+ e.preventDefault();
96
+ emit("click", e);
97
+ }
98
+ : undefined
99
+ }
100
+ onClick={props.clickable ? (e: MouseEvent) => emit("click", e) : undefined}
101
+
102
+ >
103
+ {props.icon ? <HkIconChip icon={props.icon} tone={props.iconTone} /> : null}
104
+ <div class="hk-list-row-main">
105
+ {props.title ? <div class="hk-list-row-title">{props.title}</div> : null}
106
+ {props.subtitle ? <div class="hk-list-row-subtitle">{props.subtitle}</div> : null}
107
+ {slots.default?.()}
108
+ </div>
109
+ {slots.trailing ? <div class="hk-list-row-trailing">{slots.trailing()}</div> : null}
110
+ </div>
111
+ );
112
+ },
113
+ });
@@ -0,0 +1,27 @@
1
+ /* HkIconChip — the standard leading icon chip.
2
+ * The tone rules mirror HkStatCard's chip-icon palette verbatim so the
3
+ * two never drift apart. */
4
+ .hk-icon-chip {
5
+ display: inline-flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ border-radius: var(--radius-md, 0.5rem);
9
+ flex-shrink: 0;
10
+ }
11
+
12
+ .hk-icon-chip-md {
13
+ width: var(--space-28, 1.75rem);
14
+ height: var(--space-28, 1.75rem);
15
+ }
16
+
17
+ .hk-icon-chip-sm {
18
+ width: var(--space-24, 1.5rem);
19
+ height: var(--space-24, 1.5rem);
20
+ }
21
+
22
+ .hk-icon-chip-tone-success { background: rgb(var(--color-success) / 12%); color: rgb(var(--color-success)); }
23
+ .hk-icon-chip-tone-warning { background: rgb(var(--color-warning) / 12%); color: rgb(var(--color-warning)); }
24
+ .hk-icon-chip-tone-error { background: rgb(var(--color-error) / 12%); color: rgb(var(--color-error)); }
25
+ .hk-icon-chip-tone-info { background: rgb(var(--color-info, rgb(var(--color-primary))) / 12%); color: rgb(var(--color-info, rgb(var(--color-primary)))); }
26
+ .hk-icon-chip-tone-primary { background: rgb(var(--color-primary) / 12%); color: rgb(var(--color-primary)); }
27
+ .hk-icon-chip-tone-muted { background: rgb(var(--color-muted) / 12%); color: rgb(var(--color-muted)); }
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h } from "vue";
3
+
4
+ import { HkIconChip } from "./HkIconChip";
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("HkIconChip", () => {
31
+ it("renders the icon at the md box size by default", () => {
32
+ const c = mount(h(HkIconChip, { icon: FakeIcon }));
33
+ const chip = c.querySelector(".hk-icon-chip") as HTMLElement;
34
+ expect(chip).toBeTruthy();
35
+ expect(chip.className).toContain("hk-icon-chip-md");
36
+ expect(chip.className).toContain("hk-icon-chip-tone-primary");
37
+ expect(chip.getAttribute("aria-hidden")).toBe("true");
38
+ expect(chip.querySelector("svg[data-fake-icon]")?.getAttribute("width")).toBe("15");
39
+ });
40
+
41
+ it("renders the sm box with a 13px glyph", () => {
42
+ const c = mount(h(HkIconChip, { icon: FakeIcon, size: "sm" }));
43
+ const chip = c.querySelector(".hk-icon-chip") as HTMLElement;
44
+ expect(chip.className).toContain("hk-icon-chip-sm");
45
+ expect(chip.querySelector("svg[data-fake-icon]")?.getAttribute("width")).toBe("13");
46
+ });
47
+
48
+ it("carries the tone class for every palette entry", () => {
49
+ for (const tone of ["success", "warning", "error", "info", "primary", "muted"] as const) {
50
+ const c = mount(h(HkIconChip, { icon: FakeIcon, tone }));
51
+ expect((c.querySelector(".hk-icon-chip") as HTMLElement).className).toContain(
52
+ `hk-icon-chip-tone-${tone}`,
53
+ );
54
+ }
55
+ });
56
+ });
@@ -0,0 +1,42 @@
1
+ import { defineComponent, h, type Component, type PropType } from "vue";
2
+
3
+ import type { StatTone } from "./HkStatCard";
4
+ import "./HkIconChip.scss";
5
+
6
+ /**
7
+ * HkIconChip — the standard leading icon chip: a small rounded square
8
+ * painted in a 10% tint of the tone color with the tone color as the
9
+ * glyph ink. This anatomy was copy-pasted as inline styles across the
10
+ * consumers (roster rows, card headers, stat strips) before it became a
11
+ * component; the tone palette is the one HkStatCard owns, so chips always
12
+ * match the stat card's health colors.
13
+ *
14
+ * Purely decorative by contract — the chip is aria-hidden and the icon
15
+ * meaning must be conveyed by adjacent text (row title, card label).
16
+ *
17
+ * ```tsx
18
+ * <HkIconChip icon={Cpu} tone="info" />
19
+ * <HkIconChip icon={Coins} size="sm" />
20
+ * ```
21
+ */
22
+ export const HkIconChip = defineComponent({
23
+ name: "HkIconChip",
24
+ props: {
25
+ /** Icon component (lucide-vue-next style). */
26
+ icon: { type: Object as PropType<Component>, required: true },
27
+ /** Tone drives both the tint background and the glyph ink. */
28
+ tone: { type: String as PropType<StatTone>, default: "primary" },
29
+ /** sm = 24px box / 13px glyph, md = 28px box / 15px glyph. */
30
+ size: { type: String as PropType<"sm" | "md">, default: "md" },
31
+ },
32
+ setup(props) {
33
+ return () => (
34
+ <span
35
+ class={["hk-icon-chip", `hk-icon-chip-${props.size}`, `hk-icon-chip-tone-${props.tone}`]}
36
+ aria-hidden="true"
37
+ >
38
+ {h(props.icon, { size: props.size === "sm" ? 13 : 15 })}
39
+ </span>
40
+ );
41
+ },
42
+ });
@@ -269,6 +269,7 @@ export default defineComponent({
269
269
  height={b.bounds.h * s}
270
270
  rx={2}
271
271
  fill={b.color}
272
+ style={{ fill: b.color, stroke: b.color }}
272
273
  opacity="0.28"
273
274
  stroke={b.color}
274
275
  stroke-width="0.6"
@@ -305,6 +306,7 @@ export default defineComponent({
305
306
  cy={hubP[1]}
306
307
  r={3}
307
308
  fill="rgb(var(--color-primary))"
309
+ style={{ fill: "rgb(var(--color-primary))" }}
308
310
  filter="drop-shadow(0 0 2px rgb(var(--color-primary) / 0.6))"
309
311
  />
310
312
  )}
@@ -315,6 +317,7 @@ export default defineComponent({
315
317
  height={Math.max(1, vr.h)}
316
318
  fill="none"
317
319
  stroke="rgb(var(--color-primary))"
320
+ style={{ stroke: "rgb(var(--color-primary))" }}
318
321
  stroke-width="1"
319
322
  stroke-dasharray="3 2"
320
323
  rx="2"