@celestia-island/hikari 0.55.276 → 0.55.279
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/animation/registerAnimations.ts +4 -0
- package/src/components/HkAmbientLayer.scss +23 -0
- package/src/components/HkAmbientLayer.styles.test.ts +43 -0
- package/src/components/HkAmbientLayer.test.tsx +124 -0
- package/src/components/HkAmbientLayer.tsx +74 -0
- package/src/components/HkButton.test.tsx +5 -2
- package/src/components/HkJsonTree.contract.test.ts +3 -1
- package/src/components/HkModal.open-close-scale.test.ts +150 -0
- package/src/components/HkModal.scss +30 -48
- package/src/components/HkModal.tsx +1 -1
- package/src/components/HkScreenIdentify.barrel.test.ts +48 -0
- package/src/components/HkScreenIdentifyCard.scss +103 -0
- package/src/components/HkScreenIdentifyCard.styles.test.ts +89 -0
- package/src/components/HkScreenIdentifyCard.test.tsx +113 -0
- package/src/components/HkScreenIdentifyCard.tsx +90 -0
- package/src/components/HkScreenIdentifyHost.test.tsx +124 -0
- package/src/components/HkScreenIdentifyHost.tsx +62 -0
- package/src/components/HkSettingsDialog.test.tsx +5 -2
- package/src/components/HkSvgVarPresentation.test.tsx +18 -8
- package/src/components/HkWallpaperBackdrop.styles.contract.test.ts +4 -1
- package/src/i18n/locales/ar/components.json +3 -1
- package/src/i18n/locales/de/components.json +3 -1
- package/src/i18n/locales/en/components.json +3 -1
- package/src/i18n/locales/es/components.json +3 -1
- package/src/i18n/locales/fr/components.json +3 -1
- package/src/i18n/locales/ja/components.json +3 -1
- package/src/i18n/locales/ko/components.json +3 -1
- package/src/i18n/locales/pt/components.json +3 -1
- package/src/i18n/locales/ru/components.json +3 -1
- package/src/i18n/locales/zh-Hans/components.json +3 -1
- package/src/i18n/locales/zh-Hant/components.json +3 -1
- package/src/index.ts +21 -0
- package/src/overlay/hudRoot.scss +38 -0
- package/src/overlay/hudRoot.styles.test.ts +90 -0
- package/src/overlay/hudRoot.ts +85 -0
- package/src/overlay/overlayRoot.styles.test.ts +58 -17
- package/src/runtime/index.ts +9 -0
- package/src/runtime/useScreenIdentify.test.ts +138 -0
- package/src/runtime/useScreenIdentify.ts +198 -0
- package/src/scale.scss +13 -2
- package/src/styles/index.scss +1 -0
- package/src/styles/theme/scale.scss +13 -2
- package/src/test-utils/vueJsxSsrRegisterHelper.ts +41 -0
- package/src/components/HkModal.close-collapse.test.ts +0 -79
package/package.json
CHANGED
|
@@ -134,3 +134,7 @@ registerCssAnimation("hk-persistent-toast-card-in");
|
|
|
134
134
|
// The styles/theme/mixins.scss ambient/glow/entrance keyframes were
|
|
135
135
|
// retired with the legacy Gen-1 sheet vendoring (hikari #476) — their
|
|
136
136
|
// registrar entries went with them.
|
|
137
|
+
// components/HkScreenIdentifyCard.scss — the identify card's entrance
|
|
138
|
+
// (opacity + 6px rise, plays once; the card unmounts at dwell end, so
|
|
139
|
+
// no leave keyframe).
|
|
140
|
+
registerCssAnimation("hk-screen-identify-in");
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// HkAmbientLayer — the ambient HUD host (see HkAmbientLayer.tsx).
|
|
2
|
+
//
|
|
3
|
+
// The wrapper lives INSIDE #hk-hud-root (overlay/hudRoot.scss owns the
|
|
4
|
+
// root's document elevation). It spans the viewport so slot children can
|
|
5
|
+
// position themselves against the screen edges, but it is a ghost for
|
|
6
|
+
// pointers: annotations must never block the window they annotate.
|
|
7
|
+
|
|
8
|
+
.hk-ambient-layer {
|
|
9
|
+
position: fixed;
|
|
10
|
+
inset: 0;
|
|
11
|
+
z-index: var(--hk-overlay-z-hud, 1);
|
|
12
|
+
overflow: visible;
|
|
13
|
+
pointer-events: none;
|
|
14
|
+
|
|
15
|
+
// The one opt-in for a clickable HUD targets the slot CONTENT, never
|
|
16
|
+
// the wrapper: the wrapper spans the viewport, so re-enabling pointer
|
|
17
|
+
// events on it would swallow every click on the page behind an
|
|
18
|
+
// "interactive" HUD (R2 S2). Children restore their own
|
|
19
|
+
// pointer-events, the layer itself stays a ghost.
|
|
20
|
+
&[data-pointer-events="auto"] > * {
|
|
21
|
+
pointer-events: auto;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HkAmbientLayer layout contract (R2 S2): the layer spans the viewport so
|
|
9
|
+
* slot children can position themselves against the screen edges, which
|
|
10
|
+
* means the WRAPPER must never take pointer input — enabling it there
|
|
11
|
+
* would make a transparent full-screen div swallow every click on the
|
|
12
|
+
* page. The `pointerEvents="auto"` opt-in therefore targets the slot
|
|
13
|
+
* children only.
|
|
14
|
+
*/
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const scss = readFileSync(join(here, "HkAmbientLayer.scss"), "utf8");
|
|
17
|
+
|
|
18
|
+
/** Comments stripped: a comment naming a property must not satisfy a
|
|
19
|
+
* guard about it. */
|
|
20
|
+
const declarations = scss.replace(/\/\/[^\n]*/g, "");
|
|
21
|
+
|
|
22
|
+
describe("HkAmbientLayer pointer contract", () => {
|
|
23
|
+
it("spans the viewport but stays a pointer ghost", () => {
|
|
24
|
+
expect(declarations).toMatch(/position:\s*fixed;/);
|
|
25
|
+
expect(declarations).toMatch(/inset:\s*0;/);
|
|
26
|
+
expect(declarations).toMatch(/pointer-events:\s*none;/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("opt-in pointer events target the slot children, not the wrapper", () => {
|
|
30
|
+
// The auto declaration must live inside the CHILD-scoped block. This
|
|
31
|
+
// is the S2 footgun: a transparent full-viewport wrapper with
|
|
32
|
+
// pointer-events:auto swallows every page click.
|
|
33
|
+
const autoAt = declarations.indexOf("pointer-events: auto");
|
|
34
|
+
expect(autoAt).toBeGreaterThan(-1);
|
|
35
|
+
const blockStart = declarations.lastIndexOf('[data-pointer-events="auto"]', autoAt);
|
|
36
|
+
expect(blockStart).toBeGreaterThan(-1);
|
|
37
|
+
expect(declarations.slice(blockStart, autoAt)).toContain("> *");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("sits on the HUD ordinal band, below its root's document elevation", () => {
|
|
41
|
+
expect(declarations).toMatch(/z-index:\s*var\(--hk-overlay-z-hud/);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkAmbientLayer from "./HkAmbientLayer";
|
|
5
|
+
|
|
6
|
+
type LayerProps = {
|
|
7
|
+
pointerEvents?: "none" | "auto";
|
|
8
|
+
zIndex?: number;
|
|
9
|
+
label?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* HkAmbientLayer contract:
|
|
14
|
+
* - the slot teleports into #hk-hud-root (never <body> directly), which
|
|
15
|
+
* is what makes an annotation out-paint the whole popup tree;
|
|
16
|
+
* - the wrapper is a pointer ghost by default and exposes its own
|
|
17
|
+
* pointer-events opt-in;
|
|
18
|
+
* - zIndex orders ambient layers against each other inside the root;
|
|
19
|
+
* - the label turns the layer into a named landmark; no label keeps it
|
|
20
|
+
* decorative (the card carries its own status role);
|
|
21
|
+
* - unmounting removes the content from the root.
|
|
22
|
+
*
|
|
23
|
+
* (Repo test convention: raw createApp + document queries.)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const mounts: Array<() => void> = [];
|
|
27
|
+
|
|
28
|
+
function mountLayer(props: LayerProps = {}, slotText = "HUD") {
|
|
29
|
+
const container = document.createElement("div");
|
|
30
|
+
document.body.appendChild(container);
|
|
31
|
+
const app = createApp(
|
|
32
|
+
defineComponent({
|
|
33
|
+
setup() {
|
|
34
|
+
return () =>
|
|
35
|
+
h(
|
|
36
|
+
HkAmbientLayer,
|
|
37
|
+
props,
|
|
38
|
+
{ default: () => h("span", { class: "hud-probe" }, slotText) },
|
|
39
|
+
);
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
app.mount(container);
|
|
44
|
+
mounts.push(() => {
|
|
45
|
+
app.unmount();
|
|
46
|
+
container.remove();
|
|
47
|
+
});
|
|
48
|
+
return container;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
while (mounts.length) mounts.pop()?.();
|
|
53
|
+
// The HUD root is a module-owned singleton; leave the DOM tidy for the
|
|
54
|
+
// next case (the root itself stays, layer content must be gone).
|
|
55
|
+
document.querySelectorAll("#hk-hud-root .hk-ambient-layer").forEach((el) => el.remove());
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("HkAmbientLayer", () => {
|
|
59
|
+
it("teleports the slot into the ambient HUD root", async () => {
|
|
60
|
+
mountLayer();
|
|
61
|
+
await nextTick();
|
|
62
|
+
const root = document.querySelector("#hk-hud-root");
|
|
63
|
+
expect(root).not.toBeNull();
|
|
64
|
+
const layer = root!.querySelector(".hk-ambient-layer");
|
|
65
|
+
expect(layer).not.toBeNull();
|
|
66
|
+
expect(layer!.querySelector(".hud-probe")?.textContent).toBe("HUD");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not leave the slot in <body> outside the root", async () => {
|
|
70
|
+
const container = mountLayer();
|
|
71
|
+
await nextTick();
|
|
72
|
+
expect(container.querySelector(".hud-probe")).toBeNull();
|
|
73
|
+
const inRoot = document
|
|
74
|
+
.querySelector("#hk-hud-root")!
|
|
75
|
+
.querySelector(".hud-probe");
|
|
76
|
+
expect(inRoot).not.toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("is a pointer ghost by default and exposes the auto opt-in", async () => {
|
|
80
|
+
mountLayer();
|
|
81
|
+
await nextTick();
|
|
82
|
+
const layer = document.querySelector<HTMLElement>("#hk-hud-root .hk-ambient-layer")!;
|
|
83
|
+
expect(layer.getAttribute("data-pointer-events")).toBe("none");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("carries the auto pointer mode when asked", async () => {
|
|
87
|
+
mountLayer({ pointerEvents: "auto" });
|
|
88
|
+
await nextTick();
|
|
89
|
+
const layer = document.querySelector<HTMLElement>("#hk-hud-root .hk-ambient-layer")!;
|
|
90
|
+
expect(layer.getAttribute("data-pointer-events")).toBe("auto");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("orders ambient layers by the zIndex prop inside the root", async () => {
|
|
94
|
+
mountLayer({ zIndex: 7 });
|
|
95
|
+
await nextTick();
|
|
96
|
+
const layer = document.querySelector<HTMLElement>("#hk-hud-root .hk-ambient-layer")!;
|
|
97
|
+
expect(layer.style.zIndex).toBe("7");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("labels the layer as a landmark only when a label is given", async () => {
|
|
101
|
+
mountLayer();
|
|
102
|
+
await nextTick();
|
|
103
|
+
let layer = document.querySelector<HTMLElement>("#hk-hud-root .hk-ambient-layer")!;
|
|
104
|
+
expect(layer.hasAttribute("role")).toBe(false);
|
|
105
|
+
expect(layer.hasAttribute("aria-label")).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("turns a labelled layer into a named region", async () => {
|
|
109
|
+
mountLayer({ label: "Recording" });
|
|
110
|
+
await nextTick();
|
|
111
|
+
const layer = document.querySelector<HTMLElement>("#hk-hud-root .hk-ambient-layer")!;
|
|
112
|
+
expect(layer.getAttribute("role")).toBe("region");
|
|
113
|
+
expect(layer.getAttribute("aria-label")).toBe("Recording");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("removes its content from the root on unmount", async () => {
|
|
117
|
+
mountLayer();
|
|
118
|
+
await nextTick();
|
|
119
|
+
expect(document.querySelector("#hk-hud-root .hud-probe")).not.toBeNull();
|
|
120
|
+
mounts.pop()?.();
|
|
121
|
+
await nextTick();
|
|
122
|
+
expect(document.querySelector("#hk-hud-root .hud-probe")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defineComponent, type PropType, Teleport } from "vue";
|
|
2
|
+
|
|
3
|
+
import { ensureHudRoot } from "../overlay/hudRoot";
|
|
4
|
+
import "./HkAmbientLayer.scss";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkAmbientLayer — the shared host for always-visible, NON-INTERACTIVE
|
|
8
|
+
* annotations: a slot teleported into hikari's ambient HUD root
|
|
9
|
+
* (`#hk-hud-root`, overlay/hudRoot.ts) so it paints above EVERY popup —
|
|
10
|
+
* the modal backdrop included — without entering the popup manager's
|
|
11
|
+
* window/anchored/tooltip stack.
|
|
12
|
+
*
|
|
13
|
+
* The 2026-11-05 identify defect is the reason this exists: the
|
|
14
|
+
* screen-identify card was teleported to `<body>` with a hand-written
|
|
15
|
+
* `z-index: 5000`, so the modal tree (document band 90000) buried it and
|
|
16
|
+
* the card showed through the scrim as dimmed background. Layer
|
|
17
|
+
* selection is a LIBRARY decision, not a per-consumer number: one HUD
|
|
18
|
+
* root, one band (`--hk-z-hud`), and a single reviewable floor
|
|
19
|
+
* (`--hk-overlay-z-hud`) for layers inside it.
|
|
20
|
+
*
|
|
21
|
+
* Non-interactive by default: the wrapper spans the viewport but lets
|
|
22
|
+
* every pointer through (`pointer-events: none`). Pass
|
|
23
|
+
* `pointerEvents="auto"` only for a HUD that really is clickable (an
|
|
24
|
+
* annotation chip with a dismiss button), and keep its footprint small so
|
|
25
|
+
* it never swallows the page.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
*
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <HkAmbientLayer>
|
|
31
|
+
* <div class="hk-recording-dot" />
|
|
32
|
+
* </HkAmbientLayer>
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* `zIndex` orders several ambient layers against each other inside the
|
|
36
|
+
* HUD root (default: the base floor). The wrapper never leaves the root,
|
|
37
|
+
* so it cannot be covered by a later-opened modal.
|
|
38
|
+
*/
|
|
39
|
+
export default defineComponent({
|
|
40
|
+
name: "HkAmbientLayer",
|
|
41
|
+
props: {
|
|
42
|
+
/** Whether the layer's CONTENT accepts pointer input. Default `none`
|
|
43
|
+
* — an annotation must not block the window it annotates. `auto`
|
|
44
|
+
* restores pointer events on the slot children only; the
|
|
45
|
+
* viewport-spanning wrapper itself always stays a ghost, so an
|
|
46
|
+
* interactive HUD can never swallow page clicks. */
|
|
47
|
+
pointerEvents: {
|
|
48
|
+
type: String as PropType<"none" | "auto">,
|
|
49
|
+
default: "none",
|
|
50
|
+
validator: (value: string) => value === "none" || value === "auto",
|
|
51
|
+
},
|
|
52
|
+
/** Stacking order among ambient layers (larger = nearer the user).
|
|
53
|
+
* Local to the HUD root, never a document-space number. */
|
|
54
|
+
zIndex: { type: Number, default: 1 },
|
|
55
|
+
/** Accessible name for the layer as a landmark. Omit for purely
|
|
56
|
+
* decorative hosts; the card inside carries its own status role. */
|
|
57
|
+
label: { type: String, default: undefined },
|
|
58
|
+
},
|
|
59
|
+
setup(props, { slots }) {
|
|
60
|
+
return () => (
|
|
61
|
+
<Teleport to={ensureHudRoot()}>
|
|
62
|
+
<div
|
|
63
|
+
class="hk-ambient-layer"
|
|
64
|
+
data-pointer-events={props.pointerEvents}
|
|
65
|
+
role={props.label ? "region" : undefined}
|
|
66
|
+
aria-label={props.label}
|
|
67
|
+
style={{ zIndex: String(props.zIndex) }}
|
|
68
|
+
>
|
|
69
|
+
{slots.default?.()}
|
|
70
|
+
</div>
|
|
71
|
+
</Teleport>
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -201,7 +201,9 @@ describe("HkButton icon-only square contract", () => {
|
|
|
201
201
|
// over the square width. Textual assertions because happy-dom does not
|
|
202
202
|
// lay out.
|
|
203
203
|
describe("HkButton icon-only SCSS contract", () => {
|
|
204
|
-
|
|
204
|
+
// autocrlf checkouts write CRLF to disk; normalize so the LF-joined
|
|
205
|
+
// selector anchor below matches what git stores regardless of platform.
|
|
206
|
+
const scss = readFileSync(join(here, "HkButton.scss"), "utf-8").replace(/\r\n/g, "\n");
|
|
205
207
|
|
|
206
208
|
it("self-declares the collapsed square geometry", () => {
|
|
207
209
|
const rule = scss.match(/\.hk-btn-icon-only\s*{[^}]*}/)?.[0] ?? "";
|
|
@@ -310,7 +312,8 @@ describe("HkButton label prop + responsive collapse contract", () => {
|
|
|
310
312
|
// useBreakpoint px scale (sm 640 / md 768 / lg 1024 / xl 1280), and must
|
|
311
313
|
// land on the exact icon-only square width of its size.
|
|
312
314
|
describe("HkButton label-collapse SCSS contract", () => {
|
|
313
|
-
|
|
315
|
+
// Normalized to LF (autocrlf checkouts) — see the icon-only contract above.
|
|
316
|
+
const scss = readFileSync(join(here, "HkButton.scss"), "utf-8").replace(/\r\n/g, "\n");
|
|
314
317
|
|
|
315
318
|
const breakpoints: Array<[string, string]> = [
|
|
316
319
|
["sm", "639.98px"],
|
|
@@ -13,7 +13,9 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
* the normalization fixed.
|
|
14
14
|
*/
|
|
15
15
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
|
|
16
|
+
// autocrlf checkouts write CRLF to disk; normalize so the LF-joined
|
|
17
|
+
// selector anchor below matches what git stores regardless of platform.
|
|
18
|
+
const scss = readFileSync(join(here, "HkJsonTree.scss"), "utf-8").replace(/\r\n/g, "\n");
|
|
17
19
|
|
|
18
20
|
describe("HkJsonTree spacing contract", () => {
|
|
19
21
|
it("keeps the toggle exactly one spacing unit from the row text", () => {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the DESKTOP modal open/close choreography (user
|
|
3
|
+
* direction 2026-11-04): the window grows by scaling the content itself
|
|
4
|
+
* about the frame's center — width pinned at 100% (scaleX 1), height
|
|
5
|
+
* scaling 90% → 100% — replacing the 2026-09-14 clip unveil (and the
|
|
6
|
+
* #508/#512/#524 lineage before it). The old "content rides rigidly,
|
|
7
|
+
* never squashed" invariant was DELIBERATELY dropped: the rigid ride is
|
|
8
|
+
* exactly what made the unveil read as dead weight on ordinary modals.
|
|
9
|
+
*
|
|
10
|
+
* Pinned on the stylesheet SOURCE (computed animation geometry is not
|
|
11
|
+
* observable in happy-dom), mirroring the sheetmorph / tokenHygiene
|
|
12
|
+
* guard pattern.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const src = readFileSync(join(here, "HkModal.scss"), "utf-8");
|
|
21
|
+
|
|
22
|
+
/** The desktop choreography block — the mobile sheet block (≤767px)
|
|
23
|
+
* carries its own enter/leave classes and must not leak in. */
|
|
24
|
+
const desktop = src.slice(
|
|
25
|
+
src.indexOf("@media (min-width: 768px)"),
|
|
26
|
+
src.indexOf("@media (max-width: 767px)"),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/** The mobile sheet block — sliced the same way, for the parity guard. */
|
|
30
|
+
const mobile = src.slice(src.indexOf("@media (max-width: 767px)"));
|
|
31
|
+
|
|
32
|
+
/** Body of one rule (selector possibly grouped / multi-line). Line
|
|
33
|
+
* comments are stripped so prose in the sheet cannot satisfy or break
|
|
34
|
+
* a structural assertion. */
|
|
35
|
+
function bodyOf(selector: string, scope: string = desktop): string {
|
|
36
|
+
const escaped = selector.replace(/\./g, "\\.");
|
|
37
|
+
const match = scope.match(new RegExp(`${escaped}[^{]*\\{([^}]*)\\}`));
|
|
38
|
+
expect(match, `rule ${selector} must exist in scope`).toBeTruthy();
|
|
39
|
+
return match![1]
|
|
40
|
+
.replace(/\/\/[^\n]*/g, "")
|
|
41
|
+
.replace(/\s+/g, " ")
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ENTER_LEAVE = [
|
|
46
|
+
".hk-modal-content-enter-active",
|
|
47
|
+
".hk-modal-content-leave-active",
|
|
48
|
+
".hk-modal-content-enter-from",
|
|
49
|
+
".hk-modal-content-enter-to",
|
|
50
|
+
".hk-modal-content-leave-from",
|
|
51
|
+
".hk-modal-content-leave-to",
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
/** The centering translate: every transform state keeps it as the
|
|
55
|
+
* leading term, so the frame never re-seats mid-flight (dragged windows
|
|
56
|
+
* keep their shift through the animation). */
|
|
57
|
+
const CENTERING =
|
|
58
|
+
/translate\(\s*calc\(-50% \+ var\(--hk-modal-shift-x, 0px\)\),\s*calc\(-50% \+ var\(--hk-modal-shift-y, 0px\)\)\s*\)/;
|
|
59
|
+
|
|
60
|
+
describe("HkModal desktop content-scale open/close contract (user direction 2026-11-04)", () => {
|
|
61
|
+
it("scales the content from 90% height about the center on enter", () => {
|
|
62
|
+
const from = bodyOf(".hk-modal-content-enter-from");
|
|
63
|
+
expect(from).toMatch(CENTERING);
|
|
64
|
+
expect(from).toMatch(/scale\(1, 0\.9\)/);
|
|
65
|
+
// scaleX stays 1 — the width is pinned at 100% for the whole ride.
|
|
66
|
+
expect(from).not.toMatch(/scale\(0\.9\)/);
|
|
67
|
+
expect(from).toMatch(/opacity:\s*0/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("collapses back to 90% height on leave (the reveal's true reverse)", () => {
|
|
71
|
+
const to = bodyOf(".hk-modal-content-leave-to");
|
|
72
|
+
expect(to).toMatch(CENTERING);
|
|
73
|
+
expect(to).toMatch(/scale\(1, 0\.9\)/);
|
|
74
|
+
expect(to).toMatch(/opacity:\s*0/);
|
|
75
|
+
const from = bodyOf(".hk-modal-content-leave-from");
|
|
76
|
+
expect(from).toMatch(CENTERING);
|
|
77
|
+
expect(from).toMatch(/opacity:\s*1/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rests at the identity transform (no scale, no offset) at both ends", () => {
|
|
81
|
+
const enterTo = bodyOf(".hk-modal-content-enter-to");
|
|
82
|
+
expect(enterTo).toMatch(CENTERING);
|
|
83
|
+
// The identity state carries NO trailing transform term — the
|
|
84
|
+
// scale-to-1 end of the interpolation is a bare translate, so the
|
|
85
|
+
// transform lists on both ends share one shape and interpolate
|
|
86
|
+
// continuously (the clip-era "pin the identity clip" workaround is
|
|
87
|
+
// structurally unnecessary now).
|
|
88
|
+
expect(enterTo).toMatch(new RegExp(`transform:\\s*${CENTERING.source};`));
|
|
89
|
+
expect(enterTo).not.toMatch(/scale\(|translate[XY]\(/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("anchors the scale at the frame's center for the whole ride", () => {
|
|
93
|
+
for (const active of [
|
|
94
|
+
".hk-modal-content-enter-active",
|
|
95
|
+
".hk-modal-content-leave-active",
|
|
96
|
+
]) {
|
|
97
|
+
expect(bodyOf(active)).toMatch(/transform-origin:\s*50% 50%/);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("retires the clip unveil: no clip-path and no Y drift in any open/close rule", () => {
|
|
102
|
+
// History: the 2026-09-14 unveil rode translateY(5%) + inset() clip
|
|
103
|
+
// (with the #508 identity-clip fix). The scale grammar replaces the
|
|
104
|
+
// whole mechanism — any reappearance is a regression to the retired
|
|
105
|
+
// choreography.
|
|
106
|
+
for (const rule of ENTER_LEAVE) {
|
|
107
|
+
const body = bodyOf(rule);
|
|
108
|
+
expect(body, `${rule} must not clip`).not.toMatch(/clip-path/);
|
|
109
|
+
expect(body, `${rule} must not drift`).not.toMatch(/translateY\(/);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps the ride animated on the timing tokens (never a snap)", () => {
|
|
114
|
+
// The retired close-collapse contract pinned "transitions clip-path
|
|
115
|
+
// on leave (the collapse animates, not snaps)"; its replacement must
|
|
116
|
+
// not be weaker on that axis. Deleting the transition blocks (or
|
|
117
|
+
// hardcoding their durations away from the family tokens) must turn
|
|
118
|
+
// this red — computed animation is unobservable in happy-dom, so
|
|
119
|
+
// this source pin is the only CI guard for "it animates at all".
|
|
120
|
+
const enter = bodyOf(".hk-modal-content-enter-active");
|
|
121
|
+
expect(enter).toMatch(
|
|
122
|
+
/opacity var\(--hk-modal-duration, 0\.42s\) var\(--hk-modal-ease-out, cubic-bezier\(0\.33, 1, 0\.68, 1\)\)/,
|
|
123
|
+
);
|
|
124
|
+
expect(enter).toMatch(
|
|
125
|
+
/transform var\(--hk-modal-duration, 0\.42s\) var\(--hk-modal-ease-out, cubic-bezier\(0\.33, 1, 0\.68, 1\)\)/,
|
|
126
|
+
);
|
|
127
|
+
expect(enter).not.toMatch(/clip-path/);
|
|
128
|
+
|
|
129
|
+
const leave = bodyOf(".hk-modal-content-leave-active");
|
|
130
|
+
expect(leave).toMatch(
|
|
131
|
+
/opacity var\(--hk-modal-duration, 0\.32s\) var\(--hk-modal-ease-in, cubic-bezier\(0\.45, 0, 0\.55, 1\)\)/,
|
|
132
|
+
);
|
|
133
|
+
expect(leave).toMatch(
|
|
134
|
+
/transform var\(--hk-modal-duration, 0\.32s\) var\(--hk-modal-ease-in, cubic-bezier\(0\.45, 0, 0\.55, 1\)\)/,
|
|
135
|
+
);
|
|
136
|
+
expect(leave).not.toMatch(/clip-path/);
|
|
137
|
+
|
|
138
|
+
for (const active of [
|
|
139
|
+
".hk-modal-content-enter-active",
|
|
140
|
+
".hk-modal-content-leave-active",
|
|
141
|
+
]) {
|
|
142
|
+
expect(bodyOf(active)).toMatch(/will-change: transform, opacity/);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("keeps the mobile sheet's translateY choreography untouched", () => {
|
|
147
|
+
const from = bodyOf(".hk-modal-content-enter-from", mobile);
|
|
148
|
+
expect(from).toMatch(/translateY\(100%\)/);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -97,56 +97,46 @@
|
|
|
97
97
|
// ------
|
|
98
98
|
// Content enter/leave transitions (desktop ≥768px)
|
|
99
99
|
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// drops on content-heavy modals, chest history topic detail). Scoped to
|
|
115
|
-
// ≥768px; ≤767px keeps the sheet's translateY choreography below.
|
|
100
|
+
// Content-scale reveal (user direction 2026-11-04): the frame grows by
|
|
101
|
+
// SCALING the content itself about the frame's center — the width stays
|
|
102
|
+
// pinned at 100% the whole time while the height scales from 90% up to
|
|
103
|
+
// 100%. No clip-path and no Y offset ride the open/close any more: the
|
|
104
|
+
// visible box IS the frame at every t, so nothing is ever sliced off
|
|
105
|
+
// (the 2026-09-14 clip unveil — and the #508/#512/#524 lineage before
|
|
106
|
+
// it — is superseded; the "content rides rigidly" invariant was the
|
|
107
|
+
// very thing that made the old reveal read as dead weight on ordinary
|
|
108
|
+
// modals). Implemented with transform (translate + uniform-axis scale)
|
|
109
|
+
// + opacity: all compositor-level, zero layout per frame, and the
|
|
110
|
+
// centering translate stays the leading term of EVERY transform state
|
|
111
|
+
// so the frame never re-seats mid-flight (dragged windows keep their
|
|
112
|
+
// shift). Scoped to ≥768px; ≤767px keeps the sheet's translateY
|
|
113
|
+
// choreography below.
|
|
116
114
|
// ------
|
|
117
115
|
|
|
118
116
|
@media (min-width: 768px) {
|
|
119
117
|
.hk-modal-content-enter-active {
|
|
118
|
+
// The scale anchors at the frame's center: the window expands from
|
|
119
|
+
// the middle outward on the Y axis only (X is pinned by scaleX 1).
|
|
120
|
+
transform-origin: 50% 50%;
|
|
120
121
|
transition:
|
|
121
122
|
opacity var(--hk-modal-duration, 0.42s)
|
|
122
123
|
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
|
|
123
124
|
transform var(--hk-modal-duration, 0.42s)
|
|
124
|
-
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
|
|
125
|
-
clip-path var(--hk-modal-duration, 0.42s)
|
|
126
125
|
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
|
|
127
|
-
will-change: transform, opacity
|
|
126
|
+
will-change: transform, opacity;
|
|
128
127
|
}
|
|
129
128
|
|
|
130
|
-
// Close: the
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// (user report 2026-09-15: the fade+drift close read as the box
|
|
134
|
-
// shrinking straight down instead of folding back into its center).
|
|
135
|
-
// The earlier re-clip attempt (#508) broke because leave-from had no
|
|
136
|
-
// clip at all: interpolating `none → inset()` is DISCRETE, so the
|
|
137
|
-
// bottom band (the footer) vanished at the first frame. Pinning the
|
|
138
|
-
// identity clip in leave-from — byte-identical to enter-to — makes
|
|
139
|
-
// the same-shape insets interpolate continuously, which structurally
|
|
140
|
-
// rules that jump out while restoring the symmetric collapse.
|
|
129
|
+
// Close: the reveal's TRUE reverse — the frame scales back down to
|
|
130
|
+
// 90% height about its center (both vertical edges ease toward the
|
|
131
|
+
// center line) while fading out.
|
|
141
132
|
.hk-modal-content-leave-active {
|
|
133
|
+
transform-origin: 50% 50%;
|
|
142
134
|
transition:
|
|
143
135
|
opacity var(--hk-modal-duration, 0.32s)
|
|
144
136
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
|
|
145
137
|
transform var(--hk-modal-duration, 0.32s)
|
|
146
|
-
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
|
|
147
|
-
clip-path var(--hk-modal-duration, 0.32s)
|
|
148
138
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
|
|
149
|
-
will-change: transform, opacity
|
|
139
|
+
will-change: transform, opacity;
|
|
150
140
|
}
|
|
151
141
|
|
|
152
142
|
.hk-modal-content-enter-from {
|
|
@@ -155,9 +145,7 @@
|
|
|
155
145
|
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
156
146
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
157
147
|
)
|
|
158
|
-
|
|
159
|
-
clip-path: inset(0 0 10% 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
160
|
-
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
148
|
+
scale(1, 0.9);
|
|
161
149
|
}
|
|
162
150
|
|
|
163
151
|
.hk-modal-content-enter-to {
|
|
@@ -166,8 +154,6 @@
|
|
|
166
154
|
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
167
155
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
168
156
|
);
|
|
169
|
-
clip-path: inset(0 0 0 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
170
|
-
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
171
157
|
}
|
|
172
158
|
|
|
173
159
|
.hk-modal-content-leave-from {
|
|
@@ -176,24 +162,20 @@
|
|
|
176
162
|
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
177
163
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
178
164
|
);
|
|
179
|
-
// The pinned identity clip: identical to enter-to so the leave's
|
|
180
|
-
// clip interpolates (never jumps) — see the close note above.
|
|
181
|
-
clip-path: inset(0 0 0 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
182
|
-
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
183
165
|
}
|
|
184
166
|
|
|
185
167
|
.hk-modal-content-leave-to {
|
|
186
168
|
opacity: 0;
|
|
187
|
-
// Mirror of enter-from
|
|
188
|
-
//
|
|
189
|
-
//
|
|
169
|
+
// Mirror of enter-from. No identity pin is needed any more: CSS
|
|
170
|
+
// transform-list interpolation pads the shorter list with identity
|
|
171
|
+
// scale(1, 1), so translate()+scale(1, 0.9) ↔ bare translate()
|
|
172
|
+
// interpolates continuously (unlike `none → inset()` clips, which
|
|
173
|
+
// are DISCRETE — the #508 lesson does not apply to transforms).
|
|
190
174
|
transform: translate(
|
|
191
175
|
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
192
176
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
193
177
|
)
|
|
194
|
-
|
|
195
|
-
clip-path: inset(0 0 10% 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
196
|
-
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
178
|
+
scale(1, 0.9);
|
|
197
179
|
}
|
|
198
180
|
}
|
|
199
181
|
|
|
@@ -588,7 +588,7 @@ export default defineComponent({
|
|
|
588
588
|
// Flush the growth deferred through the unfold. One tick later
|
|
589
589
|
// the transition classes are off the frame, so the base height
|
|
590
590
|
// transition animates the pin change instead of the enter's
|
|
591
|
-
// transform
|
|
591
|
+
// opacity/transform-only transition list swallowing it as a snap.
|
|
592
592
|
void nextTick(() => {
|
|
593
593
|
if (machine.phase.value === "open") morph.remeasure();
|
|
594
594
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import * as barrel from "../index";
|
|
4
|
+
import { asIndexable } from "../utils/typing";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Public barrel guard for the screen-identify family (R3 precedent: a
|
|
8
|
+
* forgotten index.ts line keeps the whole suite green because every
|
|
9
|
+
* other test reaches the module directly — only external consumers
|
|
10
|
+
* notice). Same shape as HkFaceShell.barrel.test.ts.
|
|
11
|
+
*
|
|
12
|
+
* This file deliberately does NOT mock "@celestia-island/hikari": the
|
|
13
|
+
* package's self-reference resolves to the very same src/index.ts module
|
|
14
|
+
* as "../index".
|
|
15
|
+
*/
|
|
16
|
+
describe("the screen-identify family in the public barrel", () => {
|
|
17
|
+
it("exports the card and the once-per-window host under both names", () => {
|
|
18
|
+
const card = asIndexable(barrel.HkScreenIdentifyCard);
|
|
19
|
+
const host = asIndexable(barrel.HkScreenIdentifyHost);
|
|
20
|
+
expect(card, "HkScreenIdentifyCard must be re-exported").toBeTruthy();
|
|
21
|
+
expect(card.name).toBe("HkScreenIdentifyCard");
|
|
22
|
+
expect(host.name).toBe("HkScreenIdentifyHost");
|
|
23
|
+
// The H- alias rides the HCard/HkCard family convention.
|
|
24
|
+
expect(barrel.HScreenIdentifyCard).toBe(barrel.HkScreenIdentifyCard);
|
|
25
|
+
expect(barrel.HScreenIdentifyHost).toBe(barrel.HkScreenIdentifyHost);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("exports the ambient HUD layer primitive and its root", () => {
|
|
29
|
+
const layer = asIndexable(barrel.HkAmbientLayer);
|
|
30
|
+
expect(layer.name).toBe("HkAmbientLayer");
|
|
31
|
+
expect(barrel.HAmbientLayer).toBe(barrel.HkAmbientLayer);
|
|
32
|
+
expect(typeof barrel.ensureHudRoot).toBe("function");
|
|
33
|
+
expect(barrel.HUD_ROOT_SELECTOR).toBe("#hk-hud-root");
|
|
34
|
+
expect(barrel.HUD_ROOT_DOCUMENT_Z).toBe(90050);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("exports the context that drives the card", () => {
|
|
38
|
+
expect(typeof barrel.useScreenIdentify).toBe("function");
|
|
39
|
+
expect(typeof barrel.showScreenIdentify).toBe("function");
|
|
40
|
+
expect(typeof barrel.dismissScreenIdentify).toBe("function");
|
|
41
|
+
expect(barrel.SCREEN_IDENTIFY_DEFAULT_MS).toBe(3000);
|
|
42
|
+
// The context accessor hands out live refs and the two verbs.
|
|
43
|
+
const identify = barrel.useScreenIdentify();
|
|
44
|
+
expect(identify.active.value).toBe(false);
|
|
45
|
+
expect(typeof identify.show).toBe("function");
|
|
46
|
+
expect(typeof identify.dismiss).toBe("function");
|
|
47
|
+
});
|
|
48
|
+
});
|