Files
cinny/src/app/components/url-preview/UrlPreview.css.tsx
T
jaredandClaude Opus 4.8 d615999737 fix(mobile): overflow breaks — tables, composer, call bar, previews, cards (M1)
Mobile-audit batch 1. All fixes reuse cinny's own responsive primitives and are
mobile-gated so desktop is unchanged.

- Message tables: wrap <table> in an overflow-x container so a wide table scrolls
  instead of overflowing the message column / page body.
- Composer toolbar: let the before|editable|after row and the toolbar wrap on
  phones (@media <=750px) instead of squeezing the editable to zero and pushing
  Send off-screen.
- In-call control bar: collapse to the compact/stacked layout on a mobile
  viewport (ScreenSize.Mobile) too, not just when the bar's own container is
  <500px — fixes the 500-750px band where the control row overflowed.
- URL-preview card: base width toRem(400) -> min(25rem, 92vw) so a single card
  fits a narrow phone (still exactly 400px on desktop).
- Explore card grid: drop to one column at <=750px (was a fixed 3-col grid).

Two review passes: desktop behavior provably unchanged (all gated by @media /
ScreenSize.Mobile; the table wrapper only contains previously-overflowing tables).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:14:51 -04:00

914 lines
20 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)',
});
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',
width: toRem(300),
maxWidth: '100%',
// 9:16 via the padding-top hack (177.78%) — see EmbedMediaLandscape.
height: 0,
paddingTop: '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',
});
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',
});
// ---------------------------------------------------------------------------
// 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',
});