@eifi1/ui-kit 0.39.1 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +8 -7
  2. package/dist/billing/billing-overview.d.ts +10 -0
  3. package/dist/components/autocomplete.js +5 -1
  4. package/dist/components/autocomplete.js.map +1 -1
  5. package/dist/components/combobox-core.d.ts +14 -1
  6. package/dist/components/combobox-core.js +4 -0
  7. package/dist/components/combobox-core.js.map +1 -1
  8. package/dist/components/combobox.js +9 -3
  9. package/dist/components/combobox.js.map +1 -1
  10. package/dist/components/data-table-labels.d.ts +5 -0
  11. package/dist/components/data-table-labels.js +2 -0
  12. package/dist/components/data-table-labels.js.map +1 -1
  13. package/dist/components/data-table-mobile.d.ts +36 -1
  14. package/dist/components/data-table-mobile.js +162 -15
  15. package/dist/components/data-table-mobile.js.map +1 -1
  16. package/dist/components/data-table.d.ts +39 -12
  17. package/dist/components/data-table.js +74 -13
  18. package/dist/components/data-table.js.map +1 -1
  19. package/dist/components/inline-entity-combobox.js +10 -3
  20. package/dist/components/inline-entity-combobox.js.map +1 -1
  21. package/dist/components/picker-sheet.d.ts +18 -1
  22. package/dist/components/picker-sheet.js +20 -2
  23. package/dist/components/picker-sheet.js.map +1 -1
  24. package/dist/components/tabs.d.ts +26 -2
  25. package/dist/components/tabs.js +41 -2
  26. package/dist/components/tabs.js.map +1 -1
  27. package/dist/components/translation-review-parts.d.ts +2 -1
  28. package/dist/components/translation-review-parts.js +1 -1
  29. package/dist/components/translation-review-parts.js.map +1 -1
  30. package/dist/components/ui-labels.js +3 -1
  31. package/dist/components/ui-labels.js.map +1 -1
  32. package/dist/eslint/index.js +1 -1
  33. package/dist/feedback/feedback-comment-thread.d.ts +8 -1
  34. package/dist/feedback/feedback-comment-thread.js +26 -1
  35. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  36. package/dist/feedback/feedback-labels.d.ts +2 -1
  37. package/dist/feedback/feedback-labels.js +1 -0
  38. package/dist/feedback/feedback-labels.js.map +1 -1
  39. package/dist/feedback/feedback-menu-labels.js +3 -1
  40. package/dist/feedback/feedback-menu-labels.js.map +1 -1
  41. package/dist/feedback/feedback-menu.d.ts +31 -24
  42. package/dist/feedback/feedback-menu.js +16 -7
  43. package/dist/feedback/feedback-menu.js.map +1 -1
  44. package/dist/feedback/feedback-public-labels.d.ts +3 -2
  45. package/dist/feedback/feedback-public-labels.js +1 -1
  46. package/dist/feedback/feedback-public-labels.js.map +1 -1
  47. package/dist/feedback/feedback-public-list.d.ts +4 -1
  48. package/dist/feedback/feedback-public-list.js +67 -55
  49. package/dist/feedback/feedback-public-list.js.map +1 -1
  50. package/dist/feedback/feedback-public-types.d.ts +16 -3
  51. package/dist/feedback/feedback-record.d.ts +20 -3
  52. package/dist/feedback/feedback-record.js +15 -0
  53. package/dist/feedback/feedback-record.js.map +1 -1
  54. package/dist/feedback/feedback-replies.d.ts +13 -2
  55. package/dist/feedback/feedback-replies.js +67 -33
  56. package/dist/feedback/feedback-replies.js.map +1 -1
  57. package/dist/feedback/feedback-rework-entry-chip.d.ts +9 -0
  58. package/dist/feedback/feedback-rework-entry-chip.js +13 -0
  59. package/dist/feedback/feedback-rework-entry-chip.js.map +1 -0
  60. package/dist/feedback/feedback-row-detail.d.ts +31 -8
  61. package/dist/feedback/feedback-row-detail.js +31 -7
  62. package/dist/feedback/feedback-row-detail.js.map +1 -1
  63. package/dist/feedback/feedback-table-labels.js +1 -1
  64. package/dist/feedback/feedback-table-labels.js.map +1 -1
  65. package/dist/feedback/feedback-table.d.ts +5 -2
  66. package/dist/feedback/feedback-table.js +2 -2
  67. package/dist/feedback/feedback-table.js.map +1 -1
  68. package/dist/feedback/feedback-thread.js +3 -1
  69. package/dist/feedback/feedback-thread.js.map +1 -1
  70. package/dist/feedback/feedback-vocabulary.d.ts +9 -6
  71. package/dist/feedback/feedback-vocabulary.js +16 -1
  72. package/dist/feedback/feedback-vocabulary.js.map +1 -1
  73. package/dist/hooks/use-long-press.d.ts +30 -0
  74. package/dist/hooks/use-long-press.js +77 -0
  75. package/dist/hooks/use-long-press.js.map +1 -0
  76. package/dist/hooks/use-overlay-history.js +37 -3
  77. package/dist/hooks/use-overlay-history.js.map +1 -1
  78. package/dist/i18n/german.js +13 -5
  79. package/dist/i18n/german.js.map +1 -1
  80. package/dist/i18n/kit-labels.d.ts +3 -0
  81. package/dist/i18n/kit-labels.js.map +1 -1
  82. package/dist/i18n/locales/en.js +12 -5
  83. package/dist/i18n/locales/en.js.map +2 -2
  84. package/dist/i18n/locales/es.js +13 -5
  85. package/dist/i18n/locales/es.js.map +1 -1
  86. package/dist/i18n/locales/fr.js +13 -5
  87. package/dist/i18n/locales/fr.js.map +1 -1
  88. package/dist/i18n/locales/hu.js +13 -5
  89. package/dist/i18n/locales/hu.js.map +1 -1
  90. package/dist/i18n/locales/it.js +13 -5
  91. package/dist/i18n/locales/it.js.map +1 -1
  92. package/dist/i18n/locales/zh.js +13 -5
  93. package/dist/i18n/locales/zh.js.map +1 -1
  94. package/dist/i18n/shared-labels.js +1 -1
  95. package/dist/i18n/shared-labels.js.map +1 -1
  96. package/dist/shell/app-shell.js +9 -0
  97. package/dist/shell/app-shell.js.map +1 -1
  98. package/dist/shell/fill-height.d.ts +38 -0
  99. package/dist/shell/fill-height.js +12 -0
  100. package/dist/shell/fill-height.js.map +1 -0
  101. package/dist/shell.d.ts +2 -0
  102. package/dist/shell.js +3 -0
  103. package/dist/shell.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/billing/billing-overview.ts +10 -0
  106. package/src/components/autocomplete.tsx +5 -1
  107. package/src/components/combobox-core.tsx +20 -1
  108. package/src/components/combobox.tsx +9 -3
  109. package/src/components/data-table-labels.ts +7 -0
  110. package/src/components/data-table-mobile.tsx +193 -9
  111. package/src/components/data-table.tsx +141 -24
  112. package/src/components/inline-entity-combobox.tsx +12 -4
  113. package/src/components/picker-sheet.tsx +38 -1
  114. package/src/components/tabs.tsx +73 -3
  115. package/src/components/translation-review-parts.tsx +3 -2
  116. package/src/components/ui-labels.ts +2 -0
  117. package/src/feedback/feedback-comment-thread.tsx +39 -1
  118. package/src/feedback/feedback-labels.ts +3 -1
  119. package/src/feedback/feedback-menu-labels.ts +2 -0
  120. package/src/feedback/feedback-menu.tsx +48 -30
  121. package/src/feedback/feedback-public-labels.ts +4 -3
  122. package/src/feedback/feedback-public-list.tsx +15 -2
  123. package/src/feedback/feedback-public-types.ts +17 -3
  124. package/src/feedback/feedback-record.ts +34 -3
  125. package/src/feedback/feedback-replies.tsx +100 -31
  126. package/src/feedback/feedback-rework-entry-chip.tsx +18 -0
  127. package/src/feedback/feedback-row-detail.tsx +65 -12
  128. package/src/feedback/feedback-table-labels.ts +1 -1
  129. package/src/feedback/feedback-table.tsx +7 -4
  130. package/src/feedback/feedback-thread.tsx +5 -1
  131. package/src/feedback/feedback-vocabulary.ts +27 -6
  132. package/src/hooks/use-long-press.ts +126 -0
  133. package/src/hooks/use-overlay-history.ts +74 -3
  134. package/src/i18n/german.ts +10 -2
  135. package/src/i18n/kit-labels.tsx +3 -0
  136. package/src/i18n/locales/es.ts +10 -2
  137. package/src/i18n/locales/fr.ts +10 -2
  138. package/src/i18n/locales/hu.ts +10 -2
  139. package/src/i18n/locales/it.ts +10 -2
  140. package/src/i18n/locales/zh.ts +10 -2
  141. package/src/i18n/shared-labels.ts +1 -1
  142. package/src/shell/app-shell.tsx +9 -0
  143. package/src/shell/fill-height.tsx +45 -0
  144. package/src/shell.ts +3 -0
