修复 AGC 模板库筛选与卡片状态的表现缺陷
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled

- 模板库筛选区改用共享筛选条与标签 chip:选中为实心品牌填充 + 反白文字,未选为浅底描边
- 未选中悬停不再借用品牌色(品牌色专属已选中),状态阶梯固定为静止 / 悬停 / 按下 / 选中
- 新增 getPlatformCategoryChipClassName 收敛筛选 chip 类名口径,模板库、资源画布、参考图弹窗与平台 Web 端共用
- PlatformSegmentedTabs 增加 accent 选中口径,与筛选 chip 共用 --platform-chip-* 语义色
- 修复卡片文字区按 grid auto 行排版导致的标题 / 简介 / 标签被裁:行高契约拆为分项常量,文字区固定 174
- 修复忙状态下动作行塞入第三个元素导致的按钮文案换行顶出卡片:忙状态写在触发它的按钮上
- 修复经典滚动条占宽导致的卡片区横向滚动条:按竖滚动条预留列宽后再算列宽行高
- 焦点环由 15% 透明度改为实心色并用 outline 绘制,选中项聚焦不再被状态投影盖掉
- 封面加载失败改为隐藏图片留中性底色,不再出现浏览器裂图图标
- 补充状态对比度、行高契约、滚动条预留、封面兜底与忙状态文案的回归测试
- 同步技术方案、踩坑记录与决策记录的口径
This commit is contained in:
kdletters
2026-09-23 16:46:49 +08:00
parent e8d987c207
commit e072c66ce9
21 changed files with 697 additions and 167 deletions
@@ -14,8 +14,31 @@ export const TEMPLATE_CARD_MIN_WIDTH = 250;
export const TEMPLATE_CARD_GAP = 14;
/** 封面宽高比:16:9。 */
export const TEMPLATE_CARD_COVER_RATIO = 9 / 16;
/** 卡片封面以下的文字与按钮区固定高度。 */
export const TEMPLATE_CARD_TEXT_HEIGHT = 150;
/**
* 卡片封面以下的文字与按钮区固定高度。
*
* 虚拟列表要求行高完全确定,所以文字区**不放任内容撑高**:`TemplateCard` 里每一行都
* 写死高度(标题 `h-5`、元信息 `h-4`、简介 `h-8`、标签 `h-5.5`、按钮行 `h-7`),
* 这里按同样的口径把总高算出来。两边必须同时改,下面的分项常量就是这条契约的锚点。
*/
export const TEMPLATE_CARD_TITLE_HEIGHT = 20;
export const TEMPLATE_CARD_META_HEIGHT = 16;
export const TEMPLATE_CARD_SUMMARY_HEIGHT = 32;
export const TEMPLATE_CARD_TAGS_HEIGHT = 22;
export const TEMPLATE_CARD_ACTIONS_HEIGHT = 28;
/** 文字区上下内边距(`p-3`)。 */
export const TEMPLATE_CARD_TEXT_PADDING = 12;
/** 文字区行间距(`gap-2`)。 */
export const TEMPLATE_CARD_TEXT_GAP = 8;
/** 文字区共 5 行、4 个行间距。 */
export const TEMPLATE_CARD_TEXT_HEIGHT =
TEMPLATE_CARD_TEXT_PADDING * 2 +
TEMPLATE_CARD_TITLE_HEIGHT +
TEMPLATE_CARD_META_HEIGHT +
TEMPLATE_CARD_SUMMARY_HEIGHT +
TEMPLATE_CARD_TAGS_HEIGHT +
TEMPLATE_CARD_ACTIONS_HEIGHT +
TEMPLATE_CARD_TEXT_GAP * 4;
/** 额外预渲染的行数,减小快速滚动时的白屏。 */
export const TEMPLATE_GRID_OVERSCAN_ROWS = 2;
@@ -68,6 +91,40 @@ export function computeTemplateGridLayout({
};
}
/**
* 带竖滚动条预留的布局。
*
* react-window 的内层宽度是 `列数 × 列宽`,而**经典(非 overlay)滚动条**会吃掉外层
* `clientWidth`Windows / WebView2 上竖滚动条约 15–17px,于是内层比外层可用宽度多出
* 正好一个滚动条,卡片区底部就多出一条横向滚动条。这里在「内容确实会竖向溢出」时,
* 先把滚动条宽度从容器宽度里扣掉再算列宽;不会竖向溢出时保持原口径,避免右侧留一条
* 无意义的白边。overlay 滚动条平台(占宽 0)结果与不预留完全一致。
*/
export function computeTemplateGridLayoutWithScrollbar({
containerWidth,
itemCount,
viewportHeight,
scrollbarWidth,
}: {
containerWidth: number;
itemCount: number;
viewportHeight: number;
scrollbarWidth: number;
}): TemplateGridLayout {
const full = computeTemplateGridLayout({ containerWidth, itemCount });
const reserve = Math.max(0, scrollbarWidth);
if (reserve === 0 || full.rowCount * full.rowHeight <= viewportHeight) {
return full;
}
const usableWidth = Math.max(0, containerWidth - reserve);
const reserved = computeTemplateGridLayout({
containerWidth: usableWidth,
itemCount,
});
// 预留后行数变多时,竖向溢出只会更明显,不会退回「不需要滚动条」的情况。
return reserved;
}
/** 按行切分,行尾补 `null` 占位,保证虚拟列表的列索引与条目一一对应。 */
export function buildTemplateRows(
templates: readonly GameTemplateEntry[],
@@ -127,10 +127,16 @@ export function filterGameTemplates(
});
}
/** 筛选条上的单个标签:名称 + 命中的模板数。 */
export type GameTemplateTagOption = {
tag: string;
assetCount: number;
};
/** 标签按出现次数降序,次数相同按名称排序,保证筛选条顺序稳定。 */
export function collectGameTemplateTags(
export function collectGameTemplateTagOptions(
templates: readonly GameTemplateEntry[],
): string[] {
): GameTemplateTagOption[] {
const counts = new Map<string, number>();
for (const template of templates) {
for (const tag of template.tags) {
@@ -144,7 +150,13 @@ export function collectGameTemplateTags(
([leftTag, leftCount], [rightTag, rightCount]) =>
rightCount - leftCount || leftTag.localeCompare(rightTag, 'zh-CN'),
)
.map(([tag]) => tag);
.map(([tag, assetCount]) => ({ tag, assetCount }));
}
export function collectGameTemplateTags(
templates: readonly GameTemplateEntry[],
): string[] {
return collectGameTemplateTagOptions(templates).map((option) => option.tag);
}
export function collectGameTemplateRuntimes(
@@ -21,7 +21,6 @@ import {
import { readProjectCreationDirectory } from '../app-shell/model';
import {
collectGameTemplateRuntimes,
collectGameTemplateTags,
EMPTY_TEMPLATE_LIBRARY_FILTERS,
filterGameTemplates,
type GameTemplateEntry,
@@ -298,10 +297,6 @@ export function useTemplateLibrary({
() => filterGameTemplates(templates, filters),
[templates, filters],
);
const tagOptions = useMemo(
() => collectGameTemplateTags(templates),
[templates],
);
const runtimeOptions = useMemo(
() => collectGameTemplateRuntimes(templates),
[templates],
@@ -343,7 +338,6 @@ export function useTemplateLibrary({
notice,
templates,
visibleTemplates,
tagOptions,
runtimeOptions,
installedCount,
filters,
@@ -1,5 +1,6 @@
import { type RefObject, useEffect, useId, useRef } from 'react';
import { getPlatformCategoryChipClassName } from '../../../../../packages/shared/src/components/platformCategoryChipModel';
import {
PlatformFilterPanel,
PlatformFilterPanelField,
@@ -15,19 +16,6 @@ import {
type ResourceFilterTagOption,
} from './resourceCanvasFilterModel';
/**
* 已选标签的 chip 用与既有筛选条同一套类名,保持「选中的标签长什么样」只有一处实现。
* `PlatformResourceFilterBar` 自己渲染未选标签时用的是同一个类。
*/
function resourceFilterTagChipClassName(active: boolean) {
return [
'platform-category-chip gap-1.5 px-2.5 text-xs font-bold',
active ? 'platform-category-chip--active' : null,
]
.filter(Boolean)
.join(' ');
}
type ResourceFilterPanelProps = {
onClose: () => void;
/** 关键词:右下角放大镜与 Ctrl/Cmd+F 叫出的就是这一个面板,状态由宿主持有。 */
@@ -186,7 +174,7 @@ export function ResourceFilterPanel({
key={option.tag}
type="button"
aria-pressed={active}
className={resourceFilterTagChipClassName(active)}
className={getPlatformCategoryChipClassName(active)}
onClick={() => onToggleTag(option.tag)}
>
<span>{option.tag}</span>
@@ -18,6 +18,10 @@ export type TemplateCardActions = {
/**
* 虚拟列表里的单个模板卡片:高度由行高契约固定(封面 16:9 + 固定文字区),
* 用 memo 包住,滚动时只重渲染可视区域内的少量卡片。
*
* 文字区每行都写死高度且不参与压缩(`shrink-0`):行高契约(`templateLibraryGrid.ts`
* 的 `TEMPLATE_CARD_TEXT_HEIGHT`)按同样的口径算出卡片总高,两边一旦不一致,
* 被截断的就是标题、简介和标签这些真实文字。
*/
function TemplateCardView({
template,
@@ -28,12 +32,8 @@ function TemplateCardView({
}: { template: GameTemplateEntry } & TemplateCardActions) {
const busy = busyTemplateId === template.id;
const needsDownload = needsTemplateDownload(template);
const busyLabel =
busy && busyKind === 'download'
? '正在下载模板'
: busy && busyKind === 'create'
? '正在创建项目'
: '';
const creating = busy && busyKind === 'create';
const downloading = busy && busyKind === 'download';
const meta = [
templateRuntimeLabel(template.runtime),
template.engine,
@@ -56,6 +56,11 @@ function TemplateCardView({
alt=""
loading="lazy"
decoding="async"
/* 封面读不到时留出中性的占位底色,而不是让浏览器画一个「裂图」图标。
直接改样式、不进 state:卡片是被 memo 包住的纯展示组件。 */
onError={(event) => {
event.currentTarget.style.visibility = 'hidden';
}}
/>
{template.installed ? (
<span
@@ -67,23 +72,31 @@ function TemplateCardView({
</span>
) : null}
</div>
<div className="grid min-h-0 content-start gap-2 overflow-hidden p-3">
<strong className="truncate text-[13px] text-(--platform-text-strong)">
<div className="flex min-h-0 flex-col gap-2 overflow-hidden p-3">
<strong
className="block h-5 shrink-0 truncate text-[13px] leading-5 text-(--platform-text-strong)"
title={template.title}
>
{template.title}
</strong>
<span className="truncate text-[10px] text-(--platform-text-soft)">
<span className="block h-4 shrink-0 truncate text-[10px] leading-4 text-(--platform-text-soft)">
{meta}
</span>
{template.summary ? (
<p className="m-0 line-clamp-2 text-[11px] leading-4 text-(--platform-neutral-text)">
<p className="m-0 line-clamp-2 h-8 shrink-0 text-[11px] leading-4 text-(--platform-neutral-text)">
{template.summary}
</p>
) : null}
{template.tags.length > 0 ? (
<div className="flex max-h-5.5 flex-wrap gap-1 overflow-hidden">
// 卡片只给标签一行(行高契约固定 22px)。清单模板 ≤4 个标签时正好放得下;
// 真出现超长标签集合时这一行会裁掉后半段,用 title 兜住完整列表。
<div
className="flex h-5.5 shrink-0 flex-wrap gap-1 overflow-hidden"
title={template.tags.join('、')}
>
{template.tags.map((tag) => (
<span
className="rounded-full bg-(--platform-nav-item-hover-fill) px-2 py-0.5 text-[10px] text-(--platform-text-soft)"
className="rounded-full bg-(--platform-nav-item-hover-fill) px-2 py-0.5 text-[10px] leading-4 text-(--platform-text-soft)"
key={tag}
>
{tag}
@@ -91,29 +104,32 @@ function TemplateCardView({
))}
</div>
) : null}
<div className="mt-auto flex items-center gap-2">
{/* 动作行固定高度、按钮不换行:忙状态写在**触发它的那个按钮**上,不要再往这一行
塞第三个元素 —— 卡片最小宽度只有 250px,多一段「正在下载模板」就会把两个
按钮的文案挤成两行、顶出卡片。 */}
<div className="mt-auto flex h-7 shrink-0 items-center gap-2 overflow-hidden">
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-(--platform-warm-text) bg-transparent px-2.5 py-1.5 text-[11px] text-(--platform-warm-text) disabled:cursor-default disabled:opacity-50"
className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-lg border border-(--platform-warm-text) bg-transparent px-2.5 text-[11px] leading-4 text-(--platform-warm-text) disabled:cursor-default disabled:opacity-50"
disabled={busy}
onClick={() => onUse(template)}
>
{busy && busyKind === 'create' ? (
{creating ? (
<Loader2 className="animate-spin" size={12} aria-hidden="true" />
) : (
<Play size={12} aria-hidden="true" />
)}
使
{creating ? '创建中' : '使用模板'}
</button>
{/* 已下载且版本一致时不再提供下载入口;只有缺包或版本落后才显示(落后时按「更新」)。 */}
{needsDownload ? (
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1.5 text-[11px] text-(--platform-text-soft) disabled:cursor-default disabled:opacity-50"
className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-lg border border-(--platform-subpanel-border) bg-transparent px-2.5 text-[11px] leading-4 text-(--platform-text-soft) disabled:cursor-default disabled:opacity-50"
disabled={busy}
onClick={() => onDownload(template)}
>
{busy && busyKind === 'download' ? (
{downloading ? (
<Loader2
className="animate-spin"
size={12}
@@ -122,14 +138,9 @@ function TemplateCardView({
) : (
<Download size={12} aria-hidden="true" />
)}
{template.installed ? '更新' : '下载'}
{downloading ? '下载中' : template.installed ? '更新' : '下载'}
</button>
) : null}
{busyLabel ? (
<span className="text-[10px] text-(--platform-text-soft)">
{busyLabel}
</span>
) : null}
</div>
</div>
</article>
@@ -1,25 +1,25 @@
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
import {
ArrowLeft,
Loader2,
RefreshCw,
Search,
SearchX,
SlidersHorizontal,
} from 'lucide-react';
getPlatformCategoryChipClassName,
PlatformRuntimeStatusToast,
} from '@genarrative/shared/components';
import { ArrowLeft, Loader2, RefreshCw, SearchX } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { FixedSizeGrid, type GridChildComponentProps } from 'react-window';
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
import {
buildTemplateRows,
computeTemplateGridLayout,
computeTemplateGridLayoutWithScrollbar,
TEMPLATE_CARD_GAP,
TEMPLATE_GRID_OVERSCAN_ROWS,
templateGridItemKey,
} from '../../features/template-library/templateLibraryGrid';
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
import { templateRuntimeLabel } from '../../features/template-library/templateLibraryModel';
import {
collectGameTemplateTagOptions,
type GameTemplateEntry,
templateRuntimeLabel,
} from '../../features/template-library/templateLibraryModel';
import type { TemplateLibraryController } from '../../features/template-library/useTemplateLibrary';
import { TemplateCard, type TemplateCardActions } from './TemplateCard';
@@ -73,15 +73,33 @@ function TemplateLibraryToast({
);
}
const chipClass =
'cursor-pointer rounded-full border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-text-soft) transition hover:border-(--platform-warm-text) hover:text-(--platform-warm-text)';
const activeChipClass =
'cursor-pointer rounded-full border border-(--platform-warm-text) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-warm-text)';
type TemplateGridCellData = TemplateCardActions & {
rows: Array<Array<GameTemplateEntry | null>>;
};
let cachedScrollbarWidth: number | null = null;
/**
* 量一次经典(非 overlay)竖滚动条的占宽:Windows / WebView2 上约 1517px,会吃掉
* grid 外层的可用宽度。overlay 滚动条平台测得 0(此时预留逻辑不生效)。
*/
function measureVerticalScrollbarWidth(): number {
if (cachedScrollbarWidth !== null) {
return cachedScrollbarWidth;
}
if (typeof document === 'undefined' || !document.body) {
return 0;
}
const probe = document.createElement('div');
probe.setAttribute('aria-hidden', 'true');
probe.style.cssText =
'position:absolute;top:-9999px;left:-9999px;width:100px;height:100px;overflow:scroll';
document.body.appendChild(probe);
cachedScrollbarWidth = Math.max(0, probe.offsetWidth - probe.clientWidth);
probe.remove();
return cachedScrollbarWidth;
}
function TemplateGridCell({
columnIndex,
rowIndex,
@@ -115,7 +133,6 @@ export default function TemplateLibraryView({
notice,
templates,
visibleTemplates,
tagOptions,
runtimeOptions,
installedCount,
filters,
@@ -201,16 +218,30 @@ export default function TemplateLibraryView({
const layout = useMemo(
() =>
computeTemplateGridLayout({
computeTemplateGridLayoutWithScrollbar({
containerWidth: viewportSize.width,
itemCount: visibleTemplates.length,
viewportHeight: viewportSize.height,
scrollbarWidth: measureVerticalScrollbarWidth(),
}),
[viewportSize.width, visibleTemplates.length],
[viewportSize.width, viewportSize.height, visibleTemplates.length],
);
const rows = useMemo(
() => buildTemplateRows(visibleTemplates, layout.columnCount),
[visibleTemplates, layout.columnCount],
);
const tagItems = useMemo(
() => collectGameTemplateTagOptions(templates),
[templates],
);
const runtimeItems = useMemo(
() =>
runtimeOptions.map((runtime) => {
const label = templateRuntimeLabel(runtime);
return { id: runtime, label, ariaLabel: `运行时筛选 ${label}` };
}),
[runtimeOptions],
);
// 换筛选条件回到列表顶部:否则筛选后条目变少会把视口留在空白处,看起来像“卡住”。
useEffect(() => {
@@ -285,83 +316,75 @@ export default function TemplateLibraryView({
</button>
</header>
{/* 标签/运行时筛选区可独立滚动:标签数量随库量增长时不会把卡片区挤出窗口。 */}
<div className="grid max-h-[24vh] shrink-0 gap-3 overflow-y-auto rounded-xl border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)] p-3">
<div className="flex flex-wrap items-center gap-2">
<label className="inline-flex min-w-[220px] flex-1 items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1.5">
<Search
className="text-(--platform-icon-text)"
size={14}
aria-hidden="true"
/>
<input
className="w-full border-0 bg-transparent p-0 text-[12px] text-(--platform-text-strong) outline-none"
type="search"
value={filters.query}
placeholder="搜索模板名称、玩法、标签"
aria-label="搜索模板"
onChange={(event) => setQuery(event.target.value)}
/>
</label>
{/* 搜索 + 运行时用共享筛选条(与资源画布、参考图弹窗同一套筛选 UI);
「仅看已下载」「清除筛选」是模板库自己的口径,靠右跟在同一条上。 */}
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-2">
<PlatformResourceFilterBar
ariaLabel="模板筛选"
className="min-w-0 max-w-[560px] flex-1"
search={{
value: filters.query,
label: '搜索模板',
placeholder: '搜索模板名称、玩法、标签',
onChange: setQuery,
}}
categoryItems={runtimeItems}
activeCategoryId={filters.runtime}
onCategoryChange={selectRuntime}
/>
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
<button
type="button"
className={filters.installedOnly ? activeChipClass : chipClass}
className={getPlatformCategoryChipClassName(filters.installedOnly)}
aria-pressed={filters.installedOnly}
onClick={() => setInstalledOnly(!filters.installedOnly)}
>
</button>
{filtersActive ? (
<button type="button" className={chipClass} onClick={clearFilters}>
<button
type="button"
className={getPlatformCategoryChipClassName(false)}
onClick={clearFilters}
>
</button>
) : null}
</div>
{runtimeOptions.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1 text-[11px] text-(--platform-text-soft)">
<SlidersHorizontal size={12} aria-hidden="true" />
</span>
{runtimeOptions.map((runtime) => (
<button
type="button"
key={runtime}
className={
filters.runtime === runtime ? activeChipClass : chipClass
}
aria-pressed={filters.runtime === runtime}
aria-label={`运行时筛选 ${templateRuntimeLabel(runtime)}`}
onClick={() => selectRuntime(runtime)}
>
{templateRuntimeLabel(runtime)}
</button>
))}
</div>
) : null}
{tagOptions.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] text-(--platform-text-soft)">
</span>
{tagOptions.map((tag) => (
<button
type="button"
key={tag}
className={
filters.tags.includes(tag) ? activeChipClass : chipClass
}
aria-pressed={filters.tags.includes(tag)}
aria-label={`标签筛选 ${tag}`}
onClick={() => toggleTag(tag)}
>
{tag}
</button>
))}
</div>
) : null}
</div>
{/* 标签单独一行并换行排布:标签数量随库量增长时优先换行,超过上限再滚动,
不会把卡片区挤出窗口,也不会让用户只能看到横向滚动条切掉的后半截标签。 */}
{tagItems.length > 0 ? (
<div
className="flex max-h-[20vh] min-w-0 shrink-0 flex-wrap items-center gap-1.5 overflow-y-auto"
role="group"
aria-label="模板筛选标签"
>
{tagItems.map((option) => {
const active = filters.tags.includes(option.tag);
return (
<button
type="button"
key={option.tag}
className={getPlatformCategoryChipClassName(active)}
aria-pressed={active}
aria-label={`标签筛选 ${option.tag}`}
onClick={() => toggleTag(option.tag)}
>
{option.tag}
<span
className="platform-category-chip__count"
aria-hidden="true"
>
{option.assetCount}
</span>
</button>
);
})}
</div>
) : null}
<TemplateLibraryToast message={notice} onDismiss={clearNotice} />
{error ? (
<div
@@ -371,7 +394,7 @@ export default function TemplateLibraryView({
<span>{error}</span>
<button
type="button"
className={chipClass}
className={getPlatformCategoryChipClassName(false)}
onClick={() => void refresh()}
>
@@ -393,7 +416,11 @@ export default function TemplateLibraryView({
<div className="flex items-center gap-2 text-[12px] text-(--platform-text-soft)">
<SearchX size={14} aria-hidden="true" />
<button type="button" className={chipClass} onClick={clearFilters}>
<button
type="button"
className={getPlatformCategoryChipClassName(false)}
onClick={clearFilters}
>
</button>
</div>
@@ -4,10 +4,18 @@ import {
buildTemplateRows,
computeTemplateGridColumns,
computeTemplateGridLayout,
computeTemplateGridLayoutWithScrollbar,
computeTemplateRowHeight,
TEMPLATE_CARD_ACTIONS_HEIGHT,
TEMPLATE_CARD_GAP,
TEMPLATE_CARD_META_HEIGHT,
TEMPLATE_CARD_MIN_WIDTH,
TEMPLATE_CARD_SUMMARY_HEIGHT,
TEMPLATE_CARD_TAGS_HEIGHT,
TEMPLATE_CARD_TEXT_GAP,
TEMPLATE_CARD_TEXT_HEIGHT,
TEMPLATE_CARD_TEXT_PADDING,
TEMPLATE_CARD_TITLE_HEIGHT,
} from '../src/features/template-library/templateLibraryGrid';
import type { GameTemplateEntry } from '../src/features/template-library/templateLibraryModel';
@@ -50,6 +58,19 @@ describe('computeTemplateGridColumns', () => {
});
describe('computeTemplateRowHeight', () => {
it('budgets the same height the card rows actually need', () => {
// 这些数字对应 TemplateCard 的 `h-5`/`h-4`/`h-8`/`h-5.5`/`h-7` 与 `p-3`/`gap-2`
// 行高契约比真实内容小,被截断的就是卡片里的标题与简介。
expect(TEMPLATE_CARD_TITLE_HEIGHT).toBe(20);
expect(TEMPLATE_CARD_META_HEIGHT).toBe(16);
expect(TEMPLATE_CARD_SUMMARY_HEIGHT).toBe(32);
expect(TEMPLATE_CARD_TAGS_HEIGHT).toBe(22);
expect(TEMPLATE_CARD_ACTIONS_HEIGHT).toBe(28);
expect(TEMPLATE_CARD_TEXT_PADDING).toBe(12);
expect(TEMPLATE_CARD_TEXT_GAP).toBe(8);
expect(TEMPLATE_CARD_TEXT_HEIGHT).toBe(174);
});
it('keeps cover ratio + fixed text block', () => {
// 列宽 300 → 卡片 286 → 封面 286*9/16 = 160.875 → 161
expect(computeTemplateRowHeight(300)).toBe(
@@ -94,6 +115,59 @@ describe('computeTemplateGridLayout', () => {
});
});
describe('computeTemplateGridLayoutWithScrollbar', () => {
const innerWidth = (layout: { columnCount: number; columnWidth: number }) =>
layout.columnCount * layout.columnWidth;
it('reserves the classic scrollbar width once the grid scrolls vertically', () => {
// 经典滚动条(Windows/WebView2 ≈ 17px)不在包裹层宽度里,不预留就会多出一条横向滚动条。
const plain = computeTemplateGridLayout({
containerWidth: 1184,
itemCount: 13,
});
const layout = computeTemplateGridLayoutWithScrollbar({
containerWidth: 1184,
itemCount: 13,
viewportHeight: 500,
scrollbarWidth: 17,
});
expect(layout.columnCount).toBe(4);
// 内层宽度必须落在竖滚动条左侧的可用宽度里,否则又会出现横向滚动条。
expect(innerWidth(layout)).toBeLessThanOrEqual(1184 - 17);
expect(innerWidth(layout)).toBeLessThan(innerWidth(plain));
// 行高仍按预留后的列宽算,卡片内容不会被压。
expect(layout.rowHeight).toBe(computeTemplateRowHeight(layout.columnWidth));
});
it('keeps the plain layout when nothing overflows or the scrollbar is an overlay', () => {
const plain = computeTemplateGridLayout({
containerWidth: 1184,
itemCount: 4,
});
// 只有一行:不会竖向滚动,不需要预留,右侧不留白边。
expect(
computeTemplateGridLayoutWithScrollbar({
containerWidth: 1184,
itemCount: 4,
viewportHeight: 900,
scrollbarWidth: 17,
}),
).toEqual(plain);
// overlay 滚动条占宽 0:与不预留完全一致。
expect(
computeTemplateGridLayoutWithScrollbar({
containerWidth: 1184,
itemCount: 13,
viewportHeight: 500,
scrollbarWidth: 0,
}),
).toEqual(
computeTemplateGridLayout({ containerWidth: 1184, itemCount: 13 }),
);
});
});
describe('buildTemplateRows', () => {
it('chunks entries per row and pads the tail with nulls', () => {
const rows = buildTemplateRows([entry('a'), entry('b'), entry('c')], 2);
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
collectGameTemplateRuntimes,
collectGameTemplateTagOptions,
collectGameTemplateTags,
EMPTY_TEMPLATE_LIBRARY_FILTERS,
filterGameTemplates,
@@ -145,6 +146,24 @@ describe('tag and runtime options', () => {
]);
});
it('keeps the same order while reporting how many templates carry each tag', () => {
// 筛选条上的标签 chip 要显示命中数量,顺序必须与 `collectGameTemplateTags` 完全一致。
const withBlank = [
...templates,
template({ id: 'blank-tag', tags: ['', ' ', '经营'] }),
];
const options = collectGameTemplateTagOptions(withBlank);
expect(options).toEqual([
{ tag: '经营', assetCount: 3 },
{ tag: '三消', assetCount: 1 },
{ tag: '射击', assetCount: 1 },
{ tag: '像素', assetCount: 1 },
]);
expect(options.map((option) => option.tag)).toEqual(
collectGameTemplateTags(withBlank),
);
});
it('collects distinct runtimes and labels them', () => {
expect(collectGameTemplateRuntimes(templates)).toEqual([
'godot',
@@ -8,7 +8,6 @@ import type {
TemplateLibraryFilters,
} from '../src/features/template-library/templateLibraryModel';
import {
collectGameTemplateTags,
EMPTY_TEMPLATE_LIBRARY_FILTERS,
filterGameTemplates,
} from '../src/features/template-library/templateLibraryModel';
@@ -81,7 +80,6 @@ function controller(
notice: '',
templates,
visibleTemplates: templates,
tagOptions: ['空白', '2d', 'canvas', '网页'],
runtimeOptions: ['html'],
installedCount: 1,
filters,
@@ -190,6 +188,28 @@ describe('TemplateLibraryView', () => {
expect(viewport?.querySelector('article')).not.toBeNull();
});
it('pins every card text row to the height the grid contract budgets for it', () => {
// 回归点:文字区若是 `grid` 的 auto 行,行高会按 max-content 算成「一行」,
// 标题 / 简介 / 标签会被逐行截断(现场表现为标题文字被切掉)。这里钉住
// 卡片每一行的固定高度,改动必须同时改 `TEMPLATE_CARD_*_HEIGHT` 那组常量。
render(<TemplateLibraryView controller={controller()} onBack={() => {}} />);
const card = cardFor('空白网页工程');
const textBlock = card.children[1] as HTMLElement;
const rows = Array.from(textBlock.children) as HTMLElement[];
expect(textBlock.className).toContain('flex-col');
expect(rows.map((row) => row.className)).toEqual([
expect.stringContaining('h-5'),
expect.stringContaining('h-4'),
expect.stringContaining('h-8'),
expect.stringContaining('h-5.5'),
expect.stringContaining('h-7'),
]);
// 每行都不参与压缩,否则 flex 会把文字压回去。
rows.forEach((row) => expect(row.className).toContain('shrink-0'));
});
it('offers 更新 instead of 下载 when the installed version is stale', () => {
const stale = template({
id: 'blank-web',
@@ -253,6 +273,17 @@ describe('TemplateLibraryView', () => {
expect(clearFilters).toHaveBeenCalled();
});
it('hides a cover that failed to load instead of showing a broken image', () => {
render(<TemplateLibraryView controller={controller()} onBack={() => {}} />);
const cover = cardFor('空白网页工程').querySelector(
'img',
) as HTMLImageElement;
expect(cover.style.visibility).toBe('');
fireEvent.error(cover);
expect(cover.style.visibility).toBe('hidden');
});
it('starts a download and a template project from the card actions', () => {
const downloadTemplate = vi.fn(async () => undefined);
const createProjectFromTemplate = vi.fn(async () => undefined);
@@ -289,11 +320,16 @@ describe('TemplateLibraryView', () => {
const busyCard = cardFor('空白二维画布工程');
const buttons = Array.from(busyCard.querySelectorAll('button'));
// 忙状态写在触发它的按钮上(文案就地变成「创建中」),动作行里不额外塞第三个元素,
// 否则最小卡宽(250px)下两个按钮的文案会被挤成两行、顶出卡片。
expect(buttons).toHaveLength(2);
expect(buttons.every((button) => button.hasAttribute('disabled'))).toBe(
true,
);
expect(busyCard.textContent).toContain('正在创建项目');
expect(cardFor('空白网页工程').textContent).not.toContain('正在创建项目');
expect(busyCard.textContent).toContain('创建中');
expect(busyCard.textContent).not.toContain('使用模板');
expect(cardFor('空白网页工程').textContent).toContain('使用模板');
expect(cardFor('空白网页工程').textContent).not.toContain('创建中');
});
it('shows empty, no-match, error and notice states', () => {
@@ -390,7 +426,6 @@ describe('大库量渲染(1000 条假数据)', () => {
templates: bulk,
visibleTemplates: bulk,
installedCount: bulk.filter((entry) => entry.installed).length,
tagOptions: collectGameTemplateTags(bulk),
})}
onBack={() => {}}
/>,
@@ -98,7 +98,128 @@ function contrastRatio(first: Rgba, second: Rgba) {
);
}
/** 取渐变里的色标(`linear-gradient(135deg, #b3542f, #8f3f22)` → 两个颜色)。 */
function parseGradientStops(source: string): Rgba[] {
const body = source.slice(source.indexOf('(') + 1, source.lastIndexOf(')'));
return body
.split(',')
.map((part) => part.trim())
.filter((part) => part.startsWith('#') || part.startsWith('rgb'))
.map((part) => parseCssColor(part.split(/\s+/)[0] ?? part));
}
/** 页面背景(`--platform-body-fill`)里的不透明色标:chip 实际落在这层之上。 */
function parseBodyFillUnderlays(source: string): Rgba[] {
return Array.from(source.matchAll(/#[\da-f]{6}/gi)).map((match) =>
parseCssColor(match[0]),
);
}
describe('workbench theme contrast', () => {
/**
* 筛选 chip 的两态对比:用户反馈「选中和没选中的颜色看不出差别」,根因是选中态
* 只换了低透明度的暖色底(两态对比 1.09:1)。这里把「选中 = 实心填充」这条口径
* 钉死:反白文字在渐变两端都要过 AA,且与未选底色至少差 3:1。
*/
it('keeps the chip selected state legible and distinct in both themes', () => {
const css = readFileSync(themePath, 'utf8');
const themes = [
{
name: 'light',
block: getCssBlock(css, '.platform-theme--light'),
// 浅色主题下 chip 落在页面底色上,用页面渐变的最亮与最暗色标夹住两种情况。
idleUnderlays: parseBodyFillUnderlays(
getCssVariable(
getCssBlock(css, '.platform-theme--light'),
'--platform-body-fill',
),
),
},
{
name: 'dark',
block: getCssBlock(css, '.platform-theme--dark'),
idleUnderlays: parseBodyFillUnderlays(
getCssVariable(
getCssBlock(css, '.platform-theme--dark'),
'--platform-body-fill',
),
),
},
];
expect(
parseGradientStops('linear-gradient(135deg, #b3542f, #8f3f22)'),
).toEqual([
[179, 84, 47, 1],
[143, 63, 34, 1],
]);
for (const theme of themes) {
const activeFill = parseGradientStops(
getCssVariable(theme.block, '--platform-chip-active-fill'),
);
const activeText = parseCssColor(
getCssVariable(theme.block, '--platform-chip-active-text'),
);
const idleFill = parseCssColor(
getCssVariable(theme.block, '--platform-chip-idle-fill'),
);
expect(activeFill, `${theme.name} active gradient stops`).toHaveLength(2);
expect(
theme.idleUnderlays.length,
`${theme.name} body fill stops`,
).toBeGreaterThan(0);
// 反白文字:渐变两端都要过 AA,不能只保证深的那一端。
for (const stop of activeFill) {
expect(
contrastRatio(activeText, stop),
`${theme.name} label on fill ${stop.slice(0, 3).join(',')}`,
).toBeGreaterThanOrEqual(4.5);
}
// 两态可分辨:选中填充与任意页面底色上的未选 chip 至少差 3:1。
for (const underlay of theme.idleUnderlays) {
const idleChip = compositeColor(idleFill, underlay);
for (const stop of activeFill) {
expect(
contrastRatio(stop, idleChip),
`${theme.name} selected vs idle over ${underlay.slice(0, 3).join(',')}`,
).toBeGreaterThanOrEqual(3);
}
}
}
});
/**
* 焦点环可见性:键盘用户靠它找焦点。旧口径是 15% 透明度的暖色,合成到页面底色只有
* 1.17:1——等于没有焦点提示。这里按 WCAG 非文本对比 3:1 钉住两套皮肤。
*/
it('keeps the keyboard focus ring visible in both themes', () => {
const css = readFileSync(themePath, 'utf8');
for (const selector of [
'.platform-theme--light',
'.platform-theme--dark',
]) {
const block = getCssBlock(css, selector);
const ring = parseCssColor(
getCssVariable(block, '--platform-input-focus-ring'),
);
const underlays = parseBodyFillUnderlays(
getCssVariable(block, '--platform-body-fill'),
);
expect(underlays.length, `${selector} body fill stops`).toBeGreaterThan(
0,
);
for (const underlay of underlays) {
expect(
contrastRatio(ring, underlay),
`${selector} focus ring over ${underlay.slice(0, 3).join(',')}`,
).toBeGreaterThanOrEqual(3);
}
}
});
it('keeps warm user bubbles above WCAG AA text contrast', () => {
const css = readFileSync(themePath, 'utf8');
const light = getCssBlock(css, '.platform-theme--light');
@@ -1,5 +1,15 @@
# 决策记录
## 2026-09-22 筛选控件选中态:类名收敛到 helper,视觉收敛到「实心填充 + 反白文字」
- 背景:`platform-category-chip` 的类名字符串此前在三个宿主各抄一份(共享筛选条 `PlatformResourceFilterBar`、资源画布筛选浮层 `ResourceFilterPanel`、模板库筛选区),「选中的筛选胶囊长什么样」随时会各自漂移;更严重的是选中态本身只用了 `--platform-cool-*` 这组低透明度暖色,实测选中/未选底色对比只有 1.09:1,用户反馈「选中和没选中的颜色看不出差别」。
- 决策:① 类名口径收敛到 `packages/shared/src/components/platformCategoryChipModel.ts``getPlatformCategoryChipClassName(active)`(从 `@genarrative/shared/components` 导出),三处宿主统一改调它;② 选中态改为**实心品牌填充 + 反白文字**,语义色收在新的 `--platform-chip-idle-fill` / `--platform-chip-active-{fill,border,text,shadow}`(浅色皮肤深暖填充、深色皮肤亮靛蓝填充 + 深文字),`PlatformSegmentedTabs` 新增 `tone="accent"` 与 chip 共用这套色;③ `src/index.css`(平台 Web/平台 H5)里那份重复的 `--active` 规则同步改口径,避免覆盖共享样式把 Web 端打回旧样子。
- 状态阶梯(同一份口径,三个状态不许互相冒充):静止 = 浅底 + 中性描边 + 常规文字;悬停 = 中性加描边 + 极淡暖底 + 深色文字(**品牌色只能属于「已选中」**,悬停用品牌色会让未选中的 chip 看起来已选中);按下 = 再压一层;选中 = 实心填充 + 反白文字,是唯一的强状态。运行时分段的 `accent` 未选中悬停同理(淡暖底 + 深文字)。
- 焦点态同批收口:`--platform-input-focus-ring` 从 15% 透明度改成实心色(合成后 1.17:1 的环等于没有),筛选 chip / 分段项 / 排序按钮的焦点提示改用 `outline: 2px solid <ring>; outline-offset: 2px`——不再用 `box-shadow` 画环,避免被选中态自己的投影盖掉。
- 原因:二元状态必须靠**填充/明度**表达而不是色相微调;颜色只允许在 `packages/shared/src/theme.css` 的语义变量里出现,组件不再自己写颜色字面量。
- 影响范围:`packages/shared/src/theme.css``packages/shared/src/components/{platformCategoryChipModel.ts,styles.css,PlatformSegmentedTabs.tsx,PlatformResourceFilterBar.tsx,index.ts}``src/index.css``apps/ai-game-creator-shell/src/view/{project-development/ResourceFilterPanel.tsx,template-library/index.tsx}`
- 验证方式:`apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts` 按 WCAG 公式断言两套皮肤都满足「选中文字 ≥ 4.5:1(渐变两端)」且「选中填充 vs 未选底色 ≥ 3:1」;`platformCategoryChipModel.test.ts` 钉住选中类名分支;`PlatformResourceFilterBar.test.tsx` / `resourceFilterPanel.test.tsx` / `src/index.test.ts` 覆盖各宿主。真机 AGC 客户端截图实测两态填充对比 6.0:1、选中文字 4.86.0:1。
## 2026-09-22 退役 AGC 项目对话斜杠命令与终端 swarm chat 入口
- 背景:AGC 项目对话曾把大量能力挂在「聊天输入 `/<cmd>`」上(`/history``/read``/help``/status``/trace``/export``/preview``/remember``/brief` 等),无 GUI 的终端 swarm chat 入口 `--swarm-chat` 又自带一套控制命令(`/help``/agents``/status``/history``/compact``/resume``/goal``/quit`)。两套入口都没有现役调用方,撤回成本却持续存在:命令字面量散落在前端命令分支、润色绕过、摘要模块、`swarm_cli` 终端输入解析、构建期门禁条目和文档承诺里,任何新对话形态都要额外维护这套死词汇表。
@@ -5875,3 +5875,43 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- CI 产物清理:Gitea 1.26.4 的仓库 REST 仅列出 finalized/expired V4 artifact,内置到期清理不回收上传中断的 tmp-upload 分块。缓存上传块须带专属标识,宿主只清理目标仓库已结束且超过 7 天的 master run 中同样过期的自有普通文件,未知文件/符号链接保护,不改数据库或全局 prune。Artifact.workflow_run 仅含 ID/SHA,判断过期产物所属事件和状态须再读 run API,不能当作完整 run 使用。
- 自动切换:Gitea 1.26.4 的 disabled 检查与 FetchTask 事务不原子,Runner 客户端超时不能证明服务端回滚,容器暂时为空也不能证明没有已领取任务。网关必须解析实际 Connect Protobuf/gzip,转发 FetchTask 结果前持久化任务 ID,仅在最终日志及执行清理后的最终 UpdateTask 确认后清账;取消响应不能提前释放。暂停新领取、在途为零、账本为零且内层活动容器为空才可切换,无需全局 Runner admin API。未知协议/响应或崩溃遗留标记停止切换;旧网关缺 active_tasks 不能默认零。首次接入与账本升级须空闲窗口。.runner 的 mtime 不证明地址已加载,应核验真实 FetchTask 来源及本次容器启动时间。
- 扩展:预热所有 Rust 测试组时保留各自 cwd、profile、features 和锁策略;同一临时 target 的 Cargo fresh 不代表不同 cwd 都已生成缓存键,AGC 提示词契约、分片和 smoke 切换入口前清理预热 target。不要把 workspace 与 spacetime-module 合并成一次编译;Native shell release step 清空双 wrapper,避免将测试缓存扩展成发布缓存。当前 sccache 0.18.0 的 READ_ONLY 在 miss 后仍打包产物并产生 cache write error,不适合用来承诺“未命中无开销”。
## 2026-09-22 卡片文字用 grid 的 auto 行排版,会被按「一行」裁掉
- **现象**:AGC 模板库卡片标题看着被切掉、简介只剩一行、标签行缺半截;`TEMPLATE_CARD_TEXT_HEIGHT` 与真实内容相差约 24px,但卡片底部看起来仍「刚好贴住」,很容易误判成没问题。
- **原因**:文字区原本是 `grid min-h-0 content-start gap-2 overflow-hidden`,行高由 auto 轨道决定。auto 轨道的 max-content 高度对可换行文本等于**一行**的高度:标题拿到 14px(实际需要 20)、简介 14px(两行需要 32)、标签 14px(需要 19),只有最后一个子项(按钮行)拿到完整高度。真实浏览器实测 `clientHeight`/`scrollHeight` 为 14/20、14/32、14/19。jsdom 不计算布局,单测全绿也照不出来。
- **处理(现行口径)**:文字区改 `flex flex-col`,每行写死高度并加 `shrink-0`(标题 `h-5`、元信息 `h-4`、简介 `h-8`、标签 `h-5.5`、按钮行 `h-7`,内边距 `p-3`、行距 `gap-2`),行高契约按同一组分项常量(`TEMPLATE_CARD_*_HEIGHT`)算出文字区 174。卡片行高、`TemplateCard` 类名与这组常量必须同时改。
- **写死高度的连带约束**:动作行一旦固定成 `h-7`,那它就**只能放两个按钮**。再往里塞第三段文本(当时的「正在下载模板」)时,最小卡宽 250px 下按钮文案会被挤成两行并顶出卡片(用户看到「使用模 板 / 更 新」叠成一团)。现行口径是忙状态写在触发它的按钮上(`下载中` / `创建中`,按钮就地换图标+文案),按钮一律 `whitespace-nowrap shrink-0`,动作行 `overflow-hidden` 兜底。
- **验证**:真实浏览器逐行核对 `clientHeight === scrollHeight`20/20、16/16、32/32、22/22、28/28);单测钉住分项常量与卡片各行类名(`templateLibraryGrid.test.ts``templateLibraryView.test.tsx`)。
- **关联**`apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts``apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx``docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`(卡片列表虚拟滚动)。
## 2026-09-22 筛选 chip 的选中态只靠低透明度色相微调,用户看不出选中了什么
- **现象**:模板库筛选 chip 选中与未选中的底色实测只差 **1.09:1**(选中 `rgba(238,208,183,0.26)` 合成后 ≈ `#f9eee5`,未选 ≈ `#fdf9f5`),选中文字 `#b76038` 在自己底色上只有 3.88:1(低于 AA);运行时分段选中的白底 pill 也和米色页面几乎同色。反馈原话是「选中和没选中的颜色都看不出有差别」。
- **原因**:选中态走的是 `--platform-cool-bg/border/text`,这三个变量在浅色皮肤里是同一个暖色调的低透明度版本(bg 26%、border 24% alpha),只够做「淡淡的染色」,不足以表达二元状态;深色皮肤里 `rgba(8,145,178,0.14)` 同样太弱,而深色皮肤里「更深的填充」反而更不可分辨。
- **处理(现行口径)**:筛选控件的二元状态统一为**未选 = 浅底描边 + 常规文字,选中 = 实心品牌填充 + 反白文字**(`.platform-category-chip--active``PlatformSegmentedTabs``tone="accent"` 共用 `--platform-chip-*` 语义色;深色皮肤用「亮填充 + 深文字」,因为深色下只有更亮才算选中)。改色只改 `packages/shared/src/theme.css` 的语义变量,不要再往组件里写颜色。
- **同一坑的第二种表现**:把「悬停」也刷成品牌色(暖色描边 + 暖色文字)后,未选中的 chip 一悬停就像已选中——品牌色一旦被悬停借走,「已选中」这个强状态就没有颜色可用了。现行阶梯是「静止 = 浅底中性描边 / 悬停 = 中性加描边 + 极淡暖底 + 深色文字 / 按下 = 再压一层 / 选中 = 实心填充 + 反白文字」。客户端实测:选中填充 `#b0522e` vs 悬停底 `#f9efe4` = 4.52:1,选中 vs 静止 = 4.97:1,悬停 vs 静止 = 1.10:1(只是提示,不抢选中)。
- **验证**`apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts` 按 WCAG 公式断言两套皮肤都满足「选中标签文字 ≥ 4.5:1(渐变两端都要过)」且「选中填充 vs 未选底色 ≥ 3:1」;真实浏览器实测改后两态对比 5.6–6.1:1、选中文字 5.56.0:1(改前分别是 1.09:1 与 3.88:1)。
- **关联**`packages/shared/src/theme.css``packages/shared/src/components/styles.css``packages/shared/src/components/PlatformSegmentedTabs.tsx``apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts`
## 2026-09-22 虚拟网格按「包裹层宽度」算列宽,经典滚动条一出现就多出横向滚动条
- **现象**:AGC 模板库卡片区底部在客户端里凭空多出一条横向滚动条(外层并没有横向溢出内容);窗口放到没有竖向滚动的尺寸时又不出现。
- **原因**react-window 的内层宽度 = `列数 × 列宽`,而列宽是按**包裹层**宽度算的(`floor(容器宽 / 列数)`)。经典(非 overlay)滚动条会吃掉 grid 外层的 `clientWidth`Windows / WebView2 上竖向滚动条约 17px,于是内层 1184 比外层可用宽 1167 宽出正好一个滚动条,react-window 就按「横向也要滚」处理。**Playwright 自带的 Chromium 用的是 overlay 滚动条(占宽 0),本地量 `offsetWidth - clientWidth` 是 0,完全复现不出来** —— 这类问题只能在 WebView2 客户端里看,或者按算术推。
- **处理(现行口径)**`computeTemplateGridLayoutWithScrollbar``templateLibraryGrid.ts`)在「内容确实会竖向溢出」时先把滚动条宽度从容器宽度里扣掉再算列宽/行高,不竖向溢出时不预留(否则右侧会留一条无意义的白边);滚动条宽度由 `measureVerticalScrollbarWidth()` 量一次(overlay 平台为 0,逻辑自动退化)。同类虚拟列表再出现「莫名其妙的横向滚动条」,先查这里的算术,不要靠 `overflow-x: hidden` 掩盖(那会把最后一列切掉)。
- **验证**`templateLibraryGrid.test.ts` 断言「竖向溢出时 `列数 × 列宽 ≤ 容器宽 - 滚动条`」「不溢出或 overlay 时与不预留完全一致」;真机客户端截图确认横向滚动条消失、右侧只剩竖向滚动条。
- **关联**`apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts``apps/ai-game-creator-shell/src/view/template-library/index.tsx`
## 2026-09-22 「15% 透明度的焦点环」等于没有焦点提示;选中态自己的投影还会把焦点环顶掉
- **现象**:键盘 Tab 走到筛选 chip、开关、卡片按钮上时,屏幕上完全看不出焦点在哪;自动化里更隐蔽——`box-shadow` 计算值非空(一串 `rgba(0,0,0,0) 0 0 0 0` 的 Tailwind ring 占位),只查「有没有 shadow」会全部判过。
- **原因**:两个叠加的问题。① `--platform-input-focus-ring``rgba(204,117,76,0.15)`,合成到页面底色后对底色只有 **1.17:1**,远低于 WCAG 非文本对比要求的 3:1;② 焦点环用 `box-shadow` 画,而**选中态自己也有 `box-shadow`**(实心 chip 的投影),选中的 chip / 分段项聚焦时环被状态投影盖掉,等于没有提示。
- **处理(现行口径)**`--platform-input-focus-ring` 改成实心色(浅色 `#b6623f`,对页面 4.3:1;深色 `#9fb0ff`,对深色底 6.5:1);筛选 chip / 分段项 / 排序按钮的焦点环改用 `outline: 2px solid var(--platform-input-focus-ring); outline-offset: 2px`——`outline` 不参与 `box-shadow` 层叠,不会被选中态投影顶掉,也不撑开布局。
- **验证**`tests/workbenchThemeContrast.test.ts` 断言焦点环对两套皮肤的页面底色 ≥ 3:1;真实浏览器里对页面上**全部 175 个可聚焦控件**做 blur→focus 前后比对,无一例外都能看到焦点变化(改前有 8 个控件聚焦前后完全一致)。查焦点态时必须比较「聚焦前后的计算样式差异」,不能只看属性是否非空。
- **关联**`packages/shared/src/theme.css``packages/shared/src/components/styles.css``apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts`
## 2026-09-22 封面图加载失败会画出浏览器的「裂图」图标
- **现象**:模板清单里的封面 URL 失效(或离线)时,卡片封面上出现浏览器的破碎图片图标,比没有封面更难看。
- **处理**`TemplateCard``img``onError` 直接把自身 `visibility` 设为 `hidden`(不进 state,卡片是 memo 的纯展示组件),留下封面容器本身的中性底色;单测用 `fireEvent.error(cover)` 钉住。
- **关联**`apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx``apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`
@@ -116,7 +116,10 @@ templates/
- `src/features/template-library/templateLibraryModel.ts`:清单类型、搜索(空白分隔多关键词「与」)、标签/运行时/已下载筛选、标签选项聚合、体积格式化等纯函数。
- `src/features/template-library/useTemplateLibrary.ts`:一次拉清单,暴露筛选状态、下载与「用模板建项目」;下载成功后只就地更新该条目的已下载状态。
- `src/view/template-library/index.tsx`:模板库全屏页(返回、刷新、搜索、运行时/标签筛选、仅看已下载、卡片显示封面与已下载徽标、下载/使用模板)。
- 筛选区拆两行:第一行是共享筛选条 `PlatformResourceFilterBar`(搜索框 + 运行时分段)与靠右的「仅看已下载 / 清除筛选」,第二行是标签 chip(带命中数量,换行排布、`max-h-[20vh]` 上限内滚动)。
- 筛选控件的两态口径(模板库、资源画布、参考图弹窗共用):**未选 = 浅底描边 + 常规文字,选中 = 实心品牌填充 + 反白文字**,两态填充对比 ≥ 3:1、标签文字在选中填充上 ≥ 4.5:1(由 `tests/workbenchThemeContrast.test.ts` 钉住)。运行时分段用 `PlatformSegmentedTabs``tone="accent"`,与标签 chip 共用同一组 `--platform-chip-active-*` 语义色,不再出现「选中只换了一点点暖色」的弱状态。
- 卡片动作按安装状态收口:已下载且版本一致时**不再显示下载入口**,只留「使用模板」;版本落后才显示「更新」;缺包显示「下载」。
- 忙状态写在**触发它的那个按钮**上(下载中 / 创建中,按钮就地换图标与文案),动作行里不额外塞状态文本:最小卡宽(250px)下「使用模板 + 下载/更新 + 状态文本」三个元素会把按钮文案挤成两行并顶出卡片。
- 过程提示(下载完成、开始建项目)走浮层 toast(复用 `packages/shared``PlatformRuntimeStatusToast``document.body` 浮层 + 2.6 秒自动消失),不再占用页面内位置;页面内只保留可操作的错误与空态。
- 首页「灵感推荐」替换为「模板库」推荐位(`src/view/home/TemplateRecommendations.tsx`):只展示封面、标题、运行时与已下载徽标,点击进入模板库页面;首页不再直接触发建项目。
- 左侧导航新增模板库入口(`LauncherView = 'template-library'`)。
@@ -164,14 +167,15 @@ AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT=300 AGC_DEV_CARGO_FEATURES=template-library
### 卡片列表虚拟滚动(react-window
- 列表改用 workspace 里已有的 `react-window@1.8.11``FixedSizeGrid``react-arborist` 已在用同一版本,不引入新包;类型来自 devDependency `@types/react-window`)。
- 布局契约收在纯函数 `templateLibraryGrid.ts`(单测覆盖):列数 = `floor((容器宽 + gap) / (最小卡宽 + gap))`、列宽 = 容器宽 / 列数、行高 = `卡片宽 × 9/16 + 文字区 150 + gap``buildTemplateRows` 按行切分并在行尾补 `null` 占位。
- 布局契约收在纯函数 `templateLibraryGrid.ts`(单测覆盖):列数 = `floor((容器宽 + gap) / (最小卡宽 + gap))`、列宽 = 容器宽 / 列数、行高 = `卡片宽 × 9/16 + 文字区 174 + gap``buildTemplateRows` 按行切分并在行尾补 `null` 占位。
- **卡片文字区行高契约**:文字区 174 = 内边距 12×2 + 标题 20`h-5`+ 元信息 16`h-4`+ 简介 32`h-8`,两行)+ 标签 22`h-5.5`+ 按钮行 28`h-7`+ 行距 8×4;这些分项在 `templateLibraryGrid.ts` 里各有一个常量,`TemplateCard` 用同一组固定高度 + `shrink-0` 渲染。**不要**再把文字区写成 `grid` 的 auto 行:auto 行按 max-content 计高,多行文字只按一行算,标题 / 简介 / 标签会被逐行裁掉。
- 卡片抽成 `TemplateCard``memo`),网格只渲染可视行 + 2 行 overscan;筛选条件(关键词/标签/运行时/仅看已下载)变化时把滚动位置复位到顶部,避免"从筛选切回全量后停在空白处"。
- **页面高度契约**:页面根节点的高度按**父级 `.launcher-main` 的实测高度**内联设置,既不用百分比也不用 `100vh`。原因:外壳样式 `.launcher-main > .platform-theme { height: 100% }` 特异性高于 Tailwind 工具类,而这条百分比在 `.launcher-shell { min-height: 100vh }` 链路上是不定高,页面会退化成内容高度(虚拟网格视口高度 0、卡片区整片空白);`100vh` 又比真实舞台高一个标题栏高度(窗口 100vh=800 / 舞台 750),底部会被裁掉。
- 筛选区(运行时/标签)改成可独立滚动的区块`max-h-[24vh]`),标签数量随库量增长时不再把卡片区挤出窗口。
- 筛选区(运行时/标签)在窗口变窄时整体换行,标签行单独限高`max-h-[20vh]` 内滚动),标签数量随库量增长时不再把卡片区挤出窗口。
- 回归:`templateLibraryGrid.test.ts` 覆盖列数/行高/行数/切行;页面测试用固定视口断言「1000 条只渲染 ≤ 40 张卡片,滚动高度仍按 250 行计算」。
- 页面能正常渲染 1000 张卡片(头部显示「共 1000 个模板 · 已下载 335 个」),并且滚动容器生效(窗口高度压到 430px 时右侧出现滚动条,页面内容被裁切而不是溢出到窗口外)。
- 需要后续收口的两点(本次未改):① 标签筛选条随库量膨胀——1000 条时聚合出 35 个标签、占三行;② 一次性渲染 1000 个卡片节点并触发 1000 次封面请求建议标签只展示 Top N + 「更多」,卡片列表加分页或虚拟滚动
- 仍需关注:① 标签筛选条随库量膨胀——1000 条时聚合出 35 个标签;现为「换行 + `max-h-[20vh]` 滚动」兜底,量大时仍建议只展示 Top N + 「更多」;② 一次性渲染 1000 个卡片节点并触发 1000 次封面请求建议加封面懒加载上限或分页
- 前端回归:1000 条渲染 + 已安装过滤(334)/标签过滤(50)/关键词过滤数量自洽,见 `apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`
```bash
@@ -1,6 +1,7 @@
import { Search, Tag } from 'lucide-react';
import type { Ref } from 'react';
import { getPlatformCategoryChipClassName } from './platformCategoryChipModel';
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
export type PlatformResourceFilterOption<TId extends string = string> = {
@@ -53,15 +54,6 @@ export type PlatformResourceFilterBarProps<TId extends string = string> =
onToggleTag: (tag: string) => void;
});
function tagChipClassName(active: boolean) {
return [
'platform-category-chip gap-1.5 px-2.5 text-xs font-bold',
active ? 'platform-category-chip--active' : null,
]
.filter(Boolean)
.join(' ');
}
/**
* 资源搜索 + 功能分类 + 标签的筛选条。
*
@@ -115,6 +107,9 @@ export function PlatformResourceFilterBar<TId extends string = string>({
frame="bare"
surface="transparent"
size="sm"
// 选中项与下面的标签 chip 用同一套实心口径:筛选条里「哪个条件在生效」
// 只允许一种视觉语言。
tone="accent"
className="platform-theme platform-theme--light min-w-0 flex-none"
/>
{tagOptions.length > 0 ? (
@@ -130,7 +125,7 @@ export function PlatformResourceFilterBar<TId extends string = string>({
key={option.tag}
type="button"
aria-pressed={active}
className={tagChipClassName(active)}
className={getPlatformCategoryChipClassName(active)}
// 类型上 `tagItems` 必然带 `onToggleTag`;这一层兜底是给不带类型检查的
// 调用方(JS / 动态构造的 props):没有处理函数就不该渲染成可点按钮。
disabled={!onToggleTag}
@@ -138,7 +133,12 @@ export function PlatformResourceFilterBar<TId extends string = string>({
>
<Tag className="h-3 w-3" aria-hidden="true" />
<span>{option.tag}</span>
<span aria-hidden="true">{option.assetCount}</span>
<span
className="platform-category-chip__count"
aria-hidden="true"
>
{option.assetCount}
</span>
</button>
);
})}
@@ -19,6 +19,7 @@ export type PlatformSegmentedTabsTone =
| 'neutral'
| 'warm'
| 'rose'
| 'accent'
| 'underline';
export type PlatformSegmentedTabsFrame = 'panel' | 'bare';
export type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
@@ -127,6 +128,12 @@ const PLATFORM_SEGMENTED_TABS_TONE_CLASS: Record<
'border border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]',
idle: 'border border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-[var(--platform-surface-hover-border)] hover:bg-white',
},
// 与筛选 chip 的选中态共用同一组语义色:实心填充 + 反白文字。
// 未选中的悬停只加淡暖底 + 加深文字,不换成品牌色 —— 品牌色是「已选中」专用的。
accent: {
active: 'platform-segmented-tabs__item--solid',
idle: 'text-[var(--platform-text-base)] hover:bg-[var(--platform-warm-bg)] hover:text-[var(--platform-text-strong)]',
},
underline: {
active: 'text-[var(--platform-text-strong)]',
idle: 'text-[var(--platform-text-muted)] hover:text-[var(--platform-text-base)]',
+1
View File
@@ -17,6 +17,7 @@ export type {
export { PlatformBackActionButton } from './PlatformBackActionButton';
export type { PlatformBadgeProps, PlatformBadgeTone } from './PlatformBadge';
export { PlatformBadge } from './PlatformBadge';
export { getPlatformCategoryChipClassName } from './platformCategoryChipModel';
export type {
PlatformEmptyStateProps,
PlatformEmptyStateSize,
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { getPlatformCategoryChipClassName } from './platformCategoryChipModel';
describe('getPlatformCategoryChipClassName', () => {
it('returns the shared chip chrome and only marks the active state when selected', () => {
const idle = getPlatformCategoryChipClassName(false);
expect(idle).toContain('platform-category-chip');
expect(idle).not.toContain('platform-category-chip--active');
const active = getPlatformCategoryChipClassName(true);
expect(active).toContain('platform-category-chip');
expect(active).toContain('platform-category-chip--active');
});
});
@@ -0,0 +1,15 @@
/**
* 分类 / 标签 chip 的类名口径。
*
* `platform-category-chip` 的公共表现(高度、圆角、hover、focus ring)挂在共享样式表里,
* 这里只负责「选中态」这一处分支,让资源画布、参考图弹窗、模板库这些宿主共用同一份定义,
* 不再各抄一遍类名字符串。
*/
export function getPlatformCategoryChipClassName(active: boolean): string {
return [
'platform-category-chip gap-1.5 px-2.5 text-xs font-bold',
active ? 'platform-category-chip--active' : null,
]
.filter(Boolean)
.join(' ');
}
+58 -6
View File
@@ -540,7 +540,7 @@
justify-content: center;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.78rem;
background: rgba(255, 255, 255, 0.04);
background: var(--platform-chip-idle-fill, rgba(255, 253, 250, 0.72));
color: var(--platform-text-base);
padding: 0 0.92rem;
font-size: 0.88rem;
@@ -548,10 +548,59 @@
white-space: nowrap;
}
/* 状态阶梯(三个状态必须一眼分得开):
静止 = 浅底 + 中性描边;悬停 = 中性加描边 + 极淡暖底 + 常规文字(**不能**用品牌色,
否则一颗未选中的 chip 悬停起来就像已选中,等于把「选中」这个强状态用掉了);
按下 = 再压一层;选中 = 实心品牌填充 + 反白文字,是唯一的强状态。 */
.platform-category-chip:not(.platform-category-chip--active):hover {
border-color: var(--platform-surface-hover-border, rgba(204, 117, 76, 0.42));
background: var(--platform-warm-bg, rgba(234, 204, 179, 0.32));
color: var(--platform-text-strong);
}
.platform-category-chip:not(.platform-category-chip--active):active {
border-color: var(--platform-warm-border, rgba(204, 117, 76, 0.28));
background: var(--platform-warm-bg, rgba(234, 204, 179, 0.32));
}
.platform-category-chip--active {
border-color: var(--platform-cool-border);
background: var(--platform-cool-bg);
color: var(--platform-cool-text);
border-color: var(--platform-chip-active-border, #7d3719);
background: var(
--platform-chip-active-fill,
linear-gradient(135deg, #b3542f, #8f3f22)
);
color: var(--platform-chip-active-text, #fffaf5);
box-shadow: var(
--platform-chip-active-shadow,
0 6px 14px rgba(150, 71, 39, 0.24)
);
}
/* chip 里的计数(标签命中数量):次要信息,比标签文字轻一档;
选中态跟着标签文字一起变白,不参与不透明度衰减,避免落在 AA 以下。 */
.platform-category-chip__count {
font-size: 0.72rem;
font-weight: 600;
color: var(--platform-text-soft);
}
.platform-category-chip--active .platform-category-chip__count {
color: inherit;
}
/* 分段页签的「实心选中」项:与筛选 chip 共用同一组语义色,
避免两处各写一套「选中长什么样」。 */
.platform-segmented-tabs__item--solid {
border-color: var(--platform-chip-active-border, #7d3719);
background: var(
--platform-chip-active-fill,
linear-gradient(135deg, #b3542f, #8f3f22)
);
color: var(--platform-chip-active-text, #fffaf5);
box-shadow: var(
--platform-chip-active-shadow,
0 6px 14px rgba(150, 71, 39, 0.24)
);
}
.platform-category-sort-button {
@@ -586,8 +635,11 @@
.platform-category-filter-button:focus-visible,
.platform-category-chip:focus-visible,
.platform-category-sort-button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
/* 焦点环用 outline 而不是 box-shadow:选中态自己带 box-shadow(实心 chip 的投影),
用 box-shadow 画环会被状态投影覆盖,选中的 chip 就完全没有焦点提示。outline 不参与
box-shadow 的层叠,也不会撑开布局(offset 2px 落在元素外),选中态自己的投影照旧。 */
outline: 2px solid var(--platform-input-focus-ring);
outline-offset: 2px;
}
.platform-navigable-list-item {
+23 -2
View File
@@ -75,6 +75,15 @@
--platform-cool-border: rgba(199, 117, 76, 0.24);
--platform-cool-bg: rgba(238, 208, 183, 0.26);
--platform-cool-text: #b76038;
/* 筛选 chip 的两态:未选是浅底描边,选中是**实心填充**。选中填充必须深到
白字仍过 WCAG AA,并且与未选底色的对比 ≥ 3:1 —— 否则「选中 / 未选中」
在米色页面上肉眼分不出来(旧口径两态对比只有 1.09:1)。
口径由 apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts 钉住。 */
--platform-chip-idle-fill: rgba(255, 253, 250, 0.72);
--platform-chip-active-fill: linear-gradient(135deg, #b3542f, #8f3f22);
--platform-chip-active-border: #7d3719;
--platform-chip-active-text: #fffaf5;
--platform-chip-active-shadow: 0 6px 14px rgba(150, 71, 39, 0.24);
--platform-neutral-border: rgba(226, 203, 184, 0.44);
--platform-neutral-bg: rgba(255, 253, 250, 0.7);
--platform-neutral-text: #7b6150;
@@ -150,7 +159,10 @@
--platform-input-fill: rgba(255, 253, 250, 0.94);
--platform-input-fill-focus: rgba(255, 254, 252, 0.96);
--platform-input-highlight: rgba(255, 255, 255, 0.9);
--platform-input-focus-ring: rgba(204, 117, 76, 0.15);
/* 焦点环必须**看得见**:原来 15% 透明度合成到页面底色后只有 1.17:1,键盘用户
根本看不到焦点在哪。改成实心品牌色,对页面底色 4.3:1(≥ WCAG 非文本对比 3:1),
由 tests/workbenchThemeContrast.test.ts 钉住。 */
--platform-input-focus-ring: #b6623f;
--platform-nav-item-text: #80695a;
--platform-nav-item-text-active: #3d1f10;
--platform-nav-item-hover-fill: rgba(253, 248, 243, 0.94);
@@ -343,6 +355,14 @@
--platform-cool-border: rgba(103, 232, 249, 0.22);
--platform-cool-bg: rgba(8, 145, 178, 0.14);
--platform-cool-text: rgb(236 254 255);
/* 同浅色主题:选中态是实心填充。深色皮肤里「填充比底色更亮」才是可分辨的选中,
所以这里用亮靛蓝填充 + 深色文字,同时在渐变两端都满足
「文字 ≥ 4.5:1」「与未选底色 ≥ 3:1」。 */
--platform-chip-idle-fill: rgba(255, 255, 255, 0.05);
--platform-chip-active-fill: linear-gradient(135deg, #9fb0ff, #7b8cff);
--platform-chip-active-border: #b7c4ff;
--platform-chip-active-text: #0b1030;
--platform-chip-active-shadow: 0 8px 18px rgba(77, 92, 245, 0.32);
--platform-neutral-border: rgba(255, 255, 255, 0.08);
--platform-neutral-bg: rgba(255, 255, 255, 0.05);
--platform-neutral-text: rgb(228 228 231);
@@ -414,7 +434,8 @@
--platform-input-fill: rgba(255, 255, 255, 0.05);
--platform-input-fill-focus: rgba(255, 255, 255, 0.08);
--platform-input-highlight: rgba(255, 255, 255, 0.06);
--platform-input-focus-ring: rgba(91, 108, 255, 0.22);
/* 同浅色主题:深色皮肤用亮靛蓝焦点环,对深色底色 6.5:1。 */
--platform-input-focus-ring: #9fb0ff;
--platform-nav-item-text: rgb(161 161 170);
--platform-nav-item-text-active: rgb(238 248 255);
--platform-nav-item-hover-fill: rgba(91, 108, 255, 0.08);
+34 -7
View File
@@ -1602,7 +1602,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
justify-content: center;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.78rem;
background: rgba(255, 255, 255, 0.04);
background: var(--platform-chip-idle-fill, rgba(255, 253, 250, 0.72));
color: var(--platform-text-base);
padding: 0 0.92rem;
font-size: 0.88rem;
@@ -1610,10 +1610,30 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
white-space: nowrap;
}
/* 与共享样式表同一口径未选是浅底描边选中是实心填充 + 反白文字
这里的规则必须写在共享样式之后本文件在 @import 之后否则会被
`.platform-category-chip` 的基础底色压回去 */
.platform-category-chip--active {
border-color: var(--platform-cool-border);
background: var(--platform-cool-bg);
color: var(--platform-cool-text);
border-color: var(--platform-chip-active-border, #7d3719);
background: var(
--platform-chip-active-fill,
linear-gradient(135deg, #b3542f, #8f3f22)
);
color: var(--platform-chip-active-text, #fffaf5);
box-shadow: var(
--platform-chip-active-shadow,
0 6px 14px rgba(150, 71, 39, 0.24)
);
}
.platform-category-chip__count {
font-size: 0.72rem;
font-weight: 600;
color: var(--platform-text-soft);
}
.platform-category-chip--active .platform-category-chip__count {
color: inherit;
}
.platform-category-sort-button {
@@ -1652,9 +1672,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.platform-category-filter-dialog__option--active {
border-color: var(--platform-cool-border);
background: var(--platform-cool-bg);
color: var(--platform-cool-text);
border-color: var(--platform-chip-active-border, #7d3719);
background: var(
--platform-chip-active-fill,
linear-gradient(135deg, #b3542f, #8f3f22)
);
color: var(--platform-chip-active-text, #fffaf5);
box-shadow: var(
--platform-chip-active-shadow,
0 6px 14px rgba(150, 71, 39, 0.24)
);
}
.platform-category-filter-dialog__actions {