feat(ui): theme 契约(类型+默认值+合并+Provider)
子游戏只写要改的字段, 缺省集中在 DEFAULT_THEME 一处(spec §4.3, 第二准则的唯一例外)。ThemeProvider 未 init 即读抛错, 不兜底。 皮肤构建期定死, 故静态读取不做响应式。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ResolvedTheme } from './types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题缺省值的**唯一来源**(spec §4.3)。
|
||||||
|
* 这是 CLAUDE.md 第二准则明文列出的唯一例外——缺省集中在来源处,下游子游戏零兜底。
|
||||||
|
* 不得以此为先例:协议数据缺失仍须显式暴露,不得 ?? 抹平。
|
||||||
|
*/
|
||||||
|
export const DEFAULT_THEME: ResolvedTheme = {
|
||||||
|
colors: {
|
||||||
|
primary: '#1E7A46',
|
||||||
|
accent: '#F2B233',
|
||||||
|
textPrimary: '#FFFFFF',
|
||||||
|
textSecondary: '#C8D6CE',
|
||||||
|
disabled: '#8A8A8A',
|
||||||
|
warning: '#D94F3D',
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
familyPath: null,
|
||||||
|
sizeTitle: 32,
|
||||||
|
sizeBody: 24,
|
||||||
|
sizeCaption: 20,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
myInfoAnchorX: 130,
|
||||||
|
nicknameMaxChars: 8,
|
||||||
|
infoNicknameMaxChars: 16,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"ver": "4.0.24",
|
||||||
|
"importer": "typescript",
|
||||||
|
"imported": true,
|
||||||
|
"uuid": "5f3f9c00-dd28-4c44-91c2-b747b751595c",
|
||||||
|
"files": [],
|
||||||
|
"subMetas": {},
|
||||||
|
"userData": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ResolvedTheme, ThemeConfig } from './types.ts';
|
||||||
|
import { DEFAULT_THEME } from './default-theme.ts';
|
||||||
|
|
||||||
|
/** 把子游戏的部分主题配置合并到默认主题上。分组独立合并,不改动 DEFAULT_THEME。 */
|
||||||
|
export function resolveTheme(game: ThemeConfig = {}): ResolvedTheme {
|
||||||
|
return {
|
||||||
|
colors: { ...DEFAULT_THEME.colors, ...game.colors },
|
||||||
|
fonts: { ...DEFAULT_THEME.fonts, ...game.fonts },
|
||||||
|
layout: { ...DEFAULT_THEME.layout, ...game.layout },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"ver": "4.0.24",
|
||||||
|
"importer": "typescript",
|
||||||
|
"imported": true,
|
||||||
|
"uuid": "86f0162e-5db7-47c9-9fe0-a31d1e9dd643",
|
||||||
|
"files": [],
|
||||||
|
"subMetas": {},
|
||||||
|
"userData": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ResolvedTheme } from './types.ts';
|
||||||
|
|
||||||
|
let current: ResolvedTheme | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题持有者。皮肤在构建期即定死,运行时不变,故为静态读取、**不做响应式**(spec §4.5)。
|
||||||
|
* 子游戏在启动时调 init 注册自己的主题——框架不 import 子游戏(零耦合规则)。
|
||||||
|
*/
|
||||||
|
export const ThemeProvider = {
|
||||||
|
init(theme: ResolvedTheme): void {
|
||||||
|
current = theme;
|
||||||
|
},
|
||||||
|
get(): ResolvedTheme {
|
||||||
|
if (!current) {
|
||||||
|
throw new Error('ThemeProvider 未初始化:请在启动时调用 ThemeProvider.init(resolveTheme(theme))');
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
/** 仅供测试复位 */
|
||||||
|
reset(): void {
|
||||||
|
current = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"ver": "4.0.24",
|
||||||
|
"importer": "typescript",
|
||||||
|
"imported": true,
|
||||||
|
"uuid": "105e374f-7644-4114-9ced-5082cc129939",
|
||||||
|
"files": [],
|
||||||
|
"subMetas": {},
|
||||||
|
"userData": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 主题契约。见 spec `2026-08-27-ui-asset-and-skin-design.md` §4。
|
||||||
|
* 字段集不预先穷举——随框架 UI 抽象逐项浮现;加字段非破坏性,删字段/改语义为 breaking change。
|
||||||
|
*/
|
||||||
|
export interface ThemeColors {
|
||||||
|
primary: string;
|
||||||
|
accent: string;
|
||||||
|
textPrimary: string;
|
||||||
|
textSecondary: string;
|
||||||
|
disabled: string;
|
||||||
|
warning: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeFonts {
|
||||||
|
/** 字体资源逻辑路径;null = 用引擎默认字体 */
|
||||||
|
familyPath: string | null;
|
||||||
|
sizeTitle: number;
|
||||||
|
sizeBody: number;
|
||||||
|
sizeCaption: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeLayout {
|
||||||
|
/** 自己信息的对齐点 x(旧 Game_Config.Info.myPosition) */
|
||||||
|
myInfoAnchorX: number;
|
||||||
|
/** 主界面昵称最大字符数(旧 Game_Config.Info.Mainnickname) */
|
||||||
|
nicknameMaxChars: number;
|
||||||
|
/** 个人信息页昵称最大字符数(旧 Game_Config.Info.Infonickname) */
|
||||||
|
infoNicknameMaxChars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子游戏声明用:各组均可只写要改的字段 */
|
||||||
|
export interface ThemeConfig {
|
||||||
|
colors?: Partial<ThemeColors>;
|
||||||
|
fonts?: Partial<ThemeFonts>;
|
||||||
|
layout?: Partial<ThemeLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并后的完整主题,框架内部消费 */
|
||||||
|
export interface ResolvedTheme {
|
||||||
|
colors: ThemeColors;
|
||||||
|
fonts: ThemeFonts;
|
||||||
|
layout: ThemeLayout;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"ver": "4.0.24",
|
||||||
|
"importer": "typescript",
|
||||||
|
"imported": true,
|
||||||
|
"uuid": "3974f701-a0a3-4590-ba45-db688cf5a672",
|
||||||
|
"files": [],
|
||||||
|
"subMetas": {},
|
||||||
|
"userData": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DEFAULT_THEME } from '../../YouleNexus/assets/framework/ui/theme/default-theme.ts';
|
||||||
|
import { resolveTheme } from '../../YouleNexus/assets/framework/ui/theme/resolve-theme.ts';
|
||||||
|
import { ThemeProvider } from '../../YouleNexus/assets/framework/ui/theme/theme-provider.ts';
|
||||||
|
|
||||||
|
test('resolveTheme 不传配置时返回默认主题', () => {
|
||||||
|
const t = resolveTheme();
|
||||||
|
assert.deepEqual(t, DEFAULT_THEME);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTheme 只覆盖给出的字段,其余取默认', () => {
|
||||||
|
const t = resolveTheme({ colors: { primary: '#C8102E' } });
|
||||||
|
assert.equal(t.colors.primary, '#C8102E');
|
||||||
|
assert.equal(t.colors.accent, DEFAULT_THEME.colors.accent);
|
||||||
|
assert.equal(t.fonts.sizeBody, DEFAULT_THEME.fonts.sizeBody);
|
||||||
|
assert.equal(t.layout.myInfoAnchorX, DEFAULT_THEME.layout.myInfoAnchorX);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTheme 分组独立合并,互不影响', () => {
|
||||||
|
const t = resolveTheme({ layout: { myInfoAnchorX: 130 } });
|
||||||
|
assert.equal(t.layout.myInfoAnchorX, 130);
|
||||||
|
assert.equal(t.layout.nicknameMaxChars, DEFAULT_THEME.layout.nicknameMaxChars);
|
||||||
|
assert.deepEqual(t.colors, DEFAULT_THEME.colors);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTheme 不修改 DEFAULT_THEME 本身', () => {
|
||||||
|
const before = DEFAULT_THEME.colors.primary;
|
||||||
|
resolveTheme({ colors: { primary: '#000000' } });
|
||||||
|
assert.equal(DEFAULT_THEME.colors.primary, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ThemeProvider 未 init 就读取会抛错(第二准则:不兜底)', () => {
|
||||||
|
ThemeProvider.reset();
|
||||||
|
assert.throws(() => ThemeProvider.get(), /未初始化/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ThemeProvider init 后可读回同一份主题', () => {
|
||||||
|
ThemeProvider.reset();
|
||||||
|
const t = resolveTheme({ colors: { primary: '#123456' } });
|
||||||
|
ThemeProvider.init(t);
|
||||||
|
assert.equal(ThemeProvider.get().colors.primary, '#123456');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user