/**
* Banner Component Generator
*
* Generates a single Banner ComponentSet in Figma with variant property.
* Reads variant definitions from component-registry.json (the source of truth).
*/
import {
createTextNode,
bindFillToVariable,
bindStrokeToVariable,
getVariableByName,
createModeSection,
createRowLabel,
bindTextColorToVariable,
bindFillToVariableWithOpacity,
SECTION_PADDING,
SECTION_GAP,
SECTION_TITLE,
GRID_LAYOUT,
FALLBACK_VALUES,
SECTION_LAYOUT,
FONT_SIZE,
} from "./shared";
import { parseTailwindClasses } from "../parsers/tailwind-to-figma";
import { createIconInstance, bindIconColor } from "./icon-utils";
import { logComplete } from "../logger";
// Import variant data from the registry (generated by build:ai-metadata)
import registry from "@cloudflare/kumo/ai/component-registry.json";
const bannerData = registry.components
.Banner as typeof registry.components.Banner & {
baseStyles?: string;
};
const bannerProps = bannerData.props;
const variantProp = bannerProps.variant as {
values: string[];
classes: Record<string, string>;
descriptions: Record<string, string>;
default: string;
};
/**
* Banner base styles from KUMO_BANNER_BASE_STYLES in banner.tsx
* Read from component-registry.json (the source of truth)
*/
const BANNER_BASE_STYLES =
bannerData.baseStyles ||
"flex w-full items-center gap-2 rounded-lg border px-4 py-1.5 text-base";
/**
* TESTABLE EXPORTS - Pure functions that return intermediate data
* These functions compute data without calling Figma APIs, enabling snapshot tests.
*/
/**
* Get variant configuration from registry
*/
export function getBannerVariantConfig() {
return {
values: variantProp.values,
classes: variantProp.classes,
descriptions: variantProp.descriptions,
default: variantProp.default,
};
}
/**
* Get parsed base styles
*/
export function getBannerParsedBaseStyles() {
return parseTailwindClasses(BANNER_BASE_STYLES);
}
/**
* Get parsed styles for a specific variant
*/
export function getBannerParsedVariantStyles(variant: string) {
const classes = variantProp.classes[variant] || "";
return {
variant,
classes,
description: variantProp.descriptions[variant] || "",
parsed: parseTailwindClasses(classes),
};
}
/**
* Banner icon mapping by variant
*/
const BANNER_ICONS: Record<string, string> = {
default: "ph-info",
alert: "ph-warning",
error: "ph-warning",
};
/**
* Get all variant data (for snapshot testing)
* Returns intermediate data before Figma API calls
*/
export function getAllBannerVariantData() {
const baseStyles = getBannerParsedBaseStyles();
const config = getBannerVariantConfig();
return {
baseStyles: {
raw: BANNER_BASE_STYLES,
parsed: baseStyles,
},
variants: config.values.map((variant) => {
const variantData = getBannerParsedVariantStyles(variant);
return {
...variantData,
// Layout calculations
layout: {
layoutMode: "HORIZONTAL",
primaryAxisAlignItems: "CENTER",
counterAxisAlignItems: "CENTER",
itemSpacing: baseStyles.gap ?? 8,
paddingLeft: baseStyles.paddingX ?? 16,
paddingRight: baseStyles.paddingX ?? 16,
paddingTop: baseStyles.paddingY ?? 6,
paddingBottom: baseStyles.paddingY ?? 6,
cornerRadius: baseStyles.borderRadius ?? 8,
primaryAxisSizingMode: "AUTO",
counterAxisSizingMode: "AUTO",
},
// Icon properties
icon: {
iconId: BANNER_ICONS[variant] || "ph-info",
iconSize: FONT_SIZE.lg, // 16px (Kumo's text-lg, slightly larger than text-base)
},
// Text properties
text: {
fontSize: baseStyles.fontSize ?? 16,
fontWeight: baseStyles.fontWeight ?? 400,
},
};
}),
};
}
/**
* Create a single Banner component with the specified variant
*/
async function createBannerComponent(variant: string): Promise<ComponentNode> {
const classes = variantProp.classes[variant] || "";
const description = variantProp.descriptions[variant] || "";
// Parse base styles and variant-specific styles
const baseStyles = parseTailwindClasses(BANNER_BASE_STYLES);
const variantStyles = parseTailwindClasses(classes);
// Create component
const component = figma.createComponent();
component.name = "variant=" + variant;
component.description = description;
// Set up auto-layout (horizontal for banner with icon + text)
component.layoutMode = "HORIZONTAL";
component.primaryAxisAlignItems = "CENTER";
component.counterAxisAlignItems = "CENTER";
component.itemSpacing = baseStyles.gap || FALLBACK_VALUES.gap.medium; // gap-2 = 8px
component.paddingLeft =
baseStyles.paddingX || FALLBACK_VALUES.padding.standard; // px-4 = 16px
component.paddingRight =
baseStyles.paddingX || FALLBACK_VALUES.padding.standard;
component.paddingTop =
baseStyles.paddingY || FALLBACK_VALUES.padding.vertical; // py-1.5 = 6px
component.paddingBottom =
baseStyles.paddingY || FALLBACK_VALUES.padding.vertical;
component.primaryAxisSizingMode = "AUTO";
component.counterAxisSizingMode = "AUTO";
component.cornerRadius =
baseStyles.borderRadius || FALLBACK_VALUES.borderRadius.large; // rounded-lg = 8px
// Apply fill from variant (bg-kumo-info/20, bg-kumo-warning/20, bg-kumo-danger/20)
// Uses bindFillToVariableWithOpacity to handle opacity suffix in variable name
if (variantStyles.fillVariable) {
bindFillToVariableWithOpacity(component, variantStyles.fillVariable);
} else {
// Transparent background
component.fills = [];
}
// Apply border (all banner variants have borders)
if (variantStyles.strokeVariable) {
const strokeVar = getVariableByName(variantStyles.strokeVariable);
if (strokeVar) {
bindStrokeToVariable(component, strokeVar.id, 1);
}
}
// Create icon based on variant
// - default (info): ph-info
// - alert: ph-warning
// - error: ph-warning (same icon, different color via variant)
const iconId = BANNER_ICONS[variant] || "ph-info";
const iconSize = FONT_SIZE.lg; // 16px (Kumo's text-lg, slightly larger than text-base)
const iconInstance = createIconInstance(iconId, iconSize);
if (iconInstance) {
iconInstance.name = "Icon";
// Bind icon color to variant text color
if (variantStyles.textVariable) {
bindIconColor(iconInstance, variantStyles.textVariable);
}
component.appendChild(iconInstance);
} else {
// Fallback: create a placeholder rectangle if icon not found
const iconPlaceholder = figma.createRectangle();
iconPlaceholder.name = "Icon (placeholder)";
iconPlaceholder.resize(iconSize, iconSize);
iconPlaceholder.cornerRadius = 2;
if (variantStyles.textVariable) {
const iconColorVar = getVariableByName(variantStyles.textVariable);
if (iconColorVar) {
bindFillToVariable(iconPlaceholder, iconColorVar.id);
}
}
component.appendChild(iconPlaceholder);
}
// Create text label
const textNode = await createTextNode(
"This is a banner message",
baseStyles.fontSize || FALLBACK_VALUES.fontSize, // text-base = 16px
baseStyles.fontWeight || FALLBACK_VALUES.fontWeight.normal, // normal weight (CSS default)
);
textNode.name = "Text";
// Apply text color from variant
if (variantStyles.textVariable) {
const textVar = getVariableByName(variantStyles.textVariable);
if (textVar) {
bindTextColorToVariable(textNode, textVar.id);
}
}
component.appendChild(textNode);
return component;
}
/**
* Generate Banner ComponentSet with variant property
*
* Creates a single "Banner" ComponentSet with variants derived from
* component-registry.json. Creates both light and dark mode sections.
*
* @param startY - Y position to start placing the section
* @returns The Y position after this section (for next section placement)
*/
export async function generateBannerComponents(
startY: number,
): Promise<number> {
if (startY === undefined) startY = 100;
// Get variant keys from the registry
const variants = variantProp.values;
const components: ComponentNode[] = [];
// Track row labels: { y, text }
const rowLabels: { y: number; text: string }[] = [];
// Layout spacing - vertical layout with labels
const rowGap = GRID_LAYOUT.rowGap.standard;
const labelColumnWidth = GRID_LAYOUT.labelColumnWidth.standard;
// Track position for laying out components vertically
let currentY = 0;
for (let i = 0; i < variants.length; i++) {
const variant = variants[i];
const component = await createBannerComponent(variant);
// Record row label
rowLabels.push({ y: currentY, text: "variant=" + variant });
// Position each component vertically with label offset
component.x = labelColumnWidth;
component.y = currentY;
currentY += component.height + rowGap;
components.push(component);
}
// Combine all variants into a single ComponentSet
// @ts-ignore - combineAsVariants works at runtime
const componentSet = figma.combineAsVariants(components, figma.currentPage);
componentSet.name = "Banner";
componentSet.description = "Banner component with variant styles";
// Calculate content dimensions (add label column width)
const contentWidth = componentSet.width + labelColumnWidth;
const contentHeight = componentSet.height;
// Content Y offset to make room for title inside frame
const contentYOffset = SECTION_TITLE.height;
// Create light mode section
const lightSection = createModeSection(figma.currentPage, "Banner", "light");
lightSection.frame.resize(
contentWidth + SECTION_PADDING * 2,
contentHeight + SECTION_PADDING * 2 + contentYOffset,
);
// Create dark mode section
const darkSection = createModeSection(figma.currentPage, "Banner", "dark");
darkSection.frame.resize(
contentWidth + SECTION_PADDING * 2,
contentHeight + SECTION_PADDING * 2 + contentYOffset,
);
// Add title inside each frame
// Move ComponentSet into light section frame
lightSection.frame.appendChild(componentSet);
componentSet.x = SECTION_PADDING + labelColumnWidth;
componentSet.y = SECTION_PADDING + contentYOffset;
// Add row labels to light section
for (const label of rowLabels) {
const labelNode = await createRowLabel(
label.text,
SECTION_PADDING,
SECTION_PADDING +
contentYOffset +
label.y +
GRID_LAYOUT.labelVerticalOffset.md, // +8 to vertically center with banner
);
lightSection.frame.appendChild(labelNode);
}
// Create instances for dark section
// Note: component positions are relative to ComponentSet after combineAsVariants
// We need to add labelColumnWidth to match the light section layout
for (const component of components) {
const instance = component.createInstance();
instance.x = component.x + SECTION_PADDING + labelColumnWidth;
instance.y = component.y + SECTION_PADDING + contentYOffset;
darkSection.frame.appendChild(instance);
}
// Add row labels to dark section
for (const label of rowLabels) {
const labelNode = await createRowLabel(
label.text,
SECTION_PADDING,
SECTION_PADDING +
contentYOffset +
label.y +
GRID_LAYOUT.labelVerticalOffset.md,
);
darkSection.frame.appendChild(labelNode);
}
// Resize sections to fit content with padding
const totalWidth = contentWidth + SECTION_PADDING * 2;
const totalHeight = contentHeight + SECTION_PADDING * 2 + contentYOffset;
lightSection.section.resizeWithoutConstraints(totalWidth, totalHeight);
darkSection.section.resizeWithoutConstraints(totalWidth, totalHeight);
// Position sections at startY (no title offset needed since title is inside)
lightSection.frame.x = SECTION_LAYOUT.startX;
lightSection.frame.y = startY;
darkSection.frame.x =
lightSection.frame.x + totalWidth + SECTION_LAYOUT.modeGap;
darkSection.frame.y = startY;
logComplete(
"Generated Banner ComponentSet with " +
variants.length +
" variants (light + dark)",
);
return startY + totalHeight + SECTION_GAP;
}cloudflare/kumo
Publicmirrored fromhttps://github.com/cloudflare/kumoAvailable
packages/kumo-figma/src/generators/banner.ts
384lines · modepreview