@celestia-island/hikari 0.55.339 → 0.55.340

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.
@@ -19,20 +19,21 @@ import jaBundle from "../i18n/locales/ja/components.json";
19
19
  *
20
20
  * The component's whole reason to exist is the window contract, so the
21
21
  * cases below pin it from both sides:
22
- * · windowed lanes are the ONLY clipping site (full domain → positions
23
- * untouched; clipped → re-normalized; wholly-outside → absent), while
24
- * the overview keeps painting the full domain;
25
- * · the emitted window is ALWAYS in full-domain absolute fractions —
26
- * never relative to the currently zoomed sub-domain (the chest row
27
- * filter depends on exactly that);
22
+ * · clipping has exactly TWO sites: the window (windowed lanes: outside →
23
+ * absent, straddling → re-normalized) and the DOMAIN (`clipToDomain`:
24
+ * content outside the caller's axis paints nowhere on either layer),
25
+ * while the overview keeps painting the whole domain;
26
+ * · the emitted window is ALWAYS in absolute domain coordinates — never
27
+ * relative to the currently zoomed sub-domain (the chest row filter
28
+ * depends on exactly that);
28
29
  * · every reset path (button / dblclick / Escape / Home) lands null and
29
30
  * restores the full view;
30
31
  * · sub-minimum brushes, edge drags and keyboard nudges clamp instead of
31
32
  * crossing;
32
- * · the two layers stay explicit (minimap / detail) over one absolute
33
- * fraction contract, and `headroom` widens ONLY the minimap axis — the
34
- * detail stays window-relative and the blank tail clamps to content
35
- * 100%.
33
+ * · the two layers stay explicit (minimap / detail) over ONE caller
34
+ * `domain` axis (default [0, 1]): the minimap draws the domain, the
35
+ * detail draws the selected window, labels read domain percentages, and
36
+ * the deprecated `headroom` prop is the `[0, 1 + headroom]` alias.
36
37
  * A stylesheet contract at the bottom compiles the sheet and pins the
37
38
  * classes, geometry and the one animation (repo convention: vitest stubs
38
39
  * CSS, so the runtime cases can never catch a dead rule).
@@ -288,6 +289,7 @@ describe("HkRangeRibbon full domain", () => {
288
289
  { id: "far", lane: "cpu", start: -0.5, end: 0.25, label: "far" },
289
290
  { id: "nan", lane: "cpu", start: Number.NaN, end: 0.4, label: "nan" },
290
291
  { id: "orphan", lane: "gpu", start: 0.1, end: 0.2, label: "orphan" },
292
+ { id: "outside", lane: "cpu", start: 1.2, end: 1.4, label: "outside" },
291
293
  ];
292
294
  const root = mountRibbon({ window: null, segments });
293
295
  // Inverted → swapped, out-of-domain → clamped, zero-length kept as a tick.
@@ -302,6 +304,11 @@ describe("HkRangeRibbon full domain", () => {
302
304
  // …and a segment can never invent an undeclared lane.
303
305
  expect(segmentById(root, "orphan")).toBeNull();
304
306
  expect(root.querySelector('[data-segment-id="orphan"]')).toBeNull();
307
+ // Content wholly outside the axis paints NOTHING: the domain is a
308
+ // clipping rule, not a clamp to an edge tick (pre-domain content would
309
+ // have collapsed [1.2, 1.4] onto 100% and drawn a tick there).
310
+ expect(segmentById(root, "outside")).toBeNull();
311
+ expect(root.querySelector('[data-segment-id="outside"]')).toBeNull();
305
312
  });
306
313
 
307
314
  it("shares the label column with the lanes so both axes agree", () => {
@@ -327,7 +334,7 @@ describe("HkRangeRibbon window normalizer parity", () => {
327
334
  // component must keep CALLING the exported normalizer rather than
328
335
  // growing a private copy again.
329
336
  const tsx = readFileSync(resolve(componentDir, "HkRangeRibbon.tsx"), "utf8");
330
- expect(tsx).toMatch(/return normalizeHkRangeWindow\(source, minWidth\.value\)/);
337
+ expect(tsx).toMatch(/return normalizeHkRangeWindow\(source, minWidth\.value, domain\.value\)/);
331
338
  });
332
339
 
333
340
  it("renders exactly the window the exported helper returns", () => {
@@ -1361,14 +1368,83 @@ describe("HkRangeRibbon controlled contract", () => {
1361
1368
  });
1362
1369
  });
1363
1370
 
1364
- // ── Dual-layer waterfall + headroom axis ────────────────────────────────
1371
+ // ── Domain-aware normalizer ─────────────────────────────────────────────
1372
+ describe("HkRangeRibbon domain normalizer", () => {
1373
+ it("clamps, expands and caps inside the caller's domain", () => {
1374
+ // Inverted and out-of-domain windows clamp to the axis.
1375
+ const inverted = normalizeHkRangeWindow([1.5, 0.5], 0.02, [0, 2])!;
1376
+ expect(inverted[0]).toBeCloseTo(0.5, 10);
1377
+ expect(inverted[1]).toBeCloseTo(1.5, 10);
1378
+ const clamped = normalizeHkRangeWindow([-1, 3], 0.02, [0, 2])!;
1379
+ expect(clamped[0]).toBeCloseTo(0, 10);
1380
+ expect(clamped[1]).toBeCloseTo(2, 10);
1381
+
1382
+ // The min width lives in domain units: 0.02 around a point on [0, 2].
1383
+ const point = normalizeHkRangeWindow([0.5, 0.5], 0.02, [0, 2])!;
1384
+ expect(point[0]).toBeCloseTo(0.49, 10);
1385
+ expect(point[1]).toBeCloseTo(0.51, 10);
1386
+
1387
+ // A min width wider than the axis caps at the span instead of escaping
1388
+ // the domain.
1389
+ const capped = normalizeHkRangeWindow([1, 1], 5, [0, 2])!;
1390
+ expect(capped[0]).toBeCloseTo(0, 10);
1391
+ expect(capped[1]).toBeCloseTo(2, 10);
1392
+
1393
+ // At the domain edges the expansion shifts inward, never outside.
1394
+ const leftEdge = normalizeHkRangeWindow([0, 0], 0.02, [0, 2])!;
1395
+ expect(leftEdge[0]).toBeCloseTo(0, 10);
1396
+ expect(leftEdge[1]).toBeCloseTo(0.02, 10);
1397
+ const rightEdge = normalizeHkRangeWindow([2, 2], 0.02, [0, 2])!;
1398
+ expect(rightEdge[0]).toBeCloseTo(1.98, 10);
1399
+ expect(rightEdge[1]).toBeCloseTo(2, 10);
1400
+ });
1401
+
1402
+ it("treats a malformed domain as the unit axis (two-argument parity)", () => {
1403
+ // Hosts calling with two arguments are byte-identical to the old contract.
1404
+ expect(normalizeHkRangeWindow([0.2, 0.4])).toEqual([0.2, 0.4]);
1405
+ expect(normalizeHkRangeWindow([0.2, 0.4], 0.02)).toEqual([0.2, 0.4]);
1406
+ expect(normalizeHkRangeWindow([0.2, 0.4], 0.02, null)).toEqual([0.2, 0.4]);
1407
+ expect(normalizeHkRangeWindow([0.2, 0.4], 0.02, "nope" as unknown as number[])).toEqual([
1408
+ 0.2, 0.4,
1409
+ ]);
1410
+ // A zero-width domain has no interior: the unit axis takes over.
1411
+ expect(normalizeHkRangeWindow([0.2, 0.4], 0.02, [0.5, 0.5])).toEqual([0.2, 0.4]);
1412
+ // An inverted domain is sorted, so it behaves like the same ordered pair.
1413
+ expect(normalizeHkRangeWindow([0.2, 0.4], 0.02, [1, 0])).toEqual([0.2, 0.4]);
1414
+ });
1415
+ });
1416
+
1417
+ // ── Dual-layer waterfall + caller domain axis ───────────────────────────
1365
1418
  describe("HkRangeRibbon dual-layer waterfall", () => {
1366
- /** The minimap's x for a content fraction, in percent: every minimap box
1367
- * is `fraction / span`, where span = 1 + headroom. */
1419
+ /** The minimap's x for a fraction on the deprecated headroom ALIAS axis
1420
+ * `[0, 1 + headroom]`, in percent. */
1368
1421
  function axisPercent(fraction: number, headroom: number): number {
1369
1422
  return (fraction / (1 + headroom)) * 100;
1370
1423
  }
1371
1424
 
1425
+ /** The minimap's x of a DOMAIN coordinate / of a domain LENGTH, in percent. */
1426
+ function domainPercent(value: number, domain: [number, number]): number {
1427
+ return ((value - domain[0]) / (domain[1] - domain[0])) * 100;
1428
+ }
1429
+
1430
+ function domainWidthPercent(length: number, domain: [number, number]): number {
1431
+ return (length / (domain[1] - domain[0])) * 100;
1432
+ }
1433
+
1434
+ /** Inline box assertions at the geometry tolerance. `percent()` rounds to
1435
+ * four decimals OF A PERCENT (~1e-6 fraction), so a four-decimal
1436
+ * `toBeCloseTo` sits exactly on that rounding boundary; 3 keeps a margin
1437
+ * while still pinning well below a percent. */
1438
+ function expectBox(
1439
+ element: HTMLElement | null,
1440
+ expected: { left: number; width: number },
1441
+ label: string,
1442
+ ): void {
1443
+ expect(element, label).toBeTruthy();
1444
+ expect(boxOf(element!).left, `${label} left`).toBeCloseTo(expected.left, 3);
1445
+ expect(boxOf(element!).width, `${label} width`).toBeCloseTo(expected.width, 3);
1446
+ }
1447
+
1372
1448
  it("marks both layers explicitly and keeps the lane markup intact", () => {
1373
1449
  const root = mountRibbon({ window: [0.35, 0.67] });
1374
1450
  const minimap = root.querySelector<HTMLElement>(".hk-range-ribbon-overview");
@@ -1397,85 +1473,204 @@ describe("HkRangeRibbon dual-layer waterfall", () => {
1397
1473
  expect(mountRibbon({ minimal: true }).querySelector(".hk-range-ribbon-detail")).toBeNull();
1398
1474
  });
1399
1475
 
1400
- it("maps the same absolute fraction onto both layers (minimap f/span, detail window-relative)", () => {
1401
- // parse covers 30%..45% of the whole progress; the window is 35%..67%.
1402
- const root = mountRibbon({ window: [0.35, 0.67], headroom: 0.1 });
1403
- const parseOverview = root.querySelector<HTMLElement>(
1476
+ it("keeps the unit axis when no domain is supplied (backward compatible)", () => {
1477
+ // Omitting `domain` (and the alias) is exactly [0, 1]: same boxes, same
1478
+ // aria bounds as an explicit unit axis.
1479
+ const implicit = mountRibbon({ window: [0.2, 0.6] });
1480
+ const explicit = mountRibbon({ domain: [0, 1], window: [0.2, 0.6] });
1481
+ for (const selector of [
1482
+ ".hk-range-ribbon-window",
1483
+ '.hk-range-ribbon-scrim[data-side="after"]',
1484
+ '.hk-range-ribbon-overview-segment[data-segment-id="write"]',
1485
+ '.hk-range-ribbon-lane-track .hk-range-ribbon-segment[data-segment-id="write"]',
1486
+ ]) {
1487
+ const a = implicit.querySelector<HTMLElement>(selector)!;
1488
+ const b = explicit.querySelector<HTMLElement>(selector)!;
1489
+ expect(a.style.left, `${selector} left`).toBe(b.style.left);
1490
+ expect(a.style.width, `${selector} width`).toBe(b.style.width);
1491
+ }
1492
+ const strip = implicit.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
1493
+ expect(strip.getAttribute("aria-valuemin")).toBe("0");
1494
+ expect(strip.getAttribute("aria-valuemax")).toBe("100");
1495
+ });
1496
+
1497
+ it("draws the minimap x as (f - domain[0]) / span across caller axes", () => {
1498
+ // Hard anchor first: on [0, 2] half the strip is coordinate 1.0.
1499
+ const wide = mountRibbon({ domain: [0, 2], window: [0.5, 1.5] });
1500
+ expectBox(
1501
+ wide.querySelector<HTMLElement>(".hk-range-ribbon-window"),
1502
+ { left: 25, width: 50 },
1503
+ "frame [0.5, 1.5] of [0, 2]",
1504
+ );
1505
+ expectBox(
1506
+ wide.querySelector<HTMLElement>('.hk-range-ribbon-overview-segment[data-segment-id="parse"]'),
1507
+ { left: 15, width: 7.5 },
1508
+ "parse 0.3..0.45 of [0, 2]",
1509
+ );
1510
+ expectBox(
1511
+ wide.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="before"]'),
1512
+ { left: 0, width: 25 },
1513
+ "before scrim covers the domain before the window",
1514
+ );
1515
+ expectBox(
1516
+ wide.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="after"]'),
1517
+ { left: 75, width: 25 },
1518
+ "after scrim runs to the domain end",
1519
+ );
1520
+ expect(
1521
+ parseFloat(wide.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!.style.left),
1522
+ ).toBeCloseTo(25, 3);
1523
+ expect(
1524
+ parseFloat(wide.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!.style.left),
1525
+ ).toBeCloseTo(75, 3);
1526
+
1527
+ // Formula sweep over the shapes a caller may declare: negative start,
1528
+ // trailing budget and a narrower window of interest. A window at 10%..70%
1529
+ // of each axis must land at the same strip percentages.
1530
+ const cases: Array<[number, number]> = [
1531
+ [-0.1, 1],
1532
+ [0.2, 0.8],
1533
+ [-0.5, 1.5],
1534
+ ];
1535
+ for (const domain of cases) {
1536
+ const span = domain[1] - domain[0];
1537
+ const window: [number, number] = [domain[0] + 0.1 * span, domain[0] + 0.7 * span];
1538
+ const root = mountRibbon({ domain, window });
1539
+ expectBox(
1540
+ root.querySelector<HTMLElement>(".hk-range-ribbon-window"),
1541
+ { left: 10, width: 60 },
1542
+ `${domain} frame is 10%..70% of the axis`,
1543
+ );
1544
+ expectBox(
1545
+ root.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="before"]'),
1546
+ { left: 0, width: 10 },
1547
+ `${domain} before scrim`,
1548
+ );
1549
+ expectBox(
1550
+ root.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="after"]'),
1551
+ { left: 70, width: 30 },
1552
+ `${domain} after scrim reaches the axis end`,
1553
+ );
1554
+ expect(
1555
+ parseFloat(
1556
+ root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!.style.left,
1557
+ ),
1558
+ `${domain} start grip`,
1559
+ ).toBeCloseTo(10, 3);
1560
+ expect(
1561
+ parseFloat(
1562
+ root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!.style.left,
1563
+ ),
1564
+ `${domain} end grip`,
1565
+ ).toBeCloseTo(70, 3);
1566
+ }
1567
+ });
1568
+
1569
+ it("maps the detail track to the selected WINDOW in domain coordinates", () => {
1570
+ // Domain [0, 2], window [0.5, 1.5]: the detail is 0..100% of that window,
1571
+ // so a bar starting at coordinate 1.0 lands halfway.
1572
+ const segments: RibbonSegment[] = [
1573
+ { id: "lead", lane: "cpu", start: 0.5, end: 0.9, label: "lead" },
1574
+ { id: "mid", lane: "cpu", start: 1, end: 1.2, label: "mid" },
1575
+ { id: "tail", lane: "cpu", start: 1.4, end: 1.8, label: "tail" },
1576
+ ];
1577
+ const root = mountRibbon({ domain: [0, 2], window: [0.5, 1.5], segments });
1578
+ expectBox(segmentById(root, "lead"), { left: 0, width: 40 }, "lead 0.5..0.9");
1579
+ expectBox(segmentById(root, "mid"), { left: 50, width: 20 }, "a bar at 1.0 starts halfway");
1580
+ expectBox(segmentById(root, "tail"), { left: 90, width: 10 }, "tail clipped at the window end");
1581
+
1582
+ // The same window on a negative-start axis keeps its relative mapping:
1583
+ // [-0.1, 1] with window [0.2, 0.6] puts parse (0.3..0.45) at 25%..62.5%.
1584
+ const negative = mountRibbon({ domain: [-0.1, 1], window: [0.2, 0.6] });
1585
+ expectBox(segmentById(negative, "parse"), { left: 25, width: 37.5 }, "parse inside a negative axis");
1586
+ });
1587
+
1588
+ it("paints the no-window detail on the domain axis (the full view)", () => {
1589
+ // With no window the detail IS the full view, so its axis is the domain:
1590
+ // on [0, 2] a segment at 0.3..0.45 lands at 15%..22.5%, and the minimap
1591
+ // twin agrees — the full-domain view is where the two layers line up.
1592
+ const root = mountRibbon({ window: null, domain: [0, 2] });
1593
+ const parse = segmentById(root, "parse")!;
1594
+ expect(boxOf(parse).left, "0.3 on a [0, 2] axis").toBeCloseTo(15, 3);
1595
+ expect(boxOf(parse).width).toBeCloseTo(7.5, 3);
1596
+ const overview = root.querySelector<HTMLElement>(
1404
1597
  '.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
1405
1598
  )!;
1406
- expect(boxOf(parseOverview).left, "minimap left = 0.3 / span").toBeCloseTo(
1407
- axisPercent(0.3, 0.1),
1408
- 4,
1599
+ expect(boxOf(overview).left, "both layers share the domain axis").toBeCloseTo(
1600
+ boxOf(parse).left,
1601
+ 3,
1409
1602
  );
1410
- expect(boxOf(parseOverview).width, "minimap width = 0.15 / span").toBeCloseTo(
1411
- axisPercent(0.15, 0.1),
1412
- 4,
1603
+ expect(boxOf(overview).width).toBeCloseTo(boxOf(parse).width, 3);
1604
+
1605
+ // The deprecated alias drives the no-window detail the same way: it IS a
1606
+ // domain, not a minimap-only tail.
1607
+ const alias = mountRibbon({ window: null, headroom: 0.1 });
1608
+ expect(boxOf(segmentById(alias, "parse")!).left).toBeCloseTo(axisPercent(0.3, 0.1), 3);
1609
+ });
1610
+
1611
+ it("maps the same absolute fraction onto the alias minimap and the window-relative detail", () => {
1612
+ // parse covers 30%..45% of the alias axis [0, 1.1]; the window is
1613
+ // 35%..67% of that same axis.
1614
+ const root = mountRibbon({ window: [0.35, 0.67], headroom: 0.1 });
1615
+ expectBox(
1616
+ root.querySelector<HTMLElement>('.hk-range-ribbon-overview-segment[data-segment-id="parse"]'),
1617
+ { left: axisPercent(0.3, 0.1), width: axisPercent(0.15, 0.1) },
1618
+ "alias minimap parse",
1413
1619
  );
1414
1620
 
1415
- // Detail: the SAME segment, clipped to the window and re-normalized. The
1416
- // headroom never enters this axis — a [0.35, 0.67] window spans 0..100%.
1621
+ // The detail ignores the axis entirely and re-normalizes the window to
1622
+ // its own 0..100% track.
1417
1623
  const parseDetail = segmentById(root, "parse")!;
1418
- expect(boxOf(parseDetail).left, "detail starts at the window start").toBeCloseTo(0, 4);
1624
+ expect(boxOf(parseDetail).left, "detail starts at the window start").toBeCloseTo(0, 3);
1419
1625
  expect(boxOf(parseDetail).width, "detail width is window-relative").toBeCloseTo(
1420
1626
  ((0.45 - 0.35) / (0.67 - 0.35)) * 100,
1421
- 4,
1627
+ 3,
1422
1628
  );
1423
1629
  const writeDetail = segmentById(root, "write")!;
1424
1630
  expect(
1425
1631
  boxOf(writeDetail).left + boxOf(writeDetail).width,
1426
1632
  "detail ends at the window end",
1427
- ).toBeCloseTo(100, 4);
1633
+ ).toBeCloseTo(100, 3);
1428
1634
 
1429
- // A detail box under a headroom is byte-identical to its box with no
1430
- // headroom at all — the tail is a minimap-only projection.
1431
- const noTail = mountRibbon({ window: [0.35, 0.67] });
1432
- expect(boxOf(segmentById(root, "write")!)).toEqual(boxOf(segmentById(noTail, "write")!));
1635
+ // A detail box under the alias is byte-identical to its box with no
1636
+ // alias at all — the window, not the axis, is the detail's unit.
1637
+ const noAlias = mountRibbon({ window: [0.35, 0.67] });
1638
+ expect(boxOf(segmentById(root, "write")!)).toEqual(boxOf(segmentById(noAlias, "write")!));
1433
1639
  // …while the window frame is axis-projected on the minimap.
1434
- const frame = root.querySelector<HTMLElement>(".hk-range-ribbon-window")!;
1435
- expect(boxOf(frame).left).toBeCloseTo(axisPercent(0.35, 0.1), 4);
1436
- expect(boxOf(frame).width).toBeCloseTo(axisPercent(0.32, 0.1), 4);
1640
+ expectBox(
1641
+ root.querySelector<HTMLElement>(".hk-range-ribbon-window"),
1642
+ { left: axisPercent(0.35, 0.1), width: axisPercent(0.32, 0.1) },
1643
+ "alias frame",
1644
+ );
1437
1645
  });
1438
1646
 
1439
- it("reserves the blank tail: scrims, frame and handles all stop at the axis end", () => {
1440
- // window [0.2, 1]: the whole content is selected, so the entire tail is
1441
- // "after the window" and must stay dimmed — reusing 1 - win[1] would
1442
- // leave it bare.
1647
+ it("dims the unselected alias axis all the way to its end", () => {
1648
+ // window [0.2, 1]: content 100% is selected, so the whole 1.0..1.1 end of
1649
+ // the alias axis is unselected and must stay dimmed.
1443
1650
  const root = mountRibbon({ window: [0.2, 1], headroom: 0.1 });
1444
- const before = root.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="before"]')!;
1445
- const after = root.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="after"]')!;
1446
- expect(boxOf(before).width, "before scrim = window start / span").toBeCloseTo(
1447
- axisPercent(0.2, 0.1),
1448
- 4,
1449
- );
1450
- expect(boxOf(after).left, "after scrim starts where content 100% sits").toBeCloseTo(
1451
- axisPercent(1, 0.1),
1452
- 4,
1453
- );
1454
- expect(boxOf(after).width, "after scrim width = (span - window end) / span").toBeCloseTo(
1455
- ((1.1 - 1) / 1.1) * 100,
1456
- 4,
1651
+ expectBox(
1652
+ root.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="before"]'),
1653
+ { left: 0, width: axisPercent(0.2, 0.1) },
1654
+ "before scrim",
1457
1655
  );
1656
+ const after = root.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="after"]')!;
1657
+ expect(boxOf(after).left, "after scrim starts at content 100%").toBeCloseTo(90.9091, 3);
1658
+ expect(boxOf(after).width, "after scrim covers to the alias end").toBeCloseTo(9.0909, 3);
1458
1659
  expect(
1459
1660
  boxOf(after).left + boxOf(after).width,
1460
- "the after scrim reaches the axis end",
1461
- ).toBeCloseTo(100, 4);
1462
-
1463
- const frame = root.querySelector<HTMLElement>(".hk-range-ribbon-window")!;
1464
- expect(boxOf(frame).left).toBeCloseTo(axisPercent(0.2, 0.1), 4);
1465
- expect(boxOf(frame).width).toBeCloseTo(axisPercent(0.8, 0.1), 4);
1661
+ "the scrim reaches the axis end",
1662
+ ).toBeCloseTo(100, 3);
1466
1663
 
1664
+ expectBox(
1665
+ root.querySelector<HTMLElement>(".hk-range-ribbon-window"),
1666
+ { left: axisPercent(0.2, 0.1), width: axisPercent(0.8, 0.1) },
1667
+ "alias frame",
1668
+ );
1467
1669
  const start = root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!;
1468
1670
  const end = root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
1469
- expect(parseFloat(start.style.left), "start grip at content 20% on the axis").toBeCloseTo(
1470
- axisPercent(0.2, 0.1),
1471
- 4,
1472
- );
1473
- expect(parseFloat(end.style.left), "end grip at content 100% on the axis").toBeCloseTo(
1474
- axisPercent(1, 0.1),
1475
- 4,
1476
- );
1477
- // The a11y readings stay CONTENT percentages: the axis projection is
1478
- // presentation, never the value the host or assistive tech sees.
1671
+ expect(parseFloat(start.style.left)).toBeCloseTo(axisPercent(0.2, 0.1), 3);
1672
+ expect(parseFloat(end.style.left)).toBeCloseTo(axisPercent(1, 0.1), 3);
1673
+ // The a11y readings are coordinates, not axis percentages.
1479
1674
  expect(end.getAttribute("aria-valuenow")).toBe("100");
1480
1675
  expect(end.getAttribute("aria-valuetext")).toBe("100%");
1481
1676
  expect(
@@ -1483,7 +1678,7 @@ describe("HkRangeRibbon dual-layer waterfall", () => {
1483
1678
  ).toBe("20% – 100%");
1484
1679
  });
1485
1680
 
1486
- it("sanitizes the headroom so a bad value never distorts the axis", () => {
1681
+ it("sanitizes the deprecated headroom alias into a real axis", () => {
1487
1682
  const cases: Array<[number, number]> = [
1488
1683
  [0, 1],
1489
1684
  [-0.5, 1],
@@ -1500,72 +1695,402 @@ describe("HkRangeRibbon dual-layer waterfall", () => {
1500
1695
  expect(parse, `headroom ${headroom} still paints`).toBeTruthy();
1501
1696
  expect(boxOf(parse).left, `headroom ${headroom} maps through span ${span}`).toBeCloseTo(
1502
1697
  (0.3 / span) * 100,
1503
- 4,
1698
+ 3,
1504
1699
  );
1505
1700
  }
1506
1701
  });
1507
1702
 
1508
- it("keeps the headroom out of the detail when no window is selected", () => {
1509
- const root = mountRibbon({ window: null, headroom: 0.1 });
1510
- // Detail bars keep the full-domain content axis (30%, not 27.27%).
1511
- expect(boxOf(segmentById(root, "parse")!).left).toBeCloseTo(30, 4);
1512
- const overview = root.querySelector<HTMLElement>(
1513
- '.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
1514
- )!;
1515
- expect(boxOf(overview).left, "the minimap still reserves the tail").toBeCloseTo(
1516
- axisPercent(0.3, 0.1),
1517
- 4,
1703
+ it("treats headroom as the [0, 1 + headroom] alias, and a domain wins over it", () => {
1704
+ // Equivalence: the alias and its explicit domain draw the same geometry.
1705
+ const alias = mountRibbon({ headroom: 0.1, window: [0.2, 0.6] });
1706
+ const explicit = mountRibbon({ domain: [0, 1.1], window: [0.2, 0.6] });
1707
+ const compared: Array<{ selector: string; width: boolean }> = [
1708
+ { selector: ".hk-range-ribbon-window", width: true },
1709
+ { selector: '.hk-range-ribbon-scrim[data-side="before"]', width: true },
1710
+ { selector: '.hk-range-ribbon-scrim[data-side="after"]', width: true },
1711
+ { selector: '.hk-range-ribbon-handle[data-edge="start"]', width: false },
1712
+ { selector: '.hk-range-ribbon-handle[data-edge="end"]', width: false },
1713
+ { selector: '.hk-range-ribbon-overview-segment[data-segment-id="parse"]', width: true },
1714
+ ];
1715
+ for (const { selector, width } of compared) {
1716
+ const fromAlias = alias.querySelector<HTMLElement>(selector)!;
1717
+ const fromDomain = explicit.querySelector<HTMLElement>(selector)!;
1718
+ expect(parseFloat(fromAlias.style.left), `${selector} left`).toBeCloseTo(
1719
+ parseFloat(fromDomain.style.left),
1720
+ 10,
1721
+ );
1722
+ if (width) {
1723
+ expect(parseFloat(fromAlias.style.width), `${selector} width`).toBeCloseTo(
1724
+ parseFloat(fromDomain.style.width),
1725
+ 10,
1726
+ );
1727
+ }
1728
+ }
1729
+ // The alias announces its own end (110%), never the unit axis.
1730
+ expect(
1731
+ alias.querySelector<HTMLElement>(".hk-range-ribbon-strip")!.getAttribute("aria-valuemax"),
1732
+ ).toBe("110");
1733
+
1734
+ // A valid `domain` wins over a supplied headroom.
1735
+ const both = mountRibbon({ domain: [0, 2], headroom: 0.1 });
1736
+ expect(
1737
+ boxOf(
1738
+ both.querySelector<HTMLElement>(
1739
+ '.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
1740
+ )!,
1741
+ ).left,
1742
+ "domain [0, 2] wins: parse at 15%",
1743
+ ).toBeCloseTo(15, 3);
1744
+ expect(
1745
+ both.querySelector<HTMLElement>(".hk-range-ribbon-strip")!.getAttribute("aria-valuemax"),
1746
+ ).toBe("200");
1747
+ });
1748
+
1749
+ it("treats the headroom alias as a real axis: selectable tail, axis-end rest state", async () => {
1750
+ // The deprecated `headroom` alias is SOURCE-compatible with the old prop,
1751
+ // not behaviour-compatible: [0, 1.1] is the coordinate system, so the old
1752
+ // "padding, not selectable progress" tail is gone. This case pins the
1753
+ // deltas a headroom host would observe.
1754
+ const host = mountHost([0, 1], { headroom: 0.1 });
1755
+ const root = host.root;
1756
+ // [0, 1] is a SUB-view of a [0, 1.1] axis, so there IS something to reset
1757
+ // (the old component treated content 100% as the rest state).
1758
+ expect(
1759
+ root.querySelector(".hk-range-ribbon-reset"),
1760
+ "a sub-view of the alias axis has a reset chip",
1761
+ ).toBeTruthy();
1762
+ keydown(root, "Escape");
1763
+ await nextTick();
1764
+ expect(lastWindow(host.windowChange), "Escape clears to the full axis").toBeNull();
1765
+
1766
+ // The tail is selectable axis: a press at strip 95% reads 1.045 (the old
1767
+ // component clamped it to content 1.0).
1768
+ const { strip, x } = stubStrip(root);
1769
+ await drag(strip, 0.95, 0.5, x);
1770
+ expectWindow(lastWindow(host.windowChange), 0.55, 1.045);
1771
+
1772
+ // Shift+ArrowLeft from the full axis contracts it by 5% of the SPAN
1773
+ // (0.055), not the unit axis's 0.05.
1774
+ keydown(root, "Escape");
1775
+ await nextTick();
1776
+ expect(lastWindow(host.windowChange), "back on the full axis").toBeNull();
1777
+ keydown(root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!, "ArrowLeft", {
1778
+ shiftKey: true,
1779
+ });
1780
+ await nextTick();
1781
+ expectWindow(lastWindow(host.windowChange), 0, 1.045);
1782
+ });
1783
+
1784
+ it("caps minWindow at the axis span when the domain is narrower", () => {
1785
+ // A 0.01 span cannot honour the default 0.02 floor, so the component caps
1786
+ // the floor at the span. The cap is visible in the handle bounds: an
1787
+ // uncapped 0.02 floor would announce 49% / 52% instead of 50% / 51%.
1788
+ const root = mountRibbon({ domain: [0.5, 0.51], window: [0.505, 0.505] });
1789
+ expectBox(
1790
+ root.querySelector<HTMLElement>(".hk-range-ribbon-window"),
1791
+ { left: 0, width: 100 },
1792
+ "a point expands to the whole narrow axis",
1518
1793
  );
1794
+ expect(
1795
+ root.querySelector<HTMLElement>('[data-edge="start"]')!.getAttribute("aria-valuemax"),
1796
+ "the floor is the span, not the 0.02 default",
1797
+ ).toBe("50");
1798
+ expect(
1799
+ root.querySelector<HTMLElement>('[data-edge="end"]')!.getAttribute("aria-valuemin"),
1800
+ "end floor = start + the capped min",
1801
+ ).toBe("51");
1802
+ });
1803
+
1804
+ it("maps pointer input onto the domain and clamps to its edges", async () => {
1805
+ // Domain [0, 2]: half the strip is coordinate 1.0, so a brush from axis
1806
+ // 50% to the far right is [1, 2]. A read that ignored the domain would
1807
+ // answer [0.5, 1].
1808
+ const wide = mountHost(null, { domain: [0, 2] });
1809
+ const wideStrip = stubStrip(wide.root);
1810
+ await drag(wideStrip.strip, 0.5, 1, wideStrip.x);
1811
+ expectWindow(lastWindow(wide.windowChange), 1, 2);
1812
+
1813
+ // Negative start, [-0.1, 1]: axis 50% is coordinate 0.45, and a press
1814
+ // past the right edge still clamps to the domain end 1.
1815
+ const negative = mountHost(null, { domain: [-0.1, 1] });
1816
+ const neg = stubStrip(negative.root);
1817
+ await drag(neg.strip, 0.5, 1.25, neg.x);
1818
+ expectWindow(lastWindow(negative.windowChange), 0.45, 1);
1819
+
1820
+ // The handle path reads the same axis: with domain [0, 2] the end grip
1821
+ // dragged to axis 95% is coordinate 1.9 (a read that ignored the domain
1822
+ // would leave the window at 0.95).
1823
+ await wide.setWindow([0.5, 1.5]);
1824
+ const end = wide.root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
1825
+ expect(end, "the end grip renders for the sub-window").toBeTruthy();
1826
+ await drag(end, 0.75, 0.95, wideStrip.x);
1827
+ expectWindow(lastWindow(wide.windowChange), 0.5, 1.9);
1828
+ });
1829
+
1830
+ it("reads labels and ARIA in domain units (negative and above 100%)", () => {
1831
+ // Negative start: the full view announces its own axis, not 0%..100%.
1832
+ const negative = mountRibbon({ domain: [-0.1, 0.5] });
1833
+ const strip = negative.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
1834
+ expect(strip.getAttribute("aria-valuemin")).toBe("-10");
1835
+ expect(strip.getAttribute("aria-valuemax")).toBe("50");
1836
+ expect(strip.getAttribute("aria-valuenow"), "full view = domain start").toBe("-10");
1837
+ expect(strip.getAttribute("aria-valuetext")).toBe("-10% – 50%");
1838
+
1839
+ // A window that itself starts below zero keeps its sign on both the strip
1840
+ // and the start grip.
1841
+ const windowed = mountRibbon({ domain: [-0.1, 1], window: [-0.05, 0.4] });
1842
+ const windowedStrip = windowed.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
1843
+ expect(windowedStrip.getAttribute("aria-valuenow")).toBe("-5");
1844
+ expect(windowedStrip.getAttribute("aria-valuetext")).toBe("-5% – 40%");
1845
+ expect(
1846
+ windowed
1847
+ .querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!
1848
+ .getAttribute("aria-valuetext"),
1849
+ ).toBe("-5%");
1850
+
1851
+ // Above 100%: a 200% budget reads as 0..200, and a window ending at 150%
1852
+ // announces 150 rather than a rescaled 75.
1853
+ const wide = mountRibbon({ domain: [0, 2], window: [0.5, 1.5] });
1854
+ const wideStrip = wide.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
1855
+ expect(wideStrip.getAttribute("aria-valuemin")).toBe("0");
1856
+ expect(wideStrip.getAttribute("aria-valuemax")).toBe("200");
1857
+ expect(wideStrip.getAttribute("aria-valuetext")).toBe("50% – 150%");
1858
+ const wideEnd = wide.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
1859
+ expect(wideEnd.getAttribute("aria-valuenow")).toBe("150");
1860
+ expect(wideEnd.getAttribute("aria-valuetext")).toBe("150%");
1861
+ expect(wideEnd.getAttribute("aria-valuemax")).toBe("200");
1862
+ });
1863
+
1864
+ it("sanitizes the domain: inverted sorts, unusable values fall back to [0, 1]", () => {
1865
+ // An inverted pair is a caller typo, not a different axis: it is sorted.
1866
+ const inverted = mountRibbon({ domain: [0.6, 0.2] });
1867
+ expect(
1868
+ boxOf(
1869
+ inverted.querySelector<HTMLElement>(
1870
+ '.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
1871
+ )!,
1872
+ ).left,
1873
+ "parse 0.3 on the sorted [0.2, 0.6] axis",
1874
+ ).toBeCloseTo(25, 3);
1875
+
1876
+ const unusable: unknown[] = [
1877
+ [0.5, 0.5],
1878
+ [Number.NaN, 1],
1879
+ [0.2],
1880
+ [],
1881
+ null,
1882
+ [0.2, Number.POSITIVE_INFINITY],
1883
+ ];
1884
+ for (const domain of unusable) {
1885
+ const root = mountRibbon({ domain });
1886
+ const strip = root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
1887
+ expect(strip.getAttribute("aria-valuemax"), `${JSON.stringify(domain)} falls back`).toBe(
1888
+ "100",
1889
+ );
1890
+ expect(
1891
+ boxOf(
1892
+ root.querySelector<HTMLElement>(
1893
+ '.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
1894
+ )!,
1895
+ ).left,
1896
+ ).toBeCloseTo(30, 3);
1897
+ }
1519
1898
  });
1520
1899
 
1521
- it("clamps a pointer in the blank tail to content 100% (padding is not progress)", async () => {
1522
- const host = mountHost(null, { headroom: 0.1 });
1523
- const { strip, x } = stubStrip(host.root);
1900
+ it("clips overview segments to the domain and drops points outside it", () => {
1901
+ // Domain [0.2, 0.8]: boot ends exactly at the domain start and flush
1902
+ // starts past its end, so neither paints. An explicit segment list is
1903
+ // needed here: the shared fixture straddles the axis differently.
1904
+ const segments: RibbonSegment[] = [
1905
+ { id: "boot", lane: "cpu", start: 0.05, end: 0.2, label: "boot" },
1906
+ { id: "write", lane: "io", start: 0.5, end: 0.9, label: "write" },
1907
+ { id: "flush", lane: "io", start: 0.95, end: 1, label: "flush" },
1908
+ { id: "edge", lane: "cpu", start: 0.2, end: 0.2, label: "edge" },
1909
+ { id: "gone", lane: "cpu", start: 0.9, end: 0.9, label: "gone" },
1910
+ ];
1911
+ const root = mountRibbon({ window: null, domain: [0.2, 0.8], segments });
1912
+ expect(root.querySelector('[data-segment-id="boot"]'), "ends at the domain start").toBeNull();
1913
+ expect(root.querySelector('[data-segment-id="flush"]'), "starts past the domain end").toBeNull();
1914
+ // A zero-length point ON the domain start is kept as a tick; one past the
1915
+ // domain end is gone (the domain is the clipping rule, not a clamp).
1916
+ expect(
1917
+ root.querySelector('.hk-range-ribbon-overview-segment[data-segment-id="edge"]'),
1918
+ "a point on the domain edge paints",
1919
+ ).toBeTruthy();
1920
+ expect(root.querySelector('[data-segment-id="gone"]'), "a point outside is gone").toBeNull();
1921
+ // A bar straddling the domain end is clipped, not rescaled away.
1922
+ expectBox(
1923
+ root.querySelector<HTMLElement>(
1924
+ '.hk-range-ribbon-overview-segment[data-segment-id="write"]',
1925
+ ),
1926
+ { left: 50, width: 50 },
1927
+ "write clipped at the domain end",
1928
+ );
1524
1929
 
1525
- // A press at axis 95% is inside the tail (content 104.5%) and must anchor
1526
- // at content 1.0; the drag back to axis 30% is content 33%.
1527
- await drag(strip, 0.95, 0.3, x);
1528
- expectWindow(lastWindow(host.windowChange), 0.33, 1);
1529
-
1530
- // The end handle dragged INTO the blank tail (axis 95% reads content
1531
- // 104.5%) still commits content 100%: a read that skipped the span would
1532
- // answer 95% and leave the window at [0.2, 0.95]. Dragging to the exact
1533
- // far right would pass either way (1 × span = 1), so this target is the
1534
- // revert discriminator for the handle path.
1535
- await host.setWindow([0.2, 0.5]);
1536
- const end = host.root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
1537
- expect(end, "the end grip renders for the sub-window").toBeTruthy();
1538
- await drag(end, 0.45, 0.95, x);
1539
- expectWindow(lastWindow(host.windowChange), 0.2, 1);
1930
+ // The START clip anchors at the caller's d0, not at absolute 0: on
1931
+ // [-0.5, 1.5] a segment [-1, 0] straddles the domain start and paints
1932
+ // 0%..25% (clipping at 0 would paint 25%..0%: an empty box at the wrong
1933
+ // place). The no-window detail reuses the same domain box.
1934
+ const straddling = mountRibbon({
1935
+ window: null,
1936
+ domain: [-0.5, 1.5],
1937
+ segments: [{ id: "straddle", lane: "cpu", start: -1, end: 0, label: "straddle" }],
1938
+ });
1939
+ expectBox(
1940
+ straddling.querySelector<HTMLElement>(
1941
+ '.hk-range-ribbon-overview-segment[data-segment-id="straddle"]',
1942
+ ),
1943
+ { left: 0, width: 25 },
1944
+ "the overview start clip anchors at d0",
1945
+ );
1946
+ expectBox(
1947
+ segmentById(straddling, "straddle"),
1948
+ { left: 0, width: 25 },
1949
+ "the no-window detail shares that domain box",
1950
+ );
1951
+ });
1952
+
1953
+ it("treats a window covering the whole domain as the unfiltered state", () => {
1954
+ // The reset chip keys off the DOMAIN: [0, 2] on a [0, 2] axis is the rest
1955
+ // state (nothing to reset), while [0, 1] is a real sub-selection.
1956
+ const full = mountRibbon({ domain: [0, 2], window: [0, 2] });
1957
+ expect(
1958
+ full.querySelector(".hk-range-ribbon-reset"),
1959
+ "the whole axis selected = nothing to reset",
1960
+ ).toBeNull();
1961
+ const sub = mountRibbon({ domain: [0, 2], window: [0, 1] });
1962
+ expect(sub.querySelector(".hk-range-ribbon-reset"), "a sub-window shows reset").toBeTruthy();
1540
1963
  });
1541
1964
 
1542
- it("ignores the headroom in minimal mode (it is a minimap-only axis)", async () => {
1543
- // Minimal mode has no minimap layer, so the bare bar must stay on the
1544
- // CONTENT axis. A headroom-applied span would park the rest end grip at
1545
- // ~90.9% and leave an unlabeled dim tail on a bar that has no tail
1546
- // semantics.
1547
- const rest = mountRibbon({ minimal: true, headroom: 0.1, window: [0, 1] });
1965
+ it("nudges the window by 5% of the visible span from the keyboard", async () => {
1966
+ // Domain [0, 2]: one arrow moves the window 0.1 (5% of the span), not the
1967
+ // unit axis's 0.05, so the visual step stays 5% of the strip.
1968
+ const host = mountHost([0.5, 1], { domain: [0, 2] });
1969
+ const strip = host.root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
1970
+ keydown(strip, "ArrowRight");
1971
+ await nextTick();
1972
+ expectWindow(lastWindow(host.windowChange), 0.6, 1.1);
1973
+ // Shift+Arrow resizes from the trailing edge by the same step.
1974
+ keydown(strip, "ArrowRight", { shiftKey: true });
1975
+ await nextTick();
1976
+ expectWindow(lastWindow(host.windowChange), 0.6, 1.2);
1977
+ // The handle slider nudges its own edge by the same step.
1978
+ const start = host.root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!;
1979
+ keydown(start, "ArrowRight");
1980
+ await nextTick();
1981
+ expectWindow(lastWindow(host.windowChange), 0.7, 1.2);
1982
+ });
1983
+
1984
+ it("keeps the keyboard range entry inside the caller's domain", async () => {
1985
+ // Shift+ArrowLeft on the full view opens a window from the domain start,
1986
+ // stopped at `max(d0 + minWindow, d1 - step)` — a COORDINATE floor. The
1987
+ // bare-width version went dead on axes whose end sits below minWindow
1988
+ // and emitted sub-minimum windows on tight axes.
1989
+ const open = async (domain: [number, number]): Promise<[number, number] | null> => {
1990
+ const host = mountHost(null, { domain });
1991
+ keydown(host.root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!, "ArrowLeft", {
1992
+ shiftKey: true,
1993
+ });
1994
+ await nextTick();
1995
+ return host.windowChange.mock.calls.length === 0 ? null : lastWindow(host.windowChange);
1996
+ };
1997
+
1998
+ // (a) An axis whose END is below the 0.02 floor: the entry must still
1999
+ // work (the old floor exceeded the domain end and returned early).
2000
+ expectWindow(await open([-1, 0]), -1, -0.05);
2001
+ // (b) A tight axis: the emitted width may never sit below minWindow (the
2002
+ // bare-width floor emitted 0.01995 against a 0.02 floor).
2003
+ const tight = await open([0.5, 0.521]);
2004
+ expectWindow(tight, 0.5, 0.52);
2005
+ expect(tight![1] - tight![0], "never narrower than minWindow").toBeGreaterThanOrEqual(0.02);
2006
+ // (c) The floor anchors at d0 + minWindow (-0.45), not at absolute 0.02.
2007
+ expectWindow(await open([-0.47, 0.03]), -0.47, 0.005);
2008
+ });
2009
+
2010
+ it("keeps a live brush inside a domain that changed mid-gesture", async () => {
2011
+ // The anchor is captured at pointerdown. If the host narrows the axis
2012
+ // while the pointer is down, the anchor must be re-clamped before the
2013
+ // window is built — otherwise the committed window escapes the new axis.
2014
+ const container = document.createElement("div");
2015
+ document.body.appendChild(container);
2016
+ const liveDomain = ref<[number, number]>([0, 1]);
2017
+ const win = ref<[number, number] | null>(null);
2018
+ const windowChange = vi.fn();
2019
+ const Wrapper = defineComponent({
2020
+ setup() {
2021
+ return () =>
2022
+ h(HkRangeRibbon, {
2023
+ lanes: LANES,
2024
+ segments: SEGMENTS,
2025
+ domain: liveDomain.value,
2026
+ window: win.value,
2027
+ onWindowChange: (next: [number, number] | null) => {
2028
+ windowChange(next);
2029
+ win.value = next;
2030
+ },
2031
+ });
2032
+ },
2033
+ });
2034
+ const app = createApp(Wrapper);
2035
+ app.mount(container);
2036
+ mounts.push({ app, container });
2037
+ const root = container.querySelector<HTMLElement>(".hk-range-ribbon")!;
2038
+ const { strip, x } = stubStrip(root);
2039
+
2040
+ // Press at 95% of the unit axis, then narrow the axis to [0, 0.5].
2041
+ pointer("pointerdown", x(0.95), strip);
2042
+ liveDomain.value = [0, 0.5];
2043
+ await nextTick();
2044
+ // Moving to half of the NEW axis must build [0.25, 0.5] (a stale 0.95
2045
+ // anchor would have built [0.25, 0.95], outside the new axis).
2046
+ pointer("pointermove", x(0.5), strip);
2047
+ pointer("pointerup", x(0.5), strip);
2048
+ await nextTick();
2049
+ expectWindow(lastWindow(windowChange), 0.25, 0.5);
2050
+ });
2051
+
2052
+ it("draws the caller's domain on minimal mode's bare bar", async () => {
2053
+ // Minimal mode suppresses the minimap lane rows and the whole detail
2054
+ // layer; the bare bar still lives in the overview container, and its axis
2055
+ // is the caller's domain.
2056
+ const rest = mountRibbon({ minimal: true, domain: [0, 2], window: [0, 2] });
1548
2057
  const restStart = rest.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!;
1549
2058
  const restEnd = rest.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
1550
- expect(parseFloat(restStart.style.left), "rest start grip at content 0%").toBeCloseTo(0, 4);
1551
- expect(parseFloat(restEnd.style.left), "rest end grip at content 100%").toBeCloseTo(100, 4);
1552
-
1553
- // A sub-window keeps content geometry for its grips and after scrim: with
1554
- // a span the scrim would start at 54.5% and run 45.5%, i.e. a reserved
1555
- // tail that minimal mode cannot explain.
1556
- const sub = mountRibbon({ minimal: true, headroom: 0.1, window: [0.2, 0.6] });
1557
- const subEnd = sub.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
1558
- expect(parseFloat(subEnd.style.left), "grips keep content fractions").toBeCloseTo(60, 4);
1559
- const after = sub.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="after"]')!;
1560
- expect(boxOf(after).left, "the after scrim starts at the window end").toBeCloseTo(60, 4);
1561
- expect(boxOf(after).width, "no reserved tail to dim").toBeCloseTo(40, 4);
1562
-
1563
- // …and the pointer axis is the content axis: axis 50% reads content 50%
1564
- // (a headroom-applied read would answer 55%), and the far right is 100%.
1565
- const host = mountHost([0.2, 0.6], { minimal: true, headroom: 0.1 });
2059
+ expect(parseFloat(restStart.style.left), "domain start grip").toBeCloseTo(0, 3);
2060
+ expect(parseFloat(restEnd.style.left), "domain end grip").toBeCloseTo(100, 3);
2061
+ expect(restStart.getAttribute("aria-valuetext"), "labels read domain percents").toBe("0%");
2062
+ expect(restEnd.getAttribute("aria-valuetext")).toBe("200%");
2063
+
2064
+ // A sub-window is projected by the domain: [0.5, 1.5] of [0, 2] is
2065
+ // 25%..75%, and the scrims cover the domain the caller did not select.
2066
+ const sub = mountRibbon({ minimal: true, domain: [0, 2], window: [0.5, 1.5] });
2067
+ expect(
2068
+ parseFloat(
2069
+ sub.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!.style.left,
2070
+ ),
2071
+ ).toBeCloseTo(25, 3);
2072
+ expect(
2073
+ parseFloat(
2074
+ sub.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!.style.left,
2075
+ ),
2076
+ ).toBeCloseTo(75, 3);
2077
+ expectBox(
2078
+ sub.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="before"]'),
2079
+ { left: 0, width: 25 },
2080
+ "before scrim",
2081
+ );
2082
+ expectBox(
2083
+ sub.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="after"]'),
2084
+ { left: 75, width: 25 },
2085
+ "after scrim",
2086
+ );
2087
+
2088
+ // The pointer maps onto the same domain: axis 50% is coordinate 1.0, and
2089
+ // a press past the right edge clamps to the domain end 2.0.
2090
+ const host = mountHost([0.5, 1.5], { minimal: true, domain: [0, 2] });
1566
2091
  const { strip, x } = stubStrip(host.root);
1567
- await drag(strip, 0.5, 1, x);
1568
- expectWindow(lastWindow(host.windowChange), 0.5, 1);
2092
+ await drag(strip, 0.5, 1.4, x);
2093
+ expectWindow(lastWindow(host.windowChange), 1, 2);
1569
2094
  });
1570
2095
  });
1571
2096
 
@@ -1748,12 +2273,12 @@ describe("HkRangeRibbon stylesheet contract", () => {
1748
2273
  });
1749
2274
 
1750
2275
  // Wave (2026-11-06 raw-records five): MINIMAL MODE - the bare dual-handle
1751
- // bar. Contract: data-minimal on the root, strip present, ZERO lane chrome
1752
- // (no lanes block, no lane rows, no overview minimap, no gutter probe),
1753
- // and the standard mode keeps its lane chrome + alignment probe. The
1754
- // uncontrolled default window is NULL here — the grips need a controlled
1755
- // non-null window (the chest raw-records adapter feeds [0, 1] for its
1756
- // "default = everything selected" reading); the aria readings below come
2276
+ // bar. Contract: data-minimal on the root, strip present, no lane rows and
2277
+ // no detail layer (the bar still lives in the overview container); the
2278
+ // shared label gutter is still rendered and only display:none in this mode
2279
+ // (the strip spans the full width). The standard mode keeps its lane chrome
2280
+ // + alignment probe. The uncontrolled default window is NULL here — the
2281
+ // grips need a controlled non-null window; the aria readings below come
1757
2282
  // from the strip's null-window fallbacks, not from a real window.
1758
2283
  it("minimal mode renders the bare dual-handle bar (no lane chrome)", () => {
1759
2284
  const root = mountRibbon({ minimal: true });