补齐 AGC「生成动画」面板的宿主样式并加样式缺口守卫

- resourceCanvasChrome.css 逐条照抄 src/index.css 的角色动画面板规则:面板 / 提示词 textarea / 动作预设 / 底部动作行 / 提交按钮,以及关闭键、素材缩略图与 reference-chip 全族
- 窄屏(≤720px)与网页端同口径:面板落到底部整宽、动作竖排、提交按钮整宽
- 新增 tests/resourceCanvasCharacterAnimationPanelStyle.test.tsx:渲染共享动画面板,按 src/main.tsx 的真实 import 关系解析 AGC 实际加载的样式表,断言「网页端给出规则的类 AGC 必须也有一条」并抽查撑开布局的关键声明
- 变异验证:临时移除本次补的样式块,守卫用例列出缺规则的 15 个类并红灯
This commit is contained in:
2026-09-20 23:48:53 +08:00
parent 5b73082ad0
commit 573f9f447a
2 changed files with 626 additions and 0 deletions
@@ -1408,3 +1408,339 @@
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/*
* 角色动画生成面板(共享 `ImageCanvasCharacterAnimationPanelView`)与它的素材缩略图。
*
* 同一处样式缺口:`image-canvas-editor__character-animation-*`、`__generation-close` 与
* `__reference-strip/__reference-slot/__reference-chip*` 在网页端由整站表 `src/index.css` 提供,
* 而 AGC 不引那张表、宿主 chrome 表又只照抄了 composer 的通用规则。结果是这个面板只剩通用
* composer 外观:素材缩略图、预设胶囊、提交按钮与右上角关闭键全部落回默认流布局,面板里出现
* 一大块空白、预设竖排、`生成80泥点` 悬在中间(客户端验收现场截图)。
*
* 下面逐条照抄 `src/index.css` 的同名规则(注释里给行号),不手写近似值;AGC 不会渲染的兄弟
* 选择器不搬(例如 `.image-canvas-editor__character-animation-head` 在这版组件里没有节点)。
*/
/* src/index.css:6776 */
.image-canvas-editor__generation-composer--character-animation {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto auto auto auto;
width: min(42rem, calc(100% - 1.5rem));
}
/* src/index.css:6874 */
.image-canvas-editor__generation-close {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: #374151;
font-size: 0.82rem;
font-weight: 800;
}
/* src/index.css:6906 */
.image-canvas-editor__reference-strip {
display: flex;
grid-column: 1 / -1;
min-width: 0;
align-items: center;
gap: 0.36rem;
overflow-x: auto;
padding: 0 2.2rem 0.02rem 0;
scrollbar-width: thin;
}
/* src/index.css:6917 */
.image-canvas-editor__reference-slot {
position: relative;
display: inline-flex;
flex: 0 0 auto;
width: 3.95rem;
height: 3.95rem;
}
/* src/index.css:6925 */
.image-canvas-editor__reference-chip {
position: relative;
display: inline-grid;
width: 3.95rem;
min-width: 3.95rem;
max-width: 3.95rem;
height: 3.95rem;
min-height: 3.95rem;
grid-template-rows: 1.42rem auto;
align-items: center;
justify-items: center;
gap: 0.28rem;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 1rem;
background: #f2f3f5;
padding: 0.62rem 0.36rem 0.5rem;
color: #a3a8ae;
font-size: 0.68rem;
font-weight: 850;
line-height: 1.04;
text-align: center;
box-shadow: none;
}
/* src/index.css:6950 */
button.image-canvas-editor__reference-chip {
cursor: pointer;
}
button.image-canvas-editor__reference-chip:hover:not(:disabled) {
transform: translateY(-1px);
border-color: rgba(148, 163, 184, 0.22);
background: #eef0f2;
}
button.image-canvas-editor__reference-chip:disabled {
cursor: not-allowed;
opacity: 0.62;
transform: none;
}
/* src/index.css:6966 */
.image-canvas-editor__reference-chip-icon {
display: grid;
width: 1.42rem;
height: 1.42rem;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: 0.36rem;
background: transparent;
color: currentColor;
}
.image-canvas-editor__reference-chip-icon img {
width: 100%;
height: 100%;
border-radius: 0.36rem;
object-fit: cover;
}
/* src/index.css:6985 */
.image-canvas-editor__reference-chip-label {
display: -webkit-box;
min-width: 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
white-space: normal;
word-break: keep-all;
}
/* src/index.css:6996 */
.image-canvas-editor__reference-chip-source-label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
clip-path: inset(50%);
}
/* src/index.css:7006 */
.image-canvas-editor__reference-chip-remove {
position: absolute;
top: 0.16rem;
right: 0.16rem;
z-index: 2;
display: grid;
width: 1.12rem;
height: 1.12rem;
place-items: center;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 999px;
background: rgba(15, 23, 42, 0.86);
color: #ffffff;
opacity: 0;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.18);
pointer-events: none;
transform: scale(0.88);
transition:
opacity 120ms ease,
transform 120ms ease;
}
.image-canvas-editor__reference-chip-remove--always-visible {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
.image-canvas-editor__reference-slot:hover
.image-canvas-editor__reference-chip-remove,
.image-canvas-editor__reference-slot:focus-within
.image-canvas-editor__reference-chip-remove {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
@media (hover: none) {
.image-canvas-editor__reference-chip-remove {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}
.image-canvas-editor__reference-chip-remove:disabled {
cursor: not-allowed;
opacity: 0;
pointer-events: none;
}
.image-canvas-editor__reference-chip-remove:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.95);
}
/* src/index.css:7061:带图缩略图的图标尺寸(角色动画的源图就是这一档) */
.image-canvas-editor__reference-chip--has-image
.image-canvas-editor__reference-chip-icon {
width: 1.74rem;
height: 1.74rem;
border-radius: 0.48rem;
}
.image-canvas-editor__reference-chip--has-image
.image-canvas-editor__reference-chip-icon
img {
border-radius: 0.48rem;
}
/* src/index.css:70747112tone 取色(AGC 目前渲染 quick-edit 与 character,其余一并有备) */
.image-canvas-editor__reference-chip--spec
.image-canvas-editor__reference-chip-icon {
color: #8b5cf6;
}
.image-canvas-editor__reference-chip--icon
.image-canvas-editor__reference-chip-icon {
color: #059669;
}
.image-canvas-editor__reference-chip--ui
.image-canvas-editor__reference-chip-icon {
color: #d97706;
}
.image-canvas-editor__reference-chip--character
.image-canvas-editor__reference-chip-icon {
color: #0f172a;
}
.image-canvas-editor__reference-chip--quick-edit {
background: #f3f4f6;
color: #9ca3af;
}
.image-canvas-editor__reference-chip--quick-edit
.image-canvas-editor__reference-chip-icon {
color: #9ca3af;
}
.image-canvas-editor__reference-chip--video
.image-canvas-editor__reference-chip-icon {
color: #2563eb;
}
.image-canvas-editor__reference-chip--audio
.image-canvas-editor__reference-chip-icon {
color: #7c3aed;
}
/* src/index.css:8728 */
.image-canvas-editor__character-animation-panel {
position: absolute;
z-index: 14;
display: grid;
width: min(42rem, calc(100vw - 1.5rem));
max-height: min(34rem, calc(100% - 1.5rem));
gap: 0.58rem;
overflow: auto;
border: 1px solid rgba(148, 163, 184, 0.36);
border-radius: 1.1rem;
background: rgba(255, 255, 255, 0.96);
padding: 0.76rem;
font-size: 0.82rem;
box-shadow: 0 22px 48px rgba(15, 23, 42, 0.22);
}
/* src/index.css:8753 */
.image-canvas-editor__character-animation-textarea {
grid-column: 1 / -1;
--auto-grow-editor-min-height: 8rem;
--auto-grow-editor-max-height: 16rem;
}
/* src/index.css:8759 */
.image-canvas-editor__character-animation-presets {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
gap: 0.38rem;
}
.image-canvas-editor__character-animation-preset {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 999px;
background: #f8fafc;
padding: 0.34rem 0.58rem;
color: #334155;
font-size: 0.78rem;
}
/* src/index.css:8775 */
.image-canvas-editor__character-animation-footer {
display: grid;
grid-column: 1 / -1;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.46rem;
}
.image-canvas-editor__character-animation-submit {
grid-column: 3;
min-width: 5.45rem;
height: 2.25rem;
gap: 0.4rem;
border: 0;
background: var(--image-canvas-brand-fill);
color: var(--image-canvas-brand-text-on-fill);
}
/* 窄屏与上面的浮层工具条同档(src/index.css:9020 用 760px):面板落到底部整宽、动作竖排。 */
@media (max-width: 720px) {
.image-canvas-editor__character-animation-panel {
position: fixed;
left: 0.75rem !important;
right: 0.75rem;
top: auto !important;
bottom: 0.75rem;
width: auto;
max-height: min(72vh, 34rem);
transform: none;
}
.image-canvas-editor__character-animation-footer {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
}
.image-canvas-editor__character-animation-submit {
grid-column: auto;
width: 100%;
max-width: none;
justify-self: stretch;
}
}
@@ -0,0 +1,290 @@
// @vitest-environment jsdom
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { cleanup, render } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ImageCanvasCharacterAnimationPanelView } from '../../../src/components/image-editor/ImageCanvasCharacterAnimationPanelView';
import type {
CanvasLayer,
CharacterAnimationPanelState,
} from '../../../src/components/image-editor/ImageCanvasEditorTypes';
/**
* 「生成动画」面板在 AGC 的样式覆盖契约。
*
* 现象(客户端验收现场):资源卡上点「生成动画」,面板只剩通用 composer 外观——素材缩略图、
* 动作预设、`生成NN泥点` 按钮与关闭键全部落回默认流布局,面板里出现一大块空白、预设竖排。
* 根因是这套 `image-canvas-editor__character-animation-*` / `__generation-close` /
* `__reference-*` 规则只写在网页端整站表 `src/index.css` 里,而 AGC 是独立宿主。
*
* jsdom 不计算外部 CSS,所以这里不做"渲染出来看观感",而是把两半分别钉死:
* 1) DOM 契约:面板真的渲染出这些类名(含 `reference-chip--${tone}` 这种运行时拼出来的);
* 2) 样式表契约:从 AGC 入口 `src/main.tsx` 按真实 import 关系解析出"AGC 到底加载了哪些
* 样式表",断言上面每个类都至少命中一条规则,并抽查几条撑开布局的关键声明。
*
* 这是**弱验证**(声明级,不是真机观感)。真机判据:资源卡 → 生成动画 → 缩略图、五个动作
* 预设一排、按钮落在面板右下角、右上角有关闭键;窄屏(≤720px)面板落到底部整宽、按钮整宽。
*/
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
const AGC_ROOT = resolve(TEST_DIR, '..');
const REPO_ROOT = resolve(AGC_ROOT, '..', '..');
const PANEL_CLASS = 'image-canvas-editor__character-animation-panel';
type CssRule = { selector: string; body: string };
/** `@genarrative/<pkg>/<subpath>` → `packages/<pkg>/package.json` 的 exports 指向的真实文件。 */
function resolveStyleSpecifier(specifier: string, fromFile: string): string {
if (specifier.startsWith('.')) {
return resolve(dirname(fromFile), specifier);
}
const match = /^(@[^/]+\/[^/]+|[^@/][^/]*)(\/.*)?$/u.exec(specifier);
expect(match, `样式入口无法解析:${specifier}`).not.toBeNull();
const packageName = match![1]!
.replace(/^@genarrative\//u, '')
.replace(/\//gu, '-');
const packageDir = resolve(REPO_ROOT, 'packages', packageName);
const manifestPath = resolve(packageDir, 'package.json');
if (!existsSync(manifestPath)) {
throw new Error(
`AGC 新增了未知样式入口「${specifier}」:请把它的解析方式补进本用例的清单。`,
);
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
exports?: Record<string, string>;
};
const subpath = match![2] ? `.${match![2]}` : '.';
const target = manifest.exports?.[subpath];
if (!target) {
throw new Error(
`样式入口「${specifier}」在 packages/${packageName}/package.json 的 exports 里没有对应项。`,
);
}
return resolve(packageDir, target);
}
/** 从 AGC 入口出发按 import / @import 关系收集它加载的样式表,不手写清单。 */
function collectAgcLoadedStyleSheets(): string[] {
const entryFile = resolve(AGC_ROOT, 'src/main.tsx');
const pending = [
...readFileSync(entryFile, 'utf8').matchAll(
/import\s+'(?<specifier>[^']+\.css)'/gu,
),
].map((match) => resolveStyleSpecifier(match.groups!.specifier!, entryFile));
const loaded: string[] = [];
while (pending.length > 0) {
const sheet = pending.pop()!;
if (loaded.includes(sheet)) {
continue;
}
loaded.push(sheet);
const source = readFileSync(sheet, 'utf8');
for (const match of source.matchAll(/@import\s+'(?<specifier>[^']+)'/gu)) {
const specifier = match.groups!.specifier!;
// 由 Vite / Tailwind 插件接管的入口没有实体文件。
if (specifier === 'tailwindcss') {
continue;
}
pending.push(resolveStyleSpecifier(specifier, sheet));
}
}
return loaded.sort();
}
/** 够用的规则读取:取每一段 `选择器 { 声明 }`,跳过 `@media` 这类容器本身。 */
function readRules(file: string): CssRule[] {
const source = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//gu, '');
const rules: CssRule[] = [];
for (const match of source.matchAll(/([^{}]+)\{([^{}]*)\}/gu)) {
const selector = match[1]!.trim().replace(/\s+/gu, ' ');
if (!selector || selector.startsWith('@')) {
continue;
}
rules.push({ selector, body: match[2]!.trim().replace(/\s+/gu, ' ') });
}
return rules;
}
function declarationOf(
rules: readonly CssRule[],
className: string,
property: string,
): string | null {
const dot = `.${className}`;
for (const rule of rules) {
const selectors = rule.selector.split(',').map((item) => item.trim());
// 只认「这个类自己那一档」的声明:`.cls` 精确出现,或 `.cls 后代`。
const owns = selectors.some(
(selector) =>
selector === dot ||
selector.startsWith(`${dot} `) ||
selector.startsWith(`${dot}:`) ||
selector.startsWith(`${dot}[`) ||
selector.endsWith(` ${dot}`),
);
if (!owns) {
continue;
}
for (const chunk of rule.body.split(';')) {
const separator = chunk.indexOf(':');
if (separator < 0) {
continue;
}
if (chunk.slice(0, separator).trim() === property) {
return chunk
.slice(separator + 1)
.trim()
.replace(/\s+/gu, ' ');
}
}
}
return null;
}
function animationPanel() {
const sourceLayer: CanvasLayer = {
id: 'layer-a',
resourceId: 'resource-a',
title: 'char_hero_back.png',
src: 'data:image/png;base64,Y2hhcmFjdGVy',
x: 0,
y: 0,
width: 128,
height: 128,
originalWidth: 128,
originalHeight: 128,
zIndex: 1,
sourceType: 'uploaded',
};
const initialPanel: CharacterAnimationPanelState = {
sourceLayerId: 'layer-a',
promptText: '待机动作',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
};
function Harness() {
const [panel, setPanel] = useState<CharacterAnimationPanelState | null>(
initialPanel,
);
return panel ? (
<ImageCanvasCharacterAnimationPanelView
panel={panel}
sourceLayer={sourceLayer}
style={{ left: 12, top: 24 }}
price={80}
setCharacterAnimationPanel={setPanel}
onUpdateDuration={vi.fn()}
onSubmit={vi.fn()}
/>
) : null;
}
const { container } = render(<Harness />);
return container;
}
afterEach(() => {
cleanup();
});
describe('「生成动画」面板的 AGC 样式覆盖', () => {
it('网页端给了规则的类,AGC 也必须有一条(样式缺口守卫)', () => {
const container = animationPanel();
const classes = new Set<string>();
for (const element of container.querySelectorAll('[class]')) {
for (const name of element.classList) {
if (name.startsWith('image-canvas-editor__')) {
classes.add(name);
}
}
}
expect(classes.size).toBeGreaterThan(8);
/** 判据只认「网页端有、AGC 没有」这一类:两边都没有的类是按设计不带样式(如纯语义 span)。 */
const agcSelectors = collectAgcLoadedStyleSheets()
.flatMap(readRules)
.map((rule) => rule.selector)
.join('\n');
const webSelectors = readRules(resolve(REPO_ROOT, 'src/index.css'))
.map((rule) => rule.selector)
.join('\n');
const webStyled = [...classes].filter((name) =>
webSelectors.includes(`.${name}`),
);
expect(
webStyled.length,
'网页端样式这一次一条都没匹配上:断言退化成空集,守卫失效',
).toBeGreaterThan(8);
const missing = webStyled.filter(
(name) => !agcSelectors.includes(`.${name}`),
);
expect(
missing,
`这些类网页端有规则、AGC 一条都没有:${missing.join(', ')}`,
).toEqual([]);
});
it('撑开面板布局的关键声明逐条在位', () => {
const rules = collectAgcLoadedStyleSheets().flatMap(readRules);
expect(declarationOf(rules, PANEL_CLASS, 'position')).toBe('absolute');
expect(declarationOf(rules, PANEL_CLASS, 'width')).toContain('42rem');
expect(
declarationOf(
rules,
'image-canvas-editor__generation-composer--character-animation',
'grid-template-columns',
),
).toBe('minmax(0, 1fr)');
expect(
declarationOf(
rules,
'image-canvas-editor__character-animation-presets',
'display',
),
).toBe('flex');
expect(
declarationOf(
rules,
'image-canvas-editor__character-animation-preset',
'border-radius',
),
).toBe('999px');
expect(
declarationOf(
rules,
'image-canvas-editor__character-animation-footer',
'grid-template-columns',
),
).toBe('auto minmax(0, 1fr) auto');
expect(
declarationOf(
rules,
'image-canvas-editor__character-animation-submit',
'grid-column',
),
).toBe('3');
expect(
declarationOf(rules, 'image-canvas-editor__generation-close', 'display'),
).toBe('inline-flex');
expect(
declarationOf(
rules,
'image-canvas-editor__reference-strip',
'grid-column',
),
).toBe('1 / -1');
expect(
declarationOf(rules, 'image-canvas-editor__reference-chip', 'width'),
).toBe('3.95rem');
});
});