feat: enhance incident simulator with view mode and user email input; improve landing transitions and navigation controls; update technical profile accessibility; add favicon and touch icons

This commit is contained in:
bryancito1090 2026-09-08 10:01:22 -05:00
parent 2d875bb9f3
commit a6cadd10b8
25 changed files with 1316 additions and 750 deletions

View File

@ -44,8 +44,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "8kB",
"maximumError": "16kB"
"maximumWarning": "15kB",
"maximumError": "30kB"
}
],
"outputHashing": "all"

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -14,6 +14,8 @@ export const EN_TRANSLATIONS: TranslationDictionary = {
navTerminal: 'Terminal',
navContact: 'Contact',
navAll: 'View All',
navPrevSection: 'Previous section',
navNextSection: 'Next section',
// Hero 3D
preloading: 'PRELOADING',
@ -63,6 +65,9 @@ export const EN_TRANSLATIONS: TranslationDictionary = {
tpAboutName: 'Bryan Baño',
tpAboutDescription: 'I am a software engineer focused on building thoughtful, accessible, and technically solid digital experiences. I combine clean architecture, modern frontend development, and scalable backend solutions. I work with .NET, Angular, Signals, REST APIs, and relational databases.\n\nMy approach combines engineering and design. First I understand the problem, then simplify the experience, and finally build a maintainable solution. I prioritize performance, accessibility, separation of concerns, and code quality.\n\nI also explore interfaces inspired by Unix terminals, retro systems, Cyber-Zen aesthetics, and new ways to present technical information without sacrificing readability. Every project must be functional, expressive, and easy to maintain.\n\nI currently develop solutions that connect frontend, backend, visual design, and digital interaction. I am interested in creating products that not only work reliably, but also communicate a clear and memorable identity.',
tpAboutAria: 'About Bryan Baño',
tpAboutLink: 'About me',
tpAsciiError: 'Could not load portrait.',
tpAsciiLoading: 'Loading portrait...',
tpEyebrow: 'INGENIERÍA EN COMPUTACIÓN & EXPERTISE',
tpTitle: 'Technical Skills & Areas of Interest',
tpLeadStudent: '9th semester Computer Engineering student at',
@ -287,9 +292,22 @@ Approach: Zero over-engineering (Ponytail), high throughput, and Zen aesthetic d
simTag: '03 // CLOUD ARCHITECTURE & SERVERLESS SECURITY',
simHeading: 'Real-Time Incident Response Simulator',
simSubheading: 'Interactive visualization of an automated active-defense architecture in AWS. Experience how an unauthorized IAM access event is detected, mitigated by a Lambda function, and dispatched via SES in under 180 ms without manual intervention.',
simBtnSimulate: 'Simulate Unauthorized IAM Access',
simModeStory: 'Explained View (Business)',
simModeTech: 'SecOps Telemetry View (Dev)',
simEmailLabel: 'Email to receive the forensic report:',
simEmailPlaceholder: 'your-email@company.com',
simEmailHint: 'Enter your email to receive cryptographic audit details of the contained incident.',
simBtnSimulate: 'Simulate Attack & Defense',
simBtnSimulating: 'Containing Threat in Real-Time...',
simBtnReset: 'Reset Simulation',
simStep1Title: 'Intrusion Attempt',
simStep1Desc: 'An attacker attempts to steal administrative credentials from an untrusted IP.',
simStep2Title: 'Instant Detection',
simStep2Desc: 'The security perimeter flags the anomaly and triggers active defense in under 50 milliseconds.',
simStep3Title: 'Automated Lockdown',
simStep3Desc: 'A serverless function revokes active sessions and seals the perimeter without human intervention.',
simStep4Title: 'Forensic Notification',
simStep4Desc: 'Forensic evidence is compiled and the security certificate is dispatched by email.',
simThreatNodeTitle: 'Threat Vector',
simThreatNodeSub: 'Anomalous iam:CreateAccessKey',
simCloudTrailNodeTitle: 'AWS CloudTrail & EventBridge',
@ -304,5 +322,13 @@ Approach: Zero over-engineering (Ponytail), high throughput, and Zen aesthetic d
simMetricArchitecture: 'Serverless Architecture',
simStatusIdle: 'SYSTEM NOMINAL // LISTENING FOR EVENTS',
simStatusActive: 'THREAT DETECTED // EXECUTING REMEDIATION',
simStatusContained: 'THREAT CONTAINED // SECURE STATE RESTORED'
simStatusContained: 'THREAT CONTAINED // SECURE STATE RESTORED',
simReportTitle: 'FORENSIC AUDIT CERTIFICATE // INCIDENT MITIGATED',
simReportTargetEmail: 'Audit Recipient:',
simReportStatusSecure: 'STATUS: 100% SECURE // ZERO LEAKS',
simReportTimeVsAvg: '184 ms (Vs. 4.2 hours industry average)',
simReportCredentialsCompromised: '0 credentials compromised',
simReportFinancialLoss: '$0.00 USD (Financial damage avoided)',
simReportDownloadBtn: 'Download Forensic Report (Demo PDF)',
simReportDemoBadge: 'LIVE AUTOGENERATED CERTIFICATE'
};

View File

@ -14,6 +14,8 @@ export const ES_TRANSLATIONS: TranslationDictionary = {
navTerminal: 'Terminal',
navContact: 'Contacto',
navAll: 'Ver Todo',
navPrevSection: 'Sección anterior',
navNextSection: 'Siguiente sección',
// Hero 3D
preloading: 'PRECARGANDO',
@ -63,6 +65,9 @@ export const ES_TRANSLATIONS: TranslationDictionary = {
tpAboutName: 'Bryan Baño',
tpAboutDescription: 'Egresado de Ingeniería en Computación (EPN) | Desarrollador Full Stack apasionado por crear arquitecturas eficientes. Me enfoco en construir experiencias digitales claras, accesibles y técnicamente sólidas. Me interesa combinar arquitectura limpia, desarrollo frontend moderno y soluciones backend escalables. Trabajo con .NET, Angular, Signals, APIs REST y bases de datos relacionales.\n\nMi proceso combina ingeniería y diseño. Primero entiendo el problema, después simplifico la experiencia y finalmente construyo una solución mantenible. Priorizo el rendimiento, la accesibilidad, la separación de responsabilidades y la calidad del código.\n\nTambién exploro interfaces inspiradas en terminales Unix, sistemas retro, estética Cyber-Zen y nuevas formas de presentar información técnica sin sacrificar legibilidad. Cada proyecto debe ser funcional, expresivo y fácil de mantener.\n\nActualmente desarrollo soluciones que conectan frontend, backend, diseño visual e interacción digital. Me interesa crear productos que no solo funcionen correctamente, sino que también comuniquen una identidad clara y memorable.',
tpAboutAria: 'Sobre Bryan Baño',
tpAboutLink: 'Acerca de mí',
tpAsciiError: 'No se pudo cargar el retrato.',
tpAsciiLoading: 'Cargando retrato...',
tpEyebrow: 'INGENIERÍA EN COMPUTACIÓN & EXPERTISE',
tpTitle: 'Habilidades Técnicas & Áreas de Interés',
tpLeadStudent: 'Estudiante de 9no semestre de Ingeniería en Computación en la',
@ -287,9 +292,22 @@ Enfoque: Cero sobreingeniería (Ponytail), alta concurrencia y diseño estético
simTag: 'ARQUITECTURA CLOUD & SEGURIDAD SERVERLESS',
simHeading: 'Simulador de Respuesta a Incidentes en Tiempo Real',
simSubheading: 'Visualización interactiva de una arquitectura de contención reactiva en AWS. Experimenta cómo un evento de acceso IAM no autorizado es detectado, mitigado por una función Lambda y reportado vía SES en menos de 180 ms sin intervención manual.',
simBtnSimulate: 'Simular Acceso IAM No Autorizado',
simModeStory: 'Vista Explicada (Negocio)',
simModeTech: 'Vista Telemetría SecOps (Dev)',
simEmailLabel: 'Correo para recibir el reporte forense:',
simEmailPlaceholder: 'tu-correo@empresa.com',
simEmailHint: 'Ingresa tu correo para recibir la auditoría criptográfica del incidente mitigado.',
simBtnSimulate: 'Simular Ataque y Defensa',
simBtnSimulating: 'Conteniendo Amenaza en Tiempo Real...',
simBtnReset: 'Reiniciar Simulación',
simStep1Title: 'Intento de Intrusión',
simStep1Desc: 'Un atacante intenta robar credenciales de administración desde una IP no autorizada.',
simStep2Title: 'Detección Instantánea',
simStep2Desc: 'El sistema monitorea anomalías y dispara la regla de defensa en menos de 50 milisegundos.',
simStep3Title: 'Bloqueo Automático',
simStep3Desc: 'Una función serverless revoca las sesiones y sella el perímetro sin intervención humana.',
simStep4Title: 'Notificación y Reporte',
simStep4Desc: 'Se compila la evidencia forense y se despacha el certificado de seguridad por correo.',
simThreatNodeTitle: 'Vector de Amenaza',
simThreatNodeSub: 'iam:CreateAccessKey anómalo',
simCloudTrailNodeTitle: 'AWS CloudTrail & EventBridge',
@ -304,5 +322,13 @@ Enfoque: Cero sobreingeniería (Ponytail), alta concurrencia y diseño estético
simMetricArchitecture: 'Arquitectura Serverless',
simStatusIdle: 'SISTEMA NOMINAL // ESCUCHANDO EVENTOS',
simStatusActive: 'AMENAZA DETECTADA // EJECUTANDO MITIGACIÓN',
simStatusContained: 'AMENAZA NEUTRALIZADA // ESTADO SEGURO RESTAURADO'
simStatusContained: 'AMENAZA NEUTRALIZADA // ESTADO SEGURO RESTAURADO',
simReportTitle: 'CERTIFICADO FORENSE DE INCIDENTE MITIGADO',
simReportTargetEmail: 'Destinatario de Auditoría:',
simReportStatusSecure: 'ESTADO: 100% SEGURO // SIN FILTRACIONES',
simReportTimeVsAvg: '184 ms (Vs. 4.2 horas promedio de la industria)',
simReportCredentialsCompromised: '0 credenciales comprometidas',
simReportFinancialLoss: '$0.00 USD (Daño financiero evitado)',
simReportDownloadBtn: 'Descargar Reporte Forense (Demo PDF)',
simReportDemoBadge: 'CERTIFICADO AUTOGENERADO EN VIVO'
};

View File

@ -14,6 +14,8 @@ export interface TranslationDictionary {
readonly navTerminal: string;
readonly navContact: string;
readonly navAll: string;
readonly navPrevSection: string;
readonly navNextSection: string;
// Hero 3D
readonly preloading: string;
@ -63,6 +65,9 @@ export interface TranslationDictionary {
readonly tpAboutName: string;
readonly tpAboutDescription: string;
readonly tpAboutAria: string;
readonly tpAboutLink: string;
readonly tpAsciiError: string;
readonly tpAsciiLoading: string;
readonly tpEyebrow: string;
readonly tpTitle: string;
readonly tpLeadStudent: string;
@ -255,9 +260,22 @@ export interface TranslationDictionary {
readonly simTag: string;
readonly simHeading: string;
readonly simSubheading: string;
readonly simModeStory: string;
readonly simModeTech: string;
readonly simEmailLabel: string;
readonly simEmailPlaceholder: string;
readonly simEmailHint: string;
readonly simBtnSimulate: string;
readonly simBtnSimulating: string;
readonly simBtnReset: string;
readonly simStep1Title: string;
readonly simStep1Desc: string;
readonly simStep2Title: string;
readonly simStep2Desc: string;
readonly simStep3Title: string;
readonly simStep3Desc: string;
readonly simStep4Title: string;
readonly simStep4Desc: string;
readonly simThreatNodeTitle: string;
readonly simThreatNodeSub: string;
readonly simCloudTrailNodeTitle: string;
@ -273,4 +291,12 @@ export interface TranslationDictionary {
readonly simStatusIdle: string;
readonly simStatusActive: string;
readonly simStatusContained: string;
readonly simReportTitle: string;
readonly simReportTargetEmail: string;
readonly simReportStatusSecure: string;
readonly simReportTimeVsAvg: string;
readonly simReportCredentialsCompromised: string;
readonly simReportFinancialLoss: string;
readonly simReportDownloadBtn: string;
readonly simReportDemoBadge: string;
}

View File

@ -221,161 +221,146 @@
background: rgba(78, 155, 111, 0.12);
}
.mobile-only {
display: none;
}
@media (max-width: 900px) {
.lateral-panel {
position: absolute;
z-index: 10;
width: calc(100% - 24px) !important;
max-width: 440px !important;
left: 50% !important;
right: auto !important;
transform: translateX(-50%) translateY(12px);
background: rgba(14, 17, 23, 0.88);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--zen-radius-sm, 6px);
padding: 12px 14px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.75);
box-sizing: border-box;
}
.lateral-panel.active {
transform: translateX(-50%) translateY(0);
}
/* FASE 1: Lead arriba, Filosofía abajo */
.pos-f1-left {
top: 56px !important;
bottom: auto !important;
}
.pos-f1-left .lead-desc,
.pos-f1-left .lead-footer {
.desktop-only {
display: none !important;
}
.pos-f1-right {
top: auto !important;
bottom: max(16px, env(safe-area-inset-bottom, 16px)) !important;
.mobile-only {
display: flex;
flex-direction: column;
width: 100%;
gap: var(--space-4);
padding: var(--space-2) 0 var(--space-8);
box-sizing: border-box;
}
/* FASE 2: Frontend arriba, Backend abajo */
.pos-f2-right {
top: 56px !important;
bottom: auto !important;
padding: 10px 14px;
/* Tarjeta Principal de Perfil Móvil */
.mobile-lead-card {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5);
background:
linear-gradient(180deg, rgba(18, 22, 30, 0.96), rgba(13, 16, 22, 0.92)),
#0d1117;
border: 1px solid var(--zen-border-subtle);
border-radius: var(--zen-radius-md);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
box-sizing: border-box;
width: 100%;
}
.pos-f2-left {
top: auto !important;
bottom: max(16px, env(safe-area-inset-bottom, 16px)) !important;
}
/* FASE 3: Laboratorio arriba, Clean Arch abajo */
.pos-f3-right {
top: 56px !important;
bottom: auto !important;
padding: 10px 14px;
}
.pos-f3-left {
top: auto !important;
bottom: max(16px, env(safe-area-inset-bottom, 16px)) !important;
}
/* Tipografía y espaciado compacto para móvil */
.lead-header {
gap: 4px;
margin-bottom: 8px;
flex-direction: row;
.mobile-lead-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.lead-status {
font-size: 0.62rem;
line-height: 1.2;
}
.lead-tag {
font-size: 0.58rem;
line-height: 1.2;
}
.lead-name {
font-size: 1.5rem;
margin: 0 0 4px 0;
line-height: 1.15;
}
.lead-role {
font-size: 0.64rem;
margin-bottom: 6px;
line-height: 1.3;
}
.lead-desc {
font-size: 0.74rem;
line-height: 1.45;
margin: 0 0 10px 0;
max-width: 100%;
}
.lead-badges, .panel-tags {
gap: 6px;
}
.lead-footer {
margin-top: 10px;
padding-top: 8px;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.lead-metric {
font-size: 0.62rem;
line-height: 1.3;
.mobile-lead-name {
font-family: var(--font-sans);
font-size: clamp(2rem, 7vw, 2.6rem);
font-weight: 800;
color: var(--zen-text-primary);
line-height: 1.12;
letter-spacing: -0.03em;
margin: 0;
}
.lead-social-links {
flex-direction: row;
gap: 8px;
}
.social-link {
width: 32px;
height: 32px;
}
.social-link svg {
width: 16px;
height: 16px;
}
.panel-title {
font-size: 0.95rem;
margin-bottom: 4px;
}
.panel-desc {
.mobile-lead-role {
font-family: var(--font-sans);
font-size: 0.72rem;
line-height: 1.35;
margin-bottom: 6px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--zen-accent-kintsugi);
margin: 0;
}
.panel-eyebrow {
font-size: 0.6rem;
margin-bottom: 4px;
.mobile-lead-desc {
font-family: var(--font-sans);
font-size: 0.875rem;
color: var(--zen-text-secondary);
line-height: 1.65;
margin: 0;
}
.zen-loader-pill {
top: 60px;
right: 12px;
font-size: 0.7rem;
padding: 3px 8px;
.mobile-lead-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 4px 0;
}
.mobile-lead-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-top: var(--space-3);
border-top: 1px solid var(--zen-border-subtle);
margin-top: var(--space-1);
}
.mobile-lead-metric {
font-family: var(--font-sans);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--zen-text-muted);
}
.mobile-social-links {
display: flex;
align-items: center;
gap: 10px;
}
/* Cuadrícula de Pilares Técnicos Móvil */
.mobile-pillars-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
width: 100%;
}
@media (min-width: 600px) {
.mobile-pillars-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.mobile-pillar-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: var(--space-4);
background: rgba(18, 22, 28, 0.7);
border: 1px solid var(--zen-border-subtle);
border-radius: var(--zen-radius-sm);
box-sizing: border-box;
backdrop-filter: blur(8px);
}
.mobile-pillar-card .panel-title {
font-size: 1.05rem;
margin: 0;
}
.mobile-pillar-card .panel-desc {
font-size: 0.78rem;
line-height: 1.5;
margin: 0;
}
.mobile-pillar-card .panel-tags {
margin-top: 4px;
}
}

View File

@ -1,4 +1,5 @@
<section #scrollContainer class="hero-scroll-wrapper" [attr.aria-label]="i18n.t().heroAriaExperience">
<!-- Vista Desktop: Animación 3D Laptop Lenovo ThinkPad (min-width: 901px) -->
<section #scrollContainer class="hero-scroll-wrapper desktop-only" [attr.aria-label]="i18n.t().heroAriaExperience">
<div class="hero-sticky-stage">
<!-- Lienzo Canvas en alta resolución que ocupa toda la pantalla de fondo -->
<canvas #canvas class="thinkpad-canvas" aria-hidden="true"></canvas>
@ -131,3 +132,122 @@
</aside>
</div>
</section>
<!-- Vista Móvil: Información estructurada y limpia sin animación pesada (max-width: 900px) -->
<section class="hero-mobile-container mobile-only" [attr.aria-label]="i18n.t().heroAriaExperience">
<!-- Tarjeta Principal de Perfil / Lead -->
<div class="mobile-lead-card">
<div class="mobile-lead-header">
<span class="lead-status">{{ i18n.t().p1LeadStatus }}</span>
<span class="lead-tag">{{ i18n.t().p1LeadTag }}</span>
</div>
<h1 class="mobile-lead-name">{{ i18n.t().p1LeadName }}</h1>
<span class="mobile-lead-role">{{ i18n.t().p1LeadRole }}</span>
<p class="mobile-lead-desc">{{ i18n.t().p1LeadDesc }}</p>
<div class="mobile-lead-badges">
<span class="zen-chip chip-gold">{{ i18n.t().p1LeadBadge1 }}</span>
<span class="zen-chip chip-matcha">{{ i18n.t().p1LeadBadge2 }}</span>
<span class="zen-chip">{{ i18n.t().p1LeadBadge3 }}</span>
</div>
<div class="mobile-lead-footer">
<span class="mobile-lead-metric">{{ i18n.t().p1LeadMetric }}</span>
<nav class="mobile-social-links" aria-label="Redes sociales">
<a
class="social-link"
href="https://github.com/bryancito1090"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.61-3.37-1.18-3.37-1.18-.45-1.15-1.11-1.46-1.11-1.46-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.36 1.09 2.94.83.09-.65.35-1.09.64-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.58 9.58 0 0 1 12 7.99c.85 0 1.7.11 2.49.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.6c0 .26.18.57.69.47A10 10 0 0 0 12 2Z"/>
</svg>
</a>
<a
class="social-link"
href="https://www.linkedin.com/in/bryan-bano"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5.2 3.5a2.2 2.2 0 1 1 0 4.4 2.2 2.2 0 0 1 0-4.4ZM3.4 9.2h3.6V21H3.4V9.2Zm5.8 0h3.4v1.61h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.26 2.37 4.26 5.45V21h-3.6v-5.84c0-1.39-.03-3.18-1.94-3.18-1.94 0-2.24 1.52-2.24 3.08V21H9.2V9.2Z"/>
</svg>
</a>
<a
class="social-link"
href="https://www.instagram.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3.5" y="3.5" width="17" height="17" rx="4"/>
<circle cx="12" cy="12" r="4"/>
<circle cx="17.5" cy="6.5" r="1"/>
</svg>
</a>
</nav>
</div>
</div>
<!-- Cuadrícula de Pilares Técnicos y Filosofía -->
<div class="mobile-pillars-grid">
<!-- Pilar 1: Backend Enterprise -->
<div class="mobile-pillar-card">
<span class="panel-eyebrow">{{ i18n.t().p2LeftTag }}</span>
<h3 class="panel-title">{{ i18n.t().p2LeftTitle }}</h3>
<p class="panel-desc">{{ i18n.t().p2LeftDesc }}</p>
<div class="panel-tags">
<span class="zen-chip chip-gold">{{ i18n.t().p2LeftBadge1 }}</span>
<span class="zen-chip">{{ i18n.t().p2LeftBadge2 }}</span>
</div>
</div>
<!-- Pilar 2: Frontend Reactivo -->
<div class="mobile-pillar-card">
<span class="panel-eyebrow">{{ i18n.t().p2RightTag }}</span>
<h3 class="panel-title">{{ i18n.t().p2RightTitle }}</h3>
<p class="panel-desc">{{ i18n.t().p2RightDesc }}</p>
<div class="panel-tags">
<span class="zen-chip chip-matcha">{{ i18n.t().p2RightBadge1 }}</span>
<span class="zen-chip">{{ i18n.t().p2RightBadge2 }}</span>
</div>
</div>
<!-- Pilar 3: Arquitectura & Filosofía -->
<div class="mobile-pillar-card">
<span class="panel-eyebrow">{{ i18n.t().p1RightTag }}</span>
<h3 class="panel-title">{{ i18n.t().p1RightTitle }}</h3>
<p class="panel-desc">{{ i18n.t().p1RightDesc }}</p>
<div class="panel-tags">
<span class="zen-chip">{{ i18n.t().p1RightBadge }}</span>
</div>
</div>
<!-- Pilar 4: Estándares de Código -->
<div class="mobile-pillar-card">
<span class="panel-eyebrow">{{ i18n.t().p3LeftTag }}</span>
<h3 class="panel-title">{{ i18n.t().p3LeftTitle }}</h3>
<p class="panel-desc">{{ i18n.t().p3LeftDesc }}</p>
<div class="panel-tags">
<span class="zen-chip">{{ i18n.t().p3LeftBadge1 }}</span>
<span class="zen-chip">{{ i18n.t().p3LeftBadge2 }}</span>
</div>
</div>
<!-- Pilar 5: Laboratorio Interactivo -->
<div class="mobile-pillar-card">
<span class="panel-eyebrow">{{ i18n.t().p3RightTag }}</span>
<h3 class="panel-title">{{ i18n.t().p3RightTitle }}</h3>
<p class="panel-desc">{{ i18n.t().p3RightDesc }}</p>
<div class="panel-tags">
<span class="zen-chip chip-gold">{{ i18n.t().p3RightBadge1 }}</span>
<span class="zen-chip chip-matcha">{{ i18n.t().p3RightBadge2 }}</span>
</div>
</div>
</div>
</section>

View File

@ -8,7 +8,8 @@ import {
ChangeDetectionStrategy,
signal,
inject,
NgZone
NgZone,
input
} from '@angular/core';
import { I18nService } from '../../../application/services/i18n.service';
@ -23,6 +24,8 @@ const LERP_FACTOR = 0.09;
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
readonly initialPosition = input<'top' | 'bottom'>('top');
@ViewChild('scrollContainer') private containerRef!: ElementRef<HTMLDivElement>;
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
@ -47,17 +50,33 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
private mediaQueryList?: MediaQueryList;
ngOnInit(): void {
if (this.initialPosition() === 'bottom') {
this.targetFrame = TOTAL_FRAMES;
this.currentInterpolatedFrame = TOTAL_FRAMES;
this.lastRenderedFrame = TOTAL_FRAMES;
this.activePhase.set(3);
}
this.checkReducedMotion();
this.preloadImages();
if (typeof window !== 'undefined' && window.innerWidth <= 900) {
this.isLoaded.set(true);
} else {
this.preloadImages();
}
}
ngAfterViewInit(): void {
if (!this.canvasRef || !this.containerRef) {
this.isLoaded.set(true);
return;
}
const canvas = this.canvasRef.nativeElement;
this.ctx = canvas.getContext('2d', { alpha: false });
this.intersectionObserver = new IntersectionObserver(([entry]) => {
this.isVisible = entry.isIntersecting;
if (this.isVisible && !this.isReducedMotion) {
if (this.isVisible && !this.isReducedMotion && typeof window !== 'undefined' && window.innerWidth > 900) {
this.startAnimationLoop();
} else if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
@ -72,8 +91,27 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
window.addEventListener('resize', this.onResize, { passive: true });
});
this.onResize();
this.onScroll();
if (typeof window !== 'undefined' && window.innerWidth > 900) {
const container = this.containerRef.nativeElement;
const scrollableDistance = container.offsetHeight - window.innerHeight;
if (this.initialPosition() === 'bottom' && scrollableDistance > 0) {
window.scrollTo({ top: scrollableDistance, behavior: 'instant' });
this.targetFrame = TOTAL_FRAMES;
this.currentInterpolatedFrame = TOTAL_FRAMES;
this.lastRenderedFrame = TOTAL_FRAMES;
this.activePhase.set(3);
} else if (this.initialPosition() === 'top') {
window.scrollTo({ top: 0, behavior: 'instant' });
this.targetFrame = 1;
this.currentInterpolatedFrame = 1;
this.lastRenderedFrame = 1;
this.activePhase.set(1);
}
this.onResize();
this.renderFrame(Math.round(this.currentInterpolatedFrame));
}
}
private checkReducedMotion(): void {
@ -88,7 +126,7 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
this.isLoopRunning = false;
}
this.renderFrame(1);
} else {
} else if (typeof window !== 'undefined' && window.innerWidth > 900) {
this.startAnimationLoop();
}
});
@ -96,15 +134,17 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
private preloadImages(): void {
let loaded = 0;
const startFrame = this.initialPosition() === 'bottom' ? TOTAL_FRAMES : 1;
const firstImg = new Image();
firstImg.src = this.getFramePath(1);
firstImg.src = this.getFramePath(startFrame);
firstImg.onload = () => {
this.images[1] = firstImg;
this.images[startFrame] = firstImg;
loaded++;
this.renderFrame(1);
this.renderFrame(startFrame);
for (let i = 2; i <= TOTAL_FRAMES; i++) {
for (let i = 1; i <= TOTAL_FRAMES; i++) {
if (i === startFrame) continue;
const img = new Image();
img.src = this.getFramePath(i);
img.onload = () => {
@ -131,6 +171,7 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
}
private onScroll = (): void => {
if (typeof window !== 'undefined' && window.innerWidth <= 900) return;
if (!this.isVisible || !this.containerRef || this.isReducedMotion) return;
const container = this.containerRef.nativeElement;
@ -143,10 +184,26 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
const progress = Math.min(Math.max(rawProgress, 0), 1);
this.targetFrame = Math.min(Math.max(1, 1 + progress * (TOTAL_FRAMES - 1)), TOTAL_FRAMES);
// Si ocurre un salto grande de posición inicial, sincronizar frame directamente sin animación forzada
const jumpDiff = Math.abs(this.targetFrame - this.currentInterpolatedFrame);
if (jumpDiff > 40 && !this.isLoopRunning) {
this.currentInterpolatedFrame = this.targetFrame;
this.renderFrame(Math.round(this.targetFrame));
this.updateActivePhase(this.targetFrame);
return;
}
this.startAnimationLoop();
};
private onResize = (): void => {
if (typeof window !== 'undefined' && window.innerWidth <= 900) {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.isLoopRunning = false;
}
return;
}
this.resizeCanvas();
this.renderFrame(Math.round(this.currentInterpolatedFrame));
};

View File

@ -1,344 +1,126 @@
:host { display: block; width: 100%; }
.zen-sim-container {
max-width: 1540px;
margin: 0 auto;
padding: 0 24px 40px;
display: flex;
flex-direction: column;
gap: var(--space-4, 16px);
box-sizing: border-box;
width: 100%;
}
/* Cabecera Normalizada de Sección */
.sim-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-bottom: var(--space-2, 8px);
border-bottom: 1px solid var(--zen-border-subtle, #1e2533);
margin-bottom: var(--space-1, 4px);
flex-wrap: wrap;
gap: var(--space-3, 12px);
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-3, 12px);
}
.title-with-badge {
display: flex;
align-items: center;
gap: var(--space-3, 12px);
}
.pulse-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--zen-accent-kintsugi, #e0af68);
box-shadow: 0 0 12px var(--zen-accent-kintsugi, #e0af68);
flex-shrink: 0;
}
.section-title {
margin: 0;
font-family: var(--font-sans);
font-size: clamp(1.6rem, 3vw, 2.25rem);
font-weight: 800;
letter-spacing: -0.01em;
color: var(--zen-text-primary, #f0f3f8);
line-height: 1.15;
text-transform: uppercase;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(30, 41, 59, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px 14px;
border-radius: 999px;
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
font-weight: 600;
color: #9aa5b8;
}
.status-beacon {
width: 8px;
height: 8px;
border-radius: 50%;
background: #38bdf8;
box-shadow: 0 0 8px #38bdf8;
animation: pulseBeacon 2s infinite ease-in-out;
}
.zen-sim-board {
background: #0a0d14;
border: 1px solid #1e2533;
border-radius: 6px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
}
.diagram-canvas {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
align-items: center;
gap: 8px;
}
.arch-node {
background: #11151f;
border: 1px solid #1e293b;
border-radius: 4px;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
min-height: 125px;
box-sizing: border-box;
}
.node-badge-tag {
font-family: var(--font-mono, monospace);
font-size: 0.625rem;
font-weight: 800;
letter-spacing: 1px;
padding: 2px 6px;
border-radius: 2px;
width: fit-content;
}
.node-badge-tag.threat { background: rgba(239, 68, 68, 0.2); color: #f87171; }
.node-badge-tag.detection { background: rgba(56, 189, 248, 0.2); color: #38bdf8; }
.node-badge-tag.remediation { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
.node-badge-tag.notification { background: rgba(197, 155, 39, 0.2); color: #e2b94a; }
.node-icon-frame {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #9aa5b8;
}
.node-svg { width: 20px; height: 20px; }
.node-info { display: flex; flex-direction: column; gap: 2px; }
.node-title {
font-family: var(--font-sans, sans-serif);
font-size: 0.8125rem;
font-weight: 700;
color: #f0f3f8;
margin: 0;
}
.node-detail, .node-meta-ip, .node-meta-svc {
font-family: var(--font-mono, monospace);
font-size: 0.6875rem;
color: #9aa5b8;
}
.node-meta-ip, .node-meta-svc { font-size: 0.625rem; color: #64748b; }
.node-status-indicator {
position: absolute;
top: 8px;
right: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #334155;
}
.threat-node.active { border-color: #ef4444; background: #1c1316; }
.threat-node.active .node-icon-frame, .threat-node.active .node-status-indicator.ping { color: #ef4444; background: #ef4444; }
.detection-node.active { border-color: #38bdf8; background: #0f1c29; }
.detection-node.active .node-icon-frame, .detection-node.active .node-status-indicator.ping { color: #38bdf8; background: #38bdf8; }
.remediation-node.active { border-color: #22c55e; background: #0f2316; }
.remediation-node.active .node-icon-frame, .remediation-node.active .node-status-indicator.ping { color: #22c55e; background: #22c55e; }
.notification-node.active { border-color: #c59b27; background: #231c0e; }
.notification-node.active .node-icon-frame, .notification-node.active .node-status-indicator.ping { color: #c59b27; background: #c59b27; }
.arch-node.past { border-color: rgba(34, 197, 94, 0.4); }
.arch-node.past .node-status-indicator { background: #22c55e; }
.flow-connector {
display: flex;
align-items: center;
justify-content: center;
position: relative;
height: 20px;
color: #334155;
}
.beam-line {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 2px;
background: #1e293b;
transform: translateY(-50%);
}
.flow-arrow {
position: relative;
z-index: 2;
font-size: 0.625rem;
background: #0a0d14;
padding: 0 2px;
}
.flow-connector.flowing { color: #38bdf8; }
.flow-connector.flowing .beam-line {
background: linear-gradient(90deg, #ef4444, #38bdf8, #22c55e);
}
.sim-action-deck {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.sim-trigger-btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: #c59b27;
color: #07080b;
border: 1px solid #e2b94a;
border-radius: 4px;
padding: 10px 18px;
font-family: var(--font-sans, sans-serif);
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
transition: opacity 0.2s;
}
.sim-trigger-btn:disabled { opacity: 0.65; cursor: not-allowed; }
.sim-reset-btn {
background: transparent;
color: #9aa5b8;
border: 1px solid #2d3748;
border-radius: 4px;
padding: 9px 16px;
font-family: var(--font-sans, sans-serif);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.secops-console-frame {
background: #06080c;
border: 1px solid #1e2533;
border-radius: 4px;
overflow: hidden;
font-family: var(--font-mono, monospace);
display: flex;
flex-direction: column;
}
.console-titlebar {
background: #0f131a;
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #1e2533;
}
.console-dots { display: flex; gap: 5px; }
.dot { width: 7px; height: 7px; border-radius: 50%; }
.dot.red { background: #ef4444; }
.dot.yellow { background: #eab308; }
.dot.green { background: #22c55e; }
.console-title { font-size: 0.6875rem; color: #94a3b8; }
.console-pill {
font-size: 0.5625rem;
color: #38bdf8;
background: rgba(56, 189, 248, 0.12);
padding: 2px 5px;
border-radius: 2px;
}
.console-body {
padding: 8px 10px;
min-height: 100px;
max-height: 160px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
line-height: 1.4;
}
.log-row { display: flex; gap: 8px; color: #94a3b8; }
.log-time { color: #475569; flex-shrink: 0; }
.log-tag { font-weight: 700; flex-shrink: 0; }
.log-row.warn .log-tag, .log-row.warn .log-msg { color: #f87171; }
.log-row.action .log-tag, .log-row.action .log-msg { color: #fde047; }
.log-row.success .log-tag, .log-row.success .log-msg { color: #4ade80; }
.console-cursor-row { display: flex; align-items: center; gap: 6px; margin-top: 3px; }
.console-prompt { color: #34d399; font-size: 0.6875rem; }
.cursor-block { width: 6px; height: 11px; background: #38bdf8; }
.arch-metrics-bar {
display: flex;
align-items: center;
justify-content: space-around;
padding-top: 10px;
border-top: 1px solid #1a2230;
flex-wrap: wrap;
gap: 12px;
}
.metric-card { display: flex; flex-direction: column; gap: 2px; text-align: center; }
.metric-label {
font-family: var(--font-mono, monospace);
font-size: 0.6875rem;
color: #5e697d;
text-transform: uppercase;
}
.metric-val {
font-family: var(--font-mono, monospace);
font-size: 0.8125rem;
font-weight: 700;
color: #f0f3f8;
}
.metric-val.highlight { color: #38bdf8; }
.metric-val.success { color: #4ade80; }
.metric-divider { width: 1px; height: 20px; background: #1e293b; }
@keyframes pulseBeacon { 0%, 100% { opacity: 0.6; transform: scale(1); } 50% { opacity: 1; transform: scale(1.3); } }
@media (max-width: 900px) {
.diagram-canvas { grid-template-columns: 1fr; gap: 10px; }
.flow-connector { height: 16px; width: 100%; transform: rotate(90deg); }
.metric-divider { display: none; }
.arch-metrics-bar { flex-direction: column; gap: 10px; }
:host{display:block;width:100%}
.zen-sim-container{max-width:1540px;margin:0 auto;padding:0 24px 40px;display:flex;flex-direction:column;gap:16px;width:100%;box-sizing:border-box}
.sim-section-header,.report-header,.console-titlebar,.arch-metrics-bar{display:flex;align-items:center;justify-content:space-between}
.sim-section-header{width:100%;padding-bottom:8px;border-bottom:1px solid #1e2533;margin-bottom:4px;flex-wrap:wrap;gap:12px}
.header-left,.report-header-left,.email-input-wrapper,.story-step-card,.arch-node,.node-info,.secops-console-frame,.forensic-report-card,.report-metric-box,.metric-card{display:flex;flex-direction:column}
.header-left{gap:4px}
.title-with-badge,.email-label,.action-buttons,.sim-trigger-btn,.mode-btn,.console-cursor-row,.download-report-btn{display:flex;align-items:center}
.title-with-badge{gap:12px}
.section-tag,.step-badge,.node-badge-tag,.report-badge,.box-label,.metric-label,.console-title,.console-pill,.log-row,.status-pill,.step-meta,.node-detail,.node-meta-ip,.node-meta-svc,.email-field,.box-val,.metric-val,.console-prompt{font-family:var(--font-mono,monospace)}
.section-tag{font-size:.6875rem;color:#5e697d;letter-spacing:.08em}
.pulse-indicator{width:10px;height:10px;border-radius:50%;background:#c59b27;box-shadow:0 0 12px #c59b27;flex-shrink:0}
.section-title{margin:0;font-size:clamp(1.6rem,3vw,2.25rem);font-weight:800;color:#f0f3f8;line-height:1.15;text-transform:uppercase}
.header-controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.view-mode-toggle{display:inline-flex;background:#0d1117;border:1px solid #1e2533;border-radius:4px;padding:2px;gap:2px}
.mode-btn{gap:6px;padding:5px 10px;background:0 0;border:none;border-radius:2px;color:#9aa5b8;font-size:.75rem;font-weight:600;cursor:pointer}
.mode-btn.active{background:#131720;color:#c59b27;box-shadow:0 1px 4px rgba(0,0,0,.4)}
.mode-icon{width:13px;height:13px}
.status-pill{gap:8px;background:rgba(30,41,59,.6);border:1px solid rgba(255,255,255,.1);padding:6px 14px;border-radius:999px;font-size:.75rem;font-weight:600;color:#9aa5b8}
.status-beacon{width:8px;height:8px;border-radius:50%;background:#38bdf8;box-shadow:0 0 8px #38bdf8;animation:pulseBeacon 2s infinite ease-in-out}
.status-pill.danger .status-beacon,.dot.red{background:#ef4444}
.status-pill.active .status-beacon,.dot.yellow{background:#eab308}
.status-pill.contained .status-beacon,.dot.green{background:#22c55e}
.zen-sim-board{background:#0a0d14;border:1px solid #1e2533;border-radius:6px;padding:24px;display:flex;flex-direction:column;gap:20px}
.email-control-deck{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;background:#10141d;border:1px solid #1c2331;border-radius:4px;padding:14px 18px;flex-wrap:wrap}
.email-input-wrapper{gap:6px;flex:1;min-width:260px}
.email-label{gap:6px;font-size:.78rem;font-weight:600;color:#9aa5b8}
.email-icon{width:14px;height:14px;color:#c59b27}
.email-field{background:#07090e;border:1px solid #1e2533;border-radius:3px;color:#f0f3f8;font-size:.8125rem;padding:8px 12px;outline:0}
.email-field:focus{border-color:#c59b27;box-shadow:0 0 6px rgba(197,155,39,.25)}
.action-buttons{gap:10px}
.sim-trigger-btn{gap:8px;background:#c59b27;color:#07080b;border:1px solid #e2b94a;border-radius:3px;padding:9px 18px;font-size:.84rem;font-weight:700;cursor:pointer}
.sim-trigger-btn:hover:not(:disabled){background:#d4af37;box-shadow:0 0 12px rgba(197,155,39,.4)}
.sim-trigger-btn:disabled{opacity:.6;cursor:not-allowed}
.sim-reset-btn{background:0 0;color:#9aa5b8;border:1px solid #2d3748;border-radius:3px;padding:8px 14px;font-size:.8125rem;font-weight:600;cursor:pointer}
.story-grid-flow{display:flex;align-items:stretch;justify-content:space-between;gap:8px;width:100%}
.story-step-card{flex:1;background:#11151f;border:1px solid #1e293b;border-radius:4px;padding:16px 14px;gap:8px;transition:all .3s}
.step-badge,.node-badge-tag{font-size:.625rem;font-weight:700;letter-spacing:.08em}
.step-badge{color:#64748b}
.step-icon-wrap{width:36px;height:36px;border-radius:4px;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.04);color:#9aa5b8}
.step-icon-wrap svg{width:20px;height:20px}
.node-badge-tag.threat,.step-icon-wrap.danger{color:#f87171;background:rgba(239,68,68,.1)}
.node-badge-tag.detection,.step-icon-wrap.warning{color:#38bdf8;background:rgba(56,189,248,.1)}
.node-badge-tag.remediation,.step-icon-wrap.action{color:#4ade80;background:rgba(34,197,94,.1)}
.node-badge-tag.notification,.step-icon-wrap.success{color:#e2b94a;background:rgba(197,155,39,.1)}
.step-title,.node-title{font-size:.8125rem;font-weight:700;color:#f0f3f8;margin:0}
.step-title{font-size:.875rem}
.step-desc{font-size:.75rem;color:#9aa5b8;line-height:1.45;margin:0;flex-grow:1}
.step-meta{font-size:.6875rem;color:#64748b;border-top:1px solid #1c2331;padding-top:6px}
.story-step-card.active{border-color:#c59b27;background:#171b26;box-shadow:0 0 16px rgba(197,155,39,.2);transform:translateY(-2px)}
.story-step-card.completed,.arch-node.past{border-color:rgba(34,197,94,.4)}
.story-arrow{display:flex;align-items:center;justify-content:center;color:#2d3748;font-size:1.1rem;font-weight:700;padding:0 4px}
.story-arrow.active{color:#c59b27}
.diagram-canvas{display:grid;grid-template-columns:1fr auto 1fr auto 1fr auto 1fr;align-items:center;gap:8px;width:100%}
.arch-node{background:#11151f;border:1px solid #1e293b;border-radius:4px;padding:12px 10px;gap:6px;position:relative;min-height:125px;box-sizing:border-box}
.node-badge-tag{padding:2px 6px;border-radius:2px;width:fit-content}
.node-icon-frame{width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#9aa5b8;flex-shrink:0}
.node-svg{width:22px;height:22px;max-width:22px;max-height:22px;display:block}
.node-info{gap:2px}
.node-detail,.node-meta-ip,.node-meta-svc{font-size:.6875rem;color:#9aa5b8}
.node-meta-ip,.node-meta-svc{font-size:.625rem;color:#64748b}
.node-status-indicator{position:absolute;top:8px;right:8px;width:6px;height:6px;border-radius:50%;background:#334155}
.threat-node.active{border-color:#ef4444;background:#1c1316}
.threat-node.active .node-icon-frame,.threat-node.active .node-status-indicator.ping{color:#ef4444;background:#ef4444}
.detection-node.active{border-color:#38bdf8;background:#0f1c29}
.detection-node.active .node-icon-frame,.detection-node.active .node-status-indicator.ping{color:#38bdf8;background:#38bdf8}
.remediation-node.active{border-color:#22c55e;background:#0f2316}
.remediation-node.active .node-icon-frame,.remediation-node.active .node-status-indicator.ping{color:#22c55e;background:#22c55e}
.notification-node.active{border-color:#c59b27;background:#231c0e}
.notification-node.active .node-icon-frame,.notification-node.active .node-status-indicator.ping{color:#c59b27;background:#c59b27}
.arch-node.past .node-status-indicator{background:#22c55e}
.flow-connector{display:flex;align-items:center;justify-content:center;position:relative;height:20px;color:#334155}
.beam-line{position:absolute;top:50%;left:0;right:0;height:2px;background:#1e293b;transform:translateY(-50%)}
.flow-arrow{position:relative;z-index:2;font-size:.625rem;background:#0a0d14;padding:0 2px}
.flow-connector.flowing{color:#38bdf8}
.flow-connector.flowing .beam-line{background:linear-gradient(90deg,#ef4444,#38bdf8,#22c55e)}
.secops-console-frame{background:#06080c;border:1px solid #1e2533;border-radius:4px;overflow:hidden}
.console-titlebar{background:#0f131a;padding:6px 10px;border-bottom:1px solid #1e2533}
.console-dots{display:flex;gap:5px}
.dot{width:7px;height:7px;border-radius:50%}
.console-title{font-size:.6875rem;color:#94a3b8}
.console-pill{font-size:.5625rem;color:#38bdf8;background:rgba(56,189,248,.12);padding:2px 5px;border-radius:2px}
.console-body{padding:8px 10px;min-height:100px;max-height:160px;overflow-y:auto;display:flex;flex-direction:column;gap:4px;font-size:.75rem;line-height:1.4}
.log-row{display:flex;gap:8px;color:#94a3b8}
.log-time{color:#475569;flex-shrink:0}
.log-tag{font-weight:700;flex-shrink:0}
.log-row.warn .log-msg,.log-row.warn .log-tag{color:#f87171}
.log-row.action .log-msg,.log-row.action .log-tag{color:#fde047}
.log-row.success .log-msg,.log-row.success .log-tag{color:#4ade80}
.console-cursor-row{gap:6px;margin-top:3px}
.console-prompt{color:#34d399;font-size:.6875rem}
.cursor-block{width:6px;height:11px;background:#38bdf8}
.forensic-report-card{background:linear-gradient(180deg,#10151f 0%,#0c1017 100%);border:1px solid rgba(197,155,39,.4);border-radius:6px;padding:20px;gap:16px;box-shadow:0 4px 20px rgba(0,0,0,.4);animation:fadeInReport .4s ease-out}
@keyframes fadeInReport{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
.report-header{flex-wrap:wrap;gap:12px;border-bottom:1px solid #1e293b;padding-bottom:12px}
.report-header-left{gap:4px}
.report-badge{font-size:.625rem;color:#c59b27;letter-spacing:.1em;font-weight:700}
.report-title{margin:0;font-size:1.05rem;font-weight:800;color:#f0f3f8}
.report-status-pill{background:rgba(34,197,94,.15);border:1px solid rgba(34,197,94,.4);color:#4ade80;font-size:.6875rem;font-weight:700;padding:4px 10px;border-radius:999px}
.report-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
.report-metric-box{background:#080b10;border:1px solid #1a2230;border-radius:4px;padding:10px 12px;gap:4px}
.box-label{font-size:.625rem;color:#64748b;text-transform:uppercase}
.box-val{font-size:.8125rem;font-weight:700;color:#f0f3f8}
.box-val.highlight-mail{color:#e2b94a}
.box-val.highlight-time{color:#38bdf8}
.box-val.success{color:#4ade80}
.report-footer{display:flex;justify-content:flex-end}
.download-report-btn{gap:8px;background:0 0;border:1px solid rgba(197,155,39,.5);color:#c59b27;padding:8px 16px;border-radius:3px;font-size:.8125rem;font-weight:600;cursor:pointer}
.download-report-btn:hover{background:rgba(197,155,39,.1);border-color:#c59b27}
.dl-icon{width:15px;height:15px}
.arch-metrics-bar{padding:16px 12px 6px;border-top:1px solid #1a2230;flex-wrap:nowrap;gap:16px;width:100%;box-sizing:border-box}
.metric-card{align-items:center;justify-content:center;gap:4px;text-align:center;flex:1}
.metric-label{font-size:.6875rem;color:#5e697d;text-transform:uppercase;letter-spacing:.04em}
.metric-val{font-size:.875rem;font-weight:700;color:#f0f3f8}
.metric-val.highlight{color:#38bdf8}
.metric-val.success{color:#4ade80}
.metric-divider{width:1px;height:28px;background:#1e293b;flex-shrink:0}
@keyframes pulseBeacon{0%,100%{opacity:.6;transform:scale(1)}50%{opacity:1;transform:scale(1.3)}}
@media (max-width:1024px){
.story-grid-flow{flex-direction:column}
.story-arrow{transform:rotate(90deg);margin:-4px 0}
.report-grid{grid-template-columns:repeat(2,1fr)}
.diagram-canvas{grid-template-columns:1fr;gap:10px}
.flow-connector{height:16px;width:100%;transform:rotate(90deg)}
.metric-divider{display:none}
.arch-metrics-bar{flex-direction:column;gap:12px}
}

View File

@ -1,173 +1,350 @@
<section class="zen-sim-container" aria-label="Simulador de Respuesta a Incidentes Cloud">
<!-- Cabecera Normalizada de Sección -->
<!-- Cabecera de Sección con Selector de Modo y Status -->
<header class="sim-section-header">
<div class="header-left">
<div class="title-with-badge">
<span class="pulse-indicator" aria-hidden="true"></span>
<h2 class="section-title">{{ i18n.t().navSimulator }}</h2>
</div>
<span class="section-tag">{{ i18n.t().simTag }}</span>
</div>
<div class="status-pill" [class.danger]="stage() === 'breach' || stage() === 'detection'" [class.contained]="isContained()" [class.active]="stage() === 'remediation' || stage() === 'notification'">
<span class="status-beacon"></span>
<span class="status-text">
@if (stage() === 'idle') {
{{ i18n.t().simStatusIdle }}
} @else if (isContained()) {
{{ i18n.t().simStatusContained }}
} @else {
{{ i18n.t().simStatusActive }}
}
</span>
<div class="header-controls">
<!-- Selector de Modo de Vista (Humana vs Técnica) -->
<div class="view-mode-toggle" role="group" aria-label="Modo de visualización">
<button
type="button"
class="mode-btn"
[class.active]="viewMode() === 'story'"
(click)="setViewMode('story')"
>
<svg class="mode-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
<span>{{ i18n.t().simModeStory }}</span>
</button>
<button
type="button"
class="mode-btn"
[class.active]="viewMode() === 'tech'"
(click)="setViewMode('tech')"
>
<svg class="mode-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"/>
<line x1="12" y1="19" x2="20" y2="19"/>
</svg>
<span>{{ i18n.t().simModeTech }}</span>
</button>
</div>
<!-- Status Pill -->
<div class="status-pill" [class.danger]="stage() === 'breach' || stage() === 'detection'" [class.contained]="isContained()" [class.active]="stage() === 'remediation' || stage() === 'notification'">
<span class="status-beacon"></span>
<span class="status-text">
@if (stage() === 'idle') {
{{ i18n.t().simStatusIdle }}
} @else if (isContained()) {
{{ i18n.t().simStatusContained }}
} @else {
{{ i18n.t().simStatusActive }}
}
</span>
</div>
</div>
</header>
<!-- Panel Principal: Diagrama de Arquitectura Interactivo -->
<!-- Panel Principal del Simulador -->
<div class="zen-sim-board">
<!-- Diagrama de Flujo en 4 Nodos -->
<div class="diagram-canvas">
<!-- NODO 1: Vector de Amenaza -->
<div class="arch-node threat-node" [class.active]="stage() === 'breach'" [class.past]="stage() !== 'idle' && stage() !== 'breach'">
<div class="node-badge-tag threat">VECTOR // 01</div>
<div class="node-icon-frame">
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L3 7v6c0 5.5 3.8 10.7 9 12 5.2-1.3 9-6.5 9-12V7l-9-5z"/>
<path d="M12 8v4m0 4h.01" stroke-linecap="round"/>
</svg>
</div>
<div class="node-info">
<h3 class="node-title">{{ i18n.t().simThreatNodeTitle }}</h3>
<span class="node-detail">{{ i18n.t().simThreatNodeSub }}</span>
<span class="node-meta-ip">IP: {{ threatIp() }}</span>
</div>
<div class="node-status-indicator" [class.ping]="stage() === 'breach'"></div>
</div>
<!-- CONECTOR 1 -> 2 -->
<div class="flow-connector" [class.flowing]="stage() === 'breach' || stage() === 'detection' || stage() === 'remediation' || stage() === 'notification' || isContained()">
<div class="beam-line"></div>
<span class="flow-arrow">►</span>
</div>
<!-- NODO 2: AWS CloudTrail & EventBridge -->
<div class="arch-node detection-node" [class.active]="stage() === 'detection'" [class.past]="stage() === 'remediation' || stage() === 'notification' || isContained()">
<div class="node-badge-tag detection">AWS // 02</div>
<div class="node-icon-frame">
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div class="node-info">
<h3 class="node-title">{{ i18n.t().simCloudTrailNodeTitle }}</h3>
<span class="node-detail">{{ i18n.t().simCloudTrailNodeSub }}</span>
<span class="node-meta-svc">us-east-1 // EventBridge Rule</span>
</div>
<div class="node-status-indicator" [class.ping]="stage() === 'detection'"></div>
</div>
<!-- CONECTOR 2 -> 3 -->
<div class="flow-connector" [class.flowing]="stage() === 'detection' || stage() === 'remediation' || stage() === 'notification' || isContained()">
<div class="beam-line"></div>
<span class="flow-arrow">►</span>
</div>
<!-- NODO 3: AWS Lambda (Mitigación) -->
<div class="arch-node remediation-node" [class.active]="stage() === 'remediation'" [class.past]="stage() === 'notification' || isContained()">
<div class="node-badge-tag remediation">SERVERLESS // 03</div>
<div class="node-icon-frame">
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
</div>
<div class="node-info">
<h3 class="node-title">{{ i18n.t().simLambdaNodeTitle }}</h3>
<span class="node-detail">{{ i18n.t().simLambdaNodeSub }}</span>
<span class="node-meta-svc">STS Invalidation & DenyPolicy</span>
</div>
<div class="node-status-indicator" [class.ping]="stage() === 'remediation'"></div>
</div>
<!-- CONECTOR 3 -> 4 -->
<div class="flow-connector" [class.flowing]="stage() === 'remediation' || stage() === 'notification' || isContained()">
<div class="beam-line"></div>
<span class="flow-arrow">►</span>
</div>
<!-- NODO 4: Amazon SES / SecOps Alert -->
<div class="arch-node notification-node" [class.active]="stage() === 'notification'" [class.past]="isContained()">
<div class="node-badge-tag notification">SECOPS // 04</div>
<div class="node-icon-frame">
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<!-- Barra Interactiva de Captura de Correo y Ejecución -->
<div class="email-control-deck">
<div class="email-input-wrapper">
<label for="sim-email-input" class="email-label">
<svg class="email-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<div class="node-info">
<h3 class="node-title">{{ i18n.t().simSesNodeTitle }}</h3>
<span class="node-detail">{{ i18n.t().simSesNodeSub }}</span>
<span class="node-meta-svc">secops-team@bryan.dev</span>
</div>
<div class="node-status-indicator" [class.ping]="stage() === 'notification'"></div>
<span>{{ i18n.t().simEmailLabel }}</span>
</label>
<input
id="sim-email-input"
type="email"
class="email-field"
[value]="userEmail()"
(input)="onEmailInput($event)"
[placeholder]="i18n.t().simEmailPlaceholder"
[disabled]="isSimulating()"
/>
</div>
<div class="action-buttons">
<button
type="button"
class="sim-trigger-btn"
[disabled]="isSimulating()"
(click)="startSimulation()"
[attr.aria-label]="i18n.t().simBtnSimulate"
>
<span class="btn-glow"></span>
<span class="btn-icon">⚡</span>
<span class="btn-text">
@if (isSimulating()) {
{{ i18n.t().simBtnSimulating }}
} @else {
{{ i18n.t().simBtnSimulate }}
}
</span>
</button>
<button
type="button"
class="sim-reset-btn"
[disabled]="isSimulating()"
(click)="resetSimulation()"
[attr.aria-label]="i18n.t().simBtnReset"
>
<span>↺ {{ i18n.t().simBtnReset }}</span>
</button>
</div>
</div>
<!-- Botonera de Control de la Simulación -->
<div class="sim-action-deck">
<button
type="button"
class="sim-trigger-btn"
[disabled]="isSimulating()"
(click)="startSimulation()"
[attr.aria-label]="i18n.t().simBtnSimulate"
>
<span class="btn-glow"></span>
<span class="btn-icon">⚡</span>
<span class="btn-text">
@if (isSimulating()) {
{{ i18n.t().simBtnSimulating }}
} @else {
{{ i18n.t().simBtnSimulate }}
}
</span>
</button>
<button
type="button"
class="sim-reset-btn"
[disabled]="isSimulating()"
(click)="resetSimulation()"
[attr.aria-label]="i18n.t().simBtnReset"
>
<span>↺ {{ i18n.t().simBtnReset }}</span>
</button>
</div>
<!-- Consola de Telemetría SecOps en Tiempo Real -->
<div class="secops-console-frame">
<header class="console-titlebar">
<div class="console-dots">
<span class="dot red"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
<span class="console-title">{{ i18n.t().simConsoleTitle }}</span>
<span class="console-pill">JSON LOGSTREAM</span>
</header>
<div class="console-body" role="log" aria-live="polite">
@for (log of logs(); track $index) {
<div class="log-row" [class.warn]="log.level === 'WARN'" [class.action]="log.level === 'ACTION'" [class.success]="log.level === 'SUCCESS'">
<span class="log-time">[{{ log.timestamp }}]</span>
<span class="log-tag">[{{ log.tag }}]</span>
<span class="log-msg">{{ log.message }}</span>
<!-- VISTA 1: MODO STORY / EXPLICADO PARA TODO PÚBLICO -->
@if (viewMode() === 'story') {
<div class="story-grid-flow" aria-live="polite">
<!-- PASO 1 -->
<div class="story-step-card" [class.active]="stage() === 'breach'" [class.completed]="stage() !== 'idle' && stage() !== 'breach'">
<div class="step-badge">01 // ATAQUE</div>
<div class="step-icon-wrap danger">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L3 7v6c0 5.5 3.8 10.7 9 12 5.2-1.3 9-6.5 9-12V7l-9-5z"/>
<path d="M12 8v4m0 4h.01" stroke-linecap="round"/>
</svg>
</div>
}
<div class="console-cursor-row">
<span class="console-prompt">secops@aws-perimeter:~$</span>
<span class="cursor-block"></span>
<h3 class="step-title">{{ i18n.t().simStep1Title }}</h3>
<p class="step-desc">{{ i18n.t().simStep1Desc }}</p>
<div class="step-meta">IP: {{ threatIp() }}</div>
</div>
<div class="story-arrow" [class.active]="stage() !== 'idle'">→</div>
<!-- PASO 2 -->
<div class="story-step-card" [class.active]="stage() === 'detection'" [class.completed]="stage() === 'remediation' || stage() === 'notification' || isContained()">
<div class="step-badge">02 // DETECCIÓN</div>
<div class="step-icon-wrap warning">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<h3 class="step-title">{{ i18n.t().simStep2Title }}</h3>
<p class="step-desc">{{ i18n.t().simStep2Desc }}</p>
<div class="step-meta">&lt; 50ms // Monitoreo Activo</div>
</div>
<div class="story-arrow" [class.active]="stage() === 'detection' || stage() === 'remediation' || stage() === 'notification' || isContained()">→</div>
<!-- PASO 3 -->
<div class="story-step-card" [class.active]="stage() === 'remediation'" [class.completed]="stage() === 'notification' || isContained()">
<div class="step-badge">03 // BLOQUEO</div>
<div class="step-icon-wrap action">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<h3 class="step-title">{{ i18n.t().simStep3Title }}</h3>
<p class="step-desc">{{ i18n.t().simStep3Desc }}</p>
<div class="step-meta">Revocación Inmediata STS</div>
</div>
<div class="story-arrow" [class.active]="stage() === 'remediation' || stage() === 'notification' || isContained()">→</div>
<!-- PASO 4 -->
<div class="story-step-card" [class.active]="stage() === 'notification'" [class.completed]="isContained()">
<div class="step-badge">04 // INFORME</div>
<div class="step-icon-wrap success">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
</div>
<h3 class="step-title">{{ i18n.t().simStep4Title }}</h3>
<p class="step-desc">{{ i18n.t().simStep4Desc }}</p>
<div class="step-meta">{{ targetRecipient() }}</div>
</div>
</div>
</div>
}
<!-- VISTA 2: MODO TÉCNICO SECOPS / AWS ARQUITECTURA -->
@if (viewMode() === 'tech') {
<div class="diagram-canvas">
<!-- NODO 1: Vector de Amenaza -->
<div class="arch-node threat-node" [class.active]="stage() === 'breach'" [class.past]="stage() !== 'idle' && stage() !== 'breach'">
<div class="node-badge-tag threat">VECTOR // 01</div>
<div class="node-icon-frame">
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L3 7v6c0 5.5 3.8 10.7 9 12 5.2-1.3 9-6.5 9-12V7l-9-5z"/>
<path d="M12 8v4m0 4h.01" stroke-linecap="round"/>
</svg>
</div>
<div class="node-info">
<h3 class="node-title">{{ i18n.t().simThreatNodeTitle }}</h3>
<span class="node-detail">{{ i18n.t().simThreatNodeSub }}</span>
<span class="node-meta-ip">IP: {{ threatIp() }}</span>
</div>
<div class="node-status-indicator" [class.ping]="stage() === 'breach'"></div>
</div>
<!-- CONECTOR 1 -> 2 -->
<div class="flow-connector" [class.flowing]="stage() !== 'idle'">
<div class="beam-line"></div>
<span class="flow-arrow">►</span>
</div>
<!-- NODO 2: AWS CloudTrail & EventBridge -->
<div class="arch-node detection-node" [class.active]="stage() === 'detection'" [class.past]="stage() === 'remediation' || stage() === 'notification' || isContained()">
<div class="node-badge-tag detection">AWS // 02</div>
<div class="node-icon-frame">
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div class="node-info">
<h3 class="node-title">{{ i18n.t().simCloudTrailNodeTitle }}</h3>
<span class="node-detail">{{ i18n.t().simCloudTrailNodeSub }}</span>
<span class="node-meta-svc">us-east-1 // EventBridge Rule</span>
</div>
<div class="node-status-indicator" [class.ping]="stage() === 'detection'"></div>
</div>
<!-- CONECTOR 2 -> 3 -->
<div class="flow-connector" [class.flowing]="stage() === 'detection' || stage() === 'remediation' || stage() === 'notification' || isContained()">
<div class="beam-line"></div>
<span class="flow-arrow">►</span>
</div>
<!-- NODO 3: AWS Lambda -->
<div class="arch-node remediation-node" [class.active]="stage() === 'remediation'" [class.past]="stage() === 'notification' || isContained()">
<div class="node-badge-tag remediation">SERVERLESS // 03</div>
<div class="node-icon-frame">
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
</div>
<div class="node-info">
<h3 class="node-title">{{ i18n.t().simLambdaNodeTitle }}</h3>
<span class="node-detail">{{ i18n.t().simLambdaNodeSub }}</span>
<span class="node-meta-svc">STS Invalidation & DenyPolicy</span>
</div>
<div class="node-status-indicator" [class.ping]="stage() === 'remediation'"></div>
</div>
<!-- CONECTOR 3 -> 4 -->
<div class="flow-connector" [class.flowing]="stage() === 'remediation' || stage() === 'notification' || isContained()">
<div class="beam-line"></div>
<span class="flow-arrow">►</span>
</div>
<!-- NODO 4: Amazon SES -->
<div class="arch-node notification-node" [class.active]="stage() === 'notification'" [class.past]="isContained()">
<div class="node-badge-tag notification">SECOPS // 04</div>
<div class="node-icon-frame">
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<div class="node-info">
<h3 class="node-title">{{ i18n.t().simSesNodeTitle }}</h3>
<span class="node-detail">{{ i18n.t().simSesNodeSub }}</span>
<span class="node-meta-svc">{{ targetRecipient() }}</span>
</div>
<div class="node-status-indicator" [class.ping]="stage() === 'notification'"></div>
</div>
</div>
<!-- Consola de Telemetría SecOps -->
<div class="secops-console-frame">
<header class="console-titlebar">
<div class="console-dots">
<span class="dot red"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
<span class="console-title">{{ i18n.t().simConsoleTitle }}</span>
<span class="console-pill">JSON LOGSTREAM</span>
</header>
<div class="console-body" role="log" aria-live="polite">
@for (log of logs(); track $index) {
<div class="log-row" [class.warn]="log.level === 'WARN'" [class.action]="log.level === 'ACTION'" [class.success]="log.level === 'SUCCESS'">
<span class="log-time">[{{ log.timestamp }}]</span>
<span class="log-tag">[{{ log.tag }}]</span>
<span class="log-msg">{{ log.message }}</span>
</div>
}
<div class="console-cursor-row">
<span class="console-prompt">secops@aws-perimeter:~$</span>
<span class="cursor-block"></span>
</div>
</div>
</div>
}
<!-- CERTIFICADO DE AUDITORÍA FORENSE (Visible al contener la amenaza) -->
@if (showReport() || isContained()) {
<div class="forensic-report-card">
<header class="report-header">
<div class="report-header-left">
<span class="report-badge">{{ i18n.t().simReportDemoBadge }}</span>
<h3 class="report-title">{{ i18n.t().simReportTitle }}</h3>
</div>
<span class="report-status-pill">{{ i18n.t().simReportStatusSecure }}</span>
</header>
<div class="report-grid">
<div class="report-metric-box">
<span class="box-label">{{ i18n.t().simReportTargetEmail }}</span>
<span class="box-val highlight-mail">{{ targetRecipient() }}</span>
</div>
<div class="report-metric-box">
<span class="box-label">{{ i18n.t().simMetricLatency }}</span>
<span class="box-val highlight-time">{{ i18n.t().simReportTimeVsAvg }}</span>
</div>
<div class="report-metric-box">
<span class="box-label">Integridad de Credenciales</span>
<span class="box-val">{{ i18n.t().simReportCredentialsCompromised }}</span>
</div>
<div class="report-metric-box">
<span class="box-label">Impacto Financiero Evitado</span>
<span class="box-val success">{{ i18n.t().simReportFinancialLoss }}</span>
</div>
</div>
<footer class="report-footer">
<button type="button" class="download-report-btn" (click)="simulateDownload()">
<svg class="dl-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
@if (downloadFeedback()) {
<span>✓ Reporte Forense Simulado con Éxito</span>
} @else {
<span>{{ i18n.t().simReportDownloadBtn }}</span>
}
</button>
</footer>
</div>
}
<!-- Métricas de Arquitectura Serverless -->
<footer class="arch-metrics-bar">

View File

@ -3,6 +3,7 @@ import { I18nService } from '../../../application/services/i18n.service';
import { RetroAudioService } from '../../../application/services/retro-audio.service';
export type SimulationStage = 'idle' | 'breach' | 'detection' | 'remediation' | 'notification' | 'contained';
export type SimulatorViewMode = 'story' | 'tech';
export interface SecOpsLog {
readonly timestamp: string;
@ -23,9 +24,13 @@ export class IncidentSimulatorComponent implements OnInit, OnDestroy {
private readonly audio = inject(RetroAudioService);
protected readonly stage = signal<SimulationStage>('idle');
protected readonly viewMode = signal<SimulatorViewMode>('story');
protected readonly userEmail = signal<string>('');
protected readonly logs = signal<SecOpsLog[]>([]);
protected readonly threatIp = signal<string>('198.51.100.42');
protected readonly executionTimeMs = signal<number>(184);
protected readonly showReport = signal<boolean>(false);
protected readonly downloadFeedback = signal<boolean>(false);
protected readonly isSimulating = computed<boolean>(() => {
const s = this.stage();
@ -34,12 +39,112 @@ export class IncidentSimulatorComponent implements OnInit, OnDestroy {
protected readonly isContained = computed<boolean>(() => this.stage() === 'contained');
protected readonly targetRecipient = computed<string>(() => {
const email = this.userEmail().trim();
return email.length > 3 && email.includes('@') ? email : 'secops-team@bryan.dev';
});
private timeoutIds: ReturnType<typeof setTimeout>[] = [];
ngOnInit(): void {
this.initDefaultLogs();
}
setViewMode(mode: SimulatorViewMode): void {
if (this.viewMode() === mode) return;
this.audio.playClick();
this.viewMode.set(mode);
}
onEmailInput(event: Event): void {
const val = (event.target as HTMLInputElement).value;
this.userEmail.set(val);
}
startSimulation(): void {
if (this.isSimulating()) return;
this.clearTimeouts();
this.audio.playClick();
this.logs.set([]);
this.showReport.set(false);
this.downloadFeedback.set(false);
// Paso 1: Intento de Intrusión (Vector de Amenaza)
this.stage.set('breach');
this.appendLog({
level: 'WARN',
tag: 'IAM-ALERT',
message: `Anomalous API request: iam:CreateAccessKey from untrusted IP ${this.threatIp()} for principal "svc-deployer".`
});
// Paso 2: Detección Inmediata en EventBridge & CloudTrail (después de 700ms)
const t1 = setTimeout(() => {
this.stage.set('detection');
this.audio.playCellReveal();
this.appendLog({
level: 'INFO',
tag: 'CLOUDTRAIL',
message: 'Management event ingested in us-east-1. EventBridge rule "SecOps-AutoRemediate" triggered synchronously.'
});
}, 700);
this.timeoutIds.push(t1);
// Paso 3: Remediación Automatizada Serverless (después de 1500ms)
const t2 = setTimeout(() => {
this.stage.set('remediation');
this.audio.playFlagToggle();
this.appendLog({
level: 'ACTION',
tag: 'LAMBDA',
message: 'Executing fn-iam-incident-response: Revoking active STS sessions & attaching inline DenyAllSecurityPolicy.'
});
}, 1500);
this.timeoutIds.push(t2);
// Paso 4: Despacho de Reporte Forense (después de 2300ms)
const t3 = setTimeout(() => {
this.stage.set('notification');
this.audio.playCellReveal();
this.appendLog({
level: 'INFO',
tag: 'SES-ALERT',
message: `Dispatching cryptographic audit report to ${this.targetRecipient()} via Amazon SES.`
});
}, 2300);
this.timeoutIds.push(t3);
// Paso 5: Amenaza Contenida & Generación de Reporte (después de 3100ms)
const t4 = setTimeout(() => {
this.stage.set('contained');
this.audio.playVictory();
this.showReport.set(true);
this.appendLog({
level: 'SUCCESS',
tag: 'CONTAINED',
message: `Threat neutralized successfully in ${this.executionTimeMs()}ms. Zero credentials compromised. State: SECURE.`
});
}, 3100);
this.timeoutIds.push(t4);
}
resetSimulation(): void {
this.clearTimeouts();
this.audio.playClick();
this.stage.set('idle');
this.showReport.set(false);
this.downloadFeedback.set(false);
this.initDefaultLogs();
}
simulateDownload(): void {
this.audio.playClick();
this.downloadFeedback.set(true);
setTimeout(() => {
this.downloadFeedback.set(false);
}, 3000);
}
private initDefaultLogs(): void {
const time = this.getCurrentTimestamp();
this.logs.set([
@ -58,77 +163,6 @@ export class IncidentSimulatorComponent implements OnInit, OnDestroy {
]);
}
startSimulation(): void {
if (this.isSimulating()) return;
this.clearTimeouts();
this.audio.playClick();
this.logs.set([]);
// Fase 1: Intento de Acceso No Autorizado (Vector de Amenaza)
this.stage.set('breach');
this.appendLog({
level: 'WARN',
tag: 'IAM-ALERT',
message: `Anomalous API request: iam:CreateAccessKey from untrusted IP ${this.threatIp()} for principal "svc-deployer".`
});
// Fase 2: Captura en CloudTrail & Enrutamiento en EventBridge (después de 700ms)
const t1 = setTimeout(() => {
this.stage.set('detection');
this.audio.playCellReveal();
this.appendLog({
level: 'INFO',
tag: 'CLOUDTRAIL',
message: 'Management event ingested in us-east-1. EventBridge rule "SecOps-AutoRemediate" triggered synchronously.'
});
}, 700);
this.timeoutIds.push(t1);
// Fase 3: Remediación Automatizada en AWS Lambda (después de 1500ms)
const t2 = setTimeout(() => {
this.stage.set('remediation');
this.audio.playFlagToggle();
this.appendLog({
level: 'ACTION',
tag: 'LAMBDA',
message: 'Executing fn-iam-incident-response: Revoking active STS sessions & attaching inline DenyAllSecurityPolicy.'
});
}, 1500);
this.timeoutIds.push(t2);
// Fase 4: Despacho de Notificación Amazon SES (después de 2300ms)
const t3 = setTimeout(() => {
this.stage.set('notification');
this.audio.playCellReveal();
this.appendLog({
level: 'INFO',
tag: 'SES-ALERT',
message: 'Dispatching cryptographic audit report to secops-team@bryan.dev via Amazon SES.'
});
}, 2300);
this.timeoutIds.push(t3);
// Fase 5: Amenaza Contenida con Éxito (después de 3100ms)
const t4 = setTimeout(() => {
this.stage.set('contained');
this.audio.playVictory();
this.appendLog({
level: 'SUCCESS',
tag: 'CONTAINED',
message: `Threat neutralized successfully in ${this.executionTimeMs()}ms. Zero credentials compromised. State: SECURE.`
});
}, 3100);
this.timeoutIds.push(t4);
}
resetSimulation(): void {
this.clearTimeouts();
this.audio.playClick();
this.stage.set('idle');
this.initDefaultLogs();
}
private appendLog(entry: Omit<SecOpsLog, 'timestamp'>): void {
const timestamp = this.getCurrentTimestamp();
this.logs.update(prev => [...prev, { ...entry, timestamp }]);

View File

@ -153,11 +153,24 @@
box-sizing: border-box;
background-color: #121517;
flex: 1 0 auto;
animation: zenViewEnter 0.32s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes zenViewEnter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero-container.isolated-view {
padding-top: 0;
background-color: #000000;
animation: none;
}
.interactive-stage.isolated-view {
@ -209,6 +222,15 @@
}
@media (max-width: 960px) {
.hero-container.isolated-view {
padding: calc(56px + var(--space-4)) clamp(0.75rem, 3vw, 1.5rem) var(--space-8);
background-color: #121517;
min-height: calc(100vh - 56px);
height: auto;
overflow-y: visible;
display: block;
}
.interactive-stage.isolated-view {
height: auto;
min-height: calc(100vh - 56px);
@ -438,6 +460,194 @@
background-color: #121517;
}
/* ==============================================================================
FLECHAS SUTILES FLOTANTES DE NAVEGACIÓN (DEBAJO DEL NAVBAR)
============================================================================== */
.zen-floating-nav-controls {
position: fixed;
top: 68px;
left: 0;
right: 0;
width: 100%;
pointer-events: none;
z-index: 950;
display: flex;
justify-content: space-between;
padding: 0 clamp(0.75rem, 2.5vw, 2.5rem);
box-sizing: border-box;
}
.zen-nav-arrow-btn {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(10, 13, 20, 0.75);
backdrop-filter: blur(12px) saturate(160%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
padding: 6px 14px;
color: #9aa5b8;
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
opacity: 1;
}
.zen-nav-arrow-btn:hover:not(:disabled) {
color: #f0f3f8;
border-color: rgba(197, 155, 39, 0.5);
background: rgba(18, 22, 32, 0.9);
transform: translateY(-1px);
box-shadow: 0 0 14px rgba(197, 155, 39, 0.25);
}
.zen-nav-arrow-btn.disabled,
.zen-nav-arrow-btn:disabled {
opacity: 0;
pointer-events: none;
transform: scale(0.92);
}
.nav-arrow-svg {
width: 14px;
height: 14px;
stroke: #c59b27;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.nav-arrow-left:hover:not(:disabled) .nav-arrow-svg {
transform: translateX(-3px);
}
.nav-arrow-right:hover:not(:disabled) .nav-arrow-svg {
transform: translateX(3px);
}
.nav-arrow-label {
letter-spacing: 0.02em;
white-space: nowrap;
}
/* ==============================================================================
TELÓN DE TRANSICIÓN SUAVE Y SCANLINE ZEN
============================================================================== */
.zen-transition-curtain {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(8, 10, 14, 0.88);
backdrop-filter: blur(8px);
pointer-events: none;
opacity: 0;
transition: opacity 0.16s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.zen-transition-curtain.visible {
opacity: 1;
pointer-events: auto;
}
.curtain-scanline {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, #c59b27, #38bdf8, transparent);
box-shadow: 0 0 10px #c59b27;
animation: scanlinePass 0.8s linear infinite;
}
.curtain-logo-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
animation: logoWrapperEnter 0.25s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.logo-glow-ring {
position: absolute;
top: 10px;
width: 90px;
height: 90px;
border-radius: 50%;
background: radial-gradient(circle, rgba(197, 155, 39, 0.45) 0%, rgba(56, 189, 248, 0.18) 50%, transparent 70%);
filter: blur(14px);
animation: logoPulseGlow 1.2s ease-in-out infinite alternate;
pointer-events: none;
}
.logo-image-container {
position: relative;
width: 68px;
height: 68px;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(197, 155, 39, 0.45);
box-shadow: 0 0 24px rgba(197, 155, 39, 0.3), 0 8px 32px rgba(0, 0, 0, 0.85);
background: #000000;
display: flex;
align-items: center;
justify-content: center;
}
.curtain-logo-img {
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(0.92);
animation: logoBreath 1.3s ease-in-out infinite alternate;
}
.logo-shimmer-sweep {
position: absolute;
inset: 0;
background: linear-gradient(110deg, transparent 25%, rgba(255, 255, 255, 0.4) 50%, transparent 75%);
transform: translateX(-100%);
animation: logoShimmer 1.1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
pointer-events: none;
}
@keyframes logoWrapperEnter {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
@keyframes logoPulseGlow {
0% { transform: scale(0.85); opacity: 0.4; }
100% { transform: scale(1.3); opacity: 0.95; }
}
@keyframes logoBreath {
0% { transform: scale(0.88); filter: drop-shadow(0 0 4px rgba(197, 155, 39, 0.3)); }
100% { transform: scale(0.96); filter: drop-shadow(0 0 12px rgba(197, 155, 39, 0.7)); }
}
@keyframes logoShimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes scanlinePass {
0% { transform: translateY(0); opacity: 0; }
50% { opacity: 1; }
100% { transform: translateY(100vh); opacity: 0; }
}
@media (max-width: 768px) {
.zen-header {
padding: 0 8px;
@ -471,4 +681,17 @@
padding: 3px 6px;
font-size: 0.6875rem;
}
.zen-floating-nav-controls {
top: 58px;
padding: 0 10px;
}
.nav-arrow-label {
display: none;
}
.zen-nav-arrow-btn {
padding: 6px 10px;
}
}

View File

@ -81,10 +81,59 @@
</div>
</header>
<!-- Hero con Laptop 3D desarmable al scroll (300 frames) -->
<!-- Flechas Sutiles de Navegación Flotantes (Izquierda y Derecha debajo del Navbar) -->
<aside class="zen-floating-nav-controls" aria-label="Navegación entre secciones">
<button
type="button"
class="zen-nav-arrow-btn nav-arrow-left"
[class.disabled]="!hasPrevSection()"
[disabled]="!hasPrevSection()"
(click)="goToPrevSection()"
[attr.aria-label]="i18n.t().navPrevSection + ': ' + prevSectionName()"
[title]="hasPrevSection() ? i18n.t().navPrevSection + ' (' + prevSectionName() + ')' : ''"
>
<svg class="nav-arrow-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
<span class="nav-arrow-label">{{ prevSectionName() }}</span>
</button>
<button
type="button"
class="zen-nav-arrow-btn nav-arrow-right"
[class.disabled]="!hasNextSection()"
[disabled]="!hasNextSection()"
(click)="goToNextSection()"
[attr.aria-label]="i18n.t().navNextSection + ': ' + nextSectionName()"
[title]="hasNextSection() ? i18n.t().navNextSection + ' (' + nextSectionName() + ')' : ''"
>
<span class="nav-arrow-label">{{ nextSectionName() }}</span>
<svg class="nav-arrow-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</aside>
<!-- Telón de Transición Suave con Animación de Logo Zen -->
<div
class="zen-transition-curtain"
[class.visible]="isCurtainVisible()"
aria-hidden="true"
>
<div class="curtain-scanline"></div>
<div class="curtain-logo-wrapper">
<div class="logo-glow-ring"></div>
<div class="logo-image-container">
<img src="assets/images/logo.jpg" alt="Bryan Baño Logo" class="curtain-logo-img" />
<div class="logo-shimmer-sweep"></div>
</div>
</div>
</div>
<!-- Hero con Laptop 3D desarmable al scroll (240 frames) -->
@if (activeSection() === 'hero') {
<main class="hero-container isolated-view">
<app-hero-3d></app-hero-3d>
<app-hero-3d [initialPosition]="heroEntryPosition()"></app-hero-3d>
</main>
}

View File

@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, signal, inject, ViewChild, ElementRef } from '@angular/core';
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, signal, computed, inject, ViewChild, ElementRef } from '@angular/core';
import { Hero3dComponent } from '../hero-3d/hero-3d.component';
import { RetroOsComponent } from '../retro-os/retro-os.component';
import { TerminalComponent } from '../terminal/terminal.component';
@ -40,6 +40,31 @@ export class LandingComponent implements OnInit, OnDestroy {
protected readonly currentYear = signal(new Date().getFullYear());
protected readonly currentTime = signal<string>('');
protected readonly activeSection = signal<LandingSection>('hero');
protected readonly heroEntryPosition = signal<'top' | 'bottom'>('top');
protected readonly isCurtainVisible = signal<boolean>(false);
protected readonly transitionTargetSection = signal<LandingSection>('hero');
protected readonly hasPrevSection = computed(() => {
return LANDING_SECTIONS.indexOf(this.activeSection()) > 0;
});
protected readonly hasNextSection = computed(() => {
return LANDING_SECTIONS.indexOf(this.activeSection()) < LANDING_SECTIONS.length - 1;
});
protected readonly prevSectionName = computed(() => {
const idx = LANDING_SECTIONS.indexOf(this.activeSection());
return idx > 0 ? this.getSectionTitle(LANDING_SECTIONS[idx - 1]) : '';
});
protected readonly nextSectionName = computed(() => {
const idx = LANDING_SECTIONS.indexOf(this.activeSection());
return idx < LANDING_SECTIONS.length - 1 ? this.getSectionTitle(LANDING_SECTIONS[idx + 1]) : '';
});
protected readonly transitionTargetTitle = computed(() => {
return this.getSectionTitle(this.transitionTargetSection());
});
private clockInterval: ReturnType<typeof setInterval> | null = null;
private readonly timeFormatter = new Intl.DateTimeFormat('en-GB', {
@ -57,6 +82,7 @@ export class LandingComponent implements OnInit, OnDestroy {
private touchStartX = 0;
private wheelDeltaAccum = 0;
private wheelResetTimer: ReturnType<typeof setTimeout> | null = null;
private transitionTimer: ReturnType<typeof setTimeout> | null = null;
ngOnInit(): void {
this.updateClock();
@ -65,31 +91,60 @@ export class LandingComponent implements OnInit, OnDestroy {
this.setupScrollTransitions();
}
setSection(section: LandingSection, entryPosition: 'top' | 'bottom' = 'top'): void {
if (this.activeSection() === section && this.isTransitioning) return;
this.isTransitioning = true;
setTimeout(() => {
this.isTransitioning = false;
}, 750);
this.activeSection.set(section);
if (typeof window !== 'undefined') {
const hash = section === 'hero' ? '' : `#${section}`;
const url = window.location.pathname + window.location.search + hash;
window.history.replaceState(null, '', url);
this.scrollActiveNavIntoView(section);
setTimeout(() => {
if (entryPosition === 'bottom') {
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
window.scrollTo({ top: maxScroll, behavior: 'instant' });
} else {
window.scrollTo({ top: 0, behavior: 'instant' });
}
}, 30);
getSectionTitle(section: LandingSection): string {
const dict = this.i18n.t();
switch (section) {
case 'hero': return dict.navHome;
case 'retro': return dict.navRetro;
case 'projects': return dict.navProjects;
case 'simulator': return dict.navSimulator;
case 'terminal': return dict.navTerminal;
case 'contact': return dict.navContact;
}
}
setSection(section: LandingSection, entryPosition: 'top' | 'bottom' = 'top'): void {
if (this.activeSection() === section || this.isTransitioning) return;
this.isTransitioning = true;
this.transitionTargetSection.set(section);
this.isCurtainVisible.set(true);
if (this.transitionTimer) clearTimeout(this.transitionTimer);
// Brief fade-curtain duration before switching section component
this.transitionTimer = setTimeout(() => {
if (section === 'hero') {
this.heroEntryPosition.set(entryPosition);
}
this.activeSection.set(section);
if (typeof window !== 'undefined') {
const hash = section === 'hero' ? '' : `#${section}`;
const url = window.location.pathname + window.location.search + hash;
window.history.replaceState(null, '', url);
this.scrollActiveNavIntoView(section);
if (section !== 'hero') {
if (entryPosition === 'bottom') {
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
window.scrollTo({ top: maxScroll, behavior: 'instant' });
} else {
window.scrollTo({ top: 0, behavior: 'instant' });
}
}
}
// Smoothly hide curtain after DOM mount and scroll placement
setTimeout(() => {
this.isCurtainVisible.set(false);
setTimeout(() => {
this.isTransitioning = false;
}, 220);
}, 120);
}, 150);
}
private setupScrollTransitions(): void {
if (typeof window === 'undefined') return;
window.addEventListener('wheel', this.onWheel, { passive: false });
@ -167,7 +222,7 @@ export class LandingComponent implements OnInit, OnDestroy {
}
};
private goToNextSection(): void {
protected goToNextSection(): void {
const currentIndex = LANDING_SECTIONS.indexOf(this.activeSection());
if (currentIndex >= 0 && currentIndex < LANDING_SECTIONS.length - 1) {
const next = LANDING_SECTIONS[currentIndex + 1];
@ -175,7 +230,7 @@ export class LandingComponent implements OnInit, OnDestroy {
}
}
private goToPrevSection(): void {
protected goToPrevSection(): void {
const currentIndex = LANDING_SECTIONS.indexOf(this.activeSection());
if (currentIndex > 0) {
const prev = LANDING_SECTIONS[currentIndex - 1];
@ -224,6 +279,9 @@ export class LandingComponent implements OnInit, OnDestroy {
if (this.wheelResetTimer !== null) {
clearTimeout(this.wheelResetTimer);
}
if (this.transitionTimer !== null) {
clearTimeout(this.transitionTimer);
}
if (typeof window !== 'undefined') {
window.removeEventListener('wheel', this.onWheel);
window.removeEventListener('touchstart', this.onTouchStart);

View File

@ -6,11 +6,11 @@
<figure class="ascii-portrait">
@if (asciiArt()) {
<pre aria-label="Retrato ASCII de Bryan">{{ asciiArt() }}</pre>
<pre [attr.aria-label]="i18n.t().tpAboutAria">{{ asciiArt() }}</pre>
} @else if (asciiLoadError()) {
<figcaption class="ascii-status" role="status">No se pudo cargar el retrato.</figcaption>
<figcaption class="ascii-status" role="status">{{ i18n.t().tpAsciiError }}</figcaption>
} @else {
<figcaption class="ascii-status" role="status">Cargando retrato...</figcaption>
<figcaption class="ascii-status" role="status">{{ i18n.t().tpAsciiLoading }}</figcaption>
}
</figure>
@ -18,7 +18,7 @@
@for (paragraph of aboutParagraphs(); track $index) {
<p class="about-description">{{ paragraph }}</p>
}
<span class="about-link">About me <span aria-hidden="true">→</span></span>
<span class="about-link">{{ i18n.t().tpAboutLink }} <span aria-hidden="true">→</span></span>
</div>
</section>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -2,10 +2,13 @@
<html lang="es" data-theme="zen-dark">
<head>
<meta charset="utf-8">
<title>Bryan // Portafolio Profesional Interactivo</title>
<title>Bryan Baño</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="icon" type="image/x-icon" href="/favicon.ico?v=2">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=2">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=2">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=2">
<!-- Tipografía: Plus Jakarta Sans, JetBrains Mono y VT323 (Retro) -->
<link rel="preconnect" href="https://fonts.googleapis.com">