@@ -179,11 +179,12 @@ export const FEEDBACK_PICKABLE_CATEGORIES: readonly FeedbackCategory[] = Object.
179
179
 
180
180
  /**
181
181
  * The statuses a report can be sent back for **rework** from (§3.4): everything the team
182
- * has answered — `IN_EVALUATION`, `NEEDS_LIVE_TEST`, `POSTPONED`, `DONE`, `WONT_DO`. On
183
- * these the server reopens the row when the body grows by a rework block; on `OPEN`,
184
- * `READY` and `IN_PROGRESS` there is nothing to send back yet.
182
+ * has answered — `IN_EVALUATION`, `NEEDS_LIVE_TEST`, `POSTPONED`, `DONE`, `WONT_DO` — and,
183
+ * since 0.40, `REWORK` itself (§10.2 decision 4: a second rework adds another entry). On
184
+ * `OPEN`, `READY` and `IN_PROGRESS` there is nothing to send back yet.
185
185
  */
186
186
  export const FEEDBACK_REWORKABLE_STATUSES: readonly FeedbackStatus[] = Object.freeze([
187
+ "REWORK",
187
188
  "IN_EVALUATION",
188
189
  "NEEDS_LIVE_TEST",
189
190
  "POSTPONED",
@@ -191,6 +192,18 @@ export const FEEDBACK_REWORKABLE_STATUSES: readonly FeedbackStatus[] = Object.fr
191
192
  "WONT_DO",
192
193
  ] as const);
193
194
 
