@celestia-island/hikari 0.55.156 → 0.55.158

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.156",
3
+ "version": "0.55.158",
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",
@@ -108,7 +108,6 @@ registerCssAnimation("hk-picker-pane-back-out");
108
108
  // once; keyframes ride the component's inline <style>).
109
109
  registerCssAnimation("hk-flip-out");
110
110
  registerCssAnimation("hk-flip-in");
111
- // components/HkVoiceInputPopup.scss — voice waveform bounce (loops
112
111
  // forever while listening; upstreamed from chest's plana-legacy).
113
112
  // components/HkPlaceholderMarquee.scss — overflowing placeholder sweep
114
113
  // (loops forever; pure CSS, loop geometry on inline custom properties).
@@ -137,7 +137,7 @@
137
137
  }
138
138
 
139
139
  /* Rendered GFM links are real anchors — keyboard-focusable, so they need
140
- the house :focus-visible ring (HkFab/HkContextRing pattern). Ring uses
140
+ the house :focus-visible ring (HkFab house pattern). Ring uses
141
141
  the same contract var as the link ink so it matches in every theme. */
142
142
  .hk-markdown a:focus-visible {
143
143
  outline: 2px solid var(--hi-color-primary, #7aa2f7);
@@ -205,7 +205,7 @@
205
205
  * long-press selected it; every other window in the app carries an ✕).
206
206
  * The title aligns to the CONTENT edge: the leading inset defaults to
207
207
  * --space-12, matching the padded content blocks popover sheets host
208
- * (e.g. HkContextRing's 0.75rem body). Hosts tune it via
208
+ * (e.g. the house 0.75rem popover body). Hosts tune it via
209
209
  * --hk-sheet-title-inset; the trailing inset hugs the close button. */
210
210
  .hk-popover-sheet-header {
211
211
  flex-shrink: 0;
@@ -135,7 +135,7 @@
135
135
  }
136
136
 
137
137
  // Width is a size animation — progress steps snap instead of animating
138
- // (house guard: HkTokenUsagePanel's bar fill).
138
+ // (house guard: the family bar-fill pattern).
139
139
  .hk-progress-bar-fill,
140
140
  .hk-progress-bar-secondary,
141
141
  .hk-progress-bar-seg {
@@ -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
@@ -104,7 +104,7 @@ export const HkStatCard = defineComponent({
104
104
  tabindex: props.clickable ? 0 : undefined,
105
105
  onClick: props.clickable ? (e: MouseEvent) => emit("click", e) : undefined,
106
106
  // role="button" promises keyboard activation (Enter/Space) — the
107
- // same contract HkContextRing's clickable ring honors. Key events
107
+ // same contract the house clickable-ring convention honors. Key events
108
108
  // bubbling from a focusable INSIDE the card (an aside button) are
109
109
  // left alone so they don't double-fire the card's click.
110
110
  onKeydown: props.clickable
@@ -51,7 +51,7 @@
51
51
  opacity: 1;
52
52
  }
53
53
 
54
- /* Keyboard focus indicator (house HkContextRing pattern). */
54
+ /* Keyboard focus indicator (house focus-ring pattern). */
55
55
  .hk-tag-close:focus-visible {
56
56
  outline: 2px solid rgb(var(--color-primary));
57
57
  outline-offset: 2px;
@@ -416,7 +416,7 @@
416
416
  cursor: pointer;
417
417
 
418
418
  // Keyboard parity: the tsx gives completed steps role="button" +
419
- // tabindex 0 (HkContextRing outer-ring pattern).
419
+ // tabindex 0 (house outer-ring pattern).
420
420
  &:focus-visible {
421
421
  outline: 2px solid var(--hi-color-primary);
422
422
  outline-offset: 2px;
@@ -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
  });
@@ -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();
package/src/index.ts CHANGED
@@ -549,15 +549,12 @@ export {
549
549
  export { HK_ENGINE_HEALTH } from "./components/HkConnectionStatus";
550
550
  export type { ProbeResult, EngineHealth, EngineNetworkInfo } from "./composables/engineHealthTypes";
551
551
 
552
- // ── Chat kit (upstreamed from shittim-chest's plana-legacy layer) ──────────
552
+ // ── JSON viewer & shared formatters ──
553
553
  export {
554
554
  HkJsonTree as HJsonTree,
555
555
  buildJsonTree,
556
556
  type HJsonNode,
557
557
  } from "./components/HkJsonTree";
558
- // Canonical Hk* names for the directly-exported types above (2026-09-24
559
- // unification follow-up, second batch — caught by the third-scan reverse
560
- // validation). These types are defined with their H* names at the source.
561
558
 
562
559
  export {
563
560
  formatNumber,
@@ -7,7 +7,7 @@
7
7
 
8
8
  /* ── Syntax highlighting palette family: --code-* ───────────────────
9
9
  * Token names consumed by hikari's highlighted-code surfaces (the
10
- * HkToolBlock syntax layer and its JSON viewer, see HkToolBlock.scss).
10
+ * The JSON-viewer syntax layer and its theme channels (HkJsonTree).
11
11
  * The VALUES are intentionally NOT defined here: the family is owned by
12
12
  * the host theme, which maps each token to mode-aware colors. The
13
13
  * canonical mapping (dark = One Dark, light = GitHub Light) lives in the
@@ -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
  }
package/src/tokens.scss CHANGED
@@ -1,6 +1,6 @@
1
1
  /* ── Syntax highlighting palette family: --code-* ───────────────────
2
2
  * Token names consumed by hikari's highlighted-code surfaces (the
3
- * HkToolBlock syntax layer and its JSON viewer, see HkToolBlock.scss).
3
+ * The JSON-viewer syntax layer and its theme channels (HkJsonTree).
4
4
  * The VALUES are intentionally NOT defined here: the family is owned by
5
5
  * the host theme, which maps each token to mode-aware colors. The
6
6
  * canonical mapping (dark = One Dark, light = GitHub Light) lives in the
@@ -14,7 +14,6 @@
14
14
  * under a zh-Hans app on an en browser). */
15
15
  import { activeLocale } from "../i18n/context";
16
16
 
17
- /** "1234" -> "1.2k", "2500000" -> "2.5M". */
18
17
 
19
18
  /** "1234" -> "1.2k". */
20
19
  export function formatNumber(n: number): string {
@@ -31,10 +30,7 @@ export function formatBytes(bytes: number): string {
31
30
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
32
31
  }
33
32
 
34
- /** USD amount -> "$0.10" / "$1.5" / "$120". Negative/NaN clamp to 0. */
35
33
 
36
- /** Timestamp -> "Just now" / "5m ago" / "3h ago" / "2d ago" / "2w ago" /
37
- * locale date. */
38
34
  export type RelativeTimeT = (
39
35
  key: string,
40
36
  fallback: string,
@@ -11,7 +11,7 @@ export type ChallengeDescriptor =
11
11
  * undefined/missed config value would otherwise fetch `undefined/health`
12
12
  * — a relative URL resolving to `<origin>/undefined/health` — and fail
13
13
  * with a 404 that looks like a backend problem. Same pattern as
14
- * useEngineHealth's baseUrl guard.
14
+ * the consuming app's health-probe guard.
15
15
  */
16
16
  function normalizeBase(baseUrl: string): string {
17
17
  return (baseUrl ?? "").replace(/\/+$/, "");