@celestia-island/hikari 0.55.338 → 0.55.339

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.338",
3
+ "version": "0.55.339",
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",
@@ -1,9 +1,16 @@
1
1
  @use "../styles/polyfill" as *;
2
2
 
3
- /* HkRangeRibbon — a multi-lane sequence ribbon with a brushed window.
4
- Anatomy: a thin OVERVIEW strip (the full domain, always), the window
5
- highlight + outside scrims + two edge handles riding over it, then the
6
- lane list whose segment bars show either the full domain or the window.
3
+ /* HkRangeRibbon — the two-layer sequence waterfall with a brushed window.
4
+ Anatomy: LAYER 1 (data-layer="minimap") is the thin OVERVIEW strip over
5
+ the full domain, always — the window highlight + outside scrims + two edge
6
+ handles ride over it and the reset seat sits on its field. LAYER 2
7
+ (data-layer="detail") is the lane list: the same segments shown either
8
+ across the full domain or mapped onto the window (0..100% of the track).
9
+ The minimap axis is `1 + headroom` wide, so a host can reserve a blank
10
+ trailing tail for a >100% budget; the after scrim dims that tail once a
11
+ window exists. The headroom never reaches the detail layer, and minimal
12
+ mode — which suppresses the lane rows and the detail layer while the bare
13
+ bar still lives in the minimap container — ignores it too.
7
14
 
8
15
  Geometry is part of the contract and pinned by HkRangeRibbon.test.tsx:
9
16
  the strip is 32px tall, a handle is an 8px half-overlapping grip
@@ -20,15 +27,17 @@
20
27
  pending-segment breathe, registered in animation/registerAnimations.ts. */
21
28
 
22
29
  .hk-range-ribbon {
23
- // ONE label column for both surfaces: the overview paints through an
24
- // empty gutter of this width, so an x% bar sits at the same inline
25
- // position in the strip and in its lane (the strip locates segments).
30
+ // ONE label column for both layers: the overview paints through an empty
31
+ // gutter of this width, so both layers always start at the same inline x.
32
+ // Their x% agree only in the full-domain view — a window re-normalizes the
33
+ // detail against the selected sub-domain, and a headroom compresses the
34
+ // strip by `1/span`.
26
35
  // Wave (2026-11-06 raw-records five): the label column is JS-MEASURED —
27
36
  // the component publishes the widest rendered label as a px value on the
28
37
  // root (two-phase free/measure/pin). THIS fixed column is the no-JS /
29
- // pre-measure fallback: a shared width keeps strip-lane x% alignment
30
- // before the measurement lands (a bare max-content here would resolve
31
- // 0px in the empty gutter and misalign everything).
38
+ // pre-measure fallback: a shared width keeps the two layers' inline START
39
+ // aligned before the measurement lands (a bare max-content here would
40
+ // resolve 0px in the empty gutter and misalign everything).
32
41
  --hk-range-ribbon-label-col: minmax(48px, 84px);
33
42
 
34
43
  display: flex;
@@ -38,7 +47,7 @@
38
47
  min-width: 0;
39
48
  }
40
49
 
41
- // ── Overview strip (the full-domain minimap + brush surface) ─────────────
50
+ // ── LAYER 1: overview strip (the full-domain minimap + brush surface) ───
42
51
  .hk-range-ribbon-overview {
43
52
  position: relative;
44
53
  display: grid;
@@ -253,6 +262,17 @@
253
262
  white-space: nowrap;
254
263
  }
255
264
 
265
+ // ── Detail layer (the window-focused lane list) ─────────────────────────
266
+ // A plain column container, nothing else: it exists so the two layers are
267
+ // explicit in the DOM (and to the stylesheet contract) without disturbing
268
+ // the lane list's own gap/column layout. Its x-axis is the selected window
269
+ // mapped to 0..100% — the headroom tail lives only on the minimap layer.
270
+ .hk-range-ribbon-detail {
271
+ display: flex;
272
+ flex-direction: column;
273
+ min-width: 0;
274
+ }
275
+
256
276
  // ── Lane list ───────────────────────────────────────────────────────────
