@astryxdesign/core 0.7.1 → 0.7.2-canary.b06a093

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 (51) hide show
  1. package/dist/Button/Button.d.ts.map +1 -1
  2. package/dist/Button/Button.js +38 -14
  3. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  4. package/dist/MultiSelector/hooks.js +3 -1
  5. package/dist/Pagination/Pagination.d.ts +8 -5
  6. package/dist/Pagination/Pagination.d.ts.map +1 -1
  7. package/dist/Pagination/Pagination.js +21 -8
  8. package/dist/PowerSearch/PowerSearch.js +1 -1
  9. package/dist/Switch/Switch.d.ts +1 -1
  10. package/dist/Switch/Switch.d.ts.map +1 -1
  11. package/dist/Switch/Switch.js +6 -0
  12. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  13. package/dist/Tooltip/useTooltip.js +11 -0
  14. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  15. package/dist/Typeahead/BaseTypeahead.js +26 -0
  16. package/dist/astryx.css +1 -0
  17. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  18. package/dist/i18n/generated-locales/en.generated.js +19 -0
  19. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  20. package/dist/i18n/generated-locales/pseudo.generated.js +19 -0
  21. package/locales/en.json +76 -0
  22. package/locales/pseudo.json +57 -0
  23. package/package.json +4 -4
  24. package/src/Button/Button.doc.mjs +2 -2
  25. package/src/Button/Button.test.tsx +155 -9
  26. package/src/Button/Button.tsx +39 -16
  27. package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
  28. package/src/Button/__tests__/Button.a11y.states.ts +0 -10
  29. package/src/IconButton/IconButton.doc.mjs +2 -2
  30. package/src/IconButton/IconButton.test.tsx +3 -1
  31. package/src/MultiSelector/hooks.ts +3 -1
  32. package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
  33. package/src/Pagination/Pagination.doc.mjs +7 -7
  34. package/src/Pagination/Pagination.spec.md +9 -9
  35. package/src/Pagination/Pagination.test.tsx +242 -0
  36. package/src/Pagination/Pagination.tsx +27 -11
  37. package/src/PowerSearch/PowerSearch.test.tsx +85 -0
  38. package/src/PowerSearch/PowerSearch.tsx +2 -2
  39. package/src/Switch/Switch.test.tsx +28 -0
  40. package/src/Switch/Switch.tsx +6 -0
  41. package/src/Tokenizer/Tokenizer.test.tsx +54 -1
  42. package/src/Tooltip/useTooltip.test.tsx +13 -0
  43. package/src/Tooltip/useTooltip.tsx +11 -0
  44. package/src/Typeahead/BaseTypeahead.tsx +26 -0
  45. package/src/Typeahead/Typeahead.test.tsx +260 -2
  46. package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
  47. package/src/i18n/InternationalizationProvider.spec.md +252 -0
  48. package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
  49. package/src/i18n/generated-locales/en.generated.ts +21 -0
  50. package/src/i18n/generated-locales/pseudo.generated.ts +21 -0
  51. package/src/i18n/useTranslator.test.tsx +37 -0
@@ -17,10 +17,20 @@ import {
17
17
  beforeAll,
18
18
  afterAll,
19
19
  beforeEach,
20
+ afterEach,
20
21
  } from 'vitest';
21
22
  import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
22
23
  import userEvent from '@testing-library/user-event';
23
- import {createRef, Profiler, type ProfilerOnRenderCallback} from 'react';
24
+ import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
25
+ import {
26
+ createRef,
27
+ Profiler,
28
+ startTransition,
29
+ StrictMode,
30
+ Suspense,
31
+ useState,
32
+ type ProfilerOnRenderCallback,
33
+ } from 'react';
24
34
  import {Typeahead} from './Typeahead';
25
35
  import {readFile} from 'node:fs/promises';
26
36
  import {resolve} from 'node:path';
@@ -70,6 +80,10 @@ afterAll(() => {
70
80
  (HTMLElement.prototype as any).matches = originalMatches;
71
81
  });
72
82
 
