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