257
277
  .hk-range-ribbon-lanes {
258
278
  display: flex;
@@ -28,7 +28,11 @@ import jaBundle from "../i18n/locales/ja/components.json";
28
28
  * · every reset path (button / dblclick / Escape / Home) lands null and
29
29
  * restores the full view;
30
30
  * · sub-minimum brushes, edge drags and keyboard nudges clamp instead of
31
- * crossing.
31
+ * 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%.
32
36
  * A stylesheet contract at the bottom compiles the sheet and pins the
33
37
  * classes, geometry and the one animation (repo convention: vitest stubs
34
38
  * CSS, so the runtime cases can never catch a dead rule).
@@ -1357,6 +1361,214 @@ describe("HkRangeRibbon controlled contract", () => {
1357
1361
  });
1358
1362
  });
1359
1363
 
1364
+ // ── Dual-layer waterfall + headroom axis ────────────────────────────────
1365
+ 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. */
1368
+ function axisPercent(fraction: number, headroom: number): number {
1369
+ return (fraction / (1 + headroom)) * 100;
1370
+ }
1371
+
1372
+ it("marks both layers explicitly and keeps the lane markup intact", () => {
1373
+ const root = mountRibbon({ window: [0.35, 0.67] });
1374
+ const minimap = root.querySelector<HTMLElement>(".hk-range-ribbon-overview");
1375
+ expect(minimap, "the minimap layer renders").toBeTruthy();
1376
+ expect(minimap!.getAttribute("data-layer")).toBe("minimap");
1377
+ // The strip (brush surface + window chrome host) is the minimap's own.
1378
+ expect(minimap!.querySelector(".hk-range-ribbon-strip")).toBeTruthy();
1379
+ expect(minimap!.querySelector(".hk-range-ribbon-overview-gutter")).toBeTruthy();
1380
+ expect(minimap!.querySelectorAll(".hk-range-ribbon-overview-row")).toHaveLength(2);
1381
+
1382
+ const detail = root.querySelector<HTMLElement>(".hk-range-ribbon-detail");
1383
+ expect(detail, "the detail layer renders").toBeTruthy();
1384
+ expect(detail!.getAttribute("data-layer")).toBe("detail");
1385
+ // The lane list keeps its markup verbatim and lives INSIDE the wrapper.
1386
+ const lanes = detail!.querySelector<HTMLElement>(".hk-range-ribbon-lanes");
1387
+ expect(lanes, "the lane list is nested in the detail layer").toBeTruthy();
1388
+ expect(lanes!.parentElement).toBe(detail);
1389
+ expect(detail!.querySelectorAll(".hk-range-ribbon-lane")).toHaveLength(2);
1390
+ expect(detail!.querySelectorAll(".hk-range-ribbon-lane-track")).toHaveLength(2);
1391
+ // Exactly two layers, in reading order (minimap above detail).
1392
+ expect(Array.from(root.children).map((child) => child.getAttribute("data-layer"))).toEqual([
1393
+ "minimap",
1394
+ "detail",
1395
+ ]);
1396
+ // Minimal mode stays a bare bar: no detail layer at all.
1397
+ expect(mountRibbon({ minimal: true }).querySelector(".hk-range-ribbon-detail")).toBeNull();
1398
+ });
1399
+
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>(
1404
+ '.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
1405
+ )!;
1406
+ expect(boxOf(parseOverview).left, "minimap left = 0.3 / span").toBeCloseTo(
1407
+ axisPercent(0.3, 0.1),
1408
+ 4,
1409
+ );
1410
+ expect(boxOf(parseOverview).width, "minimap width = 0.15 / span").toBeCloseTo(
1411
+ axisPercent(0.15, 0.1),
1412
+ 4,
1413
+ );
1414
+
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%.
1417
+ const parseDetail = segmentById(root, "parse")!;
1418
+ expect(boxOf(parseDetail).left, "detail starts at the window start").toBeCloseTo(0, 4);
1419
+ expect(boxOf(parseDetail).width, "detail width is window-relative").toBeCloseTo(
1420
+ ((0.45 - 0.35) / (0.67 - 0.35)) * 100,
1421
+ 4,
1422
+ );
1423
+ const writeDetail = segmentById(root, "write")!;
1424
+ expect(
1425
+ boxOf(writeDetail).left + boxOf(writeDetail).width,
1426
+ "detail ends at the window end",
1427
+ ).toBeCloseTo(100, 4);
1428
+
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")!));
1433
+ // …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);
1437
+ });
1438
+
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.
1443
+ 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,
1457
+ );
1458
+ expect(
1459
+ 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);
1466
+
1467
+ const start = root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!;
1468
+ 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.
1479
+ expect(end.getAttribute("aria-valuenow")).toBe("100");
1480
+ expect(end.getAttribute("aria-valuetext")).toBe("100%");
1481
+ expect(
1482
+ root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!.getAttribute("aria-valuetext"),
1483
+ ).toBe("20% – 100%");
1484
+ });
1485
+
1486
+ it("sanitizes the headroom so a bad value never distorts the axis", () => {
1487
+ const cases: Array<[number, number]> = [
1488
+ [0, 1],
1489
+ [-0.5, 1],
1490
+ [Number.NaN, 1],
1491
+ [Number.NEGATIVE_INFINITY, 1],
1492
+ [Number.POSITIVE_INFINITY, 1],
1493
+ [2, 2],
1494
+ ];
1495
+ for (const [headroom, span] of cases) {
1496
+ const root = mountRibbon({ window: null, headroom });
1497
+ const parse = root.querySelector<HTMLElement>(
1498
+ '.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
1499
+ )!;
1500
+ expect(parse, `headroom ${headroom} still paints`).toBeTruthy();
1501
+ expect(boxOf(parse).left, `headroom ${headroom} maps through span ${span}`).toBeCloseTo(
1502
+ (0.3 / span) * 100,
1503
+ 4,
1504
+ );
1505
+ }
1506
+ });
1507
+
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,
1518
+ );
1519
+ });
1520
+
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);
1524
+
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);
1540
+ });
1541
+
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] });
1548
+ const restStart = rest.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!;
1549
+ 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 });
1566
+ const { strip, x } = stubStrip(host.root);
1567
+ await drag(strip, 0.5, 1, x);
1568
+ expectWindow(lastWindow(host.windowChange), 0.5, 1);
1569
+ });
1570
+ });
1571
+
1360
1572
  // ── Stylesheet contract ─────────────────────────────────────────────────
