@celestia-island/hikari 0.55.189 → 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 +1 -1
- package/src/components/HkAffixPicker.scss +2 -2
- package/src/components/HkAuthSubmitButton.scss +1 -1
- package/src/components/HkAvatar.scss +2 -2
- package/src/components/HkBlockingToast.scss +6 -2
- package/src/components/HkConfirmDialog.scss +1 -1
- package/src/components/HkCornerBadge.scss +1 -1
- package/src/components/HkDockBar.scss +8 -2
- package/src/components/HkDrawer.motion.test.ts +111 -0
- package/src/components/HkDrawer.scss +24 -0
- package/src/components/HkIconButtonGroup.scss +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/HkMenu.scss +1 -1
- package/src/components/HkMessageBox.scss +1 -1
- package/src/components/HkToast.scss +4 -2
- package/src/components/_menu-item.scss +4 -4
- package/src/styles/admin-tokens.scss +3 -3
- package/src/styles/channelFallbacks.test.ts +8 -6
- package/src/styles/ghostChannels.contract.test.ts +138 -0
package/package.json
CHANGED
|
@@ -196,8 +196,8 @@
|
|
|
196
196
|
|
|
197
197
|
&:hover,
|
|
198
198
|
&:focus-visible {
|
|
199
|
-
color: rgb(var(--color-
|
|
200
|
-
background: rgb(var(--color-
|
|
199
|
+
color: rgb(var(--color-error, 247 118 142));
|
|
200
|
+
background: rgb(var(--color-error, 247 118 142) / 12%);
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
align-items: center;
|
|
8
8
|
justify-content: center;
|
|
9
9
|
overflow: hidden;
|
|
10
|
-
background-color: var(--
|
|
11
|
-
color: var(--
|
|
10
|
+
background-color: rgb(var(--color-muted, 108 108 108) / 15%);
|
|
11
|
+
color: rgb(var(--color-text-secondary, 102 102 102));
|
|
12
12
|
transition: all 0.2s ease;
|
|
13
13
|
|
|
14
14
|
img {
|
|
@@ -80,17 +80,21 @@
|
|
|
80
80
|
// dark-on-amber warning text for contrast).
|
|
81
81
|
.hk-blocking-toast-info {
|
|
82
82
|
background: var(--hi-color-info, #6abed6);
|
|
83
|
-
|
|
83
|
+
/* On-solid content: ride the canonical slot (HkToast family), keep the
|
|
84
|
+
* #fff fallback so themeless hosts render as before. */
|
|
85
|
+
color: var(--hi-color-text-on-solid, #fff);
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
.hk-blocking-toast-warning {
|
|
87
89
|
background: var(--hi-color-warning, #eed677);
|
|
90
|
+
/* Deliberate DARK-on-amber: the warning band is light by design, and
|
|
91
|
+
* the on-solid slot is white — a white glyph here would be unreadable. */
|
|
88
92
|
color: #1c1c1e;
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
.hk-blocking-toast-danger {
|
|
92
96
|
background: var(--hi-color-danger, #f85149);
|
|
93
|
-
color: #fff;
|
|
97
|
+
color: var(--hi-color-text-on-danger, #fff);
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
// ── Actions on a tinted surface ─────────────────────────────────────
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
.hk-confirm-dialog-message {
|
|
18
18
|
margin: 0;
|
|
19
19
|
color: color-mix(in srgb, var(--hi-color-text-secondary) 85%, transparent);
|
|
20
|
-
font-size: var(--
|
|
20
|
+
font-size: var(--text-sm, 0.8125rem);
|
|
21
21
|
line-height: 1.6;
|
|
22
22
|
overflow-wrap: anywhere;
|
|
23
23
|
}
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
background: var(--hk-badge-bg);
|
|
74
74
|
color: var(--hk-badge-text);
|
|
75
75
|
border: 1px solid var(--hk-badge-border);
|
|
76
|
-
font-size: var(--text-
|
|
76
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
77
77
|
font-weight: 700;
|
|
78
78
|
line-height: 1;
|
|
79
79
|
font-variant-numeric: tabular-nums;
|
|
@@ -117,8 +117,14 @@
|
|
|
117
117
|
border: 1px solid var(--border-subtle);
|
|
118
118
|
border-radius: var(--radius-md);
|
|
119
119
|
/* Reverse soft light — dark mode: white halo; light mode: soft black
|
|
120
|
-
* shadow. Strength scales with --glow-scale (Style → Shadow).
|
|
121
|
-
|
|
120
|
+
* shadow. Strength scales with --glow-scale (Style → Shadow). The
|
|
121
|
+
* default derives the halo from the theme's own ink channel, which IS
|
|
122
|
+
* mode-reversed — before 2026-10-19 the bare var(--glow-dock) had no
|
|
123
|
+
* publisher and no fallback, so the dock halo silently never rendered. */
|
|
124
|
+
box-shadow: var(
|
|
125
|
+
--glow-dock,
|
|
126
|
+
0 8px 32px rgb(var(--color-text, 30 30 30) / calc(8% * var(--glow-scale, 1)))
|
|
127
|
+
);
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
/* Vertical side docks (west / east edges) stack along the viewport's
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import * as sass from "sass";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Side-drawer motion contract (2026-10-19 phone recording).
|
|
10
|
+
*
|
|
11
|
+
* The left/right drawer used to share the scrim's opacity fade: at
|
|
12
|
+
* halfway through the close the panel was still on screen but its right
|
|
13
|
+
* half was transparent, so the page behind showed through and the right
|
|
14
|
+
* rounded corners dissolved — it read as a width shrink. Side drawers
|
|
15
|
+
* must slide as an opaque surface (transform only); the scrim keeps its
|
|
16
|
+
* own fade, and top/bottom sheets keep the fade.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const src = readFileSync(join(here, "HkDrawer.scss"), "utf-8");
|
|
21
|
+
|
|
22
|
+
// The side-drawer overrides sit after the generic blocks and before the
|
|
23
|
+
// reduced-motion block (which also lists the active classes with
|
|
24
|
+
// `transition: none`, so the full-file last occurrence would find that).
|
|
25
|
+
const sideStart = src.indexOf("/* Side drawers slide as an OPAQUE surface.");
|
|
26
|
+
const reduceStart = src.indexOf("@media (prefers-reduced-motion: reduce)");
|
|
27
|
+
const side = src.slice(sideStart, reduceStart);
|
|
28
|
+
|
|
29
|
+
/** Body of the LAST rule for a selector — the side overrides are declared
|
|
30
|
+
* after the generic four-direction blocks. */
|
|
31
|
+
function lastRule(selector: string, from = src): string {
|
|
32
|
+
const at = from.lastIndexOf(selector);
|
|
33
|
+
expect(at, `${selector} exists`).toBeGreaterThanOrEqual(0);
|
|
34
|
+
const open = from.indexOf("{", at);
|
|
35
|
+
const close = from.indexOf("}", open);
|
|
36
|
+
return from.slice(open + 1, close);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("HkDrawer side motion (opaque slide)", () => {
|
|
40
|
+
it("does not fade the left/right panel out", () => {
|
|
41
|
+
for (const sel of [
|
|
42
|
+
".hk-drawer-left-enter-active",
|
|
43
|
+
".hk-drawer-left-leave-active",
|
|
44
|
+
".hk-drawer-right-enter-active",
|
|
45
|
+
".hk-drawer-right-leave-active",
|
|
46
|
+
]) {
|
|
47
|
+
const rule = lastRule(sel, side);
|
|
48
|
+
expect(rule, sel).toContain("transform");
|
|
49
|
+
expect(rule, sel).not.toContain("opacity");
|
|
50
|
+
}
|
|
51
|
+
for (const sel of [
|
|
52
|
+
".hk-drawer-left-enter-from",
|
|
53
|
+
".hk-drawer-left-leave-to",
|
|
54
|
+
".hk-drawer-right-enter-from",
|
|
55
|
+
".hk-drawer-right-leave-to",
|
|
56
|
+
]) {
|
|
57
|
+
expect(lastRule(sel, side), sel).toContain("opacity: 1");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps the opaque side overrides after the generic fade rules (compiled order)", () => {
|
|
62
|
+
// The source slices above are anchored on a comment marker; this
|
|
63
|
+
// compiled assertion pins the cascade itself: the side override must
|
|
64
|
+
// come AFTER the generic four-direction block in the emitted CSS.
|
|
65
|
+
const fullCss = sass.compile(resolve(here, "HkDrawer.scss")).css;
|
|
66
|
+
// The reduced-motion block repeats the active selectors with
|
|
67
|
+
// `transition: none`; exclude it so the last occurrence is the side
|
|
68
|
+
// override under test.
|
|
69
|
+
const reduceAt = fullCss.lastIndexOf(
|
|
70
|
+
"@media (prefers-reduced-motion: reduce)",
|
|
71
|
+
);
|
|
72
|
+
const css = fullCss.slice(0, reduceAt > 0 ? reduceAt : fullCss.length);
|
|
73
|
+
const genericAt = css.indexOf(".hk-drawer-left-enter-active");
|
|
74
|
+
const overrideAt = css.lastIndexOf(".hk-drawer-left-enter-active");
|
|
75
|
+
expect(overrideAt).toBeGreaterThan(genericAt);
|
|
76
|
+
const overrideBody = css.slice(overrideAt, css.indexOf("}", overrideAt));
|
|
77
|
+
expect(overrideBody).toContain("transform");
|
|
78
|
+
expect(overrideBody).not.toContain("opacity");
|
|
79
|
+
|
|
80
|
+
const genericFromAt = css.indexOf(".hk-drawer-left-enter-from");
|
|
81
|
+
const sideFromAt = css.lastIndexOf(".hk-drawer-left-enter-from");
|
|
82
|
+
expect(sideFromAt).toBeGreaterThan(genericFromAt);
|
|
83
|
+
const sideFromBody = css.slice(sideFromAt, css.indexOf("}", sideFromAt));
|
|
84
|
+
expect(sideFromBody).toContain("opacity: 1");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps the sheet active opacity transition", () => {
|
|
88
|
+
// R3 mutation: deleting the generic active opacity transition kept the
|
|
89
|
+
// suite green. Guard that top/bottom sheets still animate opacity.
|
|
90
|
+
const generic = src.slice(0, sideStart);
|
|
91
|
+
expect(lastRule(".hk-drawer-top-enter-active", generic)).toContain("opacity");
|
|
92
|
+
expect(lastRule(".hk-drawer-bottom-leave-active", generic)).toContain(
|
|
93
|
+
"opacity",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps the off-screen side transforms", () => {
|
|
98
|
+
// The side override only resets opacity to 1; the generic from/to
|
|
99
|
+
// blocks must keep the translateX(±100%) that carries the panel in
|
|
100
|
+
// and out of the screen.
|
|
101
|
+
const left = src.slice(src.indexOf(".hk-drawer-left-enter-from"));
|
|
102
|
+
expect(left.slice(0, left.indexOf("}") + 1)).toContain("translateX(-100%)");
|
|
103
|
+
const right = src.slice(src.indexOf(".hk-drawer-right-enter-from"));
|
|
104
|
+
expect(right.slice(0, right.indexOf("}") + 1)).toContain("translateX(100%)");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("keeps the fade for top/bottom sheets", () => {
|
|
108
|
+
expect(lastRule(".hk-drawer-top-enter-from")).toContain("opacity: 0");
|
|
109
|
+
expect(lastRule(".hk-drawer-bottom-enter-from")).toContain("opacity: 0");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -123,6 +123,30 @@
|
|
|
123
123
|
transform: translateY(100%);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/* Side drawers slide as an OPAQUE surface. The panel used to share the
|
|
127
|
+
* scrim's fade (opacity: 0 in the enter/leave states); on a phone the
|
|
128
|
+
* drawer was still partly on screen when its right half went transparent
|
|
129
|
+
* — the page behind showed through and the right rounded corners
|
|
130
|
+
* dissolved, which reads as a width shrink (screen recording
|
|
131
|
+
* 2026-10-19). The scrim keeps its own fade; the panel only translates.
|
|
132
|
+
* Top/bottom sheets keep the fade above. */
|
|
133
|
+
.hk-drawer-left-enter-active,
|
|
134
|
+
.hk-drawer-right-enter-active {
|
|
135
|
+
transition: transform var(--duration-normal, 0.3s) cubic-bezier(0.16, 1, 0.3, 1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.hk-drawer-left-leave-active,
|
|
139
|
+
.hk-drawer-right-leave-active {
|
|
140
|
+
transition: transform var(--duration-normal, 0.3s) cubic-bezier(0.4, 0, 1, 1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.hk-drawer-left-enter-from,
|
|
144
|
+
.hk-drawer-left-leave-to,
|
|
145
|
+
.hk-drawer-right-enter-from,
|
|
146
|
+
.hk-drawer-right-leave-to {
|
|
147
|
+
opacity: 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
126
150
|
.hk-drawer-header {
|
|
127
151
|
display: flex;
|
|
128
152
|
align-items: center;
|
|
@@ -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}
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
padding: 0 var(--space-4, 4px);
|
|
133
133
|
border-radius: var(--radius-full, 9999px);
|
|
134
134
|
background: rgb(var(--color-error, 247 118 142));
|
|
135
|
-
color: #fff;
|
|
135
|
+
color: var(--hi-color-text-on-danger, #fff);
|
|
136
136
|
font-size: var(--text-2xs, 10px);
|
|
137
137
|
font-weight: 700;
|
|
138
138
|
line-height: 1;
|
|
@@ -115,8 +115,10 @@
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
.hk-toast-loading-spinner {
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
/* The spinner rides the toast's SOLID band, so its glyph is on-solid
|
|
119
|
+
* content: ride the on-solid slot (white by default, theme-following) */
|
|
120
|
+
border: 2px solid rgb(var(--color-on-solid-text, 255 255 255) / 30%);
|
|
121
|
+
border-top-color: rgb(var(--color-on-solid-text, 255 255 255));
|
|
120
122
|
border-radius: 50%;
|
|
121
123
|
animation: hk-toast-spin 0.7s linear infinite;
|
|
122
124
|
box-sizing: border-box;
|
|
@@ -168,14 +168,14 @@
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
&[data-danger] {
|
|
171
|
-
color: rgb(var(--color-
|
|
171
|
+
color: rgb(var(--color-error, 247 118 142) / 90%);
|
|
172
172
|
|
|
173
173
|
/* Same checked-row guard as the base hover above: a checked danger
|
|
174
174
|
* row keeps its checked face instead of flashing the hover wash. */
|
|
175
175
|
&:hover:not(:disabled):not([data-disabled]):not([data-active]):not([data-checked]) {
|
|
176
|
-
background: rgb(var(--color-
|
|
177
|
-
border-color: rgb(var(--color-
|
|
178
|
-
color: rgb(var(--color-
|
|
176
|
+
background: rgb(var(--color-error, 247 118 142) / 10%);
|
|
177
|
+
border-color: rgb(var(--color-error, 247 118 142) / 25%);
|
|
178
|
+
color: rgb(var(--color-error, 247 118 142));
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
}
|
|
@@ -1179,7 +1179,7 @@
|
|
|
1179
1179
|
|
|
1180
1180
|
/* ── Arona app utility classes ── */
|
|
1181
1181
|
.a-text-primary {
|
|
1182
|
-
color: var(--color-text
|
|
1182
|
+
color: var(--color-text);
|
|
1183
1183
|
}
|
|
1184
1184
|
|
|
1185
1185
|
.a-text-secondary {
|
|
@@ -1207,11 +1207,11 @@
|
|
|
1207
1207
|
}
|
|
1208
1208
|
|
|
1209
1209
|
.a-bg-secondary {
|
|
1210
|
-
background: var(--color-
|
|
1210
|
+
background: rgb(var(--color-background, 214 236 240));
|
|
1211
1211
|
}
|
|
1212
1212
|
|
|
1213
1213
|
.a-bg-tertiary {
|
|
1214
|
-
background: var(--color-
|
|
1214
|
+
background: rgb(var(--color-surface, 240 244 248));
|
|
1215
1215
|
}
|
|
1216
1216
|
|
|
1217
1217
|
.a-latency-text {
|
|
@@ -33,18 +33,20 @@ import { describe, expect, it } from "vitest";
|
|
|
33
33
|
* is still the regression this guard exists to catch.
|
|
34
34
|
*
|
|
35
35
|
* Slots with no seed are listed in UNSEEDED rather than skipped silently:
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* The retired spellings that used to live in UNSEEDED —
|
|
37
|
+
* `--color-bg-secondary` / `--color-bg-tertiary` / `--color-danger` — were
|
|
38
|
+
* paid on 2026-10-19 (their last usages re-pointed to the seeded channels;
|
|
39
|
+
* see ghostChannels.contract.test.ts, which now forbids them outright).
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
42
|
const stylesDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
43
43
|
const packageDir = resolve(stylesDir, "..", "..");
|
|
44
44
|
const legacyDir = resolve(packageDir, "..", "theme", "styles");
|
|
45
45
|
|
|
46
|
-
/** Slots this guard cannot hold to the seed
|
|
47
|
-
|
|
46
|
+
/** Slots this guard cannot hold to the seed. The 2026-10-19 re-pointing
|
|
47
|
+
* paid the debt this list used to record; it stays as an empty-seeded
|
|
48
|
+
* invariant — a NEW unseeded slot must be justified here, not smuggled in. */
|
|
49
|
+
const UNSEEDED: string[] = [];
|
|
48
50
|
|
|
49
51
|
function read(path: string): string {
|
|
50
52
|
return readFileSync(path, "utf8");
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retired color spellings — source contract (2026-10-19 color audit).
|
|
3
|
+
*
|
|
4
|
+
* The audit swept every var() reference across the shipped sheets and the
|
|
5
|
+
* components crate against the two real emitters (vue's tokensToCSSVars
|
|
6
|
+
* channels + the Rust css.rs alias emissions) and found a family of ghost
|
|
7
|
+
* spellings: names that LOOK like theme tokens, were never emitted by
|
|
8
|
+
* anything, and therefore always resolved to their (often one-mode-biased)
|
|
9
|
+
* literal fallbacks — or, with no fallback, silently killed the face
|
|
10
|
+
* (hover washes → transparent, glows → none). Each entry below was
|
|
11
|
+
* re-pointed to its canonical channel in the same change that added this
|
|
12
|
+
* guard; this file exists so none of them can quietly come back.
|
|
13
|
+
*
|
|
14
|
+
* Scope: shipped sheets and crate style strings only (tests, demos and
|
|
15
|
+
* comments are stripped). Not a general resolver — a name lands here only
|
|
16
|
+
* when it is permanently retired, never to be emitted; names that ARE
|
|
17
|
+
* emitted are guarded by their own contracts (see
|
|
18
|
+
* theme::css::interaction_alias_tests in the crate).
|
|
19
|
+
*/
|
|
20
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
21
|
+
import { dirname, join, resolve } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
import { describe, expect, it } from "vitest";
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const vueDir = resolve(here, "..", "..");
|
|
28
|
+
const repoDir = resolve(vueDir, "..", "..");
|
|
29
|
+
const crateSrc = join(repoDir, "packages", "components", "src");
|
|
30
|
+
const themeStyles = join(repoDir, "packages", "theme", "styles");
|
|
31
|
+
|
|
32
|
+
/** Permanently retired spellings — a var() reference to any of these is a
|
|
33
|
+
* regression. (Names that are merely host-tunable knobs live elsewhere;
|
|
34
|
+
* this list is only for spellings the emitters must never grow.) */
|
|
35
|
+
const RETIRED = [
|
|
36
|
+
// wrong channel: the danger channel is --color-error (seed 247 118 142);
|
|
37
|
+
// --color-danger was emitted by nothing and its 240 70 80 fallback was a
|
|
38
|
+
// second, drifting palette decision (the 2026-09-16 bug class again).
|
|
39
|
+
"--color-danger",
|
|
40
|
+
// retired spellings: the audit's admin-surface backgrounds ride
|
|
41
|
+
// --color-background / --color-surface now.
|
|
42
|
+
"--color-bg-secondary",
|
|
43
|
+
"--color-bg-tertiary",
|
|
44
|
+
// the vue-channel namespace has no --color-text-primary slot (the primary
|
|
45
|
+
// ink channel is --color-text); the admin .a-text-primary utility rode
|
|
46
|
+
// this phantom spelling with no fallback and inherited its parent ink.
|
|
47
|
+
"--color-text-primary",
|
|
48
|
+
// the alias family spells the primary ink slot --hi-color-text-primary;
|
|
49
|
+
// plain --hi-color-text never existed, and its #333 fallback was unreadable
|
|
50
|
+
// on dark surfaces (the exact bug class of the 2026-10-18 breadcrumb fix).
|
|
51
|
+
"--hi-color-text",
|
|
52
|
+
// the crate emits --hi-border-light; the --hi-color-border-light spelling
|
|
53
|
+
// was a ghost whose #f3f4f6 fallback was light-biased.
|
|
54
|
+
"--hi-color-border-light",
|
|
55
|
+
// spelling typo that was never emitted anywhere; the canonical scale token
|
|
56
|
+
// is --text-sm.
|
|
57
|
+
"--hi-text-sm",
|
|
58
|
+
// invented family prefix nothing emits; the values were already inline
|
|
59
|
+
// (unwrapped to the same literals).
|
|
60
|
+
"--hikari-duration-fast",
|
|
61
|
+
"--hikari-duration-normal",
|
|
62
|
+
"--hikari-ease-smooth",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function stripComments(source: string): string {
|
|
66
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Boundary-aware retired-spelling probe: `var(--name` NOT followed by
|
|
70
|
+
* another name character, so --hi-color-text does not collide with the
|
|
71
|
+
* legitimate --hi-color-text-primary/-secondary emissions. */
|
|
72
|
+
function referencesRetired(source: string, name: string): boolean {
|
|
73
|
+
const re = new RegExp(`var\\(\\s*${name}(?![a-zA-Z-])`);
|
|
74
|
+
return re.test(source);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function* sheetsUnder(dir: string): Generator<string> {
|
|
78
|
+
for (const entry of readdirSync(dir)) {
|
|
79
|
+
const p = join(dir, entry);
|
|
80
|
+
if (statSync(p).isDirectory()) {
|
|
81
|
+
if (entry === "node_modules" || entry === "target" || entry.includes(".test.")) continue;
|
|
82
|
+
yield* sheetsUnder(p);
|
|
83
|
+
} else if (entry.endsWith(".scss") || entry.endsWith(".css")) {
|
|
84
|
+
yield p;
|
|
85
|
+
} else if (entry.endsWith(".rs") && !entry.endsWith(".test.rs")) {
|
|
86
|
+
yield p;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("retired color spellings", () => {
|
|
92
|
+
it("never appears in a shipped vue sheet", () => {
|
|
93
|
+
for (const file of sheetsUnder(join(vueDir, "src", "styles"))) {
|
|
94
|
+
const text = stripComments(readFileSync(file, "utf8"));
|
|
95
|
+
for (const name of RETIRED) {
|
|
96
|
+
expect(
|
|
97
|
+
referencesRetired(text, name),
|
|
98
|
+
`${file.replace(vueDir, "packages/vue")} references retired spelling ${name}`,
|
|
99
|
+
).toBe(false);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("never appears in a shipped component scss", () => {
|
|
105
|
+
for (const file of sheetsUnder(join(vueDir, "src", "components"))) {
|
|
106
|
+
const text = stripComments(readFileSync(file, "utf8"));
|
|
107
|
+
for (const name of RETIRED) {
|
|
108
|
+
expect(
|
|
109
|
+
referencesRetired(text, name),
|
|
110
|
+
`${file.replace(vueDir, "packages/vue")} references retired spelling ${name}`,
|
|
111
|
+
).toBe(false);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("never appears in the components crate styles or rs style strings", () => {
|
|
117
|
+
for (const root of [crateSrc, themeStyles]) {
|
|
118
|
+
for (const file of sheetsUnder(root)) {
|
|
119
|
+
const text = stripComments(readFileSync(file, "utf8"));
|
|
120
|
+
for (const name of RETIRED) {
|
|
121
|
+
expect(
|
|
122
|
+
referencesRetired(text, name),
|
|
123
|
+
`${file.replace(repoDir, "packages")} references retired spelling ${name}`,
|
|
124
|
+
).toBe(false);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("keeps its own denylist honest (positive control)", () => {
|
|
131
|
+
// The sweep above must be able to see a violation: feed it a synthetic
|
|
132
|
+
// line carrying a retired spelling and assert the detector matches.
|
|
133
|
+
const probe = "color: var(--color-danger, 240 70 80);";
|
|
134
|
+
expect(referencesRetired(probe, "--color-danger")).toBe(true);
|
|
135
|
+
// and the boundary holds: a legit longer name must NOT trip the probe
|
|
136
|
+
expect(referencesRetired("color: var(--color-dangerous);", "--color-danger")).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|