@terpjs/react-core 0.11.0 → 0.13.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.
package/README.md CHANGED
@@ -31,9 +31,10 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
31
31
  focus ring, a checkbox's `accent-color`. One token cannot do both: in a dark theme the
32
32
  surface use needs a value dark enough to hold a white label and the ink use needs one light
33
33
  enough to read on a dark canvas, and there is no value satisfying both.
34
- - **User-facing text is `UiText`**every text prop accepts a plain string or an
35
- `{id, message}` descriptor, so apps can localize via `UiTextProvider` without
36
- react-core taking an i18n dependency.
34
+ - **Static user-facing text is cataloged**use an `{id, message}` `UiText`
35
+ descriptor for text props and `<Trans id message />` for JSX bodies. Plain strings are
36
+ reserved for dynamic business data, identifiers and product names. The boundary lint
37
+ refuses bare static copy and checks every id against every target in `frontend/i18n.json`.
37
38
  - **Dependency-free UI** — react-core ships no icon/toast/i18n libraries. Glyphs are
38
39
  inline SVG; transient feedback goes through `ToastProvider` / `useToast`.
39
40
  - **Security defaults** — `dangerouslySetInnerHTML` and the DOM HTML-injection sinks
@@ -54,7 +55,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
54
55
  | `useSso`, `parseSsoCallback`, `fetchSsoAuthorizationUrl`, `completeSsoCallback` | The SSO login seam (ADR 0058): `useSso().begin(provider)` opens an OIDC flow; `TerpProvider` completes the `/auth/callback/{provider}` redirect landing into a normal session on boot. `renderTerpApp({ ssoProviders })` wires the buttons in one line. |
55
56
  | `RequireAuth` | Renders children only with a session; pairs with the router so the app mounts only when signed in. |
56
57
  | `ThemeProvider`, `ThemeToggle`, `useTheme` | Theming over the shipped palettes — `light`, `dark`, `midnight`, `twilight`, `contrast` — plus `system` to follow the OS preference. Applies `data-theme` on `<html>` (the token stylesheet carries every palette) and persists the choice. `defaultTheme` is how an app ships on a named theme — declare it in `layout-contract.json` so a tool can read and rewrite it, or pass the bootstrap option; both is refused. `renderTerpApp` mounts it for every app; the shell header uses an icon-only, token-themed `variant="inline"` menu. |
57
- | `LocaleProvider`, `LanguageSwitcher`, `useLocale`, `LOCALE_EN`, `LOCALE_NL` | The language seam over `UiTextProvider`: per-locale string catalogs, a persisted active locale, and an icon-only, token-themed menu in the shell header once an app declares a second locale. English and Dutch catalogs ship complete; `renderTerpApp({ locales })` wires them. |
58
+ | `LocaleProvider`, `defineAppLocales`, `LanguageSwitcher`, `useLocale`, `LOCALE_EN`, `LOCALE_NL` | The language seam over `UiTextProvider`: `defineAppLocales(i18n, frameworkCatalogs)` validates and merges checked-in app messages with framework chrome, the active locale persists, and the shell offers a picker. Pass `sourceLocale` with `locales`; missing/empty target messages, undocumented source copies, invalid locale selection and a non-English locale without a complete framework-string catalog throw rather than silently falling back. |
58
59
  | `UserMenu`, `userInitials` | The signed-in user's menu, pinned by `buildAppRouter` to the bottom of the sidebar: an initials avatar trigger opening the identity block, **Settings** (the built-in profile page) and sign-out. Collapses to the avatar in the icon rail. |
59
60
  | `ProfileView` | The built-in profile / settings page (`/profile`): the server-validated identity, theme + language preferences, and sign-out. |
60
61
 
@@ -266,7 +267,7 @@ single screen by claiming its path from an app module.
266
267
 
267
268
  | Export | Use |
268
269
  |---|---|
