@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
@@ -146,8 +146,9 @@ function isConsequence(item: DeleteAccountConsequence): item is DangerConsequenc
146
146
  * and the password. They are the tile's own {@link TypedConfirmField} and
147
147
  * {@link CurrentPasswordInput}, drawn in its fields slot and held by
148
148
  * `confirmDisabledReason` with the tile's own sentences, rather than its built-in
149
- * `phrase` / `requirePassword` — those have no error of their own, and a wrong password
150
- * belongs under the password. Its consequences are the app's list, plus
149
+ * `phrase` / `requirePassword` — those share ONE `error` (0.54, under the password), and
150
+ * this card's refusals go to two fields: a wrong password under the password, a
151
+ * mismatched address under the address. Its consequences are the app's list, plus
151
152
  * Kurvenschmiede's hand-over line (`handOverCount`, §2.7: shared work goes to an
152
153
  * administrator, never a refusal). The typed address goes to `onRequest` as typed, so
153
154
  * the server re-checks it (`confirm_email_matches`).
@@ -1,3 +1,4 @@
1
+ import type { BillingLabels } from "../billing/billing-labels";
1
2
  import { httpStatusOf } from "../lib/http-status";
2
3
 
3
4
  /**
@@ -254,6 +255,74 @@ export function isBillingError(err: unknown, code?: BillingErrorCode): boolean {
254
255
  return found !== undefined && BILLING_CODES.has(found as BillingErrorCode) && (code === undefined || found === code);
255
256
  }
256
257
 
258
+ /** Who reads a `billing_read_only` refusal — {@link billingRefusalText}'s third argument,
259
+ * `useBillingLockReason`'s two options. */
260
+ export interface BillingRefusalTextOptions {
261
+ /** The reader is a GUEST in someone else's item (§12.6): the words never say why. */
262
+ guest?: boolean;
263
+ /** A guest's item, by its own name ("Household 2026"). Ignored for the payer. */
264
+ item?: string;
265
+ }
266
+
267
+ type RefusalWords = (labels: BillingLabels, options: BillingRefusalTextOptions) => string;
268
+
269
+ /**
270
+ * Each billing refusal's words in the `billing` labels. A `Record` over the whole union, so
271
+ * a code server-kit adds is a type error here until it has words.
272
+ */
273
+ const BILLING_REFUSAL_WORDS: Record<BillingErrorCode, RefusalWords> = {
274
+ billing_disabled: (labels) => labels.disabled,
275
+ // The lock's own sentence (`useBillingLockReason`): a guest's never says why (§12.6).
276
+ billing_read_only: (labels, options) => (options.guest ? labels.guestReadOnly(options.item) : labels.lockReason),
277
+ // The notice's first line. The figure and the way to more are `isPlanLimit`'s, for
278
+ // `PlanLimitNotice` or `usePlanLimitToast`.
279
+ plan_limit: (labels) => labels.limitReached,
280
+ billing_not_configured: (labels) => labels.notConfigured,
281
+ billing_provider_unavailable: (labels) => labels.providerUnavailable,
282
+ billing_not_at_provider: (labels) => labels.notAtProvider,
283
+ billing_already_subscribed: (labels) => labels.alreadySubscribed,
284
+ billing_plan_not_sold: (labels) => labels.planNotSold,
285
+ billing_not_subscribed: (labels) => labels.notSubscribed,
286
+ billing_plan_unchanged: (labels) => labels.planUnchanged,
287
+ };
288
+
289
+ /**
290
+ * The words for a billing refusal, in `labels` (an app's `useBillingLabels()`, or its
291
+ * `billing` catalogue outside React), or `null` when `err` is not one — for the toast or
292
+ * the dialog after a billing call failed, with the app's own words for anything else
293
+ * (kastlan reuse audit 2026-11-02, proposal 4).
294
+ *
295
+ * All three apps mapped the codes to the labels by hand: kastlan's
296
+ * `billing-refusal-dialog.tsx`, keksdose's `subscription-cards.tsx` and Kurvenschmiede's
297
+ * `api/client.ts`, the same eight codes each. This follows the most complete of them:
298
+ * every {@link BillingErrorCode}, so it is `null` only for an error that is not billing's.
299
+ *
300
+ * - `billing_read_only` — the write lock's sentence, as `useBillingLockReason` says it:
301
+ * the payer's `lockReason`, or with `{ guest: true, item }` a guest's `guestReadOnly`,
302
+ * which never says why (§12.6) — kastlan's dialog, which passes `guest: !isAdmin`.
303
+ * - `plan_limit` — `limitReached`, "Plan limit reached". Where the figure fits, read
304
+ * `isPlanLimit` first and show `PlanLimitNotice` or `usePlanLimitToast`, as kastlan does.
305
+ * - the eight others — their labels: `disabled`, `notConfigured`, `providerUnavailable`,
306
+ * `notAtProvider`, `alreadySubscribed`, `planNotSold`, `notSubscribed`, `planUnchanged`.
307
+ *
308
+ * Reads the code wherever {@link authErrorCode} does (axios, ofetch, a generated client,
309
+ * FastAPI's nested `detail`).
310
+ *
311
+ * catch (err) {
312
+ * toast.error(billingRefusalText(err, labels) ?? describeError(err));
313
+ * }
314
+ */
315
+ export function billingRefusalText(
316
+ err: unknown,
317
+ labels: BillingLabels,
318
+ options: BillingRefusalTextOptions = {},
319
+ ): string | null {
320
+ const found = authErrorCode(err);
321
+ return found !== undefined && BILLING_CODES.has(found as BillingErrorCode)
322
+ ? BILLING_REFUSAL_WORDS[found as BillingErrorCode](labels, options)
323
+ : null;
324
+ }
325
+
257
326
  /**
258
327
  * The parsed body that carries a code, wherever {@link authErrorCode} would find it (in
259
328
  * the same order) — the object with the `code`, so FastAPI's nested `detail` when the code
@@ -149,7 +149,8 @@ export interface AmountInputProps extends FieldHelpProps, DataAttributes {
149
149
  currencyNames?: Record<string, string>;
150
150
  /** Offer only these ISO codes in the chip's list, in this order — `CurrencySelect`'s
151
151
  * `options`, since the chip renders CurrencySelect's list (round 0.34 Wave 2, DUP-4).
152
- * Default every currency the kit knows. */
152
+ * Default every currency the kit knows but a `retired` one (the lev), which only the
153
+ * field that holds it lists. */
153
154
  currencyOptions?: string[];
