@celestia-island/hikari 0.55.298 → 0.55.299

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.298",
3
+ "version": "0.55.299",
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",
@@ -660,4 +660,158 @@ describe("HkModal Escape precedence", () => {
660
660
  document.removeEventListener("keydown", pageListener);
661
661
  }
662
662
  });
663
+
664
+ it("does not close a non-closable window on a consumed Escape, and does not swallow it", async () => {
665
+ const container = document.createElement("div");
666
+ document.body.appendChild(container);
667
+ containers.push(container);
668
+ const open = ref(true);
669
+ const emitted: boolean[] = [];
670
+ const Wrapper = defineComponent({
671
+ setup() {
672
+ return () =>
673
+ h(HkModal, {
674
+ modelValue: open.value,
675
+ closable: false,
676
+ title: "Locked",
677
+ "onUpdate:modelValue": (v: boolean) => { emitted.push(v); open.value = v; },
678
+ }, {
679
+ default: () => h("div", {
680
+ class: "escape-owner",
681
+ onKeydown: (e: KeyboardEvent) => {
682
+ if (e.key === "Escape") e.preventDefault();
683
+ },
684
+ }, "nested control"),
685
+ });
686
+ },
687
+ });
688
+ const app = createApp(Wrapper);
689
+ mounts.push(app);
690
+ app.mount(container);
691
+ await vi.waitFor(() => {
692
+ expect(document.body.querySelector(".escape-owner")).not.toBeNull();
693
+ });
694
+ const owner = document.body.querySelector<HTMLElement>(".escape-owner")!;
695
+
696
+ const pageHits: string[] = [];
697
+ const pageListener = (e: KeyboardEvent) => {
698
+ if (e.key === "Escape") pageHits.push("escape");
699
+ };
700
+ document.addEventListener("keydown", pageListener);
701
+ try {
702
+ const consumed = new KeyboardEvent("keydown", {
703
+ key: "Escape",
704
+ bubbles: true,
705
+ cancelable: true,
706
+ });
707
+ owner.dispatchEvent(consumed);
708
+ await nextTick();
709
+ expect(consumed.defaultPrevented, "the nested control consumed the key").toBe(true);
710
+ expect(emitted, "a non-closable window never emits on Escape").toEqual([]);
711
+ expect(open.value).toBe(true);
712
+ // Current semantics: the precedence guard returns before the close path
713
+ // and does NOT stopPropagation for a non-closable window, so page-level
714
+ // listeners still see the key.
715
+ expect(pageHits, "a non-closable window must not swallow the key").toEqual(["escape"]);
716
+ } finally {
717
+ document.removeEventListener("keydown", pageListener);
718
+ }
719
+ });
720
+
721
+ it("leaves a bare Escape to the host when the window is not closable", async () => {
722
+ const container = document.createElement("div");
723
+ document.body.appendChild(container);
724
+ containers.push(container);
725
+ const open = ref(true);
726
+ const emitted: boolean[] = [];
727
+ const Wrapper = defineComponent({
728
+ setup() {
729
+ return () =>
730
+ h(HkModal, {
731
+ modelValue: open.value,
732
+ closable: false,
733
+ title: "Locked",
734
+ "onUpdate:modelValue": (v: boolean) => { emitted.push(v); open.value = v; },
735
+ }, {
736
+ default: () => h("div", "content"),
737
+ });
738
+ },
739
+ });
740
+ const app = createApp(Wrapper);
741
+ mounts.push(app);
742
+ app.mount(container);
743
+ await vi.waitFor(() => {
744
+ expect(document.body.querySelector(".hk-modal-content")).not.toBeNull();
745
+ });
746
+ const content = document.body.querySelector<HTMLElement>(".hk-modal-content")!;
747
+
748
+ const pageHits: string[] = [];
749
+ const pageListener = (e: KeyboardEvent) => {
750
+ if (e.key === "Escape") pageHits.push("escape");
751
+ };
752
+ document.addEventListener("keydown", pageListener);
753
+ try {
754
+ const bare = new KeyboardEvent("keydown", {
755
+ key: "Escape",
756
+ bubbles: true,
757
+ cancelable: true,
758
+ });
759
+ content.dispatchEvent(bare);
760
+ await nextTick();
761
+ expect(emitted, "a non-closable window must stay open").toEqual([]);
762
+ expect(open.value).toBe(true);
763
+ expect(pageHits, "the host still gets the key").toEqual(["escape"]);
764
+ expect(bare.defaultPrevented, "and the window did not consume it").toBe(false);
765
+ } finally {
766
+ document.removeEventListener("keydown", pageListener);
767
+ }
768
+ });
769
+
770
+ it("keeps the Tab focus trap working when a nested control consumes Tab", async () => {
771
+ const container = document.createElement("div");
772
+ document.body.appendChild(container);
773
+ containers.push(container);
774
+ const Wrapper = defineComponent({
775
+ setup() {
776
+ return () =>
777
+ h(HkModal, {
778
+ modelValue: true,
779
+ closable: false,
780
+ title: "Trap",
781
+ }, {
782
+ default: () => h("div", {
783
+ class: "tab-owner",
784
+ onKeydown: (e: KeyboardEvent) => {
785
+ if (e.key === "Tab") e.preventDefault();
786
+ },
787
+ }, [
788
+ h("button", { class: "tab-first" }, "first"),
789
+ h("button", { class: "tab-last" }, "last"),
790
+ ]),
791
+ });
792
+ },
793
+ });
794
+ const app = createApp(Wrapper);
795
+ mounts.push(app);
796
+ app.mount(container);
797
+ await vi.waitFor(() => {
798
+ expect(document.body.querySelector(".tab-last")).not.toBeNull();
799
+ });
800
+ const first = document.body.querySelector<HTMLButtonElement>(".tab-first")!;
801
+ const last = document.body.querySelector<HTMLButtonElement>(".tab-last")!;
802
+ const owner = document.body.querySelector<HTMLElement>(".tab-owner")!;
803
+
804
+ last.focus();
805
+ expect(document.activeElement, "the last focusable holds focus").toBe(last);
806
+
807
+ // A nested control consumes Tab; the focus trap must still run. This pins
808
+ // the defaultPrevented guard INSIDE the Escape branch: hoisting it to the
809
+ // top of the handler would silently disable wrapping for exactly this
810
+ // case.
811
+ owner.dispatchEvent(
812
+ new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }),
813
+ );
814
+ await nextTick();
815
+ expect(document.activeElement, "focus wraps back to the first focusable").toBe(first);
816
+ });
663
817
  });
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
4
  HkRangeRibbon,
