Exportación de código

Por: Artiko
openpencilexportaciónreacttailwindvueflutterswiftui

Exportación de código

El puente entre diseño y código

La exportación de código es donde OpenPencil realmente diferencia su propuesta de valor. No se trata de generar código de baja calidad que un desarrollador tiene que reescribir. El motor de exportación de OpenPencil analiza el diseño considerando la semántica, no solo las propiedades visuales: detecta auto-layouts y los convierte en Flexbox/Grid, detecta componentes y los convierte en componentes del framework target, detecta variables de diseño y las convierte en CSS custom properties o en el equivalente del framework.

flowchart TD
    A[Archivo .op] --> B[Motor de exportación]
    B --> C{Analiza}
    C --> D[Detecta auto-layouts<br/>→ Flexbox/Grid]
    C --> E[Detecta componentes<br/>→ Componentes del framework]
    C --> F[Detecta variables<br/>→ CSS custom properties]
    C --> G[Detecta imágenes<br/>→ Assets optimizados]
    D --> H[Genera código]
    E --> H
    F --> H
    G --> H
    H --> I{Framework target}
    I --> J[React + Tailwind]
    I --> K[Vue 3 + Tailwind]
    I --> L[Svelte + Tailwind]
    I --> M[Flutter]
    I --> N[SwiftUI]
    I --> O[React Native]

Preparar el diseño para exportar

Antes de exportar, es crucial que el diseño esté preparado correctamente. El código generado es proporcional a la calidad del diseño.

Nombrar nodos correctamente

Los nombres de los nodos en el canvas se convierten en nombres de componentes y variables en el código:

Frame "UserCard"          → function UserCard() { ... }
Frame "Button/Primary"    → function ButtonPrimary() { ... }
Text "user-name"          → className para el elemento de texto
Frame "card-header"       → <div className="card-header">

Convención recomendada:

Usar auto-layout para todo

El motor de exportación convierte auto-layout directamente a CSS Flexbox o Grid. Cualquier frame que NO use auto-layout se exportará con posicionamiento absoluto, lo cual raramente es útil en código.

Regla práctica: si un nodo tiene children, debe tener auto-layout activado.

Vincular colores y tamaños a variables

Los valores hardcoded en el diseño (colores hex, tamaños px específicos) se exportan como valores literales en el código. Los valores vinculados a variables se exportan como referencias a CSS custom properties o a constantes del sistema de tokens.

Fill color: #2563EB (hardcoded) → bg-[#2563EB] en Tailwind
Fill color: $color-primary-600  → bg-primary-600 (si está en el config de Tailwind)

Exportar a React + Tailwind CSS

Esta es la exportación más completa y mejor mantenida de OpenPencil.

Desde la interfaz

  1. Selecciona el frame o componente a exportar
  2. En el panel derecho, sección “Export” (o click derecho → Export)
  3. Selecciona “React + Tailwind”
  4. Configura las opciones y haz click en “Export”

Desde el CLI

# Exportar un componente específico
op export react \
  --file diseño.op \
  --component "UserCard" \
  --out ./src/components \
  --typescript

# Exportar toda una página
op export react \
  --file diseño.op \
  --page "Components" \
  --out ./src/components \
  --typescript

# Exportar con configuración personalizada de Tailwind
op export react \
  --file diseño.op \
  --component "UserCard" \
  --out ./src/components \
  --tailwind-config ./tailwind.config.ts \
  --typescript \
  --use-cn  # Usa la función cn() de clsx+tailwind-merge

Ejemplo de output: componente de tarjeta de usuario

Diseño en OpenPencil:

Código generado:

// UserCard.tsx — generado por OpenPencil
import { cn } from '@/lib/utils'

interface UserCardProps {
  name: string
  role: string
  email: string
  avatarUrl?: string
  status?: 'online' | 'offline' | 'away'
  className?: string
}

