@celestia-island/hikari 0.55.277 → 0.55.280
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/HkCornerBadge.tsx +7 -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/HkTabs.test.ts +109 -0
- package/src/components/HkTabs.tsx +73 -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/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
|
+
});
|
|
@@ -13,6 +13,12 @@ export type CornerBadgeVariant =
|
|
|
13
13
|
| "info"
|
|
14
14
|
| "muted";
|
|
15
15
|
|
|
16
|
+
/** Which corner of the anchor the mark hangs on. Exported so hosts that
|
|
17
|
+
* embed the shell (HkTabs' trigger badge) share the one vocabulary
|
|
18
|
+
* instead of re-spelling the union; HkIconButton's dot borrows the tone
|
|
19
|
+
* channels but is an inset span of its own, not a corner mark. */
|
|
20
|
+
export type CornerBadgeCorner = "top-right" | "top-left" | "bottom-right" | "bottom-left";
|
|
21
|
+
|
|
16
22
|
/**
|
|
17
23
|
* HkCornerBadge — the generic corner mark for ANY anchor (user
|
|
18
24
|
* direction 2026-10-15): a green/red/traffic dot, a notification count
|
|
@@ -44,7 +50,7 @@ export default defineComponent({
|
|
|
44
50
|
text: { type: String, default: undefined },
|
|
45
51
|
/** Which corner of the anchor. */
|
|
46
52
|
corner: {
|
|
47
|
-
type: String as PropType<
|
|
53
|
+
type: String as PropType<CornerBadgeCorner>,
|
|
48
54
|
default: "top-right",
|
|
49
55
|
},
|
|
50
56
|
/** Extra clearance from the corner in px (0 = sit ON the corner,
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// HkScreenIdentifyCard — the numbered "which screen is this?" card.
|
|
2
|
+
//
|
|
3
|
+
// Grammar (user direction 2026-10-12): a LARGE SQUARE card, fixed light
|
|
4
|
+
// surface, BLACK number. The card must read from across a room and look
|
|
5
|
+
// identical in light and dark themes, so the surface/ink here are
|
|
6
|
+
// deliberately NOT the themed tokens (a themed card would swallow the
|
|
7
|
+
// number in dark mode — the reason the original card hardcoded both).
|
|
8
|
+
// It is a HUD: pointer-events none, it never intercepts the window it
|
|
9
|
+
// annotates.
|
|
10
|
+
|
|
11
|
+
.hk-screen-identify {
|
|
12
|
+
position: fixed;
|
|
13
|
+
z-index: var(--hk-overlay-z-hud, 1);
|
|
14
|
+
// Logical side properties: the card stays in the same corner of the
|
|
15
|
+
// glass under RTL.
|
|
16
|
+
inset-inline-start: var(--space-16, 16px);
|
|
17
|
+
bottom: var(--space-16, 16px);
|
|
18
|
+
// One viewport-clamped width drives both axes, so the badge reads as a
|
|
19
|
+
// physical numbered card, not a text bubble.
|
|
20
|
+
aspect-ratio: 1;
|
|
21
|
+
width: clamp(96px, 18vw, 160px);
|
|
22
|
+
display: flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
justify-content: center;
|
|
25
|
+
// Fixed LIGHT card: the black number must read in every theme.
|
|
26
|
+
background: rgb(255 255 255 / 96%);
|
|
27
|
+
border: 1px solid rgb(0 0 0 / 18%);
|
|
28
|
+
border-radius: var(--radius-md, 8px);
|
|
29
|
+
box-shadow: var(--shadow-elevated);
|
|
30
|
+
pointer-events: none;
|
|
31
|
+
animation: hk-screen-identify-in 180ms ease-out;
|
|
32
|
+
|
|
33
|
+
// The four corners. Left/right pair mirrors under RTL (logical
|
|
34
|
+
// properties); top/bottom are the physical edges either way.
|
|
35
|
+
&[data-placement="bottom-right"] {
|
|
36
|
+
inset-inline-start: auto;
|
|
37
|
+
inset-inline-end: var(--space-16, 16px);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
&[data-placement="top-left"] {
|
|
41
|
+
top: var(--space-16, 16px);
|
|
42
|
+
bottom: auto;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
&[data-placement="top-right"] {
|
|
46
|
+
top: var(--space-16, 16px);
|
|
47
|
+
inset-inline-start: auto;
|
|
48
|
+
inset-inline-end: var(--space-16, 16px);
|
|
49
|
+
bottom: auto;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Reduce-motion: the card still appears, it just does not slide in.
|
|
53
|
+
@media (prefers-reduced-motion: reduce) {
|
|
54
|
+
animation: none;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.hk-screen-identify-num {
|
|
59
|
+
font-family: var(--font-mono, monospace);
|
|
60
|
+
// Big enough to read from across a room, scaled with the card: half the
|
|
61
|
+
// card's clamp at every viewport width. The token scale stops at
|
|
62
|
+
// heading sizes, so this one numeral is intentionally a clamp().
|
|
63
|
+
font-size: clamp(3rem, 9vw, 5rem);
|
|
64
|
+
font-weight: 700;
|
|
65
|
+
line-height: 1;
|
|
66
|
+
// BLACK, deliberately not themed (user direction 2026-10-12).
|
|
67
|
+
color: #000;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The seconds left, as hikari's rolling digit: a corner badge on the
|
|
71
|
+
// card, top-inline-end.
|
|
72
|
+
.hk-screen-identify-count {
|
|
73
|
+
position: absolute;
|
|
74
|
+
top: var(--space-4, 4px);
|
|
75
|
+
inset-inline-end: var(--space-8, 8px);
|
|
76
|
+
font-size: var(--text-base, 0.875rem);
|
|
77
|
+
color: rgb(0 0 0 / 62%);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The stable screen name: the opposite corner badge, bottom-inline-start,
|
|
81
|
+
// one ellipsized line so a long window label cannot leave the card.
|
|
82
|
+
.hk-screen-identify-name {
|
|
83
|
+
position: absolute;
|
|
84
|
+
inset-inline-start: var(--space-8, 8px);
|
|
85
|
+
bottom: var(--space-4, 4px);
|
|
86
|
+
max-width: calc(100% - 2 * var(--space-8, 8px));
|
|
87
|
+
overflow: hidden;
|
|
88
|
+
font-size: var(--text-xs, 0.75rem);
|
|
89
|
+
text-overflow: ellipsis;
|
|
90
|
+
white-space: nowrap;
|
|
91
|
+
color: rgb(0 0 0 / 62%);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@keyframes hk-screen-identify-in {
|
|
95
|
+
from {
|
|
96
|
+
opacity: 0;
|
|
97
|
+
transform: translateY(6px);
|
|
98
|
+
}
|
|
99
|
+
to {
|
|
100
|
+
opacity: 1;
|
|
101
|
+
transform: none;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
import { listCssAnimations } from "../animation/registerAnimations";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Source contract for the identify card's load-bearing look (the chest
|
|
11
|
+
* v4 wave pinned the same rules before the card was upstreamed): a
|
|
12
|
+
* LARGE SQUARE, fixed-light surface with a BLACK theme-independent
|
|
13
|
+
* number, corner badges, one viewport-clamped room-readable numeral and
|
|
14
|
+
* hikari tokens everywhere else. vitest cannot see stylesheets, so a
|
|
15
|
+
* deleted rule would leave every DOM guard green.
|
|
16
|
+
*/
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const scss = readFileSync(join(here, "HkScreenIdentifyCard.scss"), "utf8");
|
|
19
|
+
|
|
20
|
+
/** The DECLARATIONS of a top-level class rule, comments stripped (a
|
|
21
|
+
* comment that names a property must not satisfy a guard about it). */
|
|
22
|
+
function ruleBlock(selector: string): string {
|
|
23
|
+
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
24
|
+
const match = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(scss);
|
|
25
|
+
if (!match) throw new Error(`no rule for ${selector}`);
|
|
26
|
+
return match[1]
|
|
27
|
+
.split("\n")
|
|
28
|
+
.filter((line) => !line.trim().startsWith("*") && !line.trim().startsWith("/*"))
|
|
29
|
+
.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("HkScreenIdentifyCard layout contract", () => {
|
|
33
|
+
it("is a large square card with a viewport-clamped width", () => {
|
|
34
|
+
const card = ruleBlock(".hk-screen-identify");
|
|
35
|
+
expect(card).toMatch(/aspect-ratio:\s*1/);
|
|
36
|
+
expect(card).toMatch(/width:\s*clamp\(96px,\s*18vw,\s*160px\)/);
|
|
37
|
+
// A HUD never intercepts the window it annotates.
|
|
38
|
+
expect(card).toMatch(/pointer-events:\s*none/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps the fixed light surface and the black number", () => {
|
|
42
|
+
const card = ruleBlock(".hk-screen-identify");
|
|
43
|
+
// A themed surface would swallow the black number in dark mode.
|
|
44
|
+
expect(card).toMatch(/background:\s*rgb\(255 255 255/);
|
|
45
|
+
const num = ruleBlock(".hk-screen-identify-num");
|
|
46
|
+
expect(num).toMatch(/color:\s*#000/);
|
|
47
|
+
expect(num).not.toMatch(/--color-/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps the name and countdown as corner badges on the card", () => {
|
|
51
|
+
for (const badge of [".hk-screen-identify-count", ".hk-screen-identify-name"]) {
|
|
52
|
+
expect(ruleBlock(badge)).toMatch(/position:\s*absolute/);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("parks the card in every corner through placement attributes", () => {
|
|
57
|
+
for (const placement of ["bottom-right", "top-left", "top-right"]) {
|
|
58
|
+
expect(scss).toContain(`&[data-placement="${placement}"]`);
|
|
59
|
+
}
|
|
60
|
+
// Left/right use logical properties so RTL mirrors the pair while
|
|
61
|
+
// the card stays at the same corner of the glass.
|
|
62
|
+
expect(ruleBlock(".hk-screen-identify")).toMatch(/inset-inline-start:/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("rides hikari text tokens for every font size except the numeral", () => {
|
|
66
|
+
const raw = [...scss.matchAll(/font-size:\s*([^;]+);/g)].map((m) => m[1].trim());
|
|
67
|
+
expect(raw.length).toBeGreaterThan(1);
|
|
68
|
+
const nonToken = raw.filter((value) => !/^var\(--text-/.test(value));
|
|
69
|
+
// The room-readable numeral is the ONE intentional clamp() beyond
|
|
70
|
+
// the token scale (which stops at heading sizes).
|
|
71
|
+
expect(nonToken).toHaveLength(1);
|
|
72
|
+
expect(nonToken[0]).toMatch(/^clamp\(/);
|
|
73
|
+
expect(ruleBlock(".hk-screen-identify-num")).toContain(nonToken[0]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("sits on the HUD ordinal band and honours reduced motion", () => {
|
|
77
|
+
expect(ruleBlock(".hk-screen-identify")).toMatch(
|
|
78
|
+
/z-index:\s*var\(--hk-overlay-z-hud/,
|
|
79
|
+
);
|
|
80
|
+
expect(scss).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
|
|
81
|
+
expect(scss).toContain("animation: none;");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("registers its entrance animation in the CSS animation registrar", () => {
|
|
85
|
+
const names = listCssAnimations().map((entry) => entry.name);
|
|
86
|
+
expect(names).toContain("hk-screen-identify-in");
|
|
87
|
+
expect(scss).toContain("@keyframes hk-screen-identify-in");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkScreenIdentifyCard, {
|
|
5
|
+
type ScreenIdentifyPlacement,
|
|
6
|
+
} from "./HkScreenIdentifyCard";
|
|
7
|
+
|
|
8
|
+
type CardProps = {
|
|
9
|
+
number: number | string;
|
|
10
|
+
name?: string;
|
|
11
|
+
secondsLeft?: number;
|
|
12
|
+
placement?: ScreenIdentifyPlacement;
|
|
13
|
+
label?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* HkScreenIdentifyCard contract (ported from the chest card precedent,
|
|
18
|
+
* user direction 2026-10-12):
|
|
19
|
+
* - the number is the loud element; the name and countdown are corner
|
|
20
|
+
* badges;
|
|
21
|
+
* - role="status" + an accessible label composed from the number and the
|
|
22
|
+
* optional name (the layer must announce, not just paint);
|
|
23
|
+
* - the corner is a data attribute (CSS owns the geometry, including the
|
|
24
|
+
* RTL mirror);
|
|
25
|
+
* - a countdown appears only while there are seconds left.
|
|
26
|
+
*/
|
|
27
|
+
const mounts: Array<() => void> = [];
|
|
28
|
+
|
|
29
|
+
function mountCard(props: CardProps) {
|
|
30
|
+
const container = document.createElement("div");
|
|
31
|
+
document.body.appendChild(container);
|
|
32
|
+
const app = createApp(
|
|
33
|
+
defineComponent({
|
|
34
|
+
setup() {
|
|
35
|
+
return () => h(HkScreenIdentifyCard, props);
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
app.mount(container);
|
|
40
|
+
mounts.push(() => {
|
|
41
|
+
app.unmount();
|
|
42
|
+
container.remove();
|
|
43
|
+
});
|
|
44
|
+
return container;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
while (mounts.length) mounts.pop()?.();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("HkScreenIdentifyCard", () => {
|
|
52
|
+
it("renders the number, the optional name and the countdown", async () => {
|
|
53
|
+
const container = mountCard({ number: 7, name: "Kiosk AB12", secondsLeft: 3 });
|
|
54
|
+
await nextTick();
|
|
55
|
+
const card = container.querySelector(".hk-screen-identify")!;
|
|
56
|
+
expect(card.querySelector(".hk-screen-identify-num")?.textContent).toBe("7");
|
|
57
|
+
expect(card.querySelector(".hk-screen-identify-name")?.textContent).toBe("Kiosk AB12");
|
|
58
|
+
const count = card.querySelector(".hk-screen-identify-count");
|
|
59
|
+
expect(count).not.toBeNull();
|
|
60
|
+
// The rolling digit is hikari's own component, not a hand-rolled
|
|
61
|
+
// odometer.
|
|
62
|
+
expect(count!.querySelector(".hk-countdown-digit")).not.toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("omits the name badge when no name is given", async () => {
|
|
66
|
+
const container = mountCard({ number: 2 });
|
|
67
|
+
await nextTick();
|
|
68
|
+
expect(container.querySelector(".hk-screen-identify-name")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("omits the countdown when there are no seconds left", async () => {
|
|
72
|
+
const container = mountCard({ number: 2, secondsLeft: 0 });
|
|
73
|
+
await nextTick();
|
|
74
|
+
expect(container.querySelector(".hk-screen-identify-count")).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("accepts a string number", async () => {
|
|
78
|
+
const container = mountCard({ number: "A1" });
|
|
79
|
+
await nextTick();
|
|
80
|
+
expect(container.querySelector(".hk-screen-identify-num")?.textContent).toBe("A1");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("announces itself with the composed label", async () => {
|
|
84
|
+
const container = mountCard({ number: 3 });
|
|
85
|
+
await nextTick();
|
|
86
|
+
const card = container.querySelector<HTMLElement>(".hk-screen-identify")!;
|
|
87
|
+
expect(card.getAttribute("role")).toBe("status");
|
|
88
|
+
expect(card.getAttribute("aria-label")).toBe("Screen 3");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("includes the screen name in the announcement", async () => {
|
|
92
|
+
const container = mountCard({ number: 3, name: "AB12" });
|
|
93
|
+
await nextTick();
|
|
94
|
+
const card = container.querySelector<HTMLElement>(".hk-screen-identify")!;
|
|
95
|
+
expect(card.getAttribute("aria-label")).toBe("Screen 3, AB12");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("lets the caller override the announcement", async () => {
|
|
99
|
+
const container = mountCard({ number: 3, label: "Third display" });
|
|
100
|
+
await nextTick();
|
|
101
|
+
const card = container.querySelector<HTMLElement>(".hk-screen-identify")!;
|
|
102
|
+
expect(card.getAttribute("aria-label")).toBe("Third display");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("carries the placement as a data attribute for every corner", async () => {
|
|
106
|
+
for (const placement of ["bottom-left", "bottom-right", "top-left", "top-right"] as const) {
|
|
107
|
+
const container = mountCard({ number: 1, placement });
|
|
108
|
+
await nextTick();
|
|
109
|
+
const card = container.querySelector<HTMLElement>(".hk-screen-identify")!;
|
|
110
|
+
expect(card.getAttribute("data-placement")).toBe(placement);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|