@celestia-island/hikari 0.55.76 → 0.55.78

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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkFilterBar.scss +59 -0
  3. package/src/components/HkFilterBar.test.tsx +130 -0
  4. package/src/components/HkFilterBar.tsx +143 -0
  5. package/src/components/HkGaugeRing.tsx +13 -1
  6. package/src/components/HkSelectionGrid.scss +91 -0
  7. package/src/components/HkSelectionGrid.test.tsx +68 -0
  8. package/src/components/HkSelectionGrid.tsx +56 -1
  9. package/src/components/HkSettingsDialog.scss +12 -1
  10. package/src/components/HkStatCard.scss +107 -0
  11. package/src/components/HkStatCard.tsx +173 -21
  12. package/src/components/HkStatCard.variants.test.tsx +210 -0
  13. package/src/components/HkStepGuide.scss +177 -0
  14. package/src/components/HkStepGuide.test.tsx +81 -0
  15. package/src/components/HkStepGuide.tsx +129 -0
  16. package/src/components/HkThemeToggle.test.ts +23 -0
  17. package/src/components/HkThemeToggle.tsx +11 -2
  18. package/src/i18n/locales/ar/components.json +5 -1
  19. package/src/i18n/locales/de/components.json +5 -1
  20. package/src/i18n/locales/en/components.json +5 -1
  21. package/src/i18n/locales/es/components.json +5 -1
  22. package/src/i18n/locales/fr/components.json +5 -1
  23. package/src/i18n/locales/ja/components.json +5 -1
  24. package/src/i18n/locales/ko/components.json +5 -1
  25. package/src/i18n/locales/pt/components.json +5 -1
  26. package/src/i18n/locales/ru/components.json +5 -1
  27. package/src/i18n/locales/zh-Hans/components.json +5 -1
  28. package/src/i18n/locales/zh-Hant/components.json +5 -1
  29. package/src/index.ts +6 -1
  30. package/src/styles/admin-tokens.scss +21 -0
  31. package/src/theme/presets.ts +7 -0
@@ -80,3 +80,110 @@
80
80
  text-overflow: ellipsis;
81
81
  white-space: nowrap;
82
82
  }
