feat: scaffold front-end architecture and import thinkpad animation asset sequence

This commit is contained in:
bryancito1090 2026-08-29 00:01:32 -05:00
commit a8cc90704c
1057 changed files with 17928 additions and 0 deletions

View File

@ -0,0 +1,639 @@
---
name: portfolio-backend
description: Parámetros, reglas y estándares de arquitectura backend en C# / .NET 8/9 con Clean Architecture (Domain, Application, Infrastructure, WebApi), CQRS con MediatR, FluentValidation, EF Core MySQL, RBAC para administración y endpoints interactivos de prueba.
---
# Estándar de Arquitectura Backend — Clean Architecture en .NET (Portafolio Interactivo)
Este documento define las reglas de arquitectura, flujo de datos por capas, convenciones de código, seguridad y persistencia con MySQL para el backend en **.NET 8/9** del portafolio interactivo.
---
## 1. Mentalidad de Arquitecto Backend Senior — Reglas de Oro
El agente actúa como un **Ingeniero Principal y Arquitecto Backend Senior con 10+ años de experiencia** en desarrollo con .NET y arquitecturas limpias de alto rendimiento.
1. **Clean Architecture & Regla de Dependencia**:
- Las dependencias van estrictamente hacia adentro:
`WebApi` ──► `Application` ──► `Domain` ◄── `Infrastructure`
- El núcleo (`Domain`) nunca depende de EF Core, MySQL, MediatR, ASP.NET Core ni librerías externas.
- La inversión de dependencias permite aislar la lógica de negocio: `Domain` declara los contratos (`IProjectRepository`), e `Infrastructure` los implementa.
2. **Filosofía Ponytail (Prevención de Sobreingeniería)**:
- **Prohibido Generic Repository genérico** (`IRepository<T>` con 30 métodos que nadie usa). Diseñar interfaces de repositorio específicas y quirúrgicas para cada raíz de agregado (Aggregate Root).
- **Prohibido envolver DbContext en un UnitOfWork casero adicional**: `DbContext` de EF Core ya es un *Unit of Work* y *Identity Map* probado en producción.
- **Prohibido AutoMapper**: En su lugar, proyectar directamente con `.Select()` de LINQ para lecturas, o constructores/métodos estáticos en records para mapeos explícitos, seguros y sin overhead de reflection.
- **Records posicionales**: Usar `public sealed record ...` para DTOs, Commands y Queries. Cero clases verbosas con getters/setters tradicionales.
- **Primary Constructors**: Aprovechar la sintaxis moderna de C# 12+ para inyección de dependencias limpia en una sola línea.
3. **Cero operaciones síncronas en I/O**:
- Todo acceso a base de datos, hasher o servicios de token es estrictamente asíncrono (`async/await`) recibiendo siempre `CancellationToken`.
4. **Manejo Centralizado de Excepciones**:
- Prohibido capturar excepciones dentro de los controladores. Se utiliza `AddProblemDetails()` y `IExceptionHandler` según el estándar **RFC 7807**.
---
## 2. Límites de Líneas por Archivo y Modularidad Backend
Para mantener un balance óptimo entre cohesión y separación de responsabilidades (evitando tanto las *God Classes* monolíticas como la sobre-fragmentación artificial en decenas de micro-archivos), se establecen los siguientes umbrales tolerables:
| Tipo de Archivo | Capa / Ubicación | Líneas Ideales | Máximo Tolerable | Acción al Exceder el Límite |
|---|---|---|---|---|
| **Controlador REST** | `WebApi/Controllers/` | **50 – 120** | **200** | Prohibido incluir lógica de negocio. Solo validar HTTP, delegar a MediatR (`ISender`) y retornar `ActionResult<T>`. Permite agrupar las rutas CRUD completas de un recurso. |
| **Command / Query Handler** | `Application/Features/.../` | **40 – 90** | **160** | Principio *One Handler per File*. Si supera 160 líneas, encapsular reglas dentro de la Entidad de Dominio o delegar a subservicios especializados. |
| **Entidad de Dominio** | `Domain/Entities/` | **60 – 130** | **220** | Contiene propiedades, reglas de negocio y fábrica estática `Create()`. Extraer sub-conceptos a Value Objects si crece. |
| **DTO / Command / Query** | `Application/.../DTOs/` | **10 – 40** | **80** | Usar `public sealed record` posicionales. Si se agrupan varios DTOs relacionados en un archivo (ej: `ProjectDtos.cs`), el archivo puede alcanzar hasta **120 líneas**. |
| **Validador FluentValidation**| `Application/.../Validators/` | **25 – 60** | **100** | Encapsular validaciones complejas o reutilizables en métodos de extensión propios. |
| **Repositorio de Infraestructura** | `Infrastructure/Persistence/` | **50 – 100** | **180** | Implementar los métodos de consulta y persistencia requeridos por el contrato de dominio (`IProjectRepository`). |
| **Configuración EF Core (Fluent API)** | `Infrastructure/Configurations/` | **30 – 70** | **120** | Una clase por entidad (`IEntityTypeConfiguration<T>`). |
| **DbContext** | `Infrastructure/Context/` | **40 – 90** | **150** | Registrar los `DbSet<T>` y llamar a `modelBuilder.ApplyConfigurationsFromAssembly(...)`. |
### Reglas de Descomposición Modular:
- **One Handler per File**: Cada caso de uso (Command o Query) y su Handler viven en su propio archivo independiente para facilitar pruebas unitarias y navegación.
- **División de Controladores**: Un controlador puede manejar cómodamente las operaciones CRUD de una entidad (hasta 150-200 líneas). Solo se divide si mezcla recursos no relacionados o responsabilidades administrativas distintas.
---
## 3. Mapa de Capas y Responsabilidades
```
backend/src/
├── Portfolio.Domain/ <- Núcleo del Negocio. Cero dependencias externas.
│ ├── Entities/ <- Project, Skill, User, Role, InteractiveLog
│ ├── Enums/ <- ProjectCategory, SkillLevel, UserRole
│ ├── Exceptions/ <- DomainException, NotFoundException
│ └── Interfaces/ <- IProjectRepository, IUserRepository, IUnitOfWork (opcional)
│
├── Portfolio.Application/ <- Casos de Uso (CQRS). Solo depende de Domain.
│ ├── Common/
│ │ ├── Behaviors/ <- ValidationBehavior (MediatR Pipeline)
│ │ └── Interfaces/ <- IJwtTokenGenerator, IPasswordHasher
│ ├── Features/ <- Organización vertical por Feature (Vertical Slice inside App)
│ │ ├── Projects/
│ │ │ ├── Commands/ <- CreateProjectCommand, UpdateProjectCommand
│ │ │ ├── Queries/ <- GetProjectsQuery, GetProjectByIdQuery
│ │ │ ├── DTOs/ <- ProjectResponseDto, CreateProjectRequestDto
│ │ │ └── Validators/ <- CreateProjectCommandValidator
│ │ ├── Interactive/ <- Endpoints de prueba para visitantes
│ │ │ ├── Queries/ <- GetServerTelemetryQuery, PingServerQuery
│ │ │ └── DTOs/ <- ServerTelemetryDto, PingResponseDto
│ │ └── Auth/ <- Login, RefreshToken, RBAC
│ │ ├── Commands/ <- LoginCommand, RefreshTokenCommand
│ │ ├── DTOs/ <- AuthResponseDto, LoginRequestDto
│ │ └── Validators/ <- LoginCommandValidator
│ └── DependencyInjection.cs
│
├── Portfolio.Infrastructure/ <- Implementación Técnica. Depende de Application y Domain.
│ ├── Persistence/
│ │ ├── Context/ <- PortfolioDbContext (EF Core con MySQL)
│ │ ├── Configurations/ <- EntityTypeConfigurations (Fluent API)
│ │ └── Repositories/ <- ProjectRepository, UserRepository
│ ├── Security/
│ │ ├── JwtTokenGenerator.cs <- Generación y validación de tokens JWT
│ │ └── PasswordHasher.cs <- Hasheo de contraseñas (BCrypt / PBKDF2)
│ └── DependencyInjection.cs
│
└── Portfolio.WebApi/ <- Punto de Entrada HTTP. Depende de Application e Infrastructure.
├── Controllers/ <- ProjectsController, InteractiveController, AuthController
├── Middleware/ <- GlobalExceptionHandler (ProblemDetails RFC 7807)
├── Security/ <- HasPermissionAttribute o Authorize policies
├── Program.cs <- Pipeline HTTP, DI y configuración
└── appsettings.json
```
---
## 4. Flujo de Datos Obligatorio para Endpoints (Data Flow Pipeline)
Para mantener total coherencia y evitar sobreingeniería, todo endpoint debe seguir estrictamente este flujo de 7 pasos secuenciales:
```
[1. Request HTTP / DTO]
│
▼
[2. Command / Query (MediatR IRequest<T>)]
│
▼
[3. FluentValidation (Pipeline Behavior)]
│
▼
[4. Handler (IRequestHandler<T, TResponse>)]
│
▼
[5. Dominio & Persistencia]
├── Si es ESCRITURA: Entidad de Dominio ejecuta regla ──► IRepository.AddAsync() ──► SaveChangesAsync()
└── Si es LECTURA: DbContext.Set<T>().AsNoTracking().Select(...) ──► Directo a DTO (Sin overhead)
│
▼
[6. Response DTO (Record Inmutable)]
│
▼
[7. Controller Action] (Retorna ActionResult<TResponse> con 200, 201 o 204)
```
---
## 5. Implementación Paso a Paso con Código Real
### Paso 1: Definir los DTOs (Records Inmutables)
Los DTOs definen los contratos externos sin exponer la estructura de base de datos.
```csharp
// Application/Features/Projects/DTOs/ProjectDtos.cs
namespace Portfolio.Application.Features.Projects.DTOs;
public sealed record CreateProjectRequestDto(
string Title,
string Description,
string RepositoryUrl,
string? LiveDemoUrl,
string[] Technologies,
bool IsFeatured
);
public sealed record ProjectResponseDto(
Guid Id,
string Title,
string Description,
string RepositoryUrl,
string? LiveDemoUrl,
IReadOnlyList<string> Technologies,
bool IsFeatured,
DateTime CreatedAtUtc
);
```
---
### Paso 2: Command o Query (MediatR `IRequest<T>`)
Representa la intención del usuario. El Command muta estado; la Query solo consulta.
```csharp
// Application/Features/Projects/Commands/CreateProjectCommand.cs
using MediatR;
using Portfolio.Application.Features.Projects.DTOs;
namespace Portfolio.Application.Features.Projects.Commands;
public sealed record CreateProjectCommand(
string Title,
string Description,
string RepositoryUrl,
string? LiveDemoUrl,
string[] Technologies,
bool IsFeatured
) : IRequest<ProjectResponseDto>;
```
---
### Paso 3: Validación con FluentValidation
Valida la integridad de los datos antes de que el Handler sea ejecutado.
```csharp
// Application/Features/Projects/Validators/CreateProjectCommandValidator.cs
using FluentValidation;
using Portfolio.Application.Features.Projects.Commands;
namespace Portfolio.Application.Features.Projects.Validators;
public sealed class CreateProjectCommandValidator : AbstractValidator<CreateProjectCommand>
{
public CreateProjectCommandValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("El título del proyecto es obligatorio.")
.MaximumLength(150).WithMessage("El título no puede exceder 150 caracteres.");
RuleFor(x => x.Description)
.NotEmpty().WithMessage("La descripción es obligatoria.")
.MaximumLength(1000).WithMessage("La descripción no puede exceder 1000 caracteres.");
RuleFor(x => x.RepositoryUrl)
.NotEmpty().WithMessage("La URL del repositorio es obligatoria.")
.Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _))
.WithMessage("La URL del repositorio no es válida.");
RuleFor(x => x.Technologies)
.NotEmpty().WithMessage("Debe incluir al menos una tecnología.");
}
}
```
#### Pipeline de Validación Automático (ValidationBehavior)
MediatR ejecuta este pipeline antes del Handler; si la validación falla, lanza `ValidationException` que el `GlobalExceptionHandler` captura retornando HTTP 422:
```csharp
// Application/Common/Behaviors/ValidationBehavior.cs
using FluentValidation;
using MediatR;
namespace Portfolio.Application.Common.Behaviors;
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!validators.Any()) return await next();
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
```
---
### Paso 4: Handler (`IRequestHandler<T, TResponse>`)
Orquesta la lógica de negocio delegando la persistencia a través de la interfaz del repositorio.
```csharp
// Application/Features/Projects/Commands/CreateProjectCommandHandler.cs
using MediatR;
using Portfolio.Application.Features.Projects.DTOs;
using Portfolio.Domain.Entities;
using Portfolio.Domain.Interfaces;
namespace Portfolio.Application.Features.Projects.Commands;
public sealed class CreateProjectCommandHandler(IProjectRepository projectRepository)
: IRequestHandler<CreateProjectCommand, ProjectResponseDto>
{
public async Task<ProjectResponseDto> Handle(
CreateProjectCommand request,
CancellationToken cancellationToken)
{
// Creación y encapsulación en entidad de dominio
var project = Project.Create(
request.Title,
request.Description,
request.RepositoryUrl,
request.LiveDemoUrl,
request.Technologies,
request.IsFeatured
);
await projectRepository.AddAsync(project, cancellationToken);
await projectRepository.SaveChangesAsync(cancellationToken);
return new ProjectResponseDto(
project.Id,
project.Title,
project.Description,
project.RepositoryUrl,
project.LiveDemoUrl,
project.Technologies,
project.IsFeatured,
project.CreatedAtUtc
);
}
}
```
---
### Paso 5: Dominio e Inversión de Persistencia
#### A. Entidad de Dominio Pura (Sin EF Core)
```csharp
// Domain/Entities/Project.cs
namespace Portfolio.Domain.Entities;
public sealed class Project
{
public Guid Id { get; private set; }
public string Title { get; private set; } = string.Empty;
public string Description { get; private set; } = string.Empty;
public string RepositoryUrl { get; private set; } = string.Empty;
public string? LiveDemoUrl { get; private set; }
public List<string> Technologies { get; private set; } = [];
public bool IsFeatured { get; private set; }
public DateTime CreatedAtUtc { get; private set; }
private Project() { } // Constructor privado para EF Core
public static Project Create(
string title,
string description,
string repoUrl,
string? demoUrl,
string[] technologies,
bool isFeatured)
{
return new Project
{
Id = Guid.NewGuid(),
Title = title.Trim(),
Description = description.Trim(),
RepositoryUrl = repoUrl.Trim(),
LiveDemoUrl = demoUrl?.Trim(),
Technologies = [..technologies],
IsFeatured = isFeatured,
CreatedAtUtc = DateTime.UtcNow
};
}
}
```
#### B. Contrato en Dominio (Puerto)
```csharp
// Domain/Interfaces/IProjectRepository.cs
using Portfolio.Domain.Entities;
namespace Portfolio.Domain.Interfaces;
public interface IProjectRepository
{
Task<Project?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken cancellationToken);
Task AddAsync(Project project, CancellationToken cancellationToken);
void Remove(Project project);
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
```
#### C. Implementación en Infraestructura (Adaptador EF Core MySQL)
```csharp
// Infrastructure/Persistence/Repositories/ProjectRepository.cs
using Microsoft.EntityFrameworkCore;
using Portfolio.Domain.Entities;
using Portfolio.Domain.Interfaces;
using Portfolio.Infrastructure.Persistence.Context;
namespace Portfolio.Infrastructure.Persistence.Repositories;
public sealed class ProjectRepository(PortfolioDbContext context) : IProjectRepository
{
public async Task<Project?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
=> await context.Projects.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
public async Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken cancellationToken)
=> await context.Projects.AsNoTracking().ToListAsync(cancellationToken);
public async Task AddAsync(Project project, CancellationToken cancellationToken)
=> await context.Projects.AddAsync(project, cancellationToken);
public void Remove(Project project)
=> context.Projects.Remove(project);
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken)
=> await context.SaveChangesAsync(cancellationToken);
}
```
#### D. Contexto EF Core MySQL y Migraciones
```csharp
// Infrastructure/Persistence/Context/PortfolioDbContext.cs
using Microsoft.EntityFrameworkCore;
using Portfolio.Domain.Entities;
namespace Portfolio.Infrastructure.Persistence.Context;
public sealed class PortfolioDbContext(DbContextOptions<PortfolioDbContext> options) : DbContext(options)
{
public DbSet<Project> Projects => Set<Project>();
public DbSet<Skill> Skills => Set<Skill>();
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(PortfolioDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}
```
Registro de Inyección en `Infrastructure/DependencyInjection.cs`:
```csharp
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<PortfolioDbContext>(options =>
options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)));
services.AddScoped<IProjectRepository, ProjectRepository>();
return services;
}
```
Comandos de Migración MySQL:
```bash
dotnet ef migrations add InitialCreate --project src/Portfolio.Infrastructure --startup-project src/Portfolio.WebApi
dotnet ef database update --project src/Portfolio.Infrastructure --startup-project src/Portfolio.WebApi
```
---
### Paso 6 & 7: Controlador REST en WebApi
```csharp
// WebApi/Controllers/ProjectsController.cs
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Portfolio.Application.Features.Projects.Commands;
using Portfolio.Application.Features.Projects.DTOs;
using Portfolio.Application.Features.Projects.Queries;
namespace Portfolio.WebApi.Controllers;
[ApiController]
[Route("api/v1/[controller]")]
public sealed class ProjectsController(ISender sender) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<ProjectResponseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ProjectResponseDto>>> GetAll(
CancellationToken cancellationToken)
{
var result = await sender.Send(new GetProjectsQuery(), cancellationToken);
return Ok(result);
}
[HttpPost]
[Authorize(Roles = "Admin")] // Protegido por RBAC para que Bryan gestione sus proyectos
[ProducesResponseType(typeof(ProjectResponseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ProjectResponseDto>> Create(
[FromBody] CreateProjectRequestDto request,
CancellationToken cancellationToken)
{
var command = new CreateProjectCommand(
request.Title,
request.Description,
request.RepositoryUrl,
request.LiveDemoUrl,
request.Technologies,
request.IsFeatured
);
var created = await sender.Send(command, cancellationToken);
return CreatedAtAction(nameof(GetAll), new { id = created.Id }, created);
}
}
```
---
## 6. Endpoints Interactivos Públicos (Live Playground)
Para que los visitantes y reclutadores interactúen con el backend sin autenticación pero con rate-limiting nativo para evitar abusos o denegación de servicio:
```csharp
// WebApi/Controllers/InteractiveController.cs
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Portfolio.Application.Features.Interactive.DTOs;
using Portfolio.Application.Features.Interactive.Queries;
namespace Portfolio.WebApi.Controllers;
[ApiController]
[Route("api/v1/[controller]")]
[EnableRateLimiting("interactive-policy")] // Límite nativo de .NET 8/9 (ej. 30 req/min por IP)
public sealed class InteractiveController(ISender sender) : ControllerBase
{
[HttpGet("ping")]
[ProducesResponseType(typeof(PingResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<PingResponseDto>> Ping(CancellationToken cancellationToken)
{
var response = await sender.Send(new PingServerQuery(), cancellationToken);
return Ok(response);
}
[HttpGet("telemetry")]
[ProducesResponseType(typeof(ServerTelemetryDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ServerTelemetryDto>> Telemetry(CancellationToken cancellationToken)
{
var response = await sender.Send(new GetServerTelemetryQuery(), cancellationToken);
return Ok(response);
}
}
```
Configuración en `WebApi/Program.cs`:
```csharp
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("interactive-policy", opt =>
{
opt.PermitLimit = 30; // Máximo 30 peticiones
opt.Window = TimeSpan.FromMinutes(1); // por cada minuto
opt.QueueLimit = 0;
});
});
// En el pipeline:
app.UseRateLimiter();
```
---
## 7. Seguridad y RBAC (Administración para Bryan)
1. **Autenticación con JWT**:
- Tokens con tiempo de expiración corto (15 a 30 minutos).
- `ClockSkew = TimeSpan.Zero` obligatorio para no permitir ventanas de tokens vencidos.
2. **Refresh Token Rotation en MySQL**:
- Cada refresh token se almacena en la tabla `auth_refresh_tokens` con estado `IsRevoked`, `ExpiresAtUtc` y `ReplacedByToken`.
- Al renovar, el token anterior se revoca inmediatamente y se emite uno nuevo. Si un token revocado intenta usarse, se revoca toda la cadena por sospecha de compromiso.
```csharp
// Domain/Entities/RefreshToken.cs
namespace Portfolio.Domain.Entities;
public sealed class RefreshToken
{
public Guid Id { get; private set; }
public Guid UserId { get; private set; }
public string TokenHash { get; private set; } = string.Empty;
public DateTime ExpiresAtUtc { get; private set; }
public bool IsRevoked { get; private set; }
public DateTime CreatedAtUtc { get; private set; }
public bool IsActive => !IsRevoked && DateTime.UtcNow < ExpiresAtUtc;
public void Revoke() => IsRevoked = true;
}
```
3. **RBAC Simple y Efectivo**:
- Roles en Claim: `Role: Admin`, `Role: Visitor`.
- Bryan inicia sesión desde el portal o terminal (`POST /api/v1/auth/login`) y recibe los tokens para realizar operaciones de escritura (`POST`, `PUT`, `DELETE`).
- Los visitantes solo tienen acceso de lectura a los proyectos/skills y a los endpoints públicos de `/api/v1/interactive/*`.
---
## 8. Manejo Centralizado de Errores — ProblemDetails RFC 7807
```csharp
// WebApi/Middleware/GlobalExceptionHandler.cs
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Portfolio.Domain.Exceptions;
namespace Portfolio.WebApi.Middleware;
public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Excepción capturada: {Message}", exception.Message);
var (statusCode, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Recurso no encontrado"),
FluentValidation.ValidationException => (StatusCodes.Status422UnprocessableEntity, "Error de validación"),
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "No autorizado"),
_ => (StatusCodes.Status500InternalServerError, "Error interno del servidor")
};
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = exception.Message,
Instance = httpContext.Request.Path
};
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
```
---
## 9. Anti-Patrones Prohibidos en Backend
| Anti-patrón Prohibido | Solución Obligatoria |
|---|---|
| Crear `GenericRepository<T>` con docenas de métodos genéricos | Contratos de repositorio específicos por entidad con los métodos exactos necesarios |
| Envolver `DbContext` en una clase `UnitOfWork` casera | Usar `PortfolioDbContext` directamente como Unit of Work |
| Usar AutoMapper con perfiles complejos y mapeo por reflexión | Proyección directa `.Select()` en Queries o mapeo estático con Records |
| Clases DTO tradicionales con getters/setters y constructores boilerplate | `public sealed record ...` inmutable de C# moderno |
| Inyección de dependencias clásica con campos privados repetidos | **Primary Constructors** de C# 12+ |
| Rutas en camelCase o PascalCase en controladores | kebab-case con versionado explícito: `[Route("api/v1/[controller]")]` |
| `SaveChanges()` síncrono en cualquier punto | Siempre `SaveChangesAsync(cancellationToken)` |
| Catch de excepciones dentro de acciones del controlador | Delegar al `IExceptionHandler` global con RFC 7807 |
| Consultas de lectura que cargan entidades con tracking | Usar `.AsNoTracking()` o `.Select()` directo a DTO |

