@celestia-island/hikari 0.55.282 → 0.55.284

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.282",
3
+ "version": "0.55.284",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -35,6 +35,25 @@ describe("HkAmbientLayer pointer contract", () => {
35
35
  const blockStart = declarations.lastIndexOf('[data-pointer-events="auto"]', autoAt);
36
36
  expect(blockStart).toBeGreaterThan(-1);
37
37
  expect(declarations.slice(blockStart, autoAt)).toContain("> *");
38
+ // EVERY ruleset is checked by SCOPE, not proximity: any block that
39
+ // states pointer-events:auto must be the child-scoped opt-in, and any
40
+ // other block stating pointer-events at all must say none (an earlier
41
+ // round flagged both the first-only and the proximity-based shapes).
42
+ const rulesets = [...declarations.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(
43
+ ([, selector, body]) => ({ selector: selector.trim(), body }),
44
+ );
45
+ expect(rulesets.length).toBeGreaterThan(0);
46
+ for (const { selector, body } of rulesets) {
47
+ if (!/pointer-events/.test(body)) continue;
48
+ if (/pointer-events:\s*auto/.test(body)) {
49
+ expect(selector).toContain('[data-pointer-events="auto"]');
50
+ expect(selector).toContain("> *");
51
+ } else {
52
+ expect(body).toMatch(/pointer-events:\s*none/);
53
+ }
54
+ }
55
+ // The wrapper's own block states the ghost rule exactly once.
56
+ expect(declarations.match(/pointer-events:\s*none/g)).toHaveLength(1);
38
57
  });
39
58
 
40
59
  it("sits on the HUD ordinal band, below its root's document elevation", () => {
@@ -42,12 +42,20 @@
42
42
  }
43
43
 
44
44
  // Weekday rail: the first (Monday) and last (Sunday) rows carry the
45
- // labels (the wowsp convention); sticky so it survives horizontal
46
- // scroll on narrow screens.
45
+ // labels (the wowsp convention). `align-self: flex-end` rows-aligns
46
+ // the rail with the GRID, not with the -cells box: -cells is one
47
+ // marker strip taller than the grid (the month labels ride above
48
+ // it), so a stretched rail started its seven rows on the markers and
49
+ // every label sat one strip too high (2026-11-05 user report from
50
+ // the chest quota card). The rail's natural height (7 cells + 6
51
+ // gaps) equals the grid's exactly, so end-alignment is row-exact.
52
+ // No sticky mask on purpose: a hard-coded surface can't match every
53
+ // host card (it painted a visible stripe behind the labels), and
54
+ // sticky without a mask lets cells slide beneath the labels during
55
+ // horizontal scroll — the rail scrolls with the calendar instead
56
+ // (the GitHub convention).
47
57
  &-rail {
48
- position: sticky;
49
- left: 0;
50
- z-index: 1;
58
+ align-self: flex-end;
51
59
  display: flex;
52
60
  flex-direction: column;
53
61
  flex-shrink: 0;
@@ -55,7 +63,6 @@
55
63
  font-size: 10px;
56
64
  line-height: 14px;
57
65
  color: var(--hi-color-muted);
58
- background: var(--hi-color-surface);
59
66
 
60
67
  > span {
61
68
  white-space: nowrap;
@@ -54,12 +54,35 @@ describe("HkScreenIdentifyCard layout contract", () => {
54
54
  });
55
55
 
56
56
  it("parks the card in every corner through placement attributes", () => {
57
- for (const placement of ["bottom-right", "top-left", "top-right"]) {
58
- expect(scss).toContain(`&[data-placement="${placement}"]`);
57
+ // Each non-default corner must OVERRIDE the exact edges that move it
58
+ // there — an earlier round flagged this guard as presence-only, so a
59
+ // placement block that forgot its edge rule stayed green.
60
+ const corners: Array<[string, RegExp[]]> = [
61
+ // End-side corners must ALSO release the logical start edge — an
62
+ // over-constrained inset would resolve to the start edge and leave
63
+ // the card bottom-left no matter the placement (R1 V-3 probe).
64
+ ["bottom-right", [/inset-inline-end:\s*var\(--space-16/, /inset-inline-start:\s*auto/]],
65
+ ["top-left", [/top:\s*var\(--space-16/, /bottom:\s*auto/]],
66
+ [
67
+ "top-right",
68
+ [
69
+ /inset-inline-end:\s*var\(--space-16/,
70
+ /inset-inline-start:\s*auto/,
71
+ /top:\s*var\(--space-16[\s\S]*bottom:\s*auto/,
72
+ ],
73
+ ],
74
+ ];
75
+ for (const [placement, edges] of corners) {
76
+ const block = ruleBlock(`&[data-placement="${placement}"]`);
77
+ for (const edge of edges) {
78
+ expect(block).toMatch(edge);
79
+ }
59
80
  }
60
- // Left/right use logical properties so RTL mirrors the pair while
61
- // the card stays at the same corner of the glass.
62
- expect(ruleBlock(".hk-screen-identify")).toMatch(/inset-inline-start:/);
81
+ // The default corner's own block must not have been turned into a
82
+ // catch-all: it keeps the bottom-left seat via the logical edge only.
83
+ const card = ruleBlock(".hk-screen-identify");
84
+ expect(card).toMatch(/inset-inline-start:\s*var\(--space-16/);
85
+ expect(card).toMatch(/bottom:\s*var\(--space-16/);
63
86
  });
64
87
 
65
88
  it("rides hikari text tokens for every font size except the numeral", () => {
@@ -3,12 +3,15 @@ import { readFileSync } from "node:fs";
3
3
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
4
4
  import { Languages, Globe } from "lucide-vue-next";
5
5
  import {
6
+ SHEET_MORPH_MS,
7
+ sheetMorphMs,
6
8
  HkSettingsBody as HSettingsBody,
7
9
  HkSettingsDialog as HSettingsDialog,
8
10
  HkSettingsGroup as HSettingsGroup,
9
11
  HkSettingsHint as HSettingsHint,
10
12
  HkSettingsSub as HSettingsSub,
11
13
  } from "./HkSettingsDialog";
14
+ import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
12
15
 
13
16
  function mount(component: ReturnType<typeof defineComponent>) {
14
17
  const container = document.createElement("div");
@@ -524,3 +527,169 @@ describe("phone shell (rail → top tab strip, 2026-10-28 user direction)", () =
524
527
  expect(phoneBlock).toContain("font-size: var(--text-base);");
525
528
  });
526
529
  });
530
+
531
+ /**
532
+ * Docked-sheet height hand-off (2026-11-04 chest phone report).
533
+ *
534
+ * Inside a phone sheet the modal sizes the window from this shell's
535
+ * content, but it observes `.hk-modal-body-inner` — pinned at
536
+ * `height: 100%` by the host rule — so a section swap never changes the
537
+ * observed box and the sheet kept the height of whichever section it
538
+ * OPENED with (the About card clipped inside a ~24px pane). The shell
539
+ * therefore forwards the pane's settled swap through the family's
540
+ * `hk-stepflow-swap` protocol, which HkModal already turns into a frame
541
+ * re-measure.
542
+ *
543
+ * The morph container only emits `swap` once its ResizeObserver has seen
544
+ * the incoming stage and its settle timer has run, so these tests drive
545
+ * the observer directly (happy-dom ships no layout engine: every rect
546
+ * would read zero, which the container correctly reads as "same box" and
547
+ * never settles).
548
+ */
549
+ class StageResizeObserver implements ResizeObserver {
550
+ static all: StageResizeObserver[] = [];
551
+ observed: Element[] = [];
552
+ private readonly callback: ResizeObserverCallback;
553
+ constructor(callback: ResizeObserverCallback) {
554
+ this.callback = callback;
555
+ StageResizeObserver.all.push(this);
556
+ }
557
+ observe(target: Element): void {
558
+ this.observed.push(target);
559
+ }
560
+ unobserve(): void {}
561
+ disconnect(): void {}
562
+ /** Fire the callback for the observer watching the transition stage. */
563
+ static settleStage(height = 220): boolean {
564
+ const ro = StageResizeObserver.all.find((o) =>
565
+ o.observed.some((el) => el.classList.contains("hk-tc-stage")),
566
+ );
567
+ if (!ro) return false;
568
+ // Only `contentRect` is consumed by the container; the rest of the
569
+ // entry descriptor is layout detail happy-dom cannot produce.
570
+ const entry = { contentRect: { width: 320, height } } as ResizeObserverEntry;
571
+ ro.callback([entry], ro);
572
+ return true;
573
+ }
574
+ }
575
+
576
+ describe("HSettingsBody — docked-sheet height hand-off", () => {
577
+ const originalRO = globalThis.ResizeObserver;
578
+ const originalMatchMedia = window.matchMedia;
579
+
580
+ afterEach(() => {
581
+ globalThis.ResizeObserver = originalRO;
582
+ window.matchMedia = originalMatchMedia;
583
+ StageResizeObserver.all = [];
584
+ document.documentElement.removeAttribute("data-css-animations");
585
+ });
586
+
587
+ function mountWithSwapLog() {
588
+ StageResizeObserver.all = [];
589
+ globalThis.ResizeObserver = StageResizeObserver;
590
+ const events: Array<{ phase?: string; durationMs?: number }> = [];
591
+ const container = document.createElement("div");
592
+ document.body.appendChild(container);
593
+ container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
594
+ events.push((e as CustomEvent).detail ?? {});
595
+ });
596
+ const app = createApp(Host);
597
+ app.mount(container);
598
+ return { container, events, unmount: () => { app.unmount(); container.remove(); } };
599
+ }
600
+
601
+ async function switchToNetwork(container: HTMLElement) {
602
+ container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item")[1].click();
603
+ await nextTick();
604
+ }
605
+
606
+ it("forwards a settled section swap as a morph beat for the modal", async () => {
607
+ const { container, events, unmount } = mountWithSwapLog();
608
+ await switchToNetwork(container);
609
+ expect(StageResizeObserver.settleStage(220)).toBe(true);
610
+ // The container arms its settle timer for the choreography remainder
611
+ // (300ms default) plus a 40ms grace; give it room.
612
+ await new Promise((r) => setTimeout(r, 500));
613
+ expect(events).toEqual([{ phase: "morph", durationMs: 300 }]);
614
+ unmount();
615
+ });
616
+
617
+ it("announces an instant settle as soon as the pane height is final", async () => {
618
+ // Growing swap: the stage has already reached its incoming height when
619
+ // the container settles, so the sheet must be told on the first frame —
620
+ // not after the bounded wait (which exists only for the leaving
621
+ // generation still holding a shrink at the old height).
622
+ window.matchMedia = (query: string): MediaQueryList => ({
623
+ matches: query.includes("prefers-reduced-motion"),
624
+ media: query,
625
+ onchange: null,
626
+ addListener: () => undefined,
627
+ removeListener: () => undefined,
628
+ addEventListener: () => undefined,
629
+ removeEventListener: () => undefined,
630
+ dispatchEvent: () => false,
631
+ });
632
+ const { container, events, unmount } = mountWithSwapLog();
633
+ const host = container.querySelector(".hk-tc") as HTMLElement;
634
+ let height = 100;
635
+ Object.defineProperty(host, "offsetHeight", {
636
+ configurable: true,
637
+ get: () => height,
638
+ });
639
+ await switchToNetwork(container); // records the pre-swap height (100)
640
+ height = 150; // the incoming section is taller
641
+ expect(StageResizeObserver.settleStage(220)).toBe(true);
642
+ await new Promise((r) => setTimeout(r, 120));
643
+ expect(events).toEqual([{ phase: "instant" }]);
644
+ unmount();
645
+ });
646
+
647
+ it("snaps the sheet when the swap settles with no animation", async () => {
648
+ // Reduced motion zeroes the container's morph duration.
649
+ window.matchMedia = (query: string): MediaQueryList => ({
650
+ matches: query.includes("prefers-reduced-motion"),
651
+ media: query,
652
+ onchange: null,
653
+ addListener: () => undefined,
654
+ removeListener: () => undefined,
655
+ addEventListener: () => undefined,
656
+ removeEventListener: () => undefined,
657
+ dispatchEvent: () => false,
658
+ });
659
+ const { container, events, unmount } = mountWithSwapLog();
660
+ const host = container.querySelector(".hk-tc") as HTMLElement;
661
+ Object.defineProperty(host, "offsetHeight", {
662
+ configurable: true,
663
+ get: () => 150,
664
+ });
665
+ await switchToNetwork(container);
666
+ expect(StageResizeObserver.settleStage(220)).toBe(true);
667
+ // No layout in happy-dom: the stage never changes, so the instant
668
+ // branch must WAIT out its bounded settle window (a shrink keeps the
669
+ // leaving generation in the flow) — nothing may be announced early…
670
+ await new Promise((r) => setTimeout(r, 150));
671
+ expect(events).toEqual([]);
672
+ // …and the bounded window must still fire (a swap that never changes
673
+ // height must not swallow the announcement).
674
+ await new Promise((r) => setTimeout(r, 500));
675
+ expect(events).toEqual([{ phase: "instant" }]);
676
+ unmount();
677
+ });
678
+ });
679
+
680
+
681
+ describe("sheetMorphMs — the sheet's own morph window", () => {
682
+ it("caps the container's full choreography at the family morph beat", () => {
683
+ // A height-changing swap reports enterDelay + morph (600ms); the sheet
684
+ // must resize inside the family's 300ms window, not twice as long.
685
+ expect(sheetMorphMs(600)).toBe(SHEET_MORPH_MS);
686
+ expect(sheetMorphMs(SHEET_MORPH_MS)).toBe(SHEET_MORPH_MS);
687
+ expect(sheetMorphMs(120)).toBe(120);
688
+ });
689
+
690
+ it("maps a non-positive choreography to the instant snap", () => {
691
+ expect(sheetMorphMs(0)).toBe(0);
692
+ expect(sheetMorphMs(-1)).toBe(0);
693
+ expect(sheetMorphMs(Number.NaN)).toBe(0);
694
+ });
695
+ });
@@ -2,6 +2,7 @@ import {
2
2
  defineComponent,
3
3
  h,
4
4
  nextTick,
5
+ onBeforeUnmount,
5
6
  ref,
6
7
  watch,
7
8
  type Component,
@@ -9,12 +10,24 @@ import {
9
10
  } from "vue";
10
11
 
11
12
  import HkTransitionContainer from "./HkTransitionContainer";
13
+ import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
12
14
  import HkScrollContainer from "./HkScrollContainer";
13
15
  import HTabs from "./HkTabs";
14
16
  import { useBreakpoint } from "../runtime/useBreakpoint";
15
17
  import HModal from "./HkModal";
16
18
  import "./HkSettingsDialog.scss";
17
19
 
20
+ /** The family's sheet-morph window (HkStepFlow announces the same beat):
21
+ * the pane container reports its WHOLE choreography (enter delay + morph),
22
+ * but the sheet's own resize must not inherit that doubling. */
23
+ export const SHEET_MORPH_MS = 300;
24
+
25
+ /** Morph duration to hand the modal for a settled swap. */
26
+ export function sheetMorphMs(choreographyMs: number): number {
27
+ if (!(choreographyMs > 0)) return 0;
28
+ return Math.min(choreographyMs, SHEET_MORPH_MS);
29
+ }
30
+
18
31
  /** One rail entry of the settings dialog. */
19
32
  export interface HkSettingsSection {
20
33
  /** Identity of the section — also the name of the slot that renders its
@@ -63,6 +76,22 @@ export interface HkSettingsSection {
63
76
  * Desktop keeps the rail exactly as it was; the flip is live on resize
64
77
  * and shared by every consumer of this shell (page-embedded bodies
65
78
  * included).
79
+ *
80
+ * Docked-sheet height hand-off (2026-11-04 chest report): inside a phone
81
+ * sheet the modal sizes the window from this shell's content, but it
82
+ * measures the frame through `.hk-modal-body-inner` — which the host
83
+ * rule pins at `height: 100%` (unscoped, so it pins the phone sheet too),
84
+ * so the observed box never changes on a section swap and the sheet froze
85
+ * at whichever section it OPENED with (the 470px About card inside a 24px
86
+ * pane; the freeze survives hikari #1041's phone fill). Every settled
87
+ * swap of the pane's morph container is therefore forwarded as the
88
+ * family's sheet protocol event (`hk-stepflow-swap`, detail
89
+ * `{phase: "morph", durationMs}`, where the duration is capped to the
90
+ * family's own sheet morph beat — the container reports its whole
91
+ * choreography, enter delay included — or `{phase: "instant"}` when the
92
+ * swap settled with no animation), which HkModal already turns into a
93
+ * frame re-measure. Page-embedded bodies (no modal ancestor) simply
94
+ * bubble it nowhere.
66
95
  */
67
96
  export const HkSettingsBody = defineComponent({
68
97
  name: "HkSettingsBody",
@@ -114,16 +143,109 @@ export const HkSettingsBody = defineComponent({
114
143
  // typed here — the scroll reset needs the live viewport element and
115
144
  // nothing else.
116
145
  const paneRef = ref<{ getScrollElement(): HTMLElement | undefined } | null>(null);
146
+ const shellRef = ref<HTMLElement | null>(null);
147
+ const morphRef = ref<{ $el?: HTMLElement } | null>(null);
148
+ let stageBeforeSwap = 0;
149
+ // One instant-wait loop at a time: a new section change (or unmount)
150
+ // retires the previous epoch so a stale poll can never announce against
151
+ // the next swap's half-settled geometry.
152
+ let instantEpoch = 0;
153
+ let instantPoll: number | null = null;
154
+ const scheduleFrame = (cb: () => void): number =>
155
+ typeof requestAnimationFrame === "function"
156
+ ? requestAnimationFrame(cb)
157
+ : window.setTimeout(cb, 16);
158
+ const cancelFrame = (id: number): void => {
159
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(id);
160
+ else clearTimeout(id);
161
+ };
162
+ const cancelInstantPoll = (): void => {
163
+ if (instantPoll !== null) {
164
+ cancelFrame(instantPoll);
165
+ instantPoll = null;
166
+ }
167
+ };
168
+ onBeforeUnmount(() => {
169
+ instantEpoch += 1;
170
+ cancelInstantPoll();
171
+ });
117
172
  // Section switches restart the pane from the top. Written through the
118
173
  // public ref slice (the viewport element) rather than scrollTo so the
119
174
  // reset stays synchronous and testable in no-layout environments.
120
175
  watch(active, () => {
176
+ // The instant settle below needs the height the pane had BEFORE the
177
+ // swap; watchers run before the component patches, so this is still
178
+ // the outgoing stage.
179
+ instantEpoch += 1;
180
+ cancelInstantPoll();
181
+ stageBeforeSwap = morphRef.value?.$el?.offsetHeight ?? 0;
121
182
  void nextTick(() => {
122
183
  const viewport = paneRef.value?.getScrollElement();
123
184
  if (viewport) viewport.scrollTop = 0;
124
185
  });
125
186
  });
126
187
 
188
+ // ── docked-sheet height hand-off ──────────────────────────────────
189
+ // A phone sheet is sized from this shell's content, but HkModal
190
+ // observes `.hk-modal-body-inner`, which the host rule pins at
191
+ // `height: 100%` (see the SCSS): the box never changes on a section
192
+ // swap, so the sheet would keep the section it OPENED with — about
193
+ // 24px of pane for a 470px 「About」 card (2026-11-04 chest report;
194
+ // the same freeze survives hikari #1041). The pane's own morph
195
+ // container already knows when a swap settles; forward that beat
196
+ // through the family's sheet protocol (`hk-stepflow-swap`, the one
197
+ // HkModal listens for) so the modal re-measures the frame.
198
+ //
199
+ // `durationMs` 0 = the swap settled instantly (reduced motion / the
200
+ // global animation switch) — the protocol's `instant` phase snaps the
201
+ // sheet instead of animating. That snap must not be announced from the
202
+ // settle beat itself: with no choreography the settle lands while the
203
+ // pane's crossfade still holds the LEAVING generation in the flow, so
204
+ // the modal would measure the outgoing (taller) height and a shrink
205
+ // would no-op — the sheet stayed 621px over a 251px section in the
206
+ // round-1 probe. Wait (bounded) for the stage to actually change, then
207
+ // hand the beat over; a growing swap has already changed by then and
208
+ // dispatches on the first frame.
209
+ function dispatchSheetSwap(durationMs: number): void {
210
+ const el = shellRef.value;
211
+ if (!el) return;
212
+ el.dispatchEvent(
213
+ new CustomEvent(STEPFLOW_SWAP_EVENT, {
214
+ bubbles: true,
215
+ detail:
216
+ durationMs > 0
217
+ ? { phase: "morph", durationMs }
218
+ : { phase: "instant" },
219
+ }),
220
+ );
221
+ }
222
+
223
+ function announceSheetSwap(payload: { durationMs: number }): void {
224
+ if (!shellRef.value) return;
225
+ if (payload.durationMs > 0) {
226
+ dispatchSheetSwap(sheetMorphMs(payload.durationMs));
227
+ return;
228
+ }
229
+ cancelInstantPoll();
230
+ const epoch = instantEpoch;
231
+ const stage = (): number => morphRef.value?.$el?.offsetHeight ?? 0;
232
+ const before = stageBeforeSwap;
233
+ const deadline = Date.now() + 400;
234
+ const tick = (): void => {
235
+ if (epoch !== instantEpoch) {
236
+ instantPoll = null;
237
+ return;
238
+ }
239
+ if (stage() !== before || Date.now() >= deadline) {
240
+ instantPoll = null;
241
+ dispatchSheetSwap(0);
242
+ return;
243
+ }
244
+ instantPoll = scheduleFrame(tick);
245
+ };
246
+ instantPoll = scheduleFrame(tick);
247
+ }
248
+
127
249
  function pick(key: string) {
128
250
  // Belt-and-braces: every caller already refuses disabled entries
129
251
  // upstream (rail: natively disabled buttons; strip: HTabs' disabled
@@ -144,7 +266,7 @@ export const HkSettingsBody = defineComponent({
144
266
  const { isMobile } = useBreakpoint();
145
267
 
146
268
  return () => (
147
- <div class="hk-settings" data-phone={isMobile.value || undefined}>
269
+ <div class="hk-settings" data-phone={isMobile.value || undefined} ref={shellRef}>
148
270
  {/* Scroll ownership (see the docblock): both scrollers are
149
271
  HkScrollContainers — the overlay chrome replaces the native
150
272
  bars, and the paired-class rules in the SCSS carry each
@@ -219,7 +341,12 @@ export const HkSettingsBody = defineComponent({
219
341
  incoming section's height and the contents dissolve, the
220
342
  same mechanism HkTabs panels use. A settings pane is a page
221
343
  slot — it must not hard-cut. */}
222
- <HkTransitionContainer swapKey={active()} axis="height">
344
+ <HkTransitionContainer
345
+ ref={morphRef}
346
+ swapKey={active()}
347
+ axis="height"
348
+ onSwap={(payload: { durationMs: number }) => announceSheetSwap(payload)}
349
+ >
223
350
  {slots[active()]?.()}
224
351
  </HkTransitionContainer>
225
352
  </HkScrollContainer>
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Source contract for the HkCalendarHeatmap weekday rail geometry
3
+ * (2026-11-05 user report from the chest quota card: the Monday/Sunday
4
+ * labels sat one marker strip too high, and a hard-coded surface fill
5
+ * painted a visible stripe behind them).
6
+ *
7
+ * jsdom does no layout, so the row alignment itself cannot be asserted
8
+ * here; these pins hold the two sheet decisions the fix rests on:
9
+ *
10
+ * 1. `align-self: flex-end` — the -cells box is one marker strip taller
11
+ * than the grid (the month labels ride above it); a stretched rail
12
+ * starts its seven rows on the markers. The rail's natural height
13
+ * (7 cells + 6 gaps) equals the grid's exactly, so end-alignment is
14
+ * row-exact. This also assumes the grid stays the LAST child of
15
+ * -cells with no trailing padding — the third pin below.
16
+ * 2. no sticky mask — a hard-coded surface can't match every host
17
+ * card, and sticky without a mask lets cells slide beneath the
18
+ * labels during horizontal scroll. The rail scrolls with the
19
+ * calendar (the GitHub convention).
20
+ */
21
+ import { describe, expect, it } from "vitest";
22
+ import { readFileSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ const sheet = readFileSync(join(here, "HkCalendarHeatmap.scss"), "utf-8");
28
+
29
+ // The `&-rail` rule up to its closing brace (two-space indent inside
30
+ // the `.hk-calendar-heatmap` block) — keeps the assertions scoped to
31
+ // the rail (a background elsewhere in the sheet is legitimate).
32
+ const rail = sheet.match(/&-rail \{[\s\S]*?\n \}/)?.[0] ?? "";
33
+ // Same for `-cells`, whose last-child geometry the alignment relies on.
34
+ const cells = sheet.match(/&-cells \{[\s\S]*?\n \}/)?.[0] ?? "";
35
+
36
+ describe("HkCalendarHeatmap weekday rail contract", () => {
37
+ it("extracts the rail rule from the sheet", () => {
38
+ expect(rail, "the &-rail rule must exist").toContain("&-rail {");
39
+ });
40
+
41
+ it("bottom-aligns the rail with the grid, not the markers row", () => {
42
+ expect(rail, "rail must carry align-self: flex-end").toContain(
43
+ "align-self: flex-end",
44
+ );
45
+ });
46
+
47
+ it("carries no sticky mask (no hard-coded surface fill)", () => {
48
+ expect(rail, "rail must not paint a hard-coded surface").not.toContain(
49
+ "background:",
50
+ );
51
+ expect(rail, "rail must not go sticky").not.toContain("position: sticky");
52
+ expect(rail, "sticky's scroll pin has no business left behind").not.toContain(
53
+ "z-index",
54
+ );
55
+ });
56
+
57
+ it("keeps the grid the flush last child of -cells", () => {
58
+ expect(cells, "the &-cells rule must exist").toContain("&-cells {");
59
+ expect(cells, "trailing padding would break the end-alignment").not.toContain(
60
+ "padding",
61
+ );
62
+ });
63
+ });
package/src/index.ts CHANGED
@@ -860,6 +860,7 @@ export { default as HkDatePicker } from "./components/HkDatePicker";
860
860
  export { default as HkTimeline } from "./components/HkTimeline";
861
861
  export { default as HkTitleBar } from "./components/HkTitleBar";
862
862
  export { default as HkStepFlow } from "./components/HkStepFlow";
863
+ export { STEPFLOW_SWAP_EVENT } from "./components/HkStepFlow";
863
864
  export { default as HkScrollPin } from "./components/HkScrollPin";
864
865
  export {
865
866
  default as HkScreenIdentifyCard,
@@ -514,6 +514,20 @@ describe("solar IP insight (the strategy dialog's readout)", () => {
514
514
  expect(mod.solarIpInsightFresh(insight.fetchedAt + 11 * 60 * 1000)).toBe(false);
515
515
  });
516
516
 
517
+ it("a probe and the ip cascade's geo resolution racing share one lookup GET", async () => {
518
+ mod.setSolarLocationStrategy("ip");
519
+ mod.setGeolocationProvider(async () => null);
520
+ const fetchMock = lookupFetch();
521
+ vi.stubGlobal("fetch", fetchMock);
522
+ // The dialog probe and the theme clock's resolution race the same
523
+ // lookup — the body fetch dedupes them into one network request and
524
+ // both consumers converge on the same insight.
525
+ await Promise.all([mod.probeSolarIpInsight(), mod.getGeolocation()]);
526
+ expect(fetchMock).toHaveBeenCalledTimes(1);
527
+ expect(mod.solarIpInsight.value?.ip).toBe("203.0.113.7");
528
+ expect(mod.solarIpProbeState.value).toBe("ok");
529
+ });
530
+
517
531
  it("the ip strategy's own geo resolution records the insight from the same body", async () => {
518
532
  mod.setSolarLocationStrategy("ip");
519
533
  mod.setGeolocationProvider(async () => null);
@@ -330,16 +330,30 @@ const GEO_API_TIMEOUT_MS = 4000;
330
330
  /** One fetch of the lookup body with the shared timeout — the single
331
331
  * network path for both consumers (the "ip" strategy's geo resolution
332
332
  * and the strategy dialog's insight readout), so narrowing, abort
333
- * handling and the no-store contract live in exactly one place. */
333
+ * handling and the no-store contract live in exactly one place.
334
+ * Concurrent callers SHARE the in-flight GET (a probe and an apply's
335
+ * geo resolution racing each other used to issue two identical
336
+ * requests); failures propagate to every joiner. */
337
+ let ipBodyInflight: Promise<unknown> | null = null;
338
+
334
339
  async function fetchIpLookupBody(): Promise<unknown> {
335
- const controller = new AbortController();
336
- const timer = scheduleCronAfter(() => controller.abort(), GEO_API_TIMEOUT_MS);
337
- return fetch(GEO_API_URL, {
338
- signal: controller.signal,
339
- cache: "no-store",
340
- })
341
- .then((r) => (r.ok ? r.json() : null))
342
- .finally(() => timer.disconnect());
340
+ if (ipBodyInflight) return ipBodyInflight;
341
+ const inflight = (async () => {
342
+ const controller = new AbortController();
343
+ const timer = scheduleCronAfter(() => controller.abort(), GEO_API_TIMEOUT_MS);
344
+ return fetch(GEO_API_URL, {
345
+ signal: controller.signal,
346
+ cache: "no-store",
347
+ })
348
+ .then((r) => (r.ok ? r.json() : null))
349
+ .finally(() => timer.disconnect());
350
+ })();
351
+ ipBodyInflight = inflight;
352
+ try {
353
+ return await inflight;
354
+ } finally {
355
+ ipBodyInflight = null;
356
+ }
343
357
  }
344
358
 
345
359
  /** The trimmed-string guard for the untrusted lookup body: only a