@eifi1/ui-kit 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +9 -9
  2. package/dist/account/delete-account-setting.d.ts +3 -2
  3. package/dist/account/delete-account-setting.js.map +1 -1
  4. package/dist/auth/auth-errors.d.ts +36 -0
  5. package/dist/auth/auth-errors.js +20 -0
  6. package/dist/auth/auth-errors.js.map +1 -1
  7. package/dist/components/amount-input.d.ts +2 -1
  8. package/dist/components/amount-input.js.map +1 -1
  9. package/dist/components/calendar-heatmap.js +4 -2
  10. package/dist/components/calendar-heatmap.js.map +1 -1
  11. package/dist/components/currency-option-list.d.ts +2 -1
  12. package/dist/components/currency-option-list.js +10 -3
  13. package/dist/components/currency-option-list.js.map +1 -1
  14. package/dist/components/currency-select.d.ts +3 -1
  15. package/dist/components/currency-select.js.map +1 -1
  16. package/dist/components/danger-confirm.d.ts +22 -1
  17. package/dist/components/danger-confirm.js +50 -28
  18. package/dist/components/danger-confirm.js.map +1 -1
  19. package/dist/components/data-table-filter-popover.d.ts +2 -1
  20. package/dist/components/data-table-filter-popover.js +3 -2
  21. package/dist/components/data-table-filter-popover.js.map +1 -1
  22. package/dist/components/data-table-filters.d.ts +33 -3
  23. package/dist/components/data-table-filters.js +6 -4
  24. package/dist/components/data-table-filters.js.map +1 -1
  25. package/dist/components/dialog-frame.d.ts +3 -1
  26. package/dist/components/dialog-frame.js +6 -2
  27. package/dist/components/dialog-frame.js.map +1 -1
  28. package/dist/components/mini-calendar.js +3 -2
  29. package/dist/components/mini-calendar.js.map +1 -1
  30. package/dist/components/modal.d.ts +27 -0
  31. package/dist/components/modal.js +5 -3
  32. package/dist/components/modal.js.map +1 -1
  33. package/dist/components/number-input.js.map +1 -1
  34. package/dist/components/stat-tile.d.ts +21 -2
  35. package/dist/components/stat-tile.js +4 -2
  36. package/dist/components/stat-tile.js.map +1 -1
  37. package/dist/currencies.d.ts +16 -2
  38. package/dist/currencies.js +2 -1
  39. package/dist/currencies.js.map +1 -1
  40. package/dist/eslint/index.js +1 -1
  41. package/dist/feedback/feedback-context-labels.js +2 -1
  42. package/dist/feedback/feedback-context-labels.js.map +1 -1
  43. package/dist/feedback/feedback-context.d.ts +19 -3
  44. package/dist/feedback/feedback-context.js +12 -1
  45. package/dist/feedback/feedback-context.js.map +1 -1
  46. package/dist/feedback/feedback-diagnostics.d.ts +17 -0
  47. package/dist/feedback/feedback-diagnostics.js +27 -0
  48. package/dist/feedback/feedback-diagnostics.js.map +1 -0
  49. package/dist/feedback/feedback-dialog.d.ts +10 -0
  50. package/dist/feedback/feedback-dialog.js +6 -4
  51. package/dist/feedback/feedback-dialog.js.map +1 -1
  52. package/dist/feedback/feedback-record.d.ts +5 -0
  53. package/dist/feedback/feedback-record.js.map +1 -1
  54. package/dist/feedback/feedback-row-detail-labels.js +1 -0
  55. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  56. package/dist/feedback/feedback-row-detail.d.ts +3 -0
  57. package/dist/feedback/feedback-row-detail.js +3 -0
  58. package/dist/feedback/feedback-row-detail.js.map +1 -1
  59. package/dist/feedback/feedback-submit-dialog.d.ts +5 -1
  60. package/dist/feedback/feedback-submit-dialog.js +15 -3
  61. package/dist/feedback/feedback-submit-dialog.js.map +1 -1
  62. package/dist/feedback/feedback-submit.d.ts +30 -2
  63. package/dist/feedback/feedback-submit.js +5 -2
  64. package/dist/feedback/feedback-submit.js.map +1 -1
  65. package/dist/hooks/use-hotkey.js +11 -1
  66. package/dist/hooks/use-hotkey.js.map +1 -1
  67. package/dist/i18n/german.js +3 -1
  68. package/dist/i18n/german.js.map +1 -1
  69. package/dist/i18n/locales/en.js +3 -1
  70. package/dist/i18n/locales/en.js.map +2 -2
  71. package/dist/i18n/locales/es.js +3 -1
  72. package/dist/i18n/locales/es.js.map +1 -1
  73. package/dist/i18n/locales/fr.js +3 -1
  74. package/dist/i18n/locales/fr.js.map +1 -1
  75. package/dist/i18n/locales/hu.js +3 -1
  76. package/dist/i18n/locales/hu.js.map +1 -1
  77. package/dist/i18n/locales/it.js +3 -1
  78. package/dist/i18n/locales/it.js.map +1 -1
  79. package/dist/i18n/locales/zh.js +3 -1
  80. package/dist/i18n/locales/zh.js.map +1 -1
  81. package/dist/index.d.ts +3 -3
  82. package/dist/index.js +6 -1
  83. package/dist/index.js.map +1 -1
  84. package/dist/lib/dates.d.ts +7 -1
  85. package/dist/lib/dates.js +8 -1
  86. package/dist/lib/dates.js.map +1 -1
  87. package/dist/lib/format.d.ts +37 -2
  88. package/dist/lib/format.js +25 -1
  89. package/dist/lib/format.js.map +1 -1
  90. package/dist/lib/intl-cache.d.ts +4 -2
  91. package/dist/lib/intl-cache.js.map +1 -1
  92. package/dist/lib/translation-review.d.ts +26 -0
  93. package/dist/lib/translation-review.js +14 -0
  94. package/dist/lib/translation-review.js.map +1 -1
  95. package/package.json +1 -1
  96. package/src/account/delete-account-setting.tsx +3 -2
  97. package/src/auth/auth-errors.ts +69 -0
  98. package/src/components/amount-input.tsx +2 -1
  99. package/src/components/calendar-heatmap.tsx +8 -2
  100. package/src/components/currency-option-list.tsx +12 -4
  101. package/src/components/currency-select.tsx +3 -1
  102. package/src/components/danger-confirm.tsx +80 -27
  103. package/src/components/data-table-filter-popover.tsx +19 -11
  104. package/src/components/data-table-filters.ts +36 -8
  105. package/src/components/dialog-frame.tsx +12 -4
  106. package/src/components/mini-calendar.tsx +5 -2
  107. package/src/components/modal.tsx +36 -7
  108. package/src/components/number-input.tsx +4 -3
  109. package/src/components/stat-tile.tsx +32 -3
  110. package/src/currencies.ts +23 -3
  111. package/src/feedback/feedback-context-labels.ts +1 -0
  112. package/src/feedback/feedback-context.tsx +34 -2
  113. package/src/feedback/feedback-diagnostics.tsx +41 -0
  114. package/src/feedback/feedback-dialog.tsx +16 -4
  115. package/src/feedback/feedback-record.ts +5 -0
  116. package/src/feedback/feedback-row-detail-labels.ts +1 -0
  117. package/src/feedback/feedback-row-detail.tsx +13 -0
  118. package/src/feedback/feedback-submit-dialog.tsx +15 -2
  119. package/src/feedback/feedback-submit.tsx +36 -3
  120. package/src/hooks/use-hotkey.ts +24 -1
  121. package/src/i18n/german.ts +2 -0
  122. package/src/i18n/locales/es.ts +2 -0
  123. package/src/i18n/locales/fr.ts +2 -0
  124. package/src/i18n/locales/hu.ts +2 -0
  125. package/src/i18n/locales/it.ts +2 -0
  126. package/src/i18n/locales/zh.ts +2 -0
  127. package/src/index.ts +14 -2
  128. package/src/lib/dates.ts +24 -2
  129. package/src/lib/format.ts +66 -3
  130. package/src/lib/intl-cache.ts +4 -2
  131. package/src/lib/translation-review.ts +52 -0
