@celestia-island/hikari 0.55.337 → 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 +1 -1
- package/src/components/HkKanban.scss +9 -3
- package/src/components/HkKanban.tsx +14 -4
- package/src/components/HkRangeRibbon.scss +31 -11
- package/src/components/HkRangeRibbon.test.tsx +224 -1
- package/src/components/HkRangeRibbon.tsx +151 -42
- package/src/components/HkScrollContainer.dock.test.tsx +6 -1
- package/src/components/HkScrollContainer.dockRide.test.tsx +195 -6
- package/src/components/HkScrollContainer.scss +29 -0
- package/src/components/HkScrollContainer.tsx +23 -3
package/package.json
CHANGED
|
@@ -37,9 +37,15 @@
|
|
|
37
37
|
|
|
38
38
|
Ride only, by the root hook HkKanban emits for a ride host: a pin board
|
|
39
39
|
must keep the full-height lanes box it always had, so this rule never
|
|
40
|
-
reaches an existing board.
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
reaches an existing board. DIRECT-CHILD chain (2026-11-10 follow-up,
|
|
41
|
+
same shape as chest's consumer-side override): the earlier descendant
|
|
42
|
+
form ALSO released the lanes box of a board NESTED inside a card — an
|
|
43
|
+
inner `.hk-kanban` is not the ride host and must keep its own
|
|
44
|
+
full-height lanes (its strip viewport scrolls its own cards; the
|
|
45
|
+
release would cut its lane chrome short). Specificity (0,5,0) beats the
|
|
46
|
+
base rule above; a host that wants its own rule can override it the
|
|
47
|
+
same way. */
|
|
48
|
+
.hk-kanban[data-dock-mode="ride"] > .hk-kanban-strip > .hk-scroll-container-viewport > .hk-kanban-lanes {
|
|
43
49
|
min-height: 0;
|
|
44
50
|
}
|
|
45
51
|
|
|
@@ -122,8 +122,9 @@ export default defineComponent({
|
|
|
122
122
|
* window's leftover (measured on a real board: 275.8px static vs the
|
|
123
123
|
* 139px dock, i.e. zero improvement over the pin board). The
|
|
124
124
|
* attribute is emitted only while a ride dock is genuinely mounted (not
|
|
125
|
-
* for pin, not for a horizontal board, not for an empty
|
|
126
|
-
*
|
|
125
|
+
* for pin, not for a horizontal board, not for an empty board — with no
|
|
126
|
+
* lanes the whole strip including the dock is not rendered — and not
|
|
127
|
+
* for an empty dock slot), so every existing board is untouched. */
|
|
127
128
|
dockMode: { type: String as PropType<"pin" | "ride">, default: "pin" },
|
|
128
129
|
},
|
|
129
130
|
emits: ["move"],
|
|
@@ -219,8 +220,17 @@ export default defineComponent({
|
|
|
219
220
|
// always had, so no existing board changes. Gated on the dock actually
|
|
220
221
|
// being mounted too (S2-2): a requested-but-empty slot is legal, and
|
|
221
222
|
// releasing the lanes box for it would only shrink lane chrome on a
|
|
222
|
-
// short board while no dock is there to justify it.
|
|
223
|
-
|
|
223
|
+
// short board while no dock is there to justify it. Gated on a
|
|
224
|
+
// non-empty board as well (2026-11-10 follow-up): with
|
|
225
|
+
// `lanes.length === 0` the whole strip is replaced by the empty slot,
|
|
226
|
+
// so the container — and any dock in it — is not rendered at all; the
|
|
227
|
+
// hook means "a ride dock is mounted", and on an empty board none
|
|
228
|
+
// ever is.
|
|
229
|
+
const rideBoard =
|
|
230
|
+
docksOnTail &&
|
|
231
|
+
props.dockMode === "ride" &&
|
|
232
|
+
!!slots.dockBottom &&
|
|
233
|
+
props.lanes.length > 0;
|
|
224
234
|
return (
|
|
225
235
|
<div
|
|
226
236
|
class="hk-kanban"
|
|
@@ -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>
|
|
@@ -163,10 +163,15 @@ describe("HkScrollContainer floating-dock tail clearance", () => {
|
|
|
163
163
|
// hidden vertical axis (measured: scrollHeight +spacer, and a
|
|
164
164
|
// programmatic scrollTop shifts content up behind
|
|
165
165
|
// overflow-y:hidden with no wheel path back).
|
|
166
|
+
// Comments stripped (block + line, house two-step form — see
|
|
167
|
+
// HkScrollContainer.dockRide.test.ts): a rule commented out must go
|
|
168
|
+
// red, not hide in the text the regex matches.
|
|
166
169
|
const scss = readFileSync(
|
|
167
170
|
join(dirname(fileURLToPath(import.meta.url)), "HkScrollContainer.scss"),
|
|
168
171
|
"utf8",
|
|
169
|
-
)
|
|
172
|
+
)
|
|
173
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
174
|
+
.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
170
175
|
const spacer = scss.match(
|
|
171
176
|
/\.hk-scroll-container\[data-dock-tail="true"\]:not\(\[data-axis="horizontal"\]\) > \.hk-scroll-container-viewport::after\s*\{[^}]*\}/,
|
|
172
177
|
);
|
|
@@ -164,6 +164,25 @@ function stubGeometry(
|
|
|
164
164
|
Object.defineProperties(el, desc);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/** Stub one element's drawn vertical band (house pattern,
|
|
168
|
+
* HkScrollContainer.selectionHint.test.ts): the selection hint reads
|
|
169
|
+
* `getBoundingClientRect` on the viewport AND on each selected row, and
|
|
170
|
+
* happy-dom reports zeros for both. */
|
|
171
|
+
function stubBand(el: HTMLElement, top: number, bottom: number) {
|
|
172
|
+
const rect = {
|
|
173
|
+
x: 0,
|
|
174
|
+
y: top,
|
|
175
|
+
top,
|
|
176
|
+
bottom,
|
|
177
|
+
left: 0,
|
|
178
|
+
right: 100,
|
|
179
|
+
width: 100,
|
|
180
|
+
height: bottom - top,
|
|
181
|
+
toJSON: () => ({}),
|
|
182
|
+
};
|
|
183
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue(rect as DOMRect);
|
|
184
|
+
}
|
|
185
|
+
|
|
167
186
|
/** The physical edges the shared <HkEdgeGlow> is painting right now. */
|
|
168
187
|
function bandsOf(host: HTMLElement): (string | null)[] {
|
|
169
188
|
return Array.from(host.querySelectorAll<HTMLElement>(".hk-edge-glow-band"), (band) =>
|
|
@@ -231,17 +250,30 @@ const MOUNT_SLOTS = {
|
|
|
231
250
|
dockBottom: () => h("div", { class: "the-dock" }, "dock"),
|
|
232
251
|
};
|
|
233
252
|
|
|
253
|
+
/** The sheets with comments STRIPPED (block + line comments, house
|
|
254
|
+
* two-step form, tokenInventory.test.ts): every source-contract match
|
|
255
|
+
* below judges the LIVE rules. A rule commented out must go red — a bare
|
|
256
|
+
* regex happily matches commented text, which is exactly how the
|
|
257
|
+
* 2026-11-10 mutation round's comment-out survivor stayed green (and how
|
|
258
|
+
* the round-1 review of a73a9f99 caught the same hole re-created by the
|
|
259
|
+
* newer assertions). Both steps are needed: the sheets carry block
|
|
260
|
+
* comments as well as line comments. */
|
|
234
261
|
const scss = readFileSync(
|
|
235
262
|
join(dirname(fileURLToPath(import.meta.url)), "HkScrollContainer.scss"),
|
|
236
263
|
"utf8",
|
|
237
|
-
)
|
|
264
|
+
)
|
|
265
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
266
|
+
.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
238
267
|
|
|
239
268
|
/** The board's own sheet — the ride board's content-height switch lives
|
|
240
|
-
* there (see the S0 block in the ride contract tests).
|
|
269
|
+
* there (see the S0 block in the ride contract tests). Stripped like
|
|
270
|
+
* `scss` above, for the same reason. */
|
|
241
271
|
const kanbanScss = readFileSync(
|
|
242
272
|
join(dirname(fileURLToPath(import.meta.url)), "HkKanban.scss"),
|
|
243
273
|
"utf8",
|
|
244
|
-
)
|
|
274
|
+
)
|
|
275
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
276
|
+
.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
245
277
|
|
|
246
278
|
afterEach(() => {
|
|
247
279
|
restoreRect?.();
|
|
@@ -351,6 +383,46 @@ describe("HkScrollContainer ride-mode bottom dock", () => {
|
|
|
351
383
|
expect(host.getAttribute("data-dock-mode")).toBeNull();
|
|
352
384
|
});
|
|
353
385
|
|
|
386
|
+
it("zeroes the published heights when a pin dock slot disappears at runtime", async () => {
|
|
387
|
+
// 2026-11-10 follow-up: the onUpdated re-point path is
|
|
388
|
+
// placement-agnostic and the ride case above guards it — this pin
|
|
389
|
+
// sibling pins the SAME contract there, where the published vars feed
|
|
390
|
+
// consumer end-padding instead of ride geometry: a dock removed at
|
|
391
|
+
// runtime must republish the inert 0px on BOTH vars, else a consumer
|
|
392
|
+
// keeps padding clear of a dock that no longer exists.
|
|
393
|
+
stubMeasuredHeight(".hk-scroll-dock", 139);
|
|
394
|
+
const container = document.createElement("div");
|
|
395
|
+
document.body.appendChild(container);
|
|
396
|
+
containers.push(container);
|
|
397
|
+
const show = ref(true);
|
|
398
|
+
const app = createApp(defineComponent({
|
|
399
|
+
setup() {
|
|
400
|
+
return () => {
|
|
401
|
+
const slots: Record<string, () => VNodeChild> = {
|
|
402
|
+
default: () => h("div", { class: "content" }, "rows"),
|
|
403
|
+
};
|
|
404
|
+
if (show.value) slots.dockBottom = () => h("div", { class: "the-dock" }, "dock");
|
|
405
|
+
// dockMode unset — the pin placement, the pre-ride default.
|
|
406
|
+
return h(HkScrollContainer, {}, slots);
|
|
407
|
+
};
|
|
408
|
+
},
|
|
409
|
+
}));
|
|
410
|
+
mounts.push(app);
|
|
411
|
+
const host = (app.mount(container) as unknown as { $el: HTMLElement }).$el;
|
|
412
|
+
expect(host.querySelector(".hk-scroll-dock[data-side='bottom']")).toBeTruthy();
|
|
413
|
+
expect(host.style.getPropertyValue("--hk-scroll-dock-bottom")).toBe("139px");
|
|
414
|
+
|
|
415
|
+
show.value = false;
|
|
416
|
+
await nextTick();
|
|
417
|
+
expect(host.querySelector(".hk-scroll-dock")).toBeNull();
|
|
418
|
+
expect(host.style.getPropertyValue("--hk-scroll-dock-bottom")).toBe("0px");
|
|
419
|
+
expect(host.style.getPropertyValue("--hk-scroll-dock-top")).toBe("0px");
|
|
420
|
+
// The pin container keeps its pre-ride attribute set: no ride hook and,
|
|
421
|
+
// with the slot gone, no dock-edge fade either.
|
|
422
|
+
expect(host.getAttribute("data-dock-mode")).toBeNull();
|
|
423
|
+
expect(host.getAttribute("data-dock-fade")).toBeNull();
|
|
424
|
+
});
|
|
425
|
+
|
|
354
426
|
it("drops the bottom glow band under a mounted ride dock (pin keeps it)", async () => {
|
|
355
427
|
// S2-1: the shared <HkEdgeGlow> painter lives on the container root at
|
|
356
428
|
// z-index 2, i.e. ABOVE the viewport — with a ride dock inside that
|
|
@@ -375,6 +447,38 @@ describe("HkScrollContainer ride-mode bottom dock", () => {
|
|
|
375
447
|
expect(bandsOf(pin.host)).toEqual(["top", "bottom"]);
|
|
376
448
|
});
|
|
377
449
|
|
|
450
|
+
it("drops the bottom band from the selection hint under a mounted ride dock (pin keeps it)", async () => {
|
|
451
|
+
// 2026-11-10 follow-up: the selection hint's painter rides the SAME
|
|
452
|
+
// `painterEdges` filter as the overflow glow (same function, same
|
|
453
|
+
// bottom drop — the band would paint over the ride dock's lower limb
|
|
454
|
+
// exactly like the glow's). Until now only the glow call site had a
|
|
455
|
+
// test; the review's mutation g (bypassing the filter at the hint call
|
|
456
|
+
// site) survived the whole suite green. The verdict attribute — the
|
|
457
|
+
// documented consumer contract — is untouched: only the BAND is
|
|
458
|
+
// dropped.
|
|
459
|
+
const slots = {
|
|
460
|
+
default: () => h("div", { class: "is-active" }, "row"),
|
|
461
|
+
dockBottom: () => h("div", { class: "the-dock" }, "dock"),
|
|
462
|
+
};
|
|
463
|
+
const ride = mountWithApi(slots, { dockMode: "ride", selectionHint: true });
|
|
464
|
+
const rideViewport = ride.host.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
|
|
465
|
+
stubBand(rideViewport, 0, 400);
|
|
466
|
+
stubBand(rideViewport.querySelector<HTMLElement>(".is-active")!, 500, 560);
|
|
467
|
+
ride.api.value!.refresh();
|
|
468
|
+
await nextTick();
|
|
469
|
+
expect(ride.host.getAttribute("data-selection-beyond")).toBe("bottom");
|
|
470
|
+
expect(bandsOf(ride.host)).toEqual([]);
|
|
471
|
+
|
|
472
|
+
const pin = mountWithApi(slots, { selectionHint: true });
|
|
473
|
+
const pinViewport = pin.host.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
|
|
474
|
+
stubBand(pinViewport, 0, 400);
|
|
475
|
+
stubBand(pinViewport.querySelector<HTMLElement>(".is-active")!, 500, 560);
|
|
476
|
+
pin.api.value!.refresh();
|
|
477
|
+
await nextTick();
|
|
478
|
+
expect(pin.host.getAttribute("data-selection-beyond")).toBe("bottom");
|
|
479
|
+
expect(bandsOf(pin.host)).toEqual(["bottom"]);
|
|
480
|
+
});
|
|
481
|
+
|
|
378
482
|
it("warns once in dev when autoFollow is combined with a ride dock", async () => {
|
|
379
483
|
// S2-3: the combination reproduces the full-height-content trap that
|
|
380
484
|
// S0 fixed (autoFollow bottom-anchors a min-height:100% box), so it is
|
|
@@ -405,6 +509,25 @@ describe("HkScrollContainer ride-mode bottom dock", () => {
|
|
|
405
509
|
expect((pin.$el as HTMLElement).getAttribute("data-dock-fade")).toBe("bottom");
|
|
406
510
|
});
|
|
407
511
|
|
|
512
|
+
it("publishes the dock-edge fade for a top dock even in ride mode", () => {
|
|
513
|
+
// 2026-11-10 follow-up: the ride suppression concerns the BOTTOM dock
|
|
514
|
+
// only — that is the one living inside the masked viewport band. A
|
|
515
|
+
// `dockTop` dock is not on that edge, so the container publishes the
|
|
516
|
+
// dock-edge fade for it as usual, flipping the attribute to "top"
|
|
517
|
+
// (and, with no bottom slot, emitting no ride hook at all).
|
|
518
|
+
const vm = mountDock(
|
|
519
|
+
{
|
|
520
|
+
default: () => h("div", { class: "content" }, "rows"),
|
|
521
|
+
dockTop: () => h("div", { class: "top-chrome" }, "top"),
|
|
522
|
+
},
|
|
523
|
+
{ dockMode: "ride" },
|
|
524
|
+
);
|
|
525
|
+
const host = vm.$el as HTMLElement;
|
|
526
|
+
expect(host.getAttribute("data-dock-fade")).toBe("top");
|
|
527
|
+
expect(host.getAttribute("data-dock-mode")).toBeNull();
|
|
528
|
+
expect(host.querySelector(".hk-scroll-dock-ride")).toBeNull();
|
|
529
|
+
});
|
|
530
|
+
|
|
408
531
|
it("rides when the axis is both", () => {
|
|
409
532
|
const vm = mountDock(MOUNT_SLOTS, { dockMode: "ride", axis: "both" });
|
|
410
533
|
const host = vm.$el as HTMLElement;
|
|
@@ -443,7 +566,7 @@ describe("HkScrollContainer ride-mode bottom dock", () => {
|
|
|
443
566
|
expect(host.getAttribute("data-dock-mode")).toBeNull();
|
|
444
567
|
});
|
|
445
568
|
|
|
446
|
-
it("leaves the pin placement
|
|
569
|
+
it("leaves the pin placement structurally compatible", () => {
|
|
447
570
|
// dockMode is additive: the default must keep today's DOM exactly —
|
|
448
571
|
// sibling dock, no spacer, no ride class, dock fade published, both
|
|
449
572
|
// measured-height vars on the host.
|
|
@@ -501,6 +624,10 @@ describe("HkScrollContainer ride placement style contract", () => {
|
|
|
501
624
|
// lands it UNDER the dock. `scroll-padding-bottom` belongs on the
|
|
502
625
|
// scrollport (the viewport) and is exactly this band; keyed on the ride
|
|
503
626
|
// hook, which is emitted only while the dock is mounted.
|
|
627
|
+
//
|
|
628
|
+
// `scss` is the comment-stripped sheet (see the module const): the
|
|
629
|
+
// 2026-11-10 mutation check found that commenting the rule out left
|
|
630
|
+
// this test green — the regex happily matched the commented text.
|
|
504
631
|
const rule = scss.match(
|
|
505
632
|
/\.hk-scroll-container\[data-dock-mode="ride"\] > \.hk-scroll-container-viewport \{[^}]*\}/,
|
|
506
633
|
);
|
|
@@ -510,6 +637,40 @@ describe("HkScrollContainer ride placement style contract", () => {
|
|
|
510
637
|
);
|
|
511
638
|
});
|
|
512
639
|
|
|
640
|
+
it("suppresses the dockTailClearance spacer while a ride dock is mounted", () => {
|
|
641
|
+
// 2026-11-10 follow-up S3 (flagged by two review rounds): with
|
|
642
|
+
// `dockTailClearance` on AND a ride dock mounted, the tail spacer's
|
|
643
|
+
// `::after` lined up BEHIND the ride dock in the content flow — two
|
|
644
|
+
// stacked tail reservations for one dock. Ride wins: the sheet drops
|
|
645
|
+
// the spacer box under the same two root hooks.
|
|
646
|
+
//
|
|
647
|
+
// happy-dom computes no CSS, so this is pinned the suite's usual way —
|
|
648
|
+
// the runtime precondition (both hooks on the same root) plus the rule
|
|
649
|
+
// itself as a source contract.
|
|
650
|
+
const vm = mountDock(MOUNT_SLOTS, { dockMode: "ride", dockTailClearance: true });
|
|
651
|
+
const host = vm.$el as HTMLElement;
|
|
652
|
+
expect(host.getAttribute("data-dock-mode")).toBe("ride");
|
|
653
|
+
expect(host.getAttribute("data-dock-tail")).toBe("true");
|
|
654
|
+
|
|
655
|
+
const generation = scss.match(
|
|
656
|
+
/\.hk-scroll-container\[data-dock-tail="true"\]:not\(\[data-axis="horizontal"\]\) > \.hk-scroll-container-viewport::after \{[^}]*\}/,
|
|
657
|
+
);
|
|
658
|
+
expect(generation, "the tail spacer generation rule exists").not.toBeNull();
|
|
659
|
+
const guard = scss.match(
|
|
660
|
+
/\.hk-scroll-container\[data-dock-mode="ride"\]\[data-dock-tail\] > \.hk-scroll-container-viewport::after \{[^}]*\}/,
|
|
661
|
+
);
|
|
662
|
+
expect(guard, "ride suppresses the tail spacer").not.toBeNull();
|
|
663
|
+
// `content: none` removes the box outright — a zero-height box could
|
|
664
|
+
// still perturb the layout.
|
|
665
|
+
expect(guard![0]).toMatch(/content:\s*none/);
|
|
666
|
+
// Equal specificity (0,4,0) with the generation rule, so source order
|
|
667
|
+
// is what makes the suppression win.
|
|
668
|
+
expect(
|
|
669
|
+
scss.indexOf(guard![0]),
|
|
670
|
+
"the suppression wins on source order, not on specificity",
|
|
671
|
+
).toBeGreaterThan(scss.indexOf(generation![0]));
|
|
672
|
+
});
|
|
673
|
+
|
|
513
674
|
it("zeroes the container's own bottom fade band in ride mode", () => {
|
|
514
675
|
// S2-c: the ride dock lives INSIDE the viewport at its trailing edge,
|
|
515
676
|
// so the container's own fade mask dissolves the dock's lower limb
|
|
@@ -717,6 +878,24 @@ describe("HkKanban ride-dock forwarding", () => {
|
|
|
717
878
|
expect(vm.$el.querySelector(".hk-scroll-dock-ride")).toBeNull();
|
|
718
879
|
});
|
|
719
880
|
|
|
881
|
+
it("emits no ride hook for an empty board (the strip is not mounted at all)", () => {
|
|
882
|
+
// 2026-11-10 follow-up: with `lanes.length === 0` the whole strip —
|
|
883
|
+
// container, dock slot and all — is replaced by the empty slot, so no
|
|
884
|
+
// ride dock is ever mounted and the root must keep its pre-ride
|
|
885
|
+
// attribute set. The hook means "a ride dock is mounted", and here
|
|
886
|
+
// none is: the previously ungated emission contradicted the prop doc.
|
|
887
|
+
const vm = mountKanban(
|
|
888
|
+
{ axis: "vertical", dockMode: "ride", lanes: [] },
|
|
889
|
+
{
|
|
890
|
+
dockBottom: () => h("div", { class: "board-dock" }, "dock"),
|
|
891
|
+
empty: () => h("div", { class: "board-empty" }, "empty"),
|
|
892
|
+
},
|
|
893
|
+
);
|
|
894
|
+
expect(vm.$el.getAttribute("data-dock-mode")).toBeNull();
|
|
895
|
+
expect(vm.$el.querySelector(".hk-kanban-strip")).toBeNull();
|
|
896
|
+
expect(vm.$el.querySelector(".board-empty")).toBeTruthy();
|
|
897
|
+
});
|
|
898
|
+
|
|
720
899
|
it("ignores the slot on a horizontal board (the cross-axis tail)", () => {
|
|
721
900
|
// The default axis: pre-2026-11-10 shape — no forwarded dock slot and
|
|
722
901
|
// no ride chrome, because a horizontal strip's tail is its cross axis.
|
|
@@ -734,7 +913,7 @@ describe("HkKanban ride-dock forwarding", () => {
|
|
|
734
913
|
});
|
|
735
914
|
|
|
736
915
|
describe("HkKanban ride board content-height contract", () => {
|
|
737
|
-
it("releases the full-height lanes box under the ride hook only", () => {
|
|
916
|
+
it("releases the full-height lanes box under the ride hook only, via the direct-child chain", () => {
|
|
738
917
|
// S0, measured on a real board: in a column-flex strip viewport
|
|
739
918
|
// `min-height: 100%` makes the lanes BOX as tall as the viewport, so
|
|
740
919
|
// the in-flow spacer + sticky dock always sit below the fold — the dock
|
|
@@ -742,11 +921,21 @@ describe("HkKanban ride board content-height contract", () => {
|
|
|
742
921
|
// window's leftover (275.8px static / 563.8px scrolled against a 139px
|
|
743
922
|
// dock). Releasing it is the half of the contract that makes the air
|
|
744
923
|
// follow the CONTENT end.
|
|
924
|
+
//
|
|
925
|
+
// 2026-11-10 follow-up: the release selector is the DIRECT-CHILD chain
|
|
926
|
+
// (same shape as chest's consumer-side override). The earlier
|
|
927
|
+
// descendant form also released the lanes box of a board NESTED inside
|
|
928
|
+
// a card of the outer ride board — the inner board is not the ride
|
|
929
|
+
// host, and its strip viewport scrolls its own cards, so cutting its
|
|
930
|
+
// lanes short broke its lane chrome.
|
|
745
931
|
const release = kanbanScss.match(
|
|
746
|
-
/\.hk-kanban\[data-dock-mode="ride"\] \.hk-kanban-lanes \{[^}]*\}/,
|
|
932
|
+
/\.hk-kanban\[data-dock-mode="ride"\] > \.hk-kanban-strip > \.hk-scroll-container-viewport > \.hk-kanban-lanes \{[^}]*\}/,
|
|
747
933
|
);
|
|
748
934
|
expect(release, "the ride boards release their lanes min-height").not.toBeNull();
|
|
749
935
|
expect(release![0]).toMatch(/min-height:\s*0/);
|
|
936
|
+
// And the descendant spelling stays gone: reintroducing it (the
|
|
937
|
+
// mutation) would leak the release into nested boards again.
|
|
938
|
+
expect(kanbanScss).not.toMatch(/\.hk-kanban\[data-dock-mode="ride"\] \.hk-kanban-lanes/);
|
|
750
939
|
// The base rule keeps the fill for every other mode (pin, horizontal
|
|
751
940
|
// boards): a short board still reaches the strip's floor there.
|
|
752
941
|
const base = kanbanScss.match(/\.hk-kanban-lanes \{[^}]*\}/);
|
|
@@ -342,6 +342,14 @@
|
|
|
342
342
|
geometry is unchanged by this line (measured). */
|
|
343
343
|
inset-inline-start: 0;
|
|
344
344
|
flex: none;
|
|
345
|
+
/* WEAK STACKING GUARANTEE (2026-11-10 follow-up): 1 stays a literal, not
|
|
346
|
+
* a `--hk-scroll-dock-ride-z` variable. It only lifts the dock above the
|
|
347
|
+
* PLAIN in-flow content scrolling under it; a descendant of the viewport
|
|
348
|
+
* that is itself positioned with z-index >= 2 (a floating toolbar, a drag
|
|
349
|
+
* layer) paints OVER the pinned dock. No shipped consumer stacks one
|
|
350
|
+
* inside the dock band, and a variable would add an override surface
|
|
351
|
+
* before a second consumer needs it (additive-chrome discipline) —
|
|
352
|
+
* promote it to a variable if one appears. */
|
|
345
353
|
z-index: 1;
|
|
346
354
|
/* Transparent band: the dock surface inside opts back in (see the
|
|
347
355
|
* pointer contract above). */
|
|
@@ -429,6 +437,27 @@
|
|
|
429
437
|
height: var(--hk-scroll-tail-bottom, 0px);
|
|
430
438
|
}
|
|
431
439
|
|
|
440
|
+
/* ── Ride wins over the tail spacer (mutual exclusion) ─────────────
|
|
441
|
+
2026-11-10 follow-up S3, flagged by two review rounds: with
|
|
442
|
+
`dockTailClearance` on AND a ride dock mounted, the spacer above lined
|
|
443
|
+
up BEHIND the ride dock (in flow after it) and the tail got TWO
|
|
444
|
+
stacked reservations — the shell-published `--hk-scroll-tail-bottom`
|
|
445
|
+
band plus the dock's own measured box. The two props answer the same
|
|
446
|
+
question for two different dock geometries: the clearance spacer is
|
|
447
|
+
for a dock FLOATING OVER the scroll area from outside the flow, ride
|
|
448
|
+
mounts the dock INSIDE it and spends its measured box as the air. So
|
|
449
|
+
while a ride dock is actually mounted (the root carries BOTH
|
|
450
|
+
`data-dock-mode="ride"` and `data-dock-tail`) the spacer box is not
|
|
451
|
+
generated at all. Same specificity (0,4,0) as the generation rule
|
|
452
|
+
above and declared after it, so source order decides; `content: none`
|
|
453
|
+
removes the box outright rather than shrinking it (a zero-height box
|
|
454
|
+
could still perturb the layout). The shrink pin above stays: it is
|
|
455
|
+
harmless under ride and still load-bearing while the prop serves a
|
|
456
|
+
floating dock (no ride hook present). */
|
|
457
|
+
.hk-scroll-container[data-dock-mode="ride"][data-dock-tail] > .hk-scroll-container-viewport::after {
|
|
458
|
+
content: none;
|
|
459
|
+
}
|
|
460
|
+
|
|
432
461
|
|
|
433
462
|
/* ── Selection edge hint (selectionHint opt-in) ────────────────────
|
|
434
463
|
A list taller than its viewport hides its SELECTED row as soon as it
|
|
@@ -281,7 +281,19 @@ export default defineComponent({
|
|
|
281
281
|
* accepted cost: a child that legitimately needs main-axis shrink
|
|
282
282
|
* inside an opted-in container (a nested pane meant to fit the
|
|
283
283
|
* viewport and scroll internally) cannot shrink; consumers escape
|
|
284
|
-
* with a >=(0,2,0) override loaded after the library CSS.
|
|
284
|
+
* with a >=(0,2,0) override loaded after the library CSS.
|
|
285
|
+
*
|
|
286
|
+
* MUTUALLY EXCLUSIVE WITH `dockMode="ride"` (2026-11-10 follow-up):
|
|
287
|
+
* the two props answer the same tail question for two different dock
|
|
288
|
+
* geometries — this spacer reserves a band for a dock FLOATING OVER
|
|
289
|
+
* the scroll area from outside the flow, ride mounts the dock inside
|
|
290
|
+
* it and spends its measured box as the air. With both armed and a
|
|
291
|
+
* ride dock actually mounted, the tail spacer is NOT generated (the
|
|
292
|
+
* sheet drops the viewport's `::after` under
|
|
293
|
+
* `[data-dock-mode="ride"][data-dock-tail]`) — ride wins, the two
|
|
294
|
+
* reservations would stack into a double-clear band. The shrink pin
|
|
295
|
+
* above stays: harmless under ride, still load-bearing while this
|
|
296
|
+
* prop serves a floating dock. */
|
|
285
297
|
dockTailClearance: { type: Boolean, default: false },
|
|
286
298
|
/** Where a mounted `dockBottom` dock LIVES (2026-11-10 user ruling):
|
|
287
299
|
*
|
|
@@ -326,7 +338,8 @@ export default defineComponent({
|
|
|
326
338
|
* its only effect (measured: identical to the pin board's 184px,
|
|
327
339
|
* and worse at the scroll end). HkKanban releases its own
|
|
328
340
|
* `.hk-kanban-lanes` box for a ride host
|
|
329
|
-
* (`.hk-kanban[data-dock-mode="ride"] .hk-kanban-
|
|
341
|
+
* (`.hk-kanban[data-dock-mode="ride"] > .hk-kanban-strip >
|
|
342
|
+
* .hk-scroll-container-viewport > .hk-kanban-lanes
|
|
330
343
|
* { min-height: 0 }`); a custom host has to do the same.
|
|
331
344
|
* - `autoFollow` IS NOT SUPPORTED TOGETHER WITH RIDE (documented,
|
|
332
345
|
* not policed beyond a dev warning): autoFollow bottom-anchors a
|
|
@@ -381,6 +394,13 @@ export default defineComponent({
|
|
|
381
394
|
* SLOT CONTENT THAT MUST CATCH EVENTS HAS TO DECLARE
|
|
382
395
|
* `pointer-events: auto` ON THE PART THAT CATCHES THEM — the
|
|
383
396
|
* wrapper does not hand interactivity to a generic slot child.
|
|
397
|
+
* - MUTUALLY EXCLUSIVE WITH `dockTailClearance` (2026-11-10
|
|
398
|
+
* follow-up): that prop reserves a tail band for a dock FLOATING
|
|
399
|
+
* OVER the scroll area from outside the flow; ride mounts the
|
|
400
|
+
* dock inside it. With both set and a ride dock mounted, the
|
|
401
|
+
* tail spacer is not generated (the sheet drops the viewport's
|
|
402
|
+
* `::after` under the same two root hooks) and ride wins — the
|
|
403
|
+
* stacked reservations would double-clear the same tail.
|
|
384
404
|
*
|
|
385
405
|
* Ignored (falls back to `pin`) on `axis="horizontal"` containers:
|
|
386
406
|
* there the content tail is the cross axis and an in-flow block
|
|
@@ -1372,7 +1392,7 @@ export default defineComponent({
|
|
|
1372
1392
|
// owns the viewport's bottom edge — so the sheet must keep fading that
|
|
1373
1393
|
// edge and HkKanban must keep its full-height lanes box (S2-2).
|
|
1374
1394
|
const rideDockMounted = !!dockBottomSlot && rideBottom;
|
|
1375
|
-
if (import.meta.env
|
|
1395
|
+
if (import.meta.env?.DEV && !warnedAutoFollowRide && rideDockMounted && props.autoFollow) {
|
|
1376
1396
|
warnedAutoFollowRide = true;
|
|
1377
1397
|
// eslint-disable-next-line no-console -- dev-only unsupported-combination alarm
|
|
1378
1398
|
console.warn(
|