@pithy-sh/i18n 0.13.0 → 0.15.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.
Files changed (48) hide show
  1. package/dist/capability.d.ts +1 -1
  2. package/dist/capability.d.ts.map +1 -1
  3. package/dist/capability.js +1 -1
  4. package/dist/catalogs/es/errors.d.ts.map +1 -1
  5. package/dist/catalogs/es/errors.js +14 -0
  6. package/dist/catalogs/es/screens.d.ts.map +1 -1
  7. package/dist/catalogs/es/screens.js +79 -0
  8. package/dist/config/config.d.ts.map +1 -1
  9. package/dist/config/config.js +0 -1
  10. package/dist/http/middleware.d.ts +1 -1
  11. package/dist/http/middleware.js +1 -1
  12. package/dist/native/environment.d.ts +16 -0
  13. package/dist/native/environment.d.ts.map +1 -0
  14. package/dist/native/environment.js +56 -0
  15. package/dist/native/useNativeDirection.d.ts +13 -0
  16. package/dist/native/useNativeDirection.d.ts.map +1 -0
  17. package/dist/native/useNativeDirection.js +25 -0
  18. package/dist/react/browserLocaleEnvironment.d.ts +6 -0
  19. package/dist/react/browserLocaleEnvironment.d.ts.map +1 -0
  20. package/dist/react/browserLocaleEnvironment.js +28 -0
  21. package/dist/react/localeEnvironment.d.ts +29 -0
  22. package/dist/react/localeEnvironment.d.ts.map +1 -0
  23. package/dist/react/localeEnvironment.js +2 -0
  24. package/dist/react/useDocumentLocale.d.ts +10 -0
  25. package/dist/react/useDocumentLocale.d.ts.map +1 -0
  26. package/dist/react/useDocumentLocale.js +23 -0
  27. package/dist/react/useNegotiatedLocale.d.ts +16 -3
  28. package/dist/react/useNegotiatedLocale.d.ts.map +1 -1
  29. package/dist/react/useNegotiatedLocale.js +30 -37
  30. package/dist/resolve/browser.d.ts +2 -1
  31. package/dist/resolve/browser.d.ts.map +1 -1
  32. package/dist/resolve/browser.js +2 -1
  33. package/dist/version.generated.d.ts +1 -1
  34. package/dist/version.generated.js +1 -1
  35. package/package.json +20 -3
  36. package/src/capability.ts +5 -9
  37. package/src/catalogs/es/errors.ts +16 -0
  38. package/src/catalogs/es/screens.ts +94 -1
  39. package/src/config/config.ts +0 -1
  40. package/src/http/middleware.ts +1 -1
  41. package/src/native/environment.ts +57 -0
  42. package/src/native/useNativeDirection.ts +24 -0
  43. package/src/react/browserLocaleEnvironment.ts +32 -0
  44. package/src/react/localeEnvironment.ts +31 -0
  45. package/src/react/useDocumentLocale.ts +18 -0
  46. package/src/react/useNegotiatedLocale.ts +52 -51
  47. package/src/resolve/browser.ts +2 -1
  48. package/src/version.generated.ts +1 -1
@@ -1,10 +1,11 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { resolveBrowserLocale } from "../resolve/browser.js";
4
- import { applyDocumentLocale, readBrowserSignals, rememberBrowserLocale } from "../browser/signals.js";
5
4
  import { loadKitCatalog } from "../catalogs/browser.js";
5
+ import { browserLocaleEnvironment } from "./browserLocaleEnvironment.js";
6
6
  import { localeDirection } from "@pithy-sh/core/src/i18n/locale";
7
- import { useCallback, useEffect, useMemo, useState } from "react";
7
+ import { useRead } from "@pithy-sh/ui-core/src/read";
8
+ import { useCallback, useMemo, useState } from "react";
8
9
  //#region src/react/useNegotiatedLocale.ts
9
10
  /**
10
11
  * What `locale` answers, and what nothing writes to the document, when the capability is not composed.
@@ -15,8 +16,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
15
16
  */
16
17
  const UNNEGOTIATED_LOCALE = "en";
