{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "social-post",
  "title": "Social Post",
  "author": "Lloyd Humphreys",
  "description": "EXPERIMENTAL — the API is still settling and will change in breaking ways; pin what you install. A themeable, platform-neutral social post embed card: no platform prop, no brand icons, so a quote grabbed from X, Bluesky, Mastodon, LinkedIn, or a blog renders uniformly. All data is passed as props — name, handle, content, an optional avatarUrl (a neutral person-silhouette fallback covers missing or dead avatars), 0–4 images in a Twitter-style grid (1 full-width, 2 columns, 3 tall + stacked, 4 in a 2×2, cover-cropped inside one fixed 16:9 frame), a required link to the original, an optional preformatted date string (never parsed), and an optional verified flag rendering a small neutral-colored check badge. @mentions, #hashtags, and URLs in the content are detected and tinted as inert spans — never anchors. The footer's 'Source ↗' link, under a full-bleed hairline, is the only interactive element; the card itself is not clickable and its text stays selectable. Two variants: 'outline' (bordered card) and 'filled' (borderless muted-gray fill), with --social-post-* CSS variables overriding any single part of either. Styled on shadcn theme tokens with light-dark() fallbacks. Ships a framework-agnostic vanilla core plus a React wrapper.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/social-post/social-post.ts",
      "content": "// social-post — a zero-dependency, platform-neutral social post embed card.\n//\n// EXPERIMENTAL — the API is still settling and will change in breaking ways; pin what you\n// install.\n//\n// An embedded-tweet-shaped quotation card with no platform branding: avatar (with a\n// silhouette fallback), name, handle, an optional neutral verified badge, the post text,\n// an optional 1–4 image grid, and a \"Source ↗\" link in the footer, sitting under a\n// full-bleed hairline. Because every field is passed in as data — nothing is fetched, no\n// oEmbed, no brand chrome — quotes grabbed from X, Bluesky, Mastodon, LinkedIn, or a blog\n// all render uniformly and pick up the host theme.\n//\n// Two variants: 'outline' (default — a bordered card on the card surface) and 'filled'\n// (the same geometry on a borderless muted-gray fill, for quote-walls and dense lists\n// where borders get noisy). Either way --social-post-bg overrides the fill directly.\n//\n// The footer link is deliberately the only interactive element. The card is not a\n// stretched link: it's a quotation, so its text stays selectable and nothing intercepts\n// clicks. @mentions, #hashtags, and URLs inside the content are detected and tinted, but\n// rendered as inert spans — never anchors — so the card holds exactly one real link.\n//\n// Media: 1 image fills the frame, 2 sit side by side, 3 render one tall + two stacked,\n// 4 make a 2×2 grid. The grid's overall frame is always 16:9 regardless of count\n// (cover-cropped cells), so cards with media keep identical proportions.\n//\n// Framework-agnostic vanilla DOM — no dependencies, no build step. A React wrapper lives\n// in social-post-react.tsx; a shadcn-native rebuild lives in social-post-shadcn.tsx.\n//\n// ── State ownership ────────────────────────────────────────────────────────────────────\n// Unlike steps/workflow-button there is no engine layer here: the card has no navigation\n// or timing state to be a source of truth for, only display data. getState()/setState()\n// keep the house control surface without inventing subscribe() machinery nothing would\n// listen to.\n//\n// ── Entity detection limits ────────────────────────────────────────────────────────────\n// Only explicit http(s):// URLs are detected (no bare domains / www.). Mentions accept\n// fediverse form (@user@instance.tld). Unicode hashtags work (#café, #日本語). Sentence\n// punctuation trailing a URL is trimmed, including an unbalanced closing paren — but\n// entities glued to a previous entity without whitespace (e.g. `@jane#tag`) stay merged.\n// The regex is a single bounded scan per alternative — no nested quantifiers, no\n// backtracking blow-up.\n//\n// ── Theming ────────────────────────────────────────────────────────────────────────────\n// Styles consume shadcn theme tokens when present, with light-dark() fallbacks so the\n// card reads correctly standalone in both themes. Override independently of the app theme\n// via the --social-post-* escape hatches (set on the root or any ancestor):\n//   --social-post-bg / --social-post-fg          card surface + body text (default: --card / --card-foreground;\n//                                                the filled variant's bg defaults to --muted instead)\n//   --social-post-border                         card border + media seams (default: --border)\n//   --social-post-radius                         card corners             (default: --radius, 0.75rem)\n//   --social-post-name                           display name             (default: --foreground)\n//   --social-post-handle                         @handle                  (default: --muted-foreground)\n//   --social-post-verified                       verified badge — neutral on purpose\n//                                                                         (default: --foreground)\n//   --social-post-avatar-bg / --social-post-avatar-fg\n//                                                silhouette fallback      (default: --muted / --muted-foreground)\n//   --social-post-accent                         mention/hashtag/URL tint (default: --primary)\n//   --social-post-media-radius                   media frame corners      (default: radius − 2px)\n//   --social-post-media-gap                      seam width               (default: 2px)\n//   --social-post-date                           date text                (default: --muted-foreground)\n//   --social-post-link / --social-post-link-hover\n//                                                footer link              (default: --muted-foreground / --foreground)\n//   --social-post-ring                           footer link focus ring   (default: --ring)\n\n// ── Entities ───────────────────────────────────────────────────────────────────────────\n\nexport type SocialPostEntityKind = 'mention' | 'hashtag' | 'url'\n\nexport interface SocialPostSegment {\n  /** null for plain text between entities. */\n  kind: SocialPostEntityKind | null\n  text: string\n}\n\n// Alternation order matters: at any position the URL branch wins, so a URL's own\n// #fragment or ?q=@x is consumed whole and never re-matched as a hashtag/mention. The\n// lookbehinds require a non-word boundary, which is what keeps bob@x.com from tagging @x\n// and ##x from double-matching.\nconst ENTITY_RE =\n  /(https?:\\/\\/[^\\s<>\"']+)|(?<![\\w@])(@[A-Za-z0-9_]{1,30}(?:@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*)?)|(?<![\\w#])(#[\\p{L}\\p{N}_]+)/gu\n\n/** Strip sentence punctuation a URL match swept in, plus a closing paren with no opening\n *  partner inside the match (so wiki_(disambiguation) keeps its paren while a prose\n *  `(see https://x.com)` drops it). */\nfunction trimTrailingPunctuation(url: string): string {\n  for (;;) {\n    if (/[.,!?;:'\"…]$/.test(url)) {\n      url = url.slice(0, -1)\n      continue\n    }\n    if (url.endsWith(')')) {\n      const opens = (url.match(/\\(/g) ?? []).length\n      const closes = (url.match(/\\)/g) ?? []).length\n      if (closes > opens) {\n        url = url.slice(0, -1)\n        continue\n      }\n    }\n    return url\n  }\n}\n\n/** Split post text into plain runs and mention/hashtag/URL entities, in order. Purely\n *  lexical — no platform lookups, no validation beyond the shapes above. */\nexport function splitContentEntities(content: string): SocialPostSegment[] {\n  const segments: SocialPostSegment[] = []\n  let last = 0\n  for (const m of content.matchAll(ENTITY_RE)) {\n    const start = m.index\n    let text = m[0]\n    const kind: SocialPostEntityKind = m[1] ? 'url' : m[2] ? 'mention' : 'hashtag'\n    if (kind === 'url') text = trimTrailingPunctuation(text)\n    if (start > last) segments.push({ kind: null, text: content.slice(last, start) })\n    segments.push({ kind, text })\n    // The scan resumes after the *untrimmed* match, so a trimmed URL tail lands in the\n    // next plain segment naturally — no lastIndex bookkeeping.\n    last = start + text.length\n  }\n  if (last < content.length) segments.push({ kind: null, text: content.slice(last) })\n  return segments\n}\n\n// ── Card ───────────────────────────────────────────────────────────────────────────────\n\nexport interface SocialPostData {\n  /** Display name, e.g. 'Ada Lovelace'. */\n  name: string\n  /** Bare handle without the leading '@' — the card prepends it (a passed '@' is\n   *  forgiven and stripped). */\n  handle: string\n  /** The post text. Line breaks are preserved; @mentions, #hashtags, and http(s) URLs\n   *  are tinted as inert spans. Always rendered as text — never markup. */\n  content: string\n  /** Avatar image URL. Omitted — or dead (a load error) — the neutral silhouette\n   *  fallback shows instead; the image is only revealed once it actually loads. */\n  avatarUrl?: string\n  /** 0–4 image URLs for the media grid; more than 4 are truncated with a console.warn. */\n  images?: string[]\n  /** Href of the original post — the footer's \"View original ↗\", the card's only link. */\n  link: string\n  /** Preformatted display string ('4:20 PM · Mar 3, 2026', 'Mar 2026'…). Rendered\n   *  verbatim — never parsed, which is also why it's a plain span, not <time datetime>. */\n  date?: string\n  /** Show the neutral-colored check badge after the name. Neutral on purpose: shape says\n   *  \"verified\", color stays --foreground so it reads as no platform's brand check. */\n  verified?: boolean\n}\n\nexport interface SocialPostLabels {\n  /** Accessible name of the card. Default `Post by ${name} (@${handle})`. */\n  root?: string\n  /** Footer link text. Default 'Source'. */\n  source?: string\n  /** Accessible label of the verified badge. Default 'Verified'. */\n  verified?: string\n}\n\nexport interface SocialPostOptions extends SocialPostData {\n  /** 'outline' (default): a bordered card on the card surface. 'filled': the same\n   *  geometry on a borderless muted-gray fill — the border stays transparent rather than\n   *  removed, so nothing shifts by a pixel when variants mix. */\n  variant?: 'outline' | 'filled'\n  labels?: SocialPostLabels\n  /** Inject the component stylesheet on first use. Default true; set false to ship the\n   *  CSS yourself (see `socialPostStyles()`). */\n  injectStyles?: boolean\n  /** Extra class(es) added to the root, for your own overrides. */\n  className?: string\n}\n\nexport interface SocialPost {\n  /** The card root (<article>). Append it anywhere. */\n  readonly element: HTMLElement\n  getState(): SocialPostData\n  /** Patch and re-render. Keys present in the patch are applied; `key: undefined` clears\n   *  an optional field (drops the avatar, empties the grid, removes the date). Absent\n   *  keys are untouched. Required fields ignore null/undefined — they have no default to\n   *  reset to. The avatar <img> and the media grid are only rebuilt when their values\n   *  actually changed, so toggling `verified` never re-fetches images. */\n  setState(patch: Partial<SocialPostOptions>): void\n  /** Nothing global to detach — every listener lives on the card's own children. Kept\n   *  for parity with the other controls (and callers' cleanup habits). */\n  destroy(): void\n}\n\nconst SVG_NS = 'http://www.w3.org/2000/svg'\n\n/** Only these trusted literal strings ever go through innerHTML; user text is always\n *  textContent. */\nfunction svgIcon(viewBox: string, strokeWidth: string, inner: string): SVGSVGElement {\n  const svg = document.createElementNS(SVG_NS, 'svg')\n  svg.setAttribute('viewBox', viewBox)\n  svg.setAttribute('fill', 'none')\n  svg.setAttribute('stroke', 'currentColor')\n  svg.setAttribute('stroke-width', strokeWidth)\n  svg.setAttribute('stroke-linecap', 'round')\n  svg.setAttribute('stroke-linejoin', 'round')\n  svg.innerHTML = inner\n  return svg\n}\n\n/** Head + shoulders, sized so the shoulders crop at the avatar circle's lower edge. */\nfunction silhouetteSvg(): SVGSVGElement {\n  const svg = svgIcon('0 0 40 40', '2.4', '<circle cx=\"20\" cy=\"16\" r=\"6.5\"/><path d=\"M7.5 36.5a12.5 12.5 0 0 1 25 0\"/>')\n  svg.setAttribute('aria-hidden', 'true')\n  return svg\n}\n\nfunction verifiedSvg(label: string): SVGSVGElement {\n  const svg = svgIcon(\n    '0 0 24 24',\n    '2',\n    '<path d=\"M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z\"/><path d=\"m9 12 2 2 4-4\"/>',\n  )\n  svg.setAttribute('class', 'social-post-verified')\n  svg.setAttribute('role', 'img')\n  svg.setAttribute('aria-label', label)\n  return svg\n}\n\nfunction normalizeImages(images: string[] | undefined): string[] {\n  if (!images) return []\n  if (images.length <= 4) return [...images]\n  console.warn('social-post: more than 4 images — rendering the first 4.')\n  return images.slice(0, 4)\n}\n\nconst stripAt = (handle: string) => handle.replace(/^@/, '')\n\n/** Build the card. Append `.element` anywhere; it fills its container's width, so size it\n *  from outside (e.g. max-width on a wrapper). */\nexport function createSocialPost(opts: SocialPostOptions): SocialPost {\n  if (opts.injectStyles !== false) injectSocialPostStyles()\n\n  let name = opts.name\n  let handle = opts.handle\n  let content = opts.content\n  let avatarUrl = opts.avatarUrl\n  let images = normalizeImages(opts.images)\n  let link = opts.link\n  let date = opts.date\n  let verified = opts.verified ?? false\n  let variant = opts.variant ?? 'outline'\n  let labels = opts.labels\n  let className = opts.className\n\n  const root = document.createElement('article')\n\n  const header = document.createElement('header')\n  header.className = 'social-post-header'\n  // The whole avatar block is decorative — the name and handle beside it carry the\n  // identity as text.\n  const avatar = document.createElement('span')\n  avatar.className = 'social-post-avatar'\n  avatar.setAttribute('aria-hidden', 'true')\n  avatar.appendChild(silhouetteSvg())\n  let avatarImg: HTMLImageElement | null = null\n\n  const identity = document.createElement('span')\n  identity.className = 'social-post-identity'\n  const nameRow = document.createElement('span')\n  nameRow.className = 'social-post-name-row'\n  const nameEl = document.createElement('span')\n  nameEl.className = 'social-post-name'\n  let verifiedEl: SVGSVGElement | null = null\n  const handleEl = document.createElement('span')\n  handleEl.className = 'social-post-handle'\n  nameRow.appendChild(nameEl)\n  identity.append(nameRow, handleEl)\n  header.append(avatar, identity)\n\n  const contentEl = document.createElement('p')\n  contentEl.className = 'social-post-content'\n\n  let mediaEl: HTMLDivElement | null = null\n  let mediaKey: string | null = null\n\n  const footer = document.createElement('footer')\n  footer.className = 'social-post-footer'\n  const dateEl = document.createElement('span')\n  dateEl.className = 'social-post-date'\n  const linkEl = document.createElement('a')\n  linkEl.className = 'social-post-link'\n  linkEl.target = '_blank'\n  linkEl.rel = 'noopener noreferrer'\n  const linkText = document.createElement('span')\n  const linkArrow = document.createElement('span')\n  linkArrow.className = 'social-post-link-arrow'\n  linkArrow.setAttribute('aria-hidden', 'true')\n  linkArrow.textContent = '↗'\n  linkEl.append(linkText, linkArrow)\n  footer.append(dateEl, linkEl)\n\n  root.append(header, contentEl, footer)\n\n  const setAvatar = (url: string | undefined) => {\n    avatarImg?.remove()\n    avatarImg = null\n    avatar.classList.remove('has-image')\n    if (!url) return\n    const img = document.createElement('img')\n    img.className = 'social-post-avatar-img'\n    img.alt = ''\n    // Deliberately NOT loading=\"lazy\": the img is display:none until it loads, and a\n    // lazy image with no box never intersects the viewport — it would deadlock hidden.\n    img.referrerPolicy = 'no-referrer'\n    // Revealed only on a real load: an error (or a request that never finishes) leaves\n    // the silhouette in place, so a dead URL can't paint a broken-image glyph.\n    img.addEventListener('load', () => {\n      if (img === avatarImg) avatar.classList.add('has-image')\n    }, { once: true })\n    img.addEventListener('error', () => {\n      if (img === avatarImg) {\n        img.remove()\n        avatarImg = null\n      }\n    }, { once: true })\n    img.src = url\n    avatar.appendChild(img)\n    avatarImg = img\n  }\n\n  const buildMedia = () => {\n    mediaEl?.remove()\n    mediaEl = null\n    if (images.length === 0) return\n    const grid = document.createElement('div')\n    grid.className = 'social-post-media'\n    grid.dataset.count = String(images.length)\n    for (const src of images) {\n      const cell = document.createElement('img')\n      cell.className = 'social-post-media-item'\n      cell.alt = ''\n      cell.loading = 'lazy'\n      cell.referrerPolicy = 'no-referrer'\n      cell.src = src\n      grid.appendChild(cell)\n    }\n    root.insertBefore(grid, footer)\n    mediaEl = grid\n  }\n\n  const render = () => {\n    root.className = `social-post social-post--${variant}${className ? ` ${className}` : ''}`\n    root.setAttribute('aria-label', labels?.root ?? `Post by ${name} (@${stripAt(handle)})`)\n    nameEl.textContent = name\n    nameEl.title = name // ellipsized names keep a native tooltip\n    verifiedEl?.remove()\n    verifiedEl = null\n    if (verified) {\n      verifiedEl = verifiedSvg(labels?.verified ?? 'Verified')\n      nameRow.appendChild(verifiedEl)\n    }\n    handleEl.textContent = `@${stripAt(handle)}`\n    contentEl.replaceChildren(\n      ...splitContentEntities(content).map((seg) => {\n        if (!seg.kind) return document.createTextNode(seg.text)\n        const span = document.createElement('span')\n        span.className = 'social-post-entity'\n        span.dataset.entity = seg.kind\n        span.textContent = seg.text\n        return span\n      }),\n    )\n    dateEl.textContent = date ?? ''\n    dateEl.style.display = date ? '' : 'none'\n    linkText.textContent = labels?.source ?? 'Source'\n    linkEl.href = link\n  }\n\n  setAvatar(avatarUrl)\n  mediaKey = images.join('\\n')\n  buildMedia()\n  render()\n\n  return {\n    element: root,\n    getState: () => ({ name, handle, content, avatarUrl, images: [...images], link, date, verified }),\n    setState(patch) {\n      if (patch.name != null) name = patch.name\n      if (patch.handle != null) handle = patch.handle\n      if (patch.content != null) content = patch.content\n      if (patch.link != null) link = patch.link\n      if ('date' in patch) date = patch.date\n      if ('verified' in patch) verified = patch.verified ?? false\n      if ('variant' in patch) variant = patch.variant ?? 'outline'\n      if ('labels' in patch) labels = patch.labels\n      if ('className' in patch) className = patch.className\n      if ('avatarUrl' in patch && patch.avatarUrl !== avatarUrl) {\n        avatarUrl = patch.avatarUrl\n        setAvatar(avatarUrl)\n      }\n      if ('images' in patch) {\n        const next = normalizeImages(patch.images)\n        const key = next.join('\\n')\n        if (key !== mediaKey) {\n          images = next\n          mediaKey = key\n          buildMedia()\n        }\n      }\n      render()\n    },\n    destroy() {},\n  }\n}\n\n// ── Styles ─────────────────────────────────────────────────────────────────────────────\n\nlet stylesInjected = false\n/** Inject the card stylesheet once. Called automatically unless `injectStyles: false`. */\nexport function injectSocialPostStyles(): void {\n  if (stylesInjected || typeof document === 'undefined') return\n  if (document.getElementById('social-post-styles')) {\n    stylesInjected = true\n    return\n  }\n  const style = document.createElement('style')\n  style.id = 'social-post-styles'\n  style.textContent = socialPostStyles()\n  document.head.appendChild(style)\n  stylesInjected = true\n}\n\n/** The card's CSS as a string (for callers who inject styles themselves / SSR). */\nexport function socialPostStyles(): string {\n  return `\n.social-post {\n  /* The footer pulls itself back out past this padding for its full-bleed rule. */\n  --_pad: 16px;\n  display: flex; flex-direction: column; gap: 12px;\n  padding: var(--_pad);\n  border: 1px solid var(--social-post-border, var(--border, light-dark(#e4e4e7, #303036)));\n  border-radius: var(--social-post-radius, var(--radius, 0.75rem));\n  background: var(--social-post-bg, var(--card, light-dark(#ffffff, #18181b)));\n  color: var(--social-post-fg, var(--card-foreground, light-dark(#09090b, #fafafa)));\n  font-size: 15px; line-height: 1.5;\n  text-align: start;\n}\n/* Filled: same geometry on a muted fill; the border goes transparent, not away, so\n   nothing shifts by a pixel when variants mix. */\n.social-post--filled {\n  border-color: transparent;\n  background: var(--social-post-bg, var(--muted, light-dark(#f4f4f5, #26262b)));\n}\n.social-post-header { display: flex; align-items: center; gap: 10px; min-width: 0; }\n.social-post-avatar {\n  position: relative; flex: none;\n  width: 40px; height: 40px;\n  border-radius: 999px; overflow: hidden;\n  background: var(--social-post-avatar-bg, var(--muted, light-dark(#ececee, #26262b)));\n  color: var(--social-post-avatar-fg, var(--muted-foreground, light-dark(#8a8a93, #8b8b95)));\n}\n.social-post-avatar svg { position: absolute; inset: 0; width: 100%; height: 100%; }\n.social-post-avatar-img {\n  position: absolute; inset: 0; width: 100%; height: 100%;\n  object-fit: cover; display: none;\n}\n.social-post-avatar.has-image .social-post-avatar-img { display: block; }\n.social-post-identity { display: flex; flex-direction: column; min-width: 0; }\n.social-post-name-row { display: flex; align-items: center; gap: 4px; min-width: 0; }\n.social-post-name {\n  font-size: 14.5px; font-weight: 600; line-height: 1.35;\n  color: var(--social-post-name, var(--foreground, light-dark(#18181b, #fafafa)));\n  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n.social-post-verified {\n  flex: none; width: 15px; height: 15px;\n  color: var(--social-post-verified, var(--foreground, light-dark(#3f3f46, #d4d4d8)));\n}\n.social-post-handle {\n  font-size: 13px; line-height: 1.35;\n  color: var(--social-post-handle, var(--muted-foreground, light-dark(#71717a, #a1a1aa)));\n  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n.social-post-content { margin: 0; white-space: pre-wrap; overflow-wrap: break-word; }\n/* Tint plus a slight weight bump: in themes where --primary sits near --foreground (the\n   default zinc theme), color alone wouldn't read. No underline, no cursor — these spans\n   are inert on purpose and must not pose as links. */\n.social-post-entity {\n  color: var(--social-post-accent, var(--primary, light-dark(#2563eb, #60a5fa)));\n  font-weight: 500;\n}\n/* One fixed 16:9 frame regardless of image count — only the internal grid template\n   varies, so every card with media keeps identical proportions. The container's own\n   background paints through the gaps as thin seams. */\n.social-post-media {\n  display: grid; gap: var(--social-post-media-gap, 2px);\n  aspect-ratio: 16 / 9;\n  border-radius: var(--social-post-media-radius, calc(var(--social-post-radius, var(--radius, 0.75rem)) - 2px));\n  overflow: hidden;\n  background: var(--social-post-border, var(--border, light-dark(#e4e4e7, #303036)));\n}\n.social-post-media[data-count=\"2\"] { grid-template-columns: 1fr 1fr; }\n.social-post-media[data-count=\"3\"] {\n  grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr;\n  grid-template-areas: 'a b' 'a c';\n}\n.social-post-media[data-count=\"3\"] .social-post-media-item:nth-child(1) { grid-area: a; }\n.social-post-media[data-count=\"3\"] .social-post-media-item:nth-child(2) { grid-area: b; }\n.social-post-media[data-count=\"3\"] .social-post-media-item:nth-child(3) { grid-area: c; }\n.social-post-media[data-count=\"4\"] { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }\n.social-post-media-item { width: 100%; height: 100%; object-fit: cover; display: block; min-height: 0; }\n.social-post-footer {\n  display: flex; align-items: center; gap: 10px;\n  /* Full-bleed hairline: pull past the card padding so the rule runs edge to edge. The\n     footer also swallows the card's bottom padding and caps the card itself, so its text\n     sits vertically centered — 10px off the hairline, 10px off the bottom edge. */\n  margin: 0 calc(-1 * var(--_pad)) calc(-1 * var(--_pad));\n  padding: 10px var(--_pad);\n  border-top: 1px solid color-mix(in srgb, var(--social-post-border, var(--border, light-dark(#e4e4e7, #303036))) 60%, transparent);\n  font-size: 13px;\n}\n/* The filled card has no visible border to echo — use the token at full strength so the\n   hairline still reads against the muted fill. */\n.social-post--filled .social-post-footer {\n  border-top-color: var(--social-post-border, var(--border, light-dark(#e4e4e7, #303036)));\n}\n.social-post-date {\n  color: var(--social-post-date, var(--muted-foreground, light-dark(#71717a, #a1a1aa)));\n  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n.social-post-link {\n  margin-inline-start: auto; flex: none;\n  display: inline-flex; align-items: center; gap: 3px;\n  color: var(--social-post-link, var(--muted-foreground, light-dark(#71717a, #a1a1aa)));\n  font-weight: 500; text-decoration: none;\n  border-radius: 6px;\n  transition: color 0.15s ease;\n}\n.social-post-link:hover {\n  color: var(--social-post-link-hover, var(--foreground, light-dark(#18181b, #fafafa)));\n}\n.social-post-link:focus-visible {\n  outline: 2px solid var(--social-post-ring, var(--ring, light-dark(#a1a1aa, #71717a)));\n  outline-offset: 3px;\n}\n.social-post-link-arrow { font-size: 12px; line-height: 1; }\n@media (prefers-reduced-motion: reduce) {\n  .social-post-link { transition: none !important; }\n}\n`\n}\n",
      "type": "registry:lib",
      "target": "components/social-post/social-post.ts"
    },
    {
      "path": "registry/social-post/social-post-react.tsx",
      "content": "// social-post-react — a thin React wrapper over the framework-agnostic social-post core.\n//\n// EXPERIMENTAL — the API is still settling and will change in breaking ways; pin what you\n// install.\n//\n//   <SocialPost name=\"Ada\" handle=\"ada\" content=\"…\" link=\"https://…\" />\n//\n// The wrapper mounts the vanilla card once and syncs every prop into the running control\n// via setState — the core only rebuilds the avatar <img> / media grid when those values\n// actually change, so re-renders are cheap. There's no engine to share (the card is\n// purely presentational), which is why this file is so much smaller than steps-react.\n\n'use client'\n\nimport { useEffect, useRef } from 'react'\nimport {\n  createSocialPost,\n  splitContentEntities,\n  type SocialPost as VanillaSocialPost,\n  type SocialPostData,\n  type SocialPostEntityKind,\n  type SocialPostLabels,\n  type SocialPostOptions,\n  type SocialPostSegment,\n} from './social-post'\n\ninterface SocialPostProps extends Omit<SocialPostOptions, 'injectStyles'> {}\n\n/**\n * The card. The returned wrapper is `display: contents`, so it adds no layout box of its\n * own — size the card with `className` (or a parent) instead.\n */\nfunction SocialPost({\n  name,\n  handle,\n  content,\n  avatarUrl,\n  images,\n  link,\n  date,\n  verified,\n  variant,\n  labels,\n  className,\n}: SocialPostProps) {\n  const hostRef = useRef<HTMLSpanElement>(null)\n  const controlRef = useRef<VanillaSocialPost | null>(null)\n  // Initial-only options, captured at creation like a useState initializer.\n  const initial = useRef({ name, handle, content, avatarUrl, images, link, date, verified, variant, labels, className })\n  initial.current = { name, handle, content, avatarUrl, images, link, date, verified, variant, labels, className }\n\n  useEffect(() => {\n    const host = hostRef.current\n    if (!host) return\n    const control = createSocialPost(initial.current)\n    host.appendChild(control.element)\n    controlRef.current = control\n    return () => {\n      control.destroy()\n      control.element.remove()\n      controlRef.current = null\n    }\n  }, [])\n\n  // Every prop each sync, so a removed optional prop genuinely clears its field ('key in\n  // patch' semantics in the core).\n  useEffect(() => {\n    controlRef.current?.setState({ name, handle, content, avatarUrl, images, link, date, verified, variant, labels, className })\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [name, handle, content, avatarUrl, images, link, date, verified, variant, labels, className])\n\n  return <span ref={hostRef} style={{ display: 'contents' }} />\n}\n\nexport {\n  splitContentEntities,\n  type SocialPostData,\n  type SocialPostEntityKind,\n  type SocialPostLabels,\n  type SocialPostOptions,\n  type SocialPostSegment,\n  type SocialPostProps,\n  SocialPost,\n}\n",
      "type": "registry:component",
      "target": "components/social-post/social-post-react.tsx"
    }
  ],
  "type": "registry:component"
}