@eifi1/ui-kit 0.17.0 → 0.19.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 (210) hide show
  1. package/README.md +83 -4
  2. package/dist/chart.d.ts +13 -1
  3. package/dist/components/account-chips.d.ts +153 -0
  4. package/dist/components/account-chips.js +135 -0
  5. package/dist/components/account-chips.js.map +1 -0
  6. package/dist/components/account-settings-labels.d.ts +10 -2
  7. package/dist/components/account-settings-labels.js +3 -0
  8. package/dist/components/account-settings-labels.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +13 -1
  10. package/dist/components/button-group.d.ts +13 -1
  11. package/dist/components/calculator.d.ts +13 -1
  12. package/dist/components/chip.d.ts +25 -1
  13. package/dist/components/chip.js +143 -6
  14. package/dist/components/chip.js.map +1 -1
  15. package/dist/components/confirm-dialog.d.ts +42 -1
  16. package/dist/components/confirm-dialog.js +36 -5
  17. package/dist/components/confirm-dialog.js.map +1 -1
  18. package/dist/components/copy-button.d.ts +13 -1
  19. package/dist/components/danger-confirm.d.ts +55 -4
  20. package/dist/components/danger-confirm.js +67 -30
  21. package/dist/components/danger-confirm.js.map +1 -1
  22. package/dist/components/data-table-cells.d.ts +1 -1
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table.d.ts +1 -1
  26. package/dist/components/data-table.js +2 -0
  27. package/dist/components/data-table.js.map +1 -1
  28. package/dist/components/facing-pair.d.ts +13 -1
  29. package/dist/components/file-button.d.ts +13 -1
  30. package/dist/components/file-dropzone.d.ts +13 -1
  31. package/dist/components/floating-panel.d.ts +4 -1
  32. package/dist/components/floating-panel.js +15 -1
  33. package/dist/components/floating-panel.js.map +1 -1
  34. package/dist/components/form-actions.d.ts +13 -1
  35. package/dist/components/form-actions.js +3 -0
  36. package/dist/components/form-actions.js.map +1 -1
  37. package/dist/components/legal.d.ts +84 -0
  38. package/dist/components/legal.js +55 -0
  39. package/dist/components/legal.js.map +1 -0
  40. package/dist/components/money-field.d.ts +13 -1
  41. package/dist/components/number-field.d.ts +14 -2
  42. package/dist/components/number-field.js +23 -22
  43. package/dist/components/number-field.js.map +1 -1
  44. package/dist/components/number-input.d.ts +13 -1
  45. package/dist/components/numpad-sheet.d.ts +13 -1
  46. package/dist/components/passkeys-setting.d.ts +27 -1
  47. package/dist/components/passkeys-setting.js +31 -5
  48. package/dist/components/passkeys-setting.js.map +1 -1
  49. package/dist/components/progress-bar.d.ts +15 -1
  50. package/dist/components/progress-bar.js +8 -1
  51. package/dist/components/progress-bar.js.map +1 -1
  52. package/dist/components/reauth-dialog.d.ts +90 -0
  53. package/dist/components/reauth-dialog.js +158 -0
  54. package/dist/components/reauth-dialog.js.map +1 -0
  55. package/dist/components/series-chart.d.ts +13 -1
  56. package/dist/components/server-wake.d.ts +60 -0
  57. package/dist/components/server-wake.js +79 -0
  58. package/dist/components/server-wake.js.map +1 -0
  59. package/dist/components/settings-fields.d.ts +13 -1
  60. package/dist/components/share-card.d.ts +64 -0
  61. package/dist/components/share-card.js +350 -0
  62. package/dist/components/share-card.js.map +1 -0
  63. package/dist/components/status-dot.d.ts +2 -0
  64. package/dist/components/text-link.d.ts +13 -1
  65. package/dist/components/time-input.d.ts +13 -1
  66. package/dist/components/toast.d.ts +10 -1
  67. package/dist/components/toast.js +42 -2
  68. package/dist/components/toast.js.map +1 -1
  69. package/dist/components/toggle-group.js +5 -2
  70. package/dist/components/toggle-group.js.map +1 -1
  71. package/dist/components/translation-review-editor.d.ts +56 -0
  72. package/dist/components/translation-review-editor.js +159 -0
  73. package/dist/components/translation-review-editor.js.map +1 -0
  74. package/dist/components/translation-review-labels.d.ts +92 -0
  75. package/dist/components/translation-review-labels.js +67 -0
  76. package/dist/components/translation-review-labels.js.map +1 -0
  77. package/dist/components/translation-review-parts.d.ts +97 -0
  78. package/dist/components/translation-review-parts.js +163 -0
  79. package/dist/components/translation-review-parts.js.map +1 -0
  80. package/dist/components/translation-review.d.ts +94 -0
  81. package/dist/components/translation-review.js +344 -0
  82. package/dist/components/translation-review.js.map +1 -0
  83. package/dist/components/ui.d.ts +13 -1
  84. package/dist/components/ui.js +43 -28
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/components/use-table-state.d.ts +1 -1
  87. package/dist/components/user-avatar.d.ts +2 -0
  88. package/dist/components/write-lock.d.ts +68 -0
  89. package/dist/components/write-lock.js +33 -0
  90. package/dist/components/write-lock.js.map +1 -0
  91. package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
  92. package/dist/data-table.d.ts +1 -1
  93. package/dist/hooks/use-file-drop.d.ts +13 -1
  94. package/dist/hooks/use-overlay-history.js +16 -0
  95. package/dist/hooks/use-overlay-history.js.map +1 -1
  96. package/dist/i18n/defaults.d.ts +13 -1
  97. package/dist/i18n/defaults.js +15 -1
  98. package/dist/i18n/defaults.js.map +1 -1
  99. package/dist/i18n/german.d.ts +81 -0
  100. package/dist/i18n/{locales/de.js → german.js} +110 -9
  101. package/dist/i18n/german.js.map +1 -0
  102. package/dist/i18n/kit-labels.d.ts +13 -1
  103. package/dist/i18n/kit-labels.js +5 -0
  104. package/dist/i18n/kit-labels.js.map +1 -1
  105. package/dist/i18n/languages.d.ts +243 -0
  106. package/dist/i18n/languages.js +70 -0
  107. package/dist/i18n/languages.js.map +1 -0
  108. package/dist/i18n/locales/de-CH.d.ts +26 -8
  109. package/dist/i18n/locales/de-CH.js +7 -3
  110. package/dist/i18n/locales/de-CH.js.map +1 -1
  111. package/dist/i18n/locales/en.d.ts +13 -1
  112. package/dist/i18n/locales/en.js +8 -0
  113. package/dist/i18n/locales/en.js.map +1 -1
  114. package/dist/i18n/locales/es.d.ts +13 -1
  115. package/dist/i18n/locales/es.js +127 -27
  116. package/dist/i18n/locales/es.js.map +1 -1
  117. package/dist/i18n/locales/fr.d.ts +13 -1
  118. package/dist/i18n/locales/fr.js +101 -1
  119. package/dist/i18n/locales/fr.js.map +1 -1
  120. package/dist/i18n/locales/hu.d.ts +13 -1
  121. package/dist/i18n/locales/hu.js +105 -1
  122. package/dist/i18n/locales/hu.js.map +1 -1
  123. package/dist/i18n/locales/it.d.ts +13 -1
  124. package/dist/i18n/locales/it.js +134 -34
  125. package/dist/i18n/locales/it.js.map +1 -1
  126. package/dist/i18n/locales/zh.d.ts +13 -1
  127. package/dist/i18n/locales/zh.js +101 -1
  128. package/dist/i18n/locales/zh.js.map +1 -1
  129. package/dist/i18n/review.d.ts +102 -0
  130. package/dist/i18n/review.js +158 -0
  131. package/dist/i18n/review.js.map +1 -0
  132. package/dist/i18n/swiss.d.ts +3 -3
  133. package/dist/i18n/swiss.js.map +1 -1
  134. package/dist/index.d.ts +17 -4
  135. package/dist/index.js +97 -1
  136. package/dist/index.js.map +1 -1
  137. package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-D1J2_jyD.d.ts} +236 -9
  138. package/dist/lib/floating-stack.d.ts +31 -0
  139. package/dist/lib/floating-stack.js +42 -0
  140. package/dist/lib/floating-stack.js.map +1 -0
  141. package/dist/lib/server-wake.d.ts +146 -0
  142. package/dist/lib/server-wake.js +122 -0
  143. package/dist/lib/server-wake.js.map +1 -0
  144. package/dist/lib/translation-review.d.ts +232 -0
  145. package/dist/lib/translation-review.js +194 -0
  146. package/dist/lib/translation-review.js.map +1 -0
  147. package/dist/rhf/fields.d.ts +13 -1
  148. package/dist/rhf/form.d.ts +13 -1
  149. package/dist/rhf.d.ts +13 -1
  150. package/dist/search/global-search.d.ts +3 -1
  151. package/dist/search/global-search.js.map +1 -1
  152. package/dist/shell/app-shell.d.ts +13 -1
  153. package/dist/shell/top-bar-brand.d.ts +13 -1
  154. package/dist/shell/topbar-action-menu.d.ts +2 -0
  155. package/dist/shell.d.ts +13 -1
  156. package/dist/wizard/stepper-nav.d.ts +13 -1
  157. package/dist/wizard.d.ts +13 -1
  158. package/package.json +1 -1
  159. package/src/components/account-chips.tsx +302 -0
  160. package/src/components/account-settings-labels.ts +16 -2
  161. package/src/components/chip.tsx +239 -6
  162. package/src/components/confirm-dialog.tsx +82 -7
  163. package/src/components/danger-confirm.tsx +118 -33
  164. package/src/components/data-table.tsx +20 -0
  165. package/src/components/floating-panel.tsx +19 -1
  166. package/src/components/form-actions.tsx +22 -3
  167. package/src/components/legal.tsx +159 -0
  168. package/src/components/number-field.tsx +32 -26
  169. package/src/components/passkeys-setting.tsx +68 -7
  170. package/src/components/progress-bar.tsx +21 -1
  171. package/src/components/reauth-dialog.tsx +273 -0
  172. package/src/components/server-wake.tsx +156 -0
  173. package/src/components/share-card.tsx +615 -0
  174. package/src/components/toast.tsx +72 -2
  175. package/src/components/toggle-group.tsx +5 -2
  176. package/src/components/translation-review-editor.tsx +261 -0
  177. package/src/components/translation-review-labels.ts +152 -0
  178. package/src/components/translation-review-parts.tsx +269 -0
  179. package/src/components/translation-review.tsx +502 -0
  180. package/src/components/ui.tsx +89 -32
  181. package/src/components/write-lock.tsx +96 -0
  182. package/src/hooks/use-overlay-history.ts +51 -0
  183. package/src/i18n/defaults.ts +14 -0
  184. package/src/i18n/{locales/de.ts → german.ts} +120 -13
  185. package/src/i18n/kit-labels.tsx +29 -2
  186. package/src/i18n/languages.ts +285 -0
  187. package/src/i18n/locales/de-CH.ts +17 -8
  188. package/src/i18n/locales/en.ts +8 -0
  189. package/src/i18n/locales/es.ts +128 -26
  190. package/src/i18n/locales/fr.ts +103 -0
  191. package/src/i18n/locales/hu.ts +107 -0
  192. package/src/i18n/locales/it.ts +135 -33
  193. package/src/i18n/locales/zh.ts +102 -0
  194. package/src/i18n/review.ts +193 -0
  195. package/src/i18n/swiss.ts +3 -3
  196. package/src/index.ts +86 -1
  197. package/src/lib/floating-stack.ts +66 -0
  198. package/src/lib/server-wake.ts +291 -0
  199. package/src/lib/translation-review.ts +465 -0
  200. package/src/search/global-search.tsx +3 -1
  201. package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
  202. package/dist/i18n/locales/de-CH-informal.js +0 -8
  203. package/dist/i18n/locales/de-CH-informal.js.map +0 -1
  204. package/dist/i18n/locales/de-informal.d.ts +0 -82
  205. package/dist/i18n/locales/de-informal.js +0 -37
  206. package/dist/i18n/locales/de-informal.js.map +0 -1
  207. package/dist/i18n/locales/de.d.ts +0 -66
  208. package/dist/i18n/locales/de.js.map +0 -1
  209. package/src/i18n/locales/de-CH-informal.ts +0 -10
  210. package/src/i18n/locales/de-informal.ts +0 -61
