From 8b859778283d90eb84416570d2cff14204ef0367 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 19:43:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20PR316=20review=EF=BC=9A?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E5=88=86=E6=AE=B5=E9=A1=B5=E7=AD=BE=E5=AE=BD?= =?UTF-8?q?=E5=BA=A6=E4=B8=8B=E9=99=90=E4=BD=9C=E7=94=A8=E5=9F=9F=E3=80=81?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=A5=91=E7=BA=A6=E4=B8=8E=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/shared/src/components/styles.css:528 [bug medium] min-width: max-content 收窄到新的 .platform-segmented-tabs--scroll 修饰类:grid 分支没有溢出容器、轨道是 minmax(0,1fr),同样的下限只会让内容画到相邻格上;grid 侧靠轨道等分与宿主 truncateLabels 收口,选中 PlatformSegmentedTabs.tsx 的 scroll 分支补修修饰类。 - packages/shared/src/components/PlatformResourceFilterBar.test.tsx 同步改成钉「下限只挂在 --scroll」+「grid 分支不带 --scroll/overflow-x-auto」(声明级断言,jsdom 不计算外部样式表,属弱验证,真机判据见提交说明)。 - packages/shared/src/components/PlatformResourceFilterBar.tsx:24 [maintainability low] Props 改为以 tagItems 为判别项的联合类型:给 tagItems 就必须给 onToggleTag,非法状态编译期不可表达;组件内部再兜 disabled={!onToggleTag} 供给不带类型检查的调用方,并补对应运行时用例。 - packages/shared/src/components/PlatformResourceFilterBar.tsx:107 [style low] 标签行补 scrollbar-hide:它是唯一还画滚动条的横向滚动容器,与 styles.css 里 .scrollbar-hide 的注释口径对齐;补类名与声明级用例。 - packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:51 [maintainability low] assetIds 排序改为确定性 code-unit sort(),不再依赖运行环境默认 locale(标签那一层仍显式钉 zh-CN),补跨输入顺序可复现的用例。 - packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:68 [bug low] assetTagsMatchSelection 两侧对称归一化:selectedTags 也过 normalizeGameCreationAppAssetTags,' 像素风 ' 这类未归一化的选中项不再静默匹配不上。 - packages/shared/src/contracts/gameCreationApp.test.ts [test low] 新增解析 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 与 TS 常量对齐的交叉校验(仿 assetKindCanonicalMapping.test.ts 解析 Rust 常量先例),钉住两侧分区顺序不漂移。 - packages/shared/src/contracts/gameCreationApp.ts 同步「读显示 / 写回」口径注释:未知 category 已在 Rust 读侧失败关闭,「非法值 → 派生」只剩内存对象语义。 - apps/ai-game-creator-shell/src/styles.css:5790 [bug medium] :has() 悬停高亮分类值换成现行 7 个分区(ui-interaction/character/scene/audio/document/unclassified/version),删掉永不渲染的 code/art 死选择器,补齐缺失栏目的高亮。 - apps/ai-game-creator-shell/src/styles.css:6873 [bug medium] hover/focus-within/is-selected 由只写 border-color 改为完整 border: 1px solid #d57b51(基规则 border: 0 下 border-color 无效果,选中/焦点态此前完全不可见),box-sizing: border-box 下不改卡片尺寸。 - apps/ai-game-creator-shell/src/styles.css:10151 [bug medium] 去掉高优先级 :focus-within 规则里的 box-shadow: none:它盖掉了 --platform-input-focus-ring 焦点光环,direct-codex 输入区此前没有任何可见焦点(无障碍回归),现在光环生效。 - apps/ai-game-creator-shell/src/styles.css:10198 [bug low] 润色状态文字与操作排改为同一行分列(grid-row: 2 + grid-column: 1 / 2),不再整行独占或与按钮重叠,状态出现/消失不改变输入框高度。 - apps/ai-game-creator-shell/src/styles.css:10206 [maintainability low] 删除永不命中的 .resource-reference-input-at direct-codex 规则(showTriggerButton={!directCodex} 不渲染该按钮),保留说明指向真正的 .project-supervisor-reference-trigger。 - 附带 prettier 对 .design-agent-reasoning summary 单行规则的换行归一(该文件本来未过 prettier --check)。 --- apps/ai-game-creator-shell/src/styles.css | 104 ++++++++++-------- .../PlatformResourceFilterBar.test.tsx | 66 ++++++++++- .../components/PlatformResourceFilterBar.tsx | 32 ++++-- .../src/components/PlatformSegmentedTabs.tsx | 2 +- packages/shared/src/components/styles.css | 11 +- .../src/contracts/gameCreationApp.test.ts | 60 ++++++++-- .../shared/src/contracts/gameCreationApp.ts | 9 +- .../gameCreationAppAssetTagLibrary.test.ts | 33 ++++++ .../gameCreationAppAssetTagLibrary.ts | 11 +- 9 files changed, 253 insertions(+), 75 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 10f93b156..2fed9dc75 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5768,31 +5768,45 @@ iframe.preview-frame { transition: none; } +/* 缩略卡悬停 / 聚焦时高亮它所在栏目(那一摞)的标题条。 + 取值必须与 `PROJECT_RESOURCE_CANVAS_SECTIONS`(7 个现行分区)逐一对齐: + `code` / `art` 是旧四、五栏目的历史值,总览里不会渲染,写它们等于空规则; + 少写一个现行分区,对应栏目就完全没有悬停高亮。 */ .game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='code']:hover, - .game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible + .game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:hover, + .game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:focus-visible ) - .game-resource-book-scene-titlebar[data-resource-book-category='code'], + .game-resource-book-scene-titlebar[data-resource-book-category='ui-interaction'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='character']:hover, + .game-resource-book-thumbnail[data-resource-book-category='character']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='character'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='scene']:hover, + .game-resource-book-thumbnail[data-resource-book-category='scene']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='scene'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='audio']:hover, + .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='audio'], .game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='document']:hover, .game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible ) .game-resource-book-scene-titlebar[data-resource-book-category='document'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='unclassified']:hover, + .game-resource-book-thumbnail[data-resource-book-category='unclassified']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='unclassified'], .game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='version']:hover, .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible ) - .game-resource-book-scene-titlebar[data-resource-book-category='version'], -.game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='art']:hover, - .game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='art'], -.game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='audio']:hover, - .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='audio'] { + .game-resource-book-scene-titlebar[data-resource-book-category='version'] { border-top-color: #cc8060; border-right-color: #cc8060; border-bottom-color: #ebd9cf; @@ -6902,10 +6916,13 @@ iframe.preview-frame { 0 0 0 2px rgb(216 115 66 / 24%); } +/* 卡片本体是 `border: 0`(下面那条基规则)。`border-color` 单独写没有意义——0 宽的边框 + 画不出颜色,所以选中 / 悬停 / 聚焦都必须写成完整的 `border`,否则这三个状态在视觉上 + 完全看不出来。宽度与圆角保持 1px / 12px,`box-sizing: border-box` 下不会改变卡片尺寸。 */ .game-resource-card:hover, .game-resource-card:focus-within, .game-resource-card.is-selected { - border-color: #d57b51; + border: 1px solid #d57b51; outline: 0; box-shadow: 0 8px 22px rgb(195 105 62 / 15%); } @@ -10418,13 +10435,18 @@ button.design-workspace-tree__entry:hover, box-shadow: none; } +/* direct-codex 输入区去默认灰描边:焦点可见性交给下面那条 + `.project-supervisor-composer .resource-reference-input:focus-within` + (`--platform-input-focus-ring` 光环)。这条规则比它多一个 `is-direct-codex` 类, + 优先级更高,所以**不能写 `box-shadow: none`**——写回去会把那条光环整条盖掉, + 而 base `.resource-reference-input-editor` 又是 `outline: 0`,输入区就完全没有 + 可见焦点了(无障碍回归)。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex .resource-reference-input:focus-within { border: 0; outline: none; - box-shadow: none; } .game-workbench-chat @@ -10704,18 +10726,19 @@ button.design-workspace-tree__entry:hover, line-height: 1.6; } -/* 陶泥儿输入区的操作排(`@` / AI 润色 / 恢复原文)回到文档流:`grid-column: 1 / -1` - 让它独占编辑器下面的一行,`align-items: end` + `justify-content: flex-end` 把它 - 贴到输入框右下角。之前这里用 `position: absolute; right: 48px; bottom: 10px` - 把整排从网格里摘出来浮在输入框中间,AI 润色因此看起来压在文本区里、和下面那排 - 主操作(`.project-supervisor-composer-controls` 的 `@` / 快速 / 发送)脱节。 +/* 陶泥儿输入区的操作排(`AI 润色` / `恢复原文`)回到文档流:网格第二行右侧那一列, + `justify-content: flex-end` 贴住输入框右下角。之前这里用 + `position: absolute; right: 48px; bottom: 10px` 把整排从网格里摘出来浮在输入框中间, + AI 润色因此看起来压在文本区里、和下面那排主操作(`.project-supervisor-composer-controls` + 的 `@` / 快速 / 发送)脱节。 不设 `min-height`:这一行的高度由 28px 方钮自己撑开,行高一旦被顶起来会连带把 编辑器的 `min-height: 96px` 改掉。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer .resource-reference-input-actions { - grid-column: 1 / -1; + grid-row: 2; + grid-column: 2; position: static; display: flex; align-items: end; @@ -10724,46 +10747,35 @@ button.design-workspace-tree__entry:hover, height: auto; } -/* 「润色中… / 润色失败」和应用排同一行:状态文字靠左占剩余宽度,操作排靠右。 - 单独占一行会把输入框整体顶高 22.8px,把操作排和下面那排主操作推开一个状态行的 - 距离;共行则状态出现/消失都不改变输入框高度。 */ +/* 「润色中… / 润色失败」和应用排**同一行**:状态文字在第二行左侧那一列(占剩余宽度), + 操作排在第二行右侧那一列。单独占一行会把输入框整体顶高一个状态行的距离;而给它 + `grid-column: 1 / -1` 又会和操作排落进同一个网格单元互相重叠(状态文字长时压到按钮下面)。 + 共行分列则状态出现/消失都不改变输入框高度,也不会互相压。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer .resource-reference-input-status { - grid-column: 1 / -1; grid-row: 2; + grid-column: 1; align-self: center; min-width: 0; margin-top: 0; } -.game-workbench-chat - .project-supervisor-surface.is-direct-codex - .project-supervisor-composer - .resource-reference-input-at { - position: static !important; - right: auto !important; - bottom: auto !important; - display: grid; - width: 30px; - min-width: 30px; - height: 30px; - min-height: 30px; - padding: 0; - border: 0; - border-radius: 9px; - background: var(--platform-button-primary-fill); - color: var(--platform-button-primary-text); - place-items: center; -} +/* direct-codex 里 `ResourceReferenceInput` 收到的是 `showTriggerButton={!directCodex}`, + 即输入框内的 `.resource-reference-input-at` 根本不会渲染(`@` 触发钮是下面控制排里的 + `.project-supervisor-reference-trigger`)。这里原本有一条针对它的 + `position: static !important` / 30px 主色方块规则,永远匹配不到,已删除; + 要改 direct-codex 的 `@` 触发钮外观请改 `.project-supervisor-reference-trigger`。 */ .design-agent-reasoning { margin: 8px 0; color: var(--text-muted); font-size: 0.82em; } -.design-agent-reasoning summary { cursor: pointer; } +.design-agent-reasoning summary { + cursor: pointer; +} .design-agent-reasoning pre { margin: 6px 0 0; white-space: pre-wrap; diff --git a/packages/shared/src/components/PlatformResourceFilterBar.test.tsx b/packages/shared/src/components/PlatformResourceFilterBar.test.tsx index f226662ff..b146e9810 100644 --- a/packages/shared/src/components/PlatformResourceFilterBar.test.tsx +++ b/packages/shared/src/components/PlatformResourceFilterBar.test.tsx @@ -6,7 +6,10 @@ import { resolve } from 'node:path'; import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; -import { PlatformResourceFilterBar } from './PlatformResourceFilterBar'; +import { + PlatformResourceFilterBar, + type PlatformResourceFilterBarProps, +} from './PlatformResourceFilterBar'; import { PlatformSegmentedTabs } from './PlatformSegmentedTabs'; const CATEGORY_ITEMS = [ @@ -15,9 +18,10 @@ const CATEGORY_ITEMS = [ ]; /** - * 分段页签的子项宽度下限是「不叠字」的唯一判据:grid 的轨道按容器等分、scroll 的 - * flex 子项默认可收缩,两者都会把中文标签压到内容宽度以下,标签虽然 nowrap, - * 文字仍会画出自己的盒子压到相邻项上。这条规则一旦被删掉就要失败。 + * 分段页签子项的宽度下限只属于 `layout="scroll"`:那一条有 `overflow-x-auto` 做兜底, + * 宽度下限换来的是「不叠字」。grid 布局没有溢出容器、轨道是 `minmax(0, 1fr)`,同样的 + * `min-width: max-content` 只会把内容画到相邻格上。这两条声明级断言(jsdom 不计算外部 + * 样式表,可见性只能钉在声明与类名上)钉住「下限只挂在 --scroll 修饰类」。 */ function segmentedTabsStylesheet() { return readFileSync( @@ -110,6 +114,50 @@ describe('PlatformResourceFilterBar', () => { expect(screen.getByRole('button', { name: '全部' })).toBeTruthy(); }); + test('标签行是横向滚动容器且不画滚动条', () => { + render( + {}} + tagItems={[{ tag: '像素风', assetCount: 1 }]} + onToggleTag={() => {}} + />, + ); + + const row = screen.getByRole('group', { name: '素材筛选标签' }); + expect(row.className).toContain('platform-category-chip-scroll'); + // `platform-category-chip-scroll` 只提供 `overflow-x: auto`;不配 `scrollbar-hide` + // 它就会成为唯一的「看得见滚动条」的横向滚动容器。 + expect(row.className).toContain('scrollbar-hide'); + expect(segmentedTabsStylesheet()).toMatch( + /\.scrollbar-hide\s*\{[^}]*scrollbar-width:\s*none/s, + ); + }); + + test('没有 onToggleTag 时标签 chip 不得渲染成可点按钮', () => { + // 类型上 `tagItems` 必须带 `onToggleTag`(联合类型让这个非法状态不可表达); + // 这里刻意 `as unknown as` 绕过类型检查,模拟不带类型检查的调用方, + // 断言运行时兜底是「禁用」而不是「看起来可点、点了没反应」。 + const props = { + ariaLabel: '素材筛选', + categoryItems: CATEGORY_ITEMS, + activeCategoryId: 'all', + onCategoryChange: () => {}, + tagItems: [{ tag: '像素风', assetCount: 1 }], + activeTags: [], + } as unknown as PlatformResourceFilterBarProps<'all' | 'character'>; + + render(); + + const chip = screen.getByRole('button', { + name: /像素风/, + }) as HTMLButtonElement; + expect(chip.disabled).toBe(true); + expect(chip.getAttribute('aria-pressed')).toBe('false'); + }); + test('never lets a category chip shrink below its label width', () => { const { container } = render( { // 分类条是 scroll 布局:容器横向可滚,标签不换行。 const row = container.querySelector('.platform-segmented-tabs'); expect(row).not.toBeNull(); + expect(row?.className).toContain('platform-segmented-tabs--scroll'); expect(row?.className).toContain('overflow-x-auto'); for (const button of row?.querySelectorAll('button') ?? []) { expect(button.className).toContain('whitespace-nowrap'); @@ -130,11 +179,15 @@ describe('PlatformResourceFilterBar', () => { // 单靠 nowrap 不够:子项必须有不低于内容宽度的下限,否则文字会画出盒子压到相邻项。 expect(segmentedTabsStylesheet()).toMatch( + /\.platform-segmented-tabs--scroll\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s, + ); + // 反过来,没有溢出容器的 grid 布局不得吃这条下限(否则内容会压到相邻格上)。 + expect(segmentedTabsStylesheet()).not.toMatch( /\.platform-segmented-tabs\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s, ); }); - test('grid 布局的分段页签同样带宽度下限钩子', () => { + test('grid 布局的分段页签不带 scroll 修饰类,靠轨道等分收口', () => { render( { .closest('div'); expect(row?.className).toContain('platform-segmented-tabs'); expect(row?.className).toContain('grid-cols-2'); + // grid 分支没有溢出容器,宽度下限一旦回到这里就会压到相邻格(本轮 review 的回归点)。 + expect(row?.className).not.toContain('platform-segmented-tabs--scroll'); + expect(row?.className).not.toContain('overflow-x-auto'); }); }); diff --git a/packages/shared/src/components/PlatformResourceFilterBar.tsx b/packages/shared/src/components/PlatformResourceFilterBar.tsx index 7d24f96f8..a5b685b1c 100644 --- a/packages/shared/src/components/PlatformResourceFilterBar.tsx +++ b/packages/shared/src/components/PlatformResourceFilterBar.tsx @@ -21,7 +21,7 @@ export type PlatformResourceFilterSearch = { inputRef?: Ref; }; -export type PlatformResourceFilterBarProps = { +type PlatformResourceFilterBarBaseProps = { /** 控件组无障碍名称,例如「资源筛选」。 */ ariaLabel: string; /** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */ @@ -30,14 +30,29 @@ export type PlatformResourceFilterBarProps = { categoryItems: readonly PlatformResourceFilterOption[]; activeCategoryId: TId; onCategoryChange: (id: TId) => void; - /** 派生标签库;为空时不渲染标签行。 */ - tagItems?: readonly PlatformResourceTagOption[]; - /** 已选标签(多选叠加)。 */ - activeTags?: readonly string[]; - onToggleTag?: (tag: string) => void; className?: string; }; +/** + * `tagItems` 与 `onToggleTag` 是**成对的**:标签 chip 是带 `aria-pressed` 的按钮, + * 只给标签、不给处理函数就会渲染出一排「看起来可点、点了没反应」的控件。 + * 这里用联合类型让这个非法状态在编译期就不可表达;`Component` 内部再兜一层 + * `disabled`,供不带类型检查的调用方(JS / 动态构造的 props)也拿到诚实的行为。 + */ +export type PlatformResourceFilterBarProps = + | (PlatformResourceFilterBarBaseProps & { + tagItems?: undefined; + activeTags?: readonly string[]; + onToggleTag?: undefined; + }) + | (PlatformResourceFilterBarBaseProps & { + /** 派生标签库;为空数组时不渲染标签行。 */ + tagItems: readonly PlatformResourceTagOption[]; + /** 已选标签(多选叠加)。 */ + activeTags?: readonly string[]; + onToggleTag: (tag: string) => void; + }); + function tagChipClassName(active: boolean) { return [ 'platform-category-chip gap-1.5 px-2.5 text-xs font-bold', @@ -104,7 +119,7 @@ export function PlatformResourceFilterBar({ /> {tagOptions.length > 0 ? (
@@ -116,6 +131,9 @@ export function PlatformResourceFilterBar({ type="button" aria-pressed={active} className={tagChipClassName(active)} + // 类型上 `tagItems` 必然带 `onToggleTag`;这一层兜底是给不带类型检查的 + // 调用方(JS / 动态构造的 props):没有处理函数就不该渲染成可点按钮。 + disabled={!onToggleTag} onClick={() => onToggleTag?.(option.tag)} >