feat(P2-7): deep link preview cards for TikTok, X, Twitch, Reddit, YT Shorts

YouTube Shorts: portrait 9:16 thumbnail, red Shorts badge, channel parse
TikTok: portrait thumbnail, @user extract, caption parse (3 OG formats),
  hashtag chips, dark ♫ placeholder fallback
Twitter/X: tweet text parse from all og:title formats, media image when
  og:image:width>=300, profile vs tweet URL distinction, 𝕏 SVG badge
Twitch: live/clip/VOD detection, pulsing LIVE badge with CSS keyframes,
  game extraction from og:description, channel from URL
Reddit: r/subreddit badge, u/author + upvote + comment count parsed from
  og:description, post thumbnail 80x60, redd.it short URL support

Shared PortraitThumbnail (80x142) reused by TikTok + Shorts.
All brand hex colors in CSS file only, never in TSX.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 09:42:26 -04:00
parent 73921cb2a1
commit ad508ac61e
2 changed files with 1087 additions and 224 deletions
@@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css';
import { keyframes, style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const UrlPreview = style([
@@ -305,6 +305,16 @@ export const BadgeImdb = style({
color: '#000000',
});
export const BadgeYouTubeShorts = style({
backgroundColor: '#FF0000',
color: '#ffffff',
});
export const BadgeTikTok = style({
backgroundColor: '#000000',
color: '#ffffff',
});
// ---------------------------------------------------------------------------
// Twitch LIVE badge
// ---------------------------------------------------------------------------
@@ -344,3 +354,324 @@ export const IconWrapper = style([
backgroundColor: color.Surface.ContainerHover,
},
]);
// ---------------------------------------------------------------------------
// Portrait thumbnail — 9:16 for Shorts & TikTok
// ---------------------------------------------------------------------------
export const PortraitThumbnail = style([
DefaultReset,
{
position: 'relative',
flexShrink: 0,
width: toRem(80),
height: toRem(142),
borderRadius: config.radii.R300,
overflow: 'hidden',
cursor: 'pointer',
backgroundColor: color.Surface.Container,
':hover': {
filter: 'brightness(0.85)',
},
},
]);
export const PortraitThumbnailImg = style([
DefaultReset,
{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
export const PortraitPlayOverlay = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
},
]);
export const PortraitPlayButton = style([
DefaultReset,
{
width: toRem(36),
height: toRem(36),
borderRadius: '50%',
backgroundColor: 'rgba(0, 0, 0, 0.72)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
fontSize: toRem(16),
},
]);
export const PortraitPlaceholder = style([
DefaultReset,
{
flexShrink: 0,
width: toRem(80),
height: toRem(142),
borderRadius: config.radii.R300,
overflow: 'hidden',
backgroundColor: '#111111',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: toRem(32),
},
]);
// ---------------------------------------------------------------------------
// Twitter / X card
// ---------------------------------------------------------------------------
export const TweetBlock = style([
DefaultReset,
{
backgroundColor: 'rgba(0,0,0,0.15)',
borderRadius: config.radii.R300,
padding: config.space.S200,
display: '-webkit-box',
WebkitLineClamp: 4,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
fontSize: toRem(13),
lineHeight: '1.5',
},
]);
export const TweetMediaImg = style([
DefaultReset,
{
width: '100%',
maxHeight: toRem(200),
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
borderRadius: config.radii.R300,
marginTop: config.space.S100,
},
]);
export const TwitterHeader = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: config.space.S100,
padding: `${config.space.S100} ${config.space.S200}`,
paddingTop: config.space.S200,
},
]);
export const ProfileAvatarImg = style([
DefaultReset,
{
width: toRem(36),
height: toRem(36),
borderRadius: '50%',
objectFit: 'cover',
objectPosition: 'center',
flexShrink: 0,
display: 'block',
},
]);
// ---------------------------------------------------------------------------
// Twitch card
// ---------------------------------------------------------------------------
export const TwitchThumbnailWrapper = style([
DefaultReset,
{
position: 'relative',
width: '100%',
maxWidth: toRem(240),
aspectRatio: '16 / 9',
overflow: 'hidden',
flexShrink: 0,
backgroundColor: '#0e0e10',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.85)',
},
},
]);
export const TwitchThumbnailImg = style([
DefaultReset,
{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
export const TwitchLiveOverlay = style([
DefaultReset,
{
position: 'absolute',
top: config.space.S100,
right: config.space.S100,
display: 'flex',
alignItems: 'center',
gap: '4px',
backgroundColor: '#e91916',
color: '#ffffff',
borderRadius: config.radii.R300,
paddingLeft: config.space.S100,
paddingRight: config.space.S100,
paddingTop: '2px',
paddingBottom: '2px',
fontSize: toRem(11),
fontWeight: 700,
letterSpacing: '0.04em',
pointerEvents: 'none',
},
]);
const livePulseKeyframes = keyframes({
'0%': { opacity: 1 },
'100%': { opacity: 0.4 },
});
export const LiveDot = style([
DefaultReset,
{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: '#ffffff',
animation: `${livePulseKeyframes} 1s ease-in-out infinite alternate`,
},
]);
export const BadgeTwitchPurple = style({
backgroundColor: '#9146FF',
color: '#ffffff',
});
// ---------------------------------------------------------------------------
// Reddit card
// ---------------------------------------------------------------------------
export const RedditPostLayout = style([
DefaultReset,
{
display: 'flex',
gap: config.space.S200,
padding: config.space.S200,
alignItems: 'flex-start',
},
]);
export const RedditThumb = style([
DefaultReset,
{
flexShrink: 0,
width: toRem(80),
height: toRem(60),
borderRadius: config.radii.R300,
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
export const RedditSubBadge = style([
DefaultReset,
{
display: 'inline-flex',
alignItems: 'center',
paddingLeft: config.space.S100,
paddingRight: config.space.S100,
paddingTop: '2px',
paddingBottom: '2px',
borderRadius: config.radii.R300,
fontSize: toRem(11),
fontWeight: 700,
lineHeight: '1.4',
backgroundColor: '#FF4500',
color: '#ffffff',
letterSpacing: '0.02em',
},
]);
export const RedditUpvote = style([
DefaultReset,
{
color: '#FF4500',
fontWeight: 700,
fontSize: toRem(12),
},
]);
export const RedditMeta = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
marginTop: config.space.S100,
fontSize: toRem(12),
opacity: 0.75,
},
]);
// ---------------------------------------------------------------------------
// TikTok hashtag chip
// ---------------------------------------------------------------------------
export const HashtagChip = style([
DefaultReset,
{
display: 'inline-flex',
alignItems: 'center',
fontSize: toRem(11),
opacity: 0.7,
marginRight: '4px',
},
]);
// ---------------------------------------------------------------------------
// Shorts card header bar
// ---------------------------------------------------------------------------
export const ShortsHeader = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: config.space.S100,
padding: `${config.space.S100} ${config.space.S200}`,
backgroundColor: color.Surface.ContainerHover,
borderBottom: `1px solid ${color.SurfaceVariant.ContainerLine}`,
},
]);
// ---------------------------------------------------------------------------
// Side-by-side portrait layout (Shorts, TikTok)
// ---------------------------------------------------------------------------
export const PortraitSideLayout = style([
DefaultReset,
{
display: 'flex',
gap: config.space.S200,
padding: config.space.S200,
alignItems: 'flex-start',
},
]);
File diff suppressed because it is too large Load Diff