@lovett/ui 0.0.11 → 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.
Files changed (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,610 @@
1
+ /**
2
+ * TaskCard — the board card.
3
+ *
4
+ * `docs/design-briefs/tasks-board-design-language.md` §2 + §3, as corrected by
5
+ * `tasks-board-interface-standards.md` §1 (radius), §2 (surface) and §3
6
+ * (grouping). ADR-146 D13.
7
+ *
8
+ * "Dynamic and more informative" concretely means: **every row is optional
9
+ * except the title.** The card grows to fit what the item actually has and
10
+ * reserves nothing for what it does not. It is therefore not a template with
11
+ * placeholders — an item with no note, no media and no dates renders as three
12
+ * short lines, and one with everything renders tall.
13
+ *
14
+ * ## The five groups (interface-standards §3)
15
+ *
16
+ * Grouped with SPACE, not lines — `--space-1` within a group, `--space-3`
17
+ * between groups, and no dividers inside a card:
18
+ *
19
+ * 1. tags + overflow menu
20
+ * 2. eyebrow · title · note
21
+ * 3. media
22
+ * 4. embedded panel, then the ledger
23
+ * 5. date chip · avatars · counts
24
+ *
25
+ * ## The radius chain
26
+ *
27
+ * Column `--radius-2xl` (24) − `--space-2` (8) = card `--radius-lg` (16);
28
+ * card 16 − media inset `--space-2` (8) = media `--radius-sm` (8). Text rows
29
+ * pad `--space-3` and are outside the chain because text has no corner. Chips
30
+ * sit flat on the card, so they are `--radius-sm` and outside it too.
31
+ *
32
+ * ## Why the card is not a `<button>`, and why it is not a drag handle either
33
+ *
34
+ * It holds a menu, a vote control and clickable ledger segments; a button
35
+ * cannot legally contain those. The TITLE is the actionable element instead,
36
+ * so the card is fully keyboard-operable without nesting interactive content,
37
+ * and the article carries a POINTER click on top of it — one target for the
38
+ * mouse, one real control for the keyboard, one `onOpen`.
39
+ *
40
+ * The same reasoning is why the card is not the drag handle. The article gets
41
+ * `setNodeRef` and `style` — it is what dnd-kit measures and transforms — and
42
+ * NOTHING else. The activators (`onPointerDown`, `onKeyDown`) and the aria
43
+ * wiring both ride the visible grip. Spreading the listeners on the article
44
+ * made every pointer-down anywhere on the card the start of a drag, which is
45
+ * why the card read `cursor: grab` and could not be clicked.
46
+ *
47
+ * The hue comes from an ancestor's `.hue hue-<key>` pair (see `hue.ts`).
48
+ */
49
+
50
+ import { useState, type ComponentPropsWithoutRef } from 'react'
51
+ import type {
52
+ CSSProperties,
53
+ HTMLAttributes,
54
+ MouseEvent as ReactMouseEvent,
55
+ ReactNode,
56
+ } from 'react'
57
+ import { Flag, GripVertical, ImageOff } from 'lucide-react'
58
+ import { cn } from './lib/utils'
59
+ import { hueClass, hueForKey } from './hue'
60
+
61
+ type CardHeadingLevel = 2 | 3 | 4 | 5
62
+
63
+ function CardTitleHeading({
64
+ level,
65
+ ...rest
66
+ }: { level: CardHeadingLevel } & ComponentPropsWithoutRef<'h4'>) {
67
+ const Tag = `h${level}` as const
68
+ return <Tag {...rest} />
69
+ }
70
+
71
+ export interface TaskCardMedia {
72
+ /** Resolved by the caller — the primitive never builds a URL. */
73
+ src: string
74
+ alt: string
75
+ }
76
+
77
+ export interface TaskCardCount {
78
+ /** A Lucide icon at `strokeWidth={1.5}` — it sits beside 400-weight text. */
79
+ icon: ReactNode
80
+ value: number
81
+ /** Sentence-case, for the accessible name: "comments", "attachments". */
82
+ label: string
83
+ }
84
+
85
+ export interface TaskCardAvatar {
86
+ id: string
87
+ name: string
88
+ }
89
+
90
+ export interface TaskCardDate {
91
+ /** Operator language — "Due", "Target launch", "Launched". Never a schema word. */
92
+ label: string
93
+ value: string
94
+ /** A terminal column reads in `--success-ink` rather than the neutral. */
95
+ terminal?: boolean
96
+ }
97
+
98
+ export interface TaskCardDragProps {
99
+ /** The measured/transformed node. Carries no handlers — see the file header. */
100
+ setNodeRef?: ((node: HTMLElement | null) => void) | undefined
101
+ /**
102
+ * The drag activator, pointer AND keyboard, plus dnd-kit's aria wiring.
103
+ * Rendered as a visible grip — in group 1 when that row exists for its own
104
+ * reasons, otherwise floated into the title row — so a drag starts from one
105
+ * named, focusable control and the card itself stays clickable.
106
+ */
107
+ activatorProps?: HTMLAttributes<HTMLElement> | undefined
108
+ /** dnd-kit's handle ref, so a finished keyboard drag restores focus to the grip. */
109
+ activatorRef?: ((node: HTMLElement | null) => void) | undefined
110
+ style?: CSSProperties | undefined
111
+ isDragging?: boolean | undefined
112
+ }
113
+
114
+ export interface TaskCardProps {
115
+ /**
116
+ * Document-outline level for the card title. Default 4.
117
+ * Should sit one below the column heading so the outline has no skip.
118
+ */
119
+ headingLevel?: CardHeadingLevel
120
+ title: string
121
+ eyebrow?: string
122
+ note?: string
123
+ tags?: readonly string[]
124
+ /** Tags shown before a `+N` chip takes over. */
125
+ maxTags?: number
126
+ /**
127
+ * Consumer-rendered chips alongside the tags — Community's LensChip, a
128
+ * priority pill. Group 1 is "what this card is labelled", and a caller with
129
+ * its own chip vocabulary should not have to fork the card to use it.
130
+ */
131
+ chips?: ReactNode
132
+ media?: readonly TaskCardMedia[]
133
+ /** Declared up front so the card cannot reflow as an image loads. */
134
+ mediaAspectRatio?: string
135
+ /** An embedded sub-panel — option tiles, a mini widget. */
136
+ panel?: ReactNode
137
+ /** Typically a <ProgressLedger>. Any node; the card owns only its slot. */
138
+ ledger?: ReactNode
139
+ date?: TaskCardDate
140
+ avatars?: readonly TaskCardAvatar[]
141
+ maxAvatars?: number
142
+ counts?: readonly TaskCardCount[]
143
+ /**
144
+ * Rendered after the counts at the end of the footer. This is the seam that
145
+ * makes the board primitive generic over its CARD BODY rather than over its
146
+ * data (ADR-146 D2) — Community passes its vote control here, beside the
147
+ * comment count, and neither consumer forks the card.
148
+ */
149
+ footerEnd?: ReactNode
150
+ /** Overflow menu trigger. Supply an `aria-label`; the card cannot know it. */
151
+ menu?: ReactNode
152
+ /**
153
+ * Opens the item. Makes the title an actionable button AND the whole card a
154
+ * pointer target. Omit for a static card — a card with no `onOpen` gets no
155
+ * pointer cursor and no hover lift, so it never claims an affordance it
156
+ * cannot honour.
157
+ */
158
+ onOpen?: () => void
159
+ /** Archived / shipped treatment — dimmed, never hidden. */
160
+ dimmed?: boolean
161
+ drag?: TaskCardDragProps
162
+ /** Rendering inside a DragOverlay: no hover lift, a heavier shadow. */
163
+ isOverlay?: boolean
164
+ className?: string
165
+ }
166
+
167
+ export function TaskCard({
168
+ headingLevel = 4,
169
+ title,
170
+ eyebrow,
171
+ note,
172
+ tags,
173
+ maxTags = 3,
174
+ chips,
175
+ media,
176
+ mediaAspectRatio = '16 / 10',
177
+ panel,
178
+ ledger,
179
+ date,
180
+ avatars,
181
+ maxAvatars = 4,
182
+ counts,
183
+ footerEnd,
184
+ menu,
185
+ onOpen,
186
+ dimmed = false,
187
+ drag,
188
+ isOverlay = false,
189
+ className,
190
+ }: TaskCardProps) {
191
+ const shownTags = tags?.slice(0, maxTags) ?? []
192
+ const hiddenTags = (tags?.length ?? 0) - shownTags.length
193
+ const hasFooter = Boolean(date || avatars?.length || counts?.length || footerEnd)
194
+ const grip = drag?.activatorProps
195
+ // Group 1 is "what this card is LABELLED". A grip is not a label, so a card
196
+ // with no tags, no chips and no menu does not get a group for it — the grip
197
+ // floats into the title row instead. Rendering an otherwise-empty 24px row
198
+ // on every card was pure overhead on the Tasks board, where no card has any
199
+ // of the three.
200
+ const hasTopRow = shownTags.length > 0 || Boolean(chips) || Boolean(menu)
201
+ const gripInTitleRow = Boolean(grip) && !hasTopRow
202
+ const interactive = Boolean(onOpen) && !isOverlay
203
+
204
+ /**
205
+ * The card's POINTER target.
206
+ *
207
+ * The keyboard path is the title `<button>` below — one real control, with
208
+ * the title as its accessible name — so this handler deliberately adds no
209
+ * `tabIndex` and no `role` to the article. A `role="button"` here would take
210
+ * presentational children and erase the grip, the overflow menu and the
211
+ * footer's own controls from assistive tech.
212
+ *
213
+ * Clicks that started on a control inside the card belong to that control:
214
+ * the grip, the menu, a ledger segment, a vote button, a link — and the
215
+ * title button, which calls `onOpen` itself and must not call it twice.
216
+ */
217
+ const handleCardClick = (event: ReactMouseEvent<HTMLElement>) => {
218
+ if (!onOpen) return
219
+ const origin = event.target
220
+ if (
221
+ origin instanceof Element &&
222
+ origin.closest('button, a, input, select, textarea, [role="button"], [role="menuitem"]')
223
+ ) {
224
+ return
225
+ }
226
+ onOpen()
227
+ }
228
+
229
+ return (
230
+ <article
231
+ ref={drag?.setNodeRef}
232
+ data-interactive={interactive ? 'true' : undefined}
233
+ data-overlay={isOverlay ? 'true' : undefined}
234
+ {...(interactive ? { onClick: handleCardClick } : {})}
235
+ className={cn('ds-board-card flex flex-col', className)}
236
+ style={{
237
+ background: 'rgb(var(--surface-card))',
238
+ // Structure, not depth: near-invisible, but in dark mode it is the
239
+ // only thing separating the card from the column behind it.
240
+ border: '1px solid rgb(var(--border))',
241
+ borderRadius: 'var(--radius-lg)',
242
+ // The SHADOW is not here. It was, and an inline box-shadow outranks
243
+ // every stylesheet rule, so `.ds-board-card:hover`'s --shadow-sm ->
244
+ // --shadow-md step had never once applied: the 1px lift was the whole
245
+ // of the hover feedback and the focus step could not work at all. The
246
+ // resting and overlay shadows now live in styles.css beside the states
247
+ // that step them.
248
+ paddingBlock: 'var(--space-3)',
249
+ gap: 'var(--space-3)',
250
+ opacity: dimmed ? 0.6 : 1,
251
+ ...drag?.style,
252
+ }}
253
+ >
254
+ {/* GROUP 1 — drag grip, tags, overflow menu. */}
255
+ {hasTopRow ? (
256
+ <div
257
+ className="flex items-center flex-wrap"
258
+ style={{ gap: 'var(--space-1)', paddingInline: 'var(--space-3)' }}
259
+ >
260
+ {grip ? (
261
+ <CardGrip grip={grip} gripRef={drag?.activatorRef} title={title} />
262
+ ) : null}
263
+ {shownTags.map((tag) => (
264
+ <TagChip key={tag}>#{tag.toLowerCase()}</TagChip>
265
+ ))}
266
+ {hiddenTags > 0 ? <TagChip>+{hiddenTags}</TagChip> : null}
267
+ {chips}
268
+ {menu ? <span className="ms-auto shrink-0">{menu}</span> : null}
269
+ </div>
270
+ ) : null}
271
+
272
+ {/* GROUP 2 — eyebrow, title, note. When group 1 has nothing of its own
273
+ to hold, the drag grip rides here rather than owning a row. */}
274
+ <div
275
+ className="flex items-start"
276
+ style={{ gap: 'var(--space-1)', paddingInline: 'var(--space-3)' }}
277
+ >
278
+ {gripInTitleRow && grip ? (
279
+ <CardGrip grip={grip} gripRef={drag?.activatorRef} title={title} inTitleRow />
280
+ ) : null}
281
+ <div className="flex flex-col min-w-0 flex-1" style={{ gap: 'var(--space-1)' }}>
282
+ {eyebrow ? (
283
+ <span
284
+ className="text-[11px] leading-[1.3]"
285
+ style={{ color: 'rgb(var(--text-tertiary))' }}
286
+ >
287
+ {eyebrow}
288
+ </span>
289
+ ) : null}
290
+ {/* `h4`, one level under the column's `h3` — the card is nested inside
291
+ the column, and the heading tree should say so. When the card is
292
+ openable the button lives INSIDE the heading rather than replacing
293
+ it, so the title is still reachable by heading navigation. */}
294
+ {/* No line clamp. A clamped title with no tooltip and no drawer is a
295
+ title the reader cannot finish — and the card is variable-height by
296
+ design, so it has somewhere to put the second and third line. */}
297
+ <CardTitleHeading
298
+ level={headingLevel}
299
+ className="ds-board-card-title text-[15px] font-semibold leading-[1.3]"
300
+ style={{ margin: 0, color: 'rgb(var(--foreground))' }}
301
+ >
302
+ {onOpen ? (
303
+ <button
304
+ type="button"
305
+ onClick={onOpen}
306
+ className="text-start rounded-[var(--radius-sm)] focus-visible:[box-shadow:var(--ring-focus)] focus-visible:outline-none"
307
+ style={{
308
+ background: 'transparent',
309
+ border: 'none',
310
+ padding: 0,
311
+ font: 'inherit',
312
+ color: 'inherit',
313
+ }}
314
+ >
315
+ {title}
316
+ </button>
317
+ ) : (
318
+ title
319
+ )}
320
+ </CardTitleHeading>
321
+ {note ? (
322
+ <p
323
+ className="text-[12.5px] leading-[1.45] line-clamp-2"
324
+ style={{ margin: 0, color: 'rgb(var(--text-tertiary))' }}
325
+ >
326
+ {note}
327
+ </p>
328
+ ) : null}
329
+ </div>
330
+ </div>
331
+
332
+ {/* GROUP 3 — media. */}
333
+ {media && media[0] ? (
334
+ <div style={{ paddingInline: 'var(--space-2)' }}>
335
+ <CardMedia
336
+ media={media[0]}
337
+ extra={media.length - 1}
338
+ aspectRatio={mediaAspectRatio}
339
+ />
340
+ </div>
341
+ ) : null}
342
+
343
+ {/* GROUP 4 — embedded panel, then the ledger. */}
344
+ {panel ? <div style={{ paddingInline: 'var(--space-2)' }}>{panel}</div> : null}
345
+ {ledger ? <div style={{ paddingInline: 'var(--space-3)' }}>{ledger}</div> : null}
346
+
347
+ {/* GROUP 5 — date chip, avatars, counts. Wraps rather than clips. */}
348
+ {hasFooter ? (
349
+ <div
350
+ className="ds-board-card-footer flex items-center flex-wrap"
351
+ style={{ gap: 'var(--space-2)', paddingInline: 'var(--space-3)' }}
352
+ >
353
+ {date ? <DateChip date={date} /> : null}
354
+ {avatars && avatars.length > 0 ? (
355
+ <AvatarStack avatars={avatars} max={maxAvatars} />
356
+ ) : null}
357
+ <span className="ms-auto flex items-center" style={{ gap: 'var(--space-2)' }}>
358
+ {counts?.map((count) => <CountChip key={count.label} count={count} />)}
359
+ {footerEnd}
360
+ </span>
361
+ </div>
362
+ ) : null}
363
+ </article>
364
+ )
365
+ }
366
+
367
+ /**
368
+ * THE drag activator — pointer and keyboard both — rendered as a visible grip.
369
+ *
370
+ * `grip` carries dnd-kit's listeners AND its attributes, so this is the one
371
+ * element a drag can start from and the one element assistive tech is told
372
+ * about. `aria-label` comes after the spread deliberately: dnd-kit's
373
+ * `aria-roledescription` describes the gesture, the label names the card.
374
+ *
375
+ * Lives in group 1 when that row exists for its own reasons, and floats into
376
+ * the title row when it does not — so it never conjures a row of its own.
377
+ */
378
+ function CardGrip({
379
+ grip,
380
+ gripRef,
381
+ title,
382
+ inTitleRow = false,
383
+ }: {
384
+ grip: HTMLAttributes<HTMLElement>
385
+ gripRef?: ((node: HTMLElement | null) => void) | undefined
386
+ title: string
387
+ inTitleRow?: boolean
388
+ }) {
389
+ return (
390
+ <button
391
+ type="button"
392
+ ref={gripRef}
393
+ {...grip}
394
+ aria-label={`Reorder ${title}`}
395
+ className="ds-board-card-grip inline-grid place-items-center shrink-0 cursor-grab active:cursor-grabbing focus-visible:[box-shadow:var(--ring-focus)] focus-visible:outline-none"
396
+ style={{
397
+ inlineSize: '24px',
398
+ blockSize: '24px',
399
+ marginInlineStart: 'calc(var(--space-1) * -1)',
400
+ // Optical, not mathematical: a 24px box beside a 19.5px first line
401
+ // sits low unless it is nudged up by the difference's half.
402
+ ...(inTitleRow ? { marginBlockStart: '-2px' } : {}),
403
+ borderRadius: 'var(--radius-sm)',
404
+ background: 'transparent',
405
+ border: 'none',
406
+ color: 'rgb(var(--text-muted))',
407
+ touchAction: 'none',
408
+ }}
409
+ >
410
+ <GripVertical size={14} strokeWidth={1.5} aria-hidden="true" />
411
+ </button>
412
+ )
413
+ }
414
+
415
+ /** Lens-local label. Inset well on the card, never a filled accent pill. */
416
+ function TagChip({ children }: { children: ReactNode }) {
417
+ return (
418
+ <span
419
+ className="inline-flex items-center text-[11px] font-medium leading-none"
420
+ style={{
421
+ paddingInline: 'var(--space-2)',
422
+ paddingBlock: '4px',
423
+ borderRadius: 'var(--radius-sm)',
424
+ background: 'rgb(var(--surface-inset))',
425
+ color: 'rgb(var(--muted-foreground))',
426
+ }}
427
+ >
428
+ {children}
429
+ </span>
430
+ )
431
+ }
432
+
433
+ function DateChip({ date }: { date: TaskCardDate }) {
434
+ return (
435
+ <span
436
+ className="inline-flex items-center text-[11.5px] font-medium leading-none tabular-nums"
437
+ style={{
438
+ gap: '5px',
439
+ paddingInline: 'var(--space-2)',
440
+ paddingBlock: '5px',
441
+ borderRadius: 'var(--radius-sm)',
442
+ border: '1px solid rgb(var(--border))',
443
+ color: date.terminal ? 'rgb(var(--success-ink))' : 'rgb(var(--text-tertiary))',
444
+ }}
445
+ >
446
+ {/* strokeWidth 1.5 — this sits beside 400-weight meta text. */}
447
+ <FlagGlyph />
448
+ <span className="sr-only">{date.label}: </span>
449
+ {date.value}
450
+ </span>
451
+ )
452
+ }
453
+
454
+ /**
455
+ * The flag is imported rather than inlined so the icon rule holds even inside
456
+ * `packages/ui`, where an inline `<svg>` would be legal.
457
+ */
458
+ function FlagGlyph() {
459
+ return <Flag size={12} strokeWidth={1.5} aria-hidden="true" />
460
+ }
461
+
462
+ function CountChip({ count }: { count: TaskCardCount }) {
463
+ return (
464
+ <span
465
+ className="inline-flex items-center text-[11.5px] leading-none"
466
+ style={{
467
+ gap: '5px',
468
+ // 24px minimum hit-area height even though it is not a control, so a
469
+ // count sitting beside the overflow menu does not shrink the row.
470
+ minHeight: '24px',
471
+ paddingInline: 'var(--space-2)',
472
+ borderRadius: 'var(--radius-sm)',
473
+ // Bordered, not filled — a count is metadata, not an action.
474
+ border: '1px solid rgb(var(--border))',
475
+ color: 'rgb(var(--text-tertiary))',
476
+ }}
477
+ >
478
+ {count.icon}
479
+ {/* min-width slot + tabular-nums: a new comment must not resize the card. */}
480
+ <span className="tabular-nums" style={{ minInlineSize: '2ch', textAlign: 'end' }}>
481
+ {count.value}
482
+ </span>
483
+ <span className="sr-only">{count.label}</span>
484
+ </span>
485
+ )
486
+ }
487
+
488
+ function AvatarStack({
489
+ avatars,
490
+ max,
491
+ }: {
492
+ avatars: readonly TaskCardAvatar[]
493
+ max: number
494
+ }) {
495
+ const shown = avatars.slice(0, max)
496
+ const extra = avatars.length - shown.length
497
+ return (
498
+ <span className="inline-flex items-center" style={{ gap: '2px' }}>
499
+ {shown.map((person) => (
500
+ <span
501
+ key={person.id}
502
+ // Its own hue, so two people on a card are distinguishable. Stable
503
+ // per name, because an avatar that changes colour reads as someone
504
+ // else. Opaque by construction — it is a tint-ladder rung.
505
+ className={cn(hueClass(hueForKey(person.id || person.name)), 'inline-grid place-items-center text-[9.5px] font-bold')}
506
+ style={{
507
+ width: '22px',
508
+ height: '22px',
509
+ borderRadius: 'var(--radius-full)',
510
+ background: 'var(--tint-group)',
511
+ border: '1px solid var(--tint-edge)',
512
+ // The INK rung, not the raw hue: `--h` on `--tint-group` measures
513
+ // 2.77:1 (slate) / 3.13:1 (blue) in LIGHT mode at 9.5px/700.
514
+ // `--tint-ink` mixes toward --foreground, so it darkens in light
515
+ // and lightens in dark instead of being tuned for one theme.
516
+ color: 'var(--tint-ink)',
517
+ }}
518
+ title={person.name}
519
+ >
520
+ {initials(person.name)}
521
+ <span className="sr-only">{person.name}</span>
522
+ </span>
523
+ ))}
524
+ {extra > 0 ? (
525
+ <span
526
+ className="inline-grid place-items-center text-[10px] font-semibold tabular-nums"
527
+ style={{
528
+ width: '22px',
529
+ height: '22px',
530
+ borderRadius: 'var(--radius-full)',
531
+ background: 'rgb(var(--surface-inset))',
532
+ color: 'rgb(var(--text-tertiary))',
533
+ }}
534
+ >
535
+ +{extra}
536
+ </span>
537
+ ) : null}
538
+ </span>
539
+ )
540
+ }
541
+
542
+ function CardMedia({
543
+ media,
544
+ extra,
545
+ aspectRatio,
546
+ }: {
547
+ media: TaskCardMedia
548
+ extra: number
549
+ aspectRatio: string
550
+ }) {
551
+ const [failed, setFailed] = useState(false)
552
+ return (
553
+ <div
554
+ className="relative w-full overflow-hidden"
555
+ style={{
556
+ aspectRatio,
557
+ borderRadius: 'var(--radius-sm)',
558
+ // Placeholder behind the image, so the box is never empty or collapsed.
559
+ background: 'rgb(var(--surface-inset))',
560
+ // Pure-neutral hairline: a tinted border picks up the surface under it
561
+ // and reads as dirt on the image edge (interface-standards §2).
562
+ border: '1px solid rgb(var(--border))',
563
+ }}
564
+ >
565
+ {failed ? (
566
+ <span
567
+ className="absolute inset-0 grid place-items-center"
568
+ style={{ color: 'rgb(var(--text-muted))' }}
569
+ >
570
+ <ImageOff size={18} strokeWidth={1.5} aria-hidden="true" />
571
+ <span className="sr-only">Image unavailable: {media.alt}</span>
572
+ </span>
573
+ ) : (
574
+ <img
575
+ src={media.src}
576
+ alt={media.alt}
577
+ loading="lazy"
578
+ decoding="async"
579
+ onError={() => setFailed(true)}
580
+ className="absolute inset-0 h-full w-full"
581
+ style={{ objectFit: 'cover' }}
582
+ />
583
+ )}
584
+ {extra > 0 ? (
585
+ <span
586
+ className="absolute text-[11px] font-semibold tabular-nums"
587
+ style={{
588
+ insetBlockEnd: 'var(--space-2)',
589
+ insetInlineEnd: 'var(--space-2)',
590
+ paddingInline: '7px',
591
+ paddingBlock: '3px',
592
+ borderRadius: 'var(--radius-sm)',
593
+ background: 'rgb(var(--media-scrim-label))',
594
+ color: 'rgb(var(--media-on-scrim))',
595
+ }}
596
+ >
597
+ +{extra}
598
+ </span>
599
+ ) : null}
600
+ </div>
601
+ )
602
+ }
603
+
604
+ function initials(name: string): string {
605
+ const parts = name.trim().split(/\s+/).filter(Boolean)
606
+ if (parts.length === 0) return '?'
607
+ const first = parts[0]?.[0] ?? ''
608
+ const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : ''
609
+ return (first + last).toUpperCase()
610
+ }