@celestia-island/hikari 0.49.0 → 0.49.2

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.0",
3
+ "version": "0.49.2",
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",
@@ -11,7 +11,7 @@
11
11
  top: 4rem;
12
12
  /* Same viewport gutter the anchored popups clamp to (16px desktop /
13
13
  * 8px mobile) — the toast stack is a floating layer too. */
14
- inset-inline-end: var(--viewport-gutter, 1rem);
14
+ inset-inline-end: var(--viewport-gutter, 16px);
15
15
  z-index: var(--hk-z-toast, 4000);
16
16
  max-width: 24rem;
17
17
  pointer-events: none;
@@ -48,6 +48,43 @@ async function settle() {
48
48
  await nextTick();
49
49
  }
50
50
 
51
+ /** Poll until the drilled view's title button reads `expected`. The drill
52
+ * transition is frame/timeout based and a fixed 20 ms settle raced it on
53
+ * the CI runner (the year-grid test once read the previous view's title),
54
+ * so the view STATE — not a sleep — is what this waits on. */
55
+ function titleText(): string {
56
+ return panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.textContent ?? "";
57
+ }
58
+
59
+ async function waitForTitle(expected: string): Promise<void> {
60
+ const deadline = Date.now() + 2000;
61
+ for (;;) {
62
+ if (titleText() === expected) return;
63
+ if (Date.now() > deadline) {
64
+ throw new Error(`title never became ${JSON.stringify(expected)} (last: ${JSON.stringify(titleText())})`);
65
+ }
66
+ await new Promise((r) => setTimeout(r, 10));
67
+ }
68
+ }
69
+
70
+ /** Generic state poll over the same drill race: wait until `probe` holds. */
71
+ async function waitForView(desc: string, probe: () => boolean): Promise<void> {
72
+ const deadline = Date.now() + 2000;
73
+ while (!probe()) {
74
+ if (Date.now() > deadline) throw new Error(`view never reached: ${desc}`);
75
+ await new Promise((r) => setTimeout(r, 10));
76
+ }
77
+ }
78
+
79
+ function titleBtn(): HTMLButtonElement | null | undefined {
80
+ return panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn");
81
+ }
82
+
83
+ /** The pick cells of the CURRENT view (months or years, both carry 12). */
84
+ function pickCells(): HTMLButtonElement[] {
85
+ return Array.from(panel()?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell[data-variant='pick']") ?? []);
86
+ }
87
+
51
88
  function openViaEnter(harness: PickerHarness) {
52
89
  const trigger = harness.container.querySelector<HTMLElement>(".hk-dp-trigger");
53
90
  trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
@@ -326,23 +363,24 @@ describe("HkDatePicker", () => {
326
363
  const p = mountPicker({ modelValue: "2026-08-16" });
327
364
  openViaEnter(p);
328
365
  await nextTick();
329
- // days → months
330
- panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
331
- await settle();
332
- // months → years (the year button is the months-view title)
333
- panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
334
- await settle();
335
- const yearCells = Array.from(panel()?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell[data-variant='pick']") ?? []);
336
- expect(yearCells.length).toBe(12);
337
- // 2027 sits inside the 2016–2027 block of the 2026 view year.
338
- const t2027 = yearCells.find((c) => c.textContent === "2027");
339
- t2027?.click();
340
- await settle();
341
- expect(panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.textContent).toBe("2027");
342
- const months = Array.from(panel()?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell[data-variant='pick']") ?? []);
343
- months[6]?.click(); // July
344
- await settle();
345
- expect(panel()?.querySelectorAll(".hk-dp-cell").length).toBe(42);
366
+ // Every drill below waits for the DESTINATION view's state, not a
367
+ // fixed settle: the transition is frame/timeout based and the CI
368
+ // runner raced both the days→months click (the second click landed
369
+ // mid-transition and was swallowed) and the months→years read.
370
+ // days → months: the title button becomes the view year.
371
+ titleBtn()?.click();
372
+ await waitForView("the months grid of 2026", () => titleText() === "2026");
373
+ // months → years (the year button is the months-view title): the
374
+ // 2016–2027 block of the 2026 view year holds a 2027 cell.
375
+ titleBtn()?.click();
376
+ await waitForView("the year grid showing 2027", () =>
377
+ pickCells().some((c) => c.textContent === "2027"));
378
+ pickCells().find((c) => c.textContent === "2027")?.click();
379
+ // Picking a year lands on the months grid of that year.
380
+ await waitForTitle("2027");
381
+ pickCells()[6]?.click(); // July
382
+ // ...and picking a month lands back on the days grid.
383
+ await waitForView("the days grid", () => panel()?.querySelectorAll(".hk-dp-cell").length === 42);
346
384
  const fmt = new Intl.DateTimeFormat("en", { year: "numeric", month: "long" });
347
385
  expect(panel()?.querySelector<HTMLElement>(".hk-dp-title")?.textContent).toContain(
348
386
  fmt.format(new Date(2027, 6, 1)),
@@ -8,6 +8,29 @@ const mounts: ReturnType<typeof createApp>[] = [];
8
8
  const containers: HTMLElement[] = [];
9
9
  const originalWidth = window.innerWidth;
10
10
 
11
+ /** The fixed model the suite mounts with. Every expected year/month/day
12
+ * in the assertions derives from THIS constant — never from a bare
13
+ * literal, and never from Date.now() — so the file cannot rot when a
14
+ * calendar year rolls over. */
15
+ const BASE = new Date(2026, 7, 16, 9, 30);
16
+
17
+ /** Local-ISO day (`YYYY-MM-DD`) — the native date input's wire format. */
18
+ function isoDay(d: Date): string {
19
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
20
+ }
21
+
22
+ /** Local-ISO minute (`YYYY-MM-DDTHH:mm`) — datetime-local's wire format. */
23
+ function isoMinute(d: Date): string {
24
+ return `${isoDay(d)}T${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
25
+ }
26
+
27
+ /** A day inside the mounted (BASE) month, optionally with a clock time. */
28
+ function dayOf(day: number, hours?: number, minutes?: number): Date {
29
+ return hours === undefined
30
+ ? new Date(BASE.getFullYear(), BASE.getMonth(), day)
31
+ : new Date(BASE.getFullYear(), BASE.getMonth(), day, hours, minutes ?? 0);
32
+ }
33
+
11
34
  interface PickerHarness {
12
35
  container: HTMLElement;
13
36
  emitted: Date[];
@@ -18,7 +41,7 @@ function mountPicker(props: Record<string, unknown> = {}): PickerHarness {
18
41
  document.body.appendChild(container);
19
42
  containers.push(container);
20
43
 
21
- const value = ref(props.modelValue as Date ?? new Date(2026, 7, 16, 9, 30));
44
+ const value = ref(props.modelValue as Date ?? BASE);
22
45
  const emitted: Date[] = [];
23
46
  const Wrapper = defineComponent({
24
47
  setup() {
@@ -44,6 +67,22 @@ async function settle() {
44
67
  await nextTick();
45
68
  }
46
69
 
70
+ /** Poll until the drilled view's title button reads `expected`. The drill
71
+ * transition is frame/timeout based and a fixed 20 ms settle raced it
72
+ * under full-suite load (the assertion once read the days-view title),
73
+ * so the view STATE — not a sleep — is what this waits on. */
74
+ async function waitForTitle(expected: string): Promise<void> {
75
+ const deadline = Date.now() + 2000;
76
+ for (;;) {
77
+ const title = picker()?.querySelector<HTMLButtonElement>(".hk-dtp-title-btn")?.textContent ?? "";
78
+ if (title === expected) return;
79
+ if (Date.now() > deadline) {
80
+ throw new Error(`title never became ${JSON.stringify(expected)} (last: ${JSON.stringify(title)})`);
81
+ }
82
+ await new Promise((r) => setTimeout(r, 10));
83
+ }
84
+ }
85
+
47
86
  function picker(): HTMLElement | null {
48
87
  return document.querySelector<HTMLElement>(".hk-dtp");
49
88
  }
@@ -124,7 +163,9 @@ describe("HkDateTimePicker", () => {
124
163
 
125
164
  it("re-derives month and weekday labels after a locale switch", async () => {
126
165
  mountPicker();
127
- const zhMonth = new Intl.DateTimeFormat("zh-Hans", { month: "long" }).format(new Date(2026, 7, 1));
166
+ const zhMonth = new Intl.DateTimeFormat("zh-Hans", { month: "long" }).format(
167
+ new Date(BASE.getFullYear(), BASE.getMonth(), 1),
168
+ );
128
169
  await setLocale("zh-Hans");
129
170
  await nextTick();
130
171
  const title = picker()?.querySelector<HTMLElement>(".hk-dtp-title-btn")?.textContent ?? "";
@@ -173,14 +214,14 @@ describe("HkDateTimePicker", () => {
173
214
  const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
174
215
  monthBtn?.click();
175
216
  await settle();
176
- expect(picker()?.querySelector<HTMLButtonElement>(".hk-dtp-title-btn")?.textContent).toBe("2026");
217
+ await waitForTitle(String(BASE.getFullYear()));
177
218
  const navs = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-nav");
178
219
  navs?.[1].click();
179
220
  await nextTick();
180
- expect(picker()?.querySelector<HTMLButtonElement>(".hk-dtp-title-btn")?.textContent).toBe("2027");
221
+ await waitForTitle(String(BASE.getFullYear() + 1));
181
222
  navs?.[0].click();
182
223
  await nextTick();
183
- expect(picker()?.querySelector<HTMLButtonElement>(".hk-dtp-title-btn")?.textContent).toBe("2026");
224
+ await waitForTitle(String(BASE.getFullYear()));
184
225
  });
185
226
 
186
227
  it("time stepper bumps keep the drilled month view instead of snapping back", async () => {
@@ -205,14 +246,14 @@ describe("HkDateTimePicker", () => {
205
246
  expect(p.emitted.length).toBe(1);
206
247
  const d = p.emitted[0];
207
248
  expect(d instanceof Date).toBe(true);
208
- expect([d.getFullYear(), d.getMonth(), d.getDate()]).toEqual([2026, 7, 20]);
249
+ expect([d.getFullYear(), d.getMonth(), d.getDate()]).toEqual([BASE.getFullYear(), BASE.getMonth(), 20]);
209
250
  expect([d.getHours(), d.getMinutes()]).toEqual([9, 30]);
210
251
  });
211
252
 
212
253
  it("disables days outside the inclusive min/max bounds", () => {
213
254
  mountPicker({
214
- min: new Date(2026, 7, 10, 0, 0),
215
- max: new Date(2026, 7, 20, 23, 59),
255
+ min: new Date(BASE.getFullYear(), BASE.getMonth(), 10, 0, 0),
256
+ max: new Date(BASE.getFullYear(), BASE.getMonth(), 20, 23, 59),
216
257
  });
217
258
  const byDay = new Map(
218
259
  dayCells().filter((c) => !c.classList.contains("is-out")).map((c) => [c.textContent ?? "", c]),
@@ -224,14 +265,14 @@ describe("HkDateTimePicker", () => {
224
265
  });
225
266
 
226
267
  it("blocked days never emit an update when clicked", async () => {
227
- const p = mountPicker({ max: new Date(2026, 7, 20, 23, 59) });
268
+ const p = mountPicker({ max: dayOf(20, 23, 59) });
228
269
  clickDay(25);
229
270
  await nextTick();
230
271
  expect(p.emitted).toEqual([]);
231
272
  });
232
273
 
233
274
  it("marks days present in markedDays with a dot", () => {
234
- mountPicker({ markedDays: new Set(["2026-08-18"]) });
275
+ mountPicker({ markedDays: new Set([isoDay(dayOf(18))]) });
235
276
  const marked = Array.from(picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-cell") ?? [])
236
277
  .filter((c) => c.querySelector(".hk-dtp-cell-dot"));
237
278
  expect(marked.length).toBe(1);
@@ -257,7 +298,7 @@ describe("HkDateTimePicker", () => {
257
298
  const input = nativeInput(p.container);
258
299
  expect(input).not.toBeNull();
259
300
  expect(input?.type).toBe("datetime-local");
260
- expect(input?.value).toBe("2026-08-16T09:30");
301
+ expect(input?.value).toBe(isoMinute(BASE));
261
302
  expect(p.container.querySelector(".hk-dtp")).toBeNull();
262
303
  expect(p.container.querySelector(".hk-dtp-grid")).toBeNull();
263
304
  });
@@ -267,18 +308,15 @@ describe("HkDateTimePicker", () => {
267
308
  const p = mountPicker({ showTime: false });
268
309
  const input = nativeInput(p.container);
269
310
  expect(input?.type).toBe("date");
270
- expect(input?.value).toBe("2026-08-16");
311
+ expect(input?.value).toBe(isoDay(BASE));
271
312
  });
272
313
 
273
314
  it("passes min/max through to the native input in its wire format", () => {
274
315
  useMobileViewport();
275
- const p = mountPicker({
276
- min: new Date(2026, 7, 10, 8, 0),
277
- max: new Date(2026, 7, 20, 18, 30),
278
- });
316
+ const p = mountPicker({ min: dayOf(10, 8, 0), max: dayOf(20, 18, 30) });
279
317
  const input = nativeInput(p.container);
280
- expect(input?.min).toBe("2026-08-10T08:00");
281
- expect(input?.max).toBe("2026-08-20T18:30");
318
+ expect(input?.min).toBe(isoMinute(dayOf(10, 8, 0)));
319
+ expect(input?.max).toBe(isoMinute(dayOf(20, 18, 30)));
282
320
  });
283
321
 
284
322
  it("keeps the custom calendar on mobile when nativeOnMobile is false", () => {
@@ -295,33 +333,34 @@ describe("HkDateTimePicker", () => {
295
333
  useMobileViewport();
296
334
  const p = mountPicker();
297
335
  const input = nativeInput(p.container);
298
- input!.value = "2026-08-20T14:05";
336
+ input!.value = isoMinute(dayOf(20, 14, 5));
299
337
  input!.dispatchEvent(new Event("input", { bubbles: true }));
300
338
  await nextTick();
301
339
  expect(p.emitted.length).toBe(1);
302
- expect(p.emitted[0].getTime()).toBe(new Date(2026, 7, 20, 14, 5).getTime());
303
- expect(input?.value).toBe("2026-08-20T14:05");
340
+ expect(p.emitted[0].getTime()).toBe(dayOf(20, 14, 5).getTime());
341
+ expect(input?.value).toBe(isoMinute(dayOf(20, 14, 5)));
304
342
  });
305
343
 
306
344
  it("native date edits preserve the clock time when showTime is false", async () => {
307
345
  useMobileViewport();
308
346
  const p = mountPicker({ showTime: false });
309
347
  const input = nativeInput(p.container);
310
- input!.value = "2026-08-20";
348
+ input!.value = isoDay(dayOf(20));
311
349
  input!.dispatchEvent(new Event("input", { bubbles: true }));
312
350
  await nextTick();
313
- expect(p.emitted[0].getTime()).toBe(new Date(2026, 7, 20, 9, 30).getTime());
351
+ expect(p.emitted[0].getTime()).toBe(dayOf(20, 9, 30).getTime());
314
352
  });
315
353
 
316
354
  it("native input edits outside the bounds are rejected and re-synced", async () => {
317
355
  useMobileViewport();
318
- const p = mountPicker({ max: new Date(2026, 7, 20, 23, 59) });
356
+ const p = mountPicker({ max: dayOf(20, 23, 59) });
319
357
  const input = nativeInput(p.container);
320
- input!.value = "2026-09-01T10:00";
358
+ // Next month, past the max bound.
359
+ input!.value = isoMinute(new Date(BASE.getFullYear(), BASE.getMonth() + 1, 1, 10, 0));
321
360
  input!.dispatchEvent(new Event("input", { bubbles: true }));
322
361
  await nextTick();
323
362
  expect(p.emitted).toEqual([]);
324
- expect(input?.value).toBe("2026-08-16T09:30");
363
+ expect(input?.value).toBe(isoMinute(BASE));
325
364
  });
326
365
 
327
366
  it("clearing the native input falls back to the model value", async () => {
@@ -332,7 +371,7 @@ describe("HkDateTimePicker", () => {
332
371
  input!.dispatchEvent(new Event("input", { bubbles: true }));
333
372
  await nextTick();
334
373
  expect(p.emitted).toEqual([]);
335
- expect(input?.value).toBe("2026-08-16T09:30");
374
+ expect(input?.value).toBe(isoMinute(BASE));
336
375
  });
337
376
 
338
377
  it("native input replaces even the popup chrome on mobile", () => {
@@ -56,18 +56,18 @@
56
56
  z-index: 1;
57
57
  top: 50%;
58
58
  left: 50%;
59
+ // The centering translate is part of EVERY transform state — the
60
+ // enter/leave choreography scales on top of it and must never re-seat
61
+ // the frame.
59
62
  transform: translate(-50%, -50%);
60
- interpolate-size: allow-keywords;
61
63
  width: 100%;
62
64
  // Size changes animate instead of snapping: `height: auto` growth
63
65
  // cannot fire a CSS transition (verified in Chromium — neither auto
64
66
  // nor calc-size(auto, size) changes its computed value on content
65
- // growth, even under interpolate-size), so HkModal's useSizeMorph
66
- // measures the frame's natural (capped) height and pins it as px on
67
- // every content change — the transition below animates old→new px.
68
- // `interpolate-size: allow-keywords` stays for the enter/leave height
69
- // choreography's calc-size() keywords. The enter/leave classes below
70
- // override this transition during open/close, as they always have.
67
+ // growth), so HkModal's useSizeMorph measures the frame's natural
68
+ // (capped) height and pins it as px on every content change — the
69
+ // transition below animates old→new px. Both pinned values are
70
+ // explicit px, so this needs no interpolate-size keyword support.
71
71
  transition:
72
72
  height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
73
73
  max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
@@ -88,47 +88,46 @@
88
88
  // ------
89
89
  // Content enter/leave transitions
90
90
  //
91
- // Height-clipped reveal: the frame grows from 80% of its
92
- // natural height to full height while fading in. Content
93
- // children are pinned at natural size (not squashed) —
94
- // overflow: hidden on the frame clips the viewport.
91
+ // Compositor-driven pop: the frame fades and scales 0.94 → 1 — transform
92
+ // and opacity ONLY, no layout properties. The previous desktop reveal
93
+ // animated `height` (calc-size keywords): height is a layout property, so
94
+ // every animation frame re-laid-out the entire modal subtree — for a
95
+ // content-heavy modal (a full graph stage, long report bodies) that is
96
+ // visible frame drops through the whole open/close, made worse by the
97
+ // frame's own backdrop-filter whose sampling region then also changed per
98
+ // frame. transform/opacity run on the compositor: zero layout, zero
99
+ // paint, and the blurred surface keeps a constant region. Mirrors the
100
+ // mobile sheet's translateY reveal, which has always been transform-based.
95
101
  // ------
96
102
 
97
103
  .hk-modal-content-enter-active {
98
104
  transition:
99
105
  opacity var(--hk-modal-duration, 0.3s)
100
106
  var(--hk-modal-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
101
- height var(--hk-modal-duration, 0.3s)
107
+ transform var(--hk-modal-duration, 0.3s)
102
108
  var(--hk-modal-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
103
- will-change: height, opacity;
109
+ will-change: transform, opacity;
104
110
  }
105
111
 
106
112
  .hk-modal-content-leave-active {
107
113
  transition:
108
114
  opacity var(--hk-modal-duration, 0.25s)
109
115
  var(--hk-modal-ease-in, cubic-bezier(0.4, 0, 1, 1)),
110
- height var(--hk-modal-duration, 0.25s)
116
+ transform var(--hk-modal-duration, 0.25s)
111
117
  var(--hk-modal-ease-in, cubic-bezier(0.4, 0, 1, 1));
112
- will-change: height, opacity;
113
- }
114
-
115
- // Pin children to natural flex-basis during animation so the
116
- // frame's overflow: hidden does the clipping, not flex-shrink.
117
- .hk-modal-content-enter-active > *,
118
- .hk-modal-content-leave-active > * {
119
- flex: 0 0 auto;
118
+ will-change: transform, opacity;
120
119
  }
121
120
 
122
121
  .hk-modal-content-enter-from,
123
122
  .hk-modal-content-leave-to {
124
123
  opacity: 0;
125
- height: calc-size(auto, size * 0.8);
124
+ transform: translate(-50%, -50%) scale(0.94);
126
125
  }
127
126
 
128
127
  .hk-modal-content-enter-to,
129
128
  .hk-modal-content-leave-from {
130
129
  opacity: 1;
131
- height: auto;
130
+ transform: translate(-50%, -50%) scale(1);
132
131
  }
133
132
 
134
133
  // ------
@@ -344,7 +344,8 @@ export default defineComponent({
344
344
  surfTrack.cancel();
345
345
  onAfterEnter();
346
346
  // Size morphs arm once the open choreography finished —
347
- // pinning during enter would override its height reveal.
347
+ // pinning during enter would fight the reveal's transform
348
+ // transition.
348
349
  morph.start();
349
350
  } else if (to === "closingFrom") {
350
351
  // Close-request bookkeeping (was the watcher's close arm): the
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Source contract: the toast stacks are floating layers too, so their
3
+ * screen-edge inset rides the same shared --viewport-gutter token as the
4
+ * anchored popups (16px desktop / 8px mobile, hikari #501). Pinned here
5
+ * so a refactor cannot quietly re-hardcode a pixel value or drift the
6
+ * fallback away from the token's declared desktop magnitude.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import { readFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+
15
+ describe("toast container viewport gutter contract", () => {
16
+ for (const sheet of ["HkToast.scss", "HkBlockingToast.scss"]) {
17
+ it(`${sheet} pins the container inset to the shared gutter token`, () => {
18
+ const css = readFileSync(join(here, sheet), "utf-8");
19
+ // EVERY inset-inline-end declaration in the sheet must be the
20
+ // gutter var — no re-hardcoded pixel sibling may creep in.
21
+ const decls = css.match(/inset-inline-end:[^;]+;/g) ?? [];
22
+ expect(decls).toEqual(["inset-inline-end: var(--viewport-gutter, 16px);"]);
23
+ });
24
+ }
25
+ });
@@ -3,7 +3,7 @@
3
3
  top: 4rem;
4
4
  /* Same viewport gutter the anchored popups clamp to (16px desktop /
5
5
  * 8px mobile) — the toast stack is a floating layer too. */
6
- inset-inline-end: var(--viewport-gutter, 1rem);
6
+ inset-inline-end: var(--viewport-gutter, 16px);
7
7
  /* Toast band — the topmost popup z band (POPUP_Z_BANDS.toast; mirror in
8
8
  theme.scss). The live value arrives inline from the popup manager. */
9
9
  z-index: var(--hk-z-toast, 4000);
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import { installHistorySafetyNet, sanitizeHistoryUrl } from "./historySafetyNet";
4
+ import { getHkRuntimeEntry } from "./registry";
4
5
 
5
6
  /**
6
7
  * The field bug this net closes (chest #754 lineage): some producer
@@ -136,4 +137,28 @@ describe("installHistorySafetyNet", () => {
136
137
  expect(sessionStorage.getItem("hikari:historyNet")).toBeNull();
137
138
  vi.restoreAllMocks();
138
139
  });
140
+
141
+ it("keeps the first install's fold target when a HALF-overwritten net re-patches", () => {
142
+ // The recorded half-self-heal edge: pushState keeps our patch while an
143
+ // external lib restores replaceState to native. A re-install with new
144
+ // options must NOT silently adopt them for the re-patched method —
145
+ // the still-installed first capture wins for behavior AND meta, or a
146
+ // per-method option split ships to production unnoticed.
147
+ installHistorySafetyNet({ fallback: "/" });
148
+ // Another library restores replaceState (pushState stays ours).
149
+ History.prototype.replaceState = nativeReplace;
150
+ // Re-install with a different fallback: the pushState marker makes
151
+ // this a no-op claim, so "/" must stay the active fold target.
152
+ installHistorySafetyNet({ fallback: "/landing" });
153
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
154
+ history.replaceState(null, "", "https://evil.example/x");
155
+ // Folded to the FIRST install's "/" — never the re-install's
156
+ // "/landing" (which, being same-origin, would have navigated).
157
+ expect(window.location.pathname).toBe("/");
158
+ expect(sessionStorage.getItem("hikari:historyNet")).toContain("evil.example/x");
159
+ // ...and the registry meta reports the option set that is actually
160
+ // wired into the prototype, not the newest call's.
161
+ expect(getHkRuntimeEntry("historySafetyNet")?.meta?.fallback).toBe("/");
162
+ warn.mockRestore();
163
+ });
139
164
  });
@@ -95,9 +95,15 @@ let historyNetRuntime: ReturnType<typeof reportHkRuntime> | null = null;
95
95
  * our patch is still on the prototype skips re-patching (the `continue`
96
96
  * below), so the first install's fallback/evidence stay in effect — the
97
97
  * registry meta must report THOSE, not the newest call's options, or it
98
- * would misreport the active fold target. */
98
+ * would misreport the active fold target. The re-patch closure reads
99
+ * THESE module captures, never the invocation locals: when a net is
100
+ * HALF overwritten (say replaceState restored to native while pushState
101
+ * is still ours) a re-install patches the foreign method with the
102
+ * still-active capture, so behavior and meta can never diverge into
103
+ * per-method options. */
99
104
  let activeFallback = "/";
100
105
  let activeEvidenceKey: string | false = DEFAULT_EVIDENCE_KEY;
106
+ let activeOptions: HistorySafetyNetOptions = {};
101
107
  function ensureHistoryNetRuntime() {
102
108
  return (historyNetRuntime ??= reportHkRuntime("historySafetyNet", {
103
109
  kind: "hook",
@@ -120,6 +126,7 @@ export function installHistorySafetyNet(options: HistorySafetyNetOptions = {}):
120
126
  // active; a no-op re-install keeps the first install's capture.
121
127
  activeFallback = fallback;
122
128
  activeEvidenceKey = evidenceKey;
129
+ activeOptions = options;
123
130
  }
124
131
  ensureHistoryNetRuntime().setMeta({ fallback: activeFallback, evidenceKey: activeEvidenceKey === false ? "off" : activeEvidenceKey });
125
132
  ensureHistoryNetRuntime().pulse();
@@ -133,13 +140,16 @@ export function installHistorySafetyNet(options: HistorySafetyNetOptions = {}):
133
140
  }
134
141
  const native = current as (this: History, state: unknown, title: string, url?: string | URL | null) => unknown;
135
142
  if (typeof native !== "function") continue;
143
+ // The closure reads the ACTIVE capture, never this call's locals —
144
+ // the half-overwrite path above re-patches a foreign method while
145
+ // the first install's options stay in effect.
136
146
  const patched = function (this: History, state: unknown, title: string, url?: string | URL | null) {
137
- const safe = sanitizeHistoryUrl(url, fallback);
147
+ const safe = sanitizeHistoryUrl(url, activeFallback);
138
148
  if (safe !== url) {
139
- const info: HistoryCoercion = { method, raw: String(url ?? ""), foldedTo: fallback };
149
+ const info: HistoryCoercion = { method, raw: String(url ?? ""), foldedTo: activeFallback };
140
150
  coercions += 1;
141
151
  historyNetRuntime?.pulse({ coercions });
142
- reportCoercion(info, evidenceKey, options);
152
+ reportCoercion(info, activeEvidenceKey, activeOptions);
143
153
  }
144
154
  return native.call(this, state as never, title, safe as never);
145
155
  };