@celestia-island/hikari 0.55.272 → 0.55.273

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.272",
3
+ "version": "0.55.273",
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",
@@ -0,0 +1,77 @@
1
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h, nextTick } from "vue";
3
+
4
+ import { HkCardList, HkListRow } from "./HkCardList";
5
+
6
+ /**
7
+ * Touch-first clients never get hover tooltips, so the option-control
8
+ * array swaps its inline icon lane for ONE trigger that opens the same
9
+ * actions as a named menu. The media query is stubbed here (this file
10
+ * runs in its own module graph — vitest isolates test files — so the
11
+ * shared useMediaQuery registry sees the touch-first answer).
12
+ */
13
+
14
+ beforeAll(() => {
15
+ vi.spyOn(window, "matchMedia").mockImplementation(
16
+ (query: string): MediaQueryList => ({
17
+ matches: query.includes("hover: none") || query.includes("pointer: coarse"),
18
+ media: query,
19
+ onchange: null,
20
+ addEventListener: () => {},
21
+ removeEventListener: () => {},
22
+ addListener: () => {},
23
+ removeListener: () => {},
24
+ dispatchEvent: () => false,
25
+ }),
26
+ );
27
+ });
28
+
29
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
30
+
31
+ afterEach(() => {
32
+ while (mounts.length) {
33
+ const { app, container } = mounts.pop()!;
34
+ app.unmount();
35
+ container.remove();
36
+ }
37
+ });
38
+
39
+ const icon = { render: () => null };
40
+
41
+ it("swaps the inline lane for a named upward menu trigger", async () => {
42
+ const picks: string[] = [];
43
+ const container = document.createElement("div");
44
+ document.body.appendChild(container);
45
+ const app = createApp({
46
+ render: () =>
47
+ h(HkCardList, null, {
48
+ default: () => [
49
+ h(HkListRow, {
50
+ title: "row",
51
+ actionsLabel: "row actions",
52
+ actionsMenuLabel: "more actions",
53
+ actions: [
54
+ { name: "reinstall", icon, onSelect: () => picks.push("reinstall") },
55
+ { name: "uninstall", danger: true, onSelect: () => picks.push("uninstall") },
56
+ ],
57
+ }),
58
+ ],
59
+ }),
60
+ });
61
+ mounts.push({ app, container });
62
+ app.mount(container);
63
+
64
+ const lane = container.querySelector(".hk-list-row-actions") as HTMLElement;
65
+ expect(lane.classList.contains("hk-list-row-actions--touch")).toBe(true);
66
+ // No inline icon buttons — the names live in the menu on this client.
67
+ expect(lane.querySelector(".hk-list-row-actions__btn")).toBeNull();
68
+ const trigger = lane.querySelector(".hk-list-row-actions__trigger") as HTMLButtonElement;
69
+ expect(trigger).toBeTruthy();
70
+ expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
71
+ expect(trigger.getAttribute("aria-label")).toBe("more actions");
72
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
73
+
74
+ trigger.click();
75
+ await nextTick();
76
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
77
+ });
@@ -0,0 +1,148 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h, nextTick, type Component } from "vue";
3
+
4
+ import { HkCardList, HkListRow, hkListRowMenuItems, type HkListRowAction } from "./HkCardList";
5
+
6
+ /**
7
+ * HkListRow's option-control array contract (pointer-client half):
8
+ *
9
+ * - persistent mode keeps the lane in the flow, one aria-labelled icon
10
+ * button per action;
11
+ * - overlay mode (the default) parks the lane out of the flow and only
12
+ * the touch reveal flips [data-revealed] (pointer devices reveal via
13
+ * CSS :hover/:focus-within, which happy-dom cannot exercise);
14
+ * - every action's NAME is its accessible identity — the aria-label and
15
+ * the menu model label (desktop tooltip / touch menu row);
16
+ * - the menu model maps the declared actions one-to-one.
17
+ *
18
+ * (Repo test convention: raw createApp + document queries; happy-dom
19
+ * has no layout engine.)
20
+ */
21
+
22
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
23
+
24
+ afterEach(() => {
25
+ while (mounts.length) {
26
+ const { app, container } = mounts.pop()!;
27
+ app.unmount();
28
+ container.remove();
29
+ }
30
+ });
31
+
32
+ function mount(render: () => ReturnType<typeof h>): HTMLElement {
33
+ const container = document.createElement("div");
34
+ document.body.appendChild(container);
35
+ const app = createApp({ render });
36
+ mounts.push({ app, container });
37
+ app.mount(container);
38
+ return container;
39
+ }
40
+
41
+ function actionsOf(selects: Array<() => void>, over: Partial<HkListRowAction> = {}): HkListRowAction[] {
42
+ return selects.map((onSelect, i) => ({ name: "action " + (i + 1), onSelect, ...over }));
43
+ }
44
+
45
+ /** pointerType cannot ride the Event constructor in every engine —
46
+ * inject it on a plain bubbling event. */
47
+ function pointerDown(target: EventTarget, pointerType: string): void {
48
+ const ev = new Event("pointerdown", { bubbles: true, cancelable: true });
49
+ Object.defineProperty(ev, "pointerType", { value: pointerType });
50
+ target.dispatchEvent(ev);
51
+ }
52
+
53
+ function mountRow(props: Record<string, unknown>, trailing?: () => unknown): HTMLElement {
54
+ return mount(() =>
55
+ h(HkCardList, null, {
56
+ default: () => [
57
+ h(HkListRow, { title: "row", ...props }, trailing ? { trailing } : undefined),
58
+ ],
59
+ }),
60
+ );
61
+ }
62
+
63
+ describe("HkListRow actions — persistent mode", () => {
64
+ it("renders one named button per action and reports selection", () => {
65
+ const picks: string[] = [];
66
+ const container = mountRow({
67
+ actions: [
68
+ { name: "reinstall", onSelect: () => picks.push("reinstall"), icon: { render: () => null } as Component },
69
+ { name: "uninstall", onSelect: () => picks.push("uninstall"), danger: true },
70
+ { name: "disabled one", onSelect: () => picks.push("no"), disabled: true },
71
+ ],
72
+ actionsMode: "persistent",
73
+ actionsLabel: "row actions",
74
+ });
75
+ const lane = container.querySelector(".hk-list-row-actions") as HTMLElement;
76
+ expect(lane).toBeTruthy();
77
+ expect(lane.classList.contains("hk-list-row-actions--persistent")).toBe(true);
78
+ expect(lane.getAttribute("role")).toBe("toolbar");
79
+ expect(lane.getAttribute("aria-label")).toBe("row actions");
80
+ const buttons = Array.from(lane.querySelectorAll<HTMLButtonElement>(".hk-list-row-actions__btn"));
81
+ expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual([
82
+ "reinstall",
83
+ "uninstall",
84
+ "disabled one",
85
+ ]);
86
+ expect(buttons[1].hasAttribute("data-danger")).toBe(true);
87
+ expect(buttons[2].disabled).toBe(true);
88
+ buttons[0].click();
89
+ buttons[1].click();
90
+ buttons[2].click();
91
+ expect(picks).toEqual(["reinstall", "uninstall"]);
92
+ });
93
+ });
94
+
95
+ describe("HkListRow actions — overlay mode", () => {
96
+ it("parks the lane out of the flow until a non-mouse pointer engages it", async () => {
97
+ const container = mountRow({
98
+ actions: actionsOf([() => {}]),
99
+ actionsMode: "overlay",
100
+ });
101
+ const lane = () => container.querySelector(".hk-list-row-actions") as HTMLElement;
102
+ expect(lane().classList.contains("hk-list-row-actions--overlay")).toBe(true);
103
+ expect(lane().getAttribute("data-revealed")).toBeNull();
104
+ const row = container.querySelector(".hk-list-row") as HTMLElement;
105
+
106
+ // Mouse presses never reveal — pointer devices reveal through CSS
107
+ // :hover/:focus-within, so the JS path stays silent for them.
108
+ pointerDown(row, "mouse");
109
+ await nextTick();
110
+ expect(lane().getAttribute("data-revealed")).toBeNull();
111
+
112
+ // A touch tap inside the row reveals the lane…
113
+ pointerDown(row, "touch");
114
+ await nextTick();
115
+ expect(lane().getAttribute("data-revealed")).not.toBeNull();
116
+
117
+ // …and a tap anywhere else dismisses it again.
118
+ pointerDown(document.body, "touch");
119
+ await nextTick();
120
+ expect(lane().getAttribute("data-revealed")).toBeNull();
121
+ });
122
+
123
+ it("keeps the overlay lane focusable for keyboard users", () => {
124
+ // Hiding is opacity/pointer-events only: visibility:hidden would
125
+ // drop the buttons out of the tab order and strand keyboard users.
126
+ const container = mountRow({
127
+ actions: actionsOf([() => {}]),
128
+ actionsMode: "overlay",
129
+ });
130
+ const btn = container.querySelector(".hk-list-row-actions__btn") as HTMLButtonElement;
131
+ expect(btn).toBeTruthy();
132
+ expect(btn.disabled).toBe(false);
133
+ });
134
+ });
135
+
136
+ describe("hkListRowMenuItems", () => {
137
+ it("maps every declared action onto its menu row, keyed by name", () => {
138
+ const icon = { render: () => null } as Component;
139
+ const items = hkListRowMenuItems([
140
+ { name: "reinstall", onSelect: () => {}, icon },
141
+ { name: "uninstall", onSelect: () => {}, danger: true, disabled: true },
142
+ ]);
143
+ expect(items).toEqual([
144
+ { key: "reinstall", label: "reinstall", icon, danger: undefined, disabled: undefined },
145
+ { key: "uninstall", label: "uninstall", icon: undefined, danger: true, disabled: true },
146
+ ]);
147
+ });
148
+ });
@@ -66,3 +66,106 @@
66
66
  align-items: center;
