@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 +1 -1
- package/src/components/HkCardList.rowactions-touch.test.tsx +77 -0
- package/src/components/HkCardList.rowactions.test.tsx +148 -0
- package/src/components/HkCardList.scss +103 -0
- package/src/components/HkCardList.tsx +137 -1
- package/src/index.ts +1 -0
- package/src/plugins/boot-recovery.d.mts +29 -0
- package/src/plugins/boot-recovery.mjs +287 -12
- package/src/plugins/boot-recovery.test.ts +337 -6
package/package.json
CHANGED
|
@@ -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.
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
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:
|
|
432
|
-
".hk-boot-recovery__message{margin:0;font-size:.
|
|
433
|
-
".hk-boot-recovery__retry{font:inherit;font-size:.
|
|
434
|
-
".hk-boot-recovery__retry:hover{background:
|
|
435
|
-
"
|
|
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
|
-
|
|
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(
|
|
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?: {
|
|
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
|
|
135
|
-
//
|
|
136
|
-
//
|
|
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
|
-
|
|
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("
|
|
571
|
-
expect(style.textContent).toContain("
|
|
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", () => {
|