17
18
  /**
18
- * Negotiate the reader's locale in the browser, load that locale's catalog, and keep the document in
19
- * step.
19
+ * Negotiate the reader's locale, load that locale's catalog, and keep the platform in step: the document in a
20
+ * browser, `I18nManager` on React Native, through `options.environment`.
20
21
  *
21
22
  * **It takes what a browser holds.** `projection` is `virtual:pithy/i18n` — locale metadata and nothing
22
23
  * else — so a screen reads the chain order **this project** configured rather than one the front end
@@ -39,50 +40,41 @@ const UNNEGOTIATED_LOCALE = "en";
39
40
  function useNegotiatedLocale(projection, options = {}) {
40
41
  const config = projection.enabled ? projection : null;
41
42
  const enabled = projection.enabled;
42
- const resolved = useMemo(() => config === null ? null : resolveBrowserLocale({
43
- ...readBrowserSignals(config),
43
+ const environment = options.environment ?? browserLocaleEnvironment;
44
+ const signals = environment.useSignals(config);
45
+ const pending = enabled && signals === void 0;
46
+ const resolved = useMemo(() => config === null || signals === void 0 ? null : resolveBrowserLocale({
47
+ ...signals,
44
48
  account: options.account
45
- }, config), [config, options.account]);
49
+ }, config), [
50
+ config,
51
+ signals,
52
+ options.account
53
+ ]);
46
54
  const [chosen, setChosen] = useState(null);
47
- const [kit, setKit] = useState(null);
48
55
  const locale = chosen ?? resolved?.catalogLocale ?? UNNEGOTIATED_LOCALE;
49
56
  const formattingLocale = chosen ?? resolved?.formattingLocale ?? UNNEGOTIATED_LOCALE;
50
57
  const defaultLocale = config?.defaultLocale ?? UNNEGOTIATED_LOCALE;
51
- useEffect(() => {
52
- if (!enabled) return;
53
- let live = true;
54
- loadKitCatalog(locale).then((catalog) => {
55
- if (live) setKit({
56
- locale,
57
- catalog
58
- });
59
- }).catch(() => {
60
- if (live) setKit({
61
- locale,
62
- catalog: {}
63
- });
64
- });
65
- return () => {
66
- live = false;
67
- };
68
- }, [enabled, locale]);
69
- const direction = localeDirection(locale);
70
- useEffect(() => {
71
- if (!enabled) return;
72
- applyDocumentLocale(locale, direction);
73
- }, [
74
- enabled,
58
+ const kit = useRead(`catalog:${locale}`, async () => ({
75
59
  locale,
76
- direction
77
- ]);
60
+ catalog: await loadKitCatalog(locale).catch(() => ({}))
61
+ }), { skip: !enabled || pending }).value;
62
+ const direction = localeDirection(locale);
63
+ const reloadRequired = environment.useApply(enabled && !pending, locale, direction);
78
64
  const persist = options.persist;
79
65
  const choose = useCallback((next) => {
80
66
  if (config === null) return;
81
67
  if (!config.supportedLocales.includes(next)) return;
82
- rememberBrowserLocale(next, config);
68
+ try {
69
+ Promise.resolve(environment.remember(next, config)).catch(() => void 0);
70
+ } catch {}
83
71
  setChosen(next);
84
72
  Promise.resolve(persist?.(next)).catch(() => void 0);
85
- }, [config, persist]);
73
+ }, [
74
+ config,
75
+ persist,
76
+ environment
77
+ ]);
86
78
  return {
87
79
  source: useMemo(() => {
88
80
  if (kit?.locale !== locale) return null;
@@ -103,7 +95,8 @@ function useNegotiatedLocale(projection, options = {}) {
103
95
  defaultLocale
104
96
  ]),
105
97
  locale,
106
- choose
98
+ choose,
99
+ reloadRequired
107
100
  };
108
101
  }
109
102
  //#endregion
@@ -60,7 +60,8 @@ export interface BrowserLocaleSignals {
60
60
  /**
61
61
  * The reader's locale, by the configured browser chain.
62
62
  *
63
- * Default order: `?lang=`, the account, local storage, the server's answer, the project default.
63
+ * Default order: `?lang=`, the account, local storage, the browser's own languages, the server's answer,
64
+ * the project default.
64
65
  *
65
66
  * **The account outranks the device.** `pithy_auth_users.locale` is where a person's locale lives, so a
66
67
  * signed-in reader who picks a language on one device must not silently be reading another language on
@@ -1 +1 @@
1
- {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../../src/resolve/browser.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,cAAc,EAA4C,MAAM,SAAS,CAAC;AAExG;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,YAAa,SAAQ,SAAS;IAC7C,sEAAsE;IACtE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,+EAA+E;IAC/E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,sGAAsG;IACtG,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9C;AAuBD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,eAAe,EAAE,CAE/E;AAED;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,kDAAkD;IAClD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,uDAAuD;IACvD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC;;;;;;OAMG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAAC;IAC9C,sEAAsE;IACtE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,oBAAoB,EAAE,MAAM,EAAE,YAAY,GAAG,cAAc,CAIxG"}
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../../src/resolve/browser.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,cAAc,EAA4C,MAAM,SAAS,CAAC;AAExG;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,YAAa,SAAQ,SAAS;IAC7C,sEAAsE;IACtE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,+EAA+E;IAC/E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,sGAAsG;IACtG,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9C;AAuBD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,eAAe,EAAE,CAE/E;AAED;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,kDAAkD;IAClD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,uDAAuD;IACvD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC;;;;;;OAMG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAAC;IAC9C,sEAAsE;IACtE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,oBAAoB,EAAE,MAAM,EAAE,YAAY,GAAG,cAAc,CAIxG"}
@@ -35,7 +35,8 @@ function recognizedResolvers(names) {
35
35
  /**
36
36
  * The reader's locale, by the configured browser chain.
37
37
  *
38
- * Default order: `?lang=`, the account, local storage, the server's answer, the project default.
38
+ * Default order: `?lang=`, the account, local storage, the browser's own languages, the server's answer,
39
+ * the project default.
39
40
  *
40
41
  * **The account outranks the device.** `pithy_auth_users.locale` is where a person's locale lives, so a
41
42
  * signed-in reader who picks a language on one device must not silently be reading another language on
@@ -3,5 +3,5 @@
3
3
  /** This package's npm name — the join key against a release feed. */
