@flamingo-stack/openframe-frontend-core 0.0.199 → 0.0.200-snapshot.20260520171313

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 (64) hide show
  1. package/dist/{chunk-V2FNIPZJ.cjs → chunk-3B43AHYE.cjs} +2 -2
  2. package/dist/{chunk-TMD44IKJ.js.map → chunk-3B43AHYE.cjs.map} +1 -1
  3. package/dist/{chunk-TMD44IKJ.js → chunk-55HF462A.js} +2 -2
  4. package/dist/chunk-55HF462A.js.map +1 -0
  5. package/dist/{chunk-5URU5DHE.js → chunk-CSW5GYBU.js} +1005 -793
  6. package/dist/chunk-CSW5GYBU.js.map +1 -0
  7. package/dist/{chunk-332L6IO7.cjs → chunk-UCY537V4.cjs} +782 -570
  8. package/dist/chunk-UCY537V4.cjs.map +1 -0
  9. package/dist/components/features/index.cjs +3 -5
  10. package/dist/components/features/index.cjs.map +1 -1
  11. package/dist/components/features/index.d.ts +0 -1
  12. package/dist/components/features/index.d.ts.map +1 -1
  13. package/dist/components/features/index.js +2 -4
  14. package/dist/components/index.cjs +3 -3
  15. package/dist/components/index.cjs.map +1 -1
  16. package/dist/components/index.js +4 -4
  17. package/dist/components/layout/title-block.d.ts.map +1 -1
  18. package/dist/components/navigation/index.cjs +3 -3
  19. package/dist/components/navigation/index.js +2 -2
  20. package/dist/components/shared/product-release/product-release-card-skeleton.d.ts +1 -1
  21. package/dist/components/shared/product-release/product-release-card-skeleton.d.ts.map +1 -1
  22. package/dist/components/shared/product-release/product-release-card.d.ts +38 -2
  23. package/dist/components/shared/product-release/product-release-card.d.ts.map +1 -1
  24. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  25. package/dist/components/ui/entity-image.d.ts +9 -0
  26. package/dist/components/ui/entity-image.d.ts.map +1 -0
  27. package/dist/components/ui/file-manager/index.cjs +50 -50
  28. package/dist/components/ui/file-manager/index.js +1 -1
  29. package/dist/components/ui/index.cjs +5 -3
  30. package/dist/components/ui/index.cjs.map +1 -1
  31. package/dist/components/ui/index.d.ts +1 -0
  32. package/dist/components/ui/index.d.ts.map +1 -1
  33. package/dist/components/ui/index.js +4 -2
  34. package/dist/components/ui/organization-card.d.ts.map +1 -1
  35. package/dist/components/ui/release-changelog-section.d.ts +7 -1
  36. package/dist/components/ui/release-changelog-section.d.ts.map +1 -1
  37. package/dist/index.cjs +3 -3
  38. package/dist/index.cjs.map +1 -1
  39. package/dist/index.js +4 -4
  40. package/dist/types/index.cjs.map +1 -1
  41. package/dist/types/index.js.map +1 -1
  42. package/dist/types/product-release.d.ts +12 -0
  43. package/dist/types/product-release.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/components/features/index.ts +0 -1
  46. package/src/components/layout/title-block.tsx +6 -30
  47. package/src/components/shared/product-release/product-release-card-skeleton.tsx +69 -1
  48. package/src/components/shared/product-release/product-release-card.tsx +334 -4
  49. package/src/components/shared/product-release/release-detail-page.tsx +8 -2
  50. package/src/components/ui/button/button.tsx +1 -1
  51. package/src/components/ui/checkbox-block.tsx +13 -13
  52. package/src/components/ui/entity-image.tsx +56 -0
  53. package/src/components/ui/index.ts +1 -0
  54. package/src/components/ui/organization-card.tsx +4 -8
  55. package/src/components/ui/release-changelog-section.tsx +29 -2
  56. package/src/stories/CheckboxBlock.stories.tsx +1 -3
  57. package/src/stories/OrganizationCard.stories.tsx +14 -0
  58. package/src/types/product-release.ts +12 -0
  59. package/dist/chunk-332L6IO7.cjs.map +0 -1
  60. package/dist/chunk-5URU5DHE.js.map +0 -1
  61. package/dist/chunk-V2FNIPZJ.cjs.map +0 -1
  62. package/dist/components/features/organization-icon.d.ts +0 -80
  63. package/dist/components/features/organization-icon.d.ts.map +0 -1
  64. package/src/components/features/organization-icon.tsx +0 -175
