@lovett/ui 0.1.0 → 0.2.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.
package/src/sortable.tsx CHANGED
@@ -9,7 +9,7 @@
9
9
  * - <DragHandle> is a Lucide-grip handle pre-wired to the listeners
10
10
  */
11
11
 
12
- import { useRef, useState } from 'react'
12
+ import { useEffect, useRef, useState } from 'react'
13
13
  import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
14
14
  import {
15
15
  DndContext,
@@ -17,11 +17,13 @@ import {
17
17
  MeasuringStrategy,
18
18
  closestCenter,
19
19
  closestCorners,
20
+ pointerWithin,
20
21
  useDroppable,
21
22
  KeyboardSensor,
22
23
  PointerSensor,
23
24
  useSensor,
24
25
  useSensors,
26
+ type CollisionDetection,
25
27
  type DragEndEvent,
26
28
  type DragOverEvent,
27
29
  type DragStartEvent,
@@ -203,15 +205,63 @@ export function DragHandle({
203
205
  * as a click and not a 1px drag
204
206
  *
205
207
  * Added:
206
- * - `closestCorners` rather than `closestCenter`. Center-based collision
207
- * mis-resolves when the dragged card is taller than the row it is over,
208
- * which is the normal case on a board with variable-height cards.
208
+ * - pointer-first collision (`pointerFirstCollision` below), which resolves
209
+ * the drop target from the CURSOR and falls back to `closestCorners` when
210
+ * there is no cursor. That fallback is not a nicety: it is the keyboard
211
+ * drag's only path.
209
212
  * - `MeasuringStrategy.Always`, because a container's height changes as
210
213
  * cards enter and leave it and a cached rect drops the item in the wrong
211
214
  * lane.
212
215
  * - screen-reader announcements naming the destination container, not its id.
213
216
  * ========================================================================== */
214
217
 
218
+ /**
219
+ * Pointer first, `closestCorners` second — dnd-kit's documented shape for a
220
+ * multi-container board (the pointer, then a rect-based fallback; which rect
221
+ * strategy is the choice made at the bottom of this note), and the answer to
222
+ * two defects rather than one.
223
+ *
224
+ * The history is a ladder, and each rung is still true:
225
+ *
226
+ * `closestCenter` measured the dragged card's CENTRE against each droppable
227
+ * and mis-resolved whenever the card was taller than the row it was over,
228
+ * which is the normal case on a board with variable-height cards.
229
+ *
230
+ * `closestCorners` measured the card's four CORNERS instead. Better, and
231
+ * still wrong in the same family: it measures the CARD. A tall card's
232
+ * corners stay nearer its source lane until most of its body has crossed the
233
+ * boundary, so the user has to drag the whole card into the destination
234
+ * rather than point at it. Reported as "I have to literally drag the full
235
+ * entire card over the lane so it catches it".
236
+ *
237
+ * `pointerWithin` measures the POINTER, so a card is caught by the lane the
238
+ * cursor is over regardless of how tall the card is. Card height stops being
239
+ * an input at all, which is why this does not reintroduce the defect
240
+ * `closestCorners` was adopted to fix — it retires the whole measurement.
241
+ *
242
+ * `pointerWithin` returns EVERY droppable containing the pointer, ordered by
243
+ * the pointer's mean distance to that rect's four corners — so the tightest
244
+ * box around the cursor wins. A pointer inside a card yields [card, column]
245
+ * and `over` resolves to the card, which is what gives a cross-column drop its
246
+ * insertion INDEX. A pointer in the column's empty space below the last card
247
+ * yields just the column, and the item appends. Both are wanted.
248
+ *
249
+ * It returns nothing at all when `pointerCoordinates` is null, and dnd-kit
250
+ * derives those from the activator event's `clientX`/`clientY`. A KeyboardEvent
251
+ * has neither, so EVERY keyboard drag takes the fallback — a pointer-only
252
+ * strategy would leave `over` permanently null and break cross-column keyboard
253
+ * movement silently, gates all green. The fallback is therefore `closestCorners`
254
+ * and not `rectIntersection`: keyboard drags keep the exact resolution they
255
+ * have been tested against, and the pointer-outside-every-droppable case (the
256
+ * board's margins and gutters) keeps resolving to the nearest lane rather than
257
+ * to nothing. `rectIntersection` would answer that case by area of overlap —
258
+ * "most of the card", the defect above, back in the gutters.
259
+ */
260
+ export const pointerFirstCollision: CollisionDetection = (args) => {
261
+ const byPointer = pointerWithin(args)
262
+ return byPointer.length > 0 ? byPointer : closestCorners(args)
263
+ }
264
+
215
265
  /** containerId -> the ids it holds, in order. */
216
266
  export type SortableContainers = Readonly<Record<string, readonly string[]>>
217
267
 
@@ -228,11 +278,30 @@ export interface MultiSortableListProps {
228
278
  /** Container ids in board order. Also the set treated as drop targets. */
229
279
  containerOrder: readonly string[]
230
280
  /**
231
- * Commit the move. Called synchronously from the drop handler, so a
232
- * `setState` here batches with the primitive's own reset and the board does
233
- * not flash the pre-drag arrangement for a frame.
281
+ * Commit the move. Called from the drop handler with the arrangement the
282
+ * board is already showing, so the consumer's job is to make `containers`
283
+ * agree — it is not being asked to produce a frame.
284
+ *
285
+ * It used to say "called synchronously ... so a `setState` here batches with
286
+ * the primitive's own reset". True, and it described the wrong consumer. A
287
+ * consumer that patches its cache synchronously never saw the ghost; one
288
+ * that patches from a mutation's `onMutate` — a microtask at best, an
289
+ * `await cancelQueries` at worst — is not in that batch, and got a frame of
290
+ * the pre-drag arrangement. See the preview note below for what replaced it.
234
291
  */
235
292
  onMove: (move: MultiSortableMove, next: SortableContainers) => void
293
+ /**
294
+ * Override the drop-target strategy. Defaults to `pointerFirstCollision`,
295
+ * whose reasoning is in its own docblock.
296
+ *
297
+ * A prop because this component has now changed collision strategy twice
298
+ * (`closestCenter` → `closestCorners` → pointer-first) and both times a
299
+ * consumer had to wait for a release of this package to get the fix. The
300
+ * escape hatch costs one optional prop; a third such wait costs a release.
301
+ * Overriding it is opting out of the reasoning above, including the keyboard
302
+ * fallback — a bare `pointerWithin` here disables keyboard dragging.
303
+ */
304
+ collisionDetection?: CollisionDetection | undefined
236
305
  /**
237
306
  * What follows the pointer. Without this the card appears to vanish.
238
307
  *
@@ -251,9 +320,11 @@ export interface MultiSortableListProps {
251
320
  * and the container the drag is currently resolved INTO.
252
321
  *
253
322
  * The second argument exists because `useDroppable().isOver` cannot answer
254
- * it on a board: `closestCorners` resolves `over` to a sortable ITEM
255
- * whenever a column holds one, so the column's own droppable never wins and
256
- * its `isOver` measured `false` on every column during real pointer AND
323
+ * it on a board: every collision strategy here resolves `over` to a sortable
324
+ * ITEM whenever a column holds one `closestCorners` by distance, and
325
+ * `pointerWithin` because a pointer inside a card is inside its column too
326
+ * and the card's centre is nearer — so the column's own droppable never wins
327
+ * and its `isOver` measured `false` on every column during real pointer AND
257
328
  * keyboard drags. The container has to be derived from the resolved `over`
258
329
  * id — the same `containerOf` the announcements already use.
259
330
  */
@@ -263,6 +334,66 @@ export interface MultiSortableListProps {
263
334
  ) => ReactNode
264
335
  }
265
336
 
337
+ /**
338
+ * The arrangement the board renders INSTEAD of `containers`, and the thing
339
+ * `containers` has to catch up to.
340
+ *
341
+ * Defect: dropping a card in another lane showed it snap back to its origin
342
+ * and then forward again — "like an invisible or ghost presence". The old
343
+ * `handleDragEnd` cleared the preview and then called `onMove`. Clearing means
344
+ * rendering from `containers`, and `containers` is the consumer's cache, which
345
+ * at that instant still holds the PRE-DRAG arrangement. One frame in the old
346
+ * lane, then the consumer's patch lands and moves it again. That frame is the
347
+ * ghost.
348
+ *
349
+ * Reordering the two lines does not fix it. Both are in one discrete event
350
+ * handler, React flushes that as one batch, and an asynchronous consumer patch
351
+ * is not in the batch either way — the render it produces still has the
352
+ * preview cleared and `containers` stale. The fix is not to clear the preview
353
+ * at the drop at all: hold it, and let the next `containers` supersede it.
354
+ *
355
+ * So the preview is held against the exact `containers` value it was committed
356
+ * over. Any new one means the consumer has spoken — a patch, a rollback, a
357
+ * refetch, all of them authoritative — and the preview is dropped in the same
358
+ * render, not a frame later.
359
+ *
360
+ * WHEN THE CONSUMER NEVER PATCHES. A held preview is a claim that a move
361
+ * landed. A consumer that rejects the move, fails its write without rolling
362
+ * back, or simply ignores `onMove` never produces a new `containers`, and an
363
+ * unbounded hold would leave the board asserting a move that did not happen —
364
+ * silently, until something unrelated invalidated the query. So the hold is
365
+ * bounded: after `PREVIEW_SETTLE_MS` the preview expires and `containers`
366
+ * wins, which renders as the card returning to its lane. That is the correct
367
+ * picture of a rejected move, and the only picture available, because the
368
+ * primitive has no other way to be told.
369
+ *
370
+ * The cost is paid by a consumer that commits WITHOUT an optimistic patch and
371
+ * waits for a server round trip: past the deadline the card returns and then
372
+ * moves again when the response lands. That is the ghost, delayed — and it is
373
+ * honest, because a board that does not yet know the move landed should not be
374
+ * drawing it. The fix for that consumer is an optimistic patch, which is the
375
+ * pattern this component is shaped around.
376
+ */
377
+ interface DragPreview {
378
+ arrangement: SortableContainers
379
+ /** The `containers` identity this was committed over. A new one supersedes. */
380
+ basis: SortableContainers
381
+ /** False while the drag is in flight — a live preview is never superseded. */
382
+ settled: boolean
383
+ }
384
+
385
+ /**
386
+ * How long a settled preview outlives the drop before `containers` wins.
387
+ *
388
+ * Floor: dnd-kit's drop animation is 250ms, and the preview has to still be
389
+ * under the overlay when it lands or the overlay animates onto an empty slot.
390
+ * Ceiling: a rejected move has to read as rejected rather than as accepted,
391
+ * and half a second is about where a snap-back stops looking like a response.
392
+ * The healthy path never reaches either — an optimistic patch supersedes this
393
+ * in a frame or two, and the timer is cleared.
394
+ */
395
+ export const PREVIEW_SETTLE_MS = 500
396
+
266
397
  function moveBetween(
267
398
  arrangement: SortableContainers,
268
399
  itemId: string,
@@ -284,6 +415,7 @@ export function MultiSortableList({
284
415
  containers,
285
416
  containerOrder,
286
417
  onMove,
418
+ collisionDetection = pointerFirstCollision,
287
419
  renderOverlay,
288
420
  labelForItem,
289
421
  labelForContainer,
@@ -296,11 +428,59 @@ export function MultiSortableList({
296
428
 
297
429
  const [activeId, setActiveId] = useState<string | null>(null)
298
430
  const [overContainerId, setOverContainerId] = useState<string | null>(null)
299
- const [preview, setPreview] = useState<SortableContainers | null>(null)
431
+ const [preview, setPreview] = useState<DragPreview | null>(null)
300
432
  // The arrangement the drag started from, so a cancel restores it exactly.
301
433
  const originRef = useRef<SortableContainers | null>(null)
434
+ const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
435
+
436
+ useEffect(
437
+ () => () => {
438
+ if (settleTimer.current !== null) clearTimeout(settleTimer.current)
439
+ },
440
+ [],
441
+ )
442
+
443
+ const stopSettleTimer = () => {
444
+ if (settleTimer.current !== null) {
445
+ clearTimeout(settleTimer.current)
446
+ settleTimer.current = null
447
+ }
448
+ }
449
+
450
+ // Derived, not stored: on the render where `containers` finally carries the
451
+ // move, the board is already right — no second commit, no frame in between.
452
+ const superseded = preview !== null && preview.settled && preview.basis !== containers
453
+ const arrangement = preview !== null && !superseded ? preview.arrangement : containers
302
454
 
303
- const arrangement = preview ?? containers
455
+ // And a supersede is FINAL. `containers` can return to the exact object the
456
+ // preview was held against — a rolled-back optimistic write restores the
457
+ // previous reference, not a copy of it — and a preview still sitting in state
458
+ // would then stop looking superseded and draw the move a second time, after
459
+ // the consumer had undone it. Dropping it here rather than in an effect keeps
460
+ // that to zero frames: React re-runs this render with the state already gone.
461
+ // The pending timer is left to fire into a null preview, where it is a no-op.
462
+ if (superseded) setPreview(null)
463
+
464
+ /**
465
+ * Show `next` until `containers` says otherwise. Called at every exit from a
466
+ * drag — committed, no-op, dropped outside, cancelled — so there is exactly
467
+ * one place where the board stops previewing.
468
+ *
469
+ * `next === containers` covers the ordinary cancel: the board is already
470
+ * rendering the truth, so there is nothing to hold and no timer to run.
471
+ */
472
+ const holdPreview = (next: SortableContainers) => {
473
+ stopSettleTimer()
474
+ if (next === containers) {
475
+ setPreview(null)
476
+ return
477
+ }
478
+ setPreview({ arrangement: next, basis: containers, settled: true })
479
+ settleTimer.current = setTimeout(() => {
480
+ settleTimer.current = null
481
+ setPreview(null)
482
+ }, PREVIEW_SETTLE_MS)
483
+ }
304
484
 
305
485
  const containerOf = (id: string): string | null => {
306
486
  if (containerOrder.includes(id)) return id
@@ -316,8 +496,14 @@ export function MultiSortableList({
316
496
  const handleDragStart = (event: DragStartEvent) => {
317
497
  const id = String(event.active.id)
318
498
  setActiveId(id)
319
- originRef.current = containers
320
- setPreview(containers)
499
+ stopSettleTimer()
500
+ // `arrangement`, not `containers`. A previous drop may still be held while
501
+ // its consumer patch is in flight, and rebasing on `containers` here would
502
+ // yank that card back to its old lane the moment the user picks up the
503
+ // next one — the ghost again, one drag later. The drag starts from what is
504
+ // on screen, which is also what the user is dragging.
505
+ originRef.current = arrangement
506
+ setPreview({ arrangement, basis: containers, settled: false })
321
507
  // The card starts over its own column, so the drop target is lit from the
322
508
  // first frame rather than only once the pointer crosses a boundary.
323
509
  setOverContainerId(containerOf(id))
@@ -341,7 +527,11 @@ export function MultiSortableList({
341
527
  const overItems = arrangement[to] ?? []
342
528
  const overIndex = overItems.indexOf(overId)
343
529
  const index = overIndex === -1 ? overItems.length : overIndex
344
- setPreview(moveBetween(arrangement, itemId, from, to, index))
530
+ setPreview({
531
+ arrangement: moveBetween(arrangement, itemId, from, to, index),
532
+ basis: containers,
533
+ settled: false,
534
+ })
345
535
  }
346
536
 
347
537
  const handleDragEnd = (event: DragEndEvent) => {
@@ -355,8 +545,11 @@ export function MultiSortableList({
355
545
  setOverContainerId(null)
356
546
  originRef.current = null
357
547
 
548
+ // Dropped outside every column, or an id the origin does not hold. Nothing
549
+ // is committed, so nothing will patch: the board goes back to what it was
550
+ // showing when the drag began.
358
551
  if (!over || !from) {
359
- setPreview(null)
552
+ holdPreview(origin)
360
553
  return
361
554
  }
362
555
 
@@ -367,22 +560,33 @@ export function MultiSortableList({
367
560
  const index = overIndex === -1 ? overItems.length : overIndex
368
561
  const next = moveBetween(origin, itemId, from, to, index)
369
562
 
370
- setPreview(null)
371
- if (from === to && (origin[from] ?? []).indexOf(itemId) === index) return
563
+ // Same lane, same slot. No `onMove`, therefore no patch to wait for.
564
+ if (from === to && (origin[from] ?? []).indexOf(itemId) === index) {
565
+ holdPreview(origin)
566
+ return
567
+ }
568
+
569
+ // Hold BEFORE handing over. The primitive's own state is then complete and
570
+ // consistent whatever `onMove` does, including throw.
571
+ holdPreview(next)
372
572
  onMove({ itemId, fromContainerId: from, toContainerId: to, toIndex: index }, next)
373
573
  }
374
574
 
375
575
  const handleDragCancel = () => {
376
576
  setActiveId(null)
377
577
  setOverContainerId(null)
578
+ const origin = originRef.current ?? containers
378
579
  originRef.current = null
379
- setPreview(null)
580
+ // Not `setPreview(null)`: clearing renders `containers`, which is only the
581
+ // right answer when the drag started from it. If a previous drop is still
582
+ // held, cancelling this one must return to THAT, not undo both.
583
+ holdPreview(origin)
380
584
  }
381
585
 
382
586
  return (
383
587
  <DndContext
384
588
  sensors={sensors}
385
- collisionDetection={closestCorners}
589
+ collisionDetection={collisionDetection}
386
590
  measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
387
591
  onDragStart={handleDragStart}
388
592
  onDragOver={handleDragOver}
@@ -424,11 +628,12 @@ export function MultiSortableList({
424
628
  * accent (design brief §4), and only the consumer knows what its border is.
425
629
  *
426
630
  * `isOver` here is `useDroppable`'s raw answer and is NOT the board's drag-over
427
- * signal: with `closestCorners`, `over` resolves to a sortable item whenever
428
- * the column holds one, so this reads `false` for the column the card is
429
- * actually over. It is kept because the droppable registration is what makes an
430
- * EMPTY column a drop target at all. The state a consumer should paint comes
431
- * from `MultiSortableList`'s `overContainerId`.
631
+ * signal: `over` resolves to a sortable item whenever the column holds one, so
632
+ * this reads `false` for the column the card is actually over. It is kept
633
+ * because the droppable registration is what makes an EMPTY column a drop
634
+ * target at all and, under pointer-first collision, what makes the column's
635
+ * empty space below the last card a target for an append. The state a consumer
636
+ * should paint comes from `MultiSortableList`'s `overContainerId`.
432
637
  */
433
638
  export function SortableDropZone({
434
639
  id,
@@ -11,6 +11,9 @@
11
11
  * • 8 replies on one parent — 3 visible, 5 hidden (D-port)
12
12
  * • a DELETED parent with live children — D4, the tombstone that keeps its
13
13
  * subtree instead of orphaning it
14
+ * • a MODERATED tombstone — FU-0032, the second way a comment
15
+ * becomes one; `moderated` is all
16
+ * that separates it from the first
14
17
  * • a reply whose parent is ABSENT — D4's other half: "not fetched" is
15
18
  * not "deleted", and must be labelled
16
19
  * • a pending and a failed send — D17
@@ -143,6 +146,20 @@ export const THREAD_FIXTURE: readonly ThreadComment[] = [
143
146
  createdAt: ago(4 * HOUR + 20 * MINUTE),
144
147
  },
145
148
 
149
+ // ── FU-0032: a tombstone somebody ELSE made. Structurally identical to
150
+ // the one above — same blanked body, same `deletedAt` — and `moderated`
151
+ // is the entire difference, which is the point: without it the two are
152
+ // the same row and the reader is told the wrong thing about one of them.
153
+ // The author is still withheld, and the wire never carries who removed it.
154
+ {
155
+ id: 'c-moderated',
156
+ author: { id: 'usr_kai', name: 'Kai Moreau' },
157
+ bodyMd: '',
158
+ createdAt: ago(5 * HOUR + 30 * MINUTE),
159
+ deletedAt: ago(3 * HOUR),
160
+ moderated: true,
161
+ },
162
+
146
163
  // ── D4's other half: the parent is ABSENT from this page, not deleted. ──
147
164
  {
148
165
  id: 'c-orphan',
@@ -145,6 +145,86 @@ describe('Thread — D4 deletion and orphans', () => {
145
145
  })
146
146
  })
147
147
 
148
+ /**
149
+ * FU-0032 — a tombstone has to say which removal it was.
150
+ *
151
+ * `[deleted]` covered one case because, when it was written, an author
152
+ * deleting their own comment was the only way a comment became a tombstone.
153
+ * An admin redacting somebody else's is the second, and the reader it lands
154
+ * hardest on is the person whose comment is gone: `[deleted]` tells them they
155
+ * did it themselves.
156
+ *
157
+ * The load-bearing tests here are the negative ones. `moderated` absent or
158
+ * false has to render the 0.1.0 string exactly, because every existing host is
159
+ * on that path and not one of them will ever set the field.
160
+ */
161
+ describe('Thread — FU-0032 a moderated tombstone', () => {
162
+ /** One field, four states. Local because it is a permutation, not shared data. */
163
+ const TOMBSTONES: readonly ThreadComment[] = [
164
+ {
165
+ id: 't-self',
166
+ author: { id: 'usr_ada', name: 'Ada Whitfield' },
167
+ bodyMd: '',
168
+ createdAt: FIXTURE_NOW - 2 * 3_600_000,
169
+ deletedAt: FIXTURE_NOW - 60_000,
170
+ moderated: false,
171
+ },
172
+ {
173
+ id: 't-absent',
174
+ author: { id: 'usr_lee', name: 'Lee Ndiaye' },
175
+ bodyMd: '',
176
+ createdAt: FIXTURE_NOW - 3 * 3_600_000,
177
+ deletedAt: FIXTURE_NOW - 60_000,
178
+ },
179
+ {
180
+ id: 't-live',
181
+ author: { id: 'usr_dana', name: 'Dana Ortiz' },
182
+ bodyMd: 'Still here.',
183
+ createdAt: FIXTURE_NOW - 4 * 3_600_000,
184
+ moderated: true,
185
+ },
186
+ ]
187
+
188
+ it('says the comment was removed rather than deleted', () => {
189
+ renderThread()
190
+ // The byline stands in for the author, so it takes the SHORT form: a
191
+ // sentence in the name slot reads as an attribution, and the one thing
192
+ // this render must never do is imply who removed it.
193
+ expect(within(byline('c-moderated')).getByText('[removed]')).toBeTruthy()
194
+ // The body is the interface's own line, so it is where the sentence goes.
195
+ expect(
196
+ within(commentBox('c-moderated')).getByText('[removed by an admin]', { selector: 'p' }),
197
+ ).toBeTruthy()
198
+ })
199
+
200
+ it('withholds the author of a moderated comment, exactly as of a deleted one', () => {
201
+ renderThread()
202
+ expect(within(commentBox('c-moderated')).queryByText('Kai Moreau')).toBeNull()
203
+ })
204
+
205
+ it('renders [deleted] when moderated is false — the 0.1.0 string, unchanged', () => {
206
+ render(<Thread comments={TOMBSTONES} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
207
+ expect(within(byline('t-self')).getByText('[deleted]')).toBeTruthy()
208
+ expect(within(commentBox('t-self')).getByText('[deleted]', { selector: 'p' })).toBeTruthy()
209
+ expect(within(commentBox('t-self')).queryByText(/removed/)).toBeNull()
210
+ })
211
+
212
+ it('renders [deleted] when moderated is absent — every 0.1.0 host is here', () => {
213
+ render(<Thread comments={TOMBSTONES} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
214
+ expect(within(byline('t-absent')).getByText('[deleted]')).toBeTruthy()
215
+ expect(within(commentBox('t-absent')).getByText('[deleted]', { selector: 'p' })).toBeTruthy()
216
+ expect(within(commentBox('t-absent')).queryByText(/removed/)).toBeNull()
217
+ })
218
+
219
+ it('ignores moderated on a comment that is not a tombstone at all', () => {
220
+ render(<Thread comments={TOMBSTONES} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
221
+ const box = commentBox('t-live')
222
+ expect(within(box).getByText('Still here.')).toBeTruthy()
223
+ expect(within(byline('t-live')).getByText('Dana Ortiz')).toBeTruthy()
224
+ expect(within(box).queryByText(/removed/)).toBeNull()
225
+ })
226
+ })
227
+
148
228
  describe('Thread — collapse, paging and the depth cap', () => {
149
229
  it('shows three replies then "Show 5 more replies"', () => {
150
230
  renderThread()
@@ -212,6 +212,53 @@ function authorLabel(author: ThreadAuthor | null | undefined): string {
212
212
  return 'Unknown member'
213
213
  }
214
214
 
215
+ /**
216
+ * THE tombstone strings — two acts, and a deliberate word for each.
217
+ *
218
+ * A comment becomes a tombstone two ways now (FU-0031, FU-0032): its author
219
+ * deleted it, or somebody else removed it. `[deleted]` was written when only
220
+ * the first existed, and it is read by the person whose comment is gone —
221
+ * telling them they did it themselves when they did not is the whole defect.
222
+ *
223
+ * REMOVED, not deleted. The two verbs carry the entire distinction, so each
224
+ * one is reserved for exactly one act: you delete your own, somebody removes
225
+ * yours. Any pair that shared a verb would need a qualifier to be read at all.
226
+ *
227
+ * "AN ADMIN", not "a workspace admin" and not "a moderator". The ONE thing
228
+ * `moderated` licenses the render to claim is that the author did not do it —
229
+ * that is the whole content of the flag, and how a host derives it is the
230
+ * host's business, not this module's. (An earlier draft asserted it comes from
231
+ * `deleted_by <> author_id`. That is one consumer's wire and appears nowhere in
232
+ * this repository; stating another product's schema as fact here is exactly the
233
+ * coupling this module's entity-agnostic claim rules out.) Naming a ROLE rather
234
+ * than a person keeps the claim true — nothing obliges a host to send an actor,
235
+ * and a string with a slot for one is a string somebody eventually fills.
236
+ * "Workspace" is one host's noun and this module is entity-agnostic by its own
237
+ * opening claim (types.ts); "moderator" is a forum's word for a role that
238
+ * exists in neither consumer. "Admin" is what both of them actually call the
239
+ * person who can do this, which is the test interface copy actually has to
240
+ * pass.
241
+ *
242
+ * TWO STRINGS PER ACT, because they land in two different slots. The byline is
243
+ * the AUTHOR position: a sentence there reads as an attribution, and "removed
244
+ * by an admin" sitting where a name goes says the admin wrote it. It also
245
+ * feeds two accessible names — "Collapse replies to X" and "More actions for
246
+ * X's comment" — which a sentence makes unusable. So the byline takes the
247
+ * short form and the body, which is the interface speaking in its own voice,
248
+ * takes the sentence.
249
+ *
250
+ * The brackets and the lower case are unchanged from `[deleted]`: that form is
251
+ * what marks a line as the system talking rather than a person, and a
252
+ * tombstone that stopped looking like one would be a worse regression than the
253
+ * one being fixed.
254
+ */
255
+ const TOMBSTONE: Readonly<
256
+ Record<'deleted' | 'moderated', { readonly byline: string; readonly body: string }>
257
+ > = {
258
+ deleted: { byline: '[deleted]', body: '[deleted]' },
259
+ moderated: { byline: '[removed]', body: '[removed by an admin]' },
260
+ }
261
+
215
262
  /** "1 reply" / "8 replies" — the live region's wording, in one place. */
216
263
  function replyCountLabel(count: number): string {
217
264
  return `${String(count)} ${count === 1 ? 'reply' : 'replies'}`
@@ -375,7 +422,12 @@ export function CommentItem({
375
422
  const collapsed = thread.isCollapsed(node.id)
376
423
  const deleted = node.deletedAt != null
377
424
  const state = node.state ?? 'sent'
378
- const label = deleted ? '[deleted]' : authorLabel(node.author)
425
+ // `moderated` says nothing about a live comment — nobody has removed one —
426
+ // so it is read HERE, under `deleted`, and nowhere else in the render. A
427
+ // comment carrying the flag without a `deletedAt` is not a tombstone and
428
+ // does not become one.
429
+ const tombstone = deleted ? TOMBSTONE[node.moderated === true ? 'moderated' : 'deleted'] : null
430
+ const label = tombstone === null ? authorLabel(node.author) : tombstone.byline
379
431
  const descendants = countDescendants(node)
380
432
  const atCap = renderDepth >= thread.maxDepth && !continued
381
433
  const { visible, hidden } = thread.visibleReplies(node)
@@ -597,9 +649,9 @@ export function CommentItem({
597
649
  </p>
598
650
  )}
599
651
 
600
- {deleted ? (
652
+ {tombstone !== null ? (
601
653
  <p className="text-[13px] italic" style={{ color: 'rgb(var(--text-tertiary))' }}>
602
- [deleted]
654
+ {tombstone.body}
603
655
  </p>
604
656
  ) : (
605
657
  <CommentBody
@@ -624,7 +676,7 @@ export function CommentItem({
624
676
  deliberately attached outranks what a scanner found in their
625
677
  prose. A tombstone shows none — the worker already returns none
626
678
  for one (ADR-150 D8), and this is the second lock on it, because
627
- "[deleted]" followed by three intact cards would leak the body
679
+ a tombstone followed by three intact cards would leak the body
628
680
  the tombstone exists to remove. */}
629
681
  {node.linkPreviews === undefined || node.linkPreviews.length === 0 || deleted ? null : (
630
682
  <LinkPreviews
@@ -149,6 +149,20 @@ export interface ThreadComment {
149
149
  readonly editedAt?: number | null | undefined
150
150
  /** Set = tombstone (D4). The row still renders and KEEPS its subtree. */
151
151
  readonly deletedAt?: number | null | undefined
152
+ /**
153
+ * TRUE = the tombstone above was made by somebody OTHER than the author
154
+ * (FU-0031, FU-0032). Meaningless without `deletedAt`, and read nowhere else.
155
+ *
156
+ * One boolean rather than a `deletedBy` author, and that is a decision, not
157
+ * a shortcut: the wire deliberately never carries who removed a comment, so
158
+ * a field that could hold a name would be a field somebody eventually fills.
159
+ * A boolean cannot leak an identity it does not have.
160
+ *
161
+ * OPTIONAL, and absent means `[deleted]` — the render every host on 0.1.0
162
+ * already gets. A host with no moderation concept never sets it and never
163
+ * sees a word change.
164
+ */
165
+ readonly moderated?: boolean | undefined
152
166
  readonly reactions?: readonly ThreadReactionCount[] | undefined
153
167
  readonly attachments?: readonly ThreadAttachment[] | undefined
154
168
  /**