cloudflare/kumo

Public

mirrored fromhttps://github.com/cloudflare/kumoAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
5260f1a5703bb69e6c7f7cf0ce8033a561cac8b5

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

packages/kumo-figma/src/generators/banner.ts

384lines · modepreview

/**
 * 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;
}