让「当前使用」的资源卡真的发光,并修掉版本高亮的两处既有缺陷

- .game-resource-card.is-current-version 的外向扩散层从 0 0 0 2px /14%(那是描边)改成 0 0 0 3px /32% + 0 0 20px /34% 的真发光,边框口径仍是 1px
- 新增 game-resource-card-current-version-breathe 关键帧与 ::after 光环(只呼吸 opacity,照本页 .ui-editor-status-attention 的既有写法):声明档位 38%,呼吸区间 0.58 ↔ 1,视觉上约 22% ↔ 38%
- prefers-reduced-motion 下关掉动画但保留可见档位(opacity 0.75),收敛动画不收敛可见性
- 缺陷①:.game-resource-card.is-relation-version-binding 原本只写 border-color,而卡片本体 border: 0,规则从未生效(「点击版本卡后高亮绑定资源」这条 PRD 语义一直看不见);改为真实的 1px 边框 + ring,并把该规则移到 .is-current-version 之前,让常驻光环在两者同时命中时仍然赢
- 缺陷②:当前版本规则与 hover/focus/selected 同权重且在后,导致当前版本卡几乎看不出被选中;新增 .is-current-version.is-selected / :hover / :focus-within / .is-dragging 变体,显式提权到 (0,3,0) 并带上选中态自己那层阴影
- 测试:新增 resourceCardCurrentVersionStyle.test.ts(外向扩散层档位、呼吸实现方式与关键帧区间、选中态变体权重更高、reduced-motion 下仍可见、版本聚焦规则不再是死规则)
- 测试:resourceVersionSwitch 补 data-used-by-current-version 的 DOM 断言(命中为 "true"、未命中不含该属性、切版本后跟着换手)
- 测试夹具修正:project-development.suite 的版本夹具把 slotId 从 'player' 改成恒等映射 'asset:asset-player'('historical' → 'asset:asset-removed'),否则「当前版本」判定永远不命中、照它写断言必成假守卫;并补一条自证断言钉住它真的命中
This commit is contained in:
2026-09-11 16:42:58 +08:00
parent 082d8d62de
commit e088ede217
4 changed files with 246 additions and 8 deletions
+67 -5
View File
@@ -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 {
@@ -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(() => {
@@ -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);
});
});
@@ -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);
});
});