@@ -1,8 +1,20 @@
1
1
  'use client'
2
2
 
3
3
  import React from 'react'
4
+ import Image from 'next/image'
4
5
  import { InteractiveCard } from '../../ui/interactive-card'
5
- import { ChevronRight, Package } from 'lucide-react'
6
+ import { StatusBadge } from '../../ui/status-badge'
7
+ import { SquareAvatar } from '../../ui/square-avatar'
8
+ import {
9
+ AlertTriangle,
10
+ ChevronRight,
11
+ Eye,
12
+ Package,
13
+ Play,
14
+ Sparkles,
15
+ TrendingUp,
16
+ Wrench,
17
+ } from 'lucide-react'
6
18
  import { cn } from '../../../utils/cn'
7
19
 
8
20
  /**
@@ -14,8 +26,13 @@ import { cn } from '../../../utils/cn'
14
26
  * illegal inside markdown `<p>`) for `<span>` text, swaps the outer
15
27
  * `InteractiveCard` for a `<span>`-anchored link, and collapses to:
16
28
  * 56px icon + 1-line title + 1-line meta (version · date).
29
+ * - `catalog`: rich /releases catalog row. Three zones — hero (16:9 cover +
30
+ * version pill + title + summary), changelog stats strip (icons + counts),
31
+ * metadata grid footer (Type · Status · Released · Author). The grid
32
+ * mirrors the hub's `<EntityAuthorCard>` byte-for-byte (see catalog
33
+ * branch comment).
17
34
  */
18
- export type ProductReleaseCardSize = 'default' | 'sm'
35
+ export type ProductReleaseCardSize = 'default' | 'sm' | 'catalog'
19
36
 
20
37
  /**
21
38
  * Minimal structural `<a>` prop bundle the consumer composes (typically
@@ -65,6 +82,39 @@ export interface ProductReleaseCardProps {
65
82
  className?: string
66
83
  /** Card density. Defaults to `'default'`. */
67
84
  size?: ProductReleaseCardSize
85
+
86
+ // ─── Catalog-only props (ignored by `default` / `sm` branches) ─────────
87
+ /** Cover image URL. Falls back to a neutral `Package`-icon placeholder. */
88
+ coverImage?: string | null
89
+ /** Drives the Play overlay on the cover. Caller sets `true` when the cover
90
+ * came from a `*_video_thumbnail` field. */
91
+ hasVideoCover?: boolean
92
+ /** Release type for the metadata grid's first cell. */
93
+ releaseType?: 'major' | 'minor' | 'patch' | 'beta' | 'alpha'
94
+ /** Release status for the metadata grid's second cell. */
95
+ releaseStatus?: 'alpha' | 'beta' | 'stable' | 'deprecated'
96
+ /** Pre-computed `StatusBadge` colorScheme for the release-type chip. The
97
+ * hub consumer maps `release_type → colorScheme` via its local helper so
98
+ * the OSS card stays mapping-agnostic. */
99
+ releaseTypeBadgeColor?: 'error' | 'cyan' | 'success' | 'warning'
100
+ /** View count for the optional microline below the metadata grid. Hidden
101
+ * when zero or undefined. */
102
+ viewCount?: number
103
+ /** Hydrated author (from the hub DAL's `hydrateAuthor`). When present,
104
+ * renders as the last cell of the metadata grid. */
105
+ author?: {
106
+ full_name: string
107
+ avatar_url: string | null
108
+ job_title: string | null
109
+ }
110
+ /** Per-category counts for the changelog stats strip. The whole strip
111
+ * is hidden when total === 0. */
112
+ changelogCounts?: {
113
+ features: number
114
+ fixes: number
115
+ improvements: number
116
+ breaking: number
117
+ }
68
118
  }
69
119
 
