@celestia-island/hikari 0.55.281 → 0.55.283

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.281",
3
+ "version": "0.55.283",
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",
@@ -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>
@@ -1,10 +1,16 @@
1
- /* HkSolarLocationSetting — the solar-clock location strategy row and
1
+ /* HkSolarLocationSetting — the solar-clock location strategy card and
2
2
  * its editor modal body (see the component docblock for the strategy
3
3
  * ladder). AI-generated: developed with the ZCode agent (model
4
4
  * GLM-5.3); human-directed. */
5
5
 
6
- /* ── The settings-page row: live summary + Edit button ────────────────── */
6
+ /* ── The settings-page card (HkCard root; header/body classes are the
7
+ * card family's own) ─────────────────────────────────────────────── */
7
8
  .hk-solar-setting {
9
+ min-width: 0;
10
+ }
11
+
12
+ /* ── The card body's live summary + Edit button line ──────────────────── */
13
+ .hk-solar-row {
8
14
  display: flex;
9
15
  align-items: center;
10
16
  gap: var(--space-8, 0.5rem);
@@ -59,6 +65,61 @@
59
65
  line-height: 1.5;
60
66
  }
61
67
 
68
+ /* ── Detected-value readout (timezone / ip panes) ───────────────────────
69
+ * One info item per line: a fixed label column and a value column of
70
+ * copyable tags, so long values keep their own row and the two columns
71
+ * stay aligned across rows. */
72
+ .hk-solar-detected {
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: var(--space-8, 0.5rem);
76
+ }
77
+
78
+ .hk-solar-detected-row {
79
+ display: grid;
80
+ grid-template-columns: max-content minmax(0, 1fr);
81
+ gap: var(--space-8, 0.5rem);
82
+ align-items: start;
83
+ }
84
+
85
+ .hk-solar-detected-label {
86
+ padding-top: var(--space-1, 0.125rem);
87
+ font-size: var(--text-xs);
88
+ color: rgb(var(--color-muted));
89
+ /* Long localized labels wrap instead of pushing the value column out. */
90
+ overflow-wrap: anywhere;
91
+ }
92
+
93
+ .hk-solar-detected-value {
94
+ display: flex;
95
+ flex-wrap: wrap;
96
+ gap: var(--space-6, 0.375rem);
97
+ min-width: 0;
98
+ }
99
+
100
+ /* Values are labels, not prose — but a readout value that outruns the
101
+ * modal soft-wraps inside its chip instead of clipping or overflowing. */
102
+ .hk-solar-detected-value .hk-badge {
103
+ white-space: normal;
104
+ overflow-wrap: anywhere;
105
+ text-align: left;
106
+ }
107
+
108
+ /* Probe status line (IP pane): muted progress / failure note, with the
109
+ * retry affordance inline after the text. */
110
+ .hk-solar-detected-status {
111
+ display: flex;
112
+ align-items: center;
113
+ flex-wrap: wrap;
114
+ gap: var(--space-6, 0.375rem);
115
+ color: rgb(var(--color-muted));
116
+ font-size: var(--text-xs);
117
+ }
118
+
119
+ .hk-solar-detected-retry {
120
+ flex-shrink: 0;
121
+ }
122
+
62
123
  .hk-solar-manual {
63
124
  display: flex;
64
125
  flex-direction: column;