Files
moteva/frontend/src/domain/brandKit.ts
T
root 2df9ae6581 feat(frontend): add brand kit management UI and project binding
Add a brand kit domain model, repository, and dedicated page/route for
managing brand kits, plus a BrandKitSelector used on the home page and
canvas workspace. Home project creation and the workspace can attach a
brand kit to a project, with new i18n strings and a side-nav entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:59:05 +08:00

505 lines
21 KiB
TypeScript

export type BrandKitLogoVariant = "primary" | "secondary" | "mark";
export type BrandKitLogo = {
id: string;
name: string;
variant: BrandKitLogoVariant;
usage: string;
source: "upload" | "moteva-mark";
dataUrl?: string;
};
export type BrandKitColor = {
id: string;
name: string;
hex: string;
usage: string;
};
export type BrandKitColorGroup = {
id: string;
name: string;
colors: BrandKitColor[];
};
export type BrandKitReferenceImage = {
id: string;
name: string;
description: string;
source: "upload" | "moteva-board";
dataUrl?: string;
};
export type BrandKitFontFile = {
name: string;
format: string;
size: number;
dataUrl: string;
};
export type BrandKitTypography = {
id: string;
label: string;
family: string;
weight: string;
sizes: string;
description: string;
fontFile?: BrandKitFontFile;
};
export type BrandKit = {
version: 1;
id: string;
name: string;
brandName: string;
tagline: string;
summary: string;
audience: string;
positioning: string;
personality: string;
voice: {
traits: string;
guidance: string;
sample: string;
};
logos: BrandKitLogo[];
coverImage?: BrandKitReferenceImage;
colorGroups: BrandKitColorGroup[];
typography: BrandKitTypography[];
referenceImages: BrandKitReferenceImage[];
visual: {
keywords: string;
composition: string;
imagery: string;
dos: string;
donts: string;
prompt: string;
};
applyToNewProjects: boolean;
createdAt: string;
updatedAt: string;
};
export type BrandKitLocale = "zh-CN" | "en-US";
export function createBrandKit(name: string, locale: BrandKitLocale = "zh-CN"): BrandKit {
const now = new Date().toISOString();
return {
version: 1,
id: createBrandKitId("brand-kit"),
name,
brandName: "",
tagline: "",
summary: "",
audience: "",
positioning: "",
personality: "",
voice: {
traits: "",
guidance: "",
sample: ""
},
logos: [],
coverImage: undefined,
colorGroups: createDefaultColorGroups(locale),
typography: createDefaultTypography(locale),
referenceImages: [],
visual: {
keywords: "",
composition: "",
imagery: "",
dos: "",
donts: "",
prompt: ""
},
applyToNewProjects: false,
createdAt: now,
updatedAt: now
};
}
export function createMotevaBrandKitExample(locale: BrandKitLocale): BrandKit {
const kit = createBrandKit(locale === "zh-CN" ? "Moteva 核心品牌" : "Moteva Core Brand", locale);
const chinese = locale === "zh-CN";
return {
...kit,
id: "moteva-brand-kit-example",
brandName: "Moteva",
tagline: chinese ? "让设计进入流动状态" : "Keep every idea in motion",
summary: chinese
? "Moteva 是面向创意团队与独立创作者的 AI 设计伙伴,把模糊想法转化为清晰、可继续编辑的视觉成果。"
: "Moteva is an AI design partner for creative teams and independent makers, turning early ideas into clear, editable visual work.",
audience: chinese
? "需要快速探索方向、同时重视品牌一致性的设计师、市场团队与创业者。"
: "Designers, marketing teams, and founders who need to explore quickly without losing brand consistency.",
positioning: chinese
? "不是一次性的图片生成器,而是能够理解上下文、延续判断并共同完成设计的工作空间。"
: "Not a one-off image generator, but a workspace that understands context, carries decisions forward, and helps finish the design.",
personality: chinese ? "清晰、敏锐、克制、协作、持续进化" : "Clear, perceptive, restrained, collaborative, evolving",
voice: {
traits: chinese ? "直接但不生硬;专业但不炫技;主动给出下一步" : "Direct, never abrupt; expert, never showy; always offers a useful next step",
guidance: chinese
? "先说结论,再补充判断依据。使用具体动词和可执行建议,避免空泛口号、夸张承诺与过度技术化表达。"
: "Lead with the outcome, then add the reasoning. Use concrete verbs and actionable guidance. Avoid vague slogans, inflated claims, and needless technical language.",
sample: chinese ? "把复杂的设计问题,变成下一步可执行的选择。" : "Turn a complex design problem into the next clear decision."
},
logos: [
{
id: "moteva-example-logo",
name: chinese ? "Moteva 主标志" : "Moteva primary mark",
variant: "primary",
usage: chinese ? "用于产品、品牌页与浅色背景;四周保留一个标志宽度的安全空间。" : "Use across product and brand surfaces on light backgrounds. Keep one mark-width of clear space.",
source: "moteva-mark"
}
],
coverImage: {
id: "moteva-example-cover",
name: chinese ? "Moteva 品牌封面" : "Moteva brand cover",
description: chinese ? "用于品牌套件列表的识别封面。" : "Recognition cover for the Brand Kit library.",
source: "moteva-board"
},
colorGroups: [
createColorGroup("primary", chinese ? "主色" : "Primary", [
createColor("ink", chinese ? "墨黑" : "Ink", "#0B0B0C", chinese ? "主文字、品牌标志、关键动作" : "Primary text, brand mark, and key actions")
]),
createColorGroup("secondary", chinese ? "辅助色" : "Secondary", [
createColor("action", chinese ? "行动蓝" : "Action Blue", "#2F66F6", chinese ? "链接、焦点与高价值交互" : "Links, focus, and high-value interactions")
]),
createColorGroup("accent", chinese ? "强调色" : "Accent", [
createColor("signal", chinese ? "信号青柠" : "Signal Lime", "#DDFB70", chinese ? "智能能力、完成状态与少量强调" : "Intelligence cues, completion, and sparing emphasis")
]),
createColorGroup("neutral", chinese ? "中性色" : "Neutral", [
createColor("paper", chinese ? "纸白" : "Paper", "#F7F7F5", chinese ? "画布、留白与安静背景" : "Canvas, negative space, and quiet backgrounds"),
createColor("soft-gray", chinese ? "雾灰" : "Mist", "#E8E9E5", chinese ? "分隔、次级表面与安静层级" : "Dividers, secondary surfaces, and quiet hierarchy")
])
],
typography: [
createTypography(chinese ? "标题字体" : "Heading type", "Avenir Next", "700", "28 / 40 / 56", chinese ? "标题与关键结论,使用紧凑层级和充足留白。" : "Headlines and key conclusions with compact hierarchy and generous space."),
createTypography(chinese ? "正文字体" : "Body type", "Inter", "500", "12 / 14 / 16 / 20", chinese ? "产品界面、正文与说明,优先保证清晰阅读。" : "Product UI, body copy, and guidance with clarity first."),
createTypography(chinese ? "强调字体" : "Accent type", "IBM Plex Mono", "500", "11 / 12", chinese ? "模型名称、参数与系统状态,少量使用。" : "Model names, parameters, and system states, used sparingly.")
],
referenceImages: [
{
id: "moteva-example-reference",
name: chinese ? "Moteva 产品视觉" : "Moteva product visual",
description: chinese
? "高留白的真实设计画布;黑白作为结构,信号青柠只标记智能能力和关键状态。"
: "A spacious real design canvas. Black and white provide structure; signal lime marks intelligence and key states only.",
source: "moteva-board"
}
],
visual: {
keywords: chinese ? "精准、流动、克制、技术感、人性化" : "Precise, fluid, restrained, technical, human",
composition: chinese
? "用一个明确主视觉建立重心;辅助信息按严格网格排列。先用尺度、留白和对齐创造层级,再考虑装饰。"
: "Build focus around one decisive visual moment. Arrange supporting information on a disciplined grid. Create hierarchy with scale, space, and alignment before decoration.",
imagery: chinese
? "展示真实设计成果和可检查的细节。图像保持清晰、自然、具有方向性;避免只提供氛围而无法看清主体。"
: "Show real design outcomes and inspectable details. Imagery should feel clear, natural, and directed, never atmospheric at the expense of the subject.",
dos: chinese
? "使用黑白基底;把信号青柠用于智能能力提示;保持内容优先;让动效解释状态变化。"
: "Use a black-and-white foundation; reserve signal lime for intelligence cues; keep content first; let motion explain state changes.",
donts: chinese
? "避免装饰性渐变、漂浮光球、泛化图库人物、过圆卡片和无意义的营销式大标题。不要用颜色替代清晰的信息层级。"
: "Avoid decorative gradients, floating glow shapes, generic stock people, overly rounded cards, and empty marketing-scale headlines. Do not use color in place of hierarchy.",
prompt: chinese
? "为 Moteva 创建设计:以清晰、克制、持续流动为核心。使用墨黑与纸白作为基底,信号青柠仅作少量智能提示;突出一个主视觉,以严格网格、充足留白和可检查的真实细节保持专业感。"
: "Create a design for Moteva around clarity, restraint, and continuous motion. Use ink and paper as the foundation with signal lime only for intelligence cues. Establish one visual focus, then use a disciplined grid, generous space, and inspectable real detail."
},
applyToNewProjects: true
};
}
export function cloneBrandKit(source: BrandKit, name: string): BrandKit {
const now = new Date().toISOString();
return {
...source,
id: createBrandKitId("brand-kit"),
name,
logos: source.logos.map((logo) => ({ ...logo, id: createBrandKitId("logo") })),
coverImage: source.coverImage ? { ...source.coverImage, id: createBrandKitId("cover") } : undefined,
colorGroups: source.colorGroups.map((group) => ({
...group,
id: createBrandKitId("color-group"),
colors: group.colors.map((color) => ({ ...color, id: createBrandKitId("color") }))
})),
typography: source.typography.map((item) => ({ ...item, id: createBrandKitId("type"), fontFile: item.fontFile ? { ...item.fontFile } : undefined })),
referenceImages: source.referenceImages.map((image) => ({ ...image, id: createBrandKitId("reference") })),
applyToNewProjects: false,
createdAt: now,
updatedAt: now
};
}
export function brandKitCompletion(kit: BrandKit) {
const foundations = [kit.brandName, kit.summary, kit.audience, kit.positioning, kit.personality].filter(hasText).length / 5;
const voice = [kit.voice.traits, kit.voice.guidance, kit.voice.sample].filter(hasText).length / 3;
const logo = kit.logos.length > 0 ? 1 : 0;
const colors = Math.min(brandKitColors(kit).length / 4, 1);
const typography = Math.min(kit.typography.filter((item) => hasText(item.family) || Boolean(item.fontFile)).length / 3, 1);
const visual = [kit.visual.keywords, kit.visual.composition, kit.visual.imagery, kit.visual.dos, kit.visual.donts, kit.visual.prompt].filter(hasText).length / 6;
return Math.round((foundations * 0.28 + voice * 0.13 + logo * 0.13 + colors * 0.14 + typography * 0.14 + visual * 0.18) * 100);
}
export function brandKitPrompt(kit: BrandKit) {
const sections = [
promptSection("Brand", [kit.brandName, kit.tagline, kit.summary]),
promptSection("Audience", [kit.audience]),
promptSection("Positioning and personality", [kit.positioning, kit.personality]),
promptSection("Voice", [kit.voice.traits, kit.voice.guidance, kit.voice.sample]),
promptSection(
"Logo rules",
kit.logos.map((logo) => [logo.name, logo.usage].filter(hasText).join(": "))
),
promptSection("Brand cover", kit.coverImage ? [kit.coverImage.name, kit.coverImage.description] : []),
promptSection(
"Color system",
kit.colorGroups.flatMap((group) => group.colors.map((color) => `${group.name} / ${color.name || "Color"} ${color.hex}${color.usage ? `: ${color.usage}` : ""}`))
),
promptSection(
"Typography",
kit.typography.map((item) => [item.label, item.family, item.fontFile?.name ?? "", item.weight, item.sizes, item.description].filter(hasText).join(" | "))
),
promptSection(
"Reference images",
kit.referenceImages.map((image) => [image.name, image.description].filter(hasText).join(": "))
),
promptSection("Visual direction", [kit.visual.keywords, kit.visual.composition, kit.visual.imagery]),
promptSection("Use", [kit.visual.dos]),
promptSection("Avoid", [kit.visual.donts]),
promptSection("Reusable direction", [kit.visual.prompt])
].filter(hasText);
return [`Use this Brand Kit as a binding creative system for the project: ${kit.name}.`, ...sections].join("\n\n");
}
export function createBrandKitColor(): BrandKitColor {
return createColor(createBrandKitId("color"), "", "#111111", "");
}
export function createBrandKitColorGroup(name: string): BrandKitColorGroup {
return createColorGroup(createBrandKitId("color-group"), name, []);
}
export function createBrandKitTypography(label: string): BrandKitTypography {
return createTypography(label);
}
export function createBrandKitLogo(file: File, dataUrl: string): BrandKitLogo {
return {
id: createBrandKitId("logo"),
name: file.name.replace(/\.[^.]+$/, ""),
variant: "primary",
usage: "",
source: "upload",
dataUrl
};
}
export function createBrandKitFontFile(file: File, dataUrl: string): BrandKitFontFile {
const extension = file.name.split(".").pop()?.toUpperCase() || "FONT";
return {
name: file.name,
format: extension,
size: file.size,
dataUrl
};
}
export function createBrandKitReferenceImage(file: File, dataUrl: string): BrandKitReferenceImage {
return {
id: createBrandKitId("reference"),
name: file.name.replace(/\.[^.]+$/, ""),
description: "",
source: "upload",
dataUrl
};
}
export function withBrandKitDefaults(kit: BrandKit): BrandKit {
const referenceImages = (kit as BrandKit & { referenceImages?: unknown }).referenceImages;
const coverImage = (kit as BrandKit & { coverImage?: unknown }).coverImage;
const raw = kit as BrandKit & { colorGroups?: unknown; colors?: unknown };
const colorGroups = Array.isArray(raw.colorGroups)
? (raw.colorGroups as BrandKitColorGroup[])
: Array.isArray(raw.colors) && raw.colors.length > 0
? [createColorGroup("migrated-colors", "Colors", raw.colors as BrandKitColor[])]
: [];
return {
...kit,
coverImage: coverImage && typeof coverImage === "object" ? (coverImage as BrandKitReferenceImage) : undefined,
colorGroups,
typography: kit.typography.map(normalizeTypography),
referenceImages: Array.isArray(referenceImages) ? (referenceImages as BrandKitReferenceImage[]) : [],
applyToNewProjects: kit.applyToNewProjects === true
};
}
export function brandKitColors(kit: BrandKit) {
return kit.colorGroups.flatMap((group) => group.colors);
}
export function isBrandKit(value: unknown): value is BrandKit {
if (!isRecord(value)) return false;
const candidate = value as Partial<BrandKit>;
const voice = candidate.voice;
const visual = candidate.visual;
const legacyColors = (candidate as Partial<BrandKit> & { colors?: unknown }).colors;
return (
candidate.version === 1 &&
typeof candidate.id === "string" &&
typeof candidate.name === "string" &&
typeof candidate.brandName === "string" &&
typeof candidate.tagline === "string" &&
typeof candidate.summary === "string" &&
typeof candidate.audience === "string" &&
typeof candidate.positioning === "string" &&
typeof candidate.personality === "string" &&
isRecord(voice) &&
typeof voice.traits === "string" &&
typeof voice.guidance === "string" &&
typeof voice.sample === "string" &&
Array.isArray(candidate.logos) && candidate.logos.every(isStoredLogo) &&
(candidate.coverImage === undefined || isStoredReferenceImage(candidate.coverImage)) &&
(Array.isArray(candidate.colorGroups) ? candidate.colorGroups.every(isStoredColorGroup) : Array.isArray(legacyColors) && legacyColors.every(isStoredColor)) &&
Array.isArray(candidate.typography) && candidate.typography.every(isStoredTypography) &&
(candidate.referenceImages === undefined || (Array.isArray(candidate.referenceImages) && candidate.referenceImages.every(isStoredReferenceImage))) &&
isRecord(visual) &&
typeof visual.keywords === "string" &&
typeof visual.composition === "string" &&
typeof visual.imagery === "string" &&
typeof visual.dos === "string" &&
typeof visual.donts === "string" &&
typeof visual.prompt === "string" &&
typeof candidate.createdAt === "string" &&
typeof candidate.updatedAt === "string"
);
}
function isStoredLogo(value: unknown) {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.usage === "string" &&
(value.variant === "primary" || value.variant === "secondary" || value.variant === "mark") &&
(value.source === "upload" || value.source === "moteva-mark") &&
(value.dataUrl === undefined || typeof value.dataUrl === "string")
);
}
function isStoredColorGroup(value: unknown) {
return isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && Array.isArray(value.colors) && value.colors.every(isStoredColor);
}
function isStoredColor(value: unknown) {
return isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && typeof value.hex === "string" && typeof value.usage === "string";
}
function isStoredTypography(value: unknown) {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.family === "string" &&
typeof value.weight === "string" &&
(value.label === undefined || typeof value.label === "string") &&
(value.sizes === undefined || typeof value.sizes === "string") &&
(value.description === undefined || typeof value.description === "string") &&
(value.fontFile === undefined || isStoredFontFile(value.fontFile))
);
}
function isStoredReferenceImage(value: unknown) {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.description === "string" &&
(value.source === "upload" || value.source === "moteva-board") &&
(value.dataUrl === undefined || typeof value.dataUrl === "string")
);
}
function isStoredFontFile(value: unknown) {
return (
isRecord(value) &&
typeof value.name === "string" &&
typeof value.format === "string" &&
typeof value.size === "number" &&
Number.isFinite(value.size) &&
value.size >= 0 &&
typeof value.dataUrl === "string"
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object";
}
function createBrandKitId(prefix: string) {
const random = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}-${random}`;
}
function createColor(id: string, name: string, hex: string, usage: string): BrandKitColor {
return { id, name, hex, usage };
}
function createColorGroup(id: string, name: string, colors: BrandKitColor[]): BrandKitColorGroup {
return { id, name, colors };
}
function createTypography(label: string, family = "", weight = "", sizes = "", description = ""): BrandKitTypography {
return {
id: createBrandKitId("type"),
label,
family,
weight,
sizes,
description
};
}
function createDefaultColorGroups(locale: BrandKitLocale) {
const chinese = locale === "zh-CN";
return [
createBrandKitColorGroup(chinese ? "主色" : "Primary"),
createBrandKitColorGroup(chinese ? "辅助色" : "Secondary"),
createBrandKitColorGroup(chinese ? "强调色" : "Accent"),
createBrandKitColorGroup(chinese ? "中性色" : "Neutral")
];
}
function createDefaultTypography(locale: BrandKitLocale) {
const chinese = locale === "zh-CN";
return [
createBrandKitTypography(chinese ? "标题字体" : "Heading type"),
createBrandKitTypography(chinese ? "正文字体" : "Body type"),
createBrandKitTypography(chinese ? "强调字体" : "Accent type")
];
}
function normalizeTypography(item: BrandKitTypography) {
const legacy = item as BrandKitTypography & { role?: string; usage?: string; sizes?: string; description?: string; label?: string };
const fallbackLabels: Record<string, string> = {
heading: "Heading type",
body: "Body type",
accent: "Accent type"
};
return {
...item,
label: legacy.label || fallbackLabels[legacy.role ?? ""] || "Typography",
sizes: legacy.sizes || "",
description: legacy.description || legacy.usage || ""
};
}
function promptSection(title: string, values: string[]) {
const content = values.map((value) => value.trim()).filter(Boolean);
return content.length > 0 ? `${title}:\n${content.join("\n")}` : "";
}
function hasText(value: string) {
return value.trim().length > 0;
}