@celestia-island/hikari 0.55.190 → 0.55.192
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/HkImageViewer.motion.test.tsx +192 -0
- package/src/components/HkImageViewer.scss +13 -3
- package/src/components/HkImageViewer.tsx +45 -1
- package/src/components/HkThemePanelContent.tsx +6 -3
- package/src/components/HkThemeToggle.scss +3 -4
- package/src/components/HkThemeToggle.test.ts +5 -2
- package/src/components/HkThemeToggle.tsx +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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={[
|
|
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}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { computed, defineComponent, onUnmounted, ref, watch, type PropType } from "vue";
|
|
2
|
-
import {
|
|
2
|
+
import { Monitor, MoonStar, Palette, Sun, Trash as Trash2 } from "lucide-vue-next";
|
|
3
3
|
import {
|
|
4
4
|
HDivider,
|
|
5
5
|
solarAltitude,
|
|
@@ -274,12 +274,15 @@ export const HkThemePanelContent = defineComponent({
|
|
|
274
274
|
data-active={currentTheme.value === th.id || undefined}
|
|
275
275
|
onClick={() => onSelectTheme(th.id)}
|
|
276
276
|
>
|
|
277
|
+
{/* The selected-check fallback is RETIRED (2026-10-21
|
|
278
|
+
user direction): selection is conveyed by the row's
|
|
279
|
+
data-active wash alone — with the leading icon cell
|
|
280
|
+
gone across hosts, the leftover check read as a
|
|
281
|
+
stray icon. No leading slot = no leading mark. */}
|
|
277
282
|
{leadingSlot ? (
|
|
278
283
|
<span class="hk-menu-item-icon s-theme-item-lead">
|
|
279
284
|
{leadingSlot(scope)}
|
|
280
285
|
</span>
|
|
281
|
-
) : currentTheme.value === th.id ? (
|
|
282
|
-
<span class="hk-menu-item-icon s-theme-item-check"><Check size={14} /></span>
|
|
283
286
|
) : null}
|
|
284
287
|
<span class="s-theme-item-name">{displayName}</span>
|
|
285
288
|
{nameSuffixSlot ? (
|
|
@@ -329,10 +329,9 @@
|
|
|
329
329
|
margin-inline-end: 2rem;
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
332
|
+
/* The selected-check cell retired with its render branch (2026-10-21
|
|
333
|
+
* user direction): the active row's data-active wash is the sole
|
|
334
|
+
* selection indicator. */
|
|
336
335
|
|
|
337
336
|
/* Hover/active states come verbatim from the unified mi.item spec included
|
|
338
337
|
* on .s-theme-item-btn above — former local copies deleted (R3 sweep). */
|
|
@@ -280,9 +280,12 @@ describe("HkThemeToggle color-mode group", () => {
|
|
|
280
280
|
}
|
|
281
281
|
expect(customRows).toBe(1);
|
|
282
282
|
|
|
283
|
-
//
|
|
283
|
+
// The active row's selection indicator is the data-active wash alone:
|
|
284
|
+
// the check glyph retired with the leading-icon cell (2026-10-21 user
|
|
285
|
+
// direction). No leading mark renders without an item-leading slot.
|
|
284
286
|
const activeBtn = document.body.querySelector<HTMLButtonElement>(".s-theme-item-btn[data-active]");
|
|
285
|
-
expect(activeBtn
|
|
287
|
+
expect(activeBtn).toBeTruthy();
|
|
288
|
+
expect(activeBtn?.querySelector(".s-theme-item-check")).toBeNull();
|
|
286
289
|
|
|
287
290
|
// The ROW is the pill (2026-10-01): the active state surfaces on the
|
|
288
291
|
// row element too, so the highlight spans the full row width instead
|
|
@@ -25,7 +25,7 @@ export { type ThemeItemScope } from "./HkThemePanelContent";
|
|
|
25
25
|
*
|
|
26
26
|
* Theme rows are host-customizable through three scoped slots (all render
|
|
27
27
|
* in the popover AND the mobile bottom sheet — same DOM):
|
|
28
|
-
* · `item-leading` —
|
|
28
|
+
* · `item-leading` — renders for
|
|
29
29
|
* every row in the fixed icon cell so names align regardless of the
|
|
30
30
|
* active row. Scope: `ThemeItemScope` (id/name/isCustom/preset).
|
|
31
31
|
* · `item-name-suffix` — an inline cell rendered INSIDE the row button,
|