@eifi1/ui-kit 0.39.0 → 0.40.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 (148) hide show
  1. package/README.md +8 -7
  2. package/dist/billing/billing-overview.d.ts +12 -2
  3. package/dist/billing/pay-page.js +1 -1
  4. package/dist/billing/pay-page.js.map +1 -1
  5. package/dist/bin/eifi1-pay-page.mjs +1 -1
  6. package/dist/components/autocomplete.js +5 -1
  7. package/dist/components/autocomplete.js.map +1 -1
  8. package/dist/components/combobox-core.d.ts +14 -1
  9. package/dist/components/combobox-core.js +4 -0
  10. package/dist/components/combobox-core.js.map +1 -1
  11. package/dist/components/combobox.js +9 -3
  12. package/dist/components/combobox.js.map +1 -1
  13. package/dist/components/data-table-labels.d.ts +5 -0
  14. package/dist/components/data-table-labels.js +2 -0
  15. package/dist/components/data-table-labels.js.map +1 -1
  16. package/dist/components/data-table-mobile.d.ts +36 -1
  17. package/dist/components/data-table-mobile.js +162 -15
  18. package/dist/components/data-table-mobile.js.map +1 -1
  19. package/dist/components/data-table.d.ts +39 -12
  20. package/dist/components/data-table.js +74 -13
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/inline-entity-combobox.js +10 -3
  23. package/dist/components/inline-entity-combobox.js.map +1 -1
  24. package/dist/components/picker-sheet.d.ts +18 -1
  25. package/dist/components/picker-sheet.js +20 -2
  26. package/dist/components/picker-sheet.js.map +1 -1
  27. package/dist/components/tabs.d.ts +26 -2
  28. package/dist/components/tabs.js +41 -2
  29. package/dist/components/tabs.js.map +1 -1
  30. package/dist/components/translation-review-parts.d.ts +2 -1
  31. package/dist/components/translation-review-parts.js +1 -1
  32. package/dist/components/translation-review-parts.js.map +1 -1
  33. package/dist/components/ui-labels.js +3 -1
  34. package/dist/components/ui-labels.js.map +1 -1
  35. package/dist/eslint/index.js +1 -1
  36. package/dist/feedback/feedback-comment-thread.d.ts +8 -1
  37. package/dist/feedback/feedback-comment-thread.js +26 -1
  38. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  39. package/dist/feedback/feedback-labels.d.ts +2 -1
  40. package/dist/feedback/feedback-labels.js +1 -0
  41. package/dist/feedback/feedback-labels.js.map +1 -1
  42. package/dist/feedback/feedback-menu-labels.js +3 -1
  43. package/dist/feedback/feedback-menu-labels.js.map +1 -1
  44. package/dist/feedback/feedback-menu.d.ts +31 -24
  45. package/dist/feedback/feedback-menu.js +16 -7
  46. package/dist/feedback/feedback-menu.js.map +1 -1
  47. package/dist/feedback/feedback-public-labels.d.ts +3 -2
  48. package/dist/feedback/feedback-public-labels.js +1 -1
  49. package/dist/feedback/feedback-public-labels.js.map +1 -1
  50. package/dist/feedback/feedback-public-list.js +1 -0
  51. package/dist/feedback/feedback-public-list.js.map +1 -1
  52. package/dist/feedback/feedback-public-types.d.ts +16 -3
  53. package/dist/feedback/feedback-record.d.ts +20 -3
  54. package/dist/feedback/feedback-record.js +15 -0
  55. package/dist/feedback/feedback-record.js.map +1 -1
  56. package/dist/feedback/feedback-replies.d.ts +13 -2
  57. package/dist/feedback/feedback-replies.js +67 -33
  58. package/dist/feedback/feedback-replies.js.map +1 -1
  59. package/dist/feedback/feedback-rework-entry-chip.d.ts +9 -0
  60. package/dist/feedback/feedback-rework-entry-chip.js +13 -0
  61. package/dist/feedback/feedback-rework-entry-chip.js.map +1 -0
  62. package/dist/feedback/feedback-row-detail.d.ts +31 -8
  63. package/dist/feedback/feedback-row-detail.js +31 -7
  64. package/dist/feedback/feedback-row-detail.js.map +1 -1
  65. package/dist/feedback/feedback-table-labels.js +1 -1
  66. package/dist/feedback/feedback-table-labels.js.map +1 -1
  67. package/dist/feedback/feedback-table.d.ts +5 -2
  68. package/dist/feedback/feedback-table.js +2 -2
  69. package/dist/feedback/feedback-table.js.map +1 -1
  70. package/dist/feedback/feedback-thread.js +3 -1
  71. package/dist/feedback/feedback-thread.js.map +1 -1
  72. package/dist/feedback/feedback-vocabulary.d.ts +9 -6
  73. package/dist/feedback/feedback-vocabulary.js +16 -1
  74. package/dist/feedback/feedback-vocabulary.js.map +1 -1
  75. package/dist/hooks/use-long-press.d.ts +30 -0
  76. package/dist/hooks/use-long-press.js +77 -0
  77. package/dist/hooks/use-long-press.js.map +1 -0
  78. package/dist/hooks/use-overlay-history.js +37 -3
  79. package/dist/hooks/use-overlay-history.js.map +1 -1
  80. package/dist/i18n/german.js +13 -5
  81. package/dist/i18n/german.js.map +1 -1
  82. package/dist/i18n/kit-labels.d.ts +3 -0
  83. package/dist/i18n/kit-labels.js.map +1 -1
  84. package/dist/i18n/locales/en.js +12 -5
  85. package/dist/i18n/locales/en.js.map +2 -2
  86. package/dist/i18n/locales/es.js +13 -5
  87. package/dist/i18n/locales/es.js.map +1 -1
  88. package/dist/i18n/locales/fr.js +13 -5
  89. package/dist/i18n/locales/fr.js.map +1 -1
  90. package/dist/i18n/locales/hu.js +13 -5
  91. package/dist/i18n/locales/hu.js.map +1 -1
  92. package/dist/i18n/locales/it.js +13 -5
  93. package/dist/i18n/locales/it.js.map +1 -1
  94. package/dist/i18n/locales/zh.js +13 -5
  95. package/dist/i18n/locales/zh.js.map +1 -1
  96. package/dist/i18n/shared-labels.js +1 -1
  97. package/dist/i18n/shared-labels.js.map +1 -1
  98. package/dist/pay/pay.js +1 -1
  99. package/dist/shell/app-shell.js +9 -0
  100. package/dist/shell/app-shell.js.map +1 -1
  101. package/dist/shell/fill-height.d.ts +38 -0
  102. package/dist/shell/fill-height.js +12 -0
  103. package/dist/shell/fill-height.js.map +1 -0
  104. package/dist/shell.d.ts +2 -0
  105. package/dist/shell.js +3 -0
  106. package/dist/shell.js.map +1 -1
  107. package/package.json +1 -1
  108. package/src/billing/billing-overview.ts +12 -2
  109. package/src/billing/pay-page.ts +1 -1
  110. package/src/components/autocomplete.tsx +5 -1
  111. package/src/components/combobox-core.tsx +20 -1
  112. package/src/components/combobox.tsx +9 -3
  113. package/src/components/data-table-labels.ts +7 -0
  114. package/src/components/data-table-mobile.tsx +193 -9
  115. package/src/components/data-table.tsx +141 -24
  116. package/src/components/inline-entity-combobox.tsx +12 -4
  117. package/src/components/picker-sheet.tsx +38 -1
  118. package/src/components/tabs.tsx +73 -3
  119. package/src/components/translation-review-parts.tsx +3 -2
  120. package/src/components/ui-labels.ts +2 -0
  121. package/src/feedback/feedback-comment-thread.tsx +39 -1
  122. package/src/feedback/feedback-labels.ts +3 -1
  123. package/src/feedback/feedback-menu-labels.ts +2 -0
  124. package/src/feedback/feedback-menu.tsx +48 -30
  125. package/src/feedback/feedback-public-labels.ts +4 -3
  126. package/src/feedback/feedback-public-list.tsx +4 -1
  127. package/src/feedback/feedback-public-types.ts +17 -3
  128. package/src/feedback/feedback-record.ts +34 -3
  129. package/src/feedback/feedback-replies.tsx +100 -31
  130. package/src/feedback/feedback-rework-entry-chip.tsx +18 -0
  131. package/src/feedback/feedback-row-detail.tsx +65 -12
  132. package/src/feedback/feedback-table-labels.ts +1 -1
  133. package/src/feedback/feedback-table.tsx +7 -4
  134. package/src/feedback/feedback-thread.tsx +5 -1
  135. package/src/feedback/feedback-vocabulary.ts +27 -6
  136. package/src/hooks/use-long-press.ts +126 -0
  137. package/src/hooks/use-overlay-history.ts +74 -3
  138. package/src/i18n/german.ts +10 -2
  139. package/src/i18n/kit-labels.tsx +3 -0
  140. package/src/i18n/locales/es.ts +10 -2
  141. package/src/i18n/locales/fr.ts +10 -2
  142. package/src/i18n/locales/hu.ts +10 -2
  143. package/src/i18n/locales/it.ts +10 -2
  144. package/src/i18n/locales/zh.ts +10 -2
  145. package/src/i18n/shared-labels.ts +1 -1
  146. package/src/shell/app-shell.tsx +9 -0
  147. package/src/shell/fill-height.tsx +45 -0
  148. package/src/shell.ts +3 -0
