diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index e13a7e671..4d5bb02ae 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5192,22 +5192,23 @@ iframe.preview-frame { transition: none; } -.game-resource-book-scene--entering .game-resource-book-scene-world { - transition: transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1); +.game-resource-book-transition-layer { + position: absolute; + inset: 0; + z-index: 35; + overflow: hidden; + pointer-events: none; } -.game-resource-book-scene--returning-main .game-resource-book-scene-world { - transition: none; +.game-resource-book-manager[data-book-motion='running'] + .game-resource-book-scene-world { + opacity: 0; } .game-resource-book-scene--child { pointer-events: auto; } -.game-resource-book-scene--child.is-measuring { - pointer-events: none; -} - .game-resource-book-zoom { position: absolute; right: 14px; @@ -5262,8 +5263,8 @@ iframe.preview-frame { margin-left: 2px; } -.game-resource-book-scene.is-measuring { - opacity: 0; +.game-resource-book-scene--main.is-measuring { + visibility: hidden; } .game-resource-book-scene::before { @@ -5276,12 +5277,10 @@ iframe.preview-frame { content: ''; opacity: 0; pointer-events: none; - transition: opacity 180ms ease; + transition: none; } -.game-resource-book-scene--child::before, -.game-resource-book-scene--returning::before, -.game-resource-book-scene--entering::before { +.game-resource-book-scene--child::before { opacity: 1; } @@ -5295,13 +5294,7 @@ iframe.preview-frame { box-shadow: 0 8px 18px rgb(112 70 52 / 8%); pointer-events: none; transform: translateY(0); - transition: - left 420ms cubic-bezier(0.2, 0.78, 0.2, 1), - top 420ms cubic-bezier(0.2, 0.78, 0.2, 1), - width 420ms cubic-bezier(0.2, 0.78, 0.2, 1), - transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1), - translate 180ms ease, - opacity 180ms ease; + transition: none; } .game-resource-book-scene-titlebar.is-active { @@ -5309,13 +5302,6 @@ iframe.preview-frame { pointer-events: auto; } -/* 子画布中的标题栏是固定 UI。拖动画布时 viewport 会逐帧更新,不能让 - * 标题栏的逆变换继续走转场 transition,否则它会滞后跟随画布。 */ -.game-resource-book-scene--child.game-resource-book-scene--idle - .game-resource-book-scene-titlebar.is-active { - transition: none; -} - .game-resource-book-scene-titlebar .game-resource-book-titlebar { min-height: 42px; border-bottom: 0; @@ -5327,86 +5313,30 @@ iframe.preview-frame { z-index: 25; pointer-events: none; translate: 0 0; - transition: - opacity 180ms ease, - transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1), - translate 180ms ease; + transition: none; } -.game-resource-book-manager:has( +.game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='code']:hover, .game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible ) .game-resource-book-scene-titlebar[data-resource-book-category='code'], -.game-resource-book-manager:has( - .game-resource-book-thumbnail[data-resource-book-category='code']:hover, - .game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible - ) - .game-resource-book-scene-card[data-resource-book-category='code'], -.game-resource-book-manager:has( +.game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='document']:hover, .game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible ) .game-resource-book-scene-titlebar[data-resource-book-category='document'], -.game-resource-book-manager:has( - .game-resource-book-thumbnail[data-resource-book-category='document']:hover, - .game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible - ) - .game-resource-book-scene-card[data-resource-book-category='document'], -.game-resource-book-manager:has( +.game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='version']:hover, .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible ) .game-resource-book-scene-titlebar[data-resource-book-category='version'], -.game-resource-book-manager:has( - .game-resource-book-thumbnail[data-resource-book-category='version']:hover, - .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible - ) - .game-resource-book-scene-card[data-resource-book-category='version'], -.game-resource-book-manager:has( +.game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='art']:hover, .game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible ) .game-resource-book-scene-titlebar[data-resource-book-category='art'], -.game-resource-book-manager:has( - .game-resource-book-thumbnail[data-resource-book-category='art']:hover, - .game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible - ) - .game-resource-book-scene-card[data-resource-book-category='art'], -.game-resource-book-manager:has( - .game-resource-book-thumbnail[data-resource-book-category='audio']:hover, - .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='audio'], -.game-resource-book-manager:has( - .game-resource-book-thumbnail[data-resource-book-category='audio']:hover, - .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible - ) - .game-resource-book-scene-card[data-resource-book-category='audio'] { - translate: 0 -3px; -} - -.game-resource-book-manager:has( - .game-resource-book-thumbnail[data-resource-book-category='code']:hover, - .game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='code'], -.game-resource-book-manager:has( - .game-resource-book-thumbnail[data-resource-book-category='document']:hover, - .game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='document'], -.game-resource-book-manager:has( - .game-resource-book-thumbnail[data-resource-book-category='version']:hover, - .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='version'], -.game-resource-book-manager:has( - .game-resource-book-thumbnail[data-resource-book-category='art']:hover, - .game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='art'], -.game-resource-book-manager:has( +.game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='audio']:hover, .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible ) @@ -5418,16 +5348,6 @@ iframe.preview-frame { } .game-resource-book-scene-card .game-resource-card { - transition: - width 420ms cubic-bezier(0.2, 0.78, 0.2, 1), - height 420ms cubic-bezier(0.2, 0.78, 0.2, 1), - transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1), - box-shadow 180ms ease; -} - -.game-resource-book-scene--idle - .game-resource-book-scene-card - .game-resource-card { transition: box-shadow 180ms ease; } @@ -5537,7 +5457,7 @@ iframe.preview-frame { border-color: #cc8060; outline: 0; box-shadow: 0 16px 34px rgb(158 87 57 / 18%); - transform: translateY(-3px); + transform: none; } .game-resource-book-thumbnail-titlebar { diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx new file mode 100644 index 000000000..f5ebb6efc --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx @@ -0,0 +1,9 @@ +export function ResourceBookTransitionLayer() { + return ( + `; + document.body.append(root); + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600)); + const card = root.querySelector('.game-resource-card')!; + const measure = vi + .spyOn(card, 'getBoundingClientRect') + .mockReturnValue(rect()); + const flights: { + finish(): void; + reject(): void; + cancel: ReturnType; + frames: Keyframe[]; + }[] = []; + vi.spyOn(Element.prototype, 'animate').mockImplementation((frames) => { + let finish!: () => void; + let reject!: () => void; + const finished = new Promise((resolve, fail) => { + finish = () => resolve({} as Animation); + reject = () => fail(new Error('canceled')); + }); + const cancel = vi.fn(reject); + flights.push({ finish, reject, cancel, frames: frames as Keyframe[] }); + return { finished, cancel } as unknown as Animation; + }); + return { root, card, measure, flights }; +} - const secondToken = controller.begin(); - fake.runTimer(); - expect(calls).toEqual([]); - expect(controller.isCurrent(secondToken)).toBe(true); +const originalAnimate = Object.getOwnPropertyDescriptor( + Element.prototype, + 'animate', +); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + if (originalAnimate) + Object.defineProperty(Element.prototype, 'animate', originalAnimate); + else delete (Element.prototype as Partial).animate; + document.body.replaceChildren(); +}); + +function setup() { + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + writable: true, + value: () => ({}), + }); + return fixture(); +} + +describe('resource book FLIP controller', () => { + it('uses screen geometry independently of either viewport', () => { + expect( + resourceBookFlipTransform(rect(30, 40, 50, 20), rect(10, 10, 100, 80)), + ).toBe('translate(20px, 30px) scale(0.5, 0.25)'); }); - it('cancels a pending frame on disposal', () => { - const fake = createClock(); - const controller = createResourceBookTransitionController(fake.clock); - const token = controller.begin(); - const calls: number[] = []; - controller.requestFrame((frameToken) => calls.push(frameToken), token); - + it('finishes from animation completion, removes clones and preserves real controls', async () => { + const { root, measure, flights } = setup(); + const controller = createResourceBookTransitionController(); + const token = controller.begin(root); + measure.mockReturnValue(rect(150, 100, 200, 160)); + const done = vi.fn(); + controller.play(root, token, done); + expect(root.dataset.bookMotion).toBe('running'); + expect(root.querySelectorAll('#original')).toHaveLength(1); + expect(flights[0].frames[0].transform).toBe( + 'translate(-150px, -100px) scale(0.5, 0.5)', + ); + expect(done).not.toHaveBeenCalled(); + flights[0].finish(); + await vi.waitFor(() => expect(done).toHaveBeenCalledTimes(1)); + expect(root.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0); + expect(root.hasAttribute('data-book-motion')).toBe(false); + controller.dispose(); + }); + + it('retargets from the current overlay geometry and ignores old completion', async () => { + const { root, measure, flights } = setup(); + const controller = createResourceBookTransitionController(); + const firstDone = vi.fn(); + let token = controller.begin(root); + measure.mockReturnValue(rect(200, 100, 200, 160)); + controller.play(root, token, firstDone); + const moving = root.querySelector('[data-motion-snapshot]')!; + vi.spyOn(moving, 'getBoundingClientRect').mockReturnValue( + rect(90, 40, 150, 120), + ); + token = controller.begin(root); + measure.mockReturnValue(rect(300, 200, 100, 80)); + const secondDone = vi.fn(); + controller.play(root, token, secondDone); + expect(flights[1].frames[0].transform).toBe( + 'translate(-210px, -160px) scale(1.5, 1.5)', + ); + await Promise.resolve(); + expect(firstDone).not.toHaveBeenCalled(); + expect(secondDone).not.toHaveBeenCalled(); + flights[1].finish(); + await vi.waitFor(() => expect(secondDone).toHaveBeenCalledTimes(1)); + controller.dispose(); + }); + + it.each(['reduced', 'zero', 'unsupported'])( + 'settles without a timer for %s motion', + (reason) => { + const { root, measure, flights } = setup(); + if (reason === 'zero') measure.mockReturnValue(rect(0, 0, 0, 0)); + if (reason === 'unsupported') + delete (Element.prototype as Partial).animate; + if (reason === 'reduced') + vi.stubGlobal('matchMedia', () => ({ matches: true })); + const controller = createResourceBookTransitionController(); + const token = controller.begin(root); + const done = vi.fn(); + controller.play(root, token, done); + expect(done).toHaveBeenCalledTimes(1); + expect(flights).toHaveLength(0); + controller.dispose(); + }, + ); + + it('settles on resize and suppresses completion after disposal', async () => { + const { root, flights } = setup(); + const controller = createResourceBookTransitionController(); + const done = vi.fn(); + controller.play(root, controller.begin(root), done); + window.dispatchEvent(new Event('resize')); + expect(done).toHaveBeenCalledTimes(1); + controller.play(root, controller.begin(root), done); + controller.dispose(); + flights[1].finish(); + await Promise.resolve(); + expect(done).toHaveBeenCalledTimes(1); + expect(root.hasAttribute('data-book-motion')).toBe(false); + }); + + it('cleans partially started animations when animation creation fails', () => { + const { root } = setup(); + vi.mocked(Element.prototype.animate).mockImplementation(() => { + throw new Error('animation unavailable'); + }); + const controller = createResourceBookTransitionController(); + const done = vi.fn(); + controller.play(root, controller.begin(root), done); + expect(done).toHaveBeenCalledTimes(1); + expect(root.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0); + expect(root.hasAttribute('data-book-motion')).toBe(false); + controller.dispose(); + }); + + it('cancellation never waits for a CSS timer and leaves the viewport untouched', async () => { + const { root, flights } = setup(); + const world = root.querySelector( + '.game-resource-book-scene-world', + )!; + world.style.transform = 'translate(20px, 40px) scale(1.5)'; + const controller = createResourceBookTransitionController(); + const done = vi.fn(); + controller.play(root, controller.begin(root), done); + controller.settle(); + expect(flights[0].cancel).toHaveBeenCalledTimes(1); + expect(done).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(world.style.transform).toBe('translate(20px, 40px) scale(1.5)'); controller.dispose(); - fake.runFrame(); - expect(calls).toEqual([]); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts index e25ea6ee2..46ff789db 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts @@ -12,65 +12,38 @@ describe('resource book state machine', () => { expect(resourceBookShowsOverviewCards(initialResourceBookState)).toBe(true); }); - it('keeps main view free of real cards until the enter frame commits', () => { + it('commits the child target immediately and only keeps the motion phase transient', () => { const entering = resourceBookReducer(initialResourceBookState, { type: 'open-category', category: 'document', token: 1, }); expect(entering).toMatchObject({ - view: 'main', - category: null, - pendingCategory: 'document', + view: 'child', + category: 'document', phase: 'entering', token: 1, }); expect(resourceBookSceneCategory(entering)).toBe('document'); - - const child = resourceBookReducer(entering, { - type: 'commit-enter', - token: 1, - }); - expect(child).toMatchObject({ - view: 'child', - category: 'document', - pendingCategory: null, - phase: 'entering', - }); }); - it('retains the requested destination across a return leg', () => { + it('retargets directly from one child canvas to another', () => { const current = resourceBookReducer(initialResourceBookState, { type: 'open-category', category: 'document', token: 1, }); - const child = resourceBookReducer(current, { - type: 'commit-enter', - token: 1, - }); - const switching = resourceBookReducer(child, { + const switching = resourceBookReducer(current, { type: 'open-category', category: 'art', token: 2, }); expect(switching).toMatchObject({ view: 'child', - category: 'document', - pendingCategory: 'art', - phase: 'returning', - }); - const overview = resourceBookReducer(switching, { - type: 'commit-return', - token: 2, - }); - expect(overview).toMatchObject({ - view: 'main', - category: null, - pendingCategory: 'art', + category: 'art', phase: 'entering', }); - expect(resourceBookSceneCategory(overview)).toBe('art'); + expect(resourceBookSceneCategory(switching)).toBe('art'); }); it('marks a return to the main canvas separately from a child enter transition', () => { @@ -79,23 +52,13 @@ describe('resource book state machine', () => { category: 'document', token: 1, }); - const child = resourceBookReducer(entering, { - type: 'commit-enter', - token: 1, - }); - const returning = resourceBookReducer(child, { + const returning = resourceBookReducer(entering, { type: 'return-to-main', token: 2, }); - const main = resourceBookReducer(returning, { - type: 'commit-return', - token: 2, - }); - - expect(main).toMatchObject({ + expect(returning).toMatchObject({ view: 'main', category: null, - pendingCategory: null, phase: 'returning-main', }); }); @@ -106,11 +69,7 @@ describe('resource book state machine', () => { category: 'document', token: 1, }); - const child = resourceBookReducer(entering, { - type: 'commit-enter', - token: 1, - }); - const switching = resourceBookReducer(child, { + const switching = resourceBookReducer(entering, { type: 'open-category', category: 'art', token: 2, @@ -121,10 +80,9 @@ describe('resource book state machine', () => { }); expect(main).toMatchObject({ - view: 'child', - category: 'document', - pendingCategory: null, - phase: 'returning', + view: 'main', + category: null, + phase: 'returning-main', token: 3, }); }); @@ -140,9 +98,6 @@ describe('resource book state machine', () => { category: 'art', token: 2, }); - expect( - resourceBookReducer(switched, { type: 'commit-enter', token: 1 }), - ).toEqual(switched); expect( resourceBookReducer(switched, { type: 'finish-transition', token: 1 }), ).toEqual(switched); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 722ae47c9..320988776 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,13 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-09-05 资源画本转场与视口分离 + +- `resourceBookController` 使用独立 FLIP 快照层和 Web Animations 完成信号;逻辑栏目立即提交,不用定时器猜 CSS 动画终点。再次导航从当前快照位置开始,旧完成回调由 token 失效。 +- 屏幕 DOMRect 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewport;快照复制必须保留祖先缩放对内部图片、文字的影响,单纯把克隆外框改成屏幕宽高会产生内部留白。 +- 点击和滚轮共用导航入口,但滚轮提交不得重置已有的冷却和队列;监听器使用最新导航回调引用,避免视觉 phase 更新重装监听器并清空待处理手势。 +- jsdom 不执行真实转场;定向模型测试之外,仍需浏览器核验中断连续性、图片/文字尺寸、viewport 保留和窄屏布局。完整契约见资源自由画板技术方案。 + ## 2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe - **现象**:Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 `@tauri-apps/api/event.listen`,因缺少 `window.__TAURI_INTERNALS__` 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。 diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md index 4aea7c435..fe1af6e5e 100644 --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md @@ -1,38 +1,26 @@ # Game Agent 资源自由画板与快速编辑 -## 2026-09-04 动画回归排查与重构收敛方案 +## 2026-09-05 资源画本独立转场 -最近一组资源画本提交(`f339b383f`、`019b1761f`、`f883d4bf2`、`069248b29`、`820fbfd98`)把主画布、子画布、真实资源卡、分页画布和滚轮转发逐步叠加在同一个页面组件中,造成状态、坐标和副作用互相耦合。本阶段先完成低风险收敛: +- `resourceBookModel` 立即提交目标 `main / child + category`,`entering / returning-main` 只表示尚未收尾的视觉过程,不承载待提交的栏目。缩略入口、大纲、下一页、滚轮与资源定位共用分类导航入口;子画布互切直接到达目标,不经过主画布。 +- 真实 `ResourceCard` 仍由唯一的 `ResourceBookScene` 挂载,保持预览请求、播放器和交互的单一所有者。转场前 `resourceBookController.begin` 捕获当前可见标题与资源卡的屏幕矩形和表现;目标 DOM 提交后的 layout effect 调用 `play`。 +- `ResourceBookTransitionLayer` 是 manager 内未变换的独立层。快照仅复制表现,不挂载 React 业务组件,移除重复 ID、业务 data 属性及可访问控件身份,整层 `inert / aria-hidden / pointer-events:none`。播放中的视频优先捕获当前帧;跨源禁止读取时保留已有 poster,不启动第二个播放器。 +- FLIP 只动画快照的 transform/opacity,统一时长 420ms,所有 `Animation.finished` 完成后删除快照并恢复目标真实节点;动画创建失败、零尺寸或不支持 Web Animations 时直接完成。`prefers-reduced-motion` 不创建动画,不等待定时器。 +- 再次导航先读取当前动画快照的实际位置,再取消旧动画并重新建立目标;token 隔离旧 promise,过期完成和取消均不能提交新状态。窗口尺寸变化、排序或异步布局更新时直接收尾到当前目标,避免使用过期几何。 +- 缩放、适应内容和空白拖动先结束视觉转场再执行用户输入;转场中的资源卡 pointer-down 不启动持久化拖拽。普通滚轮保留分页 controller 的原有阈值、冷却与队列,导航提交不能重置该冷却;Ctrl/Meta 滚轮取消待切页队列。 +- viewport 只属于用户交互状态,按主画布以及 `sortMode/category` 隔离保存。转场不插值也不持久化 viewport;测量主画布缩略入口时先逆变换回布局坐标,避免缩放后的 DOMRect 被二次缩放。只有真实非零测量才能标记首次 fit 完成。 -- 真实 `ResourceCard` 只保留一棵由 `ResourceBookScene` 管理的节点树;主画布缩略卡只负责入口展示,不再复制真实卡片或预览。 -- 修复主画布稳定 `main/idle` 状态误把标题和共享预览卡隐藏的问题;主画布与返回中的 overview 统一保持可见,只有进入子画布后才切换为活动栏目。 -- 转场 timer、`requestAnimationFrame` 和项目切换绑定同一个 token;新目标、切项目或卸载时统一失效旧回调,避免旧动画写入新栏目。 -- 从大纲或“下一页”进入时使用已测量的缩略卡布局作为过渡起点;子画布互切先同步逻辑目标栏目,再按既有 320ms/520ms 节奏完成返回与进入动画。 -- 主画布 viewport 与子画布 viewport 分离;普通滚轮直接复用分页画布 handler,Ctrl/Meta 滚轮只更新当前 viewport,不再向隐藏 DOM 重新派发 `WheelEvent`。 -- 转场阶段由 `ResourceBookScene` 统一承载当前 viewport:主画布缩放后的背景、标题、预览卡,以及进入/返回子画布时的目标缩放,都在同一条 `transform` 动画链路中完成,避免缩放后卡片和背景使用不同坐标系。 -- 依赖图尚未完成读取时不挂载真实资源卡;失败时允许使用明确的 fallback 快照,避免预览和布局在未确定的图上提前启动。 - -重构按三个边界推进,不改变当前动画视觉参数: - -1. 已抽离 `resourceBookModel`、`resourceBookLayout`、`resourceBookViewport` 纯模块,以 reducer 管理 `main / child / entering / returning`,组件只订阅投影并渲染。 -2. 已抽离 `resourceBookController` 与 `resourceCanvasController`,由 tokenized transition controller 管理 timer/RAF,由分页 controller 管理滚轮阈值、方向、节流和重置;主画布、子画布和分页画布不再通过隐藏 DOM 转发推进状态。 -3. 已删除历史资源画本动画规则和重复 hover 选择器,并为快速切换、项目切换、origin 解析、viewport 隔离、唯一预览观察和滚轮状态补充回归测试。 - -### 当前验证与未收敛项(2026-09-04) - -`npm --prefix apps/ai-game-creator-shell run typecheck`、`npm run check:encoding`、`git diff --check` 和相关 ESLint 检查已通过。`apps/ai-game-creator-shell/tests/appSurface.test.ts` 已恢复为 `384 passed / 0 failed`,资源画本模型与 controller 测试合计 `9 passed / 0 failed`;另补充主画布稳定态标题与共享预览卡回归场景。 - -测试仍会输出既有的 React `act(...)` 提示,以及 jsdom 未实现 `HTMLMediaElement.prototype.pause` 的 stderr;这些是测试环境警告,不影响断言结果,也未新增生产兼容层。 +验收使用 `resourceBookController.test.ts`、`resourceBookModel.test.ts`、`resourceBookViewport.test.ts` 和 `appSurface.test.ts`;页面 `.suite.ts` 由 `appSurface.test.ts` 注册,不能作为独立 Vitest 文件运行。真实浏览器需覆盖主/子往返、连续改选、滚轮切换、取消后缩放、尺寸变化、减少动画偏好,以及快照图片和字体的正确比例。jsdom 不模拟真实插值,不能代替浏览器验收。 ### 2026-09-05 子画布无限平移与初始可读比例 子画布改为固定视口加可无限平移的 world 层:视口本身保持 `overflow: hidden`,平移和缩放只作用于 `.game-resource-book-scene-world`,不再让内容容器的边界限制拖动范围。首次 fit(包括隐藏分页画布提前完成的 fit)统一保证比例不低于 `1:1`,避免子画布展开后卡片缩成不可读的小块;用户后续主动缩放或平移的视口仍按每个分类独立保留。新增 viewport 回归测试覆盖初始可读比例和超出内容边界双向平移。 -返回主画布时使用独立的 `returning-main` 阶段:卡片先完成子画布到主画布预览位的缩回,随后主画布直接进入稳定布局,不再重复播放 world/main viewport 的平移动画;子画布之间切换仍使用原有的返回后进入动画。 +返回主画布时立即提交主画布布局,由独立快照层完成缩回;world/main viewport 不播放附加动画。 ## 2026-09-04 资源画本 UI 阶段补充 -资源工作台当前产品方向调整为“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略卡片,不直接展开真实资源内容;缩略卡片内部按资源类型显示有限层叠卡片,最多展示三层,更多资源以虚化卡片提示。进入子画布时使用共享元素转场:标题栏和资源卡片沿用同一组 DOM 元素,从缩略卡片的原始位置移动、展开到子画布中的目标位置,不允许先播放独立放大动画再硬切另一套内容;主画布预览和子画布展开态必须复用同一套卡片视觉与标题栏结构。进入子画布时在标题栏提供缩回主画布的缩放按钮;从一个子画布切换到另一个子画布时必须先完成缩回主画布,再进入目标子画布。 +资源工作台采用“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片,最多三层,更多资源以虚化卡片提示。主画布预览和子画布展开态复用同一套卡片视觉与标题栏结构,共享元素快照从当前屏幕位置过渡到目标位置。子画布标题栏提供缩回主画布的按钮;子画布互切直接到达目标,不强制经过主画布。 本阶段先实现前端 UI、导航状态和转场表现,资源真实跨画布移动、主画布正式资源登记、上传/生成任务、版本绑定以及 UI 编辑器导入与 UI 包工作流继续沿用现有权威链路,待 UI 编辑器完善后再接入。临时画本视图状态不得替代 manifest、草稿或其它后端业务真相。