{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-UQEOAPAI.cjs","../src/components/ui/card.tsx","../src/utils/format.ts","../src/components/ui/square-avatar.tsx","../src/components/ui/interactive-card.tsx","../src/components/ui/modal.tsx","../src/components/ui/image-gallery-modal.tsx","../src/components/ui/status-badge.tsx","../src/utils/image-proxy.ts","../src/components/chat/utils/is-cross-origin-url.ts","../src/components/chat/utils/decide-new-tab.ts","../src/components/chat/utils/nav-anchor-props.ts","../src/components/chat/entity-cards/entity-author-card.tsx","../src/components/chat/entity-cards/blog-image-placeholder.tsx","../src/components/chat/entity-cards/use-cover-image-fallback.ts","../src/components/chat/entity-cards/entity-portrait-card.tsx","../src/components/features/platform-badge.tsx","../src/components/flamingo-logo.tsx","../src/components/openmsp-logo.tsx","../src/components/chat/entity-cards/admin-content-card.tsx","../src/utils/image-proxy-stub.ts","../src/components/chat/entity-cards/employee-entry-card.tsx","../src/components/chat/entity-cards/what-i-shipped-card.tsx","../src/components/chat/entity-cards/how-i-work-card.tsx","../src/components/features/video-bites-shared.ts","../src/components/shared/product-release/product-release-card.tsx","../src/components/shared/product-release/product-release-card-skeleton.tsx","../src/utils/date-formatters.ts","../src/utils/release-badge.ts","../src/utils/release-cover.ts","../src/components/chat/entity-cards/product-release-card-defaults.ts","../src/components/chat/utils/compact-card-classes.ts","../src/components/chat/entity-cards/blog-card.tsx","../src/components/chat/entity-cards/use-entity-card-link.ts","../src/components/chat/entity-cards/use-entity-card-placeholder.ts","../src/components/chat/entity-cards/case-study-card.tsx","../src/components/chat/entity-cards/customer-interview-card.tsx","../src/components/chat/types/entities/investor-update.ts","../src/components/chat/entity-cards/investor-update-card.tsx","../src/components/chat/entity-cards/onboarding-guide-card.tsx","../src/components/ui/avatar-stack.tsx","../src/components/chat/utils/agent-status-message.ts","../src/components/chat/utils/clickup-task-type-utils.ts","../src/components/chat/entity-cards/roadmap-vote-button.tsx","../src/components/chat/entity-cards/task-type-icon.tsx","../src/components/chat/entity-cards/roadmap-card.tsx","../src/utils/media-type.ts","../src/components/chat/types/entities/program-types.ts","../src/components/chat/entity-cards/program-card.tsx","../src/utils/extract-items.ts","../src/utils/list-url.ts"],"names":["format","forwardRef","useEffect","getProxiedImageUrl","dateStr","summary","itemDate"],"mappings":"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A;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-UQEOAPAI.cjs","sourcesContent":[null,"'use client';\n\nimport { type HTMLAttributes, type ReactNode, forwardRef } from 'react';\n\nimport { cn } from '../../utils/cn';\n\nconst Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\n      // `border` with NO color token resolves to `currentColor` — on a dark card\n      // with light text that paints a WHITE outline. Every ODS surface borders\n      // on `--color-border`; say so.\n      'rounded-lg border border-ods-border bg-card text-card-foreground shadow-sm',\n      className,\n    )}\n    {...props}\n  />\n));\nCard.displayName = 'Card';\n\nconst CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />\n));\nCardHeader.displayName = 'CardHeader';\n\nconst CardTitle = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn('text-h2', className)} {...props} />\n));\nCardTitle.displayName = 'CardTitle';\n\nconst CardDescription = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn('text-muted-foreground text-h6', className)} {...props} />\n));\nCardDescription.displayName = 'CardDescription';\n\nconst CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />\n));\nCardContent.displayName = 'CardContent';\n\nconst CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />\n));\nCardFooter.displayName = 'CardFooter';\n\n// Unified horizontal card for homepage category section\ninterface CardHorizontalProps {\n  icon: ReactNode;\n  title: string;\n  description: string;\n  className?: string;\n  borderLeft?: boolean;\n}\n\nexport function CardHorizontal({ icon, title, description, className = '', borderLeft = true }: CardHorizontalProps) {\n  return (\n    <div\n      className={cn(\n        'flex min-h-[80px] w-full flex-row items-center gap-3 bg-ods-card p-4 md:gap-4 md:p-6',\n        borderLeft ? 'border-l border-ods-border' : '',\n        className,\n      )}\n    >\n      <div className=\"h-5 w-5 flex-shrink-0\">{icon}</div>\n      <div className=\"flex min-w-0 flex-col\">\n        <span className=\"mb-0.5 text-left text-ods-text-primary text-h6\">{title}</span>\n        <span className=\"text-left text-ods-text-secondary text-h6\">{description}</span>\n      </div>\n    </div>\n  );\n}\n\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };\n","/**\n * Utility functions for formatting data\n */\n\n/**\n * Format a date to a human-readable string\n * @param date - The date to format (Date object or ISO string)\n * @param options - Formatting options\n * @returns Formatted date string\n */\nexport function formatDate(\n  date: Date | string,\n  options: Intl.DateTimeFormatOptions = {\n    year: 'numeric',\n    month: 'long',\n    day: 'numeric',\n  },\n): string {\n  const dateObj = typeof date === 'string' ? new Date(date) : date;\n\n  // Check if the date is valid\n  if (isNaN(dateObj.getTime())) {\n    console.warn('Invalid date provided to formatDate:', date);\n    return 'Invalid Date';\n  }\n\n  // Pin to UTC by default so SSR (Vercel = UTC) and the client agree (React\n  // #418 hydration mismatch). A caller can still override by passing its own\n  // `timeZone` in `options`.\n  return dateObj.toLocaleDateString('en-US', { timeZone: 'UTC', ...options });\n}\n\n/**\n * Format a number with thousands separators\n * @param num - The number to format\n * @returns Formatted number string\n */\nexport function formatNumber(num: number): string {\n  return num.toLocaleString();\n}\n\n/**\n * Format a price with currency symbol\n * @param price - The price to format\n * @param currency - The currency code\n * @returns Formatted price string\n */\nexport function formatPrice(price: number, currency = 'USD'): string {\n  return new Intl.NumberFormat('en-US', {\n    style: 'currency',\n    currency,\n  }).format(price);\n}\n\n/**\n * Format bytes to a human-readable string (KB, MB, GB, etc.)\n * @param bytes - The number of bytes\n * @param decimals - Number of decimal places\n * @returns Formatted bytes string\n */\nexport function formatBytes(bytes: number, decimals = 2): string {\n  if (bytes === 0) return '0 Bytes';\n\n  const k = 1024;\n  const dm = decimals < 0 ? 0 : decimals;\n  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];\n\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n  return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];\n}\n\n/**\n * Compact bytes formatter using the single-letter unit `'B'` (not\n * `'Bytes'`). Used by upload-progress UIs where horizontal space is\n * tight and the longer \"Bytes\" string wraps. Tops out at `TB`.\n * @example formatBytesShort(0) → \"0 B\"; formatBytesShort(1536) → \"1.5 KB\"\n */\nexport function formatBytesShort(bytes: number): string {\n  if (bytes === 0) return '0 B';\n  const k = 1024;\n  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;\n}\n\n/**\n * File-size formatter (1 decimal place, `'B'` unit, caps at `GB`).\n * Hub historical semantics — distinct from `formatBytesShort` (2dp) and\n * `formatBytes` (2dp / `'Bytes'`). Lifted from the hub during the doc-viewer\n * unification so all upload UIs, publication cards, and data-room file-size\n * displays render the same way.\n * @example formatFileSize(0) → \"0 B\"; formatFileSize(1500) → \"1.5 KB\"\n */\nexport function formatFileSize(bytes: number): string {\n  if (bytes === 0) return '0 B';\n  const k = 1024;\n  const sizes = ['B', 'KB', 'MB', 'GB'];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;\n}\n\n/**\n * Format large numbers to abbreviated form (K, M, B) with no decimal points\n * @param num - The number to format\n * @returns Formatted number string (e.g., \"1K\", \"2M\", \"3B\")\n */\nexport function formatLargeNumber(num: number): string {\n  if (num === 0) return '0';\n\n  // Handle negative numbers\n  const isNegative = num < 0;\n  const absNum = Math.abs(num);\n\n  let result: string;\n\n  if (absNum >= 1_000_000_000) {\n    // Billions\n    result = `${Math.floor(absNum / 1_000_000_000)}B`;\n  } else if (absNum >= 1_000_000) {\n    // Millions\n    result = `${Math.floor(absNum / 1_000_000)}M`;\n  } else if (absNum >= 1_000) {\n    // Thousands\n    result = `${Math.floor(absNum / 1_000)}K`;\n  } else {\n    // Less than 1000, show as-is\n    result = Math.floor(absNum).toString();\n  }\n\n  return isNegative ? `-${result}` : result;\n}\n\n/**\n * Abbreviate large numbers for compact display.\n * 1 200 → 1.2K , 15 000 → 15K , 2 000 000 → 2M\n * @param n Number to format\n */\nexport function formatAbbreviatedNumber(n: number): string {\n  if (n >= 1_000_000_000) {\n    const value = n / 1_000_000_000;\n    return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}B`;\n  }\n  if (n >= 1_000_000) {\n    const value = n / 1_000_000;\n    return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;\n  }\n  if (n >= 1_000) {\n    const value = n / 1_000;\n    return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;\n  }\n  return n.toLocaleString();\n}\n\n/**\n * Two-letter uppercase initials from the FIRST + LAST word of a name.\n * Used by avatar-style fallbacks (SquareAvatar, EntityImage) where\n * \"John Michael Doe\" → \"JD\" reads better than \"JM\".\n *\n * Returns `''` for empty / whitespace-only input. Single-word names\n * return a single uppercase letter. Pure — same input always produces\n * the same output, no locale or timezone surface.\n */\n/**\n * Two-letter uppercase initials from the FIRST + SECOND word of a name.\n * Used as SquareAvatar / EntityImage / EntityAuthorCard fallback across\n * admin and public pages in both the lib and the hub. Handles empty\n * strings, all-whitespace input, and single-word names cleanly — always\n * returns at least one character so the fallback slot is never empty.\n *\n * Single source of truth: every \"first-letter of each word, uppercase,\n * max 2 chars\" computation across hub + lib MUST come through here.\n */\nexport interface NameInitialsOptions {\n  /** How many letters to keep (default 2). */\n  maxLetters?: number;\n  /**\n   * `'all'` (default) takes the leading words; `'first-last'` takes the FIRST and\n   * LAST word, which is what a person's initials mean for a middle-name case.\n   */\n  pick?: 'all' | 'first-last';\n}\n\nexport function nameInitials(\n  name: string | null | undefined,\n  fallback: string = 'E',\n  options: NameInitialsOptions = {},\n): string {\n  const { maxLetters = 2, pick: mode = 'all' } = options;\n  const source = typeof name === 'string' ? name.trim() : '';\n  const words = source.length > 0 ? source.split(/\\s+/).filter(Boolean) : [];\n  const chosen =\n    mode === 'first-last' && words.length > 1 ? [words[0], words[words.length - 1]] : words.slice(0, maxLetters);\n  const letters = chosen\n    .map(w => w[0])\n    .filter(Boolean)\n    .slice(0, maxLetters)\n    .join('');\n  return (letters || fallback).toUpperCase();\n}\n\n/**\n * A PERSON's initials: first + last word, empty when there is no name.\n * THE named policy for avatars and person cells (replaces `getFirstLastInitials`).\n */\nexport function personInitials(name?: string | null): string {\n  return nameInitials(name, '', { pick: 'first-last' });\n}\n\n/** A single leading letter, `?` when there is no name. THE named one-letter policy. */\nexport function singleInitial(name?: string | null): string {\n  return nameInitials(name, '?', { maxLetters: 1 });\n}\n\n/**\n * Format seconds to MM:SS or HH:MM:SS format\n * Used for media durations (podcasts, videos)\n * Returns: \"MM:SS\" or \"HH:MM:SS\" if hours > 0\n */\nexport function formatDurationMMSS(seconds: number | null | undefined): string {\n  if (!seconds) return '';\n  const hours = Math.floor(seconds / 3600);\n  const minutes = Math.floor((seconds % 3600) / 60);\n  const secs = Math.floor(seconds % 60);\n\n  if (hours > 0) {\n    return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;\n  }\n  return `${minutes}:${secs.toString().padStart(2, '0')}`;\n}\n\n/**\n * Format seconds to compact human-readable duration\n * Used for displaying duration in cards and headers (media cards, program\n * headers, meeting-duration chips).\n * Returns: \"Xh Xm\", \"Xh\" (whole hours), or \"X min\"\n *\n * Whole hours deliberately drop the zero-minutes tail (`3600 → \"1h\"`, not\n * `\"1h 0m\"`) — this matches the hub `formatMinutesOrDash` docblock that\n * already advertised `\"2h\"`. Distinct from `formatDuration` (long words,\n * seconds input), `formatDurationFromMs` (elapsed-time telemetry, `\"30.0m\"`),\n * `formatDurationMMSS` (clock style), `formatDurationFromRange` (two dates).\n */\nexport function formatDurationCompact(seconds: number | null | undefined): string {\n  if (!seconds) return '';\n  const hours = Math.floor(seconds / 3600);\n  const minutes = Math.floor((seconds % 3600) / 60);\n  if (hours > 0) {\n    return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;\n  }\n  return `${minutes} min`;\n}\n\n/**\n * Format a webinar/event start time as the wall-clock in its OWN timezone.\n *\n * `date` is an instant (UTC timestamp / ISO string). `timezone` is the event's\n * IANA zone (e.g. `'America/New_York'`). Rendering in that explicit zone makes\n * the server (Vercel = UTC) and the visitor's browser emit the SAME text —\n * fixing the React #418 hydration mismatch — AND shows the true event time\n * (4:00 PM EDT, not the 8:00 PM UTC a plain UTC pin would show, nor the\n * viewer-local time the old unpinned call produced). Falls back to UTC when no\n * zone is given, and tolerates a non-IANA label (legacy data) without throwing.\n * The zone LABEL is rendered separately by callers, so it is never appended here.\n *\n * Returns: \"4:00 PM\"\n */\nexport function formatTimeWithTimezone(date: Date | string | null | undefined, timezone?: string | null): string {\n  if (!date) return '';\n\n  const dateObj = typeof date === 'string' ? new Date(date) : date;\n  const opts: Intl.DateTimeFormatOptions = {\n    hour: 'numeric',\n    minute: '2-digit',\n    hour12: true,\n    timeZone: timezone || 'UTC',\n  };\n  try {\n    return dateObj.toLocaleTimeString('en-US', opts);\n  } catch {\n    // Non-IANA `timezone` (e.g. a bare \"EST\" label) makes Intl throw a\n    // RangeError. Fall back to a UTC-pinned render so we stay deterministic\n    // (still no #418) instead of crashing.\n    return dateObj.toLocaleTimeString('en-US', { ...opts, timeZone: 'UTC' });\n  }\n}\n\n/**\n * Calculate and format duration between two timestamps\n * Used for webinar durations\n * Returns: \"1h 30m\" or \"45m\"\n */\nexport function formatDurationFromRange(\n  startAt: string | Date | null | undefined,\n  endAt: string | Date | null | undefined,\n): string {\n  if (!startAt || !endAt) return '';\n\n  const start = typeof startAt === 'string' ? new Date(startAt) : startAt;\n  const end = typeof endAt === 'string' ? new Date(endAt) : endAt;\n  const durationMs = end.getTime() - start.getTime();\n  const minutes = Math.round(durationMs / 60000);\n\n  if (minutes >= 60) {\n    const hours = Math.floor(minutes / 60);\n    const mins = minutes % 60;\n    return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;\n  }\n  return `${minutes}m`;\n}\n\n/**\n * UTC-anchored date formatter — distinct from the local-rendering\n * `formatDate` above. RAG mappers + audit-style consumers want UTC anchor\n * so date-only strings (`'2024-08-15'`) don't drift across timezones, AND\n * a `'N/A'` (or caller-supplied) fallback for missing values so the LLM\n * never sees a blank.\n *\n * Accepts:\n *   - ISO date or timestamp string (`'2024-08-15'` or `'2024-08-15T10:30:00Z'`)\n *   - Numeric epoch milliseconds\n *   - Numeric string (treated as epoch ms — Slack and GitHub timestamps\n *     sometimes arrive that way)\n *   - `null` / `undefined` / empty / unparseable → returns `options.fallback`\n *\n * Date-only strings (no `'T'`) get `T00:00:00Z` appended so they anchor to\n * UTC midnight rather than midnight in the runtime's local timezone.\n */\nexport interface FormatDateUTCOptions {\n  /** Returned for null/undefined/empty/unparseable input. Defaults to 'N/A'. */\n  fallback?: string;\n  /** 'UTC' (default) or 'local' — switch off the UTC anchor when audit\n   *  stability matters less than local relevance (chat-card timestamps). */\n  timezone?: 'UTC' | 'local';\n}\n\nexport function formatDateUTC(value: string | number | null | undefined, options: FormatDateUTCOptions = {}): string {\n  const { fallback = 'N/A', timezone = 'UTC' } = options;\n\n  if (value === null || value === undefined || value === '') return fallback;\n\n  let ms: number;\n  if (typeof value === 'number') {\n    ms = value;\n  } else {\n    // String input — first try to interpret as a numeric epoch (Slack/GitHub\n    // sometimes arrive that way). Number('') is 0, but we already ruled out\n    // empty strings above.\n    const n = Number(value);\n    if (Number.isFinite(n) && n > 0 && /^-?\\d+(\\.\\d+)?$/.test(value.trim())) {\n      ms = n;\n    } else {\n      // ISO string — for date-only forms, anchor to UTC midnight to avoid\n      // timezone-offset drift (preserves the original RAG-mapper contract).\n      ms = Date.parse(value.includes('T') ? value : value + 'T00:00:00Z');\n    }\n  }\n\n  if (!Number.isFinite(ms)) return fallback;\n\n  return new Date(ms).toLocaleDateString('en-US', {\n    year: 'numeric',\n    month: 'short',\n    day: 'numeric',\n    timeZone: timezone === 'local' ? undefined : 'UTC',\n  });\n}\n\n/**\n * Format a reporting-month value (`entry_month`) as \"Mon YYYY\" / \"Month YYYY\",\n * always anchored to UTC. THE single home for the \"What I Shipped\" month label —\n * both the lib card (`'short'`) and the hub detail page (`'long'`) call this, so\n * the React #418 UTC-pin convention lives in exactly one place. Returns `null`\n * for empty input (callers omit the label entirely).\n */\nexport function formatEntryMonthUTC(\n  entryMonth: string | null | undefined,\n  style: 'short' | 'long' = 'short',\n): string | null {\n  if (!entryMonth) return null;\n  return new Date(entryMonth).toLocaleDateString('en-US', { month: style, year: 'numeric', timeZone: 'UTC' });\n}\n\n/**\n * Format a date string as `MM/DD/YYYY` for legal-document display\n * (privacy policy, terms of service). Locale-stable: always en-US.\n */\nexport function formatLegalDate(dateInput: string): string {\n  return new Intl.DateTimeFormat('en-US', {\n    year: 'numeric',\n    month: '2-digit',\n    day: '2-digit',\n  }).format(new Date(dateInput));\n}\n\n/**\n * Format a currency value as `$1,234`. Returns `'N/A'` for null/undefined.\n * USD-rounded (no cents). Used on KPI cards + investor pages.\n */\nexport function formatCurrency(value: number | null | undefined): string {\n  if (value == null) return 'N/A';\n  return new Intl.NumberFormat('en-US', {\n    style: 'currency',\n    currency: 'USD',\n    maximumFractionDigits: 0,\n  }).format(value);\n}\n\n/**\n * Format a percent value as `12.50%`. Returns `'N/A'` for null/undefined.\n */\nexport function formatPercent(value: number | null | undefined): string {\n  if (value == null) return 'N/A';\n  return `${value.toFixed(2)}%`;\n}\n\n/**\n * Whole-dollar price (no cents) — `$1,234`. Configurable currency code.\n */\nexport function formatWholeDollars(price: number, currency = 'USD'): string {\n  return new Intl.NumberFormat('en-US', {\n    style: 'currency',\n    currency,\n    maximumFractionDigits: 0,\n  }).format(price);\n}\n\n// =============================================================================\n// Metric Formatting (for KPI cards / investor updates)\n// =============================================================================\n\nexport type MetricFormat = 'number' | 'currency' | 'percentage' | 'months';\n\n/**\n * Polarity determines whether an increase is good or bad.\n * - 'positive': higher is better (revenue, users, MRR) → up = green, down = red\n * - 'negative': higher is worse (burn rate, churn, CAC) → up = red, down = green\n * - 'neutral':  no judgment (headcount, runway) → always gray\n */\nexport type TrendPolarity = 'positive' | 'negative' | 'neutral';\n\n/**\n * Format a metric value with compact notation ($1.2M, 150K, 12 months).\n */\nexport function formatCompactMetric(\n  value: number,\n  format: MetricFormat = 'number',\n  options?: { prefix?: string; suffix?: string },\n): string {\n  if (value === 0 || value === null || value === undefined) {\n    if (format === 'currency') return `${options?.prefix || '$'}0`;\n    if (format === 'percentage') return '0%';\n    if (format === 'months') return `0 ${options?.suffix || 'months'}`;\n    return '0';\n  }\n\n  const absValue = Math.abs(value);\n  const sign = value < 0 ? '-' : '';\n\n  if (format === 'currency') {\n    const prefix = options?.prefix || '$';\n    const compact = (val: number, divisor: number, suffix: string) => {\n      const divided = val / divisor;\n      const formatted = divided % 1 === 0 ? divided.toFixed(0) : divided.toFixed(1);\n      return `${sign}${prefix}${formatted}${suffix}`;\n    };\n    if (absValue >= 1_000_000_000) return compact(absValue, 1_000_000_000, 'B');\n    if (absValue >= 1_000_000) return compact(absValue, 1_000_000, 'M');\n    if (absValue >= 1_000) return `${sign}${prefix}${(absValue / 1_000).toFixed(0)}K`;\n    return `${sign}${prefix}${absValue.toLocaleString()}`;\n  }\n\n  if (format === 'percentage') {\n    return `${sign}${absValue}%`;\n  }\n\n  if (format === 'months') {\n    const rounded = Math.round(value * 10) / 10;\n    return `${rounded} ${options?.suffix || 'months'}`;\n  }\n\n  if (absValue >= 1_000_000) return `${sign}${(absValue / 1_000_000).toFixed(1)}M`;\n  if (absValue >= 1_000) return `${sign}${(absValue / 1_000).toFixed(1)}K`;\n  return value.toLocaleString();\n}\n\n/**\n * Get ODS trend colors based on direction AND polarity. Single source\n * of truth for trend coloring across hub + lib.\n */\nexport function getTrendColors(\n  direction: 'up' | 'down' | 'neutral',\n  polarity: TrendPolarity = 'positive',\n): { textClass: string; badgeClass: string } {\n  if (direction === 'neutral' || polarity === 'neutral') {\n    return {\n      textClass: 'text-ods-text-secondary',\n      badgeClass: 'bg-ods-border text-ods-text-secondary',\n    };\n  }\n\n  const isPositiveOutcome =\n    (direction === 'up' && polarity === 'positive') || (direction === 'down' && polarity === 'negative');\n\n  if (isPositiveOutcome) {\n    return {\n      textClass: 'text-ods-success',\n      badgeClass: 'bg-ods-success-secondary text-ods-success',\n    };\n  }\n\n  return {\n    textClass: 'text-ods-error',\n    badgeClass: 'bg-ods-error-secondary text-ods-error',\n  };\n}\n\n/**\n * Format a date range as `Apr 20, 2026 — Jul 20, 2026`. Used on review\n * cycle list/detail rows and summary headers.\n *\n * Accepts either full ISO timestamps or bare `YYYY-MM-DD` dates. Bare\n * dates are interpreted in the viewer's LOCAL timezone — otherwise\n * `\"2026-04-20\"` renders as `\"Apr 19\"` west of UTC. DB values for cycle\n * period are stored as plain dates, so local-tz parsing is correct.\n */\nexport function formatDateRange(start: string | null | undefined, end: string | null | undefined): string {\n  if (!start || !end) return '';\n  const fmt = (s: string): string => {\n    const bareMatch = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(s);\n    const d = bareMatch ? new Date(Number(bareMatch[1]), Number(bareMatch[2]) - 1, Number(bareMatch[3])) : new Date(s);\n    if (Number.isNaN(d.getTime())) return s;\n    return d.toLocaleDateString(undefined, {\n      month: 'short',\n      day: 'numeric',\n      year: 'numeric',\n    });\n  };\n  return `${fmt(start)} — ${fmt(end)}`;\n}\n\n/**\n * Format an ISO date string as `\"Jan 5, 2025 at 10:30 AM\"`. Used by\n * admin podcast/webinar cards where the display needs both the short\n * date AND wall-clock time in a single readable phrase.\n *\n * Uses `new Date(...).toLocale*` (NOT the TZ-safe split) because the\n * source columns store full timestamps + the wall-clock half MUST\n * render in the viewer's local timezone (a podcast scheduled \"10:30 AM\n * EST\" should display \"10:30 AM\" for the Eastern admin, \"7:30 AM\" for\n * the Pacific admin — viewer-local is the right semantics here, unlike\n * date-only fields).\n */\nexport function formatDateTimeAt(dateString: string): string {\n  const date = new Date(dateString);\n  const dateStr = date.toLocaleDateString('en-US', {\n    month: 'short',\n    day: 'numeric',\n    year: 'numeric',\n  });\n  const timeStr = date.toLocaleTimeString('en-US', {\n    hour: 'numeric',\n    minute: '2-digit',\n    hour12: true,\n  });\n  return `${dateStr} at ${timeStr}`;\n}\n\n/**\n * Format a duration measured in MILLISECONDS as a compact human-readable\n * string. Returns `\"0ms\"` for null/NaN/negative, then `Xms` → `X.Xs` →\n * `X.Xm` as the input grows. Used by job-runs / orchestrator dashboards\n * where elapsed milliseconds is the natural unit.\n *\n * Distinct from `formatDuration(seconds)` (verbose `\"X hours Y minutes\"`)\n * and `formatDurationMMSS(seconds)` (media timecode `MM:SS`).\n */\nexport function formatDurationFromMs(ms: number | null | undefined): string {\n  if (!ms || isNaN(ms) || ms < 0) return '0ms';\n  if (ms < 1000) return `${ms}ms`;\n  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;\n  return `${(ms / 60000).toFixed(1)}m`;\n}\n\n/**\n * Format seconds to verbose human-readable duration: `\"X seconds\"`,\n * `\"X minutes\"`, `\"X hours Y minutes\"`. Use this for human-readable\n * spans; for media timecodes use `formatDurationMMSS`; for compact\n * media labels use `formatDurationCompact`; for elapsed milliseconds\n * use `formatDurationFromMs`.\n */\nexport function formatDuration(seconds: number): string {\n  if (seconds < 60) return `${seconds} seconds`;\n  if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes`;\n\n  const hours = Math.floor(seconds / 3600);\n  const minutes = Math.floor((seconds % 3600) / 60);\n\n  if (minutes === 0) return `${hours} hour${hours > 1 ? 's' : ''}`;\n  return `${hours} hour${hours > 1 ? 's' : ''} ${minutes} minute${minutes > 1 ? 's' : ''}`;\n}\n\n// =============================================================================\n// Text formatting helpers (proper-case, HTML strip, bio cleanup)\n// =============================================================================\n\n/**\n * Title-case a slug-like string: split on `separator`, capitalize each word.\n *   `titleCaseFromSlug('customer-success')`      → `\"Customer Success\"`\n *   `titleCaseFromSlug('self_hosted', '_')`      → `\"Self Hosted\"`\n *\n * The separator is EXPLICIT per call site on purpose: `formatUnderscoreText`\n * delegates with `'_'` (its output stays byte-identical — `'self-hosted'`\n * remains `\"Self-hosted\"` on the vendor classification/pricing labels), while\n * scheduling-purpose labels pass `'-'`. A combined `[-_]` splitter would have\n * silently changed shipped vendor-facing strings.\n */\nexport function titleCaseFromSlug(text: string, separator: string = '-'): string {\n  return text\n    .split(separator)\n    .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n    .join(' ');\n}\n\n/**\n * Format underscore-separated text into proper case.\n *   `\"self_hosted\"` → `\"Self Hosted\"`\n *   `\"open_source\"` → `\"Open Source\"`\n *\n * Thin delegate over {@link titleCaseFromSlug} with an underscore separator.\n */\nexport function formatUnderscoreText(text: string): string {\n  return titleCaseFromSlug(text, '_');\n}\n\n/**\n * Strip HTML tags and decode common HTML entities from a string.\n * Useful for cleaning API responses that contain HTML content.\n *\n * @example\n * stripHtml('<p>Hello <strong>World</strong></p>') // \"Hello World\"\n * stripHtml('Test &amp; Example') // \"Test & Example\"\n */\nexport function stripHtml(html: string): string {\n  // Iterative tag-strip — a single pass is bypass-able by inputs like\n  // `<scr<script>ipt>`: stripping the inner `<script>` leaves the outer\n  // `<script>` reassembled. Loop until the string is stable so multi-\n  // character bypasses cannot survive (CodeQL\n  // `js/incomplete-multi-character-sanitization`). `<[^<>]*>` rejects\n  // `<` inside the tag body so each pass is itself ReDoS-safe (no\n  // backtracking on `<<<<<...<>` inputs).\n  let noTags = html;\n  let prev: string;\n  do {\n    prev = noTags;\n    noTags = noTags.replace(/<[^<>]*>/g, '');\n  } while (noTags !== prev);\n\n  // Decode entities. `&amp;` MUST come LAST so we don't double-decode\n  // sequences like `&amp;lt;` (which should render as the LITERAL text\n  // `&lt;`, not as `<`). All other named/numeric entities decode first;\n  // only after those have been replaced do we collapse `&amp;` → `&`.\n  return noTags\n    .replace(/&nbsp;/g, ' ')\n    .replace(/&lt;/g, '<')\n    .replace(/&gt;/g, '>')\n    .replace(/&quot;/g, '\"')\n    .replace(/&#39;/g, \"'\")\n    .replace(/&#x27;/g, \"'\")\n    .replace(/&apos;/g, \"'\")\n    .replace(/&amp;/g, '&')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\n/**\n * Display label for the openmsp `vendors.classification` enum.\n * Falls back to proper-cased underscore split for values not in the\n * curated mapping.\n */\nexport function formatClassification(classification: string): string {\n  const customMappings: Record<string, string> = {\n    openframe_selected: 'OpenFrame Selected',\n  };\n  return customMappings[classification] || formatUnderscoreText(classification);\n}\n\n/**\n * Display label for the openmsp `vendors.pricing_model` enum.\n * Falls back to proper-cased underscore split for values not in the\n * curated mapping.\n */\nexport function formatPricingModel(pricingModel: string): string {\n  const customMappings: Record<string, string> = {\n    one_time: 'One-time Purchase',\n    self_hosted: 'Self-hosted',\n  };\n  return customMappings[pricingModel] || formatUnderscoreText(pricingModel);\n}\n\n/**\n * Format a bio/about text from the profiles table for display.\n * Handles HTML content (e.g. `<p>` tags from rich text editors),\n * plain text passthrough, and null/undefined values.\n */\nexport function formatBioText(aboutHtml: string | null | undefined, fallback: string = ''): string {\n  if (!aboutHtml || !aboutHtml.trim()) return fallback;\n\n  if (aboutHtml.includes('<p')) {\n    // `<p[^<>]*>` rejects `<` inside the tag so the automaton can't\n    // backtrack on `<<<<<...<p>` inputs (ReDoS class fix vs. `<p[^>]*>`).\n    const paragraphs = aboutHtml\n      .split(/<p[^<>]*>/)\n      .slice(1)\n      .map(part => part.split('</p>')[0])\n      .map(text => stripHtml(text).trim())\n      .filter(text => text.length > 0);\n\n    if (paragraphs.length > 0) return paragraphs.join(' ');\n  }\n\n  return stripHtml(aboutHtml).trim() || fallback;\n}\n","'use client';\n\nimport { type HTMLAttributes, memo, forwardRef } from 'react';\nimport Image from '../../embed-shims/next-image';\nimport { useAuthedImageSrc } from '../../hooks/use-authed-image-src';\nimport { cn } from '../../utils/cn';\nimport { personInitials } from '../../utils/format';\n\ninterface SquareAvatarProps extends HTMLAttributes<HTMLDivElement> {\n  src?: string;\n  alt?: string;\n  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';\n  /** Exact pixel size (width & height). Overrides the `size` bucket dimensions —\n   *  for callers with a numeric-px API (e.g. UserDisplay/MSPDisplay). */\n  sizePx?: number;\n  /**\n   * How the image fills the frame. `cover` (default) crops to fill — right for\n   * a portrait. `contain` fits the whole image — required for a LOGO or club\n   * crest, whose round border cover slices off.\n   */\n  fit?: 'cover' | 'contain';\n  fallback?: string;\n  variant?: 'square' | 'round';\n  /** Override the initials-fallback styling (font size/color). Merged over the\n   *  defaults (`text-xs font-medium text-ods-text-primary`) via tailwind-merge,\n   *  so callers can shrink/recolor the initials for compact avatars. */\n  initialsClassName?: string;\n}\n\nconst SquareAvatar = memo(\n  forwardRef<HTMLDivElement, SquareAvatarProps>(\n    (\n      {\n        className,\n        src,\n        alt,\n        size = 'md',\n        sizePx,\n        fit = 'cover',\n        fallback,\n        variant = 'square',\n        initialsClassName,\n        style,\n        ...props\n      },\n      ref,\n    ) => {\n      const resolvedSrc = useAuthedImageSrc(src);\n      const sizeClasses = {\n        // xs (24px): dense card meta rows — avatar stacks on delivery/\n        // roadmap cards. Part of the shared scale so no caller ever\n        // bypasses the buckets with raw sizePx for a standard size.\n        xs: 'h-6 w-6',\n        sm: 'h-8 w-8',\n        md: 'h-10 w-10',\n        lg: 'h-12 w-12',\n        xl: 'h-16 w-16',\n      };\n\n      const sizePxBySize = {\n        xs: 24,\n        sm: 32,\n        md: 40,\n        lg: 48,\n        xl: 64,\n      };\n\n      const variantClasses = {\n        square: 'rounded-md',\n        round: 'rounded-full',\n      };\n\n      return (\n        <div\n          className={cn(\n            'relative flex shrink-0 items-center justify-center overflow-hidden border border-ods-border bg-ods-bg',\n            sizePx === undefined && sizeClasses[size],\n            variantClasses[variant],\n            className,\n          )}\n          style={sizePx === undefined ? style : { width: sizePx, height: sizePx, ...style }}\n          ref={ref}\n          {...props}\n        >\n          <div\n            className={cn(\n              // Initials default to `--color-text-primary` (the old\n              // `text-ods-text-primary` value) so they stay readable on the default\n              // `bg-ods-bg` AND on the brand accent fills (`bg-ods-flamingo-pink`\n              // for the current user, `bg-ods-flamingo-cyan` for Mingo). The color\n              // resolves through `--ods-avatar-initials` with that fallback, so a\n              // host themed with a custom avatar fill can override the var with a\n              // contrast-correct value (e.g. `getReadableTextColor(accent)`) WITHOUT\n              // regressing any avatar that leaves the var unset. A caller passing\n              // its own `initialsClassName` text color still wins (tailwind-merge\n              // keeps the later class).\n              'flex items-center justify-center text-xs font-medium text-[color:var(--ods-avatar-initials,var(--color-text-primary))]',\n              initialsClassName,\n              resolvedSrc && 'hidden',\n            )}\n          >\n            {personInitials(fallback || alt) || '?'}\n          </div>\n          {resolvedSrc && (\n            <Image\n              className={cn(\n                'absolute max-w-none',\n                fit === 'contain'\n                  ? // A LOGO or crest must fit whole; cover would slice its edges.\n                    'inset-0 h-full w-full object-contain'\n                  : // A portrait fills the frame; the 1px overscan hides edge seams.\n                    '-inset-px h-[calc(100%+2px)] w-[calc(100%+2px)] object-cover',\n              )}\n              // Images are draggable by default, and a draggable child WINS over a\n              // draggable ancestor: an avatar inside a drag-and-drop card lets the\n              // browser start its own \"drag this picture\" instead of the card's\n              // drag, so grabbing a card by its assignee did nothing.\n              draggable={false}\n              src={resolvedSrc}\n              alt={alt || ''}\n              width={sizePx ?? sizePxBySize[size]}\n              height={sizePx ?? sizePxBySize[size]}\n              onError={e => {\n                const img = e.currentTarget;\n                img.style.display = 'none';\n                const el = img.previousElementSibling as HTMLElement;\n                if (el) el.classList.remove('hidden');\n              }}\n            />\n          )}\n        </div>\n      );\n    },\n  ),\n);\nSquareAvatar.displayName = 'SquareAvatar';\n\nexport { SquareAvatar };\n","'use client';\n\nimport React from 'react';\nimport { cn } from '../../utils/cn';\n\nexport interface InteractiveCardProps extends React.HTMLAttributes<HTMLDivElement> {\n  /**\n   * Children elements to render inside the card\n   */\n  children: React.ReactNode;\n\n  /**\n   * Click handler for the card\n   */\n  onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;\n\n  /**\n   * Enable clickable/hover behavior\n   * Default: true if onClick is provided, false otherwise\n   */\n  clickable?: boolean;\n\n  /**\n   * Custom hover accent color (default: ods-accent)\n   * When provided, border and h3 titles will use this color on hover\n   */\n  hoverAccentColor?: string;\n\n  /**\n   * Additional CSS classes\n   */\n  className?: string;\n}\n\n/**\n * InteractiveCard - Base component for clickable cards with hover effects\n *\n * Provides the same hover pattern as VendorCard and OrganizationCard:\n * - Border changes to accent color on hover\n * - H3 titles change to accent color on hover\n * - Smooth transitions\n * - Cursor pointer\n * - Group class for child hover states\n *\n * Usage Examples:\n *\n * ```typescript\n * // Basic clickable card\n * <InteractiveCard onClick={() => navigate('/details')}>\n *   <h3>Title</h3>\n *   <p>Content...</p>\n * </InteractiveCard>\n *\n * // With custom accent color\n * <InteractiveCard\n *   onClick={handleClick}\n *   hoverAccentColor=\"#00D4AA\"\n * >\n *   <h3>Title changes to cyan on hover</h3>\n * </InteractiveCard>\n *\n * // Non-clickable card (no hover)\n * <InteractiveCard clickable={false}>\n *   <h3>Static content</h3>\n * </InteractiveCard>\n * ```\n */\nexport const InteractiveCard = React.forwardRef<HTMLDivElement, InteractiveCardProps>(\n  ({ children, onClick, clickable, hoverAccentColor, className, ...props }, ref) => {\n    // Auto-enable clickable if onClick is provided\n    const isClickable = clickable !== undefined ? clickable : !!onClick;\n\n    const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {\n      if (isClickable && onClick) {\n        onClick(e);\n      }\n    };\n\n    const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {\n      if (hoverAccentColor && isClickable) {\n        const card = e.currentTarget;\n        card.style.borderColor = hoverAccentColor;\n        // Change h3 titles\n        const title = card.querySelector('h3');\n        if (title) {\n          title.style.color = hoverAccentColor;\n        }\n        // Change primary text elements (large values, main content)\n        // Exclude button text from color change\n        const primaryTexts = card.querySelectorAll<HTMLElement>('.text-ods-text-primary:not(button):not(button *)');\n        for (const text of primaryTexts) {\n          text.style.color = hoverAccentColor;\n        }\n      }\n    };\n\n    const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {\n      if (hoverAccentColor && isClickable) {\n        const card = e.currentTarget;\n        card.style.borderColor = '';\n        // Reset h3 titles\n        const title = card.querySelector('h3');\n        if (title) {\n          title.style.color = '';\n        }\n        // Reset primary text elements\n        const primaryTexts = card.querySelectorAll<HTMLElement>('.text-ods-text-primary:not(button):not(button *)');\n        for (const text of primaryTexts) {\n          text.style.color = '';\n        }\n      }\n    };\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          'transition-all',\n          isClickable && [\n            'cursor-pointer',\n            'group',\n            !hoverAccentColor && [\n              'hover:border-ods-accent',\n              // H3 titles turn accent on hover\n              '[&:hover_h3]:text-ods-accent',\n              // Primary text turns accent on hover (but not inside buttons)\n              '[&:hover_.text-ods-text-primary:not(button):not(button_*)]:text-ods-accent',\n            ],\n          ],\n          className,\n        )}\n        onClick={handleClick}\n        onMouseEnter={handleMouseEnter}\n        onMouseLeave={handleMouseLeave}\n        style={\n          hoverAccentColor\n            ? // A CSS custom property — React accepts it at runtime but\n              // `CSSProperties` has no index signature for `--*`.\n              ({ '--hover-accent': hoverAccentColor } as React.CSSProperties)\n            : undefined\n        }\n        {...props}\n      >\n        {children}\n      </div>\n    );\n  },\n);\n\nInteractiveCard.displayName = 'InteractiveCard';\n","'use client';\n\nimport { usePreventScroll } from '@react-aria/overlays';\nimport { type ReactNode, forwardRef, useEffect } from 'react';\nimport { cn } from '../../utils/cn';\n\n/**\n * Sizing and the two-column editor layout moved to `ModalV2` (`size` prop +\n * `ModalV2TwoColumn`), which is where the focus trap, Escape stacking and\n * keyboard-inset handling already live. The class-string constants this file\n * used to export were copied per call site and drifted; a component owns the\n * scroll model instead. What remains here is the compact legacy dialog used by\n * the confirm modals that have not moved yet.\n */\ninterface ModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  children: ReactNode;\n  className?: string;\n}\n\ninterface ModalContentProps {\n  children: ReactNode;\n  className?: string;\n}\n\ninterface ModalHeaderProps {\n  children: ReactNode;\n  className?: string;\n}\n\ninterface ModalTitleProps {\n  children: ReactNode;\n  className?: string;\n}\n\ninterface ModalFooterProps {\n  children: ReactNode;\n  className?: string;\n}\n\n/** @deprecated Use ModalV2 from './modal-v2' instead. */\nconst Modal = forwardRef<HTMLDivElement, ModalProps>(({ isOpen, onClose, children, className }, ref) => {\n  // Shared ref-counted scroll lock (react-aria) — restores prior styles on\n  // release instead of clobbering to 'unset'.\n  usePreventScroll({ isDisabled: !isOpen });\n\n  // Handle Escape key (document-level: top-of-stack semantics for modals)\n  useEffect(() => {\n    if (!isOpen) return undefined;\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') {\n        onClose();\n      }\n    };\n\n    document.addEventListener('keydown', handleKeyDown);\n    return () => document.removeEventListener('keydown', handleKeyDown);\n  }, [isOpen, onClose]);\n\n  if (!isOpen) return null;\n\n  return (\n    <div className=\"fixed inset-0 z-[1300] flex items-center justify-center\">\n      <div className=\"absolute inset-0 bg-ods-overlay\" onClick={onClose} aria-hidden=\"true\" />\n      <div\n        ref={ref}\n        className={cn(\n          'relative z-10 mx-4 flex max-h-[90vh] w-full max-w-md flex-col overflow-hidden rounded-lg border border-ods-border bg-ods-card shadow-xl',\n          className,\n        )}\n        role=\"dialog\"\n        aria-modal=\"true\"\n      >\n        {children}\n      </div>\n    </div>\n  );\n});\nModal.displayName = 'Modal';\n\n/** @deprecated Use ModalV2Content from './modal-v2' instead. */\nconst ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(({ children, className }, ref) => (\n  <div ref={ref} className={cn('min-h-0 flex-1 overflow-y-auto', className)}>\n    {children}\n  </div>\n));\nModalContent.displayName = 'ModalContent';\n\n/** @deprecated Use ModalV2Header from './modal-v2' instead. */\nconst ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(({ children, className }, ref) => (\n  <div ref={ref} className={cn('shrink-0 border-b border-ods-border px-6 py-4', className)}>\n    {children}\n  </div>\n));\nModalHeader.displayName = 'ModalHeader';\n\n/** @deprecated Use ModalV2Title from './modal-v2' instead. */\nconst ModalTitle = forwardRef<HTMLHeadingElement, ModalTitleProps>(({ children, className }, ref) => (\n  <h2 ref={ref} className={cn('font-semibold text-ods-text-primary', className)}>\n    {children}\n  </h2>\n));\nModalTitle.displayName = 'ModalTitle';\n\n/** @deprecated Use ModalV2Footer from './modal-v2' instead. */\nconst ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(({ children, className }, ref) => (\n  <div ref={ref} className={cn('flex shrink-0 justify-end gap-3 px-6 py-4', className)}>\n    {children}\n  </div>\n));\nModalFooter.displayName = 'ModalFooter';\n\nexport { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle };\n","'use client';\n\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport Image from '../../embed-shims/next-image';\nimport { Button } from './button';\nimport { Modal } from './modal';\n\ninterface ImageGalleryModalProps {\n  images: string[];\n  isOpen: boolean;\n  onClose: () => void;\n  initialIndex?: number;\n}\n\n/**\n * Full-screen image gallery modal\n * Reusable component extracted from TMCG event detail page\n * Features: Keyboard navigation, prev/next buttons, image counter\n */\nexport function ImageGalleryModal({ images, isOpen, onClose, initialIndex = 0 }: ImageGalleryModalProps) {\n  const [selectedImageIndex, setSelectedImageIndex] = useState(initialIndex);\n\n  // Reset to initial index when the modal opens. Adjusted while rendering\n  // rather than from an effect: opening otherwise paints one frame of whatever\n  // image the user was last on before jumping to the requested one.\n  const [openedWith, setOpenedWith] = useState({ isOpen, initialIndex });\n  if (openedWith.isOpen !== isOpen || openedWith.initialIndex !== initialIndex) {\n    setOpenedWith({ isOpen, initialIndex });\n    if (isOpen) setSelectedImageIndex(initialIndex);\n  }\n\n  // Functional updaters, so neither callback closes over the current index and\n  // both stay stable — the keydown listener below then re-subscribes only when\n  // the modal opens/closes or the gallery length changes, instead of on every\n  // arrow press.\n  const imageCount = images.length;\n  const goToPreviousImage = useCallback(() => {\n    setSelectedImageIndex(i => (i > 0 ? i - 1 : i));\n  }, []);\n\n  const goToNextImage = useCallback(() => {\n    setSelectedImageIndex(i => (i < imageCount - 1 ? i + 1 : i));\n  }, [imageCount]);\n\n  // `onClose` is a prop and is almost always written inline; through a ref the\n  // listener does not re-subscribe on every parent render.\n  const onCloseRef = useRef(onClose);\n  useEffect(() => {\n    onCloseRef.current = onClose;\n  });\n\n  // Keyboard navigation\n  useEffect(() => {\n    const handleKeyPress = (event: KeyboardEvent) => {\n      if (!isOpen) return;\n\n      switch (event.key) {\n        case 'ArrowLeft':\n          event.preventDefault();\n          goToPreviousImage();\n          break;\n        case 'ArrowRight':\n          event.preventDefault();\n          goToNextImage();\n          break;\n        case 'Escape':\n          event.preventDefault();\n          onCloseRef.current();\n          break;\n      }\n    };\n\n    if (!isOpen) return undefined;\n    document.addEventListener('keydown', handleKeyPress);\n    return () => {\n      document.removeEventListener('keydown', handleKeyPress);\n    };\n  }, [isOpen, goToPreviousImage, goToNextImage]);\n\n  if (!isOpen || images.length === 0) return null;\n\n  return (\n    <Modal isOpen={isOpen} onClose={onClose} className=\"max-w-[95vw]\">\n      <div className=\"relative flex items-center justify-center rounded-lg bg-black\">\n        {/* Navigation Buttons */}\n        {images.length > 1 && (\n          <>\n            {selectedImageIndex > 0 && (\n              <div className=\"absolute left-4 top-1/2 z-[10000] -translate-y-1/2\">\n                <Button\n                  variant=\"transparent\"\n                  size=\"small-legacy\"\n                  onClick={goToPreviousImage}\n                  className=\"rounded-full bg-black/50 p-2 text-white hover:bg-black/70\"\n                  leftIcon={<ChevronLeft className=\"h-6 w-6\" />}\n                />\n              </div>\n            )}\n            {selectedImageIndex < images.length - 1 && (\n              <div className=\"absolute right-4 top-1/2 z-[10000] -translate-y-1/2\">\n                <Button\n                  variant=\"transparent\"\n                  size=\"small-legacy\"\n                  onClick={goToNextImage}\n                  className=\"rounded-full bg-black/50 p-2 text-white hover:bg-black/70\"\n                  leftIcon={<ChevronRight className=\"h-6 w-6\" />}\n                />\n              </div>\n            )}\n          </>\n        )}\n\n        {/* Current Image */}\n        {images[selectedImageIndex] && (\n          <div className=\"relative flex h-[90vh] w-[90vw] max-w-none items-center justify-center\">\n            <Image\n              src={images[selectedImageIndex]}\n              alt={`Screenshot ${selectedImageIndex + 1}`}\n              fill\n              className=\"object-contain\"\n              sizes=\"90vw\"\n              priority={true}\n              unoptimized\n              onError={e => {\n                // Handle HEIC and other unsupported formats\n                const target = e.target as HTMLImageElement;\n                const imageUrl = images[selectedImageIndex];\n                const isHeic = imageUrl.toLowerCase().match(/\\.heic$/);\n\n                target.style.display = 'none';\n                const parent = target.parentElement;\n                if (parent && !parent.querySelector('.image-error')) {\n                  const errorDiv = document.createElement('div');\n                  errorDiv.className =\n                    'image-error flex flex-col items-center justify-center text-white text-center px-8';\n                  errorDiv.innerHTML = `\n                    <p class=\"text-xl mb-4\">${isHeic ? 'HEIC format not supported in browser' : 'Failed to load image'}</p>\n                    <a href=\"${imageUrl}\" download class=\"px-4 py-2 bg-white/10 hover:bg-white/20 rounded border border-white/20 transition-colors\">\n                      Download original file\n                    </a>\n                  `;\n                  parent.appendChild(errorDiv);\n                }\n              }}\n            />\n          </div>\n        )}\n\n        {/* Image Counter */}\n        {images.length > 1 && (\n          <div className=\"absolute bottom-4 left-1/2 z-[10000] -translate-x-1/2 rounded-full bg-black/50 px-4 py-2 text-white text-h6\">\n            {selectedImageIndex + 1} / {images.length}\n          </div>\n        )}\n      </div>\n    </Modal>\n  );\n}\n","'use client';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport type React from 'react';\nimport { cn } from '../../utils/cn';\n\nconst statusBadgeVariants = cva('inline-flex items-center justify-center rounded', {\n  variants: {\n    // Two densities, not one style at two paddings. Both are the ODS h5\n    // label treatment (Azeret Mono 500, uppercase, -0.02em); they differ in\n    // scale. Putting BOTH on `text-h5` is what regressed the stamp: at\n    // 14/20 on desktop every inline badge grew ~40% and its box doubled,\n    // because the caption line-height replaced `leading-none`.\n    variant: {\n      // Standalone badge — the ODS caption composite, unmodified.\n      card: 'px-3 py-1.5 text-h5',\n      // Dense inline stamp. Family, weight and casing come from the SAME h5\n      // tokens as `card`; only the scale is set here, because ODS has no\n      // step below the 12/14px caption and this stamp is 10px by design.\n      button:\n        'px-2 py-0.5 font-[family-name:var(--font-h5-family)] font-[number:var(--font-h5-weight)] uppercase tracking-[-0.02em] text-badge',\n    },\n    colorScheme: {\n      cyan: 'bg-ods-flamingo-cyan text-ods-text-on-accent',\n      pink: 'bg-ods-flamingo-pink text-ods-text-on-accent',\n      yellow: 'border border-ods-border bg-ods-accent text-ods-text-on-accent',\n      green: 'bg-ods-success text-ods-text-on-accent',\n      purple: 'bg-ods-flamingo-pink text-ods-text-on-accent',\n      success: 'bg-ods-success-secondary text-ods-success',\n      error: 'bg-ods-error-secondary text-ods-error',\n      warning: 'bg-ods-warning-secondary text-ods-warning',\n      default: 'bg-ods-bg-surface text-ods-text-primary',\n      // Border-only variants (no background) - for task type badges\n      accentBorder: 'border-2 border-ods-accent bg-transparent text-ods-accent',\n      errorBorder: 'border-2 border-ods-error bg-transparent text-ods-error',\n      whiteBorder: 'border-2 border-ods-text-primary bg-transparent text-ods-text-primary',\n    },\n  },\n  defaultVariants: {\n    variant: 'card',\n    colorScheme: 'default',\n  },\n});\n\n/**\n * OpenFrame generation badge tint — the ONE ramp for the `gen` mode below.\n * Per product direction: the TEXT colour is constant (the full platform\n * `--ods-accent`) so every generation stays equally legible; only the BACKGROUND\n * opacity steps down (Gen1 strongest → later gens fainter). Returns the % of the\n * accent mixed into the badge background. */\nexport function genBadgeBgOpacity(gen: number): number {\n  if (gen <= 1) return 30;\n  if (gen === 2) return 18;\n  return Math.max(10 - (gen - 3) * 6, 4);\n}\n\n/** Parse the whole-number gen tier out of a ClickUp Target Version label\n *  (\"Gen1\", \"Gen1.5\", \"Gen2\" → 1, 1, 2). Fractional gens share the tier's tint.\n *  Returns 1 when no digit is present. */\nexport function generationTierFromLabel(label: string | null | undefined): number {\n  const m = /(\\d+)/.exec(label ?? '');\n  const n = m ? parseInt(m[1], 10) : 1;\n  return Number.isFinite(n) && n > 0 ? n : 1;\n}\n\nexport interface StatusBadgeProps\n  extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof statusBadgeVariants> {\n  text: string;\n  /**\n   * When true, renders `text` verbatim on a single line, bypassing the\n   * default multi-word vertical-stack behavior used by `variant=\"button\"`.\n   * Use this for compact inline contexts (e.g. chat-inline roadmap cards)\n   * where the stamp-like stacked layout is undesirable.\n   */\n  singleLine?: boolean;\n  /**\n   * OpenFrame generation mode. Pass the gen number (1/2/3…) to render THIS badge\n   * as a generation chip: constant full-accent text on a progressive-opacity\n   * accent background (see {@link genBadgeBgOpacity}), and the `text` shown in\n   * its SSOT ClickUp casing (\"Gen1\", not uppercased) so it matches the label\n   * everywhere else in the product. Overrides `colorScheme`. The one unified\n   * gen badge — do NOT build a parallel component.\n   */\n  gen?: number;\n}\n\nfunction StatusBadge({ text, variant, colorScheme, className, singleLine, gen, style, ...props }: StatusBadgeProps) {\n  // Generation mode: constant full-accent text, progressive-opacity accent bg,\n  // SSOT casing (textTransform:none overrides the base `uppercase`). Inline\n  // style so it wins over any colorScheme class regardless of CSS source order.\n  const genStyle: React.CSSProperties | undefined =\n    gen != null\n      ? {\n          textTransform: 'none',\n          color: 'var(--ods-accent)',\n          backgroundColor: `color-mix(in srgb, var(--ods-accent) ${genBadgeBgOpacity(gen)}%, transparent)`,\n        }\n      : undefined;\n  // Outer element is `<span>` so the badge is HTML-valid in any inline\n  // context (e.g. inside a markdown `<p>` next to a compact chat card,\n  // or inside an `<a>`). The `inline-flex` base class in\n  // `statusBadgeVariants` keeps the layout identical to the previous\n  // `<div>` outer — only the element name changed.\n  //\n  // Escape hatch: callers can pass `singleLine` to opt out of the\n  // multi-word stacking applied for `variant=\"button\"`. This is needed\n  // for compact inline contexts (chat-inline roadmap cards) where the\n  // default stamp-like vertical stack (\"TO\" / \"DO\") breaks layout.\n  const renderText = () => {\n    if (singleLine) return text;\n    if (variant === 'button' && text.includes(' ')) {\n      const words = text.split(' ');\n      return (\n        <span className=\"flex flex-col items-center justify-center gap-0 text-center\">\n          {words.map((word, index) => (\n            <span key={index} className=\"block\">\n              {word}\n            </span>\n          ))}\n        </span>\n      );\n    }\n    return text;\n  };\n\n  return (\n    <span\n      className={cn(statusBadgeVariants({ variant, colorScheme: gen != null ? undefined : colorScheme }), className)}\n      style={genStyle ? { ...genStyle, ...style } : style}\n      {...props}\n    >\n      {renderText()}\n    </span>\n  );\n}\n\nexport { StatusBadge, statusBadgeVariants };\n","/**\n * Pure image-proxy URL builder + helpers.\n *\n * Lib-side replacement for the hub's `lib/utils/image-proxy.ts`. The\n * hub used to hardcode `/api/image-proxy` + an `openmsp.ai` skip-domain;\n * this version takes BOTH as parameters so embedded apps (and other\n * platforms that host the lib) can wire their own proxy prefix + skip\n * list at the runtime layer.\n *\n * Pure function — no side effects, no env reads. Callers thread the\n * proxy config through (typically from `ChatRuntime.endpoints.imageProxyUrlPrefix`).\n */\n\nexport type GetProxiedImageUrlOptions = {\n  /**\n   * URL prefix for the image proxy (`<prefix>?url=<encoded>`). When unset,\n   * `getProxiedImageUrl` returns the original URL unchanged — relative\n   * URLs always pass through. Hub default: `/api/image-proxy`.\n   */\n  proxyPrefix?: string;\n  /**\n   * Domains that should bypass the proxy (e.g. own-CDN hosts whose\n   * `Content-Type` is reliable and that already serve CORS-permitting\n   * headers). Matched as `imageUrl.includes(domain)` so subdomains\n   * inherit. Default: `[]`.\n   */\n  skipDomains?: string[];\n  /**\n   * Return the original `https://` URL so the browser loads it directly\n   * (no proxy). Use when upstream `Content-Type` breaks the proxy (common\n   * with SVG on some CDNs) or you want the origin to see the client\n   * request. HTTP stays proxied (mixed content / legacy).\n   */\n  directHttps?: boolean;\n};\n\n/**\n * Resolve an external image URL through (or around) the image proxy.\n *\n * Resolution order:\n *   1. `imageUrl` already contains `proxyPrefix` → return unchanged\n *      (self-skip — prevents double-wrap).\n *   2. `directHttps` set AND `imageUrl` starts with `https://` → return\n *      unchanged.\n *   3. `imageUrl` doesn't start with `http://` or `https://` → return\n *      unchanged (relative URLs pass through).\n *   4. `proxyPrefix` unset → return unchanged.\n *   5. `skipDomains` matches the URL's host → return unchanged.\n *   6. Else return `<proxyPrefix>?url=<encoded>`.\n */\nexport function getProxiedImageUrl(imageUrl: string | null, options?: GetProxiedImageUrlOptions): string | null {\n  if (!imageUrl) return null;\n\n  const proxyPrefix = options?.proxyPrefix;\n  const skipDomains = options?.skipDomains ?? [];\n  const directHttps = options?.directHttps ?? false;\n\n  // (1) Self-skip — already proxied. Check this BEFORE the http/https\n  // gate so an absolute proxy URL passed in (e.g. `https://hub.example/api/image-proxy?url=…`)\n  // is treated as already-proxied even though it starts with https://.\n  if (proxyPrefix && imageUrl.includes(proxyPrefix)) {\n    return imageUrl;\n  }\n\n  // (3) Relative URLs / data: / blob: — return as-is.\n  if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {\n    return imageUrl;\n  }\n\n  // (2) Direct-https opt-out — only applies to https (http stays proxied).\n  if (directHttps && imageUrl.startsWith('https://')) {\n    return imageUrl;\n  }\n\n  // (4) No proxy configured — return as-is.\n  if (!proxyPrefix) {\n    return imageUrl;\n  }\n\n  // (5) Skip-list match.\n  for (const domain of skipDomains) {\n    if (imageUrl.includes(domain)) {\n      return imageUrl;\n    }\n  }\n\n  // (6) Proxy.\n  return `${proxyPrefix}?url=${encodeURIComponent(imageUrl)}`;\n}\n\n/**\n * Heuristic: URL path looks like an SVG. Useful with `{ directHttps: true }`\n * when only SVGs misbehave through the proxy; raster images can stay\n * proxied if you prefer.\n */\nexport function urlPathLooksLikeSvg(imageUrl: string): boolean {\n  try {\n    return /\\.svg$/i.test(new URL(imageUrl).pathname);\n  } catch {\n    return /\\.svg(\\?|#|$)/i.test(imageUrl);\n  }\n}\n\n/**\n * Check if an image URL needs to be proxied. `proxyPrefix` is the same\n * value passed to `getProxiedImageUrl` — used to short-circuit the\n * self-skip case.\n */\nexport function shouldProxyImage(imageUrl: string | null, proxyPrefix?: string): boolean {\n  if (!imageUrl) return false;\n  if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) return false;\n  if (proxyPrefix && imageUrl.includes(proxyPrefix)) return false;\n  return true;\n}\n\n/**\n * Generate a responsive `sizes` attribute for `<img>` / `<Image>` tags.\n * Pure utility — kept here for backwards-compatibility with the previous\n * stub.\n */\nexport function generateImageSizes(_url: string): string {\n  return `(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw`;\n}\n","/**\n * Pure utility — true iff `url` has an explicit host (i.e. would resolve\n * to a different origin than where the consumer is running). Used by\n * chat nav decisions and the extracted `decideNewTab` helper.\n *\n * SSR-deterministic: any URL with an explicit host (http://, https://,\n * or protocol-relative //) is treated as cross-origin. By convention\n * same-origin same-app navigation uses RELATIVE paths; an absolute URL\n * with a host is external.\n *\n * Why deterministic: an earlier `window`-dependent implementation\n * returned different values during SSR (false — no window) vs client\n * hydration (true — origin differs), causing React hydration mismatches\n * on every anchor that fell into the no-targetPlatform fallback (e.g.\n * footer \"Support\" links to flamingo.run/support). Same-platform\n * absolute URLs (rare — they only happen when a composer like\n * `buildContentURL` returns an absolute URL for a non-current platform)\n * flow through `decideNewTab`'s PLATFORM branch first (which uses\n * `targetPlatform !== currentSource`) and never reach this fallback.\n */\nexport function isCrossOriginUrl(url: string | null | undefined): boolean {\n  if (!url) return false;\n  // Same-document references — a bare fragment (`#anchor`) or query\n  // (`?tab=x`) resolves against the current page and can never leave\n  // the origin.\n  if (url.startsWith('#') || url.startsWith('?')) return false;\n  // Relative URL — by definition same origin.\n  if (url.startsWith('/') && !url.startsWith('//')) return false;\n  // Anything else — absolute URL (http(s)://) or protocol-relative (//)\n  // — is treated as cross-origin.\n  return true;\n}\n","/**\n * Pure new-tab decision helper.\n *\n * Same logic as the hub's prior inline `decideNewTab`, but parameterized:\n *   - `currentSource` is passed by caller (was `currentPlatform()` reads\n *     inside the body). Hub callers pass `currentPlatform()`; lib chip\n *     code passes `runtime.source ?? ''`.\n *\n * BRANCH PRIORITY:\n *   1. `runtimeMode === 'embed'` → always new-tab (embed-mode short-circuit)\n *   2. `openIn === 'new-tab'` or `'same-tab'` — explicit caller override\n *   3. `alwaysNewTab === true` (legacy synonym for `openIn: 'new-tab'`)\n *   4. `targetPlatform` defined AND `currentSource` non-empty →\n *      platform comparison (cross-app → new tab, same-app → same tab)\n *   5. Legacy fallback: `isCrossOriginUrl(href)` — origin compare. Works\n *      in prod (distinct hosts) but degrades to \"always same-tab\" in dev.\n *\n * No call site implements its own variant of the rule.\n */\n\nimport { isCrossOriginUrl } from './is-cross-origin-url';\n\nexport type NavSurface = 'useNavLink' | 'useUnifiedNav';\nexport type RuntimeMode = 'host' | 'embed';\n\nexport interface DecideNewTabInput {\n  href: string | null | undefined;\n  targetPlatform?: string | null;\n  openIn?: 'new-tab' | 'same-tab';\n  alwaysNewTab?: boolean;\n  /** Caller tag — labels the decision in any external trace logging. */\n  surface?: NavSurface;\n  /** Optional chat-runtime mode. When omitted OR set to `'host'`,\n   *  behavior is byte-equivalent to the legacy logic. Only `'embed'`\n   *  triggers the new priority-1 branch that forces new-tab. */\n  runtimeMode?: RuntimeMode;\n  /** Current chat source / platform identifier — caller-threaded.\n   *  Empty string disables the platform-comparison branch (falls\n   *  through to the origin-compare fallback). */\n  currentSource: string;\n}\n\nexport function decideNewTab(input: DecideNewTabInput): boolean {\n  const { href, targetPlatform, openIn, alwaysNewTab, runtimeMode, currentSource } = input;\n\n  // Priority #1: embed-mode trumps every other branch.\n  if (runtimeMode === 'embed') return true;\n  if (openIn === 'new-tab') return true;\n  if (alwaysNewTab === true) return true;\n  if (openIn === 'same-tab') return false;\n  // `currentSource` falsy guard: if empty, fall through to origin check.\n  if (targetPlatform !== undefined && targetPlatform !== null && currentSource) {\n    return targetPlatform !== currentSource;\n  }\n  if (!href) return false;\n  return isCrossOriginUrl(href);\n}\n","/**\n * Pure helpers for the new-tab decision + anchor attribute pair used\n * by chat-rendered links.\n *\n * After the navigation unification (ChatCardLoader pre-resolves\n * `chatRef.url` via `resolveSourceRowCTA` + `resolveHrefForRuntime`,\n * and `ChatCardNavWrap` handles every primary click via `onClickCapture`\n * → `handleChatNavClick`), the per-card wrapper only needs\n * `{href, target, rel}` on its inner `<a>` so modifier-click /\n * hover-preview / copy-link work natively.\n *\n * `computeIsNewTab` is the SINGLE source of the new-tab rule across\n * inline cards, source chips, and `NavLinkAnchorViaRuntime`. Do NOT\n * inline the `runtime.navigation.mode === 'embed'` short-circuit + the\n * `decideNewTab` fallback chain anywhere else.\n *\n * Rules:\n *   - `embed` mode → new-tab, EXCEPT for same-origin absolute hrefs. The chat\n *     lives on the embedder origin and most content lives on the hub (→ new\n *     tab), but an embedder may host a few content types in-app (e.g. OpenFrame\n *     serves releases / roadmap / guides under `/help-center`). Those are\n *     emitted as ABSOLUTE same-origin URLs by the embedder's `composeContentUrl`;\n *     keep them same-tab so they soft-nav in-app instead of opening a redundant\n *     new tab on the same origin. Relative hrefs stay new-tab — in embed mode\n *     they're hub-relative (absolutized against `defaultContentOrigin`).\n *   - `host` mode → defer to the runtime's `decideNewTab` callback\n *     (cross-platform → new-tab) or the lib's default.\n */\n\nimport type { ChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { decideNewTab as libDecideNewTab } from './decide-new-tab';\n\n/** An ABSOLUTE `http(s)` URL on the current page's origin. Relative hrefs return\n *  false (in embed mode they're hub-relative, not in-app), as do cross-origin\n *  and non-http URLs. SSR-safe: no `window` → false. */\nexport function isSameOriginAbsoluteHref(href: string): boolean {\n  if (typeof window === 'undefined') return false;\n  if (!/^https?:\\/\\//i.test(href)) return false;\n  try {\n    // Origin off `location.href`, not `location.origin`: jsdom leaves the latter\n    // undefined, which reads as \"every URL is foreign\" under test.\n    return new URL(href).origin === new URL(window.location.href).origin;\n  } catch {\n    return false;\n  }\n}\n\nexport function computeIsNewTab(\n  runtime: ChatRuntime,\n  href: string | null | undefined,\n  targetPlatform: string | null,\n): boolean {\n  if (!href) return false;\n  if (runtime.navigation.mode === 'embed') return !isSameOriginAbsoluteHref(href);\n  return (\n    runtime.navigation.decideNewTab?.({ href, targetPlatform }) ??\n    libDecideNewTab({\n      href,\n      targetPlatform,\n      currentSource: runtime.source ?? '',\n    })\n  );\n}\n\n/**\n * Returns the `target` + `rel` attribute pair for an inner `<a>` based\n * on the new-tab decision. Spread directly into JSX:\n *   <a href={href} {...newTabAnchorAttrs(isNewTab)} />\n *\n * Single helper so chat anchors stay consistent — new-tab links always\n * pair `_blank` with `noopener noreferrer` (defense against window.opener\n * tab-nabbing) and same-tab links don't render either attribute.\n */\nexport function newTabAnchorAttrs(isNewTab: boolean): {\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n} {\n  return isNewTab ? { target: '_blank' as const, rel: 'noopener noreferrer' as const } : {};\n}\n\n/**\n * Combine `href` + new-tab attrs into a card's `anchorProps` slot.\n * Returns `undefined` (not a falsy object) when there's no URL, so card\n * components can branch on `anchorProps != null` to render either a\n * clickable `<a>` or a static `<span>`.\n *\n * Replaces the repeated inline\n * `chatRef.url ? { href: chatRef.url, ...newTabAnchorAttrs(isNewTab) } : undefined`\n * pattern across the 7 dispatcher wrappers.\n */\nexport function buildAnchorProps(\n  href: string | null | undefined,\n  isNewTab: boolean,\n): { href: string; target?: '_blank'; rel?: 'noopener noreferrer' } | undefined {\n  return href ? { href, ...newTabAnchorAttrs(isNewTab) } : undefined;\n}\n","import Link from '../../../embed-shims/next-link';\nimport type { EntityAuthor } from '../../../types/entity-author';\nimport { formatDate, nameInitials } from '../../../utils/format';\nimport { SquareAvatar } from '../../ui/square-avatar';\n\n/**\n * Author + publication metadata, rendered as the SAME 2-or-N-cell grid pattern\n * the release detail page uses.\n *\n * Each cell sits inside a single bordered/rounded container, separated by\n * `border-r` dividers — byte-identical visual contract to the release page.\n *\n * Cell taxonomy:\n *   - `<EntityMetadataValueCell>` — top: large value (`text-h4` uppercase),\n *     bottom: small label (`DM_Sans` 14px secondary). Used for Type / Status\n *     / Date.\n *   - `<EntityMetadataAuthorCell>` — `<SquareAvatar>` + name + label. Used\n *     for the Author column.\n *\n * `<EntityAuthorCard>` is the convenience composer for the common\n * \"Published + Author\" pair. Pass `publishedAt` to get both cells; omit it\n * to get just the Author card (single-cell mode used by surfaces with no\n * separate publication-date concept).\n *\n * Renders null when `author` is null/empty unless `renderEmptyAuthor` is set.\n */\n/**\n * Documented empty-author placeholder for CHAT CARD surfaces (\"—\" name,\n * \"Unknown\" role). The release detail page deliberately keeps its legacy\n * empty rendering instead (\"Unknown Author\"/\"Author\" via a null-field stub\n * — see release-detail-page.tsx) — two documented empty styles, one cell.\n */\nexport const EMPTY_AUTHOR_PLACEHOLDER = {\n  full_name: '—',\n  avatar_url: null,\n  job_title: 'Unknown',\n} as const;\n\nexport interface EntityAuthorCardProps {\n  author: EntityAuthor | null | undefined;\n  /** Role label rendered under the name. Defaults to \"Author\". Override\n   *  to e.g. \"Presenter\" / \"Contributor\" if semantics differ. */\n  roleLabel?: string;\n  /** Link target for the author name (e.g. the public author page). The\n   *  HOST computes it (route availability is host knowledge) — absent ⇒\n   *  plain text, exactly the prior render. */\n  authorHref?: string;\n  /** Optional publication date. When provided, the component renders as a\n   *  2-cell grid (Published | Author). When omitted, only the Author cell\n   *  renders inside the same bordered container. */\n  publishedAt?: string | Date | null;\n  /** Label for the Published cell. Defaults to \"Published\". */\n  publishedLabel?: string;\n  /**\n   * Extra value cells inserted into the metadata grid BEFORE the\n   * Published cell. Each item renders as an `<EntityMetadataValueCell>`\n   * with a large `text-h4` value and a small `DM_Sans` 14px label.\n   *\n   * Use for entity-specific labels (e.g. onboarding-guide section/step,\n   * webinar host, customer-interview customer). The grid auto-sizes via\n   * `grid-cols-N`.\n   */\n  extraCells?: Array<{ value: string; label: string; uppercase?: boolean }>;\n  /** When true, render the author cell even when `author?.full_name` is\n   *  missing — using the `EMPTY_AUTHOR_PLACEHOLDER` shape above. Used by\n   *  catalog grids that must keep a fixed shape so skeleton alignment\n   *  holds. Defaults to false. */\n  renderEmptyAuthor?: boolean;\n  className?: string;\n}\n\n/**\n * Single value cell — top: large `text-h4` value (uppercase), bottom: small\n * `DM_Sans` 14px secondary label.\n */\nexport function EntityMetadataValueCell({\n  value,\n  label,\n  className,\n  uppercase = true,\n}: {\n  value: string;\n  label: string;\n  className?: string;\n  uppercase?: boolean;\n}) {\n  return (\n    <div className={`flex flex-col gap-3 bg-ods-card p-4 ${className ?? ''}`}>\n      <div className=\"flex flex-col gap-0\">\n        <p className=\"text-ods-text-primary text-h4\">{uppercase ? value.toLocaleUpperCase() : value}</p>\n        <p className=\"text-ods-text-secondary text-h6\">{label}</p>\n      </div>\n    </div>\n  );\n}\n\n/**\n * Author cell — `<SquareAvatar>` + name + role label. The containing border /\n * divider is the caller's responsibility — this is just the cell content.\n */\nexport function EntityMetadataAuthorCell({\n  author,\n  roleLabel = 'Author',\n  authorHref,\n  className,\n}: {\n  author: NonNullable<EntityAuthorCardProps['author']>;\n  roleLabel?: string;\n  authorHref?: string;\n  className?: string;\n}) {\n  // Trimmed real name gates the LINK — a placeholder/unknown label must\n  // never render as a clickable author (an authorHref passed alongside an\n  // empty name would link \"Unknown Author\").\n  const trimmedName = typeof author.full_name === 'string' ? author.full_name.trim() : '';\n  const fullName = trimmedName || 'Unknown Author';\n  return (\n    <div className={`flex items-center gap-3 bg-ods-card p-4 ${className ?? ''}`}>\n      <SquareAvatar\n        src={author.avatar_url || ''}\n        alt={fullName}\n        fallback={nameInitials(fullName, '')}\n        size=\"md\"\n        variant=\"round\"\n      />\n      <div className=\"flex min-w-0 flex-1 flex-col gap-0\">\n        {/* title = full-name tooltip on truncation (carried over from the\n            release page's cell when it adopted this shared one). */}\n        <p className=\"truncate text-ods-text-primary text-h3\" title={fullName}>\n          {authorHref && trimmedName ? (\n            <Link href={authorHref} className=\"transition-colors hover:text-ods-accent\">\n              {fullName}\n            </Link>\n          ) : (\n            fullName\n          )}\n        </p>\n        <p className=\"text-ods-text-secondary text-h6\">{author.job_title || roleLabel}</p>\n      </div>\n    </div>\n  );\n}\n\nexport function EntityAuthorCard({\n  author,\n  roleLabel = 'Author',\n  authorHref,\n  publishedAt,\n  publishedLabel = 'Published',\n  extraCells,\n  renderEmptyAuthor = false,\n  className,\n}: EntityAuthorCardProps) {\n  const hasAuthor = !!author?.full_name;\n  if (!hasAuthor && !renderEmptyAuthor) return null;\n  const effectiveAuthor = hasAuthor ? author : EMPTY_AUTHOR_PLACEHOLDER;\n\n  // Resolve the date label. `formatDate` returns \"Invalid Date\" for\n  // unparseable inputs; collapse that to empty so the cell is hidden, not\n  // literal text.\n  const formatted = publishedAt ? formatDate(publishedAt) : '';\n  const dateLabel = formatted === 'Invalid Date' ? '' : formatted;\n\n  const showDateCell = !!dateLabel;\n  const extras = extraCells ?? [];\n  // Total cell count = extras + (optional Published) + Author. Map to\n  // an explicit tailwind `md:grid-cols-N` class string so JIT picks it\n  // up at build time.\n  const totalCells = extras.length + (showDateCell ? 1 : 0) + 1;\n  const gridColsClass =\n    totalCells >= 4\n      ? 'md:grid-cols-4'\n      : totalCells === 3\n        ? 'md:grid-cols-3'\n        : totalCells === 2\n          ? 'md:grid-cols-2'\n          : 'md:grid-cols-1';\n\n  // Helper — every cell EXCEPT the last needs the dividers (bottom on\n  // mobile, right on desktop). The last cell (Author) closes the grid\n  // without a trailing divider so the rounded corner stays clean.\n  const dividerClass = 'border-b md:border-b-0 md:border-r border-ods-border';\n\n  return (\n    <div\n      className={`grid grid-cols-1 ${gridColsClass} w-full overflow-hidden rounded-md border border-ods-border ${className ?? ''}`}\n    >\n      {extras.map((cell, i) => (\n        <EntityMetadataValueCell\n          key={`${cell.label}-${i}`}\n          value={cell.value}\n          label={cell.label}\n          uppercase={cell.uppercase ?? true}\n          className={dividerClass}\n        />\n      ))}\n      {showDateCell && (\n        <EntityMetadataValueCell value={dateLabel} label={publishedLabel} uppercase={false} className={dividerClass} />\n      )}\n      <EntityMetadataAuthorCell author={effectiveAuthor} roleLabel={roleLabel} authorHref={authorHref} />\n    </div>\n  );\n}\n","'use client';\n\nimport Image from '../../../embed-shims/next-image';\n\ninterface BlogImagePlaceholderProps {\n  /** Cover-image URL. The hub passes a `useOgPlaceholderUrl({ title, siteName })`\n   *  result; embedders pass their own pre-resolved URL. When null, the\n   *  component renders nothing. */\n  imageUrl: string | null;\n  /** Used for the `alt` attribute. */\n  title: string;\n  className?: string;\n}\n\n/**\n * Pure presentation wrapper for a cover-image / OG-placeholder fallback.\n *\n * Outer must be inline-content-model so this placeholder is HTML-valid\n * when rendered inside a markdown `<p>` (e.g. via the chat shell's\n * compact `BlogCard` fallback). `<span className=\"block\">` keeps the\n * same visual behavior as the prior `<div>` while satisfying the\n * phrasing-content constraint of its parent.\n *\n * If the `imageUrl` itself 404s (cold cache, transient failure), the\n * `onError` handler hides the broken-image icon so the parent's\n * `bg-ods-bg` shows through cleanly. Same recovery pattern every\n * cover-image render path uses.\n */\nexport function BlogImagePlaceholder({ imageUrl, title, className = '' }: BlogImagePlaceholderProps) {\n  if (!imageUrl) return null;\n\n  return (\n    <span className={`relative block h-full w-full overflow-hidden bg-ods-bg ${className}`}>\n      <Image\n        src={imageUrl}\n        alt={`Cover image for ${title}`}\n        className=\"object-contain\"\n        fill\n        sizes=\"(max-width: 768px) 100vw, 50vw\"\n        unoptimized\n        loading=\"lazy\"\n        onError={e => {\n          const img = e.currentTarget;\n          img.style.display = 'none';\n        }}\n      />\n    </span>\n  );\n}\n","'use client';\n\n/**\n * THE common cover-image fallback chain for entity cards:\n *   real cover → branded placeholder → nothing (hide the media element).\n *\n * One hook so no card hand-rolls its own `imageError` state / `displayImage`\n * ternary (they drift: the pre-hook BlogCard chain couldn't recover from a\n * FAILING placeholder, EntityPortraitCard duplicated the same logic with\n * different state). Cards wire `src` + `onError` and render their own\n * empty-state when `src` is null.\n *\n * Implementation note: state is the SET of candidate URLs that failed, and\n * `src` is derived — so a prop change (new cover URL) re-resolves instantly\n * with no reset effect, and a URL that failed once isn't retried in a loop.\n */\n\nimport { useCallback, useState, type SyntheticEvent } from 'react';\n\nexport interface CoverImageFallback {\n  /** Resolved source: first non-failed of [imageUrl, placeholderUrl], else null. */\n  src: string | null;\n  /** Wire to the media element's `onError`. */\n  onError: () => void;\n}\n\nexport function useCoverImageFallback(imageUrl?: string | null, placeholderUrl?: string | null): CoverImageFallback {\n  const [failed, setFailed] = useState<ReadonlySet<string>>(() => new Set());\n\n  const src =\n    imageUrl && !failed.has(imageUrl)\n      ? imageUrl\n      : placeholderUrl && !failed.has(placeholderUrl)\n        ? placeholderUrl\n        : null;\n\n  const onError = useCallback(() => {\n    if (!src) return;\n    setFailed(prev => new Set(prev).add(src));\n  }, [src]);\n\n  return { src, onError };\n}\n\n/** Fallback for cards with NO placeholder chain (compact/sm image slots):\n *  a broken cover simply disappears, leaving the slot's background. */\nexport const hideOnError = (e: SyntheticEvent<HTMLImageElement>) => {\n  const img = e.currentTarget;\n  img.style.display = 'none';\n};\n","'use client';\n\n/**\n * <EntityPortraitCard> — THE portrait (rail/strip) card anatomy, shared by\n * EVERY entity card's `size=\"portrait\"` density (case study, blog post,\n * customer interview, program, investor update, onboarding guide, …).\n *\n * One rail = one anatomy (2026 card-UI practice): identical zone boxes across\n * all content types —\n *   media  : aspect-[1200/630] slot + content-type chip overlay\n *   title  : fixed 72px zone, 3-line clamp (typography per entity via\n *            `titleClassName` — e.g. Azeret Mono for programs)\n *   footer : fixed 60px person zone (author avatar + name + subtitle, with\n *            the optional bottom-right icon overlay — the case-study MSP\n *            pattern)\n * Card surface, padding (p-6/gap-6) and the hover treatment (accent border +\n * accent shadow) are encoded HERE exactly once, so no per-card drift is\n * possible. Entity cards only MAP their row onto these props.\n *\n * Media rule (\"our common way\"): the REAL cover always wins. Wide covers\n * (OG-style 1200×630) fill the slot `object-cover`; a non-wide cover (square\n * podcast artwork) renders contained on an `useImageEdgeColor` background —\n * the SAME edge-sampled fill the media/news cards use (OGLinkPreview) — never\n * a flat filler and never the generic placeholder while a real image exists.\n * The cover → placeholder → hide resolution is the shared\n * `useCoverImageFallback` chain (one fallback logic for all entity cards).\n *\n * The content-type chip is the common <StatusBadge> and lives ABOVE the title\n * (an eyebrow inside the title zone), never overlaid on the artwork — branded\n * covers carry their own marks (Flamingo logo top-left) that an overlay would\n * collide with.\n */\n\nimport type React from 'react';\nimport { useState } from 'react';\nimport Image from '../../../embed-shims/next-image';\nimport { useImageEdgeColor } from '../../../hooks/ui/use-image-edge-color';\nimport { cn } from '../../../utils/cn';\nimport { Card } from '../../ui/card';\nimport { StatusBadge } from '../../ui/status-badge';\nimport { useCoverImageFallback } from './use-cover-image-fallback';\n\n/** Sources narrower than this are \"not wide\" → contained on the edge-color\n *  fill. Wide covers are 1200/630 ≈ 1.9; squares are 1.0; 1.3 splits safely. */\nconst MIN_WIDE_RATIO = 1.3;\n\nexport interface EntityPortraitPerson {\n  name: string;\n  avatarUrl?: string | null;\n  subtitle?: string | null;\n  /** Small round overlay on the avatar's bottom-right corner (e.g. MSP icon). */\n  iconOverlayUrl?: string | null;\n}\n\nexport interface EntityPortraitCardProps {\n  href: string;\n  target?: '_blank';\n  rel?: string;\n  /** Content-type chip ('Case Study', 'Podcast', …). OMIT to hide the chip —\n   *  single-type rails don't need per-card type identification; only mixed\n   *  rails pass it. */\n  typeLabel?: string;\n  imageUrl?: string | null;\n  /** Branded wide OG fallback — used when `imageUrl` is missing, errors, or\n   *  isn't wide. */\n  placeholderUrl?: string | null;\n  imageAlt: string;\n  title: string;\n  /** Typography override for the title (entity identity), zone box unchanged. */\n  titleClassName?: string;\n  person?: EntityPortraitPerson | null;\n  className?: string;\n}\n\nexport function EntityPortraitCard({\n  href,\n  target,\n  rel,\n  typeLabel,\n  imageUrl,\n  placeholderUrl,\n  imageAlt,\n  title,\n  titleClassName,\n  person,\n  className,\n}: EntityPortraitCardProps) {\n  const { src, onError: onMediaError } = useCoverImageFallback(imageUrl, placeholderUrl);\n  // null = unknown (assume wide until the image reports its natural size).\n  // The measurement carries the src it was taken from, so re-detection when the\n  // source changes (prop change OR the fallback chain advancing to the\n  // placeholder) is implicit — no effect writing `null` back into state, and no\n  // frame in which the NEW cover is laid out using the PREVIOUS one's aspect.\n  const [measured, setMeasured] = useState<{ src: string | null | undefined; isWide: boolean } | null>(null);\n  const isWide = measured?.src === src ? measured.isWide : null;\n\n  const onMediaLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {\n    const img = e.currentTarget;\n    if (img.naturalWidth > 0 && img.naturalHeight > 0) {\n      setMeasured({ src, isWide: img.naturalWidth / img.naturalHeight >= MIN_WIDE_RATIO });\n    }\n  };\n\n  // Edge-sampled slot fill for non-wide covers — the SAME hook/fallback the\n  // media/news cards use (og-link-preview.tsx).\n  const edgeColor = useImageEdgeColor(isWide === false ? src : null, 'var(--color-bg-surface)');\n\n  return (\n    <a href={href} target={target} rel={rel} className={cn('block h-full', className)} aria-label={`Open ${title}`}>\n      <Card className=\"flex h-full flex-col gap-6 overflow-hidden border border-ods-border bg-ods-card p-6 transition-all duration-200 hover:border-ods-accent hover:shadow-lg hover:shadow-ods-accent/[0.08]\">\n        {/* Media zone — the real cover always wins: wide → cover-fill;\n            non-wide → contained on the edge-color fill (news-card treatment). */}\n        <div\n          className=\"relative flex aspect-[1200/630] w-full shrink-0 items-center justify-center overflow-hidden rounded-sm bg-ods-bg transition-colors duration-300\"\n          style={isWide === false ? { backgroundColor: edgeColor } : undefined}\n        >\n          {src && (\n            <Image\n              src={src}\n              alt={imageAlt}\n              className={cn('h-full w-full', isWide === false ? 'object-contain' : 'object-cover')}\n              sizes=\"(min-width: 800px) 400px, 100vw\"\n              fill\n              unoptimized\n              onLoad={onMediaLoad}\n              onError={onMediaError}\n            />\n          )}\n        </div>\n\n        {/* Title zone — fixed box. Mixed rails: common StatusBadge eyebrow +\n            2-line title (chip lives here, never on the artwork). Single-type\n            rails (no typeLabel): centered 3-line title. */}\n        <div className=\"flex h-[72px] shrink-0 flex-col justify-center gap-1.5\">\n          {typeLabel && (\n            <StatusBadge\n              variant=\"button\"\n              colorScheme=\"accentBorder\"\n              singleLine\n              text={typeLabel}\n              className=\"self-start\"\n            />\n          )}\n          <h3\n            className={cn(\n              'break-words leading-6 text-ods-text-primary text-h4',\n              typeLabel ? 'line-clamp-2' : 'line-clamp-3',\n              titleClassName,\n            )}\n          >\n            {title}\n          </h3>\n        </div>\n\n        {/* Person/footer zone — fixed box (kept even when empty so every card\n            in a rail shares baselines). */}\n        <div className=\"flex h-[60px] shrink-0 items-center\">\n          {person && (\n            <div className=\"flex w-full min-w-0 items-center gap-3\">\n              <div className=\"relative h-12 w-12 shrink-0\">\n                {person.avatarUrl ? (\n                  <Image\n                    src={person.avatarUrl}\n                    alt={person.name}\n                    className=\"h-12 w-12 rounded-full border border-ods-border bg-ods-bg object-cover\"\n                    width={48}\n                    height={48}\n                    unoptimized\n                  />\n                ) : (\n                  <div className=\"flex h-12 w-12 items-center justify-center rounded-full border border-ods-border bg-ods-bg\">\n                    <span className=\"text-ods-text-secondary text-h4\">{person.name.charAt(0).toUpperCase()}</span>\n                  </div>\n                )}\n                {person.iconOverlayUrl && (\n                  <div className=\"absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-ods-text-primary ring-1 ring-ods-bg\">\n                    <Image\n                      src={person.iconOverlayUrl}\n                      alt=\"\"\n                      className=\"h-full w-full object-cover\"\n                      width={24}\n                      height={24}\n                      unoptimized\n                    />\n                  </div>\n                )}\n              </div>\n              <div className=\"flex min-w-0 flex-col\">\n                <span className=\"truncate font-bold text-ods-text-primary text-h6\">{person.name}</span>\n                {person.subtitle && <span className=\"truncate text-ods-text-secondary text-h6\">{person.subtitle}</span>}\n              </div>\n            </div>\n          )}\n        </div>\n      </Card>\n    </a>\n  );\n}\n","'use client';\n\nimport { Globe } from 'lucide-react';\nimport { cn } from '../../utils/cn';\nimport { getPlatformBrandClasses } from '../../utils/platform-identity';\nimport { FlamingoLogo } from '../flamingo-logo';\nimport { MiamiCyberGangLogoFaceOnly } from '../icons/miami-cyber-gang-logo-face-only';\nimport { OpenFrameLogo } from '../icons/openframe-logo';\nimport { OpenmspLogo } from '../openmsp-logo';\n\ninterface PlatformBadgeProps {\n  platform?: {\n    id: string;\n    name: string;\n    display_name: string;\n  } | null;\n  size?: 'xs' | 'sm' | 'md';\n  showLabel?: boolean;\n  className?: string;\n}\n\nconst sizeClasses = {\n  xs: {\n    container: 'gap-1 px-1.5 py-0.5',\n    icon: 'h-3 w-3',\n    text: 'text-h6',\n  },\n  sm: {\n    container: 'gap-1.5 px-2 py-1',\n    icon: 'h-4 w-4',\n    text: 'text-h6',\n  },\n  md: {\n    container: 'gap-2 px-2.5 py-1.5',\n    icon: 'h-5 w-5',\n    text: 'text-h6',\n  },\n};\n\nconst PlatformIcon = ({ platform, className }: { platform: string; className: string }) => {\n  // Extract size from className (h-4 w-4 -> 16, h-5 w-5 -> 20, etc.)\n  const sizeMatch = className.match(/h-(\\d+)/);\n  const size = sizeMatch ? parseInt(sizeMatch[1]) * 4 : 16; // Convert Tailwind size to pixels\n\n  switch (platform) {\n    case 'openmsp':\n      return (\n        <OpenmspLogo\n          className={className}\n          frontBubbleColor=\"currentColor\"\n          innerFrontBubbleColor=\"var(--ods-system-greys-black)\"\n          backBubbleColor=\"currentColor\"\n        />\n      );\n    case 'flamingo':\n    case 'flamingo-teaser':\n      return <FlamingoLogo className={className} />;\n    case 'openframe':\n      return <OpenFrameLogo className={className} />;\n    case 'tmcg':\n      return <MiamiCyberGangLogoFaceOnly size={size} className={className} />;\n    case 'company-hub':\n    case 'marketing-hub':\n    case 'product-hub':\n    case 'revenue-hub':\n    case 'people-hub':\n      return <FlamingoLogo className={className} fill=\"currentColor\" />;\n    default:\n      return <Globe className={className} />;\n  }\n};\n\nexport function PlatformBadge({ platform, size = 'sm', showLabel = true, className }: PlatformBadgeProps) {\n  if (!platform) {\n    return null;\n  }\n\n  const sizes = sizeClasses[size];\n  // Soft-tinted chip: background + border at 10%/30% of the platform's accent,\n  // text at full strength. Derived from the brand record (was a hex map).\n  const colors = getPlatformBrandClasses(platform.name);\n\n  return (\n    <div\n      className={cn(\n        'inline-flex items-center rounded-full border',\n        sizes.container,\n        colors.accentBgSoft,\n        colors.accentBorderSoft,\n        colors.accentText,\n        'font-body font-medium',\n        className,\n      )}\n    >\n      <PlatformIcon platform={platform.name} className={sizes.icon} />\n      {showLabel && <span className={sizes.text}>{platform.display_name || platform.name}</span>}\n    </div>\n  );\n}\n","import type React from 'react';\n\ninterface FlamingoLogoProps extends React.SVGProps<SVGSVGElement> {\n  className?: string;\n  size?: number;\n  color?: string;\n}\n\nexport function FlamingoLogo({\n  className = '',\n  color = 'var(--ods-flamingo-pink-base)',\n  size = 120,\n  ...props\n}: FlamingoLogoProps) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 121 120\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n      {...props}\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M20.8645 77.9756C20.6306 78.095 20.3452 77.9582 20.3369 77.7002C20.2055 73.6173 23.4398 62.2314 27.7657 59.0452C31.477 45.9806 61.8811 24.0208 81.7715 48.2044C83.1662 49.7884 84.0264 52.0978 84.7512 54.0436C86.0615 57.5613 86.9291 59.8908 89.7117 54.5977C92.4782 49.335 87.2725 42.2381 81.701 34.6426C75.7955 26.5918 69.4791 17.9808 71.8102 10.4001C75.4609 -1.47146 91.9849 -4.33245 94.5422 7.89834C98.4226 10.7002 104.422 17.0815 96.2751 25.8554C96.0466 26.1014 95.6263 25.9453 95.6104 25.6132C95.417 21.5769 93.9498 16.1177 90.5641 14.5697C89.0187 13.8632 88.0307 13.6723 87.1405 13.5003C85.585 13.1998 84.3282 12.9569 80.919 10.1221C75.236 5.39658 75.236 20.1291 87.7386 32.3599C97.1156 41.533 106.493 66.2324 84.0287 70.9981C74.9174 72.9311 68.0892 72.0015 61.3076 71.0782C61.0953 71.0493 60.8831 71.0204 60.6708 70.9916V89.3274L66.3745 91.8104C67.0914 92.1225 67.4139 92.944 67.0949 93.6453C66.7759 94.3466 65.9362 94.6621 65.2193 94.35L60.6708 92.37V118.61C60.6708 119.378 60.0347 120 59.2501 120C58.4654 120 57.8293 119.378 57.8293 118.61V91.133L42.4873 84.4542C42.0611 84.2686 41.7551 83.8902 41.6687 83.4417C41.5824 82.9931 41.7266 82.5317 42.0546 82.2072L54.1527 70.2377C45.537 69.536 35.8193 70.3474 20.8645 77.9756ZM57.7692 70.613L45.5202 82.732L57.8293 88.0905V70.6204C57.8093 70.618 57.7893 70.6155 57.7692 70.613Z\"\n        fill={color}\n      />\n    </svg>\n  );\n}\n","import type React from 'react';\n\ninterface OpenmspLogoProps extends React.SVGProps<SVGSVGElement> {\n  className?: string;\n  frontBubbleColor?: string;\n  innerFrontBubbleColor?: string;\n  backBubbleColor?: string;\n  color?: string;\n}\n\nexport function OpenmspLogo({\n  className = '',\n  frontBubbleColor = '#000000',\n  innerFrontBubbleColor = '#ffffff',\n  backBubbleColor = '#ffffff',\n  ...props\n}: OpenmspLogoProps) {\n  return (\n    <svg\n      width=\"97\"\n      height=\"96\"\n      viewBox=\"0 0 97 96\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n      {...props}\n    >\n      <path\n        d=\"M73.933 0C75.7003 0 77.133 1.43269 77.133 3.2V54.4C77.133 56.1673 75.7003 57.6 73.933 57.6H3.53301C1.7657 57.6 0.333008 56.1673 0.333008 54.4V3.2C0.333008 1.43269 1.7657 0 3.53301 0H73.933ZM10.1076 47.6494C10.1076 48.0912 10.4658 48.4494 10.9076 48.4494H66.5584C67.0002 48.4494 67.3584 48.0912 67.3584 47.6494V9.95059C67.3584 9.50876 67.0002 9.15059 66.5584 9.15059H10.9076C10.4658 9.15059 10.1076 9.50876 10.1076 9.95059V47.6494Z\"\n        fill={backBubbleColor}\n      />\n      <path\n        d=\"M19.5332 22.4C19.5332 20.6326 20.9659 19.2 22.7332 19.2H93.1332C94.9005 19.2 96.3332 20.6326 96.3332 22.4V76.8C96.3332 78.2222 95.3945 79.4737 94.0292 79.8719L39.7572 95.7013C39.2452 95.8506 38.7332 95.4666 38.7332 94.9333V80C38.7332 79.5581 38.375 79.2 37.9332 79.2H22.7332C20.9659 79.2 19.5332 77.7673 19.5332 76V22.4Z\"\n        fill={innerFrontBubbleColor}\n      />\n      <path\n        d=\"M93.1332 19.2C94.9005 19.2 96.3332 20.6326 96.3332 22.4V76.8C96.3332 78.2222 95.3945 79.4737 94.0292 79.8719L39.7572 95.7013C39.2452 95.8506 38.7332 95.4666 38.7332 94.9333V80C38.7332 79.5581 38.375 79.2 37.9332 79.2H22.7332C20.9659 79.2 19.5332 77.7673 19.5332 76V22.4C19.5332 20.6326 20.9659 19.2 22.7332 19.2H93.1332ZM48.3377 82.1286C48.3355 82.6633 48.8484 83.0497 49.3617 82.9L86.1572 72.1672C86.4985 72.0676 86.7332 71.7547 86.7332 71.3992V29.6C86.7332 29.1581 86.375 28.8 85.9332 28.8H29.9332C29.4914 28.8 29.1332 29.1581 29.1332 29.6V68.8C29.1332 69.2418 29.4914 69.6 29.9332 69.6H47.5869C48.03 69.6 48.3887 69.9602 48.3869 70.4033L48.3377 82.1286Z\"\n        fill={frontBubbleColor}\n      />\n      <circle cx=\"45.1334\" cy=\"44.8\" r=\"6.4\" fill={frontBubbleColor} />\n    </svg>\n  );\n}\n","'use client';\n\nimport type React from 'react';\nimport { useState } from 'react';\nimport Image from '../../../embed-shims/next-image';\nimport { useImageEdgeColor } from '../../../hooks/ui/use-image-edge-color';\nimport { cn } from '../../../utils/cn';\nimport { PlatformBadge } from '../../features/platform-badge';\n\ninterface PlatformInfo {\n  platform_id?: string;\n  id?: string;\n  name: string;\n  display_name?: string;\n}\n\ninterface AdminContentCardProps {\n  /** Cover image URL */\n  imageUrl?: string | null;\n  /** Pre-computed placeholder URL used when `imageUrl` is missing or\n   *  fails to load. Hub callers pass `useOgPlaceholderUrl({ title })`; embedders\n   *  supply their own URL or leave null for a plain background fallback. */\n  placeholderUrl?: string | null;\n  /** Alt text / fallback title */\n  title: string;\n  /** Summary or description text */\n  summary?: string | null;\n  /** Subtitle line (e.g. customer name) */\n  subtitle?: string | null;\n  /** Platform badges */\n  platforms?: PlatformInfo[];\n  /** Status/info badges rendered after platform badges */\n  badges?: React.ReactNode;\n  /** Meta info row (date, views, etc.) */\n  meta?: React.ReactNode;\n  /** Action buttons row */\n  actions?: React.ReactNode;\n  /** Additional class names */\n  className?: string;\n}\n\nexport function AdminContentCard({\n  imageUrl,\n  placeholderUrl,\n  title,\n  summary,\n  subtitle,\n  platforms,\n  badges,\n  meta,\n  actions,\n  className,\n}: AdminContentCardProps) {\n  const [imageError, setImageError] = useState(false);\n  // WHICH url finished loading, rather than a bare flag reset from an effect on\n  // every url change. The reset is then implicit — a new url simply is not the\n  // one that loaded — so the skeleton is back in the SAME render that swapped\n  // the src, instead of one commit later where the new image briefly showed\n  // through at full opacity before being faded back out.\n  const [loadedUrl, setLoadedUrl] = useState<string | null>(null);\n  const displayUrl = imageUrl && !imageError ? imageUrl : placeholderUrl;\n  const imageLoaded = Boolean(displayUrl) && loadedUrl === displayUrl;\n  const imageBgColor = useImageEdgeColor(displayUrl || null, 'transparent');\n\n  return (\n    <article\n      className={cn(\n        'group h-full overflow-hidden rounded-2xl',\n        'border border-ods-border bg-ods-card',\n        'flex flex-col',\n        'transition-all duration-300 ease-out',\n        'hover:-translate-y-1 hover:shadow-lg hover:shadow-ods-accent/[0.08]',\n        'hover:border-ods-accent',\n        className,\n      )}\n    >\n      {/* Cover Image — 3:2 aspect ratio, centered with edge-color fill */}\n      <div className=\"relative aspect-[3/2] w-full shrink-0 overflow-hidden\" style={{ backgroundColor: imageBgColor }}>\n        {displayUrl ? (\n          <>\n            {!imageLoaded && <div className=\"absolute inset-0 animate-pulse bg-ods-border/20\" />}\n            <Image\n              src={displayUrl}\n              alt={title}\n              fill\n              sizes=\"(min-width: 1280px) 33vw, (min-width: 768px) 50vw, 100vw\"\n              className={cn(\n                'object-contain object-center',\n                'transition-transform duration-500 ease-out',\n                'group-hover:scale-[1.03]',\n                imageLoaded ? 'opacity-100' : 'opacity-0',\n              )}\n              unoptimized\n              onLoad={() => setLoadedUrl(displayUrl ?? null)}\n              onError={() => setImageError(true)}\n            />\n          </>\n        ) : (\n          <div className=\"absolute inset-0 bg-ods-bg\" />\n        )}\n      </div>\n\n      {/* Content */}\n      <div className=\"flex flex-1 flex-col gap-3 p-5\">\n        {/* Title */}\n        <h3 className=\"line-clamp-2 break-words text-ods-text-primary text-h3\">{title}</h3>\n\n        {/* Subtitle (optional) */}\n        {subtitle && <p className=\"truncate text-ods-text-secondary text-h6\">{subtitle}</p>}\n\n        {/* Summary */}\n        {summary && <p className=\"line-clamp-2 text-ods-text-secondary text-h6\">{summary}</p>}\n\n        {/* Badges row */}\n        {(Number(platforms?.length) > 0 || badges) && (\n          <div className=\"flex flex-wrap items-center gap-2\">\n            {platforms?.map(p => (\n              <PlatformBadge\n                key={p.platform_id || p.id}\n                platform={{ id: p.id || p.platform_id || '', name: p.name, display_name: p.display_name || p.name }}\n                size=\"xs\"\n              />\n            ))}\n            {badges}\n          </div>\n        )}\n\n        {/* Meta row */}\n        {meta && <div className=\"flex items-center gap-3 text-ods-text-secondary text-h6\">{meta}</div>}\n\n        {/* Actions — pushed to bottom */}\n        {actions && (\n          <div className=\"mt-auto flex items-center justify-between border-t border-ods-border pt-3\">{actions}</div>\n        )}\n      </div>\n    </article>\n  );\n}\n","/**\n * Utility functions for handling image proxy URLs\n */\n\n/**\n * Get proxied image URL for external images\n * If it's an external HTTP/HTTPS URL, proxy it through our API\n * Otherwise, return the original URL\n */\nexport function getProxiedImageUrl(imageUrl: string | null): string | null {\n  if (!imageUrl) return null;\n\n  // If it's an external HTTP/HTTPS URL, determine if we should proxy it\n  if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {\n    // Skip if it's already our own proxy URL\n    if (imageUrl.includes('/api/image-proxy')) {\n      return imageUrl;\n    }\n\n    // Skip proxying for OpenMSP-owned domains (e.g., app.openmsp.ai, cdn.openmsp.ai, etc.)\n    if (imageUrl.includes('openmsp.ai')) {\n      return imageUrl;\n    }\n\n    return `/api/image-proxy?url=${encodeURIComponent(imageUrl)}`;\n  }\n\n  // Return local/relative images as-is\n  return imageUrl;\n}\n\n/**\n * Check if an image URL needs to be proxied\n */\nexport function shouldProxyImage(imageUrl: string | null): boolean {\n  if (!imageUrl) return false;\n\n  // Proxy external HTTP/HTTPS URLs that aren't already proxied\n  return (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) && !imageUrl.includes('/api/image-proxy');\n}\n","import type React from 'react';\nimport Image from '../../../embed-shims/next-image';\nimport { getProxiedImageUrl } from '../../../utils/image-proxy-stub';\nimport { AdminContentCard } from './admin-content-card';\n\n/** The fields every people-hub employee entry shares. Entity-specific bindings\n *  (`WhatIShippedCard`, `HowIWorkCard`) map their own date column to `dateLabel`\n *  and pass the rest through unchanged. */\nexport interface EmployeeEntryCardData {\n  title?: string | null;\n  summary?: string | null;\n  status?: string | null;\n  featured_image?: string | null;\n  main_video_thumbnail?: string | null;\n  author?: { full_name?: string | null; avatar_url?: string | null } | null;\n}\n\nexport interface EmployeeEntryCardProps {\n  entry: EmployeeEntryCardData;\n  /** Already-formatted date shown in the meta row. Bindings own the format so\n   *  the UTC-pin convention stays in `utils/format`, not in the card. */\n  dateLabel?: string | null;\n  /** Extra badges rendered after the status badge (e.g. discipline / level). */\n  extraBadges?: React.ReactNode;\n  /** Fallback title when the entry has none — names the entity in the empty case. */\n  untitledLabel?: string;\n  /** OG fallback cover. Caller computes it (hub: `useOgPlaceholderUrl`; related\n   *  rail: `extras.buildOgPlaceholderUrl`). */\n  placeholderUrl?: string | null;\n  /** Owner action row (dashboard). Omit for a read-only card. */\n  actions?: React.ReactNode;\n  /** When provided, the WHOLE card becomes a link (related-rail click-through).\n   *  Don't combine with `actions` (nested interactive). */\n  anchorProps?: React.AnchorHTMLAttributes<HTMLAnchorElement>;\n  className?: string;\n}\n\nconst STATUS_BADGE_CLASS: Record<string, string> = {\n  published: 'bg-ods-success-secondary text-ods-success',\n  draft: 'bg-ods-warning-secondary text-ods-warning',\n  archived: 'bg-ods-border text-ods-text-secondary',\n  // Design-doc lifecycle (product-hub) — same ODS pairs, no new palette.\n  in_review: 'bg-ods-warning-secondary text-ods-warning',\n  approved: 'bg-ods-success-secondary text-ods-success',\n  building: 'bg-ods-success-secondary text-ods-success',\n  shipped: 'bg-ods-success-secondary text-ods-success',\n  abandoned: 'bg-ods-border text-ods-text-secondary',\n};\n\n/** Shared badge treatment so an entity's own badges (discipline, level) sit\n *  flush with the status badge instead of re-deriving the classes per card. */\nexport function EmployeeEntryBadge({ children }: { children: React.ReactNode }) {\n  return (\n    <span className=\"rounded border border-ods-border bg-ods-card px-2 py-1 text-xs font-medium text-ods-text-secondary\">\n      {children}\n    </span>\n  );\n}\n\n/**\n * THE people-hub employee-entry card. Wraps `AdminContentCard` with the canonical\n * mapping (cover = featured_image || main_video_thumbnail, OG placeholder\n * fallback, title, 140-char summary, status badge, author avatar+name + a date\n * label). Every entity that renders \"an employee's entry\" binds to this one\n * engine — What I Shipped and How I Work differ only in which date column they\n * format and which extra badges they add, so the card itself cannot drift\n * between the dashboard and the related-content rail.\n */\nexport function EmployeeEntryCard({\n  entry,\n  dateLabel,\n  extraBadges,\n  untitledLabel = 'Untitled entry',\n  placeholderUrl,\n  actions,\n  anchorProps,\n  className,\n}: EmployeeEntryCardProps) {\n  const card = (\n    <AdminContentCard\n      imageUrl={entry.featured_image || entry.main_video_thumbnail}\n      placeholderUrl={placeholderUrl}\n      title={entry.title || untitledLabel}\n      summary={(entry.summary ?? '').slice(0, 140) || 'No description'}\n      badges={\n        entry.status || extraBadges ? (\n          <>\n            {entry.status ? (\n              <span\n                className={`rounded px-2 py-1 text-xs font-medium ${\n                  STATUS_BADGE_CLASS[entry.status] ?? 'border border-ods-border bg-ods-card text-ods-text-secondary'\n                }`}\n              >\n                {entry.status}\n              </span>\n            ) : null}\n            {extraBadges}\n          </>\n        ) : null\n      }\n      meta={\n        <>\n          <span className=\"flex min-w-0 items-center gap-2\">\n            {entry.author?.avatar_url ? (\n              <Image\n                src={getProxiedImageUrl(entry.author.avatar_url) ?? entry.author.avatar_url}\n                alt=\"\"\n                className=\"h-5 w-5 shrink-0 rounded-full object-cover\"\n                width={20}\n                height={20}\n                unoptimized\n              />\n            ) : null}\n            <span className=\"truncate\">{entry.author?.full_name ?? ''}</span>\n          </span>\n          {dateLabel ? <span>{dateLabel}</span> : null}\n        </>\n      }\n      actions={actions}\n      className={className}\n    />\n  );\n  return anchorProps ? (\n    <a {...anchorProps} className=\"block h-full\">\n      {card}\n    </a>\n  ) : (\n    card\n  );\n}\n\n/** Loading skeleton matching EmployeeEntryCard's AdminContentCard shape (3:2 cover\n *  + title / summary / meta lines). Used by the related-content rail while a\n *  group hydrates so there's no shape jump when the real card lands. */\nexport function EmployeeEntryCardSkeleton({ className }: { className?: string }) {\n  // Same convention as BlogCardSkeleton et al.: animate-pulse on the container,\n  // `bg-ods-bg` placeholder blocks, flex-grow body with an `mt-auto` avatar+name\n  // row. Shape mirrors EmployeeEntryCard's AdminContentCard (rounded-2xl, 3:2 cover).\n  return (\n    <div\n      className={`group flex h-full animate-pulse flex-col overflow-hidden rounded-2xl border border-ods-border bg-ods-card ${className ?? ''}`}\n    >\n      <div className=\"aspect-[3/2] bg-ods-bg\" />\n      <div className=\"flex flex-grow flex-col space-y-3 p-4\">\n        <div className=\"h-5 w-3/4 rounded bg-ods-bg\" />\n        <div className=\"h-3 w-full rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-4/5 rounded bg-ods-bg/60\" />\n        <div className=\"mt-auto flex items-center gap-2\">\n          <div className=\"h-8 w-8 rounded-full bg-ods-bg\" />\n          <div className=\"h-3 w-24 rounded bg-ods-bg/60\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n","import type React from 'react';\nimport { formatEntryMonthUTC } from '../../../utils/format';\nimport { EmployeeEntryCard, EmployeeEntryCardSkeleton, type EmployeeEntryCardData } from './employee-entry-card';\n\n/** Minimal row shape the card renders. Both the hub dashboard entry and the\n *  related-content hydrated row satisfy it structurally. */\nexport interface WhatIShippedCardData extends EmployeeEntryCardData {\n  entry_month?: string | null;\n}\n\nexport interface WhatIShippedCardProps {\n  entry: WhatIShippedCardData;\n  /** OG fallback cover. Caller computes it (hub: `useOgPlaceholderUrl`; related\n   *  rail: `extras.buildOgPlaceholderUrl`). */\n  placeholderUrl?: string | null;\n  /** Owner action row (dashboard). Omit for a read-only card. */\n  actions?: React.ReactNode;\n  /** When provided, the WHOLE card becomes a link (related-rail click-through).\n   *  Don't combine with `actions` (nested interactive). */\n  anchorProps?: React.AnchorHTMLAttributes<HTMLAnchorElement>;\n  className?: string;\n}\n\n/**\n * THE single \"What I Shipped\" card — a thin binding over `EmployeeEntryCard`\n * that maps the reporting month to the shared meta date. Used by BOTH the\n * people-hub dashboard (with owner `actions`) and the related-content rail\n * (with `anchorProps` for click-through), so the card is byte-identical\n * everywhere — one engine, one mapping, no drift.\n */\nexport function WhatIShippedCard({ entry, placeholderUrl, actions, anchorProps, className }: WhatIShippedCardProps) {\n  return (\n    <EmployeeEntryCard\n      entry={entry}\n      dateLabel={formatEntryMonthUTC(entry.entry_month, 'short')}\n      placeholderUrl={placeholderUrl}\n      actions={actions}\n      anchorProps={anchorProps}\n      className={className}\n    />\n  );\n}\n\n/** Loading skeleton matching WhatIShippedCard's shape. Shared with every other\n *  employee-entry card so the rail's placeholder never diverges from the card. */\nexport const WhatIShippedCardSkeleton = EmployeeEntryCardSkeleton;\n","import type React from 'react';\nimport { formatDateUTC } from '../../../utils/format';\nimport {\n  EmployeeEntryCard,\n  EmployeeEntryCardSkeleton,\n  EmployeeEntryBadge,\n  type EmployeeEntryCardData,\n} from './employee-entry-card';\n\n/** Minimal row shape the card renders. Both the hub dashboard entry and the\n *  related-content hydrated row satisfy it structurally. */\nexport interface HowIWorkCardData extends EmployeeEntryCardData {\n  session_date?: string | null;\n  /** Which craft the session is about — surfaced as a badge so a reader\n   *  scanning the grid can tell a sales workflow from an engineering one. */\n  discipline?: string | null;\n}\n\nexport interface HowIWorkCardProps {\n  entry: HowIWorkCardData;\n  /** OG fallback cover. Caller computes it (hub: `useOgPlaceholderUrl`; related\n   *  rail: `extras.buildOgPlaceholderUrl`). */\n  placeholderUrl?: string | null;\n  /** Owner action row (dashboard). Omit for a read-only card. */\n  actions?: React.ReactNode;\n  /** When provided, the WHOLE card becomes a link (related-rail click-through).\n   *  Don't combine with `actions` (nested interactive). */\n  anchorProps?: React.AnchorHTMLAttributes<HTMLAnchorElement>;\n  className?: string;\n}\n\n/** `ops` is an initialism, so the generic capitalize would render it \"Ops\" —\n *  fine — but `session_date`-style multiword disciplines need the underscore\n *  stripped too. One place, so the badge matches the filter chip exactly. */\nfunction disciplineLabel(discipline: string): string {\n  return discipline.replace(/_/g, ' ').replace(/\\b\\w/g, ch => ch.toUpperCase());\n}\n\n/**\n * THE single \"How I Work\" card — a thin binding over `EmployeeEntryCard` that\n * maps the session date to the shared meta date and adds the discipline badge.\n * Used by BOTH the people-hub dashboard (with owner `actions`) and the\n * related-content rail (with `anchorProps`), so the card cannot drift between\n * the two surfaces.\n */\nexport function HowIWorkCard({ entry, placeholderUrl, actions, anchorProps, className }: HowIWorkCardProps) {\n  return (\n    <EmployeeEntryCard\n      entry={entry}\n      // `formatDateUTC` anchors date-only strings to UTC midnight, which is what\n      // keeps the label from shifting a day between server and client render.\n      dateLabel={entry.session_date ? formatDateUTC(entry.session_date, { fallback: '' }) || null : null}\n      extraBadges={\n        entry.discipline ? <EmployeeEntryBadge>{disciplineLabel(entry.discipline)}</EmployeeEntryBadge> : null\n      }\n      untitledLabel=\"Untitled session\"\n      placeholderUrl={placeholderUrl}\n      actions={actions}\n      anchorProps={anchorProps}\n      className={className}\n    />\n  );\n}\n\n/** Loading skeleton matching HowIWorkCard's shape. Shared with every other\n *  employee-entry card so the rail's placeholder never diverges from the card. */\nexport const HowIWorkCardSkeleton = EmployeeEntryCardSkeleton;\n","/**\n * video-bites-shared — SERVER-SAFE leaf for the video-bites surface.\n *\n * No React imports, no 'use client'. Published as its own subpath\n * (`@flamingo-stack/openframe-frontend-core/components/features/video-bites-shared`)\n * so server-side consumers (the hub's featured-video-bites DAL) can import the\n * title constant, profile adapter, and sort comparator WITHOUT pulling the\n * 'use client' strip/MuxPlayer module graph into a server bundle — same\n * pattern (and reason) as `mux-origins.ts`.\n *\n * Client components (`<VideoBitesStrip>`, the features barrel) re-export from\n * here; there must never be a second definition of any of these.\n */\n\nimport type { VideoTeaser } from '../../types/video-processing';\n\n/** Single exported home for the unified public bites-section title. */\nexport const DEFAULT_VIDEO_BITES_TITLE = 'Key Moments';\n\n/**\n * Profile rendered in the strip's hover overlay. Structurally identical to\n * `UserDisplayProps` — the overlay renders it via `<UserDisplay>` 1:1.\n */\nexport interface VideoBiteStripProfile {\n  name: string;\n  avatarUrl?: string | null;\n  subtitle?: string | null;\n}\n\n/**\n * Adapter SSoT: builds a strip profile from the repo-wide author/user shape\n * (`profiles` rows and hydrated authors all expose full_name / avatar_url /\n * job_title). ALL strip call sites and the featured DAL's mapProfile route\n * through this — never hand-roll the mapping.\n *\n * Returns NULL (never `{name: ''}`) when `full_name` is null/empty: callers\n * rely on `profile ?? fallback` chains, and some producers (e.g. the release\n * kit) pass null-field author stubs.\n */\nexport function toStripProfile(\n  p?: { full_name?: string | null; avatar_url?: string | null; job_title?: string | null } | null,\n  fallbackSubtitle?: string | null,\n): VideoBiteStripProfile | null {\n  if (!p?.full_name) return null;\n  return {\n    name: p.full_name,\n    avatarUrl: p.avatar_url ?? null,\n    subtitle: p.job_title ?? fallbackSubtitle ?? null,\n  };\n}\n\n/**\n * Null-safe created_at-desc COMPARATOR (missing dates sort last).\n * Call as `[...arr].sort(sortBitesByCreatedAtDesc)` — shared by the strip,\n * the hub admin editor, and the hub featured DAL.\n */\nexport function sortBitesByCreatedAtDesc(a: VideoTeaser, b: VideoTeaser): number {\n  if (!a.created_at && !b.created_at) return 0;\n  if (!a.created_at) return 1;\n  if (!b.created_at) return -1;\n  return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();\n}\n\n/**\n * FEATURED-recency comparator: `featured_at` DESC — the stamp every featured\n * bite carries (written by the admin star toggle; pre-existing featured bites\n * were backfilled 2026-08-19, so there is no date-fallback ranking). The\n * comparator FEATURED surfaces (the cross-entity aggregation + its strips)\n * must use — ranking those surfaces by `created_at` made featuring any older\n * bite a silent no-op once the surface cap filled with newer bites. A missing\n * stamp (only possible from a stale mid-deploy writer) sorts last.\n */\nexport function sortBitesByFeaturedAtDesc(a: VideoTeaser, b: VideoTeaser): number {\n  if (!a.featured_at && !b.featured_at) return 0;\n  if (!a.featured_at) return 1;\n  if (!b.featured_at) return -1;\n  return new Date(b.featured_at).getTime() - new Date(a.featured_at).getTime();\n}\n","'use client';\n\nimport { AlertTriangle, Eye, Package, Play, Sparkles, TrendingUp, Wrench } from 'lucide-react';\nimport type React from 'react';\nimport Image from '../../../embed-shims/next-image';\nimport { cn } from '../../../utils/cn';\nimport { InteractiveCard } from '../../ui/interactive-card';\nimport { SquareAvatar } from '../../ui/square-avatar';\nimport { StatusBadge } from '../../ui/status-badge';\n\n/**\n * Card density. Two variants, both actively used across openframe + flamingo\n * apps and the related-content rail:\n *\n * - `lg`: rich large card used everywhere a product release is the focal\n *   item — openframe's `/releases` catalog row, flamingo's DevCenter tab,\n *   investor-update related-content section. Three zones — hero (16:9 cover\n *   + version pill + title + summary), changelog stats strip (icons +\n *   counts), metadata grid footer (Type · Status · Released · Author). The\n *   grid mirrors the hub's `<EntityAuthorCard>` byte-for-byte (see lg\n *   branch comment).\n *\n * - `sm`: compact horizontal layout (~80px tall) for inline rendering inside\n *   chat messages and other tight surfaces. Drops `<h3>` (block-only,\n *   illegal inside markdown `<p>`) for `<span>` text, swaps the outer\n *   `InteractiveCard` for a `<span>`-anchored link, and collapses to:\n *   56px icon + 1-line title + 1-line meta (version · date).\n *\n * A previous `default` variant (vertical title+description / version+date\n * column) was deleted in the 2026-05 DRY pass — it had a single consumer\n * (the hub's RelatedContentSection) that has since moved to `lg`.\n */\nexport type ProductReleaseCardSize = 'lg' | 'sm';\n\n/**\n * Minimal structural `<a>` prop bundle the consumer composes (typically\n * via the hub's `useNavLink` hook — single source of truth for click\n * routing). Kept structural here so the OSS lib has zero hub coupling;\n * the consumer's `NavLinkProps` is type-compatible by shape.\n *\n * When supplied, the outer element renders as `<a {...anchorProps}>` so\n * the SAME routing decision (modifier-click → browser default, cross-\n * origin → new tab, same-origin → `router.push`) runs identically to\n * every other entity card. When absent, the legacy `onClick` branch is\n * used (back-compat for the public `/releases` page).\n */\nexport interface ProductReleaseCardAnchorProps {\n  href: string;\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;\n}\n\nexport interface ProductReleaseCardProps {\n  /** Release title */\n  title: string;\n  /** Release summary/description */\n  summary?: string | null;\n  /** Version string (e.g., \"1.2.0\") */\n  version: string;\n  /** Formatted date string for display */\n  formattedDate: string;\n  /**\n   * Legacy click handler — kept for back-compat with public-page callers\n   * (e.g. `/releases` tab) that route via `router.push()` directly. When\n   * `anchorProps` is also supplied, `anchorProps` wins and this is\n   * ignored.\n   */\n  onClick?: () => void;\n  /**\n   * `<a>` prop bundle from the consumer's `useNavLink` (or equivalent).\n   * When provided, the card's outer element renders as a real anchor so\n   * routing (cross-origin → new tab, same-origin → soft RSC nav,\n   * modifier-click → browser default) is owned by the hook — the card\n   * writes NO click logic of its own. This is the path every other\n   * entity card uses; `onClick` is only kept for the one legacy caller.\n   */\n  anchorProps?: ProductReleaseCardAnchorProps;\n  /** Additional CSS classes */\n  className?: string;\n  /** Card density. Defaults to `'lg'` (the canonical large card). */\n  size?: ProductReleaseCardSize;\n\n  // ─── Catalog-only props (ignored by `default` / `sm` branches) ─────────\n  /** Cover image URL. Falls back to a neutral `Package`-icon placeholder. */\n  coverImage?: string | null;\n  /** Drives the Play overlay on the cover. Caller sets `true` when the cover\n   *  came from a `*_video_thumbnail` field. */\n  hasVideoCover?: boolean;\n  /** Release type for the metadata grid's first cell. */\n  releaseType?: 'major' | 'minor' | 'patch' | 'beta' | 'alpha';\n  /** Release status for the metadata grid's second cell. */\n  releaseStatus?: 'alpha' | 'beta' | 'stable' | 'deprecated';\n  /** Pre-computed `StatusBadge` colorScheme for the release-type chip. The\n   *  hub consumer maps `release_type → colorScheme` via its local helper so\n   *  the OSS card stays mapping-agnostic. */\n  releaseTypeBadgeColor?: 'error' | 'cyan' | 'success' | 'warning';\n  /** View count for the optional microline below the metadata grid. Hidden\n   *  when zero or undefined. */\n  viewCount?: number;\n  /** Hydrated author (from the hub DAL's `hydrateAuthor`). When present,\n   *  renders as the last cell of the metadata grid. */\n  author?: {\n    full_name: string;\n    avatar_url: string | null;\n    job_title: string | null;\n  };\n  /** Per-category counts for the changelog stats strip. The whole strip\n   *  is hidden when total === 0. */\n  changelogCounts?: {\n    features: number;\n    fixes: number;\n    improvements: number;\n    breaking: number;\n  };\n}\n\nexport function ProductReleaseCard({\n  title,\n  summary,\n  version,\n  formattedDate,\n  onClick,\n  anchorProps,\n  className,\n  size = 'lg',\n  coverImage,\n  hasVideoCover,\n  releaseType,\n  releaseStatus,\n  releaseTypeBadgeColor,\n  viewCount,\n  author,\n  changelogCounts,\n}: ProductReleaseCardProps) {\n  // ----- LG branch (rich /releases catalog row + related-content rail) -----\n  // The card has THREE zones:\n  //   1. Hero — cover image LEFT, version pill + title + summary RIGHT.\n  //   2. Changelog strip — icons + counts (hidden when total === 0).\n  //   3. Metadata grid footer — bordered grid of [Type | Status | Released\n  //      | Author] cells. This grid INLINES the hub's <EntityAuthorCard>\n  //      visual treatment by hand (SAME bordered grid with value-cell +\n  //      author-cell shapes, byte-for-byte). The OSS lib has zero hub\n  //      coupling by design; we cannot import the hub's\n  //      <EntityAuthorCard>. This is the SAME inline-duplication policy\n  //      documented for the COMPACT_CARD_* string set in the chat-card\n  //      file. If the hub's <EntityAuthorCard> visual changes (cell\n  //      padding, divider styles, avatar size, etc.), update this branch\n  //      in lockstep.\n  if (size === 'lg') {\n    const totalChangelog =\n      (changelogCounts?.features ?? 0) +\n      (changelogCounts?.fixes ?? 0) +\n      (changelogCounts?.improvements ?? 0) +\n      (changelogCounts?.breaking ?? 0);\n\n    // Build the metadata-grid cell array — mirrors the hub's\n    // EntityAuthorCard composition. ALWAYS render all 3 value cells\n    // (Type / Status / Released) — missing values render as a plain\n    // em-dash + label so the grid keeps a fixed 4-cell shape (matching\n    // the skeleton). The Author cell is also always rendered below\n    // (effectiveAuthor falls back to a placeholder shape). This is\n    // load-to-resolve baseline parity: any conditional cell would\n    // introduce a reflow when the skeleton resolves.\n    //\n    // Plan note: em-dash placeholders read as plain text (NOT a colored\n    // StatusBadge for the Type cell) so empty badges don't look broken\n    // next to populated badges.\n    type ValueCell = {\n      value: string;\n      label: string;\n      uppercase: boolean;\n      colorScheme?: 'error' | 'cyan' | 'success' | 'warning';\n    };\n    const valueCells: ValueCell[] = [\n      releaseType && releaseTypeBadgeColor\n        ? {\n            value: releaseType.toUpperCase(),\n            label: 'Type',\n            uppercase: true,\n            colorScheme: releaseTypeBadgeColor,\n          }\n        : { value: '—', label: 'Type', uppercase: false },\n      releaseStatus\n        ? {\n            value: releaseStatus.toUpperCase(),\n            label: 'Status',\n            uppercase: true,\n          }\n        : { value: '—', label: 'Status', uppercase: false },\n      formattedDate\n        ? {\n            value: formattedDate,\n            label: 'Released',\n            uppercase: false,\n          }\n        : { value: '—', label: 'Released', uppercase: false },\n    ];\n    // EMPTY_AUTHOR_PLACEHOLDER shape — mirrors the hub's\n    // EMPTY_AUTHOR_PLACEHOLDER constant exported from\n    // components/shared/entity-author-card.tsx (hub can't be imported\n    // here; the two are kept in lockstep per the inline-duplication\n    // policy documented in the catalog branch comment above).\n    const effectiveAuthor = author?.full_name ? author : { full_name: '—', avatar_url: null, job_title: 'Unknown' };\n    // Fixed 4-cell grid (Type / Status / Released / Author) so the\n    // skeleton's shape matches the loaded card exactly. The earlier\n    // dynamic `gridColsClass` ternary collapsed missing cells and\n    // caused 28-56px reflow on resolve.\n    const gridColsClass = 'md:grid-cols-4';\n    const dividerClass = 'border-b md:border-b-0 md:border-r border-ods-border';\n\n    const frameClass = cn(\n      'group overflow-hidden rounded-lg border border-ods-border bg-ods-card',\n      'flex flex-col gap-4 p-6',\n      'transform transition-all duration-300 ease-out hover:translate-y-[-2px]',\n      'hover:border-ods-accent hover:shadow-lg hover:shadow-ods-accent/[0.08]',\n      'focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-2 focus-visible:ring-offset-ods-bg',\n      'no-underline',\n      className,\n    );\n\n    const innerLayout = (\n      <>\n        {/* HERO ZONE — cover LEFT + version pill + title + summary RIGHT */}\n        <div className=\"flex flex-col gap-4 md:flex-row md:gap-6\">\n          <div className=\"w-full flex-shrink-0 md:w-[256px]\">\n            <div className=\"relative aspect-[1200/630] w-full overflow-hidden rounded-lg bg-ods-bg\">\n              {coverImage ? (\n                <Image\n                  src={coverImage}\n                  alt={title}\n                  fill\n                  sizes=\"(max-width: 768px) 100vw, 256px\"\n                  className=\"object-cover\"\n                  unoptimized\n                />\n              ) : (\n                <div className=\"absolute inset-0 flex items-center justify-center text-ods-text-secondary\">\n                  <Package className=\"h-8 w-8\" />\n                </div>\n              )}\n              {hasVideoCover && coverImage && (\n                <span className=\"absolute inset-0 flex items-center justify-center bg-black/30\">\n                  <Play className=\"h-10 w-10 text-ods-text-on-dark\" fill=\"white\" />\n                </span>\n              )}\n            </div>\n          </div>\n          <div className=\"flex min-w-0 flex-1 flex-col\">\n            <div className=\"mb-3 flex items-center gap-3\">\n              <span className=\"truncate font-semibold text-ods-text-primary text-code\">v{version}</span>\n            </div>\n            {/* Title — reserve a fixed 2-line height so cards with\n                1-line titles don't shrink and the catalog skeleton-to-\n                content transition is shift-free. Mirrors the\n                onboarding-guide catalog card. */}\n            <div className=\"mb-3 flex min-h-[60px] items-start md:min-h-[72px]\">\n              <h3 className=\"line-clamp-2 font-['Azeret_Mono'] text-xl font-semibold leading-tight text-ods-text-primary md:text-2xl\">\n                {title}\n              </h3>\n            </div>\n            {/* Summary — fixed 3-line height. `line-clamp-3` caps long\n                summaries at 3 lines; `min-h` reserves the same vertical\n                space when content is shorter, so the catalog grid stays\n                row-consistent regardless of per-card content length.\n                Heights derived from text-sm md:text-base × leading-relaxed\n                (1.625): 14×1.625×3 ≈ 68 px mobile, 16×1.625×3 ≈ 78 px desktop. */}\n            <div className=\"min-h-[68px] md:min-h-[78px]\">\n              <p className=\"line-clamp-3 font-['DM_Sans'] text-sm leading-relaxed text-ods-text-secondary md:text-base\">\n                {summary ?? ''}\n              </p>\n            </div>\n          </div>\n        </div>\n\n        {/* CHANGELOG STRIP — ALWAYS rendered so the skeleton's\n            always-on changelog placeholder matches the loaded shape\n            (zero reflow on resolve). When `totalChangelog === 0`, an\n            empty-state line takes the same vertical space as the\n            populated row. */}\n        <div className=\"flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t border-ods-border pt-3 text-ods-text-secondary text-h6\">\n          {totalChangelog > 0 && changelogCounts ? (\n            <>\n              {changelogCounts.features > 0 && (\n                <span className=\"inline-flex items-center gap-1.5\">\n                  <Sparkles className=\"h-3.5 w-3.5\" />\n                  {changelogCounts.features} {changelogCounts.features === 1 ? 'feature' : 'features'}\n                </span>\n              )}\n              {changelogCounts.fixes > 0 && (\n                <span className=\"inline-flex items-center gap-1.5\">\n                  <Wrench className=\"h-3.5 w-3.5\" />\n                  {changelogCounts.fixes} {changelogCounts.fixes === 1 ? 'fix' : 'fixes'}\n                </span>\n              )}\n              {changelogCounts.improvements > 0 && (\n                <span className=\"inline-flex items-center gap-1.5\">\n                  <TrendingUp className=\"h-3.5 w-3.5\" />\n                  {changelogCounts.improvements} {changelogCounts.improvements === 1 ? 'improvement' : 'improvements'}\n                </span>\n              )}\n              {changelogCounts.breaking > 0 && (\n                <span className=\"inline-flex items-center gap-1.5 text-ods-warning\">\n                  <AlertTriangle className=\"h-3.5 w-3.5\" />\n                  {changelogCounts.breaking} breaking\n                </span>\n              )}\n            </>\n          ) : (\n            <span className=\"text-ods-text-secondary\">No changelog entries yet</span>\n          )}\n        </div>\n\n        {/* METADATA GRID FOOTER — fixed 4-cell shape (Type / Status /\n            Released / Author) so the skeleton mirrors the loaded card\n            exactly. Empty value cells render em-dash + label (plain\n            text, no colored badge — em-dash badges read as broken next\n            to populated ones); the Author cell falls back to the\n            EMPTY_AUTHOR_PLACEHOLDER shape declared above. */}\n        <div\n          className={cn(\n            'grid grid-cols-1',\n            gridColsClass,\n            'w-full overflow-hidden rounded-md border border-ods-border',\n          )}\n        >\n          {valueCells.map((cell, i) => (\n            <div key={`${cell.label}-${i}`} className={cn('flex flex-col gap-3 bg-ods-card p-4', dividerClass)}>\n              <div className=\"flex flex-col gap-0\">\n                {cell.colorScheme ? (\n                  <StatusBadge\n                    text={cell.value}\n                    variant=\"card\"\n                    colorScheme={cell.colorScheme}\n                    singleLine\n                    className=\"self-start\"\n                  />\n                ) : (\n                  <p\n                    className={cn(\n                      'text-h4',\n                      // Em-dash placeholder reads as secondary text;\n                      // populated values stay primary.\n                      cell.value === '—' ? 'text-ods-text-secondary' : 'text-ods-text-primary',\n                    )}\n                  >\n                    {cell.uppercase ? cell.value.toLocaleUpperCase() : cell.value}\n                  </p>\n                )}\n                <p className=\"text-ods-text-secondary text-h6\">{cell.label}</p>\n              </div>\n            </div>\n          ))}\n          <div className=\"flex items-center gap-3 bg-ods-card p-4\">\n            <SquareAvatar\n              src={effectiveAuthor.avatar_url ?? undefined}\n              alt={effectiveAuthor.full_name}\n              fallback={effectiveAuthor.full_name.charAt(0).toUpperCase()}\n              size=\"md\"\n              variant=\"round\"\n            />\n            <div className=\"flex min-w-0 flex-1 flex-col gap-0\">\n              <p className=\"truncate tracking-[-0.36px] text-ods-text-primary text-h3\">{effectiveAuthor.full_name}</p>\n              <p className=\"text-ods-text-secondary text-h6\">{effectiveAuthor.job_title || 'Author'}</p>\n            </div>\n          </div>\n        </div>\n\n        {typeof viewCount === 'number' && viewCount > 0 && (\n          <div className=\"flex items-center gap-1.5 text-ods-text-secondary text-h6\">\n            <Eye className=\"h-3.5 w-3.5\" />\n            <span>{viewCount.toLocaleString()} views</span>\n          </div>\n        )}\n      </>\n    );\n\n    // Outer-element three-branch decision tree, matching the existing\n    // `default` branch precedence at lines ~200-241. PRECEDENCE:\n    // anchorProps WINS over onClick.\n    if (anchorProps) {\n      return (\n        <a {...anchorProps} className={frameClass} aria-label={`Open ${title}`}>\n          {innerLayout}\n        </a>\n      );\n    }\n    if (onClick) {\n      return (\n        <InteractiveCard clickable onClick={onClick} className={frameClass}>\n          {innerLayout}\n        </InteractiveCard>\n      );\n    }\n    // Non-interactive fallback — strip the hover lift / accent-border so\n    // the cursor doesn't lie about clickability.\n    return (\n      <div className={cn(frameClass.replace('hover:border-ods-accent', '').replace('hover:translate-y-[-2px]', ''))}>\n        {innerLayout}\n      </div>\n    );\n  }\n\n  // ----- COMPACT branch (chat / tight surfaces) ------------------------------\n  // Outer must be a phrasing-content element (`<a>` or `<span>`) — block\n  // elements like `<div>`/`<h3>` are illegal inside markdown `<p>`, so we\n  // cannot reuse `InteractiveCard` (a `<div>`).\n  //\n  // - When `anchorProps` is set, render as a real `<a>` so the consumer's\n  //   click hook (`useNavLink`) owns routing identically to every other\n  //   entity card — cross-origin → new tab, same-origin → soft RSC nav.\n  // - Else fall back to legacy `<span role=\"button\">` behavior driven by\n  //   `onClick` (kept for the public `/releases` page caller).\n  // - When neither is set, render a static non-interactive span.\n  //\n  // Inner layout mirrors BlogCard/CaseStudyCard compact: 56px square slot +\n  // primary text + 1-line meta + optional summary clamp.\n  if (size === 'sm') {\n    const handleKey = (e: React.KeyboardEvent<HTMLSpanElement>) => {\n      if (!onClick) return;\n      if (e.key === 'Enter' || e.key === ' ') {\n        e.preventDefault();\n        onClick();\n      }\n    };\n    const isInteractive = !!anchorProps || !!onClick;\n    const outerClassName = cn(\n      // The base frame (`my-1.5 flex … no-underline`) mirrors the\n      // hub's `COMPACT_CARD_OUTER` in `components/shared/compact-\n      // card/compact-card-classes.ts` byte-for-byte. The OSS lib\n      // can't import from the consumer, so the two strings are\n      // kept identical by hand — if you edit one, edit the other.\n      //\n      // The interactive branch ADDS `cursor-pointer` + a focus-\n      // visible ring. The `<a>` form would normally get focus styling\n      // from the browser, but `no-underline` strips the default\n      // affordance, so explicit ring styles match the `<span role>` form.\n      'my-1.5 flex w-full items-start gap-3 p-2',\n      'rounded-lg border border-ods-border bg-ods-card no-underline',\n      isInteractive\n        ? 'cursor-pointer transition-colors hover:border-ods-text-secondary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-1 focus-visible:ring-offset-ods-card'\n        : 'cursor-default',\n      className,\n    );\n    const innerChildren = (\n      <>\n        {/* 56×56 cover slot. Mirrors BlogCard / ProgramCard / OnboardingGuide\n            sm slots — when `coverImage` is set, render the actual image\n            (object-contain to keep landscape thumbs visible); fall back to\n            the `Package` icon when no cover is provided. Play overlay\n            fires only when `hasVideoCover` is true AND a cover image was\n            actually supplied (matches the catalog variant's overlay rule). */}\n        <span className=\"relative flex aspect-square h-14 w-14 shrink-0 items-center justify-center self-start overflow-hidden rounded-md bg-ods-bg text-ods-accent\">\n          {coverImage ? (\n            <Image src={coverImage} alt={title} fill sizes=\"56px\" className=\"object-contain\" unoptimized />\n          ) : (\n            <Package className=\"h-5 w-5\" />\n          )}\n          {hasVideoCover && coverImage && (\n            <span className=\"absolute inset-0 flex items-center justify-center bg-black/30\">\n              <Play className=\"h-4 w-4 text-ods-text-on-dark\" fill=\"white\" />\n            </span>\n          )}\n        </span>\n        {/* Text column structure must mirror the hub's\n            `COMPACT_CARD_TEXT_COL` + `COMPACT_CARD_TITLE_ROW` +\n            `COMPACT_CARD_META_ROW_BOX` byte-for-byte. The OSS lib can't\n            import from the consumer, so the strings are duplicated by\n            hand — if you edit them in the hub's compact-card-classes.ts,\n            edit them here too. Per-row heights are fixed (h-5 / h-4 / h-4)\n            so a skeleton placeholder occupies the SAME pixel position\n            as the loaded text — zero load-to-resolve baseline shift. */}\n        <span className=\"flex min-h-14 min-w-0 flex-1 flex-col gap-0.5\">\n          <span className=\"flex h-5 min-w-0 items-center gap-2\">\n            <span className=\"min-w-0 truncate font-semibold text-ods-text-primary text-h6\" title={title}>\n              {title}\n            </span>\n            {version ? (\n              <span className=\"shrink-0 rounded bg-ods-accent/10 px-1.5 py-0.5 text-ods-accent text-code\">\n                {version}\n              </span>\n            ) : null}\n          </span>\n          <span className=\"flex h-4 min-w-0 items-center\">\n            <span className=\"truncate text-ods-text-secondary text-h6\">{formattedDate || 'Product release'}</span>\n          </span>\n          <span className=\"flex h-4 min-w-0 items-center\">\n            <span className=\"truncate text-ods-text-secondary/80 text-h6\" title={summary || undefined}>\n              {/* The literal between the curly-quote string is U+00A0\n                  (NBSP). The hub's `COMPACT_CARD_ROW_FILLER` is also\n                  NBSP; ASCII space here would let React collapse the\n                  child to zero content, breaking baseline parity with\n                  the skeleton. Keep these in lockstep. */}\n              {summary || ' '}\n            </span>\n          </span>\n        </span>\n      </>\n    );\n    // Anchor variant — consumer's `useNavLink` (or equivalent) owns the\n    // click decision; the card just spreads the prop bundle and renders\n    // a real `<a>` so cmd/ctrl-click new-tab + middle-click work without\n    // any extra JS. This is the SAME pattern BlogCard / CaseStudyCard /\n    // ProgramCard / etc. use across the consumer codebase.\n    if (anchorProps) {\n      return (\n        <a {...anchorProps} className={outerClassName}>\n          {innerChildren}\n        </a>\n      );\n    }\n    // Legacy fallback — `onClick` (no href). Keeps the public `/releases`\n    // page's existing `router.push(...)` flow working unchanged. When\n    // neither `anchorProps` nor `onClick` is set, renders a static\n    // non-interactive span.\n    return (\n      <span\n        role={onClick ? 'button' : undefined}\n        tabIndex={onClick ? 0 : undefined}\n        onClick={onClick}\n        onKeyDown={onClick ? handleKey : undefined}\n        className={outerClassName}\n      >\n        {innerChildren}\n      </span>\n    );\n  }\n\n  // Unreachable — `size` is typed `'lg' | 'sm'` and both branches return\n  // above. Kept as a defensive throw so a future variant addition that\n  // forgets to return doesn't silently render `undefined`.\n  throw new Error(`ProductReleaseCard: unsupported size '${size as string}'`);\n}\n","'use client';\n\nimport { cn } from '../../../utils/cn';\n\nexport interface ProductReleaseCardSkeletonProps {\n  /** Additional CSS classes */\n  className?: string;\n  /** Card density. Must match the loaded card's `size` prop so the loading\n   *  height matches the resolved height (no layout shift on resolve). */\n  size?: 'lg' | 'sm';\n}\n\nexport function ProductReleaseCardSkeleton({ className, size = 'lg' }: ProductReleaseCardSkeletonProps) {\n  // ----- LG branch — must match ProductReleaseCard size='lg'.\n  // Same outer frame (`bg-ods-card border border-ods-border …\n  // p-6 gap-4`). Inner: hero (16:9 cover + version pill + title + summary),\n  // changelog strip placeholder, metadata-grid footer (4 cells via grid).\n  // Heights chosen to match the loaded card's rendered metrics so the\n  // 5-card slot grid in `ReleasesList` doesn't jump on resolve. The\n  // loaded card ALSO always renders the changelog strip + a fixed 4-cell\n  // grid (with em-dash placeholders for missing values), so this\n  // skeleton's shape matches exactly with zero load-to-resolve reflow.\n  if (size === 'lg') {\n    return (\n      <div\n        className={cn(\n          'overflow-hidden rounded-lg border border-ods-border bg-ods-card',\n          'flex flex-col gap-4 p-6',\n          'animate-pulse',\n          className,\n        )}\n      >\n        {/* HERO — placeholders use `bg-ods-border` (#3a3a3a) so they\n            contrast against the card's `bg-ods-card`\n            (#212121) container. The metadata grid cells below use\n            `bg-ods-card` containers so `bg-ods-bg` placeholders work\n            there, but in the hero the card IS `bg-ods-card`-equivalent —\n            `bg-ods-bg` (#161616) is only 6 hex points darker than the\n            card and renders nearly invisible.\n\n            CRITICAL: title + summary use the SAME min-h containers as\n            the loaded card so total card height is byte-identical\n            between skeleton state and loaded state. Without this,\n            individual placeholder heights underrun the loaded card's\n            min-h reservations and the page jumps on resolve. */}\n        <div className=\"flex flex-col gap-4 md:flex-row md:gap-6\">\n          <div className=\"aspect-[1200/630] w-full flex-shrink-0 rounded-lg bg-ods-border md:w-[256px]\" />\n          <div className=\"flex min-w-0 flex-1 flex-col\">\n            {/* Version pill — mirrors `flex items-center gap-3 mb-3` in\n                the loaded card. The loaded `<span text-lg>` renders at\n                line-height 28 px (Tailwind text-lg = 18 px / 28 px LH);\n                placeholder uses `h-7` (28 px) to match exactly. */}\n            <div className=\"mb-3 flex items-center gap-3\">\n              <div className=\"h-7 w-20 rounded bg-ods-border\" />\n            </div>\n            {/* Title container — SAME min-h as the loaded card so the\n                card height contributed by this region matches exactly. */}\n            <div className=\"mb-3 flex min-h-[60px] flex-col justify-start gap-1.5 md:min-h-[72px]\">\n              <div className=\"h-[25px] w-3/4 rounded bg-ods-border md:h-[30px]\" />\n              <div className=\"h-[25px] w-1/2 rounded bg-ods-border md:h-[30px]\" />\n            </div>\n            {/* Summary container — SAME min-h as the loaded card. The\n                3 placeholder lines mirror the rendered 3-line clamp;\n                `bg-ods-border/70` keeps summary placeholders slightly\n                dimmer than title placeholders (primary vs secondary\n                text hierarchy). */}\n            <div className=\"flex min-h-[68px] flex-col justify-start gap-2 md:min-h-[78px]\">\n              <div className=\"h-3 w-full rounded bg-ods-border/70\" />\n              <div className=\"h-3 w-11/12 rounded bg-ods-border/70\" />\n              <div className=\"h-3 w-5/6 rounded bg-ods-border/70\" />\n            </div>\n          </div>\n        </div>\n\n        {/* CHANGELOG strip placeholder — always rendered. Inner\n            placeholder `h-5` mirrors the loaded strip's `text-sm`\n            line-height (20 px) so total height is consistent with the\n            loaded `border-t pt-3 + text content` (~32-33 px). */}\n        <div className=\"border-t border-ods-border pt-3\">\n          <div className=\"h-5 w-2/3 rounded bg-ods-border/70\" />\n        </div>\n\n        {/* METADATA GRID — 4-cell placeholder. The grid cells use\n            `bg-ods-card` containers and `bg-ods-bg` placeholders, which\n            DO contrast correctly because the cells are brighter than\n            the placeholders. Inner content heights mirror the loaded\n            cells (`text-h4` ≈ 28 px + `DM_Sans 14px leading-20`) so\n            total grid height matches the loaded ~86 px. */}\n        <div className=\"grid w-full grid-cols-1 overflow-hidden rounded-md border border-ods-border md:grid-cols-4\">\n          {[0, 1, 2].map(i => (\n            <div\n              key={`cell-${i}`}\n              className=\"flex flex-col gap-3 border-b border-ods-border bg-ods-card p-4 md:border-b-0 md:border-r\"\n            >\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"h-7 w-24 rounded bg-ods-bg\" />\n                <div className=\"h-4 w-16 rounded bg-ods-bg/60\" />\n              </div>\n            </div>\n          ))}\n          {/* Author cell */}\n          <div className=\"flex items-center gap-3 bg-ods-card p-4\">\n            <div className=\"h-10 w-10 shrink-0 rounded-full bg-ods-bg\" />\n            <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n              <div className=\"h-5 w-3/4 rounded bg-ods-bg\" />\n              <div className=\"h-4 w-1/2 rounded bg-ods-bg/60\" />\n            </div>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  // ----- COMPACT branch — must match ProductReleaseCard size='sm' exactly.\n  // Same outer: span + items-start + gap-3 + p-2 + my-1.5 (no border to keep\n  // the skeleton 1px lighter than the resolved card is fine — but we mirror\n  // the border too so width is byte-identical). Inner row 1 = title +\n  // version-pill; row 2 = date; row 3 = summary line. Heights/widths chosen\n  // to match the rendered card line-heights (text-sm = 14px line-height-20,\n  // text-[11px] = 11/16). The total height comes out the same as the loaded\n  // card so the chat message height does NOT jump on resolve.\n  if (size === 'sm') {\n    return (\n      <span\n        className={cn(\n          'my-1.5 flex w-full animate-pulse items-start gap-3',\n          'rounded-lg border border-ods-border bg-ods-card p-2',\n          className,\n        )}\n      >\n        <span className=\"block aspect-square h-14 w-14 shrink-0 self-start rounded-md bg-ods-bg\" />\n        {/* Text column: 3 rows with FIXED heights matching the loaded\n            card (h-5 title, h-4 + h-4 meta + summary). Skeleton bars\n            sit centered inside each row container so a placeholder\n            occupies the SAME pixel position as the loaded text will\n            on resolve. */}\n        <span className=\"flex min-h-14 min-w-0 flex-1 flex-col gap-0.5\">\n          <span className=\"flex h-5 min-w-0 items-center gap-2\">\n            <span className=\"h-3.5 w-3/5 rounded bg-ods-bg\" />\n            <span className=\"h-4 w-12 shrink-0 rounded bg-ods-bg/70\" />\n          </span>\n          <span className=\"flex h-4 min-w-0 items-center\">\n            <span className=\"h-3 w-1/3 rounded bg-ods-bg/70\" />\n          </span>\n          <span className=\"flex h-4 min-w-0 items-center\">\n            <span className=\"h-3 w-11/12 rounded bg-ods-bg/40\" />\n          </span>\n        </span>\n      </span>\n    );\n  }\n\n  // Unreachable — `size` is typed `'lg' | 'sm'` and both branches return\n  // above. Kept as a defensive throw so a future variant addition that\n  // forgets to return doesn't silently render `undefined`.\n  throw new Error(`ProductReleaseCardSkeleton: unsupported size '${size as string}'`);\n}\n","/**\n * Shared Date Formatting Utilities\n * Single source of truth for date formatting across the application\n */\n\nconst MONTHS_LONG = [\n  'January',\n  'February',\n  'March',\n  'April',\n  'May',\n  'June',\n  'July',\n  'August',\n  'September',\n  'October',\n  'November',\n  'December',\n] as const;\n\nconst MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const;\n\n/**\n * Split an ISO date / date-time string into `[year, month, day]` strings.\n * Internal — date-only inputs avoid the `new Date(...)` timezone shift\n * (which renders `\"2025-11-11\"` as Nov 10 west of UTC).\n */\nfunction splitYmd(dateString: string): [string, string, string] | null {\n  const head = dateString.split('T')[0];\n  const parts = head.split('-');\n  if (parts.length !== 3) return null;\n  return [parts[0], parts[1], parts[2]];\n}\n\n/**\n * Format release date — avoids timezone shifts.\n * @returns e.g. `\"November 11, 2025\"`\n */\nexport function formatReleaseDate(dateString: string): string {\n  const ymd = splitYmd(dateString);\n  if (!ymd) return dateString;\n  const [year, month, day] = ymd;\n  return `${MONTHS_LONG[parseInt(month) - 1]} ${parseInt(day)}, ${year}`;\n}\n\n/**\n * Short-form date — `\"Jan 5, 2025\"`. Same TZ-safe parsing as\n * `formatReleaseDate`; differs only in month abbreviation. Single source\n * of truth for the short-month-day-year shape used across hub admin\n * cards (waitlist, publication, media, investor, campaign, etc.) and\n * lib chat cards (campaign-card-admin).\n */\nexport function formatDateShort(dateString: string): string {\n  const ymd = splitYmd(dateString);\n  if (!ymd) return dateString;\n  const [year, month, day] = ymd;\n  return `${MONTHS_SHORT[parseInt(month) - 1]} ${parseInt(day)}, ${year}`;\n}\n\n/**\n * Slash-form date — `\"11/11/2025\"`. TZ-safe (string-split, no\n * `new Date(...)`). Used by hub admin product-release + customer-\n * interview cards where the compact MM/DD/YYYY layout is desired.\n */\nexport function formatDateSlashUTC(dateString: string): string {\n  const ymd = splitYmd(dateString);\n  if (!ymd) return dateString;\n  const [year, month, day] = ymd;\n  return `${month}/${day}/${year}`;\n}\n","/**\n * Single source of truth for mapping a product release's `release_type` to\n * the `StatusBadge` colorScheme used in the `ProductReleaseCard` lg variant's\n * metadata-grid Type cell. Every surface that renders a\n * `<ProductReleaseCard size=\"lg\" ...>` must pass this value as\n * `releaseTypeBadgeColor` or the card falls back to a `'—'` placeholder\n * (the card requires BOTH `releaseType && releaseTypeBadgeColor` to paint a\n * colored badge).\n *\n * Mapping mirrors the visual hierarchy operators expect at a glance:\n *   major  → error   (red, breaking change)\n *   minor  → cyan    (accent, feature)\n *   patch  → success (green, safe bump)\n *   beta/alpha → warning (yellow, preview)\n *\n * (Lifted from the hub so every embedder — not just the hub — gets the rich\n * card metadata. The admin form's `releaseTypeOptions.color` array uses a\n * different color vocabulary — different concern; don't unify here.)\n */\nexport type ReleaseType = 'major' | 'minor' | 'patch' | 'beta' | 'alpha';\nexport type ReleaseTypeBadgeColor = 'error' | 'cyan' | 'success' | 'warning';\n\n/**\n * Accepts a wider input type than `ReleaseType` because list-API rows can\n * carry legacy / unenforced `release_type` values. Returns `undefined` for\n * unknown values — the card guards on `releaseType && releaseTypeBadgeColor`\n * to decide whether to render a colored badge or fall back to an em-dash, so\n * an undefined return is the correct failure mode (badge hidden), not a crash.\n */\nexport function releaseTypeToBadgeColor(t: ReleaseType | string | null | undefined): ReleaseTypeBadgeColor | undefined {\n  switch (t) {\n    case 'major':\n      return 'error';\n    case 'minor':\n      return 'cyan';\n    case 'patch':\n      return 'success';\n    case 'beta':\n    case 'alpha':\n      return 'warning';\n    default:\n      return undefined;\n  }\n}\n","/**\n * Single source of truth for the ProductReleaseCard cover-image fallback\n * priority. Every surface that renders a `<ProductReleaseCard>` (catalog row,\n * chat-inline dispatcher, chat wrapper) computes the cover via this helper —\n * adding a new fallback source or reordering the priority is ONE edit.\n *\n * **Priority** (intentional):\n *   1. `featured_image` — editor-curated cover, always wins.\n *   2. `highlight_video_thumbnail` — AI-generated highlight reel thumb.\n *   3. `main_video_thumbnail` — full-release video thumb.\n *   4. `og_image_url` — SEO fallback.\n *   5. `null` — caller renders the entity placeholder.\n *\n * **`hasVideoCover`** fires ONLY when the active cover is a video thumb\n * (priority 2 or 3). When the editor configured a still `featured_image`, a\n * Play overlay would be misleading and is suppressed.\n *\n * Operator note: `||` (not `??`) so empty-string DB values fall through to the\n * next fallback rather than rendering `<Image src=\"\">`.\n *\n * (Lifted from the hub so the rich `buildProductReleaseCardProps` works in the\n * lib / any embedder, not just the hub.)\n */\n\nexport interface ReleaseCoverSource {\n  featured_image?: string | null;\n  highlight_video_thumbnail?: string | null;\n  main_video_thumbnail?: string | null;\n  og_image_url?: string | null;\n}\n\nexport interface ReleaseCoverResult {\n  cover: string | null;\n  hasVideoCover: boolean;\n}\n\nexport function resolveReleaseCover(r: ReleaseCoverSource): ReleaseCoverResult {\n  const cover = r.featured_image || r.highlight_video_thumbnail || r.main_video_thumbnail || r.og_image_url || null;\n  const hasVideoCover = !r.featured_image && !!(r.highlight_video_thumbnail || r.main_video_thumbnail);\n  return { cover, hasVideoCover };\n}\n","/**\n * Lib-side default `buildProductReleaseCardProps` for the chat dispatch.\n *\n * The compact (`size='sm'`) branch of `<ProductReleaseCard>` only reads\n * `coverImage`, `hasVideoCover`, and `formattedDate` — the rest of the\n * derived prop bundle is catalog-page (`size='lg'`) metadata. This\n * default builder satisfies the three sm-relevant fields with row data\n * the list-API already returns, so embedders that don't supply\n * `opts.extras.buildProductReleaseCardProps` still get a rendered card\n * instead of a bare text chip. Hub-side embedders pass their richer\n * builder via `extras` to get the lg-only fields (changelogCounts,\n * badge color, view count, etc.).\n */\n\nimport { formatReleaseDate } from '../../../utils/date-formatters';\nimport { releaseTypeToBadgeColor, type ReleaseType, type ReleaseTypeBadgeColor } from '../../../utils/release-badge';\nimport { resolveReleaseCover } from '../../../utils/release-cover';\n\ninterface ReleaseLike {\n  release_date?: string | null;\n  featured_image?: string | null;\n  og_image_url?: string | null;\n  main_video_thumbnail?: string | null;\n  highlight_video_thumbnail?: string | null;\n  main_video_url?: string | null;\n  youtube_url?: string | null;\n}\n\ninterface DerivedSmProps {\n  coverImage: string | null;\n  hasVideoCover: boolean;\n  formattedDate: string;\n}\n\n/** Pick the first usable image URL from the row's cover-candidate fields.\n *  This is an INTENTIONALLY SIMPLER sm-subset heuristic — it does NOT mirror the\n *  hub's `resolveReleaseCover`: when a video URL is set this prefers\n *  `main_video_thumbnail` (then `highlight_video_thumbnail`) over `featured_image`,\n *  and keys `hasVideoCover` off `main_video_url`/`youtube_url` (fields the hub\n *  helper never reads). It's the builder-less default (chat sm cards + any embedder\n *  that doesn't pass its own `buildCardProps` to `ProductReleasesView`); hosts\n *  wanting hub-identical covers pass their richer builder (the hub does — see\n *  the hub's `product-release-card-props.ts` / `resolveReleaseCover`). */\nfunction pickCover(item: ReleaseLike): string | null {\n  const hasVideo = Boolean(item.main_video_url || item.youtube_url);\n  if (hasVideo) {\n    if (item.main_video_thumbnail) return item.main_video_thumbnail;\n    if (item.highlight_video_thumbnail) return item.highlight_video_thumbnail;\n  }\n  return item.featured_image ?? item.og_image_url ?? null;\n}\n\nexport function defaultBuildProductReleaseCardProps(item: unknown): DerivedSmProps {\n  const row = (item ?? {}) as ReleaseLike;\n  return {\n    coverImage: pickCover(row),\n    hasVideoCover: Boolean(row.main_video_url || row.youtube_url),\n    // `formatReleaseDate` is TZ-safe (splits the `YYYY-MM-DD` head before\n    // constructing the Date). Using `new Date(input).toLocaleDateString`\n    // here would shift the date by one day west of UTC for date-only\n    // inputs — the hub's own `buildProductReleaseCardProps` already uses\n    // this helper, so the chat-default + hub builder agree.\n    formattedDate: row.release_date ? formatReleaseDate(row.release_date) : '',\n  };\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// RICH (lg-variant) builder — the DEFAULT for <ProductReleasesView>.\n// ───────────────────────────────────────────────────────────────────────────\n\n/** Minimal shape the RICH builder reads. List-API rows from `/api/releases`\n *  carry all of these; `release_type` is widened to `string` to absorb legacy\n *  non-enum values (the badge helper returns `undefined` for unknowns, which\n *  the card guards correctly with an em-dash placeholder). */\ninterface RichReleaseLike {\n  release_date?: string | null;\n  release_type?: string | null;\n  release_status?: 'alpha' | 'beta' | 'stable' | 'deprecated' | null;\n  view_count?: number | null;\n  author?: { full_name: string; avatar_url: string | null; job_title: string | null } | null;\n  features_added?: unknown[] | null;\n  bugs_fixed?: unknown[] | null;\n  improvements?: unknown[] | null;\n  breaking_changes?: unknown[] | null;\n  featured_image?: string | null;\n  highlight_video_thumbnail?: string | null;\n  main_video_thumbnail?: string | null;\n  og_image_url?: string | null;\n}\n\nexport interface ProductReleaseCardDerivedProps {\n  coverImage: string | null;\n  hasVideoCover: boolean;\n  formattedDate: string;\n  releaseType: ReleaseType | undefined;\n  releaseStatus: 'alpha' | 'beta' | 'stable' | 'deprecated' | undefined;\n  releaseTypeBadgeColor: ReleaseTypeBadgeColor | undefined;\n  viewCount: number;\n  author: { full_name: string; avatar_url: string | null; job_title: string | null } | undefined;\n  changelogCounts: { features: number; fixes: number; improvements: number; breaking: number };\n}\n\nconst RELEASE_TYPE_ENUM: ReadonlySet<ReleaseType> = new Set(['major', 'minor', 'patch', 'beta', 'alpha']);\n\n/**\n * The RICH `lg`-variant builder — populates the full metadata grid (Type /\n * Status / author / view count / changelog counts) the `lg` `ProductReleaseCard`\n * renders. This is the DEFAULT `buildCardProps` for `<ProductReleasesView>`, so\n * embedders get the full card config-only. (Lifted from the hub; before this,\n * the example fell back to the sm builder and the lg Type/Status/author cells\n * rendered as em-dash placeholders.) The sm chat card keeps\n * `defaultBuildProductReleaseCardProps` — it ignores the lg-only fields.\n */\nexport function buildProductReleaseCardProps(item: unknown): ProductReleaseCardDerivedProps {\n  const release = (item ?? {}) as RichReleaseLike;\n  const { cover, hasVideoCover } = resolveReleaseCover(release);\n  const rawType = release.release_type;\n  const releaseType: ReleaseType | undefined =\n    rawType && RELEASE_TYPE_ENUM.has(rawType as ReleaseType) ? (rawType as ReleaseType) : undefined;\n  return {\n    coverImage: cover,\n    hasVideoCover,\n    formattedDate: release.release_date ? formatReleaseDate(release.release_date) : '',\n    releaseType,\n    releaseStatus: release.release_status ?? undefined,\n    releaseTypeBadgeColor: releaseTypeToBadgeColor(releaseType),\n    viewCount: release.view_count ?? 0,\n    author: release.author\n      ? {\n          full_name: release.author.full_name,\n          avatar_url: release.author.avatar_url,\n          job_title: release.author.job_title,\n        }\n      : undefined,\n    changelogCounts: {\n      features: release.features_added?.length ?? 0,\n      fixes: release.bugs_fixed?.length ?? 0,\n      improvements: release.improvements?.length ?? 0,\n      breaking: release.breaking_changes?.length ?? 0,\n    },\n  };\n}\n","/**\n * Compact-card class constants — single source of truth for the chat-inline\n * `size='sm'` card frame.\n *\n * Lifted from the hub's `components/shared/compact-card/compact-card-classes.ts`\n * so the pure-presentation cards in `entity-cards/` can stop importing from\n * the hub tree. Every compact card across the chat shell shares the SAME\n * outer frame, skeleton frame, image slot, icon slot, text column, and\n * per-line typography.\n *\n * Card-specific variation (Play overlay on podcasts, #N pill on investor\n * updates, status badge on roadmap) lives INSIDE the card next to its\n * other type-specific JSX; the frame stays uniform across types.\n */\n\n/** Loaded compact card outer frame for INTERACTIVE state — anchor wrap\n *  with hover/transition affordance. Use ONLY when the card resolves\n *  to a real, safe href; the user expects a clickable feel. */\nexport const COMPACT_CARD_OUTER =\n  'my-1.5 flex items-start gap-3 w-full p-2 rounded-lg border border-ods-border bg-ods-card no-underline transition-colors hover:border-ods-text-secondary/40';\n\n/** Loaded compact card outer frame for NON-INTERACTIVE state — span wrap\n *  used when `safeHref()` rejects the chat ref's url. */\nexport const COMPACT_CARD_OUTER_STATIC =\n  'my-1.5 flex items-start gap-3 w-full p-2 rounded-lg border border-ods-border bg-ods-card no-underline cursor-default';\n\n/** Skeleton outer frame — same dimensions as loaded, plus `animate-pulse`. */\nexport const COMPACT_CARD_SKELETON_OUTER =\n  'my-1.5 flex w-full animate-pulse items-start gap-3 rounded-lg border border-ods-border bg-ods-card p-2';\n\n/** 56×56 image slot — for cards with a cover image. */\nexport const COMPACT_CARD_IMAGE_SLOT =\n  'relative block shrink-0 self-start w-14 h-14 aspect-square overflow-hidden rounded-md bg-ods-bg';\n\n/** 56×56 placeholder slot for skeletons. */\nexport const COMPACT_CARD_SKELETON_IMAGE_SLOT =\n  'block h-14 w-14 aspect-square shrink-0 self-start rounded-md bg-ods-bg';\n\n/** 56×56 icon slot — for cards without a cover image. */\nexport const COMPACT_CARD_ICON_SLOT =\n  'flex h-14 w-14 aspect-square shrink-0 self-start items-center justify-center rounded-md bg-ods-bg text-ods-accent';\n\n/** Text column wrapper — explicit 56px height with `flex flex-col`. */\nexport const COMPACT_CARD_TEXT_COL = 'flex min-w-0 flex-1 flex-col gap-0.5 min-h-14';\n\n/** Title row container — fixed 20px (h-5) to match `text-sm leading-5`. */\nexport const COMPACT_CARD_TITLE_ROW = 'flex items-center min-w-0 h-5';\n\n/** Subtitle / summary row container — fixed 16px (h-4). */\nexport const COMPACT_CARD_META_ROW_BOX = 'flex items-center min-w-0 h-4';\n\n/** Title text — bold 14px on a 20px line. */\nexport const COMPACT_CARD_TITLE = 'truncate text-sm font-semibold leading-5 text-ods-text-primary';\n\n/** Subtitle text — 11px on a 16px line. */\nexport const COMPACT_CARD_SUBTITLE = 'truncate text-h6 text-ods-text-secondary';\n\n/** Summary text — same metrics as subtitle, dropped to 80% opacity. */\nexport const COMPACT_CARD_SUMMARY = 'truncate text-h6 text-ods-text-secondary/80';\n\n/** Meta-row variant — multi-part 11px metadata. */\nexport const COMPACT_CARD_META_ROW = 'flex items-center gap-1.5 min-w-0 text-h6 text-ods-text-secondary';\n\n/** Non-breaking space — used as a fallback child so the row's height stays 16px. */\nexport const COMPACT_CARD_ROW_FILLER = ' ';\n\n/** Schemes allowed in the outer `<a href>` of a compact card. */\nconst SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:'];\n\n/** Return the input url ONLY if it's safe to drop into an `<a href>`:\n *  the scheme must be http / https / mailto, or the URL must be a\n *  same-origin path (`/blog/foo`). Otherwise returns `null`. */\nexport function safeHref(url: string | null | undefined): string | null {\n  if (!url) return null;\n  // Defense in depth: control chars + zero-width / line-separator chars.\n  if (/[\\u0000-\\u001f\\u007f\\u200b-\\u200d\\u2028\\u2029\\ufeff]/.test(url)) return null;\n  const trimmed = url.trim();\n  if (!trimmed) return null;\n  // Pure same-origin path.\n  if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed;\n  if (trimmed.startsWith('#')) return trimmed;\n  // Reject bare scheme-only inputs.\n  if (/^[a-z][a-z0-9+.-]*:$/i.test(trimmed)) return null;\n  try {\n    const parsed = new URL(trimmed, 'https://_safehref_base.invalid');\n    if (!SAFE_URL_SCHEMES.includes(parsed.protocol)) return null;\n    if ((parsed.protocol === 'https:' || parsed.protocol === 'http:') && !parsed.hostname) return null;\n    return trimmed;\n  } catch {\n    return null;\n  }\n}\n","'use client';\n\n/**\n * BlogCard (pure presentation).\n *\n * Two densities:\n *   - `default`: full vertical card with cover, title, summary, author/date\n *     footer.\n *   - `sm`: compact horizontal card (~80px tall) for chat-inline rendering.\n *\n * The card writes NO click logic — callers wrap with their own anchor\n * (e.g. hub's `<NavLinkAnchor>` or lib's `<NavLinkAnchorViaRuntime>`)\n * and pass the resolved detail URL via `href`.\n *\n * Image-fallback chain:\n *   `post.featured_image` → `placeholderUrl` (caller passes\n *   `useOgPlaceholderUrl(...)` from the hub OR any pre-resolved URL) →\n *   `bg-ods-bg` (via the slot's background).\n */\n\nimport { Eye } from 'lucide-react';\nimport Image from '../../../embed-shims/next-image';\nimport type { BlogPostSummary } from '../../../types/blog';\nimport { cn } from '../../../utils/cn';\nimport { StatusBadge } from '../../ui/status-badge';\nimport {\n  COMPACT_CARD_IMAGE_SLOT,\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_ROW_FILLER,\n  COMPACT_CARD_SKELETON_IMAGE_SLOT,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_SUBTITLE,\n  COMPACT_CARD_SUMMARY,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  COMPACT_CARD_TITLE_ROW,\n} from '../utils/compact-card-classes';\nimport { EntityPortraitCard } from './entity-portrait-card';\nimport { useCoverImageFallback } from './use-cover-image-fallback';\nimport { useEntityCardLink } from './use-entity-card-link';\nimport { useEntityCardPlaceholder } from './use-entity-card-placeholder';\n\nexport interface BlogCardProps {\n  post: BlogPostSummary;\n  /** Detail URL resolved by the caller (e.g. `buildContentURL`). */\n  href: string;\n  /** When `_blank`, opens in a new tab. Set by chat dispatch via\n   *  `computeIsNewTab`. Defaults to same-tab. */\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  /** Platform that owns `href`. Used by parent wrappers; the card\n   *  itself doesn't read it but exposes the prop for the standard\n   *  pure-presentation contract. */\n  targetPlatform?: string | null;\n  /** Placeholder URL when `post.featured_image` is missing. Caller\n   *  resolves via `useOgPlaceholderUrl` (hub) or a static asset. */\n  placeholderUrl?: string | null;\n  size?: 'default' | 'sm' | 'portrait';\n  /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */\n  showTypeBadge?: boolean;\n  className?: string;\n  /** Surfaces a \"Video\" badge in compact mode. */\n  hasEmbeddedVideo?: boolean;\n  /** Optional render-prop for the title-area anchor in `default` mode.\n   *  When omitted the title renders as plain text (caller wraps the\n   *  whole card if it wants a link). */\n  priority?: boolean;\n}\n\n/** `portrait` shares the default skeleton shape (same zone boxes). */\nexport function BlogCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' | 'portrait' }) {\n  if (size === 'sm') {\n    return (\n      <span className={COMPACT_CARD_SKELETON_OUTER}>\n        <span className={COMPACT_CARD_SKELETON_IMAGE_SLOT} />\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={COMPACT_CARD_TITLE_ROW}>\n            <span className=\"h-3.5 w-3/5 rounded bg-ods-bg\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-2/5 rounded bg-ods-bg/70\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-11/12 rounded bg-ods-bg/40\" />\n          </span>\n        </span>\n      </span>\n    );\n  }\n  return (\n    <article className=\"group flex h-full animate-pulse flex-col overflow-hidden rounded-lg border border-ods-border bg-ods-card\">\n      <div className=\"aspect-[1200/630] bg-ods-bg\" />\n      <div className=\"flex flex-grow flex-col space-y-3 p-4\">\n        <div className=\"h-5 w-3/4 rounded bg-ods-bg\" />\n        <div className=\"h-5 w-1/2 rounded bg-ods-bg\" />\n        <div className=\"h-3 w-full rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-4/5 rounded bg-ods-bg/60\" />\n        <div className=\"mt-auto flex items-center gap-2\">\n          <div className=\"h-8 w-8 rounded-full bg-ods-bg\" />\n          <div className=\"h-3 w-24 rounded bg-ods-bg/60\" />\n        </div>\n      </div>\n    </article>\n  );\n}\n\nexport function BlogCard({\n  post,\n  href,\n  target: targetProp,\n  rel: relProp,\n  targetPlatform,\n  placeholderUrl: placeholderUrlProp,\n  size = 'default',\n  showTypeBadge = true,\n  className,\n  hasEmbeddedVideo = false,\n  priority = false,\n}: BlogCardProps) {\n  const { target, rel } = useEntityCardLink({\n    href,\n    targetPlatform,\n    target: targetProp,\n    rel: relProp,\n  });\n  const placeholderUrl = useEntityCardPlaceholder({\n    title: post.title,\n    placeholderUrl: placeholderUrlProp,\n    aspect: size === 'sm' ? 'square' : 'wide',\n  });\n  // Shared cover → placeholder → hide chain (ONE fallback logic for all cards).\n  const { src: displayImage, onError: onImageError } = useCoverImageFallback(post.featured_image, placeholderUrl);\n\n  if (size === 'sm') {\n    const dateStr = post.published_at\n      ? new Date(post.published_at).toLocaleDateString('en-US', {\n          year: 'numeric',\n          month: 'short',\n          day: 'numeric',\n          timeZone: 'UTC',\n        })\n      : '';\n    const firstCategory = post.categories?.find(c => c && c.name)?.name;\n    return (\n      <a\n        href={href}\n        target={target}\n        rel={rel}\n        className={cn(COMPACT_CARD_OUTER, className)}\n        aria-label={`Read article: ${post.title}`}\n      >\n        <span className={COMPACT_CARD_IMAGE_SLOT}>\n          {displayImage ? (\n            <Image\n              src={displayImage}\n              alt={post.title}\n              fill\n              sizes=\"56px\"\n              className=\"object-contain\"\n              unoptimized\n              onError={onImageError}\n            />\n          ) : null}\n        </span>\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={cn(COMPACT_CARD_TITLE_ROW, 'gap-1.5')}>\n            <span className={cn(COMPACT_CARD_TITLE, 'font-body')}>{post.title}</span>\n            {hasEmbeddedVideo ? (\n              <StatusBadge text=\"Video\" variant=\"button\" colorScheme=\"cyan\" className=\"shrink-0\" />\n            ) : null}\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_SUBTITLE}>\n              {dateStr}\n              {dateStr && firstCategory ? ' · ' : ''}\n              {firstCategory ?? 'Blog Post'}\n            </span>\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_SUMMARY}>{post.summary || COMPACT_CARD_ROW_FILLER}</span>\n          </span>\n        </span>\n      </a>\n    );\n  }\n\n  if (size === 'portrait') {\n    // Rail/strip density — shared <EntityPortraitCard> shell. Raw cover +\n    // placeholder go in separately; the shell runs the SAME shared\n    // useCoverImageFallback chain internally (so its error recovery works).\n    const dateStr = post.published_at\n      ? new Date(post.published_at).toLocaleDateString('en-US', {\n          year: 'numeric',\n          month: 'short',\n          day: 'numeric',\n          timeZone: 'UTC',\n        })\n      : '';\n    return (\n      <EntityPortraitCard\n        href={href}\n        target={target}\n        rel={rel}\n        typeLabel={showTypeBadge ? 'Blog Post' : undefined}\n        imageUrl={post.featured_image}\n        placeholderUrl={placeholderUrl}\n        imageAlt={post.title}\n        title={post.title}\n        person={{\n          name: post.author_name || 'Anonymous',\n          avatarUrl: post.author_avatar,\n          subtitle: dateStr || null,\n        }}\n        className={className}\n      />\n    );\n  }\n\n  // Default: full vertical card.\n  const dateStr = post.published_at\n    ? new Date(post.published_at).toLocaleDateString('en-US', {\n        year: 'numeric',\n        month: 'short',\n        day: 'numeric',\n        timeZone: 'UTC',\n      })\n    : '';\n  return (\n    <article\n      className={cn(\n        'group overflow-hidden rounded-lg border border-ods-border bg-ods-card',\n        'transition-all duration-300 ease-out',\n        'transform hover:translate-y-[-2px]',\n        'hover:border-ods-accent hover:shadow-lg hover:shadow-ods-accent/[0.08]',\n        'flex h-full flex-col',\n        className,\n      )}\n      role=\"article\"\n    >\n      <a\n        href={href}\n        target={target}\n        rel={rel}\n        className=\"flex h-full flex-col focus:outline-none\"\n        aria-label={`Read article: ${post.title}`}\n      >\n        <div className=\"relative aspect-[1200/630] w-full overflow-hidden bg-ods-bg\">\n          {displayImage ? (\n            <Image\n              src={displayImage}\n              alt={post.title}\n              fill\n              priority={priority}\n              quality={priority ? 85 : 75}\n              className={cn('object-cover object-center', 'duration-400 transition-all ease-out', 'hover:scale-[1.02]')}\n              sizes=\"(max-width: 768px) 100vw, (max-width: 1519px) 50vw, 33vw\"\n              unoptimized\n              onError={onImageError}\n            />\n          ) : null}\n        </div>\n\n        <div className=\"flex flex-grow flex-col p-4\">\n          <div className=\"mb-3 flex min-h-[50.4px] items-center md:min-h-[56px] lg:min-h-[61.6px]\">\n            <h3\n              className={cn(\n                'text-ods-text-primary text-h3',\n                'line-clamp-2',\n                'transition-colors duration-300 ease-out',\n                'group-hover:text-ods-accent',\n              )}\n            >\n              {post.title}\n            </h3>\n          </div>\n\n          <div className=\"mb-3 flex min-h-[42px] items-center md:min-h-[45px] lg:min-h-[48px]\">\n            <p className={cn('text-ods-text-primary text-h6', 'line-clamp-2')}>{post.summary || ''}</p>\n          </div>\n\n          <div className=\"mt-auto flex items-center justify-between gap-2 text-ods-text-secondary text-h6\">\n            <div className=\"flex min-w-0 items-center gap-2\">\n              {post.author_avatar ? (\n                <Image\n                  src={post.author_avatar}\n                  alt={post.author_name || ''}\n                  width={32}\n                  height={32}\n                  className=\"shrink-0 rounded-full\"\n                  unoptimized\n                />\n              ) : null}\n              <span className=\"truncate\">\n                {post.author_name || 'Anonymous'}\n                {dateStr ? <> · {dateStr}</> : null}\n              </span>\n            </div>\n            <div\n              className=\"flex shrink-0 items-center gap-1\"\n              aria-label={`View count: ${(post.view_count ?? 0).toLocaleString('en-US')} views`}\n            >\n              <Eye className=\"h-4 w-4 shrink-0\" />\n              <span>{(post.view_count ?? 0).toLocaleString('en-US')}</span>\n            </div>\n          </div>\n        </div>\n      </a>\n    </article>\n  );\n}\n","'use client';\n\n/**\n * Shared link-attribute resolver for every entity card.\n *\n * Pure-presentation cards (BlogCard, OnboardingGuideCard, etc.)\n * historically required callers to pre-compute `target`/`rel` via a\n * hub-side `useNavLink` wrapper because the same-tab-vs-new-tab\n * decision depends on `currentPlatform()` and the cross-platform\n * URL topology — neither of which the lib knows.\n *\n * This hook moves that decision INTO the card via the\n * `ChatRuntime.navigation.decideNewTab` callback already wired by\n * `HubRuntimeProvider` (and overridable per-embedder). The wrapping\n * `*-card-item.tsx` files in the hub become unnecessary — every card\n * derives its own `target`/`rel` from runtime.\n *\n * Backwards compat: explicit `target` / `rel` props always WIN over\n * runtime-derived values. Chat dispatcher callsites that already pass\n * pre-resolved attributes are unaffected.\n *\n * No runtime mounted? Returns `{ target: undefined, rel: undefined }`\n * (same-tab) — matches the documented embed-shim fallback.\n */\n\nimport { useMemo } from 'react';\nimport { useChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { computeIsNewTab } from '../utils/nav-anchor-props';\n\nexport interface UseEntityCardLinkArgs {\n  href: string;\n  targetPlatform?: string | null;\n  /** Explicit override. When set, runtime decision is skipped. */\n  target?: '_blank';\n  /** Explicit override. When set, runtime decision is skipped. */\n  rel?: 'noopener noreferrer';\n}\n\nexport interface EntityCardLinkProps {\n  target: '_blank' | undefined;\n  rel: 'noopener noreferrer' | undefined;\n}\n\nexport function useEntityCardLink({ href, targetPlatform, target, rel }: UseEntityCardLinkArgs): EntityCardLinkProps {\n  const runtime = useChatRuntime();\n  return useMemo(() => {\n    // Explicit prop wins — preserves the chat-dispatcher path that\n    // pre-computes attrs from `computeIsNewTab`. When `target='_blank'`\n    // is passed without `rel`, auto-pair with `noopener noreferrer`\n    // to close the tabnabbing vector (window.opener access from the\n    // new tab back to the parent).\n    if (target !== undefined || rel !== undefined) {\n      const safeRel = rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined);\n      return { target, rel: safeRel };\n    }\n    // Use the UNIFIED new-tab decision so the rendered anchor's target/rel ALWAYS\n    // agrees with the click handler (executeNavigation also calls computeIsNewTab) —\n    // including the embed-mode short-circuit + the origin/platform fallback.\n    // Previously this used `decideNewTab ?? false`, which diverged from the click\n    // when decideNewTab was unwired (embed mode rendered a same-tab anchor that\n    // clicked into a new tab). Hub behavior is unchanged: it wires decideNewTab,\n    // which computeIsNewTab consumes identically.\n    const newTab = runtime ? computeIsNewTab(runtime, href, targetPlatform ?? null) : false;\n    return newTab\n      ? { target: '_blank' as const, rel: 'noopener noreferrer' as const }\n      : { target: undefined, rel: undefined };\n  }, [target, rel, href, targetPlatform, runtime]);\n}\n","'use client';\n\n/**\n * Entity-card OG-placeholder resolver.\n *\n * A thin wrapper over the one og-placeholder hook (`useOgPlaceholderUrl`) that\n * adds a single concern: an explicit `placeholderUrl` prop (incl. `null`) wins\n * over the runtime-derived URL — chat dispatch + tests pre-resolve through it.\n * All URL construction lives in `useOgPlaceholderUrl` / `buildOgPlaceholderUrl`,\n * so this surface shares one memo + one code path with every other consumer.\n */\n\nimport { useOgPlaceholderUrl } from '../../../hooks/use-og-placeholder-url';\n\nexport interface UseEntityCardPlaceholderArgs {\n  /** Entity title — used as the placeholder label. */\n  title: string | undefined | null;\n  /** Explicit override. When set (including `null`), the runtime default is\n   *  skipped. */\n  placeholderUrl?: string | null;\n  /** Site name shown under the title. Optional. */\n  siteName?: string;\n  /** Output aspect ratio. `'wide'` (default) for catalog cards, `'square'`\n   *  for compact chat-inline cards. */\n  aspect?: 'wide' | 'square';\n}\n\nexport function useEntityCardPlaceholder({\n  title,\n  placeholderUrl,\n  siteName = '',\n  aspect = 'wide',\n}: UseEntityCardPlaceholderArgs): string | null {\n  const derived = useOgPlaceholderUrl({ title, siteName, aspect });\n  // Explicit prop (including explicit null) wins; `undefined` → derived default.\n  return placeholderUrl !== undefined ? placeholderUrl : derived;\n}\n","'use client';\n\n/**\n * CaseStudyCard (pure presentation). Two densities — `default` (vertical\n * detail) and `sm` (compact horizontal for chat-inline).\n *\n * The card writes NO click logic — callers wrap with their own anchor\n * and pass the resolved detail URL via `href`.\n *\n * Image-fallback chain:\n *   `study.featured_image` → `placeholderUrl` (caller passes\n *   `useOgPlaceholderUrl(...)`) → `bg-ods-bg`.\n */\n\nimport Image from '../../../embed-shims/next-image';\nimport type { CaseStudy } from '../../../types/case-study';\nimport { cn } from '../../../utils/cn';\nimport { Card } from '../../ui/card';\nimport {\n  COMPACT_CARD_IMAGE_SLOT,\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_ROW_FILLER,\n  COMPACT_CARD_SKELETON_IMAGE_SLOT,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_SUBTITLE,\n  COMPACT_CARD_SUMMARY,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  COMPACT_CARD_TITLE_ROW,\n} from '../utils/compact-card-classes';\nimport { EntityPortraitCard } from './entity-portrait-card';\nimport { hideOnError } from './use-cover-image-fallback';\nimport { useEntityCardLink } from './use-entity-card-link';\nimport { useEntityCardPlaceholder } from './use-entity-card-placeholder';\n\nexport interface CaseStudyCardProps {\n  study: CaseStudy;\n  /** Detail URL resolved by the caller. */\n  href: string;\n  /** When `_blank`, opens in a new tab. Set by chat dispatch via\n   *  `computeIsNewTab`. Defaults to same-tab. */\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  targetPlatform?: string | null;\n  /** OG placeholder URL, used when `study.featured_image` is missing. */\n  placeholderUrl?: string | null;\n  size?: 'default' | 'sm' | 'portrait';\n  /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */\n  showTypeBadge?: boolean;\n  className?: string;\n}\n\n/** `portrait` shares the default skeleton shape — the portrait anatomy uses the\n *  same zone boxes (media aspect → 72px title → 60px person footer). */\nexport function CaseStudyCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' | 'portrait' }) {\n  if (size === 'sm') {\n    return (\n      <span className={COMPACT_CARD_SKELETON_OUTER}>\n        <span className={COMPACT_CARD_SKELETON_IMAGE_SLOT} />\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={COMPACT_CARD_TITLE_ROW}>\n            <span className=\"h-3.5 w-3/5 rounded bg-ods-bg\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-1/2 rounded bg-ods-bg/70\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-11/12 rounded bg-ods-bg/40\" />\n          </span>\n        </span>\n      </span>\n    );\n  }\n  return (\n    <div className=\"flex animate-pulse flex-col gap-6 overflow-hidden rounded-lg border border-ods-border bg-ods-card p-6\">\n      {/* Skeleton aspect matches the real card's image slot (OG 1200×630) */}\n      <div className=\"aspect-[1200/630] w-full rounded-sm bg-ods-bg\" />\n      <div className=\"flex h-[72px] flex-col gap-2\">\n        <div className=\"h-5 w-3/4 rounded bg-ods-bg\" />\n        <div className=\"h-5 w-1/2 rounded bg-ods-bg\" />\n      </div>\n      <div className=\"flex h-[60px] items-center gap-3\">\n        <div className=\"h-12 w-12 rounded-full bg-ods-bg\" />\n        <div className=\"flex-1 space-y-2\">\n          <div className=\"h-4 w-2/3 rounded bg-ods-bg\" />\n          <div className=\"h-3 w-1/2 rounded bg-ods-bg/60\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function CaseStudyCard({\n  study,\n  href,\n  target: targetProp,\n  rel: relProp,\n  targetPlatform,\n  placeholderUrl: placeholderUrlProp,\n  size = 'default',\n  showTypeBadge = true,\n  className,\n}: CaseStudyCardProps) {\n  const { target, rel } = useEntityCardLink({\n    href,\n    targetPlatform,\n    target: targetProp,\n    rel: relProp,\n  });\n  const placeholderUrl = useEntityCardPlaceholder({\n    title: study.title,\n    placeholderUrl: placeholderUrlProp,\n    aspect: size === 'sm' ? 'square' : 'wide',\n  });\n  const coverImage = study.featured_image || placeholderUrl || null;\n\n  if (size === 'sm') {\n    return (\n      <a href={href} target={target} rel={rel} className={cn(COMPACT_CARD_OUTER, className)}>\n        <span className={COMPACT_CARD_IMAGE_SLOT}>\n          {coverImage ? (\n            <Image\n              src={coverImage}\n              alt={`${study.msp?.name || study.title} cover`}\n              className=\"object-contain\"\n              fill\n              sizes=\"56px\"\n              unoptimized\n              onError={hideOnError}\n            />\n          ) : null}\n        </span>\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={COMPACT_CARD_TITLE_ROW}>\n            <span className={COMPACT_CARD_TITLE}>{study.title}</span>\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_SUBTITLE}>\n              {[study.msp?.name, study.user?.full_name].filter(Boolean).join(' · ') || 'Case study'}\n            </span>\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_SUMMARY}>{study.summary || COMPACT_CARD_ROW_FILLER}</span>\n          </span>\n        </span>\n      </a>\n    );\n  }\n\n  if (size === 'portrait') {\n    // Rail/strip density — shared <EntityPortraitCard> shell.\n    return (\n      <EntityPortraitCard\n        href={href}\n        target={target}\n        rel={rel}\n        typeLabel={showTypeBadge ? 'Case Study' : undefined}\n        imageUrl={study.featured_image}\n        placeholderUrl={placeholderUrl}\n        imageAlt={study.msp?.name || study.title}\n        title={study.title}\n        person={{\n          name: study.user?.full_name || 'Anonymous',\n          avatarUrl: study.user?.avatar_url,\n          subtitle: study.msp?.name ?? null,\n          iconOverlayUrl: study.msp?.icon_url ?? null,\n        }}\n        className={className}\n      />\n    );\n  }\n\n  return (\n    <a href={href} target={target} rel={rel} className={cn('block h-full', className)}>\n      <Card className=\"flex flex-col gap-6 overflow-hidden border border-ods-border bg-ods-card p-6 transition-colors hover:border-ods-accent\">\n        {/* Fixed aspect ratio matches the standard OG card source aspect\n            (1200×630 = 1.91:1), so the image fits with near-zero CSS-side\n            cropping. Subject anchoring is then a function of the source\n            image's center-66% safe zone (OG design convention) — visually\n            uniform across all cards regardless of column width. Skeleton\n            (lines 73-74) uses the same aspect for consistent layout. */}\n        <div className=\"relative aspect-[1200/630] w-full shrink-0 overflow-hidden rounded-sm bg-ods-bg\">\n          {coverImage && (\n            <Image\n              src={coverImage}\n              alt={study.msp?.name || study.title}\n              className=\"h-full w-full object-cover\"\n              sizes=\"(min-width: 1545px) 515px, (min-width: 1280px) 33vw, (min-width: 800px) 50vw, 100vw\"\n              fill\n              unoptimized\n              onError={hideOnError}\n            />\n          )}\n        </div>\n\n        <div className=\"flex h-[72px] shrink-0 items-center\">\n          <p className=\"line-clamp-3 break-words text-ods-text-primary text-h4\">{study.title}</p>\n        </div>\n\n        <div className=\"flex h-[60px] shrink-0 items-center\">\n          <div className=\"flex w-full min-w-0 items-center gap-3\">\n            <div className=\"relative h-12 w-12 shrink-0\">\n              {study.user?.avatar_url ? (\n                <Image\n                  src={study.user.avatar_url}\n                  alt={study.user?.full_name || 'User'}\n                  className=\"h-12 w-12 rounded-full border border-ods-border bg-ods-bg object-cover\"\n                  width={48}\n                  height={48}\n                  unoptimized\n                />\n              ) : (\n                <div className=\"flex h-12 w-12 items-center justify-center rounded-full border border-ods-border bg-ods-bg\">\n                  <span className=\"text-ods-text-secondary text-h4\">\n                    {(study.user?.full_name || 'A').charAt(0).toUpperCase()}\n                  </span>\n                </div>\n              )}\n              {study.msp?.icon_url && (\n                <div className=\"absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-ods-text-primary ring-1 ring-ods-bg\">\n                  <Image\n                    src={study.msp.icon_url}\n                    alt={study.msp.name || 'MSP'}\n                    className=\"h-full w-full object-cover\"\n                    width={24}\n                    height={24}\n                    unoptimized\n                  />\n                </div>\n              )}\n            </div>\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"truncate text-ods-text-primary text-h6\">\n                {study.user?.full_name || 'Anonymous'}\n                {study.msp?.name && <span className=\"text-ods-text-secondary\"> • {study.msp.name}</span>}\n              </p>\n              <p className=\"truncate text-ods-text-secondary text-h6\">{study.user?.job_title || ' '}</p>\n            </div>\n          </div>\n        </div>\n      </Card>\n    </a>\n  );\n}\n","'use client';\n\n/**\n * CustomerInterviewCard (pure presentation). Two densities — `default`\n * and `sm` (compact horizontal for chat-inline).\n *\n * The card writes NO click logic — callers wrap with their own anchor\n * and pass the resolved detail URL via `href`.\n */\n\nimport { Video } from 'lucide-react';\nimport Image from '../../../embed-shims/next-image';\nimport type { CustomerInterview } from '../../../types/customer-interview';\nimport { cn } from '../../../utils/cn';\nimport { Card } from '../../ui/card';\nimport {\n  COMPACT_CARD_IMAGE_SLOT,\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_ROW_FILLER,\n  COMPACT_CARD_SKELETON_IMAGE_SLOT,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_SUBTITLE,\n  COMPACT_CARD_SUMMARY,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  COMPACT_CARD_TITLE_ROW,\n} from '../utils/compact-card-classes';\nimport { EntityPortraitCard } from './entity-portrait-card';\nimport { hideOnError } from './use-cover-image-fallback';\nimport { useEntityCardLink } from './use-entity-card-link';\nimport { useEntityCardPlaceholder } from './use-entity-card-placeholder';\n\nexport interface CustomerInterviewCardProps {\n  interview: CustomerInterview;\n  href: string;\n  /** When `_blank`, opens in a new tab. Set by chat dispatch via\n   *  `computeIsNewTab`. Defaults to same-tab. */\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  targetPlatform?: string | null;\n  /** OG placeholder URL fallback when `interview.featured_image` is missing. */\n  placeholderUrl?: string | null;\n  size?: 'default' | 'sm' | 'portrait';\n  /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */\n  showTypeBadge?: boolean;\n  className?: string;\n}\n\n/** `portrait` shares the default skeleton shape (same zone boxes). */\nexport function CustomerInterviewCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' | 'portrait' }) {\n  if (size === 'sm') {\n    return (\n      <span className={COMPACT_CARD_SKELETON_OUTER}>\n        <span className={COMPACT_CARD_SKELETON_IMAGE_SLOT} />\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={COMPACT_CARD_TITLE_ROW}>\n            <span className=\"h-3.5 w-3/5 rounded bg-ods-bg\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-2/5 rounded bg-ods-bg/70\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-11/12 rounded bg-ods-bg/40\" />\n          </span>\n        </span>\n      </span>\n    );\n  }\n  return (\n    <div className=\"flex animate-pulse flex-col gap-6 overflow-hidden rounded-lg border border-ods-border bg-ods-card p-6\">\n      {/* Aspect matches the loaded image slot (OG 1200×630) */}\n      <div className=\"aspect-[1200/630] w-full rounded-sm bg-ods-bg\" />\n      <div className=\"space-y-2\">\n        <div className=\"h-5 w-3/4 rounded bg-ods-bg\" />\n        <div className=\"h-5 w-1/2 rounded bg-ods-bg\" />\n      </div>\n      <div className=\"space-y-2\">\n        <div className=\"h-3 w-full rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-5/6 rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-4/5 rounded bg-ods-bg/60\" />\n      </div>\n      <div className=\"mt-auto flex h-[60px] items-center gap-3\">\n        <div className=\"h-12 w-12 rounded-full bg-ods-bg\" />\n        <div className=\"flex-1 space-y-2\">\n          <div className=\"h-4 w-2/3 rounded bg-ods-bg\" />\n          <div className=\"h-3 w-1/2 rounded bg-ods-bg/60\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function CustomerInterviewCard({\n  interview,\n  href,\n  target: targetProp,\n  rel: relProp,\n  targetPlatform,\n  placeholderUrl: placeholderUrlProp,\n  size = 'default',\n  showTypeBadge = true,\n  className,\n}: CustomerInterviewCardProps) {\n  const { target, rel } = useEntityCardLink({\n    href,\n    targetPlatform,\n    target: targetProp,\n    rel: relProp,\n  });\n  const placeholderUrl = useEntityCardPlaceholder({\n    title: interview.title,\n    placeholderUrl: placeholderUrlProp,\n    aspect: size === 'sm' ? 'square' : 'wide',\n  });\n  const thumbnailUrl = interview.featured_image || placeholderUrl || null;\n\n  if (size === 'sm') {\n    return (\n      <a href={href} target={target} rel={rel} className={cn(COMPACT_CARD_OUTER, className)}>\n        <span className={COMPACT_CARD_IMAGE_SLOT}>\n          {thumbnailUrl ? (\n            <Image\n              src={thumbnailUrl}\n              alt={interview.title}\n              className=\"object-contain\"\n              fill\n              sizes=\"56px\"\n              unoptimized\n              onError={hideOnError}\n            />\n          ) : null}\n          {interview.main_video_url ? (\n            <span className=\"absolute inset-0 flex items-center justify-center bg-black/30\">\n              <Video className=\"h-4 w-4 text-ods-text-on-dark\" />\n            </span>\n          ) : null}\n        </span>\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={COMPACT_CARD_TITLE_ROW}>\n            <span className={COMPACT_CARD_TITLE}>{interview.title}</span>\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_SUBTITLE}>\n              {[interview.user?.full_name, interview.msp?.name].filter(Boolean).join(' · ') || 'Customer interview'}\n            </span>\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_SUMMARY}>{interview.video_summary || COMPACT_CARD_ROW_FILLER}</span>\n          </span>\n        </span>\n      </a>\n    );\n  }\n\n  if (size === 'portrait') {\n    // Rail/strip density — shared <EntityPortraitCard> shell.\n    return (\n      <EntityPortraitCard\n        href={href}\n        target={target}\n        rel={rel}\n        typeLabel={showTypeBadge ? 'Customer Interview' : undefined}\n        imageUrl={interview.featured_image}\n        placeholderUrl={placeholderUrl}\n        imageAlt={interview.title}\n        title={interview.title}\n        person={{\n          name: interview.user?.full_name || 'Customer',\n          avatarUrl: interview.user?.avatar_url,\n          subtitle: interview.msp?.name ?? null,\n          iconOverlayUrl: interview.msp?.icon_url ?? null,\n        }}\n        className={className}\n      />\n    );\n  }\n\n  return (\n    <a href={href} target={target} rel={rel} className={cn('block h-full', className)}>\n      <Card className=\"flex flex-col gap-6 overflow-hidden border border-ods-border bg-ods-card p-6 transition-colors hover:border-ods-accent\">\n        {/* Fixed aspect matching standard OG card source (1200×630, 1.91:1)\n            — image renders with near-zero CSS-side crop, subject anchored\n            consistently via center-66% safe-zone convention. */}\n        <div className=\"relative aspect-[1200/630] w-full shrink-0 overflow-hidden rounded-sm bg-ods-bg\">\n          {thumbnailUrl ? (\n            <>\n              <Image\n                src={thumbnailUrl}\n                alt={interview.title}\n                className=\"object-cover\"\n                fill\n                sizes=\"(max-width: 768px) 100vw, 50vw\"\n                unoptimized\n                onError={hideOnError}\n              />\n              {interview.main_video_url && (\n                <div className=\"absolute inset-0 flex items-center justify-center bg-black/40\">\n                  <div className=\"flex h-16 w-16 items-center justify-center rounded-full bg-ods-accent/90\">\n                    <Video className=\"h-8 w-8 text-ods-text-on-accent\" />\n                  </div>\n                </div>\n              )}\n            </>\n          ) : (\n            <div className=\"flex h-full w-full items-center justify-center bg-ods-bg\">\n              <Video className=\"h-12 w-12 text-ods-text-secondary\" />\n            </div>\n          )}\n        </div>\n\n        <div className=\"shrink-0\">\n          <h3 className=\"line-clamp-2 break-words text-ods-text-primary text-h3\">{interview.title}</h3>\n        </div>\n\n        {interview.video_summary && (\n          <div className=\"shrink-0\">\n            <p className=\"line-clamp-3 text-ods-text-secondary text-h6\">{interview.video_summary}</p>\n          </div>\n        )}\n\n        <div className=\"mt-auto flex h-[60px] shrink-0 items-center\">\n          <div className=\"flex w-full min-w-0 items-center gap-3\">\n            <div className=\"relative h-12 w-12 shrink-0\">\n              {interview.user?.avatar_url ? (\n                <Image\n                  src={interview.user.avatar_url}\n                  alt={interview.user?.full_name || 'Customer'}\n                  className=\"h-12 w-12 rounded-full border border-ods-border bg-ods-bg object-cover\"\n                  width={48}\n                  height={48}\n                  unoptimized\n                />\n              ) : (\n                <div className=\"flex h-12 w-12 items-center justify-center rounded-full border border-ods-border bg-ods-bg\">\n                  <span className=\"text-ods-text-secondary text-h4\">\n                    {(interview.user?.full_name || 'A').charAt(0).toUpperCase()}\n                  </span>\n                </div>\n              )}\n              {interview.msp?.icon_url && (\n                <div className=\"absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-ods-text-primary ring-1 ring-ods-bg\">\n                  <Image\n                    src={interview.msp.icon_url}\n                    alt={interview.msp.name || 'MSP'}\n                    className=\"h-full w-full object-cover\"\n                    width={24}\n                    height={24}\n                    unoptimized\n                  />\n                </div>\n              )}\n            </div>\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"truncate text-ods-text-primary text-h6\">\n                {interview.user?.full_name || 'Anonymous'}\n                {interview.msp?.name && <span className=\"text-ods-text-secondary\"> • {interview.msp.name}</span>}\n              </p>\n              <p className=\"truncate text-ods-text-secondary text-h6\">{interview.user?.job_title || ' '}</p>\n            </div>\n          </div>\n        </div>\n      </Card>\n    </a>\n  );\n}\n","/**\n * Investor Update entity — wire shape for the chat investor-update card\n * + the `/api/investor-updates` reads.\n *\n * Lifted from `lib/data/investor-update-utils-shared.ts` in the hub.\n * The server-side data utils + visibility filters in that file stay\n * hub-side; only the TYPES + the pure `formatInvestorUpdatePeriod`\n * formatter move here.\n */\n\nimport type { ContentRef } from './content-ref';\n\nexport interface MetricEntry {\n  current: number;\n  previous?: number;\n  change_pct?: number;\n  visibility: 'public' | 'internal';\n}\n\nexport interface HighlightEntry {\n  title: string;\n  description?: string;\n  visibility: 'public' | 'internal';\n}\n\nexport interface InvestorUpdateHighlights {\n  key_highlights?: HighlightEntry[];\n  product_milestones?: HighlightEntry[];\n  community_updates?: HighlightEntry[];\n  upcoming_priorities?: HighlightEntry[];\n  financial_notes?: HighlightEntry[];\n}\n\nexport interface InvestorUpdate {\n  id: string;\n  title: string;\n  slug: string;\n  update_number: number | null;\n  period_start: string | null;\n  period_end: string | null;\n  platform_id: string | null;\n  content: string | null;\n  video_summary: string | null;\n  transcript: string | null;\n  srt_content?: string | null;\n  main_video_url: string | null;\n  highlight_video_url: string | null;\n  highlight_video_thumbnail: string | null;\n  main_video_thumbnail: string | null;\n  video_bites: unknown[];\n  featured_image: string | null;\n  strategic_update: string | null;\n  financials: Record<string, MetricEntry>;\n  metrics_snapshot: Record<string, MetricEntry>;\n  content_refs: ContentRef[];\n  highlights: InvestorUpdateHighlights | null;\n  section_visibility: Record<string, 'public' | 'internal'>;\n  status: string;\n  published_at: string | null;\n  author_id: string | null;\n  author?: {\n    id: string;\n    full_name: string;\n    avatar_url: string | null;\n    job_title: string | null;\n    email?: string | null;\n  };\n  seo_title: string | null;\n  seo_description: string | null;\n  seo_keywords: string | null;\n  og_image_url: string | null;\n  hubspot_email_id: string | null;\n  hubspot_email_url?: string | null;\n  custom_instructions: string | null;\n  created_at: string;\n  updated_at: string;\n  created_by: string | null;\n  updated_by: string | null;\n}\n\n/**\n * Format an investor-update period as `Mar 2025 - May 2025` (or `?` for\n * a missing endpoint). Pure formatter — no DB / state access, so it\n * moves to lib alongside the types.\n */\nexport function formatInvestorUpdatePeriod(\n  start: string | null,\n  end: string | null,\n  options?: { monthFormat?: 'short' | 'long' },\n): string {\n  if (!start && !end) return '';\n  const fmt = (d: string) =>\n    new Date(d).toLocaleDateString('en-US', {\n      month: options?.monthFormat || 'short',\n      year: 'numeric',\n      // Pin to UTC so SSR (Vercel = UTC) and the client agree (React #418).\n      timeZone: 'UTC',\n    });\n  const s = start ? fmt(start) : '?';\n  const e = end ? fmt(end) : '?';\n  return `${s} - ${e}`;\n}\n","'use client';\n\n/**\n * InvestorUpdateCard (pure presentation).\n *\n * Two densities — `default` (uses `AdminContentCard`) and `sm` (compact\n * horizontal). The card writes NO click logic — callers wrap with their\n * own anchor and pass the resolved detail URL via `href`.\n */\n\nimport { Calendar } from 'lucide-react';\nimport Image from '../../../embed-shims/next-image';\nimport { formatInvestorUpdatePeriod, type InvestorUpdate } from '../types/entities/investor-update';\nimport {\n  COMPACT_CARD_IMAGE_SLOT,\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_ROW_FILLER,\n  COMPACT_CARD_SKELETON_IMAGE_SLOT,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_SUBTITLE,\n  COMPACT_CARD_SUMMARY,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  COMPACT_CARD_TITLE_ROW,\n} from '../utils/compact-card-classes';\nimport { AdminContentCard } from './admin-content-card';\nimport { EntityPortraitCard } from './entity-portrait-card';\nimport { useEntityCardLink } from './use-entity-card-link';\nimport { useEntityCardPlaceholder } from './use-entity-card-placeholder';\n\nexport interface InvestorUpdateCardProps {\n  update: InvestorUpdate;\n  href: string;\n  /** When `_blank`, opens in a new tab. Set by chat dispatch via\n   *  `computeIsNewTab`. Defaults to same-tab. */\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  targetPlatform?: string | null;\n  /** OG placeholder URL used when `update.featured_image` is missing. */\n  placeholderUrl?: string | null;\n  size?: 'default' | 'sm' | 'portrait';\n  /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */\n  showTypeBadge?: boolean;\n  className?: string;\n}\n\n/** `portrait` shares the default skeleton shape (same zone boxes). */\nexport function InvestorUpdateCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' | 'portrait' }) {\n  if (size === 'sm') {\n    return (\n      <span className={COMPACT_CARD_SKELETON_OUTER}>\n        <span className={COMPACT_CARD_SKELETON_IMAGE_SLOT} />\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={`${COMPACT_CARD_TITLE_ROW} flex-nowrap gap-2`}>\n            <span className=\"h-3.5 w-1/2 rounded bg-ods-bg\" />\n            <span className=\"h-4 w-8 shrink-0 rounded bg-ods-bg/70\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-1/3 rounded bg-ods-bg/70\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-11/12 rounded bg-ods-bg/40\" />\n          </span>\n        </span>\n      </span>\n    );\n  }\n  return (\n    <div className=\"h-full animate-pulse overflow-hidden rounded-lg border border-ods-border bg-ods-card\">\n      <div className=\"aspect-[1200/630] bg-ods-bg\" />\n      <div className=\"space-y-3 p-4\">\n        <div className=\"h-5 w-3/4 rounded bg-ods-bg\" />\n        <div className=\"h-3 w-full rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-4/5 rounded bg-ods-bg/60\" />\n        <div className=\"h-4 w-1/3 rounded bg-ods-bg/60\" />\n      </div>\n    </div>\n  );\n}\n\nexport function InvestorUpdateCard({\n  update,\n  href,\n  target: targetProp,\n  rel: relProp,\n  targetPlatform,\n  placeholderUrl: placeholderUrlProp,\n  size = 'default',\n  showTypeBadge = true,\n  className,\n}: InvestorUpdateCardProps) {\n  const { target, rel } = useEntityCardLink({\n    href,\n    targetPlatform,\n    target: targetProp,\n    rel: relProp,\n  });\n  const placeholderUrl = useEntityCardPlaceholder({\n    title: update.title ?? `Update #${update.update_number ?? ''}`,\n    placeholderUrl: placeholderUrlProp,\n    aspect: size === 'sm' ? 'square' : 'wide',\n  });\n  const coverImage = update.featured_image || placeholderUrl || null;\n\n  if (size === 'sm') {\n    const periodText = formatInvestorUpdatePeriod(update.period_start, update.period_end);\n    return (\n      <a href={href} target={target} rel={rel} className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n        <span className={COMPACT_CARD_IMAGE_SLOT}>\n          {coverImage ? (\n            <Image\n              src={coverImage}\n              alt={update.title ?? 'Investor update'}\n              className=\"object-contain\"\n              fill\n              sizes=\"56px\"\n              unoptimized\n              onError={e => {\n                const img = e.currentTarget;\n                img.style.display = 'none';\n              }}\n            />\n          ) : (\n            <span className=\"flex h-full w-full items-center justify-center text-ods-accent\">\n              <Calendar className=\"h-4 w-4\" />\n            </span>\n          )}\n        </span>\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={`${COMPACT_CARD_TITLE_ROW} gap-2`}>\n            <span className={`${COMPACT_CARD_TITLE} min-w-0`}>\n              {update.title || `Update #${update.update_number ?? '?'}`}\n            </span>\n            {update.update_number ? (\n              <span className=\"shrink-0 rounded bg-ods-accent/10 px-1.5 py-0.5 text-ods-accent text-h6\">\n                #{update.update_number}\n              </span>\n            ) : null}\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_SUBTITLE}>{periodText || 'Investor update'}</span>\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_SUMMARY}>\n              {(update.strategic_update || update.content || '').slice(0, 120) || COMPACT_CARD_ROW_FILLER}\n            </span>\n          </span>\n        </span>\n      </a>\n    );\n  }\n\n  if (size === 'portrait') {\n    // Rail/strip density — shared <EntityPortraitCard> shell. The reporting\n    // period is the meaningful footer line (no fabricated author — no avatar\n    // renders an initial circle).\n    const periodText = formatInvestorUpdatePeriod(update.period_start, update.period_end);\n    return (\n      <EntityPortraitCard\n        href={href}\n        target={target}\n        rel={rel}\n        typeLabel={showTypeBadge ? 'Investor Update' : undefined}\n        imageUrl={update.featured_image}\n        placeholderUrl={placeholderUrl}\n        imageAlt={update.title ?? 'Investor update'}\n        title={update.title || `Update #${update.update_number ?? '?'}`}\n        person={periodText ? { name: periodText } : null}\n        className={className}\n      />\n    );\n  }\n\n  return (\n    <a href={href} target={target} rel={rel} className={`block h-full ${className ?? ''}`}>\n      <AdminContentCard\n        imageUrl={update.featured_image}\n        placeholderUrl={placeholderUrl}\n        title={update.title || `Update #${update.update_number || '?'}`}\n        summary={update.strategic_update?.slice(0, 120) || update.content?.slice(0, 120) || 'No description'}\n        badges={\n          update.update_number ? (\n            <span className=\"rounded bg-ods-accent/10 px-2 py-0.5 text-xs font-medium text-ods-accent\">\n              #{update.update_number}\n            </span>\n          ) : undefined\n        }\n        meta={\n          update.period_start || update.period_end ? (\n            <div className=\"flex items-center gap-1\">\n              <Calendar className=\"h-3 w-3\" />\n              <span>{formatInvestorUpdatePeriod(update.period_start, update.period_end)}</span>\n            </div>\n          ) : undefined\n        }\n      />\n    </a>\n  );\n}\n","'use client';\n\n/**\n * OnboardingGuideCard (pure presentation + runtime-derived link attrs).\n *\n * Three variants:\n *   - `catalog`: rich detail card (hero + author grid) for the public catalog\n *     page.\n *   - `default`: horizontal step-numbered card for \"More in {section}\" rail.\n *   - `sm`: compact horizontal card for chat-inline rendering.\n *\n * Link semantics: the card derives `target`/`rel` from `ChatRuntime.navigation\n * .decideNewTab` (hub-wired via `HubRuntimeProvider`) and the placeholder\n * image from the runtime's base API URL (`endpoints.ogPlaceholderUrl`, via\n * `useEntityCardPlaceholder`). Explicit `target` / `rel` / `placeholderUrl`\n * props always WIN — chat dispatch and tests can pre-resolve. No runtime\n * mounted → same-tab + same-origin relative placeholder.\n */\n\nimport { Clock, ExternalLink, GraduationCap, Play } from 'lucide-react';\nimport Image from '../../../embed-shims/next-image';\nimport Link from '../../../embed-shims/next-link';\nimport { cn } from '../../../utils/cn';\nimport { formatDurationMMSS } from '../../../utils/format';\nimport type { OnboardingGuide } from '../types/entities/onboarding-guide';\nimport {\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_IMAGE_SLOT,\n  COMPACT_CARD_SKELETON_IMAGE_SLOT,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE_ROW,\n  COMPACT_CARD_TITLE,\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_SUMMARY,\n  COMPACT_CARD_ROW_FILLER,\n} from '../utils/compact-card-classes';\nimport { BlogImagePlaceholder } from './blog-image-placeholder';\nimport { EntityAuthorCard } from './entity-author-card';\nimport { EntityPortraitCard } from './entity-portrait-card';\nimport { useEntityCardLink } from './use-entity-card-link';\nimport { useEntityCardPlaceholder } from './use-entity-card-placeholder';\n\nexport interface OnboardingGuideCardProps {\n  guide: OnboardingGuide;\n  /** Detail URL resolved by the caller. */\n  href: string;\n  /** When `_blank`, opens in a new tab. Set by chat dispatch via\n   *  `computeIsNewTab`. Defaults to same-tab. */\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  targetPlatform?: string | null;\n  /** OG placeholder URL used by the catalog + sm variants when no cover. */\n  placeholderUrl?: string | null;\n  size?: 'catalog' | 'default' | 'sm' | 'portrait';\n  /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */\n  showTypeBadge?: boolean | 'default' | 'sm' | 'portrait';\n  className?: string;\n}\n\n/** Markdown source → clean one-line preview prose. The guide cards preview\n *  `video_summary || content`, and `content` is raw markdown — without this,\n *  the clamped summary shows literal `**bold**` / `## heading` noise. */\nfunction stripMarkdownPreview(text: string): string {\n  return (\n    text\n      .replace(/```[\\s\\S]*?```/g, ' ')\n      .replace(/^#{1,6}\\s+/gm, '')\n      .replace(/!?\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n      .replace(/[*_~`>]/g, '')\n      .replace(/^\\s*[-+]\\s+/gm, '')\n      .replace(/-{3,}/g, ' ')\n      .replace(/\\s+/g, ' ')\n      .trim()\n      // Hard cap — line-clamp is the visual truncation, but a CSS-order\n      // conflict (display utilities vs -webkit-box) once silently killed it\n      // and dumped whole guides into the card. Never ship more than ~2 lines\n      // of source text regardless of CSS.\n      .replace(/^([\\s\\S]{240})[\\s\\S]+$/, '$1…')\n  );\n}\n\nconst HORIZONTAL_SIZE_TOKENS = {\n  default: {\n    padding: 'p-4',\n    step: 'w-8 h-8 text-h6',\n    title: 'text-h5',\n    summaryClamp: 'line-clamp-2',\n  },\n} as const;\n\nexport function OnboardingGuideCardSkeleton({ size = 'default' }: { size?: 'catalog' | 'default' | 'sm' }) {\n  if (size === 'catalog') {\n    return (\n      <div className=\"flex animate-pulse flex-col gap-4 overflow-hidden rounded-lg border border-ods-border bg-ods-card p-6\">\n        <div className=\"flex flex-col gap-4 md:flex-row md:gap-6\">\n          <div className=\"aspect-[1200/630] w-full flex-shrink-0 rounded-lg bg-ods-border md:w-[256px]\" />\n          <div className=\"flex min-w-0 flex-1 flex-col\">\n            <div className=\"mb-3 flex min-h-[60px] flex-col justify-start gap-1.5 md:min-h-[72px]\">\n              <div className=\"h-[25px] w-3/4 rounded bg-ods-border md:h-[30px]\" />\n              <div className=\"h-[25px] w-1/2 rounded bg-ods-border md:h-[30px]\" />\n            </div>\n            <div className=\"flex min-h-[46px] flex-col justify-start gap-2 md:min-h-[52px]\">\n              <div className=\"h-3 w-full rounded bg-ods-border/70\" />\n              <div className=\"h-3 w-5/6 rounded bg-ods-border/70\" />\n            </div>\n          </div>\n        </div>\n        <div className=\"grid w-full grid-cols-1 overflow-hidden rounded-md border border-ods-border md:grid-cols-3\">\n          {[0, 1].map(i => (\n            <div\n              key={`cell-${i}`}\n              className=\"flex flex-col gap-3 border-b border-ods-border bg-ods-card p-4 md:border-b-0 md:border-r\"\n            >\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"h-6 w-32 rounded bg-ods-bg\" />\n                <div className=\"h-3 w-20 rounded bg-ods-bg/60\" />\n              </div>\n            </div>\n          ))}\n          <div className=\"flex items-center gap-3 bg-ods-card p-4\">\n            <div className=\"h-10 w-10 shrink-0 rounded-full bg-ods-bg\" />\n            <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n              <div className=\"h-4 w-3/4 rounded bg-ods-bg\" />\n              <div className=\"h-3 w-1/2 rounded bg-ods-bg/60\" />\n            </div>\n          </div>\n        </div>\n      </div>\n    );\n  }\n  if (size === 'sm') {\n    return (\n      <span className={COMPACT_CARD_SKELETON_OUTER}>\n        <span className={COMPACT_CARD_SKELETON_IMAGE_SLOT} />\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={COMPACT_CARD_TITLE_ROW}>\n            <span className=\"h-3.5 w-3/5 rounded bg-ods-bg\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-2/5 rounded bg-ods-bg/70\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-11/12 rounded bg-ods-bg/40\" />\n          </span>\n        </span>\n        <span className=\"flex h-5 shrink-0 items-center self-start\">\n          <span className=\"h-3.5 w-3.5 rounded bg-ods-bg\" />\n        </span>\n      </span>\n    );\n  }\n  const t = HORIZONTAL_SIZE_TOKENS.default;\n  return (\n    <span\n      className={`flex items-start gap-3 rounded-md border border-ods-border bg-ods-card ${t.padding} animate-pulse`}\n    >\n      <span className={`inline-flex shrink-0 items-center justify-center rounded-full bg-ods-bg ${t.step}`} />\n      <span className=\"flex min-w-0 flex-1 flex-col gap-2\">\n        <span className=\"block h-4 w-2/3 rounded bg-ods-bg\" />\n        <span className=\"block h-3 w-1/3 rounded bg-ods-bg/70\" />\n        <span className=\"block h-3 w-full rounded bg-ods-bg/40\" />\n      </span>\n    </span>\n  );\n}\n\nexport function OnboardingGuideCard({\n  guide,\n  href,\n  target: targetProp,\n  rel: relProp,\n  targetPlatform,\n  placeholderUrl: placeholderUrlProp,\n  size = 'default',\n  showTypeBadge = true,\n  className,\n}: OnboardingGuideCardProps) {\n  const { target, rel } = useEntityCardLink({\n    href,\n    targetPlatform,\n    target: targetProp,\n    rel: relProp,\n  });\n  const placeholderUrl = useEntityCardPlaceholder({\n    title: guide.title,\n    placeholderUrl: placeholderUrlProp,\n    aspect: size === 'sm' ? 'square' : 'wide',\n  });\n\n  if (size === 'portrait') {\n    // Rail/strip density — shared <EntityPortraitCard> shell. Same cover\n    // chain as the catalog variant.\n    const coverImage = guide.featured_image || guide.main_video_thumbnail || guide.og_image_url || null;\n    return (\n      <EntityPortraitCard\n        href={href}\n        target={target}\n        rel={rel}\n        typeLabel={showTypeBadge ? 'Guide' : undefined}\n        imageUrl={coverImage}\n        placeholderUrl={placeholderUrl}\n        imageAlt={guide.title}\n        title={guide.title}\n        person={guide.section ? { name: guide.section } : null}\n        className={className}\n      />\n    );\n  }\n\n  if (size === 'catalog') {\n    const coverImage = guide.featured_image || guide.main_video_thumbnail || guide.og_image_url || null;\n    const hasVideoCover = !!(guide.main_video_thumbnail || guide.highlight_video_thumbnail);\n    const stepLabel = typeof guide.step_order === 'number' ? String(guide.step_order).padStart(2, '0') : '—';\n    const durationLabel =\n      typeof guide.highlight_video_duration_ms === 'number' && guide.highlight_video_duration_ms > 0\n        ? formatDurationMMSS(Math.floor(guide.highlight_video_duration_ms / 1000))\n        : '';\n\n    return (\n      <Link\n        href={href}\n        target={target}\n        rel={rel}\n        prefetch={false}\n        className={cn(\n          'group block bg-ods-card no-underline',\n          'overflow-hidden rounded-lg border border-ods-border',\n          'transition-all duration-300 ease-out',\n          'transform hover:translate-y-[-2px]',\n          'hover:border-ods-accent hover:shadow-lg hover:shadow-ods-accent/[0.08]',\n          className,\n        )}\n        aria-label={`Open ${guide.title}`}\n      >\n        <div className=\"flex flex-col gap-4 p-6\">\n          <div className=\"flex flex-col gap-4 md:flex-row md:gap-6\">\n            <div className=\"w-full flex-shrink-0 md:w-[256px]\">\n              <div className=\"relative aspect-[1200/630] w-full overflow-hidden rounded-lg bg-ods-bg\">\n                {coverImage ? (\n                  <Image\n                    src={coverImage}\n                    alt={guide.title}\n                    fill\n                    sizes=\"(max-width: 768px) 100vw, 256px\"\n                    className=\"object-cover\"\n                    unoptimized\n                  />\n                ) : (\n                  <BlogImagePlaceholder\n                    title={guide.title}\n                    imageUrl={placeholderUrl ?? null}\n                    className=\"absolute inset-0\"\n                  />\n                )}\n                {hasVideoCover && coverImage && (\n                  <span className=\"absolute inset-0 flex items-center justify-center bg-black/30\">\n                    <Play className=\"h-10 w-10 text-ods-text-on-dark\" fill=\"white\" />\n                  </span>\n                )}\n                {durationLabel && (\n                  <span className=\"absolute bottom-2 right-2 inline-flex items-center gap-1 rounded bg-black/60 px-2 py-1 text-ods-text-on-dark text-code\">\n                    <Clock className=\"h-3 w-3\" />\n                    {durationLabel}\n                  </span>\n                )}\n              </div>\n            </div>\n\n            <div className=\"flex min-w-0 flex-1 flex-col\">\n              <div className=\"mb-3 flex min-h-[60px] items-start md:min-h-[72px]\">\n                <h3 className=\"line-clamp-2 text-ods-text-primary text-h2\">{guide.title}</h3>\n              </div>\n              <div className=\"min-h-[46px] md:min-h-[52px]\">\n                <p className=\"line-clamp-2 text-ods-text-secondary text-h6\">\n                  {stripMarkdownPreview(guide.video_summary || guide.content || '')}\n                </p>\n              </div>\n            </div>\n          </div>\n\n          <EntityAuthorCard\n            author={guide.author}\n            publishedAt={guide.published_at}\n            renderEmptyAuthor\n            extraCells={[\n              {\n                value: `${guide.section} · Step ${stepLabel}`,\n                label: 'Section',\n                uppercase: false,\n              },\n            ]}\n          />\n        </div>\n      </Link>\n    );\n  }\n\n  if (size === 'sm') {\n    const coverImage = guide.featured_image || guide.main_video_thumbnail || guide.og_image_url || null;\n    const compactCover = coverImage || placeholderUrl || null;\n    const hasVideoCover = !guide.featured_image && !!guide.main_video_thumbnail;\n    const summary = stripMarkdownPreview(guide.video_summary || guide.content || '');\n    const author = guide.author?.full_name?.trim() || '';\n    const subtitleParts = [`Step ${guide.step_order}`, guide.section, author].filter(\n      (s): s is string => typeof s === 'string' && s.length > 0,\n    );\n    return (\n      <Link href={href} target={target} rel={rel} prefetch={false} className={cn(COMPACT_CARD_OUTER, className)}>\n        <span className={COMPACT_CARD_IMAGE_SLOT}>\n          {compactCover ? (\n            <Image src={compactCover} alt={guide.title} fill sizes=\"56px\" className=\"object-contain\" unoptimized />\n          ) : (\n            <span className=\"flex h-full w-full items-center justify-center text-ods-accent\">\n              <GraduationCap className=\"h-4 w-4\" />\n            </span>\n          )}\n          {hasVideoCover && compactCover && (\n            <span className=\"absolute inset-0 flex items-center justify-center bg-black/30\">\n              <Play className=\"h-4 w-4 text-ods-text-on-dark\" fill=\"white\" />\n            </span>\n          )}\n        </span>\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={COMPACT_CARD_TITLE_ROW}>\n            <span className={cn(COMPACT_CARD_TITLE, 'font-heading')}>{guide.title}</span>\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"truncate text-ods-accent text-h6\">{subtitleParts.join(' · ')}</span>\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_SUMMARY}>{summary || COMPACT_CARD_ROW_FILLER}</span>\n          </span>\n        </span>\n        <span className=\"flex h-5 shrink-0 items-center self-start text-ods-text-secondary\">\n          <ExternalLink className=\"h-3.5 w-3.5\" />\n        </span>\n      </Link>\n    );\n  }\n\n  // size === 'default' — horizontal step-numbered card for related-rail.\n  const t = HORIZONTAL_SIZE_TOKENS.default;\n  const summary = stripMarkdownPreview(guide.video_summary || guide.content || '');\n\n  return (\n    <Link\n      href={href}\n      target={target}\n      rel={rel}\n      prefetch={false}\n      className={cn(\n        `flex items-start gap-3 rounded-md border border-ods-border bg-ods-card transition-colors hover:border-ods-accent ${t.padding}`,\n        className,\n      )}\n    >\n      <span\n        className={`inline-flex shrink-0 items-center justify-center rounded-full bg-ods-accent/10 font-semibold text-ods-accent ${t.step}`}\n        aria-hidden=\"true\"\n      >\n        {guide.step_order}\n      </span>\n      <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <span className={`block ${t.title} truncate text-ods-text-primary`}>{guide.title}</span>\n        <span className=\"inline-flex items-center gap-1 text-ods-text-secondary text-h6\">\n          <GraduationCap className=\"h-3 w-3 shrink-0\" />\n          <span className=\"truncate\">{guide.section}</span>\n        </span>\n        {summary && <span className={`text-ods-text-secondary text-h6 ${t.summaryClamp}`}>{summary}</span>}\n      </span>\n    </Link>\n  );\n}\n","'use client';\n\nimport { cn } from '../../utils/cn';\nimport { SquareAvatar } from './square-avatar';\n\n/**\n * AvatarStack — the shared overlapping-avatar cluster (\"facepile\"): up to\n * `max` round `SquareAvatar`s overlapped by one avatar-third, then a \"+N\"\n * count circle for the rest. Promoted from the meeting-scheduler (context\n * panel + directory rows both stacked hosts by hand) so every future\n * multi-person cluster reuses ONE recipe.\n */\n\nexport interface AvatarStackPerson {\n  name: string;\n  avatarUrl?: string | null;\n  /** Stable render key — pass when names can collide or be blank\n   *  (e.g. ClickUp member id). Falls back to name+index. */\n  key?: string | number;\n}\n\nexport interface AvatarStackProps {\n  people: AvatarStackPerson[];\n  /** Avatars shown before collapsing into the \"+N\" circle. */\n  max?: number;\n  /** Size bucket (also sizes the \"+N\" circle). `xs` (24px) is for\n   *  dense card meta rows (chat compact cards, delivery rows). */\n  size?: 'xs' | 'sm' | 'md' | 'lg';\n  /** Ring color class separating overlapped avatars from each other —\n   *  MUST match the surface the stack sits on (default: card surface).\n   *  Without the surface-colored ring, overlapping photos read as one\n   *  smeared blob — the ring is what makes a stack look deliberate. */\n  ringClassName?: string;\n  className?: string;\n}\n\nconst OVERFLOW_CIRCLE_SIZE: Record<NonNullable<AvatarStackProps['size']>, string> = {\n  xs: 'h-6 w-6',\n  sm: 'h-8 w-8',\n  md: 'h-10 w-10',\n  lg: 'h-12 w-12',\n};\n\nexport function AvatarStack({\n  people,\n  max = 3,\n  size = 'md',\n  ringClassName = 'ring-ods-card',\n  className,\n}: AvatarStackProps) {\n  if (people.length === 0) return null;\n  const visible = people.slice(0, max);\n  const overflow = people.slice(max);\n  return (\n    <div\n      className={cn('flex items-center', className)}\n      role=\"group\"\n      aria-label={`Assignees: ${people.map(p => p.name).join(', ')}`}\n    >\n      {visible.map((person, i) => (\n        <SquareAvatar\n          key={person.key ?? `${person.name}-${i}`}\n          variant=\"round\"\n          size={size}\n          src={person.avatarUrl ?? undefined}\n          alt={person.name}\n          fallback={person.name}\n          title={person.name}\n          className={cn('relative ring-2', ringClassName, i > 0 && (size === 'xs' ? '-ml-2' : '-ml-3'))}\n          // Leftmost (primary assignee) on TOP — later avatars tuck\n          // BEHIND, so the first face is always fully visible.\n          style={{ zIndex: visible.length - i }}\n        />\n      ))}\n      {overflow.length > 0 && (\n        <span\n          title={overflow.map(p => p.name).join(', ')}\n          className={cn(\n            'relative z-0 flex shrink-0 items-center justify-center rounded-full bg-ods-bg text-ods-text-secondary ring-2',\n            ringClassName,\n            size === 'xs' ? '-ml-2 text-badge' : '-ml-3 text-h6',\n            OVERFLOW_CIRCLE_SIZE[size],\n          )}\n        >\n          +{overflow.length}\n        </span>\n      )}\n    </div>\n  );\n}\n","/**\n * Status color-scheme resolver used by chat-adjacent badges (StatusBadge\n * components rendering job, ClickUp task, and log-level statuses).\n *\n * Lib-side subset of the hub's `lib/utils/agent-status-message.ts`. The\n * hub file also carries CSS-class resolvers + an `AgentStatusMessage`\n * builder for the SSE-status surface; those live hub-side because they\n * reference hub-only ODS class names. This function returns the abstract\n * color scheme name only — the consuming `StatusBadge` (in this lib)\n * maps it to concrete colors.\n */\n\nexport type ColorScheme = 'success' | 'error' | 'warning' | 'cyan' | 'default';\n\n/**\n * Map a status to a color scheme name for StatusBadge components.\n * Supports job/processing statuses, ClickUp task statuses, and log levels.\n */\nexport function getStatusColorScheme(status: string): ColorScheme {\n  const s = status?.toLowerCase() || '';\n\n  // Exact matches first (most common)\n  switch (status) {\n    case 'completed':\n    case 'success':\n    case 'active': // review cycles, feature flags, etc.\n      return 'success';\n    case 'failed':\n    case 'failure':\n    case 'cancelled':\n    case 'error':\n      return 'error';\n    case 'running':\n    case 'processing':\n    case 'closed': // review cycles past their active window\n      return 'cyan';\n    case 'pending':\n    case 'warning':\n      return 'warning';\n    case 'draft': // review cycles not yet opened to reviewers\n    case 'info':\n      return 'default';\n  }\n\n  // Partial matches for ClickUp-style statuses (Complete, Done, Working, etc.)\n  if (s.includes('complete') || s.includes('done')) return 'success';\n  if (s.includes('review')) return 'cyan';\n  if (s.includes('working') || s.includes('progress')) return 'warning';\n  if (s.includes('blocked') || s.includes('failed')) return 'error';\n\n  return 'default';\n}\n","/**\n * ClickUp custom_item_id → human-readable label resolver.\n *\n * Lib-side subset of the hub's `lib/utils/clickup-task-type-utils.ts`.\n * The hub file also exports delivery / workspace slug helpers used by\n * the sync engine; only the chat-surfaced label resolver migrates here\n * because chat-inline cards and activity feeds need it.\n *\n * Reference: ClickUp's standard task types (custom_item_id values):\n *   null/1000=Task, 1001=Milestone, 1002=Recurring, 1003=Subtask,\n *   1004=Form, 1006=Plan, 1007=Strategy, 1008=Bug, 1009=Request,\n *   1010=Feature, 1011=Story, 1012=Epic, 1013=Component, 1014=Initiative.\n */\n\nexport const CUSTOM_ITEM_ID = {\n  TASK: 1000,\n  MILESTONE: 1001,\n  RECURRING: 1002,\n  SUBTASK: 1003,\n  FORM: 1004,\n  PLAN: 1006,\n  STRATEGY: 1007,\n  BUG: 1008,\n  REQUEST: 1009,\n  FEATURE: 1010,\n  STORY: 1011,\n  EPIC: 1012,\n  COMPONENT: 1013,\n  INITIATIVE: 1014,\n} as const;\n\n/**\n * Display name for a ClickUp task type — used in chat-inline cards and\n * activity feeds where the type badge would otherwise show a numeric ID.\n *\n * Returning `null` for unknown IDs lets callers fall back to a generic\n * \"Task\" affordance rather than printing `task-1100` or similar.\n */\nexport function getTaskTypeLabel(customItemId: number | null | undefined): string | null {\n  switch (customItemId) {\n    case CUSTOM_ITEM_ID.TASK:\n      return 'Task';\n    case CUSTOM_ITEM_ID.MILESTONE:\n      return 'Milestone';\n    case CUSTOM_ITEM_ID.RECURRING:\n      return 'Recurring';\n    case CUSTOM_ITEM_ID.SUBTASK:\n      return 'Subtask';\n    case CUSTOM_ITEM_ID.FORM:\n      return 'Form';\n    case CUSTOM_ITEM_ID.PLAN:\n      return 'Plan';\n    case CUSTOM_ITEM_ID.STRATEGY:\n      return 'Strategy';\n    case CUSTOM_ITEM_ID.BUG:\n      return 'Bug';\n    case CUSTOM_ITEM_ID.REQUEST:\n      return 'Request';\n    case CUSTOM_ITEM_ID.FEATURE:\n      return 'Feature';\n    case CUSTOM_ITEM_ID.STORY:\n      return 'Story';\n    case CUSTOM_ITEM_ID.EPIC:\n      return 'Epic';\n    case CUSTOM_ITEM_ID.COMPONENT:\n      return 'Component';\n    case CUSTOM_ITEM_ID.INITIATIVE:\n      return 'Initiative';\n    default:\n      return null;\n  }\n}\n","'use client';\n\n/**\n * RoadmapVoteButton — small thumbs-up / thumbs-down vote button used by\n * `RoadmapCard`'s `default` variant. Pure presentation; click handler\n * comes from the parent (typically a `useRoadmapVoting` hook).\n */\n\nimport { cn } from '../../../utils/cn';\nimport { ThumbsDownIcon } from '../../icons/thumbs-down-icon';\nimport { ThumbsUpIcon } from '../../icons/thumbs-up-icon';\nimport { Button } from '../../ui/button/button';\n\nexport interface RoadmapVoteButtonProps {\n  voteType: 'up' | 'down';\n  count: number;\n  isActive: boolean;\n  onClick: () => void;\n  disabled?: boolean;\n  showCount?: boolean;\n  color?: string;\n  className?: string;\n}\n\nexport function RoadmapVoteButton({\n  voteType,\n  count,\n  isActive,\n  onClick,\n  disabled = false,\n  showCount = true,\n  color,\n  className,\n}: RoadmapVoteButtonProps) {\n  const Icon = voteType === 'up' ? ThumbsUpIcon : ThumbsDownIcon;\n  return (\n    <Button\n      variant=\"outline\"\n      onClick={onClick}\n      disabled={disabled}\n      leftIcon={<Icon className=\"h-5 w-5\" color={color} />}\n      className={cn(\n        className,\n        'flex h-full items-center justify-center gap-[2px] border-0 border-ods-border bg-ods-bg p-[12px]',\n        'disabled:cursor-not-allowed disabled:opacity-50',\n        'transition-all hover:bg-ods-border',\n        isActive && 'bg-ods-border',\n      )}\n    >\n      {showCount && <span className=\"text-h6\">{count}</span>}\n    </Button>\n  );\n}\n","'use client';\n\n/**\n * Per-type icon for a ClickUp task — driven by `custom_item_id` via the\n * canonical `CUSTOM_ITEM_ID` map in `../utils/clickup-task-type-utils`.\n *\n * Used by the roadmap entity card so the icon slot communicates the\n * task TYPE (Bug, Feature, Story, …) at a glance instead of falling\n * back to a generic logo / two-letter placeholder.\n */\n\nimport {\n  Bug,\n  Inbox,\n  Sparkles,\n  BookOpen,\n  Trophy,\n  Component,\n  Flag,\n  Goal,\n  ListTodo,\n  CheckSquare,\n  FileText,\n  Repeat,\n  Compass,\n  type LucideIcon,\n} from 'lucide-react';\nimport { CUSTOM_ITEM_ID } from '../utils/clickup-task-type-utils';\n\nfunction iconFor(customItemId: number | null | undefined): LucideIcon {\n  switch (customItemId) {\n    case CUSTOM_ITEM_ID.BUG:\n      return Bug;\n    case CUSTOM_ITEM_ID.REQUEST:\n      return Inbox;\n    case CUSTOM_ITEM_ID.FEATURE:\n      return Sparkles;\n    case CUSTOM_ITEM_ID.STORY:\n      return BookOpen;\n    case CUSTOM_ITEM_ID.EPIC:\n      return Trophy;\n    case CUSTOM_ITEM_ID.COMPONENT:\n      return Component;\n    case CUSTOM_ITEM_ID.INITIATIVE:\n      return Flag;\n    case CUSTOM_ITEM_ID.MILESTONE:\n      return Goal;\n    case CUSTOM_ITEM_ID.SUBTASK:\n      return ListTodo;\n    case CUSTOM_ITEM_ID.FORM:\n      return FileText;\n    case CUSTOM_ITEM_ID.RECURRING:\n      return Repeat;\n    case CUSTOM_ITEM_ID.PLAN:\n      return Compass;\n    case CUSTOM_ITEM_ID.STRATEGY:\n      return Compass;\n    case CUSTOM_ITEM_ID.TASK:\n    default:\n      return CheckSquare;\n  }\n}\n\nexport interface TaskTypeIconProps {\n  customItemId: number | null | undefined;\n  className?: string;\n}\n\nexport function TaskTypeIcon({ customItemId, className = 'h-5 w-5' }: TaskTypeIconProps) {\n  const Icon = iconFor(customItemId);\n  return <Icon className={className} aria-hidden=\"true\" />;\n}\n","'use client';\n\n/**\n * RoadmapCard (pure presentation). Two densities — `default` (rich\n * /roadmap page card with vote buttons + figma/screenshots controls)\n * and `sm` (compact horizontal for chat-inline).\n *\n * The card writes NO click logic — the parent wraps with its own\n * anchor for the compact branch and supplies vote handlers for the\n * default branch.\n */\n\nimport { ThumbsUp, ThumbsDown } from 'lucide-react';\nimport { useState } from 'react';\nimport Image from '../../../embed-shims/next-image';\nimport { getProxiedImageUrl } from '../../../utils/image-proxy';\nimport { FigmaIcon } from '../../icons/figma-icon';\nimport { ImageIcon } from '../../icons/image-icon';\nimport { AvatarStack, type AvatarStackPerson } from '../../ui/avatar-stack';\nimport { Button } from '../../ui/button/button';\nimport { ImageGalleryModal } from '../../ui/image-gallery-modal';\nimport { StatusBadge, generationTierFromLabel } from '../../ui/status-badge';\nimport type { RoadmapItem } from '../types/entities/roadmap-item';\nimport { getStatusColorScheme } from '../utils/agent-status-message';\nimport { getTaskTypeLabel } from '../utils/clickup-task-type-utils';\nimport {\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_OUTER_STATIC,\n  COMPACT_CARD_ROW_FILLER,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_SUMMARY,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  COMPACT_CARD_TITLE_ROW,\n  safeHref,\n} from '../utils/compact-card-classes';\nimport { RoadmapVoteButton } from './roadmap-vote-button';\nimport { TaskTypeIcon } from './task-type-icon';\n\ntype CardSize = 'default' | 'sm';\nexport type VoteType = 'up' | 'down' | null;\n\nexport function RoadmapCardSkeleton({ size = 'default' }: { size?: CardSize }) {\n  if (size === 'sm') {\n    return (\n      <span className={COMPACT_CARD_SKELETON_OUTER}>\n        <span className=\"block aspect-square h-12 w-12 shrink-0 self-start rounded-md border border-ods-border bg-ods-bg p-1.5\" />\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={`${COMPACT_CARD_TITLE_ROW} flex-nowrap gap-2`}>\n            <span className=\"h-3.5 w-1/2 rounded bg-ods-bg\" />\n            <span className=\"h-4 w-12 shrink-0 rounded bg-ods-bg/70\" />\n          </span>\n          <span className={`${COMPACT_CARD_META_ROW_BOX} flex-nowrap gap-2`}>\n            <span className=\"h-3 w-2/5 flex-1 rounded bg-ods-bg/60\" />\n            <span className=\"h-3 w-10 shrink-0 rounded bg-ods-bg/40\" />\n            {/* assignee-stack placeholder — mirrors the xs AvatarStack\n                slot so row width doesn't shift when assignees load */}\n            <span className=\"h-6 w-14 shrink-0 rounded-full bg-ods-bg/40\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-5/6 rounded bg-ods-bg/40\" />\n          </span>\n        </span>\n      </span>\n    );\n  }\n  return (\n    <div className=\"flex h-full animate-pulse flex-col gap-4 rounded-[6px] border border-ods-border bg-ods-card p-[24px]\">\n      <div className=\"flex items-center gap-4\">\n        <div className=\"h-16 w-16 flex-shrink-0 rounded-lg bg-ods-bg\" />\n        <div className=\"flex-1 space-y-2\">\n          <div className=\"h-5 w-3/4 rounded bg-ods-bg\" />\n          <div className=\"h-3 w-1/2 rounded bg-ods-bg/60\" />\n        </div>\n        <div className=\"h-6 w-16 rounded bg-ods-bg\" />\n      </div>\n      <div className=\"space-y-2\">\n        <div className=\"h-3 w-full rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-5/6 rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-4/5 rounded bg-ods-bg/60\" />\n      </div>\n      <div className=\"flex-1\" />\n      <div className=\"flex items-center justify-between\">\n        <div className=\"h-12 w-32 rounded bg-ods-bg\" />\n        <div className=\"flex items-center gap-2\">\n          {/* assignee-stack placeholder — matches the xs AvatarStack in\n              the loaded card's action row */}\n          <div className=\"h-6 w-14 rounded-full bg-ods-bg/60\" />\n          <div className=\"h-8 w-20 rounded bg-ods-bg\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport interface RoadmapCardProps {\n  item: RoadmapItem;\n  /** Detail URL for the compact branch (`sm`). Used by the parent\n   *  wrapper to drive nav. Default-branch cards don't need href —\n   *  voting + screenshot UI is the entire action surface. */\n  href?: string;\n  /** When `_blank`, opens in a new tab. Set by chat dispatch via\n   *  `computeIsNewTab`. Defaults to same-tab. */\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  targetPlatform?: string | null;\n  /** Compact-branch variant — drives the icon-slot fallback rule. */\n  cardType?: 'roadmap_item' | 'delivery_item' | 'internal_task';\n  size?: CardSize;\n  className?: string;\n  /** DOM `id` applied to the card's outer element. `RoadmapGrid` sets\n   *  `roadmap-<external_id>` so chat-card deep-links\n   *  (`?search=<id>#roadmap-<id>`) have a target for `useScrollToHash`\n   *  to scroll to. `scroll-mt-24` on the outer element keeps the card\n   *  BELOW the sticky chrome. */\n  id?: string;\n  // Default-branch vote controls (ignored in `sm`):\n  userVote?: VoteType | null;\n  onVote?: (voteType: 'up' | 'down') => void;\n  isVoting?: boolean;\n}\n\n/** Wire assignees → AvatarStack people (name+avatar only on the wire). */\nfunction assigneePeople(\n  assignees: Array<{ id: number; name: string | null; avatarUrl: string | null }> | undefined,\n): AvatarStackPerson[] {\n  return (assignees ?? []).map(a => ({\n    key: a.id,\n    name: a.name ?? 'Unknown',\n    avatarUrl: a.avatarUrl,\n  }));\n}\n\nexport function RoadmapCard({\n  item,\n  href,\n\n  target,\n\n  rel,\n  size = 'default',\n  cardType = 'roadmap_item',\n  className,\n  id,\n  userVote,\n  onVote,\n  isVoting = false,\n}: RoadmapCardProps) {\n  const [showScreenshots, setShowScreenshots] = useState(false);\n\n  const logoUrl = item.icon && item.icon.startsWith('http') ? item.icon : null;\n  const iconSrc = logoUrl ? getProxiedImageUrl(logoUrl, { directHttps: true }) || logoUrl : null;\n\n  if (size === 'sm') {\n    const compactHref = safeHref(href ?? null);\n    const hasVotes = (item.upvotes ?? 0) > 0 || (item.downvotes ?? 0) > 0;\n    const hasFigma = !!item.figmaUrl;\n    const hasScreenshots = (item.screenshots?.length ?? 0) > 0;\n    const typeLabel = getTaskTypeLabel(item.customItemId);\n    const useTypeIcon = cardType === 'internal_task' || (!iconSrc && item.customItemId != null);\n    const body = (\n      <>\n        <span\n          className=\"flex aspect-square h-12 w-12 shrink-0 items-center justify-center self-start overflow-hidden rounded-md border border-ods-border bg-ods-bg p-1.5 text-ods-accent\"\n          title={typeLabel ?? undefined}\n        >\n          {useTypeIcon ? (\n            <TaskTypeIcon customItemId={item.customItemId} className=\"h-6 w-6\" />\n          ) : iconSrc ? (\n            <Image\n              src={iconSrc}\n              alt={`${item.title} logo`}\n              width={32}\n              height={32}\n              unoptimized\n              className=\"h-8 w-8 object-contain\"\n            />\n          ) : (\n            <span className=\"uppercase text-ods-text-secondary text-h6\">{item.title?.substring(0, 2) || '??'}</span>\n          )}\n        </span>\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={`${COMPACT_CARD_TITLE_ROW} gap-2`}>\n            <span className={`${COMPACT_CARD_TITLE} min-w-0`}>{item.title}</span>\n            {item.status && item.status.trim().length > 0 ? (\n              <StatusBadge\n                text={item.status.toUpperCase()}\n                colorScheme={getStatusColorScheme(item.status)}\n                variant=\"button\"\n                singleLine\n                className=\"max-w-[60%] shrink-0 truncate whitespace-nowrap border border-ods-border\"\n              />\n            ) : null}\n          </span>\n          <span className={`${COMPACT_CARD_META_ROW_BOX} gap-2 text-ods-text-secondary`}>\n            <span className=\"min-w-0 flex-1 truncate text-h6\">\n              {(() => {\n                const parts = [item.quarter, item.targetVersion ? `${item.targetVersion} version` : null].filter(\n                  Boolean,\n                );\n                if (parts.length > 0) return parts.join(' · ');\n                if (cardType === 'delivery_item') {\n                  return typeLabel ? `Delivery · ${typeLabel}` : 'Delivery';\n                }\n                if (cardType === 'internal_task') {\n                  return typeLabel ?? 'Internal task';\n                }\n                return 'Roadmap item';\n              })()}\n            </span>\n            {hasVotes ? (\n              <span className=\"hidden shrink-0 items-center gap-2 text-ods-text-secondary text-h6 sm:flex\">\n                <span className=\"flex items-center gap-0.5\">\n                  <ThumbsUp className=\"h-3 w-3\" />\n                  <span>{item.upvotes ?? 0}</span>\n                </span>\n                <span className=\"flex items-center gap-0.5\">\n                  <ThumbsDown className=\"h-3 w-3\" />\n                  <span>{item.downvotes ?? 0}</span>\n                </span>\n              </span>\n            ) : null}\n            {hasFigma ? (\n              <span className=\"hidden shrink-0 items-center sm:flex\" title=\"Has Figma prototype\">\n                <FigmaIcon className=\"h-3 w-3\" />\n              </span>\n            ) : null}\n            {hasScreenshots ? (\n              <span\n                className=\"hidden shrink-0 items-center gap-0.5 text-h6 sm:flex\"\n                title={`${item.screenshots.length} screenshot${item.screenshots.length === 1 ? '' : 's'}`}\n              >\n                <ImageIcon className=\"h-3 w-3\" />\n                <span>{item.screenshots.length}</span>\n              </span>\n            ) : null}\n            {item.assignees && item.assignees.length > 0 ? (\n              <AvatarStack size=\"xs\" people={assigneePeople(item.assignees)} className=\"shrink-0\" />\n            ) : null}\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_SUMMARY}>{item.description || COMPACT_CARD_ROW_FILLER}</span>\n          </span>\n        </span>\n      </>\n    );\n    if (!compactHref) {\n      return (\n        <span className={`${COMPACT_CARD_OUTER_STATIC} ${className ?? ''}`} aria-label=\"No link available\">\n          {body}\n        </span>\n      );\n    }\n    return (\n      <a href={compactHref} target={target} rel={rel} className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n        {body}\n      </a>\n    );\n  }\n\n  return (\n    <div\n      id={id}\n      className={`flex h-full scroll-mt-24 flex-col gap-[16px] rounded-[6px] border border-ods-border bg-ods-card p-[24px] transition-all hover:border-ods-accent ${className ?? ''}`}\n    >\n      <div className=\"flex w-full items-center gap-[16px]\">\n        <div className=\"flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-lg border border-ods-border bg-ods-bg\">\n          {iconSrc ? (\n            <Image\n              src={iconSrc}\n              alt={`${item.title} logo`}\n              width={40}\n              height={40}\n              unoptimized\n              className=\"object-contain p-1\"\n            />\n          ) : (\n            <span className=\"text-xs font-medium uppercase text-ods-text-secondary\">\n              {item.title?.substring(0, 2) || '??'}\n            </span>\n          )}\n        </div>\n\n        <div className=\"flex min-w-0 flex-1 flex-col\">\n          <div className=\"flex min-h-[48px] items-center\">\n            <h3 className=\"line-clamp-2 flex-1 text-ods-text-primary text-h3\">{item.title}</h3>\n          </div>\n          <div className=\"flex min-h-[20px] items-center\">\n            <p className=\"truncate text-ods-text-secondary text-h5\">\n              {item.quarter}, {item.id}\n            </p>\n          </div>\n        </div>\n\n        <div className=\"hidden md:block\">\n          <StatusBadge\n            text={item.status.toUpperCase()}\n            colorScheme={getStatusColorScheme(item.status)}\n            className=\"border border-ods-border\"\n          />\n        </div>\n      </div>\n\n      <div className=\"md:hidden\">\n        <StatusBadge\n          text={item.status.toUpperCase()}\n          colorScheme={getStatusColorScheme(item.status)}\n          className=\"border border-ods-border\"\n        />\n      </div>\n\n      <div className=\"flex min-h-[72px] items-center\">\n        <p className=\"line-clamp-3 text-ods-text-secondary text-h4\">{item.description || ''}</p>\n      </div>\n\n      <div className=\"flex-1\" />\n\n      <div className=\"flex w-full items-center justify-between\">\n        {onVote && (\n          <div className=\"flex h-[48px] overflow-hidden rounded-[6px] border border-ods-border bg-ods-card\">\n            <RoadmapVoteButton\n              voteType=\"up\"\n              count={item.upvotes}\n              color=\"var(--color-text-secondary)\"\n              className=\"rounded-none\"\n              isActive={userVote === 'up'}\n              onClick={() => onVote('up')}\n              disabled={isVoting}\n            />\n            <RoadmapVoteButton\n              voteType=\"down\"\n              count={item.downvotes}\n              className=\"rounded-none\"\n              color=\"var(--color-text-secondary)\"\n              isActive={userVote === 'down'}\n              onClick={() => onVote('down')}\n              disabled={isVoting}\n            />\n          </div>\n        )}\n\n        <div className=\"flex min-w-0 items-center gap-2\">\n          {item.assignees && item.assignees.length > 0 ? (\n            <AvatarStack size=\"xs\" people={assigneePeople(item.assignees)} className=\"shrink-0\" />\n          ) : null}\n          {item.screenshots && item.screenshots.length > 0 && (\n            <Button\n              variant=\"outline\"\n              size=\"small-legacy\"\n              onClick={() => setShowScreenshots(true)}\n              leftIcon={<ImageIcon className=\"h-5 w-5\" />}\n            />\n          )}\n          {(() => {\n            const figmaSafe = safeHref(item.figmaUrl);\n            return figmaSafe ? (\n              <Button\n                variant=\"outline\"\n                size=\"small-legacy\"\n                openInNewTab\n                href={figmaSafe}\n                leftIcon={<FigmaIcon className=\"h-5 w-5\" />}\n              />\n            ) : null;\n          })()}\n          {item.targetVersion && (\n            <StatusBadge gen={generationTierFromLabel(item.targetVersion)} text={item.targetVersion} />\n          )}\n        </div>\n      </div>\n\n      {item.screenshots && item.screenshots.length > 0 && (\n        <ImageGalleryModal\n          images={item.screenshots}\n          isOpen={showScreenshots}\n          onClose={() => setShowScreenshots(false)}\n        />\n      )}\n    </div>\n  );\n}\n","/**\n * Media type — THE single source of truth for the `media_type` discriminator\n * on media rows (ai_media, program media, vendor media). Zero imports (a leaf),\n * so every consumer reads the same predicate instead of scattering\n * `=== 'video'` literals. `MediaType` (types/marketing.ts) is the row union;\n * these are the two values a post can publish.\n */\n\nexport const MEDIA_TYPE = {\n  IMAGE: 'image',\n  VIDEO: 'video',\n} as const;\n\n/** The two media types a post can publish. */\nexport type PublishableMediaType = (typeof MEDIA_TYPE)[keyof typeof MEDIA_TYPE];\n\ninterface MediaTyped {\n  media_type?: string | null;\n}\n\nexport function isVideoMedia(media: MediaTyped | null | undefined): boolean {\n  return media?.media_type === MEDIA_TYPE.VIDEO;\n}\n\nexport function isImageMedia(media: MediaTyped | null | undefined): boolean {\n  return media?.media_type === MEDIA_TYPE.IMAGE;\n}\n\nexport function isPublishableMediaType(mediaType: string | null | undefined): mediaType is PublishableMediaType {\n  return mediaType === MEDIA_TYPE.VIDEO || mediaType === MEDIA_TYPE.IMAGE;\n}\n\n/** Split a media list into its videos and images, preserving order. */\nexport function partitionMediaByType<T extends MediaTyped>(media: readonly T[]): { videos: T[]; images: T[] } {\n  return {\n    videos: media.filter(m => isVideoMedia(m)),\n    images: media.filter(m => isImageMedia(m)),\n  };\n}\n","/**\n * Unified Program System Types\n *\n * Base interfaces and types for the Unified Program System that handles\n * events, podcasts, and webinars through a single set of generic components.\n *\n * Design Goals:\n *   1. Maximum Reuse: Single set of components works for all program types\n *   2. Type Safety: TypeScript generics ensure correct data shapes\n *   3. Configuration-Driven: Program types defined via configuration objects\n *   4. Admin Consistency: Same patterns work for public pages and admin CRUD\n *\n * Lifted from `lib/types/program-types.ts` in the hub. The pure transform\n * helpers (`transformEventToProgram`, `transformPodcastToProgram`,\n * `transformWebinarToProgram`) also move here — they're pure shape\n * mappers with no DB / state access.\n */\n\nimport type { ReactNode } from 'react';\nimport { toStripProfile, type VideoBiteStripProfile } from '../../../features/video-bites-shared';\n\n// ============================================================================\n// HOST TYPES\n// ============================================================================\n\n/**\n * Host/Speaker information for programs\n */\nexport interface ProgramHost {\n  id?: string;\n  name: string;\n  email?: string;\n  avatar_url?: string;\n  bio?: string;\n  role?: string; // e.g., \"Host\", \"Speaker\", \"Guest\"\n}\n\n/**\n * Client-safe author subset hydrated onto program items from the entity's\n * `author_id → profiles` FK join (podcast episodes / webinars). Deliberately\n * excludes `email` — this shape is serialized into public payloads.\n */\nexport interface ProgramAuthorRef {\n  id?: string | null;\n  full_name?: string | null;\n  avatar_url?: string | null;\n  job_title?: string | null;\n}\n\n/**\n * Video-bites overlay profile for a program item: internal author first\n * (`author_id → profiles`), then the provider-synced primary host (Livestorm\n * speakers / Podbean hosts). Host `role` is a raw provider enum (e.g.\n * `team_member`) — never display copy, so it is not used as the subtitle.\n * Routes through `toStripProfile` (the SSoT — null on missing names).\n */\nexport function programItemToStripProfile(item: {\n  author?: ProgramAuthorRef | null;\n  hosts?: ProgramHost[] | null;\n}): VideoBiteStripProfile | null {\n  const fromAuthor = toStripProfile(item.author);\n  if (fromAuthor) return fromAuthor;\n  const host = Array.isArray(item.hosts) ? item.hosts[0] : null;\n  if (!host?.name) return null;\n  return toStripProfile({ full_name: host.name, avatar_url: host.avatar_url ?? null });\n}\n\n// ============================================================================\n// DATABASE ROW TYPES\n// ============================================================================\n\n/**\n * Raw database row from luma_events table\n * Note: Many fields are optional to support partial queries\n * where not all columns are selected. The hosts field uses\n * unknown to accommodate JSON parsing from the database.\n */\nexport interface LumaEventRow {\n  id: string;\n  name: string;\n  description?: string | null;\n  cover_url?: string | null;\n  start_at: string;\n  end_at: string;\n  timezone?: string | null;\n  location_name?: string | null;\n  location_full_address?: string | null;\n  geo_latitude?: number | null;\n  geo_longitude?: number | null;\n  meeting_url?: string | null;\n  event_url?: string | null;\n  guest_count?: number | null;\n  visibility?: string | null;\n  hosts?: ProgramHost[] | null;\n  platform_id: string;\n  luma_api_id: string;\n  is_deleted?: boolean;\n}\n\n/**\n * Raw database row from podcast_episodes table\n * Note: Many fields are optional to support partial queries\n * where not all columns are selected.\n */\nexport interface PodcastEpisodeRow {\n  id: string;\n  title: string;\n  description?: string | null;\n  cover_url?: string | null;\n  published_at?: string | null;\n  created_at: string;\n  external_url?: string | null;\n  hosts?: ProgramHost[] | null;\n  platform_id: string;\n  podbean_episode_id: string;\n  podbean_podcast_id: string;\n  audio_url?: string | null;\n  /** Podbean-supplied source video URL (was `video_url` pre-rename). */\n  main_video_url?: string | null;\n  media_type?: 'audio' | 'video' | null;\n  duration_seconds?: number | null;\n  status?: string | null;\n  is_deleted?: boolean;\n  // Override fields (not synced by job)\n  cover_image_override?: string | null;\n  custom_video_url?: string | null;\n}\n\n/**\n * Raw database row from webinars table\n * Note: Many fields are optional to support partial queries\n * where not all columns are selected.\n */\nexport interface WebinarRow {\n  id: string;\n  title: string;\n  description?: string | null;\n  cover_url?: string | null;\n  start_at: string;\n  end_at?: string | null;\n  timezone?: string | null;\n  registration_url?: string | null;\n  hosts?: ProgramHost[] | null;\n  platform_id: string;\n  livestorm_event_id: string;\n  is_deleted?: boolean;\n  /** Livestorm-supplied recording URL (was `recording_url` pre-rename). */\n  main_video_url?: string | null;\n  // Override fields (not synced by job)\n  custom_video_url?: string | null;\n  display_date_override?: string | null;\n  cover_image_override?: string | null;\n}\n\n// ============================================================================\n// BASE PROGRAM ITEM\n// ============================================================================\n\n/**\n * Base interface that all program items must implement\n * This enables the generic components to work across events, podcasts, and webinars\n */\nexport interface BaseProgramItem {\n  id: string;\n  title: string; // Maps to: name (events), title (podcasts/webinars)\n  description: string | null;\n  cover_url: string | null; // Primary image\n  date: string; // ISO date string - maps to: start_at, published_at\n  external_url?: string | null; // Registration/play link\n  hosts?: ProgramHost[] | null; // Speakers/hosts\n  // Platform tagging lives in the host app's `entity_platforms` array (exposed\n  // under the per-entity arrayKey, e.g. `podcast_platforms`), NOT a derived\n  // singular `platform_id` on the item. ProgramCard routes via the `href` /\n  // `targetPlatform` props the caller computes, so it needs no platform field here.\n}\n\n// ============================================================================\n// PROGRAM-SPECIFIC INTERFACES\n// ============================================================================\n\n/**\n * Event-specific fields (from luma_events table)\n */\nexport interface EventItem extends BaseProgramItem {\n  luma_api_id: string;\n  end_at: string;\n  timezone?: string | null;\n  location_name?: string | null;\n  location_full_address?: string | null;\n  geo_latitude?: number | null;\n  geo_longitude?: number | null;\n  meeting_url?: string | null;\n  event_url?: string | null;\n  guest_count?: number | null;\n  visibility?: string | null;\n  is_deleted: boolean;\n}\n\n/**\n * Podcast episode fields (from podcast_episodes table)\n * Updated for Podbean video podcast support\n */\nexport interface PodcastItem extends BaseProgramItem {\n  author_id?: string | null;\n  podbean_episode_id: string;\n  podbean_podcast_id: string;\n  audio_url?: string | null;\n  /** Podbean-supplied source video URL (was `video_url` pre-rename). */\n  main_video_url?: string | null;\n  media_type: 'audio' | 'video';\n  duration_seconds?: number | null;\n  status: string;\n  published_at: string | null;\n  is_deleted: boolean;\n  podcast_url?: string | null; // Main podcast page URL from Podbean\n  // Override fields (not synced by job)\n  cover_image_override?: string | null;\n  custom_video_url?: string | null;\n  // Video processing fields\n  transcript?: string | null;\n  transcript_words_data?: unknown;\n  srt_content?: string | null;\n  video_summary?: string | null;\n  video_bites?: unknown[];\n  highlight_video_url?: string | null;\n  highlight_video_source?: string | null;\n  highlight_video_thumbnail?: string | null;\n  highlight_video_duration_ms?: number | null;\n  main_video_thumbnail?: string | null;\n  config?: Record<string, unknown>;\n  custom_instructions?: string | null;\n  seo_title?: string | null;\n  seo_description?: string | null;\n  seo_keywords?: string | null;\n  ai_confidence_video_summary?: number | null;\n  ai_confidence_transcript?: number | null;\n}\n\n/**\n * Webinar fields (from webinars table)\n */\nexport interface WebinarItem extends BaseProgramItem {\n  author_id?: string | null;\n  livestorm_event_id: string;\n  start_at: string;\n  end_at?: string | null;\n  timezone?: string | null;\n  registration_url?: string | null;\n  is_deleted: boolean;\n  /** Livestorm-supplied recording URL (was `recording_url` pre-rename). */\n  main_video_url?: string | null;\n  // Override fields (not synced by job)\n  custom_video_url?: string | null;\n  display_date_override?: string | null;\n  cover_image_override?: string | null;\n  // Video processing fields\n  transcript?: string | null;\n  transcript_words_data?: unknown;\n  srt_content?: string | null;\n  video_summary?: string | null;\n  video_bites?: unknown[];\n  highlight_video_url?: string | null;\n  highlight_video_source?: string | null;\n  highlight_video_thumbnail?: string | null;\n  highlight_video_duration_ms?: number | null;\n  main_video_thumbnail?: string | null;\n  config?: Record<string, unknown>;\n  custom_instructions?: string | null;\n  seo_title?: string | null;\n  seo_description?: string | null;\n  seo_keywords?: string | null;\n  ai_confidence_video_summary?: number | null;\n  ai_confidence_transcript?: number | null;\n}\n\n// ============================================================================\n// PROGRAM CONFIGURATION\n// ============================================================================\n\n/**\n * Program type identifier\n */\nexport type ProgramType = 'event' | 'podcast' | 'webinar';\n\n/**\n * Labels configuration for a program type\n */\nexport interface ProgramLabels {\n  singular: string; // \"Event\", \"Episode\", \"Webinar\"\n  plural: string; // \"Events\", \"Episodes\", \"Webinars\"\n  upcoming: string; // \"Next Event\", \"Latest Episode\", \"Upcoming Webinar\"\n  upcomingSection: string; // \"Upcoming Events\", \"Upcoming Episodes\", \"Upcoming Webinars\"\n  archive: string; // \"Past Events\", \"All Episodes\", \"Past Webinars\"\n  empty: string; // \"No events yet\", \"No episodes yet\", \"No webinars yet\"\n}\n\n/**\n * Configuration object that defines how a program type behaves\n * This is the core abstraction that enables generic components\n */\nexport interface ProgramConfig<T extends BaseProgramItem = BaseProgramItem> {\n  type: ProgramType;\n  labels: ProgramLabels;\n  dateField: keyof T; // Which field to sort/filter by\n  table: string; // Database table name\n  apiEndpoint: string; // Base API path\n  icon: ReactNode; // Section icon (CalendarDays, Mic, Video)\n  platformIcon?: ReactNode; // External platform icon (optional)\n  externalLinkLabel: string; // \"View on Luma\", \"Listen on Podbean\", \"Watch on Livestorm\"\n  detailRoute: string; // \"/events\", \"/podcasts\", \"/webinars\"\n\n  // Optional customization\n  sortOrder?: 'asc' | 'desc'; // Default sort order for archive\n  showInNav?: boolean; // Whether to show in navigation\n  platformUrl?: string; // Fallback URL to external platform (e.g., Podbean main page)\n  heroCoverAspect?: 'landscape' | 'square'; // Hero cover image aspect ratio (default: 'landscape')\n}\n\n// ============================================================================\n// GENERIC DATA TYPES\n// ============================================================================\n\n/**\n * Generic response for program list queries\n */\nexport interface ProgramListResponse<T extends BaseProgramItem> {\n  items: T[];\n  count: number;\n  hasMore?: boolean;\n}\n\n/**\n * Generic response for single program queries\n */\nexport interface ProgramItemResponse<T extends BaseProgramItem> {\n  item: T;\n  media?: ProgramMedia[];\n}\n\n/**\n * Program media (photos, videos, etc.)\n */\nexport interface ProgramMedia {\n  id: string;\n  program_id: string;\n  media_type: 'image' | 'video' | 'audio';\n  media_url: string;\n  title?: string | null;\n  description?: string | null;\n  display_order: number;\n  created_at: string;\n}\n\n// ============================================================================\n// FILTER AND QUERY TYPES\n// ============================================================================\n\n/**\n * Filter options for program queries\n */\nexport interface ProgramFilter {\n  filter: 'upcoming' | 'past' | 'all';\n  limit?: number;\n  offset?: number;\n  search?: string;\n}\n\n/**\n * Admin query options\n */\nexport interface AdminProgramFilter extends ProgramFilter {\n  includeDeleted?: boolean;\n  sortBy?: string;\n  sortOrder?: 'asc' | 'desc';\n}\n\n// ============================================================================\n// TRANSFORM UTILITIES\n// ============================================================================\n\n/**\n * Transform a database event row to EventItem\n */\nexport function transformEventToProgram(event: LumaEventRow): EventItem {\n  return {\n    id: event.id,\n    title: event.name,\n    description: event.description ?? null,\n    cover_url: event.cover_url ?? null,\n    date: event.start_at,\n    external_url: event.event_url ?? null,\n    hosts: event.hosts ?? null,\n    luma_api_id: event.luma_api_id,\n    end_at: event.end_at,\n    timezone: event.timezone ?? null,\n    location_name: event.location_name ?? null,\n    location_full_address: event.location_full_address ?? null,\n    geo_latitude: event.geo_latitude ?? null,\n    geo_longitude: event.geo_longitude ?? null,\n    meeting_url: event.meeting_url ?? null,\n    event_url: event.event_url ?? null,\n    guest_count: event.guest_count ?? null,\n    visibility: event.visibility ?? null,\n    is_deleted: event.is_deleted ?? false,\n  };\n}\n\n/**\n * Transform a database podcast row to PodcastItem\n */\nexport function transformPodcastToProgram(episode: PodcastEpisodeRow): PodcastItem {\n  return {\n    id: episode.id,\n    title: episode.title,\n    description: episode.description ?? null,\n    // Use cover_image_override if set, otherwise use cover_url\n    cover_url: episode.cover_image_override ?? episode.cover_url ?? null,\n    date: episode.published_at ?? episode.created_at,\n    external_url: episode.external_url ?? null,\n    hosts: episode.hosts ?? null,\n    podbean_episode_id: episode.podbean_episode_id,\n    podbean_podcast_id: episode.podbean_podcast_id,\n    audio_url: episode.audio_url ?? null,\n    main_video_url: episode.main_video_url ?? null,\n    media_type: episode.media_type ?? 'audio',\n    duration_seconds: episode.duration_seconds ?? null,\n    status: episode.status ?? 'published',\n    published_at: episode.published_at ?? null,\n    is_deleted: episode.is_deleted ?? false,\n    // Override fields\n    cover_image_override: episode.cover_image_override ?? null,\n    custom_video_url: episode.custom_video_url ?? null,\n  };\n}\n\n/**\n * Transform a database webinar row to WebinarItem\n * Uses display_date_override for date field when available (for backdating)\n * Uses cover_image_override for cover_url when available\n */\nexport function transformWebinarToProgram(webinar: WebinarRow): WebinarItem {\n  return {\n    id: webinar.id,\n    title: webinar.title,\n    description: webinar.description ?? null,\n    // Use cover_image_override if set, otherwise use cover_url\n    cover_url: webinar.cover_image_override ?? webinar.cover_url ?? null,\n    // Use display_date_override if set, otherwise use start_at\n    date: webinar.display_date_override ?? webinar.start_at,\n    external_url: webinar.registration_url ?? null,\n    hosts: webinar.hosts ?? null,\n    livestorm_event_id: webinar.livestorm_event_id,\n    start_at: webinar.start_at,\n    end_at: webinar.end_at ?? null,\n    timezone: webinar.timezone ?? null,\n    registration_url: webinar.registration_url ?? null,\n    is_deleted: webinar.is_deleted ?? false,\n    main_video_url: webinar.main_video_url ?? null,\n    // Override fields\n    custom_video_url: webinar.custom_video_url ?? null,\n    display_date_override: webinar.display_date_override ?? null,\n    cover_image_override: webinar.cover_image_override ?? null,\n  };\n}\n","'use client';\n\n/**\n * ProgramCard (pure presentation). Generic card for podcasts / webinars /\n * events. Three densities — `default` (wide horizontal detail, archive\n * pages), `sm` (compact horizontal for chat-inline), and `portrait`\n * (vertical rail/strip card).\n *\n * `portrait` exists because mixed-content rails MUST share ONE card anatomy\n * (2026 card-UI practice: a rail mixes content types, never card layouts —\n * mixing orientations/aspects in one scroller is the anti-pattern). It\n * renders the SAME three zones as the other portrait entity cards\n * (CaseStudyCard et al.): wide media slot → fixed 72px title zone → fixed\n * 60px person/meta footer, p-6 / gap-6. The program's identity lives in the\n * Azeret Mono title and the date · duration meta line — not in a different\n * layout.\n *\n * The card writes NO click logic — callers wrap with their own anchor\n * and pass the resolved detail URL via `href`.\n */\n\nimport { format } from 'date-fns';\nimport { ExternalLink, Clock, Play, Video } from 'lucide-react';\nimport type React from 'react';\nimport { useState } from 'react';\nimport Image from '../../../embed-shims/next-image';\nimport { cn } from '../../../utils/cn';\nimport { formatDurationCompact, formatTimeWithTimezone, formatDurationFromRange } from '../../../utils/format';\nimport { isImageMedia } from '../../../utils/media-type';\nimport { Button } from '../../ui/button/button';\nimport { ImageGalleryModal } from '../../ui/image-gallery-modal';\nimport { SquareAvatar } from '../../ui/square-avatar';\nimport {\n  programItemToStripProfile,\n  type BaseProgramItem,\n  type ProgramConfig,\n  type ProgramMedia,\n  type ProgramHost,\n} from '../types/entities/program-types';\nimport {\n  COMPACT_CARD_IMAGE_SLOT,\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_ROW_FILLER,\n  COMPACT_CARD_SKELETON_IMAGE_SLOT,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_SUMMARY,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  COMPACT_CARD_TITLE_ROW,\n} from '../utils/compact-card-classes';\nimport { EntityPortraitCard } from './entity-portrait-card';\nimport { useEntityCardLink } from './use-entity-card-link';\nimport { useEntityCardPlaceholder } from './use-entity-card-placeholder';\ntype CardSize = 'default' | 'sm' | 'portrait';\n\n/**\n * Format a Date with date-fns pinned to UTC. `date-fns` `format()` reads the\n * runtime's LOCAL wall-clock, so the same instant renders differently on the\n * server (Vercel = UTC) and the client (visitor tz) → React #418 hydration\n * mismatch. Shifting by the local offset before formatting emits the UTC\n * wall-clock on every machine, so server and client agree. Mirrors the helper\n * in the hub's `program-header.tsx` (kept local — the lib has no date-fns-tz\n * dep) and the repo-wide \"pin program dates to UTC\" convention.\n */\nfunction formatUtc(date: Date, fmt: string): string {\n  // A row can legitimately arrive without a date (the field is not guaranteed by\n  // the wire type), and `new Date(undefined)` / `new Date('')` both give an\n  // Invalid Date, which makes date-fns `format()` throw RangeError. Thrown from\n  // render that takes down the whole card rail, not just this card — so the\n  // helper is total and an unknown date renders as nothing.\n  if (Number.isNaN(date.getTime())) return '';\n  return format(new Date(date.getTime() + date.getTimezoneOffset() * 60_000), fmt);\n}\n\nexport function ProgramCardSkeleton({ size = 'default' }: { size?: CardSize }) {\n  if (size === 'sm') {\n    return (\n      <span className={COMPACT_CARD_SKELETON_OUTER}>\n        <span className={COMPACT_CARD_SKELETON_IMAGE_SLOT} />\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={COMPACT_CARD_TITLE_ROW}>\n            <span className=\"h-3.5 w-3/5 rounded bg-ods-bg\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-2/5 rounded bg-ods-bg/70\" />\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"h-3 w-11/12 rounded bg-ods-bg/40\" />\n          </span>\n        </span>\n        <span className=\"flex h-5 shrink-0 items-center self-start\">\n          <span className=\"h-3.5 w-3.5 rounded bg-ods-bg\" />\n        </span>\n      </span>\n    );\n  }\n  return (\n    <div\n      className=\"flex animate-pulse flex-col overflow-hidden rounded-lg border border-ods-border\"\n      style={{ backgroundColor: 'var(--ods-system-greys-black)' }}\n    >\n      <div className=\"flex-1 p-6\">\n        <div className=\"flex flex-col gap-4 md:flex-row md:gap-6\">\n          <div className=\"h-[180px] w-full flex-shrink-0 rounded-lg bg-ods-bg md:w-[180px]\" />\n          <div className=\"flex min-w-0 flex-1 flex-col gap-3\">\n            <div className=\"h-7 w-3/4 rounded bg-ods-bg\" />\n            <div className=\"h-7 w-1/2 rounded bg-ods-bg\" />\n            <div className=\"h-4 w-1/3 rounded bg-ods-bg/60\" />\n            <div className=\"space-y-2\">\n              <div className=\"h-3 w-full rounded bg-ods-bg/60\" />\n              <div className=\"h-3 w-5/6 rounded bg-ods-bg/60\" />\n              <div className=\"h-3 w-4/5 rounded bg-ods-bg/60\" />\n            </div>\n          </div>\n        </div>\n      </div>\n      <div className=\"mt-auto p-6 pt-0\">\n        <div className=\"border-t border-ods-border pt-4\">\n          <div className=\"h-9 w-40 rounded bg-ods-bg\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport interface ProgramCardProps<T extends BaseProgramItem> {\n  config: ProgramConfig<T>;\n  item: T;\n  media?: ProgramMedia[];\n  renderMeta?: (item: T) => React.ReactNode;\n  size?: CardSize;\n  /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */\n  showTypeBadge?: boolean;\n  /** Detail URL resolved by the caller. */\n  href: string;\n  /** When `_blank`, opens in a new tab. Set by chat dispatch via\n   *  `computeIsNewTab` so the inner `<a>` matches the runtime\n   *  nav decision (cross-platform / embed → new tab). Defaults to\n   *  same-tab for non-chat callsites. */\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  targetPlatform?: string | null;\n  /** OG placeholder URL used by the compact branch when no cover. */\n  placeholderUrl?: string | null;\n  wholeCardClickable?: boolean;\n  className?: string;\n}\n\nfunction getHosts(hosts: ProgramHost[] | null | undefined): Array<{ name: string; avatar: string | null }> {\n  if (!hosts) return [];\n  try {\n    if (Array.isArray(hosts)) {\n      return hosts.map(host => ({\n        name: host.name || 'Unknown',\n        avatar: host.avatar_url || null,\n      }));\n    }\n    if (typeof hosts === 'string') {\n      const parsed: unknown = JSON.parse(hosts);\n      if (Array.isArray(parsed)) {\n        return parsed.map((host: unknown) => {\n          const row: Record<string, unknown> = typeof host === 'object' && host !== null ? { ...host } : {};\n          const name = typeof row.name === 'string' && row.name ? row.name : null;\n          const displayName = typeof row.display_name === 'string' && row.display_name ? row.display_name : null;\n          return {\n            name: name ?? displayName ?? 'Unknown',\n            avatar: typeof row.avatar_url === 'string' && row.avatar_url ? row.avatar_url : null,\n          };\n        });\n      }\n    }\n  } catch (error) {\n    console.warn('Failed to parse hosts data:', error);\n  }\n  return [];\n}\n\n/**\n * Webinar scheduling columns, read off a `BaseProgramItem` that has already\n * been `in`-guarded for `start_at`. Each field is validated rather than\n * asserted — the generic item type does not declare them.\n */\nfunction webinarTiming(item: BaseProgramItem): {\n  startAt: string | null;\n  endAt: string | null;\n  timezone: string | null;\n} {\n  const str = (value: unknown): string | null => (typeof value === 'string' && value ? value : null);\n  return {\n    startAt: 'start_at' in item ? str(item.start_at) : null,\n    endAt: 'end_at' in item ? str(item.end_at) : null,\n    timezone: 'timezone' in item ? str(item.timezone) : null,\n  };\n}\n\nfunction MediaGallery({ images, title }: { images: ProgramMedia[]; title: string }) {\n  const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(null);\n  const [isModalOpen, setIsModalOpen] = useState(false);\n  const openImageModal = (index: number, event?: React.MouseEvent) => {\n    if (event) event.stopPropagation();\n    setSelectedImageIndex(index);\n    setIsModalOpen(true);\n  };\n  const closeImageModal = () => {\n    setIsModalOpen(false);\n    setSelectedImageIndex(null);\n  };\n  return (\n    <>\n      <div className=\"p-6 pt-4\">\n        <div className=\"mb-4 overflow-x-auto\">\n          <div className=\"flex gap-3 pb-2\" style={{ width: 'max-content' }}>\n            {images.map((mediaItem, index) => (\n              <div\n                key={mediaItem.id}\n                className=\"group/thumb relative h-24 w-24 flex-shrink-0 cursor-pointer overflow-hidden rounded-md\"\n                onClick={e => openImageModal(index, e)}\n              >\n                <Image\n                  src={mediaItem.media_url}\n                  alt={`${title} photo ${index + 1}`}\n                  fill\n                  className=\"object-cover transition-transform duration-200 group-hover/thumb:scale-105\"\n                  sizes=\"96px\"\n                  loading=\"lazy\"\n                  unoptimized\n                />\n                <div className=\"absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 transition-opacity duration-200 group-hover/thumb:opacity-100\">\n                  <div className=\"flex h-6 w-6 items-center justify-center rounded-full bg-white/90\">\n                    <span className=\"text-sm text-black\">+</span>\n                  </div>\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n      <ImageGalleryModal\n        images={images.map(img => img.media_url)}\n        isOpen={isModalOpen}\n        onClose={closeImageModal}\n        initialIndex={selectedImageIndex || 0}\n      />\n    </>\n  );\n}\n\nexport function ProgramCard<T extends BaseProgramItem>({\n  config,\n  item,\n  media = [],\n  renderMeta,\n  size = 'default',\n  showTypeBadge = true,\n  href,\n  target: targetProp,\n  rel: relProp,\n  targetPlatform,\n  placeholderUrl: placeholderUrlProp,\n  wholeCardClickable = false,\n  className,\n}: ProgramCardProps<T>) {\n  const { target, rel } = useEntityCardLink({\n    href,\n    targetPlatform,\n    target: targetProp,\n    rel: relProp,\n  });\n  const placeholderUrl = useEntityCardPlaceholder({\n    title: item.title,\n    placeholderUrl: placeholderUrlProp,\n    aspect: size === 'sm' ? 'square' : 'wide',\n  });\n  const coverImage = item.cover_url;\n  const images = media.filter(m => isImageMedia(m));\n  const hosts = getHosts(item.hosts);\n  const accentColor = 'var(--color-accent-primary)';\n  // `status` / `duration_seconds` / `location_name` / `start_at` … live on the\n  // concrete program shapes (PodcastItem / EventItem / WebinarItem), not on\n  // `BaseProgramItem`. The `in` guards narrow them to `unknown`, so each read\n  // below is followed by a real type check instead of a cast.\n  const isScheduled = 'status' in item && item.status === 'scheduled';\n\n  // Compact per-type meta (duration / location / start time) — shared by the\n  // `sm` and `portrait` densities.\n  const compactTypeMeta = (): string | null => {\n    if (config.type === 'podcast' && 'duration_seconds' in item && !isScheduled) {\n      const dur = item.duration_seconds;\n      if (typeof dur === 'number' && dur > 0) return formatDurationCompact(dur);\n    } else if (config.type === 'event' && 'location_name' in item) {\n      const loc = item.location_name;\n      if (typeof loc === 'string' && loc.trim().length > 0) return loc;\n    } else if (config.type === 'webinar' && 'start_at' in item) {\n      const { startAt, endAt, timezone } = webinarTiming(item);\n      const time = formatTimeWithTimezone(startAt, timezone);\n      const dur = formatDurationFromRange(startAt, endAt);\n      return dur ? `${time} · ${dur}` : time;\n    }\n    return null;\n  };\n  const compactDate = formatUtc(new Date(item.date), 'MMM d, yyyy');\n\n  if (size === 'portrait') {\n    // Rail/strip density — mapped onto the shared <EntityPortraitCard> shell\n    // (media aspect-[1200/630] → h-[72px] title → h-[60px] footer, p-6/gap-6).\n    // Person = author-first via programItemToStripProfile (author → primary\n    // host); the date · duration meta line fills the subtitle when the\n    // profile has no job title.\n    const profile = programItemToStripProfile(item);\n    const dateMeta = [compactDate, compactTypeMeta()].filter(Boolean).join(' · ');\n    return (\n      <EntityPortraitCard\n        href={href}\n        target={target}\n        rel={rel}\n        typeLabel={showTypeBadge ? config.labels.singular : undefined}\n        imageUrl={coverImage}\n        placeholderUrl={placeholderUrl}\n        imageAlt={item.title}\n        title={item.title}\n        titleClassName=\"font-['Azeret_Mono'] font-semibold text-lg leading-6\"\n        person={\n          profile\n            ? { name: profile.name, avatarUrl: profile.avatarUrl, subtitle: profile.subtitle || dateMeta }\n            : { name: config.labels.singular, subtitle: dateMeta }\n        }\n        className={className}\n      />\n    );\n  }\n\n  if (size === 'sm') {\n    const itemDate = compactDate;\n    const compactCover = coverImage || placeholderUrl || null;\n    const typeMeta = compactTypeMeta();\n    const subtitleParts = [itemDate, typeMeta, config.labels?.singular].filter(\n      (s): s is string => typeof s === 'string' && s.length > 0,\n    );\n    return (\n      <a href={href} target={target} rel={rel} className={cn(COMPACT_CARD_OUTER, className)}>\n        <span className={COMPACT_CARD_IMAGE_SLOT}>\n          {compactCover ? (\n            <Image src={compactCover} alt={item.title} fill sizes=\"56px\" className=\"object-contain\" unoptimized />\n          ) : (\n            <span className=\"flex h-full w-full items-center justify-center text-ods-accent\">\n              {config.type === 'podcast' ? (\n                <Play className=\"h-4 w-4\" />\n              ) : config.type === 'webinar' ? (\n                <Video className=\"h-4 w-4\" />\n              ) : (\n                <Clock className=\"h-4 w-4\" />\n              )}\n            </span>\n          )}\n          {config.type === 'podcast' && !isScheduled && compactCover && (\n            <span className=\"absolute inset-0 flex items-center justify-center bg-black/30\">\n              <Play className=\"h-4 w-4 text-ods-text-on-dark\" fill=\"white\" />\n            </span>\n          )}\n        </span>\n        <span className={COMPACT_CARD_TEXT_COL}>\n          <span className={COMPACT_CARD_TITLE_ROW}>\n            <span className={cn(COMPACT_CARD_TITLE, 'font-heading')}>{item.title}</span>\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className=\"truncate text-ods-accent text-h6\">{subtitleParts.join(' · ')}</span>\n          </span>\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_SUMMARY}>{item.description || COMPACT_CARD_ROW_FILLER}</span>\n          </span>\n        </span>\n        <span className=\"flex h-5 shrink-0 items-center self-start text-ods-text-secondary\">\n          <ExternalLink className=\"h-3.5 w-3.5\" />\n        </span>\n      </a>\n    );\n  }\n\n  const itemDate = new Date(item.date);\n  const dateFormat = formatUtc(itemDate, 'EEEE d MMMM');\n\n  const defaultRenderMeta = () => {\n    if (config.type === 'podcast' && 'duration_seconds' in item && !isScheduled) {\n      const dur = item.duration_seconds;\n      return (\n        <>\n          <Clock className=\"h-4 w-4 text-ods-text-secondary\" />\n          <span className=\"font-body text-ods-text-secondary\">\n            {formatDurationCompact(typeof dur === 'number' ? dur : null)}\n          </span>\n        </>\n      );\n    }\n    if (config.type === 'event' && 'location_name' in item) {\n      const loc = item.location_name;\n      return (\n        <span className=\"font-body text-ods-text-secondary\">{(typeof loc === 'string' && loc) || 'Location TBD'}</span>\n      );\n    }\n    if (config.type === 'webinar' && 'start_at' in item) {\n      const { startAt, endAt, timezone } = webinarTiming(item);\n      const duration = formatDurationFromRange(startAt, endAt);\n      return (\n        <>\n          <Video className=\"h-4 w-4 text-ods-text-secondary\" />\n          <span className=\"font-body text-ods-text-secondary\">\n            {formatTimeWithTimezone(startAt, timezone)}\n            {duration && ` · ${duration}`}\n          </span>\n          {timezone && <span className=\"text-ods-text-secondary text-h6\">({timezone})</span>}\n        </>\n      );\n    }\n    return null;\n  };\n\n  const cardHeader = (\n    <div className=\"flex-1 border-ods-border p-6\">\n      <div className=\"flex flex-col gap-4 md:flex-row md:gap-6\">\n        {coverImage && (\n          <div className=\"flex w-full flex-shrink-0 items-center md:w-[180px]\">\n            <div className=\"relative overflow-hidden rounded-lg\">\n              <Image\n                src={coverImage}\n                alt={item.title}\n                width={180}\n                height={180}\n                className=\"h-auto w-full rounded-lg object-contain\"\n                unoptimized\n              />\n              {config.type === 'podcast' && !isScheduled && (\n                <div className=\"absolute inset-0 flex items-center justify-center rounded-lg bg-black/40 opacity-0 transition-opacity group-hover:opacity-100\">\n                  <Play className=\"h-10 w-10 text-ods-text-on-dark\" fill=\"white\" />\n                </div>\n              )}\n            </div>\n          </div>\n        )}\n\n        <div className=\"flex min-w-0 flex-1 flex-col\">\n          <h3 className=\"mb-3 line-clamp-2 flex min-h-[3rem] items-center text-ods-text-primary text-h2 md:min-h-[3.5rem]\">\n            {item.title}\n          </h3>\n\n          <div className=\"mb-4 flex flex-col gap-2 md:flex-row md:items-center md:gap-4\">\n            <span className=\"text-h6\" style={{ color: accentColor }}>\n              {dateFormat}\n            </span>\n            {renderMeta ? (\n              <>\n                <span className=\"hidden text-ods-text-secondary md:inline\">•</span>\n                {renderMeta(item)}\n              </>\n            ) : (\n              defaultRenderMeta() && (\n                <>\n                  <span className=\"hidden text-ods-text-secondary md:inline\">•</span>\n                  <div className=\"flex items-center gap-2\">{defaultRenderMeta()}</div>\n                </>\n              )\n            )}\n          </div>\n\n          <div className=\"flex-1\">\n            <p className=\"line-clamp-3 min-h-[4.5rem] text-ods-text-secondary text-h6\">{item.description}</p>\n          </div>\n        </div>\n\n        {hosts.length > 0 && (\n          <div className=\"md:text-right\">\n            <div className=\"flex flex-wrap gap-2 md:justify-end\">\n              {hosts.map((host, index) => (\n                <SquareAvatar\n                  variant=\"round\"\n                  key={`${item.id}-host-${index}`}\n                  src={host.avatar || undefined}\n                  alt={host.name}\n                  fallback={host.name.charAt(0).toUpperCase()}\n                  size=\"sm\"\n                />\n              ))}\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n\n  const cardFrameClass = cn(\n    'group flex flex-col overflow-hidden rounded-lg border border-ods-border transition-all duration-200',\n    className,\n  );\n  const cardFrameStyle = { backgroundColor: 'var(--ods-system-greys-black)' } as const;\n\n  if (wholeCardClickable) {\n    return (\n      <a\n        href={href}\n        target={target}\n        rel={rel}\n        className={cn(cardFrameClass, 'no-underline hover:border-ods-accent/50')}\n        style={cardFrameStyle}\n        aria-label={`Open ${item.title}`}\n      >\n        {cardHeader}\n        {images.length > 0 && <MediaGallery images={images} title={item.title} />}\n        <div className=\"mt-auto p-6 pt-0\">\n          <div className=\"border-t border-ods-border pt-4\">\n            <span\n              className=\"inline-flex items-center gap-2 rounded-md border border-ods-accent px-4 py-2 text-ods-accent text-h6\"\n              aria-hidden=\"true\"\n            >\n              View {config.labels.singular} Details\n              <ExternalLink className=\"h-5 w-5\" />\n            </span>\n          </div>\n        </div>\n      </a>\n    );\n  }\n\n  return (\n    <div className={cardFrameClass} style={cardFrameStyle}>\n      <a href={href} target={target} rel={rel} className=\"block\" aria-label={`Open ${item.title}`}>\n        {cardHeader}\n      </a>\n      {images.length > 0 && <MediaGallery images={images} title={item.title} />}\n      <div className=\"mt-auto p-6 pt-0\">\n        <div className=\"border-t border-ods-border pt-4\">\n          <Button\n            variant=\"outline\"\n            size=\"small-legacy\"\n            href={href}\n            openInNewTab={target === '_blank'}\n            rightIcon={<ExternalLink className=\"h-5 w-5\" />}\n          >\n            View {config.labels.singular} Details\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n","/**\n * List-API response normalizers — HOISTED from\n * `components/chat/hooks/use-chat-card-item.ts` (private copies) so the\n * related-content rail can import them WITHOUT touching the chat hooks\n * chunk (which reaches `@tanstack/react-query`). Pure + server-safe.\n *\n * The hub's `lib/utils/entity-list-api.ts` re-exports these — one\n * implementation of response-shape normalization across both repos.\n */\n\n/** Extract the items array from each endpoint's response shape (some\n *  return `{ items }`, others `{ posts }`, etc.). Single normalization\n *  point — callers always read `Item[]`. */\nexport function extractItems(data: unknown): unknown[] {\n  if (!data || typeof data !== 'object') return [];\n  const obj = data as Record<string, unknown>;\n  if (Array.isArray(obj.items)) return obj.items;\n  if (Array.isArray(obj.posts)) return obj.posts;\n  if (Array.isArray(obj.campaigns)) return obj.campaigns;\n  if (Array.isArray(obj.faqs)) return obj.faqs;\n  if (Array.isArray(obj.completed) || Array.isArray(obj.inProgress)) {\n    // Delivery endpoint splits into completed/inProgress arrays — flatten.\n    // Annotated because `Array.isArray` narrows an `unknown` to `any[]`, and\n    // spreading that would leak `any` elements into the returned `unknown[]`.\n    const completed: unknown[] = Array.isArray(obj.completed) ? obj.completed : [];\n    const inProgress: unknown[] = Array.isArray(obj.inProgress) ? obj.inProgress : [];\n    return [...completed, ...inProgress];\n  }\n  if (Array.isArray(obj.data)) return obj.data;\n  if (Array.isArray(data)) return data;\n  return [];\n}\n\n/** Extract a stable id from a fetched item — different shapes use\n *  `id` vs `external_id`. Used by the chat loader to match the fetched\n *  array element back to the marker's id. */\nexport function extractItemId(type: string, item: unknown): string | null {\n  if (!item || typeof item !== 'object') return null;\n  const obj = item as Record<string, unknown>;\n  if (type === 'roadmap_item' || type === 'delivery_item' || type === 'internal_task') {\n    const ext = obj.external_id;\n    if (typeof ext === 'string') return ext;\n  }\n  // RoadmapItem-shaped responses use `id` to carry the external_id; the\n  // mapper renames external_id→id at the API boundary. Treat both id and\n  // external_id as primary-key candidates for these clickup-backed types.\n  const id = obj.id;\n  if (typeof id === 'string') return id;\n  if (typeof id === 'number') return String(id);\n  return null;\n}\n","/**\n * Shared list-API URL builder — the single un-replicable piece of the\n * chat entity-card fetch path.\n *\n * ## Why this exists\n *\n * A fetch-mode entity card (`dispatch.tsx` → `useChatCardItem`) expands a\n * compact `[card://<type>:<id>]` marker by fetching the type's public list\n * endpoint with an `?ids=` (or `?task_ids=`) filter and matching the row\n * back by id. The per-type URL SHAPE is non-obvious — `task_ids` for the\n * ClickUp-backed types, `pageSize` for blog, `&limit=N&filter=all` for the\n * programs trio, a distinct `/customer-interviews` path, etc. — and lives in\n * the hub's 12 RAG mapper closures (`lib/config/rag-mappers/*.ts`). An\n * embedder can't reverse-engineer those shapes, so a wrong/missing builder\n * resolves a null URL ⇒ `enabled:false` ⇒ the card never fetches and renders\n * nothing.\n *\n * This builder is the ONE source for those shapes. The hub's 12 mappers\n * delegate their `listApi` here (a byte-parity test guards the migration),\n * and embedders wire it once:\n *\n *   endpoints.buildListUrl = (type, ids) => buildListUrl(type, ids, '/content')\n *\n * `base=''` (default) yields the hub-relative `/api/...`; `base='/content'`\n * (or any reverse-proxy prefix) yields `/content/api/...`.\n *\n * Pure + server-safe (no React, no browser APIs) so the hub's server-side\n * mappers can import it from `@flamingo-stack/openframe-frontend-core/utils`.\n */\n\n/**\n * Legacy ContentRef aliases that predate the RAG `documentType`\n * unification — direct lib/embedder callers (e.g. the chat dispatcher's\n * `endpoints.buildListUrl`) still emit `blog_post_existing`. Mirrors the\n * hub's own `LEGACY_TYPE_ALIASES` (`lib/utils/entity-list-api.ts`); both\n * entry points un-alias to the canonical `documentType` before lookup.\n */\nconst ALIASES: Record<string, string> = { blog_post_existing: 'blog_post' };\n\n/** Resolve a ContentRef rail-vocab type to its canonical `documentType`\n *  (registry vocabulary) — `blog_post_existing` → `blog_post`, everything\n *  else passes through. Exported so consumers comparing rail group keys to\n *  registry entityTypes (e.g. the related-content rail's same-type-first\n *  ordering) share THIS alias map instead of re-declaring it. */\nexport function canonicalContentRefType(contentRefType: string): string {\n  // `hasOwnProperty` guard, same as `buildListUrl` below: a bare index read\n  // resolves prototype keys, so `canonicalContentRefType('constructor')`\n  // returned `Object` itself — which then got interpolated into a URL path.\n  return Object.prototype.hasOwnProperty.call(ALIASES, contentRefType) ? ALIASES[contentRefType] : contentRefType;\n}\n\n/**\n * One builder per fetch-mode `contentRefType`, keyed by the canonical\n * `documentType`. Each body is copied VERBATIM from the matching hub\n * mapper's `listApi` (raw, unencoded `join(',')`; exact param names) —\n * the byte-parity test (`__tests__/list-url.test.ts`) fails if any drifts.\n *\n * An ABSENT key ⇒ `buildListUrl` returns null (no-fetch). Only two types\n * are intentionally absent — `deleted_data` (tombstone of a deleted row;\n * nothing exists to fetch) and `video` (body-embed content-hash ids with\n * no backing table row). Every other card type resolves here: content\n * types to their public list APIs, the rest to their per-object\n * card-hydration routes (see `ENTITY_CARD_ROUTES` below).\n * `marketing_campaign` is handled as a literal case in `buildListUrl`\n * (see there).\n */\nconst BUILDERS: Record<string, (ids: string[], base: string) => string> = {\n  roadmap_item: (ids, b) => `${b}/api/roadmap?task_ids=${ids.join(',')}`,\n  delivery_item: (ids, b) => `${b}/api/delivery?task_ids=${ids.join(',')}`,\n  internal_task: (ids, b) => `${b}/api/internal-tasks?task_ids=${ids.join(',')}`,\n  blog_post: (ids, b) => `${b}/api/blog/posts?ids=${ids.join(',')}&pageSize=${ids.length}`,\n  webinar: (ids, b) => `${b}/api/programs/webinars?ids=${ids.join(',')}&limit=${ids.length}&filter=all`,\n  podcast: (ids, b) => `${b}/api/programs/podcasts?ids=${ids.join(',')}&limit=${ids.length}&filter=all`,\n  event: (ids, b) => `${b}/api/programs/events?ids=${ids.join(',')}&limit=${ids.length}&filter=all`,\n  onboarding_guide: (ids, b) => `${b}/api/onboarding-guides?ids=${ids.join(',')}&limit=${ids.length}`,\n  case_study: (ids, b) => `${b}/api/case-studies?ids=${ids.join(',')}&limit=${ids.length}`,\n  product_release: (ids, b) => `${b}/api/releases?ids=${ids.join(',')}&limit=${ids.length}`,\n  customer_interview: (ids, b) => `${b}/api/customer-interviews?ids=${ids.join(',')}&limit=${ids.length}`,\n  investor_update: (ids, b) => `${b}/api/investor-updates?ids=${ids.join(',')}&limit=${ids.length}`,\n  faq: (ids, b) => `${b}/api/faqs?ids=${ids.join(',')}&limit=${ids.length}`,\n  // Self-scoped: the endpoint filters by the SESSION user's email server-side\n  // (`selfScopeFilter`), so foreign ids simply return no row — safe to expose\n  // through the same builder path as the public types.\n  hubspot_ticket_self: (ids, b) => `${b}/api/tickets?ids=${ids.join(',')}`,\n  // Per-OBJECT card-hydration routes for the types with no pre-existing\n  // public list API. THIS map is the only place these URL shapes exist —\n  // the hub's mappers/configs delegate here (`listApi: (ids) =>\n  // buildListUrl('<type>', ids)`), same as every entry above. The\n  // internal/public GitHub variants share their object's route; the\n  // route serves whichever variant is bound to the calling source.\n  github_commit: (ids, b) => `${b}/api/github/commits?ids=${ids.join(',')}`,\n  github_commit_public: (ids, b) => `${b}/api/github/commits?ids=${ids.join(',')}`,\n  github_pull_request: (ids, b) => `${b}/api/github/pull-requests?ids=${ids.join(',')}`,\n  github_pull_request_public: (ids, b) => `${b}/api/github/pull-requests?ids=${ids.join(',')}`,\n  github_pr_review: (ids, b) => `${b}/api/github/reviews?ids=${ids.join(',')}`,\n  github_pr_review_public: (ids, b) => `${b}/api/github/reviews?ids=${ids.join(',')}`,\n  slack_message: (ids, b) => `${b}/api/slack-community/messages?ids=${ids.join(',')}`,\n  hubspot_ticket: (ids, b) => `${b}/api/tickets/internal?ids=${ids.join(',')}`,\n  hubspot_ticket_anon: (ids, b) => `${b}/api/tickets/known-issues?ids=${ids.join(',')}`,\n  data_room_doc: (ids, b) => `${b}/api/data-room/documents?ids=${ids.join(',')}`,\n  markdown: (ids, b) => `${b}/api/docs/pages?ids=${ids.join(',')}`,\n  financial_kpi: (ids, b) => `${b}/api/financials/kpis?ids=${ids.join(',')}`,\n  cap_table: (ids, b) => `${b}/api/financials/cap-table?ids=${ids.join(',')}`,\n  profit_loss: (ids, b) => `${b}/api/financials/profit-loss?ids=${ids.join(',')}`,\n  balance_sheet: (ids, b) => `${b}/api/financials/balance-sheet?ids=${ids.join(',')}`,\n  cash_flow: (ids, b) => `${b}/api/financials/cash-flow?ids=${ids.join(',')}`,\n  // People-hub employee feeds — their EXISTING list APIs (`?ids=`, also feeding\n  // the related-content rail + author page), entry-shaped rows.\n  what_i_shipped: (ids, b) => `${b}/api/what-i-shipped?ids=${ids.join(',')}&limit=${ids.length}`,\n  how_i_work: (ids, b) => `${b}/api/how-i-work?ids=${ids.join(',')}&limit=${ids.length}`,\n  // Product-hub internal objects — per-object card-hydration routes\n  // (ChatRef-shaped items, `handleEntityCardList`), like github / slack.\n  design_doc: (ids, b) => `${b}/api/design-docs?ids=${ids.join(',')}`,\n  openframe_tenant: (ids, b) => `${b}/api/openframe-tenants?ids=${ids.join(',')}`,\n};\n\n/**\n * Build a list-API URL that returns full rows for the given ids, or `null`\n * when the type has no list endpoint (the caller skips fetching rather than\n * fabricating a URL).\n *\n *   buildListUrl('roadmap_item', ['a','b'])             → '/api/roadmap?task_ids=a,b'\n *   buildListUrl('blog_post_existing', ['a','b'])       → '/api/blog/posts?ids=a,b&pageSize=2'  (alias)\n *   buildListUrl('roadmap_item', ['a','b'], '/content') → '/content/api/roadmap?task_ids=a,b'\n *   buildListUrl('github_pr', ['a'])                    → null  (absent key)\n *\n * `marketing_campaign` (admin-only, non-RAG) is a LITERAL case, NOT a\n * `BUILDERS` entry — a static branch keeps CodeQL able to prove no\n * user-controlled dynamic dispatch reaches the admin endpoint; embedders\n * can't hit `/api/admin` through their proxy anyway.\n */\nexport function buildListUrl(contentRefType: string, ids: string[], base = ''): string | null {\n  if (ids.length === 0) return null;\n  const key = ALIASES[contentRefType] ?? contentRefType;\n  if (key === 'marketing_campaign') {\n    // Keep this URL in sync with the hub's `entity-list-api.ts` buildNonRagListUrl\n    // — an intentional dual literal (a static branch in each) so CodeQL can prove\n    // no user-controlled dynamic dispatch reaches `/api/admin`.\n    return `${base}/api/admin/marketing/campaigns?ids=${ids.join(',')}&pageSize=${ids.length}`;\n  }\n  // `hasOwnProperty` guard so a prototype key (`constructor`, `__proto__`)\n  // can't dispatch to a non-builder — absent key ⇒ null.\n  const fn = Object.prototype.hasOwnProperty.call(BUILDERS, key) ? BUILDERS[key] : undefined;\n  return fn ? fn(ids, base) : null;\n}\n"]}