View File

@ -0,0 +1,665 @@
---
name: portfolio-frontend
description: Parámetros, reglas y estándares de arquitectura frontend con Clean Architecture en Angular (Signals reactivos, Domain Ports & Adapters, Application Stores, Presentation OnPush, Terminal interactiva, Retro OS Canvas y 3D Scroll Hero).
---
# Estándar de Desarrollo Frontend — Clean Architecture en Angular (Portafolio Interactivo)
Este documento establece las directrices de arquitectura, organización de código, reactividad con **Angular Signals**, desacoplamiento hexagonal (**Ports & Adapters**) e integración de módulos interactivos (Terminal, Retro OS, Videojuegos Canvas y Hero 3D) para el cliente web del portafolio.
---
## 1. Mentalidad de Arquitecto Frontend Senior — Reglas de Oro
El agente actúa como un **Arquitecto Frontend Senior con 10+ años de experiencia** en aplicaciones enterprise y experiencias web interactivas de alto rendimiento.
1. **Clean Architecture / Inversión de Dependencias (DIP)**:
- Las capas internas nunca conocen a las externas. El código fuente solo apunta hacia el centro:
`Presentation` ──► `Application` ──► `Domain` ◄── `Infrastructure / Data`
- El **Dominio** es TypeScript puro, agnóstico del framework (cero imports de `@angular/*`, cero `HttpClient`).
- Los cambios de endpoints en el backend solo afectan a `infrastructure/`, jamás a la UI ni al Dominio.
2. **Reactividad Primitiva con Signals & OnPush**:
- Todo componente en la capa de presentación debe usar `changeDetection: ChangeDetectionStrategy.OnPush`.
- El estado se administra exclusivamente con **Angular Signals** (`signal`, `computed`, `effect`).
- Prohibido abusar de `BehaviorSubject` para estados locales o UI cuando `signal()` es nativo, más limpio y de menor overhead.
- RxJS se reserva exclusivamente para flujos asíncronos y cancelables en adaptadores de infraestructura (`HttpClient`).
3. **Smart vs. Dumb Components**:
- **Smart Components (Pages/Containers)**: Inyectan Application Stores o Facades, coordinan la vista y pasan señales a los componentes hijos.
- **Dumb / Presentational Components (UI)**: Reciben datos estrictamente por `input()` / `input.required()` y notifican eventos mediante `output()`. Cero inyecciones de repositorios o servicios HTTP.
4. **Sintaxis Moderna de Control de Flujo**:
- Uso obligatorio de la nueva sintaxis `@if`, `@for` (con `track`), `@switch` y `@defer`.
- Prohibido el uso de directivas estructurales legadas (`*ngIf`, `*ngFor`).
5. **Filosofía Ponytail (Cero Sobreingeniería)**:
- Usar las herramientas nativas de Angular y la Web Platform antes de añadir paquetes `npm`.
- Mini-juego retro: Usar la **API nativa Canvas 2D** en lugar de motores de juego pesados de 500KB.
- Scroll 3D: Implementar con Three.js optimizado o Canvas/CSS 3D ligero, pausando el render loop cuando no esté visible con `IntersectionObserver`.
---
## 2. Límites de Líneas por Archivo y Modularidad de Componentes
Para equilibrar la modularidad sin caer en una fragmentación excesiva (evitando crear cientos de micro-archivos innecesarios), se establecen los siguientes umbrales tolerables:
| Tipo de Archivo | Extensión / Ubicación | Líneas Ideales | Máximo Tolerable | Acción al Exceder el Límite |
|---|---|---|---|---|
| **Componente TypeScript** | `*.component.ts` | **80 – 180** | **280** | Extraer lógica pesada al Store o delegar bloques de vista en subcomponentes Dumb. |
| **Plantilla HTML** | `*.component.html` | **50 – 130** | **200** | Descomponer bloques visuales complejos en subcomponentes presentacionales hijos. |
| **Hojas de Estilo** | `*.component.css` | **40 – 100** | **160** | Consumir tokens globales de diseño y utilidades para no duplicar reglas CSS. |
| **Application Stores** | `*.store.ts` | **80 – 160** | **240** | Dividir por dominio funcional o sub-estado específico si acumula demasiados flujos. |
| **Servicios / Casos de Uso** | `*.service.ts` / `*.usecase.ts` | **50 – 120** | **180** | Aplicar Single Responsibility (SRP): mantener el servicio acotado a su función. |
| **Modelos y Puertos** | `domain/models/`, `ports/` | **30 – 70** | **120** | Separar modelos o agrupar contratos relacionados de forma coherente. |
| **Adaptadores y Repositorios**| `infrastructure/adapters/` | **60 – 120** | **180** | Mantener solo las llamadas HTTP del recurso específico. |
| **Mappers y DTOs** | `infrastructure/mappers/` | **30 – 80** | **130** | Dividir funciones de mapeo si el DTO tiene muchas transformaciones complejas. |
| **Motor de Juego / Canvas** | `retro-game/` | **100 – 200** | **320** | Separar en módulos si el juego crece: `game-engine.ts`, `renderer.ts`, `input-handler.ts`. |
### Criterio de Descomposición Inteligente:
- **No forzar la micro-fragmentación**: No es necesario crear un subcomponente por cada botón o etiqueta. Se crea un nuevo subcomponente cuando una sección visual o interactiva tiene identidad propia (ej. `terminal-prompt`, `retro-window-header`, `game-scoreboard`).
- **Control Flow Anidado**: Si una plantilla HTML supera **3 a 4 niveles de anidamiento** de bloques `@if` o `@for`, es momento de extraer ese bloque interno a un subcomponente Dumb dedicado para mantener la legibilidad.
---
## 3. Diagrama de Capas de Arquitectura Limpia
```
┌─────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER (Angular) │
│ - Pages (Hero, Terminal, Retro OS, Projects, Admin) │
│ - Dumb Components (WindowFrame, TerminalView, GameCanvas) │
│ - UI Directives & Pipes │
└──────────────────────────────┬──────────────────────────────┘
│ consume
┌──────────────────────────────▼──────────────────────────────┐
│ APPLICATION LAYER (Stores & Use Cases) │
│ - ProjectStore (Signals) - TerminalEngine (Signals) │
│ - RetroOsStore (Signals) - AuthStore (RBAC Signals) │
│ - InteractiveApiStore │
└──────────────────────────────┬──────────────────────────────┘
│ orquesta usando puertos
┌──────────────────────────────▼──────────────────────────────┐
│ DOMAIN LAYER (Core Puro) │
│ - Models (Project, Skill, Command, RetroWindow, AuthUser) │
│ - Ports (Abstract Classes / Injection Tokens) │
│ - Domain Value Objects & Business Rules │
└──────────────────────────────▲──────────────────────────────┘
│ implementa contratos de
┌──────────────────────────────┴──────────────────────────────┐
│ INFRASTRUCTURE / DATA LAYER (Adapters) │
│ - HttpProjectRepository - HttpInteractiveRepository │
│ - HttpAuthRepository - LocalStorageAdapter │
│ - DTOs & Mappers puros (DTO <-> Model) │
└─────────────────────────────────────────────────────────────┘
```
---
## 4. Estructura de Directorios del Proyecto Frontend
```
frontend/src/app/
├── domain/ <- TypeScript PURO (Sin Angular imports)
│ ├── models/ <- Modelos inmutables de negocio
│ │ ├── project.model.ts
│ │ ├── skill.model.ts
│ │ ├── terminal-command.model.ts
│ │ ├── retro-os.model.ts
│ │ ├── interactive-demo.model.ts
│ │ └── auth.model.ts
│ └── ports/ <- Contratos abstractos (Interfaces / Abstract Classes)
│ ├── project.port.ts
│ ├── interactive-demo.port.ts
│ └── auth.port.ts
│
├── infrastructure/ <- Implementaciones técnicas y adaptadores
│ ├── dtos/ <- Contratos directos de la API .NET
│ │ ├── project.dto.ts
│ │ ├── interactive-demo.dto.ts
│ │ └── auth.dto.ts
│ ├── mappers/ <- Transformadores puros (DTO <-> Model)
│ │ ├── project.mapper.ts
│ │ ├── interactive-demo.mapper.ts
│ │ └── auth.mapper.ts
│ └── adapters/ <- Implementación HTTP con HttpClient
│ ├── http-project.adapter.ts
│ ├── http-interactive-demo.adapter.ts
│ └── http-auth.adapter.ts
│
├── application/ <- Orquestación de casos de uso y estado reactivo
│ ├── stores/ <- Signals Stores (State Management)
│ │ ├── project.store.ts
│ │ ├── terminal.store.ts
│ │ ├── retro-os.store.ts
│ │ ├── interactive-api.store.ts
│ │ └── auth.store.ts
│ └── services/ <- Lógica de negocio de aplicación
│ ├── terminal-executor.service.ts <- Parser y ejecutor de comandos
│ └── retro-audio.service.ts <- Efectos de sonido retro con Web Audio API
│
├── presentation/ <- UI Angular (Componentes Standalone OnPush)
│ ├── features/
│ │ ├── hero-3d/ <- Laptop desarmable en scroll 3D
│ │ │ ├── hero-3d.component.ts
│ │ │ └── hero-3d.component.html
│ │ ├── terminal/ <- Consola Linux interactiva
│ │ │ ├── terminal.component.ts
│ │ │ ├── terminal.component.html
│ │ │ └── components/terminal-line.component.ts
│ │ ├── retro-os/ <- Sistema operativo vintage & mini-juego
│ │ │ ├── retro-os.component.ts
│ │ │ ├── retro-os.component.html
│ │ │ └── components/
│ │ │ ├── retro-window.component.ts
│ │ │ └── retro-game-canvas.component.ts
│ │ ├── interactive-api/ <- Probador de API en vivo (.NET Playground)
│ │ │ ├── interactive-api.component.ts
│ │ │ └── interactive-api.component.html
│ │ ├── projects/ <- Galería de proyectos con filtros
│ │ │ ├── project-list.component.ts
│ │ │ └── project-card.component.ts
│ │ └── admin/ <- Backoffice protegido por RBAC
│ │ ├── login/login.component.ts
│ │ └── dashboard/admin-dashboard.component.ts
│ └── shared/ <- Componentes UI reutilizables
│ ├── components/
│ │ ├── zen-button/zen-button.component.ts
│ │ └── zen-badge/zen-badge.component.ts
│ └── pipes/
│ └── json-pretty.pipe.ts
│
├── core/ <- Servicios transversales
│ ├── guards/
│ │ └── auth.guard.ts <- Verificación de token y rol admin
│ ├── interceptors/
│ │ ├── auth.interceptor.ts <- Inyección de Bearer Token JWT
│ │ └── error.interceptor.ts <- Mapeo de ProblemDetails RFC 7807
│ └── tokens/
│ └── api-config.token.ts
│
├── app.config.ts <- Inyección de dependencias (providers)
└── app.routes.ts <- Rutas principales con Lazy Loading
```
---
## 5. Implementación Práctica por Capas (Ejemplos Estándar)
### 5.1 Capa de Dominio (`domain/`)
Modelos inmutables en TypeScript puro. Cero decoradores `@Injectable` o librerías externas.
```typescript
// domain/models/interactive-demo.model.ts
export interface ServerTelemetry {
readonly status: string;
readonly framework: string;
readonly databaseEngine: string;
readonly uptimeSeconds: number;
readonly serverTimeUtc: string;
readonly latencyMs?: number;
}
export interface ApiDemoResult {
readonly endpoint: string;
readonly httpStatus: number;
readonly executionTimeMs: number;
readonly payload: unknown;
readonly isSuccess: boolean;
}
```
```typescript
// domain/ports/interactive-demo.port.ts
import { Observable } from 'rxjs';
import { ApiDemoResult, ServerTelemetry } from '../models/interactive-demo.model';
export abstract class InteractiveDemoPort {
abstract getTelemetry(): Observable<ServerTelemetry>;
abstract pingServer(): Observable<ApiDemoResult>;
abstract executeCustomDemo(demoId: string): Observable<ApiDemoResult>;
}
```
---
### 5.2 Capa de Infraestructura (`infrastructure/`)
Maneja la comunicación HTTP, DTOs y Mappers hacia el backend .NET.
```typescript
// infrastructure/dtos/interactive-demo.dto.ts
export interface ServerTelemetryDto {
status: string;
framework: string;
database_engine: string;
uptime_seconds: number;
server_time_utc: string;
}
export interface ApiExecutionResponseDto<T = unknown> {
endpoint: string;
status_code: number;
duration_ms: number;
data: T;
}
```
```typescript
// infrastructure/mappers/interactive-demo.mapper.ts
import { ApiDemoResult, ServerTelemetry } from '../../domain/models/interactive-demo.model';
import { ApiExecutionResponseDto, ServerTelemetryDto } from '../dtos/interactive-demo.dto';
export class InteractiveDemoMapper {
static toDomainTelemetry(dto: ServerTelemetryDto, latencyMs: number): ServerTelemetry {
return {
status: dto.status,
framework: dto.framework,
databaseEngine: dto.database_engine,
uptimeSeconds: dto.uptime_seconds,
serverTimeUtc: dto.server_time_utc,
latencyMs
};
}
static toDomainDemoResult(dto: ApiExecutionResponseDto): ApiDemoResult {
return {
endpoint: dto.endpoint,
httpStatus: dto.status_code,
executionTimeMs: dto.duration_ms,
payload: dto.data,
isSuccess: dto.status_code >= 200 && dto.status_code < 300
};
}
}
```
```typescript
// infrastructure/adapters/http-interactive-demo.adapter.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { InteractiveDemoPort } from '../../domain/ports/interactive-demo.port';
import { ApiDemoResult, ServerTelemetry } from '../../domain/models/interactive-demo.model';
import { ApiExecutionResponseDto, ServerTelemetryDto } from '../dtos/interactive-demo.dto';
import { InteractiveDemoMapper } from '../mappers/interactive-demo.mapper';
@Injectable({ providedIn: 'root' })
export class HttpInteractiveDemoAdapter implements InteractiveDemoPort {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/interactive';
getTelemetry(): Observable<ServerTelemetry> {
const startTime = performance.now();
return this.http.get<ServerTelemetryDto>(`${this.baseUrl}/telemetry`).pipe(
map(dto => {
const latency = Math.round(performance.now() - startTime);
return InteractiveDemoMapper.toDomainTelemetry(dto, latency);
})
);
}
pingServer(): Observable<ApiDemoResult> {
return this.http.get<ApiExecutionResponseDto>(`${this.baseUrl}/ping`).pipe(
map(InteractiveDemoMapper.toDomainDemoResult)
);
}
executeCustomDemo(demoId: string): Observable<ApiDemoResult> {
return this.http.post<ApiExecutionResponseDto>(`${this.baseUrl}/execute`, { demo_id: demoId }).pipe(
map(InteractiveDemoMapper.toDomainDemoResult)
);
}
}
```
---
### 5.3 Capa de Aplicación (`application/stores/`)
Administración de estado reactivo mediante **Angular Signals**.
```typescript
// application/stores/interactive-api.store.ts
import { Injectable, computed, inject, signal } from '@angular/core';
import { InteractiveDemoPort } from '../../domain/ports/interactive-demo.port';
import { ApiDemoResult, ServerTelemetry } from '../../domain/models/interactive-demo.model';
@Injectable({ providedIn: 'root' })
export class InteractiveApiStore {
private readonly demoPort = inject(InteractiveDemoPort);
// Signals privadas de estado
private readonly _telemetry = signal<ServerTelemetry | null>(null);
private readonly _lastResult = signal<ApiDemoResult | null>(null);
private readonly _isLoading = signal<boolean>(false);
private readonly _errorMessage = signal<string | null>(null);
// Signals públicas de solo lectura
readonly telemetry = this._telemetry.asReadonly();
readonly lastResult = this._lastResult.asReadonly();
readonly isLoading = this._isLoading.asReadonly();
readonly errorMessage = this._errorMessage.asReadonly();
readonly isHealthy = computed(() => this._telemetry()?.status === 'Healthy');
loadTelemetry(): void {
this._isLoading.set(true);
this._errorMessage.set(null);
this.demoPort.getTelemetry().subscribe({
next: (telemetry) => {
this._telemetry.set(telemetry);
this._isLoading.set(false);
},
error: (err) => {
this._errorMessage.set(err.message ?? 'No se pudo conectar con el backend .NET');
this._isLoading.set(false);
}
});
}
executePing(): void {
this._isLoading.set(true);
this._errorMessage.set(null);
this.demoPort.pingServer().subscribe({
next: (result) => {
this._lastResult.set(result);
this._isLoading.set(false);
},
error: (err) => {
this._errorMessage.set(err.message ?? 'Error al ejecutar ping');
this._isLoading.set(false);
}
});
}
}
```
---
### 5.4 Capa de Presentación (`presentation/`)
#### A. Smart Page Component
```typescript
// presentation/features/interactive-api/interactive-api.component.ts
import { Component, OnInit, ChangeDetectionStrategy, inject } from '@angular/core';
import { InteractiveApiStore } from '../../../application/stores/interactive-api.store';
import { JsonPrettyPipe } from '../../../shared/pipes/json-pretty.pipe';
@Component({
selector: 'app-interactive-api',
standalone: true,
imports: [JsonPrettyPipe],
templateUrl: './interactive-api.component.html',
styleUrl: './interactive-api.component.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class InteractiveApiComponent implements OnInit {
protected readonly store = inject(InteractiveApiStore);
ngOnInit(): void {
this.store.loadTelemetry();
}
onTestPing(): void {
this.store.executePing();
}
}
```
#### B. Template con Control Flow Moderno y Estilo Zen
```html
<!-- presentation/features/interactive-api/interactive-api.component.html -->
<section class="zen-card api-tester-container">
<header class="card-header">
<div class="header-badge">
<span class="status-dot" [class.online]="store.isHealthy()"></span>
<span class="mono-label">.NET 8 BACKEND // LIVE TESTER</span>
</div>
<h2 class="section-title">Interacción en Tiempo Real con la API</h2>
</header>
<div class="control-actions">
<button
type="button"
class="zen-btn-primary"
[disabled]="store.isLoading()"
(click)="onTestPing()"
>
@if (store.isLoading()) {
<span>Consultando...</span>
} @else {
<span>Ejecutar Ping Controlado</span>
}
</button>
</div>
@if (store.errorMessage(); as error) {
<div class="zen-alert-error" role="alert">
<span>{{ error }}</span>
</div>
}
@if (store.lastResult(); as result) {
<div class="api-response-viewer">
<div class="response-meta">
<span class="method-tag">GET</span>
<span class="url-tag">{{ result.endpoint }}</span>
<span class="status-tag" [class.success]="result.isSuccess">
{{ result.httpStatus }} OK
</span>
<span class="latency-tag">{{ result.executionTimeMs }} ms</span>
</div>
<pre class="json-code"><code>{{ result.payload | jsonPretty }}</code></pre>
</div>
}
</section>
```
---
## 6. Arquitectura de Módulos Específicos
### 6.1 Motor de la Terminal Interactiva (`application/services/terminal-executor.service.ts`)
Parser de comandos extensible con mapeo declarativo y llamadas HTTP reales al backend:
```typescript
import { Injectable, inject } from '@angular/core';
import { Observable, of, isObservable, map } from 'rxjs';
import { InteractiveDemoPort } from '../../domain/ports/interactive-demo.port';
@Injectable({ providedIn: 'root' })
export class TerminalExecutorService {
private readonly demoPort = inject(InteractiveDemoPort);
private readonly commands: Record<string, (args: string[]) => Observable<string> | string> = {
help: () => `Comandos disponibles:
about - Conoce sobre mi perfil y enfoque arquitectónico
skills - Stack técnico (.NET, Angular, MySQL, Clean Arch)
projects - Listado de proyectos destacados
curl <url> - Ejecuta una petición HTTP en vivo al backend .NET
retro - Abre el sistema operativo vintage y videojuegos
clear - Limpia el buffer de la consola`,
about: () => 'Bryan // Fullstack Developer especializado en .NET & Angular Clean Architecture.',
skills: () => 'Backend: .NET 8/9, EF Core, MySQL, MediatR, CQRS.\nFrontend: Angular, Signals, OnPush, Canvas 2D.\nPrácticas: Clean Architecture, Ponytail (YAGNI/KISS), TDD.',
clear: () => '__CLEAR__',
retro: () => 'Abriendo entorno ZenOS...',
curl: (args) => {
if (args[0] === '/api/v1/interactive/ping' || args[0] === 'ping') {
return this.demoPort.pingServer().pipe(
map(res => `HTTP ${res.httpStatus} OK (${res.executionTimeMs}ms)\n${JSON.stringify(res.payload, null, 2)}`)
);
}
return 'Endpoint no reconocido. Prueba con: curl /api/v1/interactive/ping';
}
};
execute(rawInput: string): Observable<string> {
const parts = rawInput.trim().split(/\s+/);
const commandName = parts[0]?.toLowerCase();
const args = parts.slice(1);
if (!commandName) return of('');
const handler = this.commands[commandName];
if (!handler) return of(`comando no encontrado: ${commandName}. Escribe "help" para ver la lista.`);
const result = handler(args);
return isObservable(result) ? result : of(result);
}
}
```
### 6.2 Motor del Videojuego Canvas Retro (`retro-game-canvas.component.ts`)
Bucle de renderizado nativo en 2D sin librerías pesadas, con limpieza estricta de memoria:
```typescript
import { Component, AfterViewInit, OnDestroy, ViewChild, ElementRef, ChangeDetectionStrategy, signal } from '@angular/core';
@Component({
selector: 'app-retro-game-canvas',
standalone: true,
template: `<canvas #gameCanvas width="320" height="240" class="pixel-canvas"></canvas>`,
styleUrl: './retro-game-canvas.component.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RetroGameCanvasComponent implements AfterViewInit, OnDestroy {
@ViewChild('gameCanvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private animationFrameId: number | null = null;
private isRunning = false;
protected readonly score = signal<number>(0);
protected readonly isGameOver = signal<boolean>(false);
ngAfterViewInit(): void {
const context = this.canvasRef.nativeElement.getContext('2d');
if (!context) return;
this.ctx = context;
this.ctx.imageSmoothingEnabled = false; // Pixel-art nítido
this.isRunning = true;
this.gameLoop();
}
private gameLoop = (): void => {
if (!this.isRunning) return;
this.update();
this.render();
this.animationFrameId = requestAnimationFrame(this.gameLoop);
};
private update(): void {
// Actualización de entidades y colisiones del juego
}
private render(): void {
this.ctx.fillStyle = '#0a0c10';
this.ctx.fillRect(0, 0, 320, 240);
}
ngOnDestroy(): void {
this.isRunning = false;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId); // Cero memory leaks
}
}
}
```
### 6.3 Hero 3D con Scroll Desarmable (`hero-3d.component.ts`)
Lectura de scroll pasiva y pausa del render loop mediante `IntersectionObserver`:
```typescript
import { Component, AfterViewInit, OnDestroy, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-hero-3d',
standalone: true,
templateUrl: './hero-3d.component.html',
styleUrl: './hero-3d.component.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Hero3dComponent implements AfterViewInit, OnDestroy {
@ViewChild('canvasContainer') private containerRef!: ElementRef<HTMLDivElement>;
private isVisible = true;
private intersectionObserver?: IntersectionObserver;
protected scrollProgress = 0;
ngAfterViewInit(): void {
this.intersectionObserver = new IntersectionObserver(([entry]) => {
this.isVisible = entry.isIntersecting;
}, { threshold: 0.05 });
this.intersectionObserver.observe(this.containerRef.nativeElement);
window.addEventListener('scroll', this.onScroll, { passive: true });
}
private onScroll = (): void => {
if (!this.isVisible) return;
const el = this.containerRef.nativeElement;
const rect = el.getBoundingClientRect();
this.scrollProgress = Math.min(Math.max(-rect.top / window.innerHeight, 0), 1);
// Aplicar transformación a las piezas 3D según scrollProgress (0.0 a 1.0)
};
ngOnDestroy(): void {
window.removeEventListener('scroll', this.onScroll);
this.intersectionObserver?.disconnect();
}
}
```
---
## 7. Interceptores y Configuración Global (`app.config.ts`)
```typescript
// core/interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem('access_token');
if (token && !req.url.includes('/auth/login')) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next(req);
};
```
```typescript
// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { errorInterceptor } from './core/interceptors/error.interceptor';
import { InteractiveDemoPort } from './domain/ports/interactive-demo.port';
import { HttpInteractiveDemoAdapter } from './infrastructure/adapters/http-interactive-demo.adapter';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
// Inversión de dependencias de puertos
{ provide: InteractiveDemoPort, useClass: HttpInteractiveDemoAdapter }
]
};
```
---
## 8. Tabla de Anti-Patrones Prohibidos en Frontend
| Anti-patrón Prohibido | Solución Obligatoria |
|---|---|
| Inyectar `HttpClient` directamente en componentes | Inyectar Store/Facade que consuma Ports implementados en `infrastructure/` |
| Directivas legadas `*ngIf`, `*ngFor` | Sintaxis moderna `@if`, `@for (item of items; track item.id)` |
| `ChangeDetectionStrategy.Default` en componentes | Siempre `ChangeDetectionStrategy.OnPush` |
| Bucle infinito en canvas del juego al cambiar de ruta | Limpiar bucle con `cancelAnimationFrame` en `ngOnDestroy()` |
| Instalar librerías de 1MB para efectos sencillos | Usar Web APIs nativas: Canvas 2D, Web Audio, IntersectionObserver |
| Uso de `any` en respuestas HTTP | Definir DTOs tipados en `infrastructure/dtos/` y mapear a modelos |
| Estado global mutable o variables sueltas | Angular Signals con interfaces inmutables (`readonly`) |

View File

@ -0,0 +1,391 @@
---
name: portfolio-ui-design
description: Estándar de diseño UI/UX para el Portafolio Profesional Interactivo. Fusión de estética Zen Japonés, Dark High-Contrast y Retro Computing (PC-98 / NeXT / Terminal Linux), con especificaciones para Laptop 3D desarmable en scroll, mini-pantalla Retro OS con videojuegos retro, terminal interactiva y probador de API en vivo.
---
# Estándar de Diseño UI/UX — Portafolio Profesional Interactivo (Zen Japonés + Dark + Retro)
Este documento define las reglas visuales, estéticas, de interacción y diseño de componentes para el **Portafolio Web Profesional Interactivo**. Su propósito es crear una experiencia memorable, sofisticada y técnicamente impresionante, combinando la serenidad y minimalismo del **Zen Japonés (Wabi-Sabi, Ma, Kanso)** con el misterio del **Dark Mode de alto contraste** y la nostalgia táctil del **Retro Computing / Cyber-Zen**.
---
## 1. Filosofía de Diseño — Reglas de Oro
El agente actúa como un **Director Creativo & Diseñador UI/UX Senior con 10+ años de experiencia** en diseño web interactivo, aplicaciones 3D y sistemas de diseño de alto impacto.
### 1.1 El Concepto Central: "Cyber-Zen & Retro Elegance"
1. **Zen Japonés (Wabi-Sabi & Ma - 間)**:
- **Ma (Espacio negativo consciente)**: Las secciones respiran. No atiborrar la pantalla. El vacío tiene peso y propósito.
- **Kanso (Simplicidad)**: Eliminar lo superfluo. Cada elemento visual tiene una función de comunicación o interacción.
- **Shizen (Naturalidad dentro de lo digital)**: Transiciones orgánicas, fluidez como el agua, contrastadas con el rigor del código.
- **Kintsugi (El valor de la técnica)**: Acentos dorados sutiles (`#d4af37` / `#c59b27`) que destacan la artesanía del software.
2. **Dark High-Contrast**:
- Fondos negros tinta (Sumi-e ink `#0a0c10`), carbón profundo (`#12151b`) y pizarra (`#1a1f26`).
- Cero grises desteñidos o descoloridos. El texto y los acentos resaltan con legibilidad WCAG AAA.
3. **Retro Computing & Terminal Nostalgia**:
- Elementos inspirados en estaciones de trabajo Unix (NeXTSTEP, Silicon Graphics, PC-98 japonés).
- Tipografía monoespaciada para datos técnicos, prompt de terminal Linux interactivo, scanlines CRT hiper-sutiles (opcionales/conmutables) y ventanas estilo OS vintage pero refinadas.
### 1.2 Prohibiciones Absolutas (Cero "AI Slop")
- **Prohibido emojis en la UI**: En ningún título, label, botón, tooltip o feedback.
- **Prohibido gradientes estridentes tipo arcoíris o neón descontrolado**: La paleta es sobria, monocromática con acentos de precisión.
- **Prohibido cards hinchadas con sombras gigantescas**: Las tarjetas usan bordes nítidos de `1px solid rgba(...)`, sombras difusas tenues y proporciones elegantes.
- **Prohibido border-radius desproporcionado**: El estilo zen/retro usa bordes rectos o de redondeo mínimo (`2px` a `6px`). Prohibido usar `border-radius: 24px` o píldoras gigantes.
- **Prohibido saturar con librerías pesadas de animación**: Las interacciones deben ser fluidas a 60fps usando CSS moderno, requestAnimationFrame y WebGL/Canvas nativo cuando sea requerido.
---
## 2. Paleta de Colores y Tokens Visuales
### 2.1 Variables CSS Base (Sumi & Gold Palette)
```css
:root {
/* Fondos (Sumi-e Ink & Obsidian) */
--zen-bg-abyss: #07080b; /* Fondo más profundo (canvas del 3D / Landing) */
--zen-bg-base: #0c0e14; /* Fondo principal de la aplicación */
--zen-bg-surface: #131720; /* Superficie de cards, ventanas y paneles */
--zen-bg-elevated: #1a202c; /* Superficies flotantes, dropdowns, tooltips */
--zen-bg-overlay: rgba(12, 14, 20, 0.85); /* Backdrops y overlays */
/* Bordes & Divisores */
--zen-border-subtle: #1e2533; /* Bordes estándar de división */
--zen-border-focus: #3d4a63; /* Bordes activos o hover */
--zen-border-kintsugi: #c59b27; /* Borde acentuado de oro kintsugi */
/* Textos & Tipografía */
--zen-text-primary: #f0f3f8; /* Blanco perla japonés (alta legibilidad) */
--zen-text-secondary: #9aa5b8; /* Gris pizarra para subtítulos y metadatos */
--zen-text-muted: #5e697d; /* Gris atenuado para hints y footer */
--zen-text-gold: #e2b94a; /* Dorado para llamadas de atención selectas */
/* Acentos Temáticos */
--zen-accent-kintsugi: #c59b27; /* Oro kintsugi (artesanía, acción primaria) */
--zen-accent-matcha: #4e9b6f; /* Verde matcha / Terminal éxito */
--zen-accent-sakura: #d96f88; /* Rosa sakura atenuado / Alertas o badges */
--zen-accent-amber: #e69d45; /* Ámbar CRT retro / Warnings */
--zen-accent-cyan: #38bdf8; /* Azul glacial / API tester */
/* Retro CRT & Terminal */
--term-bg: #090b0e;
--term-prompt: #4e9b6f; /* Verde fósforo zen */
--term-text: #d8dee9;
--term-cursor: #e2b94a;
/* Elevaciones & Sombras */
--zen-shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.4);
--zen-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.6);
--zen-shadow-window: 0 10px 30px rgba(0, 0, 0, 0.8), 0 0 1px 1px rgba(255, 255, 255, 0.05);
/* Curvaturas (Bordes nítidos y elegantes) */
--zen-radius-xs: 2px; /* Botones retro, inputs */
--zen-radius-sm: 4px; /* Tarjetas, ventanas */
--zen-radius-md: 6px; /* Contenedores principales */
}
```
### 2.2 Temas Conmutables (Data-Attributes)
El usuario puede alternar la paleta visual desde la Terminal (ej: `theme amber`) o desde los ajustes del Retro OS:
```css
/* Tema 1: Zen Dark (Predeterminado) */
[data-theme='zen-dark'] {
--zen-bg-base: #0c0e14;
--zen-bg-surface: #131720;
--zen-text-primary: #f0f3f8;
--zen-accent-kintsugi: #c59b27;
--term-prompt: #4e9b6f;
}
/* Tema 2: Retro CRT Ámbar (Estación de trabajo vintage) */
[data-theme='retro-amber'] {
--zen-bg-base: #0a0702;
--zen-bg-surface: #140f06;
--zen-border-subtle: #2d1e0a;
--zen-text-primary: #ffb833;
--zen-text-secondary: #cc8e20;
--zen-accent-kintsugi: #ffb833;
--term-bg: #0d0903;
--term-prompt: #ffb833;
--term-text: #ffcf70;
--term-cursor: #ffb833;
}
/* Tema 3: Matrix / Cyber-Zen Verde */
[data-theme='matrix-green'] {
--zen-bg-base: #040a06;
--zen-bg-surface: #0a140d;
--zen-border-subtle: #122819;
--zen-text-primary: #33ff77;
--zen-text-secondary: #22aa4e;
--zen-accent-kintsugi: #33ff77;
--term-bg: #040a06;
--term-prompt: #33ff77;
--term-text: #66ff99;
--term-cursor: #33ff77;
}
```
---
## 3. Tipografía
El portafolio comunica rigor de ingeniería mediante una cuidada jerarquía tipográfica:
```css
/* Familias Tipográficas */
--font-sans: 'Plus Jakarta Sans', 'Inter', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--font-retro: 'VT323', 'Press Start 2P', monospace; /* Para detalles retro específicos */
```
| Elemento | Fuente | Tamaño | Peso | Color | Uso |
|---|---|---|---|---|---|
| Display / Hero Title | Sans | `2.75rem - 3.5rem` | `700` | `--zen-text-primary` | Título de landing, frase de impacto |
| Heading H1 / Sección | Sans | `1.75rem - 2.25rem` | `600` | `--zen-text-primary` | Títulos de proyectos, terminal header |
| Heading H2 / Subsección | Sans | `1.25rem - 1.5rem` | `600` | `--zen-text-primary` | Títulos de cards, ventanas de OS |
| Eyebrow / Kanji Tag | Mono | `0.75rem` | `600` | `--zen-accent-kintsugi` | Acentos zen (ej: `01 // 開発 PROYECTOS`) |
| Body / Párrafo | Sans | `0.9375rem` | `400` | `--zen-text-secondary` | Descripciones, texto de lectura fluida |
| Terminal / Logs | Mono | `0.875rem` | `400` | `--term-text` | Consola interactiva, código, respuestas API |
| Retro OS UI | Mono / Retro | `0.8125rem` | `500` | `--zen-text-primary` | Barras de título de ventanas, menú de juegos |
---
## 4. Componentes Interiores Clave — Especificación Detallada
### 4.1 Fondo Hero con Laptop 3D Desarmable al Scroll (Exploded View)
#### Concepto y Comportamiento:
- En la Landing Page, el centro visual es una **laptop estética minimalista** vista en perspectiva isométrica o 3/4.
- A medida que el usuario hace scroll hacia abajo, la laptop se **desarma suavemente en capas explosivas (Exploded View)**:
1. **Scroll 0% - 15%**: Laptop cerrada/abierta en reposo, pantalla brillando tenuemente con el logo o terminal zen.
2. **Scroll 15% - 40%**: La tapa superior con la pantalla se eleva verticalmente en el eje Z.
3. **Scroll 40% - 65%**: El teclado y el chasis superior flotan hacia arriba, revelando la placa madre (motherboard).
4. **Scroll 65% - 85%**: Los microcomponentes (procesador .NET, chips de memoria, buses) se separan flotando con líneas de conexión y etiquetas técnicas flotantes que apuntan a tus habilidades técnicas reales (ej. "Arquitectura Limpia", "Motor de Signals", "MySQL Optimization").
5. **Scroll 85% - 100%**: Los componentes se reensamblan o se desvanecen suavemente dando paso al siguiente bloque de contenido.
#### Reglas de Implementación Técnica & Rendimiento:
- Utilizar **Three.js** o **WebGL Canvas** con instanced meshes ligeras o primitivas geométricas optimizadas (archivo GLTF/GLB comprimido con Draco, peso máximo `< 1.8 MB`).
- Vincular la rotación y separación exclusivamente a la posición de scroll normalizada (`0.0` a `1.0`) mediante `requestAnimationFrame`.
- **Modo Reducido / Fallback**:
- En dispositivos móviles o si `prefers-reduced-motion: reduce` está activo, deshabilitar la separación 3D continua y renderizar una vista 3D fija o una ilustración estática de alta calidad optimizada.
- Pausar el render loop de WebGL cuando la sección hero esté fuera del viewport (vía `IntersectionObserver`).
---
### 4.2 Terminal Linux Interactiva (Hacker Zen Console)
#### Concepto:
Una consola interactiva incrustada donde reclutadores y visitantes técnicos pueden ejecutar comandos reales para navegar tu información como si estuvieran en un servidor Unix.
#### Anatomía Visual de la Terminal:
- Barra superior estilo ventana Unix/Retro con:
- Indicador de estado: punto verde latente (`● online`).
- Título: `bryan@zen-kernel: ~ (zsh)`
- Botones de acción mínimos (minimizar, cerrar).
- Prompt interactivo: `bryan@zen:~$ ` en color matcha `--term-prompt`.
- Cursor parpadeante con animación escalonada `steps(2, start)`.
#### Comandos Soportados Requeridos:
- `help`: Muestra la lista de comandos disponibles con formato de tabla limpia.
- `about`: Resumen profesional, filosofía de desarrollo y enfoque arquitectónico.
- `skills`: Lista de tecnologías organizadas por Backend (.NET, MySQL), Frontend (Angular, Signals), y Prácticas (Clean Arch, CQRS).
- `projects`: Lista de proyectos destacados con enlaces clickeables.
- `curl /api/ping`: Ejecuta una petición HTTP real al backend .NET y muestra la respuesta con latencia.
- `retro`: Abre la ventana del sistema operativo retro / minijuegos.
- `clear`: Limpia el buffer de la pantalla.
- `sudo`: Mensaje de humor de permisos para administradores.
#### Estilo CSS de la Terminal:
```css
.zen-terminal-window {
background: var(--term-bg);
border: 1px solid var(--zen-border-subtle);
border-radius: var(--zen-radius-sm);
box-shadow: var(--zen-shadow-window);
font-family: var(--font-mono);
overflow: hidden;
}
.zen-terminal-header {
background: #11141a;
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--zen-border-subtle);
}
.zen-terminal-body {
padding: 16px;
min-height: 260px;
max-height: 420px;
overflow-y: auto;
color: var(--term-text);
line-height: 1.6;
}
.zen-terminal-prompt {
color: var(--term-prompt);
font-weight: 600;
}
```
---
### 4.3 Pantalla de Sistema Operativo Retro (ZenOS / Retro Desktop)
#### Concepto:
Una mini-pantalla o ventana de escritorio que evoca sistemas operativos de los años 90 (estilo NeXTSTEP / PC-98 japonés / System 7):
- Fondo de escritorio con cuadrícula punteada retro o patrón geométrico monocromático.
- Íconos en el escritorio estilo pixel-art minimalista:
- `Juegos.exe` (Abre el catálogo de videojuegos retro).
- `Curriculum.doc` (Visor de experiencia interactiva).
- `Terminal.sh` (Acceso directo a la consola).
- `Ajustes.sys` (Cambio de tema: Zen Dark / Phosphor Amber / Matrix Green).
- Ventanas arrastrables (*draggable*) con barra de título gris pizarra oscura, botones biselados clásicos de cerrar/minimizar y tipografía pixelada o mono nítida.
#### Mini-Juego Retro Integrado:
- Juegos ligeros implementados en **Canvas 2D HTML5 nativo** (cero dependencias pesadas de juegos):
- **Zen Snake**: La clásica serpiente, pero con estética de tinta japonesa (come esferas de kanjis o monedas kintsugi).
- **Space Defense / Katana Retro**: Un juego arcade simple de esquivar/disparar obstáculos con puntuación guardada localmente.
- Panel de control del juego: botones de pausa, reinicio, controles táctiles opcionales para móvil y tabla de "High Scores" que puede consultar la API del backend.
---
### 4.4 Probador de API en Vivo (Live .NET Playground)
#### Concepto:
Demuestra de inmediato que no es solo un frontend estático, sino una arquitectura fullstack real en producción. Permite al visitante invocar endpoints públicos controlados de .NET.
#### Componente Visual:
- Selector de endpoints de prueba:
- `GET /api/v1/interactive/ping` (Verifica salud y tiempo de respuesta del backend .NET).
- `GET /api/v1/interactive/skills-metrics` (Retorna métricas dinámicas calculadas por el backend).
- `GET /api/v1/interactive/server-telemetry` (Retorna tiempo de actividad, versión de .NET y motor de base de datos MySQL).
- Botón de acción: `Ejecutar Endpoint` con feedback de carga elegante.
- Visor de respuesta:
- Badge de Status Code: `200 OK` (verde matcha), `422 Unprocessable` (ámbar), `500 Server Error` (sakura).
- Medidor de latencia: `Tiempo de respuesta: 42 ms`.
- Caja de código JSON formateada con sintaxis coloreada nativa.
---
### 4.5 Panel de Administración RBAC (Backoffice Minimalista Zen)
#### Concepto:
Vista protegida para que Bryan pueda gestionar sus proyectos, habilidades y contenidos sin desplegar código nuevo.
- Acceso mediante modal discreto (o atajo desde terminal con `login`).
- Formularios limpios:
- Inputs con focus de borde inferior kintsugi (`#c59b27`).
- Tablas densas con tipografía mono, filtros rápidos y acciones CRUD (Crear, Editar, Desactivar).
- Modal de confirmación con diseño sobrio.
---
## 5. Sistema de Espaciado (Grid de 8px)
Todo padding, margen y separación debe ser múltiplo de `8px` (o submúltiplo de `4px` para elementos microscópicos):
| Token | Medida | Aplicación |
|---|---|---|
| `--space-1` | `4px` | Separación mínima entre icono y texto |
| `--space-2` | `8px` | Padding interno de inputs, tags y botones compactos |
| `--space-3` | `12px` | Separación de items en listas densas |
| `--space-4` | `16px` | Padding de cards, ventanas y botones estándar |
| `--space-6` | `24px` | Separación entre grupos de campos o elementos de card |
| `--space-8` | `32px` | Padding principal de secciones y modales |
| `--space-12`| `48px` | Separación entre módulos mayores de la página |
| `--space-16`| `64px` | Margen vertical de grandes secciones en desktop |
---
## 6. Movimiento, Animaciones y Microinteracciones
1. **Duraciones y Curvas de Aceleración**:
- Transiciones de hover/focus: `150ms cubic-bezier(0.4, 0, 0.2, 1)` (rápidas, precisas).
- Apertura de ventanas retro / modales: `200ms cubic-bezier(0, 0, 0.2, 1)` (sin rebotes infantiles).
- Movimiento de scroll 3D: Interpolación lineal suave (*lerp* con factor `0.08` a `0.12`).
2. **Efecto Scanline Retro**:
- Implementado mediante un pseudo-elemento `::after` con gradiente repetitivo muy sutil (`opacity: 0.04`) y `pointer-events: none`. Con interruptor para apagarlo en caso de preferencia del usuario.
3. **Respeto a la Accesibilidad**:
- `@media (prefers-reduced-motion: reduce)` debe anular cualquier animación continua o de scroll 3D.
### 6.2 Diseño de Sonido Retro (Web Audio API Nativa — 0 KB)
Para brindar respuesta táctil a la terminal y controles retro sin descargar archivos `.mp3` ni añadir librerías:
```typescript
export class RetroAudioService {
private ctx: AudioContext | null = null;
private getContext(): AudioContext {
if (!this.ctx) this.ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
return this.ctx;
}
// Clic mecánico sutil al teclear en la terminal (120Hz triángulo)
playKeypress(): void {
const ctx = this.getContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(120, ctx.currentTime);
gain.gain.setValueAtTime(0.03, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.03);
}
// Beep retro al ejecutar comando con éxito (880Hz senoidal)
playSuccessBeep(): void {
const ctx = this.getContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880, ctx.currentTime);
gain.gain.setValueAtTime(0.05, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.08);
}
}
```
---
## 7. Límites de Líneas y Modularidad de Componentes UI
Para permitir un desarrollo visual fluido y expresivo sin caer en el extremo de crear micro-archivos innecesarios para cada detalle, se definen los siguientes umbrales tolerables:
| Elemento Visual | Líneas Ideales | Máximo Tolerable | Acción al Exceder el Límite |
|---|---|---|---|
| **Plantilla de Componente UI (`.html`)** | **50 – 130** | **200** | Descomponer bloques visuales densos en subcomponentes presentacionales hijos. |
| **Estilos de Componente (`.css`)** | **40 – 100** | **160** | Utilizar los tokens CSS globales definidos en `portfolio-ui-design` (colores, sombras, radios) en lugar de CSS repetitivo. |
| **Componentes de Ventana (Retro OS / Terminal)** | **60 – 140** | **240** | Dividir la ventana en subcomponentes (`window-header`, `window-content`) si supera el máximo. |
| **Tarjetas de Proyecto / Skills** | **30 – 75** | **130** | Cada tarjeta encapsula su preview, descripción y tags; dividir solo si integra modales embebidos. |
| **Diálogos Modales / Drawers** | **50 – 110** | **170** | El modal provee el marco y botones; el contenido de formularios extensos es un subcomponente hijo. |
### Regla de Descomposición UI:
1. **Cohesión Visual Natural**: Un componente puede albergar cómodamente su estructura visual completa (hasta 150-200 líneas de HTML y 100-160 de CSS). Se divide cuando un sub-bloque tiene comportamiento interactivo propio e independiente.
2. **Subcomponentes Atómicos**: Si una interfaz compuesta (como la consola interactiva o la ventana retro) supera las 200 líneas de HTML o 160 de CSS, se crean subcomponentes hijos en una carpeta `components/` local a esa feature.
---
## 8. Tabla de Anti-Patrones Prohibidos
| Anti-patrón Prohibido | Solución Correcta en este Portafolio |
|---|---|
| Usar emojis en la interfaz para verse "amigable" | Tipografía limpia, badges de texto plano e iconos SVG monocromáticos precisos |
| Gradientes púrpuras/rosas genéricos de IA | Paleta Sumi-e: fondos carbón profundos con acentos dorados kintsugi y verde matcha |
| Border-radius excesivo tipo píldora (20px+) | Bordes nítidos (`2px` a `6px`) de estilo arquitectónico y retro computing |
| Ventanas retro sobrecargadas que bloquean la navegación | Ventana contenida, redimensionable o minimizable sin entorpecer el flujo principal |
| Modelo 3D pesado que demora 10 segundos en cargar | Geometría optimizada (`< 1.8 MB`), carga diferida (lazy load) y fallback estático |
| Animaciones que saltan y distraen la lectura | Movimiento solo en interacción deliberada o ligado al scroll de forma armónica |
| Falta de contraste en modo oscuro (texto gris sobre gris) | Texto principal perla `#f0f3f8` sobre fondo `#0c0e14` (ratio > 7:1, WCAG AAA) |

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# ==============================================================================
# GITIGNORE GLOBAL — PORTFOLIO (Fullstack .NET + Angular)
# ==============================================================================
# Node / Angular Frontend
node_modules/
dist/
.angular/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# .NET Backend
bin/
obj/
*.user
*.userosscache
*.sln.docstates
.vs/
TestResults/
# IDEs & Editores
.vscode/
.idea/
*.swp
*.swo
*~
# Sistema Operativo
.DS_Store
Thumbs.db
desktop.ini
# Variables de entorno y secretos locales
*.env
*.env.local