@@ -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);
@@ -0,0 +1,126 @@
1
+ import { useCallback, useEffect, useRef, type PointerEvent as ReactPointerEvent } from "react";
2
+
3
+ // The press-and-hold that puts DataTable's phone cards into selection mode (0.40,
4
+ // keksdose live #421). Taken over from keksdose's register list
5
+ // (frontend/src/shared/hooks/use-long-press.ts, its feedback #153) so the kit's cards
6
+ // and kk's register answer the same gesture the same way: 500 ms, an 8 px dead zone,
7
+ // cancelled by a scroll, the trailing click swallowed.
8
+
9
+ const LONG_PRESS_MS = 500;
10
+ /** The same 8 px dead zone `useRowSwipe` locks its axis at: the press is dead by the
11
+ * time the swipe hook takes pointer capture on its (ancestor) layer. */
12
+ const SLOP_PX = 8;
13
+
14
+ const movedBeyondSlop = (from: { x: number; y: number }, x: number, y: number) =>
15
+ Math.hypot(x - from.x, y - from.y) > SLOP_PX;
16
+
17
+ export interface LongPressResult {
18
+ handlers: {
19
+ onPointerDown: (e: ReactPointerEvent) => void;
20
+ onPointerMove: (e: ReactPointerEvent) => void;
21
+ onContextMenu: (e: { preventDefault: () => void }) => void;
22
+ };
23
+ /** True exactly once after a long press fired: call it from an `onClickCapture` to
24
+ * swallow the click the browser still sends when the finger lifts. */
25
+ consumeClick: () => boolean;
26
+ }
27
+
28
+ /**
29
+ * A press-and-hold on a touch row that may ALSO be swipeable.
30
+ *
31
+ * - The cancel listeners live on `window`: `useRowSwipe` captures the pointer on an
32
+ * ancestor once a drag resolves to the x axis, after which the pressed element's
33
+ * own pointer handlers stop firing, and a timer cancelled only there would fire
34
+ * mid-swipe.
35
+ * - A scroll cancels too (`touch-action: pan-y` hands vertical panning to the
36
+ * browser, which sends `pointercancel`; the capture-phase `scroll` is the backstop).
37
+ * - `contextmenu` is suppressed while enabled — Android fires it at ~500 ms. Pair it
38
+ * with `select-none [-webkit-touch-callout:none]` on the element: iOS's callout
39
+ * only yields to that CSS.
40
+ */
41
+ export function useLongPress(
42
+ onLongPress: () => void,
43
+ { enabled = true, delay = LONG_PRESS_MS }: { enabled?: boolean; delay?: number } = {},
44
+ ): LongPressResult {
45
+ const timer = useRef<number | null>(null);
46
+ const start = useRef({ x: 0, y: 0 });
47
+ const fired = useRef(false);
48
+ const teardown = useRef<(() => void) | null>(null);
49
+ const cbRef = useRef(onLongPress);
50
+ useEffect(() => {
51
+ cbRef.current = onLongPress;
52
+ });
53
+
54
+ const cancel = useCallback(() => {
55
+ if (timer.current !== null) {
56
+ window.clearTimeout(timer.current);
57
+ timer.current = null;
58
+ }
59
+ teardown.current?.();
60
+ teardown.current = null;
61
+ }, []);
62
+
63
+ const onPointerDown = useCallback(
64
+ (e: ReactPointerEvent) => {
65
+ // Cleared above every early return: a press whose trailing click never arrives
66
+ // (the finger lifted over another row) would otherwise leave the latch armed,
67
+ // and it would eat the next real tap once the card has switched modes.
68
+ fired.current = false;
69
+ if (!enabled) return;
70
+ if (e.pointerType === "mouse" && e.button !== 0) return;
71
+ cancel();
72
+ start.current = { x: e.clientX, y: e.clientY };
73
+
74
+ const onEnd = () => cancel();
75
+ const onMove = (ev: PointerEvent) => {
76
+ if (movedBeyondSlop(start.current, ev.clientX, ev.clientY)) cancel();
77
+ };
78
+ window.addEventListener("pointerup", onEnd);
79
+ window.addEventListener("pointercancel", onEnd);
80
+ window.addEventListener("pointermove", onMove);
81
+ window.addEventListener("scroll", onEnd, { capture: true, passive: true });
82
+ teardown.current = () => {
83
+ window.removeEventListener("pointerup", onEnd);
84
+ window.removeEventListener("pointercancel", onEnd);
85
+ window.removeEventListener("pointermove", onMove);
86
+ window.removeEventListener("scroll", onEnd, { capture: true });
87
+ };
88
+
89
+ timer.current = window.setTimeout(() => {
90
+ cancel();
91
+ fired.current = true;
92
+ // The swipe stages' haptic vocabulary; absent on iOS Safari.
93
+ navigator.vibrate?.(20);
94
+ cbRef.current();
95
+ }, delay);
96
+ },
97
+ [cancel, delay, enabled],
98
+ );
99
+
100
+ // Also on the element: it still cancels if an ancestor stops the native event
101
+ // before it reaches `window`.
102
+ const onPointerMove = useCallback(
103
+ (e: ReactPointerEvent) => {
104
+ if (timer.current === null) return;
105
+ if (movedBeyondSlop(start.current, e.clientX, e.clientY)) cancel();
106
+ },
107
+ [cancel],
108
+ );
109
+
110
+ const onContextMenu = useCallback(
111
+ (e: { preventDefault: () => void }) => {
112
+ if (enabled) e.preventDefault();
113
+ },
114
+ [enabled],
115
+ );
116
+
117
+ const consumeClick = useCallback(() => {
118
+ if (!fired.current) return false;
119
+ fired.current = false;
120
+ return true;
121
+ }, []);
122
+
123
+ useEffect(() => cancel, [cancel]);
124
+
125
+ return { handlers: { onPointerDown, onPointerMove, onContextMenu }, consumeClick };
126
+ }
@@ -48,6 +48,16 @@ import { useEffect, useRef } from "react";
48
48
  * {@link noteAddress}: an address the overlay LIVED THROUGH (a filter the table wrote
49
49
  * while its sheet was up) is still unwound, because the user has acted since.
