@terpjs/react-core 0.10.0 → 0.12.0

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.
@@ -13,7 +13,7 @@ import {
13
13
  useFormatDate,
14
14
  useFormatNumber,
15
15
  } from "./format";
16
- import { LocaleProvider } from "./locale";
16
+ import { LOCALE_EN, LOCALE_NL, LocaleProvider } from "./locale";
17
17
 
18
18
  afterEach(() => {
19
19
  cleanup();
@@ -27,8 +27,8 @@ afterEach(() => {
27
27
  // version asserted the literal digit 7 and would have failed in Kiritimati and nowhere else.
28
28
  const WHEN = "2026-07-07T12:00:00Z";
29
29
 
30
- const NL = { label: "Nederlands", strings: {} };
31
- const EN = { label: "English", strings: {} };
30
+ const NL = LOCALE_NL;
31
+ const EN = LOCALE_EN;
32
32
 
33
33
  describe("the locale-explicit formatters", () => {
34
34
  it("actually varies with the locale it is given", () => {
package/src/index.ts CHANGED
@@ -87,8 +87,23 @@ export type {
87
87
  } from "./SplitPage";
88
88
  export { HubPage, HubCard } from "./HubPage";
89
89
  export type { HubPageProps, HubCardProps, RenderHubCardLink } from "./HubPage";
90
- export { UiTextProvider, useStrings, useUiText, resolveUiText, DEFAULT_STRINGS } from "./uiText";
91
- export type { UiText, ResolveUiText, TerpStrings, UiTextProviderProps } from "./uiText";
90
+ export {
91
+ UiTextProvider,
92
+ Trans,
93
+ useStrings,
94
+ useUiText,
95
+ resolveUiText,
96
+ resolveUiTextNode,
97
+ DEFAULT_STRINGS,
98
+ } from "./uiText";
99
+ export type {
100
+ UiText,
101
+ UiTextNode,
102
+ ResolveUiText,
103
+ TerpStrings,
104
+ TransProps,
105
+ UiTextProviderProps,
106
+ } from "./uiText";
92
107
  export { EmptyState } from "./EmptyState";
93
108
  export type { EmptyStateProps } from "./EmptyState";
94
109
  export { ErrorState, describeError } from "./ErrorState";
@@ -216,13 +231,19 @@ export { ThemeProvider, ThemeToggle, useTheme, THEME_STORAGE_KEY } from "./theme
216
231
  export type { Theme, ThemeProviderProps, ThemeToggleProps } from "./theme";
217
232
  export {
218
233
  LocaleProvider,
234
+ defineAppLocales,
219
235
  LanguageSwitcher,
220
236
  useLocale,
221
237
  LOCALE_EN,
222
238
  LOCALE_NL,
223
239
  LOCALE_STORAGE_KEY,
224
240
  } from "./locale";
225
- export type { LocaleCatalog, LocaleProviderProps, LanguageSwitcherProps } from "./locale";
241
+ export type {
242
+ AppI18nDeclaration,
243
+ LocaleCatalog,
244
+ LocaleProviderProps,
245
+ LanguageSwitcherProps,
246
+ } from "./locale";
226
247
  export { UserMenu, userInitials } from "./UserMenu";
227
248
  export type { UserMenuProps } from "./UserMenu";
228
249
 
@@ -2,8 +2,15 @@
2
2
  import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
3
  import { afterEach, describe, expect, it } from "vitest";
4
4
 
5
- import { LOCALE_EN, LOCALE_NL, LOCALE_STORAGE_KEY, LanguageSwitcher, LocaleProvider } from "./locale";
6
- import { DEFAULT_STRINGS, useStrings } from "./uiText";
5
+ import {
6
+ LOCALE_EN,
7
+ LOCALE_NL,
8
+ LOCALE_STORAGE_KEY,
9
+ LanguageSwitcher,
10
+ LocaleProvider,
11
+ defineAppLocales,
12
+ } from "./locale";
13
+ import { DEFAULT_STRINGS, Trans, useStrings } from "./uiText";
7
14
 
8
15
  afterEach(() => {
9
16
  cleanup();
@@ -14,7 +21,7 @@ function SignOutLabel() {
14
21
  return <p>{useStrings().signOut}</p>;
15
22
  }
16
23
 
17
- const NL = { label: "Nederlands", strings: { signOut: "Uitloggen", language: "Taal" } };
24
+ const NL = LOCALE_NL;
18
25
 
19
26
  describe("LocaleProvider + LanguageSwitcher", () => {
20
27
  it("feeds the active catalog's overrides through the UiText seam", () => {
@@ -84,6 +91,191 @@ describe("LocaleProvider + LanguageSwitcher", () => {
84
91
  // No visible label text in the inline variant.
85
92
  expect(screen.queryByText("Language")).not.toBeInTheDocument();
86
93
  });
94
+
95
+ it("resolves app descriptors through the active locale catalog", () => {
96
+ render(
97
+ <LocaleProvider
98
+ locales={{ en: LOCALE_EN, nl: { ...NL, messages: { greeting: "Hallo" } } }}
99
+ defaultLocale="nl"
100
+ sourceLocale="en"
101
+ >
102
+ <Trans id="greeting" message="Hello" />
103
+ </LocaleProvider>,
104
+ );
105
+ expect(screen.getByText("Hallo")).toBeInTheDocument();
106
+ });
107
+
108
+ it("refuses a missing target translation instead of silently using source copy", () => {
109
+ expect(() =>
110
+ render(
111
+ <LocaleProvider locales={{ en: LOCALE_EN, nl: NL }} defaultLocale="nl" sourceLocale="en">
112
+ <Trans id="greeting" message="Hello" />
113
+ </LocaleProvider>,
114
+ ),
115
+ ).toThrow(/Missing translation "greeting" for locale "nl"/);
116
+ });
117
+
118
+ it("refuses a copied source translation unless allowIdentical documents it", () => {
119
+ expect(() =>
120
+ render(
121
+ <LocaleProvider
122
+ locales={{ en: {}, nl: { ...LOCALE_NL, messages: { greeting: "Hello" } } }}
123
+ defaultLocale="nl"
124
+ sourceLocale="en"
125
+ >
126
+ <Trans id="greeting" message="Hello" />
127
+ </LocaleProvider>,
128
+ ),
129
+ ).toThrow(/copies its source text/);
130
+
131
+ render(
132
+ <LocaleProvider
133
+ locales={{
134
+ en: {},
135
+ nl: {
136
+ ...LOCALE_NL,
137
+ messages: { greeting: "Hello" },
138
+ allowIdentical: ["greeting"],
139
+ },
140
+ }}
141
+ defaultLocale="nl"
142
+ sourceLocale="en"
143
+ >
144
+ <Trans id="greeting" message="Hello" />
145
+ </LocaleProvider>,
146
+ );
147
+ expect(screen.getByText("Hello")).toBeInTheDocument();
148
+ });
149
+
150
+ it("refuses malformed locale configuration and descriptors", () => {
151
+ expect(() =>
152
+ render(
153
+ <LocaleProvider locales={{ en: {} }} sourceLocale="nl">
154
+ <span />
155
+ </LocaleProvider>,
156
+ ),
157
+ ).toThrow(/Source locale "nl" is not present/);
158
+ expect(() =>
159
+ render(
160
+ <LocaleProvider locales={{ en: {} }} defaultLocale="nl">
161
+ <span />
162
+ </LocaleProvider>,
163
+ ),
164
+ ).toThrow(/Default locale "nl" is not present/);
165
+ expect(() =>
166
+ render(
167
+ <LocaleProvider locales={{ en: {} }}>
168
+ <Trans id="" message="Hello" />
169
+ </LocaleProvider>,
170
+ ),
171
+ ).toThrow(/non-empty id and message/);
172
+ });
173
+
174
+ it("merges checked-in app messages with framework catalogs", () => {
175
+ expect(
176
+ defineAppLocales(
177
+ { sourceLocale: "nl", locales: { nl: {}, en: { messages: { greeting: "Hello" } } } },
178
+ { en: LOCALE_EN, nl: LOCALE_NL },
179
+ ).en.messages,
180
+ ).toEqual({ greeting: "Hello" });
181
+ });
182
+
183
+ it("validates the checked-in declaration before merging it", () => {
184
+ expect(() =>
185
+ defineAppLocales({ sourceLocale: "nl", locales: { en: {} } }),
186
+ ).toThrow(/Source locale "nl" is not present/);
187
+ expect(() =>
188
+ defineAppLocales({
189
+ sourceLocale: "nl",
190
+ locales: { nl: {}, en: { messages: { greeting: "" } } },
191
+ }),
192
+ ).toThrow(/empty or invalid message entry/);
193
+ expect(() =>
194
+ defineAppLocales(
195
+ { sourceLocale: "en", locales: { en: {} } },
196
+ { en: { strings: [] as never } },
197
+ ),
198
+ ).toThrow(/framework strings must be an object/);
199
+ expect(() =>
200
+ defineAppLocales(
201
+ { sourceLocale: "en", locales: { en: {} } },
202
+ "invalid" as never,
203
+ ),
204
+ ).toThrow(/Framework locale catalogs must be an object/);
205
+ });
206
+
207
+ it("refuses incomplete framework catalogs through LocaleProvider itself", () => {
208
+ expect(() =>
209
+ render(
210
+ <LocaleProvider locales={{ en: LOCALE_EN, de: { messages: { greeting: "Hallo" } } }}>
211
+ <span />
212
+ </LocaleProvider>,
213
+ ),
214
+ ).toThrow(/missing .* framework string translation/);
215
+ });
216
+
217
+ it("refuses malformed labels and supplied framework strings", () => {
218
+ expect(() =>
219
+ render(
220
+ <LocaleProvider locales={{ en: { label: "" } }}>
221
+ <span />
222
+ </LocaleProvider>,
223
+ ),
224
+ ).toThrow(/label must be a non-empty string/);
225
+ expect(() =>
226
+ render(
227
+ <LocaleProvider
228
+ locales={{ en: { strings: { ...LOCALE_NL.strings, signOut: "" } } }}
229
+ >
230
+ <span />
231
+ </LocaleProvider>,
232
+ ),
233
+ ).toThrow(/empty or invalid framework string "signOut"/);
234
+ expect(() =>
235
+ render(
236
+ <LocaleProvider locales={{ en: { strings: "invalid" as never } }}>
237
+ <span />
238
+ </LocaleProvider>,
239
+ ),
240
+ ).toThrow(/framework strings must be an object/);
241
+ });
242
+
243
+ it("refuses a target locale whose app copy is translated but framework chrome is not", () => {
244
+ expect(() =>
245
+ defineAppLocales(
246
+ {
247
+ sourceLocale: "en",
248
+ locales: { en: {}, de: { messages: { greeting: "Hallo" } } },
249
+ },
250
+ { en: LOCALE_EN },
251
+ ),
252
+ ).toThrow(/missing .* framework string translation/);
253
+
254
+ const germanStrings = Object.fromEntries(
255
+ Object.keys(DEFAULT_STRINGS).map((key) => [key, `de:${key}`]),
256
+ );
257
+ expect(
258
+ defineAppLocales(
259
+ {
260
+ sourceLocale: "en",
261
+ locales: { en: {}, de: { messages: { greeting: "Hallo" } } },
262
+ },
263
+ { en: LOCALE_EN, de: { strings: germanStrings } },
264
+ ).de.messages,
265
+ ).toEqual({ greeting: "Hallo" });
266
+ });
267
+
268
+ it("always renders the descriptor fallback in the source locale", () => {
269
+ render(
270
+ <LocaleProvider
271
+ locales={{ en: { messages: { greeting: "stale catalog value" } } }}
272
+ sourceLocale="en"
273
+ >
274
+ <Trans id="greeting" message="Hello" />
275
+ </LocaleProvider>,
276
+ );
277
+ expect(screen.getByText("Hello")).toBeInTheDocument();
278
+ });
87
279
  });
88
280
 
89
281
  describe("LOCALE_NL", () => {
package/src/locale.tsx CHANGED
@@ -1,21 +1,153 @@
1
1
  import { createContext, useCallback, useContext, useMemo, useState } from "react";
2
2
  import type { ReactNode } from "react";
3
+ import type { UiText } from "@terpjs/contract";
3
4
 
4
5
  import { Icon } from "./icons";
5
6
  import { Menu, MenuItem } from "./ui/Menu";
6
- import { UiTextProvider, useStrings } from "./uiText";
7
+ import { DEFAULT_STRINGS, UiTextProvider, useStrings } from "./uiText";
7
8
  import type { TerpStrings } from "./uiText";
8
9
 
9
10
  /**
10
- * One locale's catalog: per-key overrides of the framework strings (missing keys fall
11
- * back to the bundled English defaults) plus an optional native display name for
12
- * language pickers. `{}` is a valid catalog English needs no overrides.
11
+ * One locale's catalog: framework strings, app messages, and an optional native display
12
+ * name for language pickers. English locales may omit `strings` because react-core's
13
+ * bundled defaults are English; every declared non-English locale must supply the complete
14
+ * `TerpStrings` set so framework chrome cannot silently fall back to English.
13
15
  */
14
16
  export interface LocaleCatalog {
15
17
  /** Native display name shown by {@link LanguageSwitcher} (default: the locale code). */
16
18
  label?: string;
17
19
  /** Framework-string overrides for this locale. */
18
20
  strings?: Partial<TerpStrings>;
21
+ /** App-authored messages, keyed by the stable id carried by a `UiText` descriptor. */
22
+ messages?: Record<string, string>;
23
+ /** Message ids intentionally identical to their source copy (catalog-gate documentation). */
24
+ allowIdentical?: readonly string[];
25
+ }
26
+
27
+ /** Checked-in, JSON-compatible app declaration consumed by {@link defineAppLocales}. */
28
+ export interface AppI18nDeclaration {
29
+ sourceLocale: string;
30
+ locales: Record<string, LocaleCatalog>;
31
+ }
32
+
33
+ function isRecord(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+
37
+ function assertLocaleCatalogs(
38
+ locales: unknown,
39
+ sourceLocale?: string,
40
+ ): asserts locales is Record<string, LocaleCatalog> {
41
+ if (!isRecord(locales) || Object.keys(locales).length === 0) {
42
+ throw new Error("Locale catalogs must declare at least one locale.");
43
+ }
44
+ if (
45
+ sourceLocale !== undefined &&
46
+ (sourceLocale.trim() === "" || !Object.hasOwn(locales, sourceLocale))
47
+ ) {
48
+ throw new Error(`Source locale "${sourceLocale}" is not present in the locale catalogs.`);
49
+ }
50
+ for (const [code, value] of Object.entries(locales)) {
51
+ if (code.trim() === "" || !isRecord(value)) {
52
+ throw new Error("Locale entries must be non-empty codes mapped to catalog objects.");
53
+ }
54
+ if (
55
+ value.label !== undefined &&
56
+ (typeof value.label !== "string" || value.label.trim() === "")
57
+ ) {
58
+ throw new Error(`Locale "${code}" label must be a non-empty string.`);
59
+ }
60
+ if (value.strings !== undefined && !isRecord(value.strings)) {
61
+ throw new Error(`Locale "${code}" framework strings must be an object.`);
62
+ }
63
+ for (const [key, translated] of Object.entries(value.strings ?? {})) {
64
+ if (!Object.hasOwn(DEFAULT_STRINGS, key)) {
65
+ throw new Error(`Locale "${code}" has unknown framework string "${key}".`);
66
+ }
67
+ if (typeof translated !== "string" || translated.trim() === "") {
68
+ throw new Error(`Locale "${code}" has an empty or invalid framework string "${key}".`);
69
+ }
70
+ }
71
+ const messages = value.messages;
72
+ if (messages !== undefined && !isRecord(messages)) {
73
+ throw new Error(`Locale "${code}" messages must be an object.`);
74
+ }
75
+ for (const [id, translated] of Object.entries(messages ?? {})) {
76
+ if (id.trim() === "" || typeof translated !== "string" || translated.trim() === "") {
77
+ throw new Error(`Locale "${code}" has an empty or invalid message entry.`);
78
+ }
79
+ }
80
+ const allowed = value.allowIdentical;
81
+ if (
82
+ allowed !== undefined &&
83
+ (!Array.isArray(allowed) ||
84
+ allowed.some((id) => typeof id !== "string" || id.trim() === ""))
85
+ ) {
86
+ throw new Error(`Locale "${code}" allowIdentical must be an array of non-empty ids.`);
87
+ }
88
+ if (allowed !== undefined && new Set(allowed).size !== allowed.length) {
89
+ throw new Error(`Locale "${code}" allowIdentical contains duplicate ids.`);
90
+ }
91
+ const stale = allowed?.find((id) => typeof messages?.[id] !== "string");
92
+ if (stale !== undefined) {
93
+ throw new Error(`Locale "${code}" allowIdentical names missing message "${stale}".`);
94
+ }
95
+ }
96
+ }
97
+
98
+ function assertFrameworkStringsComplete(locales: Record<string, LocaleCatalog>): void {
99
+ for (const [code, catalog] of Object.entries(locales)) {
100
+ if (code.split("-")[0].toLowerCase() === "en") continue;
101
+ const missing = Object.keys(DEFAULT_STRINGS).filter(
102
+ (key) =>
103
+ typeof catalog.strings?.[key as keyof TerpStrings] !== "string" ||
104
+ catalog.strings[key as keyof TerpStrings]?.trim() === "",
105
+ );
106
+ if (missing.length > 0) {
107
+ throw new Error(
108
+ `Locale "${code}" is missing ${missing.length} framework string translation(s) ` +
109
+ `(for example: ${missing.slice(0, 3).join(", ")}). ` +
110
+ "Pass a complete framework catalog; app messages alone do not translate the shell.",
111
+ );
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Merge app message catalogs with react-core's framework-string catalogs. This keeps one
118
+ * checked-in `i18n.json` authoritative for app copy without duplicating LOCALE_EN/LOCALE_NL.
119
+ */
120
+ export function defineAppLocales(
121
+ declaration: AppI18nDeclaration,
122
+ frameworkLocales: Record<string, LocaleCatalog> = {},
123
+ ): Record<string, LocaleCatalog> {
124
+ if (!isRecord(declaration) || typeof declaration.sourceLocale !== "string") {
125
+ throw new Error("frontend/i18n.json must declare sourceLocale and a locales map.");
126
+ }
127
+ assertLocaleCatalogs(declaration.locales, declaration.sourceLocale);
128
+ if (!isRecord(frameworkLocales)) {
129
+ throw new Error("Framework locale catalogs must be an object.");
130
+ }
131
+ if (Object.keys(frameworkLocales).length > 0) {
132
+ assertLocaleCatalogs(frameworkLocales);
133
+ }
134
+ const merged = Object.fromEntries(
135
+ Object.entries(declaration.locales).map(([code, app]) => {
136
+ const framework = frameworkLocales[code] ?? {};
137
+ return [
138
+ code,
139
+ {
140
+ ...framework,
141
+ ...app,
142
+ strings: { ...framework.strings, ...app.strings },
143
+ messages: { ...framework.messages, ...app.messages },
144
+ },
145
+ ];
146
+ }),
147
+ );
148
+ assertLocaleCatalogs(merged, declaration.sourceLocale);
149
+ assertFrameworkStringsComplete(merged);
150
+ return merged;
19
151
  }
20
152
 
21
153
  /** The built-in English catalog — the bundled defaults, no overrides needed. */
@@ -29,6 +161,15 @@ export const LOCALE_EN: LocaleCatalog = { label: "English" };
29
161
  export const LOCALE_NL: LocaleCatalog = {
30
162
  label: "Nederlands",
31
163
  strings: {
164
+ clearSelection: "Selectie wissen",
165
+ clearAllSelections: "Alle selecties wissen",
166
+ comboboxRemove: "Verwijderen",
167
+ comboboxLoading: "Laden…",
168
+ comboboxNoOptions: "Geen opties",
169
+ previousMonth: "Vorige maand",
170
+ selectDate: "Kies een datum",
171
+ selectDateRange: "Kies een periode",
172
+ nextMonth: "Volgende maand",
32
173
  loading: "Laden...",
33
174
  emptyList: "Nog niets te zien.",
34
175
  add: "Toevoegen",
@@ -142,6 +283,8 @@ export interface LocaleProviderProps {
142
283
  locales: Record<string, LocaleCatalog>;
143
284
  /** Starting locale when the user has not chosen one; default: the first key. */
144
285
  defaultLocale?: string;
286
+ /** Locale whose descriptor `message` is the authored fallback; default: the first key. */
287
+ sourceLocale?: string;
145
288
  children: ReactNode;
146
289
  }
147
290
 
@@ -151,11 +294,20 @@ export interface LocaleProviderProps {
151
294
  * `UiText` context — so every react-core component (and every `UiText` prop) follows the
152
295
  * switch with no per-component wiring. Adding a language to an app is one catalog entry.
153
296
  */
154
- export function LocaleProvider({ locales, defaultLocale, children }: LocaleProviderProps) {
297
+ export function LocaleProvider({
298
+ locales,
299
+ defaultLocale,
300
+ sourceLocale,
301
+ children,
302
+ }: LocaleProviderProps) {
303
+ assertLocaleCatalogs(locales, sourceLocale);
304
+ assertFrameworkStringsComplete(locales);
155
305
  const codes = Object.keys(locales);
156
- const fallback = defaultLocale !== undefined && codes.includes(defaultLocale)
157
- ? defaultLocale
158
- : codes[0];
306
+ if (defaultLocale !== undefined && !codes.includes(defaultLocale)) {
307
+ throw new Error(`Default locale "${defaultLocale}" is not present in the locale catalogs.`);
308
+ }
309
+ const resolvedSourceLocale = sourceLocale ?? codes[0];
310
+ const fallback = defaultLocale ?? codes[0];
159
311
  const [locale, setLocaleState] = useState<string>(() => {
160
312
  try {
161
313
  const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
@@ -164,6 +316,7 @@ export function LocaleProvider({ locales, defaultLocale, children }: LocaleProvi
164
316
  return fallback ?? "en";
165
317
  }
166
318
  });
319
+ const activeLocale = codes.includes(locale) ? locale : fallback;
167
320
 
168
321
  const setLocale = useCallback(
169
322
  (next: string) => {
@@ -182,17 +335,49 @@ export function LocaleProvider({ locales, defaultLocale, children }: LocaleProvi
182
335
 
183
336
  const value = useMemo<LocaleContextValue>(
184
337
  () => ({
185
- locale,
338
+ locale: activeLocale,
186
339
  locales: codes,
187
340
  labelOf: (code) => locales[code]?.label ?? code,
188
341
  setLocale,
189
342
  }),
190
- [locale, codes.join("\u0000"), setLocale, locales],
343
+ [activeLocale, codes.join("\u0000"), setLocale, locales],
344
+ );
345
+
346
+ const resolveText = useCallback(
347
+ (text: UiText): string => {
348
+ if (typeof text === "string") {
349
+ return text;
350
+ }
351
+ if (text.id.trim() === "" || text.message.trim() === "") {
352
+ throw new Error("UiText descriptors require non-empty id and message values.");
353
+ }
354
+ if (activeLocale === resolvedSourceLocale) {
355
+ return text.message;
356
+ }
357
+ const catalog = locales[activeLocale];
358
+ const translated = catalog?.messages?.[text.id];
359
+ if (typeof translated === "string" && translated.trim() !== "") {
360
+ if (translated === text.message && !catalog.allowIdentical?.includes(text.id)) {
361
+ throw new Error(
362
+ `Translation "${text.id}" for locale "${activeLocale}" copies its source text. ` +
363
+ "Translate it or document an intentional proper noun/acronym in allowIdentical.",
364
+ );
365
+ }
366
+ return translated;
367
+ }
368
+ throw new Error(
369
+ `Missing translation "${text.id}" for locale "${activeLocale}". ` +
370
+ "Add it to frontend/i18n.json and run the frontend lint gate.",
371
+ );
372
+ },
373
+ [activeLocale, locales, resolvedSourceLocale],
191
374
  );
192
375
 
193
376
  return (
194
377
  <LocaleContext.Provider value={value}>
195
- <UiTextProvider strings={locales[locale]?.strings}>{children}</UiTextProvider>
378
+ <UiTextProvider strings={locales[activeLocale]?.strings} resolveText={resolveText}>
379
+ {children}
380
+ </UiTextProvider>
196
381
  </LocaleContext.Provider>
197
382
  );
198
383
  }
@@ -91,6 +91,8 @@ const MARKERS = [
91
91
  "combobox-field",
92
92
  "combobox-list",
93
93
  "combobox-option",
94
+ "combobox-token",
95
+ "combobox-token-remove",
94
96
  "control-label",
95
97
  "dataview",
96
98
  "dataview-actions-cell",
package/src/nav.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ModuleManifest, NavGroup, NavItem } from "@terpjs/contract";
1
+ import type { ModuleManifest, NavGroup, NavItem, UiText } from "@terpjs/contract";
2
2
 
3
3
  /**
4
4
  * What a manifest's declared visibility is resolved against.
@@ -81,7 +81,7 @@ export interface NavSection {
81
81
  /** The declared group's id, or `null` for the default headerless group. */
82
82
  id: string | null;
83
83
  /** The label to render above the list, or `null` when the section renders none. */
84
- label: string | null;
84
+ label: UiText | null;
85
85
  items: NavItem[];
86
86
  }
87
87
 
package/src/router.tsx CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  } from "@tanstack/react-router";
15
15
  import type { ComponentType, ReactNode } from "react";
16
16
  import { useCallback, useEffect, useRef, useState } from "react";
17
- import type { ModuleManifest, NavGroup } from "@terpjs/contract";
17
+ import type { ModuleManifest, NavGroup, UiText } from "@terpjs/contract";
18
18
 
19
19
  import { AppShell } from "./AppShell";
20
20
  import { ProfileView } from "./ProfileView";
@@ -238,7 +238,7 @@ export interface BuildAppRouterOptions {
238
238
  /** Maps a manifest route's `view` id to the component that renders it. */
239
239
  views: Record<string, ComponentType>;
240
240
  /** App title shown in the shell's sidebar brand. */
241
- title: string;
241
+ title: UiText;
242
242
  /** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
243
243
  logo?: ReactNode;
244
244
  /**
@@ -461,8 +461,18 @@ export function buildAppRouter(
461
461
  // render it through useNavLink), so an unstable identity is worse than a re-render: it
462
462
  // remounts every in-app link in the tree on each navigation.
463
463
  const renderNavLink = useCallback<NavLinkRenderer>(
464
+ // `activeOptions={{ exact: true }}` for the same reason the shell's `renderLink`
465
+ // below carries it, and it matters MORE here: this renderer is what `Breadcrumbs`
466
+ // and `HubCard` use, so its job is rendering ANCESTORS. Under the router's default
467
+ // prefix matching every ancestor link matched the URL and was marked active, which
468
+ // on any detail route emitted a second `aria-current="page"` — one on the crumb for
469
+ // `/definitions` and one on the current crumb — plus a stray `.active` class and
470
+ // `data-status="active"` that made an ancestor look like the current page. The fix
471
+ // was applied to the nav and missed on the component whose whole purpose is the
472
+ // trail; exact matching means a crumb is only ever current when it IS the URL, and
473
+ // the current crumb is a span rather than a link.
464
474
  ({ to, children, attributes }) => (
465
- <Link to={to} {...attributes}>
475
+ <Link to={to} activeOptions={{ exact: true }} {...attributes}>
466
476
  {children}
467
477
  </Link>
468
478
  ),
package/src/sso.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { TerpClient, TerpClientFor } from "@terpjs/contract";
1
+ import type { TerpClient, TerpClientFor, UiText } from "@terpjs/contract";
2
2
 
3
3
  import { unwrap } from "./unwrap";
4
4
 
@@ -61,7 +61,7 @@ export interface SsoProvider {
61
61
  /** Provider name as mounted on the backend (the `{provider}` path segment). */
62
62
  name: string;
63
63
  /** Display label for the provider button (defaults to the name). */
64
- label?: string;
64
+ label?: UiText;
65
65
  }
66
66
 
67
67
  /** Default SPA path prefix the IdP redirects back to: `/auth/callback/{provider}`. */