详情页血缘链改竖向滚动单链:复用族谱节点内容组件,卡片不再逐字换行
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 00:41:06 +08:00
parent 4fdcdc4935
commit 62a35d0313
5 changed files with 483 additions and 152 deletions
@@ -1,17 +1,18 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { GameDistributionLineageNode } from '../../services/gameDistributionClient';
import {
coCreationAvatarInitial,
coCreationGenerationShortLabel,
} from './coCreationCopy';
import { LineageNodeContent } from './LineageNodeContent';
/**
* 血缘溯源条:横向完整链路「根原创 → 1 代 → 2 代 → … → 当前作品」。
* 详情页「共创」卡里的**血缘链**:竖向单链(根在最上、当前作品在最下)。
*
* 数据来自既有 `/games/{id}/lineage`(在详情页请求一次,按 `parentGameId` 上溯成链,
* 不用整棵大树、也不新增接口)。每个节点显示作者字标 + 世代,点击跳该代作品详情。
* 复用族谱那一套:链条顺序来自 `collectLineageAncestorChain`(族谱布局模块的纯函数)、
* 节点内容来自 `LineageNodeContent`(与族谱画布同一个组件),这里只负责**嵌入态的排布**:
* 窄容器(~220–260px)里**只能纵向滚动**,横向裁掉(`overflow-x: hidden`),
* 初次进入与「当前作品 id 变化」时自动滚到底部,让当前游戏可见。
*
* 头像:`/lineage` 节点只带 `authorName`(没有头像字段),因此用姓名首字的圆形字标降级,
* 不伪造头像图片;后端补字段后换成真实头像即可(结构不用改)。
* 为什么只画祖先链、不画整棵树:右栏只有 200 多像素宽,塞整棵树必然又挤成一片;
* 整棵树由既有的「查看创作族谱」入口负责(点击进入 `/games/lineage?id=<root>`)。
*/
export function CoCreationLineageStrip({
chain,
@@ -23,48 +24,104 @@ export function CoCreationLineageStrip({
currentGameId: string;
onOpenGameDetail: (gameId: string) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
/** 上面还有几代没露出来(滚动位置之上还有内容时给「↑ 还有 N 代」提示)。 */
const [hiddenAboveCount, setHiddenAboveCount] = useState(0);
// 默认滚到底部(当前作品在最下),并在当前作品变化时重新滚到底。
useEffect(() => {
const element = scrollRef.current;
if (!element) return;
element.scrollTop = element.scrollHeight - element.clientHeight;
setHiddenAboveCount(0);
}, [currentGameId, chain.length]);
const handleScroll = () => {
const element = scrollRef.current;
if (!element) return;
const above = element.scrollTop;
const nodeHeight = element.scrollHeight / Math.max(chain.length, 1);
setHiddenAboveCount(Math.max(0, Math.round(above / Math.max(nodeHeight, 1))));
};
const ancestors = useMemo(() => chain.slice(0, -1), [chain]);
/** 主干:每代 playCount 最高者(与族谱页同一语义:主干链的导线加粗)。 */
const trunkGameIds = useMemo(() => {
const bestByGeneration = new Map<number, GameDistributionLineageNode>();
for (const node of chain) {
const current = bestByGeneration.get(node.generation);
if (!current || node.playCount > current.playCount) {
bestByGeneration.set(node.generation, node);
}
}
return new Set([...bestByGeneration.values()].map((node) => node.gameId));
}, [chain]);
if (chain.length <= 1) return null;
return (
<div className="co-creation-lineage" aria-label="血缘溯源">
{chain.map((node, index) => {
const isCurrent = node.gameId === currentGameId;
return (
<span key={node.gameId} className="co-creation-lineage__item">
{index > 0 ? (
<span className="co-creation-lineage__arrow" aria-hidden="true">
→
</span>
) : null}
<button
type="button"
className={`co-creation-lineage__node${
isCurrent ? ' co-creation-lineage__node--current' : ''
}`}
aria-current={isCurrent ? 'true' : undefined}
aria-label={`${coCreationGenerationShortLabel(node.generation)}作品《${node.title}》· ${node.authorName?.trim() || '未署名作者'}`}
onClick={() => {
if (!isCurrent) onOpenGameDetail(node.gameId);
}}
>
<span className="co-creation-lineage__avatar" aria-hidden="true">
{coCreationAvatarInitial(node.authorName)}
</span>
<span className="co-creation-lineage__text">
<span className="co-creation-lineage__generation">
{coCreationGenerationShortLabel(node.generation)}
</span>
<span className="co-creation-lineage__title">{node.title}</span>
<span className="co-creation-lineage__author">
{node.authorName?.trim() || '未署名作者'}
</span>
</span>
</button>
</span>
);
})}
<div className="co-creation-chain">
<div
className="co-creation-chain__scroll"
ref={scrollRef}
onScroll={handleScroll}
aria-label="创作血缘链"
tabIndex={0}
>
{chain.map((node, index) => {
const isCurrent = node.gameId === currentGameId;
return (
<span key={node.gameId} className="co-creation-chain__row">
{index > 0 ? (
<span
className={`co-creation-chain__connector${
trunkGameIds.has(node.gameId) ? ' co-creation-chain__connector--trunk' : ''
}`}
aria-hidden="true"
/>
) : null}
<button
type="button"
className={`co-creation-chain__node${
isCurrent ? ' co-creation-chain__node--current' : ''
}`}
aria-current={isCurrent ? 'true' : undefined}
aria-label={`${generationLabel(node.generation)}《${node.title}》· ${
node.authorName?.trim() || '未署名作者'
}`}
onClick={() => {
if (!isCurrent) onOpenGameDetail(node.gameId);
}}
>
<LineageNodeContent
layout="inline"
title={node.title}
authorName={node.authorName ?? null}
generation={node.generation}
coverObjectKey={node.coverObjectKey}
isAnchor={isCurrent}
isTrunk={trunkGameIds.has(node.gameId)}
/>
</button>
</span>
);
})}
</div>
{hiddenAboveCount > 0 ? (
<span className="co-creation-chain__notice" role="status">
↑ 还有 {hiddenAboveCount} 代(共 {chain.length} 代)
</span>
) : null}
{ancestors.length > 0 ? (
<span className="co-creation-chain__legend">
根在最上、当前作品在最下,可纵向滚动
</span>
) : null}
</div>
);
}
function generationLabel(generation: number) {
return generation <= 0 ? '0 代原创' : `${generation} 代`;
}
export default CoCreationLineageStrip;
@@ -2353,6 +2353,38 @@ describe('GameDetailPage 共创主题入口', () => {
});
});
/** 4 节点、3 代的血缘链数据集(根 → 1 代 → 2 代 → 当前),供详情页血缘链用例共用。 */
function buildChainDataset() {
return {
rootGameId: 'game-root',
root: null,
nodes: [
createLineageNode({
gameId: 'game-root',
title: '根原创',
authorName: '根作者',
generation: 0,
parentGameId: null,
}),
createLineageNode({
gameId: 'game-mid',
title: '一代作品',
authorName: '一代作者',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-current',
title: '当前作品',
authorName: '当前作者',
generation: 2,
parentGameId: 'game-mid',
}),
],
truncated: false,
};
}
/** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器:多个 describe 共用。 */
const openButton = (title: string) =>
screen.getByRole('button', { name: `打开《${title}》详情` });
@@ -2980,21 +3012,160 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
/>,
);
const strip = await screen.findByLabelText('血缘溯源');
const strip = await screen.findByLabelText('创作血缘链');
expect(strip.textContent).toContain('根原创');
expect(strip.textContent).toContain('一代作品');
expect(strip.textContent).toContain('当前作品');
// 头像位是姓名首字(链路节点没有头像字段,不伪造头像图)。
expect(strip.textContent).toContain('根');
// 当前作品标记为 current 且点击不跳转;点中间一代跳该作品。
// 顺序必须是 根 → 1 代 → 当前(DOM 顺序)。
const chainText = strip.textContent ?? '';
expect(chainText.indexOf('根原创')).toBeLessThan(
chainText.indexOf('一代作品'),
);
expect(chainText.indexOf('一代作品')).toBeLessThan(
chainText.indexOf('当前作品'),
);
// 当前作品有明确标记(aria-current + current 类)。
const currentNode = strip.querySelector(
'.co-creation-chain__node--current',
) as HTMLElement;
expect(currentNode.getAttribute('aria-current')).toBe('true');
expect(currentNode.textContent).toContain('当前作品');
// 点击根节点跳该作品;点当前作品不跳(本就是当前页)。
fireEvent.click(
screen.getByRole('button', { name: /0 代作品《根原创》/u }),
screen.getByRole('button', { name: /0 代原创《根原创》/u }),
);
expect(onOpenGameDetail).toHaveBeenCalledWith('game-root');
fireEvent.click(
screen.getByRole('button', { name: /2 代作品《当前作品》/u }),
);
fireEvent.click(currentNode);
expect(onOpenGameDetail).toHaveBeenCalledTimes(1);
// 节点是真实 button:键盘可达(Tab 聚焦 + Enter 原生激活)。
const rootNode = screen.getByRole('button', {
name: /0 代原创《根原创》/u,
});
expect(rootNode.tagName).toBe('BUTTON');
rootNode.focus();
expect(document.activeElement).toBe(rootNode);
});
it('血缘链只允许纵向滚动并默认滚到底部(当前作品可见)', async () => {
// jsdom 不计算布局:把可滚动尺寸打桩成「内容 400 / 可视 100」,才能断言「滚到底」。
const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollHeight',
);
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'clientHeight',
);
Object.defineProperty(Element.prototype, 'scrollHeight', {
configurable: true,
get: () => 400,
});
Object.defineProperty(Element.prototype, 'clientHeight', {
configurable: true,
get: () => 100,
});
try {
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' }));
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
const first = render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
const scroll = await screen.findByLabelText('创作血缘链');
// 默认滚到底:scrollTop = scrollHeight - clientHeight = 400 - 100。
await waitFor(() => expect(scroll.scrollTop).toBe(300));
// 只允许纵向滚动:容器类名对应的 CSS 里 overflow-x 是 hidden(另有 CSS 防退化断言)。
expect(scroll.className).toContain('co-creation-chain__scroll');
first.unmount();
// 当前作品 id 变化 → 重新滚到底(而不是停在之前的位置)。
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-mid' }));
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
render(
<GameDetailPage
gameId="game-mid"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
const next = await screen.findByLabelText('创作血缘链');
await waitFor(() => expect(next.scrollTop).toBe(300));
} finally {
if (scrollHeightDescriptor) {
Object.defineProperty(
Element.prototype,
'scrollHeight',
scrollHeightDescriptor,
);
}
if (clientHeightDescriptor) {
Object.defineProperty(
Element.prototype,
'clientHeight',
clientHeightDescriptor,
);
}
}
});
it('血缘链节点卡片:标题不逐字换行、容器不横向滚动(防退化)', async () => {
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
// 防退化:这几条是「窄容器里不被挤成竖排」的根因,改坏了必须变红。
expect(css).toContain('-webkit-line-clamp: 2');
expect(css).toContain('overflow-x: hidden');
expect(css).toContain('min-width: 0');
expect(css).toContain('text-overflow: ellipsis');
expect(css).toContain('.game-lineage-node__content--inline');
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' }));
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
const scroll = await screen.findByLabelText('创作血缘链');
expect(scroll.className).toContain('co-creation-chain__scroll');
// 横向不溢出(jsdom 里两者均为 0,断言的是「没有设置横向滚动」这一契约)。
expect(scroll.scrollWidth <= scroll.clientWidth).toBe(true);
const title = scroll.querySelector('.game-lineage-node__title');
expect(title?.className).toContain('game-lineage-node__title');
expect(title?.textContent).toBe('根原创');
});
it('节点封面用横版小缩略图,链节点是矮而宽的横向排布', async () => {
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' }));
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
const scroll = await screen.findByLabelText('创作血缘链');
expect(
scroll.querySelectorAll('.game-lineage-node__content--inline').length,
).toBeGreaterThan(0);
expect(scroll.querySelectorAll('.co-creation-chain__connector').length).toBe(
2,
);
});
it('热门衍生栏按 playCount 倒序取前 5 个并可在点击后进入作品', async () => {
@@ -25,17 +25,16 @@ import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { GameCover } from './GameCover';
import { useIsMobileViewport } from './gameDistributionGuards';
import {
FORK_LAUNCH_FALLBACK_DELAY_MS,
openForkProtocol,
} from './gameForkProtocol';
import { LineageNodeContent } from './LineageNodeContent';
import {
buildLineageTreeLayout,
collectLineageHighlight,
generationAxisLabel,
LINEAGE_COVER_ASPECT,
type LineageLayoutNode,
} from './lineageTreeLayout';
@@ -109,15 +108,6 @@ function LineageTreeNodeCard({
? `原作品已不可用 · ${generationAxisLabel(generation)}`
: `${generationAxisLabel(generation)}作品《${displayTitle}》· ${authorLabel}`;
// 父作品不可用的占位节点只能聚焦(不可点进详情,也没有 Fork 动作)。
// 封面:族谱节点只带 `coverObjectKey`,没有完整游戏行;`GameCover` 只读
// title / coverColor / icon / coverObjectKey 四个字段,这里用与 `game_payload` 相同的
// 兜底色与图标补上,其余字段不参与渲染——不做逐节点详情请求。
const coverGame = {
title: displayTitle,
coverColor: '#F3E4D0',
icon: '🎮',
coverObjectKey: layoutNode.coverObjectKey,
} as unknown as GameDistributionGame;
return (
<div
@@ -182,38 +172,16 @@ function LineageTreeNodeCard({
onOpenDetail(gameId);
}}
>
<span
className="game-lineage-node__cover"
// 16:9 横版(与发布面板封面预览同一约定):容器比例由常量给出,CSS 不再另写一套。
style={{ aspectRatio: LINEAGE_COVER_ASPECT }}
>
<GameCover
game={coverGame}
objectKey={layoutNode.coverObjectKey}
/>
<span className="game-lineage-node__badge">
{generationAxisLabel(generation)}
</span>
{layoutNode.isTrunk ? (
<span
className="game-lineage-node__trunk"
title="该代热度最高的版本"
>
主干
</span>
) : null}
{isAnchor ? (
<span className="game-lineage-node__anchor">当前作品</span>
) : null}
</span>
<span className="game-lineage-node__strip">
<span className="game-lineage-node__title">{displayTitle}</span>
{compact ? null : (
<span className="game-lineage-node__meta">
<span>{authorLabel}</span>
</span>
)}
</span>
<LineageNodeContent
layout="stacked"
title={layoutNode.title}
authorName={layoutNode.authorName}
generation={generation}
coverObjectKey={layoutNode.coverObjectKey}
isAnchor={isAnchor}
isStub={isStub}
isTrunk={layoutNode.isTrunk}
/>
</button>
<span className="game-lineage-node__actions">
<button
@@ -0,0 +1,75 @@
import type { GameDistributionGame } from '../../services/gameDistributionClient';
import { GameCover } from './GameCover';
import { generationAxisLabel } from './lineageTreeLayout';
/**
* 族谱节点卡片的**内容**(封面 + 代际角标 + 标题 + 作者),族谱画布与详情页血缘链共用。
*
* 抽出来是为了只有一套「封面色块 / 角标 / 省略规则」:详情页的血缘链曾经自己写过一份窄卡片,
* 结果在 ~230px 的右栏里把标题挤成逐字换行。现在两处都用这里的规则:
* - 标题容器 `min-width: 0`(flex 子项默认不收缩的经典坑)+ 2 行 line-clamp + ellipsis;
* - `layout="stacked"`:画布卡片,封面在上、信息条在下;
* - `layout="inline"`:血缘链节点,封面在左、文字在右,**矮而宽**,绝不允许逐字换行。
*/
export function LineageNodeContent({
title,
authorName,
generation,
coverObjectKey,
layout,
isAnchor = false,
isStub = false,
isTrunk = false,
}: {
title: string | null;
authorName: string | null;
generation: number;
coverObjectKey?: string | null;
layout: 'stacked' | 'inline';
isAnchor?: boolean;
isStub?: boolean;
isTrunk?: boolean;
}) {
const displayTitle = isStub ? '原作品已不可用' : (title ?? '未命名作品');
const authorLabel = isStub
? '标题与作者名不对外展示'
: (authorName ?? '').trim() || '未署名作者';
/** `GameCover` 只读 title / coverColor / icon / coverObjectKey;族谱节点没有完整游戏行。 */
const coverGame = {
title: displayTitle,
coverColor: '#F3E4D0',
icon: '🎮',
coverObjectKey,
} as unknown as GameDistributionGame;
return (
<span
className={`game-lineage-node__content game-lineage-node__content--${layout}`}
>
<span className="game-lineage-node__cover">
<GameCover game={coverGame} objectKey={coverObjectKey} />
<span className="game-lineage-node__badge">
{generationAxisLabel(generation)}
</span>
{isTrunk ? (
<span className="game-lineage-node__trunk" title="该代热度最高的版本">
主干
</span>
) : null}
{isAnchor ? (
<span className="game-lineage-node__anchor">当前作品</span>
) : null}
</span>
<span className="game-lineage-node__text">
<span className="game-lineage-node__title">{displayTitle}</span>
{layout === 'stacked' ? null : (
<span className="game-lineage-node__meta">
<span className="game-lineage-node__author">{authorLabel}</span>
</span>
)}
</span>
</span>
);
}
export default LineageNodeContent;
@@ -764,72 +764,70 @@
color: var(--platform-text-soft);
cursor: not-allowed;
}
/* 血缘溯源条:横向链路,可横向滚动不撑破卡片。 */
.game-detail-info-card .co-creation-lineage {
/*
* 详情页共创卡里的血缘链(竖向单链):根在最上、当前作品在最下。
* 容器窄(右栏 ~220–260px)→ **只允许纵向滚动**,横向裁掉,避免被挤成逐字换行;
* 首屏与当前作品变化时由组件滚到底部(当前游戏可见)。
*/
.game-detail-info-card .co-creation-chain {
display: flex;
align-items: stretch;
gap: 0.2rem;
max-width: 100%;
overflow-x: auto;
padding-bottom: 0.2rem;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.game-detail-info-card .co-creation-lineage__item {
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.game-detail-info-card .co-creation-lineage__arrow {
color: var(--platform-text-soft);
font-size: 0.8rem;
}
.game-detail-info-card .co-creation-lineage__node {
display: inline-flex;
align-items: center;
gap: 0.35rem;
.game-detail-info-card .co-creation-chain__scroll {
display: flex;
flex-direction: column;
gap: 0;
max-height: 20rem;
overflow-y: auto;
overflow-x: hidden;
padding-right: 0.15rem;
border: 1px solid var(--platform-surface-border);
border-radius: 0.6rem;
padding: 0.3rem 0.45rem;
background: var(--platform-surface, #fff);
color: var(--platform-text-base);
background: var(--platform-surface-muted, rgba(104, 54, 36, 0.03));
}
.game-detail-info-card .co-creation-chain__row {
display: flex;
flex-direction: column;
min-width: 0;
}
/* 父→子短竖线:主干链节的连线加粗、用主题色(与桌面「主干线粗实线」同语义)。 */
.game-detail-info-card .co-creation-chain__connector {
width: 2px;
height: 0.7rem;
margin-left: 1.6rem;
background: var(--platform-surface-border);
}
.game-detail-info-card .co-creation-chain__connector--trunk {
width: 4px;
background: var(--platform-accent-strong);
}
.game-detail-info-card .co-creation-chain__node {
display: block;
width: 100%;
min-width: 0;
border: 1px solid transparent;
border-radius: 0.5rem;
padding: 0.3rem 0.4rem;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.game-detail-info-card .co-creation-lineage__node--current {
.game-detail-info-card .co-creation-chain__node:hover {
background: rgba(199, 101, 61, 0.08);
}
.game-detail-info-card .co-creation-chain__node--current {
border-color: var(--platform-accent-strong);
box-shadow: 0 0 0 2px rgba(199, 101, 61, 0.16);
cursor: default;
background: rgba(199, 101, 61, 0.12);
}
.game-detail-info-card .co-creation-lineage__avatar {
display: grid;
flex: none;
width: 1.6rem;
height: 1.6rem;
place-items: center;
border-radius: 50%;
background: rgba(199, 101, 61, 0.18);
color: var(--platform-accent-strong);
font-size: 0.72rem;
font-weight: 700;
}
.game-detail-info-card .co-creation-lineage__text {
display: flex;
flex-direction: column;
min-width: 0;
font-size: 0.7rem;
line-height: 1.3;
}
.game-detail-info-card .co-creation-lineage__generation {
color: var(--platform-accent-strong);
font-weight: 700;
}
.game-detail-info-card .co-creation-lineage__title {
color: var(--platform-text-strong);
font-weight: 600;
overflow-wrap: anywhere;
}
.game-detail-info-card .co-creation-lineage__author {
.game-detail-info-card .co-creation-chain__notice,
.game-detail-info-card .co-creation-chain__legend {
color: var(--platform-text-soft);
font-size: 0.7rem;
overflow-wrap: anywhere;
}
/* 热门衍生栏:横向卡片。 */
.game-detail-info-card .co-creation-hot {
@@ -1288,6 +1286,68 @@
font-weight: 700;
overflow-wrap: anywhere;
}
/* 链节点与画布卡片共用的内容布局(LineageNodeContent)。 */
.game-lineage-node__content {
display: flex;
min-width: 0;
}
.game-lineage-node__content--stacked {
flex-direction: column;
gap: 0;
width: 100%;
}
.game-lineage-node__content--inline {
flex-direction: row;
align-items: center;
gap: 0.45rem;
width: 100%;
min-width: 0;
padding: 0.15rem 0;
}
.game-lineage-node__text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
flex: 1 1 auto;
}
/* 防逐字换行:min-width:0 让 flex 子项可收缩,标题 2 行截断(line-clamp)+ 省略号。 */
.game-lineage-node__title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
word-break: normal;
}
.game-lineage-node__meta {
display: flex;
min-width: 0;
gap: 0.3rem;
overflow: hidden;
}
.game-lineage-node__author {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 链节点:横版小缩略图(64×36),矮而宽,触控目标 ≥44px。 */
.game-lineage-node__content--inline .game-lineage-node__cover {
flex: 0 0 auto;
width: 4rem;
aspect-ratio: 16 / 9;
border-radius: 0.35rem;
}
.game-lineage-node__content--inline .game-lineage-node__badge,
.game-lineage-node__content--inline .game-lineage-node__anchor {
font-size: 0.6rem;
padding: 0.05rem 0.35rem;
}
.game-detail-info-card .co-creation-chain__node {
min-height: 2.75rem;
}
/* 卡片主体 = 选中;标题/封面 = 打开详情。封面做主视觉(大图 + 圆角 + 轻阴影)。 */
.game-lineage-node {
padding: 0;