diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 9ce085fc..55e099ea 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -19,11 +19,19 @@ ## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐 - 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动,或切卡后反向回弹。 -- 决策:推荐页拿到推荐作品列表后预加载每个作品的卡片封面、主封面和玩法兜底封面;嵌入 runtime 的启动遮罩必须复用带玩法标签和标题的作品卡面视觉,不能再切到一层单独的纯封面图。作品切换后遮罩接手当前卡面时必须瞬时显示,不允许从旧预览卡面再淡入到同一张卡面;runtime 统一通过 ready 门控等待 run / profile、lazy 组件和 runtime DOM 内图片资源准备完成,ready 返回 true 后再由外层露出游戏画面并只让卡面遮罩渐隐。推荐 rail 切换完成后归零不能走反向过渡动画。 +- 决策:推荐页拿到推荐作品列表后预加载每个作品的卡片封面、主封面和玩法兜底封面;嵌入 runtime 的启动遮罩必须复用带玩法标签和标题的作品卡面视觉,不能再切到一层单独的纯封面图。作品切换后遮罩接手当前卡面时必须瞬时显示,不允许从旧预览卡面再淡入到同一张卡面;runtime 统一通过 ready 门控等待 run / profile、lazy 组件和 runtime DOM 内图片资源准备完成,ready 返回 true 后再由外层露出游戏画面并只让卡面遮罩渐隐。遮罩层级必须隔离下层 runtime,防止高 z-index HUD、canvas 或子运行态穿透到封面上;ready 前保留无说明文案的加载条 / 动效,不展示“加载中”文案。推荐 rail 切换完成后归零不能走反向过渡动画。 - 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页 runtime 生命周期、平台玩法链路文档。 - 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-07 登录态身份边界变更后刷新当前页 + +- 背景:推荐页运行态、作品架、个人数据和私有 query 都可能在页面内缓存当前身份;如果登录或退出只改 React 上下文,当前页可能继续拿旧身份的局部状态渲染。 +- 决策:H5 登录态从未登录变为已登录,或从已登录变为未登录后,前端必须刷新当前页面一次,让平台壳和运行态按新身份重新初始化。普通 access token refresh、账号资料更新、主题或音量设置变化不触发整页刷新。 +- 影响范围:`src/components/auth/AuthGate.tsx`、平台入口身份初始化、项目基线文档。 +- 验证方式:`npm run test -- src/components/auth/AuthGate.test.tsx`。 +- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 + ## 2026-06-07 跳一跳排行榜展示名禁止泄露内部身份键 - 背景:跳一跳排行榜曾在结果页和运行态失败弹窗里直接展示 `playerId` / `user_id`,用户可见内容暴露了内部身份键。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index f1a99151..96379462 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -176,7 +176,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。 -推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。ready 前不展示“加载中”文案,也不得把未准备好的运行态直接暴露给用户。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品;`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。遮罩层级必须高于并隔离下层 runtime,防止运行态 HUD、canvas 或高 `z-index` 子层穿透到封面上;ready 前不展示“加载中”文案,但封面内必须保留无文案加载动效或进度条,避免用户误以为卡片损坏,也不得把未准备好的运行态直接暴露给用户。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品;`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 ## 敲木鱼 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index d35b1139..712dfcb3 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -50,6 +50,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 7. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。 8. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。 9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自快捷登录后按需展示的原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。 +10. H5 登录态从未登录变为已登录,或从已登录变为未登录后,必须刷新当前页面一次,确保推荐运行态、作品架、个人缓存和私有 query 都按新身份重新初始化;普通 access token 续期、账号资料更新和同一登录态内的设置变化不得触发整页刷新。 ## 账户与充值 diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 353153df..28576bb0 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -3,11 +3,11 @@ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; -import { beforeEach, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { AuthSessionSummary, AuthUser } from '../../services/authService'; import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments'; -import { AuthGate } from './AuthGate'; +import { AuthGate, setAuthGateReloadForTest } from './AuthGate'; import { useAuthUi } from './AuthUiContext'; const authMocks = vi.hoisted(() => ({ @@ -107,6 +107,7 @@ beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); window.history.replaceState(null, '', '/'); + setAuthGateReloadForTest(vi.fn()); authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); authMocks.getStoredAccessToken.mockReturnValue(''); @@ -158,6 +159,10 @@ beforeEach(() => { authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true); }); +afterEach(() => { + setAuthGateReloadForTest(null); +}); + async function acceptLegalConsent( user: ReturnType, dialog: HTMLElement, @@ -382,6 +387,8 @@ test('auth gate keeps sms and password entries available when login options requ test('auth gate opens a login modal for protected actions and resumes after login', async () => { const user = userEvent.setup(); const onAuthenticated = vi.fn(); + const reload = vi.fn(); + setAuthGateReloadForTest(reload); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], @@ -411,6 +418,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi ); expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); expect(onAuthenticated).toHaveBeenCalledTimes(1); + expect(reload).toHaveBeenCalledTimes(1); }); expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); @@ -636,6 +644,8 @@ test('registration invite modal can skip when invite code is empty', async () => test('auth state refresh keeps mounted platform content and local tab state', async () => { const user = userEvent.setup(); + const reload = vi.fn(); + setAuthGateReloadForTest(reload); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], @@ -674,10 +684,13 @@ test('auth state refresh keeps mounted platform content and local tab state', as expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2); }); expect(screen.getByText('当前Tab:创作')).toBeTruthy(); + expect(reload).not.toHaveBeenCalled(); }); test('logout withdraws user context before backend request finishes', async () => { const user = userEvent.setup(); + const reload = vi.fn(); + setAuthGateReloadForTest(reload); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], @@ -703,11 +716,14 @@ test('logout withdraws user context before backend request finishes', async () = expect(await screen.findByText('当前用户:未登录')).toBeTruthy(); expect(screen.getByText('私有数据:不可读取')).toBeTruthy(); expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); await act(async () => { resolveLogout(); await logoutPromise; }); + + expect(reload).toHaveBeenCalledTimes(1); }); test('auth gate shows sms send feedback in the login modal', async () => { diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 404764b2..b7c7ec1b 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -65,6 +65,18 @@ type AuthStatus = const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password']; +let reloadCurrentPageForAuthStateChange = () => { + window.location.reload(); +}; + +export function setAuthGateReloadForTest(handler: (() => void) | null) { + reloadCurrentPageForAuthStateChange = + handler ?? + (() => { + window.location.reload(); + }); +} + function readInviteCodeFromLocation(): string { const params = new URLSearchParams(window.location.search || ''); return (params.get('inviteCode') || params.get('invite_code') || '') @@ -140,6 +152,8 @@ export function AuthGate({ children }: AuthGateProps) { const autoOpenedInviteCodeRef = useRef(null); const hasRenderedPlatformContentRef = useRef(false); const authHydrateVersionRef = useRef(0); + const lastStableAuthPresenceRef = useRef(null); + const pendingAuthStateReloadRef = useRef(false); const canKeepPlatformContentMounted = hasRenderedPlatformContentRef.current && (status === 'checking' || status === 'recovering'); @@ -152,36 +166,64 @@ export function AuthGate({ children }: AuthGateProps) { hasRenderedPlatformContentRef.current = true; } + const markAuthStateReloadIfChanged = useCallback( + ( + nextUser: AuthUser | null, + options: { reloadOnChange?: boolean } = {}, + ) => { + const nextHasUser = Boolean(nextUser); + const previousHasUser = lastStableAuthPresenceRef.current; + if (previousHasUser === null) { + lastStableAuthPresenceRef.current = nextHasUser; + return; + } + + lastStableAuthPresenceRef.current = nextHasUser; + if ( + previousHasUser !== nextHasUser && + options.reloadOnChange !== false + ) { + pendingAuthStateReloadRef.current = true; + } + }, + [], + ); + const activateReadyUser = useCallback((nextUser: AuthUser) => { // 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。 authHydrateVersionRef.current += 1; + markAuthStateReloadIfChanged(nextUser); setUser(nextUser); setStatus('ready'); - }, []); + }, [markAuthStateReloadIfChanged]); - const clearLocalAuthenticatedState = useCallback(() => { - // 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。 - // 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。 - authHydrateVersionRef.current += 1; - pendingProtectedActionRef.current = null; - setUser(null); - setStatus('unauthenticated'); - setShowLoginModal(false); - setShowRegistrationInviteModal(false); - setShowSettingsModal(false); - setSettingsEntryMode('settings'); - setInitialSettingsSection(null); - setSessions([]); - setRevokingSessionIds([]); - setAuditLogs([]); - setRiskBlocks([]); - setLoginCaptchaChallenge(null); - setBindCaptchaChallenge(null); - setChangePhoneCaptchaChallenge(null); - setPendingInviteCode(''); - setRegistrationInviteError(''); - setError(''); - }, []); + const clearLocalAuthenticatedState = useCallback( + (options: { reloadOnChange?: boolean } = {}) => { + // 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。 + // 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。 + authHydrateVersionRef.current += 1; + markAuthStateReloadIfChanged(null, options); + pendingProtectedActionRef.current = null; + setUser(null); + setStatus('unauthenticated'); + setShowLoginModal(false); + setShowRegistrationInviteModal(false); + setShowSettingsModal(false); + setSettingsEntryMode('settings'); + setInitialSettingsSection(null); + setSessions([]); + setRevokingSessionIds([]); + setAuditLogs([]); + setRiskBlocks([]); + setLoginCaptchaChallenge(null); + setBindCaptchaChallenge(null); + setChangePhoneCaptchaChallenge(null); + setPendingInviteCode(''); + setRegistrationInviteError(''); + setError(''); + }, + [markAuthStateReloadIfChanged], + ); const restoreAuthSession = useCallback(async () => { const hadLocalAccessToken = Boolean(getStoredAccessToken()); @@ -234,7 +276,7 @@ export function AuthGate({ children }: AuthGateProps) { }, []); const logoutCurrentSession = useCallback(async () => { - clearLocalAuthenticatedState(); + clearLocalAuthenticatedState({ reloadOnChange: false }); try { await logoutAuthUser(); } catch (logoutError) { @@ -243,11 +285,13 @@ export function AuthGate({ children }: AuthGateProps) { ? logoutError.message : '退出登录失败,请刷新页面确认状态。', ); + } finally { + reloadCurrentPageForAuthStateChange(); } }, [clearLocalAuthenticatedState]); const logoutAllSessions = useCallback(async () => { - clearLocalAuthenticatedState(); + clearLocalAuthenticatedState({ reloadOnChange: false }); try { await logoutAllAuthSessions(); } catch (logoutError) { @@ -256,6 +300,8 @@ export function AuthGate({ children }: AuthGateProps) { ? logoutError.message : '退出全部设备失败,请刷新页面确认状态。', ); + } finally { + reloadCurrentPageForAuthStateChange(); } }, [clearLocalAuthenticatedState]); @@ -386,6 +432,7 @@ export function AuthGate({ children }: AuthGateProps) { return; } + markAuthStateReloadIfChanged(null); setUser(null); setStatus('unauthenticated'); } catch (optionsError) { @@ -394,6 +441,7 @@ export function AuthGate({ children }: AuthGateProps) { } setAvailableLoginMethods(REQUIRED_LOGIN_METHODS); + markAuthStateReloadIfChanged(null); setUser(null); // 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口; // 这里不展示接口读取错误,避免用户误以为登录本身不可用。 @@ -413,6 +461,7 @@ export function AuthGate({ children }: AuthGateProps) { return; } if (restoredSession.kind === 'guest') { + markAuthStateReloadIfChanged(null); setAvailableLoginMethods( normalizeAvailableLoginMethods( restoredSession.session?.availableLoginMethods, @@ -423,6 +472,7 @@ export function AuthGate({ children }: AuthGateProps) { } const nextSession = restoredSession.session; + markAuthStateReloadIfChanged(nextSession.user); setUser(nextSession.user); setAvailableLoginMethods( normalizeAvailableLoginMethods(nextSession.availableLoginMethods), @@ -470,19 +520,23 @@ export function AuthGate({ children }: AuthGateProps) { window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange); window.removeEventListener('hashchange', handleAuthHashChange); }; - }, [restoreAuthSession]); + }, [markAuthStateReloadIfChanged, restoreAuthSession]); useEffect(() => { if (!readyUser) { setShowSettingsModal(false); - return; + } else { + setShowLoginModal(false); + + const pendingAction = pendingProtectedActionRef.current; + pendingProtectedActionRef.current = null; + pendingAction?.(); } - setShowLoginModal(false); - - const pendingAction = pendingProtectedActionRef.current; - pendingProtectedActionRef.current = null; - pendingAction?.(); + if (pendingAuthStateReloadRef.current) { + pendingAuthStateReloadRef.current = false; + reloadCurrentPageForAuthStateChange(); + } }, [readyUser]); useEffect(() => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 8147756e..cc86165d 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -3908,6 +3908,9 @@ test('mobile recommend startup keeps cover visible without loading copy', () => expect( document.querySelector('.platform-recommend-runtime-cover'), ).toBeTruthy(); + expect( + document.querySelector('.platform-recommend-runtime-loading'), + ).toBeTruthy(); expect(screen.queryByText('加载中...')).toBeNull(); expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index fa8220e2..d7ba0c8b 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -1288,6 +1288,7 @@ function RecommendRuntimeCover({ position="cover" resolvedCoverUrls={resolvedCoverUrls} /> +
); } diff --git a/src/index.css b/src/index.css index ee047ae7..06aa3eb9 100644 --- a/src/index.css +++ b/src/index.css @@ -220,6 +220,16 @@ body { } } +@keyframes platform-recommend-runtime-loading { + 0% { + transform: translateX(-110%); + } + + 100% { + transform: translateX(240%); + } +} + @keyframes puzzle-clear-card-clear-pop { 0% { opacity: 1; @@ -5281,6 +5291,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .platform-recommend-runtime-viewport { position: absolute; inset: 0; + z-index: 1; + isolation: isolate; min-width: 0; overflow: hidden; background: var(--platform-recommend-runtime-fill); @@ -5298,7 +5310,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .platform-recommend-runtime-cover { position: absolute; inset: 0; - z-index: 3; + z-index: 30; + isolation: isolate; overflow: hidden; background: var(--platform-recommend-runtime-fill); opacity: 1; @@ -5306,6 +5319,35 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { will-change: opacity; } + .platform-recommend-runtime-loading { + position: absolute; + right: 1rem; + bottom: 0.72rem; + left: 1rem; + z-index: 4; + height: 0.18rem; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.24); + box-shadow: 0 0 18px rgba(255, 255, 255, 0.24); + } + + .platform-recommend-runtime-loading::before { + position: absolute; + inset: 0; + width: 42%; + border-radius: inherit; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.38), + rgba(255, 255, 255, 0.92), + rgba(255, 180, 97, 0.86) + ); + content: ''; + transform: translateX(-110%); + animation: platform-recommend-runtime-loading 1.15s ease-in-out infinite; + } + .platform-recommend-runtime-cover--hidden { opacity: 0; pointer-events: none; diff --git a/src/index.test.ts b/src/index.test.ts index b10d925c..e51e4a4c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -131,12 +131,37 @@ describe('index stylesheet creation agent hero contrast', () => { }); describe('index stylesheet recommend runtime cover', () => { - it('only fades the card cover out after runtime is ready', () => { + it('keeps the card cover above embedded runtime and only fades it when ready', () => { const css = readIndexCss(); + const viewportBlock = getCssBlock( + css, + '.platform-recommend-runtime-viewport', + ); + expect(viewportBlock).toContain('z-index: 1;'); + expect(viewportBlock).toContain('isolation: isolate;'); + const coverBlock = getCssBlock(css, '.platform-recommend-runtime-cover'); + expect(coverBlock).toContain('z-index: 30;'); + expect(coverBlock).toContain('isolation: isolate;'); expect(coverBlock).not.toContain('transition: opacity'); + const loadingBlock = getCssBlock( + css, + '.platform-recommend-runtime-loading', + ); + expect(loadingBlock).toContain('position: absolute;'); + expect(loadingBlock).toContain('z-index: 4;'); + + const loadingAnimationBlock = getCssBlock( + css, + '.platform-recommend-runtime-loading::before', + ); + expect(loadingAnimationBlock).toContain( + 'animation: platform-recommend-runtime-loading 1.15s ease-in-out infinite;', + ); + expect(css).toContain('@keyframes platform-recommend-runtime-loading'); + const hiddenCoverBlock = getCssBlock( css, '.platform-recommend-runtime-cover--hidden',