67
67
  gap: var(--space-8, 0.5rem);
68
68
  }
69
+
70
+ /* ── Option-control array (HkListRow's `actions`) ───────────────────────
71
+ * The row's last lane: named icon buttons in one of two layouts (see
72
+ * HkListRowActionsMode). "persistent" keeps the lane in the flow — it
73
+ * costs the row its width and stays visible. "overlay" (the default)
74
+ * parks the lane OUT of the flow: pinned to the row's right edge,
75
+ * invisible and inert until engaged — hover / keyboard focus on pointer
76
+ * devices (CSS below), tap on touch (the [data-revealed] state) — and
77
+ * then temporarily covers the trailing content, so a dense list never
78
+ * pays for actions it is not using. Touch-first clients swap the lane
79
+ * for one trigger that opens the same actions as a named, upward menu
80
+ * (tooltips never fire there, so names must live in the menu). */
81
+ .hk-list-row {
82
+ position: relative;
83
+ }
84
+
85
+ .hk-list-row-actions {
86
+ display: inline-flex;
87
+ align-items: center;
88
+ gap: var(--space-4, 0.25rem);
89
+ flex-shrink: 0;
90
+
91
+ /* In the flow (persistent + touch): pin to the right edge when the
92
+ row carries no trailing slot of its own. */
93
+ &--persistent,
94
+ &--touch {
95
+ margin-left: auto;
96
+ }
97
+
98
+ /* Parked overlay: absolute over the row's right content. Kept
99
+ focusable for keyboard users — opacity alone hides it visually
100
+ while :focus-within reveals it (visibility:hidden would drop it
101
+ out of the tab order entirely). */
102
+ &--overlay {
103
+ position: absolute;
104
+ right: var(--space-8, 0.5rem);
105
+ top: 50%;
106
+ transform: translateY(-50%);
107
+ padding: 0.125rem;
108
+ border-radius: var(--radius-sm, 0.375rem);
109
+ background: rgb(var(--color-surface, 255 255 255));
110
+ box-shadow: 0 2px 10px rgb(var(--color-border, 100 100 100) / 22%);
111
+ opacity: 0;
112
+ pointer-events: none;
113
+ transition: opacity var(--duration-fast, 0.15s) ease;
114
+ }
115
+
116
+ &--overlay[data-revealed],
117
+ &--overlay:focus-within {
118
+ opacity: 1;
119
+ pointer-events: auto;
120
+ }
121
+ }
122
+
123
+ /* Pointer devices: hovering the row reveals the parked overlay. */
124
+ @media (hover: hover) {
125
+ .hk-list-row:hover > .hk-list-row-actions--overlay {
126
+ opacity: 1;
127
+ pointer-events: auto;
128
+ }
129
+ }
130
+
131
+ .hk-list-row-actions__btn,
132
+ .hk-list-row-actions__trigger {
133
+ display: inline-flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ width: 1.625rem;
137
+ height: 1.625rem;
138
+ padding: 0;
139
+ border: 1px solid transparent;
140
+ border-radius: var(--radius-xs, 0.25rem);
141
+ background: transparent;
142
+ color: rgb(var(--color-muted, 108 108 108));
143
+ cursor: pointer;
144
+ transition:
145
+ background-color var(--duration-fast, 0.15s) ease,
146
+ color var(--duration-fast, 0.15s) ease;
147
+
148
+ &:hover:not(:disabled) {
149
+ background: rgb(var(--color-text, 30 30 30) / 6%);
150
+ color: rgb(var(--color-text, 30 30 30));
151
+ }
152
+
153
+ &:focus-visible {
154
+ outline: 2px solid rgb(var(--color-primary, 122 162 247));
155
+ outline-offset: -2px;
156
+ }
157
+
158
+ &:disabled {
159
+ opacity: 0.45;
160
+ cursor: default;
161
+ }
162
+
163
+ &[data-danger] {
164
+ color: rgb(var(--color-error, 247 118 142) / 90%);
165
+
166
+ &:hover:not(:disabled) {
167
+ background: rgb(var(--color-error, 247 118 142) / 10%);
168
+ color: rgb(var(--color-error, 247 118 142));
169
+ }
170
+ }
171
+ }
@@ -1,7 +1,12 @@
1
- import { Comment, defineComponent, type Component, type PropType, type VNode } from "vue";
1
+ import { Comment, computed, defineComponent, h, onBeforeUnmount, onMounted, ref, type Component, type PropType, type VNode } from "vue";
2
+ import { MoreHorizontal } from "lucide-vue-next";
2
3
 
3
4
  import HkCard from "./HkCard";
4
5
  import { HkIconChip } from "./HkIconChip";
6
+ import HkMenu from "./HkMenu";
7
+ import type { HkMenuItem } from "./HkMenu";
8
+ import HkTooltip from "./HkTooltip";
9
+ import { useMediaQuery } from "../runtime/useMediaQuery";
5
10
  import type { StatTone } from "./HkStatCard";
6
11
  import "./HkCardList.scss";
7
12
 
@@ -57,6 +62,39 @@ export const HkCardList = defineComponent({
57
62
  },
58
63
  });
59
64
 
65
+ /** One named action of an HkListRow's option-control array (the
66
+ * `actions` prop). `name` is REQUIRED: it is the button's accessible
67
+ * name and its only visible identity — the pointer tooltip's text on
68
+ * hover devices, and the touch/narrow menu's row label where tooltips
69
+ * never fire. */
70
+ export interface HkListRowAction {
71
+ name: string;
72
+ icon?: Component;
73
+ danger?: boolean;
74
+ disabled?: boolean;
75
+ onSelect: () => void;
76
+ }
77
+
78
+ /** The option-control array's layout contract (see `actionsMode`):
79
+ * "overlay" — the array costs the row no width; it stays hidden until
80
+ * engaged (hover / keyboard focus on pointer devices, tap on touch)
81
+ * and then temporarily covers the row's right content. "persistent" —
82
+ * the array takes the last column and stays visible. */
83
+ export type HkListRowActionsMode = "overlay" | "persistent";
84
+
85
+ /** The touch trigger's menu model: one HkMenuItem per named action.
86
+ * Keys are the action NAMES (unique per row by contract), so the
87
+ * menu's select payload maps straight back onto `onSelect`. */
88
+ export function hkListRowMenuItems(actions: readonly HkListRowAction[]): HkMenuItem[] {
89
+ return actions.map((a) => ({
90
+ key: a.name,
91
+ label: a.name,
92
+ icon: a.icon,
93
+ danger: a.danger,
94
+ disabled: a.disabled,
95
+ }));
96
+ }
97
+
60
98
  /**
61
99
  * HkListRow — one entry of an HkCardList: an optional tone-tinted icon
62
100
  * chip, a main column (title / subtitle / free-form default slot), and a
@@ -76,11 +114,48 @@ export const HkListRow = defineComponent({
76
114
  subtitle: { type: String, default: undefined },
77
115
  /** Whole-row click (drill-through). */
