@celestia-island/hikari 0.55.151 → 0.55.153

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.151",
3
+ "version": "0.55.153",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,59 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick } from "vue";
3
+
4
+ import HkBoard, { type BoardNodeInput } from "./HkBoard";
5
+
6
+ /** A board whose content is far smaller than the viewport: the default fit
7
+ * parks at the library cap; a fitCap lets the host fill the stage. */
8
+ const nodes: BoardNodeInput[] = [
9
+ { id: "a", x: 0, y: 0, w: 120, h: 80, label: "A" },
10
+ ];
11
+ const VIEW_W = 800;
12
+ const VIEW_H = 600;
13
+
14
+ function mountBoard(props: Record<string, unknown>): { world: () => string; app: ReturnType<typeof createApp> } {
15
+ // happy-dom lays nothing out: give the board a real viewport box so the
16
+ // fit maths are meaningful (the chest board tests use the same stub).
17
+ Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, get: () => VIEW_W });
18
+ Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, get: () => VIEW_H });
19
+ const host = document.createElement("div");
20
+ document.body.appendChild(host);
21
+ const app = createApp(defineComponent({
22
+ setup: () => () => h(HkBoard, { nodes, ...props }),
23
+ }));
24
+ app.mount(host);
25
+ return {
26
+ world: () => document.body.querySelector<HTMLElement>(".hk-board-world")!.style.transform,
27
+ app,
28
+ };
29
+ }
30
+
31
+ /** The world transform's scale factor, parsed out of `translate(..) scale(k)`. */
32
+ function scaleOf(transform: string): number {
33
+ return Number(/scale\(([\d.]+)\)/.exec(transform)?.[1] ?? 0);
34
+ }
35
+
36
+ describe("HkBoard fitCap", () => {
37
+ afterEach(() => { document.body.innerHTML = ""; });
38
+
39
+ it("caps the default fit at the library ceiling when no fitCap is given", async () => {
40
+ const { world, app } = mountBoard({});
41
+ await nextTick();
42
+ await new Promise((r) => setTimeout(r, 30));
43
+ expect(scaleOf(world())).toBeLessThanOrEqual(1.25 + 1e-6);
44
+ app.unmount();
45
+ });
46
+
47
+ it("lets a host raise the fit ceiling to fill the stage", async () => {
48
+ const { world, app } = mountBoard({ fitCap: 3 });
49
+ await nextTick();
50
+ await new Promise((r) => setTimeout(r, 30));
51
+ const k = scaleOf(world());
52
+ // The fit honours the raised cap: with a 120px node in an 800px-ish
53
+ // stage the scale would be ~5 at fill; the cap is the ceiling, so the
54
+ // observed scale must exceed the library default and not exceed 3.
55
+ expect(k).toBeGreaterThan(1.25);
56
+ expect(k).toBeLessThanOrEqual(3 + 1e-6);
57
+ app.unmount();
58
+ });
59
+ });
@@ -117,6 +117,11 @@ export default defineComponent({
117
117
  minimap: { type: Boolean, default: true },
118
118
  minK: { type: Number, default: 0.2 },
119
119
  maxK: { type: Number, default: 4 },
120
+ /** Ceiling for the fit zoom (reset button, fitOnMount). The library
121
+ * default caps a fit at 1.25 so a small board never fills a huge
122
+ * viewport; a host that wants fill-the-stage framing passes a higher
123
+ * cap (the camera clamp still honours maxK). */
124
+ fitCap: { type: Number, default: undefined },
120
125
  animated: { type: Boolean, default: true },
121
126
  fitOnMount: { type: Boolean, default: true },
122
127
  },
@@ -141,6 +146,7 @@ export default defineComponent({
141
146
  minK: props.minK,
142
147
  maxK: props.maxK,
143
148
  animated: props.animated,
149
+ fitCap: props.fitCap,
144
150
  });
145
151
  const camera = cam.camera as Ref<BoardCamera>;
146
152
 
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, reactive, ref } from "vue";
3
3
 
4
4
  import HkScrollContainer from "./HkScrollContainer";
5
+ import type { OverlayScrollbarMarker } from "../composables/useOverlayScrollbar";
5
6
 
6
7
  interface Mounted {
7
8
  app: ReturnType<typeof createApp>;
@@ -401,3 +402,44 @@ describe("HkScrollContainer back-to-top signal", () => {
401
402
  app.unmount();
402
403
  });
403
404
  });