83
+
84
+ /* ── chip variant ──────────────────────────────────────────────────────
85
+ Horizontal: tone-tinted icon chip, value over label, optional trailing
86
+ aside (tier badge / delta). The icon chip carries the tone color the
87
+ same way the plain variant's dot does — tint on the chip only. */
88
+ .hk-stat-card-chip-body {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: var(--space-8, 0.5rem);
92
+ min-width: 0;
93
+ }
94
+
95
+ .hk-stat-card-chip-icon {
96
+ display: inline-flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ width: var(--space-28, 1.75rem);
100
+ height: var(--space-28, 1.75rem);
101
+ border-radius: var(--radius-sm, 0.375rem);
102
+ flex-shrink: 0;
103
+ }
104
+
105
+ .hk-stat-card-chip-icon-success { background: rgb(var(--color-success) / 12%); color: rgb(var(--color-success)); }
106
+ .hk-stat-card-chip-icon-warning { background: rgb(var(--color-warning) / 12%); color: rgb(var(--color-warning)); }
107
+ .hk-stat-card-chip-icon-error { background: rgb(var(--color-error) / 12%); color: rgb(var(--color-error)); }
108
+ .hk-stat-card-chip-icon-info { background: rgb(var(--color-info, rgb(var(--color-primary))) / 12%); color: rgb(var(--color-info, rgb(var(--color-primary)))); }
109
+ .hk-stat-card-chip-icon-primary { background: rgb(var(--color-primary) / 12%); color: rgb(var(--color-primary)); }
110
+ .hk-stat-card-chip-icon-muted { background: rgb(var(--color-muted) / 12%); color: rgb(var(--color-muted)); }
111
+
112
+ .hk-stat-card-chip-main {
113
+ min-width: 0;
114
+ }
115
+
116
+ .hk-stat-card-chip-value {
117
+ font-size: var(--text-lg, 1.125rem);
118
+ font-weight: 700;
119
+ line-height: 1.35;
120
+ color: rgb(var(--color-text));
121
+ overflow: hidden;
122
+ text-overflow: ellipsis;
123
+ white-space: nowrap;
124
+ }
125
+
126
+ .hk-stat-card-chip-label {
127
+ font-size: var(--text-xs, 0.75rem);
128
+ color: rgb(var(--color-muted));
129
+ overflow: hidden;
130
+ text-overflow: ellipsis;
131
+ white-space: nowrap;
132
+ }
133
+
134
+ .hk-stat-card-aside {
135
+ margin-left: auto;
136
+ flex-shrink: 0;
137
+ }
138
+
139
+ /* ── ring variant ──────────────────────────────────────────────────────
140
+ Centered utilization ring; the label (with the icon) sits under the
141
+ ring and the hint becomes the detail line. */
142
+ .hk-stat-card-ring {
143
+ align-items: center;
144
+ text-align: center;
145
+ gap: 0.375rem;
146
+ }
147
+
148
+ .hk-stat-card-ring-label {
149
+ display: flex;
150
+ align-items: center;
151
+ gap: var(--space-4, 0.25rem);
152
+ max-width: 100%;
153
+ font-size: var(--text-xs, 0.75rem);
154
+ font-weight: 500;
155
+ color: rgb(var(--color-text));
156
+ }
157
+
158
+ .hk-stat-card-ring-label-text {
159
+ overflow: hidden;
160
+ text-overflow: ellipsis;
161
+ white-space: nowrap;
162
+ }
163
+
164
+ .hk-stat-card-ring-detail {
165
+ max-width: 100%;
166
+ }
167
+
168
+ /* ── bar variant ───────────────────────────────────────────────────────
169
+ Label + value on one row over a tone-colored bar. */
170
+ .hk-stat-card-bar-head {
171
+ display: flex;
172
+ align-items: baseline;
173
+ justify-content: space-between;
174
+ gap: var(--space-8, 0.5rem);
175
+ }
176
+
177
+ .hk-stat-card-bar-value {
178
+ font-size: var(--text-sm, 0.875rem);
179
+ font-weight: 700;
180
+ font-variant-numeric: tabular-nums;
181
+ color: rgb(var(--color-text));
182
+ overflow: hidden;
183
+ text-overflow: ellipsis;
184
+ white-space: nowrap;
185
+ }
186
+
187
+ .hk-stat-card-bar-detail {
188
+ max-width: 100%;
189
+ }
@@ -1,46 +1,198 @@
1
- import { defineComponent, type PropType } from "vue";
1
+ import { defineComponent, h, type Component, type PropType } from "vue";
2
2
 
3
+ import HkGaugeRing from "./HkGaugeRing";
4
+ import HkProgressBar from "./HkProgressBar";
3
5
  import "./HkStatCard.scss";
4
6
 
5
7
  export type StatTone = "success" | "warning" | "error" | "info" | "primary" | "muted";
6
8
 
