602acf7a16
## Problem On the home feature cards, the visual frame is capped at `max-width: 411px` (the scene's design width) and centered. Below ~411px-wide cards this is invisible, but once a card grows past 411px (wider viewports) the dark scene stops filling and the **card's light background shows on both sides** of the visual. At 1200px everything looks correct because the cards are narrower than 411px and the cap is never engaged; the issue only appears as the viewport widens. ## Fix `FeatureCard.tsx`, one file: - **Remove the `max-width: 411px` cap** (and the now-dead `margin: 0 auto`) from `CardImageFrame` so the frame fills the card width at every breakpoint. `useScaleToFit` then scales the 411×508 scene up to match — it's a CSS transform on DOM, so it stays crisp; no raster upscaling. - **Even out the card gutter** — `CardImage` padding `8px → 16px` (top + sides) so the visual's inset matches the content's 16px inset instead of stepping in. Bottom stays `0` (the content block's 16px provides the bottom gutter). The visual scenes themselves are untouched — this is purely the frame/container. ## Before <img width="1477" height="681" alt="image" src="https://github.com/user-attachments/assets/731f1ef7-e761-468e-b7aa-a5a06f8ac790" /> ## After <img width="1473" height="705" alt="image" src="https://github.com/user-attachments/assets/99d036a6-d3ad-4682-99c8-f283b5b95171" />
178 lines
4.6 KiB
TypeScript
178 lines
4.6 KiB
TypeScript
'use client';
|
|
|
|
import { useLingui } from '@lingui/react';
|
|
import { styled } from '@linaria/react';
|
|
import { IconUsersGroup } from '@tabler/icons-react';
|
|
import NextImage from 'next/image';
|
|
import { useRef, useState, type ReactNode } from 'react';
|
|
|
|
import { FastPathIcon, LiveDataIcon } from '@/icons';
|
|
import {
|
|
color,
|
|
FONT_WEIGHT,
|
|
fontFamily,
|
|
radius,
|
|
spacing,
|
|
typeRampDeclarations,
|
|
} from '@/tokens';
|
|
import { Body } from '@/ui';
|
|
|
|
import { FamiliarInterfaceVisual } from './familiar-interface/FamiliarInterfaceVisual';
|
|
import { type FeatureCardRecord } from './feature-cards.data';
|
|
import { LiveDataVisual } from './live-data/LiveDataVisual';
|
|
import { FastPathVisual } from './fast-path/FastPathVisual';
|
|
|
|
const CardContainer = styled.div`
|
|
background-color: ${color('black-5')};
|
|
border: 1px solid ${color('black-20')};
|
|
border-radius: ${radius(2)};
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
grid-template-rows: auto auto;
|
|
height: 100%;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
`;
|
|
|
|
const CardImage = styled.div`
|
|
box-sizing: border-box;
|
|
padding: ${spacing(4)} ${spacing(4)} 0;
|
|
width: 100%;
|
|
`;
|
|
|
|
// The scene design box every feature-card visual is authored in.
|
|
const SCENE_DESIGN_WIDTH_PX = 411;
|
|
const SCENE_DESIGN_HEIGHT_PX = 508;
|
|
|
|
// Locked to the scene design box: every scene renders a fixed 411x508
|
|
// design box scaled by frame-width / 411, so the frame, the scaled scene,
|
|
// and any inner content scale together at every breakpoint. The frame fills
|
|
// the card width (no max-width cap) so the dark scene reaches both edges
|
|
// instead of leaving the card background showing once the card exceeds 411.
|
|
const CardImageFrame = styled.div`
|
|
aspect-ratio: ${SCENE_DESIGN_WIDTH_PX} / ${SCENE_DESIGN_HEIGHT_PX};
|
|
background-color: ${color('black-10')};
|
|
border-radius: 2px;
|
|
isolation: isolate;
|
|
overflow: hidden;
|
|
position: relative;
|
|
width: 100%;
|
|
`;
|
|
|
|
const CardContent = styled.div`
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
padding: ${spacing(3)} ${spacing(4)} ${spacing(4)};
|
|
|
|
& > * + * {
|
|
margin-top: ${spacing(2)};
|
|
}
|
|
`;
|
|
|
|
const CardTitleRow = styled.div`
|
|
align-items: center;
|
|
column-gap: ${spacing(2)};
|
|
display: grid;
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|
`;
|
|
|
|
const IconMarker = styled.div`
|
|
align-items: center;
|
|
background: ${color('blue')};
|
|
border-radius: ${radius(1)};
|
|
color: ${color('white')};
|
|
display: flex;
|
|
height: 22px;
|
|
justify-content: center;
|
|
width: 22px;
|
|
`;
|
|
|
|
const CardHeading = styled.h3`
|
|
${typeRampDeclarations('headingXs')}
|
|
font-family: ${fontFamily('sans')};
|
|
font-weight: ${FONT_WEIGHT.medium};
|
|
min-width: 0;
|
|
`;
|
|
|
|
function FeatureIcon({ icon }: { icon: FeatureCardRecord['icon'] }) {
|
|
if (icon === 'users-group') {
|
|
return <IconUsersGroup aria-hidden size={14} stroke={2} />;
|
|
}
|
|
if (icon === 'fast-path') return <FastPathIcon sizePx={14.667} />;
|
|
return <LiveDataIcon sizePx={14.667} />;
|
|
}
|
|
|
|
export function FeatureCard({ card }: { card: FeatureCardRecord }) {
|
|
const { i18n } = useLingui();
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const imageFrameRef = useRef<HTMLDivElement>(null);
|
|
|
|
const backgroundImage = (
|
|
<NextImage
|
|
alt=""
|
|
fill
|
|
sizes={`(max-width: 768px) 100vw, ${SCENE_DESIGN_WIDTH_PX}px`}
|
|
src={card.backgroundImageSrc}
|
|
style={{ objectFit: 'cover' }}
|
|
/>
|
|
);
|
|
|
|
let visual: ReactNode = backgroundImage;
|
|
if (card.illustration === 'familiar-interface') {
|
|
visual = (
|
|
<FamiliarInterfaceVisual
|
|
active={isHovered}
|
|
backgroundImageSrc={card.backgroundImageSrc}
|
|
pointerTargetRef={imageFrameRef}
|
|
/>
|
|
);
|
|
}
|
|
if (card.illustration === 'live-data') {
|
|
visual = (
|
|
<LiveDataVisual
|
|
active={isHovered}
|
|
backgroundImageSrc={card.backgroundImageSrc}
|
|
pointerTargetRef={imageFrameRef}
|
|
/>
|
|
);
|
|
}
|
|
if (card.illustration === 'fast-path') {
|
|
visual = (
|
|
<FastPathVisual
|
|
active={isHovered}
|
|
backgroundImageSrc={card.backgroundImageSrc}
|
|
pointerTargetRef={imageFrameRef}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<CardContainer
|
|
onPointerEnter={() => {
|
|
setIsHovered(true);
|
|
}}
|
|
onPointerLeave={() => {
|
|
setIsHovered(false);
|
|
}}
|
|
>
|
|
<CardImage>
|
|
<CardImageFrame
|
|
data-illustration={card.illustration}
|
|
ref={imageFrameRef}
|
|
>
|
|
{visual}
|
|
</CardImageFrame>
|
|
</CardImage>
|
|
<CardContent>
|
|
<CardTitleRow>
|
|
<IconMarker aria-hidden>
|
|
<FeatureIcon icon={card.icon} />
|
|
</IconMarker>
|
|
<CardHeading>{i18n._(card.heading)}</CardHeading>
|
|
</CardTitleRow>
|
|
<Body size="sm">{i18n._(card.body)}</Body>
|
|
</CardContent>
|
|
</CardContainer>
|
|
);
|
|
}
|