/**
* Compact-card class constants — single source of truth for the chat-inline
* `size='sm'` card frame.
*
* Lifted from the hub's `components/shared/compact-card/compact-card-classes.ts`
* so the pure-presentation cards in `entity-cards/` can stop importing from
* the hub tree. Every compact card across the chat shell shares the SAME
* outer frame, skeleton frame, image slot, icon slot, text column, and
* per-line typography.
*
* Card-specific variation (Play overlay on podcasts, #N pill on investor
* updates, status badge on roadmap) lives INSIDE the card next to its
* other type-specific JSX; the frame stays uniform across types.
*/
/** Loaded compact card outer frame for INTERACTIVE state — anchor wrap
* with hover/transition affordance. Use ONLY when the card resolves
* to a real, safe href; the user expects a clickable feel. */
export const COMPACT_CARD_OUTER =
'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'
/** Loaded compact card outer frame for NON-INTERACTIVE state — span wrap
* used when `safeHref()` rejects the chat ref's url. */
export const COMPACT_CARD_OUTER_STATIC =
'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'
/** Skeleton outer frame — same dimensions as loaded, plus `animate-pulse`. */
export const COMPACT_CARD_SKELETON_OUTER =
'my-1.5 flex w-full animate-pulse items-start gap-3 rounded-lg border border-ods-border bg-ods-card p-2'
/** 56×56 image slot — for cards with a cover image. */
export const COMPACT_CARD_IMAGE_SLOT =
'relative block shrink-0 self-start w-14 h-14 aspect-square overflow-hidden rounded-md bg-ods-bg'
/** 56×56 placeholder slot for skeletons. */
export const COMPACT_CARD_SKELETON_IMAGE_SLOT =
'block h-14 w-14 aspect-square shrink-0 self-start rounded-md bg-ods-bg'
/** 56×56 icon slot — for cards without a cover image. */
export const COMPACT_CARD_ICON_SLOT =
'flex h-14 w-14 aspect-square shrink-0 self-start items-center justify-center rounded-md bg-ods-bg text-ods-accent'
/** Text column wrapper — explicit 56px height with `flex flex-col`. */
export const COMPACT_CARD_TEXT_COL =
'flex min-w-0 flex-1 flex-col gap-0.5 min-h-14'
/** Title row container — fixed 20px (h-5) to match `text-sm leading-5`. */
export const COMPACT_CARD_TITLE_ROW = 'flex items-center min-w-0 h-5'
/** Subtitle / summary row container — fixed 16px (h-4). */
export const COMPACT_CARD_META_ROW_BOX = 'flex items-center min-w-0 h-4'
/** Title text — bold 14px on a 20px line. */
export const COMPACT_CARD_TITLE =
'truncate text-sm font-semibold leading-5 text-ods-text-primary'
/** Subtitle text — 11px on a 16px line. */
export const COMPACT_CARD_SUBTITLE =
'truncate text-h6 text-ods-text-secondary'
/** Summary text — same metrics as subtitle, dropped to 80% opacity. */
export const COMPACT_CARD_SUMMARY =
'truncate text-h6 text-ods-text-secondary/80'
/** Meta-row variant — multi-part 11px metadata. */
export const COMPACT_CARD_META_ROW =
'flex items-center gap-1.5 min-w-0 text-h6 text-ods-text-secondary'
/** Non-breaking space — used as a fallback child so the row's height stays 16px. */
export const COMPACT_CARD_ROW_FILLER = ' '
/** Schemes allowed in the outer `` of a compact card. */
const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:']
/** Return the input url ONLY if it's safe to drop into an ``:
* the scheme must be http / https / mailto, or the URL must be a
* same-origin path (`/blog/foo`). Otherwise returns `null`. */
export function safeHref(url: string | null | undefined): string | null {
if (!url) return null
// Defense in depth: control chars + zero-width / line-separator chars.
if (/[\u0000-\u001f\u007f\u200b-\u200d\u2028\u2029\ufeff]/.test(url)) return null
const trimmed = url.trim()
if (!trimmed) return null
// Pure same-origin path.
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed
if (trimmed.startsWith('#')) return trimmed
// Reject bare scheme-only inputs.
if (/^[a-z][a-z0-9+.-]*:$/i.test(trimmed)) return null
try {
const parsed = new URL(trimmed, 'https://_safehref_base.invalid')
if (!SAFE_URL_SCHEMES.includes(parsed.protocol)) return null
if ((parsed.protocol === 'https:' || parsed.protocol === 'http:') && !parsed.hostname) return null
return trimmed
} catch {
return null
}
}