@celestia-island/hikari 0.55.79 → 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.79",
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}>
@@ -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
+ });
@@ -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))");
@@ -162,6 +162,15 @@ describe("index export surface smoke", () => {
162
162
  "HkEmptyState",
163
163
  "HkPageHeader",
164
164
  "HkTable",
165
+ "HkIconChip",
166
+ "HIconChip",
167
+ "HkCardList",
168
+ "HCardList",
169
+ "HkListRow",
170
+ "HListRow",
171
+ "HkSectionHeader",
172
+ "HSectionHeader",
173
+ "HkAdminTablePage",
165
174
  ] as const) {
166
175
  expect(Hk, `missing export: ${name}`).toHaveProperty(name);
167
176
  }
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";