@celestia-island/hikari 0.55.190 → 0.55.191

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.190",
3
+ "version": "0.55.191",
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",
@@ -0,0 +1,192 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
7
+
8
+ import HkImageViewer from "./HkImageViewer";
9
+
10
+ /**
11
+ * Initial-position motion contract (2026-10-20 lightbox fly-in report).
12
+ *
13
+ * The <img> mounts parked at translate(0, 0) scale(1) — natural size,
14
+ * anchored at the container's top-left — and only the load handler knows
15
+ * the centered fit layout. With `transition: transform` armed from the
16
+ * first paint, that reposition animated and the freshly-visible image
17
+ * flew in from the top-left corner to the center every time a lightbox
18
+ * opened. The glide must stay unarmed (no .is-animatable class) until
19
+ * the repositioned transform has COMMITTED — two frames, because rAF
20
+ * callbacks run before their frame's style recalc — and re-arm the same
21
+ * way after every src change.
22
+ */
23
+
24
+ const mounts: ReturnType<typeof createApp>[] = [];
25
+ const containers: HTMLElement[] = [];
26
+
27
+ afterEach(() => {
28
+ for (const app of mounts.splice(0)) app.unmount();
29
+ for (const el of containers.splice(0)) el.remove();
30
+ vi.unstubAllGlobals();
31
+ });
32
+
33
+ // Hand-pumped rAF queue: flushFrame() models one rendered frame, and a
34
+ // callback scheduled during a flush (the nested second hop of the arming
35
+ // chain) waits for the NEXT frame, exactly like the browser.
36
+ const frames: FrameRequestCallback[] = [];
37
+
38
+ function stubRaf(): void {
39
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback): number => {
40
+ frames.push(cb);
41
+ return frames.length;
42
+ });
43
+ vi.stubGlobal("cancelAnimationFrame", () => {});
44
+ }
45
+
46
+ function flushFrame(): void {
47
+ for (const cb of frames.splice(0)) cb(performance.now());
48
+ }
49
+
50
+ interface Setup {
51
+ img: () => HTMLImageElement;
52
+ setSrc: (src: string) => void;
53
+ /** happy-dom never decodes images: hand the viewer a natural size and
54
+ * fire the load event the layout waits on, flushed to the DOM. */
55
+ markLoaded: (w: number, h: number) => Promise<void>;
56
+ }
57
+
58
+ async function mountViewer(): Promise<Setup> {
59
+ const container = document.createElement("div");
60
+ document.body.appendChild(container);
61
+ containers.push(container);
62
+
63
+ const src = ref("/a.png");
64
+ const Wrapper = defineComponent({
65
+ setup() {
66
+ return () => h(HkImageViewer, { src: src.value, alt: "pic" });
67
+ },
68
+ });
69
+ const app = createApp(Wrapper);
70
+ mounts.push(app);
71
+ app.mount(container);
72
+
73
+ const img = () => {
74
+ const el = container.querySelector<HTMLImageElement>(".hk-image-viewer-img");
75
+ if (!el) throw new Error("viewer img not rendered");
76
+ return el;
77
+ };
78
+
79
+ async function markLoaded(w: number, h: number) {
80
+ Object.defineProperty(img(), "naturalWidth", { configurable: true, value: w });
81
+ Object.defineProperty(img(), "naturalHeight", { configurable: true, value: h });
82
+ img().dispatchEvent(new Event("load"));
83
+ await nextTick();
84
+ }
85
+
86
+ return { img, setSrc: (v: string) => { src.value = v; }, markLoaded };
87
+ }
88
+
89
+ const armed = (img: HTMLImageElement) => img.classList.contains("is-animatable");
90
+
91
+ describe("HkImageViewer initial-position motion", () => {
92
+ it("jumps straight to the centered layout on load, unarmed", async () => {
93
+ stubRaf();
94
+ const s = await mountViewer();
95
+
96
+ // Pre-load: invisible, parked at the top-left origin, glide disarmed.
97
+ expect(s.img().style.transform).toBe("translate(0px, 0px) scale(1)");
98
+ expect(armed(s.img())).toBe(false);
99
+
100
+ // Load: the transform jumps to the centered fit layout in the same
101
+ // tick (happy-dom containers measure 0x0, so an 800x600 image centers
102
+ // at translate(-400px, -300px)) and the image becomes visible — the
103
+ // arming class must NOT ride along, or this very move would glide.
104
+ await s.markLoaded(800, 600);
105
+ expect(s.img().style.transform).toBe("translate(-400px, -300px) scale(1)");
106
+ expect(s.img().classList.contains("is-loading")).toBe(false);
107
+ expect(armed(s.img())).toBe(false);
108
+
109
+ // Frame 1 commits the new transform; the class must still be absent
110
+ // here — a single-hop arming would land both in one style recalc and
111
+ // the move would animate after all.
112
+ flushFrame();
113
+ expect(armed(s.img())).toBe(false);
114
+
115
+ // Frame 2 arms the glide for wheel / pinch / minimap moves.
116
+ flushFrame();
117
+ await nextTick();
118
+ expect(armed(s.img())).toBe(true);
119
+ });
120
+
121
+ it("disarms on src change and re-arms only after the new reposition", async () => {
122
+ stubRaf();
123
+ const s = await mountViewer();
124
+ await s.markLoaded(800, 600);
125
+ flushFrame();
126
+ flushFrame();
127
+ await nextTick();
128
+ expect(armed(s.img())).toBe(true);
129
+
130
+ s.setSrc("/b.png");
131
+ await nextTick();
132
+ // Disarmed immediately, and any in-flight arming retracted: the
133
+ // swap's own reposition must land unarmed too.
134
+ expect(armed(s.img())).toBe(false);
135
+
136
+ await s.markLoaded(400, 300);
137
+ expect(s.img().style.transform).toBe("translate(-200px, -150px) scale(1)");
138
+ expect(armed(s.img())).toBe(false);
139
+ flushFrame();
140
+ expect(armed(s.img())).toBe(false);
141
+ flushFrame();
142
+ await nextTick();
143
+ expect(armed(s.img())).toBe(true);
144
+ });
145
+ });
146
+
147
+ const here = dirname(fileURLToPath(import.meta.url));
148
+ const scss = readFileSync(join(here, "HkImageViewer.scss"), "utf-8");
149
+
150
+ /** Full brace-matched body of the first rule following a marker. */
151
+ function ruleBody(marker: string, from = scss): string {
152
+ const at = from.indexOf(marker);
153
+ expect(at, `${marker} exists`).toBeGreaterThanOrEqual(0);
154
+ const open = from.indexOf("{", at);
155
+ let depth = 0;
156
+ let close = open;
157
+ for (; close < from.length; close++) {
158
+ if (from[close] === "{") depth++;
159
+ else if (from[close] === "}") {
160
+ depth--;
161
+ if (depth === 0) break;
162
+ }
163
+ }
164
+ return from.slice(open + 1, close);
165
+ }
166
+
167
+ /** A rule's OWN declarations — nested blocks (the &-selectors) stripped,
168
+ * so a transition hidden one level down cannot satisfy an assertion
169
+ * aimed at the rule's direct declarations. */
170
+ function ownDecls(marker: string, from = scss): string {
171
+ return ruleBody(marker, from).replace(/\{[^{}]*\}/g, "");
172
+ }
173
+
174
+ describe("HkImageViewer motion CSS contract", () => {
175
+ it("keeps the transform transition behind the is-animatable gate", () => {
176
+ // An unconditional transition on the base rule is exactly the fly-in
177
+ // regression: deleting the gate (or hoisting the transition back to
178
+ // the base rule) must fail here.
179
+ expect(ownDecls(".hk-image-viewer-img")).not.toContain("transition");
180
+ expect(ruleBody("&.is-animatable")).toContain("transition: transform 0.12s ease-out");
181
+ });
182
+
183
+ it("keeps reduced-motion outranking the armed transition", () => {
184
+ const reduceAt = scss.indexOf("@media (prefers-reduced-motion: reduce)");
185
+ expect(reduceAt).toBeGreaterThanOrEqual(0);
186
+ const block = scss.slice(reduceAt);
187
+ // Same (0,2,0) specificity as the armed rule, declared later in the
188
+ // file — a bare .hk-image-viewer-img override would lose the cascade
189
+ // tie to the gated rule above.
190
+ expect(ruleBody(".hk-image-viewer-img.is-animatable", block)).toContain("transition: none");
191
+ });
192
+ });
@@ -10,6 +10,11 @@
10
10
  user-select: none;