154
155
  /** Focus the field on mount — on mobile this also opens the numpad sheet, so a
155
156
  * new-transaction form can jump straight to amount entry (feedback #70). */
@@ -3,6 +3,7 @@ import type { ComponentPropsWithRef, CSSProperties, KeyboardEvent, ReactNode } f
3
3
  import { cn } from "../lib/cn";
4
4
  import { dirOf, horizontalStep } from "../lib/direction";
5
5
  import { addMonthsClamped, localeWeekStart, parseIsoDate, toLocalIso } from "../lib/dates";
6
+ import { dateFormatter } from "../lib/intl-cache";
6
7
  import { useKitLabels, useKitLocale, useKitWeekStart, type LabelOverride } from "../i18n/kit-labels";
7
8
  import { lerpHex, textOn } from "../theme/chart-palette";
8
9
  import { parseHex } from "../theme/color";
@@ -154,6 +155,8 @@ const DAY_NAME_FORMAT: Intl.DateTimeFormatOptions = {
154
155
  month: "long",
155
156
  year: "numeric",
156
157
  };
158
+ /** A month's name over its first column ("Sep"). */
159
+ const MONTH_LABEL_FORMAT: Intl.DateTimeFormatOptions = { month: "short" };
157
160
 
158
161
  /** The empty day: a wash of the text colour, like `--bg-hover`, so it reads as a cell
159
162
  * on both themes without claiming any value. */
@@ -345,7 +348,10 @@ export function CalendarHeatmap({
345
348
  };
346
349
  }, [layout, firstIso, lastIso]);
347
350
 
348
- const formatDay = (d: Date) => d.toLocaleDateString(locale, DAY_NAME_FORMAT);
351
+ // Every day cell names its date, so through the kit's formatter cache, as the month
352
+ // labels are: `toLocaleDateString` built one per cell and render, 365 for a year's grid
353
+ // (keksdose audit follow-up, 2026-11-01).
354
+ const formatDay = (d: Date) => dateFormatter(locale, DAY_NAME_FORMAT).format(d);
349
355
 