1361
1573
  const componentDir = dirname(fileURLToPath(import.meta.url));
1362
1574
 
@@ -1511,6 +1723,17 @@ describe("HkRangeRibbon stylesheet contract", () => {
1511
1723
  expect(css).toContain(".hk-range-ribbon-overview-gutter");
1512
1724
  });
1513
1725
 
1726
+ it("makes the detail layer a plain column container for the lane list", () => {
1727
+ const detail = ruleBody(css, ".hk-range-ribbon-detail");
1728
+ expect(declaration(detail, "display")).toBe("flex");
1729
+ expect(declaration(detail, "flex-direction"), "the lane list keeps its own column").toBe(
1730
+ "column",
1731
+ );
1732
+ // The wrapper adds no rhythm of its own: the lanes keep their gap.
1733
+ expect(detail, "the wrapper owns no gap").not.toContain("gap:");
1734
+ expect(declaration(ruleBody(css, ".hk-range-ribbon-lanes"), "gap")).toBe("var(--space-2)");
1735
+ });
1736
+
1514
1737
  it("anchors the tip inward at the track edges", () => {
1515
1738
  const tip = ruleBody(css, ".hk-range-ribbon-tip");
1516
1739
  expect(declaration(tip, "text-align")).toBe("start");
@@ -6,17 +6,41 @@ import HIconButton from "./HkIconButton";
6
6
  import "./HkRangeRibbon.scss";
7
7
 
8
8
  /**
9
- * HkRangeRibbon — the generic multi-lane "sequence ribbon" with a brushed
10
- * observation window.
9
+ * HkRangeRibbon — the generic sequence-ribbon waterfall: two explicit layers
10
+ * projected from ONE host-supplied sequence.
11
11
  *
12
- * One thin OVERVIEW strip paints the whole sequence (fractions of the full
13
- * domain), a highlight marks the active window and the outside is dimmed;
14
- * below it, every lane renders its segments. With no window the lanes are
15
- * the full domain; with a window the lanes zoom to it — only segments that
16
- * intersect the window survive, clipped at the edges and re-normalized to
17
- * the window. The overview NEVER clips: it is the only place the full
18
- * domain stays visible. The overview shares the lanes' label column, so a
19
- * bar at x% sits at the same inline position on both surfaces.
12
+ * The ribbon owns no content vocabulary. `lanes` is the consumer's row
13
+ * structure and `segments` are its parts, each fed in ABSOLUTE fractions of
14
+ * the whole progress (0..1). Both layers derive their x from that same
15
+ * number, which is what keeps them mutually legible:
16
+ *
17
+ * · data-layer="minimap" — the top, compact strip. It paints every lane's
18
+ * segments across the FULL domain and never clips, so the window stays
19
+ * locatable; the window frame, the two outside scrims, both edge handles
20
+ * and the reset button ride on it, and it owns the brush surface.
21
+ * · data-layer="detail" — the lower, taller lane list focused on the
22
+ * selected window: a window of [0.35, 0.67] starts the detail at 35% and
23
+ * ends it at 67% of the whole progress, because only segments that
24
+ * intersect the window survive, clipped at its edges and re-normalized to
25
+ * the track. With no window the detail is the full domain.
26
+ *
27
+ * The minimap shares the lanes' label column (an empty gutter), so both
28
+ * layers always start at the same inline x. Their x% agree only in the
29
+ * FULL-DOMAIN view: a window re-normalizes the detail against the selected
30
+ * sub-domain, and a headroom compresses the minimap by `1 + headroom`.
31
+ *
32
+ * HEADROOM: a host whose sequence is read against more than 100% (a 110%
33
+ * budget, a "10% still unknown" tail) reserves that space with `headroom`, a
34
+ * fraction clamped to 0..1. The minimap axis is then `1 + headroom` wide and
35
+ * every minimap x is `fraction / span`: content 100% sits at 100/110 ≈ 90.9%
36
+ * and the tail stays blank. The detail layer NEVER sees the headroom — its
37
+ * axis is always the content window mapped to 0..100%, so a zoomed detail is
38
+ * never polluted by reserved space. Windows and every emitted value stay
39
+ * CONTENT fractions in 0..1; a pointer in the blank tail clamps to content
40
+ * 100%, because the tail is padding, not selectable progress. `minimal` mode
41
+ * suppresses the minimap lane rows and the whole detail layer (its bare bar
42
+ * still lives in the overview container), so it ignores the headroom
43
+ * entirely and stays on the content axis.
20
44
  *
21
45
  * Editing: drag on the strip to brush a new window, drag either edge handle
22
46
  * to resize it, and reset back to the full domain — the reset button, a
@@ -224,6 +248,31 @@ function windowedBox(placed: PlacedSegment, win: WindowRange): SegmentBox | null
224
248
  };
225
249
  }
226
250
 
251
+ /** The minimap projection for one CONTENT scalar — a coordinate or a length:
252
+ * its AXIS value is `value / span` (span = 1 + headroom). This pair
253
+ * (`projectToAxis` + `axisBox`) is the ONLY place the minimap axis is
254
+ * computed, and every axis value is one of them: the overview segments, the
255
+ * window frame and the before scrim go through `axisBox`; both handles and
256
+ * the after scrim's left edge go through `projectToAxis`. The after scrim's
257
+ * width is `1 - its left`, which is what makes it reach the axis end.
258
+ * `fractionFrom` is the inverse (axis → content) for pointer input. The
259
+ * detail layer is never projected — its axis is the window itself, so none
260
+ * of this may leak into `windowedBox` / `fullDomainBox`. */
261
+ function projectToAxis(value: number, span: number): number {
262
+ return value / span;
263
+ }
264
+
265
+ /** Project a CONTENT box onto the minimap's `1 + headroom` axis: the content
266
+ * is compressed into the left 1/span of the strip and the reserved tail
267
+ * stays empty. Delegates to `projectToAxis` so the axis arithmetic has one
268
+ * home. The detail layer is never projected. */
269
+ function axisBox(box: SegmentBox, span: number): SegmentBox {
270
+ return {
271
+ left: projectToAxis(box.left, span),
272
+ width: projectToAxis(box.width, span),
273
+ };
274
+ }
275
+
227
276
  /** Pointer travel (px) that turns a press into a brush; below it a click
228
277
  * with sub-pixel jitter must not zoom to a hairline window. Touch taps
229
278
  * drift much further than a mouse press (4–6px is normal), so touch gets
@@ -260,10 +309,22 @@ export default defineComponent({
260
309
  windowEnabled: { type: Boolean, default: true },
261
310
  /** Narrowest window the ribbon will produce, in domain fractions. */
262
311
  minWindow: { type: Number, default: MIN_WINDOW_FALLBACK },
312
+ /** Trailing blank axis the MINIMAP reserves after content 100%, in
313
+ * content fractions (0..1). The axis span is `1 + headroom`, so 0.1
314
+ * reads the sequence against a 110% budget: content 100% lands at
315
+ * ~90.9% of the strip and the last ~9.1% stays blank. Sanitized —
316
+ * non-finite / negative means no headroom, above 1 caps at 1. This is a
317
+ * MINIMAP-only axis: the detail layer ignores it (its axis is always
318
+ * the window), `minimal` mode suppresses the minimap lane rows so there
319
+ * is nothing to reserve (its bare bar stays on the content axis), and
320
+ * windows plus every emitted value stay content fractions. */
321
+ headroom: { type: Number, default: 0 },
263
322
  /** Wave (2026-11-06 raw-records five): MINIMAL MODE — a bare dual-handle
264
- * range bar. No lane rows, no overview minimap, no label gutter; the
265
- * strip spans the full width and its track uses muted-based tokens
266
- * (the surface-based track read whitish inside dark phone modals).
323
+ * range bar: no lane rows and no detail layer (the strip still lives in
324
+ * the overview container, which keeps data-layer="minimap"), no label
325
+ * gutter. The strip spans the full width and its track uses muted-based
326
+ * tokens (the surface-based track read whitish inside dark phone
327
+ * modals).
267
328
  * The grips paint only for a NON-null window, so an uncontrolled
268
329
  * minimal bar starts as a bare track; a host that wants the "default =
269
330
  * everything selected" reading feeds the controlled window `[0, 1]`
@@ -292,6 +353,21 @@ export default defineComponent({
292
353
  return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 1) : MIN_WINDOW_FALLBACK;
293
354
  });
294
355
 
356
+ /** Sanitized trailing headroom: a non-finite or negative value is no
357
+ * headroom at all, and more than a full domain of padding is capped
358
+ * (the axis never exceeds 2× the content). */
359
+ const headroom = computed(() => {
360
+ const raw = Number(props.headroom);
361
+ return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 1) : 0;
362
+ });
363
+
364
+ /** Width of the minimap's axis in content fractions. Every minimap x is
365
+ * divided by it; the detail layer never is. `minimal` mode suppresses
366
+ * the lane rows and the detail layer (its bare bar still lives in the
367
+ * overview container), so it ignores the headroom and keeps the content
368
+ * axis — 100% stays at the right edge. */
369
+ const axisSpan = computed(() => (props.minimal ? 1 : 1 + headroom.value));
370
+
295
371
  /** A supplied `window` prop (even null) is the controlled contract; no
296
372
  * prop at all means "keep your own state". */
297
373
  const controlled = computed(() => props.window !== undefined);
@@ -376,12 +452,18 @@ export default defineComponent({
376
452
  return true;
377
453
  }
378
454
 
455
+ /** Pointer x → absolute CONTENT fraction (0..1 of the real progress).
456
+ * The pointer reads as a fraction of the strip's WHOLE width — the
457
+ * axis, headroom included — so the reading is scaled by the span and
458
+ * clamped: a press in the blank tail lands on content 100% instead of
459
+ * inventing progress the host never declared. This is the only
460
+ * axis→content conversion in the component. */
379
461
  function fractionFrom(clientX: number): number | null {
380
462
  const strip = stripRef.value;
381
463
  if (!strip) return null;
382
464
  const rect = strip.getBoundingClientRect();
383
465
  if (!(rect.width > 0)) return null;
384
- return clamp01((clientX - rect.left) / rect.width);
466
+ return clamp01(((clientX - rect.left) / rect.width) * axisSpan.value);
385
467
  }
386
468
 
387
469
  function brushTo(fraction: number): void {
@@ -569,7 +651,8 @@ export default defineComponent({
569
651
  // to its own label; the overview gutter has no text to size from — a
570
652
  // hidden-probe attempt measured misaligned in real Chrome). Measuring
571
653
  // the widest rendered label and publishing it as the shared var gives
572
- // BOTH surfaces the same resolved column: adaptive and aligned.
654
+ // BOTH surfaces the same resolved column, so they start at the same
655
+ // inline x: adaptive and aligned.
573
656
  const rootRef = ref<HTMLElement | null>(null);
574
657
  const pinnedCol = ref<string | null>(null);
575
658
  /** TWO-PHASE measure: FREE the column first. A pinned px width clips
@@ -698,6 +781,19 @@ export default defineComponent({
698
781
  byLane.get(placed.segment.lane)?.push(placed);
699
782
  }
700
783
 
784
+ // Minimap geometry, content → axis (projected through the two helpers
785
+ // only). The after scrim runs to the AXIS end, tail included: left is
786
+ // the projection of the window end and width is `1 - left`, whereas
787
+ // `1 - win[1]` would leave the reserved tail undimmed and read as
788
+ // progress the host still owes.
789
+ const span = axisSpan.value;
790
+ const minimapFrame = win ? axisBox({ left: win[0], width: win[1] - win[0] }, span) : null;
791
+ const minimapBefore = win ? axisBox({ left: 0, width: win[0] }, span) : null;
792
+ const minimapAfterLeft = win ? projectToAxis(win[1], span) : 0;
793
+ const minimapAfter = win
794
+ ? { left: minimapAfterLeft, width: 1 - minimapAfterLeft }
795
+ : null;
796
+
701
797
  const strip = (
702
798
  <div
703
799
  ref={stripRef}
@@ -730,12 +826,14 @@ export default defineComponent({
730
826
  onKeydown={onStripKeyDown}
731
827
  >
732
828
  {/* The overview paints the FULL domain only — it never follows
733
- the zoom, so the window's position stays locatable. */}
829
+ the zoom, so the window's position stays locatable. Its x-axis
830
+ is the content plus the reserved headroom, so every bar here is
831
+ projected by `axisBox`. */}
734
832
  <div class="hk-range-ribbon-overview-lanes" aria-hidden="true">
735
833
  {!props.minimal && lanes.value.map((lane) => (
736
834
  <div class="hk-range-ribbon-overview-row" key={lane.key} data-lane={lane.key}>
737
835
  {(byLane.get(lane.key) ?? []).map((placed) => {
738
- const box = fullDomainBox(placed);
836
+ const box = axisBox(fullDomainBox(placed), span);
739
837
  return (
740
838
  <div
741
839
  key={placed.segment.id}
@@ -755,18 +853,18 @@ export default defineComponent({
755
853
  <div
756
854
  class="hk-range-ribbon-scrim"
757
855
  data-side="before"
758
- style={{ left: "0%", width: percent(win[0]) }}
856
+ style={{ left: percent(minimapBefore!.left), width: percent(minimapBefore!.width) }}
759
857
  />
760
858
  <div
761
859
  class="hk-range-ribbon-scrim"
762
860
  data-side="after"
763
- style={{ left: percent(win[1]), width: percent(1 - win[1]) }}
861
+ style={{ left: percent(minimapAfter!.left), width: percent(minimapAfter!.width) }}
764
862
  />
765
863
  <div
766
864
  class="hk-range-ribbon-window"
767
865
  style={{
768
- left: percent(win[0]),
769
- width: percent(win[1] - win[0]),
866
+ left: percent(minimapFrame!.left),
867
+ width: percent(minimapFrame!.width),
770
868
  }}
771
869
  />
772
870
  </>
@@ -783,15 +881,19 @@ export default defineComponent({
783
881
  data-window-enabled={props.windowEnabled ? "true" : "false"}
784
882
  onKeydown={onRootKeyDown}
785
883
  >
786
- {/* The overview is laid out on the SAME label column as the lanes
787
- (empty gutter first), so x% means the same inline position on
788
- both surfaces — the strip can be used to locate a bar. */}
789
- <div class="hk-range-ribbon-overview">
884
+ {/* LAYER 1 — the minimap: the full domain, the brush surface, the
885
+ window chrome. It shares the lanes' label column (an empty
886
+ gutter first), so both layers always start at the same inline
887
+ x; their x% agree only in the full-domain view (a window
888
+ re-normalizes the detail, a headroom compresses the minimap by
889
+ `1/span`). */}
890
+ <div class="hk-range-ribbon-overview" data-layer="minimap">
790
891
  {/* The gutter's width IS the shared label column: the
791
892
  component JS-measures the widest rendered label and
792
893
  publishes it on the root (two-phase free/measure/pin), so
793
- this empty cell keeps the strip's x% aligned with the
794
- lanes' x% at any label width. */}
894
+ this empty cell keeps both layers' inline START aligned at
895
+ any label width (their x% only agree in the full-domain
896
+ view, with no headroom). */}
795
897
  <span class="hk-range-ribbon-overview-gutter" aria-hidden="true" />
796
898
  <div class="hk-range-ribbon-overview-field">
797
899
  {strip}
@@ -808,7 +910,7 @@ export default defineComponent({
808
910
  aria-valuemax={percentNumber(win[1] - minWidth.value)}
809
911
  aria-valuenow={percentNumber(win[0])}
810
912
  aria-valuetext={percentText(win[0])}
811
- style={{ left: percent(win[0]) }}
913
+ style={{ left: percent(projectToAxis(win[0], span)) }}
812
914
  onPointerdown={(event: PointerEvent) => onHandlePointerDown("start", event)}
813
915
  onKeydown={(event: KeyboardEvent) => onHandleKeyDown("start", event)}
814
916
  />
@@ -823,7 +925,7 @@ export default defineComponent({
823
925
  aria-valuemax={100}
824
926
  aria-valuenow={percentNumber(win[1])}
825
927
  aria-valuetext={percentText(win[1])}
826
- style={{ left: percent(win[1]) }}
928
+ style={{ left: percent(projectToAxis(win[1], span)) }}
827
929
  onPointerdown={(event: PointerEvent) => onHandlePointerDown("end", event)}
828
930
  onKeydown={(event: KeyboardEvent) => onHandleKeyDown("end", event)}
829
931
  />
@@ -866,21 +968,28 @@ export default defineComponent({
866
968
  </span>
867
969
  )}
868
970
  </div>
971
+ {/* LAYER 2 — the detail: the same segments, focused. With a window
972
+ each bar is clipped to it and re-normalized to the track (so a
973
+ [0.35, 0.67] window spans 0..100% here); with no window it is
974
+ the full domain. The headroom is deliberately absent: this axis
975
+ is the selected content, never the reserved padding. */}
869
976
  {!props.minimal && (
870
- <div class="hk-range-ribbon-lanes">
871
- {lanes.value.map((lane) => (
872
- <div class="hk-range-ribbon-lane" key={lane.key} data-lane={lane.key}>
873
- <span class="hk-range-ribbon-lane-label" title={lane.label}>
874
- {lane.label}
875
- </span>
876
- <div class="hk-range-ribbon-lane-track" role="group" aria-label={lane.label}>
877
- {(byLane.get(lane.key) ?? []).map((placed) => {
878
- const box = win ? windowedBox(placed, win) : fullDomainBox(placed);
879
- return box ? renderSegment(placed, box) : null;
880
- })}
977
+ <div class="hk-range-ribbon-detail" data-layer="detail">
978
+ <div class="hk-range-ribbon-lanes">
979
+ {lanes.value.map((lane) => (
980
+ <div class="hk-range-ribbon-lane" key={lane.key} data-lane={lane.key}>
981
+ <span class="hk-range-ribbon-lane-label" title={lane.label}>
982
+ {lane.label}
983
+ </span>
984
+ <div class="hk-range-ribbon-lane-track" role="group" aria-label={lane.label}>
985
+ {(byLane.get(lane.key) ?? []).map((placed) => {
986
+ const box = win ? windowedBox(placed, win) : fullDomainBox(placed);
987
+ return box ? renderSegment(placed, box) : null;
988
+ })}
989
+ </div>
881
990
  </div>
882
- </div>
883
- ))}
991
+ ))}
992
+ </div>
884
993
  </div>
885
994
  )}
886
995
  </div>