70
120
  export function ProductReleaseCard({
@@ -76,7 +126,265 @@ export function ProductReleaseCard({
76
126
  anchorProps,
77
127
  className,
78
128
  size = 'default',
129
+ coverImage,
130
+ hasVideoCover,
131
+ releaseType,
132
+ releaseStatus,
133
+ releaseTypeBadgeColor,
134
+ viewCount,
135
+ author,
136
+ changelogCounts,
79
137
  }: ProductReleaseCardProps) {
138
+ // ----- CATALOG branch (rich /releases catalog row) -------------------------
139
+ // The card has THREE zones:
140
+ // 1. Hero — cover image LEFT, version pill + title + summary RIGHT.
141
+ // 2. Changelog strip — icons + counts (hidden when total === 0).
142
+ // 3. Metadata grid footer — bordered grid of [Type | Status | Released
143
+ // | Author] cells. This grid INLINES the hub's <EntityAuthorCard>
144
+ // visual treatment by hand (SAME bordered grid with value-cell +
145
+ // author-cell shapes, byte-for-byte). The OSS lib has zero hub
146
+ // coupling by design; we cannot import the hub's
147
+ // <EntityAuthorCard>. This is the SAME inline-duplication policy
148
+ // documented for the COMPACT_CARD_* string set at lines ~104-108.
149
+ // If the hub's <EntityAuthorCard> visual changes (cell padding,
150
+ // divider styles, avatar size, etc.), update this branch in
151
+ // lockstep.
152
+ if (size === 'catalog') {
153
+ const totalChangelog =
154
+ (changelogCounts?.features ?? 0) +
155
+ (changelogCounts?.fixes ?? 0) +
156
+ (changelogCounts?.improvements ?? 0) +
157
+ (changelogCounts?.breaking ?? 0)
158
+
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.
163
+ type ValueCell = {
164
+ value: string
165
+ label: string
166
+ uppercase: boolean
167
+ colorScheme?: 'error' | 'cyan' | 'success' | 'warning'
168
+ }
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'
200
+ const dividerClass = 'border-b md:border-b-0 md:border-r border-ods-border'
201
+
202
+ const frameClass = cn(
203
+ 'group bg-ods-system-greys-black border border-ods-border rounded-lg overflow-hidden',
204
+ 'flex flex-col p-6 gap-4',
205
+ 'transition-all duration-300 ease-out transform hover:translate-y-[-2px]',
206
+ 'hover:border-ods-accent hover:shadow-lg hover:shadow-ods-accent/[0.08]',
207
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-2 focus-visible:ring-offset-ods-bg',
208
+ 'no-underline',
209
+ className,
210
+ )
211
+
212
+ const innerLayout = (
213
+ <>
214
+ {/* HERO ZONE — cover LEFT + version pill + title + summary RIGHT */}
215
+ <div className="flex flex-col md:flex-row gap-4 md:gap-6">
216
+ <div className="w-full md:w-[256px] flex-shrink-0">
217
+ <div className="relative rounded-lg overflow-hidden w-full aspect-[16/9] bg-ods-bg">
218
+ {coverImage ? (
219
+ <Image
220
+ src={coverImage}
221
+ alt={title}
222
+ fill
223
+ sizes="(max-width: 768px) 100vw, 256px"
224
+ className="object-cover"
225
+ unoptimized
226
+ />
227
+ ) : (
228
+ <div className="absolute inset-0 flex items-center justify-center text-ods-text-secondary">
229
+ <Package className="w-8 h-8" />
230
+ </div>
231
+ )}
232
+ {hasVideoCover && coverImage && (
233
+ <span className="absolute inset-0 flex items-center justify-center bg-black/30">
234
+ <Play className="w-10 h-10 text-white" fill="white" />
235
+ </span>
236
+ )}
237
+ </div>
238
+ </div>
239
+ <div className="flex-1 min-w-0 flex flex-col">
240
+ <div className="flex items-center gap-3 mb-3">
241
+ <span className="font-mono font-semibold text-lg text-ods-text-primary truncate">
242
+ v{version}
243
+ </span>
244
+ </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}
251
+ </p>
252
+ )}
253
+ </div>
254
+ </div>
255
+
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
+ )}
285
+
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'}
340
+ </p>
341
+ </div>
342
+ </div>
343
+ )}
344
+ </div>
345
+ )}
346
+
347
+ {typeof viewCount === 'number' && viewCount > 0 && (
348
+ <div className="flex items-center gap-1.5 text-xs text-ods-text-secondary">
349
+ <Eye className="w-3.5 h-3.5" />
350
+ <span>{viewCount.toLocaleString()} views</span>
351
+ </div>
352
+ )}
353
+ </>
354
+ )
355
+
356
+ // Outer-element three-branch decision tree, matching the existing
357
+ // `default` branch precedence at lines ~200-241. PRECEDENCE:
358
+ // anchorProps WINS over onClick.
359
+ if (anchorProps) {
360
+ return (
361
+ <a {...anchorProps} className={frameClass} aria-label={`Open ${title}`}>
362
+ {innerLayout}
363
+ </a>
364
+ )
365
+ }
366
+ if (onClick) {
367
+ return (
368
+ <InteractiveCard clickable onClick={onClick} className={frameClass}>
369
+ {innerLayout}
370
+ </InteractiveCard>
371
+ )
372
+ }
373
+ // Non-interactive fallback — strip the hover lift / accent-border so
374
+ // the cursor doesn't lie about clickability.
375
+ return (
376
+ <div
377
+ className={cn(
378
+ frameClass
379
+ .replace('hover:border-ods-accent', '')
380
+ .replace('hover:translate-y-[-2px]', ''),
381
+ )}
382
+ >
383
+ {innerLayout}
384
+ </div>
385
+ )
386
+ }
387
+
80
388
  // ----- COMPACT branch (chat / tight surfaces) ------------------------------
