@eifi1/ui-kit 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -382,14 +382,14 @@ columns, strings and permissions (see the note at the top of `src/feedback/feedb
382
382
 
383
383
  <!-- BEGIN GENERATED: exports — node scripts/gen-export-inventory.mjs -->
384
384
 
385
- **1164 names from 175 modules** — 544 values and 620 types. _Italic_ is a type-only export.
385
+ **1167 names from 176 modules** — 547 values and 620 types. _Italic_ is a type-only export.
386
386
 
387
387
  Generated from `dist/index.d.ts` by `node scripts/gen-export-inventory.mjs`; the count
388
388
  is pinned by `src/__tests__/public-surface.test.ts`. Do not edit between the markers.
389
389
 
390
390
  | Entry point | Names |
391
391
  |---|---|
392
- | `@eifi1/ui-kit` | 1164 |
392
+ | `@eifi1/ui-kit` | 1167 |
393
393
  | `@eifi1/ui-kit/chart` | 104 |
394
394
  | `@eifi1/ui-kit/shell` | 24 |
395
395
  | `@eifi1/ui-kit/data-table` | 49 |
@@ -458,7 +458,8 @@ re-slicing of it, never a second API.
458
458
  | `i18n/kit-labels` | `DEFAULT_APP_SHELL_LABELS`, `DEFAULT_CALCULATOR_LABELS`, `DEFAULT_COMBOBOX_LABELS`, `DEFAULT_COMMON_LABELS`, `DEFAULT_CURRENCY_LABELS`, `DEFAULT_DATE_PICKER_LABELS`, `DEFAULT_FILE_LABELS`, `DEFAULT_MULTI_SELECT_LABELS`, `DEFAULT_PICKER_SHEET_LABELS`, `DEFAULT_SWIPEABLE_ROW_LABELS`, `DEFAULT_TOP_BAR_LABELS`, `formatFileSize`, `missingKitLabels`, `UiKitProvider`, `useKitChartTooltipPlacement`, `useKitFileLabels`, `useKitLabelOverrides`, `useKitLabels`, `useKitLink`, `useKitLocale`, `useKitWeekStart`, _`AppShellLabels`_, _`CalculatorLabels`_, _`ChartTooltipPlacement`_, _`ComboboxLabels`_, _`CommonLabels`_, _`CurrencyLabels`_, _`DatePickerLabels`_, _`FileLabels`_, _`KitLinkComponent`_, _`KitLinkProps`_, _`LabelOverride`_, _`MultiSelectLabels`_, _`PickerSheetLabels`_, _`SwipeableRowLabels`_, _`TopBarLabels`_, _`UiKitLabelOverrides`_, _`UiKitLabels`_, _`UiKitProviderProps`_ |
459
459
  | `i18n/defaults` | `DEFAULT_UI_KIT_LABELS` |
460
460
  | `i18n/review` | `kitLabelStrings` |
461
- | `i18n/languages` | `formatLocaleOf`, `KIT_LANGUAGES`, `languageOptions`, `loadUiKitLabels`, `resolveLanguage`, _`KitLanguage`_, _`KitLanguageCode`_ |
461
+ | `i18n/languages` | `formatLocaleOf`, `KIT_LANGUAGES`, `languageOptions`, `loadUiKitLabels`, `peekUiKitLabels`, `resolveLanguage`, `useUiKitLabels`, _`KitLanguage`_, _`KitLanguageCode`_ |
462
+ | `i18n/plurals` | `withAllPlurals` |
462
463
 
463
464
  ### components
464
465
 
@@ -798,9 +799,15 @@ Every German (`de`, `de-AT`, a stored `de-informal`) resolves to `de-CH`; every
798
799
  (`zh-TW`, `zh-Hant`) to `zh`, which is **Simplified** — there is no Traditional catalogue.
799
800
  Other regional tags take their base language (`fr-CA` → `fr`), and a candidate the app
800
801
  does not offer passes to the next. Each language formats like its home — `de-CH`, `en-GB`,
801
- `es-ES`, `fr-FR`, `it-IT`, `hu-HU`, `zh-CN` — whatever the reader's region. The loader is
802
+ `es-ES`, `fr-FR`, `it-IT`, `hu-HU`, `zh-CN` — whatever the reader's region; a tag of a
803
+ language the kit does not ship (`sv-SE` for ISO dates) formats as itself. The loader is
802
804
  asynchronous only: a synchronous one would have to bundle all seven catalogues into every
803
- app. `languageOptions` feeds `LanguageMenu` (which draws `fi fi-<country>` — the app loads
805
+ app. A provider that renders every frame reads what has arrived with
806
+ `peekUiKitLabels(code)`, or takes `useUiKitLabels(code)`, which loads on first use and
807
+ keeps the previous language's catalogue through a switch until the next one is in.
808
+ `withAllPlurals(catalogue, lng)` fills the plural categories CLDR gives a language (French,
809
+ Italian and Spanish `many`) from each `key_other`, so a catalogue written with English's
810
+ `_one`/`_other` never falls back to another language for a million. `languageOptions` feeds `LanguageMenu` (which draws `fi fi-<country>` — the app loads
804
811
  the flag CSS) and `LanguageSetting` (a native select: names only).
805
812
 
806
813
  ### The worked example
@@ -191,11 +191,17 @@ declare function resolveLanguage(candidates: readonly (string | null | undefined
191
191
  * and `en` alone does not say which English) and for the app's own `Intl` calls, so
192
192
  * the kit's counts and the app's amounts group their digits the same way.
193
193
  *
194
- * Takes any tag, not only a {@link KitLanguageCode}, and resolves it the way
195
- * {@link resolveLanguage} does against all seven: `de-AT` formats as `de-CH`, `en-AU`
196
- * as `en-GB` (the language's home, by decision — not the reader's region), and a
197
- * language the kit does not ship as the home language, `de-CH`. So an app can hand it
198
- * i18next's `resolvedLanguage` as it comes.
194
+ * Takes any tag, not only a {@link KitLanguageCode}. A tag of one of the seven
195
+ * languages formats in that language's home, by decision — not the reader's region:
196
+ * `de-AT` as `de-CH`, `en-AU` and `en-US` as `en-GB`, `zh-TW` as `zh-CN`. So an app can
197
+ * hand it i18next's `resolvedLanguage` as it comes.
198
+ *
199
+ * A tag of a language the kit does NOT ship formats as itself (canonicalised): `sv-SE`
200
+ * stays `sv-SE`. That is a formatting preference, not a language — keksdose's date
201
+ * setting offers ISO order as `sv-SE`, and 0.19.0 turned it into a German weekday by
202
+ * resolving it to the home language (keksdose's 0.19 adoption). Words are another
203
+ * matter: {@link resolveLanguage} and {@link loadUiKitLabels} still fall back to
204
+ * `de-CH` for such a tag. A tag `Intl` cannot read formats as `de-CH`.
199
205
  */
200
206
  declare function formatLocaleOf(code: string): string;
201
207
  /**
@@ -225,6 +231,25 @@ declare function formatLocaleOf(code: string): string;
225
231
  * remembered, so the next call tries again.
226
232
  */
227
233
  declare function loadUiKitLabels(code: string, formatLocale?: string): Promise<UiKitLabels>;
234
+ /**
235
+ * The catalogue {@link loadUiKitLabels} already fetched for this language and format
236
+ * locale — synchronously — or `undefined` while it has not arrived. For the provider's
237
+ * render, which cannot await: kastlan wrapped the loader in a cache of its own
238
+ * (`hasKitLabels` / `kitLabelsFor`) to get exactly this, and the other apps would have
239
+ * written the same lines. Same object as the promise resolved to.
240
+ */
241
+ declare function peekUiKitLabels(code: string, formatLocale?: string): UiKitLabels | undefined;
242
+ /**
243
+ * The kit's catalogue for `code`, loading it on first use: `<UiKitProvider
244
+ * labels={useUiKitLabels(language)} locale={formatLocaleOf(language)}>`.
245
+ *
246
+ * On a switch it keeps returning the PREVIOUS language's catalogue until the next one
247
+ * has arrived, so the kit's words never blank out mid-switch; `undefined` only before
248
+ * the very first catalogue (render nothing, or the English defaults, until then — or
249
+ * await {@link loadUiKitLabels} before the first render, as the apps do for their own
250
+ * catalogue). A failed fetch keeps the previous catalogue; the next switch tries again.
251
+ */
252
+ declare function useUiKitLabels(code: string, formatLocale?: string): UiKitLabels | undefined;
228
253
  /**
229
254
  * The rows `LanguageMenu` and `LanguageSetting` take, for the codes an app offers, in
230
255
  * the order given: `{ code, label: nativeName, country: flag }`.
@@ -240,4 +265,4 @@ declare function loadUiKitLabels(code: string, formatLocale?: string): Promise<U
240
265
  */
241
266
  declare function languageOptions(codes?: readonly KitLanguageCode[]): LanguageOption[];
242
267
 
243
- export { KIT_LANGUAGES, type KitLanguage, type KitLanguageCode, formatLocaleOf, languageOptions, loadUiKitLabels, resolveLanguage };
268
+ export { KIT_LANGUAGES, type KitLanguage, type KitLanguageCode, formatLocaleOf, languageOptions, loadUiKitLabels, peekUiKitLabels, resolveLanguage, useUiKitLabels };
@@ -1,4 +1,5 @@
1
1
  "use client";
2
+ import { useEffect, useState } from "react";
2
3
  const KIT_LANGUAGES = [
3
4
  { code: "de-CH", nativeName: "Deutsch", englishName: "German", formatLocale: "de-CH", flag: "ch" },
4
5
  { code: "en", nativeName: "English", englishName: "English", formatLocale: "en-GB", flag: "gb" },
@@ -29,9 +30,18 @@ function resolveLanguage(candidates, offered, fallback) {
29
30
  return fallback ?? (offered.includes(HOME) ? HOME : offered[0] ?? HOME);
30
31
  }
31
32
  function formatLocaleOf(code) {
32
- const resolved = resolveLanguage([code], ALL_CODES);
33
- return KIT_LANGUAGES.find((language) => language.code === resolved).formatLocale;
33
+ const shipped = answer(code, ALL_CODES);
34
+ if (shipped) return homeFormat(shipped);
35
+ try {
36
+ return Intl.getCanonicalLocales(code)[0] ?? homeFormat(HOME);
37
+ } catch {
38
+ return homeFormat(HOME);
39
+ }
40
+ }
41
+ function homeFormat(code) {
42
+ return KIT_LANGUAGES.find((language) => language.code === code).formatLocale;
34
43
  }
44
+ const catalogueFormat = (code) => homeFormat(resolveLanguage([code], ALL_CODES));
35
45
  const LOADERS = {
36
46
  "de-CH": async (locale) => (await import("./locales/de-CH.js")).uiKitLabelsDeCh(locale),
37
47
  en: async (locale) => (await import("./locales/en.js")).uiKitLabelsEn(locale),
@@ -42,17 +52,47 @@ const LOADERS = {
42
52
  zh: async (locale) => (await import("./locales/zh.js")).uiKitLabelsZh(locale)
43
53
  };
44
54
  const loaded = /* @__PURE__ */ new Map();
45
- function loadUiKitLabels(code, formatLocale = formatLocaleOf(code)) {
55
+ const ready = /* @__PURE__ */ new Map();
56
+ function cacheKey(code, formatLocale) {
46
57
  const language = resolveLanguage([code], ALL_CODES);
47
- const key = `${language}\0${formatLocale}`;
58
+ return [language, `${language}\0${formatLocale}`];
59
+ }
60
+ function loadUiKitLabels(code, formatLocale = catalogueFormat(code)) {
61
+ const [language, key] = cacheKey(code, formatLocale);
48
62
  let labels = loaded.get(key);
49
63
  if (!labels) {
50
64
  labels = LOADERS[language](formatLocale);
51
65
  loaded.set(key, labels);
52
- labels.catch(() => loaded.delete(key));
66
+ labels.then(
67
+ (done) => ready.set(key, done),
68
+ () => loaded.delete(key)
69
+ );
53
70
  }
54
71
  return labels;
55
72
  }
73
+ function peekUiKitLabels(code, formatLocale = catalogueFormat(code)) {
74
+ return ready.get(cacheKey(code, formatLocale)[1]);
75
+ }
76
+ function useUiKitLabels(code, formatLocale = catalogueFormat(code)) {
77
+ const current = peekUiKitLabels(code, formatLocale);
78
+ const [previous, setPrevious] = useState(current);
79
+ useEffect(() => {
80
+ if (peekUiKitLabels(code, formatLocale)) return;
81
+ let live = true;
82
+ loadUiKitLabels(code, formatLocale).then(
83
+ (labels) => {
84
+ if (live) setPrevious(labels);
85
+ },
86
+ () => {
87
+ }
88
+ );
89
+ return () => {
90
+ live = false;
91
+ };
92
+ }, [code, formatLocale]);
93
+ if (current && current !== previous) setPrevious(current);
94
+ return current ?? previous;
95
+ }
56
96
  function languageOptions(codes = ALL_CODES) {
57
97
  return codes.flatMap((code) => {
58
98
  const language = KIT_LANGUAGES.find((entry) => entry.code === code);
@@ -65,6 +105,8 @@ export {
65
105
  formatLocaleOf,
66
106
  languageOptions,
67
107
  loadUiKitLabels,
68
- resolveLanguage
108
+ peekUiKitLabels,
109
+ resolveLanguage,
110
+ useUiKitLabels
69
111
  };
70
112
  //# sourceMappingURL=languages.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/i18n/languages.ts"],"sourcesContent":["import type { UiKitLabels } from \"./kit-labels\";\nimport type { LanguageOption } from \"../shell/topbar-controls\";\n\n/**\n * \"Which language is this reader in, and what does the kit say in it?\" — the one\n * answer the three apps each wrote for themselves (docs/i18n-harmonization.md, H2).\n *\n * kastlan, keksdose and Kurvenschmiede all ship a subset of the same seven languages,\n * and every one of them hand-rolled the same three things: a list of languages with\n * their names and flags, a rule for turning what a browser asks for (`de-AT`, `zh-TW`,\n * `en-AU`) into a code the app ships, and a bridge from that code to the kit's label\n * catalogue. The three disagreed where nobody was looking: a reader asking for `zh-TW`\n * got German in kastlan and Chinese in Kurvenschmiede; keksdose's English got the raw\n * defaults, so its counts read \"12345\" where the other two wrote \"12,345\"; kastlan\n * formatted French as `fr-CH` and Kurvenschmiede as `fr-FR`. None of it was a decision.\n * This module makes it one, in one place, tested once.\n *\n * WHAT IT IS NOT: an i18n library. It resolves no app strings, carries no detector and\n * depends on nothing — the app keeps i18next (or whatever it uses) for its own words and\n * asks this module only the questions the kit can answer for all three apps alike:\n * - {@link KIT_LANGUAGES}: the seven codes, each with its own name, its English name,\n * the flag it flies and the locale it formats in;\n * - {@link resolveLanguage}: which of the codes an app offers answers a reader;\n * - {@link formatLocaleOf}: the `Intl` tag for a code (the provider's `locale`,\n * `<html lang>`, every `toLocaleString` in the app);\n * - {@link loadUiKitLabels}: the kit's catalogue for a code, fetched on demand;\n * - {@link languageOptions}: the rows `LanguageMenu` and `LanguageSetting` take.\n *\n * THE CODE AND THE FORMATTING LOCALE ARE TWO THINGS (Kurvenschmiede's insight, made\n * the rule). The code names a catalogue — what the account stores, what i18next\n * resolves, what a mail is chosen by — and a bare `en` says nothing about how a date is\n * written. Each language formats like its home (Marcel, 2026-10-02, H6): `de-CH`,\n * `en-GB` (British, like the spelling), `es-ES`, `fr-FR`, `it-IT`, `hu-HU`, `zh-CN`. An\n * app that must format otherwise — kastlan's QR bill follows the SIX spec, not the\n * reader — passes its own locale where it needs it; the registry is the default.\n *\n * THERE IS ONE GERMAN, `de-CH`: Swiss Standard German, formal (0.18). Every other\n * German a browser or an old localStorage can hand over — `de`, `de-DE`, `de-AT`,\n * `de-LI`, the briefly offered `de-informal` / `de-CH-informal` — is read as it.\n * Its name is plain \"Deutsch\": it is the only German, so there is nothing to tell it\n * apart from, and the Swiss flag beside it is what says where the spelling comes from.\n *\n * AND ONE CHINESE, `zh`, WHICH IS SIMPLIFIED. `zh-TW`, `zh-HK` and `zh-Hant` resolve to\n * it: there is no Traditional catalogue, and a reader of Traditional characters reads\n * Simplified far more readily than whichever second language a fallback would guess\n * at (Kurvenschmiede's reasoning, now everyone's). Its name says \"简体中文\" so that\n * reader sees what they are getting before they pick it.\n */\n\n/** The seven languages the kit ships a catalogue for. An app offers a subset. */\nexport type KitLanguageCode = \"de-CH\" | \"en\" | \"es\" | \"fr\" | \"it\" | \"hu\" | \"zh\";\n\n/** One language the kit ships: what it is called, which flag it flies and which\n * BCP 47 tag it formats in. */\nexport interface KitLanguage {\n /** The catalogue code — what an app stores, sends and hands i18next. */\n readonly code: KitLanguageCode;\n /** The language's name in itself, for a switcher: the reader looking for theirs may\n * not read the language the page is currently in. */\n readonly nativeName: string;\n /** The name in English, for places that list languages to a team rather than to a\n * reader (a translation review's locale column, an admin's reviewer grant). */\n readonly englishName: string;\n /** The BCP 47 tag the language formats numbers and dates in, and declares itself in\n * on `<html lang>`. See {@link formatLocaleOf}. */\n readonly formatLocale: string;\n /** ISO 3166-1 alpha-2 country (lowercase) for flag-icons' `fi fi-<flag>` class —\n * NOT the language code: English flies the UK flag, German the Swiss one, Chinese\n * the PRC's (the catalogue is Simplified). The kit renders the class name only; the\n * app supplies the flag stylesheet (README, \"What you install alongside it\"). */\n readonly flag: string;\n}\n\n/**\n * Every language the kit ships, in the order a switcher lists them: alphabetical by\n * native name (Deutsch, English, Español … 简体中文), which is what a reader scanning for\n * their own language's name expects.\n *\n * An app offering four of them passes those four codes to {@link resolveLanguage} and\n * {@link languageOptions}; it does not copy this list. A language added here is one the\n * kit has a catalogue for (the loader map below is typed by {@link KitLanguageCode}, so\n * a code without one does not compile).\n */\nexport const KIT_LANGUAGES: readonly KitLanguage[] = [\n { code: \"de-CH\", nativeName: \"Deutsch\", englishName: \"German\", formatLocale: \"de-CH\", flag: \"ch\" },\n { code: \"en\", nativeName: \"English\", englishName: \"English\", formatLocale: \"en-GB\", flag: \"gb\" },\n { code: \"es\", nativeName: \"Español\", englishName: \"Spanish\", formatLocale: \"es-ES\", flag: \"es\" },\n { code: \"fr\", nativeName: \"Français\", englishName: \"French\", formatLocale: \"fr-FR\", flag: \"fr\" },\n { code: \"it\", nativeName: \"Italiano\", englishName: \"Italian\", formatLocale: \"it-IT\", flag: \"it\" },\n { code: \"hu\", nativeName: \"Magyar\", englishName: \"Hungarian\", formatLocale: \"hu-HU\", flag: \"hu\" },\n { code: \"zh\", nativeName: \"简体中文\", englishName: \"Chinese (Simplified)\", formatLocale: \"zh-CN\", flag: \"cn\" },\n];\n\nconst ALL_CODES: readonly KitLanguageCode[] = KIT_LANGUAGES.map((language) => language.code);\n\n/** The kit's home language: what a reader nothing else matches gets, when it is offered. */\nconst HOME: KitLanguageCode = \"de-CH\";\n\n/** The language part of a tag, lower-cased: `de-CH-informal` → `de`, `zh_Hant` → `zh`. */\nconst languagePart = (tag: string) => tag.split(\"-\")[0].toLowerCase();\n\n/**\n * The offered code that answers ONE requested tag, or `undefined`.\n *\n * Exact first, ignoring case (`DE-ch` → `de-CH`), then by language part: since the kit\n * has exactly one code per language, \"the offered code whose language is the tag's\n * language\" is the whole of the rule, and it is what takes every German to `de-CH`\n * (`de-AT`, `de-informal`), every Chinese to `zh` (`zh-TW`, `zh-Hant-HK`) and every\n * regional form to its base (`fr-CA` → `fr`, `en-AU` → `en`). A language the app does\n * not offer answers nothing, so the caller moves on to the reader's next preference\n * rather than landing on a guess. Underscores are read as hyphens (`de_CH`), the way a\n * Java or Python backend may have stored the account's locale.\n */\nfunction answer(\n candidate: unknown,\n offered: readonly KitLanguageCode[],\n): KitLanguageCode | undefined {\n if (typeof candidate !== \"string\") return undefined;\n const tag = candidate.trim().replace(/_/g, \"-\");\n if (!tag) return undefined;\n const lower = tag.toLowerCase();\n const exact = offered.find((code) => code.toLowerCase() === lower);\n if (exact) return exact;\n const language = languagePart(tag);\n return offered.find((code) => languagePart(code) === language);\n}\n\n/**\n * Which of the codes an app offers answers this reader.\n *\n * `candidates` are the reader's preferences **in precedence order**, and the first\n * one that matches wins — so the order is the policy. The one the harmonisation plan\n * gives every app (H5):\n *\n * resolveLanguage(\n * [localStorage.getItem(\"<app>-lang\"), user?.locale, ...navigator.languages],\n * OFFERED,\n * );\n *\n * the choice stored on this device (the reader picked it, here) → the account's\n * language (read back on sign-in, so a new device starts in it) → the browser's list,\n * in its own order → the fallback. Missing sources are simply `null` / `undefined`;\n * they are skipped, as is an empty string.\n *\n * Each candidate is matched exactly (case-insensitively), then by language — every\n * German variant to `de-CH`, every Chinese to `zh` (Simplified), any other regional\n * form to its base language (`fr-CA` → `fr`, `en-AU` → `en`) — and only within\n * `offered`: a reader whose browser says `es-MX` first and `en` second, in an app that\n * offers no Spanish, gets English rather than the fallback. That is the difference\n * from a single-tag lookup, and why the browser's whole list is passed, not its first\n * entry.\n *\n * `fallback` is what a reader nothing matches gets: by default `de-CH` when the app\n * offers it — the kit's and the three apps' home language — else the app's first\n * offered code. It is returned as given, offered or not; the app chose it.\n *\n * Why not i18next's own matcher (`getBestMatchFromCodes`, which Kurvenschmiede calls):\n * it needs an i18next instance, it reduces `de-AT` to a `de` none of the apps ships\n * unless every caller remembers a converter first, and its answer to a list depends on\n * `supportedLngs` and `nonExplicitSupportedLngs` being set exactly so. This is a pure\n * function of its arguments, so the switcher, the account form, a server-rendered\n * guess and a test all get the same answer without one.\n */\nexport function resolveLanguage(\n candidates: readonly (string | null | undefined)[],\n offered: readonly KitLanguageCode[],\n fallback?: KitLanguageCode,\n): KitLanguageCode {\n for (const candidate of candidates) {\n const found = answer(candidate, offered);\n if (found) return found;\n }\n return fallback ?? (offered.includes(HOME) ? HOME : (offered[0] ?? HOME));\n}\n\n/**\n * The BCP 47 tag a language formats in — `formatLocaleOf(\"en\")` is `\"en-GB\"`.\n *\n * For `<UiKitProvider locale>`, for `<html lang>` (a screen reader pronounces by it,\n * and `en` alone does not say which English) and for the app's own `Intl` calls, so\n * the kit's counts and the app's amounts group their digits the same way.\n *\n * Takes any tag, not only a {@link KitLanguageCode}, and resolves it the way\n * {@link resolveLanguage} does against all seven: `de-AT` formats as `de-CH`, `en-AU`\n * as `en-GB` (the language's home, by decision — not the reader's region), and a\n * language the kit does not ship as the home language, `de-CH`. So an app can hand it\n * i18next's `resolvedLanguage` as it comes.\n */\nexport function formatLocaleOf(code: string): string {\n const resolved = resolveLanguage([code], ALL_CODES);\n return KIT_LANGUAGES.find((language) => language.code === resolved)!.formatLocale;\n}\n\n/**\n * One loader per catalogue, each a LITERAL relative `import()`.\n *\n * The literal is load-bearing twice. The library is built one file per module\n * (`bundle: false`, tsup.config.ts), so `./locales/fr` stays a module of its own in\n * dist/ and `scripts/fix-esm-extensions.mjs` gives the specifier its `.js`. And an\n * app's bundler can only split what it can read: a path assembled from a code is a\n * chunk rollup cannot make, and the failure is at runtime. With literals, each\n * language is a chunk of its own and a reader downloads exactly theirs — about 25 kB of\n * JavaScript a catalogue, so an entry chunk holding all of them would carry six that\n * the reader never uses.\n *\n * Relative, not `@eifi1/ui-kit/i18n/fr`: a package importing itself by name resolves\n * through the consumer's node_modules, which a linked checkout or a test of this repo\n * does not have.\n */\nconst LOADERS: Record<KitLanguageCode, (formatLocale: string) => Promise<UiKitLabels>> = {\n \"de-CH\": async (locale) => (await import(\"./locales/de-CH\")).uiKitLabelsDeCh(locale),\n en: async (locale) => (await import(\"./locales/en\")).uiKitLabelsEn(locale),\n es: async (locale) => (await import(\"./locales/es\")).uiKitLabelsEs(locale),\n fr: async (locale) => (await import(\"./locales/fr\")).uiKitLabelsFr(locale),\n it: async (locale) => (await import(\"./locales/it\")).uiKitLabelsIt(locale),\n hu: async (locale) => (await import(\"./locales/hu\")).uiKitLabelsHu(locale),\n zh: async (locale) => (await import(\"./locales/zh\")).uiKitLabelsZh(locale),\n};\n\n/** One promise per (language, format locale), so asking twice is one fetch and one\n * object: the provider's context value keeps its identity, and every kit component\n * reading it is spared a re-render on a switch back. */\nconst loaded = new Map<string, Promise<UiKitLabels>>();\n\n/**\n * The kit's catalogue for a language, fetched on demand and formatted in\n * `formatLocale` — for `<UiKitProvider labels={…}>`.\n *\n * Every language goes through its catalogue's factory, English included:\n * `uiKitLabelsEn(\"en-GB\")` writes \"12,345 rows\" where the bare\n * `DEFAULT_UI_KIT_LABELS` print \"12345\" (and keksdose's English did, by passing no\n * labels at all). `formatLocale` defaults to the language's own\n * ({@link formatLocaleOf}); pass another only for a reason, as the factories' own\n * docs describe (`\"de-DE\"` keeps the Swiss words and writes German digits).\n *\n * `code` may be any tag and is resolved like {@link formatLocaleOf}'s, so `de` loads\n * the one German and a language the kit does not ship loads `de-CH` — the same answer\n * the app's own fallback gives, rather than English beside the app's German.\n *\n * Asynchronous on purpose, and there is no synchronous twin: one would have to import\n * all seven catalogues statically, which puts every language in every app's entry\n * chunk — exactly what one subpath per language exists to prevent. Await it before the\n * first render, beside the app's own catalogue (the apps already await i18next's init\n * for the same reason: no frame in the wrong language), and keep the result in state.\n * To change a few words, spread over what it returns rather than copying a catalogue:\n * a copy misses every key a later release adds.\n *\n * A rejected fetch (a deploy replaced the chunk, the device went offline) is not\n * remembered, so the next call tries again.\n */\nexport function loadUiKitLabels(\n code: string,\n formatLocale: string = formatLocaleOf(code),\n): Promise<UiKitLabels> {\n const language = resolveLanguage([code], ALL_CODES);\n const key = `${language}\\u0000${formatLocale}`;\n let labels = loaded.get(key);\n if (!labels) {\n labels = LOADERS[language](formatLocale);\n loaded.set(key, labels);\n labels.catch(() => loaded.delete(key));\n }\n return labels;\n}\n\n/**\n * The rows `LanguageMenu` and `LanguageSetting` take, for the codes an app offers, in\n * the order given: `{ code, label: nativeName, country: flag }`.\n *\n * `LanguageMenu` draws `country` as a flag-icons class (`fi fi-ch`) and nothing more —\n * the kit ships no flag artwork and no flag CSS, the app loads the stylesheet (or just\n * the rules for its flags, as Kurvenschmiede and keksdose do). `LanguageSetting` is a\n * native `<select>`, whose options can hold text only, so it shows the names and\n * ignores `country`; the same array serves both.\n *\n * A label of the app's own (keksdose's \"Deutsch (Schweiz)\") is a `.map` over the\n * result.\n */\nexport function languageOptions(codes: readonly KitLanguageCode[] = ALL_CODES): LanguageOption[] {\n return codes.flatMap((code) => {\n const language = KIT_LANGUAGES.find((entry) => entry.code === code);\n if (!language) return [];\n return [{ code: language.code, label: language.nativeName, country: language.flag }];\n });\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/i18n/languages.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport type { UiKitLabels } from \"./kit-labels\";\nimport type { LanguageOption } from \"../shell/topbar-controls\";\n\n/**\n * \"Which language is this reader in, and what does the kit say in it?\" — the one\n * answer the three apps each wrote for themselves (docs/i18n-harmonization.md, H2).\n *\n * kastlan, keksdose and Kurvenschmiede all ship a subset of the same seven languages,\n * and every one of them hand-rolled the same three things: a list of languages with\n * their names and flags, a rule for turning what a browser asks for (`de-AT`, `zh-TW`,\n * `en-AU`) into a code the app ships, and a bridge from that code to the kit's label\n * catalogue. The three disagreed where nobody was looking: a reader asking for `zh-TW`\n * got German in kastlan and Chinese in Kurvenschmiede; keksdose's English got the raw\n * defaults, so its counts read \"12345\" where the other two wrote \"12,345\"; kastlan\n * formatted French as `fr-CH` and Kurvenschmiede as `fr-FR`. None of it was a decision.\n * This module makes it one, in one place, tested once.\n *\n * WHAT IT IS NOT: an i18n library. It resolves no app strings, carries no detector and\n * depends on nothing — the app keeps i18next (or whatever it uses) for its own words and\n * asks this module only the questions the kit can answer for all three apps alike:\n * - {@link KIT_LANGUAGES}: the seven codes, each with its own name, its English name,\n * the flag it flies and the locale it formats in;\n * - {@link resolveLanguage}: which of the codes an app offers answers a reader;\n * - {@link formatLocaleOf}: the `Intl` tag for a code (the provider's `locale`,\n * `<html lang>`, every `toLocaleString` in the app);\n * - {@link loadUiKitLabels}: the kit's catalogue for a code, fetched on demand;\n * - {@link languageOptions}: the rows `LanguageMenu` and `LanguageSetting` take.\n *\n * THE CODE AND THE FORMATTING LOCALE ARE TWO THINGS (Kurvenschmiede's insight, made\n * the rule). The code names a catalogue — what the account stores, what i18next\n * resolves, what a mail is chosen by — and a bare `en` says nothing about how a date is\n * written. Each language formats like its home (Marcel, 2026-10-02, H6): `de-CH`,\n * `en-GB` (British, like the spelling), `es-ES`, `fr-FR`, `it-IT`, `hu-HU`, `zh-CN`. An\n * app that must format otherwise — kastlan's QR bill follows the SIX spec, not the\n * reader — passes its own locale where it needs it; the registry is the default.\n *\n * THERE IS ONE GERMAN, `de-CH`: Swiss Standard German, formal (0.18). Every other\n * German a browser or an old localStorage can hand over — `de`, `de-DE`, `de-AT`,\n * `de-LI`, the briefly offered `de-informal` / `de-CH-informal` — is read as it.\n * Its name is plain \"Deutsch\": it is the only German, so there is nothing to tell it\n * apart from, and the Swiss flag beside it is what says where the spelling comes from.\n *\n * AND ONE CHINESE, `zh`, WHICH IS SIMPLIFIED. `zh-TW`, `zh-HK` and `zh-Hant` resolve to\n * it: there is no Traditional catalogue, and a reader of Traditional characters reads\n * Simplified far more readily than whichever second language a fallback would guess\n * at (Kurvenschmiede's reasoning, now everyone's). Its name says \"简体中文\" so that\n * reader sees what they are getting before they pick it.\n */\n\n/** The seven languages the kit ships a catalogue for. An app offers a subset. */\nexport type KitLanguageCode = \"de-CH\" | \"en\" | \"es\" | \"fr\" | \"it\" | \"hu\" | \"zh\";\n\n/** One language the kit ships: what it is called, which flag it flies and which\n * BCP 47 tag it formats in. */\nexport interface KitLanguage {\n /** The catalogue code — what an app stores, sends and hands i18next. */\n readonly code: KitLanguageCode;\n /** The language's name in itself, for a switcher: the reader looking for theirs may\n * not read the language the page is currently in. */\n readonly nativeName: string;\n /** The name in English, for places that list languages to a team rather than to a\n * reader (a translation review's locale column, an admin's reviewer grant). */\n readonly englishName: string;\n /** The BCP 47 tag the language formats numbers and dates in, and declares itself in\n * on `<html lang>`. See {@link formatLocaleOf}. */\n readonly formatLocale: string;\n /** ISO 3166-1 alpha-2 country (lowercase) for flag-icons' `fi fi-<flag>` class —\n * NOT the language code: English flies the UK flag, German the Swiss one, Chinese\n * the PRC's (the catalogue is Simplified). The kit renders the class name only; the\n * app supplies the flag stylesheet (README, \"What you install alongside it\"). */\n readonly flag: string;\n}\n\n/**\n * Every language the kit ships, in the order a switcher lists them: alphabetical by\n * native name (Deutsch, English, Español … 简体中文), which is what a reader scanning for\n * their own language's name expects.\n *\n * An app offering four of them passes those four codes to {@link resolveLanguage} and\n * {@link languageOptions}; it does not copy this list. A language added here is one the\n * kit has a catalogue for (the loader map below is typed by {@link KitLanguageCode}, so\n * a code without one does not compile).\n */\nexport const KIT_LANGUAGES: readonly KitLanguage[] = [\n { code: \"de-CH\", nativeName: \"Deutsch\", englishName: \"German\", formatLocale: \"de-CH\", flag: \"ch\" },\n { code: \"en\", nativeName: \"English\", englishName: \"English\", formatLocale: \"en-GB\", flag: \"gb\" },\n { code: \"es\", nativeName: \"Español\", englishName: \"Spanish\", formatLocale: \"es-ES\", flag: \"es\" },\n { code: \"fr\", nativeName: \"Français\", englishName: \"French\", formatLocale: \"fr-FR\", flag: \"fr\" },\n { code: \"it\", nativeName: \"Italiano\", englishName: \"Italian\", formatLocale: \"it-IT\", flag: \"it\" },\n { code: \"hu\", nativeName: \"Magyar\", englishName: \"Hungarian\", formatLocale: \"hu-HU\", flag: \"hu\" },\n { code: \"zh\", nativeName: \"简体中文\", englishName: \"Chinese (Simplified)\", formatLocale: \"zh-CN\", flag: \"cn\" },\n];\n\nconst ALL_CODES: readonly KitLanguageCode[] = KIT_LANGUAGES.map((language) => language.code);\n\n/** The kit's home language: what a reader nothing else matches gets, when it is offered. */\nconst HOME: KitLanguageCode = \"de-CH\";\n\n/** The language part of a tag, lower-cased: `de-CH-informal` → `de`, `zh_Hant` → `zh`. */\nconst languagePart = (tag: string) => tag.split(\"-\")[0].toLowerCase();\n\n/**\n * The offered code that answers ONE requested tag, or `undefined`.\n *\n * Exact first, ignoring case (`DE-ch` → `de-CH`), then by language part: since the kit\n * has exactly one code per language, \"the offered code whose language is the tag's\n * language\" is the whole of the rule, and it is what takes every German to `de-CH`\n * (`de-AT`, `de-informal`), every Chinese to `zh` (`zh-TW`, `zh-Hant-HK`) and every\n * regional form to its base (`fr-CA` → `fr`, `en-AU` → `en`). A language the app does\n * not offer answers nothing, so the caller moves on to the reader's next preference\n * rather than landing on a guess. Underscores are read as hyphens (`de_CH`), the way a\n * Java or Python backend may have stored the account's locale.\n */\nfunction answer(\n candidate: unknown,\n offered: readonly KitLanguageCode[],\n): KitLanguageCode | undefined {\n if (typeof candidate !== \"string\") return undefined;\n const tag = candidate.trim().replace(/_/g, \"-\");\n if (!tag) return undefined;\n const lower = tag.toLowerCase();\n const exact = offered.find((code) => code.toLowerCase() === lower);\n if (exact) return exact;\n const language = languagePart(tag);\n return offered.find((code) => languagePart(code) === language);\n}\n\n/**\n * Which of the codes an app offers answers this reader.\n *\n * `candidates` are the reader's preferences **in precedence order**, and the first\n * one that matches wins — so the order is the policy. The one the harmonisation plan\n * gives every app (H5):\n *\n * resolveLanguage(\n * [localStorage.getItem(\"<app>-lang\"), user?.locale, ...navigator.languages],\n * OFFERED,\n * );\n *\n * the choice stored on this device (the reader picked it, here) → the account's\n * language (read back on sign-in, so a new device starts in it) → the browser's list,\n * in its own order → the fallback. Missing sources are simply `null` / `undefined`;\n * they are skipped, as is an empty string.\n *\n * Each candidate is matched exactly (case-insensitively), then by language — every\n * German variant to `de-CH`, every Chinese to `zh` (Simplified), any other regional\n * form to its base language (`fr-CA` → `fr`, `en-AU` → `en`) — and only within\n * `offered`: a reader whose browser says `es-MX` first and `en` second, in an app that\n * offers no Spanish, gets English rather than the fallback. That is the difference\n * from a single-tag lookup, and why the browser's whole list is passed, not its first\n * entry.\n *\n * `fallback` is what a reader nothing matches gets: by default `de-CH` when the app\n * offers it — the kit's and the three apps' home language — else the app's first\n * offered code. It is returned as given, offered or not; the app chose it.\n *\n * Why not i18next's own matcher (`getBestMatchFromCodes`, which Kurvenschmiede calls):\n * it needs an i18next instance, it reduces `de-AT` to a `de` none of the apps ships\n * unless every caller remembers a converter first, and its answer to a list depends on\n * `supportedLngs` and `nonExplicitSupportedLngs` being set exactly so. This is a pure\n * function of its arguments, so the switcher, the account form, a server-rendered\n * guess and a test all get the same answer without one.\n */\nexport function resolveLanguage(\n candidates: readonly (string | null | undefined)[],\n offered: readonly KitLanguageCode[],\n fallback?: KitLanguageCode,\n): KitLanguageCode {\n for (const candidate of candidates) {\n const found = answer(candidate, offered);\n if (found) return found;\n }\n return fallback ?? (offered.includes(HOME) ? HOME : (offered[0] ?? HOME));\n}\n\n/**\n * The BCP 47 tag a language formats in — `formatLocaleOf(\"en\")` is `\"en-GB\"`.\n *\n * For `<UiKitProvider locale>`, for `<html lang>` (a screen reader pronounces by it,\n * and `en` alone does not say which English) and for the app's own `Intl` calls, so\n * the kit's counts and the app's amounts group their digits the same way.\n *\n * Takes any tag, not only a {@link KitLanguageCode}. A tag of one of the seven\n * languages formats in that language's home, by decision — not the reader's region:\n * `de-AT` as `de-CH`, `en-AU` and `en-US` as `en-GB`, `zh-TW` as `zh-CN`. So an app can\n * hand it i18next's `resolvedLanguage` as it comes.\n *\n * A tag of a language the kit does NOT ship formats as itself (canonicalised): `sv-SE`\n * stays `sv-SE`. That is a formatting preference, not a language — keksdose's date\n * setting offers ISO order as `sv-SE`, and 0.19.0 turned it into a German weekday by\n * resolving it to the home language (keksdose's 0.19 adoption). Words are another\n * matter: {@link resolveLanguage} and {@link loadUiKitLabels} still fall back to\n * `de-CH` for such a tag. A tag `Intl` cannot read formats as `de-CH`.\n */\nexport function formatLocaleOf(code: string): string {\n const shipped = answer(code, ALL_CODES);\n if (shipped) return homeFormat(shipped);\n try {\n return Intl.getCanonicalLocales(code)[0] ?? homeFormat(HOME);\n } catch {\n return homeFormat(HOME);\n }\n}\n\nfunction homeFormat(code: KitLanguageCode): string {\n return KIT_LANGUAGES.find((language) => language.code === code)!.formatLocale;\n}\n\n/** The format tag of the catalogue a code loads — always one of the seven languages'\n * own, so the words and the digits of a catalogue agree. */\nconst catalogueFormat = (code: string) => homeFormat(resolveLanguage([code], ALL_CODES));\n\n/**\n * One loader per catalogue, each a LITERAL relative `import()`.\n *\n * The literal is load-bearing twice. The library is built one file per module\n * (`bundle: false`, tsup.config.ts), so `./locales/fr` stays a module of its own in\n * dist/ and `scripts/fix-esm-extensions.mjs` gives the specifier its `.js`. And an\n * app's bundler can only split what it can read: a path assembled from a code is a\n * chunk rollup cannot make, and the failure is at runtime. With literals, each\n * language is a chunk of its own and a reader downloads exactly theirs — about 25 kB of\n * JavaScript a catalogue, so an entry chunk holding all of them would carry six that\n * the reader never uses.\n *\n * Relative, not `@eifi1/ui-kit/i18n/fr`: a package importing itself by name resolves\n * through the consumer's node_modules, which a linked checkout or a test of this repo\n * does not have.\n */\nconst LOADERS: Record<KitLanguageCode, (formatLocale: string) => Promise<UiKitLabels>> = {\n \"de-CH\": async (locale) => (await import(\"./locales/de-CH\")).uiKitLabelsDeCh(locale),\n en: async (locale) => (await import(\"./locales/en\")).uiKitLabelsEn(locale),\n es: async (locale) => (await import(\"./locales/es\")).uiKitLabelsEs(locale),\n fr: async (locale) => (await import(\"./locales/fr\")).uiKitLabelsFr(locale),\n it: async (locale) => (await import(\"./locales/it\")).uiKitLabelsIt(locale),\n hu: async (locale) => (await import(\"./locales/hu\")).uiKitLabelsHu(locale),\n zh: async (locale) => (await import(\"./locales/zh\")).uiKitLabelsZh(locale),\n};\n\n/** One promise per (language, format locale), so asking twice is one fetch and one\n * object: the provider's context value keeps its identity, and every kit component\n * reading it is spared a re-render on a switch back. */\nconst loaded = new Map<string, Promise<UiKitLabels>>();\n/** The same catalogues once they have arrived — what {@link peekUiKitLabels} reads. */\nconst ready = new Map<string, UiKitLabels>();\n\nfunction cacheKey(code: string, formatLocale: string): [KitLanguageCode, string] {\n const language = resolveLanguage([code], ALL_CODES);\n return [language, `${language}\\u0000${formatLocale}`];\n}\n\n/**\n * The kit's catalogue for a language, fetched on demand and formatted in\n * `formatLocale` — for `<UiKitProvider labels={…}>`.\n *\n * Every language goes through its catalogue's factory, English included:\n * `uiKitLabelsEn(\"en-GB\")` writes \"12,345 rows\" where the bare\n * `DEFAULT_UI_KIT_LABELS` print \"12345\" (and keksdose's English did, by passing no\n * labels at all). `formatLocale` defaults to the language's own\n * ({@link formatLocaleOf}); pass another only for a reason, as the factories' own\n * docs describe (`\"de-DE\"` keeps the Swiss words and writes German digits).\n *\n * `code` may be any tag and is resolved like {@link formatLocaleOf}'s, so `de` loads\n * the one German and a language the kit does not ship loads `de-CH` — the same answer\n * the app's own fallback gives, rather than English beside the app's German.\n *\n * Asynchronous on purpose, and there is no synchronous twin: one would have to import\n * all seven catalogues statically, which puts every language in every app's entry\n * chunk — exactly what one subpath per language exists to prevent. Await it before the\n * first render, beside the app's own catalogue (the apps already await i18next's init\n * for the same reason: no frame in the wrong language), and keep the result in state.\n * To change a few words, spread over what it returns rather than copying a catalogue:\n * a copy misses every key a later release adds.\n *\n * A rejected fetch (a deploy replaced the chunk, the device went offline) is not\n * remembered, so the next call tries again.\n */\nexport function loadUiKitLabels(\n code: string,\n formatLocale: string = catalogueFormat(code),\n): Promise<UiKitLabels> {\n const [language, key] = cacheKey(code, formatLocale);\n let labels = loaded.get(key);\n if (!labels) {\n labels = LOADERS[language](formatLocale);\n loaded.set(key, labels);\n labels.then(\n (done) => ready.set(key, done),\n () => loaded.delete(key),\n );\n }\n return labels;\n}\n\n/**\n * The catalogue {@link loadUiKitLabels} already fetched for this language and format\n * locale — synchronously — or `undefined` while it has not arrived. For the provider's\n * render, which cannot await: kastlan wrapped the loader in a cache of its own\n * (`hasKitLabels` / `kitLabelsFor`) to get exactly this, and the other apps would have\n * written the same lines. Same object as the promise resolved to.\n */\nexport function peekUiKitLabels(code: string, formatLocale: string = catalogueFormat(code)): UiKitLabels | undefined {\n return ready.get(cacheKey(code, formatLocale)[1]);\n}\n\n/**\n * The kit's catalogue for `code`, loading it on first use: `<UiKitProvider\n * labels={useUiKitLabels(language)} locale={formatLocaleOf(language)}>`.\n *\n * On a switch it keeps returning the PREVIOUS language's catalogue until the next one\n * has arrived, so the kit's words never blank out mid-switch; `undefined` only before\n * the very first catalogue (render nothing, or the English defaults, until then — or\n * await {@link loadUiKitLabels} before the first render, as the apps do for their own\n * catalogue). A failed fetch keeps the previous catalogue; the next switch tries again.\n */\nexport function useUiKitLabels(code: string, formatLocale: string = catalogueFormat(code)): UiKitLabels | undefined {\n const current = peekUiKitLabels(code, formatLocale);\n const [previous, setPrevious] = useState<UiKitLabels | undefined>(current);\n useEffect(() => {\n if (peekUiKitLabels(code, formatLocale)) return;\n let live = true;\n loadUiKitLabels(code, formatLocale).then(\n (labels) => {\n if (live) setPrevious(labels);\n },\n () => {},\n );\n return () => {\n live = false;\n };\n }, [code, formatLocale]);\n if (current && current !== previous) setPrevious(current);\n return current ?? previous;\n}\n\n/**\n * The rows `LanguageMenu` and `LanguageSetting` take, for the codes an app offers, in\n * the order given: `{ code, label: nativeName, country: flag }`.\n *\n * `LanguageMenu` draws `country` as a flag-icons class (`fi fi-ch`) and nothing more —\n * the kit ships no flag artwork and no flag CSS, the app loads the stylesheet (or just\n * the rules for its flags, as Kurvenschmiede and keksdose do). `LanguageSetting` is a\n * native `<select>`, whose options can hold text only, so it shows the names and\n * ignores `country`; the same array serves both.\n *\n * A label of the app's own (keksdose's \"Deutsch (Schweiz)\") is a `.map` over the\n * result.\n */\nexport function languageOptions(codes: readonly KitLanguageCode[] = ALL_CODES): LanguageOption[] {\n return codes.flatMap((code) => {\n const language = KIT_LANGUAGES.find((entry) => entry.code === code);\n if (!language) return [];\n return [{ code: language.code, label: language.nativeName, country: language.flag }];\n });\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Every plural category a language has, filled from the key family's `_other` — for an
3
+ * app's i18next catalogue, before i18next sees it.
4
+ *
5
+ * The apps write English's two forms, `key_one` and `key_other`, in every language: that
6
+ * is the key set their parity tests hold each catalogue to, and no translator should have
7
+ * to know which categories CLDR assigns. But CLDR gives French, Italian and Spanish a
8
+ * third, `many` (1 000 000, « 1,5 million »), and i18next asks for `key_many` there and
9
+ * nothing else — so without it the count fell down the fallback chain and printed German
10
+ * in a French sentence. Kurvenschmiede found it and filled the gap in memory; kastlan
11
+ * wrote the same function again (0.19 adoption). This is that function, once.
12
+ *
13
+ * `_other` is the right stand-in: CLDR's `many` shares its wording with `other` in all
14
+ * three. An ordinal family (`key_ordinal_one`, `…_ordinal_other`) is filled from the
15
+ * language's ORDINAL categories. A category the catalogue already has is left alone, and
16
+ * the input is not changed: a new tree comes back. A locale `Intl` cannot read (`""`,
17
+ * `"x"` — a backend `parse` called without a language) adds nothing rather than throwing,
18
+ * so one odd call cannot fail a whole namespace load (kastlan, 0.20 adoption).
19
+ *
20
+ * i18n.addResourceBundle(lng, "translation", withAllPlurals(catalogue, lng));
21
+ * // i18next-http-backend: { parse: (data, lng) => withAllPlurals(JSON.parse(data), lng) }
22
+ */
23
+ declare function withAllPlurals<T extends Record<string, unknown>>(catalogue: T, locale: string): T & Record<string, unknown>;
24
+
25
+ export { withAllPlurals };
@@ -0,0 +1,33 @@
1
+ "use client";
2
+ function withAllPlurals(catalogue, locale) {
3
+ const cardinal = pluralCategories(locale, "cardinal");
4
+ const ordinal = pluralCategories(locale, "ordinal");
5
+ const fill = (tree) => {
6
+ const out = {};
7
+ for (const [key, value] of Object.entries(tree)) {
8
+ out[key] = value !== null && typeof value === "object" && !Array.isArray(value) ? fill(value) : value;
9
+ }
10
+ for (const [key, value] of Object.entries(tree)) {
11
+ if (!key.endsWith("_other") || typeof value !== "string") continue;
12
+ const base = key.slice(0, -"_other".length);
13
+ const categories = base.endsWith("_ordinal") ? ordinal : cardinal;
14
+ for (const category of categories) {
15
+ const name = `${base}_${category}`;
16
+ if (!(name in out)) out[name] = value;
17
+ }
18
+ }
19
+ return out;
20
+ };
21
+ return fill(catalogue);
22
+ }
23
+ function pluralCategories(locale, type) {
24
+ try {
25
+ return new Intl.PluralRules(locale, { type }).resolvedOptions().pluralCategories;
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+ export {
31
+ withAllPlurals
32
+ };
33
+ //# sourceMappingURL=plurals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/i18n/plurals.ts"],"sourcesContent":["/**\n * Every plural category a language has, filled from the key family's `_other` — for an\n * app's i18next catalogue, before i18next sees it.\n *\n * The apps write English's two forms, `key_one` and `key_other`, in every language: that\n * is the key set their parity tests hold each catalogue to, and no translator should have\n * to know which categories CLDR assigns. But CLDR gives French, Italian and Spanish a\n * third, `many` (1 000 000, « 1,5 million »), and i18next asks for `key_many` there and\n * nothing else — so without it the count fell down the fallback chain and printed German\n * in a French sentence. Kurvenschmiede found it and filled the gap in memory; kastlan\n * wrote the same function again (0.19 adoption). This is that function, once.\n *\n * `_other` is the right stand-in: CLDR's `many` shares its wording with `other` in all\n * three. An ordinal family (`key_ordinal_one`, `…_ordinal_other`) is filled from the\n * language's ORDINAL categories. A category the catalogue already has is left alone, and\n * the input is not changed: a new tree comes back. A locale `Intl` cannot read (`\"\"`,\n * `\"x\"` — a backend `parse` called without a language) adds nothing rather than throwing,\n * so one odd call cannot fail a whole namespace load (kastlan, 0.20 adoption).\n *\n * i18n.addResourceBundle(lng, \"translation\", withAllPlurals(catalogue, lng));\n * // i18next-http-backend: { parse: (data, lng) => withAllPlurals(JSON.parse(data), lng) }\n */\nexport function withAllPlurals<T extends Record<string, unknown>>(catalogue: T, locale: string): T & Record<string, unknown> {\n const cardinal = pluralCategories(locale, \"cardinal\");\n const ordinal = pluralCategories(locale, \"ordinal\");\n const fill = (tree: Record<string, unknown>): Record<string, unknown> => {\n const out: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(tree)) {\n out[key] =\n value !== null && typeof value === \"object\" && !Array.isArray(value)\n ? fill(value as Record<string, unknown>)\n : value;\n }\n for (const [key, value] of Object.entries(tree)) {\n if (!key.endsWith(\"_other\") || typeof value !== \"string\") continue;\n const base = key.slice(0, -\"_other\".length);\n const categories = base.endsWith(\"_ordinal\") ? ordinal : cardinal;\n for (const category of categories) {\n const name = `${base}_${category}`;\n if (!(name in out)) out[name] = value;\n }\n }\n return out;\n };\n return fill(catalogue) as T & Record<string, unknown>;\n}\n\nfunction pluralCategories(locale: string, type: Intl.PluralRuleType): readonly string[] {\n try {\n return new Intl.PluralRules(locale, { type }).resolvedOptions().pluralCategories;\n } catch {\n return [];\n }\n}\n"],"mappings":";AAsBO,SAAS,eAAkD,WAAc,QAA6C;AAC3H,QAAM,WAAW,iBAAiB,QAAQ,UAAU;AACpD,QAAM,UAAU,iBAAiB,QAAQ,SAAS;AAClD,QAAM,OAAO,CAAC,SAA2D;AACvE,UAAM,MAA+B,CAAC;AACtC,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,IAAI,GAAG;AAC/C,UAAI,GAAG,IACL,UAAU,QAAQ,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,IAC/D,KAAK,KAAgC,IACrC;AAAA,IACR;AACA,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,IAAI,GAAG;AAC/C,UAAI,CAAC,IAAI,SAAS,QAAQ,KAAK,OAAO,UAAU,SAAU;AAC1D,YAAM,OAAO,IAAI,MAAM,GAAG,CAAC,SAAS,MAAM;AAC1C,YAAM,aAAa,KAAK,SAAS,UAAU,IAAI,UAAU;AACzD,iBAAW,YAAY,YAAY;AACjC,cAAM,OAAO,GAAG,IAAI,IAAI,QAAQ;AAChC,YAAI,EAAE,QAAQ,KAAM,KAAI,IAAI,IAAI;AAAA,MAClC;AAAA,IACF;AACA,WAAO;AAAA,EACT;AACA,SAAO,KAAK,SAAS;AACvB;AAEA,SAAS,iBAAiB,QAAgB,MAA8C;AACtF,MAAI;AACF,WAAO,IAAI,KAAK,YAAY,QAAQ,EAAE,KAAK,CAAC,EAAE,gBAAgB,EAAE;AAAA,EAClE,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;","names":[]}
package/dist/index.d.ts CHANGED
@@ -153,7 +153,8 @@ export { SEARCH_TIER_POINTS, SearchEntry, SearchHit, SearchIndex, SearchIndexOpt
153
153
  export { DEFAULT_GLOBAL_SEARCH_LABELS, GlobalSearch, GlobalSearchLabels, GlobalSearchProps, GlobalSearchSource, GlobalSearchSourceGroup, GlobalSearchSuggestion, GlobalSearchTriggerProps } from './search/global-search.js';
154
154
  export { DEFAULT_UI_KIT_LABELS } from './i18n/defaults.js';
155
155
  export { kitLabelStrings } from './i18n/review.js';
156
- export { KIT_LANGUAGES, KitLanguage, KitLanguageCode, formatLocaleOf, languageOptions, loadUiKitLabels, resolveLanguage } from './i18n/languages.js';
156
+ export { KIT_LANGUAGES, KitLanguage, KitLanguageCode, formatLocaleOf, languageOptions, loadUiKitLabels, peekUiKitLabels, resolveLanguage, useUiKitLabels } from './i18n/languages.js';
157
+ export { withAllPlurals } from './i18n/plurals.js';
157
158
  export { ApiTranslationReview, ApiTranslationReviewWrite, DEFAULT_TRANSLATION_REVIEW_FILTER, REVIEW_STATUSES, ReviewStatus, TranslationCorrection, TranslationReview, TranslationReviewFilter, TranslationReviewKey, TranslationReviewWrite, TranslationRow, TranslationRowsInput, TranslationSummary, TranslationVerdict, dropReviews, filterTranslationRows, flattenStrings, fromApiReview, keyInArea, keyInAreas, keyNamespace, mergeReviews, placeholderMismatch, placeholderTokens, reviewStatus, reviewWrite, summariseRows, toApiWrite, translationCorrections, translationRows } from './lib/translation-review.js';
158
159
  export { TranslationReviewPanel, TranslationReviewPanelProps } from './components/translation-review.js';
159
160
  export { DEFAULT_LEGAL_LABELS, LegalLabels, LegalLayout, LegalLayoutProps, LegalLink, LegalLinks, LegalLinksProps, LegalSection, LegalSectionProps } from './components/legal.js';
package/dist/index.js CHANGED
@@ -163,6 +163,7 @@ export * from "./i18n/kit-labels.js";
163
163
  export * from "./i18n/defaults.js";
164
164
  import { kitLabelStrings } from "./i18n/review.js";
165
165
  export * from "./i18n/languages.js";
166
+ import { withAllPlurals } from "./i18n/plurals.js";
166
167
  import {
167
168
  REVIEW_STATUSES,
168
169
  fromApiReview,
@@ -302,6 +303,7 @@ export {
302
303
  useWindowedRows,
303
304
  useWriteLock,
304
305
  watchReadsAnd,
306
+ withAllPlurals,
305
307
  wrapFetch,
306
308
  writeStored
307
309
  };
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["// @eifi1/ui-kit — shared design-system barrel.\n//\n// Domain-free UI + theme surface shared across apps (lead app: Keksdose). Consumers import\n// components/theme from \"@eifi1/ui-kit\" and the token stylesheet from \"@eifi1/ui-kit/tokens.css\".\n\n// ── lib ────────────────────────────────────────────────────────────────────\nexport * from \"./lib/calc\";\nexport { cn } from \"./lib/cn\";\nexport { logger, setStoreLog } from \"./lib/logger\";\n// Intl number / money / percent / date / relative-time formatting, and `useKitFormat()`\n// for the same bound to the provider's locale (kastlan's formatters.ts, English \"5m ago\").\nexport * from \"./lib/format\";\n// localStorage that never throws (Safari private mode, blocked site data).\nexport { readStored, writeStored } from \"./lib/safe-storage\";\n// Date helpers are also available via the \"@eifi1/ui-kit/dates\" subpath.\n\n// ── hooks ────────────────────────────────────────────────────────────────────\nexport { useMediaQuery } from \"./hooks/use-media-query\";\nexport { useBodyScrollLock } from \"./hooks/use-body-scroll-lock\";\nexport { useAnchoredRect } from \"./hooks/use-anchored-rect\";\nexport type { AnchorRect } from \"./hooks/use-anchored-rect\";\nexport { useAnchoredPanel, anchoredPanelPlacement, useVisualViewport } from \"./hooks/use-anchored-panel\";\nexport type {\n AnchoredPanel,\n AnchoredPanelOptions,\n ViewportBox,\n} from \"./hooks/use-anchored-panel\";\nexport { useEscapeKey, useOutsideClick } from \"./hooks/use-dismiss\";\n// Focus containment for an overlay, and a live region for a change that moves no focus.\n// Both were extracted from the two components that already did them correctly (Modal and\n// CommandPalette) so the rest of the kit stops being the exception.\nexport { useFocusTrap } from \"./hooks/use-focus-trap\";\nexport type { FocusTrapOptions } from \"./hooks/use-focus-trap\";\nexport { useAnnounce } from \"./hooks/use-announce\";\nexport type { UseAnnounceReturn, UseAnnounceOptions, AnnounceRegionProps } from \"./hooks/use-announce\";\nexport { useOverlayHistory } from \"./hooks/use-overlay-history\";\nexport { useCloseTransition, OVERLAY_EXIT_MS } from \"./hooks/use-close-transition\";\nexport { useRowSwipe } from \"./hooks/use-row-swipe\";\nexport type { SwipeStage, RowSwipeOptions, RowSwipeReturn } from \"./hooks/use-row-swipe\";\n// Any element as a file drop target, screened like FileButton (kastlan).\nexport { useFileDrop, dragHasFiles } from \"./hooks/use-file-drop\";\nexport type { UseFileDropOptions, UseFileDropReturn, FileDropProps } from \"./hooks/use-file-drop\";\n// kastlan's search boxes each carried a hand-rolled copy, two without the cleanup.\nexport { useDebounce, useDebouncedCallback } from \"./hooks/use-debounce\";\nexport type { DebouncedCallbackOptions, DebouncedFunction } from \"./hooks/use-debounce\";\n// One search param as state, the active tab in `?tab=`, and a dialog's open state in the\n// URL (Modal's `urlParam`) — kastlan's and keksdose's use-search-param-state, in the kit.\nexport { useSearchParamState, useTabParam, useDialogParam } from \"./hooks/use-search-param-state\";\nexport type { SearchParamStateOptions, DialogParam } from \"./hooks/use-search-param-state\";\n// Copy that reports whether it worked — keksdose's copy buttons said \"Copied\" when it had not.\nexport { useCopyToClipboard, copyToClipboard } from \"./hooks/use-copy-to-clipboard\";\nexport type { CopyState, UseCopyToClipboardOptions, UseCopyToClipboardReturn } from \"./hooks/use-copy-to-clipboard\";\n// Keyboard shortcuts (\"Mod+K\", \"Ctrl+Shift+F\") that leave typing in fields alone.\nexport { useHotkey, parseHotkey, matchesHotkey } from \"./hooks/use-hotkey\";\nexport type { Hotkey, ParsedHotkey, UseHotkeyOptions } from \"./hooks/use-hotkey\";\n// A protected file as an object URL, fetched with the app's own auth (kastlan's AuthedImage).\nexport { useAuthedSrc } from \"./hooks/use-authed-src\";\nexport type { AuthedFetcher, AuthedSrcStatus, UseAuthedSrcOptions, UseAuthedSrcResult } from \"./hooks/use-authed-src\";\n\n// ── theme / palettes ─────────────────────────────────────────────────────────\nexport * from \"./theme/chart-palette\";\n// Colour maths (sRGB ↔ OKLCH, WCAG contrast, CVD simulation) and the palette deriver\n// that builds a whole, contrast-solved palette from one brand colour plus any colours\n// pinned by hand. `auditPalette` / `auditChartRamp` measure a palette rather than\n// trusting a comment about it.\nexport * from \"./theme/color\";\nexport * from \"./theme/palette-derive\";\nexport * from \"./theme/palette-presets\";\nexport * from \"./theme/theme-store\";\nexport * from \"./theme/palette-store\";\n\n// ── components ───────────────────────────────────────────────────────────────\nexport * from \"./components/ui\";\n// The label-ABOVE field group (label, hint, error, required), inside wizards or out.\nexport * from \"./components/field\";\nexport * from \"./components/search-field\";\nexport * from \"./components/dropdown\";\nexport * from \"./components/popover\";\nexport { SwipeableRow } from \"./components/swipeable-row\";\nexport type { SwipeAction, SwipeableRowProps } from \"./components/swipeable-row\";\nexport * from \"./components/calculator\";\nexport * from \"./components/numpad-sheet\";\nexport * from \"./components/number-input\";\nexport * from \"./components/currency-select\";\nexport * from \"./components/amount-input\";\nexport * from \"./components/money-field\";\nexport * from \"./components/combobox\";\nexport * from \"./components/picker-sheet\";\nexport * from \"./components/entity-combobox\";\nexport * from \"./components/multi-entity-combobox\";\nexport * from \"./components/multi-select\";\nexport * from \"./components/tooltip\";\nexport * from \"./components/user-avatar\";\nexport * from \"./components/settings-fields\";\n// Sync state for a database-backed field: the engine (`useFieldSync`) and the\n// affordance (`FieldSyncIndicator`), kept separate because the state is useful\n// without the dot — a form can gate its submit on any field being `pending`.\nexport * from \"./components/field-sync\";\nexport * from \"./components/month-picker\";\nexport * from \"./components/checkbox\";\nexport * from \"./components/switch\";\nexport * from \"./components/slider\";\nexport * from \"./components/time-input\";\nexport * from \"./components/number-field\";\nexport * from \"./components/sparkline\";\nexport * from \"./components/stat-tile\";\nexport * from \"./components/signature-pad\";\nexport * from \"./components/password-strength\";\nexport * from \"./components/page-contents\";\nexport * from \"./components/disclosure\";\nexport * from \"./components/dialog-frame\";\n// Named, not `*`: danger-confirm also holds the password field and pending hook that\n// ReauthDialog shares with it, which are kit-internal.\nexport { DangerConfirm, DEFAULT_DANGER_CONFIRM_LABELS, typedMatches } from \"./components/danger-confirm\";\nexport type { DangerConfirmLabels, DangerConfirmProps, TypedMatch } from \"./components/danger-confirm\";\n// 0.18: re-authentication before a sensitive action (Kurvenschmiede 4).\nexport { ReauthDialog, DEFAULT_REAUTH_DIALOG_LABELS } from \"./components/reauth-dialog\";\nexport type { ReauthDialogProps, ReauthDialogLabels } from \"./components/reauth-dialog\";\n// `useConfirm()` — the promise-based replacement for `window.confirm`, one host per app.\nexport * from \"./components/confirm-dialog\";\n// A non-modal corner panel and its round trigger (keksdose's assistant launcher).\nexport * from \"./components/floating-panel\";\n// The count / clear / actions bar a selection brings up (keksdose's three copies).\nexport * from \"./components/bulk-action-bar\";\nexport * from \"./components/swatch-picker\";\nexport * from \"./components/icon-picker\";\nexport * from \"./components/choice-card\";\nexport * from \"./components/autocomplete\";\nexport * from \"./components/measured-grid\";\nexport { useWindowedRows } from \"./hooks/use-windowed-rows\";\nexport type { WindowedRows } from \"./hooks/use-windowed-rows\";\n// Named, not `export *`: file-button.tsx also holds the screening helpers the\n// dropzone shares, which are internal.\nexport { FileButton, useFilePicker, matchesAccept, DEFAULT_FILE_PICKER_LABELS } from \"./components/file-button\";\nexport type {\n FileButtonProps,\n UseFilePickerOptions,\n UseFilePickerReturn,\n FilePickerLabels,\n FileRejection,\n FileRejectionReason,\n FileScreenOptions,\n} from \"./components/file-button\";\nexport * from \"./components/treemap\";\nexport * from \"./components/series-chart\";\nexport * from \"./components/chart-zoom\";\nexport * from \"./components/toggle-legend\";\nexport * from \"./components/facing-pair\";\nexport * from \"./components/series-chart-labels\";\nexport * from \"./components/pie-chart\";\nexport * from \"./components/pie-chart-labels\";\nexport * from \"./components/account-settings\";\n// 0.12.0: the fourth account section, and the QR code TwoFactorSetting draws its\n// otpauth URI with (encoder in lib/qr-encode.ts — no dependency, see there).\nexport * from \"./components/passkeys-setting\";\nexport * from \"./components/qr-code\";\nexport { encodeQr } from \"./lib/qr-encode\";\nexport type { QrEncodeOptions, QrErrorCorrection, QrMatrix } from \"./lib/qr-encode\";\nexport * from \"./components/alert-banner\";\nexport * from \"./components/toggle-group\";\n// A wrapping set of links/buttons marked `aria-current` — Tabs-like looks, not tabs.\nexport * from \"./components/nav-pills\";\n// A pill carrying one VALUE — inert, a link, or a toggle, depending on which prop it is\n// given — and the list field built from it. Distinct from Button on purpose: a row of\n// buttons reads as \"choose an action\", a row of chips as \"here are the things\".\nexport * from \"./components/chip\";\nexport * from \"./components/wizard-stepper\";\nexport * from \"./components/hover-menu\";\nexport * from \"./components/modal\";\nexport * from \"./components/full-bleed-dialog\";\nexport * from \"./components/grouped-picker\";\nexport * from \"./components/file-dropzone\";\nexport * from \"./components/mini-calendar\";\nexport * from \"./components/calendar-heatmap\";\nexport * from \"./components/date-picker\";\nexport * from \"./components/tree-view\";\nexport * from \"./components/chart\";\n// 0.8.0 layout and feedback primitives the apps hand-rolled or took from shadcn/Radix.\nexport * from \"./components/description-list\";\n// The Save / Cancel row forms and dialogs end in, and the editable line-item repeater\n// (journal lines, lease components, invoice positions). 0.12.0.\nexport * from \"./components/form-actions\";\n// 0.18: lock every opted-in commit under one provider, the reason in its tooltip\n// (Kurvenschmiede 2). `useCommitReason` stays internal.\nexport { WriteLockProvider, useWriteLock, DEFAULT_WRITE_LOCK_LABELS } from \"./components/write-lock\";\nexport type { WriteLock, WriteLockLabels, WriteLockProviderProps } from \"./components/write-lock\";\n// 0.18: the parts both apps' admin rosters repeat (Kurvenschmiede 6).\nexport { RoleChip, AccountStateChip, DateMark, dateColumn, DEFAULT_ACCOUNT_STATE_LABELS, ACCOUNT_STATE_TONES } from \"./components/account-chips\";\nexport type { RoleChipProps, RoleDefinition, RoleVocabulary, AccountState, AccountStateLabels, AccountStateChipProps, DateMarkProps, DateColumnOptions } from \"./components/account-chips\";\n// 0.18: the cold-start notice keksdose built (#199), for every app: a framework-free\n// watchdog over the app's requests and the corner notice that explains the wait.\nexport { createServerWake, serverWake, watchReadsAnd, attachServerWake, wrapFetch } from \"./lib/server-wake\";\nexport type { ServerWakeStage, ServerWakeFilter, ServerWakeOptions, ServerWakeRequest, ServerWakeWatcher, ServerWakeAxiosConfig, AxiosLikeInstance } from \"./lib/server-wake\";\nexport { ServerWakeNotice, useServerWakeStage, DEFAULT_SERVER_WAKE_LABELS } from \"./components/server-wake\";\nexport type { ServerWakeLabels, ServerWakeNoticeProps } from \"./components/server-wake\";\n// 0.18: sharing — grantees, roles, pending grants, candidates (Kurvenschmiede 1).\nexport { ShareCard, ShareDialog, SharePanel, DEFAULT_SHARE_CARD_LABELS } from \"./components/share-card\";\nexport type { ShareCardProps, ShareDialogProps, SharePanelProps, ShareCardLabels, ShareRole, ShareGrantee, SharePendingGrant, ShareCandidate, ShareAddRequest } from \"./components/share-card\";\nexport * from \"./components/line-items\";\nexport * from \"./components/progress-bar\";\n// Named: skeleton.tsx also holds SKELETON_CLASS, the look StatTile shares — internal.\nexport { Skeleton } from \"./components/skeleton\";\nexport type { SkeletonProps, SkeletonShape } from \"./components/skeleton\";\n// 0.12.0: a centred spinner with words, signed figures and deltas, a render-error fallback.\nexport * from \"./components/loading-state\";\nexport * from \"./components/signed-amount\";\nexport * from \"./components/error-boundary\";\nexport * from \"./components/copy-button\";\nexport { AuthedImage, DEFAULT_AUTHED_IMAGE_LABELS } from \"./components/authed-image\";\nexport type { AuthedImageProps, AuthedImageLabels } from \"./components/authed-image\";\nexport * from \"./components/image-grid\";\nexport * from \"./components/lightbox\";\nexport * from \"./components/button-group\";\nexport * from \"./components/table\";\nexport * from \"./components/separator\";\n// Named: scroll-area.tsx also holds the overflow hook and scrollbar class Table shares.\nexport { ScrollArea } from \"./components/scroll-area\";\nexport type { ScrollAreaProps } from \"./components/scroll-area\";\n// 0.10.0 rows, menu rows, text primitives and page chrome the apps drew by hand.\nexport * from \"./components/list\";\nexport * from \"./components/menu-item\";\nexport * from \"./components/text\";\nexport * from \"./components/status-dot\";\nexport * from \"./components/page-header\";\nexport * from \"./components/breadcrumbs\";\n// The one inline link (kastlan's EntityLink / CellLink / legal and source links), through\n// `<UiKitProvider linkComponent>`. Named: the file also holds the kit's internal link picker.\nexport { TextLink } from \"./components/text-link\";\nexport type {\n TextLinkProps,\n TextLinkRenderProps,\n TextLinkTone,\n TextLinkUnderline,\n TextLinkCurrent,\n} from \"./components/text-link\";\n// The toast layer over sonner (an OPTIONAL peer, loaded lazily): `toast` mirrors sonner's\n// API so apps migrate by swapping the import; `<Toaster>` carries the placement, theme,\n// tones and z-index both apps had wired by hand.\nexport { toast, Toaster, DEFAULT_TOAST_LABELS, TOAST_ACTION_DURATION } from \"./components/toast\";\nexport type {\n ToastId,\n ToastAction,\n ToastOptions,\n ToastPromiseOptions,\n ToastUndoOptions,\n ToastRedoOptions,\n ToastLabels,\n ToastPosition,\n ToastSwipeDirection,\n ToasterOffset,\n ToasterProps,\n} from \"./components/toast\";\n\n// ── data-table suite ─────────────────────────────────────────────────────────\nexport * from \"./components/data-table-labels\";\nexport * from \"./components/data-table-sort\";\nexport * from \"./components/data-table-filters\";\nexport * from \"./components/data-table-pagination\";\nexport { FilterPopover } from \"./components/data-table-filter-popover\";\n// data-table re-exports SortState internally; export the rest explicitly to\n// avoid a duplicate SortState star-export (it comes from data-table-sort).\nexport { DataTable } from \"./components/data-table\";\nexport type {\n DataTableColumn,\n DataTableCellProps,\n DataTableHeadProps,\n DataTableProps,\n ServerPagination,\n FilterState,\n MobileSwipeActions,\n DataTableDensity,\n DataTableChrome,\n DataTableRowAction,\n} from \"./components/data-table\";\n// 0.12.0: the yes/no cell, and a server table's state held by its owner, URL-synced.\nexport * from \"./components/data-table-cells\";\nexport { useTableUrlState, readTableUrlState } from \"./components/use-table-state\";\nexport type {\n TableUrlState,\n TableUrlSync,\n TableUrlStateProps,\n UseTableUrlStateOptions,\n UseTableUrlStateReturn,\n} from \"./components/use-table-state\";\n\n// ── shell (composable app chrome) ────────────────────────────────────────────\nexport * from \"./shell/topbar-controls\";\nexport * from \"./shell/top-bar\";\nexport * from \"./shell/app-shell\";\nexport * from \"./shell/option-switcher-menu\";\nexport * from \"./shell/role-switcher\";\nexport * from \"./shell/topbar-action-menu\";\nexport * from \"./shell/top-bar-brand\";\nexport * from \"./shell/auth-layout\";\n\n// ── feedback ─────────────────────────────────────────────────────────────────\n// The form somebody files a report with, and the parts an inbox is built from:\n// the status vocabulary both apps share value for value, the policy about what a\n// row may move to from where it stands, and the look of a status control, a\n// category badge and an opened report. The app still wires its own API, columns\n// and strings — see the note at the top of `feedback-inbox.tsx` for what is\n// deliberately left to it.\nexport * from \"./feedback/feedback-attachment\";\nexport * from \"./feedback/feedback-dialog\";\nexport * from \"./feedback/feedback-inbox\";\nexport * from \"./feedback/feedback-thread\";\n\n// ── multi-step wizard ────────────────────────────────────────────────────────\n// The engine (step machine, validators, collected data), the chrome around it\n// (indicator + nav bar + cancel confirm) and the review step it ends on.\n// `components/wizard-stepper` next door is a different, much smaller thing: a bare\n// step indicator for a two-step import flow, with no engine.\n//\n// Two modules were removed here rather than kept as stock. `wizard-field` held a\n// `WizardField`/`WizardSelectField` pair that had never been rendered in any app —\n// a field group nothing was built from, which this comment used to advertise. And\n// `use-rhf-wizard-step` was the only thing in the package that touched\n// react-hook-form, for a hook neither consumer called: keeping it meant an optional\n// peer, a dev dependency and a type import in every consumer's typecheck, bought\n// for nobody. A design system may carry stock; untested stock nothing has ever\n// rendered is not stock, it is a liability with an export.\nexport * from \"./wizard/types\";\nexport * from \"./wizard/use-wizard\";\nexport * from \"./wizard/wizard-context\";\nexport * from \"./wizard/validation\";\nexport * from \"./wizard/wizard-step\";\nexport * from \"./wizard/wizard-summary\";\nexport * from \"./wizard/stepper-nav\";\n\n// ── guided tours ─────────────────────────────────────────────────────────────\nexport * from \"./tour/tour\";\n\n// ── command palette / global search ──────────────────────────────────────────\nexport * from \"./search/command-palette\";\nexport * from \"./search/search-index\";\nexport * from \"./search/global-search\";\n\n// ── i18n: one label tree, one optional provider ──────────────────────────────\n// `UiKitLabels` names every string the kit renders; `<UiKitProvider labels locale>`\n// hands a translation to every component below it. Precedence: a component's own\n// prop > the provider > the English default. See src/i18n/kit-labels.tsx.\nexport * from \"./i18n/kit-labels\";\nexport * from \"./i18n/defaults\";\n// The kit's words as `key → text` rows for an app's translation review (keksdose).\nexport { kitLabelStrings } from \"./i18n/review\";\n// The seven languages, how an app resolves one, and the kit catalogue loader for it.\nexport * from \"./i18n/languages\";\n// Translation review (0.19): one contract for kastlan's and keksdose's review pages —\n// the pure rows/status/placeholder logic and the presentational page parts.\nexport {\n REVIEW_STATUSES,\n fromApiReview,\n toApiWrite,\n flattenStrings,\n keyNamespace,\n placeholderTokens,\n placeholderMismatch,\n reviewStatus,\n keyInArea,\n keyInAreas,\n translationRows,\n summariseRows,\n DEFAULT_TRANSLATION_REVIEW_FILTER,\n filterTranslationRows,\n reviewWrite,\n mergeReviews,\n dropReviews,\n translationCorrections,\n} from \"./lib/translation-review\";\nexport type {\n TranslationVerdict,\n TranslationReview,\n TranslationReviewWrite,\n TranslationReviewKey,\n ReviewStatus,\n TranslationRow,\n ApiTranslationReview,\n ApiTranslationReviewWrite,\n TranslationRowsInput,\n TranslationSummary,\n TranslationReviewFilter,\n TranslationCorrection,\n} from \"./lib/translation-review\";\nexport {\n TranslationReviewPanel,\n TranslationReviewEditor,\n ReviewStatusChip,\n TranslationProgress,\n TranslationLocaleTabs,\n TranslationExportButton,\n REVIEW_STATUS_TONES,\n DEFAULT_TRANSLATION_REVIEW_LABELS,\n} from \"./components/translation-review\";\nexport type {\n TranslationReviewPanelProps,\n TranslationReviewEditorProps,\n ReviewStatusChipProps,\n TranslationProgressProps,\n TranslationLocaleTab,\n TranslationLocaleTabsProps,\n TranslationExportButtonProps,\n TranslationReviewLabels,\n} from \"./components/translation-review\";\n// The legal pages' shell (0.19, H10): Imprint, Privacy Policy, Terms in every app.\nexport { LegalLayout, LegalSection, LegalLinks, DEFAULT_LEGAL_LABELS } from \"./components/legal\";\nexport type { LegalLabels, LegalLink, LegalLinksProps, LegalSectionProps, LegalLayoutProps } from \"./components/legal\";\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["// @eifi1/ui-kit — shared design-system barrel.\n//\n// Domain-free UI + theme surface shared across apps (lead app: Keksdose). Consumers import\n// components/theme from \"@eifi1/ui-kit\" and the token stylesheet from \"@eifi1/ui-kit/tokens.css\".\n\n// ── lib ────────────────────────────────────────────────────────────────────\nexport * from \"./lib/calc\";\nexport { cn } from \"./lib/cn\";\nexport { logger, setStoreLog } from \"./lib/logger\";\n// Intl number / money / percent / date / relative-time formatting, and `useKitFormat()`\n// for the same bound to the provider's locale (kastlan's formatters.ts, English \"5m ago\").\nexport * from \"./lib/format\";\n// localStorage that never throws (Safari private mode, blocked site data).\nexport { readStored, writeStored } from \"./lib/safe-storage\";\n// Date helpers are also available via the \"@eifi1/ui-kit/dates\" subpath.\n\n// ── hooks ────────────────────────────────────────────────────────────────────\nexport { useMediaQuery } from \"./hooks/use-media-query\";\nexport { useBodyScrollLock } from \"./hooks/use-body-scroll-lock\";\nexport { useAnchoredRect } from \"./hooks/use-anchored-rect\";\nexport type { AnchorRect } from \"./hooks/use-anchored-rect\";\nexport { useAnchoredPanel, anchoredPanelPlacement, useVisualViewport } from \"./hooks/use-anchored-panel\";\nexport type {\n AnchoredPanel,\n AnchoredPanelOptions,\n ViewportBox,\n} from \"./hooks/use-anchored-panel\";\nexport { useEscapeKey, useOutsideClick } from \"./hooks/use-dismiss\";\n// Focus containment for an overlay, and a live region for a change that moves no focus.\n// Both were extracted from the two components that already did them correctly (Modal and\n// CommandPalette) so the rest of the kit stops being the exception.\nexport { useFocusTrap } from \"./hooks/use-focus-trap\";\nexport type { FocusTrapOptions } from \"./hooks/use-focus-trap\";\nexport { useAnnounce } from \"./hooks/use-announce\";\nexport type { UseAnnounceReturn, UseAnnounceOptions, AnnounceRegionProps } from \"./hooks/use-announce\";\nexport { useOverlayHistory } from \"./hooks/use-overlay-history\";\nexport { useCloseTransition, OVERLAY_EXIT_MS } from \"./hooks/use-close-transition\";\nexport { useRowSwipe } from \"./hooks/use-row-swipe\";\nexport type { SwipeStage, RowSwipeOptions, RowSwipeReturn } from \"./hooks/use-row-swipe\";\n// Any element as a file drop target, screened like FileButton (kastlan).\nexport { useFileDrop, dragHasFiles } from \"./hooks/use-file-drop\";\nexport type { UseFileDropOptions, UseFileDropReturn, FileDropProps } from \"./hooks/use-file-drop\";\n// kastlan's search boxes each carried a hand-rolled copy, two without the cleanup.\nexport { useDebounce, useDebouncedCallback } from \"./hooks/use-debounce\";\nexport type { DebouncedCallbackOptions, DebouncedFunction } from \"./hooks/use-debounce\";\n// One search param as state, the active tab in `?tab=`, and a dialog's open state in the\n// URL (Modal's `urlParam`) — kastlan's and keksdose's use-search-param-state, in the kit.\nexport { useSearchParamState, useTabParam, useDialogParam } from \"./hooks/use-search-param-state\";\nexport type { SearchParamStateOptions, DialogParam } from \"./hooks/use-search-param-state\";\n// Copy that reports whether it worked — keksdose's copy buttons said \"Copied\" when it had not.\nexport { useCopyToClipboard, copyToClipboard } from \"./hooks/use-copy-to-clipboard\";\nexport type { CopyState, UseCopyToClipboardOptions, UseCopyToClipboardReturn } from \"./hooks/use-copy-to-clipboard\";\n// Keyboard shortcuts (\"Mod+K\", \"Ctrl+Shift+F\") that leave typing in fields alone.\nexport { useHotkey, parseHotkey, matchesHotkey } from \"./hooks/use-hotkey\";\nexport type { Hotkey, ParsedHotkey, UseHotkeyOptions } from \"./hooks/use-hotkey\";\n// A protected file as an object URL, fetched with the app's own auth (kastlan's AuthedImage).\nexport { useAuthedSrc } from \"./hooks/use-authed-src\";\nexport type { AuthedFetcher, AuthedSrcStatus, UseAuthedSrcOptions, UseAuthedSrcResult } from \"./hooks/use-authed-src\";\n\n// ── theme / palettes ─────────────────────────────────────────────────────────\nexport * from \"./theme/chart-palette\";\n// Colour maths (sRGB ↔ OKLCH, WCAG contrast, CVD simulation) and the palette deriver\n// that builds a whole, contrast-solved palette from one brand colour plus any colours\n// pinned by hand. `auditPalette` / `auditChartRamp` measure a palette rather than\n// trusting a comment about it.\nexport * from \"./theme/color\";\nexport * from \"./theme/palette-derive\";\nexport * from \"./theme/palette-presets\";\nexport * from \"./theme/theme-store\";\nexport * from \"./theme/palette-store\";\n\n// ── components ───────────────────────────────────────────────────────────────\nexport * from \"./components/ui\";\n// The label-ABOVE field group (label, hint, error, required), inside wizards or out.\nexport * from \"./components/field\";\nexport * from \"./components/search-field\";\nexport * from \"./components/dropdown\";\nexport * from \"./components/popover\";\nexport { SwipeableRow } from \"./components/swipeable-row\";\nexport type { SwipeAction, SwipeableRowProps } from \"./components/swipeable-row\";\nexport * from \"./components/calculator\";\nexport * from \"./components/numpad-sheet\";\nexport * from \"./components/number-input\";\nexport * from \"./components/currency-select\";\nexport * from \"./components/amount-input\";\nexport * from \"./components/money-field\";\nexport * from \"./components/combobox\";\nexport * from \"./components/picker-sheet\";\nexport * from \"./components/entity-combobox\";\nexport * from \"./components/multi-entity-combobox\";\nexport * from \"./components/multi-select\";\nexport * from \"./components/tooltip\";\nexport * from \"./components/user-avatar\";\nexport * from \"./components/settings-fields\";\n// Sync state for a database-backed field: the engine (`useFieldSync`) and the\n// affordance (`FieldSyncIndicator`), kept separate because the state is useful\n// without the dot — a form can gate its submit on any field being `pending`.\nexport * from \"./components/field-sync\";\nexport * from \"./components/month-picker\";\nexport * from \"./components/checkbox\";\nexport * from \"./components/switch\";\nexport * from \"./components/slider\";\nexport * from \"./components/time-input\";\nexport * from \"./components/number-field\";\nexport * from \"./components/sparkline\";\nexport * from \"./components/stat-tile\";\nexport * from \"./components/signature-pad\";\nexport * from \"./components/password-strength\";\nexport * from \"./components/page-contents\";\nexport * from \"./components/disclosure\";\nexport * from \"./components/dialog-frame\";\n// Named, not `*`: danger-confirm also holds the password field and pending hook that\n// ReauthDialog shares with it, which are kit-internal.\nexport { DangerConfirm, DEFAULT_DANGER_CONFIRM_LABELS, typedMatches } from \"./components/danger-confirm\";\nexport type { DangerConfirmLabels, DangerConfirmProps, TypedMatch } from \"./components/danger-confirm\";\n// 0.18: re-authentication before a sensitive action (Kurvenschmiede 4).\nexport { ReauthDialog, DEFAULT_REAUTH_DIALOG_LABELS } from \"./components/reauth-dialog\";\nexport type { ReauthDialogProps, ReauthDialogLabels } from \"./components/reauth-dialog\";\n// `useConfirm()` — the promise-based replacement for `window.confirm`, one host per app.\nexport * from \"./components/confirm-dialog\";\n// A non-modal corner panel and its round trigger (keksdose's assistant launcher).\nexport * from \"./components/floating-panel\";\n// The count / clear / actions bar a selection brings up (keksdose's three copies).\nexport * from \"./components/bulk-action-bar\";\nexport * from \"./components/swatch-picker\";\nexport * from \"./components/icon-picker\";\nexport * from \"./components/choice-card\";\nexport * from \"./components/autocomplete\";\nexport * from \"./components/measured-grid\";\nexport { useWindowedRows } from \"./hooks/use-windowed-rows\";\nexport type { WindowedRows } from \"./hooks/use-windowed-rows\";\n// Named, not `export *`: file-button.tsx also holds the screening helpers the\n// dropzone shares, which are internal.\nexport { FileButton, useFilePicker, matchesAccept, DEFAULT_FILE_PICKER_LABELS } from \"./components/file-button\";\nexport type {\n FileButtonProps,\n UseFilePickerOptions,\n UseFilePickerReturn,\n FilePickerLabels,\n FileRejection,\n FileRejectionReason,\n FileScreenOptions,\n} from \"./components/file-button\";\nexport * from \"./components/treemap\";\nexport * from \"./components/series-chart\";\nexport * from \"./components/chart-zoom\";\nexport * from \"./components/toggle-legend\";\nexport * from \"./components/facing-pair\";\nexport * from \"./components/series-chart-labels\";\nexport * from \"./components/pie-chart\";\nexport * from \"./components/pie-chart-labels\";\nexport * from \"./components/account-settings\";\n// 0.12.0: the fourth account section, and the QR code TwoFactorSetting draws its\n// otpauth URI with (encoder in lib/qr-encode.ts — no dependency, see there).\nexport * from \"./components/passkeys-setting\";\nexport * from \"./components/qr-code\";\nexport { encodeQr } from \"./lib/qr-encode\";\nexport type { QrEncodeOptions, QrErrorCorrection, QrMatrix } from \"./lib/qr-encode\";\nexport * from \"./components/alert-banner\";\nexport * from \"./components/toggle-group\";\n// A wrapping set of links/buttons marked `aria-current` — Tabs-like looks, not tabs.\nexport * from \"./components/nav-pills\";\n// A pill carrying one VALUE — inert, a link, or a toggle, depending on which prop it is\n// given — and the list field built from it. Distinct from Button on purpose: a row of\n// buttons reads as \"choose an action\", a row of chips as \"here are the things\".\nexport * from \"./components/chip\";\nexport * from \"./components/wizard-stepper\";\nexport * from \"./components/hover-menu\";\nexport * from \"./components/modal\";\nexport * from \"./components/full-bleed-dialog\";\nexport * from \"./components/grouped-picker\";\nexport * from \"./components/file-dropzone\";\nexport * from \"./components/mini-calendar\";\nexport * from \"./components/calendar-heatmap\";\nexport * from \"./components/date-picker\";\nexport * from \"./components/tree-view\";\nexport * from \"./components/chart\";\n// 0.8.0 layout and feedback primitives the apps hand-rolled or took from shadcn/Radix.\nexport * from \"./components/description-list\";\n// The Save / Cancel row forms and dialogs end in, and the editable line-item repeater\n// (journal lines, lease components, invoice positions). 0.12.0.\nexport * from \"./components/form-actions\";\n// 0.18: lock every opted-in commit under one provider, the reason in its tooltip\n// (Kurvenschmiede 2). `useCommitReason` stays internal.\nexport { WriteLockProvider, useWriteLock, DEFAULT_WRITE_LOCK_LABELS } from \"./components/write-lock\";\nexport type { WriteLock, WriteLockLabels, WriteLockProviderProps } from \"./components/write-lock\";\n// 0.18: the parts both apps' admin rosters repeat (Kurvenschmiede 6).\nexport { RoleChip, AccountStateChip, DateMark, dateColumn, DEFAULT_ACCOUNT_STATE_LABELS, ACCOUNT_STATE_TONES } from \"./components/account-chips\";\nexport type { RoleChipProps, RoleDefinition, RoleVocabulary, AccountState, AccountStateLabels, AccountStateChipProps, DateMarkProps, DateColumnOptions } from \"./components/account-chips\";\n// 0.18: the cold-start notice keksdose built (#199), for every app: a framework-free\n// watchdog over the app's requests and the corner notice that explains the wait.\nexport { createServerWake, serverWake, watchReadsAnd, attachServerWake, wrapFetch } from \"./lib/server-wake\";\nexport type { ServerWakeStage, ServerWakeFilter, ServerWakeOptions, ServerWakeRequest, ServerWakeWatcher, ServerWakeAxiosConfig, AxiosLikeInstance } from \"./lib/server-wake\";\nexport { ServerWakeNotice, useServerWakeStage, DEFAULT_SERVER_WAKE_LABELS } from \"./components/server-wake\";\nexport type { ServerWakeLabels, ServerWakeNoticeProps } from \"./components/server-wake\";\n// 0.18: sharing — grantees, roles, pending grants, candidates (Kurvenschmiede 1).\nexport { ShareCard, ShareDialog, SharePanel, DEFAULT_SHARE_CARD_LABELS } from \"./components/share-card\";\nexport type { ShareCardProps, ShareDialogProps, SharePanelProps, ShareCardLabels, ShareRole, ShareGrantee, SharePendingGrant, ShareCandidate, ShareAddRequest } from \"./components/share-card\";\nexport * from \"./components/line-items\";\nexport * from \"./components/progress-bar\";\n// Named: skeleton.tsx also holds SKELETON_CLASS, the look StatTile shares — internal.\nexport { Skeleton } from \"./components/skeleton\";\nexport type { SkeletonProps, SkeletonShape } from \"./components/skeleton\";\n// 0.12.0: a centred spinner with words, signed figures and deltas, a render-error fallback.\nexport * from \"./components/loading-state\";\nexport * from \"./components/signed-amount\";\nexport * from \"./components/error-boundary\";\nexport * from \"./components/copy-button\";\nexport { AuthedImage, DEFAULT_AUTHED_IMAGE_LABELS } from \"./components/authed-image\";\nexport type { AuthedImageProps, AuthedImageLabels } from \"./components/authed-image\";\nexport * from \"./components/image-grid\";\nexport * from \"./components/lightbox\";\nexport * from \"./components/button-group\";\nexport * from \"./components/table\";\nexport * from \"./components/separator\";\n// Named: scroll-area.tsx also holds the overflow hook and scrollbar class Table shares.\nexport { ScrollArea } from \"./components/scroll-area\";\nexport type { ScrollAreaProps } from \"./components/scroll-area\";\n// 0.10.0 rows, menu rows, text primitives and page chrome the apps drew by hand.\nexport * from \"./components/list\";\nexport * from \"./components/menu-item\";\nexport * from \"./components/text\";\nexport * from \"./components/status-dot\";\nexport * from \"./components/page-header\";\nexport * from \"./components/breadcrumbs\";\n// The one inline link (kastlan's EntityLink / CellLink / legal and source links), through\n// `<UiKitProvider linkComponent>`. Named: the file also holds the kit's internal link picker.\nexport { TextLink } from \"./components/text-link\";\nexport type {\n TextLinkProps,\n TextLinkRenderProps,\n TextLinkTone,\n TextLinkUnderline,\n TextLinkCurrent,\n} from \"./components/text-link\";\n// The toast layer over sonner (an OPTIONAL peer, loaded lazily): `toast` mirrors sonner's\n// API so apps migrate by swapping the import; `<Toaster>` carries the placement, theme,\n// tones and z-index both apps had wired by hand.\nexport { toast, Toaster, DEFAULT_TOAST_LABELS, TOAST_ACTION_DURATION } from \"./components/toast\";\nexport type {\n ToastId,\n ToastAction,\n ToastOptions,\n ToastPromiseOptions,\n ToastUndoOptions,\n ToastRedoOptions,\n ToastLabels,\n ToastPosition,\n ToastSwipeDirection,\n ToasterOffset,\n ToasterProps,\n} from \"./components/toast\";\n\n// ── data-table suite ─────────────────────────────────────────────────────────\nexport * from \"./components/data-table-labels\";\nexport * from \"./components/data-table-sort\";\nexport * from \"./components/data-table-filters\";\nexport * from \"./components/data-table-pagination\";\nexport { FilterPopover } from \"./components/data-table-filter-popover\";\n// data-table re-exports SortState internally; export the rest explicitly to\n// avoid a duplicate SortState star-export (it comes from data-table-sort).\nexport { DataTable } from \"./components/data-table\";\nexport type {\n DataTableColumn,\n DataTableCellProps,\n DataTableHeadProps,\n DataTableProps,\n ServerPagination,\n FilterState,\n MobileSwipeActions,\n DataTableDensity,\n DataTableChrome,\n DataTableRowAction,\n} from \"./components/data-table\";\n// 0.12.0: the yes/no cell, and a server table's state held by its owner, URL-synced.\nexport * from \"./components/data-table-cells\";\nexport { useTableUrlState, readTableUrlState } from \"./components/use-table-state\";\nexport type {\n TableUrlState,\n TableUrlSync,\n TableUrlStateProps,\n UseTableUrlStateOptions,\n UseTableUrlStateReturn,\n} from \"./components/use-table-state\";\n\n// ── shell (composable app chrome) ────────────────────────────────────────────\nexport * from \"./shell/topbar-controls\";\nexport * from \"./shell/top-bar\";\nexport * from \"./shell/app-shell\";\nexport * from \"./shell/option-switcher-menu\";\nexport * from \"./shell/role-switcher\";\nexport * from \"./shell/topbar-action-menu\";\nexport * from \"./shell/top-bar-brand\";\nexport * from \"./shell/auth-layout\";\n\n// ── feedback ─────────────────────────────────────────────────────────────────\n// The form somebody files a report with, and the parts an inbox is built from:\n// the status vocabulary both apps share value for value, the policy about what a\n// row may move to from where it stands, and the look of a status control, a\n// category badge and an opened report. The app still wires its own API, columns\n// and strings — see the note at the top of `feedback-inbox.tsx` for what is\n// deliberately left to it.\nexport * from \"./feedback/feedback-attachment\";\nexport * from \"./feedback/feedback-dialog\";\nexport * from \"./feedback/feedback-inbox\";\nexport * from \"./feedback/feedback-thread\";\n\n// ── multi-step wizard ────────────────────────────────────────────────────────\n// The engine (step machine, validators, collected data), the chrome around it\n// (indicator + nav bar + cancel confirm) and the review step it ends on.\n// `components/wizard-stepper` next door is a different, much smaller thing: a bare\n// step indicator for a two-step import flow, with no engine.\n//\n// Two modules were removed here rather than kept as stock. `wizard-field` held a\n// `WizardField`/`WizardSelectField` pair that had never been rendered in any app —\n// a field group nothing was built from, which this comment used to advertise. And\n// `use-rhf-wizard-step` was the only thing in the package that touched\n// react-hook-form, for a hook neither consumer called: keeping it meant an optional\n// peer, a dev dependency and a type import in every consumer's typecheck, bought\n// for nobody. A design system may carry stock; untested stock nothing has ever\n// rendered is not stock, it is a liability with an export.\nexport * from \"./wizard/types\";\nexport * from \"./wizard/use-wizard\";\nexport * from \"./wizard/wizard-context\";\nexport * from \"./wizard/validation\";\nexport * from \"./wizard/wizard-step\";\nexport * from \"./wizard/wizard-summary\";\nexport * from \"./wizard/stepper-nav\";\n\n// ── guided tours ─────────────────────────────────────────────────────────────\nexport * from \"./tour/tour\";\n\n// ── command palette / global search ──────────────────────────────────────────\nexport * from \"./search/command-palette\";\nexport * from \"./search/search-index\";\nexport * from \"./search/global-search\";\n\n// ── i18n: one label tree, one optional provider ──────────────────────────────\n// `UiKitLabels` names every string the kit renders; `<UiKitProvider labels locale>`\n// hands a translation to every component below it. Precedence: a component's own\n// prop > the provider > the English default. See src/i18n/kit-labels.tsx.\nexport * from \"./i18n/kit-labels\";\nexport * from \"./i18n/defaults\";\n// The kit's words as `key → text` rows for an app's translation review (keksdose).\nexport { kitLabelStrings } from \"./i18n/review\";\n// The seven languages, how an app resolves one, and the kit catalogue loader for it.\nexport * from \"./i18n/languages\";\n// CLDR plural categories for an app's i18next catalogue, filled from `_other`.\nexport { withAllPlurals } from \"./i18n/plurals\";\n// Translation review (0.19): one contract for kastlan's and keksdose's review pages —\n// the pure rows/status/placeholder logic and the presentational page parts.\nexport {\n REVIEW_STATUSES,\n fromApiReview,\n toApiWrite,\n flattenStrings,\n keyNamespace,\n placeholderTokens,\n placeholderMismatch,\n reviewStatus,\n keyInArea,\n keyInAreas,\n translationRows,\n summariseRows,\n DEFAULT_TRANSLATION_REVIEW_FILTER,\n filterTranslationRows,\n reviewWrite,\n mergeReviews,\n dropReviews,\n translationCorrections,\n} from \"./lib/translation-review\";\nexport type {\n TranslationVerdict,\n TranslationReview,\n TranslationReviewWrite,\n TranslationReviewKey,\n ReviewStatus,\n TranslationRow,\n ApiTranslationReview,\n ApiTranslationReviewWrite,\n TranslationRowsInput,\n TranslationSummary,\n TranslationReviewFilter,\n TranslationCorrection,\n} from \"./lib/translation-review\";\nexport {\n TranslationReviewPanel,\n TranslationReviewEditor,\n ReviewStatusChip,\n TranslationProgress,\n TranslationLocaleTabs,\n TranslationExportButton,\n REVIEW_STATUS_TONES,\n DEFAULT_TRANSLATION_REVIEW_LABELS,\n} from \"./components/translation-review\";\nexport type {\n TranslationReviewPanelProps,\n TranslationReviewEditorProps,\n ReviewStatusChipProps,\n TranslationProgressProps,\n TranslationLocaleTab,\n TranslationLocaleTabsProps,\n TranslationExportButtonProps,\n TranslationReviewLabels,\n} from \"./components/translation-review\";\n// The legal pages' shell (0.19, H10): Imprint, Privacy Policy, Terms in every app.\nexport { LegalLayout, LegalSection, LegalLinks, DEFAULT_LEGAL_LABELS } from \"./components/legal\";\nexport type { LegalLabels, LegalLink, LegalLinksProps, LegalSectionProps, LegalLayoutProps } from \"./components/legal\";\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -1,3 +1,4 @@
1
+ import { useEffect, useState } from "react";
1
2
  import type { UiKitLabels } from "./kit-labels";
2
3
  import type { LanguageOption } from "../shell/topbar-controls";
3
4
 
@@ -180,17 +181,36 @@ export function resolveLanguage(
180
181
  * and `en` alone does not say which English) and for the app's own `Intl` calls, so
181
182
  * the kit's counts and the app's amounts group their digits the same way.
182
183
  *
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.
184
+ * Takes any tag, not only a {@link KitLanguageCode}. A tag of one of the seven
185
+ * languages formats in that language's home, by decision — not the reader's region:
186
+ * `de-AT` as `de-CH`, `en-AU` and `en-US` as `en-GB`, `zh-TW` as `zh-CN`. So an app can
187
+ * hand it i18next's `resolvedLanguage` as it comes.
188
+ *
189
+ * A tag of a language the kit does NOT ship formats as itself (canonicalised): `sv-SE`
190
+ * stays `sv-SE`. That is a formatting preference, not a language — keksdose's date
191
+ * setting offers ISO order as `sv-SE`, and 0.19.0 turned it into a German weekday by
192
+ * resolving it to the home language (keksdose's 0.19 adoption). Words are another
193
+ * matter: {@link resolveLanguage} and {@link loadUiKitLabels} still fall back to
194
+ * `de-CH` for such a tag. A tag `Intl` cannot read formats as `de-CH`.
188
195
  */
189
196
  export function formatLocaleOf(code: string): string {
190
- const resolved = resolveLanguage([code], ALL_CODES);
191
- return KIT_LANGUAGES.find((language) => language.code === resolved)!.formatLocale;
197
+ const shipped = answer(code, ALL_CODES);
198
+ if (shipped) return homeFormat(shipped);
199
+ try {
200
+ return Intl.getCanonicalLocales(code)[0] ?? homeFormat(HOME);
201
+ } catch {
202
+ return homeFormat(HOME);
203
+ }
192
204
  }
193
205
 
206
+ function homeFormat(code: KitLanguageCode): string {
207
+ return KIT_LANGUAGES.find((language) => language.code === code)!.formatLocale;
208
+ }
209
+
210
+ /** The format tag of the catalogue a code loads — always one of the seven languages'
211
+ * own, so the words and the digits of a catalogue agree. */
212
+ const catalogueFormat = (code: string) => homeFormat(resolveLanguage([code], ALL_CODES));
213
+
194
214
  /**
195
215
  * One loader per catalogue, each a LITERAL relative `import()`.
196
216
  *
@@ -221,6 +241,13 @@ const LOADERS: Record<KitLanguageCode, (formatLocale: string) => Promise<UiKitLa
221
241
  * object: the provider's context value keeps its identity, and every kit component
222
242
  * reading it is spared a re-render on a switch back. */
223
243
  const loaded = new Map<string, Promise<UiKitLabels>>();
244
+ /** The same catalogues once they have arrived — what {@link peekUiKitLabels} reads. */
245
+ const ready = new Map<string, UiKitLabels>();
246
+
247
+ function cacheKey(code: string, formatLocale: string): [KitLanguageCode, string] {
248
+ const language = resolveLanguage([code], ALL_CODES);
249
+ return [language, `${language}\u0000${formatLocale}`];
250
+ }
224
251
 
225
252
  /**
226
253
  * The kit's catalogue for a language, fetched on demand and formatted in
@@ -250,19 +277,62 @@ const loaded = new Map<string, Promise<UiKitLabels>>();
250
277
  */
251
278
  export function loadUiKitLabels(
252
279
  code: string,
253
- formatLocale: string = formatLocaleOf(code),
280
+ formatLocale: string = catalogueFormat(code),
254
281
  ): Promise<UiKitLabels> {
255
- const language = resolveLanguage([code], ALL_CODES);
256
- const key = `${language}\u0000${formatLocale}`;
282
+ const [language, key] = cacheKey(code, formatLocale);
257
283
  let labels = loaded.get(key);
258
284
  if (!labels) {
259
285
  labels = LOADERS[language](formatLocale);
260
286
  loaded.set(key, labels);
261
- labels.catch(() => loaded.delete(key));
287
+ labels.then(
288
+ (done) => ready.set(key, done),
289
+ () => loaded.delete(key),
290
+ );
262
291
  }
263
292
  return labels;
264
293
  }
265
294
 
295
+ /**
296
+ * The catalogue {@link loadUiKitLabels} already fetched for this language and format
297
+ * locale — synchronously — or `undefined` while it has not arrived. For the provider's
298
+ * render, which cannot await: kastlan wrapped the loader in a cache of its own
299
+ * (`hasKitLabels` / `kitLabelsFor`) to get exactly this, and the other apps would have
300
+ * written the same lines. Same object as the promise resolved to.
301
+ */
302
+ export function peekUiKitLabels(code: string, formatLocale: string = catalogueFormat(code)): UiKitLabels | undefined {
303
+ return ready.get(cacheKey(code, formatLocale)[1]);
304
+ }
305
+
306
+ /**
307
+ * The kit's catalogue for `code`, loading it on first use: `<UiKitProvider
308
+ * labels={useUiKitLabels(language)} locale={formatLocaleOf(language)}>`.
309
+ *
310
+ * On a switch it keeps returning the PREVIOUS language's catalogue until the next one
311
+ * has arrived, so the kit's words never blank out mid-switch; `undefined` only before
312
+ * the very first catalogue (render nothing, or the English defaults, until then — or
313
+ * await {@link loadUiKitLabels} before the first render, as the apps do for their own
314
+ * catalogue). A failed fetch keeps the previous catalogue; the next switch tries again.
315
+ */
316
+ export function useUiKitLabels(code: string, formatLocale: string = catalogueFormat(code)): UiKitLabels | undefined {
317
+ const current = peekUiKitLabels(code, formatLocale);
318
+ const [previous, setPrevious] = useState<UiKitLabels | undefined>(current);
319
+ useEffect(() => {
320
+ if (peekUiKitLabels(code, formatLocale)) return;
321
+ let live = true;
322
+ loadUiKitLabels(code, formatLocale).then(
323
+ (labels) => {
324
+ if (live) setPrevious(labels);
325
+ },
326
+ () => {},
327
+ );
328
+ return () => {
329
+ live = false;
330
+ };
331
+ }, [code, formatLocale]);
332
+ if (current && current !== previous) setPrevious(current);
333
+ return current ?? previous;
334
+ }
335
+
266
336
  /**
267
337
  * The rows `LanguageMenu` and `LanguageSetting` take, for the codes an app offers, in
268
338
  * the order given: `{ code, label: nativeName, country: flag }`.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Every plural category a language has, filled from the key family's `_other` — for an
3
+ * app's i18next catalogue, before i18next sees it.
4
+ *
5
+ * The apps write English's two forms, `key_one` and `key_other`, in every language: that
6
+ * is the key set their parity tests hold each catalogue to, and no translator should have
7
+ * to know which categories CLDR assigns. But CLDR gives French, Italian and Spanish a
8
+ * third, `many` (1 000 000, « 1,5 million »), and i18next asks for `key_many` there and
9
+ * nothing else — so without it the count fell down the fallback chain and printed German
10
+ * in a French sentence. Kurvenschmiede found it and filled the gap in memory; kastlan
11
+ * wrote the same function again (0.19 adoption). This is that function, once.
12
+ *
13
+ * `_other` is the right stand-in: CLDR's `many` shares its wording with `other` in all
14
+ * three. An ordinal family (`key_ordinal_one`, `…_ordinal_other`) is filled from the
15
+ * language's ORDINAL categories. A category the catalogue already has is left alone, and
16
+ * the input is not changed: a new tree comes back. A locale `Intl` cannot read (`""`,
17
+ * `"x"` — a backend `parse` called without a language) adds nothing rather than throwing,
18
+ * so one odd call cannot fail a whole namespace load (kastlan, 0.20 adoption).
19
+ *
20
+ * i18n.addResourceBundle(lng, "translation", withAllPlurals(catalogue, lng));
21
+ * // i18next-http-backend: { parse: (data, lng) => withAllPlurals(JSON.parse(data), lng) }
22
+ */
23
+ export function withAllPlurals<T extends Record<string, unknown>>(catalogue: T, locale: string): T & Record<string, unknown> {
24
+ const cardinal = pluralCategories(locale, "cardinal");
25
+ const ordinal = pluralCategories(locale, "ordinal");
26
+ const fill = (tree: Record<string, unknown>): Record<string, unknown> => {
27
+ const out: Record<string, unknown> = {};
28
+ for (const [key, value] of Object.entries(tree)) {
29
+ out[key] =
30
+ value !== null && typeof value === "object" && !Array.isArray(value)
31
+ ? fill(value as Record<string, unknown>)
32
+ : value;
33
+ }
34
+ for (const [key, value] of Object.entries(tree)) {
35
+ if (!key.endsWith("_other") || typeof value !== "string") continue;
36
+ const base = key.slice(0, -"_other".length);
37
+ const categories = base.endsWith("_ordinal") ? ordinal : cardinal;
38
+ for (const category of categories) {
39
+ const name = `${base}_${category}`;
40
+ if (!(name in out)) out[name] = value;
41
+ }
42
+ }
43
+ return out;
44
+ };
45
+ return fill(catalogue) as T & Record<string, unknown>;
46
+ }
47
+
48
+ function pluralCategories(locale: string, type: Intl.PluralRuleType): readonly string[] {
49
+ try {
50
+ return new Intl.PluralRules(locale, { type }).resolvedOptions().pluralCategories;
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
package/src/index.ts CHANGED
@@ -345,6 +345,8 @@ export * from "./i18n/defaults";
345
345
  export { kitLabelStrings } from "./i18n/review";
346
346
  // The seven languages, how an app resolves one, and the kit catalogue loader for it.
347
347
  export * from "./i18n/languages";
348
+ // CLDR plural categories for an app's i18next catalogue, filled from `_other`.
349
+ export { withAllPlurals } from "./i18n/plurals";
348
350
  // Translation review (0.19): one contract for kastlan's and keksdose's review pages —
349
351
  // the pure rows/status/placeholder logic and the presentational page parts.
350
352
  export {