50
50
  *
51
+ * ## Not undoing the owner's replaces either
52
+ *
53
+ * An address the overlay lived through is unwound as an ENTRY, never as an address
54
+ * (keksdose live #412). A report that writes each tick of its payee sheet into the query
55
+ * with `setSearchParams(…, { replace: true })` rewrites the overlay's own entry, and the
56
+ * page entry beneath still carries the address from before the sheet opened — so the
57
+ * traversal that took our entry away also took every tick with it. See {@link carryOnto}:
58
+ * whichever way the entry goes (the unwind after a press, or a Back press), the address it
59
+ * last showed is carried onto the entry it lands on, and the router is told.
60
+ *
51
61
  * ## The traversal is deferred
52
62
  *
53
63
  * And it is *scheduled* rather than issued, because `history.go()` lands a task
@@ -99,6 +109,10 @@ interface PushedEntry {
99
109
  key: string | undefined;
100
110
  /** {@link routeOf} the address the marker was placed at. */
101
111
  route: string;
112
+ /** The entry's address as last OBSERVED, after the user's last gesture had run its
113
+ * handlers — what a Back press must carry down (see {@link carryOnto}). `href` is
114
+ * the snapshot BEFORE the gesture, which the owner's-close check needs instead. */
115
+ seen: string;
102
116
  }
103
117
  const pushed: PushedEntry[] = [];
104
118
 
@@ -197,10 +211,41 @@ function runUnwind(): void {
197
211
  if (taggedSentinel() === null) buryUnder(job.entries);
198
212
  return;
199
213
  }
214
+ const bottom = job.entries[0];
215
+ carry = { href: job.href, idx: bottom.idx, key: bottom.key };
200
216
  pendingProgrammatic += 1;
201
217
  window.history.go(-job.entries.length);
202
218
  }
