@celestia-island/hikari 0.55.138 → 0.55.140

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.138",
3
+ "version": "0.55.140",
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,79 @@
1
+ /**
2
+ * HkFilterChip chrome — the facet filter chip (trigger) and its popup
3
+ * body. The pill group itself is HkPillToggleGroup's. Ported from the
4
+ * wowsp 水表查询 filter bar's grammar: a solid-surface chip (never a
5
+ * bare alpha tint — a lone chip over wallpaper reads as a hole).
6
+ */
7
+
8
+ .hk-filter-chip-anchor {
9
+ position: relative;
10
+ display: inline-flex;
11
+ align-items: center;
12
+ }
13
+
14
+ .hk-filter-chip {
15
+ display: inline-flex;
16
+ align-items: center;
17
+ gap: var(--space-6);
18
+ /* Vertical padding = trigger padding + one track step: the chip shares
19
+ * its row with segmented HkTabs-style chrome, whose track adds
20
+ * var(--space-2) around its triggers (the wowsp filter bar's alignment
21
+ * rule — a bare var(--space-4) chip lands two steps short of the row). */
22
+ padding: calc(var(--space-4) + var(--space-2)) var(--space-12);
23
+ font-family: inherit;
24
+ font-size: var(--text-sm);
25
+ font-weight: 600;
26
+ color: rgb(var(--color-muted));
27
+ white-space: nowrap;
28
+ cursor: pointer;
29
+ border: 1px solid rgb(var(--color-border) / 25%);
30
+ border-radius: var(--radius-sm);
31
+ background: rgb(var(--color-surface));
32
+ transition:
33
+ color var(--duration-short) ease,
34
+ background var(--duration-short) var(--ease-standard),
35
+ border-color var(--duration-short) var(--ease-standard);
36
+
37
+ &:hover {
38
+ color: rgb(var(--color-text));
39
+ }
40
+
41
+ &:active {
42
+ transform: scale(0.96);
43
+ }
44
+
45
+ &:focus-visible {
46
+ outline: 2px solid rgb(var(--color-primary));
47
+ outline-offset: 1px;
48
+ }
49
+
50
+ /* Concrete selection — the active trigger's primary ink mixed into the
51
+ * solid surface (a bare alpha tint would go translucent). */
52
+ &--on {
53
+ color: rgb(var(--color-primary));
54
+ background: color-mix(in srgb, rgb(var(--color-primary)) 12%, rgb(var(--color-surface)));
55
+ border-color: rgb(var(--color-primary) / 25%);
56
+
57
+ &:hover {
58
+ color: rgb(var(--color-primary));
59
+ }
60
+ }
61
+ }
62
+
63
+ .hk-filter-chip-caret {
64
+ opacity: 0.6;
65
+ flex: 0 0 auto;
66
+ }
67
+
68
+ .hk-filter-chip-body {
69
+ display: flex;
70
+ flex-direction: column;
71
+ gap: var(--space-8, 0.5rem);
72
+ max-width: min(92vw, 40rem);
73
+ }
74
+
75
+ .hk-filter-chip-hint {
76
+ color: rgb(var(--color-muted));
77
+ font-size: var(--text-2xs);
78
+ line-height: 1.4;
79
+ }
@@ -0,0 +1,136 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h, ref } from "vue";
3
+
4
+ import HkFilterChip from "./HkFilterChip";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ function mount(node: ReturnType<typeof h>) {
9
+ return mountRendered(() => node);
10
+ }
11
+
12
+ /** Mount with the vnode built INSIDE the render fn, so reactive props
13
+ * (a host's open ref) re-render the tree when they change. */
14
+ function mountRendered(build: () => ReturnType<typeof h>) {
15
+ const container = document.createElement("div");
16
+ document.body.appendChild(container);
17
+ const app = createApp({ render: () => build() });
18
+ app.mount(container);
19
+ mounts.push({ app, container });
20
+ return container;
21
+ }
22
+
23
+ afterEach(() => {
24
+ for (const { app, container } of mounts.splice(0)) {
25
+ app.unmount();
26
+ container.remove();
27
+ }
28
+ document.body.querySelectorAll(".hk-popover-scrim, .hk-popover-panel").forEach((el) => el.remove());
29
+ });
30
+
31
+ const OPTIONS = [
32
+ { value: "deepseek", label: "DeepSeek" },
33
+ { value: "openai", label: "OpenAI" },
34
+ { value: "anthropic", label: "Anthropic" },
35
+ { value: "gemini", label: "Gemini" },
36
+ ];
37
+
38
+ const chipButton = (c: HTMLElement) => c.querySelector<HTMLButtonElement>(".hk-filter-chip")!;
39
+
40
+ describe("HkFilterChip", () => {
41
+ it("renders the chip closed, labelled by the all-label when nothing is picked", () => {
42
+ const c = mount(
43
+ h(HkFilterChip, { label: "Providers", allLabel: "All providers", options: OPTIONS }),
44
+ );
45
+ const btn = chipButton(c);
46
+ expect(btn.textContent).toContain("All providers");
47
+ expect(btn.getAttribute("aria-haspopup")).toBe("dialog");
48
+ expect(btn.getAttribute("aria-expanded")).toBe("false");
49
+ expect(btn.className).not.toContain("hk-filter-chip--on");
50
+ // Nothing teleported while closed.
51
+ expect(document.body.querySelector(".hk-pill-group")).toBeNull();
52
+ });
53
+
54
+ it("opens the pill group in a popover and reports toggle / all", async () => {
55
+ const open = ref(false);
56
+ const toggled: string[] = [];
57
+ const allClicks: number[] = [];
58
+ const c = mountRendered(
59
+ () =>
60
+ h(HkFilterChip, {
61
+ label: "Providers",
62
+ allLabel: "All providers",
63
+ options: OPTIONS,
64
+ open: open.value,
65
+ "onUpdate:open": (v: boolean) => (open.value = v),
66
+ onToggle: (v: string) => toggled.push(v),
67
+ onAll: () => allClicks.push(1),
68
+ hint: "Multi-select hint line",
69
+ }),
70
+ );
71
+ chipButton(c).dispatchEvent(new MouseEvent("click", { bubbles: true }));
72
+ await Promise.resolve();
73
+ await Promise.resolve();
74
+ expect(open.value).toBe(true);
75
+ expect(chipButton(c).getAttribute("aria-expanded")).toBe("true");
76
+
77
+ // HkPopover teleports the surface to the body — the pill group and
78
+ // the host-authored hint line live there, not in the anchor.
79
+ const group = document.body.querySelector(".hk-pill-group");
80
+ expect(group, "pill group mounted in the popover after the chip click").toBeTruthy();
81
+ expect(document.body.querySelector(".hk-filter-chip-hint")?.textContent).toContain("Multi-select hint line");
82
+
83
+ const pills = [...document.body.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
84
+ expect(pills.length).toBe(OPTIONS.length + 1);
85
+ pills[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
86
+ await Promise.resolve();
87
+ expect(toggled).toEqual(["openai"]);
88
+
89
+ pills[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
90
+ await Promise.resolve();
91
+ expect(allClicks.length).toBe(1);
92
+ expect(toggled).toEqual(["openai"]);
93
+ });
94
+
95
+ it("mirrors the selection on the chip in canonical option order, capped with a +N tail", async () => {
96
+ // Canonical order wins over Set insertion order; the 4th pick caps
97
+ // the label instead of growing the chip without bound.
98
+ const selected = new Set(["gemini", "openai", "anthropic", "deepseek"]);
99
+ const c = mount(
100
+ h(HkFilterChip, {
101
+ label: "Providers",
102
+ allLabel: "All providers",
103
+ options: OPTIONS,
104
+ selected,
105
+ open: true,
106
+ }),
107
+ );
108
+ const text = chipButton(c).querySelector(".hk-filter-chip-text")!.textContent;
109
+ expect(text).toBe("DeepSeek, OpenAI, Anthropic +1");
110
+ expect(chipButton(c).className).toContain("hk-filter-chip--on");
111
+ // The accessible name carries the SAME selection the eye sees — an
112
+ // aria-label of just the category would mask the mirror (R1 P2).
113
+ expect(chipButton(c).getAttribute("aria-label")).toContain("DeepSeek, OpenAI, Anthropic +1");
114
+ });
115
+
116
+ it("re-renders when the host's selected set changes post-mount", async () => {
117
+ const selected = ref(new Set<string>());
118
+ const c = mountRendered(
119
+ () =>
120
+ h(HkFilterChip, {
121
+ label: "Providers",
122
+ allLabel: "All providers",
123
+ options: OPTIONS,
124
+ selected: selected.value,
125
+ open: false,
126
+ }),
127
+ );
128
+ expect(chipButton(c).querySelector(".hk-filter-chip-text")!.textContent).toBe("All providers");
129
+ selected.value = new Set(["openai"]);
130
+ await Promise.resolve();
131
+ await Promise.resolve();
132
+ expect(chipButton(c).querySelector(".hk-filter-chip-text")!.textContent).toBe("OpenAI");
133
+ expect(chipButton(c).className).toContain("hk-filter-chip--on");
134
+ expect(chipButton(c).getAttribute("aria-label")).toContain("OpenAI");
135
+ });
136
+ });
@@ -0,0 +1,105 @@
1
+ import { computed, defineComponent, ref, type PropType } from "vue";
2
+ import { ChevronDown } from "lucide-vue-next";
3
+
4
+ import HkPopover, { type PopupPlacement } from "./HkPopover";
5
+ import HkPillToggleGroup, { type HkPillToggleGroupOption } from "./HkPillToggleGroup";
6
+ import "./HkFilterChip.scss";
7
+
8
+ /**
9
+ * HkFilterChip — the FACET filter member of the filter family: a chip
10
+ * trigger that opens a popover (desktop) / bottom sheet (mobile, via
11
+ * HkPopover's sheetOnMobile) holding an HkPillToggleGroup multi-select —
12
+ * the 水表查询 filter-bar grammar, upstreamed from wowsp's
13
+ * FilterCategoryChip (2026-10-11). The sort/direction arrows and the
14
+ * drag-reorderable chip strip stay host-side there; this is the pure
15
+ * facet member.
16
+ *
17
+ * Semantics ride the thin group contract: `toggle(value)` reports one
18
+ * option toggled, `all()` reports the all-pill — the HOST decides
19
+ * whether all means clear or select-everything. The chip label mirrors
20
+ * the selection: the all-label when nothing is picked, otherwise the
21
+ * picked labels in canonical option order (capped at three plus a +N
22
+ * tail, so a wide facet cannot blow up the chip strip's width).
23
+ */
24
+ export default defineComponent({
25
+ name: "HkFilterChip",
26
+ props: {
27
+ /** Popup title + the trigger's accessible name (the category). */
28
+ label: { type: String, required: true },
29
+ /** Chip text with nothing picked, and the all-pill's label. */
30
+ allLabel: { type: String, required: true },
31
+ /** Facet values in canonical display order (also the chip-label
32
+ * order — picked labels mirror it, not the Set's insertion order). */
33
+ options: {
34
+ type: Array as PropType<HkPillToggleGroupOption[]>,
35
+ default: () => [],
36
+ },
37
+ /** The picked values — single source of truth, never mutated here. */
38
+ selected: { type: Object as PropType<Set<string>>, default: () => new Set<string>() },
39
+ open: { type: Boolean, default: false },
40
+ placement: { type: String as PropType<PopupPlacement>, default: "bottom-start" },
41
+ /** Bottom hint line below the pill group. Host-provided on purpose:
42
+ * the facet's semantics (multi-select? radio? clears what?) are the
43
+ * host's story to tell. */
44
+ hint: { type: String, default: undefined },
45
+ },
46
+ emits: {
47
+ "update:open": (_v: boolean) => true,
48
+ toggle: (_value: string) => true,
49
+ all: () => true,
50
+ },
51
+ setup(props, { emit }) {
52
+ const anchorRef = ref<HTMLElement | null>(null);
53
+
54
+ /** Picked options in canonical order — the chip label's source. */
55
+ const picked = computed(() => props.options.filter((o) => props.selected.has(o.value)));
56
+
57
+ const chipText = computed(() => {
58
+ if (picked.value.length === 0) return props.allLabel;
59
+ const names = picked.value.map((o) => o.label);
60
+ const head = names.slice(0, 3).join(", ");
61
+ const rest = names.length - 3;
62
+ return rest > 0 ? `${head} +${rest}` : head;
63
+ });
64
+
65
+ return () => (
66
+ <span ref={anchorRef} class="hk-filter-chip-anchor">
67
+ <button
68
+ type="button"
69
+ class={["hk-filter-chip", props.selected.size > 0 && "hk-filter-chip--on"]}
70
+ aria-haspopup="dialog"
71
+ aria-expanded={props.open ? "true" : "false"}
72
+ aria-label={
73
+ picked.value.length > 0 ? `${props.label}: ${chipText.value}` : props.label
74
+ }
75
+ title={props.label}
76
+ onClick={() => emit("update:open", !props.open)}
77
+ >
78
+ <span class="hk-filter-chip-text">{chipText.value}</span>
79
+ <ChevronDown size={12} class="hk-filter-chip-caret" />
80
+ </button>
81
+ <HkPopover
82
+ modelValue={props.open}
83
+ onUpdate:modelValue={(v: boolean) => emit("update:open", v)}
84
+ anchorRef={anchorRef.value}
85
+ placement={props.placement}
86
+ offset={6}
87
+ sheetOnMobile
88
+ title={props.label}
89
+ >
90
+ <div class="hk-filter-chip-body">
91
+ <HkPillToggleGroup
92
+ label={props.label}
93
+ options={props.options}
94
+ selected={props.selected}
95
+ allLabel={props.allLabel}
96
+ onToggle={(v: string) => emit("toggle", v)}
97
+ onAll={() => emit("all")}
98
+ />
99
+ {props.hint && <div class="hk-filter-chip-hint">{props.hint}</div>}
100
+ </div>
101
+ </HkPopover>
102
+ </span>
103
+ );
104
+ },
105
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * HkPillToggleGroup chrome — the segmented pill track. Ported from the
3
+ * wowsp 水表查询 filter bar's option group, token for token: shared
4
+ * chrome around per-option buttons, the active option carrying the
5
+ * indicator tint directly (multi-select cannot use HkTabs — its sliding
6
+ * indicator is radio semantics).
7
+ */
8
+
9
+ .hk-pill-group {
10
+ display: flex;
11
+ flex-wrap: wrap;
12
+ gap: var(--space-4, 4px);
13
+ padding: 2px;
14
+ border-radius: var(--radius-sm);
15
+ border: 1px solid rgb(var(--color-border) / 25%);
16
+ background: rgb(var(--color-surface) / 60%);
17
+ }
18
+
19
+ .hk-pill-group-pill {
20
+ display: inline-flex;
21
+ align-items: center;
22
+ gap: var(--space-6, 6px);
23
+ padding: var(--space-4) var(--space-12);
24
+ font-family: inherit;
25
+ font-size: var(--text-sm);
26
+ font-weight: 600;
27
+ color: rgb(var(--color-muted));
28
+ white-space: nowrap;
29
+ cursor: pointer;
30
+ background: transparent;
31
+ border: none;
32
+ border-radius: var(--radius-sm);
33
+ outline: none;
34
+ transition: color var(--duration-short) ease, transform var(--duration-short) ease;
35
+
36
+ &:hover {
37
+ color: rgb(var(--color-text));
38
+ }
39
+
40
+ &:active {
41
+ transform: scale(0.96);
42
+ }
43
+
44
+ &[data-active] {
45
+ color: rgb(var(--color-primary));
46
+ background: rgb(var(--color-primary) / 12%);
47
+ }
48
+
49
+ &:focus-visible {
50
+ outline: 2px solid rgb(var(--color-primary));
51
+ outline-offset: -2px;
52
+ }
53
+ }
54
+
55
+ .hk-pill-group-icon {
56
+ display: inline-flex;
57
+ align-items: center;
58
+ /* An icon never widens the hit floor: replaced-element glyphs
59
+ * contribute nothing to a button's min-content width, so shrink
60
+ * pressure would collapse them before the label felt it. */
61
+ flex: 0 0 auto;
62
+ }
@@ -0,0 +1,114 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import HkPillToggleGroup from "./HkPillToggleGroup";
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 OPTIONS = [
25
+ { value: "deepseek", label: "DeepSeek" },
26
+ { value: "openai", label: "OpenAI" },
27
+ { value: "anthropic", label: "Anthropic" },
28
+ ];
29
+
30
+ describe("HkPillToggleGroup", () => {
31
+ it("renders one pill per option plus the all-pill, active state from the selected set", () => {
32
+ const c = mount(
33
+ h(HkPillToggleGroup, {
34
+ label: "Providers",
35
+ options: OPTIONS,
36
+ selected: new Set(["openai"]),
37
+ allLabel: "All",
38
+ }),
39
+ );
40
+ const pills = [...c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
41
+ expect(pills.map((p) => p.textContent?.trim())).toEqual(["All", "DeepSeek", "OpenAI", "Anthropic"]);
42
+ // Active tint rides data-active (the segmented grammar — no aria-only state).
43
+ expect(pills[0].hasAttribute("data-active")).toBe(false);
44
+ expect(pills[2].hasAttribute("data-active")).toBe(true);
45
+ // Every pill is a pressed-state toggle button (screen readers hear the set).
46
+ expect(pills[2].getAttribute("aria-pressed")).toBe("true");
47
+ expect(pills[1].getAttribute("aria-pressed")).toBe("false");
48
+ expect(c.querySelector(".hk-pill-group")!.getAttribute("role")).toBe("group");
49
+ });
50
+
51
+ it("marks the all-pill active when nothing is picked, and hides it without allLabel", () => {
52
+ const withAll = mount(
53
+ h(HkPillToggleGroup, { label: "P", options: OPTIONS, selected: new Set<string>(), allLabel: "All" }),
54
+ );
55
+ expect(withAll.querySelectorAll(".hk-pill-group-pill")[0].hasAttribute("data-active")).toBe(true);
56
+
57
+ const bare = mount(h(HkPillToggleGroup, { label: "P", options: OPTIONS, selected: new Set<string>() }));
58
+ expect(bare.querySelectorAll(".hk-pill-group-pill").length).toBe(OPTIONS.length);
59
+ });
60
+
61
+ it("reports toggle(value) and all() — the set itself is never mutated here", async () => {
62
+ const seen: string[] = [];
63
+ const allClicks: number[] = [];
64
+ const selected = new Set<string>(["deepseek"]);
65
+ const c = mount(
66
+ h(HkPillToggleGroup, {
67
+ label: "P",
68
+ options: OPTIONS,
69
+ selected,
70
+ allLabel: "All",
71
+ onToggle: (v: string) => seen.push(v),
72
+ onAll: () => allClicks.push(1),
73
+ }),
74
+ );
75
+ const pills = [...c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
76
+ pills[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
77
+ await Promise.resolve();
78
+ expect(seen).toEqual(["openai"]);
79
+ // The host-owned set is untouched — the component only reports.
80
+ expect([...selected]).toEqual(["deepseek"]);
81
+
82
+ pills[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
83
+ await Promise.resolve();
84
+ expect(allClicks.length).toBe(1);
85
+ expect(seen).toEqual(["openai"]);
86
+ });
87
+
88
+ it("normalizes option icons: components render, junk renders nothing, nothing stringifies", async () => {
89
+ const { h } = await import("vue");
90
+ const { Zap } = await import("lucide-vue-next");
91
+ const c = mount(
92
+ h(HkPillToggleGroup, {
93
+ label: "P",
94
+ selected: new Set<string>(),
95
+ options: [
96
+ { value: "fn", label: "Fn", icon: Zap },
97
+ { value: "vnode", label: "Vn", icon: h(Zap) },
98
+ { value: "junk", label: "Jk", icon: 42 },
99
+ ],
100
+ }),
101
+ );
102
+ await Promise.resolve();
103
+ const pills = [...c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
104
+ // A raw lucide component FUNCTION must render as its <svg> — the R1
105
+ // landmine: unnormalized it stringifies into source text.
106
+ expect(pills[0].querySelector("svg"), "function icon renders").toBeTruthy();
107
+ expect(pills[0].textContent).not.toContain("h(");
108
+ // A prebuilt vnode passes through identically.
109
+ expect(pills[1].querySelector("svg"), "vnode icon renders").toBeTruthy();
110
+ // Junk (a number) renders nothing — and never its digits.
111
+ expect(pills[2].querySelector(".hk-pill-group-icon")).toBeNull();
112
+ expect(pills[2].textContent?.trim()).toBe("Jk");
113
+ });
114
+ });
@@ -0,0 +1,95 @@
1
+ import { defineComponent, h, type PropType, type VNode } from "vue";
2
+
3
+ import "./HkPillToggleGroup.scss";
4
+
5
+ /** One toggleable pill of the group. */
6
+ export interface HkPillToggleGroupOption {
7
+ /** Stable identity — the value emitted back through `toggle`. */
8
+ value: string;
9
+ label: string;
10
+ /** Optional leading glyph. Accepts EITHER a component (a lucide icon
11
+ * function, a defineComponent options object) or a prebuilt vnode —
12
+ * both normalize to a rendered element; anything else is skipped
13
+ * (never stringified: a raw function child renders as source text,
14
+ * which is exactly the landmine this normalization defuses). */
15
+ icon?: unknown;
16
+ }
17
+
18
+ /** Normalize an option icon to a renderable vnode. A component (function
19
+ * or options object) is instantiated with `h`; a prebuilt vnode passes
20
+ * through; anything else (string, number, null) renders nothing. */
21
+ function resolveIcon(icon: unknown): VNode | null {
22
+ if (icon == null) return null;
23
+ if (typeof icon === "object") return icon as VNode;
24
+ if (typeof icon === "function") return h(icon as Parameters<typeof h>[0]);
25
+ return null;
26
+ }
27
+
28
+ /**
29
+ * HkPillToggleGroup — the segmented pill-track multi-select: shared
30
+ * chrome around per-option toggle buttons, the active option carrying
31
+ * the indicator tint directly (multi-select cannot use HkTabs — its
32
+ * sliding indicator is radio semantics). Grammar ported from the wowsp
33
+ * 水表查询 filter bar's option strip (2026-10-11), wrapping instead of
34
+ * panning: hikari ships page-sized groups, and hosts with strip-scale
35
+ * sets host their own panning track.
36
+ *
37
+ * Deliberately THIN (the host owns the set): `toggle(value)` reports one
38
+ * pill toggled; the optional leading all-pill reports `all()` and the
39
+ * HOST decides what that means (clear the facet, or select everything —
40
+ * both are legitimate; see HkFilterChip and the provider model groups).
41
+ * `selected` is the single source of truth and is never mutated here.
42
+ */
43
+ export default defineComponent({
44
+ name: "HkPillToggleGroup",
45
+ props: {
46
+ /** Accessible name of the group (role=group aria-label). */
47
+ label: { type: String, required: true },
48
+ options: {
49
+ type: Array as PropType<HkPillToggleGroupOption[]>,
50
+ default: () => [],
51
+ },
52
+ /** The picked values — single source of truth, never mutated here. */
53
+ selected: { type: Object as PropType<Set<string>>, default: () => new Set<string>() },
54
+ /** The leading all-pill's label; omit to hide the all-pill. The pill
55
+ * wears the active tint exactly when nothing is picked. */
56
+ allLabel: { type: String, default: undefined },
57
+ },
58
+ emits: {
59
+ toggle: (_value: string) => true,
60
+ all: () => true,
61
+ },
62
+ setup(props, { emit }) {
63
+ return () => (
64
+ <div class="hk-pill-group" role="group" aria-label={props.label}>
65
+ {props.allLabel != null && (
66
+ <button
67
+ type="button"
68
+ class="hk-pill-group-pill"
69
+ data-active={props.selected.size === 0 || undefined}
70
+ aria-pressed={props.selected.size === 0 ? "true" : "false"}
71
+ onClick={() => emit("all")}
72
+ >
73
+ {props.allLabel}
74
+ </button>
75
+ )}
76
+ {props.options.map((o) => {
77
+ const icon = resolveIcon(o.icon);
78
+ return (
79
+ <button
80
+ type="button"
81
+ key={o.value}
82
+ class="hk-pill-group-pill"
83
+ data-active={props.selected.has(o.value) || undefined}
84
+ aria-pressed={props.selected.has(o.value) ? "true" : "false"}
85
+ onClick={() => emit("toggle", o.value)}
86
+ >
87
+ {icon != null && <span class="hk-pill-group-icon">{icon}</span>}
88
+ {o.label}
89
+ </button>
90
+ );
91
+ })}
92
+ </div>
93
+ );
94
+ },
95
+ });
@@ -139,6 +139,26 @@ describe("HkPlaceholderMarquee", () => {
139
139
  expect(host.classList.contains("hk-placeholder-marquee--hidden")).toBe(false);
140
140
  });
141
141
 
142
+ it("keeps a sub-pixel-exact fit static instead of flipping to scroll", async () => {
143
+ // A shrink-wrapped host (window == text width, e.g. the status-bar
144
+ // version cells) compares a fractional copy width against the
145
+ // INTEGER clientWidth: without the epsilon, every fractional part
146
+ // < 0.5 made the predicate true and permanently scrolled a FITTING
147
+ // text (2026-10-11 R1 finding, reproduced in Chromium). The epsilon
148
+ // lets exact fits keep the hidden probe while real overflow (≥0.5px
149
+ // past the edge) still flips.
150
+ const { container } = mountMarquee({ text: "0.1.0 feat/model-usage-selfhosted:770f625" });
151
+ // Copy (incl. 24px spacing) lands 0.3px past the window: fits.
152
+ await forceGeometry(container, /* copy */ 224.3, /* window */ 200);
153
+ const host = container.querySelector(".hk-placeholder-marquee") as HTMLElement;
154
+ expect(host.classList.contains("hk-placeholder-marquee--hidden"), "0.3px past → still fits").toBe(true);
155
+ expect(container.querySelectorAll(".hk-placeholder-marquee__copy")).toHaveLength(1);
156
+ // 2px past the edge: real overflow, the strip takes over.
157
+ await forceGeometry(container, /* copy */ 226, /* window */ 200);
158
+ expect(host.classList.contains("hk-placeholder-marquee--hidden"), "2px past → scrolls").toBe(false);
159
+ expect(container.querySelectorAll(".hk-placeholder-marquee__copy")).toHaveLength(3);
160
+ });
161
+
142
162
  it("drives the sweep with CSS custom properties instead of per-frame JS", async () => {
143
163
  const { container } = mountMarquee({ text: "advent calendar of placeholders" });
144
164
  await forceGeometry(container, 500, 200);
@@ -79,7 +79,16 @@ export const HkPlaceholderMarquee = defineComponent({
79
79
  const factor = space.x > 0 ? space.x : 1;
80
80
  const copyWidth = copy.getBoundingClientRect().width / factor;
81
81
  loopWidth.value = copyWidth;
82
- overflowing.value = copyWidth - COPY_SPACING > host.clientWidth;
82
+ // Sub-pixel boundary (2026-10-11): the copy width is deliberately
83
+ // fractional while clientWidth is an integer. A shrink-wrapped
84
+ // host — window == text width, e.g. the status-bar version cells —
85
+ // degenerates the predicate to `textW > round(textW)`, which is
86
+ // TRUE for every fractional part < 0.5 and would permanently flip
87
+ // a FITTING text into scrolling (no size change → the
88
+ // ResizeObserver never re-fires; reproduced in Chromium). The
89
+ // epsilon keeps exact fits static; real overflow (≥0.5px past the
90
+ // edge) still flips.
91
+ overflowing.value = copyWidth - COPY_SPACING > host.clientWidth + 0.5;
83
92
  emit("overflowChange", overflowing.value);
84
93
  };
85
94
 
@@ -668,5 +668,14 @@ describe("HkStatusBar", () => {
668
668
  const popoverRule = /\.s-status-bar-popover-value\s*\{[^}]*\}/.exec(scss)?.[0] ?? "";
669
669
  expect(popoverRule, "popover value cell bound exists").toContain("max-width: min(16rem, 60vw)");
670
670
  expect(scss).toMatch(/\.s-status-bar-popover-value \.s-status-bar-version\s*\{[^}]*max-width:\s*100%/s);
671
+ // Dead-overlay guard: if the marquee's measurement never fires, a
672
+ // capped cell degrades to an ellipsis cut, never a silent hard clip.
673
+ expect(scss).toMatch(
674
+ /\.s-status-bar-version__static\s*\{[^}]*text-overflow:\s*ellipsis/s,
675
+ );
676
+ // The stale combined selector list (`.s-status-bar-version,
677
+ // .s-status-bar-version-sep { nowrap }`) is gone — the dedicated
678
+ // block owns the version cell's rules (2026-10-11 R1 cleanup).
679
+ expect(scss).not.toMatch(/\.s-status-bar-version,\s*\n\s*\.s-status-bar-version-sep\s*\{/);
671
680
  });
672
681
  });
package/src/index.ts CHANGED
@@ -830,6 +830,9 @@ export { default as HkFilterBar } from "./components/HkFilterBar";
830
830
  export { default as HkFilterPanel, sanitizeNumberInput } from "./components/HkFilterPanel";
831
831
  export type { HkFilterOperator, HkFilterFieldDef, HkFilterCondition, HkFilterState } from "./components/HkFilterPanel";
832
832
  export { default as HkFilterTrigger } from "./components/HkFilterTrigger";
833
+ export { default as HkFilterChip } from "./components/HkFilterChip";
834
+ export { default as HkPillToggleGroup } from "./components/HkPillToggleGroup";
835
+ export type { HkPillToggleGroupOption } from "./components/HkPillToggleGroup";
833
836
  export { HkIconChip as HkIconChip } from "./components/HkIconChip";
834
837
  export { HkIconChip as HIconChip } from "./components/HkIconChip";
835
838
  export { HkCardList, HkListRow } from "./components/HkCardList";
@@ -199,9 +199,13 @@
199
199
  }
200
200
 
201
201
  .s-status-bar-version__static {
202
- /* The ghost state (marquee owns the window): opacity keeps the text
203
- in the layout and the a11y tree; only the paint is handed over. */
202
+ /* Dead-overlay guard: if the marquee's measurement never fires (RO
203
+ unavailable, zero-width probe), a capped cell must degrade to an
204
+ ellipsis cut, not a silent hard clip. Invisible while the marquee
205
+ owns the window (the ghost is opacity-0 by then). */
204
206
  min-width: 0;
207
+ overflow: hidden;
208
+ text-overflow: ellipsis;
205
209
  }
206
210
 
207
211
  .s-status-bar-version[data-overflowing] .s-status-bar-version__static {
@@ -258,12 +262,8 @@
258
262
  }
259
263
  }
260
264
 
261
- .s-status-bar-version,
262
265
  .s-status-bar-version-sep {
263
266
  white-space: nowrap;
264
- }
265
-
266
- .s-status-bar-version-sep {
267
267
  opacity: 0.5;
268
268
  }
269
269