@celestia-island/hikari 0.55.32 → 0.55.34

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.32",
3
+ "version": "0.55.34",
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",
@@ -215,11 +215,19 @@ describe("HkBlockingToast", () => {
215
215
  expect(slot).not.toBeNull();
216
216
  const wrapper = slot!.parentElement as HTMLElement;
217
217
  Object.defineProperty(wrapper, "clientWidth", { value: 384, configurable: true });
218
+ Object.defineProperty(wrapper, "getBoundingClientRect", {
219
+ configurable: true,
220
+ value: () => ({ width: 384, height: 0, top: 0, left: 0 }),
221
+ });
218
222
  Object.defineProperty(slot!, "offsetParent", { value: wrapper, configurable: true });
219
223
  Object.defineProperty(slot!, "offsetTop", { value: 0, configurable: true });
220
224
  Object.defineProperty(slot!, "offsetLeft", { value: 0, configurable: true });
221
225
  Object.defineProperty(slot!, "offsetWidth", { value: 384, configurable: true });
222
226
  Object.defineProperty(slot!, "offsetHeight", { value: 120, configurable: true });
227
+ Object.defineProperty(slot!, "getBoundingClientRect", {
228
+ configurable: true,
229
+ value: () => ({ width: 384, height: 120, top: 0, left: 0 }),
230
+ });
223
231
 
224
232
  // Dismiss (not confirm): this case only cares about the leave animation.
225
233
  cancelButton(cards()[0]).click();
@@ -256,6 +264,10 @@ describe("HkBlockingToast", () => {
256
264
  configurable: true,
257
265
  get: () => (reflowed() ? 138 : 384),
258
266
  });
267
+ Object.defineProperty(wrapper, "getBoundingClientRect", {
268
+ configurable: true,
269
+ value: () => ({ width: reflowed() ? 138 : 384, height: 0, top: 0, left: 0 }),
270
+ });
259
271
  const stub = (
260
272
  el: HTMLElement,
261
273
  box: { top: number; left: number; width: number; height: number },
@@ -265,6 +277,15 @@ describe("HkBlockingToast", () => {
265
277
  Object.defineProperty(el, "offsetLeft", { configurable: true, get: () => (reflowed() ? 0 : box.left) });
266
278
  Object.defineProperty(el, "offsetWidth", { configurable: true, get: () => (reflowed() ? 138 : box.width) });
267
279
  Object.defineProperty(el, "offsetHeight", { configurable: true, get: () => box.height });
280
+ Object.defineProperty(el, "getBoundingClientRect", {
281
+ configurable: true,
282
+ value: () => ({
283
+ width: reflowed() ? 138 : box.width,
284
+ height: box.height,
285
+ top: 0,
286
+ left: 0,
287
+ }),
288
+ });
268
289
  };
269
290
  stub(first, { top: 0, left: 0, width: 384, height: 120 });
270
291
  stub(second, { top: 132, left: 0, width: 384, height: 120 });
@@ -118,26 +118,27 @@ export default defineComponent({
118
118
  const handles = new Map<number, PopupHandle>();
119
119
 
120
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.
121
+ // multi-removal guard as HkToast (see the full rationale there,
122
+ // including the getBoundingClientRect choice for sizes: offset*
123
+ // integer rounding would shrink the pinned card by up to half a
124
+ // pixel and rewrap its last character). Snap the right anchor at
125
+ // snapshot time too — the wrapper is already re-fit when the pin
126
+ // runs.
127
127
  const hostRef = ref<{ $el?: Element } | null>(null);
128
128
  const preLeaveBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
129
129
  onBeforeUpdate(() => {
130
130
  const host = hostRef.value?.$el;
131
131
  if (host == null || host.nodeType !== 1) return;
132
- const parentWidth = (host as HTMLElement).clientWidth;
132
+ const hostWidth = (host as HTMLElement).getBoundingClientRect().width;
133
133
  for (const child of Array.from(host.children)) {
134
134
  const e = child as HTMLElement;
135
+ const rect = e.getBoundingClientRect();
135
136
  preLeaveBoxes.set(e, {
136
137
  top: e.offsetTop,
137
138
  left: e.offsetLeft,
138
- width: e.offsetWidth,
139
- height: e.offsetHeight,
140
- right: parentWidth - (e.offsetLeft + e.offsetWidth),
139
+ width: rect.width,
140
+ height: rect.height,
141
+ right: hostWidth - (e.offsetLeft + rect.width),
141
142
  });
142
143
  }
143
144
  });
@@ -252,7 +252,10 @@ describe("HkCrossfade — stylesheet contract", () => {
252
252
 
253
253
  describe("HkCrossfade — wiring contract", () => {
254
254
  const here = dirname(fileURLToPath(import.meta.url));
255
- const src = readFileSync(join(here, "HkCrossfade.tsx"), "utf-8");
255
+ // Windows checkouts (core.autocrlf) hand the working tree CRLF sources;
256
+ // normalize before text-pinning so the contract is EOL-agnostic instead
257
+ // of green-on-CI / red-on-Windows.
258
+ const src = readFileSync(join(here, "HkCrossfade.tsx"), "utf-8").replace(/\r\n?/g, "\n");
256
259
 
257
260
  it("runs the Transition in default (simultaneous) mode and never overrides its duration", () => {
258
261
  expect(src).toContain('<Transition\n name="hk-crossfade"');
@@ -877,6 +877,145 @@ describe("HkMenu sidebar variant", () => {
877
877
  });
878
878
  });
879
879
 
880
+ describe("HkMenu sidebar defaultExpandGroups", () => {
881
+ /** Two INDEPENDENT groups, no active row anywhere — the shape where
882
+ * accordion mode keeps everything closed and table-of-contents mode
883
+ * must open everything. */
884
+ const groupedItems: HkMenuItem[] = [
885
+ { key: "g1", label: "Group One", children: [{ key: "g1-a", label: "One A" }] },
886
+ { key: "g2", label: "Group Two", children: [{ key: "g2-a", label: "Two A" }] },
887
+ ];
888
+
889
+ function mountSidebar(
890
+ items: HkMenuItem[],
891
+ props: Record<string, unknown> = {},
892
+ ): HTMLElement {
893
+ const container = document.createElement("div");
894
+ document.body.appendChild(container);
895
+ containers.push(container);
896
+ const app = createApp({
897
+ render: () => h(HkMenu, { variant: "sidebar", open: true, items, ...props }),
898
+ });
899
+ mounts.push(app);
900
+ app.mount(container);
901
+ return container;
902
+ }
903
+
904
+ const toggleOf = (root: HTMLElement, label: string): HTMLButtonElement =>
905
+ Array.from(root.querySelectorAll(".hk-menu-sidebar-group-toggle")).find(
906
+ (r) => r.textContent?.includes(label),
907
+ ) as HTMLButtonElement;
908
+
909
+ it("keeps the accordion default when the prop is absent (additive no-op)", () => {
910
+ // Pinned at the DOM level: with no prop the group carries no open
911
+ // marker and its children stay unrendered — every existing consumer
912
+ // (drawer account menus, nav sidebars) is untouched.
913
+ const root = mountSidebar(groupedItems);
914
+ expect(root.textContent).not.toContain("One A");
915
+ expect(root.textContent).not.toContain("Two A");
916
+ expect(root.querySelector(".hk-menu-sidebar-group[data-open]")).toBeNull();
917
+ });
918
+
919
+ it("renders every group expanded at mount, active row or not", () => {
920
+ const root = mountSidebar(groupedItems, { defaultExpandGroups: true });
921
+ expect(root.textContent).toContain("One A");
922
+ expect(root.textContent).toContain("Two A");
923
+ expect(root.querySelectorAll(".hk-menu-sidebar-group[data-open]")).toHaveLength(2);
924
+ // The toggle rows' a11y state agrees with the rendered children.
925
+ for (const toggle of root.querySelectorAll(".hk-menu-sidebar-group-toggle")) {
926
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
927
+ }
928
+ });
929
+
930
+ it("a user collapse sticks across navigation — no auto-resurrection", async () => {
931
+ const container = document.createElement("div");
932
+ document.body.appendChild(container);
933
+ containers.push(container);
934
+ const activeKey = ref("none");
935
+ const app = createApp({
936
+ render: () =>
937
+ h(HkMenu, {
938
+ variant: "sidebar",
939
+ open: true,
940
+ items: groupedItems,
941
+ activeKey: activeKey.value,
942
+ defaultExpandGroups: true,
943
+ }),
944
+ });
945
+ mounts.push(app);
946
+ app.mount(container);
947
+ await settle();
948
+
949
+ // First toggle COLLAPSES (open is the default state here).
950
+ toggleOf(container, "Group One").click();
951
+ await settle();
952
+ expect(container.textContent).not.toContain("One A");
953
+ // The sibling keeps its default-open state.
954
+ expect(container.textContent).toContain("Two A");
955
+
956
+ // Navigating INTO the collapsed group must not resurrect it: the
957
+ // user's collapse is the user's word (the auto-expand watch fires on
958
+ // activeKey changes and must stay inert in this mode).
959
+ activeKey.value = "g1-a";
960
+ await settle();
961
+ expect(container.textContent).not.toContain("One A");
962
+
963
+ // Re-expanding works, lands the active row, and stays open.
964
+ toggleOf(container, "Group One").click();
965
+ await settle();
966
+ expect(container.textContent).toContain("One A");
967
+ const active = container.querySelector(".hk-menu-sidebar-row[data-active]");
968
+ expect(active?.textContent).toContain("One A");
969
+ });
970
+
971
+ it("collapsing one group never touches its siblings' default-open state", async () => {
972
+ const root = mountSidebar(groupedItems, { defaultExpandGroups: true });
973
+ await settle();
974
+ toggleOf(root, "Group Two").click();
975
+ await settle();
976
+ expect(root.textContent).toContain("One A");
977
+ expect(root.textContent).not.toContain("Two A");
978
+ expect(toggleOf(root, "Group One").getAttribute("aria-expanded")).toBe("true");
979
+ });
980
+
981
+ it("expands nested groups too, and a parent round-trip restores the still-open inner group", async () => {
982
+ const root = mountSidebar(
983
+ [
984
+ {
985
+ key: "outer",
986
+ label: "Outer",
987
+ children: [
988
+ {
989
+ key: "inner",
990
+ label: "Inner",
991
+ children: [{ key: "leaf", label: "Deep Leaf" }],
992
+ },
993
+ ],
994
+ },
995
+ ],
996
+ { defaultExpandGroups: true },
997
+ );
998
+ await settle();
999
+
1000
+ // Both levels render expanded at mount (depth > 1 included).
1001
+ expect(root.textContent).toContain("Deep Leaf");
1002
+ expect(root.querySelectorAll(".hk-menu-sidebar-group[data-open]")).toHaveLength(2);
1003
+
1004
+ // Collapse the parent: the whole subtree unmounts…
1005
+ toggleOf(root, "Outer").click();
1006
+ await settle();
1007
+ expect(root.textContent).not.toContain("Deep Leaf");
1008
+ expect(root.querySelectorAll(".hk-menu-sidebar-group[data-open]")).toHaveLength(0);
1009
+
1010
+ // …and re-expanding restores the inner group still open — its state
1011
+ // lived in the key sets, so no re-toggle of "Inner" is needed.
1012
+ toggleOf(root, "Outer").click();
1013
+ await settle();
1014
+ expect(root.textContent).toContain("Deep Leaf");
1015
+ expect(root.querySelectorAll(".hk-menu-sidebar-group[data-open]")).toHaveLength(2);
1016
+ });
1017
+ });
1018
+
880
1019
  describe("HkMenu close linger (leave-transition window)", () => {
881
1020
  it("keeps the panel tree mounted briefly after close, then unmounts", async () => {
882
1021
  const openRef = ref(true);
@@ -156,6 +156,16 @@ export default defineComponent({
156
156
  * length, applied on the `dvh`-capable branch, never sanitized.
157
157
  */
158
158
  maxHeight: { type: String, default: undefined },
159
+ /**
160
+ * Sidebar variant only: every collapsible group renders EXPANDED
161
+ * until the user collapses it — nav sidebars that read as a table of
162
+ * contents instead of an accordion. Default false keeps the accordion
163
+ * behavior (only the group holding the active row auto-opens). The
164
+ * user's first toggle on a group still takes over and sticks:
165
+ * expansion is the default, never a resurrection. No-op in the
166
+ * popup variant (sub-levels there are cascades, not inline groups).
167
+ */
168
+ defaultExpandGroups: { type: Boolean, default: false },
159
169
  },
160
170
  emits: ["update:open", "select"],
161
171
  setup(props, { emit, slots }) {
@@ -696,6 +706,10 @@ export default defineComponent({
696
706
  const userCollapsed = ref<Set<string>>(new Set());
697
707
 
698
708
  function isGroupExpanded(key: string): boolean {
709
+ // Table-of-contents mode (defaultExpandGroups): open IS the default
710
+ // state — only an explicit user collapse closes a group, and it
711
+ // stays closed (no auto-resurrection on activeKey/items changes).
712
+ if (props.defaultExpandGroups) return !userCollapsed.value.has(key);
699
713
  if (userGroups.value.has(key)) return true;
700
714
  if (autoGroups.value.has(key)) return !userCollapsed.value.has(key);
701
715
  return false;
@@ -779,8 +793,10 @@ export default defineComponent({
779
793
 
780
794
  function renderSidebarItem(item: HkMenuItem, depth: number) {
781
795
  if (!isGroup(item)) return renderSidebarRow(item, depth);
782
- // Auto-expanded when containing the active row (fresh mount shows
783
- // where you are); the user's first toggle on a group takes over.
796
+ // Accordion mode: auto-expanded when containing the active row
797
+ // (fresh mount shows where you are). defaultExpandGroups mode:
798
+ // everything starts open instead. Either way the user's first
799
+ // toggle on a group takes over and sticks.
784
800
  const expanded = isGroupExpanded(item.key);
785
801
  return (
786
802
  <div key={item.key} class="hk-menu-sidebar-group" data-open={expanded || undefined}>
@@ -16,6 +16,10 @@ import { useToast } from "../runtime/useToast";
16
16
  * same patch pass, so live offset reads in a later sibling's
17
17
  * before-leave hook would teleport it onto the reflowed slot and
18
18
  * squash its bar (HkListTransition's pre-patch snapshot pattern)
19
+ * - the pin reproduces the box EXACTLY (getBoundingClientRect sizes):
20
+ * offsetWidth/Height round to integers, and pinning a round-down
21
+ * integer shrank the box by the sub-pixel gap — enough to push a
22
+ * last character onto a second line the instant the leave started
19
23
  * - the toast is gone after the leave settles
20
24
  *
21
25
  * (Repo test convention: raw createApp + document queries, no
@@ -48,16 +52,30 @@ function stubLayout(el: HTMLElement, over: Partial<{
48
52
  offsetLeft: number;
49
53
  offsetWidth: number;
50
54
  offsetHeight: number;
55
+ /** getBoundingClientRect width — the exact fractional box the pin
56
+ * must reproduce (offsetWidth is its integer rounding). */
57
+ rectWidth: number;
58
+ rectHeight: number;
51
59
  }>): void {
52
60
  // happy-dom has no layout engine; feed the leave hook the geometry a
53
61
  // real browser would read from the laid-out stack.
54
62
  const wrapper = el.parentElement as HTMLElement;
63
+ const rectWidth = over.rectWidth ?? over.offsetWidth ?? 384;
64
+ const rectHeight = over.rectHeight ?? over.offsetHeight ?? 83;
55
65
  Object.defineProperty(wrapper, "clientWidth", { value: 384, configurable: true });
66
+ Object.defineProperty(wrapper, "getBoundingClientRect", {
67
+ configurable: true,
68
+ value: () => ({ width: rectWidth, height: 0, top: 0, left: 0 }),
69
+ });
56
70
  Object.defineProperty(el, "offsetParent", { value: wrapper, configurable: true });
57
71
  Object.defineProperty(el, "offsetTop", { value: over.offsetTop ?? 0, configurable: true });
58
72
  Object.defineProperty(el, "offsetLeft", { value: over.offsetLeft ?? 0, configurable: true });
59
73
  Object.defineProperty(el, "offsetWidth", { value: over.offsetWidth ?? 384, configurable: true });
60
74
  Object.defineProperty(el, "offsetHeight", { value: over.offsetHeight ?? 83, configurable: true });
75
+ Object.defineProperty(el, "getBoundingClientRect", {
76
+ configurable: true,
77
+ value: () => ({ width: rectWidth, height: rectHeight, top: 0, left: 0 }),
78
+ });
61
79
  }
62
80
 
63
81
  afterEach(async () => {
@@ -132,6 +150,10 @@ describe("HkToast", () => {
132
150
  configurable: true,
133
151
  get: () => (reflowed() ? 138 : 384),
134
152
  });
153
+ Object.defineProperty(wrapper, "getBoundingClientRect", {
154
+ configurable: true,
155
+ value: () => ({ width: reflowed() ? 138 : 384, height: 0, top: 0, left: 0 }),
156
+ });
135
157
  const stub = (
136
158
  el: HTMLElement,
137
159
  pre: { top: number; left: number; width: number; height: number },
@@ -145,6 +167,15 @@ describe("HkToast", () => {
145
167
  get: () => (reflowed() ? post[short] : pre[short]),
146
168
  });
147
169
  }
170
+ Object.defineProperty(el, "getBoundingClientRect", {
171
+ configurable: true,
172
+ value: () => ({
173
+ width: reflowed() ? post.width : pre.width,
174
+ height: reflowed() ? post.height : pre.height,
175
+ top: 0,
176
+ left: 0,
177
+ }),
178
+ });
148
179
  };
149
180
  stub(first, { top: 0, left: 0, width: 384, height: 83 }, { top: 0, left: 0, width: 384, height: 83 });
150
181
  stub(second, { top: 95, left: 0, width: 384, height: 48 }, { top: 0, left: 0, width: 138, height: 48 });
@@ -170,4 +201,37 @@ describe("HkToast", () => {
170
201
  await settle();
171
202
  expect(items().length).toBe(0);
172
203
  });
204
+
205
+ it("pins the exact fractional box so the rounding gap cannot rewrap the text", async () => {
206
+ mountHost();
207
+ const toast = useToast();
208
+ toast.error("message whose single line sits within a half pixel of the boundary");
209
+ await settle();
210
+
211
+ const item = items()[0];
212
+ // offsetWidth rounds the real box DOWN (369.40625 → 369): pinning
213
+ // that integer shrank the box by the rounding gap, which pushed the
214
+ // last character to a second line the moment the leave started. The
215
+ // pin must reproduce the exact fractional box instead.
216
+ stubLayout(item, {
217
+ offsetTop: 0,
218
+ offsetLeft: 0,
219
+ offsetWidth: 369,
220
+ offsetHeight: 83,
221
+ rectWidth: 369.40625,
222
+ rectHeight: 83,
223
+ });
224
+
225
+ item.querySelector<HTMLButtonElement>(".hk-toast-close")!.click();
226
+ await nextTick();
227
+
228
+ const leaving = document.querySelector<HTMLElement>(".hk-toast-leave-active");
229
+ expect(leaving).not.toBeNull();
230
+ expect(leaving!.style.width).toBe("369.40625px");
231
+ expect(leaving!.style.right).toBe("0px");
232
+ expect(leaving!.style.height).toBe("83px");
233
+
234
+ await settle();
235
+ expect(items().length).toBe(0);
236
+ });
173
237
  });
@@ -185,20 +185,33 @@ export default defineComponent({
185
185
  // texts read as one squashed multi-line blob. The wrapper itself may
186
186
  // also have re-fit when the pin runs, so the right anchor is snapped
187
187
  // at snapshot time too (see LeaveBoxSnapshot.right).
188
+ //
189
+ // Sizes come from getBoundingClientRect, NOT the offset* family:
190
+ // offsetWidth/Height round to integers, and the pinned box shrinks
191
+ // whenever the real box rounds down — a toast whose text sits within
192
+ // that sub-pixel distance of the wrap boundary loses its last
193
+ // character to the next line the instant the leave pins it. Rect
194
+ // width/height are exact and translation-invariant (enter/move only
195
+ // ever translate these surfaces, and translation does not change a
196
+ // box's size), so the pin reproduces the visible box to the pixel.
197
+ // Positions stay on offsetTop/offsetLeft — the transform-immune
198
+ // layout slot, where the ±0.5px integer error is invisible.
188
199
  const hostRef = ref<{ $el?: Element } | null>(null);
189
200
  const preLeaveBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
190
201
  onBeforeUpdate(() => {
191
202
  const host = hostRef.value?.$el;
192
203
  if (host == null || host.nodeType !== 1) return;
193
- const parentWidth = (host as HTMLElement).clientWidth;
204
+ const hostEl = host as HTMLElement;
205
+ const hostWidth = hostEl.getBoundingClientRect().width;
194
206
  for (const child of Array.from(host.children)) {
195
207
  const e = child as HTMLElement;
208
+ const rect = e.getBoundingClientRect();
196
209
  preLeaveBoxes.set(e, {
197
210
  top: e.offsetTop,
198
211
  left: e.offsetLeft,
199
- width: e.offsetWidth,
200
- height: e.offsetHeight,
201
- right: parentWidth - (e.offsetLeft + e.offsetWidth),
212
+ width: rect.width,
213
+ height: rect.height,
214
+ right: hostWidth - (e.offsetLeft + rect.width),
202
215
  });
203
216
  }
204
217
  });
@@ -24,6 +24,13 @@ import { describe, expect, it } from "vitest";
24
24
  * belong to the HOST-app namespace (registered at runtime via
25
25
  * mergeMessages, or passed in as props.t) and are pinned by their own
26
26
  * suites (timeKeys.test.ts & co.) — out of scope here by construction.
27
+ *
28
+ * Interpolated keys cannot be pinned by literal, so their CONSTRUCTION
29
+ * SITES are scanned wherever the backtick template appears — adjacency to
30
+ * t() is NOT required (F1: HkStatusBar.tsx:233 builds the key into a
31
+ * variable first) — and each discovered family is whitelisted and, when
32
+ * its enum is closed inside hikari, pinned key-by-key (see
33
+ * CONSTRUCTED_FAMILY_KEYS).
27
34
  */
28
35
  const modules = import.meta.glob<{ default: Record<string, unknown> }>(
29
36
  "./locales/*/*.json",
@@ -75,18 +82,23 @@ function walkSources(dir: string, acc: string[] = []): string[] {
75
82
  * `t(` and the key; the character class covers ", ', and ` so a template
76
83
  * literal WITHOUT interpolation is still a pinned literal. */
77
84
  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;
85
+ /** Constructed key families: a template key with `${…}` interpolation can
86
+ * never be resolved statically — they are enumerated in
87
+ * DYNAMIC_KEY_FAMILIES. The regex matches the template construction
88
+ * ANYWHERE in source, NOT only adjacent to `t(`: F1 (verify-hk603,
89
+ * 2026-09-19) — HkStatusBar.tsx builds `hikari::statusBar.tier.${tier}`
90
+ * into a variable and only calls t(tierLabelKey) 135 lines later, so the
91
+ * old adjacency-only regex never saw the family at all. */
92
+ const CONSTRUCTED_KEY_RE = /`hikari::[A-Za-z0-9_.]*\$\{/g;
81
93
 
82
94
  interface Site {
83
95
  key: string;
84
96
  where: string;
85
97
  }
86
98
 
87
- function scanSources(): { statics: Site[]; dynamics: string[] } {
99
+ function scanSources(): { statics: Site[]; constructed: Site[] } {
88
100
  const statics: Site[] = [];
89
- const dynamics: string[] = [];
101
+ const constructed: Site[] = [];
90
102
  for (const path of walkSources(SRC_ROOT)) {
91
103
  const text = readFileSync(path, "utf8");
92
104
  const rel = path.slice(SRC_ROOT.length + 1);
@@ -94,13 +106,14 @@ function scanSources(): { statics: Site[]; dynamics: string[] } {
94
106
  const key = match[0].slice(match[0].indexOf("hikari::"), -1);
95
107
  statics.push({ key, where: `${rel}:${text.slice(0, match.index).split("\n").length}` });
96
108
  }
97
- for (const match of text.matchAll(DYNAMIC_USE_RE)) {
109
+ for (const match of text.matchAll(CONSTRUCTED_KEY_RE)) {
98
110
  // The literal portion before "${" — e.g. "hikari::context." — which
99
111
  // is the family prefix the whitelist is keyed by.
100
- dynamics.push(match[0].slice(match[0].indexOf("hikari::"), -2));
112
+ const family = match[0].slice(1, -2);
113
+ constructed.push({ key: family, where: `${rel}:${text.slice(0, match.index).split("\n").length}` });
101
114
  }
102
115
  }
103
- return { statics, dynamics };
116
+ return { statics, constructed };
104
117
  }
105
118
 
106
119
  /** Interpolated key families that intentionally escape the static scan,
@@ -112,13 +125,25 @@ const DYNAMIC_KEY_FAMILIES: Record<string, string> = {
112
125
  "hikari::context.": "HkContextRing section keys — closed enum, pinned by name in contextKeys.test.ts.",
113
126
  "hikari::statusBar.backend.": "HkConnectionStatus — enumerated connection-state suffixes.",
114
127
  "hikari::statusBar.region.": "HkStatusBar region names — OPTIONAL per-locale overrides over Intl.DisplayNames (see LOCALE_SPECIFIC_NESTED in flatKeyParity.test.ts).",
128
+ "hikari::statusBar.tier.": "HkStatusBar transport tiers — constructed into a variable at a distance from its t() call (F1); the four concrete ids are pinned by name in CONSTRUCTED_FAMILY_KEYS below.",
115
129
  "hikari::theme.groups.": "Registry type: group/slot ids are registered by downstream apps; t() resolves via a resolveLocalizedText fallback, never a hikari bundle.",
116
130
  "hikari::theme.tokens.": "HkColorSchemeEditor token enum — closed set defined in platform.json locales.",
117
131
  };
118
132
 
133
+ /** Concrete suffixes each constructed family resolves to, for families whose
134
+ * enum is closed INSIDE hikari (the whitelist reason records which). Each
135
+ * listed key is asserted to exist, non-empty, in every locale — that is
136
+ * the tooth the adjacency regex lacked: deleting the whole family from
137
+ * every bundle used to leave every audit pin green (F1 blind spot).
138
+ * Families resolved by downstream apps (theme.groups.) or optional
139
+ * per-locale overrides (statusBar.region.) deliberately stay out. */
140
+ const CONSTRUCTED_FAMILY_KEYS: Record<string, string[]> = {
141
+ "hikari::statusBar.tier.": ["local", "poll", "sse", "ws"],
142
+ };
143
+
119
144
  describe("t() usage surface vs locale bundles", () => {
120
145
  const universes = new Map(EXPECTED_LOCALES.map((l) => [l, buildLocaleUniverse(l)]));
121
- const { statics, dynamics } = scanSources();
146
+ const { statics, constructed } = scanSources();
122
147
 
123
148
  it("covers all 11 locales in the merged bundle universe", () => {
124
149
  const found = new Set(
@@ -144,6 +169,11 @@ describe("t() usage surface vs locale bundles", () => {
144
169
  expect(firstSiteOf("hikari::filePicker.currentPath")).toMatch(/HkFileBrowserDialog\.tsx/);
145
170
  // Split-across-lines call form (t( and the key on different lines):
146
171
  expect(firstSiteOf("hikari::phoneInput.searchCountries")).toMatch(/HkPhoneInput\.tsx/);
172
+ // Constructed-key scan positive (F1): the tier template is built into a
173
+ // variable 135 lines away from its t() call — the scan must still see it.
174
+ expect(
175
+ constructed.find((s) => s.key === "hikari::statusBar.tier.")?.where ?? "NOT FOUND",
176
+ ).toMatch(/HkStatusBar\.tsx/);
147
177
  });
148
178
 
149
179
  it("resolves every statically referenced hikari:: key in every locale, non-empty", () => {
@@ -165,13 +195,36 @@ describe("t() usage surface vs locale bundles", () => {
165
195
  expect(violations, "every referenced key must resolve in all 11 locales").toEqual([]);
166
196
  });
167
197
 
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(
198
+ it("enumerates exactly the whitelisted constructed key families, both directions", () => {
199
+ const found = [...new Set(constructed.map((s) => s.key))].sort();
200
+ expect(found, "families constructed in source but missing from the whitelist").toEqual(
171
201
  Object.keys(DYNAMIC_KEY_FAMILIES).sort(),
172
202
  );
173
203
  for (const family of Object.keys(DYNAMIC_KEY_FAMILIES)) {
174
- expect(found, `${family} is whitelisted but no longer used — prune it`).toContain(family);
204
+ expect(found, `${family} is whitelisted but no longer constructed — prune it`).toContain(family);
205
+ }
206
+ });
207
+
208
+ it("resolves every named concrete key of the constructed families in every locale", () => {
209
+ const violations: string[] = [];
210
+ for (const [family, suffixes] of Object.entries(CONSTRUCTED_FAMILY_KEYS)) {
211
+ expect(
212
+ DYNAMIC_KEY_FAMILIES[family],
213
+ `${family} carries named keys but has no whitelist entry`,
214
+ ).toBeDefined();
215
+ for (const suffix of suffixes) {
216
+ const key = family + suffix;
217
+ for (const locale of EXPECTED_LOCALES) {
218
+ const value = universes.get(locale)!.get(key);
219
+ if (typeof value !== "string" || value.length === 0) {
220
+ violations.push(`${key} is missing or empty in ${locale}`);
221
+ }
222
+ }
223
+ }
175
224
  }
225
+ expect(
226
+ violations,
227
+ "constructed families' concrete keys must resolve in all 11 locales",
228
+ ).toEqual([]);
176
229
  });
177
230
  });