@@ -158,6 +158,7 @@ export function germanLabels(numberLocale: string): UiKitLabels {
158
158
  feedbackContext: {
159
159
  user: "Nutzer",
160
160
  attachUrl: "Aktuelle Seiten-URL anhängen",
161
+ diagnostics: "Mitgesendete Diagnose",
161
162
  },
162
163
  feedbackPage: {
163
164
  title: "Feedback-Eingang",
@@ -190,6 +191,7 @@ export function germanLabels(numberLocale: string): UiKitLabels {
190
191
  cancel: "Abbrechen",
191
192
  url: "URL",
192
193
  copyUrl: "URL kopieren",
194
+ diagnostics: "Diagnose",
193
195
  attachment: "Anhang",
194
196
  download: (name) => `${name} herunterladen`,
195
197
  downloadFailed: "Der Anhang konnte nicht heruntergeladen werden.",
@@ -150,6 +150,7 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
150
150
  feedbackContext: {
151
151
  user: "Usuario",
152
152
  attachUrl: "Adjuntar la URL de la página actual",
153
+ diagnostics: "Diagnóstico enviado con el informe",
153
154
  },
154
155
  feedbackPage: {
155
156
  title: "Comentarios recibidos",
@@ -182,6 +183,7 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
182
183
  cancel: "Cancelar",
183
184
  url: "URL",
184
185
  copyUrl: "Copiar URL",
186
+ diagnostics: "Diagnóstico",
185
187
  attachment: "Adjunto",
186
188
  download: (name) => `Descargar ${name}`,
187
189
  downloadFailed: "No se ha podido descargar el adjunto.",
@@ -151,6 +151,7 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
151
151
  feedbackContext: {
152
152
  user: "Utilisateur",
153
153
  attachUrl: "Joindre l’URL de la page actuelle",
154
+ diagnostics: "Diagnostic envoyé avec le retour",
154
155
  },
155
156
  feedbackPage: {
156
157
  title: "Retours reçus",
@@ -183,6 +184,7 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
183
184
  cancel: "Annuler",
184
185
  url: "URL",
185
186
  copyUrl: "Copier l’URL",
187
+ diagnostics: "Diagnostic",
186
188
  attachment: "Pièce jointe",
187
189
  download: (name) => `Télécharger ${name}`,
188
190
  downloadFailed: "La pièce jointe n’a pas pu être téléchargée.",
@@ -148,6 +148,7 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
148
148
  feedbackContext: {
149
149
  user: "Felhasználó",
150
150
  attachUrl: "Az aktuális oldal URL-jének csatolása",
151
+ diagnostics: "A bejelentéssel küldött diagnosztika",
151
152
  },
152
153
  feedbackPage: {
153
154
  title: "Beérkezett visszajelzések",
@@ -180,6 +181,7 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
180
181
  cancel: "Mégse",
181
182
  url: "URL",
182
183
  copyUrl: "URL másolása",
184
+ diagnostics: "Diagnosztika",
183
185
  attachment: "Melléklet",
184
186
  download: (name) => `${name} letöltése`,
185
187
  downloadFailed: "A mellékletet nem sikerült letölteni.",
@@ -150,6 +150,7 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
150
150
  feedbackContext: {
151
151
  user: "Utente",
152
152
  attachUrl: "Allega l’URL della pagina corrente",
153
+ diagnostics: "Diagnostica inviata con la segnalazione",
153
154
  },
154
155
  feedbackPage: {
155
156
  title: "Feedback ricevuti",
@@ -182,6 +183,7 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
182
183
  cancel: "Annulla",
183
184
  url: "URL",
184
185
  copyUrl: "Copia URL",
186
+ diagnostics: "Diagnostica",
185
187
  attachment: "Allegato",
186
188
  download: (name) => `Scarica ${name}`,
187
189
  downloadFailed: "Non è stato possibile scaricare l’allegato.",
@@ -141,6 +141,7 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
141
141
  feedbackContext: {
142
142
  user: "用户",
143
143
  attachUrl: "附加当前页面 URL",
144
+ diagnostics: "随反馈发送的诊断信息",
144
145
  },
145
146
  feedbackPage: {
146
147
  title: "反馈收件箱",
@@ -173,6 +174,7 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
173
174
  cancel: "取消",
174
175
  url: "URL",
175
176
  copyUrl: "复制 URL",
177
+ diagnostics: "诊断信息",
176
178
  attachment: "附件",
177
179
  download: (name) => `下载 ${name}`,
178
180
  downloadFailed: "无法下载附件。",
package/src/index.ts CHANGED
@@ -93,6 +93,10 @@ export {
93
93
  reviewWrite,
94
94
  mergeReviews,
95
95
  dropReviews,
96
+ // 0.54: a save or a reset of any size under server-kit's cap of 2000 (kastlan reuse
97
+ // audit 2026-11-02, proposal 5).
98
+ saveReviewsInChunks,
99
+ clearReviewsInChunks,
96
100
  translationCorrections,
97
101
  // 0.25: grouping and the approvals' Undo (keksdose live #377).
98
102
  groupTranslationRows,
@@ -761,7 +765,9 @@ export type { RegisterFormProps, RegisterLabels, RegisterValues } from "./auth/r
761
765
  export { CompleteNameDialog, DEFAULT_COMPLETE_NAME_LABELS } from "./auth/complete-name-dialog";
762
766
  export type { CompleteNameDialogProps, CompleteNameLabels, CompleteNameValues } from "./auth/complete-name-dialog";
763
767
  export { taggedEmail } from "./auth/email-tag";
764
- export { isAuthError, authErrorCode, isBillingError } from "./auth/auth-errors";
768
+ // 0.54 (kastlan reuse audit 2026-11-02, proposal 4): `billingRefusalText`, a billing
769
+ // refusal's words, which every app mapped from the code by hand.
770
+ export { isAuthError, authErrorCode, isBillingError, billingRefusalText } from "./auth/auth-errors";
765
771
  export type { AuthErrorCode } from "./auth/auth-errors";
766
772
  export { ForgotPasswordForm, DEFAULT_FORGOT_PASSWORD_LABELS } from "./auth/forgot-password-form";
767
773
  export type { ForgotPasswordFormProps, ForgotPasswordLabels } from "./auth/forgot-password-form";
@@ -793,7 +799,13 @@ export type { AuthHeadingLevel } from "./auth/status-parts";
793
799
  // 0.30.0: a rate limit and its wait, and the codes the account, billing and demo calls
794
800
  // answer with.
795
801
  export { isRateLimited, retryAfterSeconds } from "./auth/auth-errors";
796
- export type { AccountErrorCode, BillingErrorCode, DemoErrorCode, KitErrorCode } from "./auth/auth-errors";
802
+ export type {
803
+ AccountErrorCode,
804
+ BillingErrorCode,
805
+ BillingRefusalTextOptions,
806
+ DemoErrorCode,
807
+ KitErrorCode,
808
+ } from "./auth/auth-errors";
797
809
 
798
810
  // ── account ──────────────────────────────────────────────────────────────────
799
811
  // In account/ since 0.34, beside the other account cards (MOD-8, refactor audit 2026-10):
package/src/lib/dates.ts CHANGED
@@ -24,6 +24,9 @@
24
24
  * all, so sending it the user's local day is also what it wants.
25
25
  */
26
26
 
27
+ // Imports nothing else: `@eifi1/ui-kit/dates` reaches no package (packaging-contract).
28
+ import { dateFormatter } from "./intl-cache";
29
+
27
30
  /** Zero-pad a number to two digits ("3" -> "03"). */
28
31
  export function pad(n: number): string {
29
32
  return String(n).padStart(2, "0");
@@ -124,14 +127,33 @@ export function parseIsoDate(s: string): Date | null {
124
127
  return dt;
125
128
  }
126
129
 
130
+ /** The fields that make an option set a DATE for `toLocaleDateString` (ECMA-402's
131
+ * ToDateTimeOptions, required "date"): with none of them it adds year, month and day. */
132
+ const DATE_FIELDS = ["weekday", "year", "month", "day", "dateStyle"] as const;
133
+ const NUMERIC_DATE: Intl.DateTimeFormatOptions = { year: "numeric", month: "numeric", day: "numeric" };
134
+
135
+ /** `options` as `toLocaleDateString` reads them: with a date in them, whatever else they
136
+ * ask for. `{ hour: "numeric" }` alone is a date and an hour there, never the hour alone
137
+ * that `Intl.DateTimeFormat` would print for it. */
138
+ function asDateOptions(options: Intl.DateTimeFormatOptions | undefined): Intl.DateTimeFormatOptions {
139
+ if (!options) return NUMERIC_DATE;
140
+ return DATE_FIELDS.some((field) => options[field] !== undefined) ? options : { ...options, ...NUMERIC_DATE };
141
+ }
142
+
127
143
  /** Format a "YYYY-MM-DD" string for display in `locale` (empty string when
128
144
  * unparseable). Defaults to the locale's short numeric date; pass `options` to
129
145
  * override (e.g. `{ dateStyle: "medium" }`). `locale` may be `undefined` since 0.34 —
130
146
  * the runtime's default, as `Intl` reads it and as every other kit formatter takes it
131
- * (DUP-11: DatePicker re-wrapped this function only to pass none). */
147
+ * (DUP-11: DatePicker re-wrapped this function only to pass none).
148
+ *
149
+ * Through the kit's formatter cache since 0.54 (keksdose audit follow-up, 2026-11-01):
150
+ * `toLocaleDateString` builds a formatter per call, and this one runs for the date
151
+ * pickers' trigger on every render and for an app's date column once per row. The output
152
+ * is `toLocaleDateString`'s: the same options, with the date it adds when they name none
153
+ * (`asDateOptions`). */
132
154
  export function formatIsoDate(iso: string, locale: string | undefined, options?: Intl.DateTimeFormatOptions): string {
133
155
  const d = parseIsoDate(iso);
134
- return d ? d.toLocaleDateString(locale, options) : "";
156
+ return d ? dateFormatter(locale, asDateOptions(options)).format(d) : "";
135
157
  }
136
158
 
137
159
  /** A "YYYY-MM-DD" string shifted by `days` (negative shifts back), or the input
package/src/lib/format.ts CHANGED
@@ -11,8 +11,8 @@ export type { MinusStyle } from "./minus";
11
11
  export { formatWithMinus } from "./minus";
12
12
 
13
13
  /**
14
- * Number, money, percentage, date and relative-time formatting — `Intl`, in one place,
15
- * for the figures an app prints OUTSIDE a kit component.
14
+ * Number, money, percentage, duration (0.54), date and relative-time formatting — `Intl`,
15
+ * in one place, for the figures an app prints OUTSIDE a kit component.
16
16
  *
17
17
  * Each app grew its own: kastlan's `utils/formatters.ts` (a Swiss-pinned
18
18
  * `formatNumber`/`formatCurrency`, a date-fns `formatDate`, and a `formatRelativeTime`
@@ -99,6 +99,19 @@ export interface FormatMoneyOptions extends FormatNumberBaseOptions {
99
99
  currencyDisplay?: Intl.NumberFormatOptions["currencyDisplay"];
100
100
  }
101
101
 
102
+ export interface FormatDurationOptions extends BaseOptions {
103
+ /**
104
+ * How the units are written — `Intl.NumberFormat`'s `unitDisplay`, in `locale`:
105
+ * - `"narrow"` (default): "3m 12s", "3 Min. 12 Sek." (de-CH), "3min 12s" (fr) —
106
+ * keksdose's scan readout;
107
+ * - `"short"`: "3 min 12 sec", "3 Min. 12 Sek.", "3 min 12 s";
108
+ * - `"long"`: "3 minutes 12 seconds", "3 Minuten 12 Sekunden", "3 minutes 12 secondes".
109
+ */
110
+ unitDisplay?: "narrow" | "short" | "long";
111
+ /** The minus of a negative duration — {@link FormatNumberBaseOptions.minus}. */
112
+ minus?: MinusStyle;
113
+ }
114
+
102
115
  export interface FormatPercentOptions extends FormatNumberBaseOptions {
103
116
  /**
104
117
  * `true` (default): `value` is a RATIO, as `Intl` and `StatTile`'s percent delta read
@@ -162,7 +175,7 @@ function devWarnings(): boolean {
162
175
  const warnedSites = new Set<string>();
163
176
  /** This module's own frames, skipped to find the caller ("Object.formatMoney" is a
164
177
  * `useKitFormat` wrapper). */
165
- const OWN_FRAME = /\b(?:formatNumber|formatMoney|formatPercent|toFormattable|warnInvalid|callSite)\b/;
178
+ const OWN_FRAME = /\b(?:formatNumber|formatMoney|formatPercent|formatDuration|toFormattable|warnInvalid|callSite)\b/;
166
179
 
167
180
  /** The first stack frame outside the formatters: where the bad value was passed. */
168
181
  function callSite(): string {
@@ -248,6 +261,53 @@ export function formatPercent(value: FormattableNumber, options: FormatPercentOp
248
261
  return formatWithMinus(formatter, ratio, options.minus);
249
262
  }
250
263
 
264
+ /** The units a duration is written in, largest first, with their length in seconds. */
265
+ const DURATION_UNITS = [
266
+ ["hour", 3600],
267
+ ["minute", 60],
268
+ ["second", 1],
269
+ ] as const;
270
+
271
+ /**
272
+ * A number of SECONDS as an elapsed time in `locale`: "42s", "3m 12s", "1h 0m 5s" — and in
273
+ * German "3 Min. 12 Sek.", in French "3min 12s" (keksdose audit follow-up, 2026-11-01:
274
+ * keksdose's `formatElapsed`, the scan card's readout, moves here). Each unit is
275
+ * `Intl.NumberFormat`'s `style: "unit"`, so the digits and the unit's spelling follow
276
+ * `locale`; `unitDisplay` picks the form, narrow by default.
277
+ *
278
+ * - Seconds alone under a minute ("0s", "59s"); minutes and seconds under an hour ("1m 0s",
279
+ * "59m 59s"); from an hour on, hours, minutes and seconds ("1h 0m 0s"). Hours are the
280
+ * largest unit: a day and a bit is "27h 46m 40s". Every unit below the first is printed,
281
+ * zero too, so a ticking readout keeps its shape from one second to the next.
282
+ * - Whole seconds, cut toward zero: a timer reads "59s" until the minute is full.
283
+ * - The parts are joined by a space, as the readout always was — not `Intl.ListFormat`'s
284
+ * "3 Min. und 12 Sek.", which is a sentence rather than a figure.
285
+ * - Negative: the minus on the first unit, as `Intl.DurationFormat` writes one — "−3m 12s",
286
+ * with the kit's minus (`minus`). An elapsed time from a server's clock can come out a
287
+ * second below zero; clamp it before, as keksdose's card does, when that is not news.
288
+ * - `null` / `undefined` print `empty`; `NaN`, ±`Infinity` and a non-numeric string too,
289
+ * with the formatters' development warning. A numeric string ("185") is a number.
290
+ */
291
+ export function formatDuration(seconds: FormattableNumber, options: FormatDurationOptions = {}): string {
292
+ const n = toFormattable("formatDuration", seconds);
293
+ if (n === undefined) return options.empty ?? EMPTY_FORMATTED_VALUE;
294
+ const unitDisplay = options.unitDisplay ?? "narrow";
295
+ const negative = n < 0;
296
+ let rest = Math.trunc(Math.abs(n));
297
+ const parts: string[] = [];
298
+ for (const [unit, size] of DURATION_UNITS) {
299
+ const count = Math.floor(rest / size);
300
+ rest -= count * size;
301
+ // Units above the first that has a count are left out; seconds always print.
302
+ if (parts.length === 0 && count === 0 && unit !== "second") continue;
303
+ const formatter = numberFormatter(options.locale, { style: "unit", unit, unitDisplay });
304
+ // `count > 0`: under a second either way is "0s", with no sign on it.
305
+ const signed = negative && parts.length === 0 && count > 0 ? -count : count;
306
+ parts.push(formatWithMinus(formatter, signed, options.minus));
307
+ }
308
+ return parts.join(" ");
309
+ }
310
+
251
311
  /* ── Dates ─────────────────────────────────────────────────────────────────── */
252
312
 
253
313
  /**
@@ -436,6 +496,8 @@ export interface KitFormat {
436
496
  formatNumber: (value: FormattableNumber, options?: WithoutLocale<FormatNumberOptions>) => string;
437
497
  formatMoney: (value: FormattableNumber, currency: string, options?: WithoutLocale<FormatMoneyOptions>) => string;
438
498
  formatPercent: (value: FormattableNumber, options?: WithoutLocale<FormatPercentOptions>) => string;
499
+ /** 0.54: {@link formatDuration}, a number of seconds as an elapsed time. */
500
+ formatDuration: (seconds: FormattableNumber, options?: WithoutLocale<FormatDurationOptions>) => string;
439
501
  formatDate: (
440
502
  value: DateInput,
441
503
  style?: FormatDateStyle | Intl.DateTimeFormatOptions,
@@ -462,6 +524,7 @@ export function useKitFormat(locale?: string): KitFormat {
462
524
  formatNumber: (value, options) => formatNumber(value, { locale: kitLocale, ...options }),
463
525
  formatMoney: (value, currency, options) => formatMoney(value, currency, { locale: kitLocale, ...options }),
464
526
  formatPercent: (value, options) => formatPercent(value, { locale: kitLocale, ...options }),
527
+ formatDuration: (seconds, options) => formatDuration(seconds, { locale: kitLocale, ...options }),
465
528
  formatDate: (value, style, options) => formatDate(value, style, { locale: kitLocale, timeZone, ...options }),
466
529
  formatRelativeTime: (value, options) => formatRelativeTime(value, { locale: kitLocale, timeZone, ...options }),
467
530
  }),
@@ -4,10 +4,12 @@
4
4
  * Constructing the formatter is the expensive part: keksdose measured 392 ms per click on
5
5
  * a 270-row register when each cell built its own (dev#632), and kept its own renderer of
6
6
  * the date-format preference because the kit's built one per call (refactor audit
7
- * 2026-10-30, DUP-2). `lib/format` and `lib/date-format` both format through this one.
7
+ * 2026-10-30, DUP-2). `lib/format` and `lib/date-format` both format through this one, and
8
+ * since 0.54 `lib/dates`' `formatIsoDate` (keksdose audit follow-up, 2026-11-01).
8
9
  *
9
10
  * @internal Not part of the barrel — `lib/format` is re-exported whole, so the cache
10
- * lives apart from it.
11
+ * lives apart from it. It imports nothing, which `@eifi1/ui-kit/dates` (no package at
12
+ * all) relies on.
11
13
  */
12
14
 
13
15
  const dateFormatters = new Map<string, Intl.DateTimeFormat>();
@@ -570,6 +570,58 @@ export function dropReviews<T extends TranslationReviewKey>(
570
570
  return prev.filter((r) => !gone.has(`${r.locale}|${r.key}`));
571
571
  }
572
572
 
573
+ /**
574
+ * How many verdicts one request carries. server-kit refuses more than 2000 in one
575
+ * `PUT /translations/reviews` or `POST /translations/reviews/clear` (`BATCH_MAX`, its
576
+ * translation_review/schemas.py); half of it, as each of the three apps sent. A whole area
577
+ * is rarely more than a few hundred, so most saves are one request; "Approve the
578
+ * unreviewed" over an app's every string is not.
579
+ */
580
+ const REVIEW_CHUNK = 1000;
581
+
582
+ /**
583
+ * A save of any size under server-kit's cap: `writes` in chunks of 1000, one request
584
+ * after the other, in their order, and every verdict the server answered with, in that
585
+ * order — to patch the page's cached list with {@link mergeReviews} once. `save` is the
586
+ * app's own request (`PUT /translations/reviews`), answering with the verdicts it wrote,
587
+ * in either spelling. No writes, no request: the server refuses an empty batch.
588
+ *
589
+ * A chunk that fails rejects with its error, and the chunks after it are not sent. What
590
+ * the chunks before it wrote stays written — the server has it — but is not handed back,
591
+ * so the page leaves its cache as it was and the panel shows the error; a second try
592
+ * writes those verdicts again, which changes nothing but the time on them.
593
+ *
594
+ * All three apps wrote this loop, with a `chunks` helper and `SAVE_CHUNK = 1000` of
595
+ * their own (kastlan reuse audit 2026-11-02, proposal 5):
596
+ *
597
+ * const written = await saveReviewsInChunks(writes.map(toApiWrite), (part) => translationsApi.save(part));
598
+ * patchCache((reviews) => mergeReviews(reviews, written));
599
+ */
600
+ export async function saveReviewsInChunks<W extends TranslationReviewKey, R extends TranslationReviewKey>(
601
+ writes: readonly W[],
602
+ save: (chunk: W[]) => Promise<readonly R[]>,
603
+ ): Promise<R[]> {
604
+ const written: R[] = [];
605
+ for (let at = 0; at < writes.length; at += REVIEW_CHUNK) {
606
+ written.push(...(await save(writes.slice(at, at + REVIEW_CHUNK))));
607
+ }
608
+ return written;
609
+ }
610
+
611
+ /**
612
+ * {@link saveReviewsInChunks} for a reset (`POST /translations/reviews/clear`): `keys` in
613
+ * chunks of 1000, one request after the other, and the keys themselves once every chunk
614
+ * is through — to drop from the cached list with {@link dropReviews} once. A failing
615
+ * chunk rejects as a save's does, and what the chunks before it cleared stays cleared.
616
+ */
617
+ export async function clearReviewsInChunks<K extends TranslationReviewKey>(
618
+ keys: readonly K[],
619
+ clear: (chunk: K[]) => Promise<unknown>,
620
+ ): Promise<K[]> {
621
+ for (let at = 0; at < keys.length; at += REVIEW_CHUNK) await clear(keys.slice(at, at + REVIEW_CHUNK));
622
+ return [...keys];
623
+ }
624
+
573
625
  // ── Swipe bindings ──────────────────────────────────────────────────────────────
574
626
 
575
627
  /**