@celestia-island/hikari 0.40.36 → 0.41.1

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.40.36",
3
+ "version": "0.41.1",
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",
@@ -134,6 +134,11 @@
134
134
  &[data-unset] {
135
135
  font-style: italic;
136
136
  color: rgb(var(--color-muted));
137
+ /* Italic final strokes lean past the glyph advance; overflow:hidden
138
+ * (the ellipsis pair above) shaves them at the content edge. The
139
+ * inline-end cushion is clipping room, and because it sits INSIDE
140
+ * the padding box it never triggers the ellipsis for fitting text. */
141
+ padding-inline-end: 0.2em;
137
142
  }
138
143
  }
139
144
 
@@ -38,6 +38,52 @@ afterEach(() => {
38
38
  });
39
39
 
40
40
  describe("HkStatusBar", () => {
41
+ it("renders extraDetails rows capped at 400px with an ellipsized, title-backed value", async () => {
42
+ const container = mountBar({
43
+ version: "1.2.3",
44
+ connectionStatus: "connected",
45
+ connectionInfo: INFO,
46
+ extraDetails: [
47
+ { key: "gateway", label: "Gateway", value: "gateway.celestia.world" },
48
+ ],
49
+ });
50
+ await nextTick();
51
+ // Popover content teleports to <body> and only renders once the tag
52
+ // is hovered open (same grammar as the protocol/network rows).
53
+ const tag = container.querySelector<HTMLElement>(".s-status-bar-tag")!;
54
+ tag.dispatchEvent(new MouseEvent("mouseenter"));
55
+ await nextTick();
56
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel");
57
+ expect(panel, "popover opens on hover").toBeTruthy();
58
+ const row = panel!.querySelector('[data-extra-detail="gateway"]');
59
+ expect(row).not.toBeNull();
60
+ expect((row as HTMLElement).style.maxWidth).toBe("400px");
61
+ const value = panel!.querySelector("span[title]") as HTMLElement;
62
+ expect(value.textContent).toBe("gateway.celestia.world");
63
+ expect(value.title).toBe("gateway.celestia.world");
64
+ expect(value.style.textOverflow).toBe("ellipsis");
65
+ // The label column keeps a minimum width — a long value must not
66
+ // squeeze it (the row space-between-aligns instead).
67
+ const label = row!.querySelector("span[style*='min-width'], span:nth-child(2)") as HTMLElement;
68
+ expect(label.style.minWidth).toBe("72px");
69
+ expect(label.style.flexShrink).toBe("0");
70
+ });
71
+
72
+ it("renders no extraDetails rows when the prop is absent", async () => {
73
+ const container = mountBar({
74
+ version: "1.2.3",
75
+ connectionStatus: "connected",
76
+ connectionInfo: INFO,
77
+ });
78
+ await nextTick();
79
+ container
80
+ .querySelector<HTMLElement>(".s-status-bar-tag")!
81
+ .dispatchEvent(new MouseEvent("mouseenter"));
82
+ await nextTick();
83
+ const panel = document.body.querySelector(".hk-popover-panel");
84
+ expect(panel?.querySelector("[data-extra-detail]")).toBeNull();
85
+ });
86
+
41
87
  it("mounts with the traffic light and version rows inline, protocol row on hover-open", async () => {
42
88
  const container = mountBar({
43
89
  version: "1.2.3",
@@ -96,6 +96,21 @@ export const HkStatusBar = defineComponent({
96
96
  transportTier: { type: String as PropType<string>, default: undefined },
97
97
  attemptNumber: { type: Number, default: undefined },
98
98
  countdown: { type: Number, default: undefined },
99
+ /**
100
+ * Optional extra rows appended to the connection-info popover (after
101
+ * the network row): host-supplied label/value pairs with a prebuilt
102
+ * icon vnode each — the same loose-icon contract as HkAuthMethodList.
103
+ * Rows grow with their content but cap at 400px; values past the cap
104
+ * ellipsize (full text rides the native title). Lets a host surface
105
+ * stage endpoints (gateway, registry…) inside the popover without
106
+ * forking the component.
107
+ */
108
+ extraDetails: {
109
+ type: Array as PropType<
110
+ Array<{ key: string; icon?: unknown; label: string; value: string }>
111
+ >,
112
+ default: undefined,
113
+ },
99
114
  },
100
115
  setup(props) {
101
116
  const popupOpen = ref(false);
@@ -358,6 +373,13 @@ export const HkStatusBar = defineComponent({
358
373
  <span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.network", "Network")}</span>
359
374
  <span>{regionDisplayName(info.region, locale, t)}{info.asn != null ? ` · AS${info.asn}` : ""}{info.isLocalhost ? " · " + t("hikari::statusBar.local", "Local") : ""}</span>
360
375
  </div>
376
+ {(props.extraDetails ?? []).map((row) => (
377
+ <div key={row.key} data-extra-detail={row.key} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "6px", maxWidth: "400px" }}>
378
+ <span style={{ opacity: 0.5, flexShrink: 0, display: "inline-flex" }}>{row.icon}</span>
379
+ <span style={{ opacity: 0.5, flexShrink: 0, minWidth: "72px", marginRight: "auto" }}>{row.label}</span>
380
+ <span title={row.value} style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.value}</span>
381
+ </div>
382
+ ))}
361
383
  </>
362
384
  ) : (
363
385
  <div style={{ opacity: 0.5 }}>{t("hikari::statusBar.fetching", "Fetching connection info...")}</div>
@@ -3,6 +3,17 @@
3
3
  align-items: center;
4
4
  gap: 0;
5
5
  width: 100%;
6
+
7
+ // Shared node geometry — ONE source of truth for the 24px indicator
8
+ // circle and the rail thickness. Every connector math keys off these:
9
+ // window-mode links center on the node along the inline axis, the
10
+ // vertical rail hangs centered under it, so both stay glued to the
11
+ // circles even if the node size ever changes.
12
+ --hk-timeline-node-size: 24px;
13
+ --hk-timeline-link-thickness: 2px;
14
+ --hk-timeline-link-top: calc(
15
+ (var(--hk-timeline-node-size) - var(--hk-timeline-link-thickness)) / 2
16
+ );
6
17
  }
7
18
 
8
19
  .hk-timeline[data-orientation="vertical"] {
@@ -26,12 +37,9 @@
26
37
  // Geometry contract with the stacked steps above: the node is the 24px
27
38
  // indicator circle at the top of every stack, so its center sits half a
28
39
  // node below the cell top in ALL columns — that is where link segments go.
29
- --hk-timeline-node-size: 24px;
40
+ // (Node size, thickness and link-top are the shared tokens on
41
+ // `.hk-timeline`.)
30
42
  --hk-timeline-link-clearance: 20px;
31
- --hk-timeline-link-thickness: 2px;
32
- --hk-timeline-link-top: calc(
33
- (var(--hk-timeline-node-size) - var(--hk-timeline-link-thickness)) / 2
34
- );
35
43
  }
36
44
 
37
45
  .hk-timeline[data-mode="window"] .hk-timeline-window {
@@ -142,6 +150,95 @@
142
150
  transform: scaleX(-1);
143
151
  }
144
152
 
153
+ /* ── Vertical window mode ────────────────────────────────────────────────
154
+ The height-starved sibling of the compact horizontal window: when the
155
+ host cannot fit the full column, the timeline collapses to the same
156
+ three-cell window (previous / current / next) stacked as equal thirds,
157
+ the outer two dimmed exactly like their left/right counterparts, and
158
+ vertical rail segments joining the node centers on an overlay — with
159
+ fading continuations past the top/bottom edges where hidden steps go. */
160
+ .hk-timeline[data-orientation="vertical"][data-mode="window"] {
161
+ grid-template-columns: minmax(0, 1fr);
162
+ grid-template-rows: repeat(3, minmax(0, 1fr));
163
+ align-items: stretch;
164
+
165
+ // Each cell centers its step within its third, which puts every node
166
+ // center at the SAME fractions of the host height as the horizontal
167
+ // mode puts them across the width (1/6, 1/2, 5/6) — the link math
168
+ // below is the horizontal one rotated, nothing more.
169
+ .hk-timeline-window {
170
+ display: flex;
171
+ flex-direction: column;
172
+ justify-content: center;
173
+ min-height: 0;
174
+ }
175
+
176
+ .hk-timeline-window .hk-timeline-step {
177
+ flex: 0 0 auto;
178
+ max-width: 100%;
179
+
180
+ [data-el="label"] {
181
+ overflow: hidden;
182
+ text-overflow: ellipsis;
183
+ white-space: nowrap;
184
+ }
185
+ }
186
+
187
+ // Vertical rails: centered under the node column, spanning between the
188
+ // cells' node centers (at 1/6, 1/2, 5/6 of the height) minus the same
189
+ // clearance the horizontal bonds keep from the circles. Each segment
190
+ // also re-asserts the inline geometry — the horizontal segment rules
191
+ // above carry the same attribute specificity and must not leak in.
192
+ .hk-timeline-link {
193
+ width: var(--hk-timeline-link-thickness);
194
+ height: auto;
195
+ inset-inline-start: var(--hk-timeline-link-top);
196
+ top: auto;
197
+ bottom: auto;
198
+
199
+ &[data-segment="before-current"] {
200
+ inset-inline-start: var(--hk-timeline-link-top);
201
+ width: var(--hk-timeline-link-thickness);
202
+ top: calc(16.6667% + var(--hk-timeline-link-clearance));
203
+ height: calc(33.3334% - (var(--hk-timeline-link-clearance) * 2));
204
+ }
205
+
206
+ &[data-segment="current-after"] {
207
+ inset-inline-start: var(--hk-timeline-link-top);
208
+ width: var(--hk-timeline-link-thickness);
209
+ top: calc(50% + var(--hk-timeline-link-clearance));
210
+ height: calc(33.3334% - (var(--hk-timeline-link-clearance) * 2));
211
+ }
212
+
213
+ // Continuation hints: from the container edge toward that cell's node,
214
+ // fading INTO the edge ("more steps exist beyond this side").
215
+ &[data-segment="edge-before"] {
216
+ inset-inline-start: var(--hk-timeline-link-top);
217
+ width: var(--hk-timeline-link-thickness);
218
+ top: 0;
219
+ height: calc(16.6667% - var(--hk-timeline-link-clearance));
220
+ }
221
+
222
+ &[data-segment="edge-after"] {
223
+ inset-inline-start: var(--hk-timeline-link-top);
224
+ width: var(--hk-timeline-link-thickness);
225
+ top: auto;
226
+ bottom: 0;
227
+ height: calc(16.6667% - var(--hk-timeline-link-clearance));
228
+ }
229
+
230
+ &[data-fade-dir="up"] {
231
+ background-color: transparent;
232
+ background-image: linear-gradient(to top, var(--_link-color), transparent);
233
+ }
234
+
235
+ &[data-fade-dir="down"] {
236
+ background-color: transparent;
237
+ background-image: linear-gradient(to bottom, var(--_link-color), transparent);
238
+ }
239
+ }
240
+ }
241
+
145
242
 
146
243
  .hk-timeline-step {
147
244
  display: flex;
@@ -187,7 +284,7 @@
187
284
  }
188
285
 
189
286
  [data-el="connector"] {
190
- height: 2px;
287
+ height: var(--hk-timeline-link-thickness);
191
288
  flex: 1 1 0%;
192
289
  min-width: 0.5rem;
193
290
  margin: 0 var(--hi-space-4, 4px);
@@ -198,19 +295,41 @@
198
295
  flex: 0 0 auto;
199
296
  }
200
297
 
201
- .hk-timeline[data-orientation="vertical"] .hk-timeline-step {
298
+ .hk-timeline[data-orientation="vertical"][data-mode="full"] .hk-timeline-step {
202
299
  flex-direction: row;
203
300
  flex: 0 0 auto;
204
- padding-bottom: var(--hi-space-24, 24px);
301
+
302
+ // The gap this step's rail must bridge: exactly the space between the
303
+ // bottom edge of this step's node circle and the top edge of the next
304
+ // step's node circle. Padding and rail height share the token, so the
305
+ // rail always lands flush on both circles — no floating dashes, no
306
+ // overlap, whatever the rhythm is tuned to.
307
+ --hk-timeline-vert-gap: var(--hi-space-24, 24px);
308
+ padding-bottom: var(--hk-timeline-vert-gap);
205
309
 
206
310
  [data-el="connector"] {
207
- width: 2px;
208
- height: 1rem;
311
+ width: var(--hk-timeline-link-thickness);
312
+ // The horizontal base rule leaves `min-width: 0.5rem` behind; on an
313
+ // absolutely-positioned rail min-width beats width, which once fatted
314
+ // the rail to 8px and shoved it off the node center. Neutralize the
315
+ // leaked sizing constraints explicitly.
316
+ min-width: 0;
209
317
  flex: 0 0 auto;
210
318
  position: absolute;
211
- left: 0.6875rem;
212
- top: 1.5rem;
319
+ height: var(--hk-timeline-vert-gap);
320
+ // Centered under the NODE, not the row: the circle occupies the first
321
+ // node-size square of the row, so the rail's inline offset is
322
+ // (node − thickness) / 2. Logical property mirrors it under RTL
323
+ // together with the flipped flex column.
324
+ inset-inline-start: calc(
325
+ (var(--hk-timeline-node-size) - var(--hk-timeline-link-thickness)) / 2
326
+ );
327
+ inset-inline-end: auto;
328
+ top: var(--hk-timeline-node-size);
329
+ bottom: auto;
213
330
  margin: 0;
331
+ // Rounded caps keep the rail's ends from aliasing against the circles.
332
+ border-radius: calc(var(--hk-timeline-link-thickness) / 2);
214
333
  }
215
334
  }
216
335
 
@@ -138,10 +138,48 @@ describe("HkTimeline full mode", () => {
138
138
  expect(els[3].getAttribute("aria-current")).toBeNull();
139
139
  });
140
140
 
141
- it("keeps vertical orientation in full mode", () => {
142
- const t = mountTimeline({ currentKey: "b", orientation: "vertical", collapse: "always" });
141
+ it("keeps a short vertical timeline in full mode", () => {
142
+ // A 3-step vertical timeline is its own window: nothing to collapse.
143
+ const steps = [
144
+ { key: "a", label: "Alpha" },
145
+ { key: "b", label: "Beta" },
146
+ { key: "c", label: "Gamma" },
147
+ ];
148
+ const t = mountTimeline({ currentKey: "b", orientation: "vertical", collapse: "always" }, steps);
143
149
  expect(t.container.querySelector(".hk-timeline")?.getAttribute("data-mode")).toBe("full");
144
150
  });
151
+
152
+ it("collapses a vertical timeline into the stacked window", () => {
153
+ const t = mountTimeline({ currentKey: "c", orientation: "vertical", collapse: "always" });
154
+ const root = t.container.querySelector(".hk-timeline");
155
+ expect(root?.getAttribute("data-orientation")).toBe("vertical");
156
+ expect(root?.getAttribute("data-mode")).toBe("window");
157
+
158
+ const before = t.container.querySelector('.hk-timeline-window[data-side="before"]');
159
+ const current = t.container.querySelector('.hk-timeline-window[data-side="current"]');
160
+ const after = t.container.querySelector('.hk-timeline-window[data-side="after"]');
161
+
162
+ expect(before?.querySelector('[data-el="label"]')?.textContent).toBe("Beta");
163
+ expect(current?.querySelector('[data-el="label"]')?.textContent).toBe("Gamma");
164
+ expect(after?.querySelector('[data-el="label"]')?.textContent).toBe("Delta");
165
+
166
+ // Same dimming grammar as the horizontal window: outer cells dimmed,
167
+ // the current cell at full strength.
168
+ expect(before?.hasAttribute("data-dimmed")).toBe(true);
169
+ expect(current?.hasAttribute("data-dimmed")).toBe(false);
170
+ expect(after?.hasAttribute("data-dimmed")).toBe(true);
171
+
172
+ // Vertical continuation hints fade along the block axis.
173
+ expect(
174
+ t.container.querySelector('.hk-timeline-link[data-segment="edge-before"]')?.getAttribute("data-fade-dir"),
175
+ ).toBe("up");
176
+ expect(
177
+ t.container.querySelector('.hk-timeline-link[data-segment="edge-after"]')?.getAttribute("data-fade-dir"),
178
+ ).toBe("down");
179
+
180
+ // The current step keeps its aria contract inside the stacked window.
181
+ expect(current?.querySelector(".hk-timeline-step")?.getAttribute("aria-current")).toBe("step");
182
+ });
145
183
  });
146
184
 
147
185
  describe("HkTimeline window mode", () => {
@@ -91,6 +91,23 @@ function naturalRowWidth(el: HTMLElement): number {
91
91
  return Math.ceil(width);
92
92
  }
93
93
 
94
+ /** Vertical twin of [`naturalRowWidth`]: the column's natural height when
95
+ * free to grow past the host (steps are `flex: 0 0 auto`, so an offscreen
96
+ * clone with `height: max-content` measures the unclamped stack). */
97
+ function naturalColumnHeight(el: HTMLElement): number {
98
+ const probe = el.cloneNode(true) as HTMLElement;
99
+ probe.style.position = "absolute";
100
+ probe.style.visibility = "hidden";
101
+ probe.style.pointerEvents = "none";
102
+ probe.style.height = "max-content";
103
+ const parent = el.parentElement;
104
+ if (!parent) return el.scrollHeight;
105
+ parent.appendChild(probe);
106
+ const height = probe.getBoundingClientRect().height;
107
+ probe.remove();
108
+ return Math.ceil(height);
109
+ }
110
+
94
111
  export default defineComponent({
95
112
  name: "HkTimeline",
96
113
  props: {
@@ -112,22 +129,22 @@ export default defineComponent({
112
129
  setup(props, { emit }) {
113
130
  const host = ref<HTMLElement | null>(null);
114
131
  const collapsed = ref(false);
115
- /** Full-row width captured right before collapsing; used as the
116
- * expand-again threshold (plus hysteresis) while windowed. */
117
- let fullNaturalWidth = 0;
132
+ /** Full-row width (horizontal) or full-column height (vertical)
133
+ * captured right before collapsing; used as the expand-again
134
+ * threshold (plus hysteresis) while windowed. */
135
+ let fullNaturalSize = 0;
118
136
  let observer: ResizeObserver | undefined;
119
137
 
120
138
  const currentIndex = computed(() =>
121
139
  props.steps.findIndex((s) => s.key === props.currentKey),
122
140
  );
123
141
 
124
- /** The window only makes sense for a horizontal row with more steps
125
- * than the window can show (a 3-step row is its own window). */
142
+ /** The window only makes sense for more steps than the window can show
143
+ * (a 3-step timeline is its own window). Horizontal collapse keys off
144
+ * width overflow, vertical collapse off height overflow — the stepper
145
+ * behaves the same whether the host squeezes its row or its column. */
126
146
  const windowable = computed(
127
- () =>
128
- props.orientation === "horizontal" &&
129
- props.steps.length > 3 &&
130
- currentIndex.value >= 0,
147
+ () => props.steps.length > 3 && currentIndex.value >= 0,
131
148
  );
132
149
 
133
150
  const windowed = computed(
@@ -152,23 +169,25 @@ export default defineComponent({
152
169
 
153
170
  function measure(): void {
154
171
  const el = host.value;
155
- if (!el || !windowable.value || props.collapse !== "auto") {
172
+ if (!el || props.collapse !== "auto") {
156
173
  collapsed.value = false;
157
174
  return;
158
175
  }
176
+ const vertical = props.orientation === "vertical";
177
+ const fitSize = vertical ? el.clientHeight : el.clientWidth;
159
178
  if (collapsed.value) {
160
- // Expand again only once the host comfortably fits the full row it
161
- // could not fit when we collapsed (16px hysteresis against flapping
162
- // around the exact threshold).
163
- if (fullNaturalWidth > 0 && el.clientWidth >= fullNaturalWidth + 16) {
179
+ // Expand again only once the host comfortably fits the full row or
180
+ // column it could not fit when we collapsed (16px hysteresis
181
+ // against flapping around the exact threshold).
182
+ if (fullNaturalSize > 0 && fitSize >= fullNaturalSize + 16) {
164
183
  collapsed.value = false;
165
- fullNaturalWidth = 0;
184
+ fullNaturalSize = 0;
166
185
  }
167
186
  return;
168
187
  }
169
- const natural = naturalRowWidth(el);
170
- if (natural > el.clientWidth + 1) {
171
- fullNaturalWidth = natural;
188
+ const natural = vertical ? naturalColumnHeight(el) : naturalRowWidth(el);
189
+ if (natural > fitSize + 1) {
190
+ fullNaturalSize = natural;
172
191
  collapsed.value = true;
173
192
  }
174
193
  }
@@ -197,7 +216,7 @@ export default defineComponent({
197
216
  ],
198
217
  () => {
199
218
  collapsed.value = false;
200
- fullNaturalWidth = 0;
219
+ fullNaturalSize = 0;
201
220
  void nextTick(measure);
202
221
  },
203
222
  );
@@ -288,7 +307,7 @@ export default defineComponent({
288
307
  class="hk-timeline-link"
289
308
  data-segment="edge-before"
290
309
  data-status={statusOf(w.beforeIndex)}
291
- data-fade-dir="left"
310
+ data-fade-dir={props.orientation === "vertical" ? "up" : "left"}
292
311
  />
293
312
  )}
294
313
  {w.afterIndex >= 0 && w.fadeAfter && (
@@ -296,7 +315,7 @@ export default defineComponent({
296
315
  class="hk-timeline-link"
297
316
  data-segment="edge-after"
298
317
  data-status={statusOf(w.afterIndex)}
299
- data-fade-dir="right"
318
+ data-fade-dir={props.orientation === "vertical" ? "down" : "right"}
300
319
  />
301
320
  )}
302
321
  </div>
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { setLocale, useI18n } from "./context";
4
+
5
+ /**
6
+ * Regression (2026-09-08 user report): the language tag's "Not set"
7
+ * placeholder stayed ENGLISH on a Chinese UI. Root cause —
8
+ * buildLocaleMessages only merged nested domain sections and silently
9
+ * DROPPED every top-level flat key ("hikari::x.y": "…"), so all
10
+ * post-0.3 flat additions (affixPicker.*, messageBox.*,
11
+ * localizedInput.noMatches, …) fell back to the inline English default
12
+ * at runtime on every locale. These tests pin flat-key loading.
13
+ */
14
+ describe("i18n flat-key loading", () => {
15
+ it("serves top-level flat keys from the locale bundle", async () => {
16
+ await setLocale("zh-Hans");
17
+ const { t } = useI18n();
18
+ expect(t("hikari::affixPicker.unset", "Not set")).toBe("未设置");
19
+ expect(t("hikari::affixPicker.remove", "Remove")).toBe("移除");
20
+ expect(t("hikari::affixPicker.removeConfirmTitle", "Remove entry")).toBe("移除条目");
21
+ expect(t("hikari::messageBox.confirm", "Confirm")).toBe("确认");
22
+ });
23
+
24
+ it("keeps nested legacy sections loading beside the flat keys", async () => {
25
+ await setLocale("zh-Hans");
26
+ const { t } = useI18n();
27
+ expect(t("hikari::localizedInput.chooseLanguage", "Choose editing language")).toBe(
28
+ "选择编辑语言",
29
+ );
30
+ expect(t("hikari::modal.close", "Close")).toBe("关闭");
31
+ });
32
+
33
+ it("other locales serve their own flat translations, not English", async () => {
34
+ await setLocale("ja");
35
+ const { t } = useI18n();
36
+ expect(t("hikari::affixPicker.unset", "Not set")).toBe("未設定");
37
+ await setLocale("ko");
38
+ expect(t("hikari::affixPicker.unset", "Not set")).toBe("미설정");
39
+ });
40
+ });
@@ -3,7 +3,7 @@ import { reactive } from "vue";
3
3
  type Messages = Record<string, string>;
4
4
 
5
5
  interface LocaleModule {
6
- default: Record<string, Messages>;
6
+ default: Record<string, unknown>;
7
7
  }
8
8
 
9
9
  const localeModules = import.meta.glob<LocaleModule>(
@@ -18,9 +18,18 @@ function buildLocaleMessages(locale: string): Messages {
18
18
  for (const [path, mod] of Object.entries(localeModules)) {
19
19
  if (!path.startsWith(prefix)) continue;
20
20
  const domain = mod.default;
21
- for (const [_section, keys] of Object.entries(domain)) {
22
- if (typeof keys === "object" && keys !== null) {
23
- Object.assign(merged, keys);
21
+ for (const [key, value] of Object.entries(domain)) {
22
+ if (typeof value === "string") {
23
+ // Flat leaf — the appended-at-the-bottom style
24
+ // ("hikari::x.y": "…") every post-0.3 locale addition uses
25
+ // (affixPicker.*, messageBox.*, localizedInput.noMatches, …).
26
+ // The old object-only merge SILENTLY DROPPED these, so every
27
+ // flat key fell back to English at runtime no matter the
28
+ // locale (2026-09-08 user report: "Not set" stayed English).
29
+ merged[key] = value;
30
+ } else if (value !== null && typeof value === "object") {
31
+ // Legacy nested domain section ("components", "chat", …).
32
+ Object.assign(merged, value as Messages);
24
33
  }
25
34
  }
26
35
  }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ /**
4
+ * Flat-key parity guard (2026-09-10, follow-up to the flat-loader fix):
5
+ * buildLocaleMessages now serves top-level flat keys ("hikari::x.y": "…")
6
+ * beside the legacy nested sections — but a key missing from a locale's
7
+ * components.json still silently falls back to English for that locale.
8
+ * Every flat key in the en bundle must therefore exist in ALL 11 locale
9
+ * bundles. (This is the guard that would have caught the affixPicker/
10
+ * messageBox flat family missing from 8 locales for months.)
11
+ */
12
+ const modules = import.meta.glob<{ default: Record<string, unknown> }>(
13
+ "./locales/*/components.json",
14
+ { eager: true },
15
+ );
16
+
17
+ const EXPECTED_LOCALES = [
18
+ "ar", "de", "en", "es", "fr", "ja", "ko", "pt", "ru", "zh-Hans", "zh-Hant",
19
+ ];
20
+
21
+ describe("components.json flat-key parity", () => {
22
+ const bundles = Object.entries(modules).map(([path, mod]) => ({
23
+ locale: path.match(/locales\/([^/]+)\/components\.json/)?.[1] ?? "",
24
+ flat: Object.fromEntries(
25
+ Object.entries(mod.default).filter(([, v]) => typeof v === "string"),
26
+ ) as Record<string, string>,
27
+ }));
28
+
29
+ it("covers all 11 locales", () => {
30
+ expect(bundles.map((b) => b.locale).sort()).toEqual(EXPECTED_LOCALES);
31
+ });
32
+
33
+ it("defines every en flat key in every locale, non-empty", () => {
34
+ const en = bundles.find((b) => b.locale === "en");
35
+ expect(en, "en bundle renders").toBeTruthy();
36
+ const keys = Object.keys(en!.flat);
37
+ expect(keys.length, "en has flat keys").toBeGreaterThan(0);
38
+ for (const { locale, flat } of bundles) {
39
+ for (const key of keys) {
40
+ expect(typeof flat[key], `${locale} must define ${key}`).toBe("string");
41
+ expect((flat[key] ?? "").length, `${locale} ${key} must not be empty`).toBeGreaterThan(0);
42
+ }
43
+ }
44
+ });
45
+ });
@@ -182,5 +182,23 @@
182
182
  "hikari::zoomToolbar.reset": "إعادة تعيين التكبير",
183
183
  "hikari::zoomToolbar.zoomIn": "تكبير",
184
184
  "hikari::zoomToolbar.zoomOut": "تصغير"
185
- }
185
+ },
186
+ "hikari::affixPicker.unset": "غير محدد",
187
+ "hikari::affixPicker.title": "الخيارات",
188
+ "hikari::affixPicker.search": "بحث",
189
+ "hikari::affixPicker.empty": "لا تطابقات",
190
+ "hikari::affixPicker.selected": "المحدد",
191
+ "hikari::affixPicker.selectedCount": "تم اختيار {count}",
192
+ "hikari::affixPicker.switchTo": "التبديل إلى",
193
+ "hikari::affixPicker.remove": "إزالة",
194
+ "hikari::affixPicker.useCustom": "استخدام \"{query}\"",
195
+ "hikari::affixPicker.removeConfirmTitle": "إزالة العنصر",
196
+ "hikari::affixPicker.removeConfirm": "إزالة \"{label}\"؟ لا يمكن التراجع عن هذا الإجراء.",
197
+ "hikari::localizedInput.noMatches": "لا توجد لغة مطابقة",
198
+ "hikari::messageBox.confirm": "تأكيد",
199
+ "hikari::messageBox.cancel": "إلغاء",
200
+ "hikari::messageBox.ok": "موافق",
201
+ "hikari::messageBox.alertTitle": "تنبيه",
202
+ "hikari::messageBox.confirmTitle": "يرجى التأكيد",
203
+ "hikari::messageBox.promptTitle": "إدخال"
186
204
  }
@@ -182,5 +182,23 @@
182
182
  "hikari::zoomToolbar.reset": "Zoom zurücksetzen",
183
183
  "hikari::zoomToolbar.zoomIn": "Vergrößern",
184
184
  "hikari::zoomToolbar.zoomOut": "Verkleinern"
185
- }
185
+ },
186
+ "hikari::affixPicker.unset": "Nicht festgelegt",
187
+ "hikari::affixPicker.title": "Optionen",
188
+ "hikari::affixPicker.search": "Suchen",
189
+ "hikari::affixPicker.empty": "Keine Treffer",
190
+ "hikari::affixPicker.selected": "Ausgewählt",
191
+ "hikari::affixPicker.selectedCount": "{count} ausgewählt",
192
+ "hikari::affixPicker.switchTo": "Wechseln zu",
193
+ "hikari::affixPicker.remove": "Entfernen",
194
+ "hikari::affixPicker.useCustom": "„{query}“ verwenden",
195
+ "hikari::affixPicker.removeConfirmTitle": "Eintrag entfernen",
196
+ "hikari::affixPicker.removeConfirm": "„{label}“ entfernen? Dies kann nicht rückgängig gemacht werden.",
197
+ "hikari::localizedInput.noMatches": "Keine passende Sprache",
198
+ "hikari::messageBox.confirm": "Bestätigen",
199
+ "hikari::messageBox.cancel": "Abbrechen",
200
+ "hikari::messageBox.ok": "OK",
201
+ "hikari::messageBox.alertTitle": "Hinweis",
202
+ "hikari::messageBox.confirmTitle": "Bitte bestätigen",
203
+ "hikari::messageBox.promptTitle": "Eingabe"
186
204
  }
@@ -182,5 +182,23 @@
182
182
  "hikari::zoomToolbar.reset": "Restablecer zoom",
183
183
  "hikari::zoomToolbar.zoomIn": "Acercar",
184
184
  "hikari::zoomToolbar.zoomOut": "Alejar"
185
- }
185
+ },
186
+ "hikari::affixPicker.unset": "Sin establecer",
187
+ "hikari::affixPicker.title": "Opciones",
188
+ "hikari::affixPicker.search": "Buscar",
189
+ "hikari::affixPicker.empty": "Sin coincidencias",
190
+ "hikari::affixPicker.selected": "Seleccionados",
191
+ "hikari::affixPicker.selectedCount": "{count} seleccionados",
192
+ "hikari::affixPicker.switchTo": "Cambiar a",
193
+ "hikari::affixPicker.remove": "Quitar",
194
+ "hikari::affixPicker.useCustom": "Usar «{query}»",
195
+ "hikari::affixPicker.removeConfirmTitle": "Quitar elemento",
196
+ "hikari::affixPicker.removeConfirm": "¿Quitar «{label}»? Esto no se puede deshacer.",
197
+ "hikari::localizedInput.noMatches": "Ningún idioma coincidente",
198
+ "hikari::messageBox.confirm": "Confirmar",
199
+ "hikari::messageBox.cancel": "Cancelar",
200
+ "hikari::messageBox.ok": "Aceptar",
201
+ "hikari::messageBox.alertTitle": "Aviso",
202
+ "hikari::messageBox.confirmTitle": "Confirme",
203
+ "hikari::messageBox.promptTitle": "Entrada"
186
204
  }
@@ -182,5 +182,23 @@
182
182
  "hikari::zoomToolbar.reset": "Réinitialiser le zoom",
183
183
  "hikari::zoomToolbar.zoomIn": "Agrandir",
184
184
  "hikari::zoomToolbar.zoomOut": "Réduire"
185
- }
185
+ },
186
+ "hikari::affixPicker.unset": "Non défini",
187
+ "hikari::affixPicker.title": "Options",
188
+ "hikari::affixPicker.search": "Rechercher",
189
+ "hikari::affixPicker.empty": "Aucun résultat",
190
+ "hikari::affixPicker.selected": "Sélectionnés",
191
+ "hikari::affixPicker.selectedCount": "{count} sélectionnés",
192
+ "hikari::affixPicker.switchTo": "Passer à",
193
+ "hikari::affixPicker.remove": "Retirer",
194
+ "hikari::affixPicker.useCustom": "Utiliser « {query} »",
195
+ "hikari::affixPicker.removeConfirmTitle": "Retirer l’entrée",
196
+ "hikari::affixPicker.removeConfirm": "Retirer « {label} » ? Cette action est irréversible.",
197
+ "hikari::localizedInput.noMatches": "Aucune langue correspondante",
198
+ "hikari::messageBox.confirm": "Confirmer",
199
+ "hikari::messageBox.cancel": "Annuler",
200
+ "hikari::messageBox.ok": "OK",
201
+ "hikari::messageBox.alertTitle": "Avis",
202
+ "hikari::messageBox.confirmTitle": "Veuillez confirmer",
203
+ "hikari::messageBox.promptTitle": "Saisie"
186
204
  }
@@ -182,5 +182,23 @@
182
182
  "hikari::zoomToolbar.reset": "ズームをリセット",
183
183
  "hikari::zoomToolbar.zoomIn": "拡大",
184
184
  "hikari::zoomToolbar.zoomOut": "縮小"
185
- }
185
+ },
186
+ "hikari::affixPicker.unset": "未設定",
187
+ "hikari::affixPicker.title": "オプション",
188
+ "hikari::affixPicker.search": "検索",
189
+ "hikari::affixPicker.empty": "一致なし",
190
+ "hikari::affixPicker.selected": "選択中",
191
+ "hikari::affixPicker.selectedCount": "{count} 件選択中",
192
+ "hikari::affixPicker.switchTo": "切り替え",
193
+ "hikari::affixPicker.remove": "削除",
194
+ "hikari::affixPicker.useCustom": "「{query}」を使用",
195
+ "hikari::affixPicker.removeConfirmTitle": "項目を削除",
196
+ "hikari::affixPicker.removeConfirm": "「{label}」を削除しますか?この操作は元に戻せません。",
197
+ "hikari::localizedInput.noMatches": "一致する言語がありません",
198
+ "hikari::messageBox.confirm": "確認",
199
+ "hikari::messageBox.cancel": "キャンセル",
200
+ "hikari::messageBox.ok": "OK",
201
+ "hikari::messageBox.alertTitle": "お知らせ",
202
+ "hikari::messageBox.confirmTitle": "確認してください",
203
+ "hikari::messageBox.promptTitle": "入力"
186
204
  }
@@ -182,5 +182,23 @@
182
182
  "hikari::zoomToolbar.reset": "줌 초기화",
183
183
  "hikari::zoomToolbar.zoomIn": "확대",
184
184
  "hikari::zoomToolbar.zoomOut": "축소"
185
- }
185
+ },
186
+ "hikari::affixPicker.unset": "미설정",
187
+ "hikari::affixPicker.title": "옵션",
188
+ "hikari::affixPicker.search": "검색",
189
+ "hikari::affixPicker.empty": "일치 항목 없음",
190
+ "hikari::affixPicker.selected": "선택됨",
191
+ "hikari::affixPicker.selectedCount": "{count}개 선택됨",
192
+ "hikari::affixPicker.switchTo": "전환",
193
+ "hikari::affixPicker.remove": "제거",
194
+ "hikari::affixPicker.useCustom": "\"{query}\" 사용",
195
+ "hikari::affixPicker.removeConfirmTitle": "항목 제거",
196
+ "hikari::affixPicker.removeConfirm": "\"{label}\"을(를) 제거할까요? 이 작업은 되돌릴 수 없습니다.",
197
+ "hikari::localizedInput.noMatches": "일치하는 언어가 없습니다",
198
+ "hikari::messageBox.confirm": "확인",
199
+ "hikari::messageBox.cancel": "취소",
200
+ "hikari::messageBox.ok": "확인",
201
+ "hikari::messageBox.alertTitle": "알림",
202
+ "hikari::messageBox.confirmTitle": "확인 필요",
203
+ "hikari::messageBox.promptTitle": "입력"
186
204
  }
@@ -182,5 +182,23 @@
182
182
  "hikari::zoomToolbar.reset": "Redefinir zoom",
183
183
  "hikari::zoomToolbar.zoomIn": "Ampliar",
184
184
  "hikari::zoomToolbar.zoomOut": "Reduzir"
185
- }
185
+ },
186
+ "hikari::affixPicker.unset": "Não definido",
187
+ "hikari::affixPicker.title": "Opções",
188
+ "hikari::affixPicker.search": "Pesquisar",
189
+ "hikari::affixPicker.empty": "Sem correspondências",
190
+ "hikari::affixPicker.selected": "Selecionados",
191
+ "hikari::affixPicker.selectedCount": "{count} selecionados",
192
+ "hikari::affixPicker.switchTo": "Mudar para",
193
+ "hikari::affixPicker.remove": "Remover",
194
+ "hikari::affixPicker.useCustom": "Usar \"{query}\"",
195
+ "hikari::affixPicker.removeConfirmTitle": "Remover item",
196
+ "hikari::affixPicker.removeConfirm": "Remover \"{label}\"? Isso não pode ser desfeito.",
197
+ "hikari::localizedInput.noMatches": "Nenhum idioma correspondente",
198
+ "hikari::messageBox.confirm": "Confirmar",
199
+ "hikari::messageBox.cancel": "Cancelar",
200
+ "hikari::messageBox.ok": "OK",
201
+ "hikari::messageBox.alertTitle": "Aviso",
202
+ "hikari::messageBox.confirmTitle": "Confirme",
203
+ "hikari::messageBox.promptTitle": "Entrada"
186
204
  }
@@ -182,5 +182,23 @@
182
182
  "hikari::zoomToolbar.reset": "Сбросить масштаб",
183
183
  "hikari::zoomToolbar.zoomIn": "Увеличить",
184
184
  "hikari::zoomToolbar.zoomOut": "Уменьшить"
185
- }
185
+ },
186
+ "hikari::affixPicker.unset": "Не задано",
187
+ "hikari::affixPicker.title": "Параметры",
188
+ "hikari::affixPicker.search": "Поиск",
189
+ "hikari::affixPicker.empty": "Нет совпадений",
190
+ "hikari::affixPicker.selected": "Выбрано",
191
+ "hikari::affixPicker.selectedCount": "Выбрано: {count}",
192
+ "hikari::affixPicker.switchTo": "Переключиться на",
193
+ "hikari::affixPicker.remove": "Удалить",
194
+ "hikari::affixPicker.useCustom": "Использовать «{query}»",
195
+ "hikari::affixPicker.removeConfirmTitle": "Удалить запись",
196
+ "hikari::affixPicker.removeConfirm": "Удалить «{label}»? Это действие нельзя отменить.",
197
+ "hikari::localizedInput.noMatches": "Нет подходящего языка",
198
+ "hikari::messageBox.confirm": "Подтвердить",
199
+ "hikari::messageBox.cancel": "Отмена",
200
+ "hikari::messageBox.ok": "ОК",
201
+ "hikari::messageBox.alertTitle": "Уведомление",
202
+ "hikari::messageBox.confirmTitle": "Подтвердите",
203
+ "hikari::messageBox.promptTitle": "Ввод"
186
204
  }