83
+ afterEach(() => {
84
+ __resetLiveRegionsForTest();
85
+ });
86
+
73
87
  // Test data
74
88
  const fruits: SearchableItem[] = [
75
89
  {id: '1', label: 'Apple'},
@@ -964,6 +978,193 @@ describe('BaseTypeahead hasEntriesOnFocus', () => {
964
978
  ).not.toBeInTheDocument();
965
979
  });
966
980
 
981
+ it('clears the loading state when the search source is replaced during an in-flight load', async () => {
982
+ let settle: (items: SearchableItem[]) => void = () => {};
983
+ const bootstrap = vi.fn(
984
+ async () =>
985
+ new Promise<SearchableItem[]>(resolve => {
986
+ settle = resolve;
987
+ }),
988
+ );
989
+ const initialSource: SearchSource = {search: () => [], bootstrap};
990
+ const replacementSource: SearchSource = {
991
+ search: () => [],
992
+ bootstrap: () => [],
993
+ };
994
+
995
+ const {rerender} = render(
996
+ <BaseTypeahead
997
+ searchSource={initialSource}
998
+ value={null}
999
+ onChange={() => {}}
1000
+ hasEntriesOnFocus
1001
+ />,
1002
+ );
1003
+ const input = screen.getByRole('combobox');
1004
+
1005
+ fireEvent.focus(input);
1006
+ await waitFor(() => {
1007
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
1008
+ });
1009
+
1010
+ rerender(
1011
+ <BaseTypeahead
1012
+ searchSource={replacementSource}
1013
+ value={null}
1014
+ onChange={() => {}}
1015
+ hasEntriesOnFocus
1016
+ />,
1017
+ );
1018
+
1019
+ await waitFor(() => {
1020
+ expect(
1021
+ screen.queryByRole('status', {name: 'Loading'}),
1022
+ ).not.toBeInTheDocument();
1023
+ });
1024
+
1025
+ await act(async () => {
1026
+ settle([]);
1027
+ await Promise.resolve();
1028
+ });
1029
+ expect(
1030
+ screen.queryByRole('status', {name: 'Loading'}),
1031
+ ).not.toBeInTheDocument();
1032
+ });
1033
+
1034
+ it('keeps the loading state under StrictMode for a pending autofocus bootstrap', async () => {
1035
+ let settle: (items: SearchableItem[]) => void = () => {};
1036
+ const bootstrap = vi.fn(
1037
+ async () =>
1038
+ new Promise<SearchableItem[]>(resolve => {
1039
+ settle = resolve;
1040
+ }),
1041
+ );
1042
+ render(
1043
+ <StrictMode>
1044
+ <Typeahead
1045
+ label="Test"
1046
+ searchSource={{search: () => [], bootstrap}}
1047
+ value={null}
1048
+ onChange={() => {}}
1049
+ hasAutoFocus
1050
+ hasEntriesOnFocus
1051
+ />
1052
+ </StrictMode>,
1053
+ );
1054
+
1055
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
1056
+
1057
+ await act(async () => {
1058
+ settle([]);
1059
+ await Promise.resolve();
1060
+ });
1061
+ expect(
1062
+ screen.queryByRole('status', {name: 'Loading'}),
1063
+ ).not.toBeInTheDocument();
1064
+ });
1065
+
1066
+ let settlePendingTransitionA: (items: SearchableItem[]) => void = () => {};
1067
+ const stableTransitionSourceA: SearchSource = {
1068
+ search: vi.fn(
1069
+ async () =>
1070
+ new Promise<SearchableItem[]>(resolve => {
1071
+ settlePendingTransitionA = resolve;
1072
+ }),
1073
+ ),
1074
+ bootstrap: () => [],
1075
+ };
1076
+ const stableTransitionSourceB: SearchSource = {
1077
+ search: vi.fn(async () => []),
1078
+ bootstrap: () => [],
1079
+ };
1080
+
1081
+ it('clears loading and aria-busy when source is replaced in a transition that suspends and retries', async () => {
1082
+ let isSuspended = false;
1083
+ let resolveSuspension: () => void = () => {};
1084
+ let pendingSuspension: Promise<void> | null = null;
1085
+
1086
+ function SuspendingSibling({shouldSuspend}: {shouldSuspend: boolean}) {
1087
+ if (shouldSuspend && isSuspended && pendingSuspension) {
1088
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
1089
+ throw pendingSuspension;
1090
+ }
1091
+ return null;
1092
+ }
1093
+
1094
+ let updateState!: (source: SearchSource, shouldSuspend: boolean) => void;
1095
+
1096
+ function TransitionContainer() {
1097
+ const [source, setSource] = useState<SearchSource>(
1098
+ stableTransitionSourceA,
1099
+ );
1100
+ const [shouldSuspend, setShouldSuspend] = useState(false);
1101
+ updateState = (newSource, suspend) => {
1102
+ setSource(newSource);
1103
+ setShouldSuspend(suspend);
1104
+ };
1105
+
1106
+ return (
1107
+ <Suspense fallback={<div>Suspended...</div>}>
1108
+ <Typeahead
1109
+ label="Test"
1110
+ searchSource={source}
1111
+ value={null}
1112
+ onChange={() => {}}
1113
+ debounceMs={0}
1114
+ />
1115
+ <SuspendingSibling shouldSuspend={shouldSuspend} />
1116
+ </Suspense>
1117
+ );
1118
+ }
1119
+
1120
+ render(<TransitionContainer />);
1121
+ const input = screen.getByRole('combobox');
1122
+
1123
+ // Trigger a load so aria-busy="true" is set on the combobox
1124
+ fireEvent.change(input, {target: {value: 'query'}});
1125
+ await waitFor(() => {
1126
+ expect(input).toHaveAttribute('aria-busy', 'true');
1127
+ });
1128
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
1129
+
1130
+ // Create the pending promise for suspension
1131
+ isSuspended = true;
1132
+ pendingSuspension = new Promise<void>(resolve => {
1133
+ resolveSuspension = () => {
1134
+ isSuspended = false;
1135
+ resolve();
1136
+ };
1137
+ });
1138
+
1139
+ // Inside startTransition, switch from source A to source B while sibling suspends
1140
+ act(() => {
1141
+ startTransition(() => {
1142
+ updateState(stableTransitionSourceB, true);
1143
+ });
1144
+ });
1145
+
1146
+ // The render suspended, so the previous committed state remains
1147
+ expect(input).toHaveAttribute('aria-busy', 'true');
1148
+
1149
+ // Resolve the pending promise and let the transition commit
1150
+ await act(async () => {
1151
+ resolveSuspension();
1152
+ await pendingSuspension;
1153
+ });
1154
+
1155
+ // Settle the abandoned load
1156
+ await act(async () => {
1157
+ settlePendingTransitionA([]);
1158
+ await Promise.resolve();
1159
+ });
1160
+
1161
+ // The combobox must not keep aria-busy and the spinner must be gone
1162
+ expect(input).not.toHaveAttribute('aria-busy');
1163
+ expect(
1164
+ screen.queryByRole('status', {name: 'Loading'}),
1165
+ ).not.toBeInTheDocument();
1166
+ });
1167
+
967
1168
  it('shows bootstrap results on mouse click', async () => {
968
1169
  render(
969
1170
  <BaseTypeahead
@@ -1323,6 +1524,63 @@ describe('BaseTypeahead hasSearched reset', () => {
1323
1524
  // "No results found" should disappear since hasSearched is reset
1324
1525
  expect(screen.queryByText('No results found')).not.toBeInTheDocument();
1325
1526
  });
1527
+
1528
+ it('resets hasSearched and does not announce "No results found" when search source is replaced during a pending search', async () => {
1529
+ let settle: (items: SearchableItem[]) => void = () => {};
1530
+ const initialSource: SearchSource = {
1531
+ search: vi.fn(
1532
+ async () =>
1533
+ new Promise<SearchableItem[]>(resolve => {
1534
+ settle = resolve;
1535
+ }),
1536
+ ),
1537
+ bootstrap: () => [],
1538
+ };
1539
+ const replacementSource: SearchSource = {
1540
+ search: vi.fn(async () => []),
1541
+ bootstrap: () => [],
1542
+ };
1543
+
1544
+ const {rerender} = render(
1545
+ <BaseTypeahead
1546
+ searchSource={initialSource}
1547
+ value={null}
1548
+ onChange={() => {}}
1549
+ debounceMs={0}
1550
+ />,
1551
+ );
1552
+ const input = screen.getByRole('combobox');
1553
+
1554
+ // Type a query that starts an asynchronous search
1555
+ fireEvent.change(input, {target: {value: 'abc'}});
1556
+ expect(initialSource.search).toHaveBeenCalledWith('abc');
1557
+
1558
+ // Replace the search source while the search is still pending
1559
+ rerender(
1560
+ <BaseTypeahead
1561
+ searchSource={replacementSource}
1562
+ value={null}
1563
+ onChange={() => {}}
1564
+ debounceMs={0}
1565
+ />,
1566
+ );
1567
+
1568
+ // Settle the abandoned search
1569
+ await act(async () => {
1570
+ settle([]);
1571
+ await Promise.resolve();
1572
+ });
1573
+
1574
+ await act(
1575
+ async () =>
1576
+ new Promise<void>(resolve => requestAnimationFrame(() => resolve())),
1577
+ );
1578
+ expect(
1579
+ document.querySelector('[data-astryx-live-region="polite"]')
1580
+ ?.textContent ?? '',
1581
+ ).not.toContain('No results found');
1582
+ expect(screen.queryByText('No results found')).not.toBeInTheDocument();
1583
+ });
1326
1584
  });
1327
1585
 
1328
1586
  describe('BaseTypeahead popover after selection', () => {
@@ -2313,7 +2571,7 @@ describe('Typeahead consumer key handler order', () => {
2313
2571
  currentTarget = event.currentTarget;
2314
2572
  event.preventDefault();
2315
2573
  });
2316
- const {container} = render(
2574
+ render(
2317
2575
  <Typeahead
2318
2576
  label="Fruit"
2319
2577
  searchSource={fruitSource}
@@ -21,6 +21,14 @@ export const docs = {
21
21
  usage: {
22
22
  description:
23
23
  'Wraps your app to set the active locale and (optionally) merge additional translation catalogs + per-locale overrides. Astryx components inside the subtree resolve their strings against this context. If no provider is present, components fall back to the shipped English defaults.',
24
+ anatomy: [
25
+ {
26
+ name: 'Provider boundary',
27
+ required: true,
28
+ description:
29
+ 'A nonvisual context boundary that supplies locale, message catalogs, overrides, and direction to descendant Astryx components. It renders no DOM element.',
30
+ },
31
+ ],
24
32
  bestPractices: [
25
33
  {
26
34
  guidance: true,
@@ -0,0 +1,252 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 7
4
+ kind: component
5
+ id: component:InternationalizationProvider
6
+ authority: current
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: nynexman4464
10
+ approved_at: 2026-10-15
11
+ owners: [nynexman4464, cixzhang]
12
+ review_triggers: [public-api, behavior, accessibility]
13
+ verified_by:
14
+ [
15
+ packages/core/src/i18n/__tests__/resolve.test.ts,
16
+ packages/core/src/i18n/__tests__/InternationalizationProvider.test.tsx,
17
+ packages/core/src/i18n/__tests__/useDirection.test.tsx,
18
+ packages/core/src/i18n/__tests__/getLocaleDirection.test.ts,
19
+ packages/core/src/i18n/__tests__/useLocale.test.tsx,
20
+ packages/core/src/i18n/useTranslator.test.tsx,
21
+ ]
22
+ modules: []
23
+ families: []
24
+ design_specs: []
25
+ architecture: [architecture:internationalization]
26
+ contributing: []
27
+ system_specs: []
28
+ ---
29
+
30
+ # InternationalizationProvider component contract
31
+
32
+ ## Contract at a glance
33
+
34
+ | Area | Contract |
35
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
+ | Public contract | `messages` accepts `ProviderMessagesByLocale`: each locale maps to a rich `Catalog` or a generated `RuntimeCatalog` string map. `RuntimeCatalog` and `ProviderMessagesByLocale` are exported from `@astryxdesign/core/i18n`. Existing props and the rich `MessagesByLocale` context shape are unchanged. |
37
+ | Behavior | Astryx strings resolve override → supplied catalog → shipped English, walking exact → parent locale. Rich and runtime catalogs resolve identically; the context always exposes the rich shape. Changing any provider prop updates the subtree. A nested provider replaces its parent's context. Without a provider: locale `en`, direction `ltr`, shipped English. |
38
+ | End-user impact | People using a translated locale see Astryx interface and assistive text in that locale where a translation exists and meaningful English otherwise; a missing translation never removes an accessible name or announcement. |
39
+ | Builder impact | Optional: import `@astryxdesign/core/locales/<tag>.generated.js` for a smaller bundle; `<tag>.json` keeps working. Render the provider once, supply partial catalogs freely, and keep DOM `dir` aligned with the provider direction. |
40
+ | Compatibility/readiness | Released default preserved; the provider, subpath, props, hooks, and rich context shape are stable public API; runtime-catalog input is additive. Re-render, nested-provider, and runtime-catalog behavior is covered by focused tests. |
41
+ | Review checks | Reject reordering fallback (FR1, ORD1); hiding a key missing from every applicable source, including shipped English (FR2); a nested provider that merges parent context (FR8); the provider setting DOM `lang`/`dir` (FR5) or persisting locale (FR4); non-string translator results (FR7); host-locale output without a provider (FR6); a context value that exposes string-map entries or resolves rich and runtime input differently (FR9). |
42
+ | Governing rules | `architecture:internationalization` INV2–INV5, INV7, INV12, INV13, INV15. |
43
+
44
+ This table is a review projection; the body below is authoritative.
45
+
46
+ ## Intent
47
+
48
+ InternationalizationProvider binds one subtree to Astryx's active locale,
49
+ translation catalogs, sparse Astryx string overrides, and direction default. It
50
+ lets Astryx components localize their own interface while applications keep
51
+ ownership of product copy and locale persistence.
52
+
53
+ ## Compatibility and migration
54
+
55
+ - Released default preserved: `yes` — a tree with no provider continues to render
56
+ shipped English strings with locale `en` and direction `ltr`.
57
+ - Compatibility class: the released provider name, `@astryxdesign/core/i18n`
58
+ subpath, props, context behavior, and hooks are stable public API.
59
+ - Controlled/uncontrolled behavior: controlled. The caller supplies locale,
60
+ messages, overrides, and optional direction on every render.
61
+ - Migration decision: a replacement provider or hook requires an accepted
62
+ compatibility and migration decision under
63
+ `architecture:internationalization/INV2`.
64
+
65
+ Consumer migration instructions belong in consumer docs and release notes.
66
+
67
+ ## Ownership boundary
68
+
69
+ **Owns**
70
+
71
+ - The active Astryx locale and optional explicit direction for one React subtree.
72
+ - Binding supplied locale catalogs, rich or generated, and sparse overrides to a
73
+ stable translator function, and exposing them to consumers in the rich shape.
74
+ - Updating provider consumers when any provider input changes.
75
+ - The no-provider English/LTR context defaults exposed by the i18n entry point.
76
+
77
+ **Does not own / non-goals**
78
+
79
+ - Application locale selection, routing, persistence, or account preferences —
80
+ owned by the product.
81
+ - Product-authored strings and application catalogs — owned by product call sites
82
+ or their chosen i18n runtime.
83
+ - DOM `lang` or `dir` attributes — owned by the application document or region.
84
+ - A server/RSC translation context — unresolved separate API work.
85
+ - Rich React-node/function messages — outside the supported string catalog model.
86
+
87
+ ## Public concepts
88
+
89
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
90
+ | --------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------- | ---------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | Locale | Valid BCP 47 locale string | Selects lookup, ICU formatting, and provider-aware locale operations | Every provider subtree | Required on provider; `en` without one | `component:InternationalizationProvider` | stable | Callers must pass a valid tag; malformed tags may throw when formatting or collating, while static key lookup may still reach English |
92
+ | Messages | locale → `Catalog` or `RuntimeCatalog` | Adds translated or application-supplied catalogs, as rich `{defaultMessage, description?}` entries or as a generated key-to-string map, without replacing shipped English | Every locale | `{}` | `component:InternationalizationProvider` | stable | Missing entries fall back through the active locale's parent chain, then to shipped English; a supplied `en` catalog is read only when that chain includes `en`; mixed shapes within one call are accepted |
93
+ | Overrides | locale → sparse key/string map | Rewords individual Astryx strings before catalog lookup | Every locale | none | `component:InternationalizationProvider` | stable | `null` does not become visible content; unresolved keys continue through fallback |
94
+ | Direction | `ltr` or `rtl` | Exposes the provider's semantic direction default | Every provider subtree | derived from locale; `ltr` without provider | `component:InternationalizationProvider` | stable | Invalid values are rejected by the type contract |
95
+
96
+ Consumer prop syntax and examples remain in
97
+ `InternationalizationProvider.doc.mjs` and `astryx docs internationalization`.
98
+
99
+ ## Behavioral and layout contract
100
+
101
+ | ID | Candidate invariant | Basis | Draft review state |
102
+ | --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------ |
103
+ | FR1 | The provider MUST resolve Astryx strings through exact-locale overrides, parent-locale overrides, exact/parent supplied catalogs, then shipped English. | Current resolver and focused tests | settled |
104
+ | FR2 | A missing translated key MUST fall back silently; a key absent from every applicable source, including shipped English, MUST warn once in development and render the key visibly. | Released resolver and tests | settled |
105
+ | FR3 | Re-rendering with changed locale, messages, overrides, or direction MUST update the context value and translator used by descendants. | Released provider and rerender tests | settled |
106
+ | FR4 | The provider MUST NOT select, persist, or mutate application locale state. | Released API ownership | settled |
107
+ | FR5 | The provider MUST NOT set DOM `lang` or `dir`. Its direction value and the application's DOM direction are separate inputs the application keeps aligned. | `architecture:internationalization/INV12` | settled |
108
+ | FR6 | Without a provider, the public context and hooks MUST expose locale `en`, direction `ltr`, an empty supplied-catalog map, and a translator backed by shipped English. | Released context and tests | settled |
109
+ | FR7 | The provider MUST preserve a string-returning translator suitable for visible and assistive attributes. | Released catalog/runtime contract | settled |
110
+ | FR8 | A nested provider MUST replace locale, messages, overrides, and direction with values derived solely from the child provider's own props rather than merge parent context. | Released provider and nested-provider tests | settled |
111
+ | FR9 | The provider MUST accept a `RuntimeCatalog` string map or a rich `Catalog` per locale, resolve both to the same strings, and expose `messages` on the context in the rich `MessagesByLocale` shape regardless of input. | `architecture:internationalization/INV15`; provider and resolver runtime-catalog tests | settled |
112
+
113
+ ### Allowed variation
114
+
115
+ - **AV1 — Catalog completeness.** A non-English supplied catalog may contain any
116
+ subset of English keys; missing entries use fallback. Today that fallback
117
+ reaches shipped English only, so it covers Astryx keys; see OQ1.
118
+ - **AV2 — Locale specificity.** Callers may provide a regional, script, or base
119
+ locale. The resolver canonicalizes and walks the corresponding parent chain.
120
+ - **AV3 — Application integration.** Another i18n provider may wrap Astryx inside
121
+ or outside this provider as long as the application keeps locale state aligned.
122
+ - **AV4 — Catalog form.** A caller may pass the shipped rich JSON, the shipped
123
+ generated string map, or a local catalog in either shape, per locale.
124
+
125
+ ### Representative states
126
+
127
+ | State | Required invariant | Allowed variation |
128
+ | --------------------------------- | ------------------ | -------------------------------------------- |
129
+ | no provider | FR6 | none |
130
+ | English provider | FR1–FR4, FR7 | messages and overrides may be omitted |
131
+ | exact translated locale | FR1–FR4, FR7, FR9 | complete or partial catalog, rich or runtime |
132
+ | regional locale with base catalog | FR1–FR4, FR7 | number of parent tags |
133
+ | runtime locale swap | FR3 | application persistence mechanism |
134
+ | explicit direction override | FR3, FR5 | locale may remain LTR for testing |
135
+ | nested provider | FR1–FR8 | child props fully replace parent context |
136
+ | generated runtime catalog input | FR9 | mixed rich and runtime locales |
137
+
138
+ ### Transformation and precedence order
139
+
140
+ - **ORD1 — Message resolution.** Read exact-locale override → walk parent-locale
141
+ overrides → read exact-locale supplied catalog → walk parent supplied catalogs
142
+ → read shipped English → warn and render the key if absent everywhere.
143
+ - **ORD1a — Input normalization.** A `RuntimeCatalog` entry is treated as a rich
144
+ entry with that string as `defaultMessage` and no description before ORD1 runs;
145
+ the context receives the normalized rich map.
146
+ - **ORD2 — Direction resolution.** Use explicit `dir` when supplied; otherwise
147
+ derive direction from locale; outside a provider use `ltr`.
148
+
149
+ ### Performance and resources
150
+
151
+ - **PR1 — Stable translator identity.** A provider render with referentially
152
+ unchanged inputs SHOULD preserve its memoized context value and translator.
153
+ - **PR2 — Parsed message reuse.** ICU formatter instances MAY be cached by locale
154
+ and message while output remains equivalent to fresh formatting.
155
+
156
+ ## Accessibility contract
157
+
158
+ - **AR1 — Assistive strings use the same resolution contract.** `aria-label`,
159
+ `title`, live announcements, and other AT-facing Astryx strings MUST use the
160
+ active translator and remain strings; assembling them from fragments or
161
+ hand-rolled plurals is a bypass under `architecture:internationalization/INV1a`.
162
+ - **AR2 — Missing translations remain usable.** Expected non-English gaps MUST
163
+ fall back to meaningful English rather than remove an accessible name or
164
+ announcement.
165
+ - **AR3 — Direction remains available to browser semantics.** The provider MUST
166
+ document that applications set DOM `dir`; a context value alone does not fix
167
+ browser text flow, punctuation, focus order, or platform bidi behavior.
168
+
169
+ ## Design relationships
170
+
171
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
172
+ | -------------------- | ----------------------------------------------------------------------------- | ----------------------------------- | -------------- | ------------------ |
173
+ | Provider subtree | Product chooses the locale and region direction consistently. | application-owned | supporting | FR3–FR5 |
174
+ | Astryx system string | Astryx supplies a localized default; caller content may specialize semantics. | `architecture:internationalization` | supporting | FR1, FR7, AR1 |
175
+ | Missing translation | Interface remains understandable through English fallback. | `architecture:internationalization` | supporting | FR2, AR2 |
176
+
177
+ ### Theming anatomy
178
+
179
+ <!-- anatomy-theming:v1 -->
180
+
181
+ ```json
182
+ {
183
+ "Provider boundary": {
184
+ "none": {
185
+ "reason": "intentional: InternationalizationProvider renders no DOM element and exposes no theming anatomy."
186
+ }
187
+ }
188
+ }
189
+ ```
190
+
191
+ ## Family and system relationships
192
+
193
+ - `architecture:internationalization` owns cross-library catalog, locale,
194
+ formatter, date-calendar, output-type, external-runtime, and generated
195
+ runtime-catalog delivery boundaries (which files are authored, which are
196
+ derived, how they are published and resolved).
197
+ - Component contracts own which strings are Astryx-authored and which content is
198
+ caller-authored.
199
+
200
+ ## Verification map
201
+
202
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
203
+ | -------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
204
+ | FR1, FR2, ORD1 | `resolve.test.ts` | exact, parent, English, missing source key | Reordering fallback or hiding a missing source key fails focused tests | `audit:InternationalizationProvider/behavior` |
205
+ | FR3, PR1 | `useTranslator.test.tsx`, `useLocale.test.tsx`, `InternationalizationProvider.test.tsx` | stable inputs; locale, message, override, and dir swaps | A stale translator/direction or needless identity churn fails these tests | `audit:InternationalizationProvider/performance` |
206
+ | FR5, ORD2 | `useDirection.test.tsx`, `getLocaleDirection.test.ts` | derived, explicit, invalid locale, no provider | Setting DOM direction or ignoring explicit override fails source/tests | `audit:InternationalizationProvider/rtl` |
207
+ | FR6 | `useTranslator.test.tsx`, `useLocale.test.tsx`, `useDirection.test.tsx` | no provider | Host locale or missing English output fails | `audit:InternationalizationProvider/behavior` |
208
+ | FR7, AR1, AR2 | component translation tests and hardcoded-string lint | visible text, aria label, announcement, missing translation | Non-string output or untranslated owned text fails tests/lint | `audit:InternationalizationProvider/accessibility` |
209
+ | FR8 | `InternationalizationProvider.test.tsx` | parent plus child provider | Parent messages, overrides, or direction leaking into the child fails | `audit:InternationalizationProvider/behavior` |
210
+ | FR9, ORD1a | `useTranslator.test.tsx`, `resolve.test.ts` runtime-catalog cases | generated string map, rich catalog, mixed | A rejected string map, a string-map entry on the context, or divergent output between shapes fails | `audit:InternationalizationProvider/behavior` |
211
+
212
+ ## Decision log
213
+
214
+ ### DEC-1 — Keep the released Astryx runtime narrow
215
+
216
+ **Reference:** `component:InternationalizationProvider/DEC-1`
217
+ **Decider:** `nynexman4464`, `2026-09-03`
218
+
219
+ Astryx keeps `InternationalizationProvider` and the released i18n hooks as its
220
+ canonical runtime. Astryx may support small applications, but it does not absorb
221
+ application-owned rich messages and functions to compete with full i18n
222
+ frameworks. Applications with larger needs use an established application i18n
223
+ runtime alongside Astryx; an adapter remains possible as additive follow-up.
224
+
225
+ Rejected: a parallel provider/hook runtime that replaces the released surface
226
+ and mixes Astryx string ownership with application rich-message ownership.
227
+
228
+ ## Open questions
229
+
230
+ - **OQ1 — Supplied `en` as a fallback for non-Astryx keys (known bug).** The
231
+ shipped English runtime catalog is the only cross-locale fallback and it holds
232
+ Astryx keys only. An application or library key that is missing from the active
233
+ locale therefore renders as its raw key even when the caller supplied an `en`
234
+ catalog. This is a known cross-locale fallback defect. The contract describes
235
+ the shipped behavior without endorsing the limitation; a supplied-English
236
+ fallback requires a corresponding FR1/ORD1 amendment and a regression test
237
+ using a partially translated non-Astryx key. (`human-api`)
238
+ - **OQ2 — Malformed locale behavior.** Decide whether a future release should
239
+ reject invalid BCP 47 tags at the provider boundary or preserve today's
240
+ formatter-specific throws. (`human-api`)
241
+ - **OQ3 — External runtime adapter.** Decide the exact additive `Translator`
242
+ adapter contract, including who owns lookup, fallback, and formatter failures.
243
+ (`human-api`)
244
+ - **OQ4 — Server translation runtime.** Decide whether the small set of otherwise
245
+ server-safe consumers justifies a separate RSC translation API and request
246
+ isolation model. (`human-api`)
247
+
248
+ ## Content boundary
249
+
250
+ This file does not duplicate consumer prop tables/examples, catalog validation
251
+ implementation, runtime-catalog generation, current translation coverage, or
252
+ cross-library i18n rules. It links to their owners.