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/badge.ts

378lines · modepreview

/**
 * Badge Component Generator
 *
 * Generates a single Badge ComponentSet in Figma with variant property.
 * Reads variant definitions from component-registry.json (the source of truth).
 */

import {
  createTextNode,
  bindStrokeToVariable,
  getVariableByName,
  createModeSection,
  createRowLabel,
  setWhiteTextColor,
  bindTextColorToVariable,
  bindFillToVariableWithOpacity,
  SECTION_PADDING,
  SECTION_GAP,
  SECTION_TITLE,
  GRID_LAYOUT,
  SECTION_LAYOUT,
  DASH_PATTERN,
} from "./shared";
import { parseTailwindClasses } from "../parsers/tailwind-to-figma";
import { logInfo, logWarn } from "../logger";

// Import variant data from the registry (generated by build:ai-metadata)
import registry from "@cloudflare/kumo/ai/component-registry.json";

const badgeComponent = registry.components.Badge;
const badgeProps = badgeComponent.props;
const variantProp = badgeProps.variant as {
  values: string[];
  classes: Record<string, string>;
  descriptions: Record<string, string>;
  default: string;
};

/**
 * Badge base styles from KUMO_BADGE_BASE_STYLES in badge.tsx
 * Read from component-registry.json (the source of truth)
 */
const BADGE_BASE_STYLES = badgeComponent.baseStyles as string;

/**
 * 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 getBadgeVariantConfig() {
  return {
    values: variantProp.values,
    classes: variantProp.classes,
    descriptions: variantProp.descriptions,
    default: variantProp.default,
  };
}

/**
 * Get parsed base styles
 */
export function getBadgeParsedBaseStyles() {
  return parseTailwindClasses(BADGE_BASE_STYLES);
}

/**
 * Get parsed styles for a specific variant
 */
export function getBadgeParsedVariantStyles(variant: string) {
  const classes = variantProp.classes[variant] || "";
  return {
    variant,
    classes,
    description: variantProp.descriptions[variant] || "",
    parsed: parseTailwindClasses(classes),
  };
}

/**
 * Get all variant data (for snapshot testing)
 * Returns intermediate data before Figma API calls
 */
export function getAllBadgeVariantData() {
  const baseStyles = getBadgeParsedBaseStyles();
  const config = getBadgeVariantConfig();

  return {
    baseStyles: {
      raw: BADGE_BASE_STYLES,
      parsed: baseStyles,
    },
    variants: config.values.map((variant) => {
      const variantData = getBadgeParsedVariantStyles(variant);
      return {
        ...variantData,
        // Layout calculations
        layout: {
          layoutMode: "HORIZONTAL",
          primaryAxisAlignItems: "CENTER",
          counterAxisAlignItems: "CENTER",
          paddingLeft: baseStyles.paddingX ?? 8,
          paddingRight: baseStyles.paddingX ?? 8,
          paddingTop: baseStyles.paddingY ?? 2,
          paddingBottom: baseStyles.paddingY ?? 2,
          cornerRadius: baseStyles.borderRadius ?? 9999,
          primaryAxisSizingMode: "AUTO",
          counterAxisSizingMode: "AUTO",
        },
        // Text properties
        text: {
          fontSize: baseStyles.fontSize ?? 12,
          fontWeight: baseStyles.fontWeight ?? 500,
        },
      };
    }),
  };
}

/**
 * Create a single Badge component with the specified variant
 */