405
+
406
+ describe("HkScrollContainer change-mark rail", () => {
407
+ it("renders the marks prop, and re-applies new marks without rebuilding the chrome", async () => {
408
+ const markers = ref<OverlayScrollbarMarker[]>([{ start: 0.1, end: 0.2, kind: "added" }]);
409
+ const container = document.createElement("div");
410
+ document.body.appendChild(container);
411
+ containers.push(container);
412
+ const Wrapper = defineComponent({
413
+ setup() {
414
+ return () =>
415
+ h(HkScrollContainer, { markers: markers.value }, { default: () => h("span", "content") });
416
+ },
417
+ });
418
+ const app = createApp(Wrapper);
419
+ mounts.push(app);
420
+ app.mount(container);
421
+
422
+ const rail = () => container.querySelector<HTMLElement>(".hk-scrollbar-markers");
423
+ const firstRail = rail();
424
+ expect(firstRail, "the rail exists once marks arrive").not.toBeNull();
425
+ expect(firstRail!.querySelectorAll(".hk-scrollbar-marker").length).toBe(1);
426
+
427
+ markers.value = [
428
+ { start: 0.5, end: 0.6, kind: "deleted" },
429
+ { start: 0.7, end: 0.8, kind: "modified" },
430
+ ];
431
+ await nextTick();
432
+ const marks = rail()!.querySelectorAll<HTMLElement>(".hk-scrollbar-marker");
433
+ expect(marks.length).toBe(2);
434
+ expect(marks[0].getAttribute("data-kind")).toBe("deleted");
435
+ // Same element: a marks update must not detach/reattach the overlay
436
+ // (that would drop the scroll flash and re-run the position guard).
437
+ expect(rail()).toBe(firstRail);
438
+ app.unmount();
439
+ });
440
+
441
+ it("keeps the rail out of the DOM when no marks are passed", () => {
442
+ const m = mountScroller({});
443
+ expect(m.container.querySelector(".hk-scrollbar-markers")).toBeNull();
444
+ });
445
+ });
@@ -12,7 +12,7 @@ import {
12
12
  import { useI18n } from "../i18n/context";
13
13
  import { laidOffsetWithin } from "../composables/layoutGeometry";
14
14
  import "./HkScrollContainer.scss";
15
- import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
15
+ import { attachOverlayScrollbars, type OverlayScrollbarHandle, type OverlayScrollbarMarker } from "../composables/useOverlayScrollbar";
16
16
  import { useApproachEnd, type ApproachEndHandle } from "../composables/useApproachEnd";
17
17
  import { provideScrollWindow } from "../composables/useScrollWindow";
18
18
  import { SCROLL_HOST_CLASS } from "./HkScrollPin";
@@ -46,6 +46,12 @@ export default defineComponent({
46
46
  axis: { type: String as PropType<ScrollAxis>, default: "vertical" },
47
47
  mode: { type: String as PropType<ScrollMode>, default: "traditional" },
48
48
  scrollbar: { type: Boolean, default: true },
49
+ /** Change marks for the overlay's right-hand rail (VS Code's overview
50
+ * ruler): fractions of the FULL content extent, so a mark stays put
51
+ * while the user scrolls. Vertical axis only, and additive — leaving
52
+ * it out renders exactly the chrome this container had before the
53
+ * rail existed. */
54
+ markers: { type: Array as PropType<OverlayScrollbarMarker[]>, default: () => [] },
49
55
  autoFollow: { type: Boolean, default: false },
50
56
  overscanScreens: { type: Number, default: 1 },
51
57
  /** Inline-axis alignment of the content when it fits the viewport.
@@ -265,9 +271,16 @@ export default defineComponent({
265
271
  const vp = viewportRef.value;
266
272
  if (!vp) return;
267
273
  overlay?.detach();
268
- overlay = attachOverlayScrollbars(vp, { axis: props.axis });
274
+ overlay = attachOverlayScrollbars(vp, { axis: props.axis, markers: props.markers });
269
275
  }
270
276
 
277
+ /** Re-apply marks without rebuilding the chrome: a diff re-renders its
278
+ * marks on every tick, and detaching/reattaching would drop the
279
+ * scroll flash and re-run the positioning guard each time. */
280
+ watch(() => props.markers, (next) => {
281
+ overlay?.setMarkers(next ?? []);
282
+ }, { deep: true, flush: "post" });
283
+
271
284
  /** Tear down and rebuild the overlay tracks when the `scrollbar`
272
285
  * opt-in flips at runtime (HkTabs passes the prop through). */
273
286
  watch(() => props.scrollbar, (on) => {
@@ -70,4 +70,45 @@ describe("overlay scrollbar chrome contract", () => {
70
70
  expect(chrome).toMatch(/\.hk-scrollbar-track\s*\{[^}]*pointer-events:\s*none/);
71
71
  expect(chrome).toMatch(/&?\.is-scrolling,\s*&?\.is-hovering\s*\{[^}]*pointer-events:\s*auto/);
72
72
  });
73
+
74
+ it("keeps the change-mark rail clear of the track and out of the click path", () => {
75
+ // The rail is a SECOND overlay in the same corner. Two facts have to
76
+ // survive any future restyle:
77
+ //
78
+ // 1. It must not overlap the track (right 2px + width 6px), or marks
79
+ // would draw across the thumb;
80
+ // 2. it must not take pointer input — it spans the WHOLE host, docks
81
+ // included, so a live hot zone there eats presses meant for
82
+ // right-aligned dock controls. Only the marks accept input, and
83
+ // that exemption is exactly why the rail itself must stay `none`.
84
+ // The vertical geometry lives in the track's own nested
85
+ // `&:not([data-axis="horizontal"])` block, not a top-level selector.
86
+ const track = /&:not\(\[data-axis="horizontal"\]\)\s*\{[^}]*\}/.exec(chrome)?.[0] ?? "";
87
+ expect(track, "positive control: the vertical track rule was found").not.toBe("");
88
+ const trackRight = Number(/right:\s*(\d+)px/.exec(track)?.[1]);
89
+ const trackWidth = Number(/width:\s*(\d+)px/.exec(track)?.[1]);
90
+
91
+ const rail = /\.hk-scrollbar-markers\s*\{[^}]*\}/.exec(chrome)?.[0] ?? "";
92
+ expect(rail, "positive control: the rail rule was found").not.toBe("");
93
+ const railRight = Number(/right:\s*(\d+)px/.exec(rail)?.[1]);
94
+ const railWidth = Number(/width:\s*(\d+)px/.exec(rail)?.[1]);
95
+
96
+ expect(
97
+ railRight,
98
+ "the rail starts outboard of the track's inner edge",
99
+ ).toBeGreaterThanOrEqual(trackRight + trackWidth);
100
+ expect(railRight + railWidth, "and stays inside the host").toBeLessThanOrEqual(24);
101
+
102
+ expect(rail, "the rail itself takes no pointer input").toMatch(/pointer-events:\s*none/);
103
+ const marker = /\.hk-scrollbar-marker\s*\{[^}]*\}/.exec(chrome)?.[0] ?? "";
104
+ expect(marker, "positive control: the mark rule was found").not.toBe("");
105
+ expect(marker, "the marks are the hit area").toMatch(/pointer-events:\s*auto/);
106
+ // The hover ring must be drawn INSIDE the mark: the rail clips its
107
+ // children, and a plain outline loses both side strokes to that clip,
108
+ // leaving a two-line "ring" that reads as a stray rule rather than a
109
+ // hover cue. Nothing else would notice that regression.
110
+ expect(marker, "the hover ring is drawn inside the mark").toMatch(
111
+ /outline-offset:\s*-1px/,
112
+ );
113
+ });
73
114
  });
