From 573f9f447a5506ef30e5530648ce87f9aa890d0f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 20 Sep 2026 23:48:53 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20AGC=E3=80=8C=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=8A=A8=E7=94=BB=E3=80=8D=E9=9D=A2=E6=9D=BF=E7=9A=84?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E6=A0=B7=E5=BC=8F=E5=B9=B6=E5=8A=A0=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E7=BC=BA=E5=8F=A3=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resourceCanvasChrome.css 逐条照抄 src/index.css 的角色动画面板规则:面板 / 提示词 textarea / 动作预设 / 底部动作行 / 提交按钮,以及关闭键、素材缩略图与 reference-chip 全族 - 窄屏(≤720px)与网页端同口径:面板落到底部整宽、动作竖排、提交按钮整宽 - 新增 tests/resourceCanvasCharacterAnimationPanelStyle.test.tsx:渲染共享动画面板,按 src/main.tsx 的真实 import 关系解析 AGC 实际加载的样式表,断言「网页端给出规则的类 AGC 必须也有一条」并抽查撑开布局的关键声明 - 变异验证:临时移除本次补的样式块,守卫用例列出缺规则的 15 个类并红灯 --- .../resource-canvas/resourceCanvasChrome.css | 336 ++++++++++++++++++ ...anvasCharacterAnimationPanelStyle.test.tsx | 290 +++++++++++++++ 2 files changed, 626 insertions(+) create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasCharacterAnimationPanelStyle.test.tsx diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index 1b4255781..a485ea740 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -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:7074–7112:tone 取色(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; + } +} diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasCharacterAnimationPanelStyle.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasCharacterAnimationPanelStyle.test.tsx new file mode 100644 index 000000000..9c02e4768 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasCharacterAnimationPanelStyle.test.tsx @@ -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//` → `packages//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; + }; + 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+'(?[^']+\.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+'(?[^']+)'/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( + initialPanel, + ); + return panel ? ( + + ) : null; + } + const { container } = render(); + return container; +} + +afterEach(() => { + cleanup(); +}); + +describe('「生成动画」面板的 AGC 样式覆盖', () => { + it('网页端给了规则的类,AGC 也必须有一条(样式缺口守卫)', () => { + const container = animationPanel(); + const classes = new Set(); + 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'); + }); +});