@celestia-island/hikari 0.55.212 → 0.55.214

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.55.212",
3
+ "version": "0.55.214",
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",
@@ -37,6 +37,10 @@
37
37
  "./plugins": {
38
38
  "import": "./src/plugins/index.ts",
39
39
  "types": "./src/plugins/index.ts"
40
+ },
41
+ "./plugins/boot-chrome": {
42
+ "import": "./src/plugins/boot-chrome.mjs",
43
+ "types": "./src/plugins/boot-chrome.d.mts"
40
44
  }
41
45
  },
42
46
  "files": [
@@ -0,0 +1,222 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, nextTick, ref } from "vue";
3
+
4
+ import HkContextMenuProvider from "./HkContextMenuProvider";
5
+ import HkTagDotRow from "./HkTagDotRow";
6
+ import {
7
+ useContextMenu,
8
+ type ContextMenuApi,
9
+ } from "../composables/useContextMenu";
10
+
11
+ /** Mount into a detached div appended to the body (the menu panels
12
+ * teleport; assertions see them through document.querySelectorAll). */
13
+ function mountToDom(component: ReturnType<typeof defineComponent>): HTMLElement {
14
+ const el = document.createElement("div");
15
+ document.body.appendChild(el);
16
+ createApp(component).mount(el);
17
+ return el;
18
+ }
19
+
20
+ const popouts = () =>
21
+ Array.from(document.querySelectorAll(".hk-select-popout")) as HTMLElement[];
22
+
23
+ const menuRows = () =>
24
+ Array.from(document.querySelectorAll(".hk-menu-row")) as HTMLButtonElement[];
25
+
26
+ const headSlot = () => document.querySelector(".hk-menu-slot--head");
27
+ const footSlot = () => document.querySelector(".hk-menu-slot--foot");
28
+
29
+ /** The provider host, exposing the injected API to the test. */
30
+ function makeHost(): { Component: ReturnType<typeof defineComponent>; api: () => ContextMenuApi } {
31
+ let held: ContextMenuApi | null = null;
32
+ const Inner = defineComponent({
33
+ name: "ContentInner",
34
+ setup() {
35
+ held = useContextMenu();
36
+ return () => <button class="open-btn">open</button>;
37
+ },
38
+ });
39
+ const Component = defineComponent({
40
+ name: "ContentHost",
41
+ setup() {
42
+ return () => (
43
+ <HkContextMenuProvider>
44
+ <Inner />
45
+ </HkContextMenuProvider>
46
+ );
47
+ },
48
+ });
49
+ return { Component, api: () => held! };
50
+ }
51
+
52
+ afterEach(() => {
53
+ document
54
+ .querySelectorAll(
55
+ ".hk-select-popout, .hk-select-sheet-panel, .hk-select-sheet-scrim, .open-btn",
56
+ )
57
+ .forEach((el) => el.remove());
58
+ window.history.replaceState(null, "");
59
+ });
60
+
61
+ /** nextTick + a macrotask + nextTick — panels mount/unmount through
62
+ * transitions and history, so a single nextTick races them. */
63
+ async function settle(): Promise<void> {
64
+ await nextTick();
65
+ await new Promise((resolve) => setTimeout(resolve, 0));
66
+ await nextTick();
67
+ }
68
+
69
+ /** Wait until pred() holds (panel teardown settles asynchronously). */
70
+ async function until(pred: () => boolean, ms = 500): Promise<void> {
71
+ const deadline = Date.now() + ms;
72
+ while (!pred() && Date.now() < deadline) {
73
+ await new Promise((resolve) => setTimeout(resolve, 10));
74
+ await nextTick();
75
+ }
76
+ }
77
+
78
+ describe("HkContextMenuProvider — custom content regions", () => {
79
+ it("renders header and footer regions around the item rows", async () => {
80
+ const { Component, api } = makeHost();
81
+ mountToDom(Component);
82
+ api().open({
83
+ x: 100,
84
+ y: 80,
85
+ title: "Menu",
86
+ items: [{ key: "copy", label: "Copy" }],
87
+ header: () => (
88
+ <HkTagDotRow
89
+ tags={[{ key: "red", color: "#e5484d", label: "Red", active: true }]}
90
+ label="tags"
91
+ onToggle={() => {}}
92
+ />
93
+ ),
94
+ footer: () => <div class="content-foot">F</div>,
95
+ onSelect: () => {},
96
+ onClose: () => {},
97
+ });
98
+ await settle();
99
+ expect(popouts()).toHaveLength(1);
100
+
101
+ // The header hosts the tag strip; the footer its own node.
102
+ expect(headSlot()?.querySelector(".hk-tag-dot")).toBeTruthy();
103
+ expect(footSlot()?.querySelector(".content-foot")).toBeTruthy();
104
+
105
+ // Band order: header region → item rows → footer region.
106
+ const level = document.querySelector(".hk-menu-level")!;
107
+ const kinds = Array.from(level.children).map((el) =>
108
+ el.classList.contains("hk-menu-slot") && el.classList.contains("hk-menu-slot--head")
109
+ ? "head"
110
+ : el.classList.contains("hk-menu-slot") && el.classList.contains("hk-menu-slot--foot")
111
+ ? "foot"
112
+ : "row",
113
+ );
114
+ expect(kinds).toEqual(["head", "row", "foot"]);
115
+ });
116
+
117
+ it("keeps the menu open across clicks inside the header region", async () => {
118
+ const picked: string[] = [];
119
+ const closed = vi.fn();
120
+ const { Component, api } = makeHost();
121
+ mountToDom(Component);
122
+ api().open({
123
+ x: 100,
124
+ y: 80,
125
+ title: "Menu",
126
+ items: [{ key: "copy", label: "Copy" }],
127
+ header: () => (
128
+ <HkTagDotRow
129
+ tags={[
130
+ { key: "red", color: "#e5484d", label: "Red" },
131
+ { key: "yellow", color: "#ffd400", label: "Yellow" },
132
+ ]}
133
+ label="tags"
134
+ onToggle={() => {}}
135
+ />
136
+ ),
137
+ onSelect: (key) => picked.push(key),
138
+ onClose: closed,
139
+ });
140
+ await settle();
141
+ const dot = document.querySelector(".hk-tag-dot") as HTMLButtonElement;
142
+ dot.click();
143
+ await settle();
144
+ // An inside-region click toggles, it never dismisses…
145
+ expect(popouts()).toHaveLength(1);
146
+ expect(picked).toEqual([]);
147
+ expect(closed).not.toHaveBeenCalled();
148
+ // …and an item pick after it still resolves normally.
149
+ menuRows()[0].click();
150
+ await settle();
151
+ expect(picked).toEqual(["copy"]);
152
+ await until(() => popouts().length === 0);
153
+ });
154
+
155
+ it("keeps the header region live while the menu is open", async () => {
156
+ const { Component, api } = makeHost();
157
+ mountToDom(Component);
158
+ const count = ref(0);
159
+ api().open({
160
+ x: 100,
161
+ y: 80,
162
+ items: [{ key: "copy", label: "Copy" }],
163
+ header: () => <div class="content-hdr">n{count.value}</div>,
164
+ onSelect: () => {},
165
+ onClose: () => {},
166
+ });
167
+ await settle();
168
+ expect(headSlot()?.textContent).toContain("n0");
169
+ count.value = 3;
170
+ await settle();
171
+ expect(headSlot()?.textContent).toContain("n3");
172
+ });
173
+
174
+ it("renders no region wrappers when the request carries none", async () => {
175
+ const { Component, api } = makeHost();
176
+ mountToDom(Component);
177
+ api().open({
178
+ x: 100,
179
+ y: 80,
180
+ items: [{ key: "copy", label: "Copy" }],
181
+ onSelect: () => {},
182
+ onClose: () => {},
183
+ });
184
+ await settle();
185
+ expect(popouts()).toHaveLength(1);
186
+ expect(headSlot()).toBeNull();
187
+ expect(footSlot()).toBeNull();
188
+ expect(menuRows()).toHaveLength(1);
189
+ api().close();
190
+ await until(() => popouts().length === 0);
191
+ });
192
+
193
+ it("swaps the regions when a second open replaces the menu", async () => {
194
+ const { Component, api } = makeHost();
195
+ mountToDom(Component);
196
+ api().open({
197
+ x: 100,
198
+ y: 80,
199
+ items: [{ key: "a", label: "A" }],
200
+ header: () => <div class="content-a">A</div>,
201
+ onSelect: () => {},
202
+ onClose: () => {},
203
+ });
204
+ await settle();
205
+ expect(headSlot()?.querySelector(".content-a")).toBeTruthy();
206
+ api().open({
207
+ x: 120,
208
+ y: 90,
209
+ items: [{ key: "b", label: "B" }],
210
+ header: () => <div class="content-b">B</div>,
211
+ onSelect: () => {},
212
+ onClose: () => {},
213
+ });
214
+ await settle();
215
+ // The epoch remount drops the stale region and its rows wholesale.
216
+ expect(headSlot()?.querySelector(".content-a")).toBeNull();
217
+ expect(headSlot()?.querySelector(".content-b")).toBeTruthy();
218
+ expect(menuRows().map((r) => r.textContent)).toEqual(["B"]);
219
+ api().close();
220
+ await until(() => popouts().length === 0);
221
+ });
222
+ });
@@ -110,6 +110,22 @@ export default defineComponent({
110
110
  quadrantPlacement(request.value?.x ?? 0, request.value?.y ?? 0),
111
111
  );
112
112
 
113
+ /** The request's custom regions become HkMenu's level-0 header/
114
+ * footer slots (object children = slots in Vue JSX). Only the
115
+ * keys that exist are handed over, so a request without custom
116
+ * content renders exactly as before. The slot closures re-read
117
+ * the CURRENT request through the shallowRef, and each open
118
+ * bumps menuEpoch — a fresh HkMenu mounts with fresh slots, and
119
+ * the region's own reactive reads (host state) keep it live
120
+ * while the menu stays open. */
121
+ const menuSlots = () => {
122
+ const req = request.value;
123
+ return {
124
+ ...(req?.header ? { header: () => req.header!() } : {}),
125
+ ...(req?.footer ? { footer: () => req.footer!() } : {}),
126
+ };
127
+ };
128
+
113
129
  return () => (
114
130
  <>
115
131
  {slots.default?.()}
@@ -136,7 +152,9 @@ export default defineComponent({
136
152
  anchorRef={anchor.value}
137
153
  placement={placement.value}
138
154
  title={request.value?.title ?? ""}
139
- />
155
+ >
156
+ {menuSlots()}
157
+ </HkMenu>
140
158
  </>
141
159
  );
142
160
  },
@@ -0,0 +1,64 @@
1
+ /* ── HkTagDotRow — the Finder-style tag strip ────────────────────
2
+ * A horizontal row of toggleable colored dots with captions beneath
3
+ * (the macOS file-tag picker pattern): one dot per tag, a click
4
+ * toggles, several tags flip in one visit. Metrics and states come
5
+ * from the unified menu-item spec (see ./_menu-item.scss), so the
6
+ * strip keeps menu rhythm inside HkMenu header/footer slots,
7
+ * HkMenuPanel or any popover, and grows with the sheet tokens inside
8
+ * mobile sheets (registered in the spec's sheet-context list). This
9
+ * sheet owns only what is the strip's own: the even column layout,
10
+ * the dot itself, and the caption rhythm. */
11
+
12
+ @use "./menu-item" as mi;
13
+
14
+ .hk-tag-dot-row {
15
+ @include mi.item-tokens;
16
+ display: flex;
17
+ flex-direction: row;
18
+ align-items: stretch;
19
+ gap: var(--hk-menu-item-gap);
20
+ /* A band of its own inside the slot wrapper: rows above and below
21
+ * keep their rhythm, the strip reads as one control. */
22
+ padding-block: var(--hk-menu-item-pad-y);
23
+ }
24
+
25
+ .hk-tag-dot {
26
+ @include mi.item;
27
+ flex: 1 1 0;
28
+ flex-direction: column;
29
+ justify-content: center;
30
+ align-items: center;
31
+ gap: calc(var(--hk-menu-item-gap) / 2);
32
+ min-width: 0;
33
+ text-align: center;
34
+ }
35
+
36
+ .hk-tag-dot__dot {
37
+ flex: none;
38
+ width: calc(var(--hk-menu-item-icon-box) * 0.65);
39
+ height: calc(var(--hk-menu-item-icon-box) * 0.65);
40
+ border-radius: var(--radius-full, 9999px);
41
+ background: var(--hk-tag-color, rgb(var(--color-primary)));
42
+ transition: box-shadow 0.12s ease, opacity 0.12s ease;
43
+ }
44
+
45
+ /* Unapplied tags recede; the applied dot carries a soft ring of its
46
+ * own ink so the COLOR — not just the button wash — reads as on. */
47
+ .hk-tag-dot:not([data-active]) .hk-tag-dot__dot {
48
+ opacity: 0.4;
49
+ }
50
+
51
+ .hk-tag-dot[data-active] .hk-tag-dot__dot {
52
+ box-shadow: 0 0 0 2px
53
+ color-mix(in srgb, var(--hk-tag-color, rgb(var(--color-primary))) 30%, transparent);
54
+ }
55
+
56
+ /* The caption is secondary to the dot: the row grammar above sets
57
+ * size/weight; captions only keep their own line rhythm and may wrap
58
+ * (three short columns, never a wide table). */
59
+ .hk-tag-dot__label {
60
+ min-width: 0;
61
+ max-width: 100%;
62
+ overflow-wrap: anywhere;
63
+ line-height: var(--hk-menu-item-lh);
64
+ }
@@ -0,0 +1,95 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent } from "vue";
3
+
4
+ import HkTagDotRow, { type HkTagDot } from "./HkTagDotRow";
5
+
6
+ /** Mount into a detached div appended to the body — the family's
7
+ * mount pattern (assertions go through document.querySelectorAll). */
8
+ function mountRow(tags: HkTagDot[], onToggle: (key: string) => void, label?: string): void {
9
+ const el = document.createElement("div");
10
+ document.body.appendChild(el);
11
+ createApp(
12
+ defineComponent({
13
+ name: "TagDotRowHost",
14
+ setup() {
15
+ return () => <HkTagDotRow tags={tags} label={label} onToggle={onToggle} />;
16
+ },
17
+ }),
18
+ ).mount(el);
19
+ }
20
+
21
+ const dots = () =>
22
+ Array.from(document.querySelectorAll(".hk-tag-dot")) as HTMLButtonElement[];
23
+
24
+ const dotByAria = (name: string) =>
25
+ dots().find((b) => b.getAttribute("aria-label") === name);
26
+
27
+ afterEach(() => {
28
+ dots().forEach((b) => b.remove());
29
+ document.querySelectorAll(".hk-tag-dot-row").forEach((el) => el.remove());
30
+ });
31
+
32
+ describe("HkTagDotRow", () => {
33
+ it("renders one toggle per tag: dot ink, caption, pressed state", () => {
34
+ mountRow(
35
+ [
36
+ { key: "red", color: "#e5484d", label: "Red" },
37
+ { key: "yellow", color: "#ffd400", active: true },
38
+ { key: "purple", color: "#a855f7" },
39
+ ],
40
+ () => {},
41
+ "Tags",
42
+ );
43
+ const row = document.querySelector(".hk-tag-dot-row")!;
44
+ expect(row.getAttribute("role")).toBe("group");
45
+ expect(row.getAttribute("aria-label")).toBe("Tags");
46
+
47
+ expect(dots()).toHaveLength(3);
48
+ // Dot ink rides the per-tag custom property on the dot itself…
49
+ const red = dotByAria("Red")!;
50
+ const redDot = red.querySelector(".hk-tag-dot__dot") as HTMLElement;
51
+ expect(redDot.style.getPropertyValue("--hk-tag-color")).toBe("#e5484d");
52
+ expect(red.querySelector(".hk-tag-dot__label")?.textContent).toBe("Red");
53
+ expect(red.getAttribute("aria-pressed")).toBe("false");
54
+ expect(red.hasAttribute("data-active")).toBe(false);
55
+
56
+ // …the applied tag carries the selected face and pressed state.
57
+ const yellow = dotByAria("yellow")!;
58
+ expect(yellow.getAttribute("aria-pressed")).toBe("true");
59
+ expect(yellow.hasAttribute("data-active")).toBe(true);
60
+ // A labelless tag falls back to its key for the accessible name
61
+ // and renders no caption span.
62
+ expect(yellow.querySelector(".hk-tag-dot__label")).toBeNull();
63
+ });
64
+
65
+ it("emits toggle with the tag key on click", () => {
66
+ const toggled: string[] = [];
67
+ mountRow(
68
+ [
69
+ { key: "red", color: "#e5484d", label: "Red" },
70
+ { key: "purple", color: "#a855f7", label: "Purple" },
71
+ ],
72
+ (key) => toggled.push(key),
73
+ );
74
+ dotByAria("Red")!.click();
75
+ dotByAria("Purple")!.click();
76
+ expect(toggled).toEqual(["red", "purple"]);
77
+ });
78
+
79
+ it("keeps a disabled tag inert", () => {
80
+ const toggled = vi.fn();
81
+ mountRow(
82
+ [
83
+ { key: "red", color: "#e5484d", label: "Red", disabled: true },
84
+ { key: "purple", color: "#a855f7", label: "Purple" },
85
+ ],
86
+ toggled,
87
+ );
88
+ const red = dotByAria("Red")!;
89
+ expect(red.disabled).toBe(true);
90
+ red.click(); // happy-dom honors disabled on click dispatch
91
+ expect(toggled).not.toHaveBeenCalled();
92
+ dotByAria("Purple")!.click();
93
+ expect(toggled).toHaveBeenCalledWith("purple");
94
+ });
95
+ });
@@ -0,0 +1,65 @@
1
+ import { defineComponent, type PropType } from "vue";
2
+
3
+ import "./HkTagDotRow.scss";
4
+
5
+ /** One toggleable tag of a HkTagDotRow. */
6
+ export interface HkTagDot {
7
+ /** Stable identity emitted by `toggle`. */
8
+ key: string;
9
+ /** Dot color — any CSS color (hex, rgb(), a var() reference…). */
10
+ color: string;
11
+ /** Caption under the dot; the host i18n's it. */
12
+ label?: string;
13
+ /** Whether the tag is currently applied (the row's selected face). */
14
+ active?: boolean;
15
+ /** Inert: rendered dimmed, clicks never toggle it. */
16
+ disabled?: boolean;
17
+ }
18
+
19
+ /**
20
+ * HkTagDotRow — a horizontal strip of toggleable colored dots, the
21
+ * macOS Finder tag-editor pattern: one dot per tag, the caption
22
+ * beneath it, one click toggles. Tags are domain-free {key, color,
23
+ * label} triples — the host owns the vocabulary (and its i18n), the
24
+ * row owns only the geometry and states.
25
+ *
26
+ * Speaks the unified menu-item spec (see ./_menu-item.scss), so it
27
+ * drops straight into menu surfaces — HkMenu's header/footer slots,
28
+ * HkMenuPanel, a popover — and keeps menu rhythm there, growing with
29
+ * the sheet tokens inside mobile sheets; it works anywhere else a
30
+ * compact tag picker belongs (settings rows, card footers). Clicking
31
+ * a dot never closes a surrounding menu: it emits `toggle` only, and
32
+ * the host decides what to do (menus stay open across toggles so
33
+ * several tags can be flipped in one visit).
34
+ */
35
+ export default defineComponent({
36
+ name: "HkTagDotRow",
37
+ props: {
38
+ tags: { type: Array as PropType<HkTagDot[]>, required: true },
39
+ /** Accessible name for the whole group (aria-label). */
40
+ label: { type: String, default: undefined },
41
+ },
42
+ emits: ["toggle"],
43
+ setup(props, { emit }) {
44
+ return () => (
45
+ <div class="hk-tag-dot-row" role="group" aria-label={props.label}>
46
+ {props.tags.map((tag) => (
47
+ <button
48
+ key={tag.key}
49
+ type="button"
50
+ class="hk-tag-dot"
51
+ data-active={tag.active || undefined}
52
+ disabled={tag.disabled}
53
+ aria-pressed={tag.active ? "true" : "false"}
54
+ aria-label={tag.label ?? tag.key}
55
+ title={tag.label}
56
+ onClick={() => emit("toggle", tag.key)}
57
+ >
58
+ <span class="hk-tag-dot__dot" style={{ "--hk-tag-color": tag.color }} />
59
+ {tag.label ? <span class="hk-tag-dot__label">{tag.label}</span> : null}
60
+ </button>
61
+ ))}
62
+ </div>
63
+ );
64
+ },
65
+ });
@@ -109,6 +109,74 @@ describe("HkTitleBar", () => {
109
109
  });
110
110
  });
