@celestia-island/hikari 0.55.29 → 0.55.31

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.29",
3
+ "version": "0.55.31",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -237,4 +237,57 @@ describe("HkBlockingToast", () => {
237
237
  await settle();
238
238
  expect(cards().length).toBe(0);
239
239
  });
240
+
241
+ it("pins simultaneously leaving card slots to their pre-patch boxes", async () => {
242
+ mountHost();
243
+ const gateA = showBlockingToast("First prompt", { title: "One" });
244
+ const gateB = showBlockingToast("Second prompt", { title: "Two" });
245
+ await settle();
246
+
247
+ // Same multi-removal stub as HkToast.test: once the first slot goes
248
+ // position:absolute mid-patch, the shrink-to-fit column re-fits, so
249
+ // a live read in the second slot's before-leave would freeze the
250
+ // reflowed box. The pin must carry the pre-patch geometry instead.
251
+ const slots = document.querySelectorAll<HTMLElement>(".hk-blocking-toast-slot");
252
+ const [first, second] = slots;
253
+ const wrapper = first.parentElement as HTMLElement;
254
+ const reflowed = () => first.classList.contains("hk-blocking-toast-leave-active");
255
+ Object.defineProperty(wrapper, "clientWidth", {
256
+ configurable: true,
257
+ get: () => (reflowed() ? 138 : 384),
258
+ });
259
+ const stub = (
260
+ el: HTMLElement,
261
+ box: { top: number; left: number; width: number; height: number },
262
+ ) => {
263
+ Object.defineProperty(el, "offsetParent", { value: wrapper, configurable: true });
264
+ Object.defineProperty(el, "offsetTop", { configurable: true, get: () => (reflowed() ? 0 : box.top) });
265
+ Object.defineProperty(el, "offsetLeft", { configurable: true, get: () => (reflowed() ? 0 : box.left) });
266
+ Object.defineProperty(el, "offsetWidth", { configurable: true, get: () => (reflowed() ? 138 : box.width) });
267
+ Object.defineProperty(el, "offsetHeight", { configurable: true, get: () => box.height });
268
+ };
269
+ stub(first, { top: 0, left: 0, width: 384, height: 120 });
270
+ stub(second, { top: 132, left: 0, width: 384, height: 120 });
271
+
272
+ cancelButton(cards()[0]).click();
273
+ cancelButton(cards()[1]).click();
274
+ await nextTick();
275
+
276
+ const [pinFirst, pinSecond] =
277
+ document.querySelectorAll<HTMLElement>(".hk-blocking-toast-leave-active");
278
+ // The second slot's pin mirrors its PRE-patch box — full-width bar at
279
+ // its own row — not the reflowed sliver at the vacated top slot.
280
+ expect(pinFirst!.style.right).toBe("0px");
281
+ expect(pinFirst!.style.top).toBe("0px");
282
+ expect(pinFirst!.style.width).toBe("384px");
283
+ expect(pinFirst!.style.height).toBe("120px");
284
+ expect(pinSecond!.style.right).toBe("0px");
285
+ expect(pinSecond!.style.top).toBe("132px");
286
+ expect(pinSecond!.style.width).toBe("384px");
287
+ expect(pinSecond!.style.height).toBe("120px");
288
+
289
+ await Promise.allSettled([gateA, gateB]);
290
+ await settle();
291
+ expect(cards().length).toBe(0);
292
+ });
240
293
  });
