@flamingo-stack/openframe-frontend-core 0.0.202 → 0.0.203-snapshot.20260522034243

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 (108) hide show
  1. package/dist/{chunk-OII2IERE.cjs → chunk-25LVV26X.cjs} +4 -4
  2. package/dist/chunk-25LVV26X.cjs.map +1 -0
  3. package/dist/{chunk-55HF462A.js → chunk-CPXLQ57U.js} +6 -7
  4. package/dist/chunk-CPXLQ57U.js.map +1 -0
  5. package/dist/{chunk-JIKTMXTZ.cjs → chunk-QQONFWAN.cjs} +945 -784
  6. package/dist/chunk-QQONFWAN.cjs.map +1 -0
  7. package/dist/{chunk-3B43AHYE.cjs → chunk-RMB5DVED.cjs} +6 -7
  8. package/dist/chunk-RMB5DVED.cjs.map +1 -0
  9. package/dist/{chunk-4ML3NA2L.js → chunk-XGL5FKIK.js} +4 -4
  10. package/dist/chunk-XGL5FKIK.js.map +1 -0
  11. package/dist/{chunk-IDULPYOU.js → chunk-ZXDILOFR.js} +1947 -1786
  12. package/dist/chunk-ZXDILOFR.js.map +1 -0
  13. package/dist/components/chat/chat-ticket-item.d.ts.map +1 -1
  14. package/dist/components/features/index.cjs +4 -4
  15. package/dist/components/features/index.js +3 -3
  16. package/dist/components/features/select-button.d.ts.map +1 -1
  17. package/dist/components/index.cjs +6 -4
  18. package/dist/components/index.cjs.map +1 -1
  19. package/dist/components/index.js +5 -3
  20. package/dist/components/navigation/index.cjs +4 -4
  21. package/dist/components/navigation/index.js +3 -3
  22. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  23. package/dist/components/resizable.d.ts +1 -1
  24. package/dist/components/shared/product-release/product-release-card-skeleton.d.ts.map +1 -1
  25. package/dist/components/shared/product-release/product-release-card.d.ts.map +1 -1
  26. package/dist/components/ui/button/split-button.d.ts.map +1 -1
  27. package/dist/components/ui/data-table/data-table-row.d.ts +16 -4
  28. package/dist/components/ui/data-table/data-table-row.d.ts.map +1 -1
  29. package/dist/components/ui/file-manager/index.cjs +52 -52
  30. package/dist/components/ui/file-manager/index.cjs.map +1 -1
  31. package/dist/components/ui/file-manager/index.js +3 -3
  32. package/dist/components/ui/file-manager/index.js.map +1 -1
  33. package/dist/components/ui/floating-tooltip.d.ts +3 -1
  34. package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
  35. package/dist/components/ui/index.cjs +6 -4
  36. package/dist/components/ui/index.cjs.map +1 -1
  37. package/dist/components/ui/index.d.ts +1 -0
  38. package/dist/components/ui/index.d.ts.map +1 -1
  39. package/dist/components/ui/index.js +5 -3
  40. package/dist/components/ui/input-trigger.d.ts.map +1 -1
  41. package/dist/components/ui/radio-group.d.ts.map +1 -1
  42. package/dist/components/ui/ticket-info-section.d.ts.map +1 -1
  43. package/dist/components/ui/ticket-note-card.d.ts.map +1 -1
  44. package/dist/components/ui/truncate-text.d.ts +33 -0
  45. package/dist/components/ui/truncate-text.d.ts.map +1 -0
  46. package/dist/components/user-summary-stub.d.ts.map +1 -1
  47. package/dist/hooks/index.cjs +2 -2
  48. package/dist/hooks/index.js +1 -1
  49. package/dist/index.cjs +6 -4
  50. package/dist/index.cjs.map +1 -1
  51. package/dist/index.js +5 -3
  52. package/package.json +1 -1
  53. package/src/components/chat/chat-container.tsx +2 -2
  54. package/src/components/chat/chat-ticket-item.tsx +2 -3
  55. package/src/components/features/board/ticket-card.tsx +2 -2
  56. package/src/components/features/filters-dropdown.tsx +1 -1
  57. package/src/components/features/notifications/notification-tile.tsx +2 -2
  58. package/src/components/features/policy-configuration-panel.tsx +1 -1
  59. package/src/components/features/push-button-selector.tsx +1 -1
  60. package/src/components/features/select-button.tsx +2 -3
  61. package/src/components/features/video-bites-display.tsx +1 -1
  62. package/src/components/features/waitlist-form.tsx +1 -1
  63. package/src/components/filter-chip.tsx +1 -1
  64. package/src/components/layout/title-block.tsx +2 -2
  65. package/src/components/navigation/header-organization-filter.tsx +1 -1
  66. package/src/components/navigation/navigation-sidebar.tsx +107 -54
  67. package/src/components/platform/ScriptInfoSection.tsx +1 -1
  68. package/src/components/shared/onboarding/onboarding-step-card.tsx +2 -2
  69. package/src/components/shared/product-release/product-release-card-skeleton.tsx +58 -26
  70. package/src/components/shared/product-release/product-release-card.tsx +170 -133
  71. package/src/components/shared/product-release/release-detail-page.tsx +1 -1
  72. package/src/components/ui/assignee-dropdown.tsx +3 -3
  73. package/src/components/ui/autocomplete.tsx +2 -2
  74. package/src/components/ui/button/split-button.tsx +3 -5
  75. package/src/components/ui/checkbox-block.tsx +1 -1
  76. package/src/components/ui/data-table/data-table-row.tsx +82 -48
  77. package/src/components/ui/device-card-compact.tsx +2 -2
  78. package/src/components/ui/device-card.tsx +2 -2
  79. package/src/components/ui/entity-image.tsx +1 -1
  80. package/src/components/ui/field-wrapper.tsx +1 -1
  81. package/src/components/ui/file-manager/file-manager-table-row.tsx +2 -2
  82. package/src/components/ui/file-upload.tsx +2 -2
  83. package/src/components/ui/filter-list.tsx +1 -1
  84. package/src/components/ui/floating-tooltip.tsx +9 -5
  85. package/src/components/ui/hidden-tags-popup.tsx +1 -1
  86. package/src/components/ui/index.ts +1 -0
  87. package/src/components/ui/info-card.tsx +2 -2
  88. package/src/components/ui/input-trigger.tsx +1 -2
  89. package/src/components/ui/organization-card.tsx +3 -3
  90. package/src/components/ui/radio-group.tsx +2 -3
  91. package/src/components/ui/search-input.tsx +2 -2
  92. package/src/components/ui/service-card.tsx +3 -3
  93. package/src/components/ui/tag.tsx +1 -1
  94. package/src/components/ui/tags-manager.tsx +2 -2
  95. package/src/components/ui/ticket-attachments-list.tsx +1 -1
  96. package/src/components/ui/ticket-info-section.tsx +2 -3
  97. package/src/components/ui/ticket-note-card.tsx +4 -1
  98. package/src/components/ui/toaster.tsx +3 -3
  99. package/src/components/ui/truncate-text.tsx +116 -0
  100. package/src/components/user-summary-stub.tsx +32 -26
  101. package/src/components/vendor-display-button.tsx +1 -1
  102. package/src/stories/SplitButton.stories.tsx +7 -1
  103. package/dist/chunk-3B43AHYE.cjs.map +0 -1
  104. package/dist/chunk-4ML3NA2L.js.map +0 -1
  105. package/dist/chunk-55HF462A.js.map +0 -1
  106. package/dist/chunk-IDULPYOU.js.map +0 -1
  107. package/dist/chunk-JIKTMXTZ.cjs.map +0 -1
  108. package/dist/chunk-OII2IERE.cjs.map +0 -1