@@ -46,6 +46,13 @@ import type { ErrorBoundaryLabels } from "../components/error-boundary";
46
46
  import type { AuthedImageLabels } from "../components/authed-image";
47
47
  import type { ImageGridLabels } from "../components/image-grid";
48
48
  import type { LightboxLabels } from "../components/lightbox";
49
+ import type { WriteLockLabels } from "../components/write-lock";
50
+ import type { LegalLabels } from "../components/legal";
51
+ import type { AccountStateLabels } from "../components/account-chips";
52
+ import type { ShareCardLabels } from "../components/share-card";
53
+ import type { ReauthDialogLabels } from "../components/reauth-dialog";
54
+ import type { ServerWakeLabels } from "../components/server-wake";
55
+ import type { TranslationReviewLabels } from "../components/translation-review-labels";
49
56
 
50
57
  /**
51
58
  * EVERY string the kit renders, as one typed tree — and an optional provider that
@@ -274,6 +281,19 @@ export interface UiKitLabels {
274
281
  authedImage: AuthedImageLabels;
275
282
  imageGrid: ImageGridLabels;
276
283
  lightbox: LightboxLabels;
284
+ /** 0.18.0: `WriteLockProvider`'s fallback reason, `AccountStateChip`, `SharePanel` /
285
+ * `ShareCard` / `ShareDialog`, `ReauthDialog`. */
286
+ writeLock: WriteLockLabels;
287
+ accountState: AccountStateLabels;
288
+ shareCard: ShareCardLabels;
289
+ reauthDialog: ReauthDialogLabels;
290
+ /** 0.18.0: `ServerWakeNotice` — keksdose's cold-start notice, for every app. */
291
+ serverWake: ServerWakeLabels;
292
+ /** 0.19.0: the translation-review parts (`TranslationReviewPanel`, its editor, chip,
293
+ * progress, locale tabs and export) — keksdose's /translations, for every app. */
294
+ translationReview: TranslationReviewLabels;
295
+ /** 0.19.0: `LegalLinks`' navigation name — the legal pages' shell, for every app. */
296
+ legal: LegalLabels;
277
297
  }
