@eifi1/ui-kit 0.25.1 → 0.27.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 (174) hide show
  1. package/README.md +16 -5
  2. package/dist/chart.d.ts +13 -9
  3. package/dist/components/account-chips.d.ts +1 -1
  4. package/dist/components/amount-input.d.ts +13 -9
  5. package/dist/components/button-group.d.ts +13 -9
  6. package/dist/components/calculator.d.ts +13 -9
  7. package/dist/components/confirm-dialog.d.ts +13 -9
  8. package/dist/components/copy-button.d.ts +10 -6
  9. package/dist/components/danger-confirm.d.ts +13 -9
  10. package/dist/components/data-table-cells.d.ts +1 -1
  11. package/dist/components/data-table-filter-popover.d.ts +1 -1
  12. package/dist/components/data-table-filters.d.ts +1 -1
  13. package/dist/components/data-table-labels.d.ts +1 -1
  14. package/dist/components/data-table-pagination.d.ts +1 -1
  15. package/dist/components/data-table.d.ts +1 -1
  16. package/dist/components/data-table.js +3 -2
  17. package/dist/components/data-table.js.map +1 -1
  18. package/dist/components/facing-pair.d.ts +13 -9
  19. package/dist/components/file-button.d.ts +13 -9
  20. package/dist/components/file-dropzone.d.ts +13 -9
  21. package/dist/components/form-actions.d.ts +13 -9
  22. package/dist/components/iban-input.d.ts +13 -9
  23. package/dist/components/language-select.d.ts +13 -9
  24. package/dist/components/money-field.d.ts +13 -9
  25. package/dist/components/number-field.d.ts +13 -9
  26. package/dist/components/number-input.d.ts +13 -9
  27. package/dist/components/numpad-sheet.d.ts +13 -9
  28. package/dist/components/phone-input.d.ts +13 -9
  29. package/dist/components/series-chart.d.ts +13 -9
  30. package/dist/components/settings-fields.d.ts +13 -9
  31. package/dist/components/share-card.d.ts +13 -9
  32. package/dist/components/swipeable-row.d.ts +5 -1
  33. package/dist/components/swipeable-row.js.map +1 -1
  34. package/dist/components/text-link.d.ts +13 -9
  35. package/dist/components/time-input.d.ts +13 -9
  36. package/dist/components/translation-review-labels.d.ts +4 -0
  37. package/dist/components/translation-review-labels.js +2 -1
  38. package/dist/components/translation-review-labels.js.map +1 -1
  39. package/dist/components/translation-review.d.ts +27 -3
  40. package/dist/components/translation-review.js +50 -21
  41. package/dist/components/translation-review.js.map +1 -1
  42. package/dist/components/ui.d.ts +10 -6
  43. package/dist/components/use-table-state.d.ts +1 -1
  44. package/dist/{data-table-labels-Cdn1QS2k.d.ts → data-table-labels-DUELeXpa.d.ts} +5 -0
  45. package/dist/data-table.d.ts +1 -1
  46. package/dist/feedback/feedback-attachment.d.ts +13 -9
  47. package/dist/feedback/feedback-attachment.js +3 -3
  48. package/dist/feedback/feedback-attachment.js.map +1 -1
  49. package/dist/feedback/feedback-capture.d.ts +71 -0
  50. package/dist/feedback/feedback-capture.js +194 -0
  51. package/dist/feedback/feedback-capture.js.map +1 -0
  52. package/dist/feedback/feedback-context.d.ts +71 -0
  53. package/dist/feedback/feedback-context.js +96 -0
  54. package/dist/feedback/feedback-context.js.map +1 -0
  55. package/dist/feedback/feedback-crash.d.ts +276 -0
  56. package/dist/feedback/feedback-crash.js +225 -0
  57. package/dist/feedback/feedback-crash.js.map +1 -0
  58. package/dist/feedback/feedback-dialog.d.ts +13 -9
  59. package/dist/feedback/feedback-dialog.js +5 -3
  60. package/dist/feedback/feedback-dialog.js.map +1 -1
  61. package/dist/feedback/feedback-inbox.d.ts +13 -9
  62. package/dist/feedback/feedback-inbox.js +89 -49
  63. package/dist/feedback/feedback-inbox.js.map +1 -1
  64. package/dist/feedback/feedback-labels.d.ts +71 -0
  65. package/dist/feedback/feedback-labels.js +48 -0
  66. package/dist/feedback/feedback-labels.js.map +1 -0
  67. package/dist/feedback/feedback-menu.d.ts +71 -0
  68. package/dist/feedback/feedback-menu.js +61 -0
  69. package/dist/feedback/feedback-menu.js.map +1 -0
  70. package/dist/feedback/feedback-record.d.ts +71 -0
  71. package/dist/feedback/feedback-record.js +104 -0
  72. package/dist/feedback/feedback-record.js.map +1 -0
  73. package/dist/feedback/feedback-row-detail.d.ts +71 -0
  74. package/dist/feedback/feedback-row-detail.js +437 -0
  75. package/dist/feedback/feedback-row-detail.js.map +1 -0
  76. package/dist/feedback/feedback-status-undo.d.ts +71 -0
  77. package/dist/feedback/feedback-status-undo.js +40 -0
  78. package/dist/feedback/feedback-status-undo.js.map +1 -0
  79. package/dist/feedback/feedback-submit.d.ts +182 -0
  80. package/dist/feedback/feedback-submit.js +101 -0
  81. package/dist/feedback/feedback-submit.js.map +1 -0
  82. package/dist/feedback/feedback-swipe.d.ts +152 -0
  83. package/dist/feedback/feedback-swipe.js +73 -0
  84. package/dist/feedback/feedback-swipe.js.map +1 -0
  85. package/dist/feedback/feedback-table.d.ts +71 -0
  86. package/dist/feedback/feedback-table.js +357 -0
  87. package/dist/feedback/feedback-table.js.map +1 -0
  88. package/dist/feedback/feedback-thread.d.ts +13 -9
  89. package/dist/feedback.d.ts +17 -9
  90. package/dist/feedback.js +11 -0
  91. package/dist/feedback.js.map +1 -1
  92. package/dist/hooks/use-file-drop.d.ts +13 -9
  93. package/dist/i18n/defaults.d.ts +13 -9
  94. package/dist/i18n/defaults.js +16 -0
  95. package/dist/i18n/defaults.js.map +1 -1
  96. package/dist/i18n/german.d.ts +13 -9
  97. package/dist/i18n/german.js +92 -5
  98. package/dist/i18n/german.js.map +1 -1
  99. package/dist/i18n/kit-labels.d.ts +7 -3
  100. package/dist/i18n/kit-labels.js.map +1 -1
  101. package/dist/i18n/languages.d.ts +13 -9
  102. package/dist/i18n/locales/de-CH.d.ts +13 -9
  103. package/dist/i18n/locales/en.d.ts +13 -9
  104. package/dist/i18n/locales/en.js +11 -1
  105. package/dist/i18n/locales/en.js.map +1 -1
  106. package/dist/i18n/locales/es.d.ts +13 -9
  107. package/dist/i18n/locales/es.js +90 -5
  108. package/dist/i18n/locales/es.js.map +1 -1
  109. package/dist/i18n/locales/fr.d.ts +13 -9
  110. package/dist/i18n/locales/fr.js +90 -5
  111. package/dist/i18n/locales/fr.js.map +1 -1
  112. package/dist/i18n/locales/hu.d.ts +13 -9
  113. package/dist/i18n/locales/hu.js +91 -5
  114. package/dist/i18n/locales/hu.js.map +1 -1
  115. package/dist/i18n/locales/it.d.ts +13 -9
  116. package/dist/i18n/locales/it.js +90 -5
  117. package/dist/i18n/locales/it.js.map +1 -1
  118. package/dist/i18n/locales/zh.d.ts +13 -9
  119. package/dist/i18n/locales/zh.js +90 -5
  120. package/dist/i18n/locales/zh.js.map +1 -1
  121. package/dist/i18n/review.d.ts +16 -11
  122. package/dist/i18n/review.js +12 -0
  123. package/dist/i18n/review.js.map +1 -1
  124. package/dist/i18n/swiss.js +3 -1
  125. package/dist/i18n/swiss.js.map +1 -1
  126. package/dist/index.d.ts +7 -3
  127. package/dist/index.js +18 -1
  128. package/dist/index.js.map +1 -1
  129. package/dist/{feedback-BXKH-ToU.d.ts → kit-labels-DQg82eHB.d.ts} +1275 -25
  130. package/dist/lib/translation-review.d.ts +76 -1
  131. package/dist/lib/translation-review.js +30 -0
  132. package/dist/lib/translation-review.js.map +1 -1
  133. package/dist/rhf/fields.d.ts +13 -9
  134. package/dist/rhf/form.d.ts +13 -9
  135. package/dist/rhf.d.ts +13 -9
  136. package/dist/shell/app-shell.d.ts +13 -9
  137. package/dist/shell/top-bar-brand.d.ts +13 -9
  138. package/dist/shell.d.ts +11 -11
  139. package/dist/wizard/stepper-nav.d.ts +13 -9
  140. package/dist/wizard.d.ts +13 -9
  141. package/package.json +6 -1
  142. package/src/components/data-table.tsx +14 -2
  143. package/src/components/swipeable-row.tsx +5 -1
  144. package/src/components/translation-review-labels.ts +5 -0
  145. package/src/components/translation-review.tsx +102 -35
  146. package/src/feedback/feedback-attachment.tsx +19 -3
  147. package/src/feedback/feedback-capture.ts +285 -0
  148. package/src/feedback/feedback-context.tsx +217 -0
  149. package/src/feedback/feedback-crash.ts +547 -0
  150. package/src/feedback/feedback-dialog.tsx +29 -6
  151. package/src/feedback/feedback-inbox.tsx +131 -27
  152. package/src/feedback/feedback-labels.ts +127 -0
  153. package/src/feedback/feedback-menu.tsx +132 -0
  154. package/src/feedback/feedback-record.ts +345 -0
  155. package/src/feedback/feedback-row-detail.tsx +825 -0
  156. package/src/feedback/feedback-status-undo.ts +111 -0
  157. package/src/feedback/feedback-submit.tsx +224 -0
  158. package/src/feedback/feedback-swipe.tsx +170 -0
  159. package/src/feedback/feedback-table.tsx +757 -0
  160. package/src/feedback.ts +13 -0
  161. package/src/i18n/defaults.ts +16 -0
  162. package/src/i18n/german.ts +99 -5
  163. package/src/i18n/kit-labels.tsx +20 -0
  164. package/src/i18n/locales/en.ts +16 -1
  165. package/src/i18n/locales/es.ts +97 -5
  166. package/src/i18n/locales/fr.ts +96 -5
  167. package/src/i18n/locales/hu.ts +95 -5
  168. package/src/i18n/locales/it.ts +98 -5
  169. package/src/i18n/locales/zh.ts +91 -5
  170. package/src/i18n/review.ts +15 -2
  171. package/src/i18n/swiss.ts +5 -1
  172. package/src/index.ts +19 -0
  173. package/src/lib/translation-review.ts +109 -0
  174. package/tokens.css +8 -0
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from "react";
1
+ import { useId, useRef, useState } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import {
4
4
  Ban,
@@ -19,8 +19,10 @@ import { cn } from "../lib/cn";
19
19
  import { Button, Textarea } from "../components/ui";
20
20
  import type { ButtonSize, ButtonVariant } from "../components/ui";
21
21
  import { Tooltip } from "../components/tooltip";
22
+ import { useCommitReason, useWriteLock } from "../components/write-lock";
22
23
  import { FeedbackAttachmentField, type FeedbackAttachmentErrorInfo } from "./feedback-attachment";
23
24
  import type { FeedbackAttachmentLabels } from "./feedback-dialog";
25
+ import { useFeedbackCategoryLabels, useFeedbackStatusLabels } from "./feedback-labels";
24
26
 
25
27
  /**
26
28
  * The feedback **inbox**, as the parts two apps were each writing separately.
@@ -37,13 +39,20 @@ import type { FeedbackAttachmentLabels } from "./feedback-dialog";
37
39
  * Design's was thinner in every one of those places and read as a different
38
40
  * product for no reason anyone chose.
39
41
  *
40
- * **What is deliberately NOT here.** The two apps store a report differently and
41
- * are meant to: one keeps the reporter's context in a JSON column and the
42
- * screenshot in an object store, the other keeps a `page_path` and the bytes in
43
- * the row. So there is no `Feedback` type in this file and nothing here takes
44
- * one. Every component takes the values it draws, and the detail panel is a
45
- * *shell* the app fills — which is what lets each keep the shape that suits it
46
- * without either of them inventing a second look for a status pill.
42
+ * **The report itself lives next door (0.27.0).** This file used to say there
43
+ * was deliberately no `Feedback` type here: the apps stored a report differently
44
+ * (a context JSON column and an object store in one, a `page_path` and the bytes
45
+ * in the row in another). Marcel's feedback round (2026-10-04,
46
+ * docs/feedback-harmonization.md §5) gave all three apps one contract, so the
47
+ * shape is now shared — `FeedbackRecord` / `FeedbackContext` in
48
+ * feedback-record.ts, with the constants and body helpers. The components HERE
49
+ * still take the values they draw rather than a row, which keeps them usable
50
+ * from any surface; the detail panel stays a *shell* the row detail fills.
51
+ *
52
+ * **The words are the kit's too (0.27.0).** The status and category names are
53
+ * the `feedbackStatus` / `feedbackCategory` namespaces (feedback-labels.ts,
54
+ * keksdose's wording), read by default by the badges and the transitions below;
55
+ * a `label` the caller passes still wins.
47
56
  */
48
57
 
49
58
  /** The seven states a report can be in.
@@ -264,8 +273,9 @@ export function feedbackCategoryRank(category: FeedbackCategory): number {
264
273
  * Falls back to OTHER's treatment for a category this build has never heard of,
265
274
  * so an older client against a newer API degrades to a readable neutral pill
266
275
  * instead of throwing on `meta.icon` — a crash report must not be able to cause
267
- * one. The *label* is the caller's, and for the same reason it needs the same
268
- * guard: pass what OTHER is called if you cannot name the value you were given.
276
+ * one. The *label* gets the same guard: by default the `feedbackCategory`
277
+ * namespace's word (0.27.0), and for a value it has no word for, what OTHER is
278
+ * called.
269
279
  */
270
280
  export function FeedbackCategoryBadge({
271
281
  category,
@@ -274,10 +284,13 @@ export function FeedbackCategoryBadge({
274
284
  className,
275
285
  }: {
276
286
  category: FeedbackCategory;
277
- label: ReactNode;
287
+ /** Over the `feedbackCategory` label (0.27.0: optional — before, every app passed
288
+ * its own translation). */
289
+ label?: ReactNode;
278
290
  compact?: boolean;
279
291
  className?: string;
280
292
  }) {
293
+ const names = useFeedbackCategoryLabels();
281
294
  // Annotated `| undefined` because the Record's index signature promises a hit
282
295
  // for every FeedbackCategory, and at runtime the API can hand us one that is not.
283
296
  const known: (typeof FEEDBACK_CATEGORY_META)["OTHER"] | undefined = FEEDBACK_CATEGORY_META[category];
@@ -294,23 +307,28 @@ export function FeedbackCategoryBadge({
294
307
  )}
295
308
  >
296
309
  <Icon className={compact ? "size-3" : "size-3.5"} aria-hidden />
297
- {label}
310
+ {label ?? (known ? names[category] : names.OTHER)}
298
311
  </span>
299
312
  );
300
313
  }
301
314
 
302
315
  /** Where a report stands, as a badge — for the places that only *report* the
303
- * status rather than offering to change it. */
316
+ * status rather than offering to change it. Named by the `feedbackStatus`
317
+ * namespace unless `label` says otherwise (0.27.0); a status this build has never
318
+ * heard of shows OPEN's look and its own raw value, never OPEN's name. */
304
319
  export function FeedbackStatusBadge({
305
320
  status,
306
321
  label,
307
322
  className,
308
323
  }: {
309
324
  status: FeedbackStatus;
310
- label: ReactNode;
325
+ /** Over the `feedbackStatus` label (0.27.0: optional). */
326
+ label?: ReactNode;
311
327
  className?: string;
312
328
  }) {
329
+ const names = useFeedbackStatusLabels();
313
330
  const meta = FEEDBACK_STATUS_META[status] ?? FEEDBACK_STATUS_META.OPEN;
331
+ const name: string | undefined = names[status];
314
332
  const Icon = meta.icon;
315
333
  return (
316
334
  <span
@@ -322,7 +340,7 @@ export function FeedbackStatusBadge({
322
340
  )}
323
341
  >
324
342
  <Icon className="size-3" aria-hidden />
325
- {label}
343
+ {label ?? name ?? status}
326
344
  </span>
327
345
  );
328
346
  }
@@ -349,6 +367,8 @@ export function FeedbackStatusTransitions({
349
367
  onPick,
350
368
  variant,
351
369
  label,
370
+ commit,
371
+ disabledReason: ownDisabledReason,
352
372
  className,
353
373
  }: {
354
374
  status: FeedbackStatus;
@@ -356,10 +376,30 @@ export function FeedbackStatusTransitions({
356
376
  canEdit: boolean;
357
377
  onPick: (status: FeedbackStatus) => void;
358
378
  variant: "icon" | "pill";
359
- /** What each status is called, translated by the app. */
360
- label: (status: FeedbackStatus) => string;
379
+ /** What each status is called. Default (0.27.0): the `feedbackStatus` namespace —
380
+ * before, every app passed its own translation. */
381
+ label?: (status: FeedbackStatus) => string;
382
+ /**
383
+ * Every button here SAVES the moment it is pressed (0.27.0) — opt in and, under a
384
+ * locked {@link WriteLockProvider}, the row stays as it is and says why: each status
385
+ * the row could move to is `aria-disabled` but focusable, the click does nothing, and
386
+ * the lock's reason is in the kit Tooltip and the button's description — the
387
+ * `disabledReason` path `Button` takes. Off by default: a feedback inbox is not what
388
+ * every app's lock is about (keksdose's shell lock is a read-only demo BUDGET, and a
389
+ * report is not budget data).
390
+ */
391
+ commit?: boolean;
392
+ /** Why the statuses cannot be changed right now, the same way (a lock's own reason
393
+ * wins over it). Only read while `canEdit`: a read-only row is a picture of the
394
+ * workflow, not a refused action. */
395
+ disabledReason?: ReactNode;
361
396
  className?: string;
362
397
  }) {
398
+ const names = useFeedbackStatusLabels();
399
+ const nameOf = label ?? ((value: FeedbackStatus) => names[value]);
400
+ const reason = useCommitReason(commit, ownDisabledReason);
401
+ const locked = canEdit && reason !== undefined && reason !== null && reason !== false && reason !== "";
402
+ const reasonId = useId();
363
403
  return (
364
404
  // Not a control: it only stops clicks on the buttons inside from bubbling to
365
405
  // the row. The buttons are the keyboard path, and Enter/Space on them fires
@@ -370,17 +410,29 @@ export function FeedbackStatusTransitions({
370
410
  // A status control inside a clickable row must not also open the row.
371
411
  onClick={(event) => event.stopPropagation()}
372
412
  >
413
+ {locked && (
414
+ <span id={reasonId} hidden>
415
+ {reason}
416
+ </span>
417
+ )}
373
418
  {statuses.map((value) => {
374
419
  const meta = FEEDBACK_STATUS_META[value];
375
420
  const Icon = meta.icon;
376
421
  const active = status === value;
377
- const name = label(value);
422
+ const name = nameOf(value);
423
+ // Locked: focusable and described, never firing (see `commit`). The current
424
+ // status stays natively disabled — it was never something to press.
425
+ const refused = locked && !active;
378
426
  const button = (
379
427
  <button
380
428
  key={value}
381
429
  type="button"
382
- disabled={!canEdit || active}
383
- onClick={() => onPick(value)}
430
+ disabled={refused ? undefined : !canEdit || active}
431
+ aria-disabled={refused || undefined}
432
+ aria-describedby={refused ? reasonId : undefined}
433
+ onClick={() => {
434
+ if (!refused) onPick(value);
435
+ }}
384
436
  aria-label={name}
385
437
  aria-pressed={active}
386
438
  className={cn(
@@ -404,12 +456,24 @@ export function FeedbackStatusTransitions({
404
456
  // "unavailable" rather than as "this is where the row stands".
405
457
  (!canEdit || active) && "cursor-default",
406
458
  !canEdit && "opacity-60",
459
+ refused && "cursor-not-allowed opacity-50",
407
460
  )}
408
461
  >
409
462
  <Icon className={variant === "icon" ? "size-4" : "size-3.5"} aria-hidden />
410
463
  {variant === "pill" && name}
411
464
  </button>
412
465
  );
466
+ // Locked, the bubble says WHY in both variants; the icon's name is still its
467
+ // `aria-label`, and the reason its description — the hidden copy above, which
468
+ // stays put, rather than the bubble (as on `Button`: the FRAGMENT keeps Tooltip
469
+ // from adding the bubble as a second, coming-and-going description).
470
+ if (refused) {
471
+ return (
472
+ <Tooltip key={value} label={reason} side="top" portal={variant === "icon" || undefined}>
473
+ <>{button}</>
474
+ </Tooltip>
475
+ );
476
+ }
413
477
  // The icon row has no visible label, so it needs the tooltip; the pills
414
478
  // carry theirs inline.
415
479
  //
@@ -474,6 +538,8 @@ export function FeedbackNoteEditor({
474
538
  rows = 3,
475
539
  attachment,
476
540
  resetKey,
541
+ required = false,
542
+ commit,
477
543
  }: {
478
544
  initial: string;
479
545
  pending: boolean;
@@ -496,6 +562,19 @@ export function FeedbackNoteEditor({
496
562
  * that cannot put a `key` on the editor (see the note above). Leave it out and
497
563
  * the draft is never thrown away behind the user's back. */
498
564
  resetKey?: string | number;
565
+ /**
566
+ * A blank note cannot be saved (0.27.0): Save stays disabled — and Ctrl/⌘+Enter does
567
+ * nothing — until the trimmed text is non-empty, and the box is `aria-required`. For
568
+ * the rework note, which the feedback contract requires (§3.4: a file alone cannot
569
+ * be sent; keksdose `feedback-page.tsx:577` dropped the click silently instead, so a
570
+ * press on Send with an empty box looked like a lost send). Default `false`: an
571
+ * outcome or a description may be cleared on purpose.
572
+ */
573
+ required?: boolean;
574
+ /** Save COMMITS (0.27.0): under a locked `WriteLockProvider` it is disabled the
575
+ * `disabledReason` way, with the lock's reason, and Ctrl/⌘+Enter saves nothing. The
576
+ * text box stays editable — nothing in it reaches the server until the save. */
577
+ commit?: boolean;
499
578
  }) {
500
579
  const [draft, setDraft] = useState(initial);
501
580
  const [file, setFile] = useState<File | null>(null);
@@ -514,6 +593,12 @@ export function FeedbackNoteEditor({
514
593
  // never passes through the field's own subtree. It listens here instead
515
594
  // (Steering Design feedback #140).
516
595
  const root = useRef<HTMLDivElement>(null);
596
+ const hintId = useId();
597
+ const lock = useWriteLock();
598
+ const blank = required && !draft.trim();
599
+ // The shortcut obeys what the button shows: no save while one is in flight, while the
600
+ // note a `required` editor needs is missing, or while a `commit` save is locked.
601
+ const canSubmit = !pending && !blank && !(commit && lock.locked);
517
602
  const submit = () => onSave(draft, file);
518
603
  return (
519
604
  // Not a control: a delegated Ctrl/Cmd+Enter shortcut for the textarea inside,
@@ -523,18 +608,34 @@ export function FeedbackNoteEditor({
523
608
  ref={root}
524
609
  className="space-y-2"
525
610
  onKeyDown={(event) => {
526
- if ((event.ctrlKey || event.metaKey) && event.key === "Enter" && !pending) {
611
+ if ((event.ctrlKey || event.metaKey) && event.key === "Enter" && canSubmit) {
527
612
  event.preventDefault();
528
613
  submit();
529
614
  }
530
615
  }}
531
616
  >
532
- {placeholder && <p className="text-xs text-[var(--text-muted)]">{placeholder}</p>}
533
- <Textarea rows={rows} value={draft} onChange={(event) => setDraft(event.target.value)} />
617
+ {/* The line names the box (0.27.0): it is the only text that says what to write
618
+ in it, and an unnamed textarea is read out as just "edit text". */}
619
+ {placeholder && (
620
+ <p id={hintId} className="text-xs text-[var(--text-muted)]">
621
+ {placeholder}
622
+ </p>
623
+ )}
624
+ <Textarea
625
+ rows={rows}
626
+ value={draft}
627
+ onChange={(event) => setDraft(event.target.value)}
628
+ aria-labelledby={placeholder ? hintId : undefined}
629
+ aria-required={required || undefined}
630
+ />
534
631
  {attachment && (
535
632
  <FeedbackAttachmentField
536
633
  value={file}
537
634
  onChange={setFile}
635
+ // A pause while the save is in flight (the field's G5b `disabled`): the note
636
+ // goes out with the file AS IT WAS at the press, so a chip removed or added
637
+ // mid-send would be a lie about what was sent.
638
+ disabled={pending}
538
639
  labels={attachment.labels}
539
640
  accept={attachment.accept}
540
641
  maxBytes={attachment.maxBytes}
@@ -554,7 +655,7 @@ export function FeedbackNoteEditor({
554
655
  <Button variant="ghost" onClick={onCancel}>
555
656
  {cancelLabel}
556
657
  </Button>
557
- <Button variant="brand" disabled={pending} onClick={submit}>
658
+ <Button variant="brand" disabled={pending || blank} commit={commit} onClick={submit}>
558
659
  {saveLabel}
559
660
  </Button>
560
661
  </div>
@@ -567,7 +668,10 @@ export function FeedbackNoteEditor({
567
668
  * held to the app's own limits — and drawn in its own look — rather than the
568
669
  * defaults. */
569
670
  export interface FeedbackNoteAttachment {
570
- labels: FeedbackAttachmentLabels;
671
+ /** Over the field's own `feedbackAttachment` namespace. Optional since 0.27.0 — the
672
+ * field has read the provider's words since 0.7.0, so an app with a catalogue has
673
+ * nothing to restate. */
674
+ labels?: Partial<FeedbackAttachmentLabels>;
571
675
  accept?: string[];
572
676
  maxBytes?: number;
573
677
  /** `info` (0.23.0) names the refused file and the limit. */
@@ -623,8 +727,8 @@ export function FeedbackDetailSection({
623
727
 
624
728
  /** The stack a detail panel is. Here so the spacing between sections is decided
625
729
  * once rather than by whichever app was written second. */
626
- export function FeedbackDetail({ children }: { children: ReactNode }) {
627
- return <div className="space-y-3 text-sm">{children}</div>;
730
+ export function FeedbackDetail({ children, className }: { children: ReactNode; className?: string }) {
731
+ return <div className={cn("space-y-3 text-sm", className)}>{children}</div>;
628
732
  }
629
733
 
630
734
  /** Prose inside a section — a body, an outcome, a note.
@@ -0,0 +1,127 @@
1
+ import { useKitLabels } from "../i18n/kit-labels";
2
+ import type { FeedbackCategory, FeedbackStatus } from "./feedback-inbox";
3
+
4
+ /**
5
+ * The words of a report's status and category, and the toasts a feedback page shows —
6
+ * three `<UiKitProvider labels>` namespaces (0.27.0).
7
+ *
8
+ * Until 0.27 every app kept its own map from the enum to a translation key (keksdose
9
+ * `STATUS_LABEL` and `categoryLabelKey`, "the package has no translations"), and the
10
+ * three German maps had drifted apart: Kurvenschmiede said "In Arbeit", "In Prüfung",
11
+ * "Test auf der Live-Umgebung" where keksdose and kastlan said "In Bearbeitung",
12
+ * "Zur Prüfung", "Live testen". Marcel's feedback round (2026-10-04,
13
+ * docs/feedback-harmonization.md §2.4) made keksdose's wording the canon and the kit its
14
+ * owner, in all seven languages. The English defaults below are keksdose's
15
+ * `frontend/src/shared/i18n/locales/en.json` (`feedback.*`); the de-CH canon for the
16
+ * catalogues is noted on each key (keksdose `de-CH.json` — ss, never ß).
17
+ *
18
+ * `FeedbackStatusBadge`, `FeedbackCategoryBadge` and `FeedbackStatusTransitions` read
19
+ * the first two by default, so an app drops its own maps; a `label` passed to them still
20
+ * wins.
21
+ */
22
+
23
+ /**
24
+ * `feedbackStatus` — one word per status, keyed by the enum value so
25
+ * `labels[row.status]` is the whole lookup.
26
+ *
27
+ * de-CH canon: OPEN "Offen" · IN_PROGRESS "In Bearbeitung" · IN_EVALUATION "Zur Prüfung"
28
+ * · NEEDS_LIVE_TEST "Live testen" · POSTPONED "Zurückgestellt" · DONE "Erledigt" ·
29
+ * WONT_DO "Wird nicht umgesetzt".
30
+ */
31
+ export type FeedbackStatusLabels = Record<FeedbackStatus, string>;
32
+
33
+ export const DEFAULT_FEEDBACK_STATUS_LABELS: FeedbackStatusLabels = {
34
+ OPEN: "Open",
35
+ IN_PROGRESS: "In progress",
36
+ IN_EVALUATION: "In evaluation",
37
+ NEEDS_LIVE_TEST: "Test when live",
38
+ POSTPONED: "Postponed",
39
+ DONE: "Done",
40
+ WONT_DO: "Won't do",
41
+ };
42
+
43
+ /**
44
+ * `feedbackCategory` — one word per category, keyed by the enum value. `CRASH` is never
45
+ * pickable, but it is shown: on the badge, in the filter, in a row's detail.
46
+ *
47
+ * de-CH canon: CRASH "Absturz" · BUG "Fehler" · IDEA "Idee" · QUESTION "Frage" ·
48
+ * OTHER "Sonstiges".
49
+ */
50
+ export type FeedbackCategoryLabels = Record<FeedbackCategory, string>;
51
+
52
+ export const DEFAULT_FEEDBACK_CATEGORY_LABELS: FeedbackCategoryLabels = {
53
+ CRASH: "Crash",
54
+ BUG: "Bug",
55
+ IDEA: "Idea",
56
+ QUESTION: "Question",
57
+ OTHER: "Other",
58
+ };
59
+
60
+ /**
61
+ * `feedbackToast` — what the submit dialog, a failed save and a status change say in a
62
+ * toast (§4.2, §4.4, §4.5 of the contract). A server's `detail` sentence, when it sent
63
+ * one, wins over `submitFailed` and `updateFailed` (§3.7); that choice is the caller's.
64
+ */
65
+ export interface FeedbackToastLabels {
66
+ /** A report was filed. de-CH: "Danke für Ihr Feedback!" */
67
+ submitted: string;
68
+ /** `POST /feedback` failed with no `detail` of its own.
69
+ * de-CH: "Feedback konnte nicht gesendet werden" */
70
+ submitFailed: string;
71
+ /** A picked file is not an image, a PDF or a text file (§3.5).
72
+ * de-CH: "Nur Bilder, PDF- oder Textdateien sind erlaubt" */
73
+ attachmentUnsupported: string;
74
+ /** A picked file is over the 10 MB the contract allows (§3.5).
75
+ * de-CH: "Datei ist grösser als 10 MB" */
76
+ attachmentTooLarge: string;
77
+ /** More files were picked than fit; `count` is how many fit.
78
+ * de-CH: "Es passen nur {{count}} Anhänge – die übrigen wurden weggelassen." */
79
+ attachmentTooMany: (count: number) => string;
80
+ /** The screenshot capture failed (or its optional peer is not installed).
81
+ * de-CH: "Screenshot konnte nicht aufgenommen werden" */
82
+ captureFailed: string;
83
+ /** A `PATCH /feedback/{id}` failed with no `detail` of its own (§4.4).
84
+ * de-CH: "Änderung konnte nicht gespeichert werden." */
85
+ updateFailed: string;
86
+ /** A status change landed — the 8 s Undo toast (§4.5). `status` is the NEW status's
87
+ * label, `title` the report's title. de-CH: "Auf „{{status}}“ gesetzt: {{title}}" */
88
+ statusChanged: (status: string, title: string) => string;
89
+ /** That toast's action. de-CH: "Rückgängig" */
90
+ statusUndo: string;
91
+ /** The Undo landed — `status` is the RESTORED status's label.
92
+ * de-CH: "Zurück auf „{{status}}“: {{title}}" */
93
+ statusRestored: (status: string, title: string) => string;
94
+ }
95
+
96
+ export const DEFAULT_FEEDBACK_TOAST_LABELS: FeedbackToastLabels = {
97
+ submitted: "Thanks for the feedback!",
98
+ submitFailed: "Could not submit feedback",
99
+ attachmentUnsupported: "Only images, PDF or text files are allowed",
100
+ attachmentTooLarge: "File is larger than 10 MB",
101
+ attachmentTooMany: (count) =>
102
+ count === 1
103
+ ? "Only 1 attachment fits — the rest were left out."
104
+ : `Only ${count} attachments fit — the rest were left out.`,
105
+ captureFailed: "Could not capture a screenshot",
106
+ updateFailed: "Could not save that change.",
107
+ statusChanged: (status, title) => `Set to “${status}”: ${title}`,
108
+ statusUndo: "Undo",
109
+ statusRestored: (status, title) => `Back to “${status}”: ${title}`,
110
+ };
111
+
112
+ /** `feedbackStatus` resolved: English, then `<UiKitProvider labels>`, then `labels`. For
113
+ * the places that need a status's name outside the kit's own parts — a filter's options,
114
+ * `feedbackSwipePlan`'s panels, an app's overview tile. */
115
+ export function useFeedbackStatusLabels(labels?: Partial<FeedbackStatusLabels>): FeedbackStatusLabels {
116
+ return useKitLabels("feedbackStatus", DEFAULT_FEEDBACK_STATUS_LABELS, labels);
117
+ }
118
+
119
+ /** `feedbackCategory` resolved: English, then the provider, then `labels`. */
120
+ export function useFeedbackCategoryLabels(labels?: Partial<FeedbackCategoryLabels>): FeedbackCategoryLabels {
121
+ return useKitLabels("feedbackCategory", DEFAULT_FEEDBACK_CATEGORY_LABELS, labels);
122
+ }
123
+
124
+ /** `feedbackToast` resolved: English, then the provider, then `labels`. */
125
+ export function useFeedbackToastLabels(labels?: Partial<FeedbackToastLabels>): FeedbackToastLabels {
126
+ return useKitLabels("feedbackToast", DEFAULT_FEEDBACK_TOAST_LABELS, labels);
127
+ }
@@ -0,0 +1,132 @@
1
+ import { Inbox, MessageSquare } from "lucide-react";
2
+ import type { UserAvatarBadge } from "../components/user-avatar";
3
+ import { useKitLabels } from "../i18n/kit-labels";
4
+ import { TopBarActionMenu, type TopBarMenuEntry } from "../shell/topbar-action-menu";
5
+ import { FEEDBACK_CATEGORY_META, type FeedbackCategory } from "./feedback-inbox";
6
+ import { useFeedbackCategoryLabels } from "./feedback-labels";
7
+ import { FEEDBACK_PICKABLE_CATEGORIES } from "./feedback-record";
8
+
9
+ /**
10
+ * `feedbackMenu` — the top-bar feedback menu's own words (0.27.0). The four category rows
11
+ * are `feedbackCategory`'s, shared with the badges and the dialog's picker. de-CH canon
12
+ * (keksdose `de-CH.json`): `trigger` "Feedback senden", `myFeedback` "Mein Feedback",
13
+ * `viewFeedback` "Feedback ansehen".
14
+ */
15
+ export interface FeedbackMenuLabels {
16
+ /** The trigger's accessible name, and the menu's. keksdose `feedback.send`. */
17
+ trigger: string;
18
+ /** The link to the reporter's own reports — everyone but an admin. keksdose
19
+ * `feedback.my_title`. */
20
+ myFeedback: string;
21
+ /** The link to the inbox — an admin's one list link, in place of "My feedback".
22
+ * keksdose `feedback.view_all`. */
23
+ viewFeedback: string;
24
+ }
25
+
26
+ export const DEFAULT_FEEDBACK_MENU_LABELS: FeedbackMenuLabels = {
27
+ trigger: "Send feedback",
28
+ myFeedback: "My feedback",
29
+ viewFeedback: "View feedback",
30
+ };
31
+
32
+ export interface FeedbackMenuProps {
33
+ /** A category row was chosen — open the submit dialog on it (`useFeedbackSubmit`'s
34
+ * `open`). Never called with `CRASH`: there is no row for it. */
35
+ onFile: (category: FeedbackCategory) => void;
36
+ /** The list link is "View feedback" → {@link inboxHref} INSTEAD of "My feedback" (§4.1,
37
+ * keksdose `top-bar.tsx:205-214`). The admin check is the app's (kastlan's is
38
+ * company-scoped). Default `false`. */
39
+ isAdmin?: boolean;
40
+ /**
41
+ * Rows of the app's own, between the divider and the list link — keksdose's
42
+ * `Sparkles` Help assistant → `/assistant` and `MessagesSquare` Support chat →
43
+ * `/support` with its unread chip. Any {@link TopBarMenuEntry}; keep their `key`s clear
44
+ * of the menu's own (`BUG`, `IDEA`, `QUESTION`, `OTHER`, `feedback-divider`,
45
+ * `my-feedback`, `view-feedback`).
46
+ */
47
+ extraEntries?: TopBarMenuEntry[];
48
+ /** The user's own reports, linked for everyone but an admin. Default `"/my-feedback"`
49
+ * (§2.3). */
50
+ myFeedbackHref?: string;
51
+ /** The admin inbox, linked for an admin. Default `"/feedback"` (§2.3). */
52
+ inboxHref?: string;
53
+ /** A dot on the trigger — keksdose's unread support replies (`tone: "danger"`). Its
54
+ * `label` joins the trigger's name ("Send feedback 2 unread"). The words are the app's. */
55
+ iconBadge?: UserAvatarBadge | null;
56
+ /** Prop > `<UiKitProvider labels={{ feedbackMenu }}>` > English. */
57
+ labels?: Partial<FeedbackMenuLabels>;
58
+ /** Which edge of the trigger the panel lines up with. Default `end`. */
59
+ align?: "start" | "end" | "left" | "right";
60
+ }
61
+
62
+ /**
63
+ * The top-bar feedback menu, the same in every app (0.27.0, docs/feedback-harmonization.md
64
+ * §4.1) — keksdose `frontend/src/app/top-bar.tsx:156` on the kit's `TopBarActionMenu`:
65
+ *
66
+ * 1. a `MessageSquare` trigger named "Send feedback", with an optional `iconBadge`;
67
+ * 2. Bug · Idea · Question · Other (`Bug`, `Lightbulb`, `HelpCircle`, `MoreHorizontal` —
68
+ * the category badges' own glyphs), each calling `onFile(category)`;
69
+ * 3. a divider, always;
70
+ * 4. the app's `extraEntries`;
71
+ * 5. ONE list link, `Inbox`: an admin's is "View feedback" → `/feedback`, everyone
72
+ * else's "My feedback" → `/my-feedback` — keksdose's (`top-bar.tsx:205-214`, feedback
73
+ * #331: admins get the management view, everyone else their own submissions).
74
+ *
75
+ * WHY A PRESET. Three apps had drawn three menus on the same primitive and they disagreed:
76
+ * kastlan headed it "New submission", Kurvenschmiede "Send feedback" with a single link
77
+ * that changed its words by role, keksdose had no heading and one link by role. keksdose
78
+ * is the source (§2.4, §7.11): no heading, one link — the inbox for an admin, who reaches
79
+ * their own reports there too — and the kit draws it, so the row order, the icons and the
80
+ * words cannot drift again. Where the menu sits in the bar stays each app's.
81
+ *
82
+ * `CRASH` has no row and never will: crash reports are filed by the error boundary with
83
+ * the stack and a fingerprint (keksdose feedback #160), and a hand-filed "crash" would sort
84
+ * to the top like one with none of what makes it actionable. The server refuses it with a
85
+ * 422 anyway (§3.4).
86
+ *
87
+ * The menu forwards nothing to its trigger, as `TopBarActionMenu` does not: keksdose's
88
+ * tour anchor (`data-tour="feedback-menu"`) stays on a wrapping `<span>`. Needs a router,
89
+ * as every `TopBarActionMenu` with link rows does.
90
+ */
91
+ export function FeedbackMenu({
92
+ onFile,
93
+ isAdmin = false,
94
+ extraEntries,
95
+ myFeedbackHref = "/my-feedback",
96
+ inboxHref = "/feedback",
97
+ iconBadge,
98
+ labels: labelsProp,
99
+ align,
100
+ }: FeedbackMenuProps) {
101
+ const labels = useKitLabels("feedbackMenu", DEFAULT_FEEDBACK_MENU_LABELS, labelsProp);
102
+ const categoryLabels = useFeedbackCategoryLabels();
103
+ const entries: TopBarMenuEntry[] = [
104
+ ...FEEDBACK_PICKABLE_CATEGORIES.map((category): TopBarMenuEntry => {
105
+ const Icon = FEEDBACK_CATEGORY_META[category].icon;
106
+ return {
107
+ key: category,
108
+ icon: <Icon className="size-4" />,
109
+ label: categoryLabels[category],
110
+ onSelect: () => onFile(category),
111
+ };
112
+ }),
113
+ { kind: "divider", key: "feedback-divider" },
114
+ ...(extraEntries ?? []),
115
+ {
116
+ kind: "link",
117
+ key: isAdmin ? "view-feedback" : "my-feedback",
118
+ to: isAdmin ? inboxHref : myFeedbackHref,
119
+ icon: <Inbox className="size-4" />,
120
+ label: isAdmin ? labels.viewFeedback : labels.myFeedback,
121
+ },
122
+ ];
123
+ return (
124
+ <TopBarActionMenu
125
+ icon={<MessageSquare className="size-5" />}
126
+ iconBadge={iconBadge}
127
+ ariaLabel={labels.trigger}
128
+ entries={entries}
129
+ align={align}
130
+ />
131
+ );
132
+ }