350
356
  const weekdayLabels = useMemo(() => {
351
357
  const ref = new Date(2024, 0, 7 + weekStart); // 7 January 2024 was a Sunday
@@ -503,7 +509,7 @@ export function CalendarHeatmap({
503
509
  for (let r = 0; r < 7; r++) {
504
510
  const d = dayAt(w * 7 + r);
505
511
  if (d && (d.getDate() === 1 || (w === 0 && r === lead && d.getDate() <= 14))) {
506
- return d.toLocaleDateString(locale, { month: "short" });
512
+ return dateFormatter(locale, MONTH_LABEL_FORMAT).format(d);
507
513
  }
508
514
  }
509
515
  return "";
@@ -73,7 +73,8 @@ export interface CurrencyOptionListProps {
73
73
  /** A currency was picked. The host closes the list. */
74
74
  onChoose: (code: string) => void;
75
75
  /** Only these ISO codes, in this order; codes the kit has no definition for are
76
- * dropped. Default every one of {@link CURRENCIES}. */
76
+ * dropped. Default every one of {@link CURRENCIES} that is not `retired` (the chosen
77
+ * one too, if it is). A retired code named here stays: the list is the caller's. */
77
78
  codes?: readonly string[];
78
79
  /** Translated names by code — see {@link currencyName}. */
79
80
  currencyNames?: Record<string, string>;
@@ -112,19 +113,27 @@ export function CurrencyOptionList({
112
113
  searchPlaceholder,
113
114
  sheetTitle,
114
115
  }: CurrencyOptionListProps) {
116
+ const held = getCurrency(value);
117
+ const chosen = held?.code;
118
+ // A retired currency the field holds — the one the default list keeps for it. Its own
119
+ // key, so a change of an ordinary value does not rebuild the rows and their names.
120
+ const heldRetired = held?.retired ? held.code : undefined;
115
121
  // Keyed on the codes' CONTENTS: an inline `options={["EUR", "CHF"]}` is a new array
116
122
  // every render.
117
123
  const codesKey = codes?.join(",");
118
124
  const pool = useMemo(
119
125
  () =>
120
126
  codesKey === undefined
121
- ? CURRENCIES
127
+ ? // No currency that was replaced (the lev, since Bulgaria's euro: keksdose audit
128
+ // follow-up, 2026-11-01) — unless the field holds it, which then opens on its
129
+ // own row, checked, rather than on a list where nothing is chosen.
130
+ CURRENCIES.filter((c) => !c.retired || c.code === heldRetired)
122
131
  : // The caller's order; codes the kit has no definition for are dropped.
123
132
  codesKey
124
133
  .split(",")
125
134
  .map((code) => getCurrency(code))
126
135
  .filter((c): c is CurrencyOption => c !== undefined),
127
- [codesKey],
136
+ [codesKey, heldRetired],
128
137
  );
129
138
  const named = useMemo(
130
139
  () => pool.map((c) => ({ c, name: currencyName(c, currencyNames, locale) })),
@@ -147,7 +156,6 @@ export function CurrencyOptionList({
147
156
  }));
148
157
  }, [named, query, locale]);
149
158
 
150
- const chosen = getCurrency(value)?.code;
151
159
  return (
152
160
  <ComboboxPanel
153
161
  // The core with the currency filter's rows in place of its own.
@@ -41,7 +41,9 @@ export interface CurrencySelectProps extends Omit<ComponentPropsWithoutRef<"div"
41
41
  /**
42
42
  * Restrict the pickable currencies to these ISO codes (e.g. only the
43
43
  * currencies actually present in a budget). Order is preserved. Defaults to
44
- * the full {@link CURRENCIES} list.
44
+ * the full {@link CURRENCIES} list but a `retired` currency (the lev, since
45
+ * Bulgaria's euro), which only a field that holds it lists. A retired code named
46
+ * here is offered: the list is the caller's.
45
47
  */
46
48
  options?: string[];
47
49
  /** Required and unanswered — {@link FIELD_INVALID}, the same rose border the
@@ -6,6 +6,7 @@ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
6
6
  import { Button } from "./button";
7
7
  import { Input } from "./input";
8
8
  import { Label } from "./field-base";
9
+ import { FIELD_ERROR_CLASS } from "./field-parts";
9
10
  import { Spinner } from "./spinner";
10
11
  import type { ButtonVariant } from "./button-look";
11
12
  import type { InputProps } from "./input";
@@ -536,6 +537,27 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithRef<"div">, "
536
537
  * confirm, so a keyboard user can retry or Shift+Tab to the field to correct (0.24).
537
538
  */
538
539
  busy?: boolean;
540
+ /**
541
+ * Why the last confirm failed ("Wrong password"), in the tile beside the field it is
542
+ * about rather than in a toast away from it (keksdose audit follow-up, 2026-11-01:
543
+ * PasswordDangerCard toasted its `password_incorrect` for want of this). The app sets
544
+ * it from the failure and words it — a 400 is a wrong password, a 500 is not.
545
+ *
546
+ * Drawn as the field's own `error`, as {@link ReauthDialog} draws its own: under the
547
+ * password with `requirePassword`, else under the typed phrase — the field painted,
548
+ * `aria-invalid`, the message its `aria-describedby` — and in a tile with neither, as
549
+ * a line above the buttons. Announced as it appears (`role="alert"`): it arrives
550
+ * after the press, with focus on the confirm, where a field's description alone is
551
+ * not heard. Passed at all — even as `undefined` — the field keeps its box, as with
552
+ * Input's `error`, so the message going away does not rebuild the field mid-edit.
553
+ *
554
+ * Hidden while the next attempt runs, once the user edits the tick, the phrase or the
555
+ * password (it is about what was submitted), and when the tile arms or disarms, until
556
+ * the next confirm; shown again when a confirm fails with it still set. So a
557
+ * mutation's error can be handed over as it is:
558
+ * `error={mut.error ? message(mut.error) : undefined}`.
559
+ */
560
+ error?: ReactNode;
539
561
  /** The arm button is disabled. */
540
562
  disabled?: boolean;
541
563
  /**
@@ -628,30 +650,32 @@ function firstSlotField(slot: HTMLElement | null): HTMLElement | null {
628
650
  * destructive button itself), and disarming moves it back to the arm button, so the
629
651
  * keyboard user is never left on an element that just vanished.
630
652
  */
631
- export function DangerConfirm({
632
- onConfirm,
633
- requirePassword,
634
- requireAcknowledge,
635
- consequences,
636
- phrase,
637
- phraseMatch = "trim",
638
- prompt,
639
- tone = "danger",
640
- confirmVariant,
641
- confirmDisabledReason,
642
- children,
643
- armLabel,
644
- confirmLabel,
645
- busy: busyProp,
646
- disabled,
647
- lockedReason: ownLockedReason,
648
- commit,
649
- armed: armedProp,
650
- onArmedChange,
651
- labels: labelsProp,
652
- className,
653
- ...rest
654
- }: DangerConfirmProps) {
653
+ export function DangerConfirm(props: DangerConfirmProps) {
654
+ const {
655
+ onConfirm,
656
+ requirePassword,
657
+ requireAcknowledge,
658
+ consequences,
659
+ phrase,
660
+ phraseMatch = "trim",
661
+ prompt,
662
+ tone = "danger",
663
+ confirmVariant,
664
+ confirmDisabledReason,
665
+ children,
666
+ armLabel,
667
+ confirmLabel,
668
+ busy: busyProp,
669
+ error,
670
+ disabled,
671
+ lockedReason: ownLockedReason,
672
+ commit,
673
+ armed: armedProp,
674
+ onArmedChange,
675
+ labels: labelsProp,
676
+ className,
677
+ ...rest
678
+ } = props;
655
679
  const labels = useKitLabels("dangerConfirm", DEFAULT_DANGER_CONFIRM_LABELS, labelsProp);
656
680
  const lockedReason = useCommitReason(commit, ownLockedReason);
657
681
  const [armedState, setArmedState] = useState(false);
@@ -659,6 +683,9 @@ export function DangerConfirm({
659
683
  const [password, setPassword] = useState("");
660
684
  const [typed, setTyped] = useState("");
661
685
  const [acknowledged, setAcknowledged] = useState(false);
686
+ // The `error` describes what was submitted: an edit, an arm or a disarm leaves it
687
+ // describing nothing on screen. Cleared by the next confirm — ReauthDialog's `edited`.
688
+ const [errorStale, setErrorStale] = useState(false);
662
689
  const { pending, run } = usePromisePending();
663
690
  const busy = Boolean(busyProp) || pending;
664
691
  // `readOnly` rather than `disabled` on the fields while busy: disabling the field
@@ -686,6 +713,8 @@ export function DangerConfirm({
686
713
  const [wasArmed, setWasArmed] = useState(armed);
687
714
  if (wasArmed !== armed) {
688
715
  setWasArmed(armed);
716
+ // An `error` still passed is about the attempt before: not shown until a confirm.
717
+ setErrorStale(true);
689
718
  if (!armed) {
690
719
  setPassword("");
691
720
  setTyped("");
@@ -785,11 +814,22 @@ export function DangerConfirm({
785
814
  ...(asksAcknowledge && { acknowledged: true as const }),
786
815
  };
787
816
  confirmed.current = true;
817
+ setErrorStale(false);
788
818
  // Disarms when it resolves; a rejection leaves it armed, fields kept: the caller
789
819
  // shows why, the user retries.
790
820
  run(onConfirm(values.password, values), () => setArmed(false));
791
821
  };
792
822
 
823
+ // The failure's message, under the field it is about: the password, else the phrase,
824
+ // else (a tile with neither) a line of its own. Not while the next attempt runs.
825
+ const shownError = errorStale || busy ? undefined : error;
826
+ const errorAlert = hasContent(shownError) ? <span role="alert">{shownError}</span> : undefined;
827
+ const errorOn = requirePassword ? "password" : phrase !== undefined ? "phrase" : "line";
828
+ // Passed at all, the carrying field keeps its box (Input's `error` rule): only then.
829
+ const fieldError = (field: "password" | "phrase") =>
830
+ "error" in props && errorOn === field ? { error: errorAlert } : {};
831
+ const edited = () => setErrorStale(true);
832
+
793
833
  // The first field takes focus on arm: the tick, then the phrase, then the password —
794
834
  // the order they are drawn in.
795
835
  const firstField = asksAcknowledge ? "acknowledge" : phrase !== undefined ? "phrase" : "password";
@@ -880,7 +920,9 @@ export function DangerConfirm({
880
920
  // Not `disabled` while busy, for the same reason the fields are readOnly:
881
921
  // the box may hold the focus. A change while busy is simply not taken.
882
922
  onCheckedChange={(next) => {
883
- if (!busy) setAcknowledged(next);
923
+ if (busy) return;
924
+ setAcknowledged(next);
925
+ edited();
884
926
  }}
885
927
  />
886
928
  )}
@@ -890,11 +932,15 @@ export function DangerConfirm({
890
932
  target={phrase}
891
933
  match={phraseMatch}
892
934
  value={typed}
893
- onValueChange={setTyped}
935
+ onValueChange={(next) => {
936
+ setTyped(next);
937
+ edited();
938
+ }}
894
939
  busy={busy}
895
940
  // Already resolved through this tile's own `labels` prop; handed on so a
896
941
  // per-instance phrase wording reaches the field.
897
942
  labels={{ phrase: labels.phrase, phrasePlaceholder: labels.phrasePlaceholder }}
943
+ {...fieldError("phrase")}
898
944
  />
899
945
  )}
900
946
  {requirePassword && (
@@ -903,9 +949,16 @@ export function DangerConfirm({
903
949
  label={labels.password}
904
950
  value={password}
905
951
  busy={busy}
906
- onValueChange={setPassword}
952
+ onValueChange={(next) => {
953
+ setPassword(next);
954
+ edited();
955
+ }}
956
+ {...fieldError("password")}
907
957
  />
908
958
  )}
959
+ {errorOn === "line" && errorAlert !== undefined && (
960
+ <p className={cn(FIELD_ERROR_CLASS, "mt-0")}>{errorAlert}</p>
961
+ )}
909
962
  <div className="flex flex-wrap gap-2">
910
963
  <Button id={cancelId} type="button" variant="ghost" disabled={busy} onClick={() => setArmed(false)}>
911
964
  {labels.cancel}
@@ -40,7 +40,8 @@ interface FilterPopoverProps<T> {
40
40
  * The contents of a column's filter Popover. Renders the right control set for
41
41
  * the column's filter type — text search, select checklist, date range (presets
42
42
  * + {@link MiniCalendar} + from/to inputs), or numeric min/max with an abs
43
- * toggle — driving the shared {@link FilterValue} state.
43
+ * toggle (none for `numberFilter(getValue, { abs: false })`) — driving the shared
44
+ * {@link FilterValue} state.
44
45
  */
45
46
  export function FilterPopover<T>({
46
47
  column,
@@ -219,6 +220,11 @@ export function FilterPopover<T>({
219
220
  }
220
221
 
221
222
  // number
223
+ // `abs: false` on the filter: no "Absolute value" box, and a state that carries `abs`
224
+ // anyway (a view stored before the column lost the box) is handed on without it at the
225
+ // first bound typed — there is no box to untick it with (kastlan reuse audit
226
+ // 2026-11-02, proposal 7).
227
+ const absBox = filter.abs !== false;
222
228
  const v = state.type === "number" ? state : { type: "number" as const, min: "", max: "", abs: false };
223
229
  // The bounds were native `type="number"` inputs, the one numeric entry in the kit that
224
230
  // bypassed decimal-marks (BUG-10, refactor audit 2026-10): where the browser refuses a
@@ -237,7 +243,7 @@ export function FilterPopover<T>({
237
243
  type: "number",
238
244
  min: bound === "min" ? read : v.min,
239
245
  max: bound === "max" ? read : v.max,
240
- abs: v.abs,
246
+ abs: absBox && v.abs,
241
247
  });
242
248
  };
243
249
  return (
@@ -266,15 +272,17 @@ export function FilterPopover<T>({
266
272
  />
267
273
  </label>
268
274
  </div>
269
- <label className="flex items-center gap-2 text-sm text-secondary">
270
- <input
271
- type="checkbox"
272
- checked={v.abs}
273
- onChange={(e) => onChange({ type: "number", min: v.min, max: v.max, abs: e.target.checked })}
274
- className="size-3.5"
275
- />
276
- {labels.numberAbs}
277
- </label>
275
+ {absBox && (
276
+ <label className="flex items-center gap-2 text-sm text-secondary">
277
+ <input
278
+ type="checkbox"
279
+ checked={v.abs}
280
+ onChange={(e) => onChange({ type: "number", min: v.min, max: v.max, abs: e.target.checked })}
281
+ className="size-3.5"
282
+ />
283
+ {labels.numberAbs}
284
+ </label>
285
+ )}
278
286
  {clearRow}
279
287
  </div>
280
288
  );
@@ -8,7 +8,15 @@ export type ColumnFilter<T> =
8
8
  | { type: "text"; getValue: (row: T) => string }
9
9
  | { type: "select"; getValue: (row: T) => string; options?: { value: string; label?: string }[] }
10
10
  | { type: "date"; getValue: (row: T) => string | null | undefined }
11
- | { type: "number"; getValue: (row: T) => number | null | undefined };
11
+ | {
12
+ type: "number";
13
+ getValue: (row: T) => number | null | undefined;
14
+ /** `false`: no "Absolute value" box, for a figure that is never negative (a year, a
15
+ * count, a size), where the box only offers a choice that changes nothing. A state
16
+ * or a link that carries `abs` anyway is read without it. Default `true`. See
17
+ * {@link numberFilter}. */
18
+ abs?: boolean;
19
+ };
12
20
 
13
21
  /** The current state of a column's filter (what the user has entered). */
14
22
  export type FilterValue =
@@ -46,8 +54,15 @@ export function isFilterActive(v: FilterValue | undefined): boolean {
46
54
  * match every row while the column showed its filter as active; `Number(" ")` is 0, and
47
55
  * a blank bound kept every negative row out. The popover hands in the dot-decimal text
48
56
  * its locale's marks read (`readTyped`); anything else is no bound.
57
+ *
58
+ * Exported for a server-side table (`serverPagination`), which sends the bounds to its
59
+ * server instead of comparing here: send what this reads, and leave out a `null`. The box
60
+ * holds a half-typed "-" or "5e" while the user types, and kastlan's server tables sent
61
+ * that text on to `col >= :bound` until they wrote this rule again for themselves (kastlan
62
+ * reuse audit 2026-11-02, proposal 7). And send `abs` only beside a bound: on its own it
63
+ * narrows nothing.
49
64
  */
50
- function numberBound(raw: string): number | null {
65
+ export function numberBound(raw: string): number | null {
51
66
  if (raw.trim() === "") return null;
52
67
  const n = Number(raw);
53
68
  return Number.isFinite(n) ? n : null;
@@ -108,7 +123,9 @@ export function rowMatches<T>(col: DataTableColumn<T>, row: T, state: FilterValu
108
123
  const min = numberBound(state.min);
109
124
  const max = numberBound(state.max);
110
125
  if (raw == null || Number.isNaN(raw)) return min === null && max === null;
111
- const x = state.abs ? Math.abs(raw) : raw;
126
+ // A column without the box (`abs: false`) compares the figure as it is, whatever
127
+ // the state says: there is nothing on screen to untick.
128
+ const x = state.abs && filter.abs !== false ? Math.abs(raw) : raw;
112
129
  if (min !== null && x < min) return false;
113
130
  if (max !== null && x > max) return false;
114
131
  return true;
@@ -204,9 +221,12 @@ export function decodeFilterValueOfType(type: FilterValue["type"], raw: string):
204
221
  }
205
222
  }
206
223
 
207
- /** Inverse of {@link encodeFilterValue}, typed by the column's filter config. */
224
+ /** Inverse of {@link encodeFilterValue}, typed by the column's filter config. A number
225
+ * filter without the "Absolute value" box (`abs: false`) drops a link's `!`: kept, a
226
+ * server-side table would send `abs` that nobody can see or untick. */
208
227
  export function decodeFilterValue<T>(filter: ColumnFilter<T>, raw: string): FilterValue {
209
- return decodeFilterValueOfType(filter.type, raw);
228
+ const value = decodeFilterValueOfType(filter.type, raw);
229
+ return value.type === "number" && filter.type === "number" && filter.abs === false ? { ...value, abs: false } : value;
210
230
  }
211
231
 
212
232
  // ---------- Filter builders ----------
@@ -244,9 +264,17 @@ export function dateFilter<T>(getValue: (row: T) => string | null | undefined):
244
264
  return { type: "date", getValue };
245
265
  }
246
266
 
247
- /** A min/max (and absolute-value) filter over a number. */
248
- export function numberFilter<T>(getValue: (row: T) => number | null | undefined): ColumnFilter<T> {
249
- return { type: "number", getValue };
267
+ /**
268
+ * A min/max (and absolute-value) filter over a number. `{ abs: false }` leaves out the
269
+ * "Absolute value" box, for a figure that is never negative — a year, a count, a size —
270
+ * where it only offers a choice that changes nothing (kastlan reuse audit 2026-11-02,
271
+ * proposal 7: its server tables drew it under construction years and file sizes).
272
+ */
273
+ export function numberFilter<T>(
274
+ getValue: (row: T) => number | null | undefined,
275
+ options?: { abs?: boolean },
276
+ ): ColumnFilter<T> {
277
+ return options?.abs === false ? { type: "number", getValue, abs: false } : { type: "number", getValue };
250
278
  }
251
279
 
252
280
  /**
@@ -26,7 +26,9 @@ export { DEFAULT_DIALOG_FRAME_LABELS };
26
26
  * `onKeyDown`, a `data-tour` anchor, still reaches the `Modal` — and since 0.35 `ref`
27
27
  * (round 0.35 R4): the panel, the `role="dialog"` element the frame's header, body and
28
28
  * actions sit in. `busy` too (DUP-3): it holds the frame's X and the `close` that
29
- * `actions` and `headerActions` receive, since both are the `Modal`'s own close.
29
+ * `actions` and `headerActions` receive, since both are the `Modal`'s own close. And
30
+ * `held` (keksdose audit follow-up, 2026-11-01), which holds the same and shows the X
31
+ * disabled — see {@link ModalProps.held}.
30
32
  */
31
33
  export interface DialogFrameProps extends Omit<ModalProps, "labelledBy" | "children" | "title"> {
32
34
  /** The heading, and therefore the dialog's accessible name (`aria-labelledby`). */
@@ -187,7 +189,7 @@ export function DialogFrame({
187
189
  <FrameHeaderActions actions={headerActions} onClose={modal.onClose} />
188
190
  )}
189
191
  </div>
190
- {closeButton && <FrameClose label={closeLabel} onClose={modal.onClose} />}
192
+ {closeButton && <FrameClose label={closeLabel} onClose={modal.onClose} held={modal.held} />}
191
193
  </div>
192
194
  {hasBody && (
193
195
  <div
@@ -210,8 +212,10 @@ export function DialogFrame({
210
212
  }
211
213
 
212
214
  /** The X. A component of its own so it can read the panel's animated close, which
213
- * only exists INSIDE the `Modal` (the frame's own body runs outside it). */
214
- function FrameClose({ label, onClose }: { label?: string; onClose: () => void }) {
215
+ * only exists INSIDE the `Modal` (the frame's own body runs outside it). Disabled
216
+ * while `held`; under `busy` it stays enabled and the `Modal` drops its press (see
217
+ * {@link ModalProps.held} for why the two differ). */
218
+ function FrameClose({ label, onClose, held = false }: { label?: string; onClose: () => void; held?: boolean }) {
215
219
  const close = useContext(ModalCloseContext) ?? onClose;
216
220
  const labels = useKitLabels(
217
221
  "dialogFrame",
@@ -222,9 +226,13 @@ function FrameClose({ label, onClose }: { label?: string; onClose: () => void })
222
226
  <button
223
227
  type="button"
224
228
  onClick={close}
229
+ disabled={held}
225
230
  aria-label={labels.close}
226
231
  className={cn(
227
232
  "-me-1.5 -mt-1 shrink-0 rounded p-1.5 text-muted outline-none hover:bg-hover hover:text-secondary",
233
+ // The kit's disabled look (Button, IconButton), and the resting look pinned under
234
+ // `disabled:hover:` so a refused X does not light up under the pointer.
235
+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted",
228
236
  FOCUS_RING,
229
237
  )}
230
238
  >
@@ -8,6 +8,7 @@ import { FOCUS_RING, FOCUS_RING_WIDTH } from "./focus-ring";
8
8
  import { horizontalStep } from "../lib/direction";
9
9
  import { useAnnounce } from "../hooks/use-announce";
10
10
  import { addMonthsClamped, localeWeekStart, monthKey, parseIsoDate, sameYmd, toLocalIso } from "../lib/dates";
11
+ import { dateFormatter } from "../lib/intl-cache";
11
12
  import { useKitLabels, useKitLocale, useKitWeekStart, type LabelOverride } from "../i18n/kit-labels";
12
13
  import { DEFAULT_MINI_CALENDAR_LABELS } from "./mini-calendar-labels";
13
14
 
@@ -355,8 +356,10 @@ export function MiniCalendar({
355
356
  // The first letter only, not CSS `capitalize`, which upper-cased every word: Spanish
356
357
  // read "Septiembre De 2026" and Hungarian "2026. Szeptember" (refactor audit
357
358
  // 2026-10-30, DUP-11) — while the same panel with `monthJump` read them right.
358
- const monthLabel = upperFirst(firstOfMonth.toLocaleDateString(locale, { month: "long", year: "numeric" }), locale);
359
- const formatDay = (d: Date) => d.toLocaleDateString(locale, DAY_NAME_FORMAT);
359
+ // Both through the kit's formatter cache: every day cell names its date, which was a
360
+ // formatter per cell and render (keksdose audit follow-up, 2026-11-01).
361
+ const monthLabel = upperFirst(dateFormatter(locale, { month: "long", year: "numeric" }).format(firstOfMonth), locale);
362
+ const formatDay = (d: Date) => dateFormatter(locale, DAY_NAME_FORMAT).format(d);
360
363
 
361
364
  // Weekday labels via locale, from the week's first day: the narrow one for the
362
365
  // column head, the long one as its accessible name — "M" is the same letter for