@lovett/ui 0.1.0 → 0.2.3

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,
package/src/styles.css CHANGED
@@ -956,7 +956,13 @@
956
956
  display: flex;
957
957
  flex-direction: column;
958
958
  gap: var(--space-2);
959
- min-block-size: 96px;
959
+ /* 96px is the OUTER floor, and `min-block-size` is a border-box one — so it
960
+ carries the block-start compensation below, which lives outside the border
961
+ box. Without the `+ --space-1` an empty lane in a content-sized board
962
+ (the only layout where this floor binds at all; a stretched board takes
963
+ its height from the grid row) drew its frame 4px shorter than before.
964
+ Measured: column 154px -> 150px, with the empty message itself unmoved. */
965
+ min-block-size: calc(96px + var(--space-1));
960
966
  /* Overridable by the consuming surface. Capping it is what produces the
961
967
  next-card peek instead of an infinitely tall column. Declared here rather
962
968
  than spelled as a `var(--x, fallback)` so the token-shape guard can still
@@ -968,6 +974,52 @@
968
974
  max-block-size: var(--board-column-max-block-size);
969
975
  flex: 1 1 auto;
970
976
  overflow-y: auto;
977
+
978
+ /* THE LANE IS A CLIP, AND A CARD PAINTS OUTSIDE ITSELF.
979
+ *
980
+ * `overflow-y: auto` here does not clip one axis. A `visible` companion is
981
+ * coerced to `auto` — the same rule `.ds-board-scroller` above declares
982
+ * `overflow-y: hidden` to dodge — so this box clips INLINE as well. That is
983
+ * why the reserve below is four-sided and not a top inset: with zero padding
984
+ * the clip edge WAS the card's own border box, so every card lost shadow
985
+ * left and right, the top one lost its hover lift into the header gap, and
986
+ * the last one lost its shadow at the scroll end. Owner, in a browser, 2026-
987
+ * 09-09: the lift is sliced. Nothing typecheck, lint, build or the suite
988
+ * reads can see a sliced shadow, and jsdom has no layout to measure one.
989
+ *
990
+ * `scroll-padding-block-end` below is NOT this and never was. It positions
991
+ * where `scrollIntoView` lands. It reserves no paint room whatsoever.
992
+ *
993
+ * The reserve is READ OFF the shadow tokens, not guessed. A shadow paints
994
+ * `|offset| + spread + blur/2` past the border box, so the hover step
995
+ * --shadow-md reaches furthest in the theme where it is a plain
996
+ * `0 4px 12px`: 6px inline, 2px block-start, 10px block-end. The hover
997
+ * `translateY(-1px)` lifts all of that one pixel, taking block-start to 3.
998
+ * Rounded up onto the spacing scale: 8 / 4 / 12. A value off the scale would
999
+ * mean the scale is wrong; these are on it.
1000
+ *
1001
+ * Inline and block-start are pulled straight back by an equal NEGATIVE
1002
+ * margin, so this box grows outward into the column's own --space-2 padding
1003
+ * and not one card moves. That is the entire requirement — paint room
1004
+ * without repositioning the lane's resting composition. The side effect is
1005
+ * an improvement: an overlay vertical scrollbar now floats over the reserve
1006
+ * instead of over the cards.
1007
+ *
1008
+ * Block-end takes no such margin, deliberately, and this is where the
1009
+ * scroller's note at ~:839 is respected rather than overruled. 12px exceeds
1010
+ * the column's 8px of bottom padding, so pulling back would drive this box
1011
+ * through the column's border AND consume the 8px that note keeps clear for
1012
+ * an overlay HORIZONTAL scrollbar to float over — the one thing it asks for.
1013
+ * Uncompensated trailing padding on a scroll container costs nothing at
1014
+ * rest, because `flex: 1 1 auto` already makes this box taller than its
1015
+ * content; it surfaces only at the bottom of a scrolled lane, where it reads
1016
+ * as the end of the list rather than as a gap. */
1017
+ padding-inline: var(--space-2);
1018
+ margin-inline: calc(var(--space-2) * -1);
1019
+ padding-block-start: var(--space-1);
1020
+ margin-block-start: calc(var(--space-1) * -1);
1021
+ padding-block-end: var(--space-3);
1022
+
971
1023
  scroll-padding-block-end: var(--space-6);
972
1024
  }
973
1025
 
package/src/theme-v2.css CHANGED
@@ -158,6 +158,19 @@
158
158
  against its surface, under the 3:1 WCAG 2.2 SC 2.4.11 asks of a focus
159
159
  indicator. See the note on --ring-focus in tokens.css. */
160
160
  --ring-focus: 0 0 0 2px rgb(var(--accent));
161
+ /* The SAME ring, painted inward. An outset box-shadow is clipped by ANY
162
+ ancestor with a non-visible overflow, and when that happens the focus
163
+ indicator does not degrade — it disappears, which is a WCAG 2.4.7 failure
164
+ rather than a cosmetic one.
165
+ Use this at a clipping boundary: inside a scroll container, inside the
166
+ `overflow-hidden` a grid-rows reveal needs, inside a Card that keeps its
167
+ default clip. It cannot be clipped by an ancestor because it paints inside
168
+ the element's own border box.
169
+ Found the long way on 2026-09-09: the reply composer, the reaction chips
170
+ and the GIF picker all lost their ring, and `thread.tsx` had already been
171
+ forced to override a Card's `overflow-hidden` to `overflow-visible` to keep
172
+ one. That override is the cost this token removes. */
173
+ --ring-focus-inset: inset 0 0 0 2px rgb(var(--accent));
161
174
  --ring-error: 0 0 0 2px rgb(var(--destructive));
162
175
  }
163
176
 
@@ -225,4 +238,17 @@
225
238
  --inset-highlight: 255 255 255 / 0.05;
226
239
  --modal-overlay: 0 0 0 / 0.7;
227
240
  --ring-focus: 0 0 0 2px rgb(var(--accent));
241
+ /* The SAME ring, painted inward. An outset box-shadow is clipped by ANY
242
+ ancestor with a non-visible overflow, and when that happens the focus
243
+ indicator does not degrade — it disappears, which is a WCAG 2.4.7 failure
244
+ rather than a cosmetic one.
245
+ Use this at a clipping boundary: inside a scroll container, inside the
246
+ `overflow-hidden` a grid-rows reveal needs, inside a Card that keeps its
247
+ default clip. It cannot be clipped by an ancestor because it paints inside
248
+ the element's own border box.
249
+ Found the long way on 2026-09-09: the reply composer, the reaction chips
250
+ and the GIF picker all lost their ring, and `thread.tsx` had already been
251
+ forced to override a Card's `overflow-hidden` to `overflow-visible` to keep
252
+ one. That override is the cost this token removes. */
253
+ --ring-focus-inset: inset 0 0 0 2px rgb(var(--accent));
228
254
  }
@@ -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',