@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
@@ -257,8 +257,35 @@ export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"
257
257
  * the dialog asks for itself through `ModalCloseContext` while busy is refused too; one
258
258
  * made from an effect that depends on that close (ReauthDialog's on a resolved submit)
259
259
  * runs again once `busy` turns false, because the close changes with it.
260
+ *
261
+ * A dialog that must not be dismissed while NOTHING runs — a code shown once — wants
262
+ * {@link ModalProps.held} instead: the same hold without the request.
260
263
  */
261
264
  busy?: boolean;
265
+ /**
266
+ * Hold the dialog open: every dismissal `busy` refuses is refused — the backdrop,
267
+ * Escape, Back, the frame's X and an `actions(close)` button — but no request is
268
+ * claimed: the panel is not `aria-busy`. For a dialog the user must not lose by
269
+ * accident while nothing is in flight (keksdose audit follow-up, 2026-11-01): keksdose's
270
+ * one-time recovery code (auth/recovery-code-pane.tsx) refused in its own `onClose`,
271
+ * which comes after the exit has started and cannot hold Back — the first Back spent
272
+ * the dialog's history entry, and the second left the page, and the code with it.
273
+ * Held, a refused Back puts the entry back, so every Back is held, not only the first.
274
+ * The refusal is silent, as under `busy` — `onClose` is not called — so what the
275
+ * dialog is waiting for is said up front, not after a refused press.
276
+ *
277
+ * The way out is a close the CALLER decides on, which is never held: `open={false}`
278
+ * (the exit still plays) or unmounting, from the dialog's own "I have saved it" button.
279
+ * Not the `close` that `actions` and `headerActions` receive — that is the dialog's
280
+ * own dismissal, and held like the rest.
281
+ *
282
+ * `DialogFrame`'s X is DISABLED while held, where under `busy` it stays enabled and its
283
+ * press is dropped: a request is over in a moment, a hold lasts until the user acts,
284
+ * and an X that does nothing for that long reads as broken. Disabled rather than
285
+ * hidden, so the header does not reflow the moment the hold lifts; a caller that wants
286
+ * it gone passes `closeButton={!held}`. Independent of `busy`: either one holds.
287
+ */
288
+ held?: boolean;
262
289
  /**
263
290
  * The PANEL — the `role="dialog"` element the rest of the props go on, not the
264
291
  * backdrop around it (0.35, round 0.35 R4, the follow-up to API-2; as on
@@ -325,6 +352,7 @@ function ModalPanel({
325
352
  role = "dialog",
326
353
  urlParam: _urlParam,
327
354
  busy = false,
355
+ held = false,
328
356
  "aria-busy": ariaBusy,
329
357
  ownHistoryEntry = true,
330
358
  ref,
@@ -371,13 +399,14 @@ function ModalPanel({
371
399
  setDismissed(true);
372
400
  onClose();
373
401
  });
374
- // `busy` (DUP-3) refuses HERE, before `lower` starts the exit: a refusal in the
375
- // caller's `onClose` comes 220ms too late, after the panel has already gone down.
376
- // A new function when `busy` flips, so a close asked for from an effect while busy
402
+ // `busy` (DUP-3) and `held` refuse HERE, before `lower` starts the exit: a refusal in
403
+ // the caller's `onClose` comes 220ms too late, after the panel has already gone down.
404
+ // A new function when the hold flips, so a close asked for from an effect while busy
377
405
  // (a resolved action whose caller's `isPending` trails its promise) runs again.
406
+ const holding = busy || held;
378
407
  const requestClose = useCallback(() => {
379
- if (!busy) lower();
380
- }, [busy, lower]);
408
+ if (!holding) lower();
409
+ }, [holding, lower]);
381
410
  // A panel on its way out after `open` turned false is already closed: a press on
382
411
  // its fading backdrop must not report a second close.
383
412
  const backdropClose = useBackdropClose(active ? requestClose : () => {});
@@ -385,8 +414,8 @@ function ModalPanel({
385
414
  // this the only way out of a dialog is finding its close button — and Back, the
386
415
  // gesture everyone reaches for, navigated the page underneath instead (#172).
387
416
  // Held only while open: a closed `open={false}` dialog must not own a history entry.
388
- // `held`: a Back while busy is refused, and the entry it took is put back.
389
- useOverlayHistory(active && ownHistoryEntry, requestClose, { held: busy });
417
+ // `held`: a Back while busy or held is refused, and the entry it took is put back.
418
+ useOverlayHistory(active && ownHistoryEntry, requestClose, { held: holding });
390
419
 
391
420
  // Through the shared hook and not by hand: this component's own save/restore copy
392
421
  // was one half of the pair that left the page permanently unscrollable when a dialog
@@ -222,9 +222,10 @@ const asTyped = (text: string) => text;
222
222
 
223
223
  // ── The money guard (kastlan 5, Kurvenschmiede) ─────────────────────────────────
224
224
 
225
- // The codes the kit's own currency pickers offer, as whole words: "Amount (CHF)",
226
- // "EUR", "Kosten CHF". Not every ISO 4217 code — "ALL", "TOP" and "PEN" are codes too,
227
- // and an upper-case label would trip on them.
225
+ // The codes the kit's own currency pickers know, as whole words: "Amount (CHF)",
226
+ // "EUR", "Kosten CHF". A `retired` one too (BGN): a label that names it is still money,
227
+ // whatever the pickers offer. Not every ISO 4217 code — "ALL", "TOP" and "PEN" are codes
228
+ // too, and an upper-case label would trip on them.
228
229
  const CURRENCY_CODE = new RegExp(`(?:^|[^A-Za-z])(?:${CURRENCIES.map((c) => c.code).join("|")})(?![A-Za-z])`);
229
230
  // Symbols no unit of measure is written with. Not "kr", "R" or "Fr": those are words
230
231
  // and units as often as they are money.
@@ -229,8 +229,27 @@ export interface StatTileProps extends Omit<ComponentPropsWithRef<"div">, "child
229
229
  /** Anything below the rest — a progress bar, a link row. */
230
230
  footer?: ReactNode;
231
231
  /** Tag every figure `data-private`, so the host's demo-mode rule blurs it — the
232
- * same attribute `Tooltip redact` and the chart tooltip's value cell carry. */
232
+ * same attribute `Tooltip redact` and the chart tooltip's value cell carry.
233
+ * The figures are the tile's own: value, delta, projection, sub-values and the
234
+ * trend. The `description` and `footer` are the app's nodes and stay readable;
235
+ * see {@link StatTileProps.descriptionSensitive} for the ones that carry an amount. */
233
236
  sensitive?: boolean;
237
+ /**
238
+ * Tag the `description` line `data-private` too, as `sensitive` tags the figures.
239
+ * For a description that names an amount: keksdose's tax reserve tile states its
240
+ * basis there ("…applied to €42,000"), and its report KpiCard puts a `hintSensitive`
241
+ * hint there — both tag their own `<span data-private>` today
242
+ * (reports/tabs/tax-tab.tsx, reports/charts/kpi-card.tsx). Separate from
243
+ * `sensitive`, so a description of words ("Last 12 months") on a money tile stays
244
+ * readable in demo mode. keksdose audit follow-up, 2026-11-01.
245
+ */
246
+ descriptionSensitive?: boolean;
247
+ /**
248
+ * Tag the `footer` `data-private`, as `sensitive` tags the figures — for a footer
249
+ * that carries an amount: keksdose's tax reserve tile's shortfall line ("Set aside
250
+ * €1,200 more…"), which it tags by hand. keksdose audit follow-up, 2026-11-01.
251
+ */
252
+ footerSensitive?: boolean;
234
253
  /** Skeleton in place of the value; delta, sub-values and trend are withheld. */
235
254
  loading?: boolean;
236
255
  /** `"sm"` for dense grids (a metrics wall); `"md"` the default KPI. */
@@ -317,6 +336,8 @@ export function StatTile({
317
336
  icon,
318
337
  footer,
319
338
  sensitive = false,
339
+ descriptionSensitive = false,
340
+ footerSensitive = false,
320
341
  loading = false,
321
342
  size = "md",
322
343
  variant = "card",
@@ -551,7 +572,11 @@ export function StatTile({
551
572
  )}
552
573
 
553
574
  {deltaNode}
554
- {description != null && <div className="mt-1 text-xs text-muted">{description}</div>}
575
+ {description != null && (
576
+ <div data-private={descriptionSensitive ? "" : undefined} className="mt-1 text-xs text-muted">
577
+ {description}
578
+ </div>
579
+ )}
555
580
  {!large && graphic && <div className="mt-2">{graphic}</div>}
556
581
 
557
582
  {!loading && ((large && projections.length > 0) || (subValues && subValues.length > 0)) && (
@@ -572,7 +597,11 @@ export function StatTile({
572
597
  </div>
573
598
  )}
574
599
 
575
- {footer != null && <div className="mt-2">{footer}</div>}
600
+ {footer != null && (
601
+ <div data-private={footerSensitive ? "" : undefined} className="mt-2">
602
+ {footer}
603
+ </div>
604
+ )}
576
605
  </Card>
577
606
  );
578
607
  }
package/src/currencies.ts CHANGED
@@ -12,6 +12,11 @@
12
12
  // CurrencySelect, AmountInput's currency chip and NumberInput's code detection read this
13
13
  // same array (components/currency-option-list re-exports it, and currency-select as it
14
14
  // always has), so the list the pickers show and the flags an app ships cannot drift.
15
+ //
16
+ // A currency that was REPLACED stays in the array, marked `retired`: the pickers stop
17
+ // offering it, and whatever reads a value an app already holds — the trigger's flag and
18
+ // code, `getCurrency`, the flag subset, NumberInput's code detection — keeps knowing it
19
+ // (keksdose audit follow-up, 2026-11-01: the lev, after Bulgaria's euro).
15
20
 
16
21
  export interface CurrencyOption {
17
22
  code: string;
@@ -19,6 +24,19 @@ export interface CurrencyOption {
19
24
  symbol: string;
20
25
  /** ISO 3166-1 alpha-2 country code (lowercase) used by flag-icons. */
21
26
  country: string;
27
+ /**
28
+ * The day ("YYYY-MM-DD") the currency was replaced as its country's money — set only on
29
+ * one that was. The pickers leave it out of their default list. A field that already
30
+ * holds it still shows it (the trigger's flag and code, and its row in the list,
31
+ * checked), and an explicit `options` list that names it still offers it: that list is
32
+ * the caller's.
33
+ *
34
+ * Marked rather than deleted, because an app may hold amounts in it from before. Gone
35
+ * from the array, `getCurrency` would answer `undefined` for a stored value, the field
36
+ * would show its placeholder over a real amount, and an app's flag subset
37
+ * (`CURRENCY_COUNTRIES`) would ship that value's flag as an empty box.
38
+ */
39
+ retired?: string;
22
40
  }
23
41
 
24
42
  export const CURRENCIES: CurrencyOption[] = [
@@ -37,7 +55,8 @@ export const CURRENCIES: CurrencyOption[] = [
37
55
  { code: "CZK", name: "Czech Koruna", symbol: "Kč", country: "cz" },
38
56
  { code: "HUF", name: "Hungarian Forint", symbol: "Ft", country: "hu" },
39
57
  { code: "RON", name: "Romanian Leu", symbol: "lei", country: "ro" },
40
- { code: "BGN", name: "Bulgarian Lev", symbol: "лв", country: "bg" },
58
+ // Bulgaria adopted the euro on 2026-01-01 (keksdose audit follow-up, 2026-11-01).
59
+ { code: "BGN", name: "Bulgarian Lev", symbol: "лв", country: "bg", retired: "2026-01-01" },
41
60
  { code: "TRY", name: "Turkish Lira", symbol: "₺", country: "tr" },
42
61
  { code: "RUB", name: "Russian Ruble", symbol: "₽", country: "ru" },
43
62
  { code: "CNY", name: "Chinese Yuan", symbol: "¥", country: "cn" },
@@ -56,8 +75,9 @@ export const CURRENCIES: CurrencyOption[] = [
56
75
  * code flag-icons names it by (`fi-ch`, `flags/4x3/ch.svg`). `CHF` → `"ch"`, `EUR` →
57
76
  * `"eu"` (flag-icons has the EU's flag under that code).
58
77
  *
59
- * Derived from {@link CURRENCIES}, so a currency the pickers gain is a flag here too. An
60
- * app that ships only the flags it renders builds its subset from these values:
78
+ * Derived from {@link CURRENCIES}, so a currency the pickers gain is a flag here too — and
79
+ * a `retired` one keeps its flag, for the values an app still holds in it. An app that
80
+ * ships only the flags it renders builds its subset from these values:
61
81
  *
62
82
  * ```ts
63
83
  * // vite.config.ts — plain Node, no DOM
@@ -7,4 +7,5 @@ import type { FeedbackContextLabels } from "./feedback-context";
7
7
  export const DEFAULT_FEEDBACK_CONTEXT_LABELS: FeedbackContextLabels = {
8
8
  user: "User",
9
9
  attachUrl: "Attach current page URL",
10
+ diagnostics: "Diagnostics sent with the report",
10
11
  };
@@ -1,8 +1,10 @@
1
+ import { useId } from "react";
1
2
  import { Checkbox } from "../components/checkbox";
2
3
  import { DEFAULT_COMMON_LABELS, useKitLabels, type LabelOverride } from "../i18n/kit-labels";
3
4
  import { cn } from "../lib/cn";
4
5
  import type { FeedbackContext, FeedbackEnvironment } from "./feedback-record";
5
6
  import { DEFAULT_FEEDBACK_CONTEXT_LABELS } from "./feedback-context-labels";
7
+ import { diagnosticsEntries, FeedbackDiagnosticsList } from "./feedback-diagnostics";
6
8
 
7
9
  /**
8
10
  * The reporter context of a feedback report: the box the submit dialog shows, and the
@@ -70,6 +72,10 @@ export interface FeedbackContextInput {
70
72
  viewport?: string;
71
73
  /** Default `navigator.userAgent`. */
72
74
  ua?: string;
75
+ /** What the app attached about the thing reported — `useFeedbackSubmit().open(category,
76
+ * { diagnostics })` (keksdose live #481). Sent whatever `attachUrl` says, as the box
77
+ * shows it; left out of the object when it has no key to show. */
78
+ diagnostics?: Readonly<Record<string, unknown>> | null;
73
79
  }
74
80
 
75
81
  /** The pathname of a full or path-only URL; `""` for something that is neither. */
@@ -85,12 +91,13 @@ function pathOf(url: string, base: string | undefined): string {
85
91
  /**
86
92
  * The `context` object of `POST /feedback` (§3.2), keys in keksdose's order: `url`, `route`,
87
93
  * `origin`, `environment`, `user_id`, `user_email`, `user_display_name`, `viewport`, `ua`,
88
- * `version`.
94
+ * `version`, then the app's `diagnostics`.
89
95
  *
90
96
  * With `attachUrl: false`, `url` and `route` are `""` — present and empty, as keksdose
91
97
  * sends them, so a reader can tell "withheld" from "an old row without the key".
92
98
  * `environment` and `version` are omitted when the app passes none (kastlan had no
93
- * version), rather than sent as `undefined`.
99
+ * version), rather than sent as `undefined`; `diagnostics` when the box would show no
100
+ * line of it (none, `{}`, not a record).
94
101
  *
95
102
  * Reads `window` / `navigator` only for the defaults it was not given, so it is a pure
96
103
  * function in a test that passes everything.
@@ -111,6 +118,8 @@ export function feedbackContext(input: FeedbackContextInput): FeedbackContext {
111
118
  context.viewport = input.viewport ?? (win ? `${win.innerWidth}x${win.innerHeight}` : "");
112
119
  context.ua = input.ua ?? (typeof navigator === "undefined" ? "" : navigator.userAgent);
113
120
  if (input.version !== undefined) context.version = input.version;
121
+ // The box's own test: what it draws no line of is not sent either.
122
+ if (input.diagnostics && diagnosticsEntries(input.diagnostics).length) context.diagnostics = input.diagnostics;
114
123
  return context;
115
124
  }
116
125
 
@@ -127,6 +136,9 @@ export interface FeedbackContextLabels {
127
136
  user: string;
128
137
  /** The checkbox. The URL it is about is the line beneath it. */
129
138
  attachUrl: string;
139
+ /** The heading over what the app attached — `open(category, { diagnostics })`, shown
140
+ * read-only under the URL (keksdose live #481). de-CH: "Mitgesendete Diagnose" */
141
+ diagnostics: string;
130
142
  }
131
143
 
132
144
  // Defined in ./feedback-context-labels, which holds no component code (0.34, KS U8).
@@ -140,6 +152,10 @@ export interface FeedbackContextBoxProps {
140
152
  /** The checkbox, on by default in the dialog (§4.2) — the same `attachUrl`. */
141
153
  attachUrl: boolean;
142
154
  onAttachUrlChange: (next: boolean) => void;
155
+ /** What the app attached — the same `diagnostics` handed to {@link feedbackContext}.
156
+ * Shown read-only, each key and its value as the row detail draws them; no block when
157
+ * there is no line to show. */
158
+ diagnostics?: Readonly<Record<string, unknown>> | null;
143
159
  /** Prop > `<UiKitProvider labels={{ feedbackContext }}>` > English. */
144
160
  labels?: LabelOverride<FeedbackContextLabels>;
145
161
  className?: string;
@@ -157,6 +173,10 @@ export interface FeedbackContextBoxProps {
157
173
  * against whatever screen happened to be open, the queue reads as if it were about that
158
174
  * screen.
159
175
  *
176
+ * Below the URL, what the app attached (`diagnostics`, keksdose live #481: a stuck receipt
177
+ * scan's bookkeeping), read-only and in the row detail's own lines: the reporter sees what
178
+ * goes with the report, and that the URL checkbox does not withhold it.
179
+ *
160
180
  * Nothing in here is the app's: hand it the values handed to {@link feedbackContext}, and
161
181
  * what the box shows is what is sent.
162
182
  */
@@ -165,11 +185,14 @@ export function FeedbackContextBox({
165
185
  url,
166
186
  attachUrl,
167
187
  onAttachUrlChange,
188
+ diagnostics,
168
189
  labels: labelsProp,
169
190
  className,
170
191
  }: FeedbackContextBoxProps) {
171
192
  const labels = useKitLabels("feedbackContext", DEFAULT_FEEDBACK_CONTEXT_LABELS, labelsProp);
172
193
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
194
+ const diagnosticsId = useId();
195
+ const attached = diagnosticsEntries(diagnostics);
173
196
  const submitter = submitterLine(user);
174
197
  // "User: Ada …" through `common.fieldValue`, so French gets its space before the colon
175
198
  // and Chinese its full-width one; the label and its punctuation are drawn bold, as
@@ -208,6 +231,15 @@ export function FeedbackContextBox({
208
231
  ) : undefined
209
232
  }
210
233
  />
234
+ {attached.length > 0 && (
235
+ // A named group, so a screen reader hears what the pairs are before it reads them.
236
+ <div role="group" aria-labelledby={diagnosticsId} className="space-y-0.5 pt-1">
237
+ <div id={diagnosticsId} className="font-medium text-secondary">
238
+ {labels.diagnostics}
239
+ </div>
240
+ <FeedbackDiagnosticsList entries={attached} />
241
+ </div>
242
+ )}
211
243
  </div>
212
244
  );
213
245
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `context.diagnostics` (§3.2), drawn one way wherever it shows: in the submit dialog's
3
+ * context box, to the reporter before it goes (§4.2), and in the row detail, to the admin
4
+ * and the author afterwards (§4.4.2). One module for both, so the reporter is shown the
5
+ * very lines the admin will read (keksdose live #481).
6
+ *
7
+ * Internal: neither `@eifi1/ui-kit/feedback` nor the barrel names it.
8
+ */
9
+
10
+ /** `diagnostics` as [key, text] pairs, in the app's order: a string, number or boolean as
11
+ * written, an object or a list as JSON, nothing as "—". Empty for no diagnostics, and for
12
+ * a value that is not a plain object — the server stores whatever the client sent. */
13
+ export function diagnosticsEntries(diagnostics: unknown): Array<[string, string]> {
14
+ if (!diagnostics || typeof diagnostics !== "object" || Array.isArray(diagnostics)) return [];
15
+ return Object.entries(diagnostics).map(([key, v]) => [
16
+ key,
17
+ v === null || v === undefined || v === ""
18
+ ? "—"
19
+ : typeof v === "object"
20
+ ? JSON.stringify(v)
21
+ : String(v as string | number | boolean | bigint),
22
+ ]);
23
+ }
24
+
25
+ /** The pairs as a definition list: each key beside its value, in monospace. Read-only in
26
+ * both places; the heading over it is the caller's. */
27
+ export function FeedbackDiagnosticsList({ entries }: { entries: ReadonlyArray<readonly [string, string]> }) {
28
+ return (
29
+ <dl
30
+ className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-mono text-xs"
31
+ data-feedback-diagnostics=""
32
+ >
33
+ {entries.map(([key, text]) => (
34
+ <div key={key} className="contents">
35
+ <dt className="text-muted">{key}</dt>
36
+ <dd className="min-w-0 break-words text-secondary">{text}</dd>
37
+ </div>
38
+ ))}
39
+ </dl>
40
+ );
41
+ }
@@ -146,6 +146,16 @@ interface FeedbackDialogBaseProps<C extends string = string> {
146
146
  * English. Optional since 0.12.0; a whole `FeedbackDialogLabels` still fits. */
147
147
  labels?: LabelOverride<FeedbackDialogLabels>;
148
148
  submitting?: boolean;
149
+ /**
150
+ * The subject and the body each opening starts with, in place of empty fields — the
151
+ * reporter edits them like anything typed. `useFeedbackSubmit().open(category, { title,
152
+ * body })` passes them (keksdose live #481: "Report this problem" opens on words the
153
+ * screen already knows). Read when the dialog opens; a change while it is open leaves
154
+ * the draft alone.
155
+ */
156
+ defaultTitle?: string;
157
+ /** The body's, as `defaultTitle` is the subject's. */
158
+ defaultBody?: string;
149
159
  contextSlot?: ReactNode;
150
160
  attachmentAccept?: string[];
151
161
  maxAttachmentBytes?: number;
@@ -245,6 +255,8 @@ export function FeedbackDialog<C extends string = string>(props: FeedbackDialogP
245
255
  onCategoryChange,
246
256
  labels: labelsProp,
247
257
  submitting = false,
258
+ defaultTitle = "",
259
+ defaultBody = "",
248
260
  contextSlot,
249
261
  attachmentAccept = DEFAULT_ATTACHMENT_ACCEPT,
250
262
  maxAttachmentBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
@@ -254,8 +266,8 @@ export function FeedbackDialog<C extends string = string>(props: FeedbackDialogP
254
266
  attachmentButtonSize,
255
267
  publicNotice,
256
268
  } = props;
257
- const [title, setTitle] = useState("");
258
- const [body, setBody] = useState("");
269
+ const [title, setTitle] = useState(defaultTitle);
270
+ const [body, setBody] = useState(defaultBody);
259
271
  const [attachment, setAttachment] = useState<File | null>(null);
260
272
  // `attachments="multiple"` only: the capture, apart from the picked/pasted files.
261
273
  const [screenshot, setScreenshot] = useState<File | null>(null);
@@ -283,8 +295,8 @@ export function FeedbackDialog<C extends string = string>(props: FeedbackDialogP
283
295
  if (open !== wasOpen) {
284
296
  setWasOpen(open);
285
297
  if (open) {
286
- setTitle("");
287
- setBody("");
298
+ setTitle(defaultTitle);
299
+ setBody(defaultBody);
288
300
  setAttachment(null);
289
301
  setScreenshot(null);
290
302
  setFiles([]);
@@ -59,6 +59,11 @@ export interface FeedbackContext {
59
59
  ua?: string;
60
60
  /** The app build (`__APP_VERSION__`). */
61
61
  version?: string;
62
+ /** What the app attached about the thing reported (keksdose's stuck receipt scan, live
63
+ * #481): a flat record of short values, in the order the app wrote them. The row
64
+ * detail shows it to the admin and the author, never to anyone else; erasure drops it
65
+ * (it is not on server-kit's `FEEDBACK_CONTEXT_KEEP`). */
66
+ diagnostics?: Readonly<Record<string, unknown>>;
62
67
  // Crash rows (server-written, keksdose `feedback_service.py:346`).
63
68
  fingerprint?: string;
64
69
  /** Which error boundary caught it. */
@@ -14,6 +14,7 @@ export const DEFAULT_FEEDBACK_DETAIL_LABELS: FeedbackDetailLabels = {
14
14
  cancel: "Cancel",
15
15
  url: "URL",
16
16
  copyUrl: "Copy URL",
17
+ diagnostics: "Diagnostics",
17
18
  attachment: "Attachment",
18
19
  download: (name) => `Download ${name}`,
19
20
  downloadFailed: "The attachment could not be downloaded.",
@@ -35,6 +35,7 @@ import {
35
35
  splitDescription,
36
36
  } from "./feedback-record";
37
37
  import type { FeedbackContext, FeedbackRecord } from "./feedback-record";
38
+ import { diagnosticsEntries, FeedbackDiagnosticsList } from "./feedback-diagnostics";
38
39
  import { runMutation } from "./feedback-mutation";
39
40
  import { feedbackPage, feedbackPageLinkTarget } from "./feedback-page-link";
40
41
  import { useFeedbackStatusUndo } from "./feedback-status-undo";
@@ -117,6 +118,9 @@ export interface FeedbackDetailLabels {
117
118
  /** The copy button beside the link — copies the FULL `context.url`.
118
119
  * de-CH: "URL kopieren" */
119
120
  copyUrl: string;
121
+ /** The heading over `context.diagnostics`, between the URL and the attachment (keksdose
122
+ * live #481). de-CH: "Diagnose" */
123
+ diagnostics: string;
120
124
  /** Section 3's heading, and each picture's alt text. de-CH: "Anhang" */
121
125
  attachment: string;
122
126
  /** A PDF's / text file's download button, given the file's name.
@@ -631,6 +635,9 @@ export function FeedbackRowDetail({
631
635
  const { description } = splitDescription(row.body);
632
636
  const page = feedbackPage(row.context);
633
637
  const pageLink = page && feedbackPageLinkTarget(page, pageLinkExclude);
638
+ // What the app attached is for whoever handles the report and whoever sent it, never
639
+ // for another viewer of a shared row (keksdose live #481).
640
+ const diagnostics = canEdit || access.isAuthor ? diagnosticsEntries(row.context?.diagnostics) : [];
634
641
  // 0.47 (§12): with the entries, each entry's file is drawn inline in the Replies
635
642
  // timeline, so the Attachment section holds the report's own files.
636
643
  const files = feedbackAttachmentUrls(row.replies ? { ...row, replies: undefined } : row);
@@ -733,6 +740,12 @@ export function FeedbackRowDetail({
733
740
  </FeedbackDetailSection>
734
741
  )}
735
742
 
743
+ {diagnostics.length > 0 && (
744
+ <FeedbackDetailSection title={labels.diagnostics}>
745
+ <FeedbackDiagnosticsList entries={diagnostics} />
746
+ </FeedbackDetailSection>
747
+ )}
748
+
736
749
  {files.length > 0 && (
737
750
  <FeedbackDetailSection title={labels.attachment}>
738
751
  <div className="flex min-w-0 flex-wrap items-start gap-2">
@@ -12,6 +12,7 @@ import {
12
12
  type FeedbackContext,
13
13
  type FeedbackEnvironment,
14
14
  } from "./feedback-record";
15
+ import type { FeedbackSubmitOpenOptions } from "./feedback-submit";
15
16
  import type { FeedbackCategory } from "./feedback-vocabulary";
16
17
 
17
18
  /**
@@ -49,6 +50,9 @@ export interface FeedbackSubmitDialogProps {
49
50
  capture?: false | (() => Promise<File | null>);
50
51
  /** `UseFeedbackSubmitOptions.publicNotice` (0.38): the line under the body. */
51
52
  publicNotice?: boolean;
53
+ /** `open(category, options)`'s, taken when it opened (keksdose live #481): the box
54
+ * shows the diagnostics and the report carries them; the title and body prefill. */
55
+ opened?: FeedbackSubmitOpenOptions;
52
56
  }
53
57
 
54
58
  /** `FeedbackDialog` as `useFeedbackSubmit` settles it — see the hook for what it settles. */
@@ -66,6 +70,7 @@ export function FeedbackSubmitDialog({
66
70
  version,
67
71
  capture,
68
72
  publicNotice = false,
73
+ opened: { diagnostics, title, body } = {},
69
74
  }: FeedbackSubmitDialogProps) {
70
75
  const toastText = useFeedbackToastLabels();
71
76
  const dialogText = useKitLabels("feedbackDialog", DEFAULT_FEEDBACK_DIALOG_LABELS);
@@ -108,10 +113,18 @@ export function FeedbackSubmitDialog({
108
113
  }
109
114
  onCaptureScreenshot={onCaptureScreenshot}
110
115
  publicNotice={publicNotice ? (dialogText.publicNotice ?? DEFAULT_FEEDBACK_DIALOG_LABELS.publicNotice) : undefined}
116
+ defaultTitle={title}
117
+ defaultBody={body}
111
118
  // The context is read off the very values the box below shows (§3.2).
112
- onSubmit={(data) => onSubmit(data, feedbackContext({ user, attachUrl, url, environment, version }))}
119
+ onSubmit={(data) => onSubmit(data, feedbackContext({ user, attachUrl, url, environment, version, diagnostics }))}
113
120
  contextSlot={
114
- <FeedbackContextBox user={user} url={url} attachUrl={attachUrl} onAttachUrlChange={onAttachUrlChange} />
121
+ <FeedbackContextBox
122
+ user={user}
123
+ url={url}
124
+ attachUrl={attachUrl}
125
+ onAttachUrlChange={onAttachUrlChange}
126
+ diagnostics={diagnostics}
127
+ />
115
128
  }
116
129
  />
117
130
  );
@@ -97,14 +97,42 @@ export interface UseFeedbackSubmitOptions<R = unknown> {
97
97
  publicNotice?: boolean;
98
98
  }
99
99
 
100
+ /**
101
+ * `open`'s second argument (keksdose live #481: "Report this problem" on a receipt scan
102
+ * that never finished, filed with the scan's bookkeeping). All of it optional, and none of
103
+ * it outlives the opening it was handed to.
104
+ */
105
+ export interface FeedbackSubmitOpenOptions {
106
+ /**
107
+ * What the app knows about the thing reported — keksdose's stuck scan: its id, status,
108
+ * attempts, last error. Taken when the dialog opens (a shallow copy, so what the box
109
+ * shows is what goes, even if the app's object changes while the reporter types), shown
110
+ * read-only in the context box, and sent as `context.diagnostics` whatever "Attach
111
+ * current page URL" says: that box withholds the page, not this. Flat, short values,
112
+ * never the reported content itself (§3.2); the row detail shows them to the admin and
113
+ * the author only.
114
+ */
115
+ diagnostics?: Readonly<Record<string, unknown>>;
116
+ /** The subject the form starts with; the reporter can change it. Where the app's reports
117
+ * are public (`publicNotice`), other users read it: what only the admin should see goes
118
+ * in `diagnostics`. */
119
+ title?: string;
120
+ /** The body the form starts with, as `title` is the subject's. */
121
+ body?: string;
122
+ }
123
+
100
124
  export interface FeedbackSubmit {
101
125
  /**
102
126
  * Open the dialog on `category` — a `FeedbackMenu` row's (§4.2: "default category when
103
127
  * opened from a row: that row's"). Without one, or with `CRASH` (never pickable), it
104
128
  * opens on `OTHER`, the server's own default (§3.4). Each open starts a fresh form,
105
129
  * reads `location.href` for the context box and ticks "Attach current page URL" again.
130
+ *
131
+ * `options` (0.54, keksdose live #481) is a screen's own report — `open("BUG", {
132
+ * diagnostics })` from "Report this problem": the diagnostics shown in the box and sent,
133
+ * the subject and body prefilled. An open without them starts empty and sends none.
106
134
  */
107
- open: (category?: FeedbackCategory) => void;
135
+ open: (category?: FeedbackCategory, options?: FeedbackSubmitOpenOptions) => void;
108
136
  /** Close it, as Cancel does. */
109
137
  close: () => void;
110
138
  /**
@@ -140,7 +168,8 @@ export interface FeedbackSubmit {
140
168
  * - The pickable categories — Bug, Idea, Question, Other, in the kit's words. `CRASH` is
141
169
  * filed by the error boundary and is never offered (keksdose feedback #160).
142
170
  * - The context box, built from the same values as the `context` it sends
143
- * ({@link FeedbackContextBox} / `feedbackContext`), the URL read when the dialog OPENS.
171
+ * ({@link FeedbackContextBox} / `feedbackContext`), the URL read when the dialog OPENS,
172
+ * and the diagnostics `open` was handed then (0.54, keksdose live #481).
144
173
  * - Upload every file, then create (keksdose `use-feedback-dialog.tsx:89`): the
145
174
  * screenshot to `screenshot_url`, the rest to `attachment_urls`, all in parallel.
146
175
  * - The toasts, from `feedbackToast`: filed, failed (the server's `detail` through
@@ -161,18 +190,21 @@ export function useFeedbackSubmit<R = unknown>(options: UseFeedbackSubmitOptions
161
190
  const [category, setCategory] = useState<FeedbackCategory>("OTHER");
162
191
  const [url, setUrl] = useState("");
163
192
  const [attachUrl, setAttachUrl] = useState(true);
193
+ const [opened, setOpened] = useState<FeedbackSubmitOpenOptions>({});
164
194
  const [submitting, setSubmitting] = useState(false);
165
195
  // Which opening a send belongs to — see "closed and opened again" above.
166
196
  const opening = useRef(0);
167
197
  const inFlight = useRef(false);
168
198
 
169
- const open = (next?: FeedbackCategory) => {
199
+ const open = (next?: FeedbackCategory, given?: FeedbackSubmitOpenOptions) => {
170
200
  opening.current += 1;
171
201
  setCategory(next && FEEDBACK_PICKABLE_CATEGORIES.includes(next) ? next : "OTHER");
172
202
  setUrl(typeof window === "undefined" ? "" : window.location.href);
173
203
  // Fresh every time (Kurvenschmiede use-feedback-dialog.tsx:80): a reporter who left the
174
204
  // page out of one idea is not saying the bug report after it is unrelated too.
175
205
  setAttachUrl(true);
206
+ // This opening's own, nothing of the last one's; the diagnostics copied now (#481).
207
+ setOpened({ ...given, diagnostics: given?.diagnostics && { ...given.diagnostics } });
176
208
  setIsOpen(true);
177
209
  };
178
210
  const close = () => setIsOpen(false);
@@ -229,6 +261,7 @@ export function useFeedbackSubmit<R = unknown>(options: UseFeedbackSubmitOptions
229
261
  version={version}
230
262
  capture={capture}
231
263
  publicNotice={publicNotice}
264
+ opened={opened}
232
265
  />
233
266
  </Suspense>
234
267
  ) : null;
@@ -119,10 +119,33 @@ export function matchesHotkey(e: KeyboardEvent, combo: ParsedHotkey): boolean {
119
119
  return symbol && !combo.shift ? true : e.shiftKey === combo.shift;
120
120
  }
121
121
 
122
+ /**
123
+ * Whether `el` is editable content — `isContentEditable`, read from the attributes where
124
+ * the engine has no such property (keksdose audit follow-up, 2026-11-01). jsdom is one:
125
+ * there a hotkey fired while typing in a contenteditable, so a test could not show the
126
+ * editor keeping its letters, and an engine without the property would do the same.
127
+ *
128
+ * The nearest element with a `contenteditable` that names a state decides: `""`,
129
+ * `"true"` and `"plaintext-only"` edit, `"false"` does not — a `false` island inside an
130
+ * editor is not typed in. Any other value is the attribute's invalid-value default,
131
+ * which inherits, so the search goes on to the next one up. Keywords are
132
+ * case-insensitive. None at all: editable only in a `designMode` document.
133
+ */
134
+ function isEditableContent(el: HTMLElement): boolean {
135
+ const native = (el as { isContentEditable?: boolean }).isContentEditable;
136
+ if (native !== undefined) return native;
137
+ for (let at = el.closest("[contenteditable]"); at; at = at.parentElement?.closest("[contenteditable]") ?? null) {
138
+ const state = (at.getAttribute("contenteditable") ?? "").toLowerCase();
139
+ if (state === "" || state === "true" || state === "plaintext-only") return true;
140
+ if (state === "false") return false;
141
+ }
142
+ return el.ownerDocument.designMode === "on";
143
+ }
144
+
122
145
  /** Is the focus somewhere that takes typed characters? */
123
146
  function isTypingTarget(target: EventTarget | null): boolean {
124
147
  if (!(target instanceof HTMLElement)) return false;
125
- if (target.isContentEditable) return true;
148
+ if (isEditableContent(target)) return true;
126
149
  if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true;
127
150
  if (target instanceof HTMLInputElement) {
128
151
  return !["button", "checkbox", "radio", "submit", "reset", "range", "color", "file", "image"].includes(target.type);