diff --git a/apps/mesh/migrations/066-brand-context-structured.ts b/apps/mesh/migrations/066-brand-context-structured.ts new file mode 100644 index 0000000000..0784adfac3 --- /dev/null +++ b/apps/mesh/migrations/066-brand-context-structured.ts @@ -0,0 +1,217 @@ +/** + * Migration 065: Restructure brand_context colors and fonts + * + * Transforms legacy JSON shapes into structured semantic objects: + * - colors: [{label:"primary",value:"#fff"},...] → {"primary":"#fff",...} + * - fonts: [{name:"Inter",role:"heading"},...] → {"heading":"Inter",...} + * + * Idempotent — skips rows that are already in the new format. + */ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +const COLOR_ROLES = new Set([ + "primary", + "secondary", + "accent", + "background", + "foreground", +]); + +const FONT_ROLE_MAP: Record = { + heading: "heading", + headings: "heading", + head: "heading", + title: "heading", + body: "body", + primary: "body", + text: "body", + code: "code", + monospace: "code", + mono: "code", +}; + +function transformColors(raw: unknown): { + structured: string | null; + extra: Record; +} { + const extra: Record = {}; + if (!raw) return { structured: null, extra }; + + // Already structured object with known keys + if (!Array.isArray(raw) && typeof raw === "object") { + const obj = raw as Record; + if (Object.keys(obj).some((k) => COLOR_ROLES.has(k))) { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (typeof value !== "string") continue; + if (COLOR_ROLES.has(key)) { + result[key] = value; + } else { + extra[key] = value; + } + } + return { + structured: + Object.keys(result).length > 0 ? JSON.stringify(result) : null, + extra, + }; + } + // Legacy Record + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (typeof value !== "string") continue; + if (COLOR_ROLES.has(key.toLowerCase())) { + result[key.toLowerCase()] = value; + } else { + extra[key] = value; + } + } + return { + structured: + Object.keys(result).length > 0 ? JSON.stringify(result) : null, + extra, + }; + } + + // Legacy array + if (Array.isArray(raw)) { + const result: Record = {}; + for (const item of raw) { + const entry = item as Record; + const label = (entry.label as string)?.toLowerCase?.(); + const value = entry.value as string; + if (!label || !value) continue; + if (COLOR_ROLES.has(label)) { + result[label] = value; + } else { + extra[label] = value; + } + } + return { + structured: + Object.keys(result).length > 0 ? JSON.stringify(result) : null, + extra, + }; + } + + return { structured: null, extra }; +} + +function transformFonts(raw: unknown): string | null { + if (!raw) return null; + + // Already structured + if (!Array.isArray(raw) && typeof raw === "object") { + const obj = raw as Record; + if ( + typeof obj.heading === "string" || + typeof obj.body === "string" || + typeof obj.code === "string" + ) { + return JSON.stringify(obj); + } + return null; + } + + // Legacy array — two-pass: first assign explicitly mapped roles, + // then fill remaining slots with unmapped entries + if (Array.isArray(raw)) { + const result: Record = {}; + const unmapped: string[] = []; + for (const item of raw) { + const entry = item as Record; + const name = + (entry.name as string) ?? (entry.family as string) ?? undefined; + const role = (entry.role as string)?.toLowerCase?.() ?? ""; + if (!name) continue; + const mapped = FONT_ROLE_MAP[role]; + if (mapped && !result[mapped]) { + result[mapped] = name; + } else { + unmapped.push(name); + } + } + // Fill body slot with first unmapped font if not already assigned + if (!result.body && unmapped[0]) { + result.body = unmapped[0]; + } + return Object.keys(result).length > 0 ? JSON.stringify(result) : null; + } + + return null; +} + +export async function up(db: Kysely): Promise { + const rows = await sql<{ + id: string; + colors: string | null; + fonts: string | null; + metadata: string | null; + }>`SELECT id, colors, fonts, metadata FROM brand_context`.execute(db); + + for (const row of rows.rows) { + let colorsChanged = false; + let fontsChanged = false; + let metadataChanged = false; + let newColors: string | null = row.colors; + let newFonts: string | null = row.fonts; + let metadata: Record = {}; + + try { + metadata = row.metadata ? JSON.parse(row.metadata) : {}; + } catch { + // skip unparseable metadata + } + + if (row.colors) { + try { + const parsed = JSON.parse(row.colors); + // Only transform if it's an array (legacy format) + if (Array.isArray(parsed)) { + const result = transformColors(parsed); + newColors = result.structured; + colorsChanged = true; + // Preserve unmapped colors in metadata so no data is lost + if (Object.keys(result.extra).length > 0) { + metadata.extraColors = result.extra; + metadataChanged = true; + } + } + } catch { + // skip unparseable + } + } + + if (row.fonts) { + try { + const parsed = JSON.parse(row.fonts); + if (Array.isArray(parsed)) { + newFonts = transformFonts(parsed); + fontsChanged = true; + } + } catch { + // skip unparseable + } + } + + if (colorsChanged || fontsChanged || metadataChanged) { + const newMetadata = metadataChanged + ? JSON.stringify(metadata) + : row.metadata; + await sql` + UPDATE brand_context + SET + colors = ${newColors}, + fonts = ${newFonts}, + metadata = ${newMetadata}, + updated_at = NOW() + WHERE id = ${row.id} + `.execute(db); + } + } +} + +export async function down(_db: Kysely): Promise { + // Data transformation is not reversible — old format data is lost +} diff --git a/apps/mesh/migrations/index.ts b/apps/mesh/migrations/index.ts index f26577823a..3abca1df73 100644 --- a/apps/mesh/migrations/index.ts +++ b/apps/mesh/migrations/index.ts @@ -64,6 +64,7 @@ import * as migration062privateregistry from "./062-private-registry.ts"; import * as migration063eventsubscriptionsenabledboolean from "./063-event-subscriptions-enabled-boolean.ts"; import * as migration064brandcontext from "./064-brand-context.ts"; import * as migration065organizationdomains from "./065-organization-domains.ts"; +import * as migration066brandcontextstructured from "./066-brand-context-structured.ts"; /** * Core migrations for the Mesh application. @@ -142,6 +143,7 @@ const migrations: Record = { migration063eventsubscriptionsenabledboolean, "064-brand-context": migration064brandcontext, "065-organization-domains": migration065organizationdomains, + "066-brand-context-structured": migration066brandcontextstructured, }; export default migrations; diff --git a/apps/mesh/src/auth/extract-brand.ts b/apps/mesh/src/auth/extract-brand.ts index 93d996f2b0..3166aea5cc 100644 --- a/apps/mesh/src/auth/extract-brand.ts +++ b/apps/mesh/src/auth/extract-brand.ts @@ -10,8 +10,18 @@ export interface ExtractedBrand { logo: string | null; favicon: string | null; ogImage: string | null; - fonts: { name: string; role: string }[] | null; - colors: { label: string; value: string }[] | null; + fonts: { + heading?: string; + body?: string; + code?: string; + } | null; + colors: { + primary?: string; + secondary?: string; + accent?: string; + background?: string; + foreground?: string; + } | null; images: null; metadata: Record | null; } @@ -83,8 +93,8 @@ export async function extractBrandFromDomain( logo: mapped.logo, favicon: mapped.favicon, ogImage: mapped.ogImage, - fonts: mapped.fonts.length > 0 ? mapped.fonts : null, - colors: mapped.colors.length > 0 ? mapped.colors : null, + fonts: mapped.fonts, + colors: mapped.colors, images: null, metadata: Object.keys(mapped.metadata).length > 0 ? mapped.metadata : null, }; @@ -94,6 +104,27 @@ export async function extractBrandFromDomain( // Internal helpers // ============================================================================ +const COLOR_ROLES = new Set([ + "primary", + "secondary", + "accent", + "background", + "foreground", +]); + +const FONT_ROLE_MAP: Record = { + heading: "heading", + headings: "heading", + head: "heading", + title: "heading", + body: "body", + primary: "body", + text: "body", + code: "code", + monospace: "code", + mono: "code", +}; + function mapFirecrawlBranding( branding: Record, metadata: Record, @@ -101,46 +132,45 @@ function mapFirecrawlBranding( logo: string | null; favicon: string | null; ogImage: string | null; - fonts: { name: string; role: string }[]; - colors: { label: string; value: string }[]; + fonts: ExtractedBrand["fonts"]; + colors: ExtractedBrand["colors"]; metadata: Record; } { const images = (branding.images ?? {}) as Record; + // Colors: pick known semantic roles from branding.colors const rawColors = (branding.colors ?? {}) as Record; - const colors: { label: string; value: string }[] = []; - for (const [label, value] of Object.entries(rawColors)) { - if (typeof value === "string" && value) { - colors.push({ label, value }); + const colors: Record = {}; + for (const [key, value] of Object.entries(rawColors)) { + if (typeof value === "string" && value && COLOR_ROLES.has(key)) { + colors[key] = value; } } - const fonts: { name: string; role: string }[] = []; + // Fonts: map fontFamilies roles to semantic roles + const fonts: Record = {}; const typography = (branding.typography ?? {}) as Record; const fontFamilies = (typography.fontFamilies ?? {}) as Record< string, unknown >; - const seenFamilies = new Set(); for (const [role, family] of Object.entries(fontFamilies)) { if (typeof family === "string" && family) { - fonts.push({ name: family, role }); - seenFamilies.add(family.toLowerCase()); + const mapped = FONT_ROLE_MAP[role.toLowerCase()]; + if (mapped && !fonts[mapped]) { + fonts[mapped] = family; + } } } + // Fallback: additional fonts from the fonts array const rawFonts = branding.fonts; if (Array.isArray(rawFonts)) { for (const f of rawFonts) { const family = (f as Record).family; - if ( - typeof family === "string" && - family && - !seenFamilies.has(family.toLowerCase()) - ) { - fonts.push({ name: family, role: "" }); - seenFamilies.add(family.toLowerCase()); + if (typeof family === "string" && family && !fonts.body) { + fonts.body = family; } } } @@ -166,8 +196,12 @@ function mapFirecrawlBranding( logo: (images.logo as string) ?? null, favicon: (images.favicon as string) ?? null, ogImage: (images.ogImage as string) ?? (metadata.ogImage as string) ?? null, - fonts, - colors, + fonts: + Object.keys(fonts).length > 0 ? (fonts as ExtractedBrand["fonts"]) : null, + colors: + Object.keys(colors).length > 0 + ? (colors as ExtractedBrand["colors"]) + : null, metadata: richMetadata, }; } diff --git a/apps/mesh/src/storage/brand-context.ts b/apps/mesh/src/storage/brand-context.ts index fdf22304e9..0a095cc065 100644 --- a/apps/mesh/src/storage/brand-context.ts +++ b/apps/mesh/src/storage/brand-context.ts @@ -11,6 +11,123 @@ function parseJson(value: string | null): T | null { } } +const COLOR_ROLES = new Set([ + "primary", + "secondary", + "accent", + "background", + "foreground", +]); + +/** + * Normalize colors from legacy formats to structured semantic object. + * Legacy formats: [{label:"primary",value:"#fff"},...] or {"primary":"#fff",...} + */ +function normalizeColors(raw: unknown): BrandContext["colors"] { + if (!raw) return null; + // Already structured: { primary?: string, ... } + if (!Array.isArray(raw) && typeof raw === "object") { + const obj = raw as Record; + // Check if it looks structured (has known color role keys, not label/value) + if (Object.keys(obj).some((k) => COLOR_ROLES.has(k))) { + const result: Record = {}; + for (const role of COLOR_ROLES) { + if (typeof obj[role] === "string") result[role] = obj[role] as string; + } + return result as BrandContext["colors"]; + } + // Legacy Record — try to map keys to roles + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "string" && COLOR_ROLES.has(key.toLowerCase())) { + result[key.toLowerCase()] = value; + } + } + return Object.keys(result).length > 0 + ? (result as BrandContext["colors"]) + : null; + } + // Legacy array: [{label:"primary",value:"#fff"},...] + if (Array.isArray(raw)) { + const result: Record = {}; + for (const item of raw) { + const entry = item as Record; + const label = (entry.label as string)?.toLowerCase?.(); + const value = entry.value as string; + if (label && value && COLOR_ROLES.has(label)) { + result[label] = value; + } + } + return Object.keys(result).length > 0 + ? (result as BrandContext["colors"]) + : null; + } + return null; +} + +const FONT_ROLE_MAP: Record = { + heading: "heading", + headings: "heading", + head: "heading", + title: "heading", + body: "body", + primary: "body", + text: "body", + code: "code", + monospace: "code", + mono: "code", +}; + +/** + * Normalize fonts from legacy formats to structured semantic object. + * Legacy format: [{name:"Inter",role:"heading"},...] + */ +function normalizeFonts(raw: unknown): BrandContext["fonts"] { + if (!raw) return null; + // Already structured: { heading?: string, body?: string, code?: string } + if (!Array.isArray(raw) && typeof raw === "object") { + const obj = raw as Record; + if ( + typeof obj.heading === "string" || + typeof obj.body === "string" || + typeof obj.code === "string" + ) { + return { + heading: typeof obj.heading === "string" ? obj.heading : undefined, + body: typeof obj.body === "string" ? obj.body : undefined, + code: typeof obj.code === "string" ? obj.code : undefined, + }; + } + return null; + } + // Legacy array: [{name:"Inter",role:"heading"},...] + // Two-pass: first assign explicitly mapped roles, then fill body with unmapped + if (Array.isArray(raw)) { + const result: Record = {}; + const unmapped: string[] = []; + for (const item of raw) { + const entry = item as Record; + const name = + (entry.name as string) ?? (entry.family as string) ?? undefined; + const role = (entry.role as string)?.toLowerCase?.() ?? ""; + if (!name) continue; + const mapped = FONT_ROLE_MAP[role]; + if (mapped && !result[mapped]) { + result[mapped] = name; + } else { + unmapped.push(name); + } + } + if (!result.body && unmapped[0]) { + result.body = unmapped[0]; + } + return Object.keys(result).length > 0 + ? (result as BrandContext["fonts"]) + : null; + } + return null; +} + function toEntity( record: Record & { id: string; @@ -40,8 +157,8 @@ function toEntity( logo: record.logo, favicon: record.favicon, ogImage: record.og_image, - fonts: parseJson(record.fonts), - colors: parseJson(record.colors), + fonts: normalizeFonts(parseJson(record.fonts)), + colors: normalizeColors(parseJson(record.colors)), images: parseJson(record.images), metadata: parseJson(record.metadata), archivedAt: record.archived_at, diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index 00c535c72e..11a75e5525 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -987,8 +987,18 @@ export interface BrandContext { logo: string | null; favicon: string | null; ogImage: string | null; - fonts: Record[] | null; - colors: Record[] | Record | null; + fonts: { + heading?: string; + body?: string; + code?: string; + } | null; + colors: { + primary?: string; + secondary?: string; + accent?: string; + background?: string; + foreground?: string; + } | null; images: Record[] | null; metadata: Record | null; archivedAt: Date | string | null; diff --git a/apps/mesh/src/tools/connection/list.ts b/apps/mesh/src/tools/connection/list.ts index a3e1775f05..c4793062c0 100644 --- a/apps/mesh/src/tools/connection/list.ts +++ b/apps/mesh/src/tools/connection/list.ts @@ -10,6 +10,7 @@ import { createBindingChecker, EVENT_BUS_BINDING, TRIGGER_BINDING, + BRAND_BINDING, } from "@decocms/bindings"; import { ASSISTANTS_BINDING } from "@decocms/bindings/assistant"; import { @@ -64,6 +65,7 @@ const BUILTIN_BINDING_CHECKERS: Record = { EVENT_BUS: EVENT_BUS_BINDING, TRIGGER: TRIGGER_BINDING, REGISTRY: REGISTRY_BINDING, + BRAND: BRAND_BINDING, }; /** diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index f0b21e5a70..8a82f65187 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -49,6 +49,8 @@ const CORE_TOOLS = [ OrganizationTools.BRAND_CONTEXT_UPDATE, OrganizationTools.BRAND_CONTEXT_DELETE, OrganizationTools.BRAND_CONTEXT_EXTRACT, + OrganizationTools.BRAND_GET, + OrganizationTools.BRAND_LIST, OrganizationTools.ORGANIZATION_DOMAIN_GET, OrganizationTools.ORGANIZATION_DOMAIN_SET, OrganizationTools.ORGANIZATION_DOMAIN_UPDATE, @@ -299,29 +301,22 @@ export const managementMCP = async (ctx: MeshContext) => { ]; if (brand.colors) { - // Colors can be {label,value}[] (UI) or Record (legacy) - const colorEntries = Array.isArray(brand.colors) - ? (brand.colors as { label?: string; value?: string }[]) - .filter((c) => c.label || c.value) - .map((c) => [c.label ?? "", c.value ?? ""] as const) - : Object.entries(brand.colors); + const colorEntries = Object.entries(brand.colors).filter(([, v]) => v); if (colorEntries.length > 0) { lines.push("", "## Colors"); - for (const [label, value] of colorEntries) { - lines.push(`- **${label}:** ${value}`); + for (const [role, value] of colorEntries) { + lines.push(`- **${role}:** ${value}`); } } } - if (brand.fonts && brand.fonts.length > 0) { - lines.push("", "## Fonts"); - for (const font of brand.fonts) { - // Fonts can be {name,role} (UI) or {family,weight,style} (legacy) - const f = font as Record; - const label = f.name ?? f.family ?? ""; - const detail = - f.role ?? [f.weight, f.style].filter(Boolean).join(" "); - lines.push(`- ${label}${detail ? ` (${detail})` : ""}`); + if (brand.fonts) { + const fontEntries = Object.entries(brand.fonts).filter(([, v]) => v); + if (fontEntries.length > 0) { + lines.push("", "## Fonts"); + for (const [role, family] of fontEntries) { + lines.push(`- ${family} (${role})`); + } } } diff --git a/apps/mesh/src/tools/organization/brand-context-update.ts b/apps/mesh/src/tools/organization/brand-context-update.ts index 031c967a0c..405a80fbee 100644 --- a/apps/mesh/src/tools/organization/brand-context-update.ts +++ b/apps/mesh/src/tools/organization/brand-context-update.ts @@ -41,8 +41,8 @@ export const BRAND_CONTEXT_CREATE = defineTool({ logo: input.logo ?? null, favicon: input.favicon ?? null, ogImage: input.ogImage ?? null, - fonts: (input.fonts as Record[] | null) ?? null, - colors: (input.colors as Record | null) ?? null, + fonts: input.fonts ?? null, + colors: input.colors ?? null, images: (input.images as Record[] | null) ?? null, metadata: (input.metadata as Record | null) ?? null, }); @@ -112,14 +112,8 @@ export const BRAND_CONTEXT_UPDATE = defineTool({ logo: data.logo !== undefined ? (data.logo ?? null) : undefined, favicon: data.favicon !== undefined ? (data.favicon ?? null) : undefined, ogImage: data.ogImage !== undefined ? (data.ogImage ?? null) : undefined, - fonts: - data.fonts !== undefined - ? ((data.fonts as Record[] | null) ?? null) - : undefined, - colors: - data.colors !== undefined - ? ((data.colors as Record | null) ?? null) - : undefined, + fonts: data.fonts !== undefined ? (data.fonts ?? null) : undefined, + colors: data.colors !== undefined ? (data.colors ?? null) : undefined, images: data.images !== undefined ? ((data.images as Record[] | null) ?? null) diff --git a/apps/mesh/src/tools/organization/brand-get.ts b/apps/mesh/src/tools/organization/brand-get.ts new file mode 100644 index 0000000000..d9af56cc51 --- /dev/null +++ b/apps/mesh/src/tools/organization/brand-get.ts @@ -0,0 +1,104 @@ +import { + BrandGetInputSchema, + BrandSchema, + BrandListInputSchema, + BrandListOutputSchema, +} from "@decocms/bindings/brand"; +import { defineTool } from "../../core/define-tool"; +import { requireAuth } from "../../core/mesh-context"; +import type { BrandContext } from "../../storage/types"; + +function toBrandOutput(brand: BrandContext) { + const metadata = brand.metadata ?? {}; + return { + id: brand.id, + name: brand.name, + domain: brand.domain || undefined, + colors: brand.colors ?? undefined, + fonts: brand.fonts ?? undefined, + assets: + brand.logo || brand.favicon || brand.ogImage + ? { + logo: brand.logo ?? undefined, + favicon: brand.favicon ?? undefined, + ogImage: brand.ogImage ?? undefined, + } + : undefined, + overview: brand.overview || undefined, + tagline: + typeof metadata.tagline === "string" ? metadata.tagline : undefined, + tone: typeof metadata.tone === "string" ? metadata.tone : undefined, + metadata: (() => { + const filtered = Object.fromEntries( + Object.entries(metadata).filter( + ([k]) => k !== "tagline" && k !== "tone", + ), + ); + return Object.keys(filtered).length > 0 ? filtered : undefined; + })(), + }; +} + +export const BRAND_GET = defineTool({ + name: "BRAND_GET", + description: + "Get a brand context by ID. Omit the ID to get the default brand for the organization.", + annotations: { + title: "Get Brand", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: BrandGetInputSchema, + outputSchema: BrandSchema, + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + + const brand = input.id + ? await ctx.storage.brandContext.get(input.id, organizationId) + : await ctx.storage.brandContext.getDefault(organizationId); + + if (!brand) { + throw new Error( + input.id ? "Brand not found" : "No default brand configured", + ); + } + + return toBrandOutput(brand); + }, +}); + +export const BRAND_LIST = defineTool({ + name: "BRAND_LIST", + description: "List all active brands for the current organization.", + annotations: { + title: "List Brands", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: BrandListInputSchema, + outputSchema: BrandListOutputSchema, + handler: async (_input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + + const brands = await ctx.storage.brandContext.list(organizationId); + return { items: brands.map(toBrandOutput) }; + }, +}); diff --git a/apps/mesh/src/tools/organization/index.ts b/apps/mesh/src/tools/organization/index.ts index 537a80104b..144e2d9aa1 100644 --- a/apps/mesh/src/tools/organization/index.ts +++ b/apps/mesh/src/tools/organization/index.ts @@ -18,6 +18,7 @@ export { BRAND_CONTEXT_DELETE, } from "./brand-context-update"; export { BRAND_CONTEXT_EXTRACT } from "./brand-context-extract"; +export { BRAND_GET, BRAND_LIST } from "./brand-get"; // Domain management export { ORGANIZATION_DOMAIN_GET } from "./domain-get"; diff --git a/apps/mesh/src/tools/organization/schema.ts b/apps/mesh/src/tools/organization/schema.ts index 96965c734a..268fd7f2de 100644 --- a/apps/mesh/src/tools/organization/schema.ts +++ b/apps/mesh/src/tools/organization/schema.ts @@ -49,18 +49,28 @@ export const BrandContextSchema = z.object({ favicon: z.string().nullable().optional().describe("Favicon URL"), ogImage: z.string().nullable().optional().describe("OG image URL"), fonts: z - .array(z.record(z.string(), z.unknown())) + .object({ + heading: z.string().optional().describe("Font family for headings"), + body: z.string().optional().describe("Font family for body text"), + code: z.string().optional().describe("Font family for code / monospace"), + }) .nullable() .optional() - .describe("Font configuration"), + .describe("Font families by semantic role"), colors: z - .union([ - z.array(z.record(z.string(), z.unknown())), - z.record(z.string(), z.unknown()), - ]) + .object({ + primary: z.string().optional().describe("Primary brand color (hex)"), + secondary: z.string().optional().describe("Secondary brand color (hex)"), + accent: z.string().optional().describe("Accent / highlight color (hex)"), + background: z.string().optional().describe("Background color (hex)"), + foreground: z + .string() + .optional() + .describe("Foreground / text color (hex)"), + }) .nullable() .optional() - .describe("Color palette — array of {label,value} or key-value object"), + .describe("Semantic color palette"), images: z .array(z.record(z.string(), z.unknown())) .nullable() diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index dacc4a5ab4..71c7a783d5 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -51,6 +51,8 @@ const ALL_TOOL_NAMES = [ "BRAND_CONTEXT_UPDATE", "BRAND_CONTEXT_DELETE", "BRAND_CONTEXT_EXTRACT", + "BRAND_GET", + "BRAND_LIST", "ORGANIZATION_DOMAIN_GET", "ORGANIZATION_DOMAIN_SET", "ORGANIZATION_DOMAIN_UPDATE", @@ -283,6 +285,16 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Extract brand context from website", category: "Organizations", }, + { + name: "BRAND_GET", + description: "Get brand (binding)", + category: "Organizations", + }, + { + name: "BRAND_LIST", + description: "List brands (binding)", + category: "Organizations", + }, { name: "ORGANIZATION_DOMAIN_GET", description: "Get organization domain claim", @@ -857,6 +869,8 @@ const TOOL_LABELS: Record = { BRAND_CONTEXT_UPDATE: "Update brand context", BRAND_CONTEXT_DELETE: "Delete brand context", BRAND_CONTEXT_EXTRACT: "Extract brand from website", + BRAND_GET: "Get brand", + BRAND_LIST: "List brands", ORGANIZATION_DOMAIN_GET: "Get domain claim", ORGANIZATION_DOMAIN_SET: "Set domain claim", ORGANIZATION_DOMAIN_UPDATE: "Update domain settings", diff --git a/apps/mesh/src/web/hooks/use-binding.ts b/apps/mesh/src/web/hooks/use-binding.ts index a0df9bd729..228bf2086b 100644 --- a/apps/mesh/src/web/hooks/use-binding.ts +++ b/apps/mesh/src/web/hooks/use-binding.ts @@ -15,6 +15,7 @@ const BINDING_TYPE_TO_BUILTIN: Record = { "@deco/llm": "LLMS", "@deco/trigger": "TRIGGER", "@deco/object-storage": "OBJECT_STORAGE", + "@deco/brand": "BRAND", }; /** diff --git a/apps/mesh/src/web/views/settings/org-brand-context.tsx b/apps/mesh/src/web/views/settings/org-brand-context.tsx index 1834527308..168d8ae4e2 100644 --- a/apps/mesh/src/web/views/settings/org-brand-context.tsx +++ b/apps/mesh/src/web/views/settings/org-brand-context.tsx @@ -30,6 +30,20 @@ import { usePublicConfig } from "@/web/hooks/use-public-config"; // --- Types --- +type BrandColors = { + primary?: string; + secondary?: string; + accent?: string; + background?: string; + foreground?: string; +}; + +type BrandFonts = { + heading?: string; + body?: string; + code?: string; +}; + type BrandContext = { id: string; organizationId: string; @@ -39,8 +53,8 @@ type BrandContext = { logo?: string | null; favicon?: string | null; ogImage?: string | null; - fonts?: { name: string; role: string }[] | null; - colors?: { label: string; value: string }[] | null; + fonts?: BrandFonts | null; + colors?: BrandColors | null; images?: string[]; archivedAt?: string | null; isDefault?: boolean; @@ -403,6 +417,12 @@ function LogosSection({ // --- Section: Fonts --- +const FONT_ROLES = [ + { key: "heading" as const, label: "Headings" }, + { key: "body" as const, label: "Body" }, + { key: "code" as const, label: "Code" }, +]; + function FontsSection({ brand, onSave, @@ -411,29 +431,21 @@ function FontsSection({ onSave: (data: Partial) => void; }) { const [editing, setEditing] = useState(false); - const [fonts, setFonts] = useState<{ name: string; role: string }[]>( - brand.fonts ?? [], - ); + const [fonts, setFonts] = useState(brand.fonts ?? {}); const startEdit = () => { - setFonts(brand.fonts?.length ? [...brand.fonts] : [{ name: "", role: "" }]); + setFonts(brand.fonts ?? {}); setEditing(true); }; const save = () => { - const validFonts = fonts.filter((f) => f.name.trim()); - onSave({ fonts: validFonts.length ? validFonts : null }); + const hasAny = Object.values(fonts).some((v) => v?.trim()); + onSave({ fonts: hasAny ? fonts : null }); setEditing(false); }; - const updateFont = (i: number, field: "name" | "role", value: string) => { - setFonts(fonts.map((f, j) => (j === i ? { ...f, [field]: value } : f))); - }; - - const addFont = () => setFonts([...fonts, { name: "", role: "" }]); - const removeFont = (i: number) => setFonts(fonts.filter((_, j) => j !== i)); - - const hasFonts = brand.fonts && brand.fonts.length > 0; + const hasFonts = + brand.fonts && Object.values(brand.fonts).some((v) => v?.trim()); return ( {editing ? (
- {fonts.map((font, i) => ( -
- updateFont(i, "name", e.target.value)} - placeholder="Font name" - className="flex-1" - /> + {FONT_ROLES.map(({ key, label }) => ( +
+ updateFont(i, "role", e.target.value)} - placeholder="Role (e.g. Headings)" - className="flex-1" + value={fonts[key] ?? ""} + onChange={(e) => setFonts({ ...fonts, [key]: e.target.value })} + placeholder={`Font family for ${label.toLowerCase()}`} /> -
))} -
) : hasFonts ? (
- {brand.fonts!.map((font) => ( -
- - Aa - -
-

- {font.name} -

-

- {font.role} -

+ {FONT_ROLES.filter(({ key }) => brand.fonts?.[key]).map( + ({ key, label }) => ( +
+ + Aa + +
+

+ {brand.fonts![key]} +

+

+ {label} +

+
-
- ))} + ), + )}
) : (

@@ -500,6 +501,14 @@ function FontsSection({ // --- Section: Colors --- +const COLOR_ROLES = [ + { key: "primary" as const, label: "Primary" }, + { key: "secondary" as const, label: "Secondary" }, + { key: "accent" as const, label: "Accent" }, + { key: "background" as const, label: "Background" }, + { key: "foreground" as const, label: "Foreground" }, +]; + function ColorsSection({ brand, onSave, @@ -508,35 +517,21 @@ function ColorsSection({ onSave: (data: Partial) => void; }) { const [editing, setEditing] = useState(false); - const [colors, setColors] = useState<{ label: string; value: string }[]>( - brand.colors ?? [], - ); + const [colors, setColors] = useState(brand.colors ?? {}); const startEdit = () => { - setColors( - brand.colors?.length - ? [...brand.colors] - : [{ label: "", value: "#000000" }], - ); + setColors(brand.colors ?? {}); setEditing(true); }; const save = () => { - const validColors = colors.filter((c) => c.label.trim() && c.value.trim()); - onSave({ colors: validColors.length ? validColors : null }); + const hasAny = Object.values(colors).some((v) => v?.trim()); + onSave({ colors: hasAny ? colors : null }); setEditing(false); }; - const updateColor = (i: number, field: "label" | "value", value: string) => { - setColors(colors.map((c, j) => (j === i ? { ...c, [field]: value } : c))); - }; - - const addColor = () => - setColors([...colors, { label: "", value: "#000000" }]); - const removeColor = (i: number) => - setColors(colors.filter((_, j) => j !== i)); - - const hasColors = brand.colors && brand.colors.length > 0; + const hasColors = + brand.colors && Object.values(brand.colors).some((v) => v?.trim()); return ( {editing ? (

- {colors.map((color, i) => ( -
+ {COLOR_ROLES.map(({ key, label }) => ( +
updateColor(i, "value", e.target.value)} + value={colors[key] ?? "#000000"} + onChange={(e) => + setColors({ ...colors, [key]: e.target.value }) + } className="h-9 w-9 shrink-0 cursor-pointer rounded-lg border border-border bg-transparent p-0.5" /> updateColor(i, "value", e.target.value)} + value={colors[key] ?? ""} + onChange={(e) => + setColors({ ...colors, [key]: e.target.value }) + } placeholder="#000000" className="w-28" /> - updateColor(i, "label", e.target.value)} - placeholder="Label (e.g. Primary)" - className="flex-1" - /> - + + {label} +
))} -
) : hasColors ? (
- {brand.colors!.map((color) => ( -
-
-

- {color.value} -

-

{color.label}

-
- ))} + {COLOR_ROLES.filter(({ key }) => brand.colors?.[key]).map( + ({ key, label }) => ( +
+
+

+ {brand.colors![key]} +

+

{label}

+
+ ), + )}
) : (

@@ -747,33 +737,25 @@ function ExpandableBrandEntry({

{/* Color swatches */} - {brand.colors && brand.colors.length > 0 && ( + {brand.colors && Object.values(brand.colors).some((v) => v) && (
- {brand.colors.slice(0, 5).map((c) => ( -
- ))} - {brand.colors.length > 5 && ( - - +{brand.colors.length - 5} - - )} + {Object.entries(brand.colors) + .filter(([, v]) => v) + .map(([role, value]) => ( +
+ ))}
)} {/* Font names */} - {brand.fonts && brand.fonts.length > 0 && ( + {brand.fonts && Object.values(brand.fonts).some((v) => v) && ( - {brand.fonts - .slice(0, 2) - .map((f) => f.name) - .filter(Boolean) - .join(", ")} - {brand.fonts.length > 2 && ` +${brand.fonts.length - 2}`} + {Object.values(brand.fonts).filter(Boolean).join(", ")} )} diff --git a/packages/bindings/package.json b/packages/bindings/package.json index e5ecd7a319..21de5091d0 100644 --- a/packages/bindings/package.json +++ b/packages/bindings/package.json @@ -32,7 +32,8 @@ "./plugin-router": "./src/core/plugin-router.tsx", "./ai-gateway": "./src/well-known/ai-gateway.ts", "./server-plugin": "./src/core/server-plugin.ts", - "./trigger": "./src/well-known/trigger.ts" + "./trigger": "./src/well-known/trigger.ts", + "./brand": "./src/well-known/brand.ts" }, "engines": { "node": ">=24.0.0" diff --git a/packages/bindings/src/index.ts b/packages/bindings/src/index.ts index 3a07b16df2..5dd07b9bb1 100644 --- a/packages/bindings/src/index.ts +++ b/packages/bindings/src/index.ts @@ -123,3 +123,25 @@ export { // Re-export workflow binding types export { WORKFLOWS_COLLECTION_BINDING } from "./well-known/workflow"; + +// Re-export brand binding types (for reading org brand context) +export { + BrandColorsSchema, + type BrandColors, + BrandFontsSchema, + type BrandFonts, + BrandAssetsSchema, + type BrandAssets, + BrandSchema, + type Brand, + BrandGetInputSchema, + type BrandGetInput, + type BrandGetOutput, + BrandListInputSchema, + type BrandListInput, + BrandListOutputSchema, + type BrandListOutput, + BRAND_BINDING, + BrandBinding, + type BrandBindingClient, +} from "./well-known/brand"; diff --git a/packages/bindings/src/well-known/brand.ts b/packages/bindings/src/well-known/brand.ts new file mode 100644 index 0000000000..a1a0dab3f6 --- /dev/null +++ b/packages/bindings/src/well-known/brand.ts @@ -0,0 +1,151 @@ +/** + * Brand Well-Known Binding + * + * Defines the interface for reading brand context (colors, fonts, assets, + * voice) from an organization. External MCPs that need brand awareness + * can declare a dependency on this binding and call BRAND_GET / BRAND_LIST + * to pull structured brand data on demand. + * + * This binding includes: + * - BRAND_GET: Get a brand by ID (or the org default) + * - BRAND_LIST: List all active brands (optional) + */ + +import { z } from "zod"; +import { bindingClient, type ToolBinder } from "../core/binder"; + +// ============================================================================ +// Brand Sub-Schemas +// ============================================================================ + +export const BrandColorsSchema = z.object({ + primary: z.string().optional().describe("Primary brand color (hex)"), + secondary: z.string().optional().describe("Secondary brand color (hex)"), + accent: z.string().optional().describe("Accent / highlight color (hex)"), + background: z.string().optional().describe("Background color (hex)"), + foreground: z.string().optional().describe("Foreground / text color (hex)"), +}); + +export type BrandColors = z.infer; + +export const BrandFontsSchema = z.object({ + heading: z.string().optional().describe("Font family for headings"), + body: z.string().optional().describe("Font family for body text"), + code: z.string().optional().describe("Font family for code / monospace"), +}); + +export type BrandFonts = z.infer; + +export const BrandAssetsSchema = z.object({ + logo: z.string().optional().describe("Logo URL"), + favicon: z.string().optional().describe("Favicon URL"), + ogImage: z.string().optional().describe("Open Graph image URL"), +}); + +export type BrandAssets = z.infer; + +// ============================================================================ +// Brand Schema +// ============================================================================ + +export const BrandSchema = z.object({ + id: z.string(), + name: z.string().describe("Brand / company name"), + domain: z.string().optional().describe("Company domain (e.g. example.com)"), + colors: BrandColorsSchema.optional().describe("Semantic color palette"), + fonts: BrandFontsSchema.optional().describe("Font families by role"), + assets: BrandAssetsSchema.optional().describe("Visual identity assets"), + overview: z.string().optional().describe("Company overview / description"), + tagline: z.string().optional().describe("Brand tagline"), + tone: z.string().optional().describe("Tone of voice description"), + metadata: z + .record(z.string(), z.unknown()) + .optional() + .describe("Extra design tokens and metadata"), +}); + +export type Brand = z.infer; + +// ============================================================================ +// BRAND_GET Schemas +// ============================================================================ + +export const BrandGetInputSchema = z.object({ + id: z + .string() + .optional() + .describe("Brand ID. Omit to get the default brand."), +}); + +export type BrandGetInput = z.infer; + +export type BrandGetOutput = z.infer; + +// ============================================================================ +// BRAND_LIST Schemas +// ============================================================================ + +export const BrandListInputSchema = z.object({}); + +export type BrandListInput = z.infer; + +export const BrandListOutputSchema = z.object({ + items: z.array(BrandSchema), +}); + +export type BrandListOutput = z.infer; + +// ============================================================================ +// Brand Binding +// ============================================================================ + +/** + * Brand Binding + * + * Defines the interface for reading brand context from an organization. + * + * Required tools: + * - BRAND_GET: Get a single brand by ID, or the default brand when no ID + * + * Optional tools: + * - BRAND_LIST: List all active brands + */ +export const BRAND_BINDING = [ + { + name: "BRAND_GET" as const, + inputSchema: BrandGetInputSchema, + outputSchema: BrandSchema, + }, + { + name: "BRAND_LIST" as const, + inputSchema: BrandListInputSchema, + outputSchema: BrandListOutputSchema, + opt: true, + }, +] satisfies ToolBinder[]; + +/** + * Brand Binding Client + * + * @example + * ```typescript + * import { BrandBinding } from "@decocms/bindings/brand"; + * + * const client = BrandBinding.forConnection(connection); + * + * // Get the default brand + * const brand = await client.BRAND_GET({}); + * + * // Get a specific brand + * const brand = await client.BRAND_GET({ id: "acme-corp" }); + * + * // List all brands + * const { items } = await client.BRAND_LIST({}); + * ``` + */ +export const BrandBinding = bindingClient(BRAND_BINDING); + +/** + * Type helper for the Brand binding client + */ +export type BrandBindingClient = ReturnType;