78
116
  clickable: { type: Boolean, default: false },
117
+ /** The row's option-control array: named icon actions rendered as
118
+ * the row's last lane. Omit for rows without row-level actions. */
119
+ actions: { type: Array as PropType<HkListRowAction[]>, default: undefined },
120
+ /** See HkListRowActionsMode. Default "overlay": the array is parked
121
+ * out of the flow and only covers the right content while engaged. */
122
+ actionsMode: { type: String as PropType<HkListRowActionsMode>, default: "overlay" },
123
+ /** Accessible name of the action lane (role="toolbar"). */
124
+ actionsLabel: { type: String, default: undefined },
125
+ /** Accessible name / menu title of the touch trigger. Falls back to
126
+ * `actionsLabel`. */
127
+ actionsMenuLabel: { type: String, default: undefined },
79
128
  },
80
129
  emits: { click: (_e: MouseEvent | KeyboardEvent) => true },
81
130
  setup(props, { emit, slots }) {
131
+ // Touch-first clients get the named menu (tooltips never fire there);
132
+ // pointer clients get the inline icon lane whose names ride tooltips.
133
+ const touchFirst = useMediaQuery("(hover: none), (pointer: coarse)");
134
+ /** Touch-driven overlay reveal. Pointer devices reveal through CSS
135
+ * :hover/:focus-within — the common path stays declarative. */
136
+ const revealed = ref(false);
137
+ const menuOpen = ref(false);
138
+ const rowEl = ref<HTMLElement | null>(null);
139
+ const triggerEl = ref<HTMLElement | null>(null);
140
+ const hasActions = () => (props.actions?.length ?? 0) > 0;
141
+ /** A tap inside the row reveals the overlay lane; a tap anywhere
142
+ * else dismisses it (capture-phase document listener). */
143
+ function onRowPointerDown(e: PointerEvent) {
144
+ if (!hasActions() || e.pointerType === "mouse") return;
145
+ revealed.value = true;
146
+ }
147
+ function onDocumentPointerDown(e: PointerEvent) {
148
+ if (!revealed.value) return;
149
+ if (rowEl.value?.contains(e.target as Node)) return;
150
+ revealed.value = false;
151
+ }
152
+ onMounted(() => document.addEventListener("pointerdown", onDocumentPointerDown, true));
153
+ onBeforeUnmount(() => document.removeEventListener("pointerdown", onDocumentPointerDown, true));
154
+ const menuItems = computed<HkMenuItem[]>(() => hkListRowMenuItems(props.actions ?? []));
82
155
  return () => (
83
156
  <div
157
+ ref={rowEl}
158
+ onPointerdown={onRowPointerDown}
84
159
  class={["hk-list-row", props.clickable && "hk-list-row-clickable"]}
85
160
  role="listitem"
86
161
  tabindex={props.clickable ? 0 : undefined}
@@ -107,6 +182,67 @@ export const HkListRow = defineComponent({
107
182
  {slots.default?.()}
108
183
  </div>
109
184
  {slots.trailing ? <div class="hk-list-row-trailing">{slots.trailing()}</div> : null}
185
+ {hasActions() && (
186
+ <div
187
+ class={[
188
+ "hk-list-row-actions",
189
+ touchFirst.value
190
+ ? "hk-list-row-actions--touch"
191
+ : props.actionsMode === "persistent"
192
+ ? "hk-list-row-actions--persistent"
193
+ : "hk-list-row-actions--overlay",
194
+ ]}
195
+ data-revealed={revealed.value ? "" : undefined}
196
+ role={touchFirst.value ? undefined : "toolbar"}
197
+ aria-label={touchFirst.value ? undefined : props.actionsLabel}
198
+ >
199
+ {touchFirst.value ? (
200
+ <button
201
+ ref={triggerEl}
202
+ type="button"
203
+ class="hk-list-row-actions__trigger"
204
+ aria-haspopup="menu"
205
+ aria-expanded={menuOpen.value}
206
+ aria-label={props.actionsMenuLabel ?? props.actionsLabel}
207
+ onClick={() => (menuOpen.value = !menuOpen.value)}
208
+ >
209
+ <MoreHorizontal size={14} />
210
+ </button>
211
+ ) : (
212
+ (props.actions ?? []).map((a) => (
213
+ <HkTooltip text={a.name} placement="top">
214
+ {() => (
215
+ <button
216
+ type="button"
217
+ class="hk-list-row-actions__btn"
218
+ data-danger={a.danger || undefined}
219
+ disabled={a.disabled}
220
+ aria-label={a.name}
221
+ onClick={() => a.onSelect()}
222
+ >
223
+ {a.icon ? h(a.icon, { size: 14 }) : null}
224
+ </button>
225
+ )}
226
+ </HkTooltip>
227
+ ))
228
+ )}
229
+ </div>
230
+ )}
231
+ {menuOpen.value && (
232
+ <HkMenu
233
+ variant="popup"
234
+ items={menuItems.value}
235
+ open
236
+ anchorRef={triggerEl.value}
237
+ placement="top-end"
238
+ title={props.actionsMenuLabel ?? props.actionsLabel ?? ""}
239
+ onSelect={(key: string) => {
240
+ menuOpen.value = false;
241
+ (props.actions ?? []).find((a) => a.name === key)?.onSelect();
242
+ }}
243
+ onUpdate:open={(v: boolean) => (menuOpen.value = v)}
244
+ />
245
+ )}
110
246
  </div>
111
247
  );
112
248
  },
package/src/index.ts CHANGED
@@ -938,6 +938,7 @@ export type { HkPillToggleGroupOption } from "./components/HkPillToggleGroup";
938
938
  export { HkIconChip as HkIconChip } from "./components/HkIconChip";
939
939
  export { HkIconChip as HIconChip } from "./components/HkIconChip";
940
940
  export { HkCardList, HkListRow } from "./components/HkCardList";
941
+ export type { HkListRowAction, HkListRowActionsMode } from "./components/HkCardList";
941
942
  export { HkCardList as HCardList, HkListRow as HListRow } from "./components/HkCardList";
942
943
  export { HkSectionHeader, type SectionHeaderVariant } from "./components/HkSectionHeader";
943
944
  export { HkSectionHeader as HSectionHeader } from "./components/HkSectionHeader";
@@ -64,6 +64,28 @@ export interface BootRecoveryCopyOptions {
64
64
  message?: string;
65
65
  /** Retry button label. */
66
66
  retry?: string;
67
+ /**
68
+ * Host translations of the panel copy, keyed by BCP 47-ish locale tags
69
+ * (`"en-US"`, `"zh-CN"`, `"zh-TW"`, …). Keys are normalised at config
70
+ * time (trimmed, lowercased, `_` → `-`); insertion order is preserved and
71
+ * breaks ties when several keys share a primary subtag (`zh-CN` listed
72
+ * before `zh-TW` means a `zh-HK` request resolves to the zh-CN entry).
73
+ * Every entry must carry non-empty `message` and `retry` strings —
74
+ * anything else fails the build loudly, as does two keys normalising to
75
+ * the same tag. Omitting the table (or passing it empty) keeps the
76
+ * single-copy script — byte-identical to the pre-locale implementation
77
+ * apart from the panel stylesheet, so a host that hashes the script into
78
+ * its CSP only needs to refresh the hash it already owns.
79
+ */
80
+ locales?: Record<string, { message: string; retry: string }>;
81
+ /**
82
+ * `localStorage` key that holds the host's saved UI locale tag (WoWSP
83
+ * uses `"wowsp-ui-locale"`). Consulted FIRST — the user's explicit
84
+ * in-app choice outranks the shell's OS preference and the navigator
85
+ * list. Only meaningful together with `locales`: configuring it without
86
+ * a table fails at config time.
87
+ */
88
+ storageKey?: string;
67
89
  }
68
90
 
69
91
  /** Options for {@link bootRecoveryPlugin} / {@link bootRecoveryScript}. */
@@ -88,6 +110,13 @@ export interface BootRecoveryPluginOptions {
88
110
  deadlineMs?: number;
89
111
  /** Absolute http(s) app origin (default `http://tauri.localhost`). */
90
112
  canonicalOrigin?: string;
113
+ /**
114
+ * Name of the `window` global carrying the shell's OS locale preference
115
+ * (`{ locale: string }`). Default `"__CELESTIA_OS_PREFS__"` — the
116
+ * boot-chrome family global; an explicit empty string `""` disables the
117
+ * OS-preference source. Only embedded when `copy.locales` is configured.
118
+ */
119
+ osPrefsGlobal?: string;
91
120
  /**
92
121
  * Custom mangled-origin predicate. It runs in the PAGE, so it is embedded
93
122
  * by source text: pass a self-contained ES2017-safe function expression or
@@ -70,6 +70,33 @@
70
70
  * self-contained — every value it formats comes from the options, so a
71
71
  * crafted URL cannot steer the heal target or the copy.
72
72
  *
73
+ * LOCALE SELECTION (opt-in via `copy.locales`): a host that ships a locale
74
+ * table gets a failure panel whose copy is picked at PAGE runtime, before any
75
+ * framework (and any app i18n) exists. The generated script consults, in
76
+ * order — mirroring the boot-chrome family's stored → OS prefs → navigator →
77
+ * English fallback:
78
+ *
79
+ * 1. `copy.storageKey` — the host's saved UI locale tag in `localStorage`
80
+ * (the user's explicit in-app choice outranks everything; WoWSP stores
81
+ * `wowsp-ui-locale`);
82
+ * 2. the shell's OS preference, read from `window[osPrefsGlobal]` (default
83
+ * `__CELESTIA_OS_PREFS__`, the boot-chrome family global; an explicit
84
+ * `""` disables the source);
85
+ * 3. `navigator.languages`, then `navigator.language`;
86
+ * 4. none of the above matched: the English fallback copy.
87
+ *
88
+ * Each candidate tag is matched against the table exactly first, then with
89
+ * trailing subtags progressively stripped (`zh-hant-cn` → `zh-hant` →
90
+ * `zh`), then by its primary subtag alone. When several table keys share a
91
+ * primary subtag, the FIRST key in the host's insertion order wins
92
+ * (`zh-CN` listed before `zh-TW` means a `zh-HK` request resolves to the
93
+ * zh-CN entry).
94
+ *
95
+ * BYTE-COMPAT: a host that does NOT configure `copy.locales` gets a script
96
+ * byte-identical to the pre-locale implementation — none of the selection
97
+ * code is emitted, so a host that hashed the single-copy script into its CSP
98
+ * keeps its old hash.
99
+ *
73
100
  * Usage (the `/plugins/boot-recovery` subpath is the ONLY supported import for
74
101
  * consumers — it resolves to this file, not to the package's TypeScript
75
102
  * sources):
@@ -152,6 +179,42 @@ function jsonAscii(value) {
152
179
  .replace(/</g, "\\u003c");
153
180
  }
154
181
 
182
+ /**
183
+ * Serialise the locale table as the generated script's `COPY_TABLE` literal:
184
+ * a JSON array of `[tag, message, retry]` triples, every string through
185
+ * `jsonAscii` (CJK becomes \uXXXX escapes, no `</script` can survive), so
186
+ * the generated script stays ASCII-only and byte-deterministic.
187
+ *
188
+ * @param {Array<[string, string, string]>} triples
189
+ * @returns {string}
190
+ */
191
+ function copyTableLiteral(triples) {
192
+ const rows = [];
193
+ for (const triple of triples) {
194
+ rows.push(
195
+ "[" +
196
+ jsonAscii(triple[0]) +
197
+ "," +
198
+ jsonAscii(triple[1]) +
199
+ "," +
200
+ jsonAscii(triple[2]) +
201
+ "]",
202
+ );
203
+ }
204
+ return "[" + rows.join(",") + "]";
205
+ }
206
+
207
+ /**
208
+ * JSON literal for a nullable string option: a bare `null`, not the string
209
+ * `"null"` (`jsonAscii` stringifies).
210
+ *
211
+ * @param {string | null} value
212
+ * @returns {string}
213
+ */
214
+ function jsonLiteral(value) {
215
+ return value === null ? "null" : jsonAscii(value);
216
+ }
217
+
155
218
  /**
156
219
  * Hostname of an absolute http(s) canonical origin.
157
220
  *
@@ -287,6 +350,103 @@ function normaliseCopy(input) {
287
350
  };
288
351
  }
289
352
 
353
+ /**
354
+ * Normalise the host's optional locale table.
355
+ *
356
+ * Keys are BCP 47-ish tags; each is trimmed, lowercased and has `_` turned
357
+ * into `-` (so `"zh_CN"` and `"zh-cn"` collide loudly instead of one of
358
+ * them silently shadowing the other). Each entry must be an object carrying
359
+ * non-empty `message` and `retry` strings — a typo must fail the build at
360
+ * config time, not paint an empty panel on a dead boot. The result is an
361
+ * array of `[tag, message, retry]` triples in the host's insertion order
362
+ * (which is the tie-break order for shared primary subtags), or `null`
363
+ * when no table was configured — the single-copy mode keeps the
364
+ * byte-identical script a host may have hashed into its CSP.
365
+ *
366
+ * @param {unknown} locales
367
+ * @returns {Array<[string, string, string]> | null}
368
+ */
369
+ function normaliseLocales(locales) {
370
+ if (locales === undefined || locales === null) return null;
371
+ const triples = [];
372
+ const seen = new Map();
373
+ for (const rawKey in locales) {
374
+ if (!Object.prototype.hasOwnProperty.call(locales, rawKey)) continue;
375
+ const tag = String(rawKey).trim().replace(/_/g, "-").toLowerCase();
376
+ if (!tag) {
377
+ throw new Error(
378
+ "[hikari boot-recovery] copy.locales key " +
379
+ JSON.stringify(rawKey) +
380
+ " normalises to an empty locale tag",
381
+ );
382
+ }
383
+ const previous = seen.get(tag);
384
+ if (previous !== undefined) {
385
+ throw new Error(
386
+ "[hikari boot-recovery] copy.locales keys " +
387
+ JSON.stringify(previous) +
388
+ " and " +
389
+ JSON.stringify(rawKey) +
390
+ " normalise to the same locale tag " +
391
+ JSON.stringify(tag),
392
+ );
393
+ }
394
+ const entry = locales[rawKey];
395
+ if (
396
+ !entry ||
397
+ typeof entry !== "object" ||
398
+ typeof entry.message !== "string" ||
399
+ !entry.message ||
400
+ typeof entry.retry !== "string" ||
401
+ !entry.retry
402
+ ) {
403
+ throw new Error(
404
+ "[hikari boot-recovery] copy.locales[" +
405
+ JSON.stringify(tag) +
406
+ "]: copy.locales entries must carry non-empty message and retry strings",
407
+ );
408
+ }
409
+ seen.set(tag, rawKey);
410
+ triples.push([tag, entry.message, entry.retry]);
411
+ }
412
+ return triples.length ? triples : null;
413
+ }
414
+
415
+ /**
416
+ * Normalise `copy.storageKey`: the localStorage key holding the host's
417
+ * saved UI locale tag. It is the FIRST locale source (the user's explicit
418
+ * in-app choice outranks the OS preference and the navigator), so
419
+ * configuring it without a table to consult is a config error, not a silent
420
+ * no-op. An empty/whitespace value is treated as absent.
421
+ *
422
+ * @param {unknown} value
423
+ * @param {boolean} hasTable
424
+ * @returns {string | null}
425
+ */
426
+ function normaliseStorageKey(value, hasTable) {
427
+ if (typeof value !== "string" || !value.trim()) return null;
428
+ if (!hasTable) {
429
+ throw new Error(
430
+ "[hikari boot-recovery] copy.storageKey requires copy.locales",
431
+ );
432
+ }
433
+ return value.trim();
434
+ }
435
+
436
+ /**
437
+ * Normalise `osPrefsGlobal`: the name of the `window` global carrying the
438
+ * shell's OS locale preference (`{ locale: string }`). Defaults to the
439
+ * boot-chrome family global; an explicit empty string disables the source
440
+ * (for a host that publishes no such global and does not want one probed).
441
+ *
442
+ * @param {unknown} value
443
+ * @returns {string | null}
444
+ */
445
+ function normaliseOsPrefsGlobal(value) {
446
+ if (value === "") return null;
447
+ return typeof value === "string" ? value : "__CELESTIA_OS_PREFS__";
448
+ }
449
+
290
450
  /**
291
451
  * Normalise the loading-screen half: `false` disables it entirely (heal-only
292
452
  * mode); an object customises the element selector and the dismissed class.
@@ -324,6 +484,9 @@ function normaliseLoadingScreen(input) {
324
484
  * predicateSource: string | null,
325
485
  * loadingScreen: { selector: string, dismissedClass: string } | null,
326
486
  * copy: { message: string, retry: string },
487
+ * copyTable: Array<[string, string, string]> | null,
488
+ * storageKey: string | null,
489
+ * osPrefsGlobal: string | null,
327
490
  * injectTo: "head" | "head-prepend",
328
491
  * }}
329
492
  */
@@ -333,6 +496,9 @@ function resolveOptions(options) {
333
496
  typeof input.canonicalOrigin === "string" && input.canonicalOrigin.trim()
334
497
  ? input.canonicalOrigin.trim()
335
498
  : BOOT_RECOVERY_CANONICAL_ORIGIN;
499
+ const copyRaw =
500
+ input.copy && typeof input.copy === "object" ? input.copy : {};
501
+ const copyTable = normaliseLocales(copyRaw.locales);
336
502
  return {
337
503
  canonicalOrigin: canonicalOrigin,
338
504
  canonicalHostname: canonicalHostname(canonicalOrigin),
@@ -343,6 +509,9 @@ function resolveOptions(options) {
343
509
  : null,
344
510
  loadingScreen: normaliseLoadingScreen(input.loadingScreen),
345
511
  copy: normaliseCopy(input.copy),
512
+ copyTable: copyTable,
513
+ storageKey: normaliseStorageKey(copyRaw.storageKey, copyTable !== null),
514
+ osPrefsGlobal: normaliseOsPrefsGlobal(input.osPrefsGlobal),
346
515
  injectTo: normaliseInjectTo(input.injectTo),
347
516
  };
348
517
  }
@@ -422,17 +591,23 @@ function htmlFileMatches(filename, htmlFiles) {
422
591
  * CSS for the failure panel, injected as one inline `<style>` next to the panel
423
592
  * itself. Deliberately SELF-CONTAINED: it styles only hikari's own panel
424
593
  * classes, never the host's loading element, so a custom `selector` cannot
425
- * break out of a stylesheet. `color` starts from the light-scheme value and
426
- * flips under `prefers-color-scheme: dark` — the host's loading screen owns the
427
- * backdrop, but hikari can at least keep the panel text legible before any
428
- * theme has loaded. The button derives every colour from `currentColor`.
594
+ * break out of a stylesheet. The values are hikari's seed tokens written out
595
+ * literally (packages/vue/src/styles/theme/channels.scss + scale.scss): the
596
+ * primary 122 162 247 with its 82%-black hover mix and button shadow, the
597
+ * elevated card shadow, the md/lg radii, the `--font-sans` stack and the
598
+ * text seed 30 30 30. The card carries its own surface — white in the light
599
+ * scheme, the Nord surface rgb(46 52 64) under `prefers-color-scheme: dark`
600
+ * — so the panel stays legible on any backdrop (WoWSP's dark loader paints
601
+ * #0b1220) before any theme engine has loaded.
429
602
  */
430
603
  const FAILURE_PANEL_CSS = [
431
- ".hk-boot-recovery{display:flex;flex-direction:column;align-items:center;gap:1rem;max-width:32rem;padding:1.5rem;text-align:center;font-family:system-ui,sans-serif;color:#1e2837}",
432
- ".hk-boot-recovery__message{margin:0;font-size:.95rem;line-height:1.5}",
433
- ".hk-boot-recovery__retry{font:inherit;font-size:.85rem;padding:.5rem 1.25rem;border:1px solid currentColor;border-radius:6px;background:transparent;color:inherit;cursor:pointer}",
434
- ".hk-boot-recovery__retry:hover{background:rgba(127,127,127,.15)}",
435
- "@media (prefers-color-scheme:dark){.hk-boot-recovery{color:#cfe3ff}}",
604
+ ".hk-boot-recovery{display:flex;flex-direction:column;align-items:center;gap:1.25rem;max-width:32rem;padding:2rem 2.5rem;text-align:center;background:#fff;border:1px solid rgb(30 30 30 / 8%);border-radius:12px;box-shadow:0 8px 32px rgb(0 0 0 / 15%);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans CJK SC',sans-serif;color:rgb(30 30 30)}",
605
+ ".hk-boot-recovery__message{margin:0;font-size:.875rem;line-height:1.6}",
606
+ ".hk-boot-recovery__retry{appearance:none;font:inherit;font-size:.875rem;font-weight:600;padding:.5rem 1.5rem;border:0;border-radius:8px;background:rgb(122 162 247);color:#fff;cursor:pointer;box-shadow:0 4px 14px rgb(122 162 247 / 35%);transition:background .15s cubic-bezier(.4,0,.2,1),box-shadow .15s cubic-bezier(.4,0,.2,1)}",
607
+ ".hk-boot-recovery__retry:hover{background:rgb(100 133 203)}",
608
+ ".hk-boot-recovery__retry:active{background:rgb(83 111 167)}",
609
+ ".hk-boot-recovery__retry:focus-visible{outline:none;box-shadow:0 0 0 3px rgb(122 162 247 / 25%)}",
610
+ "@media (prefers-color-scheme:dark){.hk-boot-recovery{background:rgb(46 52 64);border-color:rgb(255 255 255 / 8%);box-shadow:0 8px 32px rgb(0 0 0 / 45%);color:rgb(216 222 233)}}",
436
611
  ].join("\n");
437
612
 
438
613
  /**
@@ -486,6 +661,18 @@ export function bootRecoveryScript(options = {}) {
486
661
  );
487
662
  lines.push(" var COPY_MESSAGE = " + jsonAscii(resolved.copy.message) + ";");
488
663
  lines.push(" var COPY_RETRY = " + jsonAscii(resolved.copy.retry) + ";");
664
+ if (resolved.copyTable) {
665
+ lines.push(" var COPY_FALLBACK = [COPY_MESSAGE, COPY_RETRY];");
666
+ lines.push(
667
+ " var COPY_TABLE = " + copyTableLiteral(resolved.copyTable) + ";",
668
+ );
669
+ lines.push(
670
+ " var STORAGE_KEY = " + jsonLiteral(resolved.storageKey) + ";",
671
+ );
672
+ lines.push(
673
+ " var OS_PREFS_GLOBAL = " + jsonLiteral(resolved.osPrefsGlobal) + ";",
674
+ );
675
+ }
489
676
  lines.push(" var PANEL_CSS = " + jsonAscii(FAILURE_PANEL_CSS) + ";");
490
677
  }
491
678
  lines.push("");
@@ -572,6 +759,79 @@ export function bootRecoveryScript(options = {}) {
572
759
  lines.push(" location.reload();");
573
760
  lines.push(" }");
574
761
  lines.push("");
762
+ if (resolved.copyTable) {
763
+ lines.push(" /* Panel copy locale sources, first match wins (mirrors boot-chrome's");
764
+ lines.push(" stored -> OS prefs -> navigator -> English fallback order):");
765
+ lines.push(" 1. localStorage[STORAGE_KEY] - the host's saved UI locale, the");
766
+ lines.push(" user's explicit in-app choice, outranks everything;");
767
+ lines.push(" 2. window[OS_PREFS_GLOBAL].locale - the shell's OS preference;");
768
+ lines.push(" 3. navigator.languages, then navigator.language;");
769
+ lines.push(" 4. no match - the English fallback pair (COPY_FALLBACK).");
770
+ lines.push(" Each candidate tag is matched against COPY_TABLE exactly, then");
771
+ lines.push(" with trailing subtags progressively stripped, then by its");
772
+ lines.push(" primary subtag alone; entries sharing a primary subtag resolve");
773
+ lines.push(" to the FIRST one the host listed. The winning entry is returned");
774
+ lines.push(" as its [message, retry] pair, like COPY_FALLBACK. */");
775
+ lines.push(" function localeKey(tag, drop) {");
776
+ lines.push(' var parts = String(tag).replace(/_/g, "-").toLowerCase().split("-");');
777
+ lines.push(" if (drop >= parts.length) return null;");
778
+ lines.push(' return parts.slice(0, parts.length - drop).join("-");');
779
+ lines.push(" }");
780
+ lines.push(" function tableHit(key) {");
781
+ lines.push(" for (var i = 0; i < COPY_TABLE.length; i++) {");
782
+ lines.push(" if (COPY_TABLE[i][0] === key) return COPY_TABLE[i];");
783
+ lines.push(" }");
784
+ lines.push(" return null;");
785
+ lines.push(" }");
786
+ lines.push(" function primaryHit(tag) {");
787
+ lines.push(' var primary = String(tag).replace(/_/g, "-").toLowerCase().split("-")[0];');
788
+ lines.push(" for (var i = 0; i < COPY_TABLE.length; i++) {");
789
+ lines.push(' if (COPY_TABLE[i][0].split("-")[0] === primary) return COPY_TABLE[i];');
790
+ lines.push(" }");
791
+ lines.push(" return null;");
792
+ lines.push(" }");
793
+ lines.push(" function pickCopy() {");
794
+ lines.push(" var candidates = [];");
795
+ lines.push(" if (STORAGE_KEY) {");
796
+ lines.push(" try {");
797
+ lines.push(" var stored = localStorage.getItem(STORAGE_KEY);");
798
+ lines.push(' if (typeof stored === "string" && stored) candidates.push(stored);');
799
+ lines.push(" } catch (e) {}");
800
+ lines.push(" }");
801
+ lines.push(' if (OS_PREFS_GLOBAL && typeof window !== "undefined") {');
802
+ lines.push(" try {");
803
+ lines.push(" var prefs = window[OS_PREFS_GLOBAL];");
804
+ lines.push(' if (prefs && typeof prefs.locale === "string") candidates.push(prefs.locale);');
805
+ lines.push(" } catch (e) {}");
806
+ lines.push(" }");
807
+ lines.push(' if (typeof navigator !== "undefined") {');
808
+ lines.push(" try {");
809
+ lines.push(" if (navigator.languages && navigator.languages.length) {");
810
+ lines.push(" for (var i = 0; i < navigator.languages.length; i++) {");
811
+ lines.push(' if (typeof navigator.languages[i] === "string" && navigator.languages[i]) {');
812
+ lines.push(" candidates.push(navigator.languages[i]);");
813
+ lines.push(" }");
814
+ lines.push(" }");
815
+ lines.push(" }");
816
+ lines.push(' if (typeof navigator.language === "string" && navigator.language) {');
817
+ lines.push(" candidates.push(navigator.language);");
818
+ lines.push(" }");
819
+ lines.push(" } catch (e) {}");
820
+ lines.push(" }");
821
+ lines.push(" for (var c = 0; c < candidates.length; c++) {");
822
+ lines.push(" var hit = tableHit(localeKey(candidates[c], 0));");
823
+ lines.push(" if (hit) return [hit[1], hit[2]];");
824
+ lines.push(' var parts = String(candidates[c]).replace(/_/g, "-").toLowerCase().split("-");');
825
+ lines.push(" for (var drop = 1; drop < parts.length; drop++) {");
826
+ lines.push(" hit = tableHit(localeKey(candidates[c], drop));");
827
+ lines.push(" if (hit) return [hit[1], hit[2]];");
828
+ lines.push(" }");
829
+ lines.push(" hit = primaryHit(candidates[c]);");
830
+ lines.push(" if (hit) return [hit[1], hit[2]];");
831
+ lines.push(" }");
832
+ lines.push(" return null;");
833
+ lines.push(" }");
834
+ }
575
835
  lines.push(
576
836
  " /* Replace the loader's contents with the failure panel. Built from",
577
837
  );
@@ -588,11 +848,20 @@ export function bootRecoveryScript(options = {}) {
588
848
  lines.push(' box.setAttribute("role", "alert");');
589
849
  lines.push(' var message = document.createElement("p");');
590
850
  lines.push(' message.className = "hk-boot-recovery__message";');
591
- lines.push(" message.textContent = COPY_MESSAGE;");
851
+ if (resolved.copyTable) {
852
+ lines.push(" var copy = pickCopy() || COPY_FALLBACK;");
853
+ lines.push(" message.textContent = copy[0];");
854
+ } else {
855
+ lines.push(" message.textContent = COPY_MESSAGE;");
856
+ }
592
857
  lines.push(' var button = document.createElement("button");');
593
858
  lines.push(' button.type = "button";');
594
859
  lines.push(' button.className = "hk-boot-recovery__retry";');
595
- lines.push(" button.textContent = COPY_RETRY;");
860
+ lines.push(
861
+ resolved.copyTable
862
+ ? " button.textContent = copy[1];"
863
+ : " button.textContent = COPY_RETRY;",
864
+ );
596
865
  lines.push(' button.addEventListener("click", retry);');
597
866
  lines.push(" box.appendChild(message);");
598
867
  lines.push(" box.appendChild(button);");
@@ -630,8 +899,14 @@ export function bootRecoveryScript(options = {}) {
630
899
  * loadingScreen?: { selector?: string, dismissedClass?: string } | false,
631
900
  * deadlineMs?: number,
632
901
  * canonicalOrigin?: string,
902
+ * osPrefsGlobal?: string,
633
903
  * isMangledOrigin?: (protocol: string, hostname: string) => boolean,
634
- * copy?: { message?: string, retry?: string },
904
+ * copy?: {
905
+ * message?: string,
906
+ * retry?: string,
907
+ * locales?: Record<string, { message: string, retry: string }>,
908
+ * storageKey?: string,
909
+ * },
635
910
  * injectTo?: "head" | "head-prepend",
636
911
  * }} [options]
637
912
  * @returns {import("vite").Plugin}
@@ -131,9 +131,22 @@ function tags(
131
131
  }
132
132
 
133
133
  // ── fake page harness ──────────────────────────────────────────────────────
134
- // The generated script only reaches for three globals. Passing fakes as
135
- // Function parameters shadows Node's (absent) globals, so the REAL shipped
136
- // script runs unmodified — no copied logic, no drift.
134
+ // The generated script only reaches for three globals on every page
135
+ // (location / document / setTimeout); locale-aware variants additionally
136
+ // read localStorage / navigator / window. Passing fakes as Function
137
+ // parameters shadows Node's globals (Node exposes a real `navigator`!), so
138
+ // the REAL shipped script runs unmodified — no copied logic, no drift.
139
+
140
+ /** Minimal stand-in for the `localStorage` the locale-aware copy reads. */
141
+ interface FakeLocalStorage {
142
+ getItem: (key: string) => string | null;
143
+ }
144
+
145
+ /** Minimal stand-in for the `navigator` the locale-aware copy reads. */
146
+ interface FakeNavigator {
147
+ languages?: string[];
148
+ language?: string;
149
+ }
137
150
 
138
151
  interface FakeNode {
139
152
  tag: string;
@@ -217,6 +230,12 @@ interface FakePage {
217
230
  replaceThrows?: boolean;
218
231
  /** The script to execute (default: the shipped default script). */
219
232
  script?: string;
233
+ /** `localStorage` backing the host's saved UI locale (locale-aware scripts). */
234
+ localStorage?: FakeLocalStorage;
235
+ /** `navigator` (locale-aware scripts; absent = the script must not see one). */
236
+ navigator?: FakeNavigator;
237
+ /** `window` global bag, e.g. the shell's OS prefs (locale-aware scripts). */
238
+ window?: Record<string, unknown>;
220
239
  }
221
240
 
222
241
  /**
@@ -268,13 +287,28 @@ function runRecovery(page: FakePage = {}): RecoveryRun {
268
287
  "location",
269
288
  "document",
270
289
  "setTimeout",
290
+ "localStorage",
291
+ "navigator",
292
+ "window",
271
293
  script,
272
294
  ) as (
273
295
  location: FakeLocation,
274
296
  document: FakeDocument,
275
297
  setTimeout: FakeSetTimeout,
298
+ localStorage: FakeLocalStorage | undefined,
299
+ navigator: FakeNavigator | undefined,
300
+ window: Record<string, unknown> | undefined,
276
301
  ) => void;
277
- run(location, fakeDocument, fakeSetTimeout);
302
+ // Passing undefined shadows Node's own globals: a locale-aware script run
303
+ // without a fake navigator must not see Node's real one.
304
+ run(
305
+ location,
306
+ fakeDocument,
307
+ fakeSetTimeout,
308
+ page.localStorage,
309
+ page.navigator,
310
+ page.window,
311
+ );
278
312
 
279
313
  return {
280
314
  replaced,
@@ -294,6 +328,26 @@ function panelButton(run: RecoveryRun): FakeNode {
294
328
  return mustFind(panel.children, "button");
295
329
  }
296
330
 
331
+ /** The panel's message <p>, if a panel was painted into the fake loader. */
332
+ function panelMessage(run: RecoveryRun): FakeNode {
333
+ const panel = mustFind(run.loading?.children ?? [], "div");
334
+ return mustFind(panel.children, "p");
335
+ }
336
+
337
+ /** Fake localStorage backed by a plain record. */
338
+ function fakeStorage(entries: Record<string, string> = {}): FakeLocalStorage {
339
+ return {
340
+ getItem: (key) => (key in entries ? entries[key] : null),
341
+ };
342
+ }
343
+
344
+ /** Fake navigator with a languages list (and an optional explicit language). */
345
+ function fakeNavigator(languages: string[], language?: string): FakeNavigator {
346
+ return language === undefined
347
+ ? { languages, language: languages[0] ?? "" }
348
+ : { languages, language };
349
+ }
350
+
297
351
  /** Click every listener the button carries (the generated script registers one). */
298
352
  function clickButton(button: FakeNode): void {
299
353
  for (const listener of button.listeners.click ?? []) listener();
@@ -566,9 +620,12 @@ describe("the recovery script's runtime behaviour (executed against a fake page)
566
620
  const style = mustFind(run.loading?.children ?? [], "style");
567
621
  // Colour-scheme aware and self-contained: the panel must not flip to light
568
622
  // colours on a dark desktop (or vice versa) before the app's theme loads.
623
+ // The card carries its own seed-token surface (white / Nord rgb(46 52 64)),
624
+ // so it stays legible on a dark loader backdrop (WoWSP paints #0b1220).
569
625
  expect(style.textContent).toContain("prefers-color-scheme:dark");
570
- expect(style.textContent).toContain("#1e2837");
571
- expect(style.textContent).toContain("#cfe3ff");
626
+ expect(style.textContent).toContain("rgb(30 30 30)");
627
+ expect(style.textContent).toContain("rgb(216 222 233)");
628
+ expect(style.textContent).toContain("rgb(122 162 247)");
572
629
  expect(style.textContent).toContain(".hk-boot-recovery");
573
630
 
574
631
  const panel = mustFind(run.loading?.children ?? [], "div");
@@ -735,6 +792,280 @@ describe("the recovery script's runtime behaviour (executed against a fake page)
735
792
  });
736
793
  });
737
794
 
795
+ // ── locale-aware copy (copy.locales) ──────────────────────────────────────
796
+
797
+ /** A WoWSP-shaped table: English fallback + two Chinese scripts, in the order
798
+ * the host lists them — zh-CN BEFORE zh-TW, which is the tie-break order. */
799
+ const LOCALE_TABLE = {
800
+ "en-US": { message: "EN message", retry: "Reload" },
801
+ "zh-CN": { message: "简体中文消息", retry: "重新加载" },
802
+ "zh-TW": { message: "繁體中文訊息", retry: "重新載入" },
803
+ };
804
+
805
+ const WITH_TABLE = bootRecoveryScript({
806
+ copy: { locales: LOCALE_TABLE, storageKey: "wowsp-ui-locale" },
807
+ });
808
+
809
+ /** Fire the watchdog and return the painted panel's message and button. */
810
+ function paint(page: FakePage): { message: FakeNode; button: FakeNode } {
811
+ const run = runRecovery(page);
812
+ run.timers[0].callback();
813
+ return { message: panelMessage(run), button: panelButton(run) };
814
+ }
815
+
816
+ describe("the locale-aware script's content contract", () => {
817
+ it("embeds the table, the storage key and the OS-prefs global", () => {
818
+ // The table is an ARRAY of [tag, message, retry] triples in the host's
819
+ // insertion order — the tie-break order for shared primary subtags.
820
+ expect(WITH_TABLE).toContain(
821
+ 'var COPY_TABLE = [["en-us","EN message","Reload"],["zh-cn",',
822
+ );
823
+ // CJK rides through jsonAscii as \uXXXX escapes (ASCII-only script).
824
+ expect(WITH_TABLE).toContain('"\\u7b80\\u4f53\\u4e2d\\u6587\\u6d88\\u606f"');
825
+ expect(WITH_TABLE).toContain(
826
+ '["zh-tw","\\u7e41\\u9ad4\\u4e2d\\u6587\\u8a0a\\u606f","\\u91cd\\u65b0\\u8f09\\u5165"]',
827
+ );
828
+ expect(WITH_TABLE).toContain(
829
+ 'var COPY_FALLBACK = [COPY_MESSAGE, COPY_RETRY];',
830
+ );
831
+ expect(WITH_TABLE).toContain('var STORAGE_KEY = "wowsp-ui-locale";');
832
+ expect(WITH_TABLE).toContain(
833
+ 'var OS_PREFS_GLOBAL = "__CELESTIA_OS_PREFS__";',
834
+ );
835
+ // showFailure resolves the pair at paint time.
836
+ expect(WITH_TABLE).toContain("var copy = pickCopy() || COPY_FALLBACK;");
837
+ // Insertion order is preserved in the emitted literal.
838
+ expect(WITH_TABLE.indexOf('"en-us"')).toBeLessThan(
839
+ WITH_TABLE.indexOf('"zh-cn"'),
840
+ );
841
+ expect(WITH_TABLE.indexOf('"zh-cn"')).toBeLessThan(
842
+ WITH_TABLE.indexOf('"zh-tw"'),
843
+ );
844
+ });
845
+
846
+ it("stays ASCII-only, deterministic and tag-safe with a locale table", () => {
847
+ const foreign = [...WITH_TABLE].filter((char) => {
848
+ const code = char.charCodeAt(0);
849
+ return code !== 9 && code !== 10 && (code < 32 || code > 126);
850
+ });
851
+ expect(foreign).toEqual([]);
852
+ expect(
853
+ bootRecoveryScript({
854
+ copy: { locales: LOCALE_TABLE, storageKey: "wowsp-ui-locale" },
855
+ }),
856
+ ).toBe(WITH_TABLE); // deterministic: byte-identical regeneration
857
+ expect(WITH_TABLE.toLowerCase()).not.toContain("</script");
858
+ // No template-literal hole sequence may leak into the generated text
859
+ // (built by concatenation so THIS file stays grep-safe).
860
+ expect(WITH_TABLE).not.toContain(String.fromCharCode(36) + "{");
861
+ });
862
+
863
+ it("keeps single-copy scripts free of every locale machinery string", () => {
864
+ // Byte-compat for hosts that hash the script into their CSP: without
865
+ // copy.locales NONE of the selection code is emitted.
866
+ for (const script of [
867
+ bootRecoveryScript(),
868
+ bootRecoveryScript({ copy: { message: "Boot failed", retry: "Retry" } }),
869
+ ]) {
870
+ expect(script).not.toContain("COPY_TABLE");
871
+ expect(script).not.toContain("COPY_FALLBACK");
872
+ expect(script).not.toContain("localStorage");
873
+ expect(script).not.toContain("OS_PREFS");
874
+ expect(script).not.toContain("pickCopy");
875
+ expect(script).not.toContain("STORAGE_KEY");
876
+ }
877
+ });
878
+ });
879
+
880
+ describe("copy locale selection (executed against a fake page)", () => {
881
+ it("paints the whole panel in the navigator locale (zh-CN)", () => {
882
+ const painted = paint({
883
+ script: WITH_TABLE,
884
+ navigator: fakeNavigator(["zh-CN", "en-US"]),
885
+ });
886
+ expect(painted.message.textContent).toBe(LOCALE_TABLE["zh-CN"].message);
887
+ expect(painted.button.textContent).toBe(LOCALE_TABLE["zh-CN"].retry);
888
+ });
889
+
890
+ it("prefers the host storage key over the OS preference and the navigator", () => {
891
+ // The user's explicit in-app choice outranks everything.
892
+ const painted = paint({
893
+ script: WITH_TABLE,
894
+ localStorage: fakeStorage({ "wowsp-ui-locale": "zh-TW" }),
895
+ window: { __CELESTIA_OS_PREFS__: { locale: "zh-CN" } },
896
+ navigator: fakeNavigator(["en-US"]),
897
+ });
898
+ expect(painted.message.textContent).toBe(LOCALE_TABLE["zh-TW"].message);
899
+ expect(painted.button.textContent).toBe(LOCALE_TABLE["zh-TW"].retry);
900
+ });
901
+
902
+ it("prefers the OS preference over the navigator list", () => {
903
+ const painted = paint({
904
+ script: WITH_TABLE,
905
+ window: { __CELESTIA_OS_PREFS__: { locale: "zh-CN" } },
906
+ navigator: fakeNavigator(["en-US"]),
907
+ });
908
+ expect(painted.message.textContent).toBe(LOCALE_TABLE["zh-CN"].message);
909
+ });
910
+
911
+ it("matches an exact navigator tag (ja-JP style)", () => {
912
+ const table = { "ja-JP": { message: "日本語メッセージ", retry: "再読み込み" } };
913
+ const painted = paint({
914
+ script: bootRecoveryScript({ copy: { locales: table } }),
915
+ navigator: fakeNavigator(["ja-JP"]),
916
+ });
917
+ expect(painted.message.textContent).toBe("日本語メッセージ");
918
+ expect(painted.button.textContent).toBe("再読み込み");
919
+ });
920
+
921
+ it("strips trailing subtags progressively (zh-Hant-CN -> zh-Hant)", () => {
922
+ const table = { "zh-Hant": { message: "繁體訊息", retry: "重新載入" } };
923
+ const painted = paint({
924
+ script: bootRecoveryScript({ copy: { locales: table } }),
925
+ navigator: fakeNavigator(["zh-Hant-CN"]),
926
+ });
927
+ expect(painted.message.textContent).toBe("繁體訊息");
928
+ });
929
+
930
+ it("resolves a shared primary subtag to the FIRST listed entry (zh-HK)", () => {
931
+ // zh-HK hits no key exactly; the primary subtag zh matches zh-CN first
932
+ // because the host listed it before zh-TW.
933
+ const painted = paint({
934
+ script: WITH_TABLE,
935
+ navigator: fakeNavigator(["zh-HK"]),
936
+ });
937
+ expect(painted.message.textContent).toBe(LOCALE_TABLE["zh-CN"].message);
938
+ expect(painted.button.textContent).toBe(LOCALE_TABLE["zh-CN"].retry);
939
+ });
940
+
941
+ it("falls back to the English copy when nothing matches", () => {
942
+ const script = bootRecoveryScript({
943
+ copy: { locales: { "zh-CN": LOCALE_TABLE["zh-CN"] } },
944
+ });
945
+ const painted = paint({
946
+ script,
947
+ navigator: fakeNavigator(["fr-FR", "de-DE"]),
948
+ });
949
+ expect(painted.message.textContent).toBe(BOOT_RECOVERY_MESSAGE);
950
+ expect(painted.button.textContent).toBe(BOOT_RECOVERY_RETRY);
951
+ });
952
+
953
+ it("resolves from the storage key alone (no window, no navigator)", () => {
954
+ const painted = paint({
955
+ script: WITH_TABLE,
956
+ localStorage: fakeStorage({ "wowsp-ui-locale": "zh-CN" }),
957
+ });
958
+ expect(painted.message.textContent).toBe(LOCALE_TABLE["zh-CN"].message);
959
+ });
960
+
961
+ it("still reloads via the button after painting a locale row", () => {
962
+ const run = runRecovery({
963
+ script: WITH_TABLE,
964
+ navigator: fakeNavigator(["zh-CN"]),
965
+ });
966
+ run.timers[0].callback();
967
+ clickButton(panelButton(run));
968
+ expect(run.reloads).toBe(1);
969
+ expect(run.replaced).toEqual([]);
970
+ });
971
+ });
972
+
973
+ describe("copy.locales / osPrefsGlobal validation", () => {
974
+ it("rejects two keys that normalise to the same locale tag", () => {
975
+ expect(() =>
976
+ bootRecoveryScript({
977
+ copy: {
978
+ locales: {
979
+ "zh-CN": LOCALE_TABLE["zh-CN"],
980
+ zh_cn: LOCALE_TABLE["zh-TW"],
981
+ },
982
+ },
983
+ }),
984
+ ).toThrow(/same locale tag/);
985
+ // Plugin creation fails equally loudly (config-time validation).
986
+ expect(() =>
987
+ bootRecoveryPlugin({
988
+ copy: {
989
+ locales: {
990
+ "en-US": LOCALE_TABLE["en-US"],
991
+ "en_us": LOCALE_TABLE["en-US"],
992
+ },
993
+ },
994
+ }),
995
+ ).toThrow(/same locale tag/);
996
+ });
997
+
998
+ it("rejects a malformed entry, naming the tag", () => {
999
+ expect(() =>
1000
+ bootRecoveryScript({
1001
+ copy: { locales: { "zh-CN": { message: "消息", retry: "" } } },
1002
+ }),
1003
+ ).toThrow(/must carry non-empty message and retry strings/);
1004
+ expect(() =>
1005
+ bootRecoveryScript({
1006
+ copy: { locales: { "zh_CN": invalidInput("oops") } },
1007
+ }),
1008
+ ).toThrow(/must carry non-empty message and retry strings/);
1009
+ // The thrown error names the normalised tag.
1010
+ try {
1011
+ bootRecoveryScript({
1012
+ copy: { locales: { "zh_CN": invalidInput("oops") } },
1013
+ });
1014
+ expect.unreachable("validation must throw");
1015
+ } catch (err) {
1016
+ expect(String(err)).toContain("zh-cn");
1017
+ }
1018
+ });
1019
+
1020
+ it("rejects storageKey without a locales table", () => {
1021
+ expect(() =>
1022
+ bootRecoveryScript({ copy: { storageKey: "wowsp-ui-locale" } }),
1023
+ ).toThrow(/copy\.storageKey requires copy\.locales/);
1024
+ expect(() =>
1025
+ bootRecoveryPlugin({ copy: { storageKey: "wowsp-ui-locale" } }),
1026
+ ).toThrow(/copy\.storageKey requires copy\.locales/);
1027
+ // An empty storageKey is absent, not a misconfiguration: single-copy mode.
1028
+ expect(bootRecoveryScript({ copy: { storageKey: "" } })).toBe(SCRIPT);
1029
+ });
1030
+ });
1031
+
1032
+ describe("osPrefsGlobal", () => {
1033
+ it("disables the OS-prefs source when set to an empty string", () => {
1034
+ const script = bootRecoveryScript({
1035
+ copy: { locales: LOCALE_TABLE, storageKey: "wowsp-ui-locale" },
1036
+ osPrefsGlobal: "",
1037
+ });
1038
+ expect(script).toContain("var OS_PREFS_GLOBAL = null;");
1039
+ expect(script).not.toContain("__CELESTIA_OS_PREFS__");
1040
+ // The OS preference is now ignored: the navigator list decides.
1041
+ const painted = paint({
1042
+ script,
1043
+ window: { __CELESTIA_OS_PREFS__: { locale: "zh-CN" } },
1044
+ navigator: fakeNavigator(["en-US"]),
1045
+ });
1046
+ expect(painted.message.textContent).toBe(LOCALE_TABLE["en-US"].message);
1047
+ // A navigator-only environment still resolves.
1048
+ const zhPainted = paint({ script, navigator: fakeNavigator(["zh-CN"]) });
1049
+ expect(zhPainted.message.textContent).toBe(LOCALE_TABLE["zh-CN"].message);
1050
+ });
1051
+
1052
+ it("reads a renamed OS-prefs global (WoWSP's real wiring)", () => {
1053
+ const script = bootRecoveryScript({
1054
+ copy: { locales: LOCALE_TABLE, storageKey: "wowsp-ui-locale" },
1055
+ osPrefsGlobal: "__WOWSP_OS_PREFS__",
1056
+ });
1057
+ expect(script).toContain('"__WOWSP_OS_PREFS__"');
1058
+ expect(script).not.toContain('"__CELESTIA_OS_PREFS__"');
1059
+ const painted = paint({
1060
+ script,
1061
+ window: { __WOWSP_OS_PREFS__: { locale: "zh-TW" } },
1062
+ navigator: fakeNavigator(["en-US"]),
1063
+ });
1064
+ expect(painted.message.textContent).toBe(LOCALE_TABLE["zh-TW"].message);
1065
+ expect(painted.button.textContent).toBe(LOCALE_TABLE["zh-TW"].retry);
1066
+ });
1067
+ });
1068
+
738
1069
  // ── the plugin: injection face ────────────────────────────────────────────
739
1070
 
740
1071
  describe("bootRecoveryPlugin injection", () => {