203
219
 
220
+ /** The address an unwind in flight must carry onto the entry it lands on. */
221
+ let carry: { href: string; idx: number | undefined; key: string | undefined } | null = null;
222
+
223
+ /**
224
+ * Put `href` — the address the overlay's entry last showed — onto the entry a traversal
225
+ * has just landed on, and tell the router (keksdose live #412).
226
+ *
227
+ * Only the query and the hash can differ: an address naming another page is a
228
+ * navigation, never carried. And only onto the page entry the overlay was opened over,
229
+ * which our push copied the router's `{idx, key}` from — a traversal that came to rest
230
+ * anywhere else is not ours to rewrite.
231
+ *
232
+ * The router heard the traversal land on the OLD address in this same event, so a
233
+ * `replaceState` alone would leave it showing the old query under the new one. A
234
+ * synthetic `popstate` makes it read the address again; both reads happen in one event
235
+ * dispatch, so React commits only the second. Ours absorbs it as programmatic.
236
+ */
237
+ function carryOnto(href: string, mark: { idx: number | undefined; key: string | undefined }): void {
238
+ const here = currentHref();
239
+ if (here === href || routeOf(here) !== routeOf(href)) return;
240
+ const now = routerMark();
241
+ if (mark.idx !== undefined && now.idx !== undefined && mark.idx !== now.idx) return;
242
+ if (mark.key !== undefined && now.key !== undefined && mark.key !== now.key) return;
243
+ const state: unknown = window.history.state;
244
+ window.history.replaceState(state, "", href);
245
+ pendingProgrammatic += 1;
246
+ window.dispatchEvent(new PopStateEvent("popstate", { state }));
247
+ }
248
+
204
249
  function currentHref(): string {
205
250
  return typeof window === "undefined" ? "" : window.location.href;
206
251
  }
