@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 +1 -1
- package/src/components/HkAmbientLayer.styles.test.ts +19 -0
- package/src/components/HkCalendarHeatmap.scss +13 -6
- package/src/components/HkScreenIdentifyCard.styles.test.ts +28 -5
- package/src/components/HkSettingsDialog.test.tsx +169 -0
- package/src/components/HkSettingsDialog.tsx +129 -2
- package/src/components/calendarHeatmapRail.contract.test.ts +63 -0
- package/src/index.ts +1 -0
- package/src/theme/useSolarTime.test.ts +14 -0
- package/src/theme/useSolarTime.ts +23 -9
package/package.json
CHANGED
|
@@ -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)
|
|
46
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|