@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/dist/{chunk-RBYWGBQ2.js → chunk-GP7BKVZC.js} +8 -5
- package/dist/chunk-GP7BKVZC.js.map +1 -0
- package/dist/index.d.ts +316 -17
- package/dist/index.js +96 -23
- package/dist/index.js.map +1 -1
- package/dist/{rich-composer-impl-5NO443A6.js → rich-composer-impl-F5PQVFZT.js} +3 -3
- package/dist/{rich-composer-impl-5NO443A6.js.map → rich-composer-impl-F5PQVFZT.js.map} +1 -1
- package/dist/styles.css +53 -1
- package/dist/theme-v2.css +26 -0
- package/dist/tokens.css +13 -0
- package/package.json +2 -2
- package/src/__tests__/clip-reserve.test.ts +431 -0
- package/src/__tests__/display-store.test.tsx +120 -21
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/detail/__tests__/activity-pane.test.tsx +218 -1
- package/src/detail/activity-pane.tsx +117 -3
- package/src/display-store.tsx +61 -2
- package/src/index.ts +7 -0
- package/src/sortable.tsx +230 -25
- package/src/styles.css +53 -1
- package/src/theme-v2.css +26 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +17 -0
- package/src/thread/__tests__/thread.test.tsx +268 -0
- package/src/thread/attachments.tsx +1 -1
- package/src/thread/comment.tsx +104 -6
- package/src/thread/composer.tsx +4 -1
- package/src/thread/reactions.tsx +2 -1
- package/src/thread/types.ts +109 -0
- package/src/tokens.css +13 -0
- package/dist/chunk-RBYWGBQ2.js.map +0 -1
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
|
-
* - `
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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
|
|
232
|
-
*
|
|
233
|
-
* not
|
|
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:
|
|
255
|
-
* whenever a column holds one
|
|
256
|
-
*
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
if (from === to && (origin[from] ?? []).indexOf(itemId) === index)
|
|
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={
|
|
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:
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
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
|
|
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',
|