主操作行不再内联状态文本:状态/警告改走平台 toast(复用 PlatformRuntimeStatusToast + portal 宿主),手动面板改 portal 浮层,行内子项不收缩不换行
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (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 / 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 web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 16:28:34 +08:00
parent 9329b0f842
commit 832c1787f9
5 changed files with 511 additions and 68 deletions
+100
View File
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { PlatformRuntimeStatusToast } from './PlatformRuntimeStatusToast';
import {
normalizePlatformToast,
type PlatformToastInput,
type PlatformToastTone,
subscribePlatformToast,
} from './platformToast';
type ToastItem = {
id: number;
text: string;
tone: PlatformToastTone;
durationMs: number | null;
};
/** 定时器句柄:浏览器是 number、Node 下是 Timeout 对象,直接用 `clearTimeout` 的入参类型。 */
type ToastTimerHandle = Parameters<typeof clearTimeout>[0];
let toastSeed = 0;
/**
* 轻提示宿主:在页面里**只挂一次**。
*
* - **portal 到 `body`** + `position: fixed`(右下角堆叠),因此完全不参与页面布局 ——
* 这正是用户口径「显示成 toast,不要在这里挤占别的元素的空间」的落点;
* - 视觉与无障碍复用共享组件 `PlatformRuntimeStatusToast`(`role=status`/`alert` + `aria-live`);
* - 自动消失(默认 5s;**错误/警告不自动消失**)+ 每条都能手动关闭(键盘可达);
* - 多条可堆叠;**不抢焦点**(除非是错误级 —— 错误级也只做 `role=alert` 播报,不 programmatic focus,
* 避免打断用户正在输入的内容)。
*/
export function PlatformToastHost() {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const timersRef = useRef(new Map<number, ToastTimerHandle>());
const dismiss = useCallback((id: number) => {
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(id);
}
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
useEffect(() => {
const unsubscribe = subscribePlatformToast((input: PlatformToastInput) => {
if (!input.text?.trim()) return;
const normalized = normalizePlatformToast(input);
toastSeed += 1;
const id = toastSeed;
setToasts((current) => [...current.slice(-3), { id, ...normalized }]);
if (normalized.durationMs !== null) {
timersRef.current.set(
id,
setTimeout(() => dismiss(id), normalized.durationMs),
);
}
});
const timers = timersRef.current;
return () => {
unsubscribe();
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
};
}, [dismiss]);
if (toasts.length === 0) return null;
return createPortal(
<div className="platform-toast-host" data-testid="platform-toast-host">
{toasts.map((toast) => (
<PlatformRuntimeStatusToast
key={toast.id}
tone={toast.tone}
size="sm"
shape="rounded"
className="platform-toast-host__item"
// 错误级用 alert(立即播报),其余 status(礼貌播报)。
role={toast.tone === 'error' ? 'alert' : 'status'}
aria-live={toast.tone === 'error' ? 'assertive' : 'polite'}
>
<span className="platform-toast-host__text">{toast.text}</span>
<button
type="button"
className="platform-toast-host__close"
aria-label={`关闭提示:${toast.text}`}
onClick={() => dismiss(toast.id)}
>
关闭
</button>
</PlatformRuntimeStatusToast>
))}
</div>,
document.body,
);
}
export default PlatformToastHost;
+68
View File
@@ -0,0 +1,68 @@
/**
* 平台级轻提示(toast)的最小总线。
*
* 为什么要有它:页面上「状态/错误/未安装提示」这类**一次性反馈**不该内联渲染在操作行里
* (会把同行按钮挤成两行、竖排)。这里给一个收口:任何地方 `showPlatformToast(...)`,
* 由唯一挂载的 `<PlatformToastHost />` 统一渲染(portal 到 `body` + 固定定位 + 自动消失 + 可手动关闭)。
*
* 视觉与无障碍**复用既有共享组件** `PlatformRuntimeStatusToast`(`role=status|alert` +
* `aria-live` 已由它处理),不另造一套 toast 外观。总线本身用最小的事件方案,和
* `clientDownloadLauncher` 同一套做法,避免为这一个能力引入全局 store。
*/
export type PlatformToastTone =
| 'info'
| 'success'
| 'warning'
| 'error'
| 'neutral';
export type PlatformToastInput = {
text: string;
tone?: PlatformToastTone;
/** 自动消失毫秒数;`null` = 不自动消失(错误类默认如此,只能手动关)。 */
durationMs?: number | null;
};
export const PLATFORM_TOAST_EVENT = 'genarrative:platform-toast';
/** 默认自动消失时间;错误类不自动消失(见 `normalizeToast`)。 */
export const PLATFORM_TOAST_DEFAULT_DURATION_MS = 5000;
export function normalizePlatformToast(input: PlatformToastInput): {
text: string;
tone: PlatformToastTone;
durationMs: number | null;
} {
const tone = input.tone ?? 'info';
const durationMs =
input.durationMs === undefined
? // 错误/警告都要用户看清,默认不自动消失;其余 5s 消失。
tone === 'error' || tone === 'warning'
? null
: PLATFORM_TOAST_DEFAULT_DURATION_MS
: input.durationMs;
return { text: input.text, tone, durationMs };
}
/** 弹一条轻提示(等价于把文案交给唯一的 toast 宿主)。 */
export function showPlatformToast(input: PlatformToastInput | string) {
if (typeof window === 'undefined') return;
const payload = typeof input === 'string' ? { text: input } : input;
window.dispatchEvent(
new CustomEvent<PlatformToastInput>(PLATFORM_TOAST_EVENT, {
detail: payload,
}),
);
}
/** 订阅轻提示请求;返回取消订阅函数。 */
export function subscribePlatformToast(
handler: (input: PlatformToastInput) => void,
) {
if (typeof window === 'undefined') return () => {};
const listener = (event: Event) => {
handler((event as CustomEvent<PlatformToastInput>).detail ?? { text: '' });
};
window.addEventListener(PLATFORM_TOAST_EVENT, listener);
return () => window.removeEventListener(PLATFORM_TOAST_EVENT, listener);
}
@@ -6,6 +6,7 @@ import {
} from '@genarrative/shared/components';
import { ArrowLeft, Gamepad2, Maximize2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS,
@@ -35,6 +36,8 @@ import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { showPlatformToast } from '../common/platformToast';
import { PlatformToastHost } from '../common/PlatformToastHost';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { requestClientDownload } from '../creation-home/clientDownloadLauncher';
import { CreatorAuthor } from '../creator/CreatorRelationshipControl';
@@ -673,6 +676,22 @@ function GameDetailContent({
const [isCollectionBusy, setIsCollectionBusy] = useState(false);
const [collectionNotice, setCollectionNotice] = useState('');
const serverCollected = game.collected;
/**
* 操作行的状态/提示一律走 toast(portal + fixed):**不在行内占位**,否则会把
* 「立即玩 / 收藏 / 一键开始共创」挤成两行甚至竖排(2026-10-07 用户口径)。
*/
useEffect(() => {
if (!collectionNotice) return;
showPlatformToast({ text: collectionNotice, tone: 'warning' });
}, [collectionNotice]);
useEffect(() => {
if (!forkLaunchNotice) return;
// 「已唤起…」是信息,其余(没检测到客户端 / 禁止共创 / 无法直接唤起)是要用户处理的警告。
const tone = /没有检测到|无法直接唤起|禁止/u.test(forkLaunchNotice)
? 'warning'
: 'info';
showPlatformToast({ text: forkLaunchNotice, tone });
}, [forkLaunchNotice]);
const isCollected = collectionOverride ?? serverCollected ?? false;
const canKnowCollectionState =
typeof serverCollected === 'boolean' || collectionOverride !== null;
@@ -828,11 +847,31 @@ function GameDetailContent({
},
]
: [];
/** 设备/归属类警告同样走 toast(行内那份只留给读屏)。 */
useEffect(() => {
const warning =
!isPaidLocked && mobilePlaybackBlocked
? '请在电脑上游玩'
: isEntryMissing && !mobilePlaybackBlocked
? '暂时拿不到可玩入口,请刷新页面后重试。'
: ownerView && !publishedVersion
? '作品尚未公开,暂时没有在线可玩版本'
: '';
if (warning) showPlatformToast({ text: warning, tone: 'warning' });
}, [
isEntryMissing,
isPaidLocked,
mobilePlaybackBlocked,
ownerView,
publishedVersion,
]);
return (
<div className="game-detail-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回
</button>
{/* toast 宿主:portal 到 body、固定定位,因此完全不参与本页布局。 */}
<PlatformToastHost />
<GameDetailDisplay
game={displayGame}
authorContent={
@@ -1034,11 +1073,6 @@ function GameDetailContent({
>
{isCollected ? '已收藏' : '收藏'}
</PlatformActionButton>
{collectionNotice ? (
<span className="game-collection-notice" role="status">
{collectionNotice}
</span>
) : null}
{/*
「一键开始共创」从共创信息卡搬到主操作行(2026-10-07 用户口径):
它是这部作品的主操作之一,和「立即玩 / 收藏」并列;共创卡内不再重复出现该按钮。
@@ -1085,67 +1119,78 @@ function GameDetailContent({
>
{isAuthenticated ? '一键开始共创' : '登录后一键开始共创'}
</button>
{(isAuthenticated || forkAuthorization === 'forbidden') &&
forkLaunchNotice ? (
<span className="game-fork-remix-status" role="status">
{forkLaunchNotice}
</span>
) : null}
{isAuthenticated && isForkPanelOpen ? (
<span
className="game-fork-remix-panel"
id="game-fork-remix-panel"
>
<span className="game-fork-remix-steps">
在陶泥儿客户端里打开「从平台作品开始创作」,粘贴下面的作品
ID 或作品链接。
</span>
<span className="game-fork-remix-value">
作品 ID:<code>{game.id}</code>
</span>
<span className="game-fork-remix-value">
作品链接:<code>{workLink}</code>
</span>
<span className="game-fork-remix-actions">
<CopyFeedbackButton
state={forkIdCopyState}
idleLabel="复制作品 ID"
copiedLabel="作品 ID 已复制"
failedLabel="复制失败"
className="game-fork-remix-copy"
onClick={() => void copyForkId(game.id)}
/>
<CopyFeedbackButton
state={forkLinkCopyState}
idleLabel="复制作品链接"
copiedLabel="作品链接已复制"
failedLabel="复制失败"
className="game-fork-remix-copy"
onClick={() => void copyForkLink(workLink)}
/>
</span>
<span className="game-fork-remix-note">
开始共创会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造;
复制后对他人可见,且不可撤销。
</span>
{forkIdCopyState === 'copied' ? (
<span className="game-fork-remix-status" role="status">
作品 ID 已复制,可在客户端「从平台作品开始创作」里粘贴。
</span>
) : null}
{forkIdCopyState === 'failed' ? (
<span className="game-fork-remix-status" role="status">
复制失败:请手动选中作品 ID 文本。
</span>
) : null}
</span>
) : null}
{isAuthenticated && isForkPanelOpen
? createPortal(
<div
className="game-fork-remix-panel game-fork-remix-panel--portal"
id="game-fork-remix-panel"
role="dialog"
aria-label="手动开始共创"
>
<span className="game-fork-remix-steps">
在陶泥儿客户端里打开「从平台作品开始创作」,粘贴下面的作品
ID 或作品链接。
</span>
<span className="game-fork-remix-value">
作品 ID:<code>{game.id}</code>
</span>
<span className="game-fork-remix-value">
作品链接:<code>{workLink}</code>
</span>
<span className="game-fork-remix-actions">
<CopyFeedbackButton
state={forkIdCopyState}
idleLabel="复制作品 ID"
copiedLabel="作品 ID 已复制"
failedLabel="复制失败"
className="game-fork-remix-copy"
onClick={() => void copyForkId(game.id)}
/>
<CopyFeedbackButton
state={forkLinkCopyState}
idleLabel="复制作品链接"
copiedLabel="作品链接已复制"
failedLabel="复制失败"
className="game-fork-remix-copy"
onClick={() => void copyForkLink(workLink)}
/>
</span>
<span className="game-fork-remix-note">
开始共创会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造;
复制后对他人可见,且不可撤销。
</span>
{forkIdCopyState === 'copied' ? (
<span
className="game-fork-remix-status"
role="status"
>
作品 ID
已复制,可在客户端「从平台作品开始创作」里粘贴。
</span>
) : null}
{forkIdCopyState === 'failed' ? (
<span
className="game-fork-remix-status"
role="status"
>
复制失败:请手动选中作品 ID 文本。
</span>
) : null}
</div>,
document.body,
)
: null}
</>
) : null}
{/*
这些警告过去是行内 span,会把同行按钮挤成两行。现在:**视觉上隐藏但保留 id**
(`aria-describedby` 还指着 `#game-mobile-playback-warning` / `#game-entry-missing-warning`,
读屏仍能关联到),可见提示改由 toast 承担。
*/}
{!isPaidLocked && mobilePlaybackBlocked ? (
<span
id="game-mobile-playback-warning"
className="game-device-warning"
className="game-device-warning game-device-warning--sr"
role="status"
>
请在电脑上游玩
@@ -1154,14 +1199,17 @@ function GameDetailContent({
{isEntryMissing && !mobilePlaybackBlocked ? (
<span
id="game-entry-missing-warning"
className="game-device-warning"
className="game-device-warning game-device-warning--sr"
role="status"
>
暂时拿不到可玩入口,请刷新页面后重试。
</span>
) : null}
{ownerView && !publishedVersion ? (
<span className="game-device-warning" role="status">
<span
className="game-device-warning game-device-warning--sr"
role="status"
>
作品尚未公开,暂时没有在线可玩版本
</span>
) : null}
@@ -24,6 +24,12 @@ import type {
GameDistributionMyGame,
GameDistributionVersionStatusEntry,
} from '../../services/gameDistributionClient';
import {
normalizePlatformToast,
PLATFORM_TOAST_DEFAULT_DURATION_MS,
showPlatformToast,
} from '../common/platformToast';
import { PlatformToastHost } from '../common/PlatformToastHost';
import { setCreatorViewer } from '../creator/creatorRelationshipState';
import { GameComparePage } from './GameComparePage';
import { GameDetailPage } from './GameDetailPage';
@@ -596,7 +602,15 @@ describe('GameDetailPage', () => {
const playButton = await screen.findByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText('请在电脑上游玩')).toBeTruthy();
expect(
within(await screen.findByTestId('platform-toast-host')).getByText(
'请在电脑上游玩',
),
).toBeTruthy();
// 行内那份保留(读屏用 `aria-describedby` 关联),但视觉隐藏、不占布局空间。
expect(
document.getElementById('game-mobile-playback-warning')?.className,
).toContain('game-device-warning--sr');
});
it('rejects an invalid query id without requesting the API', async () => {
@@ -905,9 +919,17 @@ describe('GameDetailPage', () => {
expect((playButton as HTMLButtonElement).disabled).toBe(true),
);
// 禁用按钮必须带可操作说明,否则用户只看到一个点不动的按钮。
const reason = screen.getByText('暂时拿不到可玩入口,请刷新页面后重试。');
expect(reason.id).toBe('game-entry-missing-warning');
expect(playButton.getAttribute('aria-describedby')).toBe(reason.id);
// 可见提示走 toast(不占操作行布局);行内那份保留 id 供 aria-describedby 关联,但视觉隐藏。
expect(
within(await screen.findByTestId('platform-toast-host')).getByText(
'暂时拿不到可玩入口,请刷新页面后重试。',
),
).toBeTruthy();
const reason = document.getElementById('game-entry-missing-warning');
expect(reason?.className).toContain('game-device-warning--sr');
expect(playButton.getAttribute('aria-describedby')).toBe(
'game-entry-missing-warning',
);
});
});
@@ -972,7 +994,11 @@ describe('GameDetailPage 作者视角', () => {
const playButton = screen.getByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText('作品尚未公开,暂时没有在线可玩版本')).toBeTruthy();
expect(
within(await screen.findByTestId('platform-toast-host')).getByText(
'作品尚未公开,暂时没有在线可玩版本',
),
).toBeTruthy();
});
it('未公开作品的封面显示审核中,且不请求评价与评分接口', async () => {
@@ -4776,6 +4802,59 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
);
});
it('主操作行不再内联状态文本:行内不换行/不被挤扁,面板改 portal 浮层', async () => {
// 窄屏:唤起直接进入「手动获取作品 ID」的降级面板(无需等失焦超时)。
setViewportWidth(390);
getGameMock.mockResolvedValueOnce(
createGame({ id: 'game-current', forkAuthorization: 'full' }),
);
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
const playButton = await screen.findByRole('button', { name: /立即玩/u });
const actionsRow = playButton.closest(
'.game-detail-hero__actions',
) as HTMLElement;
expect(actionsRow).toBeTruthy();
// 结构契约:行内**没有**任何内联状态/提示节点(历史类名都不该再出现在行里)。
expect(actionsRow.querySelector('.game-collection-notice')).toBeNull();
expect(actionsRow.querySelector('.game-fork-remix-status')).toBeNull();
// 警告节点仍在(`aria-describedby` 关联要它),但视觉隐藏、不占行内空间。
for (const warning of actionsRow.querySelectorAll('.game-device-warning')) {
expect(warning.className).toContain('game-device-warning--sr');
}
// 触发唤起 → 面板必须是 portal 到 body 的浮层(不参与行布局),且可关。
fireEvent.click(screen.getByRole('button', { name: '一键开始共创' }));
const panel = await screen.findByRole('dialog', { name: '手动开始共创' });
expect(panel.parentElement).toBe(document.body);
expect(panel.className).toContain('game-fork-remix-panel--portal');
expect(
within(panel).getByRole('button', { name: '复制作品 ID' }),
).toBeTruthy();
// CSS 契约:行内子项不收缩、不折行(无论旁边再加什么元素都不会把按钮压成两行)。
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
expect(css).toMatch(
/\.game-detail-hero__actions > \* \{[^}]*flex: 0 0 auto;[^}]*white-space: nowrap;/u,
);
expect(css).toMatch(
/\.game-fork-remix-panel--portal \{[^}]*position: fixed;/u,
);
});
it('区块默认以根为作用域,并把当前作品定位进视口', async () => {
// jsdom 不做布局:给画布一个真机尺寸,初始适配与定位才有可断言的数值。
const widthSpy = vi
@@ -5165,3 +5244,82 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
expect(screen.queryByRole('button', { name: '已收藏' })).toBeNull();
});
});
describe('PlatformToastHost(轻提示宿主)', () => {
afterEach(() => {
vi.useRealTimers();
});
it('portal 到 body、`role=status` + `aria-live=polite`,可手动关闭', async () => {
render(<PlatformToastHost />);
expect(screen.queryByTestId('platform-toast-host')).toBeNull();
act(() => {
showPlatformToast({ text: '作品 ID 已复制' });
});
const host = await screen.findByTestId('platform-toast-host');
// portal 到 body:不在渲染它的容器里,因此不参与任何页面布局。
expect(host.parentElement).toBe(document.body);
const toast = within(host).getByRole('status');
expect(toast.getAttribute('aria-live')).toBe('polite');
expect(toast.textContent).toContain('作品 ID 已复制');
// 键盘可达的关闭按钮。
fireEvent.click(
screen.getByRole('button', { name: '关闭提示:作品 ID 已复制' }),
);
expect(screen.queryByTestId('platform-toast-host')).toBeNull();
});
it('错误级用 `role=alert` + `aria-live=assertive`,且默认不自动消失', async () => {
vi.useFakeTimers();
render(<PlatformToastHost />);
act(() => {
showPlatformToast({ text: '开始共创失败', tone: 'error' });
});
const host = screen.getByTestId('platform-toast-host');
expect(within(host).getByRole('alert').getAttribute('aria-live')).toBe(
'assertive',
);
act(() => {
vi.advanceTimersByTime(PLATFORM_TOAST_DEFAULT_DURATION_MS * 4);
});
// 错误不会自己消失,必须用户关闭。
expect(screen.getByTestId('platform-toast-host')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /关闭提示/ }));
expect(screen.queryByTestId('platform-toast-host')).toBeNull();
});
it('普通提示自动消失(默认 5s),多条可堆叠', async () => {
vi.useFakeTimers();
render(<PlatformToastHost />);
act(() => {
showPlatformToast({ text: '第一条' });
showPlatformToast({ text: '第二条' });
});
const host = screen.getByTestId('platform-toast-host');
expect(within(host).getAllByRole('status')).toHaveLength(2);
act(() => {
vi.advanceTimersByTime(PLATFORM_TOAST_DEFAULT_DURATION_MS + 50);
});
// 两条都到点 → 宿主整块收起。
expect(screen.queryByTestId('platform-toast-host')).toBeNull();
void host;
});
it('normalizePlatformToast:默认 tone 与时长规则稳定', () => {
expect(normalizePlatformToast({ text: 'a' })).toEqual({
text: 'a',
tone: 'info',
durationMs: PLATFORM_TOAST_DEFAULT_DURATION_MS,
});
expect(
normalizePlatformToast({ text: 'a', tone: 'warning' }).durationMs,
).toBe(null);
expect(
normalizePlatformToast({ text: 'a', tone: 'error', durationMs: 800 })
.durationMs,
).toBe(800);
});
});
@@ -2864,3 +2864,72 @@
object-fit: cover;
object-position: center;
}
/*
* 主操作行「任何状态都不换行、不被挤扁」(2026-10-07 用户口径:提示显示成 toast,别挤占元素空间)。
* 行内状态文本已经全部改走 toast / portal 浮层;这里再钉一层样式保险:
* 子项不收缩、文字不折行 —— 无论旁边以后再加什么,都不会把「立即玩 / 收藏 / 一键开始共创」压成两行。
*/
.game-detail-hero__actions > * {
flex: 0 0 auto;
white-space: nowrap;
}
/* 读屏专用:保留 id(`aria-describedby` 还指着它)但不占布局空间。 */
.game-device-warning--sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/*
* 「手动开始共创」面板:portal 到 `body` 的浮层(底部居中),
* 不再作为主操作行的子项参与布局 —— 打开面板不会把同行按钮挤走。
*/
.game-fork-remix-panel--portal {
position: fixed;
left: 50%;
bottom: calc(1rem + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 110;
max-width: min(34rem, calc(100vw - 2rem));
box-shadow: 0 18px 48px rgba(48, 32, 22, 0.22);
}
/* 轻提示宿主:固定右下角堆叠,`pointer-events: none` 让空白区域不挡点击。 */
.platform-toast-host {
position: fixed;
right: 1rem;
bottom: calc(1rem + env(safe-area-inset-bottom));
z-index: 130;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: min(26rem, calc(100vw - 2rem));
pointer-events: none;
}
.platform-toast-host__item {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 0.5rem;
box-shadow: 0 12px 32px rgba(48, 32, 22, 0.18);
}
.platform-toast-host__text {
white-space: normal;
line-height: 1.5;
}
.platform-toast-host__close {
flex: 0 0 auto;
border: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
font-size: 0.78rem;
text-decoration: underline;
cursor: pointer;
}