12
AGENTS.md Normal file
View File

@ -0,0 +1,12 @@
# Contexto Global del Proyecto
## Uso Obligatorio de Skills (Ponytail)
El entorno tiene instalados los skills oficiales del repositorio Ponytail. Como agente, tienes estrictamente prohibido entregar código final sin antes pasarlo por estas herramientas:
1. Cada vez que escribas o modifiques código, debes ejecutar de forma autónoma el skill `ponytail-review` sobre tu propio trabajo.
2. Si el skill de Ponytail detecta sobreingeniería, abstracciones innecesarias o código que podría resolverse con la librería estándar de .NET o Angular, debes refactorizarlo inmediatamente antes de darme la respuesta final.
## Workflows y Habilidades Locales
Para saber la estructura exacta del código, lee obligatoriamente las directrices en las siguientes rutas según la tarea:
- Backend (.NET Clean Arch): `.agents/skills/portfolio-backend/`
- Frontend (Angular): `.agents/skills/portfolio-frontend/`
- Diseño UI: `.agents/skills/portfolio-ui-design/`

220
database/schema.sql Normal file
View File

@ -0,0 +1,220 @@
-- ==============================================================================
-- BASE DE DATOS: portfolio_db
-- MOTOR: MySQL 8.0+ / MariaDB 10.4+ / InnoDB
-- CHARSET: utf8mb4 / utf8mb4_unicode_ci
-- DESCRIPCIÓN: Esquema relacional minimalista y sin duplicación para
-- Portafolio Interactivo (Zen + Retro + Clean Arch .NET & Angular)
-- NOTA DBEAVER: Ejecutar todo el archivo con Alt + X (Ejecutar Script SQL / ▶▶).
-- No usar Ctrl + Enter sobre múltiples sentencias (error 1064).
-- ==============================================================================
CREATE DATABASE IF NOT EXISTS `portfolio_db`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE `portfolio_db`;
-- ------------------------------------------------------------------------------
-- 1. TABLA: users (Autenticación y RBAC del Administrador)
-- Justificación: Almacena la cuenta del propietario (Bryan) para acceder al panel
-- administrativo. No se crean tablas complejas de 'roles' o 'permisos' porque
-- solo existen 2 roles en el sistema (Admin y Visitor). Un campo 'role' es la
-- solución más limpia y minimalista (Ponytail).
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `users` (
`id` CHAR(36) NOT NULL,
`username` VARCHAR(50) NOT NULL,
`email` VARCHAR(100) NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`role` VARCHAR(20) NOT NULL DEFAULT 'Admin',
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at_utc` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_users_username` (`username`),
UNIQUE KEY `uq_users_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ------------------------------------------------------------------------------
-- 2. TABLA: refresh_tokens (Rotación segura de Tokens JWT)
-- Justificación: Permite mantener la sesión de administración de Bryan de forma
-- segura sin almacenar JWTs de larga duración. Si se detecta un token revocado,
-- se invalida la cadena. Relación 1:N con 'users' con borrado en cascada.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `refresh_tokens` (
`id` CHAR(36) NOT NULL,
`user_id` CHAR(36) NOT NULL,
`token_hash` VARCHAR(255) NOT NULL,
`expires_at_utc` DATETIME NOT NULL,
`is_revoked` BOOLEAN NOT NULL DEFAULT FALSE,
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`replaced_by_token` VARCHAR(255) NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_refresh_tokens_user` (`user_id`),
KEY `idx_refresh_tokens_validation` (`user_id`, `is_revoked`, `expires_at_utc`),
CONSTRAINT `fk_refresh_tokens_user` FOREIGN KEY (`user_id`)
REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ------------------------------------------------------------------------------
-- 3. TABLA: skills (Catálogo de Habilidades Técnicas)
-- Justificación: Administradas por Bryan vía CRUD. Se renderizan en el frontend
-- y se consultan desde el comando 'skills' de la terminal Linux interactiva.
-- Normaliza los nombres técnicos para que no se dupliquen strings en los proyectos.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `skills` (
`id` CHAR(36) NOT NULL,
`name` VARCHAR(50) NOT NULL,
`category` ENUM('Backend', 'Frontend', 'Database', 'DevOps', 'Architecture') NOT NULL,
`icon_slug` VARCHAR(50) NULL DEFAULT NULL,
`proficiency_percentage` TINYINT UNSIGNED NOT NULL DEFAULT 85,
`display_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
`is_featured` BOOLEAN NOT NULL DEFAULT TRUE,
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_skills_name` (`name`),
KEY `idx_skills_category_order` (`category`, `display_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ------------------------------------------------------------------------------
-- 4. TABLA: projects (Proyectos del Portafolio)
-- Justificación: Catálogo de proyectos destacados y proyectos generales. Permite
-- a Bryan crear/editar proyectos sin tocar código. Contiene slugs amigables para
-- URLs y terminal (`projects show [slug]`), enlaces a repositorios y demos en vivo.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `projects` (
`id` CHAR(36) NOT NULL,
`slug` VARCHAR(100) NOT NULL,
`title` VARCHAR(150) NOT NULL,
`short_description` VARCHAR(255) NOT NULL,
`full_description` TEXT NOT NULL,
`repository_url` VARCHAR(255) NOT NULL,
`live_demo_url` VARCHAR(255) NULL DEFAULT NULL,
`thumbnail_url` VARCHAR(255) NULL DEFAULT NULL,
`is_featured` BOOLEAN NOT NULL DEFAULT FALSE,
`display_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at_utc` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_projects_slug` (`slug`),
KEY `idx_projects_featured_order` (`is_featured`, `display_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ------------------------------------------------------------------------------
-- 5. TABLA: project_skills (Tabla Intermedia N:M Proyectos <-> Habilidades)
-- Justificación: Evita la duplicación de datos (3FN). Un proyecto usa muchas
-- tecnologías y una tecnología está en muchos proyectos. Permite filtrar
-- proyectos por tecnología con indexación real sin recurrir a campos de texto
-- plano o arrays desnormalizados difíciles de consultar.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `project_skills` (
`project_id` CHAR(36) NOT NULL,
`skill_id` CHAR(36) NOT NULL,
PRIMARY KEY (`project_id`, `skill_id`),
KEY `idx_project_skills_skill` (`skill_id`),
CONSTRAINT `fk_project_skills_project` FOREIGN KEY (`project_id`)
REFERENCES `projects` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_project_skills_skill` FOREIGN KEY (`skill_id`)
REFERENCES `skills` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ------------------------------------------------------------------------------
-- 6. TABLA: retro_scores (Tabla de Puntuaciones de Minijuegos Retro)
-- Justificación: Soporta la interactividad del videojuego retro en la ventana de
-- Retro OS. Los visitantes pueden ingresar su tag arcade de 3-4 letras (ej. 'BRY',
-- 'JDO') y competir por el Top 10 en tiempo real. Proporciona un endpoint público
-- interactivo perfecto para demostrar llamadas reales al backend .NET y MySQL.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `retro_scores` (
`id` CHAR(36) NOT NULL,
`player_tag` VARCHAR(4) NOT NULL,
`game_code` VARCHAR(20) NOT NULL DEFAULT 'ZEN_SNAKE',
`score` INT UNSIGNED NOT NULL,
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_retro_leaderboard` (`game_code`, `score` DESC, `created_at_utc` ASC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ------------------------------------------------------------------------------
-- 7. TABLA: interactive_messages (Libro de Visitas / Mensajes Interactivos)
-- Justificación: Permite a reclutadores y visitantes dejar una firma o mensaje
-- desde el comando interactivo de la terminal Linux (`guestbook sign "Mensaje"`)
-- o el probador de API en vivo. Demuestra operaciones de escritura inmediatas
-- y controladas en el backend con almacenamiento persistente.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `interactive_messages` (
`id` CHAR(36) NOT NULL,
`sender_name` VARCHAR(60) NOT NULL,
`sender_contact` VARCHAR(100) NULL DEFAULT NULL,
`message` VARCHAR(500) NOT NULL,
`origin` ENUM('Terminal', 'WebForm', 'ApiTester') NOT NULL DEFAULT 'Terminal',
`is_approved` BOOLEAN NOT NULL DEFAULT TRUE,
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_messages_approved_date` (`is_approved`, `created_at_utc` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==============================================================================
-- SEED DATA INICIAL (Datos de prueba para arranque inmediato)
-- ==============================================================================
-- 1. Usuario Administrador Inicial (Bryan)
-- Contraseña temporal por defecto: 'Admin123*!' (hasheada con BCrypt/PBKDF2 para prueba)
INSERT INTO `users` (`id`, `username`, `email`, `password_hash`, `role`)
VALUES (
'11111111-1111-1111-1111-111111111111',
'bryan',
'bryan@portfolio.local',
'$2a$11$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy',
'Admin'
) ON DUPLICATE KEY UPDATE `username` = `username`;
-- 2. Habilidades Iniciales
INSERT INTO `skills` (`id`, `name`, `category`, `icon_slug`, `proficiency_percentage`, `display_order`, `is_featured`)
VALUES
('22222222-0001-0000-0000-000000000001', '.NET 8/9 / C#', 'Backend', 'dotnet', 95, 1, TRUE),
('22222222-0002-0000-0000-000000000002', 'Clean Architecture & CQRS', 'Architecture', 'architecture', 95, 2, TRUE),
('22222222-0003-0000-0000-000000000003', 'Angular & Signals', 'Frontend', 'angular', 90, 3, TRUE),
('22222222-0004-0000-0000-000000000004', 'MySQL / EF Core', 'Database', 'mysql', 90, 4, TRUE),
('22222222-0005-0000-0000-000000000005', 'Docker & CI/CD', 'DevOps', 'docker', 85, 5, TRUE)
ON DUPLICATE KEY UPDATE `name` = `name`;
-- 3. Proyecto Destacado Inicial
INSERT INTO `projects` (`id`, `slug`, `title`, `short_description`, `full_description`, `repository_url`, `live_demo_url`, `is_featured`, `display_order`)
VALUES (
'33333333-0001-0000-0000-000000000001',
'portfolio-interactivo-zen',
'Portafolio Profesional Interactivo',
'Portafolio interactivo con estética Zen Dark, 3D Hero desarmable, terminal Linux y backend .NET Clean Architecture.',
'Sistema completo desarrollado con .NET 8/9, EF Core, MySQL, y Angular con Clean Architecture y Signals reactivos.',
'https://github.com/bryan/portfolio',
'https://portfolio.bryan.dev',
TRUE,
1
) ON DUPLICATE KEY UPDATE `slug` = `slug`;
-- 4. Vinculación Proyecto - Habilidades
INSERT INTO `project_skills` (`project_id`, `skill_id`)
VALUES
('33333333-0001-0000-0000-000000000001', '22222222-0001-0000-0000-000000000001'),
('33333333-0001-0000-0000-000000000001', '22222222-0002-0000-0000-000000000002'),
('33333333-0001-0000-0000-000000000001', '22222222-0003-0000-0000-000000000003'),
('33333333-0001-0000-0000-000000000001', '22222222-0004-0000-0000-000000000004')
ON DUPLICATE KEY UPDATE `project_id` = `project_id`;
-- 5. Scores de Prueba para el Minijuego Retro
INSERT INTO `retro_scores` (`id`, `player_tag`, `game_code`, `score`)
VALUES
(UUID(), 'ZEN', 'ZEN_SNAKE', 1450),
(UUID(), 'NEO', 'ZEN_SNAKE', 1120),
(UUID(), 'BRY', 'ZEN_SNAKE', 980)
ON DUPLICATE KEY UPDATE `player_tag` = `player_tag`;
-- 6. Mensaje de Bienvenida en el Libro de Visitas
INSERT INTO `interactive_messages` (`id`, `sender_name`, `sender_contact`, `message`, `origin`)
VALUES (
UUID(),
'Kernel Bot',
'system@zen.os',
'Bienvenido a la terminal interactiva. Ejecuta "help" para comenzar.',
'Terminal'
) ON DUPLICATE KEY UPDATE `sender_name` = `sender_name`;

View File

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

43
src/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

59
src/frontend/README.md Normal file
View File

@ -0,0 +1,59 @@
# PortfolioFrontend
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.26.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

97
src/frontend/angular.json Normal file
View File

@ -0,0 +1,97 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"portfolio-frontend": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "8kB",
"maximumError": "16kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "portfolio-frontend:build:production"
},
"development": {
"buildTarget": "portfolio-frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
]
}
}
}
}
}
}

9065
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
src/frontend/package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "portfolio-frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.3.26",
"@angular/cli": "^20.3.26",
"@angular/compiler-cli": "^20.3.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.9.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Some files were not shown because too many files have changed in this diff Show More