export function UserCard({
  name,
  role,
  email,
  avatarUrl,
  status = 'offline',
  className,
}: UserCardProps) {
  const statusColors = {
    online: 'bg-green-100 text-green-700',
    offline: 'bg-slate-100 text-slate-600',
    away: 'bg-amber-100 text-amber-700',
  }

  return (
    <div className={cn('flex flex-col gap-4 p-6 rounded-xl border border-slate-200 bg-white', className)}>
      <div className="flex flex-row items-center gap-3">
        <div className="w-12 h-12 rounded-full overflow-hidden bg-slate-100 flex-shrink-0">
          {avatarUrl ? (
            <img src={avatarUrl} alt={name} className="w-full h-full object-cover" />
          ) : (
            <div className="w-full h-full flex items-center justify-center text-slate-500 font-semibold text-lg">
              {name.charAt(0).toUpperCase()}
            </div>
          )}
        </div>
        <div className="flex flex-col gap-1">
          <span className="text-base font-semibold text-slate-900">{name}</span>
          <span className="text-sm text-slate-500">{role}</span>
          <span className="text-xs text-slate-400">{email}</span>
        </div>
      </div>
      <div className="flex flex-row gap-2">
        <span className={cn('px-2 py-0.5 rounded-full text-xs font-medium', statusColors[status])}>
          {status === 'online' ? 'En línea' : status === 'away' ? 'Ausente' : 'Desconectado'}
        </span>
      </div>
    </div>
  )
}

Opciones avanzadas de exportación React

# Usar shadcn/ui para componentes base
op export react \
  --file diseño.op \
  --component "Button/Primary" \
  --out ./src/components/ui \
  --use-shadcn \
  --typescript

# Exportar con stories de Storybook
op export react \
  --file diseño.op \
  --page "Components" \
  --out ./src \
  --storybook \
  --typescript

# Exportar con tests básicos
op export react \
  --file diseño.op \
  --component "UserCard" \
  --out ./src/components \
  --with-tests \
  --test-framework vitest

Exportar a Vue 3

op export vue \
  --file diseño.op \
  --component "UserCard" \
  --out ./src/components \
  --typescript \
  --composition-api  # Por defecto (vs Options API)

Componente Vue generado:

<!-- UserCard.vue — generado por OpenPencil -->
<script setup lang="ts">
interface Props {
  name: string
  role: string
  email: string
  avatarUrl?: string
  status?: 'online' | 'offline' | 'away'
}

const props = withDefaults(defineProps<Props>(), {
  status: 'offline',
})

const statusColors = {
  online: 'bg-green-100 text-green-700',
  offline: 'bg-slate-100 text-slate-600',
  away: 'bg-amber-100 text-amber-700',
}
</script>

<template>
  <div class="flex flex-col gap-4 p-6 rounded-xl border border-slate-200 bg-white">
    <div class="flex flex-row items-center gap-3">
      <div class="w-12 h-12 rounded-full overflow-hidden bg-slate-100 flex-shrink-0">
        <img v-if="avatarUrl" :src="avatarUrl" :alt="name" class="w-full h-full object-cover" />
        <div v-else class="w-full h-full flex items-center justify-center text-slate-500 font-semibold text-lg">
          {{ name.charAt(0).toUpperCase() }}
        </div>
      </div>
      <div class="flex flex-col gap-1">
        <span class="text-base font-semibold text-slate-900">{{ name }}</span>
        <span class="text-sm text-slate-500">{{ role }}</span>
        <span class="text-xs text-slate-400">{{ email }}</span>
      </div>
    </div>
    <div class="flex flex-row gap-2">
      <span :class="['px-2 py-0.5 rounded-full text-xs font-medium', statusColors[status]]">
        {{ status === 'online' ? 'En línea' : status === 'away' ? 'Ausente' : 'Desconectado' }}
      </span>
    </div>
  </div>
</template>

Exportar a Svelte

op export svelte \
  --file diseño.op \
  --component "UserCard" \
  --out ./src/lib/components \
  --svelte5  # Usa la nueva sintaxis de Svelte 5 con runes

Exportar a Flutter

op export flutter \
  --file diseño.op \
  --component "UserCard" \
  --out ./lib/widgets