195
+ /**
196
+ * The loops' queue (0.40, §10.2 decision 4; server-kit `QUEUE_STATUSES`): released for
197
+ * work (`READY`), sent back and released again (`REWORK`), and being worked
198
+ * (`IN_PROGRESS`). Kurvenschmiede's loop adds `OPEN` (decision 9) — its own difference,
199
+ * not this list's.
200
+ */
201
+ export const FEEDBACK_QUEUE_STATUSES: readonly FeedbackStatus[] = Object.freeze([
202
+ "READY",
203
+ "REWORK",
204
+ "IN_PROGRESS",
205
+ ] as const);
206
+
194
207
  /**
195
208
  * The statuses in which the AUTHOR may still edit title, body and category (§3.4,
196
209
  * keksdose `canAuthorEdit`, `feedback-page.tsx:398`): `OPEN`, `READY` and `IN_PROGRESS`.
@@ -320,6 +333,24 @@ export function reworkCount(body: string): number {
320
333
  return n;
321
334
  }
322
335
 
336
+ /**
337
+ * How many times a report has been sent back, since 0.40 (§10.3): its `kind: "rework"`
338
+ * entries when the row carries `replies`, else {@link reworkCount} off the body (a server
339
+ * before server-kit 0.13, whose reworks are still `--- REWORK … ---` blocks). What the
340
+ * Rework chip counts.
341
+ */
342
+ export function feedbackReworkCount(
343
+ row: { body?: string | null; replies?: readonly Pick<FeedbackReply, "kind">[] | null },
344
+ ): number {
345
+ if (row.replies) {
346
+ const entries = row.replies.filter((reply) => reply.kind === "rework").length;
347
+ // A row migrated by an app that answers `replies` but not yet `kind` keeps its body's
348
+ // count: no entry is a rework there, and the blocks are still in the body.
349
+ if (entries > 0) return entries;
350
+ }
351
+ return reworkCount(row.body ?? "");
352
+ }
353
+
323
354
  /**
324
355
  * A body split into the prose to render and the files to show beside it, in body order.
325
356
  *
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
- import { BellOff } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { BellOff, Paperclip } from "lucide-react";
3
4
  import { Select } from "../components/select";
4
5
  import type { CommitScope } from "../components/write-lock";
5
6
  import { useKitLabels, useKitLocale, type LabelOverride } from "../i18n/kit-labels";
@@ -8,9 +9,11 @@ import { formatDate } from "../lib/format";
8
9
  import { FeedbackStatusBadge, selectableFeedbackStatuses, type FeedbackStatus } from "./feedback-inbox";
9
10
  import { useFeedbackStatusLabels } from "./feedback-labels";
10
11
  import type { FeedbackReply } from "./feedback-public-types";
11
- import { FEEDBACK_DECIDED_STATUSES, FEEDBACK_REPLY_MAX } from "./feedback-record";
12
+ import { FEEDBACK_DECIDED_STATUSES, FEEDBACK_REPLY_MAX, attachmentName } from "./feedback-record";
12
13
  import { ChatComposer } from "./feedback-thread";
13
14
  import { DEFAULT_FEEDBACK_REPLIES_LABELS } from "./feedback-thread-parts-labels";
15
+ import { FeedbackReworkEntryChip } from "./feedback-rework-entry-chip";
16
+ import { DEFAULT_FEEDBACK_PUBLIC_LABELS } from "./feedback-public-labels";
14
17
 
15
18
  /**
16
19
  * The admin's answers as a list (docs/feedback-harmonization.md §9.7, decision 5): each
@@ -79,23 +82,53 @@ export interface FeedbackReplyTimelineProps {
79
82
  renderDate?: (iso: string) => string;
80
83
  /** Tag bodies and names `data-private` (a host's screen-share blur). Default true. */
81
84
  redact?: boolean;
85
+ /** 0.40: how a rework entry's file (`attachment_url`) is drawn under its text. Default:
86
+ * its name beside a paperclip — the file itself is in the row detail's Attachment
87
+ * section, fetched with the app's credentials. */
88
+ renderAttachment?: (url: string) => ReactNode;
82
89
  labels?: LabelOverride<FeedbackRepliesLabels>;
83
90
  className?: string;
84
91
  }
85
92
 
93
+ /** The status an entry moved the report to — "→ Done", with where from in the admin view.
94
+ * Nothing on an entry with no `status_to`: a status-less reply, or a rework migrated out
95
+ * of the body, whose move was never recorded (0.40, §10.3). */
96
+ function EntryStatus({ reply, statusChanged }: { reply: FeedbackReply; statusChanged: string }) {
97
+ if (!reply.status_to) return null;
98
+ return (
99
+ <span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1" data-feedback-reply-status={reply.status_to}>
100
+ <span className="sr-only">{statusChanged}</span>
101
+ {reply.status_from && reply.status_from !== reply.status_to && (
102
+ <>
103
+ <FeedbackStatusBadge status={reply.status_from} />
104
+ <span aria-hidden>→</span>
105
+ </>
106
+ )}
107
+ {!reply.status_from && <span aria-hidden>→</span>}
108
+ <FeedbackStatusBadge status={reply.status_to} />
109
+ </span>
110
+ );
111
+ }
112
+
86
113
  /**
87
114
  * The replies, oldest first, down a rule: each with its author (`null` → "Admin"), its
88
115
  * time, the status it set ("→ Done"; the admin view adds where from) and its text. No
89
116
  * edit and no delete (§9.7): a correction is a new reply.
117
+ *
118
+ * 0.40 (§10.3): an author's **rework** entry (`kind: "rework"`) stands among them as the
119
+ * author's — their name (`null` → "A user", never "Admin"), the **Rework** chip, the
120
+ * status it moved the report to (none on a migrated one) and its file.
90
121
  */