81
389
  // Outer must be a phrasing-content element (`<a>` or `<span>`) — block
82
390
  // elements like `<div>`/`<h3>` are illegal inside markdown `<p>`, so we
@@ -120,8 +428,30 @@ export function ProductReleaseCard({
120
428
  )
121
429
  const innerChildren = (
122
430
  <>
123
- <span className="flex h-14 w-14 aspect-square shrink-0 self-start items-center justify-center rounded-md bg-ods-bg text-ods-accent">
124
- <Package className="h-5 w-5" />
431
+ {/* 56×56 cover slot. Mirrors BlogCard / ProgramCard / OnboardingGuide
432
+ sm slots when `coverImage` is set, render the actual image
433
+ (object-contain to keep landscape thumbs visible); fall back to
434
+ the `Package` icon when no cover is provided. Play overlay
435
+ fires only when `hasVideoCover` is true AND a cover image was
436
+ actually supplied (matches the catalog variant's overlay rule). */}
437
+ <span className="relative flex h-14 w-14 aspect-square shrink-0 self-start items-center justify-center overflow-hidden rounded-md bg-ods-bg text-ods-accent">
438
+ {coverImage ? (
439
+ <Image
440
+ src={coverImage}
441
+ alt={title}
442
+ fill
443
+ sizes="56px"
444
+ className="object-contain"
445
+ unoptimized
446
+ />
447
+ ) : (
448
+ <Package className="h-5 w-5" />
449
+ )}
450
+ {hasVideoCover && coverImage && (
451
+ <span className="absolute inset-0 flex items-center justify-center bg-black/30">
452
+ <Play className="h-4 w-4 text-white" fill="white" />
453
+ </span>
454
+ )}
125
455
  </span>
126
456
  {/* Text column structure must mirror the hub's
127
457
  `COMPACT_CARD_TEXT_COL` + `COMPACT_CARD_TITLE_ROW` +
@@ -9,7 +9,7 @@ import { StatusBadge } from '../../ui/status-badge';
9
9
  import { SquareAvatar } from '../../ui/square-avatar';
10
10
  import { ImageGalleryModal } from '../../ui/image-gallery-modal';
11
11
  import { GitHubIcon } from '../../icons/github-icon';
12
- import { AlertTriangle, ExternalLink, BookMarked } from 'lucide-react';
12
+ import { AlertTriangle, ExternalLink, BookMarked, Sparkles, TrendingUp, Wrench } from 'lucide-react';
13
13
  import { formatReleaseDate } from '../../../utils/date-formatters';
14
14
  import { Video } from '../../features/video';
15
15
  import { DetailPageSkeleton } from '../detail-page-skeleton';
@@ -392,27 +392,33 @@ export function ReleaseDetailPage({
392
392
  </Card>
393
393
  )}
394
394
 
395
- {/* Changelog Sections */}
395
+ {/* Changelog Sections — icons match the catalog card's changelog
396
+ strip taxonomy (Sparkles/Wrench/TrendingUp/AlertTriangle) so the
397
+ user sees a consistent visual signature across catalog → detail. */}
396
398
  <ReleaseChangelogSection
397
399
  title="Breaking Changes"
398
400
  entries={breakingChanges || []}
399
401
  isBreaking
400
402
  hideTitle
403
+ icon={<AlertTriangle className="h-6 w-6" />}
401
404
  SimpleMarkdownRenderer={MarkdownRenderer}
402
405
  />
403
406
  <ReleaseChangelogSection
404
407
  title="Features Added"
405
408
  entries={featuresAdded || []}
409
+ icon={<Sparkles className="h-6 w-6" />}
406
410
  SimpleMarkdownRenderer={MarkdownRenderer}
407
411
  />
408
412
  <ReleaseChangelogSection
409
413
  title="Bugs Fixed"
410
414
  entries={bugFixed || []}
415
+ icon={<Wrench className="h-6 w-6" />}
411
416
  SimpleMarkdownRenderer={MarkdownRenderer}
412
417
  />
413
418
  <ReleaseChangelogSection
414
419
  title="Improvements"
415
420
  entries={improvements || []}
421
+ icon={<TrendingUp className="h-6 w-6" />}
416
422
  SimpleMarkdownRenderer={MarkdownRenderer}
417
423
  />
418
424
 
@@ -27,7 +27,7 @@ const buttonVariants = cva(
27
27
  destructive: buttonSurfaceClasses.destructive,
28
28
  },
29
29
  size: {
30
- default: "py-[var(--spacing-system-sf)] px-[var(--spacing-system-m)] text-h3 h-12",
30
+ default: "py-[var(--spacing-system-sf)] px-[var(--spacing-system-m)] text-h3 md:h-12 h-10",
31
31
  small: "p-[var(--spacing-system-xs)] text-h5 h-6 md:h-8",
32
32
  "small-legacy": "py-[var(--spacing-system-xs)] px-[var(--spacing-system-m)] h-10 text-[14px] font-bold", // Temporary alias for "small" to avoid breaking changes in AnnouncementBar's CTA button; will be removed in the future
33
33
  icon: "p-[var(--spacing-system-sf)] h-11 w-11 md:h-12 md:w-12 [&_svg]:h-4 [&_svg]:w-4 md:[&_svg]:h-6 md:[&_svg]:w-6",
@@ -28,14 +28,15 @@ const CheckboxBlock = React.forwardRef<
28
28
  <label
29
29
  htmlFor={id}
30
30
  className={cn(
31
- "flex items-center gap-[var(--spacing-system-s)] rounded-[6px] border w-full",
31
+ "flex items-center gap-[var(--spacing-system-s)] rounded-md ring-1 ring-inset w-full",
32
32
  "p-[var(--spacing-system-sf)]",
33
- !description && "h-11 md:h-12",
34
- "bg-ods-card border-ods-border",
33
+ !description && "min-h-[44px] md:min-h-[48px]",
34
+ description && "min-h-[60px] md:min-h-[64px]",
35
+ "bg-ods-card ring-ods-border",
35
36
  "cursor-pointer transition-colors duration-200",
36
- "hover:border-ods-accent/30",
37
- disabled && "opacity-50 cursor-not-allowed hover:border-ods-border",
38
- error && "border-ods-error",
37
+ "hover:ring-ods-accent/30",
38
+ disabled && "opacity-50 cursor-not-allowed hover:ring-ods-border",
39
+ error && "ring-ods-error",
39
40
  )}
40
41
  >
41
42
  <CheckboxPrimitive.Root
@@ -46,7 +47,7 @@ const CheckboxBlock = React.forwardRef<
46
47
  onCheckedChange={onCheckedChange}
47
48
  disabled={disabled}
48
49
  className={cn(
49
- "h-6 w-6 shrink-0",
50
+ "h-4 w-4 md:h-6 md:w-6 shrink-0",
50
51
  "rounded-[6px] border-2",
51
52
  error ? "border-ods-error" : "border-[var(--color-border-strong)]",
52
53
  "bg-ods-card",
@@ -58,21 +59,20 @@ const CheckboxBlock = React.forwardRef<
58
59
  <CheckboxPrimitive.Indicator
59
60
  className="flex items-center justify-center text-ods-text-on-accent"
60
61
  >
61
- <CheckboxCheckmarkIcon size={10} />
62
+ <CheckboxCheckmarkIcon className="w-2 h-2 md:w-2.5 md:h-2.5" />
62
63
  </CheckboxPrimitive.Indicator>
63
64
  </CheckboxPrimitive.Root>
64
65
  <div className="flex flex-1 flex-col justify-center min-w-0">
65
66
  <span className={cn(
66
- "text-h4",
67
- "text-ods-text-primary select-none",
68
- !description && "truncate"
67
+ "text-h4 !leading-5 md:!leading-6",
68
+ "text-ods-text-primary select-none break-words"
69
69
  )}>
70
70
  {label}
71
71
  </span>
72
72
  {description && (
73
73
  <span className={cn(
74
- "text-h6",
75
- "text-ods-text-secondary select-none"
74
+ "text-h6 !leading-4",
75
+ "text-ods-text-secondary select-none break-words"
76
76
  )}>
77
77
  {description}
78
78
  </span>
@@ -0,0 +1,56 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+ import { cn } from '../../utils/cn'
5
+
6
+ function getInitials(name?: string): string {
7
+ if (!name) return ''
8
+ const words = name.trim().split(/\s+/)
9
+ if (words.length === 1) return words[0].charAt(0).toUpperCase()
10
+ return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase()
11
+ }
12
+
13
+ export interface EntityImageProps {
14
+ src?: string | null
15
+ alt?: string
16
+ /** Overrides the initials source. Defaults to `alt`. */
17
+ fallbackText?: string
18
+ className?: string
19
+ }
20
+
21
+ export function EntityImage({ src, alt, fallbackText, className }: EntityImageProps) {
22
+ const [imageFailed, setImageFailed] = React.useState(false)
23
+
24
+ React.useEffect(() => {
25
+ setImageFailed(false)
26
+ }, [src])
27
+
28
+ const showFallback = imageFailed || !src
29
+ const initials = getInitials(fallbackText ?? alt)
30
+
31
+ if (showFallback) {
32
+ return (
33
+ <div
34
+ aria-label={alt}
35
+ className={cn(
36
+ 'size-[52px] md:size-[60px] shrink-0 rounded-md border border-ods-border bg-ods-bg flex items-center justify-center text-ods-text-secondary text-h4 select-none',
37
+ className,
38
+ )}
39
+ >
40
+ {initials || '?'}
41
+ </div>
42
+ )
43
+ }
44
+
45
+ return (
46
+ <img
47
+ src={src ?? undefined}
48
+ alt={alt ?? ''}
49
+ onError={() => setImageFailed(true)}
50
+ className={cn(
51
+ 'size-[52px] md:size-[60px] shrink-0 rounded-md border border-ods-border object-cover',
52
+ className,
53
+ )}
54
+ />
55
+ )
56
+ }
@@ -72,6 +72,7 @@ export { CheckIcon, CheckCircleIcon as LucideCheckCircleIcon, XIcon as LucideXIc
72
72
  export * from './dashboard-info-card'
73
73
  export * from './device-card'
74
74
  export * from './device-card-compact'
75
+ export * from './entity-image'
75
76
  export * from './feature-card'
76
77
  export * from './feature-list'
77
78
  export { FloatingTooltip } from './floating-tooltip'
@@ -4,7 +4,7 @@ import React from "react"
4
4
  import Link from "next/link"
5
5
  import { Monitor } from "lucide-react"
6
6
  import { cn } from "../../utils/cn"
7
- import { OrganizationIcon } from "../features/organization-icon"
7
+ import { EntityImage } from "./entity-image"
8
8
 
9
9
  export interface Organization {
10
10
  id: string
@@ -98,13 +98,9 @@ export function OrganizationCard({
98
98
 
99
99
  {/* Header */}
100
100
  <div className="flex items-start gap-3 w-full">
101
- <OrganizationIcon
102
- imageUrl={fetchedImageUrl || organization.imageUrl}
103
- organizationName={organization.name}
104
- size="xl"
105
- backgroundStyle="dark"
106
- showBackground={true}
107
- className="w-[60px] h-[60px]"
101
+ <EntityImage
102
+ src={fetchedImageUrl || organization.imageUrl}
103
+ alt={organization.name}
108
104
  />
109
105
 
110
106
  <div className="flex-1 flex flex-col justify-center py-2 min-w-0">
@@ -14,6 +14,12 @@ interface ReleaseChangelogSectionProps {
14
14
  collapsible?: boolean;
15
15
  /** Initial collapsed state (only used when collapsible=true). Defaults to true (collapsed). */
16
16
  defaultCollapsed?: boolean;
17
+ /** Optional lucide icon rendered inline before the title text. Matches the
18
+ * catalog card's changelog-strip icons (Sparkles for Features, Wrench for
19
+ * Fixes, TrendingUp for Improvements, AlertTriangle for Breaking) — same
20
+ * visual taxonomy across catalog and detail. Inherits the title's color
21
+ * (secondary for normal sections, red for breaking). */
22
+ icon?: React.ReactNode;
17
23
  SimpleMarkdownRenderer: React.ComponentType<{ content: string }>;
18
24
  }
19
25
 
@@ -24,6 +30,7 @@ export function ReleaseChangelogSection({
24
30
  hideTitle = false,
25
31
  collapsible = false,
26
32
  defaultCollapsed = true,
33
+ icon,
27
34
  SimpleMarkdownRenderer
28
35
  }: ReleaseChangelogSectionProps) {
29
36
  const [collapsed, setCollapsed] = useState(collapsible ? defaultCollapsed : false);
@@ -42,6 +49,7 @@ export function ReleaseChangelogSection({
42
49
  className="flex items-center justify-between w-full cursor-pointer"
43
50
  >
44
51
  <h2 className={`flex items-center gap-2 text-2xl font-bold ${isBreaking ? 'text-red-500' : 'text-ods-text-primary'}`}>
52
+ {icon}
45
53
  {title}
46
54
  <Badge variant="secondary" className="ml-2">{entries.length}</Badge>
47
55
  </h2>
@@ -53,6 +61,7 @@ export function ReleaseChangelogSection({
53
61
  </button>
54
62
  ) : (
55
63
  <h2 className={`flex items-center gap-2 text-2xl font-bold ${isBreaking ? 'text-red-500' : 'text-ods-text-primary'}`}>
64
+ {icon}
56
65
  {title}
57
66
  <Badge variant="secondary" className="ml-2">{entries.length}</Badge>
58
67
  </h2>
@@ -62,9 +71,27 @@ export function ReleaseChangelogSection({
62
71
  <ul className="space-y-6">
63
72
  {entries.map((entry, index) => (
64
73
  <li key={index} className="border-l-2 border-ods-border pl-4 ml-0">
65
- <p className="font-['DM_Sans'] font-semibold text-[20px] leading-[24px] text-ods-text-primary mb-2">{entry.title}</p>
74
+ {/* Entry title — `text-h3` is body family + BOLD weight (per
75
+ ODS tokens: `--font-h3-weight: var(--font-weight-bold)`)
76
+ at 14/18px responsive. Same body size as the description
77
+ below, distinguished by weight — clean visual hierarchy
78
+ without inflating the body scale. */}
79
+ <p className="text-h3 text-ods-text-primary mb-2">{entry.title}</p>
66
80
  {entry.description && (
67
- <div className="[&_p]:!font-['DM_Sans'] [&_p]:!font-medium [&_p]:!text-[18px] [&_p]:!leading-[24px] [&_p]:!text-ods-text-primary [&_p]:!my-1">
81
+ /* Entry description body text matches the main release
82
+ summary (release-detail-page.tsx:321) at the SAME 14/18px
83
+ responsive `text-h4` scale. The `SimpleMarkdownRenderer`
84
+ forces its own `<p>` typography
85
+ (`text-[16px] md:text-[18px] lg:text-[20px]`) which
86
+ overrides the wrapper's `text-h4` on `lg+` viewports and
87
+ inflates the changelog body to 20px — larger than the
88
+ main summary AND larger than the entry title.
89
+ The `[&_p]:!` overrides pin every descendant `<p>` back
90
+ to the h4 responsive tokens (`var(--font-size-h4-body)`
91
+ + `var(--font-line-space-h4-body)`) — same variables
92
+ `text-h4` itself uses, so the responsive breakpoints
93
+ stay aligned with the rest of the page. */
94
+ <div className="text-h4 text-ods-text-primary [&_p]:!text-[length:var(--font-size-h4-body)] [&_p]:!leading-[var(--font-line-space-h4-body)] [&_p]:!font-medium">
68
95
  <SimpleMarkdownRenderer content={entry.description} />
69
96
  </div>
70
97
  )}
@@ -186,9 +186,7 @@ export const FlamingoError: Story = {
186
186
  },
187
187
  decorators: [
188
188
  (Story) => (
189
- <div data-app-type="flamingo" style={{ width: '320px' }}>
190
- <Story />
191
- </div>
189
+ <Story />
192
190
  ),
193
191
  ],
194
192
  };