5
+ normalizeHkRangeWindow,
5
6
  type RibbonLane,
6
7
  type RibbonSegment,
7
8
  type RibbonStatus,
@@ -22,6 +23,14 @@ describe("the public barrel", () => {
22
23
  expect(HkRangeRibbon.name).toBe("HkRangeRibbon");
23
24
  });
24
25
 
26
+ it("re-exports the range-window normalizer hosts share", () => {
27
+ expect(typeof normalizeHkRangeWindow, "normalizeHkRangeWindow must be re-exported").toBe(
28
+ "function",
29
+ );
30
+ expect(normalizeHkRangeWindow([0.7, 0.3])).toEqual([0.3, 0.7]);
31
+ expect(normalizeHkRangeWindow(null)).toBeNull();
32
+ });
33
+
25
34
  it("re-exports the ribbon's public types", () => {
26
35
  // Type-only exports vanish at runtime, so each one is CONSUMED here: a
27
36
  // dropped `type Ribbon*` line fails vue-tsc on this file instead of
@@ -12,7 +12,9 @@
12
12
  the reset chip clears the edge handle (right: 4px = the handle's
13
13
  half-width, so a handle at 0% / 100% is never covered).
14
14
 
15
- Every colour/size comes from hikari theme tokens; the only motion is the
15
+ Colours and spacing come from hikari theme tokens; a few geometry values
16
+ (the 32px strip, the 8px grip, the 2px min-width floor) are deliberate
17
+ literals pinned by the contract, not token drift. The only motion is the
16
18
  pending-segment breathe, registered in animation/registerAnimations.ts. */
17
19
 
18
20
  .hk-range-ribbon {
@@ -6,7 +6,11 @@ import { compile } from "sass";
6
6
  import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { createApp, defineComponent, h, nextTick, ref, type Ref } from "vue";
8
8
 
9
- import HkRangeRibbon, { type RibbonLane, type RibbonSegment } from "./HkRangeRibbon";
9
+ import HkRangeRibbon, {
10
+ normalizeHkRangeWindow,
11
+ type RibbonLane,
12
+ type RibbonSegment,
13
+ } from "./HkRangeRibbon";
10
14
  import { setLocale } from "../i18n/context";
11
15
  import jaBundle from "../i18n/locales/ja/components.json";
12
16
 
@@ -310,6 +314,50 @@ describe("HkRangeRibbon full domain", () => {
310
314
  });
311
315
  });
312
316
 
317
+ // ── Exported normalizer / component parity ──────────────────────────────
318
+ describe("HkRangeRibbon window normalizer parity", () => {
319
+ it("keeps the component on the exported helper (source contract)", () => {
320
+ // The rendered-parity cases below are DIVERGENCE detectors: a
321
+ // byte-identical inlined copy of the algorithm renders the same window
322
+ // and passes them. This source pin is the CALL-SITE guard — the
323
+ // component must keep CALLING the exported normalizer rather than
324
+ // growing a private copy again.
325
+ const tsx = readFileSync(resolve(componentDir, "HkRangeRibbon.tsx"), "utf8");
326
+ expect(tsx).toMatch(/return normalizeHkRangeWindow\(source, minWidth\.value\)/);
327
+ });
328
+
329
+ it("renders exactly the window the exported helper returns", () => {
330
+ const rawWindows: Array<[number, number]> = [
331
+ [0.7, 0.3],
332
+ [0.5, 0.5],
333
+ [1.2, 1.5],
334
+ [-0.5, -0.2],
335
+ [1, 1],
336
+ [0.49, 0.51],
337
+ ];
338
+ for (const raw of rawWindows) {
339
+ const normalized = normalizeHkRangeWindow(raw);
340
+ expect(normalized, `${raw} normalizes`).not.toBeNull();
341
+
342
+ const root = mountRibbon({ window: raw });
343
+ expect(root.hasAttribute("data-windowed"), `${raw} is windowed`).toBe(true);
344
+ const highlight = root.querySelector<HTMLElement>(".hk-range-ribbon-window")!;
345
+ expect(boxOf(highlight).left, `${raw} left`).toBeCloseTo(normalized![0] * 100, 4);
346
+ expect(boxOf(highlight).width, `${raw} width`).toBeCloseTo(
347
+ (normalized![1] - normalized![0]) * 100,
348
+ 4,
349
+ );
350
+
351
+ // Feeding the helper's output back in is idempotent — the rendered
352
+ // box is byte-identical, which is what lets a host pass either the raw
353
+ // or the normalized value into the same prop.
354
+ const again = mountRibbon({ window: [...normalized!] as [number, number] });
355
+ const againHighlight = again.querySelector<HTMLElement>(".hk-range-ribbon-window")!;
356
+ expect(boxOf(againHighlight), `${raw} idempotent`).toEqual(boxOf(highlight));
357
+ }
358
+ });
359
+ });
360
+
313
361
  // ── Windowed view: clipping ─────────────────────────────────────────────
314
362
  describe("HkRangeRibbon windowed view", () => {
315
363
  it("clips and re-normalizes only intersecting segments — the only clipping site", () => {
@@ -802,6 +850,27 @@ describe("HkRangeRibbon reset", () => {
802
850
  expect(second.defaultPrevented).toBe(true);
803
851
  });
804
852
 
853
+ it("cancels an in-flight brush with Escape and consumes the key", async () => {
854
+ const host = mountHost(null);
855
+ const { strip, x } = stubStrip(host.root);
856
+ // Pointer down and stay inside the brush slop: no window exists yet.
857
+ pointer("pointerdown", x(0.5), strip);
858
+ pointer("pointermove", x(0.5) + 2, strip); // 2px < the 3px mouse slop
859
+ await nextTick();
860
+ expect(host.windowChange, "still inside the slop").not.toHaveBeenCalled();
861
+
862
+ const escape = keydown(strip, "Escape");
863
+ await nextTick();
864
+ expect(escape.defaultPrevented, "cancelling a live gesture consumes the key").toBe(true);
865
+ expect(host.windowChange, "cancelling is not a window change").not.toHaveBeenCalled();
866
+
867
+ // PROBE: the gesture is gone — a later move must not revive the brush.
868
+ pointer("pointermove", x(0.8), strip);
869
+ await nextTick();
870
+ expect(host.windowChange, "no brush may be revived after the cancel").not.toHaveBeenCalled();
871
+ expect(host.root.hasAttribute("data-windowed")).toBe(false);
872
+ });
873
+
805
874
  it("drops an in-flight brush when a reset interrupts it", async () => {
806
875
  const host = mountHost(null);
807
876
  const { strip, x } = stubStrip(host.root);
@@ -1107,6 +1176,38 @@ describe("HkRangeRibbon a11y and i18n", () => {
1107
1176
  expect(hint!.textContent).toContain("Resize window");
1108
1177
  });
1109
1178
 
1179
+ it("announces a narrow window with a decimal so it differs from the full domain", () => {
1180
+ const full = mountRibbon({ window: null });
1181
+ expect(
1182
+ full.querySelector<HTMLElement>(".hk-range-ribbon-strip")!.getAttribute("aria-valuetext"),
1183
+ "the full domain keeps the classic integer text",
1184
+ ).toBe("0% – 100%");
1185
+
1186
+ const narrow = mountRibbon({ window: [0.004, 0.996] });
1187
+ const strip = narrow.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
1188
+ const text = strip.getAttribute("aria-valuetext")!;
1189
+ expect(text, "a fractional window keeps one decimal").toContain("0.4%");
1190
+ expect(text).toContain("99.6%");
1191
+ expect(text, "rounding to integers would be indistinguishable").not.toBe("0% – 100%");
1192
+ expect(strip.getAttribute("aria-valuenow"), "valuenow uses the same precision").toBe("0.4");
1193
+
1194
+ const start = narrow.querySelector<HTMLElement>('[data-edge="start"]')!;
1195
+ const end = narrow.querySelector<HTMLElement>('[data-edge="end"]')!;
1196
+ expect(start.getAttribute("aria-valuenow")).toBe("0.4");
1197
+ expect(start.getAttribute("aria-valuetext")).toBe("0.4%");
1198
+ expect(end.getAttribute("aria-valuetext")).toBe("99.6%");
1199
+ // The handle bounds share the precision: at a razor-narrow window an
1200
+ // integer-rounded max (97) would contradict a decimal valuenow (97.5).
1201
+ expect(start.getAttribute("aria-valuemax"), "start max keeps the decimal").toBe("97.6");
1202
+ expect(end.getAttribute("aria-valuemin"), "end min keeps the decimal").toBe("2.4");
1203
+
1204
+ // An integer window keeps the integer text the earlier pins assert.
1205
+ const integer = mountRibbon({ window: [0.2, 0.6] });
1206
+ expect(
1207
+ integer.querySelector<HTMLElement>(".hk-range-ribbon-strip")!.getAttribute("aria-valuetext"),
1208
+ ).toBe("20% – 60%");
1209
+ });
1210
+
1110
1211
  it("localizes the labels and hint from the active locale bundle", async () => {
1111
1212
  await setLocale("ja");
1112
1213
  const root = mountRibbon({ window: [0.2, 0.6] });
@@ -1293,8 +1394,12 @@ describe("HkRangeRibbon stylesheet contract", () => {
1293
1394
  );
1294
1395
  expect(declaration(handle, "cursor")).toBe("ew-resize");
1295
1396
  expect(handle).toContain("touch-action: none");
1397
+ // Require the UNPREFIXED declaration at a declaration boundary: the old
1398
+ // /user-select:\s*none/ also matched "-webkit-user-select: none", so a
1399
+ // hypothetical polyfill that emitted only the prefixed property would
1400
+ // satisfy the pin while the standard property was gone.
1296
1401
  expect(handle, "a handle drag cannot start a text selection").toMatch(
1297
- /user-select:\s*none/,
1402
+ /(?:^|[;{])\s*user-select\s*:\s*none/,
1298
1403
  );
1299
1404
 
1300
1405
  // A clipped 2px sliver must still be visible.
@@ -87,21 +87,60 @@ function clamp01(value: number): number {
87
87
  return value < 0 ? 0 : value > 1 ? 1 : value;
88
88
  }
89
89
 
90
+ /** Percent with adaptive precision. An integer percentage stays an integer —
91
+ * the full domain must keep announcing the classic "0% – 100%" — while a
92
+ * fractional one keeps a decimal, so a narrow window ("0.4% – 99.6%") is
93
+ * distinguishable from the full domain instead of rounding to the same
94
+ * text. */
95
+ function percentNumber(fraction: number): number {
96
+ const percentage = fraction * 100;
97
+ const rounded = Math.round(percentage);
98
+ return Math.abs(percentage - rounded) < 1e-9 ? rounded : Number(percentage.toFixed(1));
99
+ }
100
+
101
+ function percentText(fraction: number): string {
102
+ return `${percentNumber(fraction)}%`;
103
+ }
104
+
90
105
  /** Inline left/width pair — percentages are deterministic in tests and do
91
106
  * not depend on layout measurement for rendering. */
92
107
  function percent(fraction: number): string {
93
108
  return `${Number((fraction * 100).toFixed(4))}%`;
94
109
  }
95
110
 
96
- /** Normalize a host window: swap inverted ranges, clamp to the domain,
97
- * ignore non-finite or malformed input (that means "no window"), and never
98
- * return a window narrower than `minWidth`. */
99
- function normalizeWindow(input: unknown, minWidth: number): WindowRange | null {
111
+ /**
112
+ * Normalize a host-supplied window into the ribbon's contract.
113
+ *
114
+ * This is the ONE implementation the component and its hosts share — the
115
+ * component's former private helper, identical except the min-guard, which
116
+ * absorbs the component's existing sanitizer (that helper had no other
117
+ * observers, so this is not a behaviour change). A consumer that keeps its
118
+ * own rendering/filter driven by the same window (chest's trace model builds
119
+ * its row filter from these fractions) must import this instead of mirroring
120
+ * the algorithm: a copy forks silently the moment the algorithm or the 0.02
121
+ * default changes upstream.
122
+ *
123
+ * @param input Any host window value. `null` / `undefined`, a non-array, an
124
+ * array with fewer than two entries, or either member non-finite all mean
125
+ * "no window" and return `null` (the host reads that as the full domain).
126
+ * @param minWidth Narrowest window the ribbon will produce, defaulting to
127
+ * 0.02. Clamped to [0, 1]; a non-finite or non-positive value falls back
128
+ * to the default.
129
+ * @returns An ordered, domain-clamped `[start, end]` pair of full-domain
130
+ * fractions, never narrower than `minWidth`, or `null` for "no window".
131
+ */
132
+ export function normalizeHkRangeWindow(
133
+ input: unknown,
134
+ minWidth: number = MIN_WINDOW_FALLBACK,
135
+ ): [number, number] | null {
100
136
  if (input == null || !Array.isArray(input) || input.length < 2) return null;
101
137
  const first = Number(input[0]);
102
138
  const second = Number(input[1]);
103
139
  if (!Number.isFinite(first) || !Number.isFinite(second)) return null;
104
- const min = Math.min(Math.max(minWidth, 0), 1);
140
+ const min =
141
+ Number.isFinite(minWidth) && minWidth > 0
142
+ ? Math.min(minWidth, 1)
143
+ : MIN_WINDOW_FALLBACK;
105
144
  let lo = clamp01(Math.min(first, second));
106
145
  let hi = clamp01(Math.max(first, second));
107
146
  if (hi - lo < min) {
@@ -142,8 +181,10 @@ function intersectsWindow(placed: PlacedSegment, win: WindowRange): boolean {
142
181
  }
143
182
 
144
183
  /** Clamp every segment into the domain; drop NaN/unusable entries (a broken
145
- * row must not take the whole ribbon down). A zero-length segment is kept —
146
- * it renders as a tick thanks to the sheet's min-width. */
184
+ * row must not take the whole ribbon down). A zero-length segment is kept:
185
+ * the LANE sheet gives it a 2px min-width floor under `box-sizing:
186
+ * border-box`, so a lane tick is exactly that 2px (the 1px borders sit
187
+ * inside it); the overview twin draws 1px. */
147
188
  function placeSegments(segments: RibbonSegment[] | undefined): PlacedSegment[] {
148
189
  if (!Array.isArray(segments)) return [];
149
190
  const placed: PlacedSegment[] = [];
@@ -254,7 +295,7 @@ export default defineComponent({
254
295
  const activeWindow = computed<WindowRange | null>(() => {
255
296
  if (!props.windowEnabled) return null;
256
297
  const source = controlled.value ? props.window : internalWindow.value;
257
- return normalizeWindow(source, minWidth.value);
298
+ return normalizeHkRangeWindow(source, minWidth.value);
258
299
  });
259
300
 
260
301
  const lanes = computed<RibbonLane[]>(() =>
@@ -402,7 +443,12 @@ export default defineComponent({
402
443
  if (!props.windowEnabled || gesture) return;
403
444
  if (event.pointerType === "mouse" && event.button !== 0) return;
404
445
  if (!activeWindow.value) return;
405
- // The handle sits over the strip: never start a brush underneath it.
446
+ // The handles are SIBLINGS of the strip, not children of it, so this
447
+ // press never reaches the strip's brush handler in the first place.
448
+ // The stopPropagation below keeps the press out of BUBBLE-phase
449
+ // listeners in the consuming host — none exist in-package today (every
450
+ // in-package document-level pointerdown listener is capture-phase and
451
+ // still sees the press).
406
452
  event.stopPropagation();
407
453
  gesture = { kind: "edge", pointerId: event.pointerId, edge };
408
454
  (event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
@@ -470,10 +516,17 @@ export default defineComponent({
470
516
  function onRootKeyDown(event: KeyboardEvent): void {
471
517
  if (!props.windowEnabled) return;
472
518
  // Escape resets from anywhere inside the ribbon — but only a REAL reset
473
- // consumes the key: with no window, Escape must still reach the host
474
- // (a dialog closes on it while focus sits in here).
519
+ // consumes the key: with no window AND no live gesture, Escape still
520
+ // reaches the host (a dialog closes on it while focus sits in here).
475
521
  if (event.key === "Escape") {
476
- if (resetWindow()) event.preventDefault();
522
+ // An in-flight drag is a real, unsaved gesture: cancel it even before
523
+ // the first commit (a brush still inside the slop has produced no
524
+ // window yet, so the reset path alone would find nothing to do).
525
+ // Cancelling consumes the key on its own; with NO gesture and no
526
+ // window the key is still handed back to the host.
527
+ const hadGesture = gesture !== null;
528
+ gesture = null;
529
+ if (resetWindow() || hadGesture) event.preventDefault();
477
530
  return;
478
531
  }
479
532
  if (event.key !== "Home") return;
@@ -582,10 +635,8 @@ export default defineComponent({
582
635
  aria-disabled={props.windowEnabled ? undefined : "true"}
583
636
  aria-valuemin={0}
584
637
  aria-valuemax={100}
585
- aria-valuenow={Math.round((win ? win[0] : 0) * 100)}
586
- aria-valuetext={`${Math.round((win ? win[0] : 0) * 100)}% – ${Math.round(
587
- (win ? win[1] : 1) * 100,
588
- )}%`}
638
+ aria-valuenow={percentNumber(win ? win[0] : 0)}
639
+ aria-valuetext={`${percentText(win ? win[0] : 0)} – ${percentText(win ? win[1] : 1)}`}
589
640
  title={
590
641
  props.windowEnabled
591
642
  ? t(
@@ -671,9 +722,9 @@ export default defineComponent({
671
722
  tabindex={0}
672
723
  aria-label={t("hikari::ribbon.windowStart", "Window start")}
673
724
  aria-valuemin={0}
674
- aria-valuemax={Math.round((win[1] - minWidth.value) * 100)}
675
- aria-valuenow={Math.round(win[0] * 100)}
676
- aria-valuetext={`${Math.round(win[0] * 100)}%`}
725
+ aria-valuemax={percentNumber(win[1] - minWidth.value)}
726
+ aria-valuenow={percentNumber(win[0])}
727
+ aria-valuetext={percentText(win[0])}
677
728
  style={{ left: percent(win[0]) }}
678
729
  onPointerdown={(event: PointerEvent) => onHandlePointerDown("start", event)}
679
730
  onKeydown={(event: KeyboardEvent) => onHandleKeyDown("start", event)}
@@ -685,10 +736,10 @@ export default defineComponent({
685
736
  aria-orientation="horizontal"
686
737
  tabindex={0}
687
738
  aria-label={t("hikari::ribbon.windowEnd", "Window end")}
688
- aria-valuemin={Math.round((win[0] + minWidth.value) * 100)}
739
+ aria-valuemin={percentNumber(win[0] + minWidth.value)}
689
740
  aria-valuemax={100}
690
- aria-valuenow={Math.round(win[1] * 100)}
691
- aria-valuetext={`${Math.round(win[1] * 100)}%`}
741
+ aria-valuenow={percentNumber(win[1])}
742
+ aria-valuetext={percentText(win[1])}
692
743
  style={{ left: percent(win[1]) }}
693
744
  onPointerdown={(event: PointerEvent) => onHandlePointerDown("end", event)}
694
745
  onKeydown={(event: KeyboardEvent) => onHandleKeyDown("end", event)}
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { normalizeHkRangeWindow } from "./HkRangeRibbon";
4
+
5
+ /**
6
+ * Public contract of `normalizeHkRangeWindow` — the ONE normalizer the
7
+ * ribbon and its hosts share.
8
+ *
9
+ * Motivation: chest's trace model mirrored this algorithm line-by-line
10
+ * (including the 0.02 minimum) to filter its rows with the same window. A
11
+ * mirror like that only has prose pins, so an upstream algorithm/default
12
+ * change forks the host silently. These pins are the function's contract —
13
+ * exact tuples, not "close enough" — because hosts bake these numbers into
14
+ * their own filters.
15
+ */
16
+ /** Window endpoints at double precision + the contract invariants exactly
17
+ * (ordered, in-domain, at least `minWidth` wide). The endpoints of an
18
+ * expanded window are arithmetic results (e.g. 0.5 - 0.7/2), so pinning the
19
+ * 17th significant digit would test IEEE-754, not the contract. */
20
+ function expectWindow(
21
+ actual: [number, number] | null,
22
+ expected: [number, number],
23
+ minWidth = 0.02,
24
+ ): void {
25
+ expect(actual, "a window is returned").not.toBeNull();
26
+ expect(actual![0], "start").toBeCloseTo(expected[0], 12);
27
+ expect(actual![1], "end").toBeCloseTo(expected[1], 12);
28
+ expect(actual![0], "ordered").toBeLessThan(actual![1]);
29
+ expect(actual![0], "clamped to the domain").toBeGreaterThanOrEqual(0);
30
+ expect(actual![1], "clamped to the domain").toBeLessThanOrEqual(1);
31
+ expect(actual![1] - actual![0], "never narrower than minWidth").toBeGreaterThanOrEqual(
32
+ minWidth - 1e-12,
33
+ );
34
+ }
35
+
36
+ describe("normalizeHkRangeWindow", () => {
37
+ it("returns null for anything that is not a usable window", () => {
38
+ expect(normalizeHkRangeWindow(null)).toBeNull();
39
+ expect(normalizeHkRangeWindow(undefined)).toBeNull();
40
+ // Not an array / not a pair / not finite: all mean "no window".
41
+ expect(normalizeHkRangeWindow(0.5)).toBeNull();
42
+ expect(normalizeHkRangeWindow("0.2,0.4")).toBeNull();
43
+ expect(normalizeHkRangeWindow([])).toBeNull();
44
+ expect(normalizeHkRangeWindow([0.2])).toBeNull();
45
+ expect(normalizeHkRangeWindow([Number.NaN, 0.5])).toBeNull();
46
+ expect(normalizeHkRangeWindow([0.2, Number.POSITIVE_INFINITY])).toBeNull();
47
+ });
48
+
49
+ it("orders inverted windows and clamps them to the domain", () => {
50
+ expectWindow(normalizeHkRangeWindow([0.7, 0.3]), [0.3, 0.7]);
51
+ // Both members beyond the end: collapsed to the trailing minimum.
52
+ expectWindow(normalizeHkRangeWindow([1.2, 1.5]), [0.98, 1]);
53
+ // Both members before the start: collapsed to the leading minimum.
54
+ expectWindow(normalizeHkRangeWindow([-0.5, -0.2]), [0, 0.02]);
55
+ });
56
+
57
+ it("expands a degenerate window to the default minimum (0.02)", () => {
58
+ expectWindow(normalizeHkRangeWindow([0.5, 0.5]), [0.49, 0.51]);
59
+ // Exactly the minimum width is left alone — no gratuitous expansion.
60
+ expectWindow(normalizeHkRangeWindow([0.49, 0.51]), [0.49, 0.51]);
61
+ });
62
+
63
+ it("honours a custom minWidth", () => {
64
+ expectWindow(normalizeHkRangeWindow([0.5, 0.5], 0.2), [0.4, 0.6], 0.2);
65
+ expectWindow(normalizeHkRangeWindow([0.5, 0.5], 0.7), [0.15, 0.85], 0.7);
66
+ });
67
+
68
+ it("falls back to the default for an unusable minWidth", () => {
69
+ for (const minWidth of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
70
+ expectWindow(normalizeHkRangeWindow([0.5, 0.5], minWidth), [0.49, 0.51]);
71
+ }
72
+ });
73
+ });
package/src/index.ts CHANGED
@@ -826,6 +826,7 @@ export { default as HkTagDotRow } from "./components/HkTagDotRow";
826
826
  export { default as HkModelCard, type ModelCardTier } from "./components/HkModelCard";
827
827
  export {
828
828
  default as HkRangeRibbon,
829
+ normalizeHkRangeWindow,
829
830
  type RibbonLane,
830
831
  type RibbonSegment,
831
832
  type RibbonStatus,