@celestia-island/hikari 0.55.277 → 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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/animation/registerAnimations.ts +4 -0
  3. package/src/components/HkAmbientLayer.scss +23 -0
  4. package/src/components/HkAmbientLayer.styles.test.ts +43 -0
  5. package/src/components/HkAmbientLayer.test.tsx +124 -0
  6. package/src/components/HkAmbientLayer.tsx +74 -0
  7. package/src/components/HkScreenIdentify.barrel.test.ts +48 -0
  8. package/src/components/HkScreenIdentifyCard.scss +103 -0
  9. package/src/components/HkScreenIdentifyCard.styles.test.ts +89 -0
  10. package/src/components/HkScreenIdentifyCard.test.tsx +113 -0
  11. package/src/components/HkScreenIdentifyCard.tsx +90 -0
  12. package/src/components/HkScreenIdentifyHost.test.tsx +124 -0
  13. package/src/components/HkScreenIdentifyHost.tsx +62 -0
  14. package/src/i18n/locales/ar/components.json +3 -1
  15. package/src/i18n/locales/de/components.json +3 -1
  16. package/src/i18n/locales/en/components.json +3 -1
  17. package/src/i18n/locales/es/components.json +3 -1
  18. package/src/i18n/locales/fr/components.json +3 -1
  19. package/src/i18n/locales/ja/components.json +3 -1
  20. package/src/i18n/locales/ko/components.json +3 -1
  21. package/src/i18n/locales/pt/components.json +3 -1
  22. package/src/i18n/locales/ru/components.json +3 -1
  23. package/src/i18n/locales/zh-Hans/components.json +3 -1
  24. package/src/i18n/locales/zh-Hant/components.json +3 -1
  25. package/src/index.ts +21 -0
  26. package/src/overlay/hudRoot.scss +38 -0
  27. package/src/overlay/hudRoot.styles.test.ts +90 -0
  28. package/src/overlay/hudRoot.ts +85 -0
  29. package/src/overlay/overlayRoot.styles.test.ts +58 -17
  30. package/src/runtime/index.ts +9 -0
  31. package/src/runtime/useScreenIdentify.test.ts +138 -0
  32. package/src/runtime/useScreenIdentify.ts +198 -0
  33. package/src/scale.scss +13 -2
  34. package/src/styles/index.scss +1 -0
  35. package/src/styles/theme/scale.scss +13 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.277",
3
+ "version": "0.55.279",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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
+ });
@@ -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
+ });