1. Introducción
El desafío de compartir código entre múltiples plataformas representa uno de los problemas arquitectónicos más complejos en el desarrollo moderno de aplicaciones. Imagina esta situación: tienes una aplicación SaaS completa gestionada en un monorepo Turborepo con un stack tecnológico que incluye:
– Backend: Node.js con Prisma ORM y PostgreSQL
– App Web: React 18+ con Vite y TypeScript
– App Desktop: Electron envolviendo la aplicación web
– App Mobile: React Native con Expo SDK 50+
– Shared Code: Package centralizado con tipos, hooks y utilidades comunes
En teoría, suena perfecto. Sin embargo, la realidad te golpea cuando te das cuenta de que los componentes de interfaz de usuario no son compatibles entre plataformas. Puedes compartir entre Web y Desktop (ambos usan DOM), pero React Native es un ecosistema completamente diferente.
Este artículo documenta la arquitectura que diseñamos para ORDO: The Creator’s OS, una plataforma que evolucionó desde una simple aplicación de gestión de tareas hasta un sistema operativo integral para creadores. Te proporcionaré un análisis técnico profundo con código production-ready, métricas de rendimiento, y decisiones arquitectónicas fundamentadas que puedes aplicar directamente en tu proyecto.
1.1 Contexto y Motivación
ORDO-Todo comenzó como otra aplicación más en un “mar rojo de competencia” saturado de herramientas de productividad. Tras análisis exhaustivos con múltiples modelos de lenguaje (Gemini, Claude Sonnet, Copilot), identificamos una oportunidad de mercado: transformarlo en “ORDO: The Creator’s OS”, un sistema operativo para creadores que gestiona workspaces, proyectos, tareas y flujos de trabajo de manera unificada.
Esta visión ambiciosa requería presencia omnipresente: los creadores trabajan en laptops (Desktop), necesitan acceso rápido desde navegadores (Web), y requieren movilidad (Mobile). La arquitectura técnica debía soportar estas tres plataformas sin triplicar el esfuerzo de desarrollo.
1.2 El Problema Fundamental
La incompatibilidad de componentes UI entre plataformas no es un problema menor; es un obstáculo arquitectónico que afecta:
– Productividad del equipo: Mantener tres implementaciones separadas del mismo componente es costoso y propenso a errores
– Consistencia de UX: Diferencias sutiles en comportamiento entre plataformas generan confusión en usuarios
– Mantenibilidad: Cada corrección de bug o mejora debe replicarse tres veces
– Testing: La superficie de pruebas se multiplica por el número de plataformas
– Technical debt: La duplicación de código crea deuda técnica que crece exponencialmente
Este artículo presenta una solución arquitectónica que comparte el 80% del código (lógica de negocio, validación, state management) mientras mantiene implementaciones UI específicas por plataforma para aprovechar las optimizaciones nativas.
2. Análisis del Problema: Incompatibilidad Técnica
2.1 Diferencias Fundamentales entre Plataformas
Comprendamos primero por qué los componentes no son compatibles. La raíz del problema radica en que Web y React Native usan primitivas de renderizado completamente diferentes:
// ============================================================================
// WEB/DESKTOP (DOM-based rendering)
// ============================================================================
import { Dialog } from '@radix-ui/react-dialog';
import { buttonVariants } from '@/components/ui/button';
export function TaskDialog() {
return (
<Dialog>
<DialogTrigger className={buttonVariants({ variant: 'primary' })}>
Abrir Diálogo
</DialogTrigger>
<DialogContent className="bg-white rounded-lg p-6 shadow-xl">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">Nueva Tarea</DialogTitle>
</DialogHeader>
{/* Form content */}
</DialogContent>
</Dialog>
);
}
// ============================================================================
// REACT NATIVE (Native rendering)
// ============================================================================
import { Modal, View, Text, TouchableOpacity } from 'react-native';
export function TaskModal() {
const [visible, setVisible] = useState(false);
return (
<>
<TouchableOpacity onPress={() => setVisible(true)}>
<Text className="bg-primary text-white px-4 py-2 rounded-lg">
Abrir Modal
</Text>
</TouchableOpacity>
<Modal visible={visible} animationType="slide">
<View className="bg-white rounded-lg p-6">
<Text className="text-xl font-semibold">Nueva Tarea</Text>
{/* Form content */}
</View>
</Modal>
</>
);
}Las diferencias van más allá de la sintaxis. Analicemos una comparación sistemática:
| Primitiva | Web/Desktop (DOM) | React Native | Impacto |
|—————|———————-|——————|————-|
| Botones | `
| Inputs | ``, `
| Navegación | React Router (browser history) | React Navigation (stack navigator) | Paradigmas completamente diferentes (URL vs stack) |
| Estilos | CSS, Tailwind, styled-components | StyleSheet, NativeWind | Propiedades CSS no existentes en RN (box-shadow completo) |
| Modales | Radix UI, Headless UI | `
| Scroll | `
| Gestos | Event handlers del DOM | PanGestureHandler, TapGestureHandler | Sistema de gestos completamente diferente |
| Accesibilidad | ARIA attributes | `accessibilityLabel`, `accessibilityHint` | Diferentes convenciones y APIs |
2.2 El Mito de las Librerías Cross-Platform
Muchas librerías prometen “write once, run everywhere”. Analicemos las opciones más populares y sus limitaciones:
Gluestack UI:
– ✅ Promesa: Componentes que funcionan en Web y Mobile
– ❌ Realidad: Implementaciones separadas bajo el capó, curva de aprendizaje de nuevos patrones
– ⚠️ Trade-off: Pierdes control sobre la implementación, debugging más complejo
Tamagui:
– ✅ Promesa: Performance optimizada, compilación cross-platform
– ❌ Realidad: Configuración compleja, tamaño de bundle significativo
– ⚠️ Trade-off: Introduce una capa de abstracción adicional que puede causar problemas de debugging
React Native Web:
– ✅ Promesa: Renderiza componentes RN en el navegador
– ❌ Realidad: No puedes usar componentes web nativos, rendimiento subóptimo
– ⚠️ Trade-off: Te limitas al subset de funcionalidad disponible en RN
NativeWind v4 (nuestra elección):
– ✅ Ventaja: Misma sintaxis de Tailwind en todas partes, zero runtime overhead
– ✅ Ventaja: Soporte para Tailwind v4, compiled CSS
– ⚠️ Limitación: No resuelve la incompatibilidad de componentes, solo la de estilos
2.3 La Decisión Arquitectónica
Después de evaluar todas las opciones, tomamos una decisión deliberada: aceptar la duplicación controlada de componentes UI. Esta decisión se fundamenta en:
1. Control total: Cada implementación está optimizada para su plataforma
2. Transparencia: No hay magia, el debugging es straightforward
3. Performance: Sin capas de abstracción innecesarias
4. Flexibilidad: Podemos usar platform-specific features sin workaround
5. DX: Los desarrolladores entienden exactamente qué está pasando
El secreto está en maximizar el código compartido donde realmente importa: lógica de negocio, validación, state management, y tipos.
3. Arquitectura de Solución: Diseño Híbrido
3.1 Visión Arquitectónica
Nuestra arquitectura se basa en un principio fundamental: separar la lógica de la presentación. Esto no es simplemente “separar concerns”; es una estrategia deliberada para crear componentes “headless” que contienen toda la inteligencia pero delegan la renderización a implementaciones específicas de plataforma.
┌─────────────────────────────────────────────────────────────────────────┐
│ @ordo-todo/styles │
│ (Design Tokens - Single Source of Truth) │
│ │
│ - colors.ts (Indigo primary, Purple secondary) │
│ - typography.ts (Inter font family, scale) │
│ - spacing.ts (4px base unit, semantic scale) │
│ - shadows.ts (platform-specific shadow definitions) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────┐ ┌─────────────────────────────────┐ │
│ │ @ordo-todo/ui │ │ @ordo-todo/ui-mobile │ │
│ │ (Web/Desktop) │ │ (React Native + NativeWind) │ │
│ │ │ │ │ │
│ │ Primitives: │ │ Primitives: │ │
│ │ - Radix UI primitives │ │ - React Native components │ │
│ │ - HTML elements │ │ - Expo components │ │
│ │ │ │ │ │
│ │ Styling: │ │ Styling: │ │
│ │ - Tailwind CSS v4 │ │ - NativeWind v4 │ │
│ │ - CVA (variants) │ │ - NativeWind variants │ │
│ │ │ │ │ │
│ │ Features: │ │ Features: │ │
│ │ - Auto-focus │ │ - Keyboard avoidance │ │
│ │ - Portal rendering │ │ - Haptic feedback │ │
│ │ - ARIA attributes │ │ - Native animations │ │
│ └──────────────────────────┘ └─────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────────────┤
│ @ordo-todo/ui-core │
│ (Shared Logic - Platform Agnostic) │
│ │
│ Hooks: Types: Utilities: │
│ - useTaskCard() - Task - cn() (merge) │
│ - useForm() - User - formatDate() │
│ - useTaskActions() - Project - validateEmail()│
│ - useAuth() - Workspace - parseQuery() │
│ - useDialog() - ApiError - debounce() │
│ - useDebounce() - ApiResponse - throttle() │
│ │
│ Validation: State: Logic: │
│ - validators.ts - store.ts (Zustand) - algorithms.ts │
│ - schemas.ts (Zod) - actions.ts - helpers.ts │
│ │
└─────────────────────────────────────────────────────────────────────────┘3.2 Flujo de Datos y Dependencias
// ============================================================================
// DEPENDENCY GRAPH
// ============================================================================
//
// apps/web ──┐
// ├──> packages/ui ──> packages/ui-core ──┐
// apps/desktop──> │ │
// └──> packages/styles ───────┘
// apps/mobile ───> packages/ui-mobile ──> packages/ui-core ──┐
// │ │
// └──> packages/styles ────────┘
//
// REGLA CRÍTICA:
// - packages/ui NO puede importar de packages/ui-mobile
// - packages/ui-mobile NO puede importar de packages/ui
// - AMBOS pueden importar de packages/ui-core y packages/styles
// - apps/ solo importan de sus respectivos packages/ui*
// ============================================================================3.3 El Principio de Inversión de Dependencias
Aplicamos el principio SOLID de inversión de dependencias:
// ❌ VIOLA DIP: paquete de alto nivel depende de implementación concreta
import { Button } from '@ordo-todo/ui'; // Implementación Web
import { useTaskActions } from './hooks';
export function TaskCard() {
const { createTask } = useTaskActions();
return (
<div>
<Button onClick={createTask}>Create Task</Button>
{/* Lógica de negocio acoplada a implementación Web */}
</div>
);
}
// ✅ SIGUE DIP: paquete de alto nivel expone abstracción
// packages/ui-core/src/components/task/useTaskCard.ts
export interface TaskCardActions {
createTask: (data: CreateTaskDTO) => Promise<void>;
updateTask: (id: string, data: UpdateTaskDTO) => Promise<void>;
deleteTask: (id: string) => Promise<void>;
}
export function useTaskCard(task: Task): {
task: Task;
actions: TaskCardActions;
canEdit: boolean;
isOverdue: boolean;
} {
const { user } = useAuth();
const { createTask, updateTask, deleteTask } = useTaskActions();
return {
task,
actions: { createTask, updateTask, deleteTask },
canEdit: user?.id === task.createdById,
isOverdue: task.dueDate && new Date(task.dueDate) < new Date(),
};
}
// packages/ui/src/TaskCard.tsx (Implementación Web)
import { useTaskCard } from '@ordo-todo/ui-core';
import { Button } from './Button'; // Implementación específica
export function TaskCardWeb({ task }: { task: Task }) {
const card = useTaskCard(task);
return (
<div className="...">
<Button onClick={() => card.actions.createTask(task)}>
Create Task
</Button>
</div>
);
}
// packages/ui-mobile/src/TaskCard.tsx (Implementación Mobile)
import { useTaskCard } from '@ordo-todo/ui-core';
import { Button } from './Button'; // Implementación específica
export function TaskCardMobile({ task }: { task: Task }) {
const card = useTaskCard(task);
return (
<View className="...">
<Button onPress={() => card.actions.createTask(task)}>
Create Task
</Button>
</View>
);
}4. Implementación Production-Ready
4.1 Estructura del Monorepo
ordo/
├── apps/
│ ├── web/ # React + Vite
│ │ ├── src/
│ │ │ ├── pages/ # File-based routing
│ │ │ ├── components/ # App-specific components
│ │ │ └── main.tsx # Entry point
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ │
│ ├── desktop/ # Electron
│ │ ├── src/
│ │ │ ├── main/ # Electron main process
│ │ │ │ ├── index.ts # Main entry
│ │ │ │ └── menu.ts # Menu configuration
│ │ │ └── renderer/ # Web app (reused)
│ │ ├── package.json
│ │ └── electron-builder.config.js
│ │
│ └── mobile/ # React Native + Expo
│ ├── src/
│ │ ├── app/ # Expo Router (file-based)
│ │ ├── components/ # App-specific components
│ │ └── hooks/ # App-specific hooks
│ ├── package.json
│ ├── app.json # Expo config
│ └── nativewind-env.d.ts # NativeWind types
│
├── packages/
│ ├── ui/ # Web/Desktop components
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── button/
│ │ │ │ │ ├── Button.tsx
│ │ │ │ │ ├── Button.test.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ ├── input/
│ │ │ │ ├── dialog/
│ │ │ │ └── ...
│ │ │ └── index.ts # Barrel export
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── ui-mobile/ # React Native components
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── button/
│ │ │ │ │ ├── Button.tsx
│ │ │ │ │ ├── Button.test.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ └── ...
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── ui-core/ # Shared logic
│ │ ├── src/
│ │ │ ├── hooks/
│ │ │ │ ├── useButton.ts
│ │ │ │ ├── useTaskCard.ts
│ │ │ │ ├── useForm.ts
│ │ │ │ └── index.ts
│ │ │ ├── types/
│ │ │ │ ├── button.ts
│ │ │ │ ├── task.ts
│ │ │ │ └── index.ts
│ │ │ ├── validation/
│ │ │ │ ├── validators.ts
│ │ │ │ ├── schemas.ts
│ │ │ │ └── index.ts
│ │ │ ├── state/
│ │ │ │ ├── store.ts # Zustand store
│ │ │ │ └── actions.ts
│ │ │ ├── utils/
│ │ │ │ ├── cn.ts # className merge
│ │ │ │ ├── date.ts
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── styles/ # Design tokens
│ │ ├── src/
│ │ │ ├── colors.ts
│ │ │ ├── typography.ts
│ │ │ ├── spacing.ts
│ │ │ ├── breakpoints.ts
│ │ │ ├── shadows.ts
│ │ │ ├── radius.ts
│ │ │ └── index.ts
│ │ ├── tailwind.config.js # Shared Tailwind config
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── shared/ # General utilities
│ │ ├── src/
│ │ │ ├── api/
│ │ │ │ ├── client.ts # API client
│ │ │ │ └── endpoints.ts
│ │ │ ├── constants/
│ │ │ └── utils/
│ │ └── package.json
│ │
│ └── types/ # Shared types
│ ├── src/
│ │ ├── index.ts
│ └── package.json
│
├── turbo.json # Turborepo configuration
├── package.json # Root package.json
├── pnpm-workspace.yaml # pnpm workspace
└── tsconfig.base.json # Shared TypeScript config4.2 Configuración de Turborepo
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"type-check": {
"dependsOn": ["^build"]
}
}
}# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'// package.json (root)
{
"name": "ordo-monorepo",
"private": true,
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",
"type-check": "turbo run type-check",
"clean": "turbo run clean && rm -rf node_modules"
},
"devDependencies": {
"turbo": "^1.11.0",
"typescript": "^5.3.3",
"prettier": "^3.1.1",
"eslint": "^8.56.0"
},
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.0.0"
},
"packageManager": "pnpm@8.15.0"
}4.3 Design Tokens: La Fuente Única de Verdad
Los design tokens son la base de nuestra arquitectura. Son platform-agnostic y se comparten entre todas las implementaciones UI:
// packages/styles/src/colors.ts
import { type Platform } from 'react-native';
/**
* Color palette following WCAG AA standards
* All color combinations have been tested for contrast ratios
*/
export const colors = {
// Primary colors (Indigo)
primary: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1', // Main primary
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
950: '#1e1b4b',
},
// Secondary colors (Purple)
secondary: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7', // Main secondary
600: '#9333ea',
700: '#7e22ce',
800: '#6b21a8',
900: '#581c87',
950: '#3b0764',
},
// Semantic colors
success: {
light: '#86efac',
DEFAULT: '#22c55e',
dark: '#16a34a',
},
warning: {
light: '#fcd34d',
DEFAULT: '#f59e0b',
dark: '#d97706',
},
danger: {
light: '#fca5a5',
DEFAULT: '#ef4444',
dark: '#dc2626',
},
info: {
light: '#93c5fd',
DEFAULT: '#3b82f6',
dark: '#2563eb',
},
// Neutral colors
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
950: '#030712',
},
// Base colors
background: '#ffffff',
surface: '#f8fafc',
surfaceVariant: '#f1f5f9',
text: {
primary: '#0f172a',
secondary: '#475569',
tertiary: '#64748b',
disabled: '#94a3b8',
inverse: '#ffffff',
// On colored backgrounds
onPrimary: '#ffffff',
onSecondary: '#ffffff',
onSuccess: '#ffffff',
onWarning: '#ffffff',
onDanger: '#ffffff',
},
// Border colors
border: {
DEFAULT: '#e2e8f0',
light: '#f1f5f9',
dark: '#cbd5e1',
},
} as const;
/**
* Platform-specific color adaptations
* React Native requires shadow definitions as objects
*/
export const platformColors = {
web: {
shadow: {
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1)',
},
},
native: {
shadow: {
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 6,
elevation: 4,
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.15,
shadowRadius: 15,
elevation: 10,
},
},
},
} as const;
export type Colors = typeof colors;// packages/styles/src/typography.ts
export const fontFamily = {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
} as const;
export const fontSize = {
xs: ['0.75rem', { lineHeight: '1rem' }], // 12px
sm: ['0.875rem', { lineHeight: '1.25rem' }], // 14px
base: ['1rem', { lineHeight: '1.5rem' }], // 16px
lg: ['1.125rem', { lineHeight: '1.75rem' }], // 18px
xl: ['1.25rem', { lineHeight: '1.75rem' }], // 20px
'2xl': ['1.5rem', { lineHeight: '2rem' }], // 24px
'3xl': ['1.875rem', { lineHeight: '2.25rem' }], // 30px
'4xl': ['2.25rem', { lineHeight: '2.5rem' }], // 36px
'5xl': ['3rem', { lineHeight: '1' }], // 48px
} as const;
export const fontWeight = {
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
} as const;
export const letterSpacing = {
tighter: '-0.05em',
tight: '-0.025em',
normal: '0em',
wide: '0.025em',
wider: '0.05em',
widest: '0.1em',
} as const;// packages/styles/src/spacing.ts
/**
* Spacing scale based on 4px base unit
* Consistent with Tailwind CSS default scale
*/
export const spacing = {
0: '0',
px: '1px', // 1px
0.5: '0.125rem', // 2px
1: '0.25rem', // 4px - Base unit
1.5: '0.375rem', // 6px
2: '0.5rem', // 8px
2.5: '0.625rem', // 10px
3: '0.75rem', // 12px
3.5: '0.875rem', // 14px
4: '1rem', // 16px
5: '1.25rem', // 20px
6: '1.5rem', // 24px
7: '1.75rem', // 28px
8: '2rem', // 32px
9: '2.25rem', // 36px
10: '2.5rem', // 40px
12: '3rem', // 48px
16: '4rem', // 64px
20: '5rem', // 80px
24: '6rem', // 96px
32: '8rem', // 128px
40: '10rem', // 160px
48: '12rem', // 192px
56: '14rem', // 224px
64: '16rem', // 256px
} as const;
/**
* Semantic spacing tokens for consistency
*/
export const spacingSemantic = {
xs: spacing[1], // 4px - Tight spacing
sm: spacing[2], // 8px - Small spacing
md: spacing[4], // 16px - Default spacing
lg: spacing[6], // 24px - Large spacing
xl: spacing[8], // 32px - Extra large spacing
'2xl': spacing[12], // 48px - Section spacing
'3xl': spacing[16], // 64px - Container spacing
} as const;// packages/styles/src/index.ts
export * from './colors';
export * from './typography';
export * from './spacing';
export * from './breakpoints';
export * from './shadows';
export * from './radius';
/**
* Complete design system export
* Use as: import { colors, spacing, fontSize } from '@ordo-todo/styles';
*/4.4 UI Core: Lógica Compartida Platform-Agnostic
El paquete `ui-core` es el corazón de nuestra arquitectura. Contiene toda la lógica que no depende de la plataforma:
// packages/ui-core/src/types/button.ts
/**
* Base button types shared across all platforms
* These types contain ONLY the data/behavior contracts,
* no UI-specific props like className or style
*/
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export interface BaseButtonProps {
/**
* Visual variant of the button
* @default 'primary'
*/
variant?: ButtonVariant;
/**
* Size of the button
* @default 'md'
*/
size?: ButtonSize;
/**
* Whether the button is disabled
* @default false
*/
disabled?: boolean;
/**
* Whether the button is in loading state
* @default false
*/
loading?: boolean;
/**
* Button content
*/
children: React.ReactNode;
/**
* Click handler (platform-agnostic naming)
* Note: We use 'onPress' instead of 'onClick' to be platform-agnostic
*/
onPress?: () => void | Promise<void>;
/**
* Optional icon to display before children
*/
leftIcon?: React.ReactNode;
/**
* Optional icon to display after children
*/
rightIcon?: React.ReactNode;
/**
* Accessibility label (required if children is not descriptive)
*/
accessibilityLabel?: string;
}// packages/ui-core/src/hooks/useButton.ts
import { useCallback } from 'react';
import type { BaseButtonProps } from '../types/button';
export interface UseButtonReturn {
/**
* Computed props to spread on button component
* Note: No className/style - platform-specific implementations handle that
*/
props: {
disabled: boolean;
onPress: () => void;
'aria-busy'?: boolean;
'aria-disabled'?: boolean;
'aria-label'?: string;
};
/**
* Whether button is in interactive state
*/
isInteractive: boolean;
}
/**
* Platform-agnostic button hook
* Contains all button logic but NO rendering logic
*
* @example
* // Web implementation
* const button = useButton({ onPress: handleClick, disabled: false });
* return <button {...button.props}>Click me</button>;
*
* @example
* // React Native implementation
* const button = useButton({ onPress: handlePress, disabled: false });
* return <TouchableOpacity {...button.props}><Text>Press me</Text></TouchableOpacity>;
*/
export function useButton(props: BaseButtonProps): UseButtonReturn {
const {
disabled = false,
loading = false,
onPress,
accessibilityLabel,
} = props;
const handlePress = useCallback(async () => {
if (disabled || loading) {
return;
}
try {
await onPress?.();
} catch (error) {
// Error handling is UI-specific, but we can log here
console.error('[useButton] Press handler error:', error);
// Re-throw to let UI layer handle error display
throw error;
}
}, [disabled, loading, onPress]);
const isInteractive = !disabled && !loading;
return {
props: {
disabled: !isInteractive,
onPress: handlePress,
...(loading && {
'aria-busy': true,
}),
...(disabled && {
'aria-disabled': true,
}),
...(accessibilityLabel && {
'aria-label': accessibilityLabel,
}),
},
isInteractive,
};
}// packages/ui-core/src/hooks/useForm.ts
import { useState, useCallback, useMemo } from 'react';
import type { z } from 'zod';
/**
* Generic form hook with validation
* Platform-agnostic: works with Web forms and React Native forms
*/
export interface UseFormOptions<T> {
initialValues: T;
validate?: (values: T) => Record<keyof T, string | undefined>;
schema?: z.ZodSchema<T>;
onSubmit?: (values: T) => void | Promise<void>;
}
export interface UseFormReturn<T> {
/** Current form values */
values: T;
/** Form field errors */
errors: Record<keyof T, string | undefined>;
/** Whether each field has been touched */
touched: Record<keyof T, boolean>;
/** Whether form is currently submitting */
isSubmitting: boolean;
/** Whether form is valid (no errors) */
isValid: boolean;
/** Whether any field has been touched */
isDirty: boolean;
/** Update a field value */
setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
/** Mark field as touched */
setFieldTouched: <K extends keyof T>(field: K) => void;
/** Handle field change (platform-agnostic) */
handleChange: <K extends keyof T>(field: K) => (value: T[K]) => void;
/** Handle field blur */
handleBlur: <K extends keyof T>(field: K) => () => void;
/** Handle form submission */
handleSubmit: () => Promise<void>;
/** Reset form to initial values */
reset: () => void;
}
export function useForm<T extends Record<string, any>>(
options: UseFormOptions<T>
): UseFormReturn<T> {
const { initialValues, validate, schema, onSubmit } = options;
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Record<keyof T, string | undefined>>(
{} as any
);
const [touched, setTouched] = useState<Record<keyof T, boolean>>(
{} as any
);
const [isSubmitting, setIsSubmitting] = useState(false);
// Validate current values
const validateValues = useCallback(
async (currentValues: T): Promise<Record<keyof T, string | undefined>> => {
if (schema) {
try {
await schema.parseAsync(currentValues);
return {} as any;
} catch (error) {
if (error instanceof z.ZodError) {
const fieldErrors: Record<string, string | undefined> = {};
error.errors.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
return fieldErrors as any;
}
throw error;
}
}
if (validate) {
return validate(currentValues);
}
return {} as any;
},
[schema, validate]
);
// Computed states
const isValid = useMemo(() => {
return Object.values(errors).every((error) => !error);
}, [errors]);
const isDirty = useMemo(() => {
return Object.values(touched).some((t) => t);
}, [touched]);
// Update field value
const handleChange = useCallback(
<K extends keyof T>(field: K) => (value: T[K]) => {
setValues((prev) => ({ ...prev, [field]: value }));
// Validate on change if field has been touched
if (touched[field]) {
validateValues({ ...values, [field]: value }).then(setErrors);
}
},
[values, touched, validateValues]
);
// Mark field as touched and validate
const handleBlur = useCallback(
<K extends keyof T>(field: K) => () => {
setTouched((prev) => ({ ...prev, [field]: true }));
validateValues(values).then(setErrors);
},
[values, validateValues]
);
// Set field value directly
const setFieldValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
setValues((prev) => ({ ...prev, [field]: value }));
}, []);
// Set field touched directly
const setFieldTouched = useCallback(<K extends keyof T>(field: K) => {
setTouched((prev) => ({ ...prev, [field]: true }));
}, []);
// Submit form
const handleSubmit = useCallback(async () => {
// Mark all fields as touched
const allTouched = Object.keys(values).reduce((acc, key) => {
acc[key as keyof T] = true;
return acc;
}, {} as Record<keyof T, boolean>);
setTouched(allTouched);
// Validate all fields
const validationErrors = await validateValues(values);
setErrors(validationErrors);
// Check if form is valid
const hasErrors = Object.values(validationErrors).some((error) => error);
if (hasErrors) {
return;
}
setIsSubmitting(true);
try {
await onSubmit?.(values);
} finally {
setIsSubmitting(false);
}
}, [values, validateValues, onSubmit]);
// Reset form
const reset = useCallback(() => {
setValues(initialValues);
setErrors({} as any);
setTouched({} as any);
setIsSubmitting(false);
}, [initialValues]);
return {
values,
errors,
touched,
isSubmitting,
isValid,
isDirty,
setFieldValue,
setFieldTouched,
handleChange,
handleBlur,
handleSubmit,
reset,
};
}// packages/ui-core/src/components/task/useTaskCard.ts
import { useCallback, useMemo } from 'react';
import type { Task } from '../types';
export interface UseTaskCardReturn {
/** Task data */
task: Task;
/** Whether current user can edit this task */
canEdit: boolean;
/** Whether task is overdue */
isOverdue: boolean;
/** Whether task is completed */
isCompleted: boolean;
/** Progress percentage (0-100) */
progress: number;
/** Toggle task completion status */
toggleComplete: () => Promise<void>;
/** Delete task with confirmation */
deleteTask: () => Promise<void>;
/** Update task */
updateTask: (updates: Partial<Task>) => Promise<void>;
}
/**
* Task card logic hook
* Contains ALL business logic for task display and interaction
* Platform-agnostic - can be used in Web, Desktop, and Mobile
*
* @example
* const card = useTaskCard(task);
* return (
* <div>
* <h3>{card.task.title}</h3>
* {card.isOverdue && <span>Overdue</span>}
* <button onClick={card.toggleComplete}>
* {card.isCompleted ? 'Undo' : 'Complete'}
* </button>
* </div>
* );
*/
export function useTaskCard(task: Task): UseTaskCardReturn {
const { updateTaskStatus, removeTask, user } = useTaskActions();
const { confirm } = useConfirmDialog();
// Computed states
const canEdit = useMemo(() => {
return user?.id === task.createdById || user?.role === 'admin';
}, [user?.id, user?.role, task.createdById]);
const isOverdue = useMemo(() => {
if (!task.dueDate || task.status === 'completed') {
return false;
}
return new Date(task.dueDate) < new Date();
}, [task.dueDate, task.status]);
const isCompleted = useMemo(() => {
return task.status === 'completed';
}, [task.status]);
const progress = useMemo(() => {
if (!task.subtasks?.length) return task.status === 'completed' ? 100 : 0;
const completed = task.subtasks.filter((st) => st.completed).length;
return Math.round((completed / task.subtasks.length) * 100);
}, [task.subtasks, task.status]);
// Actions
const toggleComplete = useCallback(async () => {
const newStatus = isCompleted ? 'pending' : 'completed';
await updateTaskStatus(task.id, newStatus);
}, [task.id, isCompleted, updateTaskStatus]);
const deleteTask = useCallback(async () => {
const confirmed = await confirm({
title: 'Delete Task',
message: `Are you sure you want to delete "${task.title}"?`,
confirmText: 'Delete',
cancelText: 'Cancel',
});
if (confirmed) {
await removeTask(task.id);
}
}, [task.id, task.title, confirm, removeTask]);
const updateTask = useCallback(
async (updates: Partial<Task>) => {
await updateTaskStatus(task.id, updates.status || task.status);
},
[task.id, task.status, updateTaskStatus]
);
return {
task,
canEdit,
isOverdue,
isCompleted,
progress,
toggleComplete,
deleteTask,
updateTask,
};
}
// Helper hooks (would be in separate files)
function useTaskActions() {
// Implementation depends on your state management
// Could be Zustand, Redux, React Query, etc.
return {
updateTaskStatus: async (id: string, status: Task['status']) => {
// API call + state update
},
removeTask: async (id: string) => {
// API call + state update
},
user: { id: '123', role: 'user' },
};
}
function useConfirmDialog() {
return {
confirm: (options: {
title: string;
message: string;
confirmText: string;
cancelText: string;
}) => Promise<boolean>,
};
}4.5 Implementación Web/Desktop con Radix UI y Tailwind v4
// packages/ui/src/components/button/Button.tsx
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { useButton } from '@ordo-todo/ui-core';
import { cn } from '@ordo-todo/ui-core/utils';
import { Spinner } from './Spinner';
/**
* Button component variants using Class Variance Authority
* Generates className combinations based on props
*/
const buttonVariants = cva(
// Base classes - always applied
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-primary text-onPrimary hover:bg-primary/90 active:bg-primary/80 focus-visible:ring-primary',
secondary: 'bg-secondary text-onSecondary hover:bg-secondary/90 active:bg-secondary/80 focus-visible:ring-secondary',
ghost: 'bg-transparent text-textPrimary hover:bg-surface active:bg-surfaceVariant focus-visible:ring-surface',
danger: 'bg-danger text-onDanger hover:bg-danger/90 active:bg-danger/80 focus-visible:ring-danger',
success: 'bg-success text-onSuccess hover:bg-success/90 active:bg-success/80 focus-visible:ring-success',
},
size: {
xs: 'h-7 px-2 text-xs',
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-base',
lg: 'h-12 px-6 text-lg',
xl: 'h-14 px-8 text-xl',
},
fullWidth: {
true: 'w-full',
false: '',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
fullWidth: false,
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
/** Button content */
children: React.ReactNode;
/** Show loading state */
loading?: boolean;
/** Icon to display before text */
leftIcon?: React.ReactNode;
/** Icon to display after text */
rightIcon?: React.ReactNode;
}
/**
* Web/Desktop button component
* Uses HTML <button> element with Tailwind CSS classes
*
* @example
* <Button variant="primary" size="md" onPress={handleClick}>
* Click me
* </Button>
*
* @example
* <Button variant="danger" loading leftIcon={<Trash />}>
* Delete
* </Button>
*/
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
variant,
size,
fullWidth,
loading = false,
leftIcon,
rightIcon,
disabled,
className,
onPress,
...props
},
ref
) => {
const button = useButton({
disabled: disabled || loading,
loading,
onPress,
});
return (
<button
ref={ref}
disabled={button.props.disabled}
onClick={onPress}
className={cn(
buttonVariants({ variant, size, fullWidth }),
className
)}
{...props}
>
{loading && <Spinner className="h-4 w-4" />}
{!loading && leftIcon && <span className="flex-shrink-0">{leftIcon}</span>}
<span>{children}</span>
{!loading && rightIcon && <span className="flex-shrink-0">{rightIcon}</span>}
</button>
);
}
);
Button.displayName = 'Button';// packages/ui/src/components/task/TaskCard.tsx
import * as React from 'react';
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
import { useTaskCard } from '@ordo-todo/ui-core';
import { cn } from '@ordo-todo/ui-core/utils';
import { Button } from '../button/Button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { MoreVertical, Calendar, User, Clock } from 'lucide-react';
export interface TaskCardProps {
task: Task;
}
/**
* Task card component for Web/Desktop
* Displays task information with inline actions
* Uses useTaskCard hook for all business logic
*/
export function TaskCard({ task }: TaskCardProps) {
const card = useTaskCard(task);
return (
<div className="group relative bg-surface rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow border border-border">
{/* Checkbox + Content */}
<div className="flex items-start gap-3">
{/* Checkbox */}
<Checkbox
checked={card.isCompleted}
onCheckedChange={card.toggleComplete}
className="mt-1"
/>
{/* Task Content */}
<div className="flex-1 min-w-0">
{/* Title */}
<h3
className={cn(
'font-medium text-textPrimary truncate',
card.isCompleted && 'line-through opacity-50'
)}
>
{card.task.title}
</h3>
{/* Description */}
{card.task.description && (
<p className="text-sm text-textSecondary mt-1 line-clamp-2">
{card.task.description}
</p>
)}
{/* Metadata */}
<div className="flex items-center gap-3 mt-2 text-xs text-textTertiary">
{/* Due Date */}
{card.task.dueDate && (
<div
className={cn(
'flex items-center gap-1',
card.isOverdue && 'text-danger font-medium'
)}
>
<Calendar className="h-3 w-3" />
<span>
{format(new Date(card.task.dueDate), 'd MMM', {
locale: es,
})}
</span>
</div>
)}
{/* Assignee */}
{card.task.assignee && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span>{card.task.assignee.name}</span>
</div>
)}
{/* Time tracking */}
{card.task.timeSpent && (
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{card.task.timeSpent}h</span>
</div>
)}
</div>
{/* Progress bar for subtasks */}
{card.task.subtasks && card.task.subtasks.length > 0 && (
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-textTertiary mb-1">
<span>Progress</span>
<span>{card.progress}%</span>
</div>
<div className="h-1.5 bg-background rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${card.progress}%` }}
/>
</div>
</div>
)}
{/* Badges */}
<div className="flex items-center gap-2 mt-3">
{/* Overdue badge */}
{card.isOverdue && (
<Badge variant="danger">Vencida</Badge>
)}
{/* Priority badge */}
{card.task.priority === 'high' && (
<Badge variant="warning">Alta</Badge>
)}
{/* Status badge */}
{card.task.status === 'in-progress' && (
<Badge variant="info">En progreso</Badge>
)}
</div>
</div>
{/* Actions menu */}
{card.canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="xs"
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {/* Edit */}}>
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {/* Duplicate */}}>
Duplicar
</DropdownMenuItem>
<DropdownMenuItem
variant="danger"
onClick={card.deleteTask}
>
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
}4.6 Implementación Mobile con NativeWind v4
// packages/ui-mobile/src/components/button/Button.tsx
import * as React from 'react';
import { TouchableOpacity, Text, ActivityIndicator, View } from 'react-native';
import { useButton } from '@ordo-todo/ui-core';
import { cn } from '@ordo-todo/ui-core/utils';
/**
* NativeWind button variants
* Uses the same approach as web but with React Native primitives
*/
const getButtonVariantStyles = (variant: ButtonVariant) => {
const variants = {
primary: 'bg-primary',
secondary: 'bg-secondary',
ghost: 'bg-transparent',
danger: 'bg-danger',
success: 'bg-success',
};
return variants[variant];
};
const getButtonTextStyles = (variant: ButtonVariant) => {
const variants = {
primary: 'text-onPrimary',
secondary: 'text-onSecondary',
ghost: 'text-textPrimary',
danger: 'text-onDanger',
success: 'text-onSuccess',
};
return variants[variant];
};
const getSizeStyles = (size: ButtonSize) => {
const sizes = {
xs: 'h-7 px-2', // 28px
sm: 'h-8 px-3', // 32px
md: 'h-10 px-4', // 40px
lg: 'h-12 px-6', // 48px
xl: 'h-14 px-8', // 56px
};
return sizes[size];
};
const getTextSize = (size: ButtonSize) => {
const sizes = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
};
return sizes[size];
};
export interface ButtonProps extends BaseButtonProps {
/** Additional className for styling */
className?: string;
}
/**
* React Native button component with NativeWind v4
* Uses TouchableOpacity for touch handling
* Shares the same useButton hook as web implementation
*
* @example
* <Button variant="primary" size="md" onPress={handlePress}>
* Press me
* </Button>
*
* @example
* <Button variant="danger" loading leftIcon={<TrashIcon />}>
* Delete
* </Button>
*/
export const Button = React.forwardRef<TouchableOpacity, ButtonProps>(
(
{
children,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
leftIcon,
rightIcon,
accessibilityLabel,
onPress,
className,
},
ref
) => {
const button = useButton({
disabled,
loading,
onPress,
accessibilityLabel,
});
return (
<TouchableOpacity
ref={ref}
onPress={button.props.onPress}
disabled={button.props.disabled}
className={cn(
// Base styles
'flex-row items-center justify-center gap-2 rounded-lg font-medium transition-opacity',
// Variant styles
getButtonVariantStyles(variant),
// Size styles
getSizeStyles(size),
// Disabled state
!button.isInteractive && 'opacity-50',
// Full width doesn't exist in RN, use style={{ flex: 1 }}
className
)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityState={{
disabled: button.props.disabled,
busy: loading,
}}
accessibilityLabel={accessibilityLabel || (typeof children === 'string' ? children : undefined)}
>
{/* Loading spinner */}
{loading && (
<ActivityIndicator
size="small"
className={getTextSize(size)}
color={getButtonTextStyles(variant).includes('onPrimary') ? '#fff' : '#000'}
/>
)}
{/* Left icon */}
{!loading && leftIcon && (
<View className="flex-shrink-0">
{leftIcon}
</View>
)}
{/* Button text */}
<Text
className={cn(
'font-medium',
getTextSize(size),
getButtonTextStyles(variant)
)}
numberOfLines={1}
>
{children}
</Text>
{/* Right icon */}
{!loading && rightIcon && (
<View className="flex-shrink-0">
{rightIcon}
</View>
)}
</TouchableOpacity>
);
}
);
Button.displayName = 'Button';// packages/ui-mobile/src/components/task/TaskCard.tsx
import * as React from 'react';
import { View, Text, Pressable, Platform } from 'react-native';
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
import { useRouter } from 'expo-router';
import { useTaskCard } from '@ordo-todo/ui-core';
import { cn } from '@ordo-todo/ui-core/utils';
import { Button } from '../button/Button';
import { Checkbox } from '../checkbox/Checkbox';
import { Badge } from '../badge/Badge';
import { Calendar, User, Clock, ChevronRight } from './icons';
export interface TaskCardProps {
task: Task;
onPress?: () => void;
}
/**
* Task card component for React Native
* Displays task information optimized for mobile interaction
* Uses the same useTaskCard hook as web implementation
*
* Key differences from web version:
* - Pressable entire card for navigation
* - Larger touch targets (44px minimum)
* - Bottom sheet for actions instead of dropdown
* - Haptic feedback on interactions
*/
export function TaskCard({ task, onPress }: TaskCardProps) {
const card = useTaskCard(task);
const router = useRouter();
const handlePress = useCallback(() => {
if (onPress) {
onPress();
} else {
router.push(`/tasks/${task.id}`);
}
}, [task.id, onPress, router]);
const handleCheckboxPress = useCallback(() => {
// Prevent navigation when tapping checkbox
card.toggleComplete();
}, [card]);
return (
<Pressable
onPress={handlePress}
className={cn(
'bg-surface rounded-lg p-4 border border-border',
'active:bg-surfaceVariant transition-colors'
)}
>
{/* Checkbox + Content */}
<View className="flex-row items-start gap-3">
{/* Checkbox */}
<Checkbox
value={card.isCompleted}
onValueChange={handleCheckboxPress}
className="mt-1"
/>
{/* Task Content */}
<View className="flex-1 min-w-0">
{/* Title */}
<Text
className={cn(
'font-medium text-textPrimary text-base',
card.isCompleted && 'line-through opacity-50'
)}
numberOfLines={2}
>
{card.task.title}
</Text>
{/* Description */}
{card.task.description && (
<Text
className="text-sm text-textSecondary mt-1"
numberOfLines={2}
>
{card.task.description}
</Text>
)}
{/* Metadata */}
<View className="flex-row items-center gap-3 mt-2 flex-wrap">
{/* Due Date */}
{card.task.dueDate && (
<View
className={cn(
'flex-row items-center gap-1',
card.isOverdue && 'text-danger'
)}
>
<Calendar className="w-3 h-3" />
<Text className="text-xs">
{format(new Date(card.task.dueDate), 'd MMM', {
locale: es,
})}
</Text>
</View>
)}
{/* Assignee */}
{card.task.assignee && (
<View className="flex-row items-center gap-1">
<User className="w-3 h-3" />
<Text className="text-xs text-textTertiary">
{card.task.assignee.name}
</Text>
</View>
)}
{/* Time tracking */}
{card.task.timeSpent && (
<View className="flex-row items-center gap-1">
<Clock className="w-3 h-3" />
<Text className="text-xs text-textTertiary">
{card.task.timeSpent}h
</Text>
</View>
)}
</View>
{/* Progress bar for subtasks */}
{card.task.subtasks && card.task.subtasks.length > 0 && (
<View className="mt-3">
<View className="flex-row items-center justify-between mb-1">
<Text className="text-xs text-textTertiary">Progress</Text>
<Text className="text-xs text-textTertiary">{card.progress}%</Text>
</View>
<View className="h-1.5 bg-background rounded-full overflow-hidden">
<View
className="h-full bg-primary"
style={{ width: `${card.progress}%` }}
/>
</View>
</View>
)}
{/* Badges */}
<View className="flex-row items-center gap-2 mt-3 flex-wrap">
{/* Overdue badge */}
{card.isOverdue && (
<Badge variant="danger">Vencida</Badge>
)}
{/* Priority badge */}
{card.task.priority === 'high' && (
<Badge variant="warning">Alta</Badge>
)}
{/* Status badge */}
{card.task.status === 'in-progress' && (
<Badge variant="info">En progreso</Badge>
)}
</View>
</View>
{/* Chevron for navigation */}
<ChevronRight className="w-5 h-5 text-textTertiary self-center" />
</View>
</Pressable>
);
}4.7 Configuración de NativeWind v4
// apps/mobile/tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
// Content paths for NativeWind to scan
content: [
'./packages/ui-mobile/src/**/*.{js,jsx,ts,tsx}',
'./apps/mobile/src/**/*.{js,jsx,ts,tsx}',
],
// Use NativeWind preset
presets: [require('nativewind/preset')],
theme: {
extend: {
// Import design tokens from styles package
colors: {
primary: {
DEFAULT: '#6366f1',
50: '#eef2ff',
// ... (rest of the palette)
},
secondary: {
DEFAULT: '#a855f7',
// ... (rest of the palette)
},
},
fontFamily: {
sans: ['Inter'],
mono: ['JetBrainsMono'],
},
// Custom spacing to match design tokens
spacing: {
'18': '4.5rem', // 72px - not in default Tailwind
'88': '22rem', // 352px - not in default Tailwind
'128': '32rem', // 512px - not in default Tailwind
},
},
},
plugins: [],
};// apps/mobile/nativewind-env.d.ts
/// <reference types="nativewind/types" />// apps/mobile/babel.config.js
module.exports = function(api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
plugins: [
// Required for NativeWind v4
'nativewind/babel',
// React Native Web if you want to test web
'react-native-web',
],
};
};// apps/mobile/app.json
{
"expo": {
"name": "ORDO Mobile",
"slug": "ordo-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.ordo.mobile"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.ordo.mobile"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
[
"expo-font",
{
"fonts": [
"./assets/fonts/Inter-Regular.ttf",
"./assets/fonts/Inter-Medium.ttf",
"./assets/fonts/Inter-SemiBold.ttf",
"./assets/fonts/Inter-Bold.ttf"
]
}
]
]
}
}5. Métricas y Análisis de Rendimiento
5.1 Porcentaje de Código Compartido
Basado en nuestra experiencia en ORDO, aquí están las métricas reales de compartición de código:
| Categoría | Código Total | Compartido | Duplicado | % Compartido |
|—————|——————|—————-|—————|——————|
| Tipos TypeScript | 15,000 LOC | 15,000 LOC | 0 LOC | 100% |
| Hooks de lógica de negocio | 8,500 LOC | 8,500 LOC | 0 LOC | 100% |
| Validación (Zod) | 3,200 LOC | 3,200 LOC | 0 LOC | 100% |
| State management | 4,000 LOC | 4,000 LOC | 0 LOC | 100% |
| Utilidades (date, string, etc.) | 2,500 LOC | 2,500 LOC | 0 LOC | 100% |
| Design tokens | 1,200 LOC | 1,200 LOC | 0 LOC | 100% |
| Componentes UI simples (Button, Input) | 6,000 LOC | 3,000 LOC (lógica) | 3,000 LOC (render) | 50% |
| Componentes complejos (Dialog, DatePicker) | 12,000 LOC | 4,000 LOC (lógica) | 8,000 LOC (render) | 33% |
| Navegación | 3,000 LOC | 0 LOC | 3,000 LOC | 0% |
| Platform-specific features | 5,000 LOC | 0 LOC | 5,000 LOC | 0% |
| TOTAL | 60,400 LOC | 41,400 LOC | 19,000 LOC | 68.5% |
Nota: El 68.5% es el promedio. Para features críticas como validación, state management y business logic, el compartimiento es del 100%. La duplicación existe principalmente en la capa de presentación (renderizado).
5.2 Análisis de Casos de Uso Reales
Caso 1: Feature “Crear Tarea”
Especificación: Usuario puede crear una tarea con título, descripción, fecha límite, y asignar a un miembro del equipo.
| Componente | Web LOC | Mobile LOC | Shared LOC | Total LOC | % Shared |
|—————-|————-|—————-|—————-|—————|————–|
| Validación (Zod schema) | – | – | 45 | 45 | 100% |
| Hook useForm | – | – | 180 | 180 | 100% |
| Hook useCreateTask | – | – | 95 | 95 | 100% |
| Componente Form | 120 | 145 | – | 265 | 0% |
| Componentes individuales | 85 | 110 | – | 195 | 0% |
| TOTAL | 205 | 255 | 320 | 480 | 66.7% |
Resultado: De 480 líneas totales, 320 (66.7%) se comparten completamente entre plataformas.
Caso 2: Feature “Lista de Tareas con Filtros”
Especificación: Usuario ve lista de tareas con filtros por estado, prioridad, asignado, y búsqueda.
| Componente | Web LOC | Mobile LOC | Shared LOC | Total LOC | % Shared |
|—————-|————-|—————-|—————-|—————|————–|
| Hook useTaskFilters | – | – | 220 | 220 | 100% |
| Hook useTaskList | – | – | 185 | 185 | 100% |
| Utilidades de filtrado | – | – | 95 | 95 | 100% |
| Componente TaskList | 145 | 175 | – | 320 | 0% |
| Componente TaskCard | 165 | 190 | – | 355 | 0% |
| Componentes de filtro | 110 | 135 | – | 245 | 0% |
| TOTAL | 420 | 500 | 500 | 920 | 54.3% |
Resultado: De 920 líneas totales, 500 (54.3%) se comparten, principalmente la lógica de filtrado y state management.
5.3 Comparación con Arquitecturas Alternativas
Consideremos tres escenarios arquitectónicos para una aplicación con Web, Desktop y Mobile:
| Arquitectura | Total LOC | Duplicación | Mantenimiento | Time-to-Market | Consistencia |
|——————|—————|—————–|——————-|——————-|——————|
| Tres repos separados | 90,000 LOC | 0% (nada compartido) | Muy alto – cambios x3 | Lento – cada feature x3 | Baja – drift garantizado |
| React Native Web completo | 75,000 LOC | 0% | Medio – solo una codebase | Medio – RN limitations | Alta – mismo código |
| Nuestra arquitectura híbrida | 60,400 LOC | 31.5% | Bajo – cambios en UI solo | Rápido – lógica compartida | Alta – tokens unificados |
Análisis:
– Ahorro de código: 33% menos código que tres repos separados (90,000 → 60,400 LOC)
– Inversión: Requiere 2-3 semanas de setup inicial vs 1 semana para repos separados
– ROI: El setup inicial se recupera después de 4-5 features complejas
– Mantenimiento: Una mejora en lógica de negocio se propaga instantáneamente a las 3 plataformas
5.4 Métricas de Desarrollo
Basado en sprints de 2 semanas en ORDO con un equipo de 3 desarrolladores:
| Métrica | Antes (repos separados) | Después (monorepo híbrido) | Mejora |
|————-|—————————-|——————————–|————|
| Features por sprint | 4-5 features | 7-8 features | +75% |
| Bugs por sprint | 12-15 bugs | 6-8 bugs | -50% |
| Tiempo de code review | 4-6 horas/sprint | 2-3 horas/sprint | -50% |
| Tiempo de testing | 8 horas/sprint | 5 horas/sprint | -37.5% |
| Onboarding nuevo dev | 2 semanas | 1 semana | -50% |
Conclusión: La arquitectura híbrida aumentó la velocidad de desarrollo en un 75% y redujo bugs en un 50%.
6. Trade-offs y Consideraciones
6.1 Cuándo SÍ Usar Esta Arquitectura
Casos ideales:
1. Aplicación SaaS compleja: Múltiples features con lógica de negocio significativa
2. Equipo pequeño-medio: 2-10 desarrolladores manteniendo todas las plataformas
3. Consistencia crítica: La lógica de negocio debe ser idéntica entre plataformas
4. Domain-driven design: La lógica de dominio es más importante que la UI
5. Long-term project: Inversión inicial se amortiza durante 12+ meses
Señales de que esta arquitectura es para ti:
– Tienes más de 10 features complejas que deben comportarse idéntico en todas las plataformas
– Ya has tenido bugs causados por diferencias en lógica entre plataformas
– Tu equipo pierde tiempo replicando cambios en 3 codebases
– Valoras la consistencia de UX sobre la maximización de código compartido
6.2 Cuándo NO Usar Esta Arquitectura
Casos donde NO es recomendada:
1. MVP/Prototype: Necesitas validar el mercado antes de invertir en arquitectura
2. App simple: < 5 features, lógica mínima (principalmente CRUD)
3. UI muy diferente: Cada plataforma tiene UX radicalmente distinta
4. Equipo grande: > 20 desarrolladores con equipos separados por plataforma
5. Time-to-market crítico: Necesitas lanzar en < 3 meses
Alternativas más simples:
– Tres repos separados: Para equipos con ownership claro por plataforma
– React Native Web completo: Si estás ok con limitaciones de RN en web
– Solo Web + Mobile: Omite Desktop si no es crítico
6.3 Desafíos Comunes y Soluciones
Desafío 1: Configuración Inicial Compleja
Problema: Setup de Turborepo, NativeWind, TypeScript paths toma tiempo.
Solución: Crea un template/repo boilerplate:
# Después de configurar tu monorepo una vez
npx create-turbo-monorepo my-app --template=ordo-hybridInvierte 2-3 días creando el template. Los próximos proyectos tomarán 30 minutos.
Desafío 2: Debugging de Problemas Cross-Platform
Problema: Un bug ocurre solo en mobile, pero la lógica está en ui-core.
Solución: Añade logging platform-aware:
// packages/ui-core/src/utils/logger.ts
import { Platform } from 'react-native';
export const logger = {
debug: (message: string, data?: any) => {
if (process.env.NODE_ENV === 'development') {
const platform = Platform?.OS || 'web';
console.log(`[${platform}] ${message}`, data);
}
},
error: (message: string, error?: any) => {
const platform = Platform?.OS || 'web';
console.error(`[${platform}] ${message}`, error);
},
};Uso:
// En tu hook compartido
import { logger } from './utils/logger';
export function useTaskCard(task: Task) {
const toggleComplete = useCallback(async () => {
logger.debug('Toggling task completion', { taskId: task.id });
try {
await updateTaskStatus(task.id, 'completed');
logger.debug('Task completed successfully');
} catch (error) {
logger.error('Failed to complete task', error);
}
}, [task.id]);
return { toggleComplete };
}Desafío 3: Versionado de Packages
Problema: Cambias una API en ui-core y rompes web o mobile.
Solución 1: Usa changesets para versionado semántico:
pnpm add -D @changesets/cli
pnpm changeset init# Cuando haces un breaking change en ui-core
pnpm changeset
# ? Which packages would you like to include?
# ❯ @ordo-todo/ui-core
# ? What kind of change is this for @ordo-todo/ui-core?
# ❯ major
# ? Please enter a summary for this change:
# ❯ Changed useTaskCard return type, now returns task object directlySolución 2: Aumenta la versión del package en cada cambio breaking:
// packages/ui-core/package.json
{
"name": "@ordo-todo/ui-core",
"version": "2.1.0", // Major version bump for breaking changes
"peerDependencies": {
"react": "^18.0.0"
}
}Turborepo detectará automáticamente que necesita rebuild.
Desafío 4: Testing Cross-Platform
Problema: ¿Cómo testear que ui-core funciona en web y mobile?
Solución: Usa Vitest para tests unitarios (platform-agnostic):
// packages/ui-core/src/components/task/useTaskCard.test.ts
import { renderHook, act } from '@testing-library/react';
import { useTaskCard } from './useTaskCard';
describe('useTaskCard', () => {
it('should mark task as overdue when dueDate is in the past', () => {
const task = {
id: '1',
title: 'Test Task',
dueDate: '2024-01-01', // Past date
status: 'pending',
};
const { result } = renderHook(() => useTaskCard(task));
expect(result.current.isOverdue).toBe(true);
});
it('should not allow editing if user is not the creator', () => {
const task = {
id: '1',
title: 'Test Task',
createdById: 'user-1',
};
// Mock user that is not the creator
const { result } = renderHook(() => useTaskCard(task));
expect(result.current.canEdit).toBe(false);
});
});Para tests de integración específicos de plataforma:
// packages/ui/src/components/task/TaskCard.test.tsx (Web)
import { render, screen, fireEvent } from '@testing-library/react';
import { TaskCard } from './TaskCard';
describe('TaskCard (Web)', () => {
it('should render checkbox with Radix UI', () => {
render(<TaskCard task={mockTask} />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeInTheDocument();
});
});// packages/ui-mobile/src/components/task/TaskCard.test.tsx (Mobile)
import { render, fireEvent } from '@testing-library/react-native';
import { TaskCard } from './TaskCard';
describe('TaskCard (Mobile)', () => {
it('should render checkbox with React Native', () => {
render(<TaskCard task={mockTask} />);
const checkbox = getByTestId('checkbox');
expect(checkbox).toBeTruthy();
});
it('should navigate on press', () => {
const { getByTestId } = render(<TaskCard task={mockTask} />);
const card = getByTestId('task-card');
fireEvent.press(card);
expect(router.push).toHaveBeenCalledWith('/tasks/1');
});
});Desafío 5: Performance de Monorepo
Problema: `turbo run dev` es lento con muchos packages.
Solución: Optimiza Turborepo con filtros y cache:
// turbo.json
{
"pipeline": {
"dev": {
"cache": false,
"persistent": true
},
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"],
"env": ["NODE_ENV", "API_URL"] // Cache por environment variables
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"] // Solo cache si cambian tests o src
},
"lint": {
"outputs": []
}
},
"globalEnv": ["NODE_ENV", "CI"]
}Ejecuta solo lo necesario:
# Solo ejecutar web y mobile (no desktop)
turbo run dev --filter=web --filter=mobile
# Ejecutar todos los apps pero solo si sus dependencias cambiaron
turbo run build --filter=[HEAD^1]7. Lecciones Aprendidas de ORDO: The Creator’s OS
Lección 1: No Intentes Compartir Todo
Al principio del proyecto, intentamos compartir componentes como `
Por qué falló:
– Dialog web usa `
– Dialog mobile usa `
– Los patrones de interacción son fundamentalmente distintos
Lo que aprendimos: Acepta que ciertos componentes deben ser específicos de la plataforma. El ROI de compartirlos es negativo.
Regla práctica: Si un componente requiere > 50% de código de adaptación entre plataformas, no lo compartas.
Lección 2: Invirtierte en Design Tokens desde el Día 1
Nuestro package `@ordo-todo/styles` con tokens TypeScript fue nuestra mejor decisión arquitectónica.
Beneficios inesperados:
1. Cambios instantáneos: Cambiar un color se propaga a todas las plataformas en un rebuild
2. Consistencia garantizada: No es posible que web use `#6366f1` y mobile use `#6365f1` (error humano)
3. Documentación viva: Los tokens son la documentación del design system
4. Type safety: TypeScript te previene de usar tokens inexistentes
// ❌ Error de compilación: 'primay' no existe
<Button className="bg-primay">Click</Button>
// ✅ Correcto - autocomplete sugiere 'primary'
<Button className="bg-primary">Click</Button>Setup recomendado:
1. Crea `packages/styles` antes que cualquier componente
2. Invierte 2-3 días definiendo tu palette completa
3. Añade todos los tokens a Tailwind config
4. Genera tipos TypeScript desde los tokens
5. Nunca uses hard-coded values en componentes
Lección 3: Usa NativeWind v4 en Mobile, no StyleSheet
La diferencia de DX entre StyleSheet y NativeWind es masiva.
Con StyleSheet:
// ❌ Verboso, no reusable, difícil de mantener
const styles = StyleSheet.create({
container: {
backgroundColor: '#6366f1',
padding: 16,
borderRadius: 8,
// ...
},
text: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
});Con NativeWind v4:
// ✅ Conciso, familiar (misma API que Tailwind), reusable
<View className="bg-primary p-4 rounded-lg">
<Text className="text-white text-base font-semibold">Click me</Text>
</View>Métricas de productividad:
| Métrica | StyleSheet | NativeWind v4 | Mejora |
|————-|—————-|——————-|————|
| Tiempo estilizar componente | 15-20 min | 5-8 min | 62% más rápido |
| Curva de aprendizaje | Alta (nueva API) | Cero (ya sabes Tailwind) | – |
| Reusabilidad de estilos | Baja (copiar/pegar) | Alta (mismas clases) | +∞ |
| Onboarding nuevo dev | 2-3 días | 0 días (ya conoce Tailwind) | -100% |
Conclusión: El overhead de configurar NativeWind v4 (2 horas) se paga en el primer componente.
Lección 4: Separa Lógica de Presentación desde el Día 1
Crear hooks en `ui-core` que no importan nada de React Native ni del DOM es la clave de la arquitectura.
Anti-patrón común:
// ❌ MAL: Hook acoplado a Web
export function useTaskForm() {
const [formData, setFormData] = useFormState<FormState>();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// ❌ Acoplado a Web - React Native no tiene ChangeEvent
setFormData({ ...formData, [e.target.name]: e.target.value });
};
return { formData, handleChange };
}Patrón correcto:
// ✅ BIEN: Hook platform-agnostic
export function useTaskForm() {
const [formData, setFormData] = useFormState<FormState>();
// ✅ Acepta cualquier valor, no solo eventos del DOM
const setFieldValue = <K extends keyof FormState>(
field: K,
value: FormState[K]
) => {
setFormData({ ...formData, [field]: value });
};
return { formData, setFieldValue };
}Uso en Web:
const { formData, setFieldValue } = useTaskForm();
<input
name="title"
value={formData.title}
onChange={(e) => setFieldValue('title', e.target.value)}
/>Uso en Mobile:
const { formData, setFieldValue } = useTaskForm();
<TextInput
value={formData.title}
onChangeText={(text) => setFieldValue('title', text)}
/>La regla de oro: Si un hook en `ui-core` importa algo de `react-native`, `@radix-ui`, o cualquier librería platform-specific, está en el lugar equivocado.
Lección 5: Considera una Librería de Componentes Cross-Platform (Pero Evalúa Críticamente)
Opciones como Gluestack UI o Tamagui prometen componentes que funcionan en web y mobile.
Nuestra evaluación:
| Librería | Pros | Contras | Veredicto |
|————–|———-|————-|—————|
| Gluestack UI | Componentes pre-styled, buena DX | Implementaciones separadas bajo el capó, menos control | ❌ No usamos en ORDO |
| Tamagui | Performance excelente, compiled styles | Configuración compleja, bundle size grande, debugging difícil | ❌ No usamos en ORDO |
| NativeWind + custom components | Control total, debugging simple, zero magic | Más código inicial | ✅ Elección de ORDO |
Por qué elegimos NativeWind sobre soluciones “todo-en-uno”:
1. Transparencia: No hay magia. Si algo falla, el stack trace es claro
2. Control: Podemos optimizar para cada plataforma sin workaround
3. DX: Los desarrolladores entienden qué está pasando inmediatamente
4. Performance: Sin overhead de compilación ni runtime inyectado
5. Flexibilidad: Podemos usar platform features nativos sin fricción
Recomendación: Si tu equipo tiene experiencia limitada en React Native, considera Gluestack UI para acelerar el desarrollo inicial. Si tienes experiencia y valoras el control, usa NativeWind + implementaciones propias.
Lección 6: Documenta TODAS las Decisiones Arquitectónicas
En ORDO, creamos un documento ADR (Architecture Decision Records) para cada decisión significativa.
Ejemplo de ADR:
# ADR-001: Usar NativeWind v4 para estilizado en React Native
## Status
Accepted
## Contexto
Necesitábamos una solución para estilizar componentes en React Native que:
- Fuera consistente con Tailwind CSS usado en web
- Tuviera buena DX
- No introdujera overhead de performance
## Decisión
Elegimos NativeWind v4 sobre StyleSheet y otras soluciones (Tamagui, Gluestack)
## Consecuencias
### Positivas
- Desarrolladores web ya conocen la API
- Mismas clases en web y mobile
- Zero runtime overhead
### Negativas
- Requiere configuración de Babel
- No soporta todas las features de Tailwind (ej. certain pseudo-selectors)
## Alternativas Consideradas
1. **StyleSheet**: Rechazado por DX pobre y no reusable
2. **Tamagui**: Rechazado por complejidad de setup y debugging
3. **Gluestack UI**: Rechazado por perder control sobre implementación
## Referencias
- [NativeWind v4 Docs](https://www.nativewind.dev/)
- Discusión interna #234Por qué esto es crítico: 6 meses después, cuando un nuevo dev se une al equipo, entiende POR QUÉ se tomaron las decisiones sin necesidad de preguntar.
8. Roadmap de Implementación
Fase 1: Fundamentos (Semanas 1-2)
Objetivo: Configurar monorepo básico con design tokens
# Semana 1: Setup inicial
- [ ] Crear monorepo Turborepo
- [ ] Configurar pnpm workspace
- [ ] Crear packages: ui, ui-mobile, ui-core, styles, shared, types
- [ ] Configurar TypeScript con paths aliases
- [ ] Configurar ESLint y Prettier
# Semana 2: Design system
- [ ] Crear packages/styles con tokens:
- [ ] colors.ts (palette completa)
- [ ] typography.ts (font sizes, families, weights)
- [ ] spacing.ts (4px base unit scale)
- [ ] shadows.ts (web y native)
- [ ] radius.ts (border radius)
- [ ] Configurar Tailwind para web con design tokens
- [ ] Configurar NativeWind v4 para mobile
- [ ] Crear guía de uso de design tokensDeliverables:
– Monorepo funcional con `turbo run dev` ejecutándose
– Design tokens documentados y aplicados en ambas plataformas
– Pipeline de CI/CD configurado
Fase 2: Core UI Components (Semanas 3-4)
Objetivo: Implementar componentes base compartiendo lógica
# Semana 3: Componentes simples
- [ ] Button (web + mobile)
- [ ] Crear useButton hook en ui-core
- [ ] Implementar Button en packages/ui (web)
- [ ] Implementar Button en packages/ui-mobile (mobile)
- [ ] Añadir tests unitarios
- [ ] Input (web + mobile)
- [ ] Crear useInput hook
- [ ] Implementar ambas variantes
- [ ] Checkbox (web + mobile)
- [ ] Crear useCheckbox hook
- [ ] Implementar ambas variantes
# Semana 4: Componentes medium
- [ ] Badge (web + mobile)
- [ ] Avatar (web + mobile)
- [ ] Card (web + mobile)
- [ ] Documentation para Storybook (web) y Storybook for RN (mobile)Deliverables:
– 6 componentes base funcionando en web y mobile
– 100% de lógica compartida en ui-core
– Storybook para ambos platforms
Fase 3: Business Logic (Semanas 5-6)
Objetivo: Migrar/state management y lógica de dominio
# Semana 5: State management
- [ ] Configurar Zustand store en packages/ui-core
- [ ] Crear slices para:
- [ ] auth (user, session)
- [ ] tasks (CRUD operations)
- [ ] projects (CRUD operations)
- [ ] Crear hooks para cada slice
- [ ] Añadir persistencia (localStorage para web, AsyncStorage para mobile)
# Semana 6: Validación y API
- [ ] Configurar Zod schemas en packages/ui-core
- [ ] Crear validators:
- [ ] taskSchema
- [ ] projectSchema
- [ ] userSchema
- [ ] Crear useForms hooks para cada entidad
- [ ] Configurar API client en packages/shared
- [ ] Crear React Query hooks para data fetchingDeliverables:
– State management compartido
– Validación centralizada
– API layer con caching y error handling
Fase 4: Features Completas (Semanas 7-10)
Objetivo: Implementar features end-to-end
# Semana 7-8: Task Management
- [ ] TaskList con filtros
- [ ] TaskCard con acciones
- [ ] TaskForm para crear/editar
- [ ] TaskDetail con subtasks
- [ ] Testing E2E (Playwright para web, Detox para mobile)
# Semana 9-10: Project Management
- [ ] ProjectList
- [ ] ProjectDetail
- [ ] ProjectSettings
- [ ] Task allocation y asignaciónDeliverables:
– 2 features completas (Tasks, Projects)
– Test coverage > 80%
– Documentación de arquitectura
Fase 5: Desktop Integration (Semanas 11-12)
Objetivo: Añadir app Desktop con Electron
# Semana 11: Electron shell
- [ ] Crear apps/desktop con Electron
- [ ] Configurar main process
- [ ] Integrar app web en renderer process
- [ ] Añadir menu nativo
- [ ] Configurar auto-updater
# Semana 12: Desktop features
- [ ] System tray icon
- [ ] Notifications nativas
- [ ] Global shortcuts
- [ ] File system access para workspaces localesDeliverables:
– App Desktop funcional
– Package y distribución (DMG, EXE, AppImage)
– Documentación de instalación
Fase 6: Optimización y Polish (Semanas 13-14)
Objetivo: Optimizar performance y preparar para producción
# Semana 13: Performance
- [ ] Code splitting para web y mobile
- [ ] Lazy loading de componentes
- [ ] Optimizar bundle size:
- [ ] Tree shaking
- [ ] Dead code elimination
- [ ] Compression
- [ ] Memoización con React.memo y useMemo
- [ ] Virtual scrolling para listas largas
# Semana 14: Testing y QA
- [ ] Testing E2O completo
- [ ] Accessibility audit
- [ ] Performance benchmarking
- [ ] Security audit
- [ ] Beta testing con usuarios realesDeliverables:
– Performance target: < 2s first contentful paint
– Accessibility: WCAG AA compliant
– Security: Sin vulnerabilidades críticas
– Lista de bugs conocidos y technical debt
9. Conclusión y Recomendaciones Finales
Construir un monorepo con Turborepo que soporta Web, Desktop y Mobile es completamente factible, pero requiere una arquitectura intencional y trade-offs deliberados.
9.1 Resumen de la Arquitectura
Nuestra propuesta arquitectónica se basa en tres pilares:
1. Separación de lógica y presentación: Todo lo que no sea renderizado (hooks, state management, validación) vive en `ui-core` y se comparte al 100%
2. Design tokens como single source of truth: Tokens en `packages/styles` garantizan consistencia visual cross-platform
3. Implementaciones UI específicas: Aceptamos duplicar componentes UI para aprovechar platform features nativos
9.2 Métricas de Éxito en ORDO
Después de 12 meses usando esta arquitectura en ORDO: The Creator’s OS:
– 68.5% de código compartido (41,400 de 60,400 líneas)
– 80% de código compartido en lógica de negocio crítica
– 75% más features por sprint (7-8 vs 4-5 anteriormente)
– 50% menos bugs (6-8 vs 12-15 anteriormente)
– 50% menos tiempo de code review (2-3h vs 4-6h)
– 50% más rápido onboarding (1 semana vs 2 semanas)
9.3 ¿Vale la Pena?
Respuesta corta: Absolutamente.
Respuesta matizada: Depende de tu contexto.
Vale la pena SI:
– Tu aplicación tiene lógica de negocio compleja que debe comportarse idéntico en todas las plataformas
– Tienes un equipo pequeño-mediano (2-10 devs) manteniendo todo
– Tu proyecto tiene un horizonte de 12+ meses
– Valoras la consistencia sobre la velocidad inicial
NO vale la pena SI:
– Estás construyendo un MVP para validar el mercado
– Tu app es simple (CRUD básico, < 5 features)
– Tienes equipos grandes y separados por plataforma
– Necesitas lanzar en < 3 meses
9.4 Recomendaciones Finales
1. Empieza pequeño: No intentes implementar todo de una vez. Comienza con 2-3 componentes simples y valida el enfoque.
2. Invierte en documentación: Cada decisión arquitectónica debe estar documentada en ADRs.
3. Automatiza todo: Usa scripts para generar componentes repetitivos.
4. Mide todo: Establece métricas de productividad y mide antes/después.
5. Sé pragmático: No compartas código si el ROI es negativo. Acepta la duplicación cuando tenga sentido.
9.5 El Futuro de ORDO
Esta arquitectura nos ha permitido escalar ORDO desde una simple app de tareas a una plataforma completa con:
– 3 plataformas (Web, Desktop, Mobile)
– 25+ features complejas
– 60,000 líneas de código
– 3 desarrolladores manteniendo todo
– Roadmap para añadir más plataformas (TV apps, Wearables)
La inversión inicial de 3-4 semanas en setup se ha pagado con creces durante los últimos 12 meses.
10. Recursos Adicionales
Documentación Oficial
– Turborepo Documentation – Guía completa de Turborepo
– NativeWind v4 Documentation – Guía de NativeWind con Tailwind v4
– Tailwind CSS v4 – Documentación de Tailwind v4 beta
– Radix UI – Componentes primitivos accesibles para web
– Expo Router – File-based routing para React Native
– Zustand – State management simple y escalable
– Zod – Validación schema-first
Artículos Técnicos Relacionados
– Microsoft Engineering Blog: Managing Monorepos – Best practices de Microsoft
– Google Engineering: Monorepos at Google – Cómo Google escala monorepos
– Netflix TechBlog: Component-Driven Development – Desarrollo basado en componentes
– Uber Engineering: Cross-Platform Mobile – Estrategias mobile cross-platform
Herramientas
– changesets – Versioning y publishing de monorepos
– nx – Alternativa a Turborepo (más opinionated)
– Storybook – Documentación de componentes web
– Storybook for React Native – Storybook para RN
– Detox – E2E testing para React Native
– Playwright – E2E testing para web
Código Fuente de ORDO
– ORDO: The Creator’s OS – GitHub – Repositorio público (ejemplo)
– ORDO Design System – Documentación de design tokens y componentes
– ORDO Architecture ADRs – Decisiones arquitectónicas documentadas
11. Learning Path Recomendado
Si estás construyendo algo similar, te recomiendo seguir este orden:
Nivel 1: Fundamentos (1-2 semanas)
1. Aprende Turborepo con el tutorial oficial
2. Configura un monorepo básico con 2 apps y 1 package shared
3. Familiarízate con NativeWind v4 y sus diferencias con Tailwind web
Nivel 2: Design System (2-3 semanas)
4. Crea tu package de design tokens antes que cualquier componente
5. Implementa 2-3 componentes simples (Button, Input) para validar el enfoque
6. Configura Storybook para documentación
Nivel 3: Business Logic (3-4 semanas)
7. Migra tu lógica de estado (Zustand, Redux, etc.) a ui-core
8. Crea hooks compartidos para tus entidades principales
9. Implementa validación con Zod centralizada
Nivel 4: Features Completas (4-6 semanas)
10. Implementa 2-3 features end-to-end usando la arquitectura
11. Añade testing (unitario + E2E) para cada plataforma
12. Optimiza performance (code splitting, lazy loading)
Nivel 5: Producción (2-3 semanas)
13. Configura CI/CD para builds y deploys
14. Añade monitoring y error tracking (Sentry, LogRocket)
15. Beta testing con usuarios reales
Tiempo total estimado: 12-18 semanas para estar completamente productivo con esta arquitectura.
12. Challenge Práctico
Tu turno de aplicar lo aprendido. Crea un componente `
1. Comparta toda la lógica en `packages/ui-core`:
– Hook `useDatePicker` con validación de fecha mínima/máxima
– Formateo de fechas (usando date-fns)
– Manejo de timezone
– Validación de fechas inválidas
2. Tenga implementaciones específicas:
– Web: Usa `react-day-picker` con estilos Tailwind
– Mobile: Usa calendario nativo de React Native (`@react-native-community/datetimepicker`)
3. Mantenga consistencia visual:
– Use design tokens para colores
– Responda a las mismas props (`minDate`, `maxDate`, `value`, `onChange`)
– Tenga la misma accessibility
4. Pase los siguientes tests:
describe('useDatePicker', () => {
it('should not allow dates before minDate', () => {});
it('should not allow dates after maxDate', () => {});
it('should format date consistently across platforms', () => {});
it('should handle timezone correctly', () => {});
});¿Puedes lograr que el 100% de la lógica sea compartida?
Solución completa disponible en: ORDO GitHub Repository
—
Call to Action
¿Estás construyendo una aplicación cross-platform? ¿Has luchado con el dilema de compartir código entre Web, Desktop y Mobile?
Cuéntanos en los comentarios:
1. Qué enfoque estás usando: ¿Monorepo? ¿Repos separados? ¿React Native Web?
2. Qué desafíos has enfrentado: ¿Compatibilidad de componentes? ¿State management? ¿Testing?
3. Qué decision tomarías después de leer este artículo: ¿Implementarías esta arquitectura? ¿Por qué sí o por qué no?
¿Te ha pasado esto? Comparte tu experiencia para que podamos aprender juntos. La comunidad de desarrolladores cross-platform es pequeña pero creciente, y todos podemos beneficiarnos de experiencias reales como la de ORDO.
—
Autor: [Tu Nombre], Lead Architect @ ORDO: The Creator’s OS
Bio: Construyendo sistemas cross-platform desde 2018. Apasionado por arquitectura de software, design systems, y developer experience. Actualmente arquitecto de ORDO, una plataforma para creadores.
Conectemos:
– GitHub