91
122
  export function FeedbackReplyTimeline({
92
123
  replies,
93
124
  renderDate,
94
125
  redact = true,
126
+ renderAttachment,
95
127
  labels: labelsProp,
96
128
  className,
97
129
  }: FeedbackReplyTimelineProps) {
98
130
  const labels = useFeedbackRepliesLabels(labelsProp);
131
+ const anonymous = useKitLabels("feedbackPublic", DEFAULT_FEEDBACK_PUBLIC_LABELS).anonymous;
99
132
  const locale = useKitLocale();
100
133
  const priv = redact ? "" : undefined;
101
134
  if (replies.length === 0) {
@@ -105,40 +138,72 @@ export function FeedbackReplyTimeline({
105
138
  renderDate ? renderDate(iso) : formatDate(iso, { dateStyle: "medium", timeStyle: "short" }, { locale });
106
139
  return (
107
140
  <ol aria-label={labels.replies} className={cn("space-y-3 border-s-2 border-subtle ps-3", className)}>
108
- {replies.map((reply) => (
109
- <li key={reply.id} data-feedback-reply={reply.id} className="min-w-0 text-sm">
110
- <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-caption text-muted">
111
- <span data-private={priv} className="font-medium text-secondary">
112
- {reply.author_name ?? labels.admin}
113
- </span>
114
- <time dateTime={reply.created_at} className="tabular-nums">
115
- {dateText(reply.created_at)}
116
- </time>
117
- {reply.status_to && (
118
- <span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1" data-feedback-reply-status={reply.status_to}>
119
- <span className="sr-only">{labels.statusChanged}</span>
120
- {reply.status_from && reply.status_from !== reply.status_to && (
121
- <>
122
- <FeedbackStatusBadge status={reply.status_from} />
123
- <span aria-hidden>→</span>
124
- </>
125
- )}
126
- {!reply.status_from && <span aria-hidden>→</span>}
127
- <FeedbackStatusBadge status={reply.status_to} />
141
+ {replies.map((reply) => {
142
+ const rework = reply.kind === "rework";
143
+ return (
144
+ <li
145
+ key={reply.id}
146
+ data-feedback-reply={reply.id}
147
+ data-feedback-entry-kind={rework ? "rework" : undefined}
148
+ className="min-w-0 text-sm"
149
+ >
150
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-caption text-muted">
151
+ <span data-private={priv} className="font-medium text-secondary">
152
+ {reply.author_name ?? (rework ? anonymous : labels.admin)}
128
153
  </span>
154
+ {rework && <FeedbackReworkEntryChip />}
155
+ <time dateTime={reply.created_at} className="tabular-nums">
156
+ {dateText(reply.created_at)}
157
+ </time>
158
+ <EntryStatus reply={reply} statusChanged={labels.statusChanged} />
159
+ </div>
160
+ <p data-private={priv} dir="auto" className="mt-0.5 whitespace-pre-wrap break-words text-start text-primary">
161
+ {reply.body}
162
+ </p>
163
+ {rework && reply.attachment_url && (
164
+ <div className="mt-1 min-w-0" data-private={priv} data-feedback-entry-file="">
165
+ {renderAttachment ? (
166
+ renderAttachment(reply.attachment_url)
167
+ ) : (
168
+ <span className="inline-flex min-w-0 max-w-full items-center gap-1 text-xs text-secondary">
169
+ <Paperclip className="size-3.5 shrink-0" aria-hidden />
170
+ <span className="min-w-0 break-all">{attachmentName(reply.attachment_url)}</span>
171
+ </span>
172
+ )}
173
+ </div>
129
174
  )}
130
- </div>
131
- <p data-private={priv} dir="auto" className="mt-0.5 whitespace-pre-wrap break-words text-start text-primary">
132
- {reply.body}
133
- </p>
134
- </li>
135
- ))}
175
+ </li>
176
+ );
177
+ })}
136
178
  </ol>
137
179
  );
138
180
  }
139
181
 
140
182
  /* ── The composer ─────────────────────────────────────────────────────────── */
141
183
 
184
+ /**
185
+ * The status Select and Reply on one line, one height (keksdose live #417, Android, dark).
186
+ *
187
+ * A field and a button of the same padding are not the same height everywhere: on a touch
188
+ * screen the field's type grows to 16 px (FIELD_TOUCH_TEXT, so iOS does not zoom) and its
189
+ * box with it, while the button keeps its 14 px line — and at Large the button takes its
190
+ * 48 px touch floor, which the field has not. So the composer's action row stretches its
191
+ * two halves to one height and Reply fills it (Button's `stretch`, which is the kit's
192
+ * answer for a button beside a taller field). Scoped to this composer through the marks
193
+ * ChatComposer puts on its row: a host slot with an attachment preview must not drag the
194
+ * send button to the preview's 80 px.
195
+ *
196
+ * On a touch screen the "Ctrl+Enter to send" hint has no keyboard to speak of, and beside
197
+ * a field-height select it pushed Reply onto a line of its own on a phone — it is
198
+ * visually hidden there (still read in order). At Large the button's 48 px floor is
199
+ * taller than any field, here as in every form row of the kit.
200
+ */
201
+ const REPLY_ROW_ALIGN = cn(
202
+ "[&_[data-chat-composer-actions]]:items-stretch",
203
+ "[&_[data-chat-composer-send]]:self-stretch",
204
+ "pointer-coarse:[&_[data-chat-composer-hint]]:sr-only",
205
+ );
206
+
142
207
  export interface FeedbackReplyComposerProps {
143
208
  /** The report's status now — what "No change" keeps. */
144
209
  status: FeedbackStatus;
@@ -161,7 +226,9 @@ export interface FeedbackReplyComposerProps {
161
226
 
162
227
  /**
163
228
  * The admin's reply box (§9.7): the text, the **status** to set with it ("No change"
164
- * first, then every status — {@link selectableFeedbackStatuses}), and **Reply**. A
229
+ * first, then every status — {@link selectableFeedbackStatuses}, `REWORK` among them
230
+ * since 0.40: how an admin sends any report back, their own included, §10.2 decision 6),
231
+ * and **Reply**. A
165
232
  * {@link ChatComposer} underneath: Ctrl/⌘+Enter sends, the count runs to 8 000, a draft
166
233
  * typed while a reply is in flight stays.
167
234
  *
@@ -196,7 +263,7 @@ export function FeedbackReplyComposer({
196
263
  };
197
264
 
198
265
  return (
199
- <div className={cn("space-y-2", className)} data-feedback-reply-composer="">
266
+ <div className={cn("space-y-2", REPLY_ROW_ALIGN, className)} data-feedback-reply-composer="">
200
267
  {suggestedStatus && (
201
268
  <p className="flex items-start gap-1.5 text-xs text-secondary" data-feedback-nudge={suggestedStatus}>
202
269
  <BellOff className="mt-0.5 size-3.5 shrink-0 text-warning" aria-hidden />
@@ -211,8 +278,10 @@ export function FeedbackReplyComposer({
211
278
  showCount
212
279
  labels={{ field: labels.field, placeholder: labels.placeholder, send: labels.reply }}
213
280
  attachmentSlot={
281
+ // The kit's standard Select at field height (#417: "not invent new things where
282
+ // there are usable ones already") — `size="sm"` was a 28 px box beside the
283
+ // 38 px Reply. See REPLY_ROW_ALIGN for how the two keep one height.
214
284
  <Select
215
- size="sm"
216
285
  aria-label={labels.status}
217
286
  value={picked}
218
287
  disabled={pending}
@@ -0,0 +1,18 @@
1
+ import { Chip } from "../components/chip";
2
+ import { useKitLabels } from "../i18n/kit-labels";
3
+ import { DEFAULT_FEEDBACK_PAGE_LABELS } from "./feedback-table-labels";
4
+
5
+ /**
6
+ * The **Rework** chip on an author's rework entry (0.40, docs/feedback-harmonization.md
7
+ * §10.3) — the table's own `feedbackPage.reworkChip(1)` and tone, so the entry and the
8
+ * row's chip say the same. Drawn by `FeedbackReplyTimeline` and `FeedbackCommentThread`.
9
+ * Internal: not re-exported.
10
+ */
11
+ export function FeedbackReworkEntryChip({ className }: { className?: string }) {
12
+ const pageLabels = useKitLabels("feedbackPage", DEFAULT_FEEDBACK_PAGE_LABELS);
13
+ return (
14
+ <Chip size="sm" tone="warning" caps className={className} data-feedback-entry-rework="">
15
+ {pageLabels.reworkChip(1)}
16
+ </Chip>
17
+ );
18
+ }
@@ -237,7 +237,8 @@ export function feedbackPageUrl(context: FeedbackContext | null | undefined): st
237
237
  * keyed by URL, a React key clash keksdose had).
238
238
  */
239
239
  export function feedbackAttachmentUrls(
240
- row: Pick<FeedbackRecord, "screenshot_url" | "attachment_urls" | "body">,
240
+ row: Pick<FeedbackRecord, "screenshot_url" | "attachment_urls" | "body"> &
241
+ Partial<Pick<FeedbackRecord, "replies">>,
241
242
  ): string[] {
242
243
  const urls = [
243
244
  ...(row.screenshot_url ? [row.screenshot_url] : []),
@@ -245,6 +246,11 @@ export function feedbackAttachmentUrls(
245
246
  // that predates it (keksdose dev #578).
246
247
  ...(row.attachment_urls ?? []),
247
248
  ...splitBodyAttachments(row.body).urls,
249
+ // 0.40 (§10.3): a rework entry's one file, in entry order — where the rework
250
+ // pictures of the body were before.
251
+ ...(row.replies ?? []).flatMap((reply) =>
252
+ reply.kind === "rework" && reply.attachment_url ? [reply.attachment_url] : [],
253
+ ),
248
254
  ];
249
255
  return [...new Set(urls)];
250
256
  }
@@ -330,8 +336,9 @@ export interface FeedbackRowAccess {
330
336
  /** The admin on the inbox (`canEdit`): since 0.38 the reply composer (the outcome
331
337
  * itself is frozen, §9.7). */
332
338
  canEditOutcome: boolean;
333
- /** "Rework": the admin or the author, on an answered row
334
- * ({@link FEEDBACK_REWORKABLE_STATUSES}) — §3.4, anyone else gets the server's 403. */
339
+ /** "Rework": the author, on an answered row ({@link FEEDBACK_REWORKABLE_STATUSES}).
340
+ * Since 0.40 (§10.2 decision 6) the author's only — an admin sends a report back with
341
+ * a reply whose status is `REWORK`; the server answers anyone else 403. */
335
342
  canRework: boolean;
336
343
  /** The status pills: the admin on the inbox; everyone else sees them read-only. */
337
344
  canChangeStatus: boolean;
@@ -356,7 +363,7 @@ export function feedbackRowAccess(
356
363
  isAuthor,
357
364
  canEditDescription: isAuthor && FEEDBACK_AUTHOR_EDITABLE_STATUSES.includes(row.status),
358
365
  canEditOutcome: canEdit,
359
- canRework: (canEdit || isAuthor) && FEEDBACK_REWORKABLE_STATUSES.includes(row.status),
366
+ canRework: isAuthor && FEEDBACK_REWORKABLE_STATUSES.includes(row.status),
360
367
  canChangeStatus: canEdit,
361
368
  };
362
369
  }
@@ -396,11 +403,22 @@ export type FeedbackUpload = (file: File) => Promise<string>;
396
403
 
397
404
  /* ── Send for rework (§4.4.5) ─────────────────────────────────────────────── */
398
405
 
406
+ /** 0.40 (§10.3): `POST /feedback/{id}/rework` — the trimmed note and the uploaded file's
407
+ * URL. Return the promise: the section closes when it resolves and keeps the note when
408
+ * it rejects (the toast is the app's: the server's `detail`, else
409
+ * `feedbackToast.updateFailed`). */
410
+ export type FeedbackReworkSend = (note: string, attachmentUrl?: string) => void | Promise<unknown>;
411
+
399
412
  export interface FeedbackReworkSectionProps {
400
413
  /** The row being sent back: its id, and the body the round is appended to. */
401
414
  row: Pick<FeedbackRecord, "id" | "body">;
402
- /** The PATCH — see {@link FeedbackDetailUpdate}. Called with `{ id, body }` only. */
403
- onUpdate: FeedbackDetailUpdate;
415
+ /** 0.40: the rework as an entry of its own — see {@link FeedbackReworkSend}. With it,
416
+ * `onUpdate` is not called. */
417
+ onRework?: FeedbackReworkSend;
418
+ /** The PATCH — see {@link FeedbackDetailUpdate}. Called with `{ id, body }` only, and
419
+ * only without `onRework`: the legacy append (§3.4), which server-kit 0.13 still
420
+ * turns into an entry for one release. Optional since 0.40; pass one of the two. */
421
+ onUpdate?: FeedbackDetailUpdate;
404
422
  /** The PATCH is in flight (`update.isPending`). The upload adds its own. */
405
423
  pending?: boolean;
406
424
  /** The upload. Left out, the note goes without a file field. */
@@ -422,7 +440,7 @@ export interface FeedbackReworkSectionProps {
422
440
  }
423
441
 
424
442
  /**
425
- * The rework editor (§4.4.5): the submitter — or the admin — sends an answered report
443
+ * The rework editor (§4.4.5): the submitter sends an answered report
426
444
  * back with a note and at most one file, and the server reopens it (§3.4, keksdose
427
445
  * `submitRework`, `feedback-page.tsx:350`).
428
446
  *
@@ -432,6 +450,9 @@ export interface FeedbackReworkSectionProps {
432
450
  * uploaded FIRST and awaited: a body naming a file that never arrived would be a
433
451
  * rework whose evidence is missing, and the upload is the half that fails on its
434
452
  * own. If it fails nothing is sent and the toast says so; the note stays in the box.
453
+ * - **0.40: an entry of its own** (§10.3) — with `onRework`, the note and the file's URL
454
+ * go to `POST /feedback/{id}/rework` and the body is not touched. Without it, the
455
+ * legacy append below, which server-kit 0.13 still converts (one minor).
435
456
  * - **The body alone goes in the PATCH** — {@link appendRework}'s strict extension of
436
457
  * the current body, which is how the server recognises the append and reopens the
437
458
  * row. Never a `status` (kastlan's client sent `OPEN`; an author may not write one,
@@ -443,6 +464,7 @@ export interface FeedbackReworkSectionProps {
443
464
  */
444
465
  export function FeedbackReworkSection({
445
466
  row,
467
+ onRework,
446
468
  onUpdate,
447
469
  pending = false,
448
470
  onUpload,
@@ -456,6 +478,7 @@ export function FeedbackReworkSection({
456
478
  const labels = useFeedbackDetailLabels(labelsProp);
457
479
  const toasts = useFeedbackToastLabels();
458
480
  const [uploading, setUploading] = useState(false);
481
+ const [sending, setSending] = useState(false);
459
482
  // The body as it is NOW, not as it was when Send was pressed: the list can refresh
460
483
  // while the file uploads, and an append to a stale body is no longer a strict
461
484
  // extension of the stored one — the server would read it as an edit, and refuse it.
@@ -490,6 +513,21 @@ export function FeedbackReworkSection({
490
513
  setUploading(false);
491
514
  }
492
515
  }
516
+ if (onRework) {
517
+ // 0.40 (§10.3): the entry, not the body — the server checks the URL (a 422 outside
518
+ // the app's attachment prefix).
519
+ setSending(true);
520
+ try {
521
+ await onRework(note, url ?? undefined);
522
+ } catch {
523
+ return; // The app's toast; the note stays in the box.
524
+ } finally {
525
+ setSending(false);
526
+ }
527
+ onSent?.();
528
+ return;
529
+ }
530
+ if (!onUpdate) return;
493
531
  let body: string | null;
494
532
  try {
495
533
  body = appendRework(latestBody.current, note, url);
@@ -511,7 +549,7 @@ export function FeedbackReworkSection({
511
549
  initial=""
512
550
  required
513
551
  commit={commit}
514
- pending={pending || uploading}
552
+ pending={pending || uploading || sending}
515
553
  onSave={(value, file) => void send(value, file)}
516
554
  onCancel={onCancel}
517
555
  saveLabel={labels.reworkSend}
@@ -593,6 +631,15 @@ export interface FeedbackRowDetailProps {
593
631
  onReply?: (body: string, status?: FeedbackStatus) => void | Promise<unknown>;
594
632
  /** 0.38: the reply is in flight. */
595
633
  replying?: boolean;
634
+ /**
635
+ * 0.40 (§10.3): `POST /feedback/{id}/rework` — the author's rework as an entry of its
636
+ * own (see {@link FeedbackReworkSend}). The **Rework** button shows only to the author
637
+ * on an answered row, and only where the reply composer is not shown (§10.2 decision 6:
638
+ * an admin answering a report sends it back with a reply whose status is `REWORK`).
639
+ * Left out, the rework goes through `onUpdate` as the legacy body append — for one
640
+ * minor, while the server converts it.
641
+ */
642
+ onRework?: FeedbackReworkSend;
596
643
  /** 0.38: the report's comments — the app's `FeedbackCommentThread` — in a section of
597
644
  * their own under the replies. A thread here draws the comments alone (0.38.1): the
598
645
  * Replies section above has the replies, so whatever `replies` the thread is handed
@@ -647,6 +694,7 @@ export function FeedbackRowDetail({
647
694
  showSubject = true,
648
695
  onReply,
649
696
  replying = false,
697
+ onRework,
650
698
  comments,
651
699
  onPublicChange,
652
700
  labels: labelsProp,
@@ -687,7 +735,11 @@ export function FeedbackRowDetail({
687
735
  // 0.38, whose frozen outcome is shown as it is, read-only.
688
736
  const replies = row.replies;
689
737
  const commentLabels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS);
690
- const showRework = access.canRework && reworking;
738
+ // 0.40 (§10.2 decision 6): whoever has the reply composer answers with it — REWORK is
739
+ // one of its statuses — so the Rework button is for an author without it.
740
+ const composerShown = Boolean(replies && access.canEditOutcome && onReply);
741
+ const canRework = access.canRework && !composerShown;
742
+ const showRework = canRework && reworking;
691
743
 
692
744
  return (
693
745
  <FeedbackDetail className={className}>
@@ -791,7 +843,7 @@ export function FeedbackRowDetail({
791
843
  ) : (
792
844
  <FeedbackProse>{row.outcome || undefined}</FeedbackProse>
793
845
  )}
794
- {replies && access.canEditOutcome && onReply && (
846
+ {composerShown && replies && onReply && (
795
847
  <FeedbackReplyComposer
796
848
  key={row.id}
797
849
  status={row.status}
@@ -801,9 +853,9 @@ export function FeedbackRowDetail({
801
853
  commit={commit}
802
854
  />
803
855
  )}
804
- {((access.canRework && !reworking) || pageHref) && (
856
+ {((canRework && !reworking) || pageHref) && (
805
857
  <div className="flex flex-wrap gap-2">
806
- {access.canRework && !reworking && (
858
+ {canRework && !reworking && (
807
859
  <Button
808
860
  type="button"
809
861
  variant="secondary"
@@ -836,6 +888,7 @@ export function FeedbackRowDetail({
836
888
  {showRework && (
837
889
  <FeedbackReworkSection
838
890
  row={row}
891
+ onRework={onRework}
839
892
  onUpdate={onUpdate}
840
893
  pending={pending}
841
894
  onUpload={onUpload}
@@ -5,7 +5,7 @@
5
5
  import type { FeedbackPageLabels } from "./feedback-table";
6
6
 
7
7
  export const DEFAULT_FEEDBACK_PAGE_LABELS: FeedbackPageLabels = {
8
- title: "Feedback",
8
+ title: "Feedback inbox",
9
9
  myTitle: "My feedback",
10
10
  columnId: "#",
11
11
  columnDate: "Date",
@@ -28,7 +28,7 @@ import {
28
28
  type FeedbackCategoryLabels,
29
29
  type FeedbackStatusLabels,
30
30
  } from "./feedback-labels";
31
- import { FEEDBACK_AWAITING_STATUSES, reworkCount, type FeedbackContext, type FeedbackRecord } from "./feedback-record";
31
+ import { FEEDBACK_AWAITING_STATUSES, feedbackReworkCount, type FeedbackContext, type FeedbackRecord } from "./feedback-record";
32
32
  import type { FeedbackStatusChange } from "./feedback-status-undo";
33
33
  import { feedbackPageHref, feedbackPagePath } from "./feedback-row-detail";
34
34
  import type { CommitScope } from "../components/write-lock";
@@ -70,7 +70,8 @@ import { DEFAULT_FEEDBACK_PUBLIC_LABELS } from "./feedback-public-labels";
70
70
  * `de-CH.json`, ss never ß).
71
71
  */
72
72
  export interface FeedbackPageLabels {
73
- /** The admin inbox's page title, `/feedback`. de-CH: "Feedback" */
73
+ /** The admin inbox's page title, `/feedback` — the menu's "Feedback inbox" (0.40,
74
+ * §10.2 decision 11; "Feedback" before). de-CH: "Feedback-Eingang" */
74
75
  title: string;
75
76
  /** The user's own list, `/my-feedback`. de-CH: "Mein Feedback" */
76
77
  myTitle: string;
@@ -268,7 +269,9 @@ function instant(value: string | null | undefined): number | null {
268
269
  /* ── The subject: title + environment chip + rework chip ───────────────────── */
269
270
 
270
271
  export interface FeedbackSubjectProps {
271
- row: Pick<FeedbackRecord, "title" | "body" | "context">;
272
+ /** `replies` (0.40): the chip counts the rework entries, else the body's blocks —
273
+ * {@link feedbackReworkCount}. */
274
+ row: Pick<FeedbackRecord, "title" | "body" | "context"> & Partial<Pick<FeedbackRecord, "replies">>;
272
275
  /**
273
276
  * `false` (default): one line — the title truncates and the chips stay whole, as a
274
277
  * table cell wants. `true`: the title wraps and the chips flow after its last word, as
@@ -292,7 +295,7 @@ export interface FeedbackSubjectProps {
292
295
  export function FeedbackSubject({ row, wrap = false, labels: labelsProp, className }: FeedbackSubjectProps) {
293
296
  const labels = useFeedbackPageLabels(labelsProp);
294
297
  const env = feedbackEnvironment(row.context);
295
- const rounds = reworkCount(row.body ?? "");
298
+ const rounds = feedbackReworkCount(row);
296
299
  const chipClass = wrap ? "ms-1.5 align-middle" : "shrink-0";
297
300
  const chips = (
298
301
  <>
@@ -852,7 +852,9 @@ export function ChatComposer({
852
852
  }
853
853
  }}
854
854
  />
855
- <div className="flex flex-wrap items-end justify-between gap-2">
855
+ {/* The `data-chat-composer-*` marks are for a host that lines its slot up with the
856
+ send button — FeedbackReplyComposer's status Select (#417). */}
857
+ <div data-chat-composer-actions="" className="flex flex-wrap items-end justify-between gap-2">
856
858
  {config || slot != null ? (
857
859
  <div className="flex min-w-0 flex-wrap items-end gap-2">
858
860
  {config && (
@@ -878,11 +880,13 @@ export function ChatComposer({
878
880
  <div className="ms-auto flex items-center gap-2">
879
881
  <span
880
882
  id={hintId}
883
+ data-chat-composer-hint=""
881
884
  className={size === "sm" ? "sr-only" : "text-xs text-placeholder"}
882
885
  >
883
886
  {sendOn === "enter" ? labels.sendHintEnter : labels.sendHint(modifier)}
884
887
  </span>
885
888
  <Button
889
+ data-chat-composer-send=""
886
890
  onClick={send}
887
891
  disabled={!canSend}
888
892
  commit={commit}
@@ -11,12 +11,13 @@ import {
11
11
  MoreHorizontal,
12
12
  OctagonAlert,
13
13
  PauseCircle,
14
+ RotateCcw,
14
15
  Wrench,
15
16
  } from "lucide-react";
16
17
  import type { LucideIcon } from "lucide-react";
17
18
 
18
19
  /**
19
- * The feedback **vocabulary**: the eight statuses and five categories, the glyph and tone
20
+ * The feedback **vocabulary**: the nine statuses and five categories, the glyph and tone
20
21
  * of each, their orders, and the policy for how a report moves from status to status.
21
22
  *
22
23
  * Data and plain functions, no components. Until 0.34 this lived at the top of
@@ -27,16 +28,18 @@ import type { LucideIcon } from "lucide-react";
27
28
  * feedback-inbox.tsx re-exports every name here, so nothing an app imports has moved.
28
29
  */
29
30
 
30
- /** The eight states a report can be in.
31
+ /** The nine states a report can be in.
31
32
  *
32
33
  * Shared value for value across the apps on purpose: it is what lets one habit,
33
34
  * and one agent prompt, work on any repo. Five are a chain and three sit off
34
35
  * it — see {@link visibleFeedbackStatuses} for what that buys. `READY` joined in 0.31
35
36
  * (feedback contract §8.2): OPEN is "filed, not yet triaged", READY "released for
36
- * implementation", and nobody works an OPEN row. */
37
+ * implementation", and nobody works an OPEN row. `REWORK` joined in 0.40 (§10.2
38
+ * decisions 2, 4): "sent back, released for work again" — a READY that came back. */
37
39
  export type FeedbackStatus =
38
40
  | "OPEN"
39
41
  | "READY"
42
+ | "REWORK"
40
43
  | "IN_PROGRESS"
41
44
  | "IN_EVALUATION"
42
45
  | "NEEDS_LIVE_TEST"
@@ -76,6 +79,16 @@ export const FEEDBACK_STATUS_META: Record<
76
79
  activeBg: "bg-brand-soft",
77
80
  activeText: "text-brand",
78
81
  },
82
+ // Sent back by its author or an admin and released for work again (0.40, feedback
83
+ // contract §10.2 decision 4): the Rework button's `RotateCcw`, in the danger tint so the
84
+ // queue shows reworks apart from new reports. Not IN_PROGRESS's warning tint — that is
85
+ // where a REWORK row moves next — and WONT_DO, the only other red, sits at the far end
86
+ // with `Ban`.
87
+ REWORK: {
88
+ icon: RotateCcw,
89
+ activeBg: "bg-danger-soft",
90
+ activeText: "text-danger",
91
+ },
79
92
  IN_PROGRESS: {
80
93
  icon: Wrench,
81
94
  activeBg: "bg-warning-soft",
@@ -142,8 +155,11 @@ const OFF_CHAIN: FeedbackStatus[] = ["NEEDS_LIVE_TEST", "POSTPONED", "WONT_DO"];
142
155
  const FORWARD_CHAIN: FeedbackStatus[] = ["OPEN", "READY", "IN_PROGRESS", "IN_EVALUATION", "DONE"];
143
156
 
144
157
  /** The next status along the chain, or null at the end (or off it, as `WONT_DO`
145
- * is). What a swipe-to-advance gesture commits. */
158
+ * is). What a swipe-to-advance gesture commits. `REWORK` (0.40) is not in the chain —
159
+ * "advance" from READY still goes to IN_PROGRESS — but it leads there too: a reworked
160
+ * row is READY's twin. */
146
161
  export function nextFeedbackStatus(current: FeedbackStatus): FeedbackStatus | null {
162
+ if (current === "REWORK") return "IN_PROGRESS";
147
163
  const at = FORWARD_CHAIN.indexOf(current);
148
164
  return at !== -1 && at < FORWARD_CHAIN.length - 1 ? FORWARD_CHAIN[at + 1] : null;
149
165
  }
@@ -152,7 +168,7 @@ export function nextFeedbackStatus(current: FeedbackStatus): FeedbackStatus | nu
152
168
  * The steps that make sense from where a row currently stands.
153
169
  *
154
170
  * For the compact control in a table cell: it is a glanceable triage affordance,
155
- * and a row of eight icons in a cell is noise. One step forward, one step back
171
+ * and a row of nine icons in a cell is noise. One step forward, one step back
156
172
  * (so an item can be sent back for rework), and any of the three off-chain
157
173
  * verdicts — which is what makes those usable as verdicts at all.
158
174
  *
@@ -165,7 +181,12 @@ export function nextFeedbackStatus(current: FeedbackStatus): FeedbackStatus | nu
165
181
  export function visibleFeedbackStatuses(current: FeedbackStatus): FeedbackStatus[] {
166
182
  const set = new Set<FeedbackStatus>([current]);
167
183
  const at = FORWARD_CHAIN.indexOf(current);
168
- if (at !== -1) {
184
+ if (current === "REWORK") {
185
+ // READY's twin (0.40, §10.2 decision 4): the step that picks the work up, and the
186
+ // verdicts a released row can still get.
187
+ set.add("IN_PROGRESS");
188
+ for (const off of OFF_CHAIN) set.add(off);
189
+ } else if (at !== -1) {
169
190
  if (at < FORWARD_CHAIN.length - 1) set.add(FORWARD_CHAIN[at + 1]);
170
191
  if (at > 0) set.add(FORWARD_CHAIN[at - 1]);
171
192
  for (const off of OFF_CHAIN) set.add(off);