Files
cinny/src/app/components/url-preview/UrlPreview.css.tsx
T
Lotus CIandClaude Opus 5.5 ba5b1ffe7d
CI / Build & Quality Checks (push) Successful in 3m24s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 9m57s
feat(embeds): fallback for hung embeds and deleted X posts (#200)
Probed how real providers fail before picking signals:
- X renders an EMPTY frame for a deleted/private/suspended post and says
  so only via postMessage `twttr.private.no_results`. The post embed now
  swaps to "This post isn't available…" with an "Open on X" link.
- A hung frame never fires `load`. After 20 s every player (media, rich
  posts, TikTok, Steam widget, X) overlays "This embed is taking too long
  to load" with Retry (remounts the iframe) and "Open on <site>". A late
  `load` clears it.
- Instagram and Bluesky show their own "removed / not found" page, and a
  refused request still fires `load` (browser error page), so neither needs
  or can use a guess. A missing height message is not treated as failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 11:54:16 -04:00

1018 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { keyframes, style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const UrlPreview = style([
DefaultReset,
{
// 25rem (=400px) on desktop, but shrink to fit narrow phones so a single
// card doesn't exceed the viewport (mirrors UrlPreviewWide's min()).
width: 'min(25rem, 92vw)',
minHeight: toRem(102),
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
overflow: 'hidden',
},
]);
// Wider, responsive card for interactive embeds (video players, tweets, posts)
// so the player chrome / tweet content isn't cramped or clipped. Defined after
// UrlPreview so its width wins the cascade.
export const UrlPreviewWide = style({
width: 'min(38rem, 94vw)',
});
// The Twitch/Twitter/TikTok-fallback cards lay their header/thumbnail out BESIDE
// the content as direct children of the UrlPreview flex row; on a phone that
// squeezes both. Stack them vertically on narrow viewports only. `UrlPreview`'s
// Box has no explicit direction (browser default row), so this override wins
// with nothing to compete against, and desktop (>750px) is unchanged.
export const StackOnMobile = style({
'@media': {
'(max-width: 750px)': {
flexDirection: 'column',
},
},
});
export const UrlPreviewImg = style([
DefaultReset,
{
width: toRem(100),
height: toRem(100),
objectFit: 'cover',
objectPosition: 'center',
flexShrink: 0,
overflow: 'hidden',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.8)',
},
},
]);
export const UrlPreviewContent = style([
DefaultReset,
{
padding: config.space.S200,
},
]);
export const UrlPreviewDescription = style([
DefaultReset,
{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
},
]);
// YouTube card styles
export const YouTubeThumbnailWrapper = style([
DefaultReset,
{
position: 'relative',
flexShrink: 0,
width: toRem(160),
height: toRem(90),
overflow: 'hidden',
cursor: 'pointer',
backgroundColor: color.Surface.Container,
':hover': {
filter: 'brightness(0.85)',
},
},
]);
export const YouTubeThumbnailImg = style([
DefaultReset,
{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
export const YouTubePlayOverlay = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
},
]);
export const YouTubePlayButton = style([
DefaultReset,
{
width: toRem(44),
height: toRem(44),
borderRadius: '50%',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
},
]);
// GitHub card styles
export const GitHubIconWrapper = style([
DefaultReset,
{
flexShrink: 0,
width: toRem(72),
minHeight: toRem(72),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: color.SurfaceVariant.OnContainer,
opacity: 0.8,
backgroundColor: color.Surface.ContainerHover,
},
]);
// Generic card favicon styles
export const GenericFaviconWrapper = style([
DefaultReset,
{
display: 'inline-flex',
alignItems: 'center',
gap: config.space.S100,
verticalAlign: 'middle',
},
]);
export const GenericFaviconImg = style([
DefaultReset,
{
width: toRem(16),
height: toRem(16),
flexShrink: 0,
},
]);
// ---------------------------------------------------------------------------
// Shared media-card thumbnail (VideoCard, Steam, etc.)
// ---------------------------------------------------------------------------
export const MediaThumbnailWrapper = style([
DefaultReset,
{
position: 'relative',
flexShrink: 0,
width: toRem(160),
height: toRem(90),
overflow: 'hidden',
cursor: 'pointer',
backgroundColor: color.Surface.Container,
':hover': {
filter: 'brightness(0.85)',
},
},
]);
export const MediaThumbnailImg = style([
DefaultReset,
{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
export const MediaPlayOverlay = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
},
]);
export const MediaPlayButton = style([
DefaultReset,
{
width: toRem(44),
height: toRem(44),
borderRadius: '50%',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
},
]);
// ---------------------------------------------------------------------------
// Inline video embed (YouTube / Vimeo) — media-forward click-to-play facade
// ---------------------------------------------------------------------------
export const EmbedMediaLandscape = style([
DefaultReset,
{
position: 'relative',
width: '100%',
// 16:9 via the padding-top hack (56.25%), NOT `aspect-ratio`. An absolutely-
// positioned *replaced* element (the player <iframe>) collapses to its ~200px
// intrinsic size inside an `aspect-ratio` box; padding derives a definite height
// from the definite width so the iframe (inset:0) reliably fills it.
height: 0,
paddingTop: '56.25%',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
selectors: {
'&:fullscreen': { width: '100vw', height: '100vh', paddingTop: 0 },
},
},
]);
export const EmbedMediaPortrait = style([
DefaultReset,
{
position: 'relative',
// [Gitea #228] `min()` on both axes so the 9:16 ratio holds whatever the
// parent's width is. A padding-top percentage resolves against the
// CONTAINING BLOCK's width, not the element's, so a bare `177.78%` inside a
// wide card made the box ~2x too tall (606px card → 1077px instead of 533).
// Capped at the pixel height of a 300px-wide 9:16 box, it is exact when the
// parent is at least 300px and falls back to the percentage below that.
width: `min(${toRem(300)}, 100%)`,
height: 0,
paddingTop: `min(${toRem(533)}, 177.78%)`,
margin: '0 auto',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
selectors: {
'&:fullscreen': { width: '100vw', height: '100vh', paddingTop: 0, margin: 0 },
},
},
]);
// Fills the aspect box; used for both the <button> facade and the <a> fallback.
export const EmbedFacade = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
display: 'block',
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.85)',
},
selectors: {
'&:focus-visible': {
outline: `2px solid ${color.Primary.Main}`,
outlineOffset: '-2px',
},
},
},
]);
export const EmbedIframe = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
border: 0,
display: 'block',
},
]);
// Variable-height iframe that sizes itself (X/Twitter, Instagram, Reddit posts —
// height set inline). Capped ~550px (X/IG native max) + centered in the wide card.
export const EmbedIframeStatic = style([
DefaultReset,
{
width: '100%',
maxWidth: toRem(550),
margin: '0 auto',
border: 0,
display: 'block',
},
]);
// Shown over (or instead of) an embed that hung or that the provider says is gone.
export const EmbedNotice = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: config.space.S200,
padding: config.space.S300,
textAlign: 'center',
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
},
]);
export const EmbedNoticeStatic = style([
EmbedNotice,
{
position: 'static',
minHeight: toRem(120),
borderRadius: config.radii.R300,
},
]);
export const EmbedPlaceholder = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#111111',
color: '#ffffff',
fontSize: toRem(28),
},
]);
// Audio embeds (Spotify / SoundCloud) — full-width, short fixed height (set inline).
export const EmbedMediaAudio = style([
DefaultReset,
{
position: 'relative',
width: '100%',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
},
]);
// ---------------------------------------------------------------------------
// Shared square artwork thumbnail (Spotify, IMDb poster, Discord icon)
// ---------------------------------------------------------------------------
export const ArtworkThumbnail = style([
DefaultReset,
{
flexShrink: 0,
width: toRem(72),
height: toRem(72),
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
// IMDb portrait poster (60 × 90)
export const PosterThumbnail = style([
DefaultReset,
{
flexShrink: 0,
width: toRem(60),
height: toRem(90),
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
// ---------------------------------------------------------------------------
// Site-badge shared base
// ---------------------------------------------------------------------------
export const SiteBadge = style([
DefaultReset,
{
display: 'inline-flex',
alignItems: 'center',
gap: config.space.S100,
paddingLeft: config.space.S100,
paddingRight: config.space.S100,
paddingTop: '2px',
paddingBottom: '2px',
borderRadius: config.radii.R300,
fontSize: toRem(11),
fontWeight: 700,
lineHeight: '1.4',
letterSpacing: '0.03em',
flexShrink: 0,
},
]);
// Individual badge colour overrides (background / foreground)
export const BadgeVimeo = style({
backgroundColor: '#1ab7ea',
color: '#ffffff',
});
export const BadgeTwitter = style({
backgroundColor: '#000000',
color: '#ffffff',
});
export const BadgeReddit = style({
backgroundColor: '#ff4500',
color: '#ffffff',
});
export const BadgeSpotify = style({
backgroundColor: '#1db954',
color: '#ffffff',
});
export const BadgeTwitch = style({
backgroundColor: '#9146ff',
color: '#ffffff',
});
export const BadgeSteam = style({
backgroundColor: '#1b2838',
color: '#c7d5e0',
});
// ---------------------------------------------------------------------------
// Steam card — full-width header/banner image + official store widget iframe
// ---------------------------------------------------------------------------
export const SteamBannerWrapper = style([
DefaultReset,
{
position: 'relative',
display: 'block',
width: '100%',
// Steam header capsules are 460×215 (~2.14:1); news banners vary but crop
// fine to the same ratio.
aspectRatio: '460 / 215',
overflow: 'hidden',
flexShrink: 0,
backgroundColor: '#0e1520',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.9)',
},
},
]);
export const SteamBannerImg = style([
DefaultReset,
{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
// The official Steam store widget is a compact banner (~646×190). Full-width,
// fixed height so the iframe doesn't collapse to its intrinsic size.
export const SteamWidget = style([
DefaultReset,
{
width: '100%',
height: toRem(190),
border: 0,
display: 'block',
borderRadius: config.radii.R300,
backgroundColor: '#1b2838',
marginTop: config.space.S100,
},
]);
export const BadgeWikipedia = style({
backgroundColor: color.SurfaceVariant.ContainerLine,
color: color.SurfaceVariant.OnContainer,
});
export const BadgeDiscord = style({
backgroundColor: '#5865f2',
color: '#ffffff',
});
export const BadgeNpm = style({
backgroundColor: '#cb3837',
color: '#ffffff',
});
export const BadgeStackOverflow = style({
backgroundColor: '#f48024',
color: '#ffffff',
});
export const BadgeImdb = style({
backgroundColor: '#f5c518',
color: '#000000',
});
export const BadgeYouTubeShorts = style({
backgroundColor: '#FF0000',
color: '#ffffff',
});
export const BadgeTikTok = style({
backgroundColor: '#000000',
color: '#ffffff',
});
export const BadgeSoundCloud = style({
backgroundColor: '#ff5500',
color: '#ffffff',
});
export const BadgeDailymotion = style({
backgroundColor: '#0066dc',
color: '#ffffff',
});
export const BadgeStreamable = style({
backgroundColor: '#0f90fa',
color: '#ffffff',
});
export const BadgeAppleMusic = style({
backgroundColor: '#fa243c',
color: '#ffffff',
});
export const BadgeInstagram = style({
backgroundColor: '#e1306c',
color: '#ffffff',
});
export const BadgeBluesky = style({
backgroundColor: '#0085ff',
color: '#ffffff',
});
export const BadgeLoom = style({
backgroundColor: '#625df5',
color: '#ffffff',
});
export const BadgeKick = style({
backgroundColor: '#53fc18',
color: '#000000',
});
export const BadgeTidal = style({
backgroundColor: '#000000',
color: '#ffffff',
});
export const BadgeMixcloud = style({
backgroundColor: '#52aad8',
color: '#ffffff',
});
export const BadgeDeezer = style({
backgroundColor: '#a238ff',
color: '#ffffff',
});
// ---------------------------------------------------------------------------
// Twitch LIVE badge
// ---------------------------------------------------------------------------
export const TwitchLiveBadge = 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: '#e91916',
color: '#ffffff',
letterSpacing: '0.04em',
},
]);
// ---------------------------------------------------------------------------
// Icon-wrapper (generic left-side icon block — Twitter, Reddit, Wikipedia…)
// ---------------------------------------------------------------------------
export const IconWrapper = style([
DefaultReset,
{
flexShrink: 0,
width: toRem(72),
minHeight: toRem(72),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
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',
},
]);
// ---------------------------------------------------------------------------
// GIF card (Giphy / Tenor)
// ---------------------------------------------------------------------------
export const GifThumbnailWrapper = style([
DefaultReset,
{
position: 'relative',
width: '100%',
maxHeight: toRem(200),
overflow: 'hidden',
flexShrink: 0,
backgroundColor: color.Surface.Container,
cursor: 'pointer',
':hover': {
filter: 'brightness(0.9)',
},
},
]);
export const GifThumbnailImg = style([
DefaultReset,
{
width: '100%',
maxHeight: toRem(200),
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
export const GifBadge = style([
DefaultReset,
{
position: 'absolute',
top: config.space.S100,
right: config.space.S100,
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',
letterSpacing: '0.05em',
backgroundColor: 'rgba(0, 0, 0, 0.65)',
color: '#ffffff',
pointerEvents: 'none',
},
]);
export const BadgeGiphy = style({
backgroundColor: '#00c0c0',
color: '#ffffff',
});
export const BadgeTenor = style({
backgroundColor: '#0078d4',
color: '#ffffff',
});