@@ -26,6 +26,8 @@ import { AlertTriangle, CircleX as XCircle, Info } from "lucide-vue-next";
26
26
  import {
27
27
  defineComponent,
28
28
  onBeforeUnmount,
29
+ onBeforeUpdate,
30
+ ref,
29
31
  Teleport,
30
32
  TransitionGroup,
31
33
  watchEffect,
@@ -33,7 +35,7 @@ import {
33
35
 
34
36
  import { useI18n } from "../i18n/context";
35
37
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
36
- import { clearLeaveGeometry, pinLeaveGeometry } from "../utils/dom";
38
+ import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
37
39
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
38
40
  import {
39
41
  resolveBlockingToast,
@@ -115,6 +117,31 @@ export default defineComponent({
115
117
  const itemHooks = useSurfaceTransition(320).hooks();
116
118
  const handles = new Map<number, PopupHandle>();
117
119
 
120
+ // Pre-patch geometry of every card, refreshed on each update — same
121
+ // multi-removal guard as HkToast (see the full rationale there):
122
+ // within one patch pass an earlier leaving card's position:absolute
123
+ // re-fits the shrink-to-fit column before a later card's beforeLeave
124
+ // reads its box, so live offset reads would teleport and squash the
125
+ // later cards. Snap the right anchor at snapshot time too — the
126
+ // wrapper's live clientWidth is already re-fit when the pin runs.
127
+ const hostRef = ref<{ $el?: Element } | null>(null);
128
+ const preLeaveBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
129
+ onBeforeUpdate(() => {
130
+ const host = hostRef.value?.$el;
131
+ if (host == null || host.nodeType !== 1) return;
132
+ const parentWidth = (host as HTMLElement).clientWidth;
133
+ for (const child of Array.from(host.children)) {
134
+ const e = child as HTMLElement;
135
+ preLeaveBoxes.set(e, {
136
+ top: e.offsetTop,
137
+ left: e.offsetLeft,
138
+ width: e.offsetWidth,
139
+ height: e.offsetHeight,
140
+ right: parentWidth - (e.offsetLeft + e.offsetWidth),
141
+ });
142
+ }
143
+ });
144
+
118
145
  // Keep one popup-manager entry (kind "toast") per visible card so
119
146
  // registry introspection and stacking stay coherent; unregister on
120
147
  // removal. Cards register in queue order, so newer prompts get the
@@ -150,13 +177,14 @@ export default defineComponent({
150
177
  <Teleport to="body">
151
178
  <div class="hk-blocking-toast-container">
152
179
  <TransitionGroup
180
+ ref={hostRef}
153
181
  tag="div"
154
182
  name="hk-blocking-toast"
155
183
  onBeforeEnter={itemHooks.onBeforeEnter}
156
184
  onAfterEnter={itemHooks.onAfterEnter}
157
185
  onBeforeLeave={(el: Element) => {
158
186
  itemHooks.onBeforeLeave();
159
- pinLeaveGeometry(el);
187
+ pinLeaveGeometry(el, { box: preLeaveBoxes.get(el) });
160
188
  }}
161
189
  onAfterLeave={itemHooks.onAfterLeave}
162
190
  onLeaveCancelled={(el: Element) => {
@@ -11,6 +11,11 @@ import { useToast } from "../runtime/useToast";
11
11
  * stack column is shrink-to-fit and collapses once the toast leaves
12
12
  * the flow — a percentage width there resolves against a dead
13
13
  * containing block and the toast "flashes" into a sliver
14
+ * - the pin measures the PRE-PATCH box: during a multi-toast removal an
15
+ * earlier sibling's position:absolute re-fits the column inside the
16
+ * same patch pass, so live offset reads in a later sibling's
17
+ * before-leave hook would teleport it onto the reflowed slot and
18
+ * squash its bar (HkListTransition's pre-patch snapshot pattern)
14
19
  * - the toast is gone after the leave settles
15
20
  *
16
21
  * (Repo test convention: raw createApp + document queries, no
@@ -106,4 +111,63 @@ describe("HkToast", () => {
106
111
  await settle();
107
112
  expect(items().length).toBe(0);
108
113
  });
114
+
115
+ it("pins a later sibling to its pre-patch box when several toasts leave in one pass", async () => {
116
+ mountHost();
117
+ const toast = useToast();
118
+ toast.error("long enough error body that wrapped to a tall box");
119
+ toast.warning("medium body on the second row");
120
+ await settle();
121
+
122
+ const [first, second] = items();
123
+ // Model the browser's synchronous re-layout: once the FIRST toast
124
+ // goes position:absolute mid-patch, the shrink-to-fit column re-fits
125
+ // to the remaining in-flow toast — its box re-stretches to its own
126
+ // max-content (138) and it shifts up to the vacated top slot.
127
+ // happy-dom has no layout engine, so the stubs branch on the first
128
+ // toast's leave-active class to emulate the before/after readings.
129
+ const wrapper = first.parentElement as HTMLElement;
130
+ const reflowed = () => first.classList.contains("hk-toast-leave-active");
131
+ Object.defineProperty(wrapper, "clientWidth", {
132
+ configurable: true,
133
+ get: () => (reflowed() ? 138 : 384),
134
+ });
135
+ const stub = (
136
+ el: HTMLElement,
137
+ pre: { top: number; left: number; width: number; height: number },
138
+ post: { top: number; left: number; width: number; height: number },
139
+ ) => {
140
+ Object.defineProperty(el, "offsetParent", { value: wrapper, configurable: true });
141
+ for (const key of ["offsetTop", "offsetLeft", "offsetWidth", "offsetHeight"] as const) {
142
+ const short = key.slice(6).toLowerCase() as "top" | "left" | "width" | "height";
143
+ Object.defineProperty(el, key, {
144
+ configurable: true,
145
+ get: () => (reflowed() ? post[short] : pre[short]),
146
+ });
147
+ }
148
+ };
149
+ stub(first, { top: 0, left: 0, width: 384, height: 83 }, { top: 0, left: 0, width: 384, height: 83 });
150
+ stub(second, { top: 95, left: 0, width: 384, height: 48 }, { top: 0, left: 0, width: 138, height: 48 });
151
+
152
+ const [idA, idB] = toast.toasts.map((t) => t.id);
153
+ toast.remove(idA);
154
+ toast.remove(idB);
155
+ await nextTick();
156
+
157
+ const [pinFirst, pinSecond] =
158
+ document.querySelectorAll<HTMLElement>(".hk-toast-leave-active");
159
+ // The second toast's pin mirrors its PRE-patch box — full-width bar
160
+ // at its own row — not the reflowed sliver at the vacated top slot.
161
+ expect(pinFirst!.style.right).toBe("0px");
162
+ expect(pinFirst!.style.top).toBe("0px");
163
+ expect(pinFirst!.style.width).toBe("384px");
164
+ expect(pinFirst!.style.height).toBe("83px");
165
+ expect(pinSecond!.style.right).toBe("0px");
166
+ expect(pinSecond!.style.top).toBe("95px");
167
+ expect(pinSecond!.style.width).toBe("384px");
168
+ expect(pinSecond!.style.height).toBe("48px");
169
+
170
+ await settle();
171
+ expect(items().length).toBe(0);
172
+ });
109
173
  });
@@ -1,5 +1,5 @@
1
1
  import { CircleX as XCircle, X } from "lucide-vue-next";
2
- import { computed, defineComponent, onMounted, onUnmounted, ref, Teleport, Transition, TransitionGroup, watch } from "vue";
2
+ import { computed, defineComponent, onBeforeUpdate, onMounted, onUnmounted, ref, Teleport, Transition, TransitionGroup, watch } from "vue";
3
3
  import { AlertTriangle, CheckCircle, Copy, Info } from "lucide-vue-next";
4
4
 
5
5
 
@@ -7,7 +7,7 @@ import { useToast, type ToastItem, type ToastMessage, type ToastType } from "../
7
7
  import { useClipboard } from "../runtime/useClipboard";
8
8
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
9
9
  import { useI18n } from "../i18n/context";
10
- import { clearLeaveGeometry, pinLeaveGeometry } from "../utils/dom";
10
+ import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
11
11
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
12
12
  import "./HkToast.scss";
13
13
 
@@ -173,6 +173,36 @@ export default defineComponent({
173
173
  let popupHandle: PopupHandle | null = null;
174
174
  const containerZ = ref<number | null>(null);
175
175
 
176
+ // Pre-patch geometry of every toast, refreshed on each update (the
177
+ // DOM is still the pre-patch tree at onBeforeUpdate — same pattern
178
+ // as HkListTransition / HkTabs). During a multi-toast removal the
179
+ // first leaving sibling gets its leave-active class (position:
180
+ // absolute) synchronously inside the patch pass, so by the time the
181
+ // NEXT sibling's beforeLeave runs, the shrink-to-fit stack column
182
+ // has already re-fit: a live offset read there would freeze the
183
+ // reflowed box — toasts teleport onto the top slot, a narrow toast's
184
+ // bar collapses to its own max-content, and the overlapped fading
185
+ // texts read as one squashed multi-line blob. The wrapper itself may
186
+ // also have re-fit when the pin runs, so the right anchor is snapped
187
+ // at snapshot time too (see LeaveBoxSnapshot.right).
188
+ const hostRef = ref<{ $el?: Element } | null>(null);
189
+ const preLeaveBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
190
+ onBeforeUpdate(() => {
191
+ const host = hostRef.value?.$el;
192
+ if (host == null || host.nodeType !== 1) return;
193
+ const parentWidth = (host as HTMLElement).clientWidth;
194
+ for (const child of Array.from(host.children)) {
195
+ const e = child as HTMLElement;
196
+ preLeaveBoxes.set(e, {
197
+ top: e.offsetTop,
198
+ left: e.offsetLeft,
199
+ width: e.offsetWidth,
200
+ height: e.offsetHeight,
201
+ right: parentWidth - (e.offsetLeft + e.offsetWidth),
202
+ });
203
+ }
204
+ });
205
+
176
206
  onMounted(() => {
177
207
  popupHandle = manager.register("toast", false);
178
208
  containerZ.value = popupHandle.zIndex;
@@ -195,13 +225,14 @@ export default defineComponent({
195
225
  <Teleport to="body">
196
226
  <div class="hk-toast-container" style={containerStyle.value}>
197
227
  <TransitionGroup
228
+ ref={hostRef}
198
229
  tag="div"
199
230
  name="hk-toast"
200
231
  onBeforeEnter={itemHooks.onBeforeEnter}
201
232
  onAfterEnter={itemHooks.onAfterEnter}
202
233
  onBeforeLeave={(el: Element) => {
203
234
  itemHooks.onBeforeLeave();
204
- pinLeaveGeometry(el);
235
+ pinLeaveGeometry(el, { box: preLeaveBoxes.get(el) });
205
236
  }}
206
237
  onAfterLeave={itemHooks.onAfterLeave}
207
238
  onLeaveCancelled={(el: Element) => {
@@ -51,6 +51,7 @@
51
51
  "hikari::filePicker.confirm": "اختيار",
52
52
  "hikari::filePicker.copy": "نسخ",
53
53
  "hikari::filePicker.copyPath": "نسخ المسار",
54
+ "hikari::filePicker.currentPath": "المسار الحالي",
54
55
  "hikari::filePicker.cut": "قص",
55
56
  "hikari::filePicker.empty": "هذا المجلد فارغ",
56
57
  "hikari::filePicker.fieldChooseMany": "اختر ملفات…",
@@ -217,5 +218,7 @@
217
218
  "hikari::tagInput.remove": "إزالة",
218
219
  "hikari::tagInput.addCustom": "استخدام \"{q}\"",
219
220
  "hikari::tagInput.reorder": "إعادة الترتيب — {label}",
220
- "hikari::modal.hiddenLayers": "الطبقات المخفية"
221
+ "hikari::modal.hiddenLayers": "الطبقات المخفية",
222
+ "hikari::theme.modeTabHint": "هذه التبويبات تختار لوحة الألوان التي تحرّرها؛ لتبديل الوضع الفاتح/الداكن للتطبيق كله استخدم شريط الأوضاع أعلى نافذة السمة.",
223
+ "hikari::emptyState.loading": "جارٍ التحميل"
221
224
  }
@@ -51,6 +51,7 @@
51
51
  "hikari::filePicker.confirm": "Auswählen",
52
52
  "hikari::filePicker.copy": "Kopieren",
53
53
  "hikari::filePicker.copyPath": "Pfad kopieren",
54
+ "hikari::filePicker.currentPath": "Aktueller Pfad",
54
55
  "hikari::filePicker.cut": "Ausschneiden",
55
56
  "hikari::filePicker.empty": "Dieser Ordner ist leer",
56
57
  "hikari::filePicker.fieldChooseMany": "Dateien auswählen…",
@@ -217,5 +218,7 @@
217
218
  "hikari::tagInput.remove": "Entfernen",
218
219
  "hikari::tagInput.addCustom": "„{q}“ verwenden",
219
220
  "hikari::tagInput.reorder": "Neu anordnen — {label}",
220
- "hikari::modal.hiddenLayers": "Verdeckte Ebenen"
221
+ "hikari::modal.hiddenLayers": "Verdeckte Ebenen",
222
+ "hikari::theme.modeTabHint": "Diese Reiter wählen die zu bearbeitende Palette; die appweite Hell/Dunkel-Umschaltung liegt im Modusstreifen des Themen-Popups.",
223
+ "hikari::emptyState.loading": "Wird geladen"
221
224
  }
@@ -51,6 +51,7 @@
51
51
  "hikari::filePicker.confirm": "Choose",
52
52
  "hikari::filePicker.copy": "Copy",
53
53
  "hikari::filePicker.copyPath": "Copy path",
54
+ "hikari::filePicker.currentPath": "Current path",
54
55
  "hikari::filePicker.cut": "Cut",
55
56
  "hikari::filePicker.empty": "This folder is empty",
56
57
  "hikari::filePicker.fieldChooseMany": "Choose files…",
@@ -217,5 +218,7 @@
217
218
  "hikari::tagInput.remove": "Remove",
218
219
  "hikari::tagInput.addCustom": "Use “{q}”",
219
220
  "hikari::tagInput.reorder": "Reorder {label}",
220
- "hikari::modal.hiddenLayers": "Hidden layers"
221
+ "hikari::modal.hiddenLayers": "Hidden layers",
222
+ "hikari::theme.modeTabHint": "These tabs pick which palette you are EDITING; to switch the app-wide light/dark, use the mode strip at the top of the theme popup.",
223
+ "hikari::emptyState.loading": "Loading"
221
224
  }
@@ -51,6 +51,7 @@
51
51
  "hikari::filePicker.confirm": "Elegir",
52
52
  "hikari::filePicker.copy": "Copiar",
53
53
  "hikari::filePicker.copyPath": "Copiar ruta",
54
+ "hikari::filePicker.currentPath": "Ruta actual",
54
55
  "hikari::filePicker.cut": "Cortar",
55
56
  "hikari::filePicker.empty": "Esta carpeta está vacía",
56
57
  "hikari::filePicker.fieldChooseMany": "Elegir archivos…",
@@ -217,5 +218,7 @@
217
218
  "hikari::tagInput.remove": "Quitar",
218
219
  "hikari::tagInput.addCustom": "Usar «{q}»",
219
220
  "hikari::tagInput.reorder": "Reordenar — {label}",
220
- "hikari::modal.hiddenLayers": "Capas ocultas"
221
+ "hikari::modal.hiddenLayers": "Capas ocultas",
222
+ "hikari::theme.modeTabHint": "Estas pestañas eligen la paleta que estás EDITANDO; para cambiar el modo claro/oscuro de toda la app, usa la franja de modos de la parte superior del panel de temas.",
223
+ "hikari::emptyState.loading": "Cargando"
221
224
  }
@@ -51,6 +51,7 @@
51
51
  "hikari::filePicker.confirm": "Choisir",
52
52
  "hikari::filePicker.copy": "Copier",
53
53
  "hikari::filePicker.copyPath": "Copier le chemin",
54
+ "hikari::filePicker.currentPath": "Chemin actuel",
54
55
  "hikari::filePicker.cut": "Couper",
55
56
  "hikari::filePicker.empty": "Ce dossier est vide",
56
57
  "hikari::filePicker.fieldChooseMany": "Choisir des fichiers…",
@@ -217,5 +218,7 @@
217
218
  "hikari::tagInput.remove": "Retirer",
218
219
  "hikari::tagInput.addCustom": "Utiliser « {q} »",
219
220
  "hikari::tagInput.reorder": "Réorganiser — {label}",
220
- "hikari::modal.hiddenLayers": "Couches masquées"
221
+ "hikari::modal.hiddenLayers": "Couches masquées",
222
+ "hikari::theme.modeTabHint": "Ces onglets choisissent la palette éditée ; pour basculer clair/sombre dans toute l’app, utilisez la barre de mode du menu de thème.",
223
+ "hikari::emptyState.loading": "Chargement"
221
224
  }
@@ -51,6 +51,7 @@
51
51
  "hikari::filePicker.confirm": "選択",
52
52
  "hikari::filePicker.copy": "コピー",
53
53
  "hikari::filePicker.copyPath": "パスをコピー",
54
+ "hikari::filePicker.currentPath": "現在のパス",
54
55
  "hikari::filePicker.cut": "切り取り",
55
56
  "hikari::filePicker.empty": "このフォルダーは空です",
56
57
  "hikari::filePicker.fieldChooseMany": "複数のファイルを選択…",
@@ -217,5 +218,7 @@
217
218
  "hikari::tagInput.remove": "削除",
218
219
  "hikari::tagInput.addCustom": "「{q}」を使用",
219
220
  "hikari::tagInput.reorder": "並べ替え — {label}",
220
- "hikari::modal.hiddenLayers": "非表示の階層"
221
+ "hikari::modal.hiddenLayers": "非表示の階層",
222
+ "hikari::theme.modeTabHint": "このタブは編集対象のパレットを選ぶだけです。アプリ全体の明/暗はテーマポップアップ上部のモードストリップで切り替えてください。",
223
+ "hikari::emptyState.loading": "読み込み中"
221
224
  }
@@ -51,6 +51,7 @@
51
51
  "hikari::filePicker.confirm": "선택",
52
52
  "hikari::filePicker.copy": "복사",
53
53
  "hikari::filePicker.copyPath": "경로 복사",
54
+ "hikari::filePicker.currentPath": "현재 경로",
54
55
  "hikari::filePicker.cut": "잘라내기",
55
56
  "hikari::filePicker.empty": "이 폴더는 비어 있습니다",
56
57
  "hikari::filePicker.fieldChooseMany": "여러 파일 선택…",
@@ -217,5 +218,7 @@
217
218
  "hikari::tagInput.remove": "제거",
218
219
  "hikari::tagInput.addCustom": "\"{q}\" 사용",
219
220
  "hikari::tagInput.reorder": "순서 변경 — {label}",
220
- "hikari::modal.hiddenLayers": "숨겨진 레이어"
221
+ "hikari::modal.hiddenLayers": "숨겨진 레이어",
222
+ "hikari::theme.modeTabHint": "이 탭은 편집할 팔레트를 고를 뿐입니다. 앱 전체 밝기/어둠은 테마 팝업 상단의 모드 스트립에서 바꾸세요.",
223
+ "hikari::emptyState.loading": "불러오는 중"
221
224
  }
@@ -51,6 +51,7 @@
51
51
  "hikari::filePicker.confirm": "Escolher",
52
52
  "hikari::filePicker.copy": "Copiar",
53
53
  "hikari::filePicker.copyPath": "Copiar caminho",
54
+ "hikari::filePicker.currentPath": "Caminho atual",
54
55
  "hikari::filePicker.cut": "Recortar",
55
56
  "hikari::filePicker.empty": "Esta pasta está vazia",
56
57
  "hikari::filePicker.fieldChooseMany": "Escolher arquivos…",
@@ -217,5 +218,7 @@
217
218
  "hikari::tagInput.remove": "Remover",
218
219
  "hikari::tagInput.addCustom": "Usar \"{q}\"",
219
220
  "hikari::tagInput.reorder": "Reordenar — {label}",
220
- "hikari::modal.hiddenLayers": "Camadas ocultas"
221
+ "hikari::modal.hiddenLayers": "Camadas ocultas",
222
+ "hikari::theme.modeTabHint": "Estas abas escolhem a paleta que você está EDITANDO; para alternar o claro/escuro do app inteiro, use a faixa de modos no topo do painel de temas.",
223
+ "hikari::emptyState.loading": "Carregando"
221
224
  }
@@ -51,6 +51,7 @@
51
51
  "hikari::filePicker.confirm": "Выбрать",
52
52
  "hikari::filePicker.copy": "Копировать",
53
53
  "hikari::filePicker.copyPath": "Копировать путь",
54
+ "hikari::filePicker.currentPath": "Текущий путь",
54
55
  "hikari::filePicker.cut": "Вырезать",
55
56
  "hikari::filePicker.empty": "Эта папка пуста",
56
57
  "hikari::filePicker.fieldChooseMany": "Выбрать файлы…",
@@ -217,5 +218,7 @@
217
218
  "hikari::tagInput.remove": "Удалить",
218
219
  "hikari::tagInput.addCustom": "Использовать «{q}»",
219
220
  "hikari::tagInput.reorder": "Изменить порядок — {label}",
220
- "hikari::modal.hiddenLayers": "Скрытые слои"
221
+ "hikari::modal.hiddenLayers": "Скрытые слои",
222
+ "hikari::theme.modeTabHint": "Вкладки выбирают редактируемую палитру; общесистемное переключение светлой/тёмной — в полосе режимов вверху меню темы.",
223
+ "hikari::emptyState.loading": "Загрузка"
221
224
  }
@@ -51,6 +51,7 @@
51
51
  "hikari::filePicker.confirm": "选择",
52
52
  "hikari::filePicker.copy": "复制",
53
53
  "hikari::filePicker.copyPath": "复制路径",
54
+ "hikari::filePicker.currentPath": "当前路径",
54
55
  "hikari::filePicker.cut": "剪切",
55
56
  "hikari::filePicker.empty": "此文件夹为空",
56
57
  "hikari::filePicker.fieldChooseMany": "选择多个文件…",
@@ -221,5 +222,7 @@
221
222
  "hikari::tagInput.remove": "移除",
222
223
  "hikari::tagInput.addCustom": "使用「{q}」",
223
224
  "hikari::tagInput.reorder": "拖动排序 — {label}",
224
- "hikari::modal.hiddenLayers": "隐藏层级"
225
+ "hikari::modal.hiddenLayers": "隐藏层级",
226
+ "hikari::theme.modeTabHint": "切换这里编辑的是对应模式的调色板;要切换应用整体深/浅,请用主题弹窗顶部的模式条。",
227
+ "hikari::emptyState.loading": "加载中"
225
228
  }
@@ -51,6 +51,7 @@
51
51
  "hikari::filePicker.confirm": "選擇",
52
52
  "hikari::filePicker.copy": "複製",
53
53
  "hikari::filePicker.copyPath": "複製路徑",
54
+ "hikari::filePicker.currentPath": "目前路徑",
54
55
  "hikari::filePicker.cut": "剪下",
55
56
  "hikari::filePicker.empty": "此資料夾為空",
56
57
  "hikari::filePicker.fieldChooseMany": "選擇多個檔案…",
@@ -221,5 +222,7 @@
221
222
  "hikari::tagInput.remove": "移除",
222
223
  "hikari::tagInput.addCustom": "使用「{q}」",
223
224
  "hikari::tagInput.reorder": "拖曳排序 — {label}",
224
- "hikari::modal.hiddenLayers": "隱藏層級"
225
+ "hikari::modal.hiddenLayers": "隱藏層級",
226
+ "hikari::theme.modeTabHint": "切換這裡編輯的是對應模式的調色盤;要切換應用整體深/淺,請用主題彈窗頂部的模式條。",
227
+ "hikari::emptyState.loading": "載入中"
225
228
  }
@@ -0,0 +1,177 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ /**
6
+ * t() usage-surface audit (2026-09-19, follow-up to the #599 incident):
7
+ * flatKeyParity.test.ts proves the locale bundles AGREE with en, but both
8
+ * sides of that check live in the JSON — nothing pinned the source side.
9
+ * Renaming a key in the bundles (or deleting it from en only) left every
10
+ * test green while the components fell back to their hard-coded English
11
+ * literals in every language (exactly how the theme.modeTabHint gap sat
12
+ * unnoticed until #599, alongside two older strays this suite landed:
13
+ * emptyState.loading and filePicker.currentPath).
14
+ *
15
+ * This file closes that loop: it statically scans every consumer source
16
+ * file under packages/vue/src for `t("hikari::…")` literals — in ALL
17
+ * THREE quote forms (double, single, backtick) and across line breaks
18
+ * after the opening paren (HkPhoneInput.tsx:194 already uses that split
19
+ * form) — and asserts each referenced key exists, non-empty, in the
20
+ * loader-merged bundle of EVERY locale.
21
+ *
22
+ * The `hikari::` prefix is exactly hikari's own component vocabulary.
23
+ * Unprefixed literals ("common.time.*", "${i18nKey}.*", "locale.title")
24
+ * belong to the HOST-app namespace (registered at runtime via
25
+ * mergeMessages, or passed in as props.t) and are pinned by their own
26
+ * suites (timeKeys.test.ts & co.) — out of scope here by construction.
27
+ */
28
+ const modules = import.meta.glob<{ default: Record<string, unknown> }>(
29
+ "./locales/*/*.json",
30
+ { eager: true },
31
+ );
32
+
33
+ const EXPECTED_LOCALES = [
34
+ "ar", "de", "en", "es", "fr", "ja", "ko", "pt", "ru", "zh-Hans", "zh-Hant",
35
+ ];
36
+
37
+ /** The loader's own merge (i18n/context.ts buildLocaleMessages): per locale
38
+ * directory, the union over every *.json of top-level flat string keys plus
39
+ * one level of legacy nested sections. */
40
+ function buildLocaleUniverse(locale: string): Map<string, string> {
41
+ const merged = new Map<string, string>();
42
+ for (const [path, mod] of Object.entries(modules)) {
43
+ if (!path.includes(`/locales/${locale}/`)) continue;
44
+ for (const [key, value] of Object.entries(mod.default)) {
45
+ if (typeof value === "string") merged.set(key, value);
46
+ else if (value !== null && typeof value === "object") {
47
+ for (const [leaf, text] of Object.entries(value as Record<string, unknown>)) {
48
+ if (typeof text === "string") merged.set(leaf, text);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ return merged;
54
+ }
55
+
56
+ const SRC_ROOT = join(import.meta.dirname, "..");
57
+
58
+ /** Consumer sources: everything under src/ EXCEPT the i18n suite itself
59
+ * (its tests exercise the loader with their own literals — bundle-side
60
+ * guards already cover those keys; scanning them here would just couple
61
+ * this audit to test churn, not to the real usage surface). */
62
+ function walkSources(dir: string, acc: string[] = []): string[] {
63
+ for (const name of readdirSync(dir)) {
64
+ const path = join(dir, name);
65
+ const info = statSync(path);
66
+ if (info.isDirectory()) {
67
+ if (dir === SRC_ROOT && name === "i18n") continue;
68
+ walkSources(path, acc);
69
+ } else if (/\.(ts|tsx|vue)$/.test(name)) acc.push(path);
70
+ }
71
+ return acc;
72
+ }
73
+
74
+ /** Static literal call sites. The `\s*` tolerates a line break between
75
+ * `t(` and the key; the character class covers ", ', and ` so a template
76
+ * literal WITHOUT interpolation is still a pinned literal. */
77
+ const STATIC_USE_RE = /\bt\(\s*(["'`])hikari::[A-Za-z0-9_.]+\1/g;
78
+ /** Dynamic families: a template key with `${…}` interpolation can never be
79
+ * resolved statically — they are enumerated in DYNAMIC_KEY_FAMILIES. */
80
+ const DYNAMIC_USE_RE = /\bt\(\s*`hikari::[A-Za-z0-9_.]*\$\{/g;
81
+
82
+ interface Site {
83
+ key: string;
84
+ where: string;
85
+ }
86
+
87
+ function scanSources(): { statics: Site[]; dynamics: string[] } {
88
+ const statics: Site[] = [];
89
+ const dynamics: string[] = [];
90
+ for (const path of walkSources(SRC_ROOT)) {
91
+ const text = readFileSync(path, "utf8");
92
+ const rel = path.slice(SRC_ROOT.length + 1);
93
+ for (const match of text.matchAll(STATIC_USE_RE)) {
94
+ const key = match[0].slice(match[0].indexOf("hikari::"), -1);
95
+ statics.push({ key, where: `${rel}:${text.slice(0, match.index).split("\n").length}` });
96
+ }
97
+ for (const match of text.matchAll(DYNAMIC_USE_RE)) {
98
+ // The literal portion before "${" — e.g. "hikari::context." — which
99
+ // is the family prefix the whitelist is keyed by.
100
+ dynamics.push(match[0].slice(match[0].indexOf("hikari::"), -2));
101
+ }
102
+ }
103
+ return { statics, dynamics };
104
+ }
105
+
106
+ /** Interpolated key families that intentionally escape the static scan,
107
+ * each with the reason it cannot be pinned by literal. Both directions
108
+ * are enforced below: every family found in source must be listed, and
109
+ * every listed family must still exist in source, so this whitelist
110
+ * cannot rot into silence. */
111
+ const DYNAMIC_KEY_FAMILIES: Record<string, string> = {
112
+ "hikari::context.": "HkContextRing section keys — closed enum, pinned by name in contextKeys.test.ts.",
113
+ "hikari::statusBar.backend.": "HkConnectionStatus — enumerated connection-state suffixes.",
114
+ "hikari::statusBar.region.": "HkStatusBar region names — OPTIONAL per-locale overrides over Intl.DisplayNames (see LOCALE_SPECIFIC_NESTED in flatKeyParity.test.ts).",
115
+ "hikari::theme.groups.": "Registry type: group/slot ids are registered by downstream apps; t() resolves via a resolveLocalizedText fallback, never a hikari bundle.",
116
+ "hikari::theme.tokens.": "HkColorSchemeEditor token enum — closed set defined in platform.json locales.",
117
+ };
118
+
119
+ describe("t() usage surface vs locale bundles", () => {
120
+ const universes = new Map(EXPECTED_LOCALES.map((l) => [l, buildLocaleUniverse(l)]));
121
+ const { statics, dynamics } = scanSources();
122
+
123
+ it("covers all 11 locales in the merged bundle universe", () => {
124
+ const found = new Set(
125
+ Object.keys(modules)
126
+ .map((p) => p.match(/locales\/([^/]+)\//)?.[1])
127
+ .filter((l): l is string => Boolean(l)),
128
+ );
129
+ expect([...found].sort()).toEqual(EXPECTED_LOCALES);
130
+ });
131
+
132
+ it("scanner self-proof: the extraction actually extracts (no vacuous pass)", () => {
133
+ // Zero-hit discipline: a broken regex/walk yields zeros that LOOK green.
134
+ // Pin the floor near today's real volume (446 files, 325 sites, 270
135
+ // distinct keys) so a hollowed scanner goes loudly red, and pin known
136
+ // positives — including one multi-line call form — by name and file.
137
+ expect(walkSources(SRC_ROOT).length).toBeGreaterThan(400);
138
+ expect(statics.length).toBeGreaterThan(320);
139
+ expect(new Set(statics.map((s) => s.key)).size).toBeGreaterThan(260);
140
+ const firstSiteOf = (key: string) =>
141
+ statics.find((s) => s.key === key)?.where ?? "NOT FOUND";
142
+ expect(firstSiteOf("hikari::theme.modeTabHint")).toMatch(/HkColorSchemeEditor\.tsx/);
143
+ expect(firstSiteOf("hikari::emptyState.loading")).toMatch(/HkEmptyState\.tsx/);
144
+ expect(firstSiteOf("hikari::filePicker.currentPath")).toMatch(/HkFileBrowserDialog\.tsx/);
145
+ // Split-across-lines call form (t( and the key on different lines):
146
+ expect(firstSiteOf("hikari::phoneInput.searchCountries")).toMatch(/HkPhoneInput\.tsx/);
147
+ });
148
+
149
+ it("resolves every statically referenced hikari:: key in every locale, non-empty", () => {
150
+ expect(statics.length, "source scan found call sites").toBeGreaterThan(0);
151
+ const violations: string[] = [];
152
+ const reported = new Set<string>();
153
+ for (const { key, where } of statics) {
154
+ for (const locale of EXPECTED_LOCALES) {
155
+ const value = universes.get(locale)!.get(key);
156
+ if (typeof value !== "string" || value.length === 0) {
157
+ if (reported.has(key)) continue;
158
+ reported.add(key);
159
+ violations.push(
160
+ `${key} (first referenced at ${where}) is missing or empty in ${locale}`,
161
+ );
162
+ }
163
+ }
164
+ }
165
+ expect(violations, "every referenced key must resolve in all 11 locales").toEqual([]);
166
+ });
167
+
168
+ it("enumerates exactly the whitelisted dynamic key families, both directions", () => {
169
+ const found = [...new Set(dynamics)].sort();
170
+ expect(found, "families found in source but missing from the whitelist").toEqual(
171
+ Object.keys(DYNAMIC_KEY_FAMILIES).sort(),
172
+ );
173
+ for (const family of Object.keys(DYNAMIC_KEY_FAMILIES)) {
174
+ expect(found, `${family} is whitelisted but no longer used — prune it`).toContain(family);
175
+ }
176
+ });
177
+ });
@@ -110,6 +110,24 @@ describe("pinLeaveGeometry", () => {
110
110
  expect(el.style.boxSizing).toBe("border-box");
111
111
  });
112
112
 
113
+ it("prefers the snapshot's right pin over a live clientWidth on the right anchor", () => {
114
+ // Right-anchored hosts re-fit mid-removal: by the time a later
115
+ // sibling pins, the live clientWidth has already collapsed, so the
116
+ // snapshot-time right offset must win over the live computation.
117
+ const parent = { clientWidth: 138 };
118
+ const el = stubEl(parent, { offsetTop: 95, offsetLeft: 0, offsetWidth: 384, offsetHeight: 48 });
119
+
120
+ pinLeaveGeometry(el as unknown as Element, {
121
+ box: { top: 95, left: 0, width: 384, height: 48, right: 0 },
122
+ });
123
+
124
+ expect(el.style.right).toBe("0px");
125
+ expect(el.style.top).toBe("95px");
126
+ expect(el.style.width).toBe("384px");
127
+ expect(el.style.height).toBe("48px");
128
+ expect(el.style.boxSizing).toBe("border-box");
129
+ });
130
+
113
131
  it("clearLeaveGeometry strips exactly the pins pinLeaveGeometry wrote", () => {
114
132
  const parent = { clientWidth: 384 };
115
133
  const el = stubEl(parent, { offsetWidth: 384, offsetHeight: 83 });
package/src/utils/dom.ts CHANGED
@@ -49,6 +49,13 @@ export interface LeaveBoxSnapshot {
49
49
  left: number;
50
50
  width: number;
51
51
  height: number;
52
+ /** Right-pin offset (`containing-block width − (left + width)`)
53
+ * computed at SNAPSHOT time. Right-anchored hosts need this because
54
+ * the containing block itself re-fits mid-removal: by the time a
55
+ * later sibling pins, a live `clientWidth` read would measure the
56
+ * already-collapsed wrapper and shove the pinned box past the edge
57
+ * the anchor is supposed to hold. */
58
+ right?: number;
52
59
  }
53
60
 
54
61
  /**
@@ -107,7 +114,10 @@ export function pinLeaveGeometry(
107
114
  // CSS `right` for an absolutely positioned box is measured from the
108
115
  // containing block's right padding-box edge; offsetLeft/offsetWidth
109
116
  // place the item's right border edge within that same padding box.
110
- const right = parent.clientWidth - (left + width);
117
+ // Prefer the snapshot-time value: a live clientWidth here would
118
+ // measure the wrapper AFTER earlier siblings already left the flow
119
+ // (multi-element removal re-fits it synchronously).
120
+ const right = opts?.box?.right ?? parent.clientWidth - (left + width);
111
121
  node.style.right = `${right}px`;
112
122
  }
113
123
  node.style.top = `${top}px`;