269
- | `UiTextProvider`, `useUiText`, `useStrings`, `resolveUiText`, `DEFAULT_STRINGS` | The `UiText` seam: override built-in strings and plug in a resolver (e.g. an i18n library) at the app root. `LocaleProvider` (above) is the batteries-included layer over it: per-locale catalogs + a persisted switcher. |
270
+ | `UiTextProvider`, `Trans`, `useUiText`, `useStrings`, `resolveUiText`, `DEFAULT_STRINGS` | The `UiText` seam: descriptors for props, `Trans` for body copy, and framework strings through one resolver. `LocaleProvider` is the batteries-included catalog layer and refuses missing target-locale entries. |
270
271
 
271
272
  ## Testing components
272
273
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/react-core",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
6
6
  "exports": {
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@tanstack/react-router": "^1.170.16",
17
- "@terpjs/contract": "^0.11.0"
17
+ "@terpjs/contract": "^0.13.0"
18
18
  },
19
19
  "peerDependencies": {
20
20
  "react": "^19.0.0",
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import type { NavItem } from "@terpjs/contract";
5
5
 
6
6
  import { AppShell, SIDEBAR_STORAGE_KEY } from "./AppShell";
7
+ import { LOCALE_NL, LocaleProvider } from "./locale";
7
8
 
8
9
  afterEach(() => {
9
10
  cleanup();
@@ -360,6 +361,48 @@ describe("AppShell navigation groups", () => {
360
361
  { label: "Loose", to: "/loose" },
361
362
  ];
362
363
 
364
+ it("resolves localized item and group descriptors before rendering navigation", () => {
365
+ render(
366
+ <LocaleProvider
367
+ locales={{
368
+ en: {},
369
+ nl: {
370
+ ...LOCALE_NL,
371
+ messages: {
372
+ "nav.work": "Werkruimte",
373
+ "nav.notes": "Notities",
374
+ },
375
+ },
376
+ }}
377
+ defaultLocale="nl"
378
+ sourceLocale="en"
379
+ >
380
+ <AppShell
381
+ title="Terp"
382
+ nav={[
383
+ {
384
+ label: { id: "nav.notes", message: "Notes" },
385
+ to: "/notes",
386
+ group: "work",
387
+ },
388
+ ]}
389
+ navGroups={[
390
+ {
391
+ id: "work",
392
+ label: { id: "nav.work", message: "Workspace" },
393
+ },
394
+ ]}
395
+ renderLink={(item, children) => <a href={item.to}>{children}</a>}
396
+ >
397
+ <p>page content</p>
398
+ </AppShell>
399
+ </LocaleProvider>,
400
+ );
401
+
402
+ expect(screen.getByRole("list", { name: "Werkruimte" })).toBeInTheDocument();
403
+ expect(screen.getByRole("link", { name: "Notities" })).toHaveAttribute("href", "/notes");
404
+ });
405
+
363
406
  it("labels each group's list with its own visible label", () => {
364
407
  render(
365
408
  <AppShell
package/src/AppShell.tsx CHANGED
@@ -477,11 +477,12 @@ export function AppShell({
477
477
  onClick={isMobile ? closeDrawer : undefined}
478
478
  >
479
479
  {groupNav(nav, navGroups).map((section, index) => {
480
+ const groupLabel = section.label === null ? null : resolve(section.label);
480
481
  // Only a labelled section needs an id, and only a DECLARED section can be labelled — the
481
482
  // default one has no declaration to carry a label. Keyed on the index rather than on
482
483
  // `section.id`: a group id is an app-supplied string, and whitespace in one would
483
484
  // silently break the IDREF rather than fail anywhere.
484
- const labelId = section.label === null ? undefined : `${navGroupId}-${index}`;
485
+ const labelId = groupLabel === null ? undefined : `${navGroupId}-${index}`;
485
486
  return (
486
487
  // No heading element, and this is the decision rather than an oversight. `Heading`
487
488
  // refuses level 1 to reserve it for the routed view's title (see typography.tsx), and
@@ -504,22 +505,25 @@ export function AppShell({
504
505
  <div key={index} data-terp="appshell-nav-group">
505
506
  {labelId !== undefined && (
506
507
  <span id={labelId} data-terp="appshell-nav-group-label">
507
- {section.label}
508
+ {groupLabel}
508
509
  </span>
509
510
  )}
510
511
  <ul data-terp="appshell-nav-list" aria-labelledby={labelId}>
511
- {section.items.map((item) => (
512
- <li key={item.to} title={railCollapsed ? item.label : undefined}>
513
- {renderLink(
514
- item,
515
- <>
516
- <NavIcon name={item.icon} label={item.label} />
517
- <span data-terp="appshell-nav-label">{item.label}</span>
518
- </>,
519
- { collapsed: railCollapsed, active: item.to === currentTo },
520
- )}
521
- </li>
522
- ))}
512
+ {section.items.map((item) => {
513
+ const label = resolve(item.label);
514
+ return (
515
+ <li key={item.to} title={railCollapsed ? label : undefined}>
516
+ {renderLink(
517
+ item,
518
+ <>
519
+ <NavIcon name={item.icon} label={label} />
520
+ <span data-terp="appshell-nav-label">{label}</span>
521
+ </>,
522
+ { collapsed: railCollapsed, active: item.to === currentTo },
523
+ )}
524
+ </li>
525
+ );
526
+ })}
523
527
  </ul>
524
528
  </div>
525
529
  );
@@ -3,8 +3,8 @@ import type { ReactNode } from "react";
3
3
 
4
4
  import { Button } from "./ui/Button";
5
5
  import { injectTerpStyles } from "./styles";
6
- import { useStrings, useUiText } from "./uiText";
7
- import type { UiText } from "./uiText";
6
+ import { resolveUiTextNode, useStrings, useUiText } from "./uiText";
7
+ import type { UiText, UiTextNode } from "./uiText";
8
8
 
9
9
  injectTerpStyles();
10
10
 
@@ -18,7 +18,7 @@ export interface ConfirmDialogProps {
18
18
  /** Short question — what is about to happen. */
19
19
  title: UiText;
20
20
  /** Optional consequence explanation. */
21
- description?: ReactNode;
21
+ description?: UiTextNode;
22
22
  /** Confirm-button label; defaults to the `confirm` string. */
23
23
  confirmLabel?: UiText;
24
24
  /** Cancel-button label; defaults to the `cancel` string. */
@@ -118,7 +118,7 @@ export function ConfirmDialog({
118
118
  </h2>
119
119
  {description !== undefined && (
120
120
  <div id={descriptionId} data-terp="dialog-description">
121
- {description}
121
+ {resolveUiTextNode(description, resolve)}
122
122
  </div>
123
123
  )}
124
124
  <div data-terp="dialog-actions">
@@ -2,8 +2,8 @@ import type { ReactNode } from "react";
2
2
 
3
3
  import { Icon } from "./icons";
4
4
  import { injectTerpStyles } from "./styles";
5
- import { useUiText } from "./uiText";
6
- import type { UiText } from "./uiText";
5
+ import { resolveUiTextNode, useUiText } from "./uiText";
6
+ import type { UiText, UiTextNode } from "./uiText";
7
7
 
8
8
  injectTerpStyles();
9
9
 
@@ -16,7 +16,7 @@ export interface EmptyStateProps {
16
16
  /** Short title — what is missing. */
17
17
  title: UiText;
18
18
  /** Optional explanation — why it's missing, or what to do next. */
19
- description?: ReactNode;
19
+ description?: UiTextNode;
20
20
  /** Optional call to action (typically a `Button`). */
21
21
  action?: ReactNode;
22
22
  /**
@@ -58,9 +58,12 @@ export function EmptyState({
58
58
  <div data-terp="empty-state" data-size={compact ? "compact" : undefined}>
59
59
  {leading}
60
60
  <p data-terp="empty-state-title">{resolve(title)}</p>
61
- {description !== undefined && <div data-terp="empty-state-description">{description}</div>}
61
+ {description !== undefined && (
62
+ <div data-terp="empty-state-description">
63
+ {resolveUiTextNode(description, resolve)}
64
+ </div>
65
+ )}
62
66
  {action}
63
67
  </div>
64
68
  );
65
69
  }
66
-
@@ -3,8 +3,8 @@ import type { ReactNode } from "react";
3
3
  import { useErrorMessage } from "./errorMessages";
4
4
  import { Icon } from "./icons";
5
5
  import { injectTerpStyles } from "./styles";
6
- import { useStrings, useUiText } from "./uiText";
7
- import type { UiText } from "./uiText";
6
+ import { resolveUiTextNode, useStrings, useUiText } from "./uiText";
7
+ import type { UiText, UiTextNode } from "./uiText";
8
8
 
9
9
  injectTerpStyles();
10
10
 
@@ -43,7 +43,7 @@ export interface ErrorStateProps {
43
43
  * copy for the error's stable `code` (see `useErrorMessage`), falling back to
44
44
  * {@link describeError}, so the platform error envelope surfaces consistently.
45
45
  */
46
- description?: ReactNode;
46
+ description?: UiTextNode;
47
47
  /** The caught failure — used to derive `description` when none is given. */
48
48
  error?: unknown;
49
49
  /** Optional call to action (typically a retry `Button`). */
@@ -73,7 +73,11 @@ export function ErrorState({ icon, title, description, error, action }: ErrorSta
73
73
  <div role="alert" data-terp="error-state">
74
74
  {leading}
75
75
  <p data-terp="error-state-title">{resolve(title ?? strings.errorTitle)}</p>
76
- {message !== null && message !== undefined && <div data-terp="error-state-description">{message}</div>}
76
+ {message !== null && message !== undefined && (
77
+ <div data-terp="error-state-description">
78
+ {resolveUiTextNode(message, resolve)}
79
+ </div>
80
+ )}
77
81
  {action}
78
82
  </div>
79
83
  );
@@ -3,6 +3,7 @@ import { cleanup, render, screen } from "@testing-library/react";
3
3
  import { afterEach, describe, expect, it } from "vitest";
4
4
 
5
5
  import { Field } from "./Field";
6
+ import { UiTextProvider } from "./uiText";
6
7
  import { Input } from "./ui/Input";
7
8
  import { Select } from "./ui/Select";
8
9
  import { Textarea } from "./ui/Textarea";
@@ -10,6 +11,25 @@ import { Textarea } from "./ui/Textarea";
10
11
  afterEach(cleanup);
11
12
 
12
13
  describe("Field", () => {
14
+ it("resolves a descriptor used as helper text", () => {
15
+ render(
16
+ <UiTextProvider
17
+ resolveText={(text) =>
18
+ typeof text === "string" ? text : `translated:${text.id}`
19
+ }
20
+ >
21
+ <Field
22
+ label="Email"
23
+ hint={{ id: "account.email.hint", message: "We never share it" }}
24
+ >
25
+ <Input />
26
+ </Field>
27
+ </UiTextProvider>,
28
+ );
29
+
30
+ expect(screen.getByText("translated:account.email.hint")).toBeInTheDocument();
31
+ });
32
+
13
33
  it("labels its control (accessible association) and shows hint + error", () => {
14
34
  render(
15
35
  <Field label="Email" hint="we never share it" error="required">
package/src/Field.tsx CHANGED
@@ -15,7 +15,7 @@ export interface FieldProps {
15
15
  /** A field-level error (e.g. mapped from a 422), shown under the control. */
16
16
  error?: string | null;
17
17
  /** Optional helper text under the control. */
18
- hint?: string;
18
+ hint?: UiText;
19
19
  }
20
20
 
21
21
  /**
@@ -90,7 +90,7 @@ export function Field({ label, children, error, hint }: FieldProps) {
90
90
  </label>
91
91
  {hint !== undefined && (
92
92
  <span id={hintId} data-terp="field-hint">
93
- {hint}
93
+ {resolve(hint)}
94
94
  </span>
95
95
  )}
96
96
  {hasError && (
package/src/LoginView.tsx CHANGED
@@ -3,7 +3,7 @@ import type { FormEvent } from "react";
3
3
 
4
4
  import { TerpMark } from "./icons";
5
5
  import { useAuth, useSso } from "./TerpProvider";
6
- import { useStrings } from "./uiText";
6
+ import { useStrings, useUiText } from "./uiText";
7
7
  import { Button } from "./ui/Button";
8
8
  import { Field } from "./Field";
9
9
  import { Input } from "./ui/Input";
@@ -53,6 +53,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
53
53
  const auth = useAuth();
54
54
  const sso = useSso();
55
55
  const strings = useStrings();
56
+ const resolve = useUiText();
56
57
  const [email, setEmail] = useState("");
57
58
  const [password, setPassword] = useState("");
58
59
  const [error, setError] = useState<string | null>(null);
@@ -158,7 +159,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
158
159
  disabled={busy}
159
160
  onClick={() => void onSso(provider)}
160
161
  >
161
- {`${strings.continueWith} ${provider.label ?? provider.name}`}
162
+ {`${strings.continueWith} ${resolve(provider.label ?? provider.name)}`}
162
163
  </Button>
163
164
  ))}
164
165
  </div>
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  import { TerpProvider, useAuth } from "./TerpProvider";
7
7
  import { UserMenu, userInitials } from "./UserMenu";
8
- import { LOCALE_EN, LocaleProvider } from "./locale";
8
+ import { LOCALE_EN, LOCALE_NL, LocaleProvider } from "./locale";
9
9
  import { ThemeProvider } from "./theme";
10
10
 
11
11
  function jsonResponse(body: unknown): Response {
@@ -73,7 +73,7 @@ describe("UserMenu", () => {
73
73
  stubAuthFetch();
74
74
  render(
75
75
  <ThemeProvider>
76
- <LocaleProvider locales={{ en: LOCALE_EN, nl: { label: "Nederlands" } }}>
76
+ <LocaleProvider locales={{ en: LOCALE_EN, nl: LOCALE_NL }}>
77
77
  <TerpProvider baseUrl="https://api.test">
78
78
  <LogInOnMount />
79
79
  <UserMenu />
package/src/bootstrap.tsx CHANGED
@@ -2,7 +2,7 @@ import { RouterProvider } from "@tanstack/react-router";
2
2
  import { StrictMode } from "react";
3
3
  import type { ComponentType, ReactNode } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import type { ModuleManifest, NavGroup } from "@terpjs/contract";
5
+ import type { ModuleManifest, NavGroup, UiText } from "@terpjs/contract";
6
6
 
7
7
  import { LoginView } from "./LoginView";
8
8
  import type { DevCredentials } from "./LoginView";
@@ -95,7 +95,7 @@ export function collectModules(modules: Record<string, unknown>): {
95
95
 
96
96
  export interface RenderTerpAppOptions {
97
97
  /** App title shown in the shell's sidebar brand (and the default footer). */
98
- title: string;
98
+ title: UiText;
99
99
  /** Discovered modules from an import.meta.glob over "./modules/<name>/module.tsx" (eager). */
100
100
  modules: Record<string, unknown>;
101
101
  /** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
@@ -187,6 +187,8 @@ export interface RenderTerpAppOptions {
187
187
  locales?: Record<string, LocaleCatalog>;
188
188
  /** Starting locale when the user has not chosen one; default: the first `locales` key. */
189
189
  defaultLocale?: string;
190
+ /** Source locale for app-authored UiText descriptors; default: the first locale key. */
191
+ sourceLocale?: string;
190
192
  /**
191
193
  * Starting theme when the user has not chosen one; default `"system"` (OS preference).
192
194
  *
@@ -362,6 +364,7 @@ export function renderTerpApp(options: RenderTerpAppOptions): void {
362
364
  <LocaleProvider
363
365
  locales={options.locales ?? { en: {} }}
364
366
  defaultLocale={options.defaultLocale}
367
+ sourceLocale={options.sourceLocale}
365
368
  >
366
369
  <TerpProvider baseUrl={options.baseUrl ?? ""} ssoCallbackPath={options.ssoCallbackPath}>
367
370
  <ToastProvider>
@@ -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. */
@@ -151,6 +283,8 @@ export interface LocaleProviderProps {
151
283
  locales: Record<string, LocaleCatalog>;
152
284
  /** Starting locale when the user has not chosen one; default: the first key. */
153
285
  defaultLocale?: string;
286
+ /** Locale whose descriptor `message` is the authored fallback; default: the first key. */
287
+ sourceLocale?: string;
154
288
  children: ReactNode;
155
289
  }
156
290
 
@@ -160,11 +294,20 @@ export interface LocaleProviderProps {
160
294
  * `UiText` context — so every react-core component (and every `UiText` prop) follows the
161
295
  * switch with no per-component wiring. Adding a language to an app is one catalog entry.
162
296
  */
163
- 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);
164
305
  const codes = Object.keys(locales);
165
- const fallback = defaultLocale !== undefined && codes.includes(defaultLocale)
166
- ? defaultLocale
167
- : 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];
168
311
  const [locale, setLocaleState] = useState<string>(() => {
169
312
  try {
170
313
  const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
@@ -173,6 +316,7 @@ export function LocaleProvider({ locales, defaultLocale, children }: LocaleProvi
173
316
  return fallback ?? "en";
174
317
  }
175
318
  });
319
+ const activeLocale = codes.includes(locale) ? locale : fallback;
176
320
 
177
321
  const setLocale = useCallback(
178
322
  (next: string) => {
@@ -191,17 +335,49 @@ export function LocaleProvider({ locales, defaultLocale, children }: LocaleProvi
191
335
 
192
336
  const value = useMemo<LocaleContextValue>(
193
337
  () => ({
194
- locale,
338
+ locale: activeLocale,
195
339
  locales: codes,
196
340
  labelOf: (code) => locales[code]?.label ?? code,
197
341
  setLocale,
198
342
  }),
199
- [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],
200
374
  );
201
375
 
202
376
  return (
203
377
  <LocaleContext.Provider value={value}>
204
- <UiTextProvider strings={locales[locale]?.strings}>{children}</UiTextProvider>
378
+ <UiTextProvider strings={locales[activeLocale]?.strings} resolveText={resolveText}>
379
+ {children}
380
+ </UiTextProvider>
205
381
  </LocaleContext.Provider>
206
382
  );
207
383
  }
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
  /**
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}`. */
@@ -63,6 +63,38 @@ afterEach(() => {
63
63
  });
64
64
 
65
65
  describe("injectTerpStyles", () => {
66
+ it("adopts a constructable stylesheet when the document supports one", () => {
67
+ // jsdom exposes the CSSStyleSheet constructor but not document.adoptedStyleSheets,
68
+ // so the property is defined here to reach the branch a real browser takes. That
69
+ // branch is the whole point of the injector: an adopted sheet is not governed by
70
+ // CSP's style-src, so a generated app needs no 'unsafe-inline' — measured in
71
+ // Chromium, where a <style> element is reported as style-src-elem and dropped.
72
+ const sheets: CSSStyleSheet[] = [];
73
+ Object.defineProperty(document, "adoptedStyleSheets", {
74
+ configurable: true,
75
+ get: () => sheets,
76
+ set: (next: CSSStyleSheet[]) => {
77
+ sheets.length = 0;
78
+ sheets.push(...next);
79
+ },
80
+ });
81
+ try {
82
+ injectTerpStyles();
83
+ expect(sheets.length).toBe(1);
84
+ // No element: taking the adopted route must not also append a <style>, or the
85
+ // page would carry the rules twice and still need the CSP keyword.
86
+ expect(document.getElementById(TERP_STYLES_ID)).toBeNull();
87
+ expect(sheets[0]?.cssRules.length ?? 0).toBeGreaterThan(0);
88
+
89
+ // Idempotent by the sheet's own mark rather than by an element id.
90
+ injectTerpStyles();
91
+ injectTerpStyles();
92
+ expect(sheets.length).toBe(1);
93
+ } finally {
94
+ delete (document as unknown as { adoptedStyleSheets?: unknown }).adoptedStyleSheets;
95
+ }
96
+ });
97
+
66
98
  it("appends the stylesheet once and is idempotent on re-invocation", () => {
67
99
  injectTerpStyles();
68
100
  injectTerpStyles();
package/src/styles.ts CHANGED
@@ -4228,13 +4228,50 @@ button[data-terp="input"][data-placeholder="true"] {
4228
4228
  }
4229
4229
  `;
4230
4230
 
4231
+ /**
4232
+ * The property stamped on the constructed sheet so a second call recognises it.
4233
+ *
4234
+ * On the sheet object rather than on an element or a `data-` attribute: the sheet
4235
+ * lives on the `document`, so the mark is document-scoped exactly like the old
4236
+ * element-id check was, and it cannot collide with the `data-terp*` selectors
4237
+ * this very stylesheet declares.
4238
+ */
4239
+ const ADOPTED_MARKER = "__terpStylesId";
4240
+
4241
+ /** A constructed sheet plus the mark identifying it as ours. */
4242
+ type MarkedSheet = CSSStyleSheet & { [ADOPTED_MARKER]?: string };
4243
+
4244
+ /**
4245
+ * `document.adoptedStyleSheets` is absent in jsdom, so the property is read
4246
+ * through a type that admits that. Cast via `unknown` rather than intersected
4247
+ * with `Document`, because the DOM lib declares the property as always present
4248
+ * and an intersection would keep that stricter declaration.
4249
+ */
4250
+ type AdoptableDocument = { adoptedStyleSheets?: MarkedSheet[] };
4251
+
4231
4252
  /**
4232
4253
  * Inject the react-core interaction-state stylesheet once per document.
4233
4254
  *
4234
- * SSR-safe: no-op when `document` is undefined. Idempotent: the sheet element
4235
- * is keyed by {@link TERP_STYLES_ID}, so repeated calls (from any component's
4236
- * module scope) attach the rules exactly once. Content is set via
4237
- * `textContent` never `innerHTML` so no HTML-injection sink is touched.
4255
+ * Prefers a **constructable stylesheet** (`new CSSStyleSheet()` +
4256
+ * `document.adoptedStyleSheets`), because that is the only injection route a
4257
+ * Content-Security-Policy does not have to widen for. A `<style>` element's
4258
+ * rules are inline styles as far as CSP is concerned, so shipping them obliged
4259
+ * every generated app to serve `style-src 'unsafe-inline'` — a keyword that,
4260
+ * once present, also permits every *other* inline stylesheet on the page,
4261
+ * including one an injection managed to introduce. Measured in Chromium: an
4262
+ * adopted sheet applies cleanly under `style-src 'self'` while a `<style>`
4263
+ * element is reported as a `style-src-elem` violation and its rules dropped.
4264
+ *
4265
+ * The `<style>` element remains the fallback, because a browser without
4266
+ * constructable stylesheets would otherwise render the chrome unstyled. Under a
4267
+ * strict policy those browsers get no styling either way, so the fallback only
4268
+ * ever helps.
4269
+ *
4270
+ * SSR-safe: no-op when `document` is undefined. Idempotent by either route — the
4271
+ * adopted sheet carries {@link ADOPTED_MARKER}, the element is keyed by
4272
+ * {@link TERP_STYLES_ID} — so repeated calls from any component's module scope
4273
+ * attach the rules exactly once. Neither route touches an HTML sink: the element
4274
+ * path sets `textContent`, never `innerHTML`, and `replaceSync` parses CSS only.
4238
4275
  */
4239
4276
  export function injectTerpStyles(): void {
4240
4277
  if (typeof document === "undefined") {
@@ -4243,6 +4280,23 @@ export function injectTerpStyles(): void {
4243
4280
  if (document.getElementById(TERP_STYLES_ID) !== null) {
4244
4281
  return;
4245
4282
  }
4283
+
4284
+ const adopted = (document as unknown as AdoptableDocument).adoptedStyleSheets;
4285
+ if (adopted !== undefined && typeof CSSStyleSheet === "function") {
4286
+ if (adopted.some((sheet) => sheet[ADOPTED_MARKER] === TERP_STYLES_ID)) {
4287
+ return;
4288
+ }
4289
+ try {
4290
+ const sheet: MarkedSheet = new CSSStyleSheet();
4291
+ sheet.replaceSync(TERP_STYLES_CSS);
4292
+ sheet[ADOPTED_MARKER] = TERP_STYLES_ID;
4293
+ (document as unknown as AdoptableDocument).adoptedStyleSheets = [...adopted, sheet];
4294
+ return;
4295
+ } catch {
4296
+ // A browser that exposes the API but refuses this sheet still gets styling.
4297
+ }
4298
+ }
4299
+
4246
4300
  const el = document.createElement("style");
4247
4301
  el.id = TERP_STYLES_ID;
4248
4302
  el.textContent = TERP_STYLES_CSS;
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
5
5
  import { Page } from "./Page";
6
6
  import { ResourceList } from "./ResourceList";
7
7
  import { TerpProvider } from "./TerpProvider";
8
- import { resolveUiText, UiTextProvider } from "./uiText";
8
+ import { resolveUiText, resolveUiTextNode, UiTextProvider } from "./uiText";
9
9
 
10
10
  afterEach(cleanup);
11
11
 
@@ -14,6 +14,20 @@ describe("resolveUiText", () => {
14
14
  expect(resolveUiText("Tasks")).toBe("Tasks");
15
15
  expect(resolveUiText({ id: "tasks.title", message: "Tasks" })).toBe("Tasks");
16
16
  });
17
+
18
+ it("resolves descriptors in prose slots and preserves rich React nodes", () => {
19
+ const resolve = (text: string | { readonly id: string; readonly message: string }) =>
20
+ typeof text === "string" ? text : `translated:${text.id}`;
21
+ expect(
22
+ resolveUiTextNode(
23
+ { id: "tasks.empty.description", message: "Create your first task." },
24
+ resolve,
25
+ ),
26
+ ).toBe("translated:tasks.empty.description");
27
+
28
+ const rich = <strong>Already rendered</strong>;
29
+ expect(resolveUiTextNode(rich, resolve)).toBe(rich);
30
+ });
17
31
  });
18
32
 
19
33
  describe("UiTextProvider", () => {
package/src/uiText.tsx CHANGED
@@ -1,5 +1,8 @@
1
1
  import { createContext, useCallback, useContext, useMemo } from "react";
2
2
  import type { ReactNode } from "react";
3
+ import type { UiText } from "@terpjs/contract";
4
+
5
+ export type { UiText } from "@terpjs/contract";
3
6
 
4
7
  /**
5
8
  * A piece of user-facing text: either a plain string (used as-is) or a message
@@ -7,11 +10,38 @@ import type { ReactNode } from "react";
7
10
  * `message` used as the fallback. Components accept `UiText` so an app can go
8
11
  * from hardcoded strings to a full i18n runtime without changing call sites.
9
12
  */
10
- export type UiText = string | { id: string; message: string };
11
-
12
13
  /** Resolves a {@link UiText} to the display string for the active locale. */
13
14
  export type ResolveUiText = (text: UiText) => string;
14
15
 
16
+ /** Textual copy or an already-rendered rich node, for prose-bearing component slots. */
17
+ export type UiTextNode = UiText | ReactNode;
18
+
19
+ /**
20
+ * Resolve a descriptor/string while leaving rich React content untouched. Components
21
+ * with prose slots use this instead of making callers choose between localization and
22
+ * inline emphasis/links.
23
+ */
24
+ export function resolveUiTextNode(
25
+ value: UiTextNode,
26
+ resolve: ResolveUiText = resolveUiText,
27
+ ): ReactNode {
28
+ if (typeof value === "string") {
29
+ return resolve(value);
30
+ }
31
+ if (
32
+ typeof value === "object" &&
33
+ value !== null &&
34
+ !Array.isArray(value) &&
35
+ "id" in value &&
36
+ "message" in value &&
37
+ typeof value.id === "string" &&
38
+ typeof value.message === "string"
39
+ ) {
40
+ return resolve(value);
41
+ }
42
+ return value as ReactNode;
43
+ }
44
+
15
45
  /** The default resolver: plain strings as-is, descriptors via their fallback `message`. */
16
46
  export function resolveUiText(text: UiText): string {
17
47
  return typeof text === "string" ? text : text.message;
@@ -385,3 +415,15 @@ export function useUiText(): ResolveUiText {
385
415
  const { resolveText } = useContext(UiTextContext);
386
416
  return useCallback((text: UiText) => resolveText(text), [resolveText]);
387
417
  }
418
+
419
+ /** Props for {@link Trans}: one stable catalog id and its source-language fallback. */
420
+ export interface TransProps {
421
+ id: string;
422
+ message: string;
423
+ }
424
+
425
+ /** Render authored copy through the active locale resolver, including plain JSX body text. */
426
+ export function Trans({ id, message }: TransProps) {
427
+ const resolve = useUiText();
428
+ return <>{resolve({ id, message })}</>;
429
+ }