9
+ /**
10
+ * The standard KPI-cell shapes. Every variant shares the card surface
11
+ * (border, radius, background) and the tone palette; they differ only in
12
+ * how the number is presented:
13
+ *
14
+ * - `plain` — text-forward: label row with a tone dot, big value, optional
15
+ * hint (the original anatomy, still the default).
16
+ * - `chip` — horizontal: a tone-tinted icon chip beside value + label,
17
+ * with an optional trailing `aside` slot (tier badge, delta, …).
18
+ * - `ring` — a single utilization ring with the value inside, the label
19
+ * (with the icon) underneath, and the hint as the detail line.
20
+ * - `bar` — label + value on one row over a tone-colored progress bar,
21
+ * with the hint as the detail line.
22
+ */
23
+ export type StatVariant = "plain" | "chip" | "ring" | "bar";
24
+
25
+ /**
26
+ * Tone → theme-variable CSS color (the exact palette the card paints
27
+ * itself with). Exported so hosts can match sibling chrome — badge
28
+ * variants, inline rings, legend dots — to the card's tone without
29
+ * re-declaring the mapping (and drifting from it).
30
+ */
31
+ export function statToneColor(tone: StatTone): string {
32
+ switch (tone) {
33
+ case "success":
34
+ return "rgb(var(--color-success))";
35
+ case "warning":
36
+ return "rgb(var(--color-warning))";
37
+ case "error":
38
+ return "rgb(var(--color-error))";
39
+ case "info":
40
+ return "rgb(var(--color-info, rgb(var(--color-primary))))";
41
+ case "primary":
42
+ return "rgb(var(--color-primary))";
43
+ case "muted":
44
+ return "rgb(var(--color-muted))";
45
+ }
46
+ }
47
+
48
+ const clampPct = (pct: number | undefined): number =>
49
+ pct == null ? 0 : Math.min(100, Math.max(0, pct));
50
+
7
51
  /**
8
52
  * HkStatCard — one KPI cell for the admin stat grids.
9
53
  *
10
- * Anatomy: label + value + optional hint, with a tone dot carrying the
11
- * health semantics (the text hue never changes — tone is expressed by
12
- * the dot alone, matching the interaction-state precedence rules).
54
+ * Anatomy (plain, the default): label + value + optional hint, with a
55
+ * tone dot carrying the health semantics (the text hue never changes —
56
+ * tone is expressed by the dot alone, matching the interaction-state
57
+ * precedence rules).
13
58
  *
14
59
  * ```tsx
15
- * <HStatCard label="Hub health" value="2/2" tone="success" hint="entelecheia / arona" />
60
+ * <HkStatCard label="Hub health" value="2/2" tone="success" hint="entelecheia / arona" />
61
+ * <HkStatCard variant="chip" icon={Cpu} tone="info" label="Running" value={3} />
62
+ * <HkStatCard variant="ring" tone="success" pct={37.5} value="38%" unit="cores"
63
+ * icon={Cpu} label="Cores" hint="12 / 32 · monthly" />
64
+ * <HkStatCard variant="bar" tone="warning" pct={72} label="Storage" value="72%" hint="47 / 65 GB" />
16
65
  * ```
17
66
  */
