@celestia-island/hikari 0.55.206 → 0.55.207

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.206",
3
+ "version": "0.55.207",
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",
@@ -32,11 +32,16 @@ const triggerButton = (c: HTMLElement) =>
32
32
  c.querySelector<HTMLButtonElement>(".hk-filter-trigger .hk-icon-button")!;
33
33
 
34
34
  describe("HkFilterTrigger", () => {
35
- it("renders the filter affordance as a ghost/28 icon button (the theme toggle's step)", () => {
35
+ it("renders the filter affordance on the chrome action ladder (ghost class, ladder step)", () => {
36
36
  const c = mount(h(HkFilterTrigger, { fields: FIELDS, modelValue: [] }));
37
37
  const btn = triggerButton(c);
38
38
  expect(btn).toBeTruthy();
39
- expect(btn.className).toContain("hk-icon-button-28");
39
+ // The ladder rung is viewport-driven (useChromeActionSize): the test
40
+ // environment's width lands on one of the two rungs — the contract
41
+ // is "the ladder's class", never a hard-coded step.
42
+ expect(
43
+ btn.className.includes("hk-icon-button-28") || btn.className.includes("hk-icon-button-32"),
44
+ ).toBe(true);
40
45
  expect(btn.className).toContain("hk-icon-button-ghost");
41
46
  expect(btn.getAttribute("aria-haspopup")).toBe("dialog");
42
47
  // No active dot on an untouched state.
@@ -12,12 +12,14 @@ import HkIconButton from "./HkIconButton";
12
12
  import HkPopover, { type PopupPlacement } from "./HkPopover";
13
13
  import "./HkFilterTrigger.scss";
14
14
  import { useBreakpoint } from "../runtime/useBreakpoint";
15
+ import { useChromeActionSize } from "../composables/useChromeActionSize";
15
16
  import { useI18n } from "../i18n/context";
16
17
 
17
18
  /**
18
- * HkFilterTrigger — the header filter affordance: a ghost/28 icon button
19
- * (the same step as the theme toggle and the page refresh — the whole
20
- * top-right cluster reads as one row of chrome) anchoring the
19
+ * HkFilterTrigger — the header filter affordance: a ghost icon button on
20
+ * the chrome action ladder (useChromeActionSize: ghost/32 wide, ghost/28
21
+ * narrow — the same step as the global search and settings buttons, so
22
+ * the whole top-right cluster reads as one row of chrome) anchoring the
21
23
  * HkFilterPanel in a popover on desktop and a bottom sheet on mobile
22
24
  * (HkPopover sheetOnMobile).
23
25
  *
@@ -50,6 +52,11 @@ export default defineComponent({
50
52
  setup(props, { emit }) {
51
53
  const { t } = useI18n();
52
54
  const { isMobile } = useBreakpoint();
55
+ // The chrome ladder (2026-10-24 ruling, one source): wide screens
56
+ // wear ghost/32 like the header's search and settings buttons — a
57
+ // flat 28 here read as a different button family from its own
58
+ // neighbors on desktop (2026-10-27 report).
59
+ const chromeSize = useChromeActionSize();
53
60
  const open = ref(false);
54
61
  const anchorRef = ref<HTMLElement | null>(null);
55
62
 
@@ -63,7 +70,7 @@ export default defineComponent({
63
70
  <span ref={anchorRef} class="hk-filter-trigger">
64
71
  <HkCornerBadge dot={active.value} variant="success" corner="bottom-right" offset={1}>
65
72
  <HkIconButton
66
- size={28}
73
+ size={chromeSize.value}
67
74
  variant="ghost"
68
75
  aria-label={label}
69
76
  aria-haspopup="dialog"
@@ -74,14 +74,15 @@
74
74
 
75
75
  .hk-icon-button-28 {
76
76
  --hi-icon-button-size: 28px;
77
- /* The header-chrome step carries the sm glyph (16px): its consumers are
78
- * the header clusters — global search, the pages' refresh, the filter
79
- * trigger — whose visual sibling is HkThemeToggle's own trigger (one
80
- * 28px box with a direct 16px lucide glyph). The xs mapping (12px)
81
- * rendered those glyphs visibly smaller than the glyphs beside them
82
- * (user report 2026-09-29: 不像一个尺寸的图标按钮) — the `.svg` rule
83
- * in HkIconButton.scss forces ANY slotted glyph to this var, so the
84
- * slot's size-16 attribute never reached the screen. */
77
+ /* The narrow header-chrome step carries the sm glyph (16px): its
78
+ * consumers are header clusters whose visual sibling is a compact
79
+ * trigger (one 28px box with a direct 16px lucide glyph). The xs
80
+ * mapping (12px) rendered those glyphs visibly smaller than the glyphs
81
+ * beside them (user report 2026-09-29: 不像一个尺寸的图标按钮) — the
82
+ * `.svg` rule in HkIconButton.scss forces ANY slotted glyph to this
83
+ * var, so the slot's size-16 attribute never reached the screen. The
84
+ * wide-rung sibling is the 32px block below (the 2026-10-24 chrome
85
+ * ladder; useChromeActionSize picks the rung). */
85
86
  --hi-icon-button-icon-size: var(--hi-icon-size-sm);
86
87
  }
87
88
 
@@ -8,6 +8,7 @@ import HkPopover, { type PopupPlacement } from "./HkPopover";
8
8
  import HkSearchInput from "./HkSearchInput";
9
9
  import { useBreakpoint } from "../runtime/useBreakpoint";
10
10
  import { useI18n } from "../i18n/context";
11
+ import { useChromeActionSize } from "../composables/useChromeActionSize";
11
12
  import "./HkSearchTrigger.scss";
12
13
 
13
14
  /**
@@ -56,6 +57,7 @@ const HkSearchTrigger = defineComponent({
56
57
  },
57
58
  setup(props, { emit }) {
58
59
  const { t } = useI18n();
60
+ const chromeSize = useChromeActionSize();
59
61
  const { isMobile } = useBreakpoint();
60
62
  const open = ref(false);
61
63
  const anchorRef = ref<HTMLElement | null>(null);
@@ -96,7 +98,7 @@ const HkSearchTrigger = defineComponent({
96
98
  * (2026-10-15: was a hand-rolled primary dot, top-right). */}
97
99
  <HkCornerBadge dot={active.value} variant="success" corner="bottom-right" offset={1}>
98
100
  <HkIconButton
99
- size={28}
101
+ size={chromeSize.value}
100
102
  variant="ghost"
101
103
  aria-label={label}
102
104
  aria-haspopup="dialog"
@@ -0,0 +1,60 @@
1
+ /**
2
+ * HkSegmentedRadio chrome — the single-choice joined strip. One chrome
3
+ * for every filter-popover option group: the two chest popovers this
4
+ * component converges styled their borders, paddings and hover tints
5
+ * independently and drifted apart within one release week. Tokens only
6
+ * (hikari vars with px fallbacks), per the component SCSS convention.
7
+ */
8
+
9
+ .hk-segmented-radio {
10
+ display: flex;
11
+ width: 100%;
12
+ overflow: hidden;
13
+ border: 1px solid rgb(var(--color-border));
14
+ border-radius: var(--radius-sm, 4px);
15
+
16
+ &[data-disabled] {
17
+ opacity: 0.5;
18
+ }
19
+ }
20
+
21
+ .hk-segmented-radio-option {
22
+ flex: 1 1 0;
23
+ min-width: 0;
24
+ appearance: none;
25
+ border: 0;
26
+ border-inline-end: 1px solid rgb(var(--color-border));
27
+ padding: var(--space-6, 0.375rem) var(--space-10, 10px);
28
+ background: transparent;
29
+ color: rgb(var(--color-text) / 78%);
30
+ font-size: var(--text-xs, 12px);
31
+ line-height: 1.4;
32
+ white-space: nowrap;
33
+ cursor: pointer;
34
+ transition:
35
+ background var(--duration-short, 0.15s),
36
+ color var(--duration-short, 0.15s);
37
+
38
+ &:last-child {
39
+ border-inline-end: 0;
40
+ }
41
+
42
+ &:disabled {
43
+ cursor: not-allowed;
44
+ }
45
+
46
+ &[data-on] {
47
+ background: rgb(var(--color-primary) / 14%);
48
+ color: rgb(var(--color-primary));
49
+ font-weight: 600;
50
+ }
51
+
52
+ &:not([data-on]):not(:disabled):hover {
53
+ background: rgb(var(--color-primary) / 6%);
54
+ }
55
+
56
+ &:focus-visible {
57
+ outline: 2px solid rgb(var(--color-primary) / 60%);
58
+ outline-offset: -2px;
59
+ }
60
+ }
@@ -0,0 +1,259 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h, nextTick, ref } from "vue";
3
+
4
+ import HkSegmentedRadio from "./HkSegmentedRadio";
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: "active", label: "Active only" },
26
+ { value: "ended", label: "Ended only" },
27
+ { value: "all", label: "Show all" },
28
+ ];
29
+
30
+ describe("HkSegmentedRadio", () => {
31
+ it("renders one radio segment per option with radiogroup semantics", () => {
32
+ const c = mount(
33
+ h(HkSegmentedRadio, {
34
+ label: "Node status",
35
+ options: OPTIONS,
36
+ modelValue: "ended",
37
+ }),
38
+ );
39
+ const radios = [...c.querySelectorAll<HTMLButtonElement>(".hk-segmented-radio-option")];
40
+ expect(radios.map((r) => r.textContent?.trim())).toEqual([
41
+ "Active only",
42
+ "Ended only",
43
+ "Show all",
44
+ ]);
45
+ const group = c.querySelector(".hk-segmented-radio")!;
46
+ expect(group.getAttribute("role")).toBe("radiogroup");
47
+ expect(group.getAttribute("aria-label")).toBe("Node status");
48
+ // Radio semantics: aria-checked + roving tabindex on the picked one.
49
+ expect(radios[1].getAttribute("aria-checked")).toBe("true");
50
+ expect(radios[1].getAttribute("tabindex")).toBe("0");
51
+ expect(radios[0].getAttribute("aria-checked")).toBe("false");
52
+ expect(radios[0].getAttribute("tabindex")).toBe("-1");
53
+ expect(radios[1].hasAttribute("data-on")).toBe(true);
54
+ expect(radios[0].hasAttribute("data-on")).toBe(false);
55
+ });
56
+
57
+ it("clicking another segment emits exactly its value (host feeds it back)", async () => {
58
+ const emitted: string[] = [];
59
+ // A REACTIVE host: the component is controlled, so the test host
60
+ // must apply what it emits (the real popover does the same).
61
+ const c = mount(
62
+ h({
63
+ setup() {
64
+ const value = ref("active");
65
+ return () =>
66
+ h(HkSegmentedRadio, {
67
+ label: "Node status",
68
+ options: OPTIONS,
69
+ modelValue: value.value,
70
+ "onUpdate:modelValue": (v: string) => {
71
+ emitted.push(v);
72
+ value.value = v;
73
+ },
74
+ });
75
+ },
76
+ }),
77
+ );
78
+ const radios = () => [...c.querySelectorAll<HTMLButtonElement>(".hk-segmented-radio-option")];
79
+ radios()[2].click();
80
+ // Vue re-renders asynchronously: the controlled contract needs the
81
+ // host's modelValue writeback flushed before the next interaction.
82
+ await nextTick();
83
+ radios()[0].click();
84
+ await nextTick();
85
+ expect(emitted).toEqual(["all", "active"]);
86
+ expect(radios()[0].hasAttribute("data-on")).toBe(true);
87
+ });
88
+
89
+ it("keyboard arrows move the pick with wrapping and refocus the new segment", async () => {
90
+ const emitted: string[] = [];
91
+ const c = mount(
92
+ h(
93
+ {
94
+ setup() {
95
+ const value = ref("ended");
96
+ return () =>
97
+ h(HkSegmentedRadio, {
98
+ label: "Node status",
99
+ options: OPTIONS,
100
+ modelValue: value.value,
101
+ "onUpdate:modelValue": (v: string) => {
102
+ emitted.push(v);
103
+ value.value = v;
104
+ },
105
+ });
106
+ },
107
+ },
108
+ ),
109
+ );
110
+ const group = c.querySelector<HTMLElement>(".hk-segmented-radio")!;
111
+ group.dispatchEvent(
112
+ new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }),
113
+ );
114
+ expect(emitted).toEqual(["all"]);
115
+ await nextTick();
116
+ group.dispatchEvent(
117
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
118
+ );
119
+ await nextTick();
120
+ expect(emitted).toEqual(["all", "active"]);
121
+ // Home/End jump to the ends.
122
+ group.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
123
+ await nextTick();
124
+ expect(emitted).toEqual(["all", "active", "all"]);
125
+ group.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
126
+ await nextTick();
127
+ expect(emitted).toEqual(["all", "active", "all", "active"]);
128
+ });
129
+
130
+ it("disabled disables every segment and the group carries the mark", () => {
131
+ const emitted: string[] = [];
132
+ const c = mount(
133
+ h(HkSegmentedRadio, {
134
+ label: "Node status",
135
+ options: OPTIONS,
136
+ modelValue: "active",
137
+ disabled: true,
138
+ "onUpdate:modelValue": (v: string) => emitted.push(v),
139
+ }),
140
+ );
141
+ const group = c.querySelector(".hk-segmented-radio")!;
142
+ expect(group.hasAttribute("data-disabled")).toBe(true);
143
+ const radios = [...c.querySelectorAll<HTMLButtonElement>(".hk-segmented-radio-option")];
144
+ for (const r of radios) expect(r.disabled).toBe(true);
145
+ radios[1].click();
146
+ expect(emitted).toEqual([]);
147
+ });
148
+
149
+ it("an unpicked model keeps the FIRST segment tabbable (roving entry point)", () => {
150
+ const c = mount(
151
+ h(HkSegmentedRadio, {
152
+ label: "Node status",
153
+ options: OPTIONS,
154
+ modelValue: "",
155
+ }),
156
+ );
157
+ const radios = [...c.querySelectorAll<HTMLButtonElement>(".hk-segmented-radio-option")];
158
+ expect(radios[0].getAttribute("tabindex")).toBe("0");
159
+ expect(radios[1].getAttribute("tabindex")).toBe("-1");
160
+ });
161
+
162
+ it("an unpicked model reads honest edges (ArrowLeft → LAST, ArrowRight → FIRST)", async () => {
163
+ // R3 pin: the index === -1 branches had no coverage — a plain modulo
164
+ // wrap would send ArrowLeft-from-unpicked to the MIDDLE option
165
+ // ((-1 - 1 + 3) % 3 === 1), not the honest last edge.
166
+ const press = (root: HTMLElement, key: string) =>
167
+ root
168
+ .querySelector<HTMLElement>(".hk-segmented-radio")!
169
+ .dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
170
+ const host = (push: (v: string) => void) =>
171
+ h({
172
+ setup() {
173
+ const value = ref("");
174
+ return () =>
175
+ h(HkSegmentedRadio, {
176
+ label: "Node status",
177
+ options: OPTIONS,
178
+ modelValue: value.value,
179
+ "onUpdate:modelValue": (v: string) => {
180
+ push(v);
181
+ value.value = v;
182
+ },
183
+ });
184
+ },
185
+ });
186
+
187
+ // ArrowLeft from unpicked lands on the LAST option.
188
+ const fromLeft: string[] = [];
189
+ const leftRoot = mount(h(host((v) => fromLeft.push(v))));
190
+ press(leftRoot, "ArrowLeft");
191
+ await nextTick();
192
+ expect(fromLeft).toEqual(["all"]);
193
+
194
+ // ArrowRight from unpicked lands on the FIRST option.
195
+ const fromRight: string[] = [];
196
+ const rightRoot = mount(h(host((v) => fromRight.push(v))));
197
+ press(rightRoot, "ArrowRight");
198
+ await nextTick();
199
+ expect(fromRight).toEqual(["active"]);
200
+ });
201
+
202
+ it("keyboard nav over duplicate-bearing options walks the DEDUPED list", async () => {
203
+ // Delta-review S2 pin: the onGroupKeydown index math must run over
204
+ // dedupedOptions (raw options with duplicates make the honest-edge
205
+ // math land on a NON-rendered occurrence — ArrowLeft-from-unpicked
206
+ // over [A, A2, B] would emit "A" (hidden), not "B" (visually last)).
207
+ const emitted: string[] = [];
208
+ const c = mount(
209
+ h({
210
+ setup() {
211
+ const value = ref("");
212
+ return () =>
213
+ h(HkSegmentedRadio, {
214
+ label: "Node status",
215
+ options: [
216
+ { value: "active", label: "Active only" },
217
+ { value: "active", label: "Active again (dup)" },
218
+ { value: "all", label: "Show all" },
219
+ ],
220
+ modelValue: value.value,
221
+ "onUpdate:modelValue": (v: string) => {
222
+ emitted.push(v);
223
+ value.value = v;
224
+ },
225
+ });
226
+ },
227
+ }),
228
+ );
229
+ const group = c.querySelector<HTMLElement>(".hk-segmented-radio")!;
230
+ group.dispatchEvent(
231
+ new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }),
232
+ );
233
+ await nextTick();
234
+ // The last RENDERED segment, not the hidden duplicate.
235
+ expect(emitted).toEqual(["all"]);
236
+ });
237
+
238
+ it("duplicate option values collapse to one radio (first wins, one tabbable)", () => {
239
+ // R3 pin: the dedupe filter had no coverage — without it a
240
+ // duplicated value renders two aria-checked radios for one value.
241
+ const c = mount(
242
+ h(HkSegmentedRadio, {
243
+ label: "Node status",
244
+ options: [
245
+ { value: "active", label: "Active only" },
246
+ { value: "ended", label: "Ended only" },
247
+ { value: "active", label: "Active again (dup)" },
248
+ ],
249
+ modelValue: "",
250
+ }),
251
+ );
252
+ const radios = [...c.querySelectorAll<HTMLButtonElement>(".hk-segmented-radio-option")];
253
+ // Exactly two segments render — the duplicate "active" is dropped.
254
+ expect(radios.map((r) => r.textContent?.trim())).toEqual(["Active only", "Ended only"]);
255
+ expect(c.querySelectorAll('[data-option="active"]')).toHaveLength(1);
256
+ // And the roving tabindex stays single (unpicked → first segment only).
257
+ expect(radios.filter((r) => r.getAttribute("tabindex") === "0")).toHaveLength(1);
258
+ });
259
+ });
@@ -0,0 +1,141 @@
1
+ import { computed, defineComponent, nextTick, ref, type PropType } from "vue";
2
+
3
+ import "./HkSegmentedRadio.scss";
4
+
5
+ /** One joined segment of the single-choice strip. */
6
+ export interface HkSegmentedRadioOption {
7
+ /** Stable identity — emitted back through `update:modelValue`. */
8
+ value: string;
9
+ label: string;
10
+ }
11
+
12
+ /**
13
+ * HkSegmentedRadio — the single-choice joined strip for filter popovers
14
+ * and compact pickers: a flat bordered track of full-label segments, the
15
+ * picked one carrying the primary tint (multi-select's sibling is
16
+ * HkPillToggleGroup, whose toggle semantics cannot express "exactly
17
+ * one"; HkTabs owns the view-switching indicator strip — this component
18
+ * is the FORM control in that gap, radio semantics throughout).
19
+ *
20
+ * Grammar ported from the two chest filter popovers that hand-rolled
21
+ * this control independently (history TopicFilterMenu 2026-10-21, cruise
22
+ * CruiseNodeFilterMenu 2026-10-26) with DIFFERENT paddings and hover
23
+ * tints — the point of the component is that they converge on one
24
+ * chrome (#demiurge.673 follow-up, 2026-10-27).
25
+ *
26
+ * Keyboard: a true radiogroup — roving tabindex on the picked segment,
27
+ * Arrow/Home/End to move (wrapping), Enter/Space to pick.
28
+ */
29
+ export default defineComponent({
30
+ name: "HkSegmentedRadio",
31
+ props: {
32
+ /** Accessible name of the group (the radiogroup's aria-label). */
33
+ label: { type: String, required: true },
34
+ options: {
35
+ type: Array as PropType<HkSegmentedRadioOption[]>,
36
+ default: () => [],
37
+ },
38
+ /** The picked value — single source of truth, never mutated here. */
39
+ modelValue: { type: String, default: "" },
40
+ disabled: { type: Boolean, default: false },
41
+ },
42
+ emits: {
43
+ "update:modelValue": (_value: string) => true,
44
+ },
45
+ setup(props, { emit }) {
46
+ const groupRef = ref<HTMLElement | null>(null);
47
+ const picked = computed(() =>
48
+ props.options.find((item) => item.value === props.modelValue),
49
+ );
50
+ // Duplicate values would render two aria-checked radios (two
51
+ // tabbable segments) and hazard the keyed patch — keep the first
52
+ // occurrence and say so in dev.
53
+ const dedupedOptions = computed(() => {
54
+ const seen = new Set<string>();
55
+ return props.options.filter((item) => {
56
+ if (seen.has(item.value)) return false;
57
+ seen.add(item.value);
58
+ return true;
59
+ });
60
+ });
61
+ if (import.meta.env.DEV && dedupedOptions.value.length !== props.options.length) {
62
+ // eslint-disable-next-line no-console -- dev-only drift alarm
63
+ console.warn(
64
+ "[HkSegmentedRadio] duplicate option values dropped:",
65
+ props.options.map((o) => o.value).join(", "),
66
+ );
67
+ }
68
+
69
+ function choose(value: string): void {
70
+ if (value !== props.modelValue) emit("update:modelValue", value);
71
+ }
72
+
73
+ function onGroupKeydown(event: KeyboardEvent): void {
74
+ // Walk the DEDUPED list: index math over raw options counts
75
+ // non-rendered duplicates and can land on a hidden occurrence.
76
+ const items = dedupedOptions.value;
77
+ if (items.length === 0) return;
78
+ const index = items.findIndex((item) => item.value === props.modelValue);
79
+ let next: number;
80
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
81
+ // Nothing picked yet: the edges are honest neighbors (last /
82
+ // first), not the element at the wrap of index -1.
83
+ next = index === -1
84
+ ? items.length - 1
85
+ : (index - 1 + items.length) % items.length;
86
+ } else if (event.key === "ArrowRight" || event.key === "ArrowDown") {
87
+ next = index === -1 ? 0 : (index + 1) % items.length;
88
+ } else if (event.key === "Home") {
89
+ next = 0;
90
+ } else if (event.key === "End") {
91
+ next = items.length - 1;
92
+ } else {
93
+ return;
94
+ }
95
+ event.preventDefault();
96
+ const value = items[next]!.value;
97
+ choose(value);
98
+ void nextTick(() => {
99
+ // Walk the rendered buttons comparing dataset — a quoted
100
+ // attribute selector with CSS.escape throws on quote-bearing
101
+ // values in happy-dom and misses NUL-bearing ones, which would
102
+ // kill the refocus as an unhandled rejection.
103
+ const target = Array.from(
104
+ groupRef.value?.querySelectorAll<HTMLElement>("[data-option]") ?? [],
105
+ ).find((el) => el.dataset.option === value);
106
+ target?.focus();
107
+ });
108
+ }
109
+
110
+ return () => (
111
+ <div
112
+ ref={groupRef}
113
+ class="hk-segmented-radio"
114
+ role="radiogroup"
115
+ aria-label={props.label}
116
+ data-disabled={props.disabled || undefined}
117
+ onKeydown={onGroupKeydown}
118
+ >
119
+ {dedupedOptions.value.map((item) => {
120
+ const active = item.value === props.modelValue;
121
+ return (
122
+ <button
123
+ key={item.value}
124
+ type="button"
125
+ role="radio"
126
+ aria-checked={active}
127
+ tabindex={picked.value ? (active ? 0 : -1) : item === props.options[0] ? 0 : -1}
128
+ class="hk-segmented-radio-option"
129
+ data-option={item.value}
130
+ data-on={active || undefined}
131
+ disabled={props.disabled}
132
+ onClick={() => choose(item.value)}
133
+ >
134
+ {item.label}
135
+ </button>
136
+ );
137
+ })}
138
+ </div>
139
+ );
140
+ },
141
+ });
@@ -113,13 +113,18 @@ describe("HkThemeToggle trigger", () => {
113
113
  // single button (user direction 2026-09-30) must be chrome-identical
114
114
  // to the functional HkIconButtons hosts place beside it — the ghost
115
115
  // variant's shared rest/hover/press paint, not a bespoke block. The
116
- // 28 step is the header chrome step (user report 2026-09-28: the
117
- // cluster reads as one row only at ghost/28, sm 16px glyph).
116
+ // size step is the chrome action ladder (2026-10-24 ruling via
117
+ // useChromeActionSize): ghost/32 wide, ghost/28 narrow — the rung is
118
+ // viewport-driven, so the test pins "the ladder's class", never one
119
+ // hard-coded step.
118
120
  const triggers = container.querySelectorAll(".s-theme-toggle-btn");
119
121
  expect(triggers).toHaveLength(1);
120
122
  const trigger = triggers[0] as HTMLElement;
121
123
  expect(trigger.classList.contains("hk-icon-button")).toBe(true);
122
- expect(trigger.classList.contains("hk-icon-button-28")).toBe(true);
124
+ expect(
125
+ trigger.classList.contains("hk-icon-button-28")
126
+ || trigger.classList.contains("hk-icon-button-32"),
127
+ ).toBe(true);
123
128
  expect(trigger.classList.contains("hk-icon-button-ghost")).toBe(true);
124
129
  // The retired main/arrow data hooks must never come back.
125
130
  expect(trigger.hasAttribute("data-variant")).toBe(false);
@@ -6,6 +6,7 @@ import HkIconButton from "./HkIconButton";
6
6
  import { HkThemePanelContent } from "./HkThemePanelContent";
7
7
  import type { HCustomTheme } from "./HkColorSchemeDialog";
8
8
  import "./HkThemeToggle.scss";
9
+ import { useChromeActionSize } from "../composables/useChromeActionSize";
9
10
 
10
11
  export { type ThemeItemScope } from "./HkThemePanelContent";
11
12
 
@@ -80,6 +81,7 @@ export const HkThemeToggle = defineComponent({
80
81
  },
81
82
  setup(props, { emit, slots, expose }) {
82
83
  const { t } = useI18n();
84
+ const chromeSize = useChromeActionSize();
83
85
  const menuOpen = ref(false);
84
86
  const triggerRef = ref<HTMLElement | null>(null);
85
87
 
@@ -104,7 +106,7 @@ export const HkThemeToggle = defineComponent({
104
106
  * its own. */}
105
107
  <HkIconButton
106
108
  class="s-theme-toggle-btn"
107
- size={28}
109
+ size={chromeSize.value}
108
110
  variant="ghost"
109
111
  aria-haspopup="menu"
110
112
  aria-expanded={menuOpen.value ? "true" : "false"}
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import { useChromeActionSize } from "./useChromeActionSize";
5
+
6
+ /**
7
+ * The ladder's VALUES are viewport-dependent (useBreakpoint reads the
8
+ * real window width, and a jsdom default falls outside the wide rung),
9
+ * so this suite pins the CONTRACT — one source, both rungs reachable,
10
+ * never anything else — rather than a hard-coded mapping.
11
+ */
12
+ describe("useChromeActionSize", () => {
13
+ it("returns a computed ref resolving to one of the two ladder rungs", () => {
14
+ const container = document.createElement("div");
15
+ document.body.appendChild(container);
16
+ const app = createApp({
17
+ setup() {
18
+ const size = useChromeActionSize();
19
+ // Render the resolved value into the DOM (setupState proxies are
20
+ // unwrapped — rendering is the honest read path).
21
+ return () => h("span", { "data-size": String(size.value) });
22
+ },
23
+ });
24
+ app.mount(container);
25
+ const span = container.querySelector("span")!;
26
+ expect(["28", "32"]).toContain(span.dataset.size!);
27
+ app.unmount();
28
+ container.remove();
29
+ });
30
+
31
+ it("maps the viewport onto the ladder rungs (wide→32, narrow→28) and follows a flip", async () => {
32
+ // R3 S2 pin: nothing else pins WHICH rung a width maps to — a
33
+ // regression to a frozen constant passed the whole suite. Drive the
34
+ // REAL useBreakpoint (it listens to resize; no mock split needed).
35
+ const container = document.createElement("div");
36
+ document.body.appendChild(container);
37
+ const app = createApp({
38
+ setup() {
39
+ const size = useChromeActionSize();
40
+ return () => h("span", { "data-size": String(size.value) });
41
+ },
42
+ });
43
+ app.mount(container);
44
+ const read = () => container.querySelector("span")!.dataset.size!;
45
+
46
+ window.innerWidth = 1280;
47
+ window.dispatchEvent(new Event("resize"));
48
+ await new Promise((r) => setTimeout(r, 20));
49
+ expect(read()).toBe("32");
50
+
51
+ window.innerWidth = 800;
52
+ window.dispatchEvent(new Event("resize"));
53
+ await new Promise((r) => setTimeout(r, 20));
54
+ expect(read()).toBe("28");
55
+
56
+ // And back: the flip is followed, not frozen at first read.
57
+ window.innerWidth = 1280;
58
+ window.dispatchEvent(new Event("resize"));
59
+ await new Promise((r) => setTimeout(r, 20));
60
+ expect(read()).toBe("32");
61
+
62
+ app.unmount();
63
+ container.remove();
64
+ });
65
+ });
@@ -0,0 +1,25 @@
1
+ import { computed, type ComputedRef } from "vue";
2
+
3
+ import { useBreakpoint } from "../runtime/useBreakpoint";
4
+
5
+ /**
6
+ * The top-right chrome action size ladder (user ruling 2026-10-24):
7
+ * wide screens (≥1024) wear the generous ghost/32 cluster; narrower
8
+ * viewports tighten to ghost/28 so the row fits. ONE source for every
9
+ * header-chrome icon button — the global search and settings buttons,
10
+ * the page filter trigger, the refresh — so the cluster reads as one
11
+ * row of identical chrome on every face.
12
+ *
13
+ * This is the single authority; hand-rolled `isDesktop ? 32 : 28`
14
+ * copies in consumers drifted exactly the way this helper exists to
15
+ * prevent (2026-10-27: the filter triggers stayed at the old flat 28
16
+ * while the header moved to the ladder, and the funnel read as a
17
+ * different button family from its own neighbors).
18
+ */
19
+ export function useChromeActionSize(): ComputedRef<28 | 32> {
20
+ const { isDesktop } = useBreakpoint();
21
+ // `?.` — partial breakpoint surfaces exist (test mocks, embedders that
22
+ // pass only what they use); an absent isDesktop reads as the NARROW
23
+ // rung, the safe-fitting choice for an unknown viewport.
24
+ return computed(() => (isDesktop?.value ? 32 : 28));
25
+ }
package/src/index.ts CHANGED
@@ -511,6 +511,7 @@ export {
511
511
  export { usePageTitle, useRouteTitle } from "./composables/usePageTitle";
512
512
  export { provideActionBar, useActionBar } from "./composables/useActionBar";
513
513
  export type { ActionBarRenderer } from "./composables/useActionBar";
514
+ export { useChromeActionSize } from "./composables/useChromeActionSize";
514
515
  export { leadingZeroBits, sha256, solvePow, solvePowSync, verifyPow } from "./utils/pow";
515
516
  export type { PowChallenge, PowSolution } from "./utils/pow";
516
517
  export { HkLocalePicker as HLocalePicker } from "./components/HkLocalePicker";
@@ -670,6 +671,8 @@ export { default as HkPopupSelect } from "./components/HkPopupSelect";
670
671
  export { default as HkProgressBar } from "./components/HkProgressBar";
671
672
  export { default as HkProgressDialog } from "./components/HkProgressDialog";
672
673
  export { default as HkRadio } from "./components/HkRadio";
674
+ export { default as HkSegmentedRadio } from "./components/HkSegmentedRadio";
675
+ export type { HkSegmentedRadioOption } from "./components/HkSegmentedRadio";
673
676
  export { default as HkScrollContainer } from "./components/HkScrollContainer";
674
677
  export { default as HkSearchInput } from "./components/HkSearchInput";
675
678
  export { default as HkSplash } from "./components/HkSplash";