修复 PR316 review:共享分段页签宽度下限作用域、标签契约与资源画布样式
- 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)。
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
<PlatformResourceFilterBar
|
||||
ariaLabel="素材筛选"
|
||||
categoryItems={CATEGORY_ITEMS}
|
||||
activeCategoryId="all"
|
||||
onCategoryChange={() => {}}
|
||||
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(<PlatformResourceFilterBar {...props} />);
|
||||
|
||||
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(
|
||||
<PlatformResourceFilterBar
|
||||
@@ -123,6 +171,7 @@ describe('PlatformResourceFilterBar', () => {
|
||||
// 分类条是 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(
|
||||
<PlatformSegmentedTabs
|
||||
items={CATEGORY_ITEMS}
|
||||
@@ -149,5 +202,8 @@ describe('PlatformResourceFilterBar', () => {
|
||||
.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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ export type PlatformResourceFilterSearch = {
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
};
|
||||
|
||||
export type PlatformResourceFilterBarProps<TId extends string = string> = {
|
||||
type PlatformResourceFilterBarBaseProps<TId extends string = string> = {
|
||||
/** 控件组无障碍名称,例如「资源筛选」。 */
|
||||
ariaLabel: string;
|
||||
/** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */
|
||||
@@ -30,14 +30,29 @@ export type PlatformResourceFilterBarProps<TId extends string = string> = {
|
||||
categoryItems: readonly PlatformResourceFilterOption<TId>[];
|
||||
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<TId extends string = string> =
|
||||
| (PlatformResourceFilterBarBaseProps<TId> & {
|
||||
tagItems?: undefined;
|
||||
activeTags?: readonly string[];
|
||||
onToggleTag?: undefined;
|
||||
})
|
||||
| (PlatformResourceFilterBarBaseProps<TId> & {
|
||||
/** 派生标签库;为空数组时不渲染标签行。 */
|
||||
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<TId extends string = string>({
|
||||
/>
|
||||
{tagOptions.length > 0 ? (
|
||||
<div
|
||||
className="platform-category-chip-scroll min-w-0 flex-1"
|
||||
className="platform-category-chip-scroll scrollbar-hide min-w-0 flex-1"
|
||||
role="group"
|
||||
aria-label={`${ariaLabel}标签`}
|
||||
>
|
||||
@@ -116,6 +131,9 @@ export function PlatformResourceFilterBar<TId extends string = string>({
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={tagChipClassName(active)}
|
||||
// 类型上 `tagItems` 必然带 `onToggleTag`;这一层兜底是给不带类型检查的
|
||||
// 调用方(JS / 动态构造的 props):没有处理函数就不该渲染成可点按钮。
|
||||
disabled={!onToggleTag}
|
||||
onClick={() => onToggleTag?.(option.tag)}
|
||||
>
|
||||
<Tag className="h-3 w-3" aria-hidden="true" />
|
||||
|
||||
@@ -193,7 +193,7 @@ export function PlatformSegmentedTabs<TId extends string = string>({
|
||||
className={[
|
||||
'platform-segmented-tabs',
|
||||
layout === 'scroll'
|
||||
? 'flex min-w-0 items-center overflow-x-auto scrollbar-hide'
|
||||
? 'platform-segmented-tabs--scroll flex min-w-0 items-center overflow-x-auto scrollbar-hide'
|
||||
: 'grid',
|
||||
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
|
||||
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
|
||||
|
||||
@@ -519,13 +519,16 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 分段页签的子项不得被压到内容宽度以下。中文标签(例如「角色与对象」需要一个
|
||||
/* 分段页签的**横滚**子项不得被压到内容宽度以下。中文标签(例如「角色与对象」需要一个
|
||||
`1.5rem 内边距 + 4 个字`的宽度)在窄容器里一旦被压扁,标签虽然 `nowrap`,文字仍会
|
||||
画出自己的盒子压到相邻项上——现场表现就是「后几项挤成一团、文字互相重叠」。
|
||||
`min-width: max-content` 给每个子项一个「内容宽度」下限:放得下就照原样排,放不下
|
||||
就由容器横向滚动兜底,任何情况下都不叠字。grid 与 scroll 两条 layout 都需要它:
|
||||
grid 的轨道按容器等分,scroll 的 flex 子项默认可收缩,两者都会压到内容宽度以下。 */
|
||||
.platform-segmented-tabs > button {
|
||||
就由容器横向滚动兜底(`layout="scroll"` 的容器有 `overflow-x-auto`),任何情况下都不叠字。
|
||||
|
||||
这里**只作用于 scroll 布局**:grid 布局没有溢出容器,轨道是 `minmax(0, 1fr)`,
|
||||
给子项加 `min-width: max-content` 只会让盒子宽过自己那条轨道、直接画到相邻格上,
|
||||
正是本规则要防的重叠。grid 侧靠轨道等分 + 宿主的 `truncateLabels` 收口。 */
|
||||
.platform-segmented-tabs--scroll > button {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -23,6 +26,7 @@ import {
|
||||
type GameCreationAppManifest,
|
||||
normalizeGameCreationAppAssetCategory,
|
||||
normalizeGameCreationAppAssetTags,
|
||||
PROJECT_RESOURCE_CANVAS_SECTIONS,
|
||||
selectGameCreationAppReadyTasks,
|
||||
} from './gameCreationApp';
|
||||
|
||||
@@ -933,7 +937,7 @@ describe('AI 游戏创作 App 共享契约', () => {
|
||||
* 「读显示」与「写回」是两个口径,只有写回口径等于落盘值。
|
||||
*
|
||||
* `gameCreationAppAssetCategory` 会把落盘 `unclassified` 自愈成 kind 派生值(读显示要正确),
|
||||
* `gameCreationAppAssetPersistedCategory` 只做缺失 / 非法兜底(写回必须原样)。
|
||||
* `gameCreationAppAssetPersistedCategory` 只做兜底、不套自愈(写回必须原样)。
|
||||
* 「编辑标签」面板一旦用读显示口径回写,用户只改标签就会静默改分类。
|
||||
*/
|
||||
it('写回口径保留落盘 unclassified,读显示口径才自愈', () => {
|
||||
@@ -941,23 +945,63 @@ describe('AI 游戏创作 App 共享契约', () => {
|
||||
expect(gameCreationAppAssetCategory(uiAsset)).toBe('ui-interaction');
|
||||
expect(gameCreationAppAssetPersistedCategory(uiAsset)).toBe('unclassified');
|
||||
|
||||
// 落盘值非法或缺失时,两个口径都按 kind 派生(与 Rust 反序列化兜底一致)。
|
||||
expect(
|
||||
gameCreationAppAssetPersistedCategory({
|
||||
kind: 'character',
|
||||
category: 'future-category' as never,
|
||||
}),
|
||||
).toBe('character');
|
||||
// 字段缺失时两个口径都按 kind 派生(与 Rust 反序列化的缺字段兜底一致)。
|
||||
expect(
|
||||
gameCreationAppAssetPersistedCategory({
|
||||
kind: 'character',
|
||||
category: undefined,
|
||||
}),
|
||||
).toBe('character');
|
||||
// 非法值这一支只服务内存对象:Rust 读侧对未知 category 失败关闭,
|
||||
// 比本客户端新的值根本读不出 manifest,不会以「派生值」的形式出现在 UI 里。
|
||||
expect(
|
||||
gameCreationAppAssetPersistedCategory({
|
||||
kind: 'character',
|
||||
category: 'future-category' as never,
|
||||
}),
|
||||
).toBe('character');
|
||||
|
||||
// 落盘值合法且非 unclassified 时两个口径一致。
|
||||
expect(
|
||||
gameCreationAppAssetPersistedCategory({ kind: 'ui', category: 'audio' }),
|
||||
).toBe('audio');
|
||||
});
|
||||
|
||||
/**
|
||||
* 分区顺序必须跨语言一致:Rust 侧 doc 明写 `PROJECT_RESOURCE_CANVAS_SECTIONS`
|
||||
* 「必须与前端 `PROJECT_RESOURCE_CANVAS_SECTIONS` 保持一致」,但 Rust 单测只钉住自己那份的
|
||||
* 序列化顺序,谁也发现不了另一侧改了顺序。这里解析 Rust 源码里的常量再与 TS 常量对齐,
|
||||
* 由 `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析 Rust
|
||||
* `EFFECTIVE_CATEGORY_CONTRACT` 的同一手法:两侧各写一份就一定会分叉。
|
||||
*/
|
||||
it('资源画布分区顺序与 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 对齐', () => {
|
||||
const rustSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'server-rs/crates/shared-contracts/src/game_creation_app.rs',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const start = rustSource.indexOf(
|
||||
'pub const PROJECT_RESOURCE_CANVAS_SECTIONS',
|
||||
);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const body = rustSource.slice(
|
||||
start,
|
||||
rustSource.indexOf('];', start) + '];'.length,
|
||||
);
|
||||
const rustSections = Array.from(
|
||||
body.matchAll(/ProjectResourceCanvasSection::(\w+)/gu),
|
||||
).map((match) =>
|
||||
// Rust 变体名 → kebab-case 线上值(与 `#[serde(rename_all = "kebab-case")]` 同口径)。
|
||||
match[1]!.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase(),
|
||||
);
|
||||
|
||||
expect(rustSections).toEqual([...PROJECT_RESOURCE_CANVAS_SECTIONS]);
|
||||
// 顺带钉住「资产分类轴 + 末尾独立 version」这条分区口径本身没有被改掉。
|
||||
expect(PROJECT_RESOURCE_CANVAS_SECTIONS).toEqual([
|
||||
...GAME_CREATION_APP_ASSET_CATEGORIES,
|
||||
'version',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -626,7 +626,10 @@ export function gameCreationAppAssetCategoryForKind(
|
||||
* manifest 资产的最终功能分类。
|
||||
*
|
||||
* 优先级:落盘 `category`(用户可在「分类与标签」面板手动设置,是权威值)→ 按 `kind` 派生。
|
||||
* 历史 manifest 缺少 category、或 category 非法时都退回 kind 派生。
|
||||
* 历史 manifest 缺少 category、或(内存对象上的)category 非法时都退回 kind 派生;
|
||||
* 注意「落盘值非法」这一支已经不可达:Rust 读侧对未知 `category` 失败关闭(见
|
||||
* `server-rs/crates/shared-contracts/src/game_creation_app.rs` 的
|
||||
* `GameCreationAppAssetManifestEntryWire`),比本客户端新的值会让整份 manifest 直接读不出来。
|
||||
*
|
||||
* **例外(唯一的覆盖窗口)**:落盘值是 `unclassified`,而该资产 `kind` 能派生出明确的
|
||||
* 非 `unclassified` 分类时,采用派生值。这条规则用于自愈「历史上被系统错误写成
|
||||
@@ -667,7 +670,9 @@ export function gameCreationAppAssetCategory(
|
||||
* 静默改分类(真机已发生:同一条 `kind:"ui"` 资产出现 `unclassified` 与 `ui-interaction` 两种落盘值)。
|
||||
*
|
||||
* Rust 侧 manifest 反序列化(`game_creation_app.rs` 的 `GameCreationAppAssetManifestEntry`
|
||||
* `Deserialize`)用的是同一口径,因此本函数结果必然等于该资产当前的落盘 `category`。
|
||||
* `Deserialize`)在字段缺失时用同一套 `kind` 派生;未知分类值则在读侧失败关闭(不再退化成
|
||||
* 派生值),所以从 manifest 读进来的资产本函数结果必然等于它的落盘 `category`。
|
||||
* 剩下这一支「非法值 → 派生」只服务内存对象(手工构造的资产),不是落盘口径的兜底。
|
||||
*/
|
||||
export function gameCreationAppAssetPersistedCategory(
|
||||
asset: Pick<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
|
||||
|
||||
@@ -49,6 +49,25 @@ describe('AI 游戏创作 App 标签库派生', () => {
|
||||
expect(buildGameCreationAppAssetTagLibrary([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts asset ids deterministically without a locale-dependent compare', () => {
|
||||
// `localeCompare` 用运行环境默认 locale:同一份 manifest 在不同机器上可能排出不同顺序,
|
||||
// 而这里承诺的是「与输入顺序无关的稳定升序」。素材 id 是 ASCII,code-unit 顺序即可。
|
||||
const library = buildGameCreationAppAssetTagLibrary([
|
||||
{ id: 'asset-10', tags: ['像素风'] },
|
||||
{ id: 'asset-9', tags: ['像素风'] },
|
||||
{ id: 'asset-100', tags: ['像素风'] },
|
||||
{ id: 'Asset-2', tags: ['像素风'] },
|
||||
]);
|
||||
|
||||
expect(library).toEqual([
|
||||
{
|
||||
tag: '像素风',
|
||||
assetCount: 4,
|
||||
assetIds: ['Asset-2', 'asset-10', 'asset-100', 'asset-9'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires every selected tag to match and treats an empty selection as no filter', () => {
|
||||
const tags = ['像素风', '主角'];
|
||||
|
||||
@@ -62,4 +81,18 @@ describe('AI 游戏创作 App 标签库派生', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes the selected tags the same way as the asset tags', () => {
|
||||
// 对称归一化:素材侧 tags 会去空白,已选标签若原样比较,`' 像素风 '` 永远匹配不上。
|
||||
const tags = [' 像素风 ', '主角'];
|
||||
|
||||
expect(assetTagsMatchSelection(tags, [' 像素风 '])).toBe(true);
|
||||
expect(assetTagsMatchSelection(tags, ['像素风', ' 主角 '])).toBe(true);
|
||||
// 归一化后为空的选中项等于「没有选中任何标签」→ 不过滤,与空选择同一条语义。
|
||||
expect(assetTagsMatchSelection(tags, [' '])).toBe(true);
|
||||
// 归一化不会把「去空白后仍然不同的标签」误判为命中。
|
||||
expect(assetTagsMatchSelection(tags, ['像素风 '])).toBe(true);
|
||||
expect(assetTagsMatchSelection(tags, [' 场景 '])).toBe(false);
|
||||
expect(gameCreationAppAssetMatchesTags({ tags }, [' 像素风 '])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,10 @@ export function buildGameCreationAppAssetTagLibrary(
|
||||
return Array.from(byTag, ([tag, assetIds]) => ({
|
||||
tag,
|
||||
assetCount: assetIds.length,
|
||||
assetIds: [...assetIds].sort((left, right) => left.localeCompare(right)),
|
||||
// 确定性 code-unit 升序:`localeCompare` 的默认 locale 随环境变化,会让同一份 manifest
|
||||
// 在不同机器上排出不同的 `assetIds`。素材 id 是 ASCII(UUID / 数字串 / `asset-1` 之类),
|
||||
// code-unit 顺序就是稳定升序;标签那一层另有 `zh-CN` 显式口径,两者不要混用。
|
||||
assetIds: [...assetIds].sort(),
|
||||
})).sort(
|
||||
(left, right) =>
|
||||
right.assetCount - left.assetCount ||
|
||||
@@ -59,6 +62,9 @@ export function buildGameCreationAppAssetTagLibrary(
|
||||
/**
|
||||
* 标签筛选判据:已选标签必须全部命中(AND),空选择视为不过滤。
|
||||
* 与 `category` 筛选共存时由调用方叠加,本函数只看标签。
|
||||
*
|
||||
* 两侧都过 `normalizeGameCreationAppAssetTags`:素材侧 tags 会去空白,已选标签如果原样比较,
|
||||
* `' 像素风 '` 这种带空白的选中项会永远匹配不上素材上已归一化的 `'像素风'`。
|
||||
*/
|
||||
export function assetTagsMatchSelection(
|
||||
tags: readonly string[] | undefined,
|
||||
@@ -66,7 +72,8 @@ export function assetTagsMatchSelection(
|
||||
): boolean {
|
||||
if (selectedTags.length === 0) return true;
|
||||
const normalized = new Set(normalizeGameCreationAppAssetTags(tags ?? []));
|
||||
return selectedTags.every((tag) => normalized.has(tag));
|
||||
const selected = normalizeGameCreationAppAssetTags(selectedTags);
|
||||
return selected.every((tag) => normalized.has(tag));
|
||||
}
|
||||
|
||||
/** manifest 资产条目上的标签筛选,口径与 `assetTagsMatchSelection` 一致。 */
|
||||
|
||||
Reference in New Issue
Block a user