Widget Flutter generado:

// user_card.dart — generado por OpenPencil
import 'package:flutter/material.dart';

class UserCard extends StatelessWidget {
  final String name;
  final String role;
  final String email;
  final String? avatarUrl;
  final UserStatus status;

  const UserCard({
    super.key,
    required this.name,
    required this.role,
    required this.email,
    this.avatarUrl,
    this.status = UserStatus.offline,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(24),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: const Color(0xFFE2E8F0)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              CircleAvatar(
                radius: 24,
                backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl!) : null,
                child: avatarUrl == null
                    ? Text(name[0].toUpperCase(),
                        style: const TextStyle(color: Color(0xFF64748B), fontWeight: FontWeight.w600))
                    : null,
              ),
              const SizedBox(width: 12),
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF0F172A))),
                  Text(role, style: const TextStyle(fontSize: 14, color: Color(0xFF64748B))),
                  Text(email, style: const TextStyle(fontSize: 12, color: Color(0xFF94A3B8))),
                ],
              ),
            ],
          ),
          const SizedBox(height: 16),
          StatusBadge(status: status),
        ],
      ),
    );
  }
}

enum UserStatus { online, offline, away }

Exportar a SwiftUI

op export swiftui \
  --file diseño.op \
  --component "UserCard" \
  --out ./Sources/Views

Vista SwiftUI generada:

// UserCard.swift — generado por OpenPencil
import SwiftUI

struct UserCard: View {
    let name: String
    let role: String
    let email: String
    var avatarUrl: String? = nil
    var status: UserStatus = .offline
    
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            HStack(spacing: 12) {
                AsyncImage(url: URL(string: avatarUrl ?? "")) { image in
                    image.resizable().aspectRatio(contentMode: .fill)
                } placeholder: {
                    Text(String(name.prefix(1)).uppercased())
                        .font(.system(size: 20, weight: .semibold))
                        .foregroundColor(.init(hex: "64748B"))
                }
                .frame(width: 48, height: 48)
                .clipShape(Circle())
                
                VStack(alignment: .leading, spacing: 4) {
                    Text(name).font(.system(size: 16, weight: .semibold)).foregroundColor(.init(hex: "0F172A"))
                    Text(role).font(.system(size: 14)).foregroundColor(.init(hex: "64748B"))
                    Text(email).font(.system(size: 12)).foregroundColor(.init(hex: "94A3B8"))
                }
            }
            StatusBadgeView(status: status)
        }
        .padding(24)
        .background(Color.white)
        .cornerRadius(12)
        .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.init(hex: "E2E8F0"), lineWidth: 1))
    }
}

Variables de diseño como CSS custom properties

Esta es una de las funciones más poderosas del motor de exportación. Si usas variables de diseño en OpenPencil, el export las convierte en CSS custom properties que puedes usar en toda tu aplicación.

Configurar variables de diseño

En OpenPencil, panel de Variables:

Colección: "Brand"
  color/primary/50:  #EFF6FF
  color/primary/100: #DBEAFE
  color/primary/500: #3B82F6
  color/primary/600: #2563EB
  color/primary/900: #1E3A8A

Colección: "Typography"
  font/size/xs:   12px
  font/size/sm:   14px
  font/size/base: 16px
  font/size/lg:   18px
  font/size/xl:   20px
  font/size/2xl:  24px

Colección: "Spacing"
  spacing/1: 4px
  spacing/2: 8px
  spacing/4: 16px
  spacing/8: 32px

Exportar tokens como CSS

op tokens export diseño.op --format css --out ./src/styles/tokens.css

Resultado:

/* tokens.css — generado por OpenPencil */
:root {
  /* Brand */
  --color-primary-50: #EFF6FF;
  --color-primary-100: #DBEAFE;
  --color-primary-500: #3B82F6;
  --color-primary-600: #2563EB;
  --color-primary-900: #1E3A8A;

  /* Typography */
  --font-size-xs: 0.75rem;
  --font-size-sm: 0.875rem;
  --font-size-base: 1rem;
  --font-size-lg: 1.125rem;
  --font-size-xl: 1.25rem;
  --font-size-2xl: 1.5rem;

  /* Spacing */
  --spacing-1: 0.25rem;
  --spacing-2: 0.5rem;
  --spacing-4: 1rem;
  --spacing-8: 2rem;
}

