@celestia-island/hikari 0.40.8 → 0.40.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.40.8",
3
+ "version": "0.40.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
package/src/DemoApp.tsx CHANGED
@@ -2,7 +2,7 @@
2
2
  // house rule: no SFCs in the tree). Render-only showcase of every exported
3
3
  // component; state is local and throwaway.
4
4
  import { computed, defineComponent, ref } from "vue";
5
- import { PackageOpen } from "lucide-vue-next";
5
+ import { ChevronDown, ChevronUp, PackageOpen } from "lucide-vue-next";
6
6
  import { HIKARI_FONT_MONO } from "./theme/fontContext";
7
7
  import {
8
8
  HButton, HIconButton, HTooltip, HBadge, HTag, HIcon, HSpinner,
@@ -214,7 +214,7 @@ export default defineComponent({
214
214
  <div class="row">
215
215
  <HButton variant="primary" loading>Loading</HButton>
216
216
  <HButton variant="primary" disabled>Disabled</HButton>
217
- <HButton variant="primary" shortcut="K">Shortcut</HButton>
217
+ <HButton variant="primary" shortcut="Ctrl+K">Shortcut</HButton>
218
218
  </div>
219
219
  </section>
220
220
 
@@ -337,7 +337,7 @@ export default defineComponent({
337
337
  <div class="row">
338
338
  <span style="align-self:center;color:var(--color-muted, gray)">custom trigger + checkbox rows:</span>
339
339
  <span ref={panelAnchor} style="display:inline-flex">
340
- <HButton onClick={() => (panel.value.open = !panel.value.open)}>Filter ▲</HButton>
340
+ <HButton onClick={() => (panel.value.open = !panel.value.open)}>Filter {panel.value.open ? <ChevronUp size={14} /> : <ChevronDown size={14} />}</HButton>
341
341
  </span>
342
342
  <HSelectPanel
343
343
  open={panel.value.open}
@@ -393,7 +393,7 @@ export default defineComponent({
393
393
 
394
394
  <section>
395
395
  <h2>HKbd</h2>
396
- <div class="row"><HKbd keys="" /><HKbd keys="" /><HKbd keys="" /><HKbd keys="⌃" /><HKbd keys="K" /><span> = open command palette</span></div>
396
+ <div class="row"><HKbd keys="Ctrl" /><HKbd keys="Alt" /><HKbd keys="Shift" /><HKbd keys="K" /><span> = open command palette</span></div>
397
397
  </section>
398
398
 
399
399
  <section>
@@ -3,11 +3,19 @@ import { createApp, h, nextTick } from "vue";
3
3
 
4
4
  import HkAffixPicker, { type HkAffixOption } from "./HkAffixPicker";
5
5
 
6
+ /** Regional-indicator flag pair for an ISO 3166-1 alpha-2 code, built
7
+ * from code points (same derivation as data/dialCodes flagEmoji) so the
8
+ * fixture source itself carries no literal emoji. */
9
+ const flagOf = (iso: string): string =>
10
+ String.fromCodePoint(
11
+ ...Array.from(iso.toUpperCase(), (c) => 0x1f1e6 + c.charCodeAt(0) - 65),
12
+ );
13
+
6
14
  const OPTIONS: HkAffixOption[] = [
7
- { key: "cn", label: "China", meta: "+86", flag: "🇨🇳", keywords: "zhongguo 中国 0086" },
8
- { key: "cn-main", label: "中华人民共和国", meta: "+86", flag: "🇨🇳" },
9
- { key: "jp", label: "Japan", meta: "+81", flag: "🇯🇵" },
10
- { key: "us", label: "United States", meta: "+1", flag: "🇺🇸" },
15
+ { key: "cn", label: "China", meta: "+86", flag: flagOf("cn"), keywords: "zhongguo 中国 0086" },
16
+ { key: "cn-main", label: "中华人民共和国", meta: "+86", flag: flagOf("cn") },
17
+ { key: "jp", label: "Japan", meta: "+81", flag: flagOf("jp") },
18
+ { key: "us", label: "United States", meta: "+1", flag: flagOf("us") },
11
19
  ];
12
20
 
13
21
  const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
@@ -187,7 +195,7 @@ describe("HkAffixPicker", () => {
187
195
  const labels = rows().map((r) => r.textContent);
188
196
  // Both the China row (keyword substring match) and the renamed
189
197
  // cn-main row (fuzzy subsequence) surface.
190
- expect(labels).toContain("🇨🇳China+86");
198
+ expect(labels).toContain(`${flagOf("cn")}China+86`);
191
199
  expect(labels.some((l) => l.includes("中华人民共和国"))).toBe(true);
192
200
  });
193
201
 
@@ -124,32 +124,11 @@
124
124
  color: var(--hi-color-text-primary, #333);
125
125
  }
126
126
 
127
+ // Legacy placement hook — the chrome lives on the shared .hk-icon-button
128
+ // / .hk-window-close rules (window-close.scss) since the unified-close
129
+ // wave; only the header-flex placement stays here.
127
130
  .hk-drawer-close {
128
131
  flex-shrink: 0;
129
- width: 2rem;
130
- height: 2rem;
131
- display: inline-flex;
132
- align-items: center;
133
- justify-content: center;
134
- padding: 0;
135
- background: transparent;
136
- border: none;
137
- border-radius: var(--hi-radius-md, 0.5rem);
138
- color: var(--hi-color-text-secondary, #666);
139
- cursor: pointer;
140
- transition:
141
- background 0.15s ease,
142
- color 0.15s ease;
143
-
144
- &:hover {
145
- background: var(--hi-secondary-bg, rgba(0, 0, 0, 0.06));
146
- color: var(--hi-color-text-primary, #333);
147
- }
148
-
149
- &:focus-visible {
150
- outline: 2px solid var(--hi-color-primary, #58a6ff);
151
- outline-offset: 2px;
152
- }
153
132
  }
154
133
 
155
134
  /* Track host for the body's overlay scrollbar — owns the flex slot in
@@ -29,7 +29,7 @@ describe("HkDrawer back-guard (window-first back priority)", () => {
29
29
  }
30
30
  }
31
31
 
32
- function mountDrawer(open: { value: boolean }, closable = true) {
32
+ function mountDrawer(open: { value: boolean }, closable = true, extra: Record<string, unknown> = {}) {
33
33
  const container = document.createElement("div");
34
34
  document.body.appendChild(container);
35
35
  containers.push(container);
@@ -39,6 +39,7 @@ describe("HkDrawer back-guard (window-first back priority)", () => {
39
39
  h(HkDrawer, {
40
40
  modelValue: open.value,
41
41
  closable,
42
+ ...extra,
42
43
  "onUpdate:modelValue": (v: boolean) => { open.value = v; },
43
44
  }, { default: () => h("div", "content") });
44
45
  },
@@ -62,6 +63,23 @@ describe("HkDrawer back-guard (window-first back priority)", () => {
62
63
  app.unmount();
63
64
  });
64
65
 
66
+ it("renders the unified icon-button close with the X glyph when titled", async () => {
67
+ const open = ref(true);
68
+ // The header (and thus the close) renders only with a title/header —
69
+ // pass one so the chrome exists to assert on.
70
+ const app = mountDrawer(open, true, { title: "Inspector" });
71
+ await settle();
72
+
73
+ const closeBtn = document.body.querySelector<HTMLButtonElement>(".hk-drawer-close")!;
74
+ expect(closeBtn).not.toBeNull();
75
+ expect(closeBtn.classList.contains("hk-window-close")).toBe(true);
76
+ // Unified close glyph: shared icon-button renders the registry X
77
+ // (default-slot branch, never the fallback placeholder circle).
78
+ expect(closeBtn.querySelector(".hk-icon")).not.toBeNull();
79
+ expect(closeBtn.querySelector("circle")).toBeNull();
80
+ app.unmount();
81
+ });
82
+
65
83
  it("a non-closable drawer never owns history", async () => {
66
84
  const open = ref(true);
67
85
  const app = mountDrawer(open, false);
@@ -19,6 +19,9 @@ import { usePopupManager } from "../runtime/usePopupManager";
19
19
  import { createBackGuard } from "../runtime/backStack";
20
20
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
21
21
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
22
+ import HIconButton from "./HkIconButton";
23
+ import HIcon from "./HkIcon";
24
+ import "./window-close.scss";
22
25
 
23
26
  type DrawerSide = "left" | "right" | "top" | "bottom";
24
27
 
@@ -282,24 +285,15 @@ export default defineComponent({
282
285
  <span class="hk-drawer-title">{props.title}</span>
283
286
  )}
284
287
  {props.closable ? (
285
- <button
286
- class="hk-drawer-close"
288
+ <HIconButton
289
+ class="hk-window-close hk-drawer-close"
290
+ size={32}
291
+ variant="ghost"
287
292
  aria-label={t("hikari::drawer.close", "Close")}
288
293
  onClick={close}
289
294
  >
290
- <svg
291
- viewBox="0 0 24 24"
292
- fill="none"
293
- stroke="currentColor"
294
- stroke-width="2"
295
- stroke-linecap="round"
296
- width="16"
297
- height="16"
298
- >
299
- <line x1="18" y1="6" x2="6" y2="18" />
300
- <line x1="6" y1="6" x2="18" y2="18" />
301
- </svg>
302
- </button>
295
+ <HIcon name="X" size={16} />
296
+ </HIconButton>
303
297
  ) : null}
304
298
  </div>
305
299
  ) : null}
@@ -72,4 +72,34 @@ describe("HkIconButton", () => {
72
72
  expect(btn.disabled).toBe(true);
73
73
  expect(btn.getAttribute("aria-label")).toBe("sync");
74
74
  });
75
+
76
+ // Unified window-close wave: children passed as the DEFAULT slot render
77
+ // as the icon (named `icon` slot still wins; no children at all keeps the
78
+ // placeholder circle). Without the default-slot branch, natural JSX usage
79
+ // `<HIconButton><HIcon name="X"/></HIconButton>` silently rendered the
80
+ // fallback circle instead of the consumer's glyph.
81
+ it("renders default-slot children as the icon", () => {
82
+ const c = mountComp(() =>
83
+ h(HkIconButton, { "aria-label": "close" }, { default: () => h("span", { class: "glyph-probe" }, "✕") }),
84
+ );
85
+ expect(c.querySelector(".glyph-probe")).not.toBeNull();
86
+ // The placeholder circle only renders when NEITHER slot is provided.
87
+ expect(c.querySelector("circle")).toBeNull();
88
+ });
89
+
90
+ it("prefers the named icon slot over default children", () => {
91
+ const c = mountComp(() =>
92
+ h(HkIconButton, { "aria-label": "close" }, {
93
+ icon: () => h("span", { class: "named-probe" }),
94
+ default: () => h("span", { class: "default-probe" }),
95
+ }),
96
+ );
97
+ expect(c.querySelector(".named-probe")).not.toBeNull();
98
+ expect(c.querySelector(".default-probe")).toBeNull();
99
+ });
100
+
101
+ it("keeps the placeholder circle when no slot content is given", () => {
102
+ const c = mountComp(() => h(HkIconButton, { "aria-label": "probe" }));
103
+ expect(c.querySelector("circle")).not.toBeNull();
104
+ });
75
105
  });
@@ -29,13 +29,17 @@ export default defineComponent({
29
29
  {...attrs}
30
30
  >
31
31
  <span class="hk-icon-button-icon">
32
- {slots.icon ? (
33
- slots.icon()
34
- ) : (
35
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
36
- <circle cx="12" cy="12" r="10" />
37
- </svg>
38
- )}
32
+ {slots.icon
33
+ ? slots.icon()
34
+ : slots.default
35
+ ? // Natural children usage: <HIconButton><HIcon .../></HIconButton>
36
+ // renders the child as the icon (unified window-close wave).
37
+ slots.default()
38
+ : (
39
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
40
+ <circle cx="12" cy="12" r="10" />
41
+ </svg>
42
+ )}
39
43
  </span>
40
44
  </button>
41
45
  );
@@ -61,6 +61,10 @@ describe("HkImageLightbox", () => {
61
61
 
62
62
  const btn = s.closeButton();
63
63
  expect(btn.getAttribute("aria-label")).toBe("Close");
64
+ // Unified close glyph: the shared icon-button renders the registry X
65
+ // (default-slot branch), never the fallback placeholder circle.
66
+ expect(btn.querySelector(".hk-icon")).not.toBeNull();
67
+ expect(btn.querySelector("circle")).toBeNull();
64
68
  });
65
69
 
66
70
  it("renders nothing while closed", () => {
@@ -3,8 +3,10 @@ import { defineComponent, onBeforeUnmount, watch } from "vue";
3
3
  import { useI18n } from "../i18n/context";
4
4
  import "./HkImageLightbox.scss";
5
5
  import HIconButton from "./HkIconButton";
6
+ import HIcon from "./HkIcon";
6
7
  import HImageViewer from "./HkImageViewer";
7
8
  import HModal from "./HkModal";
9
+ import "./window-close.scss";
8
10
 
9
11
  /**
10
12
  * Immersive fullscreen viewer for a single image — the "special modal"
@@ -67,18 +69,13 @@ export default defineComponent({
67
69
  <div class="hk-image-lightbox-stage">
68
70
  <HImageViewer src={props.src} alt={props.alt} />
69
71
  <HIconButton
70
- class="hk-image-lightbox-close"
72
+ class="hk-window-close hk-image-lightbox-close"
71
73
  size={32}
72
74
  variant="ghost"
73
75
  aria-label={t("hikari::imageLightbox.close", "Close")}
74
76
  onClick={close}
75
77
  >
76
- {{ icon: () => (
77
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
78
- <line x1="18" y1="6" x2="6" y2="18" />
79
- <line x1="6" y1="6" x2="18" y2="18" />
80
- </svg>
81
- ) }}
78
+ <HIcon name="X" size={16} />
82
79
  </HIconButton>
83
80
  </div>
84
81
  </HModal>
@@ -3,6 +3,14 @@ import { createApp, h, nextTick } from "vue";
3
3
 
4
4
  import { HkLocalizedInput } from "./HkLocalizedInput";
5
5
 
6
+ /** Regional-indicator flag pair for an ISO 3166-1 alpha-2 code, built
7
+ * from code points (same derivation as data/dialCodes flagEmoji) so the
8
+ * test source itself carries no literal emoji. */
9
+ const flagOf = (iso: string): string =>
10
+ String.fromCodePoint(
11
+ ...Array.from(iso.toUpperCase(), (c) => 0x1f1e6 + c.charCodeAt(0) - 65),
12
+ );
13
+
6
14
  const LOCALES = [
7
15
  { code: "en", label: "English" },
8
16
  { code: "zh-Hans", label: "简体中文" },
@@ -504,15 +512,15 @@ describe("HkLocalizedInput", () => {
504
512
  sourceLang: "en",
505
513
  translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
506
514
  localeOptions: [
507
- { code: "en", label: "English", flag: "🇬🇧" },
508
- { code: "zh-Hans", label: "简体中文", flag: "🇨🇳" },
515
+ { code: "en", label: "English", flag: flagOf("gb") },
516
+ { code: "zh-Hans", label: "简体中文", flag: flagOf("cn") },
509
517
  ],
510
518
  }),
511
519
  });
512
520
  app.mount(container);
513
521
  mounts.push({ app, container });
514
522
  await openPicker(container);
515
- expect(tag("简体中文")?.querySelector(".hk-affix-tag-flag")?.textContent).toBe("🇨🇳");
523
+ expect(tag("简体中文")?.querySelector(".hk-affix-tag-flag")?.textContent).toBe(flagOf("cn"));
516
524
  });
517
525
 
518
526
  it("follows a sourceLang change without stealing focus", async () => {
@@ -36,7 +36,7 @@
36
36
  /* Leading check column (selectMode). The cell is reserved on EVERY row
37
37
  * of a level while the column is on, so labels stay aligned; only
38
38
  * checked === true shows the mark — an SVG check inside the same fixed
39
- * cell grammar as leading icons (the old text "✓" carried its own
39
+ * cell grammar as leading icons (the old text glyph carried its own
40
40
  * baseline rhythm and sat off-center against svg icons). */
41
41
  .hk-menu-check {
42
42
  @include mi.icon;
@@ -3,6 +3,14 @@ import { createApp, defineComponent, h, nextTick, ref, type ComponentPublicInsta
3
3
 
4
4
  import HkMenu, { type HkMenuItem } from "./HkMenu";
5
5
 
6
+ /** Regional-indicator flag pair for an ISO 3166-1 alpha-2 code, built
7
+ * from code points (same derivation as data/dialCodes flagEmoji) so the
8
+ * fixture source itself carries no literal emoji. */
9
+ const flagOf = (iso: string): string =>
10
+ String.fromCodePoint(
11
+ ...Array.from(iso.toUpperCase(), (c) => 0x1f1e6 + c.charCodeAt(0) - 65),
12
+ );
13
+
6
14
  const mounts: ReturnType<typeof createApp>[] = [];
7
15
  const containers: HTMLElement[] = [];
8
16
 
@@ -11,8 +19,8 @@ const items: HkMenuItem[] = [
11
19
  key: "lang",
12
20
  label: "Language",
13
21
  children: [
14
- { key: "en", label: "English", flag: "🇬🇧" },
15
- { key: "zh", label: "中文", flag: "🇨🇳", checked: true },
22
+ { key: "en", label: "English", flag: flagOf("gb") },
23
+ { key: "zh", label: "中文", flag: flagOf("cn"), checked: true },
16
24
  ],
17
25
  },
18
26
  { key: "logout", label: "Log out", danger: true },
@@ -173,44 +173,18 @@
173
173
  white-space: nowrap;
174
174
  overflow: hidden;
175
175
  text-overflow: ellipsis;
176
+ // Window chrome, not content — titles never participate in text
177
+ // selection (2026-09-04 user report alongside the sheet chrome pass).
178
+ user-select: none;
179
+ -webkit-user-select: none;
176
180
  }
177
181
 
182
+ // Legacy placement hook — the chrome (box, radius, colors, hover tint,
183
+ // selection) lives on the shared .hk-icon-button / .hk-window-close rules
184
+ // (window-close.scss) since the unified-close wave; only placement stays
185
+ // here so the header flex keeps the button pinned right.
178
186
  .hk-modal-close {
179
187
  flex-shrink: 0;
180
- width: 2rem;
181
- height: 2rem;
182
- display: inline-flex;
183
- align-items: center;
184
- justify-content: center;
185
- padding: 0;
186
- background: transparent;
187
- border: none;
188
- border-radius: var(--hi-radius-md, 0.5rem);
189
- color: var(--hk-modal-close-color, var(--hi-color-text-secondary, #666));
190
- cursor: pointer;
191
- transition:
192
- background 0.15s ease,
193
- color 0.15s ease;
194
-
195
- &:hover {
196
- background: var(
197
- --hk-modal-close-hover-bg,
198
- var(--hi-secondary-bg, rgba(0, 0, 0, 0.06))
199
- );
200
- color: var(
201
- --hk-modal-close-hover-color,
202
- var(--hi-color-text-primary, #333)
203
- );
204
- }
205
-
206
- &:active {
207
- transform: scale(0.92);
208
- }
209
-
210
- &:focus-visible {
211
- outline: 2px solid var(--hi-color-primary, #ffc0cb);
212
- outline-offset: 2px;
213
- }
214
188
  }
215
189
 
216
190
  // ------
@@ -339,9 +313,10 @@
339
313
  // modal rendered ~60vh of blank body under three rows). The top inset
340
314
  // now only bounds the sheet via the max-height cap below.
341
315
  top: auto;
342
- // --hk-sheet-bottom-gap keeps the docked sheet from touching the very
343
- // bottom edge (host-tunable; chest narrows the default visual gap).
344
- bottom: var(--hk-sheet-bottom-gap, 0.375rem);
316
+ // --hk-sheet-bottom-gap: hosts may lift the docked sheet off the very
317
+ // bottom edge; the family default sits flush on it (2026-09-04 user
318
+ // report: even the tightened 0.375rem default still read as a seam).
319
+ bottom: var(--hk-sheet-bottom-gap, 0px);
345
320
  left: 0;
346
321
  right: 0;
347
322
  transform: none;
@@ -356,10 +331,10 @@
356
331
  // Plain-vh fallback first: dvh-less engines (older Android WebView /
357
332
  // Tauri) would drop the whole dvh calc and leave the sheet uncapped.
358
333
  max-height: calc(
359
- 100vh - var(--hk-sheet-top-inset, 4.25rem) - var(--hk-sheet-bottom-gap, 0.375rem)
334
+ 100vh - var(--hk-sheet-top-inset, 4.25rem) - var(--hk-sheet-bottom-gap, 0px)
360
335
  );
361
336
  max-height: calc(
362
- 100dvh - var(--hk-sheet-top-inset, 4.25rem) - var(--hk-sheet-bottom-gap, 0.375rem)
337
+ 100dvh - var(--hk-sheet-top-inset, 4.25rem) - var(--hk-sheet-bottom-gap, 0px)
363
338
  );
364
339
  }
365
340
 
@@ -21,13 +21,14 @@ const here = dirname(fileURLToPath(import.meta.url));
21
21
  const src = readFileSync(join(here, "HkModal.scss"), "utf-8");
22
22
 
23
23
  describe("HkModal mobile sheet spacing contract", () => {
24
- it("sizes the docked sheet from --hk-sheet-bottom-gap (not hard 0)", () => {
24
+ it("sizes the docked sheet from --hk-sheet-bottom-gap (host-tunable hook)", () => {
25
25
  const block = src.slice(src.indexOf("@media (max-width: 767px)"));
26
26
  expect(block).toContain("bottom: var(--hk-sheet-bottom-gap");
27
- // Default keeps a small visible gap; hosts may narrow or zero it.
28
- // (2026-08-29 user request: the docked sheet sat too far off the
29
- // bottom — default tightened from 0.5rem to 0.375rem.)
30
- expect(block).toMatch(/--hk-sheet-bottom-gap,\s*0\.375rem/);
27
+ // Family default sits flush on the bottom edge; hosts may re-add a
28
+ // gap via the same custom property. (History: 2026-08-29 tightened
29
+ // 0.5rem 0.375rem; 2026-09-04 user report — the remaining sliver
30
+ // still read as a visible seam, so the default settled on 0.)
31
+ expect(block).toMatch(/--hk-sheet-bottom-gap,\s*0px/);
31
32
  });
32
33
 
33
34
  it("keeps the footer gap host-tunable and stacks the safe-area inset", () => {
@@ -139,6 +139,13 @@ describe("HkModal back-guard (window-first back priority)", () => {
139
139
  await settle();
140
140
  expect((window.history.state as Record<string, unknown>)?.__hkBack).toEqual(expect.any(String));
141
141
 
142
+ // Unified close chrome: the header close is the shared icon-button X
143
+ // (default-slot glyph, never the fallback placeholder circle).
144
+ const closeBtn = document.querySelector<HTMLButtonElement>(".hk-modal-close")!;
145
+ expect(closeBtn.classList.contains("hk-window-close")).toBe(true);
146
+ expect(closeBtn.querySelector(".hk-icon")).not.toBeNull();
147
+ expect(closeBtn.querySelector("circle")).toBeNull();
148
+
142
149
  (document.querySelector(".hk-modal-close") as HTMLButtonElement).click();
143
150
  await until(() => open.value === false);
144
151
  // History rewound to the page base — a subsequent back leaves the page,
@@ -25,6 +25,9 @@ import { useSizeMorph } from "../composables/useSizeMorph";
25
25
  import HButton from "./HkButton";
26
26
  import HFab from "./HkFab";
27
27
  import HSpinner from "./HkSpinner";
28
+ import HIconButton from "./HkIconButton";
29
+ import HIcon from "./HkIcon";
30
+ import "./window-close.scss";
28
31
 
29
32
  export interface ModalAction {
30
33
  label: string;
@@ -738,24 +741,19 @@ export default defineComponent({
738
741
  {slots.header ? (props.title ?? "") : (props.title ?? "")}
739
742
  </h2>
740
743
  {props.closable && (
741
- <button
742
- class="hk-modal-close"
744
+ // Unified window-close affordance: the shared
745
+ // icon button + registry X glyph (see
746
+ // window-close.scss) — no per-component ✕ SVG.
747
+ // `hk-modal-close` stays as the placement hook.
748
+ <HIconButton
749
+ class="hk-window-close hk-modal-close"
750
+ size={32}
751
+ variant="ghost"
743
752
  aria-label={t("hikari::modal.close", "Close")}
744
753
  onClick={close}
745
754
  >
746
- <svg
747
- viewBox="0 0 24 24"
748
- fill="none"
749
- stroke="currentColor"
750
- stroke-width="2"
751
- stroke-linecap="round"
752
- width="16"
753
- height="16"
754
- >
755
- <line x1="18" y1="6" x2="6" y2="18" />
756
- <line x1="6" y1="6" x2="18" y2="18" />
757
- </svg>
758
- </button>
755
+ <HIcon name="X" size={16} />
756
+ </HIconButton>
759
757
  )}
760
758
  </div>
761
759
  {slots.header && (
@@ -220,14 +220,14 @@ describe("HkPasswordInput variants and icons", () => {
220
220
  "",
221
221
  { icon: "custom" },
222
222
  {
223
- icon: () => [h("span", { class: "custom-icon" }, "custom-✓")],
223
+ icon: () => [h("span", { class: "custom-icon" }, "custom-mark")],
224
224
  },
225
225
  );
226
226
  expect(
227
227
  container.querySelector(".hk-pwd-lock")?.getAttribute("data-icon"),
228
228
  ).toBe("custom");
229
229
  expect(container.querySelector(".custom-icon")?.textContent).toBe(
230
- "custom-✓",
230
+ "custom-mark",
231
231
  );
232
232
  expect(container.querySelector(".hk-pwd-lock svg")).toBeNull();
233
233
  });
@@ -100,7 +100,7 @@
100
100
  72vh,
101
101
  calc(
102
102
  100vh - var(--hk-sheet-top-inset, 4.25rem) -
103
- var(--hk-sheet-bottom-gap, 0.375rem)
103
+ var(--hk-sheet-bottom-gap, 0px)
104
104
  )
105
105
  );
106
106
  display: flex;
@@ -137,6 +137,10 @@
137
137
  cursor: pointer;
138
138
  display: flex;
139
139
  justify-content: center;
140
+ // Window chrome, not content — a long-press must never start a text
141
+ // selection over the drag handle.
142
+ user-select: none;
143
+ -webkit-user-select: none;
140
144
 
141
145
  &::before {
142
146
  content: "";
@@ -147,17 +151,46 @@
147
151
  }
148
152
  }
149
153
 
150
- .hk-popover-sheet-title {
154
+ /* Sheet heading row — title on the left, explicit close on the right,
155
+ * one vertically-centered flex line (2026-09-04 user report: the bare
156
+ * title band read too small, hung right of the content it named, and
157
+ * long-press selected it; every other window in the app carries an ✕).
158
+ * The title aligns to the CONTENT edge: the leading inset defaults to
159
+ * --space-12, matching the padded content blocks popover sheets host
160
+ * (e.g. HkContextRing's 0.75rem body). Hosts tune it via
161
+ * --hk-sheet-title-inset; the trailing inset hugs the close button. */
162
+ .hk-popover-sheet-header {
151
163
  flex-shrink: 0;
152
- // Same heading grammar as the select sheet title: small, semibold,
153
- // slightly letterspaced, inset to the content edge. Hosts tune the
154
- // leading inset via --hk-sheet-title-inset.
155
- padding: 0 var(--space-16, 1rem) var(--space-8, 0.5rem)
156
- var(--hk-sheet-title-inset, calc(var(--space-16, 1rem) * 2));
157
- font-size: var(--text-xs, 0.75rem);
164
+ display: flex;
165
+ align-items: center;
166
+ gap: var(--space-8, 0.5rem);
167
+ padding: 0 var(--space-8, 0.5rem) var(--space-8, 0.5rem)
168
+ var(--hk-sheet-title-inset, var(--space-12, 0.75rem));
169
+ }
170
+
171
+ .hk-popover-sheet-title {
172
+ flex: 1;
173
+ min-width: 0;
174
+ overflow: hidden;
175
+ white-space: nowrap;
176
+ text-overflow: ellipsis;
177
+ // Same heading grammar as the select sheet title: semibold, slightly
178
+ // letterspaced — one step up from the old 0.75rem so the sheet names
179
+ // itself at body-text size, and window chrome never participates in
180
+ // text selection.
181
+ font-size: var(--text-base, 0.875rem);
158
182
  font-weight: 600;
159
183
  letter-spacing: 0.02em;
160
184
  color: rgb(var(--color-muted, 140 140 140));
185
+ user-select: none;
186
+ -webkit-user-select: none;
187
+ }
188
+
189
+ .hk-popover-sheet-close {
190
+ // Legacy placement hook — the chrome lives on the shared .hk-icon-button
191
+ // / .hk-window-close rules (window-close.scss) since the unified-close
192
+ // wave. Only the right-edge pinning stays here.
193
+ margin-left: auto;
161
194
  }
162
195
 
163
196
  .hk-popover-sheet-enter-active {
@@ -17,11 +17,13 @@ const tsx = readFileSync(join(here, "HkPopover.tsx"), "utf-8");
17
17
  const scss = readFileSync(join(here, "HkPopover.scss"), "utf-8");
18
18
 
19
19
  describe("HkPopover mobile sheet docking contract", () => {
20
- it("docks the sheet above the shared bottom gap (inline sheet style)", () => {
20
+ it("docks the sheet at the shared bottom-gap hook (inline sheet style)", () => {
21
21
  const block = tsx.slice(tsx.indexOf("panelStyle"));
22
22
  expect(block).toContain("var(--hk-sheet-bottom-gap");
23
- // Same default as the HkModal sheet contract.
24
- expect(block).toMatch(/--hk-sheet-bottom-gap,\s*0\.375rem/);
23
+ // Family default sits flush on the bottom edge (2026-09-04 user
24
+ // report: even the tightened 0.375rem lift read as a visible seam);
25
+ // hosts may re-add a gap via the same custom property.
26
+ expect(block).toMatch(/--hk-sheet-bottom-gap,\s*0px/);
25
27
  });
26
28
 
27
29
  it("keeps sheet content clear of the home bar via the safe-area inset", () => {
@@ -15,9 +15,13 @@ import {
15
15
 
16
16
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
17
17
  import { useBreakpoint } from "../runtime/useBreakpoint";
18
+ import { useI18n } from "../i18n/context";
18
19
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
19
20
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
20
21
  import { onceFrame } from "../runtime/animationBus";
22
+ import HIconButton from "./HkIconButton";
23
+ import HIcon from "./HkIcon";
24
+ import "./window-close.scss";
21
25
  import "./HkPopover.scss";
22
26
 
23
27
  export type PopupPlacement =
@@ -76,6 +80,7 @@ export default defineComponent({
76
80
  setup(props, { emit, slots }) {
77
81
  const attrs = useAttrs();
78
82
  const manager = usePopupManager();
83
+ const { t } = useI18n();
79
84
  const handle = ref<PopupHandle | null>(null);
80
85
  const panelRef = ref<HTMLElement>();
81
86
  const resolvedPlacement = ref<PopupPlacement>(props.placement);
@@ -489,15 +494,17 @@ export default defineComponent({
489
494
 
490
495
  const panelStyle = computed(() => {
491
496
  if (sheetMode.value) {
492
- // Bottom sheet: pinned above the bottom edge with the SAME
493
- // host-tunable gap as the HkModal mobile docking
494
- // (--hk-sheet-bottom-gap) so every bottom-up window belongs to
495
- // one sheet family; full width, inside the top inset reserved
496
- // for the secondary-window breadcrumb strip.
497
+ // Bottom sheet: sits flush on the viewport bottom edge (the
498
+ // host-tunable --hk-sheet-bottom-gap lift is still honored so the
499
+ // sheet belongs to the HkModal/HkSelect sheet family — the family
500
+ // default is 0 since the 2026-09-04 report: even a 0.375rem lift
501
+ // read as a visible seam under the docked sheet); full width,
502
+ // inside the top inset reserved for the secondary-window
503
+ // breadcrumb strip.
497
504
  return {
498
505
  left: "0",
499
506
  right: "0",
500
- bottom: "var(--hk-sheet-bottom-gap, 0.375rem)",
507
+ bottom: "var(--hk-sheet-bottom-gap, 0px)",
501
508
  top: "auto" as const,
502
509
  position: "fixed" as const,
503
510
  pointerEvents: "auto" as const,
@@ -568,11 +575,27 @@ export default defineComponent({
568
575
  {sheetMode.value && (
569
576
  <div class="hk-popover-sheet-grabber" aria-hidden="true" onClick={() => close()} />
570
577
  )}
571
- {sheetMode.value && props.title && (
572
- // Sheet heading bandopt-in via the `title` prop so
573
- // anchored-desktop consumers are untouched; keeps the
574
- // docked sheet naming itself like every other window.
575
- <div class="hk-popover-sheet-title">{props.title}</div>
578
+ {sheetMode.value && (
579
+ // Sheet heading rowthe title and the close button on
580
+ // one vertically-centered line (2026-09-04 user report:
581
+ // the bare title band read too small and hung right of
582
+ // the content it named, and every other window in the
583
+ // app carries an explicit ✕). Same header grammar as the
584
+ // HkModal header: title left, close on the right edge.
585
+ <div class="hk-popover-sheet-header">
586
+ {props.title && (
587
+ <div class="hk-popover-sheet-title">{props.title}</div>
588
+ )}
589
+ <HIconButton
590
+ class="hk-window-close hk-popover-sheet-close"
591
+ size={32}
592
+ variant="ghost"
593
+ aria-label={t("hikari::modal.close", "Close")}
594
+ onClick={close}
595
+ >
596
+ <HIcon name="X" size={16} />
597
+ </HIconButton>
598
+ </div>
576
599
  )}
577
600
  {slots.default?.()}
578
601
  </div>
@@ -127,4 +127,54 @@ describe("HkPopover sheetOnMobile", () => {
127
127
  expect(document.body.querySelector(".hk-popover-panel")!.classList.contains("hk-is-sheet")).toBe(true);
128
128
  expect(document.body.querySelector(".hk-popover-scrim")).toBeNull();
129
129
  });
130
+
131
+ // 2026-09-04 user report: the sheet heading was a bare small band —
132
+ // the sheet now names itself with a title + explicit close button on
133
+ // one vertically-centered line, like every other window in the app.
134
+ it("renders a title + close heading row when titled, close alone when not", async () => {
135
+ setViewport(375);
136
+ mountPopover({ sheetOnMobile: true, title: "Context usage" });
137
+ await nextTick();
138
+
139
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
140
+ const header = panel.querySelector<HTMLElement>(".hk-popover-sheet-header")!;
141
+ expect(header).toBeTruthy();
142
+ expect(header.querySelector(".hk-popover-sheet-title")!.textContent).toBe("Context usage");
143
+ const close = header.querySelector<HTMLButtonElement>(".hk-popover-sheet-close")!;
144
+ expect(close).toBeTruthy();
145
+ expect(close.getAttribute("aria-label")).toBeTruthy();
146
+ // The X glyph renders through the default-slot branch of HkIconButton
147
+ // (the fallback placeholder circle must never appear in a window close).
148
+ expect(close.querySelector(".hk-icon")).not.toBeNull();
149
+ expect(close.querySelector("circle")).toBeNull();
150
+ });
151
+
152
+ it("closes the sheet from the heading close button", async () => {
153
+ setViewport(375);
154
+ const { open } = mountPopover({ sheetOnMobile: true, title: "Context usage" });
155
+ await nextTick();
156
+ const close = document.body.querySelector<HTMLButtonElement>(".hk-popover-sheet-close")!;
157
+ close.click();
158
+ await nextTick();
159
+ expect(open.value).toBe(false);
160
+ });
161
+
162
+ it("renders the close button even without a title, right-aligned", async () => {
163
+ setViewport(375);
164
+ mountPopover({ sheetOnMobile: true });
165
+ await nextTick();
166
+ const header = document.body.querySelector<HTMLElement>(".hk-popover-sheet-header")!;
167
+ expect(header.querySelector(".hk-popover-sheet-title")).toBeNull();
168
+ expect(header.querySelector(".hk-popover-sheet-close")).toBeTruthy();
169
+ });
170
+
171
+ it("keeps the heading row out of anchored desktop mode", async () => {
172
+ setViewport(1200);
173
+ mountPopover({ sheetOnMobile: true, title: "Context usage" });
174
+ await nextTick();
175
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
176
+ expect(panel.querySelector(".hk-popover-sheet-header")).toBeNull();
177
+ expect(panel.querySelector(".hk-popover-sheet-title")).toBeNull();
178
+ expect(panel.querySelector(".hk-popover-sheet-close")).toBeNull();
179
+ });
130
180
  });
@@ -244,11 +244,12 @@
244
244
  position: fixed;
245
245
  left: 0;
246
246
  right: 0;
247
- /* Float off the bottom edge with the SAME host-tunable gap as the
248
- * HkModal mobile docking (--hk-sheet-bottom-gap) every bottom-up
249
- * window reads as one sheet family; was glued to bottom: 0, which is
250
- * exactly why dropdown sheets read as "not the standard modal". */
251
- bottom: var(--hk-sheet-bottom-gap, 0.375rem);
247
+ /* Sit flush on the bottom edge; the host-tunable --hk-sheet-bottom-gap
248
+ * lift (shared with the HkModal mobile docking) stays available so
249
+ * every bottom-up window reads as one sheet family. Was glued to
250
+ * bottom: 0, then floated at the 0.375rem family default both wrong
251
+ * for someone; the 2026-09-04 report settles the family on flush. */
252
+ bottom: var(--hk-sheet-bottom-gap, 0px);
252
253
  display: flex;
253
254
  flex-direction: column;
254
255
  /* The panel height hugs its content (up to the cap below); the sheet
@@ -266,7 +267,7 @@
266
267
  72vh,
267
268
  calc(
268
269
  100vh - var(--hk-sheet-top-inset, 4.25rem) -
269
- var(--hk-sheet-bottom-gap, 0.375rem)
270
+ var(--hk-sheet-bottom-gap, 0px)
270
271
  )
271
272
  );
272
273
  background: rgb(var(--color-surface));
@@ -285,6 +286,10 @@
285
286
  cursor: pointer;
286
287
  display: flex;
287
288
  justify-content: center;
289
+ /* Window chrome — never a text-selection start (matches the popover
290
+ * sheet grabber). */
291
+ user-select: none;
292
+ -webkit-user-select: none;
288
293
 
289
294
  &::before {
290
295
  content: "";
@@ -295,20 +300,46 @@
295
300
  }
296
301
  }
297
302
 
298
- .hk-select-sheet-title {
303
+ /* Sheet heading row — same unified grammar as the popover sheet header
304
+ * (title left, shared icon-button ✕ on the right edge, one vertically
305
+ * centered line). The select sheet previously had NO explicit close
306
+ * affordance at all: dismissal was grabber-tap/scrim only. */
307
+ .hk-select-sheet-header {
299
308
  flex-shrink: 0;
309
+ display: flex;
310
+ align-items: center;
311
+ padding: 0 var(--space-8) var(--space-8) 0;
312
+ }
313
+
314
+ .hk-select-sheet-close {
315
+ // Right-edge pinning with or without a title beside it; the chrome lives
316
+ // on the shared .hk-icon-button / .hk-window-close rules
317
+ // (window-close.scss) since the unified-close wave.
318
+ margin-left: auto;
319
+ }
320
+
321
+ .hk-select-sheet-title {
322
+ flex: 1;
323
+ min-width: 0;
324
+ overflow: hidden;
325
+ white-space: nowrap;
326
+ text-overflow: ellipsis;
300
327
  /* Left inset tracks the sheet ENTRIES' text edge, not their card
301
328
  * edge: the list pads option blocks in by --space-16 and rows carry
302
329
  * roughly another --space-16 of leading padding inside, so the old
303
330
  * single --space-16 left the title hanging left of every row it
304
331
  * named instead of reading as the list's heading. Hosts tune it via
305
- * --hk-sheet-title-inset; the trailing inset stays --space-16. */
306
- padding: 0 var(--space-16) var(--space-8)
307
- var(--hk-sheet-title-inset, calc(var(--space-16) * 2));
308
- font-size: var(--text-xs);
332
+ * --hk-sheet-title-inset. Size/weight match the popover sheet heading
333
+ * (--text-base, window chrome never text-selectable). The trailing
334
+ * inset doubles as the gap to the close button; the vertical rhythm
335
+ * moved to the header row. */
336
+ padding: 0 var(--space-16) 0 var(--hk-sheet-title-inset, calc(var(--space-16) * 2));
337
+ font-size: var(--text-base);
309
338
  font-weight: 600;
310
339
  letter-spacing: 0.02em;
311
340
  color: rgb(var(--color-muted));
341
+ user-select: none;
342
+ -webkit-user-select: none;
312
343
  }
313
344
 
314
345
  /* Track host for the sheet list's overlay scrollbar — owns the flex
@@ -16,11 +16,13 @@ const here = dirname(fileURLToPath(import.meta.url));
16
16
  const src = readFileSync(join(here, "HkSelect.scss"), "utf-8");
17
17
 
18
18
  describe("HkSelectPanel mobile sheet docking contract", () => {
19
- it("floats the sheet on --hk-sheet-bottom-gap (not hard 0)", () => {
19
+ it("docks the sheet on the --hk-sheet-bottom-gap hook (flush family default)", () => {
20
20
  const block = src.match(/\.hk-select-sheet-panel\s*{[^}]*}/)?.[0] ?? "";
21
21
  expect(block).toContain("bottom: var(--hk-sheet-bottom-gap");
22
- // Same default as the HkModal sheet contract.
23
- expect(block).toMatch(/--hk-sheet-bottom-gap,\s*0\.375rem/);
22
+ // Family default sits flush on the bottom edge (2026-09-04 user
23
+ // report: the 0.375rem family default read as a seam); hosts may
24
+ // re-add a gap via the same custom property.
25
+ expect(block).toMatch(/--hk-sheet-bottom-gap,\s*0px/);
24
26
  });
25
27
 
26
28
  it("stacks the safe-area inset on the sheet list bottom padding", () => {
@@ -17,6 +17,10 @@ import { createBackGuard } from "../runtime/backStack";
17
17
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
18
18
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
19
19
  import { useSizeMorph } from "../composables/useSizeMorph";
20
+ import { useI18n } from "../i18n/context";
21
+ import HIconButton from "./HkIconButton";
22
+ import HIcon from "./HkIcon";
23
+ import "./window-close.scss";
20
24
  import "./HkSelect.scss";
21
25
 
22
26
  /**
@@ -86,6 +90,7 @@ export default defineComponent({
86
90
  },
87
91
  setup(props, { emit, slots, expose }) {
88
92
  const manager = usePopupManager();
93
+ const { t } = useI18n();
89
94
  const handle = ref<PopupHandle | null>(null);
90
95
  /** z of the last live registration — the leave transition keeps
91
96
  * painting at this z after the registry entry is dropped, so a
@@ -541,9 +546,26 @@ export default defineComponent({
541
546
  aria-hidden="true"
542
547
  onClick={close}
543
548
  />
544
- {props.title ? (
545
- <div class="hk-select-sheet-title">{props.title}</div>
546
- ) : null}
549
+ {/*
550
+ Unified sheet heading row — same grammar as the popover
551
+ sheet header (title left, shared icon-button ✕ on the
552
+ right edge). The select sheet previously had no explicit
553
+ close affordance at all (grabber/scrim only).
554
+ */}
555
+ <div class="hk-select-sheet-header">
556
+ {props.title ? (
557
+ <div class="hk-select-sheet-title">{props.title}</div>
558
+ ) : null}
559
+ <HIconButton
560
+ class="hk-window-close hk-select-sheet-close"
561
+ size={32}
562
+ variant="ghost"
563
+ aria-label={t("hikari::modal.close", "Close")}
564
+ onClick={close}
565
+ >
566
+ <HIcon name="X" size={16} />
567
+ </HIconButton>
568
+ </div>
547
569
  <div class="hk-select-sheet-body" ref={sheetBodyRef}>
548
570
  <div class="hk-select-sheet-list" ref={sheetListRef}>
549
571
  <div class="hk-select-sheet-content" ref={sheetContentRef}>
@@ -103,6 +103,37 @@ describe("HkSelect mobile sheet", () => {
103
103
  expect(container.querySelector<HTMLElement>(".hk-select-trigger")!.dataset.state).toBe("closed");
104
104
  });
105
105
 
106
+ // Unified window-close wave: the select sheet previously had no explicit
107
+ // close affordance (grabber/scrim only). It now carries the same heading
108
+ // row as the popover sheet — title left, shared icon-button ✕ right.
109
+ it("closes the sheet via the heading close button", async () => {
110
+ setViewport(375);
111
+ const { container } = mountSelect();
112
+ await nextTick();
113
+
114
+ container.querySelector<HTMLButtonElement>(".hk-select-trigger")!.click();
115
+ await nextTick();
116
+ const panel = document.body.querySelector<HTMLElement>(".hk-select-sheet-panel")!;
117
+ const header = panel.querySelector<HTMLElement>(".hk-select-sheet-header")!;
118
+ expect(header).toBeTruthy();
119
+ expect(header.querySelector(".hk-select-sheet-title")!.textContent).toBe("Channel");
120
+ const close = header.querySelector<HTMLButtonElement>(".hk-select-sheet-close")!;
121
+ expect(close).toBeTruthy();
122
+ expect(close.getAttribute("aria-label")).toBeTruthy();
123
+ // attrs merging through HkIconButton keeps BOTH the shared icon-button
124
+ // chrome classes and the consumer's close hooks on the same element.
125
+ expect(close.classList.contains("hk-icon-button")).toBe(true);
126
+ expect(close.classList.contains("hk-icon-button-ghost")).toBe(true);
127
+ expect(close.classList.contains("hk-window-close")).toBe(true);
128
+ // The X glyph actually renders (default-slot branch, not the fallback
129
+ // placeholder circle).
130
+ expect(close.querySelector(".hk-icon")).not.toBeNull();
131
+ expect(close.querySelector("circle")).toBeNull();
132
+ close.click();
133
+ await nextTick();
134
+ expect(container.querySelector<HTMLElement>(".hk-select-trigger")!.dataset.state).toBe("closed");
135
+ });
136
+
106
137
  it("keeps the anchored popout on desktop widths", async () => {
107
138
  setViewport(1200);
108
139
  const { container } = mountSelect();
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Source contract for the unified window-close affordance (user directive:
3
+ * unify on the shared icon button + one close glyph, not per-component ✕
4
+ * SVG copies).
5
+ *
6
+ * Every window chrome close control — modal header, drawer header,
7
+ * popover/select mobile-sheet heading row — must be an HkIconButton
8
+ * carrying the registry's X glyph and the shared `hk-window-close`
9
+ * grammar class (window-close.scss). No window component keeps its own
10
+ * inline ✕ line-SVG any more: one glyph, one chrome, many windows.
11
+ *
12
+ * Pinned here so a refactor cannot silently re-sprout per-component close
13
+ * SVG copies — the same "looks unified but drifted" failure class the
14
+ * sheet-dock contracts guard against.
15
+ */
16
+ import { describe, expect, it } from "vitest";
17
+ import { readFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const here = dirname(fileURLToPath(import.meta.url));
22
+ const WINDOW_FILES = [
23
+ "HkModal.tsx",
24
+ "HkPopover.tsx",
25
+ "HkDrawer.tsx",
26
+ "HkSelectPanel.tsx",
27
+ "HkImageLightbox.tsx",
28
+ ];
29
+
30
+ describe("unified window-close contract", () => {
31
+ it("renders every close control as HkIconButton + the X glyph", () => {
32
+ for (const f of WINDOW_FILES) {
33
+ const src = readFileSync(join(here, f), "utf-8");
34
+ expect(src, `${f} must carry the shared grammar class`).toContain(
35
+ "hk-window-close",
36
+ );
37
+ expect(src, `${f} must render the registry X glyph`).toContain(
38
+ 'HIcon name="X"',
39
+ );
40
+ expect(src, `${f} must close via HkIconButton`).toContain("HIconButton");
41
+ }
42
+ });
43
+
44
+ it("keeps no per-component inline ✕ SVG duplicates", () => {
45
+ for (const f of WINDOW_FILES) {
46
+ const src = readFileSync(join(here, f), "utf-8");
47
+ expect(src, `${f} must not re-inline the ✕ line SVG`).not.toContain(
48
+ 'x1="18"',
49
+ );
50
+ }
51
+ });
52
+ });
@@ -0,0 +1,31 @@
1
+ // ------
2
+ // Unified window-close affordance grammar.
3
+ //
4
+ // Every window chrome close control — modal header, drawer header,
5
+ // popover/select mobile-sheet heading row — renders the SAME thing: an
6
+ // HkIconButton (ghost, 32px box) carrying the registry's X glyph
7
+ // (HkIcon name="X"). No component keeps its own inline ✕ SVG any more;
8
+ // the glyph comes from the shared lucide registry and the chrome comes
9
+ // from the icon-button, so there is exactly one source for each.
10
+ //
11
+ // This partial only carries the window-chrome grammar ON TOP of the icon
12
+ // button: a close control never participates in text selection, and it
13
+ // gets the hover tint HkIconButton intentionally leaves to the Glow
14
+ // wrapper (window headers render no Glow). The `--hk-modal-close-hover-*`
15
+ // custom properties stay the shared host tunables for that tint.
16
+ // ------
17
+ .hk-icon-button.hk-window-close {
18
+ user-select: none;
19
+ -webkit-user-select: none;
20
+
21
+ &:hover {
22
+ background: var(
23
+ --hk-modal-close-hover-bg,
24
+ var(--hi-secondary-bg, rgba(0, 0, 0, 0.06))
25
+ );
26
+ color: var(
27
+ --hk-modal-close-hover-color,
28
+ var(--hi-color-text-primary, #333)
29
+ );
30
+ }
31
+ }
@@ -32,9 +32,16 @@ describe("dialCodes catalog", () => {
32
32
  });
33
33
 
34
34
  it("derives flag emoji from ISO codes", () => {
35
- expect(flagEmoji("cn")).toBe("🇨🇳");
36
- expect(flagEmoji("us")).toBe("🇺🇸");
37
- expect(flagEmoji("zz")).toBe("🇿🇿"); // any two letters map
35
+ // Expected regional-indicator pair built from code points so the
36
+ // test source carries no literal emoji while still pinning the
37
+ // exact runtime output byte-for-byte.
38
+ const expectedFlag = (iso: string): string =>
39
+ String.fromCodePoint(
40
+ ...Array.from(iso.toUpperCase(), (c) => 0x1f1e6 + c.charCodeAt(0) - 65),
41
+ );
42
+ expect(flagEmoji("cn")).toBe(expectedFlag("cn"));
43
+ expect(flagEmoji("us")).toBe(expectedFlag("us"));
44
+ expect(flagEmoji("zz")).toBe(expectedFlag("zz")); // any two letters map
38
45
  expect(flagEmoji("")).toBe("");
39
46
  });
40
47
  });