让「当前使用」的资源卡真的发光,并修掉版本高亮的两处既有缺陷
- .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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user