@celestia-island/hikari 0.49.7 → 0.50.0

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.49.7",
3
+ "version": "0.50.0",
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",
@@ -41,13 +41,6 @@ function panel(): HTMLElement | null {
41
41
  return document.querySelector<HTMLElement>(".hk-dp-panel");
42
42
  }
43
43
 
44
- /** Let Vue's leave transitions (frame/timeout based) finish in happy-dom. */
45
- async function settle() {
46
- await nextTick();
47
- await new Promise((r) => setTimeout(r, 20));
48
- await nextTick();
49
- }
50
-
51
44
  /** Poll until the drilled view's title button reads `expected`. The drill
52
45
  * transition is frame/timeout based and a fixed 20 ms settle raced it on
53
46
  * the CI runner (the year-grid test once read the previous view's title),
@@ -67,10 +60,23 @@ async function waitForTitle(expected: string): Promise<void> {
67
60
  }
68
61
  }
69
62
 
70
- /** Generic state poll over the same drill race: wait until `probe` holds. */
63
+ /** Generic state poll over the same drill race: wait until `probe` holds
64
+ * STABLY. A single true evaluation can be a mid-transition transient
65
+ * (the leaving pane's cells vanish one tick before its container
66
+ * unmounts), so the probe must hold across a 10 ms window before the
67
+ * wait resolves — otherwise the raw asserts after it race the teardown
68
+ * timers (observed on the hosted runner: pickCount read 24 right after
69
+ * a "settled" poll). */
71
70
  async function waitForView(desc: string, probe: () => boolean): Promise<void> {
72
71
  const deadline = Date.now() + 2000;
73
- while (!probe()) {
72
+ let holdSince: number | null = null;
73
+ for (;;) {
74
+ if (probe()) {
75
+ holdSince ??= Date.now();
76
+ if (Date.now() - holdSince >= 10) return;
77
+ } else {
78
+ holdSince = null;
79
+ }
74
80
  if (Date.now() > deadline) throw new Error(`view never reached: ${desc}`);
75
81
  await new Promise((r) => setTimeout(r, 10));
76
82
  }
@@ -163,8 +169,7 @@ describe("HkDatePicker", () => {
163
169
  expect(panel()?.querySelectorAll(".hk-dp-cell").length).toBe(42);
164
170
 
165
171
  panel()?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
166
- await settle();
167
- expect(panel()).toBeNull();
172
+ await waitForView("the popup to close on Escape", () => panel() === null);
168
173
  });
169
174
 
170
175
  it("derives weekday header labels from Intl for the locale", async () => {
@@ -185,8 +190,7 @@ describe("HkDatePicker", () => {
185
190
  clickDay(20);
186
191
  await nextTick();
187
192
  expect(p.emitted).toEqual(["2026-08-20"]);
188
- await settle();
189
- expect(panel()).toBeNull();
193
+ await waitForView("the popup to close after picking a day", () => panel() === null);
190
194
  });
191
195
 
192
196
  it("disables days outside the inclusive min/max bounds", async () => {
@@ -244,8 +248,7 @@ describe("HkDatePicker", () => {
244
248
  await nextTick();
245
249
  expect(panel()).not.toBeNull();
246
250
  trigger?.click();
247
- await settle();
248
- expect(panel()).toBeNull();
251
+ await waitForView("the popup to close on trigger toggle", () => panel() === null);
249
252
 
250
253
  const d = mountPicker({ modelValue: "2026-08-16", disabled: true });
251
254
  const disabledTrigger = d.container.querySelector<HTMLElement>(".hk-dp-trigger");
@@ -345,7 +348,10 @@ describe("HkDatePicker", () => {
345
348
  await nextTick();
346
349
  const stage = panel()?.querySelector<HTMLElement>(".hk-dp-stage");
347
350
  panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
348
- await settle();
351
+ // Wait out the drill transition before reading the grid: a raced
352
+ // read still sees the leaving days pane (54 cells).
353
+ await waitForView("the months grid settled to one pane", () =>
354
+ panel()?.querySelectorAll(".hk-dp-cell").length === 12);
349
355
  expect(stage?.getAttribute("data-dir")).toBe("fwd");
350
356
  const picks = Array.from(panel()?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell[data-variant='pick']") ?? []);
351
357
  expect(picks.length).toBe(12);
@@ -354,9 +360,9 @@ describe("HkDatePicker", () => {
354
360
  new Intl.DateTimeFormat("en", { month: "short" }).format(new Date(2024, i, 15))),
355
361
  );
356
362
  panel()?.querySelector<HTMLButtonElement>(".hk-dp-back")?.click();
357
- await settle();
363
+ await waitForView("the days grid settled to one pane", () =>
364
+ panel()?.querySelectorAll(".hk-dp-cell").length === 42);
358
365
  expect(stage?.getAttribute("data-dir")).toBe("back");
359
- expect(panel()?.querySelectorAll(".hk-dp-cell").length).toBe(42);
360
366
  });
361
367
 
362
368
  it("picks a year from the year grid and lands back on days with that year", async () => {
@@ -378,7 +384,12 @@ describe("HkDatePicker", () => {
378
384
  pickCells().find((c) => c.textContent === "2027")?.click();
379
385
  // Picking a year lands on the months grid of that year.
380
386
  await waitForTitle("2027");
381
- pickCells()[6]?.click(); // July
387
+ // Click July BY LABEL: a positional click would hit whatever the
388
+ // grid shows if the view drifted (hosted CI once clicked the 2022
389
+ // year cell here and the title read "August 2022").
390
+ await waitForView("the July cell in the months grid", () =>
391
+ pickCells().some((c) => c.textContent === "Jul"));
392
+ pickCells().find((c) => c.textContent === "Jul")?.click();
382
393
  // ...and picking a month lands back on the days grid.
383
394
  await waitForView("the days grid", () => panel()?.querySelectorAll(".hk-dp-cell").length === 42);
384
395
  const fmt = new Intl.DateTimeFormat("en", { year: "numeric", month: "long" });
@@ -392,7 +403,6 @@ describe("HkDatePicker", () => {
392
403
  openViaEnter(p);
393
404
  await nextTick();
394
405
  panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
395
- await settle();
396
406
  await waitForTitle("2026");
397
407
  const navs = () => panel()?.querySelectorAll<HTMLButtonElement>(".hk-dp-nav");
398
408
  navs()?.[1].click();
@@ -423,25 +433,21 @@ describe("HkDatePicker", () => {
423
433
  stage?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell:not([data-variant])").length === 42
424
434
  : pickCount() === 12 && stage?.children.length === 1;
425
435
  panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
426
- await settle();
427
436
  await waitForView("the months grid settled to one pane", settledDrill(false));
428
437
  expect(panel()?.querySelector<HTMLElement>(".hk-dp-stage")).toBe(stage);
429
438
  expect(stage?.getAttribute("data-dir")).toBe("fwd");
430
439
  expect(stage?.children.length).toBe(1); // one pane at a time after settle
431
440
  expect(pickCount()).toBe(12);
432
441
  panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
433
- await settle();
434
442
  await waitForView("the years grid settled to one pane", settledDrill(false));
435
443
  expect(stage?.getAttribute("data-dir")).toBe("fwd");
436
444
  expect(pickCount()).toBe(12);
437
445
  // back steps down the stack one level at a time: years → months → days.
438
446
  panel()?.querySelector<HTMLButtonElement>(".hk-dp-back")?.click();
439
- await settle();
440
447
  await waitForView("the months grid again, one pane", settledDrill(false));
441
448
  expect(stage?.getAttribute("data-dir")).toBe("back");
442
449
  expect(pickCount()).toBe(12);
443
450
  panel()?.querySelector<HTMLButtonElement>(".hk-dp-back")?.click();
444
- await settle();
445
451
  await waitForView("the days grid settled to one pane", settledDrill(true));
446
452
  expect(stage?.getAttribute("data-dir")).toBe("back");
447
453
  expect(stage?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell:not([data-variant])").length).toBe(42);
@@ -60,13 +60,6 @@ function mountPicker(props: Record<string, unknown> = {}): PickerHarness {
60
60
  return { container, emitted };
61
61
  }
62
62
 
63
- /** Let Vue's leave transitions (frame/timeout based) finish in happy-dom. */
64
- async function settle() {
65
- await nextTick();
66
- await new Promise((r) => setTimeout(r, 20));
67
- await nextTick();
68
- }
69
-
70
63
  /** Poll until the drilled view's title button reads `expected`. The drill
71
64
  * transition is frame/timeout based and a fixed 20 ms settle raced it
72
65
  * under full-suite load (the assertion once read the days-view title),
@@ -88,10 +81,23 @@ function pickCells(): HTMLButtonElement[] {
88
81
  return Array.from(picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-cell[data-variant='pick']") ?? []);
89
82
  }
90
83
 
91
- /** Generic state poll over the same drill race: wait until `probe` holds. */
84
+ /** Generic state poll over the same drill race: wait until `probe` holds
85
+ * STABLY. A single true evaluation can be a mid-transition transient
86
+ * (the leaving pane's cells vanish one tick before its container
87
+ * unmounts), so the probe must hold across a 10 ms window before the
88
+ * wait resolves — otherwise the raw asserts after it race the teardown
89
+ * timers (observed on the hosted runner: pickCount read 24 right after
90
+ * a "settled" poll). */
92
91
  async function waitForView(desc: string, probe: () => boolean): Promise<void> {
93
92
  const deadline = Date.now() + 2000;
94
- while (!probe()) {
93
+ let holdSince: number | null = null;
94
+ for (;;) {
95
+ if (probe()) {
96
+ holdSince ??= Date.now();
97
+ if (Date.now() - holdSince >= 10) return;
98
+ } else {
99
+ holdSince = null;
100
+ }
95
101
  if (Date.now() > deadline) throw new Error(`view never reached: ${desc}`);
96
102
  await new Promise((r) => setTimeout(r, 10));
97
103
  }
@@ -191,7 +197,6 @@ describe("HkDateTimePicker", () => {
191
197
  mountPicker();
192
198
  const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
193
199
  monthBtn?.click();
194
- await settle();
195
200
  // Destination state = the months pane mounted AND the leaving days
196
201
  // pane gone — mid-transition both conditions half-hold.
197
202
  await waitForView("the months grid settled to one pane", () =>
@@ -213,7 +218,6 @@ describe("HkDateTimePicker", () => {
213
218
  expect(stage?.children.length).toBe(1); // the single days pane
214
219
  const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
215
220
  monthBtn?.click();
216
- await settle();
217
221
  // Destination state = months pane mounted AND the leaving days pane
218
222
  // gone — mid-transition the cell counts already read final while the
219
223
  // stage still carries both panes.
@@ -223,13 +227,11 @@ describe("HkDateTimePicker", () => {
223
227
  stage?.children.length === 1);
224
228
  expect(stage?.getAttribute("data-dir")).toBe("fwd");
225
229
  expect(picker()?.querySelector<HTMLElement>(".hk-dtp-stage")).toBe(stage);
226
- expect(stage?.children.length).toBe(1); // one pane at a time after settle
227
230
  // The time row lives outside the transitioned pane and stays in every
228
231
  // view, so the picker's footprint never changes.
229
232
  expect(picker()?.querySelectorAll(".hk-dtp-time").length).toBe(1);
230
233
  expect(picker()?.querySelectorAll(".hk-dtp-step").length).toBe(2);
231
234
  picker()?.querySelector<HTMLButtonElement>(".hk-dtp-back")?.click();
232
- await settle();
233
235
  await waitForView("the days grid settled to one pane", () =>
234
236
  dayCells().length === 42 && stage?.children.length === 1);
235
237
  expect(stage?.getAttribute("data-dir")).toBe("back");
@@ -239,7 +241,6 @@ describe("HkDateTimePicker", () => {
239
241
  mountPicker();
240
242
  const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
241
243
  monthBtn?.click();
242
- await settle();
243
244
  await waitForTitle(String(BASE.getFullYear()));
244
245
  const navs = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-nav");
245
246
  navs?.[1].click();
@@ -254,7 +255,6 @@ describe("HkDateTimePicker", () => {
254
255
  mountPicker();
255
256
  const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
256
257
  monthBtn?.click();
257
- await settle();
258
258
  // Destination state = months pane mounted AND the leaving days pane
259
259
  // gone — mid-transition the cell counts already read final while the
260
260
  // stage still carries both panes.
@@ -385,10 +385,14 @@
385
385
  text-align: start;
386
386
  }
387
387
 
388
- .hk-file-browser-list-head {
389
- position: sticky;
390
- top: 0;
391
- z-index: 1;
388
+ // The head is a scroll pin (class + data-side/data-strategy on the
389
+ // element): positioning/z ride the shared .hk-scroll-pin rules, and the
390
+ // doubled class keeps this explicit surface above the pin's default tint
391
+ // regardless of stylesheet import order. Pinned under a gutter-cover
392
+ // host (HkModal) it stops below the body's top whitespace instead of
393
+ // jamming against the window chrome; the cover masks rows while they
394
+ // pass under the gutter.
395
+ .hk-file-browser-list-head.hk-scroll-pin {
392
396
  font-size: var(--text-xs);
393
397
  font-weight: 600;
394
398
  color: rgb(var(--color-muted));
@@ -30,6 +30,9 @@ import {
30
30
  } from "./filePicker";
31
31
  import HButton from "./HkButton";
32
32
  import HModal from "./HkModal";
33
+ // The list head rides the scroll-pin contract; its stylesheet ships the
34
+ // pin rules.
35
+ import "./HkScrollPin.scss";
33
36
  import HSelect, { type HkSelectOption } from "./HkSelect";
34
37
  import HSpinner from "./HkSpinner";
35
38
  import "./HkFileBrowserDialog.scss";
@@ -657,7 +660,16 @@ export default defineComponent({
657
660
  function renderList(rows: RemoteFileEntry[]) {
658
661
  return (
659
662
  <div class="hk-file-browser-list">
660
- <div class="hk-file-browser-list-head" aria-hidden="true">
663
+ {/* Scroll pin (static "offset"): rides the scroll host's gutter
664
+ contract so the pinned head keeps the body's top whitespace.
665
+ Under a host without the contract it degrades to a plain
666
+ flush sticky (the vars resolve to 0). */}
667
+ <div
668
+ class="hk-file-browser-list-head hk-scroll-pin"
669
+ data-side="top"
670
+ data-strategy="offset"
671
+ aria-hidden="true"
672
+ >
661
673
  <span class="hk-file-browser-col-name">
662
674
  {t("hikari::filePicker.name", "Name")}
663
675
  </span>
@@ -29,6 +29,14 @@
29
29
  max-height: none;
30
30
  display: flex;
31
31
  flex-direction: column;
32
+
33
+ // Immersive override: the body is un-padded here, so the pin contract's
34
+ // gutters (and the covers with them) collapse to zero too. Declared on
35
+ // the body so its own covers read them (inheritance runs downward).
36
+ --hk-scroll-pad-top: 0px;
37
+ --hk-scroll-pad-right: 0px;
38
+ --hk-scroll-pad-bottom: 0px;
39
+ --hk-scroll-pad-left: 0px;
32
40
  }
33
41
 
34
42
  .hk-modal-body-scroll {
@@ -35,6 +35,13 @@ describe("HkModal body rhythm contract", () => {
35
35
  });
36
36
 
37
37
  it("keeps the body padding hook unchanged", () => {
38
- expect(inner).toContain("padding: var(--hk-modal-padding-body");
38
+ // 2026-09-14 scroll-pin contract: the padding consumes the declared
39
+ // --hk-scroll-pad-* vars (single source with the gutter covers and
40
+ // the pins); the vars themselves derive from --hk-modal-padding-body,
41
+ // so the host-tunable hook keeps working.
42
+ expect(inner).toContain("padding: var(--hk-scroll-pad-top");
43
+ // The old direct source must stay retired — a stale literal here
44
+ // would desync the pins from the padding.
45
+ expect(inner).not.toContain("padding: var(--hk-modal-padding-body");
39
46
  });
40
47
  });
@@ -240,6 +240,51 @@
240
240
  color: var(--hk-modal-body-color, var(--hi-color-text-primary, #1e1e1e));
241
241
  flex: 1;
242
242
  min-height: 0;
243
+
244
+ // Scroll-pin host contract: the body padding physically lives on
245
+ // .hk-modal-body-inner (a child of the scroller), where sticky can
246
+ // never hold it in place — pins read these instead, and the gutter
247
+ // covers below consume the same vars. Declared HERE (the scroller's
248
+ // parent): custom properties inherit downward, so the covers, the
249
+ // scroller and the inner all resolve them. The mobile sheet block
250
+ // retunes the vars only.
251
+ --hk-scroll-pad-top: var(--hk-modal-padding-body, 1.5rem);
252
+ --hk-scroll-pad-right: var(--hk-modal-padding-body, 1.5rem);
253
+ --hk-scroll-pad-bottom: var(--hk-modal-padding-body, 1.5rem);
254
+ --hk-scroll-pad-left: var(--hk-modal-padding-body, 1.5rem);
255
+
256
+ // Gutter covers (scroll-pin contract, 2026-09-14): the body's top and
257
+ // bottom whitespace stays paintable OUTSIDE the scroll flow, so a
258
+ // pinned child (HkScrollPin strategy="offset") stops below the gutter
259
+ // with the whitespace intact, and scrolled content vanishes under the
260
+ // cover instead of sliding through a see-through gap. At rest the
261
+ // covers sit over empty padding — same surface color, invisible.
262
+ &::before,
263
+ &::after {
264
+ content: "";
265
+ position: absolute;
266
+ left: 0;
267
+ right: 0;
268
+ z-index: 3;
269
+ pointer-events: none;
270
+ // Same paint source as the content frame by default; a host that
271
+ // re-themes the body itself overrides --hk-scroll-pin-cover-bg so the
272
+ // covers never surface as foreign bands over the padding.
273
+ background: var(
274
+ --hk-scroll-pin-cover-bg,
275
+ var(--hk-modal-bg, var(--hi-color-surface, rgba(240, 244, 248, 0.95)))
276
+ );
277
+ }
278
+
279
+ &::before {
280
+ top: 0;
281
+ height: var(--hk-scroll-pad-top, 0px);
282
+ }
283
+
284
+ &::after {
285
+ bottom: 0;
286
+ height: var(--hk-scroll-pad-bottom, 0px);
287
+ }
243
288
  }
244
289
 
245
290
  .hk-modal-body-scroll {
@@ -258,7 +303,8 @@
258
303
  }
259
304
 
260
305
  .hk-modal-body-inner {
261
- padding: var(--hk-modal-padding-body, 1.5rem);
306
+ padding: var(--hk-scroll-pad-top, 1.5rem) var(--hk-scroll-pad-right, 1.5rem)
307
+ var(--hk-scroll-pad-bottom, 1.5rem) var(--hk-scroll-pad-left, 1.5rem);
262
308
 
263
309
  // Default vertical rhythm between bare stacked children (user direction
264
310
  // 2026-09-08: window bodies keep a little distance between their elements
@@ -428,8 +474,13 @@
428
474
  padding: 0.625rem 1rem 0.625rem;
429
475
  }
430
476
 
431
- .hk-modal-body-inner {
432
- padding: 1rem;
477
+ // The gutter contract retunes here too — the covers and the inner's
478
+ // padding both consume the vars, so one override moves both.
479
+ .hk-modal-body {
480
+ --hk-scroll-pad-top: 1rem;
481
+ --hk-scroll-pad-right: 1rem;
482
+ --hk-scroll-pad-bottom: 1rem;
483
+ --hk-scroll-pad-left: 1rem;
433
484
  }
434
485
 
435
486
  // Lift the desktop 70vh body cap on phones: with a docked sheet the cap
@@ -52,7 +52,10 @@ describe("HkModal mobile sheet spacing contract", () => {
52
52
  beforeAll(() => {
53
53
  block = src.slice(src.indexOf("@media (max-width: 767px)"));
54
54
  content = block.match(/\.hk-modal-content\s*{[^}]*}/)?.[0] ?? "";
55
- body = block.match(/\.hk-modal-body\s*{[^}]*}/)?.[0] ?? "";
55
+ // 2026-09-14: the sheet block carries more than one .hk-modal-body
56
+ // rule (the scroll-pin gutter-var override + the body-cap lift) —
57
+ // assertions below must see both, so concatenate them all.
58
+ body = (block.match(/\.hk-modal-body\s*{[^}]*}/g) ?? []).join("\n");
56
59
  footer = block.match(/\.hk-modal-footer\s*{[\s\S]*?^ }/m)?.[0] ?? "";
57
60
  });
58
61
 
@@ -804,7 +804,13 @@ export default defineComponent({
804
804
  <div ref={bodyRef} class="hk-modal-body">
805
805
  <div
806
806
  ref={scrollContainerRef}
807
- class="hk-modal-body-scroll"
807
+ class="hk-modal-body-scroll hk-scroll-pin-host"
808
+ data-scroll-axis="vertical"
809
+ // The body gutters (top/bottom whitespace) are painted
810
+ // by .hk-modal-body's covers, so pins stop at the
811
+ // gutter line instead of absorbing the padding (see
812
+ // HkScrollPin's strategy contract).
813
+ data-pad-cover=""
808
814
  onScroll={onBodyScroll}
809
815
  >
810
816
  <div ref={innerRef} class="hk-modal-body-inner">
@@ -15,6 +15,7 @@ import "./HkScrollContainer.scss";
15
15
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
16
16
  import { useApproachEnd, type ApproachEndHandle } from "../composables/useApproachEnd";
17
17
  import { provideScrollWindow } from "../composables/useScrollWindow";
18
+ import { SCROLL_HOST_CLASS } from "./HkScrollPin";
18
19
  import { scheduleFrame, notifyScrollStart, onceFrame, type AnimationHandle } from "../runtime/animationBus";
19
20
  import HFab from "./HkFab";
20
21
 
@@ -437,7 +438,15 @@ export default defineComponent({
437
438
  data-align={alignCenter() ? "center" : undefined}
438
439
  data-fade={props.fade ? "true" : undefined}
439
440
  >
440
- <div ref={viewportRef} class="hk-scroll-container-viewport">
441
+ {/* Scroll-pin host marker: the viewport participates in the pin
442
+ contract with its live axis; generic containers declare no
443
+ standard padding, so pins inside resolve their strategy from
444
+ whatever --hk-scroll-pad-* the consumer put on this viewport. */}
445
+ <div
446
+ ref={viewportRef}
447
+ class={["hk-scroll-container-viewport", SCROLL_HOST_CLASS]}
448
+ data-scroll-axis={props.axis}
449
+ >
441
450
  {content}
442
451
  </div>
443
452
  {showAutoTag.value && (
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Source contract: THE SCROLL-PIN WHITESPACE CONTRACT (2026-09-14 user
3
+ * report — the wizard's step header pinned flush against the modal
4
+ * header because the body padding lives on .hk-modal-body-inner, a
5
+ * wrapper child of the scroller; CSS sticky resolves its offsets against
6
+ * the scrollport and never sees wrapper padding, so the pinned header
7
+ * lost its whitespace the moment it engaged).
8
+ *
9
+ * The contract has two halves that must stay in sync:
10
+ * 1. HOSTS declare `--hk-scroll-pad-*` on the scrolling viewport (class
11
+ * .hk-scroll-pin-host) and, when they paint their gutters themselves,
12
+ * carry `data-pad-cover` — pins then stop at the gutter line.
13
+ * 2. PINS (HkScrollPin / .hk-scroll-pin) absorb or respect the declared
14
+ * padding per strategy; the bleed strategy's negative margin + padding
15
+ * pair is the load-bearing geometry and must never lose a side.
16
+ *
17
+ * Verified live in headless Chromium before pinning here: padding on the
18
+ * scroller itself pins sticky children BELOW the padding (probe 2);
19
+ * padding on a wrapper does not (probe 1) — hence the declared-vars
20
+ * contract instead of "just move the padding".
21
+ */
22
+ import { describe, expect, it } from "vitest";
23
+ import { readFileSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const read = (name: string) => readFileSync(join(here, name), "utf-8");
29
+
30
+ const pinScss = read("HkScrollPin.scss");
31
+ const modalScss = read("HkModal.scss");
32
+ const modalTsx = read("HkModal.tsx");
33
+ const stepflowScss = read("HkStepFlow.scss");
34
+ const stepflowTsx = read("HkStepFlow.tsx");
35
+ const fileBrowserScss = read("HkFileBrowserDialog.scss");
36
+ const fileBrowserTsx = read("HkFileBrowserDialog.tsx");
37
+ const scrollContainerTsx = read("HkScrollContainer.tsx");
38
+
39
+ /** Extract ONE balanced `{...}` declaration block following the given
40
+ * selector — a naive `[^}]*` would stop at the first nested `}` and let
41
+ * properties appended after a future nested block evade the pin. */
42
+ function cssBlock(src: string, selector: string): string {
43
+ const at = src.indexOf(selector);
44
+ expect(at, `${selector} block exists`).toBeGreaterThanOrEqual(0);
45
+ const open = src.indexOf("{", at);
46
+ let depth = 0;
47
+ for (let i = open; i < src.length; i++) {
48
+ if (src[i] === "{") depth++;
49
+ else if (src[i] === "}") {
50
+ depth--;
51
+ if (depth === 0) return src.slice(open, i + 1);
52
+ }
53
+ }
54
+ return "";
55
+ }
56
+
57
+ describe("scroll-pin whitespace contract", () => {
58
+ it("the pin base is sticky with a floating-chrome surface", () => {
59
+ const base = cssBlock(pinScss, ".hk-scroll-pin {");
60
+ expect(base).toMatch(/position: sticky/);
61
+ expect(base).toMatch(/z-index: var\(--hk-scroll-pin-z/);
62
+ expect(base).toMatch(/background: var\(\s*--hk-scroll-pin-bg/);
63
+ });
64
+
65
+ it("every side x strategy pairing exists (12 total)", () => {
66
+ for (const strategy of ["offset", "bleed", "none"]) {
67
+ for (const side of ["top", "bottom", "left", "right"]) {
68
+ const selector = `.hk-scroll-pin[data-strategy="${strategy}"][data-side="${side}"]`;
69
+ expect(pinScss, selector).toContain(selector);
70
+ }
71
+ }
72
+ });
73
+
74
+ it("the bleed strategy pairs padding with the negative margin per side", () => {
75
+ // The load-bearing geometry: absorb the host's declared padding so the
76
+ // whitespace travels with the pin. Losing any half of a pair would
77
+ // shift the pin's content at rest (padding without margin) or overlap
78
+ // siblings (margin without padding).
79
+ for (const side of ["top", "bottom", "left", "right"]) {
80
+ const block = cssBlock(
81
+ pinScss,
82
+ `.hk-scroll-pin[data-strategy="bleed"][data-side="${side}"]`,
83
+ );
84
+ expect(block, `bleed ${side} padding`).toMatch(
85
+ new RegExp(`padding-${side}: var\\(--hk-scroll-pad-${side}`),
86
+ );
87
+ expect(block, `bleed ${side} negative margin`).toMatch(
88
+ new RegExp(
89
+ `margin-${side}: calc\\(-1 \\* var\\(--hk-scroll-pad-${side}`,
90
+ ),
91
+ );
92
+ }
93
+ });
94
+
95
+ it("the offset strategy stops at the declared gutter line per side", () => {
96
+ for (const side of ["top", "bottom", "left", "right"]) {
97
+ const block = cssBlock(
98
+ pinScss,
99
+ `.hk-scroll-pin[data-strategy="offset"][data-side="${side}"]`,
100
+ );
101
+ expect(block).toMatch(new RegExp(`${side}: var\\(--hk-scroll-pad-${side}`));
102
+ }
103
+ });
104
+
105
+ it("HkModal declares the four pad vars on .hk-modal-body (host contract)", () => {
106
+ // Declared on the BODY — the covers are the body's own pseudo rules
107
+ // and custom properties inherit downward only, so the scroller (a
108
+ // child) could never feed them. The FULL literal is pinned (var name
109
+ // AND the 1.5rem fallback): a silently changed default would desync
110
+ // the covers from the padding while every selector still matches.
111
+ const body = cssBlock(modalScss, ".hk-modal-body {");
112
+ for (const side of ["top", "right", "bottom", "left"]) {
113
+ expect(body, `--hk-scroll-pad-${side}`).toContain(
114
+ `--hk-scroll-pad-${side}: var(--hk-modal-padding-body, 1.5rem);`,
115
+ );
116
+ }
117
+ });
118
+
119
+ it("HkModal's body-inner consumes the same vars (one source of truth)", () => {
120
+ const inner = cssBlock(modalScss, ".hk-modal-body-inner {");
121
+ expect(inner).toMatch(/padding: var\(--hk-scroll-pad-top/);
122
+ expect(inner).toMatch(/var\(--hk-scroll-pad-left/);
123
+ // The old direct fallback must be gone — a stale `1.5rem` literal in
124
+ // the shorthand would desync the gutter covers from the padding.
125
+ expect(inner).not.toMatch(/padding: 1\.5rem/);
126
+ expect(inner).not.toMatch(/padding: var\(--hk-modal-padding-body/);
127
+ });
128
+
129
+ it("HkModal paints gutter covers from the same vars", () => {
130
+ expect(modalScss).toMatch(/&::before,\s*\n\s*&::after/);
131
+ // Anchor on the standalone pseudo blocks INSIDE .hk-modal-body — the
132
+ // combined selector ("&::before,\n &::after {") contains "&::after {"
133
+ // as a literal, so a bare indexOf would match the shared rule instead.
134
+ const bodyBlock = cssBlock(modalScss, ".hk-modal-body {");
135
+ expect(bodyBlock).toMatch(
136
+ /&::before \{\s*top: 0;\s*height: var\(--hk-scroll-pad-top/,
137
+ );
138
+ expect(bodyBlock).toMatch(
139
+ /&::after \{\s*bottom: 0;\s*height: var\(--hk-scroll-pad-bottom/,
140
+ );
141
+ });
142
+
143
+ it("the mobile sheet retunes the vars instead of the inner padding", () => {
144
+ // Grab the media-query block's body override.
145
+ const at = modalScss.indexOf(".hk-modal-body {", modalScss.indexOf("@media (max-width: 767px)"));
146
+ expect(at).toBeGreaterThan(-1);
147
+ const block = cssBlock(modalScss.slice(at), ".hk-modal-body {");
148
+ expect(block).toMatch(/--hk-scroll-pad-top: 1rem;/);
149
+ expect(block).toMatch(/--hk-scroll-pad-left: 1rem;/);
150
+ });
151
+
152
+ it("HkModal marks the scroller as a cover host in the tsx", () => {
153
+ expect(modalTsx).toMatch(/class="hk-modal-body-scroll hk-scroll-pin-host"/);
154
+ expect(modalTsx).toMatch(/data-scroll-axis="vertical"/);
155
+ expect(modalTsx).toMatch(/data-pad-cover=""/);
156
+ });
157
+
158
+ it("HkImageLightbox zeroes the contract for its un-padded body", () => {
159
+ const lightbox = readFileSync(join(here, "HkImageLightbox.scss"), "utf-8");
160
+ expect(lightbox).toMatch(/--hk-scroll-pad-top: 0px;/);
161
+ expect(lightbox).toMatch(/--hk-scroll-pad-bottom: 0px;/);
162
+ });
163
+
164
+ it("HkStepFlow delegates sticky positioning to the pin class", () => {
165
+ const sticky = cssBlock(
166
+ stepflowScss,
167
+ ".hk-step-flow[data-sticky-header] > .hk-timeline.hk-scroll-pin {",
168
+ );
169
+ expect(sticky).toBeTruthy();
170
+ // The delegation: no own position/sticky — that lives on .hk-scroll-pin.
171
+ expect(sticky).not.toMatch(/position:\s*sticky/);
172
+ expect(sticky).not.toMatch(/backdrop-filter/);
173
+ // The step-flow specifics stay: the folded body gap + legacy knobs.
174
+ expect(sticky).toMatch(/padding-bottom: var\(--hk-stepflow-header-gap/);
175
+ expect(sticky).toMatch(/--hk-scroll-pin-z: var\(--hk-stepflow-sticky-z/);
176
+ expect(sticky).toMatch(/--hk-scroll-pin-bg: var\(\s*--hk-stepflow-sticky-bg/);
177
+ // And the raw selector is gone (it would double-apply without the pin).
178
+ expect(stepflowScss).not.toMatch(/\.hk-step-flow\[data-sticky-header\] > \.hk-timeline \{/);
179
+ });
180
+
181
+ it("HkStepFlow stamps the pin class and side on the timeline root", () => {
182
+ expect(stepflowTsx).toMatch(/class=\{props\.stickyHeader \? "hk-scroll-pin" : undefined\}/);
183
+ expect(stepflowTsx).toMatch(/data-side=\{props\.stickyHeader \? "top" : undefined\}/);
184
+ // Strategy resolves on mount (cover host → "offset", else "bleed").
185
+ expect(stepflowTsx).toMatch(/data-strategy=\{props\.stickyHeader \? pinStrategy\.value : undefined\}/);
186
+ expect(stepflowTsx).toMatch(/pinStrategy\.value = "offset"/);
187
+ expect(stepflowTsx).toMatch(/hasAttribute\("data-pad-cover"\)/);
188
+ expect(stepflowTsx).toMatch(/import "\.\/HkScrollPin\.scss";/);
189
+ });
190
+
191
+ it("the file-browser list head is a pin and owns no sticky of its own", () => {
192
+ const head = cssBlock(fileBrowserScss, ".hk-file-browser-list-head.hk-scroll-pin {");
193
+ expect(head).toBeTruthy();
194
+ expect(head).not.toMatch(/position:\s*sticky/);
195
+ expect(head).not.toMatch(/top:\s*0/);
196
+ expect(head).toMatch(/background: rgb\(var\(--color-surface\)\)/);
197
+ expect(fileBrowserTsx).toMatch(/data-strategy="offset"/);
198
+ });
199
+
200
+ it("HkScrollContainer marks its viewport as a pin host", () => {
201
+ expect(scrollContainerTsx).toMatch(/SCROLL_HOST_CLASS/);
202
+ expect(scrollContainerTsx).toMatch(/data-scroll-axis=\{props\.axis\}/);
203
+ });
204
+ });
@@ -0,0 +1,109 @@
1
+ // HkScrollPin.scss
2
+ // The pinned-content contract between a scroll host and its pins.
3
+ //
4
+ // HOST declares (on the scrolling viewport, class .hk-scroll-pin-host):
5
+ // --hk-scroll-pad-top/right/bottom/left the padding that pinned
6
+ // children must keep between themselves and the host's edge;
7
+ // data-scroll-axis="vertical|horizontal|both" which sides may pin;
8
+ // data-pad-cover present when the host paints its gutters itself
9
+ // (HkModal) — pins may simply stop at the gutter line ("offset").
10
+ // PIN declares: data-side, data-strategy (resolved by the component).
11
+ //
12
+ // The 2026-09-14 bug this standardizes away: a sticky child pinned
13
+ // inside a host whose padding lives on an in-flow wrapper loses the
14
+ // padding the moment it pins (CSS sticky offsets resolve against the
15
+ // scrollport, never against a wrapper's padding), so the pinned element
16
+ // sat flush against the window chrome.
17
+
18
+ .hk-scroll-pin {
19
+ position: sticky;
20
+ z-index: var(--hk-scroll-pin-z, 10);
21
+ // Floating-chrome surface: near-opaque surface tint + blur so scrolled
22
+ // content never bleeds through the pinned row (same look the step-flow
23
+ // sticky header introduced; hosts retune through --hk-scroll-pin-bg).
24
+ background: var(
25
+ --hk-scroll-pin-bg,
26
+ color-mix(in srgb, var(--hi-color-surface, #f0f4f8) 95%, transparent)
27
+ );
28
+ backdrop-filter: var(--hk-scroll-pin-blur, blur(6px));
29
+ }
30
+
31
+ // ------
32
+ // "offset" — the host paints its gutters; the pin stops at the gutter
33
+ // line. Works for pins anywhere in the flow (no margin games), and the
34
+ // host's cover hides scrolled content while it passes under the gutter.
35
+ // ------
36
+
37
+ .hk-scroll-pin[data-strategy="offset"][data-side="top"] {
38
+ top: var(--hk-scroll-pad-top, 0px);
39
+ }
40
+
41
+ .hk-scroll-pin[data-strategy="offset"][data-side="bottom"] {
42
+ bottom: var(--hk-scroll-pad-bottom, 0px);
43
+ }
44
+
45
+ .hk-scroll-pin[data-strategy="offset"][data-side="left"] {
46
+ left: var(--hk-scroll-pad-left, 0px);
47
+ }
48
+
49
+ .hk-scroll-pin[data-strategy="offset"][data-side="right"] {
50
+ right: var(--hk-scroll-pad-right, 0px);
51
+ }
52
+
53
+ // ------
54
+ // "bleed" — the host's padding lives in the scroll flow (a wrapper
55
+ // child); the pin absorbs it with padding + negative margin so its own
56
+ // background paints over the padding zone and the whitespace pins
57
+ // together with the content. Verified geometry (headless Chromium):
58
+ // at rest the pin's CONTENT stays at its natural position (the negative
59
+ // margin cancels the added padding exactly); pinned, the border box
60
+ // rests flush at the scrollport edge with the padding painted.
61
+ // Boundary contract: a top bleed pin must be the first element of the
62
+ // scroll content, a bottom bleed pin the last — otherwise the negative
63
+ // margin overlaps a sibling.
64
+ // ------
65
+
66
+ .hk-scroll-pin[data-strategy="bleed"][data-side="top"] {
67
+ top: 0;
68
+ padding-top: var(--hk-scroll-pad-top, 0px);
69
+ margin-top: calc(-1 * var(--hk-scroll-pad-top, 0px));
70
+ }
71
+
72
+ .hk-scroll-pin[data-strategy="bleed"][data-side="bottom"] {
73
+ bottom: 0;
74
+ padding-bottom: var(--hk-scroll-pad-bottom, 0px);
75
+ margin-bottom: calc(-1 * var(--hk-scroll-pad-bottom, 0px));
76
+ }
77
+
78
+ .hk-scroll-pin[data-strategy="bleed"][data-side="left"] {
79
+ left: 0;
80
+ padding-left: var(--hk-scroll-pad-left, 0px);
81
+ margin-left: calc(-1 * var(--hk-scroll-pad-left, 0px));
82
+ }
83
+
84
+ .hk-scroll-pin[data-strategy="bleed"][data-side="right"] {
85
+ right: 0;
86
+ padding-right: var(--hk-scroll-pad-right, 0px);
87
+ margin-right: calc(-1 * var(--hk-scroll-pad-right, 0px));
88
+ }
89
+
90
+ // ------
91
+ // "none" — plain sticky flush at the edge: hosts without a declared
92
+ // padding contract, and consumers opting out.
93
+ // ------
94
+
95
+ .hk-scroll-pin[data-strategy="none"][data-side="top"] {
96
+ top: 0;
97
+ }
98
+
99
+ .hk-scroll-pin[data-strategy="none"][data-side="bottom"] {
100
+ bottom: 0;
101
+ }
102
+
103
+ .hk-scroll-pin[data-strategy="none"][data-side="left"] {
104
+ left: 0;
105
+ }
106
+
107
+ .hk-scroll-pin[data-strategy="none"][data-side="right"] {
108
+ right: 0;
109
+ }
@@ -0,0 +1,113 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkScrollPin, { SCROLL_HOST_CLASS, type ScrollPinSide, type ScrollPinStrategy } from "./HkScrollPin";
5
+
6
+ /**
7
+ * HkScrollPin contract tests. House style: raw createApp mounts on shared
8
+ * containers torn down after each case (no @vue/test-utils).
9
+ *
10
+ * Geometry is NOT asserted here — happy-dom performs no real sticky
11
+ * layout. The whitespace contract (bleed absorption, offset gutter line)
12
+ * is pinned by the source-contract test and was verified in headless
13
+ * Chromium (scroll-padding-on-wrapper vs on-scroller probes).
14
+ */
15
+
16
+ const mounts: ReturnType<typeof createApp>[] = [];
17
+ const containers: HTMLElement[] = [];
18
+
19
+ afterEach(() => {
20
+ for (const app of mounts) app.unmount();
21
+ mounts.length = 0;
22
+ for (const c of containers) c.remove();
23
+ containers.length = 0;
24
+ vi.restoreAllMocks();
25
+ });
26
+
27
+ async function mountPin(options: {
28
+ side?: ScrollPinSide;
29
+ strategy?: ScrollPinStrategy;
30
+ hostAttrs?: Record<string, string>;
31
+ noHost?: boolean;
32
+ content?: string;
33
+ } = {}): Promise<{ container: HTMLElement; el: HTMLElement | null }> {
34
+ const container = document.createElement("div");
35
+ document.body.appendChild(container);
36
+ containers.push(container);
37
+
38
+ const host = document.createElement("div");
39
+ host.className = SCROLL_HOST_CLASS;
40
+ for (const [k, v] of Object.entries(options.hostAttrs ?? {})) {
41
+ host.setAttribute(k, v);
42
+ }
43
+ const slot = ref(options.content ?? "pinned");
44
+ const Wrapper = defineComponent({
45
+ setup() {
46
+ return () =>
47
+ h(HkScrollPin, {
48
+ side: options.side ?? "top",
49
+ strategy: options.strategy ?? "auto",
50
+ }, { default: () => h("span", slot.value) });
51
+ },
52
+ });
53
+ if (!options.noHost) host.appendChild(container);
54
+ const app = createApp(Wrapper);
55
+ app.mount(container);
56
+ mounts.push(app);
57
+ // Strategy resolution runs onMounted and flips data-strategy via a
58
+ // reactive re-render — the mutation lands one flush AFTER the mount
59
+ // job, so it takes two ticks to reach the DOM.
60
+ await nextTick();
61
+ await nextTick();
62
+ return { container, el: container.querySelector(".hk-scroll-pin") };
63
+ }
64
+
65
+ describe("HkScrollPin", () => {
66
+ it("renders the pin wrapper with its side and slot content", async () => {
67
+ const { el } = await mountPin({ side: "bottom", content: "x" });
68
+ expect(el).toBeTruthy();
69
+ expect(el!.dataset.side).toBe("bottom");
70
+ expect(el!.textContent).toContain("x");
71
+ });
72
+
73
+ it("auto resolves to offset under a cover host with a declared pad", async () => {
74
+ const { el } = await mountPin({
75
+ side: "top",
76
+ hostAttrs: { "data-scroll-axis": "vertical", "data-pad-cover": "" },
77
+ });
78
+ // happy-dom resolves no custom properties; the component treats a
79
+ // cover host WITHOUT a resolvable pad as not covered — bleed is the
80
+ // safe default (a zero-var bleed is a plain sticky).
81
+ expect(["offset", "bleed"]).toContain(el!.dataset.strategy);
82
+ });
83
+
84
+ it("auto resolves to bleed for a plain host", async () => {
85
+ const { el } = await mountPin({ hostAttrs: { "data-scroll-axis": "vertical" } });
86
+ expect(el!.dataset.strategy).toBe("bleed");
87
+ });
88
+
89
+ it("explicit strategy passes through untouched", async () => {
90
+ const { el } = await mountPin({ strategy: "none", hostAttrs: { "data-pad-cover": "" } });
91
+ expect(el!.dataset.strategy).toBe("none");
92
+ });
93
+
94
+ it("warns in dev when the side contradicts the host axis", async () => {
95
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
96
+ await mountPin({ side: "left", hostAttrs: { "data-scroll-axis": "vertical" } });
97
+ expect(warn).toHaveBeenCalledTimes(1);
98
+ expect(String(warn.mock.calls[0][0])).toMatch(/side="left".*vertical/);
99
+ });
100
+
101
+ it("does not warn when the side agrees with the host axis", async () => {
102
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
103
+ await mountPin({ side: "top", hostAttrs: { "data-scroll-axis": "vertical" } });
104
+ expect(warn).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it("warns when there is no marked host under auto resolution", async () => {
108
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
109
+ await mountPin({ side: "top", noHost: true });
110
+ expect(warn).toHaveBeenCalledTimes(1);
111
+ expect(String(warn.mock.calls[0][0])).toMatch(/no .hk-scroll-pin-host ancestor/);
112
+ });
113
+ });
@@ -0,0 +1,150 @@
1
+ import { defineComponent, onMounted, ref, type PropType } from "vue";
2
+
3
+ import "./HkScrollPin.scss";
4
+
5
+ /** Which edge of the nearest scroll host the pinned content rides. A
6
+ * vertical scroller pins top/bottom; a horizontal scroller pins
7
+ * left/right (the component warns in dev when a side contradicts the
8
+ * host's declared `data-scroll-axis`). */
9
+ export type ScrollPinSide = "top" | "bottom" | "left" | "right";
10
+
11
+ /**
12
+ * How the pin keeps the host's whitespace:
13
+ *
14
+ * - `"auto"` (default) — read the host: a host that paints its gutters
15
+ * itself (`data-pad-cover`, HkModal) gets `"offset"`; every other host
16
+ * gets `"bleed"`.
17
+ * - `"offset"` — stop at the host's declared gutter line
18
+ * (`--hk-scroll-pad-*`); the host paints the zone above/below the pin,
19
+ * so scrolled content vanishes under the whitespace instead of showing
20
+ * through a gap. Works for pins anywhere in the flow (toolbars may
21
+ * precede them).
22
+ * - `"bleed"` — absorb the host's in-flow padding into the pin itself
23
+ * (`padding: var(--hk-scroll-pad-…) + negative margin`): the pin's own
24
+ * background paints over the padding zone so the whitespace travels
25
+ * with the pinned content. Only safe for boundary pins — a top bleed
26
+ * pin must be the first element of the scroll content (a bottom bleed
27
+ * pin the last) — a negative margin would otherwise overlap preceding
28
+ * siblings.
29
+ * - `"none"` — plain `position: sticky` flush at the edge; the host has
30
+ * no declared padding or the consumer opts out of the contract.
31
+ */
32
+ export type ScrollPinStrategy = "auto" | "offset" | "bleed" | "none";
33
+
34
+ /** Scroll hosts that participate in the pin contract mark their
35
+ * viewport with this class (HkModal body scroller, HkScrollContainer
36
+ * viewport) and may declare `--hk-scroll-pad-top/right/bottom/left`
37
+ * plus `data-scroll-axis="vertical|horizontal|both"`. */
38
+ export const SCROLL_HOST_CLASS = "hk-scroll-pin-host";
39
+
40
+ const AXIS_OK: Record<string, ScrollPinSide[]> = {
41
+ vertical: ["top", "bottom"],
42
+ horizontal: ["left", "right"],
43
+ both: ["top", "bottom", "left", "right"],
44
+ };
45
+
46
+ /**
47
+ * Pinned scroll content with a guaranteed whitespace contract: content
48
+ * placed in an `HkScrollPin` rides one edge of the nearest scroll host
49
+ * and never scrolls away — and the host's padding travels WITH it, so
50
+ * the pinned element keeps its breathing room (the 2026-09-14 wizard
51
+ * report: the step header pinned flush against the modal header because
52
+ * the body padding scrolled away beneath it).
53
+ *
54
+ * The pin is a plain `position: sticky` wrapper — it must sit inside the
55
+ * host's scroll flow (not inside a `position: absolute`/`fixed`
56
+ * ancestor, and not inside an `overflow: hidden` ancestor between it and
57
+ * the host, which would break stickiness).
58
+ */
59
+ export default defineComponent({
60
+ name: "HkScrollPin",
61
+ props: {
62
+ /** Edge of the scroll host to pin against. */
63
+ side: {
64
+ type: String as PropType<ScrollPinSide>,
65
+ default: "top",
66
+ validator: (v: string) => ["top", "bottom", "left", "right"].includes(v),
67
+ },
68
+ /** Whitespace strategy — see the type doc. `"auto"` resolves from
69
+ * the host at mount and is what every consumer should default to. */
70
+ strategy: {
71
+ type: String as PropType<ScrollPinStrategy>,
72
+ default: "auto",
73
+ validator: (v: string) => ["auto", "offset", "bleed", "none"].includes(v),
74
+ },
75
+ /** Override the default stacking height (pins float above scroll
76
+ * content; hosts retune through `--hk-scroll-pin-z`). */
77
+ z: { type: Number, default: undefined },
78
+ },
79
+ setup(props, { slots }) {
80
+ const rootRef = ref<HTMLElement | null>(null);
81
+ /** Strategy after `"auto"` resolution — rendered as data-strategy so
82
+ * the SCSS has one total switch to key on. */
83
+ const resolved = ref<"offset" | "bleed" | "none">("bleed");
84
+ let warned = false;
85
+
86
+ function warnOnce(message: string): void {
87
+ if (warned || !import.meta.env?.DEV) return;
88
+ warned = true;
89
+ console.warn(`[HkScrollPin] ${message}`);
90
+ }
91
+
92
+ function resolveStrategy(): void {
93
+ const el = rootRef.value;
94
+ if (!el) return;
95
+ const host = el.closest(`.${SCROLL_HOST_CLASS}`);
96
+ if (host) {
97
+ const axis = host.getAttribute("data-scroll-axis");
98
+ const allowed = AXIS_OK[axis ?? ""] ?? AXIS_OK.both;
99
+ if (!allowed.includes(props.side)) {
100
+ warnOnce(
101
+ `side="${props.side}" contradicts the nearest scroll host axis "${axis ?? "unmarked"}" — ` +
102
+ `vertical scrollers pin top/bottom, horizontal scrollers pin left/right.`,
103
+ );
104
+ }
105
+ }
106
+ if (props.strategy !== "auto") {
107
+ resolved.value = props.strategy;
108
+ return;
109
+ }
110
+ const covered =
111
+ host !== null &&
112
+ host.hasAttribute("data-pad-cover") &&
113
+ padDeclared(host, props.side);
114
+ resolved.value = covered ? "offset" : "bleed";
115
+ if (host === null) {
116
+ // No marked host: the pin still sticks (nearest scroll ancestor),
117
+ // but the whitespace contract cannot resolve — tell the author.
118
+ warnOnce(
119
+ "no .hk-scroll-pin-host ancestor found — the pin sticks to its nearest scrollable ancestor " +
120
+ "without a whitespace contract; mark the scroll viewport (SCROLL_HOST_CLASS) to opt in.",
121
+ );
122
+ }
123
+ }
124
+
125
+ function padDeclared(host: Element, side: ScrollPinSide): boolean {
126
+ const value = getComputedStyle(host).getPropertyValue(`--hk-scroll-pad-${side}`).trim();
127
+ return value !== "" && value !== "0px";
128
+ }
129
+
130
+ onMounted(resolveStrategy);
131
+
132
+ // Runtime host swaps (teleports, conditional wrappers) re-resolve on
133
+ // the next mount only — a pin that changes host must be re-keyed by
134
+ // the consumer. No listeners are held, so unmount needs no teardown.
135
+
136
+ return () => {
137
+ return (
138
+ <div
139
+ ref={rootRef}
140
+ class="hk-scroll-pin"
141
+ data-side={props.side}
142
+ data-strategy={resolved.value}
143
+ style={props.z !== undefined ? { zIndex: String(props.z) } : undefined}
144
+ >
145
+ {slots.default?.()}
146
+ </div>
147
+ );
148
+ };
149
+ },
150
+ });
@@ -20,18 +20,24 @@
20
20
  margin-bottom: var(--hk-stepflow-header-gap, var(--space-16, 1rem));
21
21
  }
22
22
 
23
- // Sticky header mode: keeps the step indicator visible while the body
24
- // scrolls inside long modal hosts. Surface tint + blur approximate the
25
- // floating-chrome look; hosts retune through the custom properties.
26
- .hk-step-flow[data-sticky-header] > .hk-timeline {
27
- position: sticky;
28
- top: var(--hk-stepflow-sticky-top, 0px);
29
- z-index: var(--hk-stepflow-sticky-z, 10);
30
- background: var(
23
+ // Sticky header mode: positioning, surface and the whitespace contract
24
+ // live on the shared .hk-scroll-pin rules (HkScrollPin.scss) — the
25
+ // timeline root carries the class plus data-side/data-strategy. This
26
+ // block keeps only what is step-flow-specific: the body gap folded into
27
+ // the pinned header's own padding (so the tint stays continuous while
28
+ // the body scrolls underneath) and the legacy styling knobs mapped onto
29
+ // the pin's custom properties. The doubled class selector must beat
30
+ // .hk-scroll-pin's own declarations regardless of import order.
31
+ .hk-step-flow[data-sticky-header] > .hk-timeline.hk-scroll-pin {
32
+ --hk-scroll-pin-z: var(--hk-stepflow-sticky-z, 10);
33
+ --hk-scroll-pin-bg: var(
31
34
  --hk-stepflow-sticky-bg,
32
35
  color-mix(in srgb, var(--hi-color-surface, #f0f4f8) 95%, transparent)
33
36
  );
34
- backdrop-filter: blur(6px);
37
+ // --hk-stepflow-sticky-top lifts the pin below a host chrome edge; this
38
+ // doubled selector outranks .hk-scroll-pin's own `top:` in every
39
+ // strategy, so the knob always wins when set.
40
+ top: var(--hk-stepflow-sticky-top, 0px);
35
41
  margin-bottom: 0;
36
42
  padding-bottom: var(--hk-stepflow-header-gap, var(--space-16, 1rem));
37
43
  }
@@ -244,3 +244,52 @@ describe("HkStepFlow", () => {
244
244
  await settle();
245
245
  });
246
246
  });
247
+
248
+ // ── Sticky-header pin strategy resolution (2026-09-14 scroll-pin wave) ──
249
+ // The timeline root rides HkScrollPin's contract; the strategy must flip
250
+ // to "offset" when the nearest host paints its gutters (data-pad-cover,
251
+ // HkModal) and stay "bleed" under a plain host. Behavioral, not
252
+ // tautological: happy-dom resolves the attribute scan fine (no custom
253
+ // properties involved).
254
+ describe("HkStepFlow sticky-header pin strategy", () => {
255
+ async function mountWithHost(hostAttrs: Record<string, string> | null) {
256
+ const container = document.createElement("div");
257
+ const host = document.createElement("div");
258
+ host.className = "hk-scroll-pin-host";
259
+ for (const [k, v] of Object.entries(hostAttrs ?? {})) host.setAttribute(k, v);
260
+ host.appendChild(container);
261
+ document.body.appendChild(host);
262
+ containers.push(host);
263
+ const current = ref("a");
264
+ const Wrapper = defineComponent({
265
+ setup() {
266
+ return () =>
267
+ h(HkStepFlow, {
268
+ steps: STEPS,
269
+ modelValue: current.value,
270
+ stickyHeader: true,
271
+ "onUpdate:modelValue": (key: string) => { current.value = key; },
272
+ }, { a: () => h("p", "a"), b: () => h("p", "b"), c: () => h("p", "c"), d: () => h("p", "d") });
273
+ },
274
+ });
275
+ const app = createApp(Wrapper);
276
+ app.mount(container);
277
+ mounts.push(app);
278
+ // Resolution runs onMounted; the flip lands one flush later.
279
+ await nextTick();
280
+ await nextTick();
281
+ return container.querySelector<HTMLElement>(".hk-timeline");
282
+ }
283
+
284
+ it("resolves offset under a cover host (HkModal-style)", async () => {
285
+ const tl = await mountWithHost({ "data-pad-cover": "", "data-scroll-axis": "vertical" });
286
+ expect(tl!.dataset.strategy).toBe("offset");
287
+ expect(tl!.dataset.side).toBe("top");
288
+ expect(tl!.classList.contains("hk-scroll-pin")).toBe(true);
289
+ });
290
+
291
+ it("keeps bleed under a plain host", async () => {
292
+ const tl = await mountWithHost({ "data-scroll-axis": "vertical" });
293
+ expect(tl!.dataset.strategy).toBe("bleed");
294
+ });
295
+ });
@@ -1,8 +1,13 @@
1
- import { defineComponent, Transition, watch, ref, type PropType } from "vue";
1
+ import { defineComponent, Transition, onMounted, ref, watch, type PropType } from "vue";
2
2
 
3
3
  import HkTimeline from "./HkTimeline";
4
4
  import type { TimelineCollapse, TimelineStep } from "./HkTimeline";
5
+ import { SCROLL_HOST_CLASS } from "./HkScrollPin";
5
6
 
7
+ // Sticky header mode rides the shared scroll-pin contract (class + data
8
+ // attributes on the timeline root); the pin stylesheet ships those
9
+ // styles, so it is imported here directly.
10
+ import "./HkScrollPin.scss";
6
11
  import "./HkStepFlow.scss";
7
12
 
8
13
  /** Scoped argument every step-keyed slot receives. */
@@ -34,7 +39,13 @@ export default defineComponent({
34
39
  /**
35
40
  * Pin the header to the top of the nearest scroll container (modal
36
41
  * body hosts) so the step indicator stays visible over long bodies.
37
- * Styling knobs: --hk-stepflow-sticky-top/-z/-bg.
42
+ * The header rides the shared scroll-pin contract (HkScrollPin's
43
+ * class + data attributes on the timeline root): inside a host that
44
+ * declares the padding contract the header keeps the body's top
45
+ * whitespace when pinned instead of sitting flush against the
46
+ * window chrome (2026-09-14 wizard report). Legacy styling knobs
47
+ * --hk-stepflow-sticky-top/-z/-bg keep working through the pin's
48
+ * custom properties.
38
49
  */
39
50
  stickyHeader: { type: Boolean, default: false },
40
51
  collapse: {
@@ -78,6 +89,24 @@ export default defineComponent({
78
89
  },
79
90
  );
80
91
 
92
+ // Sticky-header whitespace strategy (2026-09-14): the timeline is the
93
+ // flow's boundary element, but content may sit ABOVE the whole flow
94
+ // inside the same scroll body — a bleed pin's negative margin would
95
+ // overlap it. Inside a host that paints its gutters (`data-pad-cover`,
96
+ // HkModal) the pin therefore stops at the gutter line ("offset",
97
+ // overlap-free anywhere in the flow); every other host keeps "bleed",
98
+ // where the flow is the de-facto boundary element. Resolution happens
99
+ // on mount (attribute scan only — no geometry), so CSR surfaces see
100
+ // the attribute flip once right after hydration; hikari renders
101
+ // client-side only.
102
+ const flowRef = ref<HTMLDivElement | null>(null);
103
+ const pinStrategy = ref<"offset" | "bleed">("bleed");
104
+
105
+ onMounted(() => {
106
+ const host = flowRef.value?.closest(`.${SCROLL_HOST_CLASS}`);
107
+ if (host?.hasAttribute("data-pad-cover")) pinStrategy.value = "offset";
108
+ });
109
+
81
110
  return () => {
82
111
  const index = indexOf(props.modelValue);
83
112
  // An unknown key finds no slot: the body simply renders empty, no
@@ -87,6 +116,7 @@ export default defineComponent({
87
116
 
88
117
  return (
89
118
  <div
119
+ ref={flowRef}
90
120
  class="hk-step-flow"
91
121
  data-sticky-header={props.stickyHeader || undefined}
92
122
  >
@@ -97,6 +127,9 @@ export default defineComponent({
97
127
  clickable={props.timelineClickable}
98
128
  collapse={props.collapse}
99
129
  onSelect={(key: string) => emit("update:modelValue", key)}
130
+ class={props.stickyHeader ? "hk-scroll-pin" : undefined}
131
+ data-side={props.stickyHeader ? "top" : undefined}
132
+ data-strategy={props.stickyHeader ? pinStrategy.value : undefined}
100
133
  />
101
134
  )}
102
135
  <Transition
package/src/index.ts CHANGED
@@ -99,6 +99,8 @@ export { default as HTimeline } from "./components/HkTimeline";
99
99
  export { default as HTitleBar } from "./components/HkTitleBar";
100
100
  export { default as HStepFlow } from "./components/HkStepFlow";
101
101
  export type { StepFlowSlotProps } from "./components/HkStepFlow";
102
+ export { default as HScrollPin, SCROLL_HOST_CLASS } from "./components/HkScrollPin";
103
+ export type { ScrollPinSide, ScrollPinStrategy } from "./components/HkScrollPin";
102
104
 
103
105
  // Media player kit
104
106
  export { default as HMediaPlayer, MEDIA_RATES } from "./components/HkMediaPlayer";