@celestia-island/hikari 0.55.272 → 0.55.274

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.274",
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
  },
@@ -65,6 +65,10 @@ button.hk-persistent-toast {
65
65
  color: rgb(var(--color-muted));
66
66
  }
67
67
 
68
+ .hk-persistent-toast--success .hk-persistent-toast__icon {
69
+ color: rgb(var(--color-success));
70
+ }
71
+
68
72
  .hk-persistent-toast__label {
69
73
  overflow: hidden;
70
74
  text-overflow: ellipsis;
@@ -111,6 +111,15 @@ describe("HkPersistentToast inert default", () => {
111
111
  expect(container.querySelector(".hk-persistent-toast__spinner")).not.toBeNull();
112
112
  });
113
113
 
114
+ it("renders a static icon for the success tone", () => {
115
+ const { container } = mount({ props: { tone: "success", label: "Saved" } });
116
+ const c = chip(container);
117
+ expect(c.classList.contains("hk-persistent-toast--success")).toBe(true);
118
+ // Success renders a static lucide icon like the other non-loading tones.
119
+ expect(container.querySelector(".hk-persistent-toast__spinner")).toBeNull();
120
+ expect(c.querySelector("svg")).not.toBeNull();
121
+ });
122
+
114
123
  it("renders the ✕ sibling button only when dismissible", () => {
115
124
  const onDismiss = vi.fn();
116
125
  const { container } = mount({ props: { label: "x", dismissible: true }, onDismiss });
@@ -1,4 +1,4 @@
1
- import { AlertTriangle, Info, X } from "lucide-vue-next";
1
+ import { AlertTriangle, Check, Info, X } from "lucide-vue-next";
2
2
  import {
3
3
  computed,
4
4
  defineComponent,
@@ -15,7 +15,7 @@ import { useI18n } from "../i18n/context";
15
15
  import "./HkPersistentToast.scss";
16
16
  import { ensureOverlayRoot } from "../overlay/overlayRoot";
17
17
 
18
- export type PersistentToastTone = "loading" | "info" | "warning" | "error";
18
+ export type PersistentToastTone = "loading" | "info" | "warning" | "error" | "success";
19
19
 
20
20
  /** Viewport gutter the detail card clamps into (mirror of
21
21
  * --viewport-gutter's desktop value; the card is chrome-adjacent and
@@ -251,6 +251,8 @@ export default defineComponent({
251
251
  <span class="hk-persistent-toast__spinner" aria-hidden="true" />
252
252
  ) : props.tone === "error" || props.tone === "warning" ? (
253
253
  <AlertTriangle size={12} class="hk-persistent-toast__icon" aria-hidden="true" />
254
+ ) : props.tone === "success" ? (
255
+ <Check size={12} class="hk-persistent-toast__icon" aria-hidden="true" />
254
256
  ) : (
255
257
  <Info size={12} class="hk-persistent-toast__icon" aria-hidden="true" />
256
258
  )}
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