feat(web): home story-card polish + play page back-link rebrand

Home (apps/web/app/page.tsx):
- StoryCard locked to uniform aspectRatio "4 / 5". The previous
  "placeholder 4/5 → naturalRatio after onLoad" flow coupled card
  height to lazy-load order: cards still below the fold sat at the
  placeholder ratio while above-the-fold cards snapped to their
  image's actual ratio (1.6 landscape vs 0.75 portrait vs 1.23
  squarish), so the gallery looked inconsistent until a hard refresh
  re-decoded everything from cache synchronously. Fixed ratio +
  object-cover removes the coupling.
- StoryCard hover overlay collapsed from two sibling layers
  (backdrop-blur + mask-image + dark gradient sibling) into one
  element with a pure rgba(0,0,0,…) linear-gradient and an opacity
  transition. Chromium does not animate backdrop-filter cleanly when
  combined with mask-image on an empty element — the first hover
  frame shows a full rectangular blur before the mask kicks in, then
  snaps to the feathered shape ("矩形磨砂 → 渐变磨砂"). One layer,
  one transitioning property, no compositing race.

Play (apps/web/app/play/page.tsx):
- Header back-link "云梦" → "InfiPlot" using the same serif + italic
  ember "Plot" treatment as the homepage wordmark. Resolved against
  the parallel plain-text rebrand already on infiplot/staging by
  keeping the styled version for brand consistency.
This commit is contained in:
DESKTOP-I1T6TF3\Q
2026-06-02 14:42:26 +08:00
parent 9a3511f220
commit 6da87df73a
2 changed files with 28 additions and 31 deletions
+24 -29
View File
@@ -183,54 +183,49 @@ function StoryCard({
title, title,
outline, outline,
image, image,
placeholderRatio = 4 / 5,
onClick, onClick,
}: { }: {
title: string; title: string;
outline: string; outline: string;
image: string; image: string;
placeholderRatio?: number;
onClick: () => void; onClick: () => void;
}) { }) {
// 卡片高度 = 图片真实宽高比。加载前先用 placeholderRatio 占好位(按该类卡片 // 卡片统一 4:5 portrait 比例。原来按图片真实 naturalWidth/Height 动态设 aspectRatio
// 的典型比例),加载后用 naturalWidth/Height 锁死真实比例——绝不塌成 0、也绝不 // 会跟懒加载顺序耦合:视口下方还没加载的卡停在 placeholder 比例,上方已加载的卡变成
// 在 lazy 图加载或性向换图时跳变高度。运行时读取,故换任意图都自动适配。 // 图片真实比例(可能是 1.6 横图或 0.75 竖图),视觉差异巨大;刷新后图从缓存读,
const [ratio, setRatio] = useState<number>(); // onLoad 几乎同步触发,看起来又恢复正常 —— 用户感知到的「偶尔尺寸不一样」就是这个。
// 改为固定比例后所有卡片视觉一致,object-cover 让不同长宽比的图自动裁切适配。
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
style={{ aspectRatio: ratio ?? placeholderRatio }} style={{ aspectRatio: "4 / 5" }}
className="group relative block w-full mb-4 md:mb-5 break-inside-avoid overflow-hidden rounded-sm border border-clay-900/10 bg-cream-100 text-left transition-transform duration-300 ease-out hover:-translate-y-1" className="group relative block w-full mb-4 md:mb-5 break-inside-avoid overflow-hidden rounded-sm border border-clay-900/10 bg-cream-100 text-left transition-transform duration-300 ease-out hover:-translate-y-1"
> >
<img <img
src={image} src={image}
alt={title} alt={title}
loading="lazy" loading="lazy"
onLoad={(e) => {
const el = e.currentTarget;
if (el.naturalWidth && el.naturalHeight) {
setRatio(el.naturalWidth / el.naturalHeight);
}
}}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
/> />
{/* hover 浮层:卡片高度已由图片比例锁定,磨砂带占比恒定,hover 前后零回流。 */} {/* hover 浮层:照参考项目(yunmeng0530/yume)的写法——满卡片单元素,纯 rgba
<div className="absolute inset-x-0 bottom-0"> 黑色 linear-gradient + opacity 过渡。完全不用 backdrop-filter / mask-image
<div className="relative px-4 pt-10 pb-4"> 从根上消除 Chromium 上「矩形磨砂 → 渐变磨砂」的跳变(这两个属性的合成顺序
{/* 毛玻璃底:backdrop-blur 0→md(不走 opacity,避免比文字慢半拍);上沿 mask 羽化,避免生硬分界 */} 是真正的元凶;只要不用它们,就不会有这个 bug)。
<div className="absolute inset-0 backdrop-blur-0 transition-[backdrop-filter] duration-300 ease-out group-hover:backdrop-blur-md [mask-image:linear-gradient(to_top,black_62%,transparent)] [-webkit-mask-image:linear-gradient(to_top,black_62%,transparent)]" /> - bottom 0.9 → 45% 处 0.45 → top 0:自然羽化,底部聚焦文字、顶部完全透出图。 */}
{/* 暗色渐变:opacity 淡入(自带 to-transparent 上沿,无需额外 mask */} <div
<div className="absolute inset-0 opacity-0 transition-opacity duration-300 ease-out group-hover:opacity-100 bg-gradient-to-t from-clay-900/92 via-clay-900/60 to-transparent" /> className="absolute inset-0 opacity-0 transition-opacity duration-300 ease-out group-hover:opacity-100 flex flex-col justify-end p-4 md:p-5"
<div className="relative opacity-0 transition-opacity duration-300 ease-out group-hover:opacity-100"> style={{
<h4 className="font-serif text-cream-50 text-base md:text-lg leading-snug mb-1 [text-shadow:0_1px_8px_rgba(20,10,4,0.6)]"> background:
{title} "linear-gradient(to top, rgba(0,0,0,0.9), rgba(0,0,0,0.45) 45%, rgba(0,0,0,0) 100%)",
</h4> }}
<p className="font-serif italic text-cream-50/95 text-xs md:text-[13px] leading-relaxed line-clamp-4 [text-shadow:0_1px_6px_rgba(20,10,4,0.55)]"> >
{outline} <h4 className="font-serif text-cream-50 text-base md:text-lg leading-snug mb-1 [text-shadow:0_1px_8px_rgba(20,10,4,0.7)]">
</p> {title}
</div> </h4>
</div> <p className="font-serif italic text-cream-50/95 text-xs md:text-[13px] leading-relaxed line-clamp-4 [text-shadow:0_1px_6px_rgba(20,10,4,0.6)]">
{outline}
</p>
</div> </div>
</button> </button>
); );
+4 -2
View File
@@ -890,10 +890,12 @@ function PlayInner() {
<header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between"> <header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between">
<Link <Link
href="/" href="/"
className="text-[10px] smallcaps text-clay-600 hover:text-clay-900 transition-colors flex items-center gap-2" className="text-clay-600 hover:text-clay-900 transition-colors flex items-center gap-2"
> >
<i className="fa-solid fa-arrow-left text-[9px]" /> <i className="fa-solid fa-arrow-left text-[9px]" />
InfiPlot <span className="font-serif text-[15px] leading-none tracking-tight">
Infi<em className="italic font-light text-ember-500">Plot</em>
</span>
</Link> </Link>
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500 num"> <div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500 num">
<span> · {String(sceneCount).padStart(3, "0")} · </span> <span> · {String(sceneCount).padStart(3, "0")} · </span>