async function createBadgeComponent(variant: string): Promise<ComponentNode> {
  const classes = variantProp.classes[variant] || "";
  const description = variantProp.descriptions[variant] || "";

  // Parse base styles and variant-specific styles
  const baseStyles = parseTailwindClasses(BADGE_BASE_STYLES);
  const variantStyles = parseTailwindClasses(classes);

  // Create component
  const component = figma.createComponent();
  component.name = "variant=" + variant;
  component.description = description;

  // Set up auto-layout
  component.layoutMode = "HORIZONTAL";
  component.primaryAxisAlignItems = "CENTER";
  component.counterAxisAlignItems = "CENTER";

  // Apply paddingX with fallback
  if (baseStyles.paddingX !== undefined) {
    component.paddingLeft = baseStyles.paddingX;
    component.paddingRight = baseStyles.paddingX;
  } else {
    logWarn("Badge: paddingX not found in baseStyles, using fallback 8");
    component.paddingLeft = 8;
    component.paddingRight = 8;
  }

  // Apply paddingY with fallback
  if (baseStyles.paddingY !== undefined) {
    component.paddingTop = baseStyles.paddingY;
    component.paddingBottom = baseStyles.paddingY;
  } else {
    logWarn("Badge: paddingY not found in baseStyles, using fallback 2");
    component.paddingTop = 2;
    component.paddingBottom = 2;
  }

  component.primaryAxisSizingMode = "AUTO";
  component.counterAxisSizingMode = "AUTO";

  // Apply borderRadius with fallback
  if (baseStyles.borderRadius !== undefined) {
    component.cornerRadius = baseStyles.borderRadius;
  } else {
    logWarn("Badge: borderRadius not found in baseStyles, using fallback 9999");
    component.cornerRadius = 9999;
  }

  // Apply fill from variant (handles opacity suffix like "color-kumo-info/20")
  if (variantStyles.fillVariable) {
    bindFillToVariableWithOpacity(component, variantStyles.fillVariable);
  } else {
    // Transparent background
    component.fills = [];
  }

  // Apply border if variant has one
  if (variantStyles.hasBorder && variantStyles.strokeVariable) {
    const strokeVar = getVariableByName(variantStyles.strokeVariable);
    if (strokeVar) {
      // Apply strokeWeight with fallback
      let strokeWeight: number;
      if (variantStyles.strokeWeight !== undefined) {
        strokeWeight = variantStyles.strokeWeight;
      } else {
        logWarn(
          "Badge: strokeWeight not found in variantStyles, using fallback 1",
        );
        strokeWeight = 1;
      }
      bindStrokeToVariable(component, strokeVar.id, strokeWeight);

      if (variantStyles.borderStyle === "dashed") {
        // Apply dashPattern with fallback
        if (variantStyles.dashPattern !== undefined) {
          component.dashPattern = variantStyles.dashPattern;
        } else {
          logWarn(
            "Badge: dashPattern not found in variantStyles, using centralized DASH_PATTERN.standard",
          );
          component.dashPattern = [...DASH_PATTERN.standard];
        }
      }
    }
  }

  // Create text label with fallbacks
  let fontSize: number;
  if (baseStyles.fontSize !== undefined) {
    fontSize = baseStyles.fontSize;
  } else {
    logWarn("Badge: fontSize not found in baseStyles, using fallback 12");
    fontSize = 12;
  }

  let fontWeight: number;
  if (baseStyles.fontWeight !== undefined) {
    fontWeight = baseStyles.fontWeight;
  } else {
    logWarn("Badge: fontWeight not found in baseStyles, using fallback 500");
    fontWeight = 500;
  }

  const textNode = await createTextNode("Badge", fontSize, fontWeight);
  textNode.name = "Label";

  // Apply text color from variant
  if (variantStyles.isWhiteText) {
    setWhiteTextColor(textNode);
  } else if (variantStyles.textVariable) {
    const textVar = getVariableByName(variantStyles.textVariable);
    if (textVar) {
      bindTextColorToVariable(textNode, textVar.id);
    }
  }

  component.appendChild(textNode);

  return component;
}

/**
 * Generate Badge ComponentSet with variant property
 *
 * Creates a single "Badge" 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 generateBadgeComponents(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.medium;
  const labelColumnWidth = GRID_LAYOUT.labelColumnWidth.medium;

  // 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 createBadgeComponent(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
  const componentSet = figma.combineAsVariants(components, figma.currentPage);
  componentSet.name = "Badge";
  componentSet.description = "Badge 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, "Badge", "light");
  lightSection.frame.resize(
    contentWidth + SECTION_PADDING * 2,
    contentHeight + SECTION_PADDING * 2 + contentYOffset,
  );

  // Create dark mode section
  const darkSection = createModeSection(figma.currentPage, "Badge", "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.sm, // Small offset to vertically center with badge
    );
    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.sm,
    );
    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;

  logInfo(
    "✅ Generated Badge ComponentSet with " +
      variants.length +
      " variants (light + dark)",
  );

  return startY + totalHeight + SECTION_GAP;
}