@celestia-island/hikari 0.55.300 → 0.55.301

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.300",
3
+ "version": "0.55.301",
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",
@@ -122,6 +122,36 @@ describe("HkIconButton", () => {
122
122
  });
123
123
  });
124
124
 
125
+
126
+ describe("HkIconButton button semantics", () => {
127
+ it("renders type=button so a form never treats the chrome as a submitter", () => {
128
+ const c = mountComp(() => h(HkIconButton, { "aria-label": "probe" }));
129
+ const btn = c.querySelector<HTMLButtonElement>("button")!;
130
+ expect(btn.getAttribute("type")).toBe("button");
131
+ });
132
+
133
+ it("never overwrites an explicit type", () => {
134
+ for (const explicit of ["submit", "reset"] as const) {
135
+ const c = mountComp(() => h(HkIconButton, { "aria-label": "probe", type: explicit } as never));
136
+ expect(c.querySelector<HTMLButtonElement>("button")!.getAttribute("type")).toBe(explicit);
137
+ }
138
+ });
139
+
140
+ it("keeps the default when the attribute is explicitly undefined or empty", () => {
141
+ // The discriminating cases: a pre-fix ordering (default first, attrs
142
+ // after) elides the attribute for `undefined` and passes an empty
143
+ // string through — the first is an implicit submitter, the second an
144
+ // invalid value that ALSO defaults to submit.
145
+ for (const value of [undefined, null, ""] as const) {
146
+ const c = mountComp(() => h(HkIconButton, { "aria-label": "probe", type: value } as never));
147
+ expect(
148
+ c.querySelector<HTMLButtonElement>("button")!.getAttribute("type"),
149
+ `type=${JSON.stringify(value)} must fall back to button`,
150
+ ).toBe("button");
151
+ }
152
+ });
153
+ });
154
+
125
155
  /**
126
156
  * Static pin for the header-chrome glyph step (user report 2026-09-29:
127
157
  * the search / refresh / filter glyphs in the top-right clusters read
@@ -61,6 +61,14 @@ export default defineComponent({
61
61
  disabled={props.disabled}
62
62
  onClick={(e: MouseEvent) => emit("click", e)}
63
63
  {...attrs}
64
+ // A rendered <button> defaults to type=submit, which silently turns
65
+ // every icon-only control inside a <form> into a submitter. The
66
+ // family is chrome, never a submitter: default it here, once, so
67
+ // call sites do not each spread an attribute the component does not
68
+ // declare. Placed AFTER the attrs spread and guarded with `||` so an
69
+ // explicit `type` still wins while an explicitly `undefined` one
70
+ // cannot silently defeat the default (HkButton's idiom).
71
+ type={((attrs.type as string) || "button") as "button" | "reset" | "submit"}
64
72
  >
65
73
  <span class="hk-icon-button-icon">
66
74
  {slots.icon
@@ -95,6 +95,40 @@
95
95
  background: rgb(var(--color-primary) / 12%);
96
96
  }
97
97
 
98
+ /* Tone channels (2026-11-06): a pill can carry the hue identity of the
99
+ * thing it filters (the trace chips tint with their lane's token so a
100
+ * chip, its ribbon segments and its rows share one identity). A tone
101
+ * re-tints the ACTIVE state only — inactive pills stay muted regardless
102
+ * of tone. The four hue tones share one alpha (16%); the "muted" tone
103
+ * rides 18% because it has to read as a neutral wash rather than a hue.
104
+ * The attribute pair wins on SPECIFICITY ((0,3,0) vs (0,2,0)) — order
105
+ * independent; they are also written after the plain rule so the source
106
+ * order agrees with what specificity already decides. */
107
+ &[data-tone="success"][data-active] {
108
+ color: rgb(var(--color-success, 14 184 64));
109
+ background: rgb(var(--color-success, 14 184 64) / 16%);
110
+ }
111
+
112
+ &[data-tone="secondary"][data-active] {
113
+ color: rgb(var(--color-secondary, 81 154 115));
114
+ background: rgb(var(--color-secondary, 81 154 115) / 16%);
115
+ }
116
+
117
+ &[data-tone="warning"][data-active] {
118
+ color: rgb(var(--color-warning, 238 214 119));
119
+ background: rgb(var(--color-warning, 238 214 119) / 16%);
120
+ }
121
+
122
+ &[data-tone="error"][data-active] {
123
+ color: rgb(var(--color-error, 247 118 142));
124
+ background: rgb(var(--color-error, 247 118 142) / 16%);
125
+ }
126
+
127
+ &[data-tone="muted"][data-active] {
128
+ color: rgb(var(--color-text, 30 30 30));
129
+ background: rgb(var(--color-muted, 108 108 108) / 18%);
130
+ }
131
+
98
132
  &:focus-visible {
99
133
  outline: 2px solid rgb(var(--color-primary));
100
134
  outline-offset: -2px;
@@ -1,5 +1,9 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
2
  import { createApp, h } from "vue";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { compile } from "sass";
3
7
 
4
8
  import HkPillToggleGroup from "./HkPillToggleGroup";
5
9
 
@@ -175,3 +179,96 @@ describe("HkPillToggleGroup", () => {
175
179
  expect(toggled).toEqual([OPTIONS[1].value]);
176
180
  });
177
181
  });
