@celestia-island/hikari 0.55.279 → 0.55.281

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.279",
3
+ "version": "0.55.281",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -13,6 +13,12 @@ export type CornerBadgeVariant =
13
13
  | "info"
14
14
  | "muted";
15
15
 
16
+ /** Which corner of the anchor the mark hangs on. Exported so hosts that
17
+ * embed the shell (HkTabs' trigger badge) share the one vocabulary
18
+ * instead of re-spelling the union; HkIconButton's dot borrows the tone
19
+ * channels but is an inset span of its own, not a corner mark. */
20
+ export type CornerBadgeCorner = "top-right" | "top-left" | "bottom-right" | "bottom-left";
21
+
16
22
  /**
17
23
  * HkCornerBadge — the generic corner mark for ANY anchor (user
18
24
  * direction 2026-10-15): a green/red/traffic dot, a notification count
@@ -44,7 +50,7 @@ export default defineComponent({
44
50
  text: { type: String, default: undefined },
45
51
  /** Which corner of the anchor. */
46
52
  corner: {
47
- type: String as PropType<"top-right" | "top-left" | "bottom-right" | "bottom-left">,
53
+ type: String as PropType<CornerBadgeCorner>,
48
54
  default: "top-right",
49
55
  },
50
56
  /** Extra clearance from the corner in px (0 = sit ON the corner,
@@ -0,0 +1,97 @@
1
+ @use "../styles/polyfill" as *;
2
+
3
+ /* HkModelCard — the dense model card for tier-grouped model grids: a
4
+ leading identity lane (the consumer's model tag), an optional actions
5
+ lane (the serving switch), an optional caption (which provider a
6
+ merged card came from) and an optional footer (usage + capability
7
+ hints). The tier rides the card as an inset leading stripe; disabled
8
+ dims without killing the controls inside.
9
+
10
+ Motion contract: hover feedback is BORDER COLOR ONLY. The card never
11
+ transforms — a hover lift displaces nothing, reflows nothing, and
12
+ (2026-11-04 user direction) never animates the card upward. */
13
+
14
+ .hk-model-card {
15
+ --hk-model-card-accent: transparent;
16
+
17
+ display: flex;
18
+ flex-direction: column;
19
+ gap: var(--space-4);
20
+ padding: var(--space-6) var(--space-8);
21
+ border: 1px solid var(--border-subtle);
22
+ border-radius: var(--radius-md);
23
+ background: rgb(var(--color-muted) / 4%);
24
+ min-width: 0;
25
+ box-shadow: inset 2px 0 0 0 var(--hk-model-card-accent);
26
+ }
27
+
28
+ .hk-model-card[data-tier="deep"] {
29
+ --hk-model-card-accent: rgb(var(--color-primary));
30
+ }
31
+
32
+ .hk-model-card[data-tier="normal"] {
33
+ --hk-model-card-accent: rgb(var(--color-success));
34
+ }
35
+
36
+ .hk-model-card[data-tier="basic"] {
37
+ --hk-model-card-accent: rgb(var(--color-muted) / 45%);
38
+ }
39
+
40
+ .hk-model-card[data-hoverable] {
41
+ transition: border-color var(--duration-normal) ease;
42
+ }
43
+
44
+ .hk-model-card[data-hoverable]:hover {
45
+ border-color: rgb(var(--color-muted) / 30%);
46
+ }
47
+
48
+ .hk-model-card[data-interactive] {
49
+ cursor: pointer;
50
+ @include user-select(none);
51
+ }
52
+
53
+ .hk-model-card[data-interactive]:focus-visible {
54
+ outline: 2px solid rgb(var(--color-primary));
55
+ outline-offset: 2px;
56
+ }
57
+
58
+ .hk-model-card[data-disabled] {
59
+ opacity: 0.5;
60
+ --hk-model-card-accent: transparent;
61
+ }
62
+
63
+ .hk-model-card-top {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: space-between;
67
+ gap: var(--space-6);
68
+ min-width: 0;
69
+ }
70
+
71
+ .hk-model-card-leading {
72
+ min-width: 0;
73
+ }
74
+
75
+ .hk-model-card-actions {
76
+ display: inline-flex;
77
+ align-items: center;
78
+ gap: var(--space-2);
79
+ flex-shrink: 0;
80
+ }
81
+
82
+ .hk-model-card-caption {
83
+ font-size: var(--text-2xs);
84
+ color: rgb(var(--color-muted));
85
+ overflow: hidden;
86
+ text-overflow: ellipsis;
87
+ white-space: nowrap;
88
+ }
89
+
90
+ .hk-model-card-footer {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: space-between;
94
+ gap: var(--space-6);
95
+ min-width: 0;
96
+ color: rgb(var(--color-muted));
97
+ }
@@ -0,0 +1,142 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, ref } from "vue";
3
+
4
+ import HkModelCard from "./HkModelCard";
5
+
6
+ /**
7
+ * HkModelCard anatomy + state guards.
8
+ *
9
+ * (Repo test convention: raw createApp + document queries, no
10
+ * @vue/test-utils dependency.)
11
+ */
12
+
13
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
14
+
15
+ function mountCard(
16
+ props: Record<string, unknown> = {},
17
+ slots: Record<string, () => unknown> = {},
18
+ ): HTMLElement {
19
+ const container = document.createElement("div");
20
+ document.body.appendChild(container);
21
+ const Wrapper = defineComponent({
22
+ setup() {
23
+ return () => h(HkModelCard, props, slots);
24
+ },
25
+ });
26
+ const app = createApp(Wrapper);
27
+ app.mount(container);
28
+ mounts.push({ app, container });
29
+ const root = container.querySelector<HTMLElement>(".hk-model-card");
30
+ expect(root, ".hk-model-card renders").toBeTruthy();
31
+ return root!;
32
+ }
33
+
34
+ afterEach(() => {
35
+ for (const { app, container } of mounts) {
36
+ app.unmount();
37
+ container.remove();
38
+ }
39
+ mounts.length = 0;
40
+ });
41
+
42
+ describe("HkModelCard anatomy", () => {
43
+ it("renders the top lane with title and actions content", () => {
44
+ const root = mountCard(
45
+ {},
46
+ {
47
+ title: () => h("span", { class: "t" }, "GLM 5.3"),
48
+ actions: () => h("button", { class: "a" }, "on"),
49
+ },
50
+ );
51
+ expect(root.querySelector(".hk-model-card-top")).toBeTruthy();
52
+ expect(root.querySelector(".hk-model-card-leading .t")?.textContent).toBe("GLM 5.3");
53
+ expect(root.querySelector(".hk-model-card-actions .a")).toBeTruthy();
54
+ });
55
+
56
+ it("collapses caption and footer when their slots are empty", () => {
57
+ const root = mountCard({}, { title: () => h("span", "m") });
58
+ expect(root.querySelector(".hk-model-card-caption")).toBeNull();
59
+ expect(root.querySelector(".hk-model-card-footer")).toBeNull();
60
+ });
61
+
62
+ it("renders caption and footer lanes when they carry content", () => {
63
+ const root = mountCard(
64
+ {},
65
+ {
66
+ title: () => h("span", "m"),
67
+ caption: () => "zai",
68
+ footer: () => h("span", { class: "f" }, "200.0k"),
69
+ },
70
+ );
71
+ expect(root.querySelector(".hk-model-card-caption")?.textContent).toBe("zai");
72
+ expect(root.querySelector(".hk-model-card-footer .f")?.textContent).toBe("200.0k");
73
+ });
74
+
75
+ it("treats a comment-placeholder slot (consumer v-if) as empty", () => {
76
+ const show = ref(false);
77
+ const root = mountCard(
78
+ {},
79
+ {
80
+ title: () => h("span", "m"),
81
+ caption: () => (show.value ? h("span", "shown") : null),
82
+ },
83
+ );
84
+ // A null-render slot produces only the comment placeholder — the
85
+ // caption lane must collapse, not render an empty row.
86
+ expect(root.querySelector(".hk-model-card-caption")).toBeNull();
87
+ });
88
+
89
+ it("treats a fragment of dead v-if comments as empty (the JSX <>{cond && …}</> shape)", () => {
90
+ const show = ref(false);
91
+ const slotsOf = () => ({
92
+ title: () => h("span", "m"),
93
+ // The compiled shape of a consumer's conditional footer is a
94
+ // Fragment vnode whose children are dead comment placeholders —
95
+ // the guard must recurse into it, not stop at the Fragment.
96
+ footer: () => (<>{show.value && <span class="live" />}</>),
97
+ });
98
+ // All-dead fragment → the footer lane collapses.
99
+ expect(mountCard({}, slotsOf()).querySelector(".hk-model-card-footer")).toBeNull();
100
+ // A fragment with live content still renders its lane.
101
+ show.value = true;
102
+ const live = mountCard({}, slotsOf());
103
+ expect(live.querySelector(".hk-model-card-footer .live")?.textContent).toBe("");
104
+ });
105
+ });
106
+
107
+ describe("HkModelCard state attributes", () => {
108
+ it("lands the tier as data-tier", () => {
109
+ expect(mountCard({ tier: "deep" }, {}).dataset.tier).toBe("deep");
110
+ expect(mountCard({ tier: "normal" }, {}).dataset.tier).toBe("normal");
111
+ expect(mountCard({ tier: "basic" }, {}).dataset.tier).toBe("basic");
112
+ });
113
+
114
+ it("an absent tier lands no data-tier at all", () => {
115
+ expect(mountCard({}, {}).hasAttribute("data-tier")).toBe(false);
116
+ });
117
+
118
+ it("hoverable is the default and drops out when disabled", () => {
119
+ expect(mountCard({}, {}).hasAttribute("data-hoverable")).toBe(true);
120
+ expect(mountCard({ disabled: true }, {}).hasAttribute("data-hoverable")).toBe(false);
121
+ expect(mountCard({ hoverable: false }, {}).hasAttribute("data-hoverable")).toBe(false);
122
+ });
123
+
124
+ it("disabled lands the dim marker, interactive lands its marker", () => {
125
+ expect(mountCard({ disabled: true }, {}).hasAttribute("data-disabled")).toBe(true);
126
+ expect(mountCard({ interactive: true }, {}).hasAttribute("data-interactive")).toBe(true);
127
+ // interactive + disabled: the dim wins, the clickability marker goes.
128
+ expect(mountCard({ interactive: true, disabled: true }, {}).hasAttribute("data-interactive")).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe("HkModelCard interaction", () => {
133
+ it("emits click with the native event", () => {
134
+ let seen: MouseEvent | null = null;
135
+ const root = mountCard(
136
+ { onClick: (e: MouseEvent) => (seen = e) },
137
+ { title: () => h("span", "m") },
138
+ );
139
+ root.querySelector<HTMLElement>(".hk-model-card-leading")!.click();
140
+ expect(seen).toBeInstanceOf(MouseEvent);
141
+ });
142
+ });
@@ -0,0 +1,80 @@
1
+ import { defineComponent, type PropType } from "vue";
2
+ import { Comment, Fragment, type VNode } from "vue";
3
+
4
+ import "./HkModelCard.scss";
5
+
6
+ /** The capability tier a model card belongs to — the family's shared
7
+ * vocabulary (arona serves it on every roster row, the consumers group
8
+ * by it). The tier only drives the inset accent stripe; the card never
9
+ * interprets it beyond color. */
10
+ export type ModelCardTier = "deep" | "normal" | "basic";
11
+
12
+ /** Whether a slot actually rendered anything. Testing the slot function
13
+ * for truthiness is not enough: a consumer's `v-if` slot renders a
14
+ * comment placeholder vnode, and a consumer's `<>{cond && …}</>`
15
+ * fragment compiles to a Fragment vnode carrying those same dead
16
+ * comments — both must read as empty (same guard as HkCardList's
17
+ * renderedRows, recursed into fragments). */
18
+ function hasRenderedContent(nodes: unknown): boolean {
19
+ if (nodes == null || nodes === false || nodes === "") return false;
20
+ if (Array.isArray(nodes)) return nodes.some((n) => hasRenderedContent(n));
21
+ if (typeof nodes === "object") {
22
+ const node = nodes as VNode;
23
+ if (node.type === Fragment) return hasRenderedContent(node.children);
24
+ return node.type !== Comment;
25
+ }
26
+ return true; // non-empty text content
27
+ }
28
+
29
+ export default defineComponent({
30
+ name: "HkModelCard",
31
+ props: {
32
+ /** Capability tier accent (inset leading stripe). Absent = no stripe. */
33
+ tier: { type: String as PropType<ModelCardTier>, default: undefined },
34
+ /** Dimmed, accent-free, hover-inert presentation for a switched-off
35
+ * model. Deliberately NOT pointer-events: none — the row's controls
36
+ * (the serving switch) stay operable exactly to un-disable it. */
37
+ disabled: { type: Boolean, default: false },
38
+ /** Border-emphasis hover feedback. The house standard is color only —
39
+ * the card never transforms, so hovering never displaces neighbors
40
+ * or reflows the grid (2026-11-04 user direction). */
41
+ hoverable: { type: Boolean, default: true },
42
+ /** Clickable-card presentation (cursor + focus ring) for surfaces
43
+ * that cycle state on click. Pure presentation — the consumer owns
44
+ * the actual click semantics and a11y wiring. */
45
+ interactive: { type: Boolean, default: false },
46
+ },
47
+ emits: {
48
+ click: (_e: MouseEvent) => true,
49
+ },
50
+ setup(props, { emit, slots }) {
51
+ return () => {
52
+ const caption = slots.caption?.();
53
+ const footer = slots.footer?.();
54
+ const actions = slots.actions?.();
55
+ return (
56
+ <div
57
+ class="hk-model-card"
58
+ data-tier={props.tier}
59
+ data-disabled={props.disabled || undefined}
60
+ data-hoverable={props.hoverable && !props.disabled ? "" : undefined}
61
+ data-interactive={props.interactive && !props.disabled ? "" : undefined}
62
+ onClick={(e: MouseEvent) => emit("click", e)}
63
+ >
64
+ <div class="hk-model-card-top">
65
+ <div class="hk-model-card-leading">{slots.title?.()}</div>
66
+ {hasRenderedContent(actions) && (
67
+ <div class="hk-model-card-actions">{actions}</div>
68
+ )}
69
+ </div>
70
+ {hasRenderedContent(caption) && (
71
+ <div class="hk-model-card-caption">{caption}</div>
72
+ )}
73
+ {hasRenderedContent(footer) && (
74
+ <div class="hk-model-card-footer">{footer}</div>
75
+ )}
76
+ </div>
77
+ );
78
+ };
79
+ },
80
+ });
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
6
6
 
7
7
  import HkTabs from "./HkTabs";
8
+ import type { TabItem } from "./HkTabs";
8
9
  import { CROSSFADE_WATCHDOG_MS } from "./HkCrossfade";
9
10
  import { DEFAULT_ANIMATION_DURATION_MS } from "../composables/useAnimationLoop";
10
11
 
@@ -661,3 +662,111 @@ describe("HkTabs panel morph (HkTransitionContainer consumption)", () => {
661
662
  expect(container.querySelector(".hk-tabs-panel")).toBeNull();
662
663
  });
663
664
  });
665
+
666
+ describe("HkTabs trigger corner badge", () => {
667
+ /** Mount a two-tab strip with an optional badge on the first option. */
668
+ function mountBadge(badge: TabItem["badge"]): { container: HTMLElement } {
669
+ const active = ref("a");
670
+ const container = document.createElement("div");
671
+ document.body.appendChild(container);
672
+ containers.push(container);
673
+ const Wrapper = defineComponent({
674
+ setup() {
675
+ return () =>
676
+ h(HkTabs, {
677
+ variant: "segmented",
678
+ tabs: [
679
+ { key: "a", label: "Alpha", badge },
680
+ { key: "b", label: "Beta" },
681
+ ],
682
+ renderPanels: false,
683
+ modelValue: active.value,
684
+ "onUpdate:modelValue": (v: string) => { active.value = v; },
685
+ });
686
+ },
687
+ });
688
+ const app = createApp(Wrapper);
689
+ mounts.push(app);
690
+ app.mount(container);
691
+ return { container };
692
+ }
693
+
694
+ it("renders the count through HkCornerBadge inside the trigger (no hand-rolled suffix)", () => {
695
+ const { container } = mountBadge({ count: 3, label: "3 items" });
696
+ const trig = container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[0]!;
697
+ // The mark is the family's corner-badge pill, anchored bottom-right
698
+ // (the subscript corner) of the label, not a "(3)" text suffix.
699
+ const mark = trig.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
700
+ expect(mark).not.toBeNull();
701
+ expect(mark.textContent).toBe("3");
702
+ expect(mark.dataset.corner).toBe("bottom-right");
703
+ expect(trig.textContent).not.toContain("(3)");
704
+ // Default clearance: the straddling pill must stay inside the
705
+ // scroller's 6px vertical clearance below the label (see
706
+ // TabItem.badge.offset) — the offset var pins that contract.
707
+ expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("4px");
708
+ // The sibling trigger stays badge-free.
709
+ expect(container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[1]!.querySelector(".hk-corner-badge")).toBeNull();
710
+ });
711
+
712
+ it("keeps zero visible (presence count, not notification noise)", () => {
713
+ const { container } = mountBadge({ count: 0, label: "0 items" });
714
+ const mark = container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
715
+ expect(mark.textContent).toBe("0");
716
+ });
717
+
718
+ it("collapses past max to the N+ form and names the mark for assistive tech", () => {
719
+ const { container } = mountBadge({ count: 120, max: 99, label: "120 pending" });
720
+ const mark = container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
721
+ expect(mark.textContent).toBe("99+");
722
+ expect(mark.getAttribute("role")).toBe("img");
723
+ expect(mark.getAttribute("aria-label")).toBe("120 pending");
724
+ });
725
+
726
+ it("follows a live count change without a remount (presence stays current)", async () => {
727
+ const active = ref("a");
728
+ const tabs = ref<TabItem[]>([{ key: "a", label: "Alpha", badge: { count: 1, label: "1 item" } }]);
729
+ const container = document.createElement("div");
730
+ document.body.appendChild(container);
731
+ containers.push(container);
732
+ const Wrapper = defineComponent({
733
+ setup() {
734
+ return () =>
735
+ h(HkTabs, {
736
+ variant: "segmented",
737
+ tabs: tabs.value,
738
+ renderPanels: false,
739
+ modelValue: active.value,
740
+ "onUpdate:modelValue": (v: string) => { active.value = v; },
741
+ });
742
+ },
743
+ });
744
+ const app = createApp(Wrapper);
745
+ mounts.push(app);
746
+ app.mount(container);
747
+ const mark = () => container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
748
+ expect(mark().textContent).toBe("1");
749
+ tabs.value = [{ key: "a", label: "Alpha", badge: { count: 12, label: "12 items" } }];
750
+ await nextTick();
751
+ expect(mark().textContent).toBe("12");
752
+ });
753
+
754
+ it("warns in DEV when a badge lacks an accessible label", () => {
755
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
756
+ try {
757
+ mountBadge({ count: 3 });
758
+ const alarms = warn.mock.calls.filter((c) => String(c[0]).includes("badge without a label"));
759
+ expect(alarms.length, "the a11y drift alarm must fire").toBeGreaterThan(0);
760
+ expect(alarms[0]![1]).toBe("a");
761
+ } finally {
762
+ warn.mockRestore();
763
+ }
764
+ });
765
+
766
+ it("leaves badgeless triggers free of the badge anchor (plain label span render)", () => {
767
+ const { container } = mountBadge(undefined);
768
+ expect(container.querySelector(".hk-corner-badge-anchor")).toBeNull();
769
+ const label = container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[0]!.querySelector("span")!;
770
+ expect(label.textContent).toBe("Alpha");
771
+ });
772
+ });
@@ -5,6 +5,8 @@ import HkScrollContainer from "./HkScrollContainer";
5
5
  import HkHoverRevealAction from "./HkHoverRevealAction";
6
6
  import HkIconButton from "./HkIconButton";
7
7
  import HkTransitionContainer from "./HkTransitionContainer";
8
+ import HkCornerBadge from "./HkCornerBadge";
9
+ import type { CornerBadgeCorner, CornerBadgeVariant } from "./HkCornerBadge";
8
10
  import { iconByName } from "../composables/iconRegistry";
9
11
  import { useI18n } from "../i18n/context";
10
12
  import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
@@ -18,6 +20,39 @@ export interface TabItem {
18
20
  /** Optional icon vnode rendered before the label (the `icon-${key}`
19
21
  * slot wins when provided). */
20
22
  icon?: unknown;
23
+ /** Corner-count badge rendered on the trigger through HkCornerBadge —
24
+ * the ONE sup/sub decoration shell (user direction 2026-11-05:
25
+ * hand-rolled "(n)" count suffixes baked into labels are being
26
+ * replaced by the standard corner mark, and triggers are badge hosts
27
+ * like every other button). Zero stays visible: a tab count is
28
+ * presence information (the "(0)" the inline suffix carried), not
29
+ * notification noise — pass no badge to hide it entirely. The badge
30
+ * anchors the LABEL, so a tab with an empty label renders no badge
31
+ * either; pass `label` (an accessible name like "{n} active") or the
32
+ * mark is aria-hidden decoration and the count is announced nowhere
33
+ * (a DEV warning fires — a silent a11y regression against the inline
34
+ * suffix it replaces must at least be loud). */
35
+ badge?: {
36
+ count: number;
37
+ /** "N+" ceiling (HkCornerBadge.max). */
38
+ max?: number;
39
+ /** Which corner of the LABEL the mark hangs on — the subscript
40
+ * corner (bottom-right) by default, per the same direction. */
41
+ corner?: CornerBadgeCorner;
42
+ /** Tone channel (HkCornerBadge.variant names, default "primary"). */
43
+ variant?: CornerBadgeVariant;
44
+ /** Corner clearance in px (HkCornerBadge.offset). Default 4: the
45
+ * pill protrudes half its 18px height past the label box, and the
46
+ * strip's vertical clearance below the label is trigger padding
47
+ * (4px) + list padding (2px) = 6px before the scroller viewport
48
+ * clips — offset 4 keeps the protrusion at 5px, inside that
49
+ * clearance. 0 sits ON the corner and clips ~3px on the default
50
+ * scrollable strip; raise it if your host adds no extra padding. */
51
+ offset?: number;
52
+ /** Accessible name for the mark — strongly expected (DEV-warned
53
+ * when missing). */
54
+ label?: string;
55
+ };
21
56
  disabled?: boolean;
22
57
  /**
23
58
  * Slot-unit width for block (row-filling) strips: how many equal
@@ -202,6 +237,22 @@ export default defineComponent({
202
237
  props.tabs.map((tb) => tb.key).join(", "),
203
238
  );
204
239
  }
240
+ if (import.meta.env.DEV) {
241
+ // A badge the strip renders as aria-hidden decoration is a
242
+ // SILENT a11y regression against the inline "(n)" suffix it
243
+ // replaces — the count was part of the accessible name before
244
+ // and is announced nowhere now. Same tier as the duplicate-key
245
+ // drift alarm above.
246
+ for (const tb of props.tabs) {
247
+ if (tb.badge && !tb.badge.label) {
248
+ // eslint-disable-next-line no-console -- dev-only drift alarm
249
+ console.warn(
250
+ "[HkTabs] tab badge without a label is aria-hidden — pass badge.label so the count is announced:",
251
+ tb.key,
252
+ );
253
+ }
254
+ }
255
+ }
205
256
  return list;
206
257
  });
207
258
  /** Merged keys actually present in `tabs`, in tab order. */
@@ -546,7 +597,28 @@ export default defineComponent({
546
597
  }}
547
598
  >
548
599
  {icon}
549
- {tab.label && <span>{tab.label}</span>}
600
+ {/* The badge anchors the LABEL (inside the trigger's own
601
+ * padding box) rather than the button box: the mark reads
602
+ * as part of the control's silhouette (the HkIconButton
603
+ * dot's 2026-10-28 lesson, count form). Clipping is kept
604
+ * honest by the offset default: the pill straddles the
605
+ * label's corner and the scroller viewport clips at
606
+ * 6px below the label — see TabItem.badge.offset. */}
607
+ {tab.label && (tab.badge ? (
608
+ <HkCornerBadge
609
+ count={tab.badge.count}
610
+ max={tab.badge.max}
611
+ showZero
612
+ corner={tab.badge.corner ?? "bottom-right"}
613
+ offset={tab.badge.offset ?? 4}
614
+ variant={tab.badge.variant ?? "primary"}
615
+ label={tab.badge.label}
616
+ >
617
+ <span>{tab.label}</span>
618
+ </HkCornerBadge>
619
+ ) : (
620
+ <span>{tab.label}</span>
621
+ ))}
550
622
  </button>,
551
623
  );
552
624
  });
package/src/index.ts CHANGED
@@ -817,6 +817,7 @@ export { default as HkMenuActionItem } from "./components/HkMenuActionItem";
817
817
  export { default as HkMenuIdentityItem } from "./components/HkMenuIdentityItem";
818
818
  export { default as HkMenuSection } from "./components/HkMenuSection";
819
819
  export { default as HkTagDotRow } from "./components/HkTagDotRow";
820
+ export { default as HkModelCard, type ModelCardTier } from "./components/HkModelCard";
820
821
  export { default as HkRowActions } from "./components/HkRowActions";
821
822
  export { default as HkPopupSelect } from "./components/HkPopupSelect";
822
823
  export { default as HkProgressBar } from "./components/HkProgressBar";