@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,9 +1,16 @@
|
|
|
1
1
|
@use "../styles/polyfill" as *;
|
|
2
2
|
|
|
3
|
-
/* HkRangeRibbon —
|
|
4
|
-
Anatomy:
|
|
5
|
-
highlight + outside scrims + two edge
|
|
6
|
-
|
|
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
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
|
30
|
-
// before the measurement lands (a bare max-content here would
|
|
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
|
-
// ──
|
|
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
|
|
10
|
-
*
|
|
9
|
+
* HkRangeRibbon — the generic sequence-ribbon waterfall: two explicit layers
|
|
10
|
+
* projected from ONE host-supplied sequence.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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
|
|
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:
|
|
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(
|
|
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(
|
|
769
|
-
width: percent(
|
|
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
|
-
{/*
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
|
794
|
-
|
|
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-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
<
|
|
874
|
-
{lane.label}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
883
|
-
|
|
991
|
+
))}
|
|
992
|
+
</div>
|
|
884
993
|
</div>
|
|
885
994
|
)}
|
|
886
995
|
</div>
|