@@ -416,7 +461,25 @@ function noteAddress(): void {
416
461
  if (!top || top.dead || !stack.some((e) => e.id === top.id)) return;
417
462
  const tag = taggedSentinel();
418
463
  // Read without `currentSentinel`'s repair: an observation must not write history.
419
- if (tag === top.id || (tag === null && standing === top.id && isStillOn(top))) top.href = currentHref();
464
+ if (tag === top.id || (tag === null && standing === top.id && isStillOn(top))) {
465
+ top.href = currentHref();
466
+ top.seen = top.href;
467
+ }
468
+ }
469
+
470
+ /** After the user's gesture has run its handlers: the address its entry now shows —
471
+ * what a Back press must carry down (keksdose live #412). Again a little later, for a
472
+ * handler that writes the address a task after the event. */
473
+ function noteSeen(): void {
474
+ const top = pushed[pushed.length - 1];
475
+ if (!top || top.dead || !stack.some((e) => e.id === top.id)) return;
476
+ const tag = taggedSentinel();
477
+ if (tag === top.id || (tag === null && standing === top.id && isStillOn(top))) top.seen = currentHref();
478
+ }
479
+
480
+ function noteSeenNowAndLater(): void {
481
+ noteSeen();
482
+ setTimeout(noteSeen, 0);
420
483
  }
