@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.
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
 
@@ -70,7 +71,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
70
71
  ## Page archetypes (the three-level screen pattern)
71
72
 
72
73
  Every routed view **must** render one of the archetypes (`Page`, or `OverviewPage` /
73
- `DetailPage` / `HubPage`, which compose it) — `buildAppRouter` refuses an unframed view at
74
+ `DetailPage` / `HubPage` / `FormPage` / `SettingsPage` / `SplitPage`, which compose it) — `buildAppRouter` refuses an unframed view at
74
75
  runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/error frame.
75
76
 
76
77
  | Export | Use |
@@ -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.10.0",
3
+ "version": "0.12.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.10.0"
17
+ "@terpjs/contract": "^0.12.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();
@@ -43,15 +44,20 @@ function stubMobileViewport() {
43
44
  }
44
45
 
45
46
  describe("AppShell", () => {
46
- it("renders the landmarks, brand, footer, and the nav via renderLink", () => {
47
+ it("renders the landmarks, brand, and the nav via renderLink — and NO footer", () => {
47
48
  renderShell();
48
49
 
49
50
  expect(screen.getByRole("banner")).toBeInTheDocument();
50
51
  expect(screen.getByRole("navigation", { name: "Primary" })).toBeInTheDocument();
51
52
  expect(screen.getByRole("main")).toBeInTheDocument();
52
- expect(screen.getByRole("contentinfo")).toBeInTheDocument();
53
- // The brand is the standard home affordance; the default footer echoes the title.
54
- expect(screen.getAllByText("Terp").length).toBeGreaterThanOrEqual(2);
53
+ // No `contentinfo` unless the app asks for one. The default used to be a strip
54
+ // restating the app title already in the header and the browser tab on every screen
55
+ // of every app, costing vertical space on exactly the viewports with least of it. An
56
+ // empty landmark is worse than none: it is somewhere a screen-reader user can navigate
57
+ // to and find nothing.
58
+ expect(screen.queryByRole("contentinfo")).toBeNull();
59
+ // The brand is the standard home affordance.
60
+ expect(screen.getAllByText("Terp").length).toBeGreaterThanOrEqual(1);
55
61
  expect(screen.getByRole("link", { name: "Terp" })).toHaveAttribute("href", "/");
56
62
  expect(screen.getByRole("link", { name: "Notes" })).toHaveAttribute("href", "/notes");
57
63
  expect(screen.getByText("page content")).toBeInTheDocument();
@@ -355,6 +361,48 @@ describe("AppShell navigation groups", () => {
355
361
  { label: "Loose", to: "/loose" },
356
362
  ];
357
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
+
358
406
  it("labels each group's list with its own visible label", () => {
359
407
  render(
360
408
  <AppShell
package/src/AppShell.tsx CHANGED
@@ -125,7 +125,11 @@ interface AppShellBaseProps {
125
125
  density?: "comfortable" | "compact";
126
126
  /** Pinned to the bottom of the sidebar (the {@link UserMenu}); may read the rail state. */
127
127
  navFooter?: ReactNode | ((context: AppShellSlotContext) => ReactNode);
128
- /** Footer line under the content; default: a muted line with the app title. */
128
+ /**
129
+ * Footer content under the routed view. **Omit it and no footer renders** — there is no
130
+ * default, because the default was a strip restating the app title already in the header
131
+ * and the browser tab, on every screen of every app, and nobody chose it.
132
+ */
129
133
  footer?: ReactNode;
130
134
  /**
131
135
  * The current URL path, so the shell can decide which nav item is current.
@@ -298,7 +302,7 @@ function PanelIcon() {
298
302
  * nav and the same user menu render in the header, and the drawer still handles mobile;
299
303
  * - a **sticky** header over the content: the sidebar toggle on the left, then
300
304
  * `headerActions` and the standard theme + language controls on the right;
301
- * - the routed `children` in a `main` landmark, with a slim `footer` underneath.
305
+ * - the routed `children` in a `main` landmark, with an optional `footer` underneath.
302
306
  *
303
307
  * Router-agnostic: `renderLink` wraps the shell-styled icon + label in the active
304
308
  * stack's link. Landmarks (`header` / `nav` / `main` / `footer`) keep it accessible.
@@ -473,11 +477,12 @@ export function AppShell({
473
477
  onClick={isMobile ? closeDrawer : undefined}
474
478
  >
475
479
  {groupNav(nav, navGroups).map((section, index) => {
480
+ const groupLabel = section.label === null ? null : resolve(section.label);
476
481
  // Only a labelled section needs an id, and only a DECLARED section can be labelled — the
477
482
  // default one has no declaration to carry a label. Keyed on the index rather than on
478
483
  // `section.id`: a group id is an app-supplied string, and whitespace in one would
479
484
  // silently break the IDREF rather than fail anywhere.
480
- const labelId = section.label === null ? undefined : `${navGroupId}-${index}`;
485
+ const labelId = groupLabel === null ? undefined : `${navGroupId}-${index}`;
481
486
  return (
482
487
  // No heading element, and this is the decision rather than an oversight. `Heading`
483
488
  // refuses level 1 to reserve it for the routed view's title (see typography.tsx), and
@@ -500,22 +505,25 @@ export function AppShell({
500
505
  <div key={index} data-terp="appshell-nav-group">
501
506
  {labelId !== undefined && (
502
507
  <span id={labelId} data-terp="appshell-nav-group-label">
503
- {section.label}
508
+ {groupLabel}
504
509
  </span>
505
510
  )}
506
511
  <ul data-terp="appshell-nav-list" aria-labelledby={labelId}>
507
- {section.items.map((item) => (
508
- <li key={item.to} title={railCollapsed ? item.label : undefined}>
509
- {renderLink(
510
- item,
511
- <>
512
- <NavIcon name={item.icon} label={item.label} />
513
- <span data-terp="appshell-nav-label">{item.label}</span>
514
- </>,
515
- { collapsed: railCollapsed, active: item.to === currentTo },
516
- )}
517
- </li>
518
- ))}
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
+ })}
519
527
  </ul>
520
528
  </div>
521
529
  );
@@ -688,7 +696,17 @@ export function AppShell({
688
696
  <main id={mainId} data-terp="appshell-main" tabIndex={-1}>
689
697
  {children}
690
698
  </main>
691
- <footer data-terp="appshell-footer">{footer ?? <small>{resolvedTitle}</small>}</footer>
699
+ {/* Rendered only when the app asks for one. It used to default to the app's own
700
+ title, which meant every screen in every app carried a footer restating the
701
+ name already in the header and the browser tab — a permanent strip of chrome
702
+ nobody chose, costing vertical space on exactly the small viewports that have
703
+ least of it. `footer` is now the switch: pass content to get a footer, pass
704
+ nothing to get none. The landmark goes with it, which is correct — an empty
705
+ `contentinfo` is a landmark a screen-reader user can navigate to and find
706
+ nothing in. */}
707
+ {footer !== undefined && (
708
+ <footer data-terp="appshell-footer">{footer}</footer>
709
+ )}
692
710
  </div>
693
711
  </div>
694
712
  );
@@ -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">
@@ -0,0 +1,30 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { EmptyState } from "./EmptyState";
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe("EmptyState size", () => {
10
+ it("stamps no attribute at the default size", () => {
11
+ // The full-page block's geometry IS the base rule, so the default matches no
12
+ // attribute selector — the same shape Button's sizes and the shell's density take.
13
+ render(<EmptyState title="Nothing yet" />);
14
+ expect(screen.getByText("Nothing yet").closest("[data-terp='empty-state']")).not.toHaveAttribute(
15
+ "data-size",
16
+ );
17
+ });
18
+
19
+ it("stamps compact, and keeps the frame and the wording", () => {
20
+ // Two default blocks stacked on one screen were 480px of chrome repeating a sentence:
21
+ // the emptiness of one section is not the page's headline. Compact takes the space back
22
+ // without changing what the block says or that it is recognisably an empty state.
23
+ render(
24
+ <EmptyState size="compact" title="No connections" description="Add one to begin." />,
25
+ );
26
+ const block = screen.getByText("No connections").closest("[data-terp='empty-state']");
27
+ expect(block).toHaveAttribute("data-size", "compact");
28
+ expect(screen.getByText("Add one to begin.")).toBeInTheDocument();
29
+ });
30
+ });
@@ -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,9 +16,20 @@ 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
+ /**
23
+ * `"compact"` for an empty block that is not the whole screen.
24
+ *
25
+ * The default is sized to be the only thing on a page — generous padding, a 2rem
26
+ * glyph, centred. That is right for an empty list and wrong the moment a screen has two
27
+ * of them: stacked, they were 480px of chrome repeating a sentence, and the emptiness of
28
+ * one section is not the page's headline. Compact keeps the frame and the wording and
29
+ * takes back the space — tighter padding, a smaller glyph, left-aligned, because a
30
+ * block that is one item among several reads as a row rather than a poster.
31
+ */
32
+ size?: "default" | "compact";
22
33
  }
23
34
 
24
35
  /**
@@ -27,20 +38,32 @@ export interface EmptyStateProps {
27
38
  * UX platform-wide tells the user "this is not an error — there is just nothing to show",
28
39
  * and the `action` slot turns the dead end into the obvious next step.
29
40
  */
30
- export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
41
+ export function EmptyState({
42
+ icon,
43
+ title,
44
+ description,
45
+ action,
46
+ size = "default",
47
+ }: EmptyStateProps) {
31
48
  const resolve = useUiText();
49
+ const compact = size === "compact";
32
50
  const leading = icon ?? (
33
51
  <span data-terp="empty-state-icon">
34
- <Icon name="inbox" size="2rem" />
52
+ <Icon name="inbox" size={compact ? "1.25rem" : "2rem"} />
35
53
  </span>
36
54
  );
55
+ // Stamped only for `compact`: the full-page block's geometry IS the base rule, the same
56
+ // shape `Button`'s sizes and the shell's density take.
37
57
  return (
38
- <div data-terp="empty-state">
58
+ <div data-terp="empty-state" data-size={compact ? "compact" : undefined}>
39
59
  {leading}
40
60
  <p data-terp="empty-state-title">{resolve(title)}</p>
41
- {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
+ )}
42
66
  {action}
43
67
  </div>
44
68
  );
45
69
  }
46
-
@@ -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 && (
@@ -51,7 +51,39 @@ describe("LoginView dev credentials", () => {
51
51
  </TerpProvider>,
52
52
  );
53
53
  fireEvent.click(await screen.findByRole("button", { name: "Fill dev credentials" }));
54
- expect(screen.getByPlaceholderText("Email")).toHaveValue("admin@example.test");
55
- expect(screen.getByPlaceholderText("Password")).toHaveValue("correct horse battery staple");
54
+ // By label, not by placeholder. Reaching for a placeholder here was itself a symptom:
55
+ // it was the only handle these inputs had.
56
+ expect(screen.getByLabelText("Email")).toHaveValue("admin@example.test");
57
+ expect(screen.getByLabelText("Password")).toHaveValue("correct horse battery staple");
58
+ });
59
+ });
60
+
61
+ describe("LoginView accessible names", () => {
62
+ it("labels both credentials so they survive typing and can be addressed by name", async () => {
63
+ // The first screen of every Terp app, and it was labelled by placeholder alone. A
64
+ // placeholder is not an accessible name and it disappears the moment someone types, so
65
+ // the field a user is halfway through filling had nothing identifying it (WCAG 3.3.2)
66
+ // — and `getByLabel` could not find either input, which made the one screen every app
67
+ // ships the one screen its own tests could not address by name.
68
+ stubFetch();
69
+ render(
70
+ <TerpProvider baseUrl="https://api.test">
71
+ <LoginView />
72
+ </TerpProvider>,
73
+ );
74
+ await screen.findByRole("heading", { name: "Sign in" });
75
+
76
+ const email = screen.getByLabelText("Email");
77
+ const password = screen.getByLabelText("Password");
78
+ expect(email).toHaveAttribute("type", "email");
79
+ expect(password).toHaveAttribute("type", "password");
80
+
81
+ // The name has to survive typing, which is the whole difference from a placeholder.
82
+ fireEvent.change(email, { target: { value: "someone@example.test" } });
83
+ expect(screen.getByLabelText("Email")).toHaveValue("someone@example.test");
84
+
85
+ // And the autocomplete tokens stay, so a password manager still offers to fill.
86
+ expect(email).toHaveAttribute("autocomplete", "username");
87
+ expect(password).toHaveAttribute("autocomplete", "current-password");
56
88
  });
57
89
  });
package/src/LoginView.tsx CHANGED
@@ -3,8 +3,9 @@ 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
+ import { Field } from "./Field";
8
9
  import { Input } from "./ui/Input";
9
10
  import type { SsoProvider } from "./sso";
10
11
 
@@ -52,6 +53,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
52
53
  const auth = useAuth();
53
54
  const sso = useSso();
54
55
  const strings = useStrings();
56
+ const resolve = useUiText();
55
57
  const [email, setEmail] = useState("");
56
58
  const [password, setPassword] = useState("");
57
59
  const [error, setError] = useState<string | null>(null);
@@ -92,24 +94,35 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
92
94
  <h1 data-terp="login-title">{strings.signIn}</h1>
93
95
  </div>
94
96
  <form data-terp="login-form" onSubmit={onSubmit}>
95
- <Input
96
- type="email"
97
- // Neither field declared an autocomplete token, so no password manager offered to
98
- // fill or save this form the one place in the framework where that matters most.
99
- autoComplete="username"
100
- placeholder={strings.email}
101
- value={email}
102
- onChange={(event) => setEmail(event.target.value)}
103
- required
104
- />
105
- <Input
106
- type="password"
107
- autoComplete="current-password"
108
- placeholder={strings.password}
109
- value={password}
110
- onChange={(event) => setPassword(event.target.value)}
111
- required
112
- />
97
+ {/* Labelled through `Field`, not by placeholder. A placeholder is not an
98
+ accessible name and it disappears the moment someone types, so the field a
99
+ user is halfway through filling has nothing identifying it WCAG 3.3.2 asks
100
+ for a label that survives typing. It also made these two inputs unreachable
101
+ by `getByLabel`, so every app's very first screen was the one place its own
102
+ tests could not address by name. The framework's own answer was already here:
103
+ `Field` wraps the control in a `<label>`, which is why it needs no id wiring.
104
+ The comment below is left because it dates the omission — an autocomplete
105
+ token was considered for these fields and a label was not. */}
106
+ <Field label={strings.email}>
107
+ <Input
108
+ type="email"
109
+ // Neither field declared an autocomplete token, so no password manager offered to
110
+ // fill or save this form — the one place in the framework where that matters most.
111
+ autoComplete="username"
112
+ value={email}
113
+ onChange={(event) => setEmail(event.target.value)}
114
+ required
115
+ />
116
+ </Field>
117
+ <Field label={strings.password}>
118
+ <Input
119
+ type="password"
120
+ autoComplete="current-password"
121
+ value={password}
122
+ onChange={(event) => setPassword(event.target.value)}
123
+ required
124
+ />
125
+ </Field>
113
126
  <Button type="submit" fullWidth loading={busy}>
114
127
  {busy ? strings.signingIn : strings.signIn}
115
128
  </Button>
@@ -146,7 +159,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
146
159
  disabled={busy}
147
160
  onClick={() => void onSso(provider)}
148
161
  >
149
- {`${strings.continueWith} ${provider.label ?? provider.name}`}
162
+ {`${strings.continueWith} ${resolve(provider.label ?? provider.name)}`}
150
163
  </Button>
151
164
  ))}
152
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>