diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 7db5bf8ae..977653391 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -6889,19 +6889,81 @@ iframe.preview-frame { box-shadow: 0 8px 22px rgb(195 105 62 / 15%); } +/* + * 版本相关的卡片状态优先级(三组选择器权重相同,靠源文件顺序定胜负,顺序是承重的): + * 1. `is-relation-version-binding`:选中版本卡 → 高亮该版本绑定的资源; + * 2. `is-current-version`:正在使用的版本绑定的素材 → 常驻的呼吸光环; + * 3. `.is-current-version` 的选中 / 悬停 / 拖拽变体:显式提权到 (0,3,0), + * 把「用户此刻的操作」赢面拿回来,不再和 `.is-selected` 拼先后。 + * 后面的规则压前面的,所以「当前版本」压过「版本聚焦」,选中态压过两者。 + */ + +/* 版本聚焦:卡片本体是 `border: 0`,旧规则只写 `border-color`,等于没有边框可染色, + * 从未生效过。这里补回真实边框与 ring,让「点击版本卡后高亮引用资源」这条语义 + * (PRD §版本,`selectedVersionBindingResourceIds`)真的看得见。 */ +.game-resource-card.is-relation-version-binding { + border: 1px solid #d87342; + box-shadow: + 0 8px 22px rgb(195 105 62 / 18%), + 0 0 0 2px rgb(216 115 66 / 32%); +} + +@keyframes game-resource-card-current-version-breathe { + 0%, + 100% { + opacity: 0.58; + } + + 50% { + opacity: 1; + } +} + /* C5 卡片边框:只有被当前版本绑定使用的素材带边框,其余资源卡无边框。 */ .game-resource-card.is-current-version { border: 1px solid #d87342; box-shadow: 0 6px 18px rgb(96 62 47 / 10%), - 0 0 0 2px rgb(216 115 66 / 14%); + 0 0 0 3px rgb(216 115 66 / 32%), + 0 0 20px rgb(216 115 66 / 34%); } -.game-resource-card.is-relation-version-binding { - border-color: #d87342; +/* + * 「微微发光」:光环挂在伪元素上,只呼吸 opacity(照本页既有的 + * `.ui-editor-status-attention::after` 写法),不动边框宽度、不动布局。 + * 声明档位 38%,呼吸区间 0.58 ↔ 1 ⇒ 视觉上约 22% ↔ 38%。 + * 静态的那一层留在卡片本体的 `box-shadow` 上,关掉动画也看得见。 + */ +.game-resource-card.is-current-version::after { + position: absolute; + inset: 0; + border-radius: inherit; box-shadow: - 0 8px 22px rgb(195 105 62 / 18%), - 0 0 0 2px rgb(216 115 66 / 14%); + 0 0 0 3px rgb(216 115 66 / 38%), + 0 0 20px rgb(216 115 66 / 38%); + content: ''; + pointer-events: none; + animation: game-resource-card-current-version-breathe 2.4s ease-in-out + infinite; +} + +.game-resource-card.is-current-version.is-selected, +.game-resource-card.is-current-version:hover, +.game-resource-card.is-current-version:focus-within, +.game-resource-card.is-current-version.is-dragging { + border-color: #d57b51; + box-shadow: + 0 8px 22px rgb(195 105 62 / 15%), + 0 0 0 3px rgb(216 115 66 / 38%), + 0 0 20px rgb(216 115 66 / 38%); +} + +@media (prefers-reduced-motion: reduce) { + /* 收敛动画但不收敛可见性:光环静态停在可见档位。 */ + .game-resource-card.is-current-version::after { + animation: none; + opacity: 0.75; + } } .game-resource-card-visual { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 996edef92..651ec03a1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -2828,7 +2828,11 @@ export function registerProjectWorkbenchFoundationTests() { versionId: 'version-root', parentVersionId: null, projectRevision: 3, - resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }], + // `slotId` 是恒等映射 `asset:{assetId}`(见 `currentVersionResourceBindingIds`)。 + // 这里曾经写成 `'player'`,于是「当前版本」判定永远不命中——照它写断言必成假守卫。 + resourceBindings: [ + { slotId: 'asset:asset-player', resourceId: 'asset-player' }, + ], createdReason: 'initial', createdAt: 100, }, @@ -2837,8 +2841,8 @@ export function registerProjectWorkbenchFoundationTests() { parentVersionId: 'version-root', projectRevision: 4, resourceBindings: [ - { slotId: 'player', resourceId: 'asset-player' }, - { slotId: 'historical', resourceId: 'asset-removed' }, + { slotId: 'asset:asset-player', resourceId: 'asset-player' }, + { slotId: 'asset:asset-removed', resourceId: 'asset-removed' }, ], createdReason: 'agent-revision', createdAt: 200, @@ -2885,6 +2889,11 @@ export function registerProjectWorkbenchFoundationTests() { expect(playerCard?.classList.contains('is-relation-version-binding')).toBe( true, ); + // 夹具口径修正后的自证:`slotId` 写成恒等映射,默认(最新)版本绑定的 + // player.png 必须真的命中「当前使用」,否则这条夹具又变回假守卫。 + expect(playerCard?.getAttribute('data-used-by-current-version')).toBe( + 'true', + ); fireEvent.click(screen.getByRole('button', { name: '按依赖' })); await waitFor(() => { diff --git a/apps/ai-game-creator-shell/tests/resourceCardCurrentVersionStyle.test.ts b/apps/ai-game-creator-shell/tests/resourceCardCurrentVersionStyle.test.ts new file mode 100644 index 000000000..892b390ce --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCardCurrentVersionStyle.test.ts @@ -0,0 +1,146 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +/** + * 「当前使用」资源卡的发光档案:声明级断言。 + * + * jsdom 不加载样式表、也不做动画,肉眼效果只能由真机确认;这里钉住的是这次改动的实质—— + * 外向扩散层的档位(旧值 14% 是描边不是发光)、呼吸的实现方式(伪元素只动 opacity)、 + * 选中态提权的形态,以及 `prefers-reduced-motion` 下仍然可见。 + */ +function stylesSource() { + return readFileSync( + resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), + 'utf8', + ); +} + +/** 取 CSS 源文件里某条规则的声明体。 */ +function ruleBody(styles: string, selector: string) { + const match = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, 'su').exec(styles); + expect(match, `${selector} 规则缺失`).not.toBeNull(); + return match![1]!; +} + +/** + * 取 `box-shadow` 里「外向扩散层」的透明度档位(百分比数字)。 + * + * 外向扩散层 = 偏移为 `0 0` 的层(`0 0 0 Npx` 的 ring 或 `0 0 Npx` 的发光), + * 与 `0 6px 18px` 这类带偏移的投影区分开。只在层边界(下一个层以 `0 ` 开头)拆逗号, + * 避免把 `rgb(216 115 66 / 14%)` 里的空格当分隔符。 + */ +function outwardGlowAlphas(ruleBodyText: string) { + // 先只取 `box-shadow` 的值(到第一个分号),否则第一层会粘着属性名而认不出来。 + const value = /box-shadow:\s*([^;]+);/u.exec(ruleBodyText)?.[1] ?? ''; + return value + .split(/,(?=\s*0 )/u) + .map((layer) => layer.trim()) + .filter((layer) => layer.startsWith('0 0 ')) + .map((layer) => Number(/\/\s*(\d+)%/u.exec(layer)?.[1] ?? 0)); +} + +/** 选择器权重里可数的那部分:类与伪类的个数(只在同类选择器之间比高低)。 */ +function selectorClassCount(selector: string) { + return (selector.match(/[.:]/gu) ?? []).length; +} + +/** 按花括号配平取出包含指定选择器的 `@media (prefers-reduced-motion: reduce)` 块。 */ +function reducedMotionBlockFor(styles: string, selectorFragment: string) { + const marker = '@media (prefers-reduced-motion: reduce)'; + let index = styles.indexOf(marker); + while (index >= 0) { + const open = styles.indexOf('{', index); + let depth = 0; + let end = open; + for (let cursor = open; cursor < styles.length; cursor += 1) { + if (styles[cursor] === '{') { + depth += 1; + } else if (styles[cursor] === '}') { + depth -= 1; + if (depth === 0) { + end = cursor; + break; + } + } + } + const block = styles.slice(open + 1, end); + if (block.includes(selectorFragment)) { + return block; + } + index = styles.indexOf(marker, index + marker.length); + } + return null; +} + +describe('当前使用资源卡的发光档案', () => { + it('当前版本资源卡带常驻呼吸光环,选中态提权压过它', () => { + const styles = stylesSource(); + + // 「当前使用」必须在卡片本体的 box-shadow 上有真的外向扩散层。旧值是 + // `0 0 0 2px rgb(216 115 66 / 14%)`——那是描边不是发光,本用例就是钉这次改动的。 + const base = ruleBody(styles, '\\.game-resource-card\\.is-current-version'); + const glowAlphas = outwardGlowAlphas(base); + expect(glowAlphas.length).toBeGreaterThanOrEqual(2); + expect(Math.max(...glowAlphas)).toBeGreaterThanOrEqual(30); + expect(base).toMatch(/0 0 20px rgb\(216 115 66 \/ 3[0-9]%\)/u); + // 发光不靠加粗边框:边框口径仍是 1px。 + expect(base).toMatch(/border:\s*1px solid/u); + + // 「呼吸」挂在伪元素上,只动 opacity(本页既有写法),不碰布局也不吃点击。 + const ring = ruleBody( + styles, + '\\.game-resource-card\\.is-current-version::after', + ); + expect(ring).toMatch( + /animation:\s*game-resource-card-current-version-breathe/u, + ); + expect(ring).toMatch(/pointer-events:\s*none/u); + expect(ring).toMatch(/border-radius:\s*inherit/u); + const breathe = + /@keyframes game-resource-card-current-version-breathe\s*\{([\s\S]*?)\n\}/u.exec( + styles, + )?.[1] ?? ''; + // 声明档位 38%,呼吸区间 0.58 ↔ 1 ⇒ 视觉上约 22% ↔ 38%。 + expect(outwardGlowAlphas(ring)).toEqual([38, 38]); + expect(breathe).toMatch(/0%,\s*100%\s*\{\s*opacity:\s*0\.58;/u); + expect(breathe).toMatch(/50%\s*\{\s*opacity:\s*1;/u); + + // 选中 / 悬停 / 拖拽必须赢过常驻光环:显式提权到 (0,3,0), + // 而不是继续和 `.is-selected` 拼源文件顺序(旧写法下当前版本卡看不出被选中)。 + const selectedVariant = + /(\.game-resource-card\.is-current-version\.is-selected,[\s\S]*?)\{([^}]*)\}/u.exec( + styles, + ); + expect(selectedVariant).not.toBeNull(); + expect(selectorClassCount(selectedVariant![1]!)).toBeGreaterThan( + selectorClassCount('.game-resource-card.is-current-version'), + ); + // 选中态自己那层阴影真的写进了这个变体。 + expect(selectedVariant![2]).toContain('0 8px 22px rgb(195 105 62 / 15%)'); + expect( + Math.max(...outwardGlowAlphas(selectedVariant![2]!)), + ).toBeGreaterThanOrEqual(30); + + // 收敛动画但不收敛可见性:静态档位必须仍然看得见。 + const reduced = reducedMotionBlockFor( + styles, + '.game-resource-card.is-current-version::after', + ); + expect(reduced).not.toBeNull(); + expect(reduced).toMatch(/animation:\s*none/u); + expect( + Number(/opacity:\s*(0?\.\d+)/u.exec(reduced!)?.[1]), + ).toBeGreaterThanOrEqual(0.5); + + // 「点击版本卡后高亮绑定资源」的旧规则只写 `border-color`,而卡片本体 `border: 0`, + // 从未生效;现在必须是能落地的边框 + ring。 + const relation = ruleBody( + styles, + '\\.game-resource-card\\.is-relation-version-binding', + ); + expect(relation).toMatch(/border:\s*1px solid/u); + expect(Math.max(...outwardGlowAlphas(relation))).toBeGreaterThanOrEqual(30); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx index f6736c90f..ad314971f 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx @@ -229,6 +229,14 @@ describe('C7 运行模块版本切换', () => { expect( cardFor('player.png')?.classList.contains('is-current-version'), ).toBe(false); + // 同一件事的 DOM 合同:命中的卡片带 `data-used-by-current-version="true"`, + // 未命中的卡片**根本没有这个属性**(不是 `"false"`),排障与验收都读它。 + expect( + cardFor('town.png')?.getAttribute('data-used-by-current-version'), + ).toBe('true'); + expect( + cardFor('player.png')?.hasAttribute('data-used-by-current-version'), + ).toBe(false); rendered.rerenderWith({ activeVersionId: 'version-root' }); await waitFor(() => { @@ -239,6 +247,13 @@ describe('C7 运行模块版本切换', () => { expect(cardFor('town.png')?.classList.contains('is-current-version')).toBe( false, ); + // 切版本后属性跟着换手,而不是两件资产都亮着。 + expect( + cardFor('player.png')?.getAttribute('data-used-by-current-version'), + ).toBe('true'); + expect( + cardFor('town.png')?.hasAttribute('data-used-by-current-version'), + ).toBe(false); // 选中的版本已经不存在时按空态处理(与 `@` 面板同一口径),不残留旧高亮; // 工作台壳会在版本消失时把选择清回「最新版本」。 @@ -251,5 +266,11 @@ describe('C7 运行模块版本切换', () => { expect( cardFor('player.png')?.classList.contains('is-current-version'), ).toBe(false); + expect( + cardFor('town.png')?.hasAttribute('data-used-by-current-version'), + ).toBe(false); + expect( + cardFor('player.png')?.hasAttribute('data-used-by-current-version'), + ).toBe(false); }); });