18
67
  export const HkStatCard = defineComponent({
19
68
  name: "HkStatCard",
20
69
  props: {
70
+ variant: { type: String as PropType<StatVariant>, default: "plain" },
21
71
  label: { type: String, required: true },
22
72
  value: { type: [String, Number], required: true },
23
73
  tone: { type: String as PropType<StatTone>, default: "muted" },
24
74
  hint: { type: String, default: undefined },
25
75
  /** Whole-card click (drill-through to the owning page). */
26
76
  clickable: { type: Boolean, default: false },
77
+ /** chip/ring: leading icon component (lucide-vue-next style). */
78
+ icon: { type: Object as PropType<Component>, default: undefined },
79
+ /** ring/bar: gauge percentage, clamped to 0–100. */
80
+ pct: { type: Number, default: undefined },
81
+ /** ring: sub-label rendered inside the ring under the value (the unit). */
82
+ unit: { type: String, default: undefined },
27
83
  },
28
- emits: { click: (_e: MouseEvent) => true },
29
- setup(props, { emit }) {
30
- return () => (
31
- <div
32
- class={["hk-stat-card", props.clickable && "hk-stat-card-clickable"]}
33
- role={props.clickable ? "button" : undefined}
34
- tabindex={props.clickable ? 0 : undefined}
35
- onClick={props.clickable ? (e: MouseEvent) => emit("click", e) : undefined}
36
- >
37
- <div class="hk-stat-card-head">
38
- <span class="hk-stat-card-label">{props.label}</span>
39
- <span class={`hk-stat-card-dot hk-stat-card-dot-${props.tone}`} aria-hidden="true" />
84
+ emits: { click: (_e: MouseEvent | KeyboardEvent) => true },
85
+ setup(props, { emit, slots }) {
86
+ return () => {
87
+ const shared = {
88
+ class: [
89
+ "hk-stat-card",
90
+ `hk-stat-card-${props.variant}`,
91
+ props.clickable && "hk-stat-card-clickable",
92
+ ],
93
+ role: props.clickable ? "button" : undefined,
94
+ tabindex: props.clickable ? 0 : undefined,
95
+ onClick: props.clickable ? (e: MouseEvent) => emit("click", e) : undefined,
96
+ // role="button" promises keyboard activation (Enter/Space) — the
97
+ // same contract HkContextRing's clickable ring honors. Key events
98
+ // bubbling from a focusable INSIDE the card (an aside button) are
99
+ // left alone so they don't double-fire the card's click.
100
+ onKeydown: props.clickable
101
+ ? (e: KeyboardEvent) => {
102
+ if (e.target !== e.currentTarget) return;
103
+ if (e.key !== "Enter" && e.key !== " ") return;
104
+ e.preventDefault();
105
+ emit("click", e);
106
+ }
107
+ : undefined,
108
+ };
109
+
110
+ if (props.variant === "chip") {
111
+ return (
112
+ <div {...shared}>
113
+ <div class="hk-stat-card-chip-body">
114
+ {props.icon && (
115
+ <span
116
+ class={`hk-stat-card-chip-icon hk-stat-card-chip-icon-${props.tone}`}
117
+ aria-hidden="true"
118
+ >
119
+ {h(props.icon, { size: 16 })}
120
+ </span>
121
+ )}
122
+ <div class="hk-stat-card-chip-main">
123
+ <div class="hk-stat-card-chip-value">{props.value}</div>
124
+ <div class="hk-stat-card-chip-label">{props.label}</div>
125
+ </div>
126
+ {slots.aside && <div class="hk-stat-card-aside">{slots.aside()}</div>}
127
+ </div>
128
+ {props.hint && <div class="hk-stat-card-hint">{props.hint}</div>}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ if (props.variant === "ring") {
134
+ return (
135
+ <div {...shared}>
136
+ <HkGaugeRing
137
+ size={104}
138
+ strokeWidth={7}
139
+ gap={4}
140
+ animate={false}
141
+ rings={[
142
+ {
143
+ pct: clampPct(props.pct),
144
+ color: statToneColor(props.tone),
145
+ trackColor: "rgb(var(--color-text) / 8%)",
146
+ },
147
+ ]}
148
+ centerValue={String(props.value)}
149
+ centerLabel={props.unit ?? ""}
150
+ />
151
+ <div class="hk-stat-card-ring-label">
152
+ {props.icon && h(props.icon, { size: 13, "aria-hidden": true })}
153
+ <span class="hk-stat-card-ring-label-text">{props.label}</span>
154
+ </div>
155
+ {props.hint && (
156
+ <div class="hk-stat-card-hint hk-stat-card-ring-detail" title={props.hint}>
157
+ {props.hint}
158
+ </div>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ if (props.variant === "bar") {
165
+ return (
166
+ <div {...shared}>
167
+ <div class="hk-stat-card-bar-head">
168
+ <span class="hk-stat-card-label">{props.label}</span>
169
+ <span class="hk-stat-card-bar-value">{props.value}</span>
170
+ </div>
171
+ <HkProgressBar
172
+ max={100}
173
+ size="xs"
174
+ segments={[{ value: clampPct(props.pct), color: statToneColor(props.tone) }]}
175
+ />
176
+ {props.hint && (
177
+ <div class="hk-stat-card-hint hk-stat-card-bar-detail" title={props.hint}>
178
+ {props.hint}
179
+ </div>
180
+ )}
181
+ </div>
182
+ );
183
+ }
184
+
185
+ // plain — the original anatomy, unchanged.
186
+ return (
187
+ <div {...shared}>
188
+ <div class="hk-stat-card-head">
189
+ <span class="hk-stat-card-label">{props.label}</span>
190
+ <span class={`hk-stat-card-dot hk-stat-card-dot-${props.tone}`} aria-hidden="true" />
191
+ </div>
192
+ <div class="hk-stat-card-value">{props.value}</div>
193
+ {props.hint && <div class="hk-stat-card-hint" title={props.hint}>{props.hint}</div>}
40
194
  </div>
41
- <div class="hk-stat-card-value">{props.value}</div>
42
- {props.hint && <div class="hk-stat-card-hint" title={props.hint}>{props.hint}</div>}
43
- </div>
44
- );
195
+ );
196
+ };
45
197
  },
46
198
  });
@@ -0,0 +1,210 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h, defineComponent, nextTick } from "vue";
3
+
4
+ import { HkStatCard, statToneColor } from "./HkStatCard";
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
+ /** A stand-in lucide-style icon component (functional, size prop). */
25
+ const FakeIcon = defineComponent({
26
+ name: "FakeIcon",
27
+ props: { size: { type: [Number, String], default: 24 } },
28
+ setup: (props) => () => h("svg", { "data-fake-icon": "", width: props.size, height: props.size }),
29
+ });
30
+
31
+ describe("HkStatCard plain (default) — the original anatomy is preserved", () => {
32
+ it("renders the label/dot/value/hint anatomy without a variant class", () => {
33
+ const c = mount(h(HkStatCard, { label: "Hub health", value: "2/2", tone: "success", hint: "hubs" }));
34
+ const card = c.querySelector(".hk-stat-card") as HTMLElement;
35
+ expect(card.className).toContain("hk-stat-card-plain");
36
+ expect(c.querySelector(".hk-stat-card-head")).toBeTruthy();
37
+ expect(c.querySelector(".hk-stat-card-dot-success")).toBeTruthy();
38
+ expect(c.querySelector(".hk-stat-card-value")?.textContent).toBe("2/2");
39
+ expect(c.querySelector(".hk-stat-card-hint")?.textContent).toBe("hubs");
40
+ // plain renders no chip/ring/bar anatomy
41
+ expect(c.querySelector(".hk-stat-card-chip-body")).toBeNull();
42
+ expect(c.querySelector(".hk-gauge-ring")).toBeNull();
43
+ expect(c.querySelector(".hk-progress-bar")).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe("HkStatCard chip", () => {
48
+ it("renders the tinted icon chip beside value-over-label", () => {
49
+ const c = mount(
50
+ h(HkStatCard, { variant: "chip", icon: FakeIcon, tone: "info", label: "Running", value: 3 }),
51
+ );
52
+ const card = c.querySelector(".hk-stat-card") as HTMLElement;
53
+ expect(card.className).toContain("hk-stat-card-chip");
54
+ const chip = c.querySelector(".hk-stat-card-chip-icon") as HTMLElement;
55
+ expect(chip).toBeTruthy();
56
+ expect(chip.className).toContain("hk-stat-card-chip-icon-info");
57
+ expect(chip.querySelector("svg[data-fake-icon]")).toBeTruthy();
58
+ expect(c.querySelector(".hk-stat-card-chip-value")?.textContent).toBe("3");
59
+ expect(c.querySelector(".hk-stat-card-chip-label")?.textContent).toBe("Running");
60
+ // no tone dot on the chip variant — the chip itself carries the tone
61
+ expect(c.querySelector(".hk-stat-card-dot")).toBeNull();
62
+ });
63
+
64
+ it("omits the icon chip node when no icon is given", () => {
65
+ const c = mount(h(HkStatCard, { variant: "chip", label: "L", value: 1 }));
66
+ expect(c.querySelector(".hk-stat-card-chip-icon")).toBeNull();
67
+ });
68
+
69
+ it("renders the aside slot at the trailing edge", () => {
70
+ const c = mount(
71
+ h(HkStatCard, { variant: "chip", label: "Balance", value: 20 }, { aside: () => h("span", { class: "aside-probe" }, "basic") }),
72
+ );
73
+ expect(c.querySelector(".hk-stat-card-aside .aside-probe")?.textContent).toBe("basic");
74
+ });
75
+
76
+ it("keeps the whole-card click affordance", () => {
77
+ const c = mount(h(HkStatCard, { variant: "chip", label: "L", value: 1, clickable: true }));
78
+ const el = c.querySelector(".hk-stat-card") as HTMLElement;
79
+ expect(el.getAttribute("role")).toBe("button");
80
+ expect(el.className).toContain("hk-stat-card-clickable");
81
+ });
82
+ });
83
+
84
+ describe("HkStatCard ring", () => {
85
+ it("renders one tone-colored ring with the value and unit inside", () => {
86
+ const c = mount(
87
+ h(HkStatCard, {
88
+ variant: "ring", tone: "success", pct: 37.5, value: "38%", unit: "cores",
89
+ icon: FakeIcon, label: "Cores", hint: "12 / 32 · monthly",
90
+ }),
91
+ );
92
+ const card = c.querySelector(".hk-stat-card") as HTMLElement;
93
+ expect(card.className).toContain("hk-stat-card-ring");
94
+ // exactly one ring block, painted with the tone palette
95
+ expect(c.querySelectorAll(".hk-gauge-ring").length).toBe(1);
96
+ expect(c.querySelector(".hk-gauge-ring-value")?.textContent).toBe("38%");
97
+ expect(c.querySelector(".hk-gauge-ring-label")?.textContent).toBe("cores");
98
+ expect(c.querySelector(".hk-stat-card-ring-label-text")?.textContent).toBe("Cores");
99
+ expect(c.querySelector(".hk-stat-card-ring-label svg[data-fake-icon]")).toBeTruthy();
100
+ expect(c.querySelector(".hk-stat-card-ring-detail")?.textContent).toBe("12 / 32 · monthly");
101
+ });
102
+
103
+ it("clamps an over-range pct to a full gauge", () => {
104
+ const c = mount(h(HkStatCard, { variant: "ring", pct: 140, value: "140%", label: "L" }));
105
+ // The dash offset must reflect a clamped 100% fill, not an overshoot:
106
+ // a clamped ring draws dashoffset 0.
107
+ const arc = c.querySelector(".hk-gauge-ring circle[stroke-dasharray]") as SVGElement;
108
+ expect(arc).toBeTruthy();
109
+ expect(arc.getAttribute("stroke-dashoffset")).toBe("0");
110
+ });
111
+
112
+ it("clamps a negative pct to an empty gauge (the guard HkGaugeRing does not provide)", () => {
113
+ // HkGaugeRing only clamps the UPPER bound internally (Math.min(pct, 100)),
114
+ // so a negative pct surviving this component would draw dashoffset
115
+ // > circumference — an arc longer than the whole ring. This assertion is
116
+ // what kills a mutation that drops clampPct's lower clamp.
117
+ const c = mount(h(HkStatCard, { variant: "ring", pct: -20, value: "-20%", label: "L" }));
118
+ const arc = c.querySelector(".hk-gauge-ring circle[stroke-dasharray]") as SVGElement;
119
+ expect(arc).toBeTruthy();
120
+ const circumference = 2 * Math.PI * ((104 - 7) / 2);
121
+ const offset = Number(arc.getAttribute("stroke-dashoffset"));
122
+ expect(Math.abs(offset - circumference)).toBeLessThan(0.01);
123
+ });
124
+
125
+ it("treats a missing pct as an empty gauge, not NaN", () => {
126
+ const c = mount(h(HkStatCard, { variant: "ring", value: "—", label: "L" }));
127
+ const arc = c.querySelector(".hk-gauge-ring circle[stroke-dasharray]") as SVGElement;
128
+ expect(arc.getAttribute("stroke-dashoffset")).not.toContain("NaN");
129
+ });
130
+
131
+ it("paints the ring through inline stroke styles, not only the presentation attribute", () => {
132
+ // var() in an SVG presentation attribute is not substituted on every
133
+ // engine (Firefox drops the declaration → stroke:none → an invisible
134
+ // gauge), so the tone color must ALSO ride the inline style — the same
135
+ // contract HkProgressRing documents for its segment color.
136
+ const c = mount(
137
+ h(HkStatCard, { variant: "ring", tone: "info", pct: 40, value: "40%", label: "L" }),
138
+ );
139
+ const [track, arc] = c.querySelectorAll(".hk-gauge-ring circle") as NodeListOf<SVGCircleElement>;
140
+ expect(track.getAttribute("stroke")).toBe("rgb(var(--color-text) / 8%)");
141
+ expect(track.style.stroke).toBe("rgb(var(--color-text) / 8%)");
142
+ expect(arc.getAttribute("stroke")).toBe(statToneColor("info"));
143
+ expect(arc.style.stroke).toBe(statToneColor("info"));
144
+ });
145
+ });
146
+
147
+ describe("HkStatCard clickable — role=button keyboard contract", () => {
148
+ it("activates on Enter and Space like a native button", async () => {
149
+ const clicks: string[] = [];
150
+ const c = mount(
151
+ h(HkStatCard, {
152
+ variant: "chip", value: 1, label: "L", clickable: true,
153
+ onClick: (e: MouseEvent | KeyboardEvent) => clicks.push("key" in e ? e.key : "mouse"),
154
+ }),
155
+ );
156
+ const card = c.querySelector(".hk-stat-card") as HTMLElement;
157
+ for (const key of ["Enter", " "]) {
158
+ card.dispatchEvent(
159
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
160
+ );
161
+ await nextTick();
162
+ }
163
+ expect(clicks).toEqual(["Enter", " "]);
164
+ });
165
+
166
+ it("ignores keys bubbling from a focusable inside the card", async () => {
167
+ const clicks: unknown[] = [];
168
+ const c = mount(
169
+ h(HkStatCard, { variant: "chip", value: 1, label: "L", clickable: true, onClick: () => clicks.push(1) }, {
170
+ aside: () => h("button", { class: "aside-btn-probe" }, "x"),
171
+ }),
172
+ );
173
+ const inner = c.querySelector(".aside-btn-probe") as HTMLElement;
174
+ inner.dispatchEvent(
175
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
176
+ );
177
+ await nextTick();
178
+ expect(clicks.length).toBe(0);
179
+ });
180
+ });
181
+
182
+ describe("HkStatCard bar", () => {
183
+ it("renders label+value over a tone-colored segmented fill", () => {
184
+ const c = mount(
185
+ h(HkStatCard, { variant: "bar", tone: "warning", pct: 72, label: "Storage", value: "72%", hint: "47 / 65 GB" }),
186
+ );
187
+ const card = c.querySelector(".hk-stat-card") as HTMLElement;
188
+ expect(card.className).toContain("hk-stat-card-bar");
189
+ expect(c.querySelector(".hk-stat-card-bar-head .hk-stat-card-label")?.textContent).toBe("Storage");
190
+ expect(c.querySelector(".hk-stat-card-bar-value")?.textContent).toBe("72%");
191
+ const seg = c.querySelector(".hk-progress-bar-seg") as HTMLElement;
192
+ expect(seg).toBeTruthy();
193
+ expect(seg.style.width).toBe("72%");
194
+ expect(seg.style.background).toBe("rgb(var(--color-warning))");
195
+ expect(c.querySelector(".hk-stat-card-bar-detail")?.textContent).toBe("47 / 65 GB");
196
+ });
197
+ });
198
+
199
+ describe("statToneColor", () => {
200
+ it("maps every tone to the theme-variable palette the card paints with", () => {
201
+ expect(statToneColor("success")).toBe("rgb(var(--color-success))");
202
+ expect(statToneColor("warning")).toBe("rgb(var(--color-warning))");
203
+ expect(statToneColor("error")).toBe("rgb(var(--color-error))");
204
+ expect(statToneColor("primary")).toBe("rgb(var(--color-primary))");
205
+ expect(statToneColor("muted")).toBe("rgb(var(--color-muted))");
206
+ // info falls back to primary when the host palette omits it — the
207
+ // same fallback the SCSS dot uses.
208
+ expect(statToneColor("info")).toBe("rgb(var(--color-info, rgb(var(--color-primary))))");
209
+ });
210
+ });