278
298
 
279
299
  /**
@@ -621,17 +641,24 @@ export function useKitFileLabels(prop?: Partial<FileLabels>): FileLabels {
621
641
  * The dot paths of every key `labels` does NOT supply — the ones a translated app is
622
642
  * still showing in English. Assert on it in the app's own test:
623
643
  *
624
- * expect(missingKitLabels(de)).toEqual([]);
644
+ * expect(missingKitLabels(labels, DEFAULT_UI_KIT_LABELS)).toEqual([]);
625
645
  *
626
646
  * `reference` is the complete tree to check against — pass
627
647
  * {@link DEFAULT_UI_KIT_LABELS} (src/i18n/defaults.ts). It is a parameter rather than an import
628
648
  * because the defaults live beside their components, and this module is imported BY
629
- * those components.
649
+ * those components. Left out (a test file is not type-checked), it throws saying so
650
+ * rather than "Cannot convert undefined or null to object" (keksdose, 0.18).
630
651
  */
631
652
  export function missingKitLabels(
632
653
  labels: UiKitLabelOverrides | undefined,
633
654
  reference: UiKitLabels,
634
655
  ): string[] {
656
+ if (!isRecord(reference)) {
657
+ throw new TypeError(
658
+ "missingKitLabels(labels, reference): pass DEFAULT_UI_KIT_LABELS as the reference — " +
659
+ "it is the tree the labels are checked against.",
660
+ );
661
+ }
635
662
  const missing: string[] = [];
636
663
  for (const [ns, keys] of Object.entries(reference)) {
637
664
  const given = (labels as Record<string, Record<string, unknown> | undefined> | undefined)?.[ns];
@@ -0,0 +1,285 @@
1
+ import type { UiKitLabels } from "./kit-labels";
2
+ import type { LanguageOption } from "../shell/topbar-controls";
3
+
4
+ /**
5
+ * "Which language is this reader in, and what does the kit say in it?" — the one
6
+ * answer the three apps each wrote for themselves (docs/i18n-harmonization.md, H2).
7
+ *
8
+ * kastlan, keksdose and Kurvenschmiede all ship a subset of the same seven languages,
9
+ * and every one of them hand-rolled the same three things: a list of languages with
10
+ * their names and flags, a rule for turning what a browser asks for (`de-AT`, `zh-TW`,
11
+ * `en-AU`) into a code the app ships, and a bridge from that code to the kit's label
12
+ * catalogue. The three disagreed where nobody was looking: a reader asking for `zh-TW`
13
+ * got German in kastlan and Chinese in Kurvenschmiede; keksdose's English got the raw
14
+ * defaults, so its counts read "12345" where the other two wrote "12,345"; kastlan
15
+ * formatted French as `fr-CH` and Kurvenschmiede as `fr-FR`. None of it was a decision.
16
+ * This module makes it one, in one place, tested once.
17
+ *
18
+ * WHAT IT IS NOT: an i18n library. It resolves no app strings, carries no detector and
19
+ * depends on nothing — the app keeps i18next (or whatever it uses) for its own words and
20
+ * asks this module only the questions the kit can answer for all three apps alike:
21
+ * - {@link KIT_LANGUAGES}: the seven codes, each with its own name, its English name,
22
+ * the flag it flies and the locale it formats in;
23
+ * - {@link resolveLanguage}: which of the codes an app offers answers a reader;
24
+ * - {@link formatLocaleOf}: the `Intl` tag for a code (the provider's `locale`,
25
+ * `<html lang>`, every `toLocaleString` in the app);
26
+ * - {@link loadUiKitLabels}: the kit's catalogue for a code, fetched on demand;
27
+ * - {@link languageOptions}: the rows `LanguageMenu` and `LanguageSetting` take.
28
+ *
29
+ * THE CODE AND THE FORMATTING LOCALE ARE TWO THINGS (Kurvenschmiede's insight, made
30
+ * the rule). The code names a catalogue — what the account stores, what i18next
31
+ * resolves, what a mail is chosen by — and a bare `en` says nothing about how a date is
32
+ * written. Each language formats like its home (Marcel, 2026-10-02, H6): `de-CH`,
33
+ * `en-GB` (British, like the spelling), `es-ES`, `fr-FR`, `it-IT`, `hu-HU`, `zh-CN`. An
34
+ * app that must format otherwise — kastlan's QR bill follows the SIX spec, not the
35
+ * reader — passes its own locale where it needs it; the registry is the default.
36
+ *
37
+ * THERE IS ONE GERMAN, `de-CH`: Swiss Standard German, formal (0.18). Every other
38
+ * German a browser or an old localStorage can hand over — `de`, `de-DE`, `de-AT`,
39
+ * `de-LI`, the briefly offered `de-informal` / `de-CH-informal` — is read as it.
40
+ * Its name is plain "Deutsch": it is the only German, so there is nothing to tell it
41
+ * apart from, and the Swiss flag beside it is what says where the spelling comes from.
42
+ *
43
+ * AND ONE CHINESE, `zh`, WHICH IS SIMPLIFIED. `zh-TW`, `zh-HK` and `zh-Hant` resolve to
44
+ * it: there is no Traditional catalogue, and a reader of Traditional characters reads
45
+ * Simplified far more readily than whichever second language a fallback would guess
46
+ * at (Kurvenschmiede's reasoning, now everyone's). Its name says "简体中文" so that
47
+ * reader sees what they are getting before they pick it.
48
+ */
49
+
50
+ /** The seven languages the kit ships a catalogue for. An app offers a subset. */
51
+ export type KitLanguageCode = "de-CH" | "en" | "es" | "fr" | "it" | "hu" | "zh";
52
+
53
+ /** One language the kit ships: what it is called, which flag it flies and which
54
+ * BCP 47 tag it formats in. */
55
+ export interface KitLanguage {
56
+ /** The catalogue code — what an app stores, sends and hands i18next. */
57
+ readonly code: KitLanguageCode;
58
+ /** The language's name in itself, for a switcher: the reader looking for theirs may
59
+ * not read the language the page is currently in. */
60
+ readonly nativeName: string;
61
+ /** The name in English, for places that list languages to a team rather than to a
62
+ * reader (a translation review's locale column, an admin's reviewer grant). */
63
+ readonly englishName: string;
64
+ /** The BCP 47 tag the language formats numbers and dates in, and declares itself in
65
+ * on `<html lang>`. See {@link formatLocaleOf}. */
66
+ readonly formatLocale: string;
67
+ /** ISO 3166-1 alpha-2 country (lowercase) for flag-icons' `fi fi-<flag>` class —
68
+ * NOT the language code: English flies the UK flag, German the Swiss one, Chinese
69
+ * the PRC's (the catalogue is Simplified). The kit renders the class name only; the
70
+ * app supplies the flag stylesheet (README, "What you install alongside it"). */
71
+ readonly flag: string;
72
+ }
73
+
74
+ /**
75
+ * Every language the kit ships, in the order a switcher lists them: alphabetical by
76
+ * native name (Deutsch, English, Español … 简体中文), which is what a reader scanning for
77
+ * their own language's name expects.
78
+ *
79
+ * An app offering four of them passes those four codes to {@link resolveLanguage} and
80
+ * {@link languageOptions}; it does not copy this list. A language added here is one the
81
+ * kit has a catalogue for (the loader map below is typed by {@link KitLanguageCode}, so
82
+ * a code without one does not compile).
83
+ */
84
+ export const KIT_LANGUAGES: readonly KitLanguage[] = [
85
+ { code: "de-CH", nativeName: "Deutsch", englishName: "German", formatLocale: "de-CH", flag: "ch" },
86
+ { code: "en", nativeName: "English", englishName: "English", formatLocale: "en-GB", flag: "gb" },
87
+ { code: "es", nativeName: "Español", englishName: "Spanish", formatLocale: "es-ES", flag: "es" },
88
+ { code: "fr", nativeName: "Français", englishName: "French", formatLocale: "fr-FR", flag: "fr" },
89
+ { code: "it", nativeName: "Italiano", englishName: "Italian", formatLocale: "it-IT", flag: "it" },
90
+ { code: "hu", nativeName: "Magyar", englishName: "Hungarian", formatLocale: "hu-HU", flag: "hu" },
91
+ { code: "zh", nativeName: "简体中文", englishName: "Chinese (Simplified)", formatLocale: "zh-CN", flag: "cn" },
92
+ ];
93
+
94
+ const ALL_CODES: readonly KitLanguageCode[] = KIT_LANGUAGES.map((language) => language.code);
95
+
96
+ /** The kit's home language: what a reader nothing else matches gets, when it is offered. */
97
+ const HOME: KitLanguageCode = "de-CH";
98
+
99
+ /** The language part of a tag, lower-cased: `de-CH-informal` → `de`, `zh_Hant` → `zh`. */
100
+ const languagePart = (tag: string) => tag.split("-")[0].toLowerCase();
101
+
102
+ /**
103
+ * The offered code that answers ONE requested tag, or `undefined`.
104
+ *
105
+ * Exact first, ignoring case (`DE-ch` → `de-CH`), then by language part: since the kit
106
+ * has exactly one code per language, "the offered code whose language is the tag's
107
+ * language" is the whole of the rule, and it is what takes every German to `de-CH`
108
+ * (`de-AT`, `de-informal`), every Chinese to `zh` (`zh-TW`, `zh-Hant-HK`) and every
109
+ * regional form to its base (`fr-CA` → `fr`, `en-AU` → `en`). A language the app does
110
+ * not offer answers nothing, so the caller moves on to the reader's next preference
111
+ * rather than landing on a guess. Underscores are read as hyphens (`de_CH`), the way a
112
+ * Java or Python backend may have stored the account's locale.
113
+ */
114
+ function answer(
115
+ candidate: unknown,
116
+ offered: readonly KitLanguageCode[],
117
+ ): KitLanguageCode | undefined {
118
+ if (typeof candidate !== "string") return undefined;
119
+ const tag = candidate.trim().replace(/_/g, "-");
120
+ if (!tag) return undefined;
121
+ const lower = tag.toLowerCase();
122
+ const exact = offered.find((code) => code.toLowerCase() === lower);
123
+ if (exact) return exact;
124
+ const language = languagePart(tag);
125
+ return offered.find((code) => languagePart(code) === language);
126
+ }
127
+
128
+ /**
129
+ * Which of the codes an app offers answers this reader.
130
+ *
131
+ * `candidates` are the reader's preferences **in precedence order**, and the first
132
+ * one that matches wins — so the order is the policy. The one the harmonisation plan
133
+ * gives every app (H5):
134
+ *
135
+ * resolveLanguage(
136
+ * [localStorage.getItem("<app>-lang"), user?.locale, ...navigator.languages],
137
+ * OFFERED,
138
+ * );
139
+ *
140
+ * the choice stored on this device (the reader picked it, here) → the account's
141
+ * language (read back on sign-in, so a new device starts in it) → the browser's list,
142
+ * in its own order → the fallback. Missing sources are simply `null` / `undefined`;
143
+ * they are skipped, as is an empty string.
144
+ *
145
+ * Each candidate is matched exactly (case-insensitively), then by language — every
146
+ * German variant to `de-CH`, every Chinese to `zh` (Simplified), any other regional
147
+ * form to its base language (`fr-CA` → `fr`, `en-AU` → `en`) — and only within
148
+ * `offered`: a reader whose browser says `es-MX` first and `en` second, in an app that
149
+ * offers no Spanish, gets English rather than the fallback. That is the difference
150
+ * from a single-tag lookup, and why the browser's whole list is passed, not its first
151
+ * entry.
152
+ *
153
+ * `fallback` is what a reader nothing matches gets: by default `de-CH` when the app
154
+ * offers it — the kit's and the three apps' home language — else the app's first
155
+ * offered code. It is returned as given, offered or not; the app chose it.
156
+ *
157
+ * Why not i18next's own matcher (`getBestMatchFromCodes`, which Kurvenschmiede calls):
158
+ * it needs an i18next instance, it reduces `de-AT` to a `de` none of the apps ships
159
+ * unless every caller remembers a converter first, and its answer to a list depends on
160
+ * `supportedLngs` and `nonExplicitSupportedLngs` being set exactly so. This is a pure
161
+ * function of its arguments, so the switcher, the account form, a server-rendered
162
+ * guess and a test all get the same answer without one.
163
+ */
164
+ export function resolveLanguage(
165
+ candidates: readonly (string | null | undefined)[],
166
+ offered: readonly KitLanguageCode[],
167
+ fallback?: KitLanguageCode,
168
+ ): KitLanguageCode {
169
+ for (const candidate of candidates) {
170
+ const found = answer(candidate, offered);
171
+ if (found) return found;
172
+ }
173
+ return fallback ?? (offered.includes(HOME) ? HOME : (offered[0] ?? HOME));
174
+ }
175
+
176
+ /**
177
+ * The BCP 47 tag a language formats in — `formatLocaleOf("en")` is `"en-GB"`.
178
+ *
179
+ * For `<UiKitProvider locale>`, for `<html lang>` (a screen reader pronounces by it,
180
+ * and `en` alone does not say which English) and for the app's own `Intl` calls, so
181
+ * the kit's counts and the app's amounts group their digits the same way.
182
+ *
183
+ * Takes any tag, not only a {@link KitLanguageCode}, and resolves it the way
184
+ * {@link resolveLanguage} does against all seven: `de-AT` formats as `de-CH`, `en-AU`
185
+ * as `en-GB` (the language's home, by decision — not the reader's region), and a
186
+ * language the kit does not ship as the home language, `de-CH`. So an app can hand it
187
+ * i18next's `resolvedLanguage` as it comes.
188
+ */
189
+ export function formatLocaleOf(code: string): string {
190
+ const resolved = resolveLanguage([code], ALL_CODES);
191
+ return KIT_LANGUAGES.find((language) => language.code === resolved)!.formatLocale;
192
+ }
193
+
194
+ /**
195
+ * One loader per catalogue, each a LITERAL relative `import()`.
196
+ *
197
+ * The literal is load-bearing twice. The library is built one file per module
198
+ * (`bundle: false`, tsup.config.ts), so `./locales/fr` stays a module of its own in
199
+ * dist/ and `scripts/fix-esm-extensions.mjs` gives the specifier its `.js`. And an
200
+ * app's bundler can only split what it can read: a path assembled from a code is a
201
+ * chunk rollup cannot make, and the failure is at runtime. With literals, each
202
+ * language is a chunk of its own and a reader downloads exactly theirs — about 25 kB of
203
+ * JavaScript a catalogue, so an entry chunk holding all of them would carry six that
204
+ * the reader never uses.
205
+ *
206
+ * Relative, not `@eifi1/ui-kit/i18n/fr`: a package importing itself by name resolves
207
+ * through the consumer's node_modules, which a linked checkout or a test of this repo
208
+ * does not have.
209
+ */
210
+ const LOADERS: Record<KitLanguageCode, (formatLocale: string) => Promise<UiKitLabels>> = {
211
+ "de-CH": async (locale) => (await import("./locales/de-CH")).uiKitLabelsDeCh(locale),
212
+ en: async (locale) => (await import("./locales/en")).uiKitLabelsEn(locale),
213
+ es: async (locale) => (await import("./locales/es")).uiKitLabelsEs(locale),
214
+ fr: async (locale) => (await import("./locales/fr")).uiKitLabelsFr(locale),
215
+ it: async (locale) => (await import("./locales/it")).uiKitLabelsIt(locale),
216
+ hu: async (locale) => (await import("./locales/hu")).uiKitLabelsHu(locale),
217
+ zh: async (locale) => (await import("./locales/zh")).uiKitLabelsZh(locale),
218
+ };
219
+
220
+ /** One promise per (language, format locale), so asking twice is one fetch and one
221
+ * object: the provider's context value keeps its identity, and every kit component
222
+ * reading it is spared a re-render on a switch back. */
223
+ const loaded = new Map<string, Promise<UiKitLabels>>();
224
+
225
+ /**
226
+ * The kit's catalogue for a language, fetched on demand and formatted in
227
+ * `formatLocale` — for `<UiKitProvider labels={…}>`.
228
+ *
229
+ * Every language goes through its catalogue's factory, English included:
230
+ * `uiKitLabelsEn("en-GB")` writes "12,345 rows" where the bare
231
+ * `DEFAULT_UI_KIT_LABELS` print "12345" (and keksdose's English did, by passing no
232
+ * labels at all). `formatLocale` defaults to the language's own
233
+ * ({@link formatLocaleOf}); pass another only for a reason, as the factories' own
234
+ * docs describe (`"de-DE"` keeps the Swiss words and writes German digits).
235
+ *
236
+ * `code` may be any tag and is resolved like {@link formatLocaleOf}'s, so `de` loads
237
+ * the one German and a language the kit does not ship loads `de-CH` — the same answer
238
+ * the app's own fallback gives, rather than English beside the app's German.
239
+ *
240
+ * Asynchronous on purpose, and there is no synchronous twin: one would have to import
241
+ * all seven catalogues statically, which puts every language in every app's entry
242
+ * chunk — exactly what one subpath per language exists to prevent. Await it before the
243
+ * first render, beside the app's own catalogue (the apps already await i18next's init
244
+ * for the same reason: no frame in the wrong language), and keep the result in state.
245
+ * To change a few words, spread over what it returns rather than copying a catalogue:
246
+ * a copy misses every key a later release adds.
247
+ *
248
+ * A rejected fetch (a deploy replaced the chunk, the device went offline) is not
249
+ * remembered, so the next call tries again.
250
+ */
251
+ export function loadUiKitLabels(
252
+ code: string,
253
+ formatLocale: string = formatLocaleOf(code),
254
+ ): Promise<UiKitLabels> {
255
+ const language = resolveLanguage([code], ALL_CODES);
256
+ const key = `${language}\u0000${formatLocale}`;
257
+ let labels = loaded.get(key);
258
+ if (!labels) {
259
+ labels = LOADERS[language](formatLocale);
260
+ loaded.set(key, labels);
261
+ labels.catch(() => loaded.delete(key));
262
+ }
263
+ return labels;
264
+ }
265
+
266
+ /**
267
+ * The rows `LanguageMenu` and `LanguageSetting` take, for the codes an app offers, in
268
+ * the order given: `{ code, label: nativeName, country: flag }`.
269
+ *
270
+ * `LanguageMenu` draws `country` as a flag-icons class (`fi fi-ch`) and nothing more —
271
+ * the kit ships no flag artwork and no flag CSS, the app loads the stylesheet (or just
272
+ * the rules for its flags, as Kurvenschmiede and keksdose do). `LanguageSetting` is a
273
+ * native `<select>`, whose options can hold text only, so it shows the names and
274
+ * ignores `country`; the same array serves both.
275
+ *
276
+ * A label of the app's own (keksdose's "Deutsch (Schweiz)") is a `.map` over the
277
+ * result.
278
+ */
279
+ export function languageOptions(codes: readonly KitLanguageCode[] = ALL_CODES): LanguageOption[] {
280
+ return codes.flatMap((code) => {
281
+ const language = KIT_LANGUAGES.find((entry) => entry.code === code);
282
+ if (!language) return [];
283
+ return [{ code: language.code, label: language.nativeName, country: language.flag }];
284
+ });
285
+ }
@@ -1,15 +1,24 @@
1
1
  import type { UiKitLabels } from "../kit-labels";
2
+ import { germanLabels } from "../german";
2
3
  import { swiss } from "../swiss";
3
- import { uiKitLabelsDe } from "./de";
4
4
 
5
5
  /**
6
- * Swiss Standard German: the German labels with Swiss spelling — "ss" for every "ß"
7
- * (Schliessen, Grösse), which is the one systematic difference in UI text — and
6
+ * The kit's words in German — Swiss Standard German, formal ("Sie"), the kit's ONE German
7
+ * catalogue: `<UiKitProvider labels={UI_KIT_LABELS_DE_CH}>`. Swiss spelling — "ss" for
8
+ * every "ß" (Schliessen, Grösse), which is the one systematic difference in UI text — and
8
9
  * `de-CH` number formatting (1’234.5).
9
10
  *
10
- * Derived rather than duplicated, so a key added to the German labels is Swiss the
11
- * moment it lands. Function labels are wrapped: their RESULT is respelled, so an
12
- * argument the app passes in (a file name, a phrase to type) is respelled too — which
13
- * is what a Swiss app writing "ss" everywhere wants.
11
+ * Derived from the internal German source (`../german`) rather than written out, so a
12
+ * key added there is Swiss the moment it lands. Function labels are wrapped: their
13
+ * RESULT is respelled, so an argument the app passes in (a file name, a phrase to type)
14
+ * is respelled too — which is what a Swiss app writing "ss" everywhere wants.
15
+ *
16
+ * {@link uiKitLabelsDeCh} takes another number locale (e.g. `"de-DE"` for 1.234,5, or
17
+ * `"de-AT"`) without touching the words — the spelling stays Swiss, as the factories of
18
+ * the other catalogues keep their words.
14
19
  */
15
- export const UI_KIT_LABELS_DE_CH: UiKitLabels = swiss(uiKitLabelsDe("de-CH"));
20
+ export function uiKitLabelsDeCh(numberLocale = "de-CH"): UiKitLabels {
21
+ return swiss(germanLabels(numberLocale));
22
+ }
23
+
24
+ export const UI_KIT_LABELS_DE_CH: UiKitLabels = uiKitLabelsDeCh();
@@ -112,5 +112,13 @@ export function uiKitLabelsEn(numberLocale = "en-US"): UiKitLabels {
112
112
  counter: (index, count) => `${n(index)} / ${n(count)}`,
113
113
  position: (index, count) => `Image ${n(index)} of ${n(count)}`,
114
114
  },
115
+ translationReview: {
116
+ ...d.translationReview,
117
+ filterCount: (label, count) => `${label} · ${n(count)}`,
118
+ placeholdersOnly: (count) => `Only placeholder problems (${n(count)})`,
119
+ progress: (approved, total) => `${n(approved)} of ${n(total)} approved`,
120
+ localeProgress: (approved, total) => `${n(approved)}/${n(total)}`,
121
+ exportCorrections: (count) => `Export corrections (${n(count)})`,
122
+ },
115
123
  };
116
124
  }