/* Dark mode */
[data-theme="dark"] {
  --color-primary-50: #1E3A8A;
  --color-primary-100: #1E40AF;
  --color-primary-500: #60A5FA;
  --color-primary-600: #93C5FD;
  --color-primary-900: #EFF6FF;
}

Integración con Tailwind CSS v4

Con Tailwind v4, puedes usar las CSS custom properties directamente en el config:

/* tailwind.css */
@import "tailwindcss";
@import "./tokens.css";

@theme {
  --color-primary-*: initial;
  --color-primary-50: var(--color-primary-50);
  --color-primary-100: var(--color-primary-100);
  --color-primary-500: var(--color-primary-500);
  --color-primary-600: var(--color-primary-600);
  --color-primary-900: var(--color-primary-900);
}

Ahora puedes usar bg-primary-600, text-primary-50, etc. en tus componentes.

Exportar una pantalla completa vs un componente

La diferencia es importante:

Exportar un componente

Un componente en OpenPencil es un nodo de tipo Component (el símbolo de diamante). Tiene:

op export react --file diseño.op --component "ProductCard" --out ./src/components

Exportar una pantalla (frame)

Un frame exportado genera el layout completo de la pantalla. Es útil para páginas o vistas completas:

op export react --file diseño.op --frame "Dashboard" --out ./src/pages

El código de un frame exportado puede contener componentes en línea (sin extraer a archivos separados) o puede pedirle que extraiga automáticamente:

op export react \
  --file diseño.op \
  --frame "Dashboard" \
  --out ./src \
  --extract-components  # Extrae cada componente a su propio archivo
  --components-dir ./src/components

Integración en el flujo de desarrollo diario

Workflow recomendado para equipos

flowchart LR
    A[Diseñador<br/>OpenPencil] -->|Commit .op| B[Repositorio Git]
    B -->|CI exporta| C[Código generado<br/>src/components/generated]
    C -->|Desarrollador<br/>extiende| D[Componentes finales<br/>src/components]
    D -->|Build| E[Producción]

Separar código generado del código manual

Una práctica recomendada es mantener el código generado separado del código escrito manualmente:

src/
  components/
    generated/     ← Generado por OpenPencil, no editar manualmente
      UserCard.tsx
      Button.tsx
      ProductCard.tsx
    ui/            ← Tu código personalizado que extiende los generados
      UserCard.tsx (re-exporta y extiende el generado)
// src/components/ui/UserCard.tsx
// Extiende el componente generado con lógica adicional
import { UserCard as UserCardBase } from '../generated/UserCard'
import { useUserStatus } from '@/hooks/useUserStatus'

interface UserCardProps {
  userId: string
}

export function UserCard({ userId }: UserCardProps) {
  const user = useUser(userId)
  const status = useUserStatus(userId)
  
  return (
    <UserCardBase
      name={user.name}
      role={user.role}
      email={user.email}
      avatarUrl={user.avatar}
      status={status}
    />
  )
}

Script de sincronización con Git hooks

#!/bin/bash
# .husky/post-merge — sincroniza diseños al hacer pull
if git diff HEAD~1 --name-only | grep -q '\.op$'; then
  echo "📐 Diseños actualizados, exportando componentes..."
  op export react \
    --file designs/components.op \
    --out src/components/generated \
    --typescript \
    --quiet
  echo "✅ Componentes sincronizados"
fi

Calidad del código generado

OpenPencil genera código con estas garantías:

Lo que el código generado NO incluye (por diseño):

El código generado es puramente presentacional, lo que facilita la separación de concerns.

Resumen

En este capítulo has aprendido:

En el próximo capítulo aprenderemos sobre la colaboración en tiempo real con WebRTC y Yjs.