@flamingo-stack/openframe-frontend-core 0.0.203 → 0.0.204

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.
@@ -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">