@@ -47,6 +47,11 @@ export interface UseBoardCameraOptions {
47
47
  maxK?: number;
48
48
  /** Master animation switch; reduced-motion forces snapping regardless. */
49
49
  animated?: boolean;
50
+ /** Optional ceiling for fit()'s scale: boards whose content is smaller
51
+ * than the viewport may want to fill it instead of parking at the
52
+ * library's default cap (BOARD_FIT_CAP). Unset keeps the default; the
53
+ * camera clamp still honours maxK. */
54
+ fitCap?: number;
50
55
  }
51
56
 
52
57
  export interface UseBoardCameraReturn {
@@ -81,6 +86,10 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
81
86
  minK = BOARD_K_MIN,
82
87
  maxK = BOARD_K_MAX,
83
88
  animated = true,
89
+ /** Ceiling for fit()'s scale: boards whose content is smaller than the
90
+ * viewport may want to fill it instead of parking at the library's
91
+ * default cap (BOARD_FIT_CAP). Unset keeps the default. */
92
+ fitCap = undefined,
84
93
  } = options;
85
94
 
86
95
  const camera = ref<BoardCamera>({ x: 0, y: 0, k: 1 });
@@ -195,7 +204,7 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
195
204
  }
196
205
 
197
206
  function fit(): void {
198
- const target = boardFit(contentRect(), viewport());
207
+ const target = boardFit(contentRect(), viewport(), fitCap !== undefined ? { cap: fitCap } : {});
199
208
  kFloor = Math.min(minK, target.k);
200
209
  animateTo(target);
201
210
  }
@@ -1,13 +1,16 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "./useOverlayScrollbar";
3
+ import { attachOverlayScrollbars, type OverlayScrollbarHandle, type OverlayScrollbarOptions } from "./useOverlayScrollbar";
4
4
 
5
5
  const handles: OverlayScrollbarHandle[] = [];
6
6
  const containers: HTMLElement[] = [];
7
7
 
8
8
  /** Mount a viewport (the scrolling element) inside a wrapper inside a
9
9
  * fresh container: wrapper ← viewport. The wrapper is the track host. */
10
- function mountViewport(axis: "vertical" | "horizontal" | "both" = "vertical") {
10
+ function mountViewport(
11
+ axis: "vertical" | "horizontal" | "both" = "vertical",
12
+ extra: Omit<OverlayScrollbarOptions, "axis"> = {},
13
+ ) {
11
14
  const container = document.createElement("div");
12
15
  document.body.appendChild(container);
13
16
  containers.push(container);
@@ -15,11 +18,37 @@ function mountViewport(axis: "vertical" | "horizontal" | "both" = "vertical") {
15
18
  const viewport = document.createElement("div");
16
19
  wrapper.appendChild(viewport);
17
20
  container.appendChild(wrapper);
18
- const handle = attachOverlayScrollbars(viewport, { axis });
21
+ const handle = attachOverlayScrollbars(viewport, { axis, ...extra });
19
22
  handles.push(handle);
20
23
  return { container, wrapper, viewport, handle };
21
24
  }
22
25
 
26
+ /** A scrollable vertical viewport with a deterministic 100px rail box:
27
+ * happy-dom performs no layout, so both the geometry and the rail's
28
+ * bounding box are stubbed. `height` of the rail is what maps a mark's
29
+ * fraction to a pixel position. */
30
+ function mountRailed(railHeight = 100) {
31
+ const mounted = mountViewport("vertical");
32
+ stubGeometry(mounted.viewport, {
33
+ scrollHeight: 1000, clientHeight: 100, scrollTop: 0,
34
+ scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
35
+ });
36
+ mounted.handle.update();
37
+ return {
38
+ ...mounted,
39
+ rail: () => {
40
+ const el = mounted.wrapper.querySelector<HTMLElement>(".hk-scrollbar-markers");
41
+ if (el) {
42
+ vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
43
+ top: 0, left: 0, width: 8, height: railHeight,
44
+ bottom: railHeight, right: 8, x: 0, y: 0, toJSON: () => ({}),
45
+ } as DOMRect);
46
+ }
47
+ return el;
48
+ },
49
+ };
50
+ }
51
+
23
52
  /** happy-dom performs no layout, so scroll geometry is stubbed on the
24
53
  * viewport instance (shadowing the prototype getters) — the same
25
54
  * trick HkScrollContainer.test.ts uses, plus writable scroll offsets
@@ -44,6 +73,16 @@ function stubGeometry(
44
73
  Object.defineProperties(el, desc);
45
74
  }
46
75
 
76
+ /** Give the rail a measured height before applying the marks under test.
77
+ * The pixel floor is derived from `clientHeight` AT RENDER TIME, so the
78
+ * stub has to exist before the marks are rendered. */
79
+ function railOfHeight(m: ReturnType<typeof mountRailed>, height: number): HTMLElement {
80
+ m.handle.setMarkers([{ start: 0, end: 0 }]);
81
+ const rail = m.rail()!;
82
+ Object.defineProperty(rail, "clientHeight", { configurable: true, get: () => height });
83
+ return rail;
84
+ }
85
+
47
86
  afterEach(() => {
48
87
  for (const h of handles.splice(0)) h.detach();
49
88
  for (const el of containers.splice(0)) el.remove();
@@ -308,7 +347,227 @@ describe("attachOverlayScrollbars", () => {
308
347
  handles.push(handle);
309
348
  expect(() => {
310
349
  handle.update();
350
+ handle.setMarkers([{ start: 0, end: 0.5 }]);
311
351
  handle.detach();
312
352
  }).not.toThrow();
313
353
  });
314
354
  });
