Files
cinny/src/app/components/url-preview/UrlPreview.css.tsx
T
jaredandClaude Opus 5 08abf5aec8 fix(embeds): players fill the card and keep their aspect ratio (#228)
Two sizing bugs, both visible the moment a video starts:

1. The video/TikTok embed columns had no width. UrlPreview is a flex ROW,
   so they shrink-to-fit: the facade's <img> supplied the width, but the
   player <iframe> is absolutely positioned and supplies none, so pressing
   play collapsed the whole embed to the iframe's ~200px intrinsic size
   (measured 606x341 -> 204x115 in a 608px card). Both columns are now
   width: 100%.
2. EmbedMediaPortrait's 9:16 came from a 177.78% padding-top, but a
   padding percentage resolves against the CONTAINING BLOCK's width, not
   the element's — so inside a wide card a Short/TikTok rendered
   300x1077 instead of 300x533. Capped with min() so it is exact at
   >= 300px and still correct on narrower phones.

Audited every provider before/after play at 1300 px, 500 px and Pixel 7:
YouTube, Vimeo, Twitch 16:9 (0.56); Shorts, TikTok 9:16 (1.78); Spotify
152 px and SoundCloud 166 px fixed-height — all stable across play.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:37:57 -04:00

991 lines
22 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',
},
]);
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',
});