421
484
 
422
485
  function handlePop() {
@@ -428,6 +491,9 @@ function handlePop() {
428
491
  lastSeen = routerMark();
429
492
  if (pendingProgrammatic > 0) {
430
493
  pendingProgrammatic -= 1;
494
+ const job = carry;
495
+ carry = null;
496
+ if (job) carryOnto(job.href, job);
431
497
  return;
432
498
  }
433
499
  closeOnPop();
@@ -451,7 +517,10 @@ function closeOnPop() {
451
517
  // The browser consumed this entry, so the overlay's own cleanup has nothing left
452
518
  // to unwind and nothing to record as owed.
453
519
  consumed.add(top.id);
520
+ const record = pushed.find((p) => p.id === top.id);
454
521
  forgetPushed(top.id);
522
+ // What the page wrote into the address while we were up stays (#412).
523
+ if (record && !record.dead) carryOnto(record.seen, record);
455
524
  top.close();
456
525
  }
457
526
 
@@ -460,6 +529,7 @@ function ensureListening() {
460
529
  window.addEventListener("popstate", handlePop);
461
530
  window.addEventListener("pointerdown", noteAddress, true);
462
531
  window.addEventListener("keydown", noteAddress, true);
532
+ for (const type of ["click", "keyup", "change", "input"]) window.addEventListener(type, noteSeenNowAndLater);
463
533
  listening = true;
464
534
  }
465
535
 
@@ -501,7 +571,7 @@ export function useOverlayHistory(open: boolean, onClose: () => void): void {
501
571
  // The abandoned ones beneath come back owed; ours takes the top slot.
502
572
  for (const owed of inherited.slice(0, -1)) pushed.push({ ...owed, dead: true });
503
573
  const was = inherited[inherited.length - 1];
504
- pushed.push({ id, href: currentHref(), dead: false, idx: was.idx, key: was.key, route: was.route });
574
+ pushed.push({ id, href: currentHref(), seen: currentHref(), dead: false, idx: was.idx, key: was.key, route: was.route });
505
575
  standing = id;
506
576
  } else {
507
577
  // A push DESTROYS every entry ahead of the one we are on, so anything we
@@ -516,7 +586,8 @@ export function useOverlayHistory(open: boolean, onClose: () => void): void {
516
586
  // the URL is unchanged, so the router treats popping this as a no-op re-render.
517
587
  window.history.pushState(marked, "");
518
588
  const page = routerMark();
519
- pushed.push({ id, href: currentHref(), dead: false, idx: page.idx, key: page.key, route: routeOf(currentHref()) });
589
+ const at = currentHref();
590
+ pushed.push({ id, href: at, seen: at, dead: false, idx: page.idx, key: page.key, route: routeOf(at) });
520
591
  standing = id;
521
592
  }
522
593
  return () => {