355
+
356
+ describe("change-mark rail", () => {
357
+ it("renders no rail at all when the consumer passes no markers", () => {
358
+ const { wrapper } = mountViewport("vertical");
359
+ expect(wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
360
+ });
361
+
362
+ it("creates the rail and one mark per entry, tagged by kind", () => {
363
+ const m = mountRailed();
364
+ m.handle.setMarkers([
365
+ { start: 0.1, end: 0.2, kind: "added" },
366
+ { start: 0.5, end: 0.6, kind: "deleted" },
367
+ ]);
368
+ const rail = m.rail();
369
+ expect(rail).not.toBeNull();
370
+ const marks = rail!.querySelectorAll<HTMLElement>(".hk-scrollbar-marker");
371
+ expect(marks.length).toBe(2);
372
+ expect(marks[0].getAttribute("data-kind")).toBe("added");
373
+ expect(marks[1].getAttribute("data-kind")).toBe("deleted");
374
+ expect(marks[0].style.top).toBe("10%");
375
+ expect(marks[0].style.height).toBe("10%");
376
+ expect(marks[1].style.top).toBe("50%");
377
+ });
378
+
379
+ it("accepts markers through the attach options", () => {
380
+ const m = mountViewport("vertical", { markers: [{ start: 0, end: 0.25, kind: "added" }] });
381
+ stubGeometry(m.viewport, {
382
+ scrollHeight: 1000, clientHeight: 100, scrollTop: 0,
383
+ scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
384
+ });
385
+ m.handle.update();
386
+ expect(m.wrapper.querySelectorAll(".hk-scrollbar-marker").length).toBe(1);
387
+ });
388
+
389
+ it("floors a hairline mark and keeps an end-of-file mark inside the rail", () => {
390
+ const m = mountRailed();
391
+ m.handle.setMarkers([
392
+ // One line of a 1000-line file: 0.1% — under the floor.
393
+ { start: 0.4, end: 0.401, kind: "modified" },
394
+ // The very last line: top + height must not overflow the rail.
395
+ { start: 1, end: 1, kind: "added" },
396
+ ]);
397
+ const marks = m.rail()!.querySelectorAll<HTMLElement>(".hk-scrollbar-marker");
398
+ expect(parseFloat(marks[0].style.height), "floored to the minimum").toBeCloseTo(0.5, 5);
399
+ const top = parseFloat(marks[1].style.top);
400
+ const height = parseFloat(marks[1].style.height);
401
+ expect(top + height, "a mark at the end stays inside the rail").toBeLessThanOrEqual(100);
402
+ });
403
+
404
+ it("floors a hairline mark in PIXELS on a short rail, not just as a fraction", () => {
405
+ const m = mountRailed();
406
+ railOfHeight(m, 100);
407
+ m.handle.setMarkers([{ start: 0.4, end: 0.401, kind: "modified" }]);
408
+ // `update()` is the path that re-renders marks laid out before a height
409
+ // existed (a no-layout DOM reports 0, which leaves the "never sized"
410
+ // sentinel behind) — without it this test silently asserted the
411
+ // FRACTION floor and would have stayed green with no pixel floor at all.
412
+ m.handle.update();
413
+ // 2px of a 100px rail = 2%. The fraction floor alone (0.5%) is a
414
+ // half-pixel target — and since the rail is pointer-transparent the
415
+ // marks ARE the hit area, so a half-pixel mark is a half-pixel button.
416
+ expect(
417
+ parseFloat(m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!.style.height),
418
+ ).toBeCloseTo(2, 5);
419
+ });
420
+
421
+ it("re-sizes the marks when the rail is resized", () => {
422
+ const m = mountRailed();
423
+ const rail = railOfHeight(m, 100);
424
+ m.handle.setMarkers([{ start: 0.4, end: 0.401, kind: "modified" }]);
425
+ const height = () =>
426
+ parseFloat(m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!.style.height);
427
+ expect(height()).toBeCloseTo(2, 5);
428
+ // A taller rail makes the same 2px a smaller share of it.
429
+ Object.defineProperty(rail, "clientHeight", { configurable: true, get: () => 400 });
430
+ m.handle.update();
431
+ expect(height(), "2px of a 400px rail").toBeCloseTo(0.5, 5);
432
+ });
433
+
434
+ it("clamps a reversed or out-of-range span instead of rendering it outside", () => {
435
+ const m = mountRailed();
436
+ m.handle.setMarkers([{ start: 0.8, end: 0.2, kind: "added" }]);
437
+ const mark = m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!;
438
+ expect(mark.style.top).toBe("20%");
439
+ expect(mark.style.height).toBe("60%");
440
+ });
441
+
442
+ it("positions marks against the content, so scrolling does not move them", () => {
443
+ const m = mountRailed();
444
+ m.handle.setMarkers([{ start: 0.25, end: 0.3, kind: "added" }]);
445
+ const top = () => m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!.style.top;
446
+ expect(top()).toBe("25%");
447
+ stubGeometry(m.viewport, {
448
+ scrollHeight: 1000, clientHeight: 100, scrollTop: 500,
449
+ scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
450
+ });
451
+ m.viewport.dispatchEvent(new Event("scroll"));
452
+ m.handle.update();
453
+ expect(top(), "the rail maps the whole document, not the window").toBe("25%");
454
+ });
455
+
456
+ it("removes the rail again when the marks are cleared", () => {
457
+ const m = mountRailed();
458
+ m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
459
+ expect(m.rail()).not.toBeNull();
460
+ m.handle.setMarkers([]);
461
+ expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
462
+ });
463
+
464
+ it("hides the rail while the content fits, and shows it again when it scrolls", () => {
465
+ const m = mountRailed();
466
+ m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
467
+ stubGeometry(m.viewport, {
468
+ scrollHeight: 100, clientHeight: 100, scrollTop: 0,
469
+ scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
470
+ });
471
+ m.handle.update();
472
+ expect(m.rail()!.style.display).toBe("none");
473
+ stubGeometry(m.viewport, {
474
+ scrollHeight: 1000, clientHeight: 100, scrollTop: 0,
475
+ scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
476
+ });
477
+ m.handle.update();
478
+ expect(m.rail()!.style.display).toBe("");
479
+ });
480
+
481
+ it("never renders a rail on a horizontal-only scroll region", () => {
482
+ const m = mountViewport("horizontal");
483
+ stubGeometry(m.viewport, {
484
+ scrollHeight: 100, clientHeight: 100, scrollTop: 0,
485
+ scrollWidth: 1000, clientWidth: 100, scrollLeft: 0,
486
+ });
487
+ m.handle.update();
488
+ m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
489
+ expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
490
+ });
491
+
492
+ it("ignores non-finite mark bounds instead of parking them at NaN%", () => {
493
+ const m = mountRailed();
494
+ m.handle.setMarkers([
495
+ { start: Number.NaN, end: Number.NaN, kind: "added" },
496
+ { start: Number.POSITIVE_INFINITY, end: 0.9, kind: "modified" },
497
+ ]);
498
+ const marks = m.rail()!.querySelectorAll<HTMLElement>(".hk-scrollbar-marker");
499
+ // "NaN%" is rejected by CSSOM: the element keeps no inline top/height,
500
+ // collapses to zero height and the mark silently does not exist.
501
+ expect(marks[0].style.top).toBe("0%");
502
+ expect(marks[0].style.height).toBe("0.5%");
503
+ // Infinity is not "the end of the file" — non-finite clamps to 0.
504
+ expect(marks[1].style.top).toBe("0%");
505
+ });
506
+
507
+ it("keeps a mark press from escaping to the page and shows the drag", () => {
508
+ const m = mountRailed();
509
+ m.handle.setMarkers([{ start: 0.3, end: 0.35, kind: "added" }]);
510
+ const rail = m.rail()!;
511
+ const mark = rail.querySelector<HTMLElement>(".hk-scrollbar-marker")!;
512
+ // The rail rides inside modals/drawers that dismiss on an outside
513
+ // press; scrubbing it must not double as that press.
514
+ let escaped = 0;
515
+ const spy = () => { escaped += 1; };
516
+ document.body.addEventListener("mousedown", spy);
517
+ const down = new MouseEvent("mousedown", { bubbles: true, cancelable: true, clientY: 0 });
518
+ mark.dispatchEvent(down);
519
+ expect(down.defaultPrevented, "the press is consumed").toBe(true);
520
+ expect(escaped, "the press never reaches the page").toBe(0);
521
+ expect(rail.classList.contains("is-dragging"), "the rail shows the drag").toBe(true);
522
+ document.dispatchEvent(new MouseEvent("mouseup"));
523
+ expect(rail.classList.contains("is-dragging"), "and clears it on release").toBe(false);
524
+ document.body.removeEventListener("mousedown", spy);
525
+ });
526
+
527
+ it("jumps to a mark when the mark is pressed", () => {
528
+ const m = mountRailed();
529
+ m.handle.setMarkers([{ start: 0.3, end: 0.35, kind: "added" }]);
530
+ const mark = m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!;
531
+ mark.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 50 }));
532
+ // Halfway down a 100px rail over a 900px scroll range.
533
+ expect(m.viewport.scrollTop).toBe(450);
534
+ document.dispatchEvent(new MouseEvent("mouseup"));
535
+ });
536
+
537
+ it("scrubs while the rail is dragged and stops on mouseup", () => {
538
+ const m = mountRailed();
539
+ m.handle.setMarkers([{ start: 0.3, end: 0.35, kind: "added" }]);
540
+ const mark = m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!;
541
+ mark.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 0 }));
542
+ expect(m.viewport.scrollTop).toBe(0);
543
+ document.dispatchEvent(new MouseEvent("mousemove", { clientY: 100 }));
544
+ expect(m.viewport.scrollTop, "dragged to the bottom").toBe(900);
545
+ document.dispatchEvent(new MouseEvent("mouseup"));
546
+ // The document listeners are gone: a later move must not scroll.
547
+ m.viewport.scrollTop = 0;
548
+ document.dispatchEvent(new MouseEvent("mousemove", { clientY: 50 }));
549
+ expect(m.viewport.scrollTop, "no scrub after mouseup").toBe(0);
550
+ });
551
+
552
+ it("detach removes the rail and its document listeners", () => {
553
+ const m = mountRailed();
554
+ m.handle.setMarkers([{ start: 0.3, end: 0.35, kind: "added" }]);
555
+ const mark = m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!;
556
+ mark.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 0 }));
557
+ m.handle.detach();
558
+ handles.splice(handles.indexOf(m.handle), 1);
559
+ expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
560
+ m.viewport.scrollTop = 0;
561
+ document.dispatchEvent(new MouseEvent("mousemove", { clientY: 100 }));
562
+ expect(m.viewport.scrollTop, "detach dropped the drag listener").toBe(0);
563
+ document.dispatchEvent(new MouseEvent("mouseup"));
564
+ });
565
+
566
+ it("ignores setMarkers after detach", () => {
567
+ const m = mountRailed();
568
+ m.handle.detach();
569
+ handles.splice(handles.indexOf(m.handle), 1);
570
+ expect(() => m.handle.setMarkers([{ start: 0, end: 1, kind: "added" }])).not.toThrow();
571
+ expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
572
+ });
573
+ });
@@ -30,6 +30,57 @@ import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
30
30
  /** Which axes get an overlay track. Defaults to "vertical". */
