22 KiB
US-FUND-008: UI/UX Base y Sistema de Diseño
Epic: MAI-001 - Fundamentos del Sistema Story Points: 3 Prioridad: Baja Dependencias:
- US-FUND-004 (Infraestructura Base)
Estado: Pendiente Asignado a: Frontend Lead + UI/UX Designer
📋 Historia de Usuario
Como usuario del sistema Quiero una interfaz intuitiva, consistente y visualmente atractiva Para navegar y trabajar de forma eficiente en la plataforma de gestión de obra.
🎯 Contexto y Objetivos
Contexto
Este documento define el sistema de diseño base de la aplicación. Incluye:
- Paleta de colores (primary, secondary, neutrals)
- Tipografía (fuentes, tamaños, weights)
- Espaciado y grid (sistema de 8px)
- Componentes reutilizables (Button, Input, Card, etc.)
- Estados de carga (Skeletons, Spinners)
- Estados vacíos (Empty States)
- Notificaciones (Toasts, Alerts)
- Responsive design (mobile-first)
Objetivos
- ✅ Diseño consistente en toda la aplicación
- ✅ Componentes reutilizables para acelerar desarrollo
- ✅ Paleta de colores definida y documentada
- ✅ Tipografía clara y legible
- ✅ Estados de loading bien definidos
- ✅ Responsive en desktop, tablet y mobile
- ✅ Accesible (WCAG 2.1 AA)
✅ Criterios de Aceptación
CA-1: Paleta de Colores
Dado la aplicación en ejecución Cuando se visualizan componentes Entonces:
-
✅ Color primario (construcción/obra):
- Primary:
#E97A20(naranja construcción) - Primary Hover:
#D46B17 - Primary Light:
#FFF4E6
- Primary:
-
✅ Colores de estado:
- Success:
#10B981(verde) - Warning:
#F59E0B(amarillo) - Error:
#EF4444(rojo) - Info:
#3B82F6(azul)
- Success:
-
✅ Colores neutrales:
- Gray-50 a Gray-900 (escala de grises)
CA-2: Tipografía
Dado cualquier página de la aplicación Cuando se visualiza texto Entonces:
-
✅ Fuente principal:
Inter(Google Fonts) -
✅ Fallback:
system-ui, -apple-system, sans-serif -
✅ Tamaños de texto:
text-xs: 12pxtext-sm: 14pxtext-base: 16pxtext-lg: 18pxtext-xl: 20pxtext-2xl: 24pxtext-3xl: 30pxtext-4xl: 36px
-
✅ Pesos (weights):
- Regular: 400
- Medium: 500
- Semibold: 600
- Bold: 700
CA-3: Componentes Reutilizables
Dado el sistema de componentes Cuando se utiliza en cualquier página Entonces están disponibles:
- ✅ Button (variants: primary, secondary, outline, ghost, destructive)
- ✅ Input (text, email, password, number)
- ✅ Select (dropdown)
- ✅ Checkbox y Radio
- ✅ Card (contenedor con sombra)
- ✅ Badge (etiquetas de estado)
- ✅ Table (tablas de datos)
- ✅ Modal/Dialog
- ✅ Dropdown Menu
- ✅ Tabs
- ✅ Tooltip
CA-4: Estados de Loading
Dado una operación asíncrona en ejecución Cuando se están cargando datos Entonces:
- ✅ Botones muestran spinner cuando están en loading
- ✅ Listas muestran skeleton loaders
- ✅ Páginas completas muestran spinner centrado
- ✅ No se permiten doble-clicks durante loading
CA-5: Estados Vacíos
Dado una lista sin datos Cuando se visualiza la página Entonces:
- ✅ Se muestra ilustración o icono grande
- ✅ Mensaje descriptivo: "No hay proyectos todavía"
- ✅ Call-to-action: Botón "Crear Proyecto"
- ✅ No se muestra tabla/grid vacío
CA-6: Notificaciones (Toasts)
Dado una acción exitosa/fallida Cuando se completa Entonces:
- ✅ Toast aparece en top-right
- ✅ Auto-dismiss después de 5 segundos
- ✅ Se puede cerrar manualmente (X)
- ✅ Iconos según tipo (success: ✓, error: ✗, warning: ⚠, info: ℹ)
- ✅ Colores según tipo
CA-7: Responsive Design
Dado la aplicación en diferentes dispositivos Cuando se ajusta el viewport Entonces:
-
✅ Desktop (≥1024px):
- Sidebar visible
- Grid de 12 columnas
- Tablas completas
-
✅ Tablet (768px - 1023px):
- Sidebar colapsable
- Grid de 8 columnas
- Tablas con scroll horizontal
-
✅ Mobile (<768px):
- Sidebar como menú hamburguesa
- Grid de 4 columnas
- Tablas adaptadas (cards)
- Inputs full-width
🔧 Especificación Técnica Detallada
1. Tailwind Configuration
Archivo: apps/frontend/tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: '#E97A20',
foreground: '#FFFFFF',
hover: '#D46B17',
light: '#FFF4E6',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
success: {
DEFAULT: '#10B981',
foreground: '#FFFFFF',
},
warning: {
DEFAULT: '#F59E0B',
foreground: '#FFFFFF',
},
error: {
DEFAULT: '#EF4444',
foreground: '#FFFFFF',
},
info: {
DEFAULT: '#3B82F6',
foreground: '#FFFFFF',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
spacing: {
18: '4.5rem',
72: '18rem',
84: '21rem',
96: '24rem',
},
},
},
plugins: [require('tailwindcss-animate')],
};
2. CSS Variables
Archivo: apps/frontend/src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: 'rlig' 1, 'calt' 1;
}
}
3. Button Component (shadcn/ui)
Archivo: apps/frontend/src/components/ui/button.tsx
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Loader2 } from 'lucide-react';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary-hover',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading = false, children, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={loading || props.disabled}
{...props}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{children}
</Comp>
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
Uso:
<Button>Default Button</Button>
<Button variant="outline">Outline Button</Button>
<Button variant="destructive">Delete</Button>
<Button loading>Guardando...</Button>
4. Card Component
Archivo: apps/frontend/src/components/ui/card.tsx
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
Uso:
<Card>
<CardHeader>
<CardTitle>Proyecto Residencial</CardTitle>
<CardDescription>150 unidades habitacionales</CardDescription>
</CardHeader>
<CardContent>
<p>Contenido del proyecto...</p>
</CardContent>
<CardFooter>
<Button>Ver Detalles</Button>
</CardFooter>
</Card>
5. Skeleton Loader
Archivo: apps/frontend/src/components/ui/skeleton.tsx
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
}
export { Skeleton };
Archivo: apps/frontend/src/components/skeletons/ProjectCardSkeleton.tsx
import { Card, CardHeader, CardContent, CardFooter } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function ProjectCardSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</CardContent>
<CardFooter>
<Skeleton className="h-10 w-24" />
</CardFooter>
</Card>
);
}
Uso:
{isLoading ? (
<ProjectCardSkeleton />
) : (
<ProjectCard project={project} />
)}
6. Empty State Component
Archivo: apps/frontend/src/components/ui/empty-state.tsx
import { Button } from '@/components/ui/button';
import { LucideIcon } from 'lucide-react';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted p-4">
<Icon className="h-10 w-10 text-muted-foreground" />
</div>
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
{description && <p className="mt-2 text-sm text-muted-foreground">{description}</p>}
{action && (
<Button onClick={action.onClick} className="mt-6">
{action.label}
</Button>
)}
</div>
);
}
Uso:
import { FolderKanban } from 'lucide-react';
{projects.length === 0 && (
<EmptyState
icon={FolderKanban}
title="No hay proyectos todavía"
description="Comienza creando tu primer proyecto de construcción"
action={{
label: 'Crear Proyecto',
onClick: () => navigate('/projects/new'),
}}
/>
)}
7. Toast Notifications (Sonner)
Instalación:
npm install sonner
Configuración en App.tsx:
import { Toaster } from 'sonner';
function App() {
return (
<>
{/* App content */}
<Toaster position="top-right" richColors />
</>
);
}
Uso:
import { toast } from 'sonner';
// Success
toast.success('Proyecto creado exitosamente');
// Error
toast.error('Error al guardar los cambios');
// Warning
toast.warning('El presupuesto excede el límite');
// Info
toast.info('Nuevo comentario en el proyecto');
// Loading
const toastId = toast.loading('Guardando cambios...');
// ... después de completar
toast.success('Cambios guardados', { id: toastId });
// Con acción
toast.success('Proyecto actualizado', {
action: {
label: 'Ver',
onClick: () => navigate(`/projects/${id}`),
},
});
8. Badge Component
Archivo: apps/frontend/src/components/ui/badge.tsx
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
success: 'border-transparent bg-success text-success-foreground',
warning: 'border-transparent bg-warning text-warning-foreground',
error: 'border-transparent bg-error text-error-foreground',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
Uso para estados de proyecto:
const statusBadgeVariant = {
planning: 'secondary',
active: 'success',
completed: 'default',
cancelled: 'error',
};
<Badge variant={statusBadgeVariant[project.status]}>
{project.status}
</Badge>
9. Confirmation Dialog Component
Archivo: apps/frontend/src/components/ui/confirmation-dialog.tsx
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface ConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
variant?: 'default' | 'destructive';
}
export function ConfirmationDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirmar',
cancelLabel = 'Cancelar',
onConfirm,
variant = 'default',
}: ConfirmationDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className={variant === 'destructive' ? 'bg-error hover:bg-error/90' : ''}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
Uso:
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
<ConfirmationDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
title="¿Eliminar proyecto?"
description="Esta acción no se puede deshacer. El proyecto será eliminado permanentemente."
confirmLabel="Eliminar"
cancelLabel="Cancelar"
variant="destructive"
onConfirm={async () => {
await deleteProject(projectId);
toast.success('Proyecto eliminado');
}}
/>
🧪 Test Cases
TC-UI-001: Botones con Loading
Pasos:
- Click en botón "Guardar"
- Observar estado durante request
Resultado esperado:
- ✅ Botón muestra spinner
- ✅ Botón está deshabilitado
- ✅ Texto cambia a "Guardando..."
- ✅ Doble-click no ejecuta acción dos veces
TC-UI-002: Empty State
Pasos:
- Navegar a
/projectssin proyectos creados
Resultado esperado:
- ✅ Se muestra icono de carpeta grande
- ✅ Título: "No hay proyectos todavía"
- ✅ Descripción visible
- ✅ Botón "Crear Proyecto" presente
TC-UI-003: Toast Notifications
Pasos:
- Crear un proyecto exitosamente
- Observar notificación
Resultado esperado:
- ✅ Toast aparece en top-right
- ✅ Color verde (success)
- ✅ Icono de checkmark
- ✅ Auto-dismiss después de 5 segundos
- ✅ Se puede cerrar manualmente
TC-UI-004: Responsive Design
Pasos:
- Abrir app en desktop (1920px)
- Reducir viewport a tablet (768px)
- Reducir viewport a mobile (375px)
Resultado esperado:
- ✅ Desktop: Sidebar visible, grid 12 columnas
- ✅ Tablet: Sidebar colapsable, grid 8 columnas
- ✅ Mobile: Menú hamburguesa, grid 4 columnas
TC-UI-005: Skeleton Loaders
Pasos:
- Navegar a
/projects - Observar durante carga
Resultado esperado:
- ✅ Se muestran 3 skeletons de cards
- ✅ Animación de pulse
- ✅ Una vez cargados, se reemplazan por cards reales
📋 Tareas de Implementación
Frontend
-
UI-FE-001: Configurar Tailwind CSS con theme custom
- Estimado: 1h
-
UI-FE-002: Instalar y configurar shadcn/ui
- Estimado: 1h
-
UI-FE-003: Crear componentes base (Button, Input, Card)
- Estimado: 2h
-
UI-FE-004: Crear Skeleton loaders para cards y tablas
- Estimado: 1.5h
-
UI-FE-005: Crear EmptyState component
- Estimado: 1h
-
UI-FE-006: Configurar Sonner para toasts
- Estimado: 0.5h
-
UI-FE-007: Crear Badge component con variants
- Estimado: 0.5h
-
UI-FE-008: Crear ConfirmationDialog component
- Estimado: 1h
-
UI-FE-009: Documentar sistema de diseño en Storybook (opcional)
- Estimado: 3h
Design
-
UI-DESIGN-001: Definir paleta de colores final
- Estimado: 2h
-
UI-DESIGN-002: Crear design tokens en Figma
- Estimado: 2h
Total estimado: ~15.5 horas
🔗 Dependencias
Depende de
- ✅ US-FUND-004 (Infraestructura Base)
Bloqueante para
- Todas las páginas y features del sistema
- UX completa
📊 Definición de Hecho (DoD)
- ✅ Tailwind configurado con paleta de colores
- ✅ Componentes base instalados (shadcn/ui)
- ✅ Button component con loading state
- ✅ Card component funcional
- ✅ Skeleton loaders implementados
- ✅ EmptyState component reutilizable
- ✅ Toasts configurados (Sonner)
- ✅ Badge component con variants
- ✅ ConfirmationDialog funcional
- ✅ Responsive en desktop, tablet, mobile
- ✅ Todos los test cases (TC-UI-001 a TC-UI-005) pasan
📝 Notas Adicionales
Accesibilidad
- ✅ Contraste de colores WCAG AA (4.5:1)
- ✅ Focus visible en todos los elementos interactivos
- ✅ ARIA labels en iconos
- ✅ Keyboard navigation funcional
Dark Mode (Opcional)
- ✅ CSS variables preparadas para dark mode
- ✅ Toggle en user settings
- ✅ Persistencia en localStorage
Icons
- ✅ Librería: Lucide React
- ✅ Tamaños estándar: 16px, 20px, 24px
- ✅ Stroke width: 2px
Fecha de creación: 2025-11-17 Última actualización: 2025-11-17 Versión: 1.0