Exportación de código
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:
- Componentes:
PascalCase(por ejemplo:UserCard,PrimaryButton) - Layers internos:
kebab-case(por ejemplo:card-header,user-avatar) - Variantes:
ComponentName/VariantName(por ejemplo:Button/Primary,Button/Ghost)
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
- Selecciona el frame o componente a exportar
- En el panel derecho, sección “Export” (o click derecho → Export)
- Selecciona “React + Tailwind”
- 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:
- Frame “UserCard” con auto-layout vertical, padding 24px, gap 16px
- Frame “card-header” con auto-layout horizontal, gap 12px
- Ellipse “user-avatar” 48x48px, fill imagen, radius 50%
- Frame “user-info” con auto-layout vertical, gap 4px
- Text “user-name” 16px/600/slate-900
- Text “user-role” 14px/400/slate-500
- Text “user-email” 12px/400/slate-400
- Frame “card-footer” con auto-layout horizontal, gap 8px
- Rectangle “status-badge” con texto “Online” en verde
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:
- Nombre de componente en PascalCase
- Props inferidas de los overrides y variantes
- Código reutilizable e independiente
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:
- TypeScript strict: Todos los props tienen tipos explícitos
- Accesibilidad: Atributos ARIA básicos donde corresponde
- Sin estilos inline: Todo en clases de Tailwind
- Sin magic strings: Valores de enum para variantes
- Tree-shakeable: Cada componente es independiente
- Sin dependencias ocultas: Solo React (o el framework elegido) y Tailwind
Lo que el código generado NO incluye (por diseño):
- Lógica de negocio
- Llamadas a APIs
- Manejo de estado global
- Efectos secundarios
El código generado es puramente presentacional, lo que facilita la separación de concerns.
Resumen
En este capítulo has aprendido:
- Cómo preparar el diseño para una exportación de calidad
- Exportar a React + Tailwind, Vue 3, Svelte, Flutter, SwiftUI y React Native
- Convertir variables de diseño en CSS custom properties
- La diferencia entre exportar un componente y una pantalla completa
- Flujo de trabajo recomendado con separación de código generado/manual
- Automatización de la sincronización con Git hooks y CI/CD
En el próximo capítulo aprenderemos sobre la colaboración en tiempo real con WebRTC y Yjs.