31
31
  export type OverlayScrollbarAxis = "vertical" | "horizontal" | "both";
32
32
 
33
+ /**
34
+ * One change mark on the vertical rail beside the track — the "where in
35
+ * the document is the interesting part" affordance a diff, a search
36
+ * result set or a lint run wants next to its scrollbar (VS Code's
37
+ * overview ruler).
38
+ *
39
+ * Positions are fractions of the FULL content extent, never of the
40
+ * visible window: line 500 of a 1000-line file sits at 0.5 while the
41
+ * user scrolls, and the rail keeps representing the whole document while
42
+ * the thumb represents the window. The engine stays domain-agnostic —
43
+ * the consumer divides its own line numbers, byte offsets or match
44
+ * indices by its own total.
45
+ */
46
+ export interface OverlayScrollbarMarker {
47
+ /** Mark start in [0, 1] along the content. */
48
+ start: number;
49
+ /** Mark end in [0, 1]; clamped to at least `start`. */
50
+ end: number;
51
+ /** Semantic tag mirrored onto `data-kind`, so the consumer's theme owns
52
+ * the palette (this engine ships no colours of its own). */
53
+ kind?: string;
54
+ }
55
+
56
+ /** Shortest a mark may render, as a fraction of the rail. A one-line edit
57
+ * in a long file is ~0.1% of the content — without a floor it would be a
58
+ * sub-pixel sliver, i.e. invisible exactly when the reader needs it. */
59
+ const MARKER_MIN_FRACTION = 0.005;
60
+
61
+ /** …and the same floor in CSS pixels, which is what actually decides
62
+ * whether a mark can be SEEN and HIT on a short rail: at 1000px tall the
63
+ * fraction floor is 5px, at 100px it is 0.5px. It lives in the engine —
64
+ * not as a stylesheet `min-height` — for two reasons: the floor has to
65
+ * scale with the rail, and the end-of-rail inset below has to be computed
66
+ * from the SAME number, which CSS cannot express. */
67
+ const MARKER_MIN_PX = 2;
68
+
69
+ /** Clamp to [0, 1]; non-finite input is the caller's bug, but the rail
70
+ * must never render a mark at a meaningless position, so it becomes 0. */
71
+ function clamp01(n: number): number {
72
+ if (!Number.isFinite(n)) return 0;
73
+ return n < 0 ? 0 : n > 1 ? 1 : n;
74
+ }
75
+
76
+ /** A rail offset as a CSS percentage with the float noise trimmed: the
77
+ * span `0.8 − 0.2` is 0.6000000000000001, and a position is not a
78
+ * measurement — "60.00000000000001%" is noise in the DOM, in snapshots
79
+ * and in any consumer assertion. */
80
+ function pct(fraction: number): string {
81
+ return `${Number((fraction * 100).toFixed(4))}%`;
82
+ }
83
+
33
84
  export interface OverlayScrollbarOptions {
34
85
  axis?: OverlayScrollbarAxis;
35
86
  /** Element the tracks are appended to — their positioning context.
@@ -42,11 +93,19 @@ export interface OverlayScrollbarOptions {
42
93
  * (see HkDrawer/HkSidebar/HkTable for the pattern). Never rely on
43
94
  * the guard to promote `body`. */
44
95
  host?: HTMLElement;
96
+ /** Change marks drawn on a rail immediately inboard of the vertical
97
+ * track. Vertical axis only — a horizontal rail has no meaningful
98
+ * "position in the document". Omitted or empty creates no rail at all,
99
+ * so consumers that never pass markers keep the exact chrome they had. */
100
+ markers?: OverlayScrollbarMarker[];
45
101
  }