@@ -157,46 +157,60 @@ export function ProductReleaseCard({
157
157
  (changelogCounts?.breaking ?? 0)
158
158
 
159
159
  // Build the metadata-grid cell array — mirrors the hub's
160
- // EntityAuthorCard composition (extras date author). The
161
- // release-type cell carries a colored chip; other cells are plain
162
- // value + label.
160
+ // EntityAuthorCard composition. ALWAYS render all 3 value cells
161
+ // (Type / Status / Released) missing values render as a plain
162
+ // em-dash + label so the grid keeps a fixed 4-cell shape (matching
163
+ // the skeleton). The Author cell is also always rendered below
164
+ // (effectiveAuthor falls back to a placeholder shape). This is
165
+ // load-to-resolve baseline parity: any conditional cell would
166
+ // introduce a reflow when the skeleton resolves.
167
+ //
168
+ // Plan note: em-dash placeholders read as plain text (NOT a colored
169
+ // StatusBadge for the Type cell) so empty badges don't look broken
170
+ // next to populated badges.
163
171
  type ValueCell = {
164
172
  value: string
165
173
  label: string
166
174
  uppercase: boolean
167
175
  colorScheme?: 'error' | 'cyan' | 'success' | 'warning'
168
176
  }
169
- const valueCells: ValueCell[] = []
170
- if (releaseType && releaseTypeBadgeColor) {
171
- valueCells.push({
172
- value: releaseType.toUpperCase(),
173
- label: 'Type',
174
- uppercase: true,
175
- colorScheme: releaseTypeBadgeColor,
176
- })
177
- }
178
- if (releaseStatus) {
179
- valueCells.push({
180
- value: releaseStatus.toUpperCase(),
181
- label: 'Status',
182
- uppercase: true,
183
- })
184
- }
185
- if (formattedDate) {
186
- valueCells.push({
187
- value: formattedDate,
188
- label: 'Released',
189
- uppercase: false,
190
- })
191
- }
192
- const hasAuthorCell = !!author?.full_name
193
- const totalCells = valueCells.length + (hasAuthorCell ? 1 : 0)
194
- // Tailwind JIT cannot compile dynamic class names — explicit branches:
195
- const gridColsClass =
196
- totalCells >= 4 ? 'md:grid-cols-4'
197
- : totalCells === 3 ? 'md:grid-cols-3'
198
- : totalCells === 2 ? 'md:grid-cols-2'
199
- : 'md:grid-cols-1'
177
+ const valueCells: ValueCell[] = [
178
+ releaseType && releaseTypeBadgeColor
179
+ ? {
180
+ value: releaseType.toUpperCase(),
181
+ label: 'Type',
182
+ uppercase: true,
183
+ colorScheme: releaseTypeBadgeColor,
184
+ }
185
+ : { value: '—', label: 'Type', uppercase: false },
186
+ releaseStatus
187
+ ? {
188
+ value: releaseStatus.toUpperCase(),
189
+ label: 'Status',
190
+ uppercase: true,
191
+ }
192
+ : { value: '—', label: 'Status', uppercase: false },
193
+ formattedDate
194
+ ? {
195
+ value: formattedDate,
196
+ label: 'Released',
197
+ uppercase: false,
198
+ }
199
+ : { value: '—', label: 'Released', uppercase: false },
200
+ ]
201
+ // EMPTY_AUTHOR_PLACEHOLDER shape mirrors the hub's
202
+ // EMPTY_AUTHOR_PLACEHOLDER constant exported from
203
+ // components/shared/entity-author-card.tsx (hub can't be imported
204
+ // here; the two are kept in lockstep per the inline-duplication
205
+ // policy documented in the catalog branch comment above).
206
+ const effectiveAuthor = author?.full_name
207
+ ? author
208
+ : { full_name: '—', avatar_url: null, job_title: 'Unknown' }
209
+ // Fixed 4-cell grid (Type / Status / Released / Author) so the
210
+ // skeleton's shape matches the loaded card exactly. The earlier
211
+ // dynamic `gridColsClass` ternary collapsed missing cells and
212
+ // caused 28-56px reflow on resolve.
213
+ const gridColsClass = 'md:grid-cols-4'
200
214
  const dividerClass = 'border-b md:border-b-0 md:border-r border-ods-border'
201
215
 
202
216
  const frameClass = cn(
@@ -242,107 +256,130 @@ export function ProductReleaseCard({
242
256
  v{version}
243
257
  </span>
244
258
  </div>
245
- <h3 className="font-['Azeret_Mono'] font-semibold text-xl md:text-2xl text-ods-text-primary leading-tight line-clamp-2 mb-3">
246
- {title}
247
- </h3>
248
- {summary && (
249
- <p className="font-['DM_Sans'] text-sm md:text-base text-ods-text-secondary leading-relaxed line-clamp-4 flex-1">
250
- {summary}
259
+ {/* Title reserve a fixed 2-line height so cards with
260
+ 1-line titles don't shrink and the catalog skeleton-to-
261
+ content transition is shift-free. Mirrors the
262
+ onboarding-guide catalog card. */}
263
+ <div className="min-h-[60px] md:min-h-[72px] flex items-start mb-3">
264
+ <h3 className="font-['Azeret_Mono'] font-semibold text-xl md:text-2xl text-ods-text-primary leading-tight line-clamp-2">
265
+ {title}
266
+ </h3>
267
+ </div>
268
+ {/* Summary — fixed 3-line height. `line-clamp-3` caps long
269
+ summaries at 3 lines; `min-h` reserves the same vertical
270
+ space when content is shorter, so the catalog grid stays
271
+ row-consistent regardless of per-card content length.
272
+ Heights derived from text-sm md:text-base × leading-relaxed
273
+ (1.625): 14×1.625×3 ≈ 68 px mobile, 16×1.625×3 ≈ 78 px desktop. */}
274
+ <div className="min-h-[68px] md:min-h-[78px]">
275
+ <p className="font-['DM_Sans'] text-sm md:text-base text-ods-text-secondary leading-relaxed line-clamp-3">
276
+ {summary ?? ''}
251
277
  </p>
252
- )}
278
+ </div>
253
279
  </div>
254
280
  </div>
255
281
 
256
- {/* CHANGELOG STRIP — hidden when total === 0 */}
257
- {totalChangelog > 0 && changelogCounts && (
258
- <div className="border-t border-ods-border pt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 font-['DM_Sans'] text-sm text-ods-text-secondary">
259
- {changelogCounts.features > 0 && (
260
- <span className="inline-flex items-center gap-1.5">
261
- <Sparkles className="w-3.5 h-3.5" />
262
- {changelogCounts.features} {changelogCounts.features === 1 ? 'feature' : 'features'}
263
- </span>
264
- )}
265
- {changelogCounts.fixes > 0 && (
266
- <span className="inline-flex items-center gap-1.5">
267
- <Wrench className="w-3.5 h-3.5" />
268
- {changelogCounts.fixes} {changelogCounts.fixes === 1 ? 'fix' : 'fixes'}
269
- </span>
270
- )}
271
- {changelogCounts.improvements > 0 && (
272
- <span className="inline-flex items-center gap-1.5">
273
- <TrendingUp className="w-3.5 h-3.5" />
274
- {changelogCounts.improvements} {changelogCounts.improvements === 1 ? 'improvement' : 'improvements'}
275
- </span>
276
- )}
277
- {changelogCounts.breaking > 0 && (
278
- <span className="inline-flex items-center gap-1.5 text-[var(--ods-attention-yellow-warning)]">
279
- <AlertTriangle className="w-3.5 h-3.5" />
280
- {changelogCounts.breaking} breaking
281
- </span>
282
- )}
283
- </div>
284
- )}
282
+ {/* CHANGELOG STRIP — ALWAYS rendered so the skeleton's
283
+ always-on changelog placeholder matches the loaded shape
284
+ (zero reflow on resolve). When `totalChangelog === 0`, an
285
+ empty-state line takes the same vertical space as the
286
+ populated row. */}
287
+ <div className="border-t border-ods-border pt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 font-['DM_Sans'] text-sm text-ods-text-secondary">
288
+ {totalChangelog > 0 && changelogCounts ? (
289
+ <>
290
+ {changelogCounts.features > 0 && (
291
+ <span className="inline-flex items-center gap-1.5">
292
+ <Sparkles className="w-3.5 h-3.5" />
293
+ {changelogCounts.features} {changelogCounts.features === 1 ? 'feature' : 'features'}
294
+ </span>
295
+ )}
296
+ {changelogCounts.fixes > 0 && (
297
+ <span className="inline-flex items-center gap-1.5">
298
+ <Wrench className="w-3.5 h-3.5" />
299
+ {changelogCounts.fixes} {changelogCounts.fixes === 1 ? 'fix' : 'fixes'}
300
+ </span>
301
+ )}
302
+ {changelogCounts.improvements > 0 && (
303
+ <span className="inline-flex items-center gap-1.5">
304
+ <TrendingUp className="w-3.5 h-3.5" />
305
+ {changelogCounts.improvements} {changelogCounts.improvements === 1 ? 'improvement' : 'improvements'}
306
+ </span>
307
+ )}
308
+ {changelogCounts.breaking > 0 && (
309
+ <span className="inline-flex items-center gap-1.5 text-[var(--ods-attention-yellow-warning)]">
310
+ <AlertTriangle className="w-3.5 h-3.5" />
311
+ {changelogCounts.breaking} breaking
312
+ </span>
313
+ )}
314
+ </>
315
+ ) : (
316
+ <span className="text-sm text-ods-text-secondary">No changelog entries yet</span>
317
+ )}
318
+ </div>
285
319
 
286
- {/* METADATA GRID FOOTER — mirrors EntityAuthorCard byte-for-byte */}
287
- {totalCells > 0 && (
288
- <div
289
- className={cn(
290
- 'grid grid-cols-1',
291
- gridColsClass,
292
- 'border border-ods-border rounded-md overflow-hidden w-full',
293
- )}
294
- >
295
- {valueCells.map((cell, i) => (
296
- <div
297
- key={`${cell.label}-${i}`}
298
- className={cn(
299
- 'bg-ods-card p-4 flex flex-col gap-3',
300
- // Last value cell skips the trailing divider when no
301
- // author cell follows; otherwise every value cell gets it.
302
- (i < valueCells.length - 1 || hasAuthorCell) && dividerClass,
303
- )}
304
- >
305
- <div className="flex flex-col gap-0">
306
- {cell.colorScheme ? (
307
- <StatusBadge
308
- text={cell.value}
309
- variant="card"
310
- colorScheme={cell.colorScheme}
311
- singleLine
312
- className="self-start"
313
- />
314
- ) : (
315
- <p className="text-h4 text-ods-text-primary">
316
- {cell.uppercase ? cell.value.toLocaleUpperCase() : cell.value}
317
- </p>
318
- )}
319
- <p className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary">
320
- {cell.label}
321
- </p>
322
- </div>
323
- </div>
324
- ))}
325
- {hasAuthorCell && author && (
326
- <div className="bg-ods-card p-4 flex items-center gap-3">
327
- <SquareAvatar
328
- src={author.avatar_url ?? undefined}
329
- alt={author.full_name}
330
- fallback={author.full_name.charAt(0).toUpperCase()}
331
- size="md"
332
- variant="round"
333
- />
334
- <div className="flex flex-col gap-0 flex-1 min-w-0">
335
- <p className="text-h3 tracking-[-0.36px] text-ods-text-primary truncate">
336
- {author.full_name}
337
- </p>
338
- <p className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary">
339
- {author.job_title || 'Author'}
320
+ {/* METADATA GRID FOOTER — fixed 4-cell shape (Type / Status /
321
+ Released / Author) so the skeleton mirrors the loaded card
322
+ exactly. Empty value cells render em-dash + label (plain
323
+ text, no colored badge — em-dash badges read as broken next
324
+ to populated ones); the Author cell falls back to the
325
+ EMPTY_AUTHOR_PLACEHOLDER shape declared above. */}
326
+ <div
327
+ className={cn(
328
+ 'grid grid-cols-1',
329
+ gridColsClass,
330
+ 'border border-ods-border rounded-md overflow-hidden w-full',
331
+ )}
332
+ >
333
+ {valueCells.map((cell, i) => (
334
+ <div
335
+ key={`${cell.label}-${i}`}
336
+ className={cn('bg-ods-card p-4 flex flex-col gap-3', dividerClass)}
337
+ >
338
+ <div className="flex flex-col gap-0">
339
+ {cell.colorScheme ? (
340
+ <StatusBadge
341
+ text={cell.value}
342
+ variant="card"
343
+ colorScheme={cell.colorScheme}
344
+ singleLine
345
+ className="self-start"
346
+ />
347
+ ) : (
348
+ <p
349
+ className={cn(
350
+ 'text-h4',
351
+ // Em-dash placeholder reads as secondary text;
352
+ // populated values stay primary.
353
+ cell.value === '' ? 'text-ods-text-secondary' : 'text-ods-text-primary',
354
+ )}
355
+ >
356
+ {cell.uppercase ? cell.value.toLocaleUpperCase() : cell.value}
340
357
  </p>
341
- </div>
358
+ )}
359
+ <p className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary">
360
+ {cell.label}
361
+ </p>
342
362
  </div>
343
- )}
363
+ </div>
364
+ ))}
365
+ <div className="bg-ods-card p-4 flex items-center gap-3">
366
+ <SquareAvatar
367
+ src={effectiveAuthor.avatar_url ?? undefined}
368
+ alt={effectiveAuthor.full_name}
369
+ fallback={effectiveAuthor.full_name.charAt(0).toUpperCase()}
370
+ size="md"
371
+ variant="round"
372
+ />
373
+ <div className="flex flex-col gap-0 flex-1 min-w-0">
374
+ <p className="text-h3 tracking-[-0.36px] text-ods-text-primary truncate">
375
+ {effectiveAuthor.full_name}
376
+ </p>
377
+ <p className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary">
378
+ {effectiveAuthor.job_title || 'Author'}
379
+ </p>
380
+ </div>
344
381
  </div>
345
- )}
382
+ </div>
346
383
 
347
384
  {typeof viewCount === 'number' && viewCount > 0 && (
348
385
  <div className="flex items-center gap-1.5 text-xs text-ods-text-secondary">
@@ -463,7 +500,7 @@ export function ProductReleaseCard({
463
500
  as the loaded text — zero load-to-resolve baseline shift. */}
464
501
  <span className="flex min-w-0 flex-1 flex-col gap-0.5 min-h-14">
465
502
  <span className="flex items-center gap-2 min-w-0 h-5">
466
- <span className="truncate text-sm font-semibold leading-5 text-ods-text-primary min-w-0">
503
+ <span className="truncate text-sm font-semibold leading-5 text-ods-text-primary min-w-0" title={title}>
467
504
  {title}
468
505
  </span>
469
506
  {version ? (
@@ -478,7 +515,7 @@ export function ProductReleaseCard({
478
515
  </span>
479
516
  </span>
480
517
  <span className="flex items-center min-w-0 h-4">
481
- <span className="truncate text-[11px] leading-4 text-ods-text-secondary/80">
518
+ <span className="truncate text-[11px] leading-4 text-ods-text-secondary/80" title={summary || undefined}>
482
519
  {/* The literal between the curly-quote string is U+00A0
483
520
  (NBSP). The hub's `COMPACT_CARD_ROW_FILLER` is also
484
521
  NBSP; ASCII space here would let React collapse the
@@ -545,11 +582,11 @@ export function ProductReleaseCard({
545
582
  {/* Left column - content */}
546
583
  <div className="flex-1 w-full md:w-auto min-w-0 flex flex-col justify-center gap-2">
547
584
  <div className="min-h-[48px] flex items-center">
548
- <h3 className="text-h3 text-ods-text-primary tracking-[-0.36px] line-clamp-2">
585
+ <h3 className="text-h3 text-ods-text-primary tracking-[-0.36px] line-clamp-2" title={title}>
549
586
  {title}
550
587
  </h3>
551
588
  </div>
552
- <p className="text-h4 text-ods-text-secondary line-clamp-3">
589
+ <p className="text-h4 text-ods-text-secondary line-clamp-3" title={summary || ' '}>
553
590
  {summary || ' '}
554
591
  </p>
555
592
  </div>
@@ -585,11 +622,11 @@ export function ProductReleaseCard({
585
622
  {/* Left column - content */}
586
623
  <div className="flex-1 w-full md:w-auto min-w-0 flex flex-col justify-center gap-2">
587
624
  <div className="min-h-[48px] flex items-center">
588
- <h3 className="text-h3 text-ods-text-primary tracking-[-0.36px] line-clamp-2">
625
+ <h3 className="text-h3 text-ods-text-primary tracking-[-0.36px] line-clamp-2" title={title}>
589
626
  {title}
590
627
  </h3>
591
628
  </div>
592
- <p className="text-h4 text-ods-text-secondary line-clamp-3">
629
+ <p className="text-h4 text-ods-text-secondary line-clamp-3" title={summary || ' '}>
593
630
  {summary || ' '}
594
631
  </p>
595
632
  </div>
@@ -282,7 +282,7 @@ export function ReleaseDetailPage({
282
282
  variant="round"
283
283
  />
284
284
  <div className="flex flex-col gap-0 flex-1 min-w-0">
285
- <p className="text-h3 tracking-[-0.36px] text-ods-text-primary truncate">
285
+ <p className="text-h3 tracking-[-0.36px] text-ods-text-primary truncate" title={author?.full_name || 'Unknown Author'}>
286
286
  {author?.full_name || 'Unknown Author'}
287
287
  </p>
288
288
  <p className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary">
@@ -159,7 +159,7 @@ function CompactAssigneeDropdown({
159
159
  variant="round"
160
160
  className="h-6 w-6 shrink-0"
161
161
  />
162
- <span className="flex-1 truncate text-h4 text-ods-text-primary">{opt.label}</span>
162
+ <span className="flex-1 truncate text-h4 text-ods-text-primary" title={opt.label}>{opt.label}</span>
163
163
  {isCurrent && <CheckIcon className="size-4 shrink-0 text-ods-accent" />}
164
164
  </button>
165
165
  )
@@ -194,7 +194,7 @@ function DefaultAssigneeDropdown({
194
194
  variant="round"
195
195
  className="h-6 w-6 shrink-0"
196
196
  />
197
- <span className="truncate">{opt.label}</span>
197
+ <span className="truncate" title={opt.label}>{opt.label}</span>
198
198
  </div>
199
199
  )
200
200
  }, [])
@@ -253,7 +253,7 @@ function DefaultAssigneeDropdown({
253
253
  className="flex items-center gap-[var(--spacing-system-xxs)] cursor-pointer group text-left"
254
254
  >
255
255
  <PenEditIcon className="size-4 shrink-0 text-ods-text-secondary group-hover:text-ods-accent transition-colors" />
256
- <span className="text-h4 text-ods-text-primary truncate">{currentAssignee!.name}</span>
256
+ <span className="text-h4 text-ods-text-primary truncate" title={currentAssignee!.name}>{currentAssignee!.name}</span>
257
257
  </button>
258
258
  </div>
259
259
  <span className="text-h6 text-ods-text-secondary truncate">Assigned</span>
@@ -574,7 +574,7 @@ function AutocompleteInner<T = string>(
574
574
  )}
575
575
  onClick={handleCreate}
576
576
  >
577
- <span className="truncate">+ Create &quot;{inputValue.trim()}&quot;</span>
577
+ <span className="truncate" title={`+ Create "${inputValue.trim()}"`}>+ Create &quot;{inputValue.trim()}&quot;</span>
578
578
  </div>
579
579
  ) : (
580
580
  <div className="px-3 py-2 text-ods-text-secondary text-[14px]">
@@ -607,7 +607,7 @@ function AutocompleteInner<T = string>(
607
607
  >
608
608
  {renderOption ? renderOption(option, isSelected) : (
609
609
  <div className="flex items-center justify-between w-full min-w-0">
610
- <span className="truncate">{option.label}</span>
610
+ <span className="truncate" title={option.label}>{option.label}</span>
611
611
  {isSelected && (
612
612
  <CheckIcon className="text-ods-accent" size={20} />
613
613
  )}
@@ -16,7 +16,7 @@ const splitHalfBase = [
16
16
  "whitespace-nowrap transition-colors duration-200",
17
17
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-focus focus-visible:z-10",
18
18
  "disabled:pointer-events-none",
19
- "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:h-5 [&_svg]:w-5",
19
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0",
20
20
  ]
21
21
 
22
22
  const splitHalfVariants = cva(splitHalfBase, {
@@ -28,8 +28,8 @@ const splitHalfVariants = cva(splitHalfBase, {
28
28
  destructive: buttonSurfaceClasses.destructive,
29
29
  },
30
30
  size: {
31
- default: "h-12 px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3",
32
- small: "h-6 md:h-8 px-[var(--spacing-system-xs)] text-h5",
31
+ default: "h-10 md:h-12 px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3 [&_svg]:h-4 [&_svg]:w-4 md:[&_svg]:h-6 md:[&_svg]:w-6",
32
+ small: "h-6 md:h-8 px-[var(--spacing-system-xs)] text-h5 [&_svg]:h-3 [&_svg]:w-3 md:[&_svg]:h-4 md:[&_svg]:w-4",
33
33
  },
34
34
  side: { main: "", icon: "" },
35
35
  },
@@ -55,8 +55,6 @@ const splitHalfVariants = cva(splitHalfBase, {
55
55
  // Icon half: per Figma, narrower than main height (default: 40×48; small: 32×32).
56
56
  { side: "icon", size: "default", class: "w-10 px-0" },
57
57
  { side: "icon", size: "small", class: "w-6 md:w-8 px-0" },
58
-
59
- { size: "small", class: "[&_svg]:h-4 [&_svg]:w-4" },
60
58
  ],
61
59
  defaultVariants: { variant: "accent", size: "default", side: "main" },
62
60
  })
@@ -80,7 +80,7 @@ const CheckboxBlock = React.forwardRef<
80
80
  </div>
81
81
  </label>
82
82
  {error && (
83
- <p className="absolute bottom-0 left-0 right-0 translate-y-full text-h6 truncate text-ods-error">
83
+ <p className="absolute bottom-0 left-0 right-0 translate-y-full text-h6 truncate text-ods-error" title={error}>
84
84
  {error}
85
85
  </p>
86
86
  )}
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import Link from 'next/link'
4
- import React, { memo, useCallback, type ReactNode } from 'react'
4
+ import React, { memo, useCallback, useRef, type ReactNode } from 'react'
5
5
  import { flexRender, type Row } from '@tanstack/react-table'
6
6
  import { cn } from '../../../utils/cn'
7
7
  import { ROW_HEIGHT_DESKTOP } from './data-table-skeleton'
@@ -19,16 +19,28 @@ export interface DataTableRowProps<T> {
19
19
  /**
20
20
  * Click-bubbling protocol: any element inside a cell that should NOT trigger
21
21
  * `onRowClick` / row navigation must carry the `data-no-row-click` attribute.
22
- * The row checks `target.closest('[data-no-row-click]')` before firing
23
- * `onClick(item)`. This is the single primitive that interactive cells
24
- * (action buttons, dropdown menus, checkboxes) must opt into.
22
+ * The row checks `target.closest('[data-no-row-click]')` and short-circuits:
23
+ * in `onClick` mode it skips the consumer's handler; in link mode (when
24
+ * `href` is set) it calls `e.preventDefault()` so `<Link>` does not navigate.
25
+ *
26
+ * Clicks originating from portaled descendants (e.g. `FloatingTooltip`,
27
+ * dropdown menus rendered through `FloatingPortal`) bubble through React's
28
+ * component tree and reach this handler, but their DOM target lives outside
29
+ * the row subtree. The handler ignores any click whose target is not
30
+ * physically contained within the row element — no `stopPropagation`
31
+ * required at the source.
32
+ *
33
+ * In link mode the row IS the `<Link>` — content lives inside it, not under
34
+ * an absolute overlay — so native browser link behaviour works: hover,
35
+ * right-click "Open in new tab", middle-click, `Cmd+click`, focus outlines,
36
+ * `:visited` styles, etc.
25
37
  *
26
38
  * Example column with action buttons:
27
39
  * ```tsx
28
40
  * {
29
41
  * id: 'actions',
30
42
  * cell: ({ row }) => (
31
- * <div data-no-row-click className="flex gap-2 justify-end pointer-events-auto">
43
+ * <div data-no-row-click className="flex gap-2 justify-end">
32
44
  * <Button onClick={() => edit(row.original)}>Edit</Button>
33
45
  * </div>
34
46
  * ),
@@ -49,61 +61,83 @@ function DataTableRowImpl<T>({
49
61
  className,
50
62
  }: DataTableRowProps<T>) {
51
63
  const isLinkMode = Boolean(href) && !onClick
64
+ const containerRef = useRef<HTMLElement | null>(null)
52
65
 
53
66
  const handleClick = useCallback(
54
67
  (e: React.MouseEvent) => {
55
68
  const target = e.target as HTMLElement
56
- if (target.closest('[data-no-row-click]')) return
69
+ // React-bubbled events from portaled descendants (tooltips, dropdowns, etc.)
70
+ // reach this handler even though their DOM target lives outside the row.
71
+ // Suppress them — and in link mode, preventDefault so `<Link>` does not navigate.
72
+ if (!containerRef.current?.contains(target)) {
73
+ if (isLinkMode) e.preventDefault()
74
+ return
75
+ }
76
+ if (target.closest('[data-no-row-click]')) {
77
+ if (isLinkMode) e.preventDefault()
78
+ return
79
+ }
57
80
  onClick?.(row.original)
58
81
  },
59
- [onClick, row.original],
82
+ [onClick, row.original, isLinkMode],
60
83
  )
61
84
 
62
- return (
85
+ const containerClassName = cn(
86
+ 'block rounded-md bg-ods-card border border-ods-border overflow-hidden no-underline text-inherit',
87
+ (onClick || isLinkMode) && 'cursor-pointer hover:bg-ods-bg-active transition-colors',
88
+ className,
89
+ )
90
+
91
+ const cells = (
63
92
  <div
64
93
  className={cn(
65
- 'relative rounded-md bg-ods-card border border-ods-border overflow-hidden',
66
- (onClick || isLinkMode) &&
67
- 'cursor-pointer hover:bg-ods-bg-active transition-colors',
68
- className,
94
+ 'flex items-center gap-[var(--spacing-system-mf)] px-[var(--spacing-system-mf)]',
95
+ compact ? 'py-[var(--spacing-system-xsf)]' : `py-0 ${ROW_HEIGHT_DESKTOP}`,
69
96
  )}
70
- onClick={isLinkMode ? undefined : handleClick}
71
97
  >
72
- {isLinkMode && href && (
73
- <Link
74
- href={href}
75
- prefetch={false}
76
- className="absolute inset-0"
77
- aria-label="View details"
78
- />
79
- )}
80
- <div
81
- className={cn(
82
- 'relative flex items-center gap-[var(--spacing-system-mf)] px-[var(--spacing-system-mf)]',
83
- compact ? 'py-[var(--spacing-system-xsf)]' : `py-0 ${ROW_HEIGHT_DESKTOP}`,
84
- isLinkMode && 'pointer-events-none',
85
- )}
98
+ {row.getVisibleCells().map(cell => {
99
+ const meta = cell.column.columnDef.meta
100
+ return (
101
+ <div
102
+ key={cell.id}
103
+ className={cn(
104
+ 'flex flex-col overflow-hidden',
105
+ alignJustify(meta?.align),
106
+ meta?.width || 'flex-1 min-w-0',
107
+ meta?.cellClassName,
108
+ getHideClasses(meta?.hideAt),
109
+ )}
110
+ >
111
+ <CellContent>
112
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
113
+ </CellContent>
114
+ </div>
115
+ )
116
+ })}
117
+ </div>
118
+ )
119
+
120
+ if (isLinkMode && href) {
121
+ return (
122
+ <Link
123
+ href={href}
124
+ prefetch={false}
125
+ ref={containerRef as React.RefObject<HTMLAnchorElement>}
126
+ className={containerClassName}
127
+ onClick={handleClick}
86
128
  >
87
- {row.getVisibleCells().map(cell => {
88
- const meta = cell.column.columnDef.meta
89
- return (
90
- <div
91
- key={cell.id}
92
- className={cn(
93
- 'flex flex-col overflow-hidden',
94
- alignJustify(meta?.align),
95
- meta?.width || 'flex-1 min-w-0',
96
- meta?.cellClassName,
97
- getHideClasses(meta?.hideAt),
98
- )}
99
- >
100
- <CellContent>
101
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
102
- </CellContent>
103
- </div>
104
- )
105
- })}
106
- </div>
129
+ {cells}
130
+ </Link>
131
+ )
132
+ }
133
+
134
+ return (
135
+ <div
136
+ ref={containerRef as React.RefObject<HTMLDivElement>}
137
+ className={containerClassName}
138
+ onClick={onClick ? handleClick : undefined}
139
+ >
140
+ {cells}
107
141
  </div>
108
142
  )
109
143
  }
@@ -114,7 +148,7 @@ export const DataTableRow = memo(DataTableRowImpl) as typeof DataTableRowImpl
114
148
  function CellContent({ children }: { children: ReactNode }) {
115
149
  if (typeof children === 'string' || typeof children === 'number') {
116
150
  return (
117
- <span className="text-h4 text-ods-text-primary truncate">{children}</span>
151
+ <span className="text-h4 text-ods-text-primary truncate" title={String(children)}>{children}</span>
118
152
  )
119
153
  }
120
154
  return <>{children}</>