182
+
183
+ // ── Tone channel ─────────────────────────────────────────────────────────
184
+ describe("HkPillToggleGroup tone channel", () => {
185
+ const TONED_OPTIONS = [
186
+ { value: "input", label: "Input", tone: "success" },
187
+ { value: "text", label: "Text", tone: "secondary" },
188
+ { value: "tool", label: "Tool", tone: "warning" },
189
+ { value: "plain", label: "Plain" },
190
+ { value: "primary", label: "Primary", tone: "primary" },
191
+ ] as const;
192
+
193
+ it("carries data-tone for non-primary tones and omits it for default/primary", () => {
194
+ const c = mount(
195
+ h(HkPillToggleGroup, { label: "Kinds", options: [...TONED_OPTIONS], selected: new Set<string>() }),
196
+ );
197
+ const pills = [...c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
198
+ expect(pills[0].getAttribute("data-tone")).toBe("success");
199
+ expect(pills[1].getAttribute("data-tone")).toBe("secondary");
200
+ expect(pills[2].getAttribute("data-tone")).toBe("warning");
201
+ expect(pills[3].hasAttribute("data-tone"), "no tone → no attribute").toBe(false);
202
+ expect(pills[4].hasAttribute("data-tone"), "primary is the default → no attribute").toBe(false);
203
+ });
204
+
205
+ it("renders the option label alongside the tone attribute", () => {
206
+ const c = mount(
207
+ h(HkPillToggleGroup, {
208
+ label: "Kinds",
209
+ options: [{ value: "tool", label: "Tool", tone: "warning" }],
210
+ selected: new Set<string>(["tool"]),
211
+ }),
212
+ );
213
+ const pill = c.querySelector<HTMLButtonElement>(".hk-pill-group-pill")!;
214
+ expect(pill.textContent?.trim()).toBe("Tool");
215
+ expect(pill.hasAttribute("data-active")).toBe(true);
216
+ // Without this the whole channel could be reverted and the test stays green.
217
+ expect(pill.getAttribute("data-tone")).toBe("warning");
218
+ });
219
+
220
+ it("stylesheet contract: every tone re-tints the ACTIVE state only, after the plain rule", () => {
221
+ const here = dirname(fileURLToPath(import.meta.url));
222
+ const css = compile(join(here, "HkPillToggleGroup.scss"), { style: "expanded" }).css;
223
+ const tones = ["success", "secondary", "warning", "error", "muted"] as const;
224
+ // Same triplets styles/theme/channels.scss defines (light base).
225
+ const FALLBACKS: Record<(typeof tones)[number], string> = {
226
+ success: "14 184 64",
227
+ secondary: "81 154 115",
228
+ warning: "238 214 119",
229
+ error: "247 118 142",
230
+ muted: "108 108 108",
231
+ };
232
+ const plainActive = css.indexOf(".hk-pill-group-pill[data-active]");
233
+ expect(plainActive, "the plain active rule exists").toBeGreaterThan(-1);
234
+ for (const tone of tones) {
235
+ // Sass drops quotes around attribute values when expanding
236
+ // (`[data-status=pending]` — the HkRangeRibbon sheet contract hits
237
+ // the same formatting), so match the compiled shape.
238
+ const selector = `.hk-pill-group-pill[data-tone=${tone}][data-active]`;
239
+ const at = css.indexOf(selector);
240
+ expect(at, `${selector} compiles`).toBeGreaterThan(-1);
241
+ // Every tone rule must come AFTER the plain rule: the attribute pair
242
+ // outranks it, and the source order keeps that dominance explicit.
243
+ expect(at, `${tone} rule rides after the plain active rule`).toBeGreaterThan(plainActive);
244
+ // Tone must never repaint an INACTIVE pill: every tone selector has to
245
+ // carry the attribute pair. A bare `[data-tone=…]` rule would pass the
246
+ // assertions above while quietly tinting muted pills.
247
+ expect(css, "no tone rule without [data-active]").not.toMatch(
248
+ new RegExp(`\\[data-tone=${tone}\\]\\s*\\{`),
249
+ );
250
+ const body = css.slice(at, css.indexOf("}", at));
251
+ expect(body, `${tone} re-tints the ink`).toContain("color:");
252
+ expect(body, `${tone} re-tints the fill`).toContain("background:");
253
+ expect(body, `${tone} tint uses its theme token`).toContain(`var(--color-${tone}`);
254
+ // Presence alone is not enough: the token appears twice (ink + fill),
255
+ // so swapping one for another hue kept this green (R3 M9/M9b). Pin the
256
+ // ink and the fill to their own channels — muted is the documented
257
+ // exception (text ink, muted wash).
258
+ expect(body, `${tone} INK uses its own channel`).toContain(
259
+ `color: rgb(var(--color-${tone === "muted" ? "text" : tone}`,
260
+ );
261
+ expect(body, `${tone} FILL uses its own channel`).toContain(
262
+ `background: rgb(var(--color-${tone}`,
263
+ );
264
+ // The canonical fallback is what keeps the Rust-side mirror rendering
265
+ // the same hue without the theme layer — pin the exact triplet.
266
+ expect(body, `${tone} carries its canonical fallback`).toContain(FALLBACKS[tone]);
267
+ // Sass's expanded output drops the space before the alpha slash:
268
+ // `rgb(var(--color-x, t t t)/16%)`.
269
+ expect(body, `${tone} keeps the house alpha`).toMatch(
270
+ tone === "muted" ? /\/\s*18%/ : /\/\s*16%/,
271
+ );
272
+ }
273
+ });
274
+ });
@@ -3,6 +3,13 @@ import { defineComponent, h, type PropType, type VNode } from "vue";
3
3
  import { useOptionStrip } from "../composables/useOptionStrip";
4
4
  import "./HkPillToggleGroup.scss";
5
5
 
6
+ /** Semantic tone channel for a pill's ACTIVE tint — the same vocabulary
7
+ * the HkRangeRibbon segment family speaks (a filter bar can keep its
8
+ * lane-hue identity while riding the shared pill chrome). Omit (or
9
+ * "primary") = the default primary tint. A tone recolors the ACTIVE
10
+ * state only; inactive pills stay muted regardless of tone. */
11
+ export type HkPillTone = "primary" | "success" | "secondary" | "warning" | "error" | "muted";
12
+
6
13
  /** One toggleable pill of the group. */
7
14
  export interface HkPillToggleGroupOption {
8
15
  /** Stable identity — the value emitted back through `toggle`. */
@@ -14,6 +21,8 @@ export interface HkPillToggleGroupOption {
14
21
  * (never stringified: a raw function child renders as source text,
15
22
  * which is exactly the landmine this normalization defuses). */
16
23
  icon?: unknown;
24
+ /** Active-tint hue channel (see {@link HkPillTone}). */
25
+ tone?: HkPillTone;
17
26
  }
18
27
 
19
28
  /** Normalize an option icon to a renderable vnode. A component (function
@@ -94,6 +103,7 @@ export default defineComponent({
94
103
  type="button"
95
104
  key={o.value}
96
105
  class="hk-pill-group-pill"
106
+ data-tone={o.tone && o.tone !== "primary" ? o.tone : undefined}
97
107
  data-active={props.selected.has(o.value) || undefined}
98
108
  aria-pressed={props.selected.has(o.value) ? "true" : "false"}
99
109
  onClick={() => emit("toggle", o.value)}
@@ -9,8 +9,10 @@
9
9
  the strip is 32px tall, a handle is an 8px half-overlapping grip
10
10
  (translateX(-50%) so `left: 20%` centers it on the window edge), a
11
11
  clipped segment keeps a 2px minimum so edge slivers stay visible, and
12
- the reset chip clears the edge handle (right: 4px = the handle's
13
- half-width, so a handle at 0% / 100% is never covered).
12
+ the reset button (a standard HkIconButton) clears the edge handle
13
+ (right: 4px = the handle's half-width, so a handle at 0% / 100% is never
14
+ covered). The 24px touch-floor seat costs the strip's top-right brush
15
+ band — the free band below the seat is what remains.
14
16
 
15
17
  Colours and spacing come from hikari theme tokens; a few geometry values
16
18
  (the 32px strip, the 8px grip, the 2px min-width floor) are deliberate
@@ -152,7 +154,12 @@
152
154
  position: absolute;
153
155
  top: 3px;
154
156
  bottom: 3px;
155
- z-index: 2;
157
+ // ABOVE the reset seat (z-index 3): a 24px seat reaches ~10-13% into the
158
+ // strip's right edge, so at z-index 2 a window whose end lands in that
159
+ // band had both grips covered by the reset's box and unreachable by
160
+ // pointer (the seat is opaque and paints later). The grip wins the band;
161
+ // the reset keeps every other pixel of its area.
162
+ z-index: 4;
156
163
  width: 8px;
157
164
  transform: translateX(-50%);
158
165
  border: 1px solid rgb(var(--color-surface));
@@ -174,32 +181,31 @@
174
181
  outline-offset: 2px;
175
182
  }
176
183
 
177
- .hk-range-ribbon-reset {
184
+ // The reset affordance is a STANDARD HkIconButton (size 24, ghost) — the
185
+ // icon-button sheet owns box, glyph size, hover, press and focus chrome.
186
+ // 24 is the touch floor the family uses for dense chrome (the same floor the
187
+ // consumers' icon-size guards enforce); the 32px strip still seats it with a
188
+ // 4px inset top and bottom.
189
+ // This rule only pins the seat on the overview field: right = the edge
190
+ // handle's half-width, so a handle at 0% / 100% is never covered.
191
+ // The seat rides the descendant key on purpose: `.hk-icon-button` declares
192
+ // `position: relative` at the SAME (0,1,0) specificity, so a bare
193
+ // `.hk-range-ribbon-reset` rule would only win while HkIconButton.scss
194
+ // happens to come first in the bundle (the exact failure the lightbox close
195
+ // hit in 2026-09-11 — its seat rule is `.hk-image-lightbox-close.hk-icon-button`
196
+ // for this reason). (0,2,0) makes the seat order-independent.
197
+ // The surface backing rides it for the same reason: the `--hi-icon-button-*`
198
+ // properties also live on `.hk-icon-button` (HkIconButtonVars). The ghost
199
+ // variant is transparent by default — a control floating on the strip's own
200
+ // paint keeps a surface tint to stay legible over segments and scrims.
201
+ .hk-range-ribbon .hk-range-ribbon-reset {
178
202
  position: absolute;
179
203
  top: 4px;
180
204
  right: 4px;
181
205
  z-index: 3;
182
- display: inline-flex;
183
- align-items: center;
184
- justify-content: center;
185
- width: 18px;
186
- height: 18px;
187
- padding: 0;
188
- border: 1px solid rgb(var(--color-border));
189
- border-radius: var(--radius-sm);
190
- background: rgb(var(--color-surface) / 85%);
191
- color: rgb(var(--color-text));
192
- cursor: pointer;
193
- transition: background-color var(--duration-fast, 0.15s) ease;
194
- }
195
-
196
- .hk-range-ribbon-reset:hover {
197
- background: rgb(var(--color-surface));
198
- }
199
-
200
- .hk-range-ribbon-reset:focus-visible {
201
- outline: 2px solid rgb(var(--color-primary));
202
- outline-offset: 1px;
206
+ --hi-icon-button-bg: rgb(var(--color-surface) / 85%);
207
+ --hi-icon-button-bg-hover: rgb(var(--color-surface));
208
+ --hi-icon-button-bg-active: rgb(var(--color-surface));
203
209
  }
204
210
 
205
211
  // Visually hidden, still read by AT: labels the keyboard contract the
@@ -771,6 +771,18 @@ describe("HkRangeRibbon reset", () => {
771
771
  const host = mountHost([0.2, 0.6]);
772
772
  const button = host.root.querySelector<HTMLElement>(".hk-range-ribbon-reset");
773
773
  expect(button, "reset button is shown while a window exists").toBeTruthy();
774
+ // The reset affordance rides the STANDARD icon button (user direction
775
+ // 2026-11-06: no hand-rolled chip chrome), carrying its lucide glyph
776
+ // through the house-normal slot — pin the lucide class the slot must
777
+ // produce so a glyph swap cannot silently vanish.
778
+ expect(button!.className, "reset is an HkIconButton, not a bespoke chip").toContain(
779
+ "hk-icon-button",
780
+ );
781
+ expect(
782
+ button!.querySelector("svg.lucide-rotate-ccw"),
783
+ "reset glyph renders through the icon slot",
784
+ ).not.toBeNull();
785
+ expect(button!.getAttribute("type"), "reset never becomes a submit button").toBe("button");
774
786
  button!.click();
775
787
  await nextTick();
776
788
  expect(host.windowChange).toHaveBeenLastCalledWith(null);
@@ -1405,8 +1417,36 @@ describe("HkRangeRibbon stylesheet contract", () => {
1405
1417
  // A clipped 2px sliver must still be visible.
1406
1418
  expect(declaration(ruleBody(css, ".hk-range-ribbon-segment"), "min-width")).toBe("2px");
1407
1419
  expect(declaration(ruleBody(css, ".hk-range-ribbon-lane-track"), "height")).toBe("20px");
1408
- // The reset chip clears the edge handle (half its 8px width).
1409
- expect(declaration(ruleBody(css, ".hk-range-ribbon-reset"), "right")).toBe("4px");
1420
+ // The reset seat clears the edge handle (half its 8px width). The rule
1421
+ // must sit on the descendant key (0,2,0): `.hk-icon-button` declares
1422
+ // `position: relative` at (0,1,0), so a bare-class seat would depend on
1423
+ // bundle order.
1424
+ const resetSeat = ruleBody(css, ".hk-range-ribbon .hk-range-ribbon-reset");
1425
+ expect(declaration(resetSeat, "right")).toBe("4px");
1426
+ expect(declaration(resetSeat, "position"), "the seat must out-rank .hk-icon-button").toBe(
1427
+ "absolute",
1428
+ );
1429
+ expect(declaration(resetSeat, "top"), "the seat clears the strip edge").toBe("4px");
1430
+ // The grip must paint ABOVE the seat: a right-side window puts its end
1431
+ // handle inside the seat's box, and an opaque seat below it would make
1432
+ // the grip unreachable by pointer.
1433
+ const gripZ = Number(declaration(ruleBody(css, ".hk-range-ribbon-handle"), "z-index"));
1434
+ const seatZ = Number(declaration(resetSeat, "z-index"));
1435
+ expect(gripZ, "the edge grip outranks the reset seat").toBeGreaterThan(seatZ);
1436
+ // The surface backing is what keeps the control legible over the strip's
1437
+ // segments and scrims: ghost carries NO background (HkIconButtonVars),
1438
+ // so without these three the reset degrades to a bare glyph floating on
1439
+ // the paint. Removing them alone left the whole suite green (R3 M5b) —
1440
+ // pin the paint, not just the geometry.
1441
+ expect(declaration(resetSeat, "--hi-icon-button-bg"), "resting surface tint").toContain(
1442
+ "var(--color-surface)",
1443
+ );
1444
+ expect(declaration(resetSeat, "--hi-icon-button-bg-hover"), "hover surface tint").toContain(
1445
+ "var(--color-surface)",
1446
+ );
1447
+ expect(declaration(resetSeat, "--hi-icon-button-bg-active"), "press surface tint").toContain(
1448
+ "var(--color-surface)",
1449
+ );
1410
1450
  expect(ruleBody(css, ".hk-range-ribbon-window")).toContain("var(--color-primary)");
1411
1451
  expect(ruleBody(css, ".hk-range-ribbon-scrim")).toContain("var(--color-background)");
1412
1452
  });
@@ -11,6 +11,7 @@ import {
11
11
  } from "vue";
12
12
 
13
13
  import { useI18n } from "../i18n/context";
14
+ import HIconButton from "./HkIconButton";
14
15
  import "./HkRangeRibbon.scss";
15
16
 
16
17
  /**
@@ -744,15 +745,23 @@ export default defineComponent({
744
745
  onPointerdown={(event: PointerEvent) => onHandlePointerDown("end", event)}
745
746
  onKeydown={(event: KeyboardEvent) => onHandleKeyDown("end", event)}
746
747
  />
747
- <button
748
- type="button"
748
+ {/* The reset affordance is the STANDARD icon button (user
749
+ direction 2026-11-06: no hand-rolled chip chrome) —
750
+ HkIconButton owns box, glyph size, hover, press and
751
+ focus; this sheet only pins the seat (position) and a
752
+ surface backing via its override vars. The glyph slots
753
+ as a lucide component (the house-normal integration:
754
+ every in-tree HkIconButton slots its glyph). */}
755
+ <HIconButton
749
756
  class="hk-range-ribbon-reset"
757
+ size={24}
758
+ variant="ghost"
750
759
  aria-label={t("hikari::ribbon.reset", "Reset range")}
751
- title={t("hikari::ribbon.reset", "Reset range")}
760
+ {...{ title: t("hikari::ribbon.reset", "Reset range") }}
752
761
  onClick={resetWindow}
753
762
  >
754
- <RotateCcw size={12} aria-hidden="true" />
755
- </button>
763
+ {{ icon: () => <RotateCcw aria-hidden="true" /> }}
764
+ </HIconButton>
756
765
  </>
757
766
  )}