46
102
 
47
103
  export interface OverlayScrollbarHandle {
48
104
  /** Re-read the viewport's scroll geometry and reposition the thumbs. */
49
105
  update(): void;
106
+ /** Replace the rail's marks. Passing an empty list removes the rail
107
+ * again. No-op when the handle was attached against no host. */
108
+ setMarkers(markers: OverlayScrollbarMarker[]): void;
50
109
  /** Remove the tracks and every listener/observer; restore any
51
110
  * inline positioning the guard applied. Safe to call twice. */
52
111
  detach(): void;
@@ -117,7 +176,7 @@ export function attachOverlayScrollbars(
117
176
  const axis = opts.axis ?? "vertical";
118
177
  const parent = opts.host ?? viewport.parentElement;
119
178
  if (!parent) {
120
- return { update() {}, detach() {} };
179
+ return { update() {}, setMarkers() {}, detach() {} };
121
180
  }
122
181
 
123
182
  const wantV = axis !== "horizontal";
@@ -156,14 +215,121 @@ export function attachOverlayScrollbars(
156
215
  let ro: ResizeObserver | null = null;
157
216
  let detached = false;
158
217
 
218
+ // ── Change-mark rail (vertical only) ─────────────────────────────────
219
+ // Created lazily on the first non-empty marker list: consumers that
220
+ // never pass markers keep byte-identical chrome, and the rail's own
221
+ // listeners exist only while the rail does.
222
+ let rail: HTMLElement | null = null;
223
+ let markers: OverlayScrollbarMarker[] = [];
224
+ let railDragging = false;
225
+ /** Rail height the marks were last sized against — the pixel floor is a
226
+ * function of it, so a resize has to re-render them. */
227
+ let renderedRailHeight = 0;
228
+
229
+ /** Scroll so the rail position under `clientY` lands in the viewport —
230
+ * the same fraction → scrollTop mapping the track's own click uses, so
231
+ * a mark and the track at the same height agree. */
232
+ function railScrollTo(clientY: number) {
233
+ if (!rail) return;
234
+ const rect = rail.getBoundingClientRect();
235
+ if (rect.height <= 0) return;
236
+ const ratio = clamp01((clientY - rect.top) / rect.height);
237
+ const { scrollSize, clientSize } = axisMetrics(viewport, false);
238
+ viewport.scrollTop = ratio * Math.max(scrollSize - clientSize, 0);
239
+ }
240
+
241
+ function onRailMove(e: MouseEvent) {
242
+ if (railDragging) railScrollTo(e.clientY);
243
+ }
244
+ function onRailUp() {
245
+ railDragging = false;
246
+ rail?.classList.remove("is-dragging");
247
+ document.removeEventListener("mousemove", onRailMove);
248
+ document.removeEventListener("mouseup", onRailUp);
249
+ }
250
+ function onRailDown(e: MouseEvent) {
251
+ e.preventDefault();
252
+ // The rail sits inside a modal/panel that may itself dismiss on an
253
+ // outside press — scrubbing the rail must not double as that press.
254
+ e.stopPropagation();
255
+ railDragging = true;
256
+ rail?.classList.add("is-dragging");
257
+ railScrollTo(e.clientY);
258
+ document.addEventListener("mousemove", onRailMove);
259
+ document.addEventListener("mouseup", onRailUp);
260
+ }
261
+
262
+ function renderMarkers() {
263
+ if (!rail) return;
264
+ rail.replaceChildren();
265
+ // Measured, not assumed: happy-dom and a `display:none` rail both
266
+ // report 0, and the fraction floor is the honest answer there.
267
+ const railH = rail.clientHeight;
268
+ renderedRailHeight = railH;
269
+ const floor = railH > 0
270
+ ? Math.max(MARKER_MIN_FRACTION, MARKER_MIN_PX / railH)
271
+ : MARKER_MIN_FRACTION;
272
+ for (const m of markers) {
273
+ const size = Math.max(
274
+ Math.abs(clamp01(m.end) - clamp01(m.start)),
275
+ floor,
276
+ );
277
+ // Keep the whole mark inside the rail: a mark at the very end would
278
+ // otherwise overflow the bottom, get clipped, and read as absent.
279
+ const top = Math.max(
280
+ Math.min(clamp01(m.start), clamp01(m.end), 1 - size),
281
+ 0,
282
+ );
283
+ const el = document.createElement("div");
284
+ el.className = "hk-scrollbar-marker";
285
+ if (m.kind) el.setAttribute("data-kind", m.kind);
286
+ el.style.top = pct(top);
287
+ el.style.height = pct(size);
288
+ rail.appendChild(el);
289
+ }
290
+ }
291
+
292
+ // An arrow const, not a function declaration: the guard above narrows
293
+ // `parent` to non-null, and only a closure created AFTER that narrowing
294
+ // keeps it (a hoisted declaration may run at any time, so TS widens the
295
+ // capture back to `HTMLElement | null`).
296
+ const setMarkers = (next: OverlayScrollbarMarker[]) => {
297
+ if (detached) return;
298
+ markers = Array.isArray(next) ? next : [];
299
+ if (markers.length === 0) {
300
+ // No marks = no rail, i.e. exactly the pre-marker chrome.
301
+ if (rail) {
302
+ rail.removeEventListener("mousedown", onRailDown);
303
+ rail.remove();
304
+ rail = null;
305
+ }
306
+ onRailUp();
307
+ return;
308
+ }
309
+ if (!wantV) return;
310
+ if (!rail) {
311
+ rail = document.createElement("div");
312
+ rail.className = "hk-scrollbar-markers";
313
+ rail.addEventListener("mousedown", onRailDown);
314
+ parent.appendChild(rail);
315
+ }
316
+ renderMarkers();
317
+ // The rail's visibility follows the track's fit test.
318
+ update();
319
+ };
320
+
159
321
  function updateAxis(s: AxisState, horizontal: boolean) {
160
322
  const { scrollSize, clientSize, scrollPos } = axisMetrics(viewport, horizontal);
161
323
  if (scrollSize <= clientSize) {
162
- // Content fits — no bar at all.
324
+ // Content fits — no bar at all. The rail follows the track: marks on
325
+ // a region that cannot scroll would claim changes the reader can
326
+ // never reach.
163
327
  s.track.style.display = "none";
328
+ if (!horizontal && rail) rail.style.display = "none";
164
329
  return;
165
330
  }
166
331
  s.track.style.display = "";
332
+ if (!horizontal && rail) rail.style.display = "";
167
333
  const trackSize = horizontal ? s.track.clientWidth : s.track.clientHeight;
168
334
  const ratio = clientSize / scrollSize;
169
335
  const thumbSize = Math.max(ratio * trackSize, 20);
@@ -188,6 +354,15 @@ export function attachOverlayScrollbars(
188
354
  function update() {
189
355
  if (detached) return;
190
356
  for (const s of states) updateAxis(s, s.horizontal);
357
+ // The marks' pixel floor is a fraction OF the rail, so a height change
358
+ // invalidates the sizes they were rendered with. A hidden rail reports
359
+ // 0 and is skipped here; `renderMarkers` leaves that 0 behind as its
360
+ // "never sized against a real height" sentinel, so the first visible
361
+ // update re-renders even when the height matches the last one used.
362
+ const railH = rail?.clientHeight ?? 0;
363
+ if (rail && markers.length > 0 && railH > 0 && railH !== renderedRailHeight) {
364
+ renderMarkers();
365
+ }
191
366
  }
192
367
 
193
368
  function scheduleUpdate() {
@@ -292,9 +467,11 @@ export function attachOverlayScrollbars(
292
467
  ro = new ResizeObserver(scheduleUpdate);
293
468
  ro.observe(viewport);
294
469
  update();
470
+ if (opts.markers?.length) setMarkers(opts.markers);
295
471
 
296
472
  return {
297
473
  update,
474
+ setMarkers,
298
475
  detach() {
299
476
  if (detached) return;
300
477
  detached = true;
@@ -303,6 +480,12 @@ export function attachOverlayScrollbars(
303
480
  ro = null;
304
481
  scheduled?.disconnect();
305
482
  scheduled = null;
483
+ if (rail) {
484
+ rail.removeEventListener("mousedown", onRailDown);
485
+ rail.remove();
486
+ rail = null;
487
+ }
488
+ onRailUp();
306
489
  for (const { state, handlers } of axisBindings) {
307
490
  state.thumb.removeEventListener("mousedown", handlers.onDown);
308
491
  state.track.removeEventListener("click", handlers.onTrackClick);
package/src/index.ts CHANGED
@@ -420,6 +420,9 @@ export type { PasswordValidationResult, PasswordLevel, PasswordStrengthEvaluator
420
420
  export { FOCUSABLE_SELECTOR, getFocusableElements, focusFirst, trapFocus, scrollToElement } from "./utils/dom";
421
421
  export { useApproachEnd } from "./composables/useApproachEnd";
422
422
  export type { ApproachEndOptions, ApproachEndHandle } from "./composables/useApproachEnd";
423
+ // The change-mark rail's public vocabulary: HkScrollContainer's `markers`
424
+ // prop is typed with it, so consumers can name what they pass.
425
+ export type { OverlayScrollbarMarker } from "./composables/useOverlayScrollbar";
423
426
  export { useDeferredTransition } from "./composables/useDeferredTransition";
424
427
  export { useSurfaceTransition } from "./composables/useSurfaceTransition";
425
428
  export { useSizeMorph } from "./composables/useSizeMorph";
@@ -83,3 +83,79 @@
83
83
  width: 100%;
84
84
  min-height: 20px;
85
85
  }
86
+
87
+ /* ── Change-mark rail (VS Code's overview ruler) ──────────────────────
88
+ Rendered only when a consumer passes `markers` to
89
+ attachOverlayScrollbars (HkScrollContainer's `markers` prop), so every
90
+ existing consumer's chrome above is unchanged. It sits immediately
91
+ inboard of the vertical track — the two together read as one thick
92
+ marked scrollbar — and carries no chrome of its own beyond a faint
93
+ groove: the marks are the content. */
94
+ .hk-scrollbar-markers {
95
+ position: absolute;
96
+ z-index: 11;
97
+ top: 4px;
98
+ bottom: 4px;
99
+ /* Vertical track: right 2px + width 6px → occupies 2–8px; a 2px gutter
100
+ puts the rail at 10px. The contract test pins this non-overlap. */
101
+ right: 10px;
102
+ width: 8px;
103
+ border-radius: 4px;
104
+ background: color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 18%, transparent);
105
+ overflow: hidden;
106
+ transition: background var(--hi-duration-fast, 0.15s) ease;
107
+
108
+ /* Pointer-transparent, exactly like the track above and for the same
109
+ reason: an overlay hugging the region's edge must never steal a press
110
+ meant for the content beneath it — and this rail spans the WHOLE host,
111
+ docks included, so it sits over right-aligned dock controls too. Only
112
+ the MARKS take input (see below): they are the targets that mean
113
+ something, and they are the rail's entire content. */
114
+ pointer-events: none;
115
+
116
+ &.is-dragging {
117
+ background: color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 32%, transparent);
118
+ }
119
+ }
120
+
121
+ /* One mark. `data-kind` is whatever tag the consumer passed; the three
122
+ diff kinds get the diff palette, anything else the neutral muted one.
123
+ The fallbacks mirror the thumb's: a themeless standalone render must
124
+ still show marks rather than an invalid-at-computed-value-time
125
+ transparent.
126
+
127
+ Height comes ENTIRELY from the engine, which floors a hairline mark in
128
+ both fractions and pixels and sizes the end-of-rail inset against that
129
+ same number. A `min-height` here is therefore redundant — the engine
130
+ already guarantees 2px — and, because the rail clips, would win over
131
+ the inset on the occasions it did bite. */
132
+ .hk-scrollbar-marker {
133
+ position: absolute;
134
+ left: 0;
135
+ width: 100%;
136
+ border-radius: 1px;
137
+ background: var(--hi-color-muted, rgb(118 122 128));
138
+ /* The rail is pointer-transparent; the marks are the hit area. */
139
+ pointer-events: auto;
140
+ cursor: pointer;
141
+
142
+ &:hover {
143
+ /* Negative offset: the rail clips its children, and a plain outline
144
+ lost both side strokes to that clip (only the top/bottom lines
145
+ survived) — drawn inside, the ring stays whole. */
146
+ outline: 1px solid color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 60%, transparent);
147
+ outline-offset: -1px;
148
+ }
149
+
150
+ &[data-kind="added"] {
151
+ background: var(--hi-color-success, #0eb840);
152
+ }
153
+
154
+ &[data-kind="deleted"] {
155
+ background: var(--hi-color-danger, #ff4c00);
156
+ }
157
+
158
+ &[data-kind="modified"] {
159
+ background: var(--hi-color-warning, #eed677);
160
+ }
161
+ }