@celestia-island/hikari 0.55.157 → 0.55.159

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.157",
3
+ "version": "0.55.159",
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",
@@ -25,11 +25,14 @@ import "./HkInfiniteTail.scss";
25
25
  * - `"idle"` — more exists, nothing in flight. With `auto` (default)
26
26
  * the invisible sentinel watches the viewport and emits `loadMore`
27
27
  * as it approaches; with `auto={false}` the explicit load-more
28
- * button (HkLoadMore) renders instead. NOTE: with `auto`, a tail
29
- * page failure retries in place every time the sentinel re-enters
30
- * view (the machine's error guard paces it to one attempt per
31
- * emission, no backoff) — hosts that want backoff map the error
32
- * state away from `"idle"` themselves.
28
+ * button (HkLoadMore) renders instead. NOTE: a failed page
29
+ * terminalizes the machine's walk — the tail keeps emitting
30
+ * `loadMore` as the sentinel re-enters view, but each emission is a
31
+ * no-op until the host calls `reload()` (the retry verb; the
32
+ * delivered rows stay on screen under the host's stale banner).
33
+ * Auto-retrying from the tail is deliberately not supported: the
34
+ * sentinel would re-fire on a fixed geometry with no backoff, one
35
+ * RPC per round trip, which locks a shared rate budget.
33
36
  *
34
37
  * The sentinel observes the BROWSER viewport (IntersectionObserver,
35
38
  * `rootMargin` lookahead), so it works inside any nested scroll
@@ -404,7 +404,7 @@ describe("HkScrollContainer back-to-top signal", () => {
404
404
  });
405
405
 
406
406
  describe("HkScrollContainer change-mark rail", () => {
407
- it("renders the marks prop, and re-applies new marks without rebuilding the chrome", async () => {
407
+ it("widens the track, renders the marks prop, and re-applies them without rebuilding", async () => {
408
408
  const markers = ref<OverlayScrollbarMarker[]>([{ start: 0.1, end: 0.2, kind: "added" }]);
409
409
  const container = document.createElement("div");
410
410
  document.body.appendChild(container);
@@ -419,9 +419,11 @@ describe("HkScrollContainer change-mark rail", () => {
419
419
  mounts.push(app);
420
420
  app.mount(container);
421
421
 
422
- const rail = () => container.querySelector<HTMLElement>(".hk-scrollbar-markers");
422
+ // The marks live INSIDE the vertical track: the rail IS the track.
423
+ const rail = () => container.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])");
423
424
  const firstRail = rail();
424
- expect(firstRail, "the rail exists once marks arrive").not.toBeNull();
425
+ expect(firstRail, "the track exists once marks arrive").not.toBeNull();
426
+ expect(firstRail!.classList.contains("has-markers"), "the track widens for marks").toBe(true);
425
427
  expect(firstRail!.querySelectorAll(".hk-scrollbar-marker").length).toBe(1);
426
428
 
427
429
  markers.value = [
@@ -438,8 +440,10 @@ describe("HkScrollContainer change-mark rail", () => {
438
440
  app.unmount();
439
441
  });
440
442
 
441
- it("keeps the rail out of the DOM when no marks are passed", () => {
443
+ it("keeps the narrow track and no marks when none are passed", () => {
442
444
  const m = mountScroller({});
443
- expect(m.container.querySelector(".hk-scrollbar-markers")).toBeNull();
445
+ const track = m.container.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])");
446
+ expect(track?.classList.contains("has-markers") ?? false, "no widening").toBe(false);
447
+ expect(track?.querySelectorAll(".hk-scrollbar-marker").length ?? 0).toBe(0);
444
448
  });
445
449
  });
@@ -52,6 +52,9 @@ export default defineComponent({
52
52
  * it out renders exactly the chrome this container had before the
53
53
  * rail existed. */
54
54
  markers: { type: Array as PropType<OverlayScrollbarMarker[]>, default: () => [] },
55
+ /** Width the vertical track widens to while marks show, in px. Omitted
56
+ * = the library default (14). Only read when `markers` is non-empty. */
57
+ markersWidth: { type: Number, default: undefined },
55
58
  autoFollow: { type: Boolean, default: false },
56
59
  overscanScreens: { type: Number, default: 1 },
57
60
  /** Inline-axis alignment of the content when it fits the viewport.
@@ -271,7 +274,7 @@ export default defineComponent({
271
274
  const vp = viewportRef.value;
272
275
  if (!vp) return;
273
276
  overlay?.detach();
274
- overlay = attachOverlayScrollbars(vp, { axis: props.axis, markers: props.markers });
277
+ overlay = attachOverlayScrollbars(vp, { axis: props.axis, markers: props.markers, markersWidth: props.markersWidth });
275
278
  }
276
279
 
277
280
  /** Re-apply marks without rebuilding the chrome: a diff re-renders its
@@ -71,43 +71,58 @@ describe("overlay scrollbar chrome contract", () => {
71
71
  expect(chrome).toMatch(/&?\.is-scrolling,\s*&?\.is-hovering\s*\{[^}]*pointer-events:\s*auto/);
72
72
  });
73
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:
74
+ it("widens the track into the ruler and keeps the click rules straight", () => {
75
+ // The marks live INSIDE the track, which widens only when it carries
76
+ // them (`.has-markers`) — VS Code's overview ruler is the scrollbar's
77
+ // own strip widened, not a second strip. Three facts must survive any
78
+ // future restyle:
77
79
  //
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]);
80
+ // 1. The widened ground is FAINT and theme-adaptive (a translucent
81
+ // mid-grey: grey over a light page, slightly lighter over a dark
82
+ // one), with the 2px rounding the band promised;
83
+ // 2. it stays pointer-transparent AT REST — the widened band spans
84
+ // the whole host, docks included, and a live hot zone there would
85
+ // eat presses meant for right-aligned dock controls;
86
+ // 3. the MARKS are the hit area and hug the band's edges: removed
87
+ // content at the start, added at the end (VS Code's two lanes),
88
+ // so one vertical position can carry both.
89
+ expect(chrome, "no second strip — the separate rail is gone").not.toContain(".hk-scrollbar-markers");
90
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);
91
+ const widened = /&\.has-markers:not\(\[data-axis="horizontal"\]\)\s*\{[^}]*\}/.exec(chrome)?.[0] ?? "";
92
+ expect(widened, "positive control: the widened-band rule was found").not.toBe("");
93
+ expect(widened, "the ground is faint (translucent grey)").toMatch(
94
+ /color-mix\(in srgb,\s*var\(--hi-color-muted,\s*rgb\(118 122 128\)\)\s*\d+%,\s*transparent\)/,
95
+ );
96
+ expect(widened, "the band keeps its 2px rounding").toMatch(/border-radius:\s*2px/);
97
+ expect(widened, "and a parameterised width the engine overrides").toMatch(/width:\s*\d+px/);
98
+ // The base track's own pointer contract (pinned above) is untouched;
99
+ // the widened rule must not sneak a `pointer-events: auto` in.
100
+ expect(widened).not.toMatch(/pointer-events:\s*auto/);
101
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
102
+ // The mark's whole rule (it is the file's last block, and its nested
103
+ // lanes/hover/kind blocks make a single-block regex fragile).
104
+ const markerBlock = chrome.slice(chrome.indexOf(".hk-scrollbar-marker {"));
105
+ expect(markerBlock, "positive control: the mark rule was found").not.toBe("");
106
+ expect(markerBlock, "the marks are the hit area").toMatch(/pointer-events:\s*auto/);
107
+ expect(markerBlock, "the band's 2px rounding reaches the marks").toMatch(/border-radius:\s*2px/);
108
+ // The two lanes, at the band's edges (the rules nest inside the mark
109
+ // selector, so they are written `&[data-side=…]`).
110
+ expect(markerBlock).toMatch(/&\[data-side="start"\]\s*\{[^}]*left:\s*0/);
111
+ expect(markerBlock).toMatch(/&\[data-side="end"\]\s*\{[^}]*right:\s*0/);
112
+ // …and their WIDTH: a restyle that silently stretched a lane across
113
+ // the whole band would collapse the two-lane reading into one strip
114
+ // (caught only by the vendored-sync byte comparison before this pin).
115
+ expect(markerBlock, "the start lane hugs a third of the band").toMatch(
116
+ /&\[data-side="start"\]\s*\{[^}]*width:\s*34%/,
117
+ );
118
+ expect(markerBlock, "and the end lane the other third").toMatch(
119
+ /&\[data-side="end"\]\s*\{[^}]*width:\s*34%/,
120
+ );
121
+ // The hover ring must be drawn INSIDE the mark: the track clips its
107
122
  // children, and a plain outline loses both side strokes to that clip,
108
123
  // leaving a two-line "ring" that reads as a stray rule rather than a
109
124
  // hover cue. Nothing else would notice that regression.
110
- expect(marker, "the hover ring is drawn inside the mark").toMatch(
125
+ expect(markerBlock, "the hover ring is drawn inside the mark").toMatch(
111
126
  /outline-offset:\s*-1px/,
112
127
  );
113
128
  });
@@ -114,7 +114,7 @@ describe("useInfinitePages", () => {
114
114
  expect(handle.exhausted.value).toBe(true);
115
115
  });
116
116
 
117
- it("a failed later page keeps the delivered rows and retries in place", async () => {
117
+ it("a failed later page keeps the delivered rows and terminalizes the walk", async () => {
118
118
  const fetcher = vi
119
119
  .fn(async (): Promise<HkPageResultLike<Row>> => page([], 0, false))
120
120
  .mockImplementationOnce(async () => page([{ id: "a" }], 3, true))
@@ -127,10 +127,36 @@ describe("useInfinitePages", () => {
127
127
  await handle.loadMore();
128
128
  expect(handle.error.value).toBe("boom");
129
129
  expect(handle.items.value.map((r) => r.id)).toEqual(["a"]);
130
- // The tail failure is retriable without a full reload.
130
+ // A tail failure does NOT auto-retry: the sentinel's idle re-check
131
+ // would re-fire loadMore on a fixed geometry with no backoff — one
132
+ // RPC per round trip forever, locking any shared rate budget. The
133
+ // failure is terminal until `reload`.
131
134
  await handle.loadMore();
135
+ expect(fetcher).toHaveBeenCalledTimes(2);
136
+ expect(handle.items.value.map((r) => r.id)).toEqual(["a"]);
137
+ // `reload` is the retry verb: it clears the failure and refetches
138
+ // from page one.
139
+ await handle.reload();
132
140
  expect(handle.error.value).toBeNull();
133
- expect(handle.items.value.map((r) => r.id)).toEqual(["a", "b", "c"]);
141
+ expect(handle.items.value.map((r) => r.id)).toEqual(["b", "c"]);
142
+ });
143
+
144
+ it("a tail failure cannot storm: repeated loadMore stays a no-op", async () => {
145
+ const fetcher = vi
146
+ .fn(async (): Promise<HkPageResultLike<Row>> => page([], 0, false))
147
+ .mockImplementationOnce(async () => page([{ id: "a" }], 5, true))
148
+ .mockImplementation(async () => {
149
+ throw new Error("tail down");
150
+ });
151
+ const { handle } = withHandle(() => useInfinitePages<Row>({ fetcher, pageSize: 1 }));
152
+ await vi.waitFor(() => expect(handle.loading.value).toBe(false));
153
+ await handle.loadMore();
154
+ expect(handle.error.value).toBe("tail down");
155
+ // Ten idle-entry re-checks (the tail sentinel's emission pattern)
156
+ // spend ZERO further RPCs.
157
+ for (let i = 0; i < 10; i++) await handle.loadMore();
158
+ expect(fetcher).toHaveBeenCalledTimes(2);
159
+ expect(handle.items.value.map((r) => r.id)).toEqual(["a"]);
134
160
  });
135
161
 
136
162
  it("a param change triggers a debounced reload from page one", async () => {
@@ -83,9 +83,9 @@ export interface InfinitePagesHandle<T> {
83
83
  error: Ref<string | null>;
84
84
  /** One-based index of the next page to fetch. */
85
85
  nextPage: Ref<number>;
86
- /** Fetch the next page (guarded: no-op while busy or exhausted).
87
- * A TAIL-page failure stays retriable in place — the next `loadMore`
88
- * refetches the same page; only a page-one failure needs `reload`. */
86
+ /** Fetch the next page (guarded: no-op while busy, exhausted, or
87
+ * after ANY failure — `reload` is the retry verb for both the
88
+ * first-page and the tail-page error dialects). */
89
89
  loadMore: () => Promise<void>;
90
90
  /** Reset to page one and fetch (also the error-clearing retry). */
91
91
  reload: () => Promise<void>;
@@ -101,7 +101,11 @@ export interface InfinitePagesHandle<T> {
101
101
  *
102
102
  * Error dialect: a failed page keeps everything already delivered (the
103
103
  * consumer renders it as a stale banner — one failed fetch must never
104
- * hide rows the user was reading); `reload` is the retry verb.
104
+ * hide rows the user was reading) and terminalizes the walk; `reload`
105
+ * is the retry verb for every failure. Auto-retrying a tail failure is
106
+ * deliberately NOT done: the tail's sentinel would re-fire on a fixed
107
+ * geometry with no backoff, turning one flaky page into an RPC storm
108
+ * that locks a shared rate budget.
105
109
  */
106
110
  export function useInfinitePages<T, P extends Record<string, unknown> = Record<string, unknown>>(
107
111
  options: InfinitePagesOptions<T, P>,
@@ -123,9 +127,6 @@ export function useInfinitePages<T, P extends Record<string, unknown> = Record<s
123
127
  let seq = 0;
124
128
  let reloadTimer: ReturnType<typeof setTimeout> | null = null;
125
129
  let disposed = false;
126
- /** Which phase the current `error` came from — a tail-page failure
127
- * retried by `loadMore` in place, a first-page failure by `reload`. */
128
- let errorPhase: "load" | "more" = "load";
129
130
 
130
131
  function normalize(result: HkPageResultLike<T>): HkPageResult<T> {
131
132
  const loose = result as {
@@ -196,14 +197,20 @@ export function useInfinitePages<T, P extends Record<string, unknown> = Record<s
196
197
  }
197
198
 
198
199
  async function loadMore(): Promise<void> {
199
- // A failure from a LATER page is retriable in place (the delivered
200
- // rows stay, the cursor stays); a failure from page one needs a
201
- // full reload (the retry verb for the hard-error state).
200
+ // ANY failure terminalizes the walk until `reload()` — including a
201
+ // tail-page failure. The in-place retry this used to allow read
202
+ // fine in isolation but stormed in the field: a failed tail leaves
203
+ // the state machine "idle", the tail's sentinel stays in view, and
204
+ // its idle-entry re-check fires loadMore again with no geometry
205
+ // change — one RPC per round trip, no backoff, forever. On a shared
206
+ // per-user rate budget that loop locks the bucket and denies every
207
+ // OTHER paged page, so the retry verb is `reload()` (the delivered
208
+ // rows stay on screen under the stale banner; the header refresh is
209
+ // the user's affordance).
202
210
  if (loading.value || loadingMore.value || exhausted.value) return;
203
- if (error.value && errorPhase !== "more") return;
211
+ if (error.value) return;
204
212
  if (nextPage.value > 1 && items.value.length === 0) return;
205
213
  loadingMore.value = true;
206
- errorPhase = "more";
207
214
  const mine = ++seq;
208
215
  try {
209
216
  await fetchPage(nextPage.value, mine);
@@ -225,7 +232,6 @@ export function useInfinitePages<T, P extends Record<string, unknown> = Record<s
225
232
  reloadTimer = null;
226
233
  }
227
234
  loading.value = true;
228
- errorPhase = "load";
229
235
  error.value = null;
230
236
  if (!options.keepDuringReload) {
231
237
  // Invalidate the cursor BEFORE the fetch so a straggler from the
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
1
4
  import { afterEach, describe, expect, it, vi } from "vitest";
2
5
 
3
6
  import { attachOverlayScrollbars, type OverlayScrollbarHandle, type OverlayScrollbarOptions } from "./useOverlayScrollbar";
@@ -23,10 +26,11 @@ function mountViewport(
23
26
  return { container, wrapper, viewport, handle };
24
27
  }
25
28
 
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. */
29
+ /** A scrollable vertical viewport with a deterministic band box: happy-dom
30
+ * performs no layout, so both the geometry and the track's bounding box
31
+ * are stubbed. `height` of the band is what maps a mark's fraction to a
32
+ * pixel position. The marks live INSIDE the vertical track (the widened
33
+ * scrollbar is the ruler), so "the rail" below IS the track. */
30
34
  function mountRailed(railHeight = 100) {
31
35
  const mounted = mountViewport("vertical");
32
36
  stubGeometry(mounted.viewport, {
@@ -37,12 +41,13 @@ function mountRailed(railHeight = 100) {
37
41
  return {
38
42
  ...mounted,
39
43
  rail: () => {
40
- const el = mounted.wrapper.querySelector<HTMLElement>(".hk-scrollbar-markers");
44
+ const el = mounted.wrapper.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])");
41
45
  if (el) {
42
46
  vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
43
- top: 0, left: 0, width: 8, height: railHeight,
44
- bottom: railHeight, right: 8, x: 0, y: 0, toJSON: () => ({}),
47
+ top: 0, left: 0, width: 14, height: railHeight,
48
+ bottom: railHeight, right: 14, x: 0, y: 0, toJSON: () => ({}),
45
49
  } as DOMRect);
50
+ Object.defineProperty(el, "clientHeight", { configurable: true, get: () => railHeight });
46
51
  }
47
52
  return el;
48
53
  },
@@ -354,9 +359,11 @@ describe("attachOverlayScrollbars", () => {
354
359
  });
355
360
 
356
361
  describe("change-mark rail", () => {
357
- it("renders no rail at all when the consumer passes no markers", () => {
362
+ it("keeps the narrow track, unmarked, when the consumer passes no markers", () => {
358
363
  const { wrapper } = mountViewport("vertical");
359
- expect(wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
364
+ const track = wrapper.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])");
365
+ expect(track?.classList.contains("has-markers") ?? false, "no widening").toBe(false);
366
+ expect(track?.querySelectorAll(".hk-scrollbar-marker").length ?? 0).toBe(0);
360
367
  });
361
368
 
362
369
  it("creates the rail and one mark per entry, tagged by kind", () => {
@@ -439,6 +446,79 @@ describe("change-mark rail", () => {
439
446
  expect(mark.style.height).toBe("60%");
440
447
  });
441
448
 
449
+ it("honours the consumer's markersWidth, clamped to a sane band", () => {
450
+ const m = mountRailed();
451
+ m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
452
+ const width = () => m.rail()!.style.width;
453
+ // The default band, then the consumer's own, then the clamps.
454
+ expect(width()).toBe("14px");
455
+ m.handle.detach();
456
+ handles.splice(handles.indexOf(m.handle), 1);
457
+
458
+ const custom = mountViewport("vertical", { markers: [{ start: 0.1, end: 0.2, kind: "added" }], markersWidth: 22 });
459
+ custom.handle.update();
460
+ expect(
461
+ custom.wrapper.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])")!.style.width,
462
+ ).toBe("22px");
463
+ custom.handle.detach();
464
+ handles.splice(handles.indexOf(custom.handle), 1);
465
+
466
+ const clamped = mountViewport("vertical", { markers: [{ start: 0.1, end: 0.2, kind: "added" }], markersWidth: 999 });
467
+ clamped.handle.update();
468
+ expect(
469
+ clamped.wrapper.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])")!.style.width,
470
+ "above the 40px ceiling it clamps").toBe("40px");
471
+ });
472
+
473
+ it("mirrors side onto data-side so the lanes can be styled", () => {
474
+ const m = mountRailed();
475
+ m.handle.setMarkers([
476
+ { start: 0.1, end: 0.2, kind: "deleted", side: "start" },
477
+ { start: 0.1, end: 0.2, kind: "added", side: "end" },
478
+ { start: 0.5, end: 0.6, kind: "modified" },
479
+ ]);
480
+ const marks = m.rail()!.querySelectorAll<HTMLElement>(".hk-scrollbar-marker");
481
+ expect(marks[0].getAttribute("data-side")).toBe("start");
482
+ expect(marks[1].getAttribute("data-side")).toBe("end");
483
+ expect(marks[2].getAttribute("data-side"), "no side = full band").toBeNull();
484
+ });
485
+
486
+ it("does not scrub on a press on the band's empty ground", () => {
487
+ const m = mountRailed();
488
+ m.handle.setMarkers([{ start: 0.3, end: 0.35, kind: "added" }]);
489
+ stubGeometry(m.viewport, {
490
+ scrollHeight: 1000, clientHeight: 100, scrollTop: 0,
491
+ scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
492
+ });
493
+ // A press on the TRACK (not on a mark) keeps the track's own
494
+ // click-to-page behaviour: no scrub, no drag state.
495
+ const track = m.rail()!;
496
+ track.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, clientY: 0 }));
497
+ expect(track.classList.contains("is-dragging"), "no drag state").toBe(false);
498
+ document.dispatchEvent(new MouseEvent("mousemove", { clientY: 100 }));
499
+ expect(m.viewport.scrollTop, "no scrub followed the pointer").toBe(0);
500
+ document.dispatchEvent(new MouseEvent("mouseup"));
501
+ });
502
+
503
+ it("keeps the band and its marks visible at rest (an overview ruler is a glance)", () => {
504
+ const m = mountRailed();
505
+ m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
506
+ // The track is pointer-transparent at rest, but the widened band and
507
+ // its marks must still SHOW: hiding them made the ruler a hover-only
508
+ // affordance, which is exactly what an overview ruler is not.
509
+ // happy-dom does not apply stylesheets to computed styles, so this
510
+ // pins the rule in the shipped chrome (the vendored copy) instead.
511
+ const chrome = readFileSync(
512
+ resolve(dirname(fileURLToPath(import.meta.url)), "../styles/theme/scrollbar.scss"),
513
+ "utf-8",
514
+ );
515
+ const widened = /&\.has-markers:not\(\[data-axis="horizontal"\]\)\s*\{[^}]*\}/.exec(chrome)?.[0] ?? "";
516
+ expect(widened, "positive control: the widened rule was found").not.toBe("");
517
+ expect(widened, "the band is visible at rest").toMatch(/opacity:\s*1/);
518
+ // …and the thumb rides above it.
519
+ expect(chrome).toMatch(/\.hk-scrollbar-thumb\s*\{[^}]*z-index:\s*2/);
520
+ });
521
+
442
522
  it("positions marks against the content, so scrolling does not move them", () => {
443
523
  const m = mountRailed();
444
524
  m.handle.setMarkers([{ start: 0.25, end: 0.3, kind: "added" }]);
@@ -458,7 +538,9 @@ describe("change-mark rail", () => {
458
538
  m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
459
539
  expect(m.rail()).not.toBeNull();
460
540
  m.handle.setMarkers([]);
461
- expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
541
+ const track = m.wrapper.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])");
542
+ expect(track?.classList.contains("has-markers") ?? false, "the widening is undone").toBe(false);
543
+ expect(track?.querySelectorAll(".hk-scrollbar-marker").length ?? 0).toBe(0);
462
544
  });
463
545
 
464
546
  it("hides the rail while the content fits, and shows it again when it scrolls", () => {
@@ -486,7 +568,9 @@ describe("change-mark rail", () => {
486
568
  });
487
569
  m.handle.update();
488
570
  m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
489
- expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
571
+ const hTrack = m.wrapper.querySelector<HTMLElement>('.hk-scrollbar-track[data-axis="horizontal"]');
572
+ expect(hTrack?.classList.contains("has-markers") ?? false, "no band on a horizontal track").toBe(false);
573
+ expect(hTrack?.querySelectorAll(".hk-scrollbar-marker").length ?? 0).toBe(0);
490
574
  });
491
575
 
492
576
  it("ignores non-finite mark bounds instead of parking them at NaN%", () => {
@@ -556,7 +640,7 @@ describe("change-mark rail", () => {
556
640
  mark.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 0 }));
557
641
  m.handle.detach();
558
642
  handles.splice(handles.indexOf(m.handle), 1);
559
- expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
643
+ expect(m.wrapper.querySelectorAll(".hk-scrollbar-marker").length).toBe(0);
560
644
  m.viewport.scrollTop = 0;
561
645
  document.dispatchEvent(new MouseEvent("mousemove", { clientY: 100 }));
562
646
  expect(m.viewport.scrollTop, "detach dropped the drag listener").toBe(0);
@@ -568,6 +652,6 @@ describe("change-mark rail", () => {
568
652
  m.handle.detach();
569
653
  handles.splice(handles.indexOf(m.handle), 1);
570
654
  expect(() => m.handle.setMarkers([{ start: 0, end: 1, kind: "added" }])).not.toThrow();
571
- expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
655
+ expect(m.wrapper.querySelectorAll(".hk-scrollbar-marker").length).toBe(0);
572
656
  });
573
657
  });
@@ -51,6 +51,13 @@ export interface OverlayScrollbarMarker {
51
51
  /** Semantic tag mirrored onto `data-kind`, so the consumer's theme owns
52
52
  * the palette (this engine ships no colours of its own). */
53
53
  kind?: string;
54
+ /**
55
+ * Which third of the band the mark hugs — VS Code's diff overview ruler
56
+ * draws REMOVED content against the band's start edge and ADDED content
57
+ * against its end edge, so one vertical position can carry both. Omitted
58
+ * = the mark spans the full band width.
59
+ */
60
+ side?: "start" | "end";
54
61
  }
55
62
 
56
63
  /** Shortest a mark may render, as a fraction of the rail. A one-line edit
@@ -66,6 +73,11 @@ const MARKER_MIN_FRACTION = 0.005;
66
73
  * from the SAME number, which CSS cannot express. */
67
74
  const MARKER_MIN_PX = 2;
68
75
 
76
+ /** Width the vertical track widens to when marks arrive, unless the
77
+ * consumer passes its own. VS Code's scrollbar strip is ~14px; the
78
+ * default keeps the marks' two lanes readable at a glance. */
79
+ const DEFAULT_MARKERS_WIDTH = 14;
80
+
69
81
  /** Clamp to [0, 1]; non-finite input is the caller's bug, but the rail
70
82
  * must never render a mark at a meaningless position, so it becomes 0. */
71
83
  function clamp01(n: number): number {
@@ -98,6 +110,10 @@ export interface OverlayScrollbarOptions {
98
110
  * "position in the document". Omitted or empty creates no rail at all,
99
111
  * so consumers that never pass markers keep the exact chrome they had. */
100
112
  markers?: OverlayScrollbarMarker[];
113
+ /** Width the vertical track widens to while marks are showing, in CSS
114
+ * pixels. Selective: consumers without marks keep the narrow track.
115
+ * Clamped to a sane range (the thumb and marks share the band). */
116
+ markersWidth?: number;
101
117
  }
102
118
 
103
119
  export interface OverlayScrollbarHandle {
@@ -216,22 +232,27 @@ export function attachOverlayScrollbars(
216
232
  let detached = false;
217
233
 
218
234
  // ── 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;
235
+ // The marks live INSIDE the vertical track, which widens to
236
+ // `markersWidth` for as long as marks are showing (VS Code's overview
237
+ // ruler is a widening of the same strip, not a second one). Consumers
238
+ // that never pass markers keep byte-identical chrome.
239
+ let track: HTMLElement | null = null;
223
240
  let markers: OverlayScrollbarMarker[] = [];
224
241
  let railDragging = false;
225
242
  /** Rail height the marks were last sized against — the pixel floor is a
226
243
  * function of it, so a resize has to re-render them. */
227
244
  let renderedRailHeight = 0;
228
245
 
246
+ /** The vertical track, found once and reused. */
247
+ const verticalTrack = (): HTMLElement | null =>
248
+ states.find((s) => !s.horizontal)?.track ?? null;
249
+
229
250
  /** Scroll so the rail position under `clientY` lands in the viewport —
230
251
  * the same fraction → scrollTop mapping the track's own click uses, so
231
252
  * a mark and the track at the same height agree. */
232
253
  function railScrollTo(clientY: number) {
233
- if (!rail) return;
234
- const rect = rail.getBoundingClientRect();
254
+ if (!track) return;
255
+ const rect = track.getBoundingClientRect();
235
256
  if (rect.height <= 0) return;
236
257
  const ratio = clamp01((clientY - rect.top) / rect.height);
237
258
  const { scrollSize, clientSize } = axisMetrics(viewport, false);
@@ -243,28 +264,34 @@ export function attachOverlayScrollbars(
243
264
  }
244
265
  function onRailUp() {
245
266
  railDragging = false;
246
- rail?.classList.remove("is-dragging");
267
+ track?.classList.remove("is-dragging");
247
268
  document.removeEventListener("mousemove", onRailMove);
248
269
  document.removeEventListener("mouseup", onRailUp);
249
270
  }
250
271
  function onRailDown(e: MouseEvent) {
272
+ // Only a press ON a mark scrubs: the rest of the band keeps the
273
+ // track's own click-to-page behaviour.
274
+ const mark = (e.target as Element | null)?.closest?.(".hk-scrollbar-marker");
275
+ if (!mark) return;
251
276
  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.
277
+ // The band rides inside modals/panels that may dismiss on an outside
278
+ // press — scrubbing a mark must not double as that press.
254
279
  e.stopPropagation();
255
280
  railDragging = true;
256
- rail?.classList.add("is-dragging");
281
+ track?.classList.add("is-dragging");
257
282
  railScrollTo(e.clientY);
258
283
  document.addEventListener("mousemove", onRailMove);
259
284
  document.addEventListener("mouseup", onRailUp);
260
285
  }
261
286
 
262
287
  function renderMarkers() {
263
- if (!rail) return;
264
- rail.replaceChildren();
288
+ if (!track) return;
289
+ // The thumb is a sibling that must stay on top of the marks (it slides
290
+ // over them, VS Code's semi-transparent thumb over the ruler).
291
+ track.querySelectorAll(":scope > .hk-scrollbar-marker").forEach((m) => m.remove());
265
292
  // Measured, not assumed: happy-dom and a `display:none` rail both
266
293
  // report 0, and the fraction floor is the honest answer there.
267
- const railH = rail.clientHeight;
294
+ const railH = track.clientHeight;
268
295
  renderedRailHeight = railH;
269
296
  const floor = railH > 0
270
297
  ? Math.max(MARKER_MIN_FRACTION, MARKER_MIN_PX / railH)
@@ -283,12 +310,28 @@ export function attachOverlayScrollbars(
283
310
  const el = document.createElement("div");
284
311
  el.className = "hk-scrollbar-marker";
285
312
  if (m.kind) el.setAttribute("data-kind", m.kind);
313
+ if (m.side) el.setAttribute("data-side", m.side);
286
314
  el.style.top = pct(top);
287
315
  el.style.height = pct(size);
288
- rail.appendChild(el);
316
+ track.appendChild(el);
289
317
  }
290
318
  }
291
319
 
320
+ /** Widen the track to the markers width (and back when they go away). */
321
+ function applyBandWidth() {
322
+ if (!track) return;
323
+ if (markers.length === 0) {
324
+ track.classList.remove("has-markers");
325
+ track.style.width = "";
326
+ return;
327
+ }
328
+ track.classList.add("has-markers");
329
+ const w = Math.round(
330
+ Math.min(Math.max(opts.markersWidth ?? DEFAULT_MARKERS_WIDTH, 8), 40),
331
+ );
332
+ track.style.width = `${w}px`;
333
+ }
334
+
292
335
  // An arrow const, not a function declaration: the guard above narrows
293
336
  // `parent` to non-null, and only a closure created AFTER that narrowing
294
337
  // keeps it (a hoisted declaration may run at any time, so TS widens the
@@ -296,40 +339,35 @@ export function attachOverlayScrollbars(
296
339
  const setMarkers = (next: OverlayScrollbarMarker[]) => {
297
340
  if (detached) return;
298
341
  markers = Array.isArray(next) ? next : [];
342
+ track = verticalTrack();
299
343
  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
- }
344
+ // No marks = exactly the pre-marker chrome.
345
+ track?.querySelectorAll(":scope > .hk-scrollbar-marker").forEach((m) => m.remove());
346
+ applyBandWidth();
306
347
  onRailUp();
307
348
  return;
308
349
  }
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);
350
+ if (!wantV || !track) return;
351
+ if (!track.dataset.markersWired) {
352
+ track.dataset.markersWired = "true";
353
+ track.addEventListener("mousedown", onRailDown);
315
354
  }
355
+ applyBandWidth();
316
356
  renderMarkers();
317
- // The rail's visibility follows the track's fit test.
357
+ // The marks' visibility follows the track's own fit test.
318
358
  update();
319
359
  };
320
360
 
321
361
  function updateAxis(s: AxisState, horizontal: boolean) {
322
362
  const { scrollSize, clientSize, scrollPos } = axisMetrics(viewport, horizontal);
323
363
  if (scrollSize <= clientSize) {
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.
364
+ // Content fits — no bar at all. The marks live inside the track, so
365
+ // hiding it hides them too: marks on a region that cannot scroll
366
+ // would claim changes the reader can never reach.
327
367
  s.track.style.display = "none";
328
- if (!horizontal && rail) rail.style.display = "none";
329
368
  return;
330
369
  }
331
370
  s.track.style.display = "";
332
- if (!horizontal && rail) rail.style.display = "";
333
371
  const trackSize = horizontal ? s.track.clientWidth : s.track.clientHeight;
334
372
  const ratio = clientSize / scrollSize;
335
373
  const thumbSize = Math.max(ratio * trackSize, 20);
@@ -354,13 +392,14 @@ export function attachOverlayScrollbars(
354
392
  function update() {
355
393
  if (detached) return;
356
394
  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
395
+ // The marks' pixel floor is a fraction OF the band, so a height change
396
+ // invalidates the sizes they were rendered with. A hidden band reports
359
397
  // 0 and is skipped here; `renderMarkers` leaves that 0 behind as its
360
398
  // "never sized against a real height" sentinel, so the first visible
361
399
  // 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) {
400
+ const band = verticalTrack();
401
+ const railH = band?.clientHeight ?? 0;
402
+ if (band && markers.length > 0 && railH > 0 && railH !== renderedRailHeight) {
364
403
  renderMarkers();
365
404
  }
366
405
  }
@@ -480,17 +519,14 @@ export function attachOverlayScrollbars(
480
519
  ro = null;
481
520
  scheduled?.disconnect();
482
521
  scheduled = null;
483
- if (rail) {
484
- rail.removeEventListener("mousedown", onRailDown);
485
- rail.remove();
486
- rail = null;
487
- }
488
522
  onRailUp();
489
523
  for (const { state, handlers } of axisBindings) {
490
524
  state.thumb.removeEventListener("mousedown", handlers.onDown);
491
525
  state.track.removeEventListener("click", handlers.onTrackClick);
492
526
  state.track.removeEventListener("mouseenter", handlers.onEnter);
493
527
  state.track.removeEventListener("mouseleave", handlers.onLeave);
528
+ state.track.removeEventListener("mousedown", onRailDown);
529
+ delete state.track.dataset.markersWired;
494
530
  document.removeEventListener("mousemove", handlers.onMove);
495
531
  document.removeEventListener("mouseup", handlers.onUp);
496
532
  state.hideTimer?.disconnect();
@@ -51,6 +51,31 @@
51
51
  opacity: 1;
52
52
  pointer-events: auto;
53
53
  }
54
+
55
+ /* ── The widened band (marks showing) ───────────────────────────────
56
+ VS Code's overview ruler is the scrollbar's own strip WIDENED, not a
57
+ second strip beside it. Selective: only a track carrying marks gets
58
+ the width, the faint ground and the rounding.
59
+ The ground is a translucent mid-grey — over a light page it reads as
60
+ a faint grey, over a dark one as slightly lighter than the page, so
61
+ one declaration serves both modes (the muted token does not flip). */
62
+ &.has-markers:not([data-axis="horizontal"]) {
63
+ width: 14px; /* the engine overrides this inline with markersWidth */
64
+ border-radius: 2px;
65
+ background: color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 12%, transparent);
66
+ /* The band and its marks are ALWAYS visible — the whole point of an
67
+ overview ruler is a glance, not a hover. The track itself stays
68
+ `pointer-events: none` (below), so the ground still never steals a
69
+ press; only the marks accept input. */
70
+ opacity: 1;
71
+ }
72
+ }
73
+
74
+ /* The thumb rides OVER the marks (VS Code's translucent thumb over the
75
+ ruler): without a z-index of its own it would paint UNDER the opaque
76
+ marks, which are appended after it in the same stacking context. */
77
+ .hk-scrollbar-thumb {
78
+ z-index: 2;
54
79
  }
55
80
 
56
81
  .hk-scrollbar-thumb {
@@ -84,65 +109,48 @@
84
109
  min-height: 20px;
85
110
  }
86
111
 
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.
112
+ /* ── Change marks (VS Code's overview ruler) ──────────────────────────
113
+ The marks live INSIDE the widened track (`.hk-scrollbar-track.has-markers`
114
+ above), so the scrollbar itself is the ruler — one band, not two. The
115
+ thumb slides OVER them (its z-index is higher and it is translucent, as
116
+ in VS Code). Only a track carrying marks changes: every other consumer's
117
+ chrome above is unchanged.
126
118
 
127
119
  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
120
+ both fractions and pixels and sizes the end-of-band inset against that
129
121
  same number. A `min-height` here is therefore redundant — the engine
130
- already guarantees 2px — and, because the rail clips, would win over
122
+ already guarantees 2px — and, because the track clips, would win over
131
123
  the inset on the occasions it did bite. */
132
124
  .hk-scrollbar-marker {
133
125
  position: absolute;
126
+ z-index: 1;
134
127
  left: 0;
135
128
  width: 100%;
136
- border-radius: 1px;
129
+ border-radius: 2px;
137
130
  background: var(--hi-color-muted, rgb(118 122 128));
138
- /* The rail is pointer-transparent; the marks are the hit area. */
131
+ /* The track is pointer-transparent at rest; the marks are the hit area. */
139
132
  pointer-events: auto;
140
133
  cursor: pointer;
141
134
 
135
+ /* VS Code's two lanes: REMOVED content hugs the band's start edge,
136
+ ADDED content its end edge, so one vertical position can carry both.
137
+ No `side` = the mark spans the full band. */
138
+ &[data-side="start"] {
139
+ left: 0;
140
+ right: auto;
141
+ width: 34%;
142
+ }
143
+
144
+ &[data-side="end"] {
145
+ left: auto;
146
+ right: 0;
147
+ width: 34%;
148
+ }
149
+
142
150
  &: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. */
151
+ /* Negative offset: an outset ring would draw past the mark's own box
152
+ and read as a stray rule floating beside it — drawn inside, the cue
153
+ stays attached to the mark it belongs to. */
146
154
  outline: 1px solid color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 60%, transparent);
147
155
  outline-offset: -1px;
148
156
  }