4
4
  export declare const PACKAGE_NAME = "@pithy-sh/i18n";
5
5
  /** This package's version, stamped from its own package.json at generation time. */
6
- export declare const PACKAGE_VERSION = "0.13.0";
6
+ export declare const PACKAGE_VERSION = "0.15.0";
7
7
  //# sourceMappingURL=version.generated.d.ts.map
@@ -4,6 +4,6 @@
4
4
  /** This package's npm name — the join key against a release feed. */
5
5
  const PACKAGE_NAME = "@pithy-sh/i18n";
6
6
  /** This package's version, stamped from its own package.json at generation time. */
7
- const PACKAGE_VERSION = "0.13.0";
7
+ const PACKAGE_VERSION = "0.15.0";
8
8
  //#endregion
9
9
  export { PACKAGE_NAME, PACKAGE_VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/i18n",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "scripts": {
29
29
  "build": "tsdown && tsc -p tsconfig.build.json && bun ../../tooling/build/src/stampDeclarations.ts",
30
- "typecheck": "bun check -p tsconfig.json && bun check -p tsconfig.browser.json",
30
+ "typecheck": "bun check -p tsconfig.json && bun check -p tsconfig.browser.json && bun check -p tsconfig.native.json",
31
31
  "test": "vitest run",
32
32
  "test:node": "vitest run --project=node --project=dom",
33
33
  "test:workers": "vitest run --project=workers",
@@ -35,14 +35,27 @@
35
35
  "reset": "bun run clean && rm -rf node_modules"
36
36
  },
37
37
  "peerDependencies": {
38
- "@pithy-sh/core": "^0.13.0",
38
+ "@pithy-sh/core": "^0.15.0",
39
+ "@pithy-sh/ui-core": "^0.1.0",
40
+ "@react-native-async-storage/async-storage": "^2.2.0",
41
+ "expo-localization": "^57.0.2",
39
42
  "hono": "^4.13.2",
40
43
  "react": "^19.0.0",
44
+ "react-native": "^0.86.3",
41
45
  "zod": "^4.4.0"
42
46
  },
43
47
  "peerDependenciesMeta": {
48
+ "@react-native-async-storage/async-storage": {
49
+ "optional": true
50
+ },
51
+ "expo-localization": {
52
+ "optional": true
53
+ },
44
54
  "react": {
45
55
  "optional": true
56
+ },
57
+ "react-native": {
58
+ "optional": true
46
59
  }
47
60
  },