758
767
  </div>
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Source contract for the range-ribbon reset affordance (user direction
3
+ * 2026-11-06: the waterfall minimap's reset control must be the STANDARD
4
+ * icon button, not a per-component hand-rolled `<button>` chip).
5
+ *
6
+ * HkRangeRibbon renders its reset through HkIconButton (the shared icon
7
+ * button: one box ladder, one glyph channel, one hover/press/focus chrome)
8
+ * carrying the a directly-imported `RotateCcw` glyph in the icon slot — the same "named-icon contract" the unified window-close wave
9
+ * pinned (window-close.contract.test.ts). The positioning class
10
+ * `hk-range-ribbon-reset` survives ONLY as the seat hook the component
11
+ * sheet pins (it must keep clearing the edge handle).
12
+ *
13
+ * Pinned here so a refactor cannot silently re-sprout a bespoke chip —
14
+ * the same "looks unified but drifted" failure class the window-close
15
+ * contract guards against.
16
+ */
17
+ import { describe, expect, it } from "vitest";
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const ribbon = readFileSync(join(here, "HkRangeRibbon.tsx"), "utf-8");
24
+
25
+ describe("range-ribbon reset control contract", () => {
26
+ it("renders the reset affordance through the standard HkIconButton", () => {
27
+ expect(ribbon, "the reset must ride HkIconButton chrome").toContain("HIconButton");
28
+ expect(ribbon, "the seat class survives as the positioning hook").toContain(
29
+ 'class="hk-range-ribbon-reset"',
30
+ );
31
+ expect(
32
+ ribbon,
33
+ "the glyph rides the house-normal lucide slot (every in-tree HkIconButton slots its glyph)",
34
+ ).toContain("<RotateCcw");
35
+ expect(ribbon, "no hand-rolled <button> may carry the reset class").not.toMatch(
36
+ /<button[^>]*hk-range-ribbon-reset/,
37
+ );
38
+ // The three things a partial revert silently drops while still passing
39
+ // the substring checks above.
40
+ expect(ribbon, "the reset keeps its touch-floor size").toMatch(
41
+ /<HIconButton[\s\S]{0,200}?size=\{24\}/,
42
+ );
43
+ // The non-submit guarantee moved upstream into HkIconButton (one
44
+ // implementation, not a per-call-site spread); it is pinned in
45
+ // HkIconButton.test.tsx and asserted on the rendered DOM in
46
+ // HkRangeRibbon.test.tsx.
47
+ expect(ribbon, "the reset passes the standard variant explicitly").toContain('variant="ghost"');
48
+ expect(ribbon, "the glyph rides the slot form, not the icon prop").toMatch(
49
+ /\{\{\s*icon:\s*\(\)\s*=>\s*<RotateCcw/,
50
+ );
51
+ });
52
+ });
package/src/index.ts CHANGED
@@ -978,7 +978,7 @@ export type {
978
978
  } from "./composables/useInfinitePages";
979
979
  export { default as HkFilterChip } from "./components/HkFilterChip";
980
980
  export { default as HkPillToggleGroup } from "./components/HkPillToggleGroup";
981
- export type { HkPillToggleGroupOption } from "./components/HkPillToggleGroup";
981
+ export type { HkPillToggleGroupOption, HkPillTone } from "./components/HkPillToggleGroup";
982
982
  export { HkIconChip as HkIconChip } from "./components/HkIconChip";
983
983
  export { HkIconChip as HIconChip } from "./components/HkIconChip";
984
984
  export { HkCardList, HkListRow } from "./components/HkCardList";