11
11
  }
12
12
 
13
+ // The transform glide sits behind the .is-animatable gate the component
14
+ // arms two frames after each load-driven reposition: an unconditional
15
+ // transition also animated the very FIRST positioning, so the freshly
16
+ // visible image flew in from its top-left natural-size origin to the
17
+ // center every time a lightbox opened (2026-10-20 fly-in report).
13
18
  .hk-image-viewer-img {
14
19
  position: absolute;
15
20
  top: 0;
@@ -17,7 +22,10 @@
17
22
  max-width: none;
18
23
  max-height: none;
19
24
  will-change: transform;
20
- transition: transform 0.12s ease-out;
25
+
26
+ &.is-animatable {
27
+ transition: transform 0.12s ease-out;
28
+ }
21
29
 
22
30
  &.is-loading {
23
31
  opacity: 0;
@@ -33,9 +41,11 @@
33
41
  }
34
42
 
35
43
  // Reduced motion: pan/zoom keeps working, the transform just stops
36
- // gliding (house pattern — HkExpansionPanel / HkAffixPicker).
44
+ // gliding (house pattern — HkExpansionPanel / HkAffixPicker). The armed
45
+ // gate selector is repeated at (0,2,0) so this still outranks the
46
+ // .is-animatable rule above.
37
47
  @media (prefers-reduced-motion: reduce) {
38
- .hk-image-viewer-img {
48
+ .hk-image-viewer-img.is-animatable {
39
49
  transition: none;
40
50
  }
41
51
  }
@@ -15,6 +15,10 @@ import "./HkImageViewer.scss";
15
15
  * double-click zoom (cursor-anchored), drag pans, and a two-finger touch
16
16
  * pinch zooms (midpoint-anchored, clamped to fit..MAX_ZOOM). The minimap
17
17
  * reuses the generic HMinimap (rendered with the image as its background).
18
+ *
19
+ * The transform glide (CSS `transition` under `.is-animatable`) stays
20
+ * DISARMED until each load-driven positioning has committed — see
21
+ * `armMotion`.
18
22
  */
19
23
  export default defineComponent({
20
24
  name: "HkImageViewer",
@@ -33,6 +37,10 @@ export default defineComponent({
33
37
  const imgW = ref(0);
34
38
  const imgH = ref(0);
35
39
  const loaded = ref(false);
40
+ /** Whether the `.is-animatable` transform glide is armed. False until
41
+ * each load-driven reposition has committed (see armMotion), so the
42
+ * initial fit/center placement never animates. */
43
+ const animatable = ref(false);
36
44
 
37
45
  const dragging = ref(false);
38
46
  const lastPt = ref({ x: 0, y: 0 });
@@ -125,6 +133,30 @@ export default defineComponent({
125
133
  center();
126
134
  }
127
135
 
136
+ /** Pending armMotion chain (outer frame, then inner); 0 when idle.
137
+ * Tracked so a src change or unmount can retract the arming. */
138
+ let armFrame = 0;
139
+
140
+ /** Arm the transform glide two committed frames after a load-driven
141
+ * reposition. The <img> mounts parked at translate(0, 0) scale(1) —
142
+ * natural size, anchored top-left — and only the load handler knows
143
+ * the centered fit layout; with the transition armed from the first
144
+ * paint, that reposition animated and the freshly-visible image flew
145
+ * in from the top-left corner (the lightbox fly-in report). ONE
146
+ * frame is not enough: rAF callbacks run before that frame's style
147
+ * recalc, so a single hop would commit the new transform and the
148
+ * .is-animatable class in the same recalc and the move would still
149
+ * transition. Frame 1 commits the transform unarmed; frame 2 arms
150
+ * the glide for wheel / pinch / minimap moves. */
151
+ function armMotion() {
152
+ cancelAnimationFrame(armFrame);
153
+ armFrame = requestAnimationFrame(() => {
154
+ armFrame = requestAnimationFrame(() => {
155
+ animatable.value = true;
156
+ });
157
+ });
158
+ }
159
+
128
160
  function onImgLoad() {
129
161
  const img = imgRef.value;
130
162
  if (!img) return;
@@ -132,6 +164,7 @@ export default defineComponent({
132
164
  imgH.value = img.naturalHeight;
133
165
  loaded.value = true;
134
166
  resetView();
167
+ armMotion();
135
168
  }
136
169
 
137
170
  function onWheel(e: WheelEvent) {
@@ -297,6 +330,7 @@ export default defineComponent({
297
330
 
298
331
  onBeforeUnmount(() => {
299
332
  const c = containerRef.value;
333
+ cancelAnimationFrame(armFrame);
300
334
  if (!c) return;
301
335
  ro?.disconnect();
302
336
  ro = null;
@@ -319,6 +353,10 @@ export default defineComponent({
319
353
  fit.value = 1;
320
354
  panX.value = 0;
321
355
  panY.value = 0;
356
+ // Retract any in-flight arming: the swap's own reposition must
357
+ // land unarmed too, and the next load re-arms on its own.
358
+ cancelAnimationFrame(armFrame);
359
+ animatable.value = false;
322
360
  },
323
361
  );
324
362
 
@@ -337,7 +375,13 @@ export default defineComponent({
337
375
  ref={imgRef}
338
376
  src={props.src}
339
377
  alt={props.alt}
340
- class={["hk-image-viewer-img", !loaded.value && "is-loading"].filter(Boolean).join(" ")}
378
+ class={[
379
+ "hk-image-viewer-img",
380
+ !loaded.value && "is-loading",
381
+ animatable.value && "is-animatable",
382
+ ]
383
+ .filter(Boolean)
384
+ .join(" ")}
341
385
  style={imgStyle.value}
342
386
  draggable={false}
343
387
  onLoad={onImgLoad}