48
61
  "devDependencies": {
@@ -51,14 +64,18 @@
51
64
  "@pithy-sh/core": "workspace:*",
52
65
  "@pithy-sh/payments": "workspace:*",
53
66
  "@pithy-sh/tsconfig": "workspace:*",
67
+ "@pithy-sh/ui-core": "workspace:*",
68
+ "@react-native-async-storage/async-storage": "2.2.0",
54
69
  "@types/node": "^22.15.0",
55
70
  "@types/react": "^19.2.17",
56
71
  "@types/react-dom": "^19.2.3",
57
72
  "@vitest/coverage-v8": "^4.1.0",
73
+ "expo-localization": "57.0.2",
58
74
  "happy-dom": "^20.11.1",
59
75
  "hono": "^4.13.2",
60
76
  "react": "^19.2.8",
61
77
  "react-dom": "^19.2.8",
78
+ "react-native": "0.86.3",
62
79
  "tsdown": "^0.23.0",
63
80
  "typescript": "^7.0.2",
64
81
  "vitest": "^4.1.0",
package/src/capability.ts CHANGED
@@ -47,7 +47,7 @@ export interface I18nCapability extends Capability {
47
47
  * 1. the adopter's catalog for the resolved locale,
48
48
  * 2. the adopter's catalog for the project default,
49
49
  * 3. **the kit's translation** for the resolved locale — the `es` this package ships,
50
- * 4. every composed capability's English, for the resolved locale,
50
+ * 4. every composed capability's own copy for the resolved locale,
51
51
  * 5. every composed capability's copy for the project default,
52
52
  * 6. and their English, which is the language the kit is written in.
53
53
  *
@@ -69,13 +69,9 @@ export function i18n(config: I18nConfigInput = {}): I18nCapability {
69
69
  KIT_CATALOGS[locale],
70
70
  composedMessages[locale],
71
71
  composedMessages[resolved.defaultLocale],
72
- // **The source language, explicitly, and last.** This was `catalogFor(composedMessages, default)`,
73
- // which answers `catalogs[default] ?? catalogs.en` — so it only ever reached English because no
74
- // capability had contributed a non-`en` locale. `@pithy-sh/email` does now (#442), which made the
75
- // fallback silently stop existing for any project whose `defaultLocale` is not `en`: layers 5 and
76
- // 6 collapsed into the same Spanish map, and an adopter's own English-only key rendered as its raw
77
- // key on a Spanish page. English is the language the kit is written in, so it is a layer of its
78
- // own rather than something another layer resolves to by coincidence.
72
+ // **The source language, explicitly, and last.** A capability may contribute a non-`en` locale, so
73
+ // the default's catalog is not English for a project whose `defaultLocale` is not `en`. English is
74
+ // the language the kit is written in, so it is a layer of its own.
79
75
  composedMessages[DEFAULT_LOCALE],
80
76
  ];
81
77
 
@@ -84,7 +80,7 @@ export function i18n(config: I18nConfigInput = {}): I18nCapability {
84
80
  // The package this capability ships in and the version it ships at, both stamped by
85
81
  // `scripts/stampVersions.ts` — a Worker cannot read its own package.json. Reported per capability by
86
82
  // the control-plane manifest, and reported together: a release feed is keyed by package name, so the
87
- // version alone leaves a client guessing the key (#626).
83
+ // version alone leaves a client guessing the key.
88
84
  version: PACKAGE_VERSION,
89
85
  package: PACKAGE_NAME,
90
86
  config: I18nConfig,
@@ -52,6 +52,7 @@ export const esErrors: MessageCatalog = {
52
52
  // not here: a translating client renders this sentence and the screen shows the reason beside it.
53
53
  "auth/account_suspended": "Tu cuenta está suspendida.",
54
54
  "auth/account_deleted": "Tu cuenta está eliminada. Aún se puede recuperar durante un tiempo.",
55
+ "auth/handoff_invalid": "Ese código de acceso ya se usó o ha caducado. Vuelve a iniciar sesión.",
55
56
  "core/not_found": "No existe el recurso solicitado.",
56
57
  "core/conflict": "La solicitud entra en conflicto con el estado actual.",
57
58
  "rate_limit/exceeded": "Demasiadas solicitudes. Inténtalo más tarde.",
@@ -65,6 +66,8 @@ export const esErrors: MessageCatalog = {
65
66
  "core/unknown_workflow": "La operación solicitada no existe.",
66
67
  "core/workflow_failed": "La operación no se ha completado.",
67
68
  "core/webhook_unverified": "La firma de la notificación no es válida.",
69
+ "core/presence_ticket_invalid": "Ese pase de presencia no es válido.",
70
+ "core/presence_room_full": "La sala está llena.",
68
71
 
69
72
  // Cloudflare REST. Naming the provider is safe — it is the adopter's own account, not our infrastructure.
70
73
  "cloudflare/not_configured": "Este servicio no está disponible ahora.",
@@ -130,6 +133,9 @@ export const esErrors: MessageCatalog = {
130
133
  "ledger/insufficient_funds": "El saldo no es suficiente.",
131
134
  "ledger/hold_not_open": "Esa reserva de saldo ya está resuelta.",
132
135
  "ledger/invalid_amount": "El importe debe ser un número entero positivo.",
136
+ "ledger/entry_not_found": "Ese movimiento no existe.",
137
+ "ledger/entry_not_reversible": "Ese movimiento no se puede revertir.",
138
+ "ledger/ref_conflict": "Esa referencia ya pertenece a otro movimiento.",
133
139
 
134
140
  // Rating.
135
141
  "rating/unknown_algorithm": "Ese algoritmo de valoración no existe.",
@@ -139,6 +145,16 @@ export const esErrors: MessageCatalog = {
139
145
  "rating/pool_not_found": "Ese grupo de valoración no existe.",
140
146
  "rating/record_forbidden": "No puedes registrar resultados valorados.",
141
147
 
148
+ // Consent.
149
+ "consent/unknown_purpose": "Ese propósito de consentimiento no existe.",
150
+ "consent/stale_notice": "El aviso ha cambiado. Vuelve a leerlo antes de aceptar.",
151
+ "consent/method_below_minimum": "Este consentimiento necesita una firma más fuerte.",
152
+ "consent/withdrawal_below_grant": "Para retirar este consentimiento, fírmalo igual que lo diste.",
153
+ "consent/evidence_missing": "Falta la firma o el documento que este consentimiento necesita.",
154
+ "consent/expiry_passed": "Esa fecha de caducidad ya ha pasado.",
155
+ "consent/document_not_found": "Ese documento firmado no existe.",
156
+ "consent/evidence_store_missing": "No se pudo guardar la firma.",
157
+
142
158
  // Matchmaking.
143
159
  "matchmaking/room_not_found": "Ese código no corresponde a ninguna sala abierta.",
144
160
  "matchmaking/room_full": "Esa sala ya no admite más jugadores.",
@@ -36,7 +36,7 @@ export const esScreens: MessageCatalog = {
36
36
  "auth/sign_in.provider.label": "Continuar con {provider}",
37
37
  "auth/sign_in.provider_unconfigured": "{provider} no está configurado aquí. Usa el enlace.",
38
38
  "auth/sign_in.provider_silent": "{provider} no respondió. Usa el enlace.",
39
- // Ni una palabra sobre si la cuenta existe (#625). El servidor manda un solo código para "tienes
39
+ // Ni una palabra sobre si la cuenta existe. El servidor manda un solo código para "tienes
40
40
  // cuenta y este proveedor no está conectado a ella" y para "no tienes ninguna", así que una frase que
41
41
  // eligiera una de las dos devolvería, escrita, el oráculo que el Worker acaba de cerrar en la cabecera.
42
42
  "auth/sign_in.refused.title": "{provider} no te ha dejado entrar.",
@@ -65,6 +65,12 @@ export const esScreens: MessageCatalog = {
65
65
  "auth/otp.failed": "Ese código no funcionó. Inténtalo otra vez o pide uno nuevo.",
66
66
  "auth/otp.submit": "Entrar",
67
67
  "auth/otp.resend": "Enviar un código nuevo",
68
+ // The native sign-in screen on a phone with no universal link, where email signs in with a code.
69
+ "auth/otp.ask.submit": "Envíame un código",
70
+ "auth/otp.ask.provider_unconfigured": "{provider} no está configurado aquí. Usa un código.",
71
+ "auth/otp.ask.provider_silent": "{provider} no respondió. Usa un código.",
72
+ "auth/otp.ask.signup_email_only":
73
+ "Te enviamos un código y se crea una. Los botones de arriba solo entran en cuentas que ya existen.",
68
74
 
69
75
  // ── auth: the magic link's landing screen ──────────────────────────────────
70
76
  "auth/callback.title": "Entrando.",
@@ -81,6 +87,39 @@ export const esScreens: MessageCatalog = {
81
87
  "auth/deleted.detail": "Hasta entonces se puede recuperar. Te enviamos la fecha a la dirección de tu cuenta.",
82
88
  "auth/deleted.contact": "Para recuperarla, responde a ese correo antes de esa fecha.",
83
89
  "auth/deleted.sign_out": "Cerrar sesión",
90
+ // ── auth: an application acting as you ─────────────────────────────────────
91
+ "auth/consent.title": "Una aplicación quiere actuar en tu nombre.",
92
+ "auth/consent.reported": "Se presenta como {name}. Ese nombre lo puso ella, y nadie lo ha comprobado.",
93
+ "auth/consent.unnamed": "No dio ningún nombre.",
94
+ "auth/consent.verified": "Comprobado: se publica en",
95
+ "auth/consent.redirect": "El acceso se enviará a",
96
+ "auth/consent.redirect_note": "Aprueba solo si empezaste esto desde esa aplicación.",
97
+ "auth/consent.can_do": "Si apruebas, podrá:",
98
+ "auth/consent.count.one": "{reachable} de {count} operación en {capability}.",
99
+ "auth/consent.count.other": "{reachable} de {count} operaciones en {capability}.",
100
+ "auth/consent.withheld.one": "{count} más necesita un rol que no tienes.",
101
+ "auth/consent.withheld.other": "{count} más necesitan un rol que no tienes.",
102
+ "auth/consent.nothing": "Tu rol todavía no le permite nada aquí. Gana lo que gane tu rol.",
103
+ "auth/consent.refused_scope": "{scope}: {reason}",
104
+ "auth/consent.not_granted.role": "Solicitado, y no concedido, porque tu rol no lo incluye.",
105
+ "auth/consent.not_granted.unknown": "Solicitado, y no concedido, porque esta aplicación no tiene ese permiso.",
106
+ "auth/consent.bound":
107
+ "Si apruebas, esta aplicación actuará en tu nombre. Nunca podrá hacer más de lo que tu rol permite, y pierde todo lo que tu rol pierda.",
108
+ "auth/consent.approve": "Dejar que actúe en mi nombre",
109
+ "auth/consent.deny": "No permitir",
110
+ "auth/consent.expired": "Esta solicitud ha caducado. Empieza de nuevo desde la aplicación.",
111
+ "auth/consent.effect.writes": "cambia datos",
112
+ "auth/consent.effect.sends": "envía un mensaje",
113
+ "auth/consent.effect.charges": "cobra dinero",
114
+ "auth/consent.effect.deletes": "borra datos",
115
+ "auth/connected.title": "Aplicaciones que actúan en tu nombre",
116
+ "auth/connected.empty": "Ninguna. Nada actúa en tu nombre.",
117
+ "auth/connected.unnamed": "Una aplicación sin nombre",
118
+ "auth/connected.verified": "Publicada en {host}. Eso está comprobado.",
119
+ "auth/connected.approved": "Aprobada el {date}. El acceso va a {redirect}.",
120
+ "auth/connected.revoke": "Revocar",
121
+ "auth/connected.failed": "No pudimos cargar tus aplicaciones.",
122
+ "auth/connected.retry": "Reintentar",
84
123
  "organization/suspended.title": "Esta organización está suspendida.",
85
124
  "organization/suspended.body": "Nadie puede usarla hasta que se reactive. Tu propia cuenta no se ve afectada.",
86
125
  "organization/suspended.contact": "A sus propietarios se les dijo el motivo. Pregunta a uno de ellos.",
@@ -100,6 +139,21 @@ export const esScreens: MessageCatalog = {
100
139
  "organization/choose.failed": "No hemos podido cargar tus cuentas.",
101
140
  "organization/choose.retry": "Reintentar",
102
141
 
142
+ // ── organization: accepting an invitation ──────────────────────────────────
143
+ "organization/invitation.title": "Únete a {organization}",
144
+ "organization/invitation.invited_by": "{inviter} te invitó como {role}.",
145
+ "organization/invitation.invited": "Te invitaron como {role}.",
146
+ "organization/invitation.expires": "La invitación caduca el {date}.",
147
+ "organization/invitation.as": "Te unirás como {email}.",
148
+ "organization/invitation.accept": "Unirme",
149
+ "organization/invitation.sign_in": "Inicia sesión para aceptar",
150
+ "organization/invitation.invalid.title": "Esta invitación no se puede usar.",
151
+ "organization/invitation.invalid.body":
152
+ "Caducó, ya se usó o se retiró, o se envió a otra dirección. Pide una nueva a quien te invitó.",
153
+ "organization/invitation.failed": "No hemos podido cargar esta invitación.",
154
+ "organization/invitation.join_failed": "No hemos podido terminar de unirte.",
155
+ "organization/invitation.retry": "Reintentar",
156
+
103
157
  // ── payments: the paywall ──────────────────────────────────────────────────
104
158
  "payments/paywall.title": "Ve más lejos.",
105
159
  "payments/paywall.body": "Elige lo que necesitas. Puedes cambiar de idea más tarde.",
@@ -113,6 +167,10 @@ export const esScreens: MessageCatalog = {
113
167
  "payments/paywall.done.title": "Todo listo.",
114
168
  "payments/paywall.done.body": "Gracias. Tu compra ya está en tu cuenta.",
115
169
  "payments/paywall.done.home": "Ir al inicio",
170
+ "payments/paywall.restore": "Restaurar compras",
171
+ "payments/paywall.nothing_to_restore": "La tienda no tiene nada que restaurar para esta cuenta.",
172
+ "payments/paywall.restored_elsewhere": "Tus compras se han restaurado. Ninguna incluye lo que se vende aquí.",
173
+ "payments/paywall.pending": "La tienda aún está procesando esta compra. Aparecerá en tu cuenta cuando se confirme.",
116
174
 
117
175
  // ── payments: the pricing screen ───────────────────────────────────────────
118
176
  "payments/pricing.title": "Lo que cuesta.",
@@ -124,6 +182,12 @@ export const esScreens: MessageCatalog = {
124
182
  "payments/pricing.sign_in": "Entra para comprar {product}",
125
183
  "payments/pricing.buy": "Comprar {product}",
126
184
  "payments/pricing.holdings": "¿Qué tengo ya?",
185
+ "payments/pricing.store_body": "Los precios son los de la tienda, para donde estás.",
186
+ "payments/pricing.at_checkout": "Verás el precio al pagar.",
187
+ "payments/pricing.done": "Gracias. Tu compra ya está en tu cuenta.",
188
+ "payments/pricing.pending": "La tienda aún está procesando esta compra. Aparecerá en tu cuenta cuando se confirme.",
189
+ "payments/pricing.empty.rail":
190
+ "Nada de aquí se vende por el método de pago de esta plataforma. Dale a un producto su SKU en ese método en",
127
191
  // `{interval}` is Paddle's own word for the billing period and arrives from their API in English, so
128
192
  // it is left where it lands rather than half-translated into a sentence that then disagrees with the
129
193
  // figure beside it. An adopter who wants `al mes` says so in `i18n({ messages })`.
@@ -152,6 +216,34 @@ export const esScreens: MessageCatalog = {
152
216
  "payments/subscription.google": "Comprado en Google Play",
153
217
  "payments/subscription.more": "Ver qué más hay",
154
218
 
219
+ // ── payments: buying in the app ────────────────────────────────────────────
220
+ "payments/checkout.in_page": "Este pago se abre dentro de una página web, y la app no puede mostrarla.",
221
+ "payments/store.unavailable": "La tienda no ha respondido. Inténtalo de nuevo en un momento.",
222
+ "payments/store.refused": "La tienda no ha terminado la compra.",
223
+ "payments/store.no_token":
224
+ "Este dispositivo no ha podido guardar lo que marca una compra como tuya, así que no se ha comprado nada.",
225
+ "payments/store.signed_out": "Inicia sesión antes de comprar.",
226
+ "payments/store.no_answer": "La tienda no ha respondido. Si la compra se completa, llegará a tu cuenta sola.",
227
+
228
+ // ── consent: the reader's privacy choices ─────────────────────────────
229
+ "consent/settings.title": "Tus opciones de privacidad.",
230
+ "consent/settings.loading": "Un momento.",
231
+ "consent/settings.unreadable": "No pudimos cargar tus opciones.",
232
+ "consent/settings.none": "Aquí nada pide tu consentimiento.",
233
+ "consent/settings.on": "Activado.",
234
+ "consent/settings.off": "Desactivado.",
235
+ "consent/settings.renew": "El aviso ha cambiado. Acepta de nuevo para mantenerlo.",
236
+ "consent/settings.expired": "Ha caducado.",
237
+ "consent/settings.agree": "Aceptar",
238
+ "consent/settings.withdraw": "Retirar",
239
+ "consent/settings.signed_elsewhere": "Necesita tu firma en el documento que cubre.",
240
+ "consent/settings.type_name": "Escribe tu nombre completo para retirarlo por escrito",
241
+ "consent/settings.draw_name": "Firma abajo para retirarlo por escrito",
242
+ "consent/settings.clear": "Borrar",
243
+ "consent/settings.signed_with_provider":
244
+ "Lo firmaste con un servicio de firma electrónica. Retíralo donde lo firmaste.",
245
+ "consent/settings.saved": "Guardado.",
246
+
155
247
  // ── app: the adopter's own shell and home screen ───────────────────────────
156
248
  //
157
249
  // Under `app/` rather than a capability name because that is what these are: the router and the home
@@ -170,6 +262,7 @@ export const esScreens: MessageCatalog = {
170
262
  "app/home.bare.checking": "comprobando",
171
263
  "app/home.bare.unknown": "desconocido",
172
264
  "app/home.bare.unreachable": "inaccesible",
265
+ "app/locale.reload_required": "Reinicia la app para terminar de cambiar el idioma.",
173
266
 
174
267
  // ── client: the failures a browser mints for itself, never sent by a Worker ─
175
268
  //
@@ -33,7 +33,6 @@ export type BrowserResolver = z.infer<typeof BrowserResolver>;
33
33
  /** The server chain's default order — explicit param, account, cookie, `Accept-Language`, project default. */
34
34
  const DEFAULT_SERVER_RESOLVERS: readonly ServerResolver[] = ["param", "user", "cookie", "header", "default"];
35
35
 
36
- /** The browser chain's default order — `?lang=`, account, local storage, the server's answer, default. */
37
36
  /**
38
37
  * The browser chain's default order — `?lang=`, account, this device's memory, the reader's own
39
38
  * browser languages, what the server declared, the project default.
@@ -30,7 +30,7 @@ export type LayersFor = (locale: string) => readonly (MessageCatalog | undefined
30
30
  * ## Why the query string is read off the URL here, and not through `validate`
31
31
  *
32
32
  * §HTTP's rule is that a route declares its request contract on the route line and a handler reads
33
- * `c.req.valid(target)`; `plugins/no-raw-request-input.grit` enforces it by making the raw accessors
33
+ * `c.req.valid(target)`; `templates/starter/plugins/no-raw-request-input.grit` enforces it by making the raw accessors
34
34
  * unreadable under every capability's `src/http/` tree. This is a **global middleware over every route**, so there
35
35
  * is no route line to declare a validator on and no handler to hand a typed value to — the rule's
36
36
  * replacement does not exist at this position.
@@ -0,0 +1,57 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { useRead } from "@pithy-sh/ui-core/src/read";
5
+ import AsyncStorage from "@react-native-async-storage/async-storage";
6
+ import { useLocales } from "expo-localization";
7
+ import { useMemo } from "react";
8
+ import type { LocaleEnvironment, LocaleKeys } from "../react/localeEnvironment";
9
+ import type { BrowserLocaleSignals } from "../resolve/browser";
10
+ import { useNativeDirection } from "./useNativeDirection";
11
+
12
+ /**
13
+ * React Native's locale environment. Pass it as `useNegotiatedLocale(projection, { environment: nativeLocaleEnvironment })`.
14
+ *
15
+ * - **Starting signals:** the device's preferred languages from `expo-localization`, in the order the reader set
16
+ * them, and a choice remembered in AsyncStorage. `useLocales` re-renders when the operating system's language
17
+ * changes, so the app follows it while it runs. There is no query string and no server-rendered `lang`, so those
18
+ * two links of the chain answer nothing.
19
+ * - **Remember:** AsyncStorage, under the projection's `storageKey`. A locale is not a secret, so it is not kept in
20
+ * the secure store.
21
+ * - **Apply:** `I18nManager`, whose direction takes effect after a reload. `reloadRequired` says when.
22
+ */
23
+ export const nativeLocaleEnvironment: LocaleEnvironment = {
24
+ useSignals: useNativeSignals,
25
+ remember: (locale, keys) => AsyncStorage.setItem(keys.storageKey, locale),
26
+ useApply: (enabled, _locale, direction) => useNativeDirection(enabled, direction),
27
+ };
28
+
29
+ /** The device's languages and this device's remembered choice; `undefined` until the memory has been read. */
30
+ function useNativeSignals(keys: LocaleKeys | null): BrowserLocaleSignals | undefined {
31
+ const device = useLocales();
32
+ const storageKey = keys?.storageKey;
33
+ const memory = useRead(
34
+ `pithy-locale-memory:${storageKey ?? ""}`,
35
+ async (): Promise<string | null> => (storageKey === undefined ? null : readMemory(storageKey)),
36
+ { skip: storageKey === undefined },
37
+ );
38
+ const navigator = useMemo(() => {
39
+ const tags = device.map((entry) => entry.languageTag).filter((tag) => tag.trim().length > 0);
40
+ return tags.length > 0 ? tags : null;
41
+ }, [device]);
42
+ const remembered = memory.value ?? null;
43
+ const ready = storageKey !== undefined && !memory.loading;
44
+ return useMemo(
45
+ () => (ready ? { query: null, storage: remembered, navigator, server: null } : undefined),
46
+ [ready, remembered, navigator],
47
+ );
48
+ }
49
+
50
+ /** The remembered choice, or `null` when there is none or the store refuses. */
51
+ async function readMemory(storageKey: string): Promise<string | null> {
52
+ try {
53
+ return await AsyncStorage.getItem(storageKey);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
@@ -0,0 +1,24 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { useEffect } from "react";
5
+ import { I18nManager } from "react-native";
6
+
7
+ /**
8
+ * Keep `I18nManager`'s direction in step with the locale in force, and answer whether it needs a reload.
9
+ *
10
+ * React Native reads the direction once, at launch: `forceRTL` changes the next launch, and `isRTL` keeps answering
11
+ * for this one. So the answer is a comparison, not a flag the effect sets. `allowRTL` moves with `forceRTL`, so an
12
+ * English choice on an Arabic device lays out left to right rather than following the device.
13
+ *
14
+ * `enabled` false touches nothing and asks for no reload.
15
+ */
16
+ export function useNativeDirection(enabled: boolean, direction: "ltr" | "rtl"): boolean {
17
+ const rtl = direction === "rtl";
18
+ useEffect(() => {
19
+ if (!enabled) return;
20
+ I18nManager.allowRTL(rtl);
21
+ I18nManager.forceRTL(rtl);
22
+ }, [enabled, rtl]);
23
+ return enabled && I18nManager.isRTL !== rtl;
24
+ }
@@ -0,0 +1,32 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { useMemo } from "react";
5
+ import { readBrowserSignals, rememberBrowserLocale } from "../browser/signals";
6
+ import type { BrowserLocaleSignals } from "../resolve/browser";
7
+ import type { LocaleEnvironment, LocaleKeys } from "./localeEnvironment";
8
+ import { useDocumentLocale } from "./useDocumentLocale";
9
+
10
+ /** The query string, `localStorage`, `navigator` and `<html lang>`, read once per pair of names. */
11
+ function useBrowserSignals(keys: LocaleKeys | null): BrowserLocaleSignals | undefined {
12
+ const queryParam = keys?.queryParam;
13
+ const storageKey = keys?.storageKey;
14
+ return useMemo(
15
+ () =>
16
+ queryParam === undefined || storageKey === undefined ? undefined : readBrowserSignals({ queryParam, storageKey }),
17
+ [queryParam, storageKey],
18
+ );
19
+ }
20
+
21
+ /** `lang` and `dir` on the document. A browser lays out a new direction at once, so no reload is ever needed. */
22
+ function useDocumentApply(enabled: boolean, locale: string, direction: "ltr" | "rtl"): boolean {
23
+ useDocumentLocale(enabled, locale, direction);
24
+ return false;
25
+ }
26
+
27
+ /** The browser's locale environment: what `useNegotiatedLocale` uses when it is handed none. */
28
+ export const browserLocaleEnvironment: LocaleEnvironment = {
29
+ useSignals: useBrowserSignals,
30
+ remember: rememberBrowserLocale,
31
+ useApply: useDocumentApply,
32
+ };
@@ -0,0 +1,31 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { BrowserChain, BrowserLocaleSignals } from "../resolve/browser";
5
+
6
+ /** The two names a client looks its own signals up by. */
7
+ export type LocaleKeys = Pick<BrowserChain, "queryParam" | "storageKey">;
8
+
9
+ /**
10
+ * Where `useNegotiatedLocale` gets its starting signals, where it remembers a choice, and how it applies the locale.
11
+ *
12
+ * The browser's is the default; `@pithy-sh/i18n/src/native/environment` is React Native's. Both hooks are called on
13
+ * every render, so the environment a component passes must be the same one for the life of that component: a module
14
+ * constant, never an object built in the render.
15
+ */
16
+ export interface LocaleEnvironment {
17
+ /**
18
+ * The signals this platform holds, as a hook, so a source that changes while the app runs, or answers after the
19
+ * first render, re-renders the caller. `undefined` while a signal is still being read: nothing is negotiated, no
20
+ * catalog is asked for and nothing is applied until it answers. `keys` is `null` when the capability is not
21
+ * composed, and nothing is read.
22
+ */
23
+ readonly useSignals: (keys: LocaleKeys | null) => BrowserLocaleSignals | undefined;
24
+ /** Remember a choice on this device. Not awaited; a rejection is caught. */
25
+ readonly remember: (locale: string, keys: LocaleKeys) => void | Promise<void>;
26
+ /**
27
+ * Apply the locale in force, as a hook. Answers `true` when the direction it applied takes effect only after the
28
+ * app reloads, which is React Native's rule for `I18nManager`. The browser always answers `false`.
29
+ */
30
+ readonly useApply: (enabled: boolean, locale: string, direction: "ltr" | "rtl") => boolean;
31
+ }
@@ -0,0 +1,18 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { useEffect } from "react";
5
+ import { applyDocumentLocale } from "../browser/signals";
6
+
7
+ /**
8
+ * Keep `lang` and `dir` on the document in step with the locale in force.
9
+ *
10
+ * `enabled` false writes nothing: a project without the capability keeps the `lang` its `index.html` shipped,
11
+ * because restamping the document with a locale no chain chose would declare a language nothing negotiated.
12
+ */
13
+ export function useDocumentLocale(enabled: boolean, locale: string, direction: "ltr" | "rtl"): void {
14
+ useEffect(() => {
15
+ if (!enabled) return;
16
+ applyDocumentLocale(locale, direction);
17
+ }, [enabled, locale, direction]);
18
+ }