111
111
 
112
+ describe("HkTitleBar window-chrome claim", () => {
113
+ // The bar IS the app's title bar, so mounting it must stand hikari's boot bar
114
+ // (boot-chrome.js) down — otherwise two caption bars overlap for the rest of
115
+ // the session. Tests use a raw window listener: that is the whole contract.
116
+ function collect(): { claims: Array<{ by?: string }>; stop: () => void } {
117
+ const claims: Array<{ by?: string }> = [];
118
+ const listener = (event: Event) => {
119
+ claims.push((event as CustomEvent<{ by?: string }>).detail ?? {});
120
+ };
121
+ window.addEventListener("hk:chrome-claim", listener);
122
+ return {
123
+ claims,
124
+ stop: () => window.removeEventListener("hk:chrome-claim", listener),
125
+ };
126
+ }
127
+
128
+ function cleanup(): void {
129
+ document.documentElement.removeAttribute("data-hk-chrome");
130
+ }
131
+
132
+ it("claims the window on mount and marks the app as the chrome owner", () => {
133
+ const { claims, stop } = collect();
134
+ try {
135
+ mount(h(HkTitleBar, { title: "App" }));
136
+
137
+ expect(claims).toHaveLength(1);
138
+ expect(claims[0]?.by).toBe("HkTitleBar");
139
+ expect(document.documentElement.getAttribute("data-hk-chrome")).toBe("app");
140
+ } finally {
141
+ stop();
142
+ cleanup();
143
+ }
144
+ });
145
+
146
+ it("never claims twice for one mount (a re-render is not a new takeover)", async () => {
147
+ const { claims, stop } = collect();
148
+ try {
149
+ const maximized = ref(false);
150
+ mount(h(() => h(HkTitleBar, { title: "App", maximized: maximized.value })));
151
+ expect(claims).toHaveLength(1);
152
+
153
+ maximized.value = true;
154
+ await nextTick();
155
+ maximized.value = false;
156
+ await nextTick();
157
+
158
+ expect(claims).toHaveLength(1);
159
+ } finally {
160
+ stop();
161
+ cleanup();
162
+ }
163
+ });
164
+
165
+ it("leaves the existing props/emits contract untouched", () => {
166
+ const emitted: string[] = [];
167
+ const host = mount(
168
+ h(HkTitleBar, {
169
+ title: "App",
170
+ onMinimize: () => emitted.push("minimize"),
171
+ onClose: () => emitted.push("close"),
172
+ }),
173
+ );
174
+ host.querySelector<HTMLButtonElement>('[title="Minimize"]')!.click();
175
+ host.querySelector<HTMLButtonElement>('[title="Close"]')!.click();
176
+ expect(emitted).toEqual(["minimize", "close"]);
177
+ });
178
+ });
179
+
112
180
  describe("HkTitleBar stylesheet contract", () => {
113
181
  const css = compile(resolve(componentDir, "HkTitleBar.scss"), {
114
182
  style: "expanded",
@@ -1,5 +1,6 @@
1
1
  import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
2
2
  import { useI18n } from "../i18n/context";
3
+ import { claimWindowChrome } from "../runtime/windowChrome";
3
4
 
4
5
  import "./HkTitleBar.scss";
5
6
 
@@ -60,6 +61,16 @@ export default defineComponent({
60
61
  },
61
62
  setup(props, { emit, slots }) {
62
63
  const { t } = useI18n();
64
+ // The bar IS the app's window chrome, so mounting it means the boot chrome
65
+ // (boot-chrome.js, drawn before the bundle exists) must hand the window
66
+ // over: dispatch `hk:chrome-claim` once per mount, never revoked. See
67
+ // runtime/windowChrome for the contract; hydration/SSR never reaches here.
68
+ let claimed = false;
69
+ onMounted(() => {
70
+ if (claimed) return;
71
+ claimed = true;
72
+ claimWindowChrome("HkTitleBar");
73
+ });
63
74
  return () => (
64
75
  <div
65
76
  class="hk-titlebar"