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", "type": "anyComponentStyle",
"maximumWarning": "8kB", "maximumWarning": "15kB",
"maximumError": "16kB" "maximumError": "30kB"
} }
], ],
"outputHashing": "all" "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', navTerminal: 'Terminal',
navContact: 'Contact', navContact: 'Contact',
navAll: 'View All', navAll: 'View All',
navPrevSection: 'Previous section',
navNextSection: 'Next section',
// Hero 3D // Hero 3D
preloading: 'PRELOADING', preloading: 'PRELOADING',
@ -63,6 +65,9 @@ export const EN_TRANSLATIONS: TranslationDictionary = {
tpAboutName: 'Bryan Baño', 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.', 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', tpAboutAria: 'About Bryan Baño',
tpAboutLink: 'About me',
tpAsciiError: 'Could not load portrait.',
tpAsciiLoading: 'Loading portrait...',
tpEyebrow: 'INGENIERÍA EN COMPUTACIÓN & EXPERTISE', tpEyebrow: 'INGENIERÍA EN COMPUTACIÓN & EXPERTISE',
tpTitle: 'Technical Skills & Areas of Interest', tpTitle: 'Technical Skills & Areas of Interest',
tpLeadStudent: '9th semester Computer Engineering student at', 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', simTag: '03 // CLOUD ARCHITECTURE & SERVERLESS SECURITY',
simHeading: 'Real-Time Incident Response Simulator', 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.', 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...', simBtnSimulating: 'Containing Threat in Real-Time...',
simBtnReset: 'Reset Simulation', 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', simThreatNodeTitle: 'Threat Vector',
simThreatNodeSub: 'Anomalous iam:CreateAccessKey', simThreatNodeSub: 'Anomalous iam:CreateAccessKey',
simCloudTrailNodeTitle: 'AWS CloudTrail & EventBridge', simCloudTrailNodeTitle: 'AWS CloudTrail & EventBridge',
@ -304,5 +322,13 @@ Approach: Zero over-engineering (Ponytail), high throughput, and Zen aesthetic d
simMetricArchitecture: 'Serverless Architecture', simMetricArchitecture: 'Serverless Architecture',
simStatusIdle: 'SYSTEM NOMINAL // LISTENING FOR EVENTS', simStatusIdle: 'SYSTEM NOMINAL // LISTENING FOR EVENTS',
simStatusActive: 'THREAT DETECTED // EXECUTING REMEDIATION', 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', navTerminal: 'Terminal',
navContact: 'Contacto', navContact: 'Contacto',
navAll: 'Ver Todo', navAll: 'Ver Todo',
navPrevSection: 'Sección anterior',
navNextSection: 'Siguiente sección',
// Hero 3D // Hero 3D
preloading: 'PRECARGANDO', preloading: 'PRECARGANDO',
@ -63,6 +65,9 @@ export const ES_TRANSLATIONS: TranslationDictionary = {
tpAboutName: 'Bryan Baño', 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.', 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', 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', tpEyebrow: 'INGENIERÍA EN COMPUTACIÓN & EXPERTISE',
tpTitle: 'Habilidades Técnicas & Áreas de Interés', tpTitle: 'Habilidades Técnicas & Áreas de Interés',
tpLeadStudent: 'Estudiante de 9no semestre de Ingeniería en Computación en la', 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', simTag: 'ARQUITECTURA CLOUD & SEGURIDAD SERVERLESS',
simHeading: 'Simulador de Respuesta a Incidentes en Tiempo Real', 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.', 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...', simBtnSimulating: 'Conteniendo Amenaza en Tiempo Real...',
simBtnReset: 'Reiniciar Simulación', 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', simThreatNodeTitle: 'Vector de Amenaza',
simThreatNodeSub: 'iam:CreateAccessKey anómalo', simThreatNodeSub: 'iam:CreateAccessKey anómalo',
simCloudTrailNodeTitle: 'AWS CloudTrail & EventBridge', simCloudTrailNodeTitle: 'AWS CloudTrail & EventBridge',
@ -304,5 +322,13 @@ Enfoque: Cero sobreingeniería (Ponytail), alta concurrencia y diseño estético
simMetricArchitecture: 'Arquitectura Serverless', simMetricArchitecture: 'Arquitectura Serverless',
simStatusIdle: 'SISTEMA NOMINAL // ESCUCHANDO EVENTOS', simStatusIdle: 'SISTEMA NOMINAL // ESCUCHANDO EVENTOS',
simStatusActive: 'AMENAZA DETECTADA // EJECUTANDO MITIGACIÓN', 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 navTerminal: string;
readonly navContact: string; readonly navContact: string;
readonly navAll: string; readonly navAll: string;
readonly navPrevSection: string;
readonly navNextSection: string;
// Hero 3D // Hero 3D
readonly preloading: string; readonly preloading: string;
@ -63,6 +65,9 @@ export interface TranslationDictionary {
readonly tpAboutName: string; readonly tpAboutName: string;
readonly tpAboutDescription: string; readonly tpAboutDescription: string;
readonly tpAboutAria: string; readonly tpAboutAria: string;
readonly tpAboutLink: string;
readonly tpAsciiError: string;
readonly tpAsciiLoading: string;
readonly tpEyebrow: string; readonly tpEyebrow: string;
readonly tpTitle: string; readonly tpTitle: string;
readonly tpLeadStudent: string; readonly tpLeadStudent: string;
@ -255,9 +260,22 @@ export interface TranslationDictionary {
readonly simTag: string; readonly simTag: string;
readonly simHeading: string; readonly simHeading: string;
readonly simSubheading: string; readonly simSubheading: string;
readonly simModeStory: string;
readonly simModeTech: string;
readonly simEmailLabel: string;
readonly simEmailPlaceholder: string;
readonly simEmailHint: string;
readonly simBtnSimulate: string; readonly simBtnSimulate: string;
readonly simBtnSimulating: string; readonly simBtnSimulating: string;
readonly simBtnReset: 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 simThreatNodeTitle: string;
readonly simThreatNodeSub: string; readonly simThreatNodeSub: string;
readonly simCloudTrailNodeTitle: string; readonly simCloudTrailNodeTitle: string;
@ -273,4 +291,12 @@ export interface TranslationDictionary {
readonly simStatusIdle: string; readonly simStatusIdle: string;
readonly simStatusActive: string; readonly simStatusActive: string;
readonly simStatusContained: 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); background: rgba(78, 155, 111, 0.12);
} }
.mobile-only {
display: none;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.lateral-panel { .desktop-only {
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 {
display: none !important; display: none !important;
} }
.pos-f1-right { .mobile-only {
top: auto !important; display: flex;
bottom: max(16px, env(safe-area-inset-bottom, 16px)) !important; 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 */ /* Tarjeta Principal de Perfil Móvil */
.pos-f2-right { .mobile-lead-card {
top: 56px !important; display: flex;
bottom: auto !important; flex-direction: column;
padding: 10px 14px; 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 { .mobile-lead-header {
top: auto !important; display: flex;
bottom: max(16px, env(safe-area-inset-bottom, 16px)) !important; align-items: center;
} justify-content: space-between;
/* 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;
flex-wrap: wrap; 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; gap: 8px;
} }
.lead-metric { .mobile-lead-name {
font-size: 0.62rem; font-family: var(--font-sans);
line-height: 1.3; 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 { .mobile-lead-role {
flex-direction: row; font-family: var(--font-sans);
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 {
font-size: 0.72rem; font-size: 0.72rem;
line-height: 1.35; font-weight: 700;
margin-bottom: 6px; letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--zen-accent-kintsugi);
margin: 0;
} }
.panel-eyebrow { .mobile-lead-desc {
font-size: 0.6rem; font-family: var(--font-sans);
margin-bottom: 4px; font-size: 0.875rem;
color: var(--zen-text-secondary);
line-height: 1.65;
margin: 0;
} }
.zen-loader-pill { .mobile-lead-badges {
top: 60px; display: flex;
right: 12px; flex-wrap: wrap;
font-size: 0.7rem; gap: 8px;
padding: 3px 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"> <div class="hero-sticky-stage">
<!-- Lienzo Canvas en alta resolución que ocupa toda la pantalla de fondo --> <!-- Lienzo Canvas en alta resolución que ocupa toda la pantalla de fondo -->
<canvas #canvas class="thinkpad-canvas" aria-hidden="true"></canvas> <canvas #canvas class="thinkpad-canvas" aria-hidden="true"></canvas>
@ -131,3 +132,122 @@
</aside> </aside>
</div> </div>
</section> </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, ChangeDetectionStrategy,
signal, signal,
inject, inject,
NgZone NgZone,
input
} from '@angular/core'; } from '@angular/core';
import { I18nService } from '../../../application/services/i18n.service'; import { I18nService } from '../../../application/services/i18n.service';
@ -23,6 +24,8 @@ const LERP_FACTOR = 0.09;
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy { export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
readonly initialPosition = input<'top' | 'bottom'>('top');
@ViewChild('scrollContainer') private containerRef!: ElementRef<HTMLDivElement>; @ViewChild('scrollContainer') private containerRef!: ElementRef<HTMLDivElement>;
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
@ -47,17 +50,33 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
private mediaQueryList?: MediaQueryList; private mediaQueryList?: MediaQueryList;
ngOnInit(): void { 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.checkReducedMotion();
this.preloadImages(); if (typeof window !== 'undefined' && window.innerWidth <= 900) {
this.isLoaded.set(true);
} else {
this.preloadImages();
}
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.canvasRef || !this.containerRef) {
this.isLoaded.set(true);
return;
}
const canvas = this.canvasRef.nativeElement; const canvas = this.canvasRef.nativeElement;
this.ctx = canvas.getContext('2d', { alpha: false }); this.ctx = canvas.getContext('2d', { alpha: false });
this.intersectionObserver = new IntersectionObserver(([entry]) => { this.intersectionObserver = new IntersectionObserver(([entry]) => {
this.isVisible = entry.isIntersecting; this.isVisible = entry.isIntersecting;
if (this.isVisible && !this.isReducedMotion) { if (this.isVisible && !this.isReducedMotion && typeof window !== 'undefined' && window.innerWidth > 900) {
this.startAnimationLoop(); this.startAnimationLoop();
} else if (this.animationFrameId !== null) { } else if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId); cancelAnimationFrame(this.animationFrameId);
@ -72,8 +91,27 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
window.addEventListener('resize', this.onResize, { passive: true }); window.addEventListener('resize', this.onResize, { passive: true });
}); });
this.onResize(); if (typeof window !== 'undefined' && window.innerWidth > 900) {
this.onScroll(); 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 { private checkReducedMotion(): void {
@ -88,7 +126,7 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
this.isLoopRunning = false; this.isLoopRunning = false;
} }
this.renderFrame(1); this.renderFrame(1);
} else { } else if (typeof window !== 'undefined' && window.innerWidth > 900) {
this.startAnimationLoop(); this.startAnimationLoop();
} }
}); });
@ -96,15 +134,17 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
private preloadImages(): void { private preloadImages(): void {
let loaded = 0; let loaded = 0;
const startFrame = this.initialPosition() === 'bottom' ? TOTAL_FRAMES : 1;
const firstImg = new Image(); const firstImg = new Image();
firstImg.src = this.getFramePath(1); firstImg.src = this.getFramePath(startFrame);
firstImg.onload = () => { firstImg.onload = () => {
this.images[1] = firstImg; this.images[startFrame] = firstImg;
loaded++; 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(); const img = new Image();
img.src = this.getFramePath(i); img.src = this.getFramePath(i);
img.onload = () => { img.onload = () => {
@ -131,6 +171,7 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
} }
private onScroll = (): void => { private onScroll = (): void => {
if (typeof window !== 'undefined' && window.innerWidth <= 900) return;
if (!this.isVisible || !this.containerRef || this.isReducedMotion) return; if (!this.isVisible || !this.containerRef || this.isReducedMotion) return;
const container = this.containerRef.nativeElement; 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); const progress = Math.min(Math.max(rawProgress, 0), 1);
this.targetFrame = Math.min(Math.max(1, 1 + progress * (TOTAL_FRAMES - 1)), TOTAL_FRAMES); 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(); this.startAnimationLoop();
}; };
private onResize = (): void => { private onResize = (): void => {
if (typeof window !== 'undefined' && window.innerWidth <= 900) {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.isLoopRunning = false;
}
return;
}
this.resizeCanvas(); this.resizeCanvas();
this.renderFrame(Math.round(this.currentInterpolatedFrame)); this.renderFrame(Math.round(this.currentInterpolatedFrame));
}; };

View File

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

View File

@ -1,173 +1,350 @@
<section class="zen-sim-container" aria-label="Simulador de Respuesta a Incidentes Cloud"> <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"> <header class="sim-section-header">
<div class="header-left"> <div class="header-left">
<div class="title-with-badge"> <div class="title-with-badge">
<span class="pulse-indicator" aria-hidden="true"></span> <span class="pulse-indicator" aria-hidden="true"></span>
<h2 class="section-title">{{ i18n.t().navSimulator }}</h2> <h2 class="section-title">{{ i18n.t().navSimulator }}</h2>
</div> </div>
<span class="section-tag">{{ i18n.t().simTag }}</span>
</div> </div>
<div class="status-pill" [class.danger]="stage() === 'breach' || stage() === 'detection'" [class.contained]="isContained()" [class.active]="stage() === 'remediation' || stage() === 'notification'"> <div class="header-controls">
<span class="status-beacon"></span> <!-- Selector de Modo de Vista (Humana vs Técnica) -->
<span class="status-text"> <div class="view-mode-toggle" role="group" aria-label="Modo de visualización">
@if (stage() === 'idle') { <button
{{ i18n.t().simStatusIdle }} type="button"
} @else if (isContained()) { class="mode-btn"
{{ i18n.t().simStatusContained }} [class.active]="viewMode() === 'story'"
} @else { (click)="setViewMode('story')"
{{ i18n.t().simStatusActive }} >
} <svg class="mode-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
</span> <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> </div>
</header> </header>
<!-- Panel Principal: Diagrama de Arquitectura Interactivo --> <!-- Panel Principal del Simulador -->
<div class="zen-sim-board"> <div class="zen-sim-board">
<!-- Diagrama de Flujo en 4 Nodos --> <!-- Barra Interactiva de Captura de Correo y Ejecución -->
<div class="diagram-canvas"> <div class="email-control-deck">
<!-- NODO 1: Vector de Amenaza --> <div class="email-input-wrapper">
<div class="arch-node threat-node" [class.active]="stage() === 'breach'" [class.past]="stage() !== 'idle' && stage() !== 'breach'"> <label for="sim-email-input" class="email-label">
<div class="node-badge-tag threat">VECTOR // 01</div> <svg class="email-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<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">
<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"/> <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"/> <polyline points="22,6 12,13 2,6"/>
</svg> </svg>
</div> <span>{{ i18n.t().simEmailLabel }}</span>
<div class="node-info"> </label>
<h3 class="node-title">{{ i18n.t().simSesNodeTitle }}</h3> <input
<span class="node-detail">{{ i18n.t().simSesNodeSub }}</span> id="sim-email-input"
<span class="node-meta-svc">secops-team@bryan.dev</span> type="email"
</div> class="email-field"
<div class="node-status-indicator" [class.ping]="stage() === 'notification'"></div> [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>
</div> </div>
<!-- Botonera de Control de la Simulación --> <!-- VISTA 1: MODO STORY / EXPLICADO PARA TODO PÚBLICO -->
<div class="sim-action-deck"> @if (viewMode() === 'story') {
<button <div class="story-grid-flow" aria-live="polite">
type="button" <!-- PASO 1 -->
class="sim-trigger-btn" <div class="story-step-card" [class.active]="stage() === 'breach'" [class.completed]="stage() !== 'idle' && stage() !== 'breach'">
[disabled]="isSimulating()" <div class="step-badge">01 // ATAQUE</div>
(click)="startSimulation()" <div class="step-icon-wrap danger">
[attr.aria-label]="i18n.t().simBtnSimulate" <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"/>
<span class="btn-glow"></span> <path d="M12 8v4m0 4h.01" stroke-linecap="round"/>
<span class="btn-icon">⚡</span> </svg>
<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>
</div> </div>
} <h3 class="step-title">{{ i18n.t().simStep1Title }}</h3>
<div class="console-cursor-row"> <p class="step-desc">{{ i18n.t().simStep1Desc }}</p>
<span class="console-prompt">secops@aws-perimeter:~$</span> <div class="step-meta">IP: {{ threatIp() }}</div>
<span class="cursor-block"></span> </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> </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 --> <!-- Métricas de Arquitectura Serverless -->
<footer class="arch-metrics-bar"> <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'; import { RetroAudioService } from '../../../application/services/retro-audio.service';
export type SimulationStage = 'idle' | 'breach' | 'detection' | 'remediation' | 'notification' | 'contained'; export type SimulationStage = 'idle' | 'breach' | 'detection' | 'remediation' | 'notification' | 'contained';
export type SimulatorViewMode = 'story' | 'tech';
export interface SecOpsLog { export interface SecOpsLog {
readonly timestamp: string; readonly timestamp: string;
@ -23,9 +24,13 @@ export class IncidentSimulatorComponent implements OnInit, OnDestroy {
private readonly audio = inject(RetroAudioService); private readonly audio = inject(RetroAudioService);
protected readonly stage = signal<SimulationStage>('idle'); protected readonly stage = signal<SimulationStage>('idle');
protected readonly viewMode = signal<SimulatorViewMode>('story');
protected readonly userEmail = signal<string>('');
protected readonly logs = signal<SecOpsLog[]>([]); protected readonly logs = signal<SecOpsLog[]>([]);
protected readonly threatIp = signal<string>('198.51.100.42'); protected readonly threatIp = signal<string>('198.51.100.42');
protected readonly executionTimeMs = signal<number>(184); protected readonly executionTimeMs = signal<number>(184);
protected readonly showReport = signal<boolean>(false);
protected readonly downloadFeedback = signal<boolean>(false);
protected readonly isSimulating = computed<boolean>(() => { protected readonly isSimulating = computed<boolean>(() => {
const s = this.stage(); const s = this.stage();
@ -34,12 +39,112 @@ export class IncidentSimulatorComponent implements OnInit, OnDestroy {
protected readonly isContained = computed<boolean>(() => this.stage() === 'contained'); 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>[] = []; private timeoutIds: ReturnType<typeof setTimeout>[] = [];
ngOnInit(): void { ngOnInit(): void {
this.initDefaultLogs(); 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 { private initDefaultLogs(): void {
const time = this.getCurrentTimestamp(); const time = this.getCurrentTimestamp();
this.logs.set([ 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 { private appendLog(entry: Omit<SecOpsLog, 'timestamp'>): void {
const timestamp = this.getCurrentTimestamp(); const timestamp = this.getCurrentTimestamp();
this.logs.update(prev => [...prev, { ...entry, timestamp }]); this.logs.update(prev => [...prev, { ...entry, timestamp }]);

View File

@ -153,11 +153,24 @@
box-sizing: border-box; box-sizing: border-box;
background-color: #121517; background-color: #121517;
flex: 1 0 auto; 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 { .hero-container.isolated-view {
padding-top: 0; padding-top: 0;
background-color: #000000; background-color: #000000;
animation: none;
} }
.interactive-stage.isolated-view { .interactive-stage.isolated-view {
@ -209,6 +222,15 @@
} }
@media (max-width: 960px) { @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 { .interactive-stage.isolated-view {
height: auto; height: auto;
min-height: calc(100vh - 56px); min-height: calc(100vh - 56px);
@ -438,6 +460,194 @@
background-color: #121517; 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) { @media (max-width: 768px) {
.zen-header { .zen-header {
padding: 0 8px; padding: 0 8px;
@ -471,4 +681,17 @@
padding: 3px 6px; padding: 3px 6px;
font-size: 0.6875rem; 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> </div>
</header> </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') { @if (activeSection() === 'hero') {
<main class="hero-container isolated-view"> <main class="hero-container isolated-view">
<app-hero-3d></app-hero-3d> <app-hero-3d [initialPosition]="heroEntryPosition()"></app-hero-3d>
</main> </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 { Hero3dComponent } from '../hero-3d/hero-3d.component';
import { RetroOsComponent } from '../retro-os/retro-os.component'; import { RetroOsComponent } from '../retro-os/retro-os.component';
import { TerminalComponent } from '../terminal/terminal.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 currentYear = signal(new Date().getFullYear());
protected readonly currentTime = signal<string>(''); protected readonly currentTime = signal<string>('');
protected readonly activeSection = signal<LandingSection>('hero'); 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 clockInterval: ReturnType<typeof setInterval> | null = null;
private readonly timeFormatter = new Intl.DateTimeFormat('en-GB', { private readonly timeFormatter = new Intl.DateTimeFormat('en-GB', {
@ -57,6 +82,7 @@ export class LandingComponent implements OnInit, OnDestroy {
private touchStartX = 0; private touchStartX = 0;
private wheelDeltaAccum = 0; private wheelDeltaAccum = 0;
private wheelResetTimer: ReturnType<typeof setTimeout> | null = null; private wheelResetTimer: ReturnType<typeof setTimeout> | null = null;
private transitionTimer: ReturnType<typeof setTimeout> | null = null;
ngOnInit(): void { ngOnInit(): void {
this.updateClock(); this.updateClock();
@ -65,31 +91,60 @@ export class LandingComponent implements OnInit, OnDestroy {
this.setupScrollTransitions(); this.setupScrollTransitions();
} }
setSection(section: LandingSection, entryPosition: 'top' | 'bottom' = 'top'): void { getSectionTitle(section: LandingSection): string {
if (this.activeSection() === section && this.isTransitioning) return; const dict = this.i18n.t();
this.isTransitioning = true; switch (section) {
setTimeout(() => { case 'hero': return dict.navHome;
this.isTransitioning = false; case 'retro': return dict.navRetro;
}, 750); case 'projects': return dict.navProjects;
case 'simulator': return dict.navSimulator;
this.activeSection.set(section); case 'terminal': return dict.navTerminal;
if (typeof window !== 'undefined') { case 'contact': return dict.navContact;
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);
} }
} }
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 { private setupScrollTransitions(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.addEventListener('wheel', this.onWheel, { passive: false }); 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()); const currentIndex = LANDING_SECTIONS.indexOf(this.activeSection());
if (currentIndex >= 0 && currentIndex < LANDING_SECTIONS.length - 1) { if (currentIndex >= 0 && currentIndex < LANDING_SECTIONS.length - 1) {
const next = LANDING_SECTIONS[currentIndex + 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()); const currentIndex = LANDING_SECTIONS.indexOf(this.activeSection());
if (currentIndex > 0) { if (currentIndex > 0) {
const prev = LANDING_SECTIONS[currentIndex - 1]; const prev = LANDING_SECTIONS[currentIndex - 1];
@ -224,6 +279,9 @@ export class LandingComponent implements OnInit, OnDestroy {
if (this.wheelResetTimer !== null) { if (this.wheelResetTimer !== null) {
clearTimeout(this.wheelResetTimer); clearTimeout(this.wheelResetTimer);
} }
if (this.transitionTimer !== null) {
clearTimeout(this.transitionTimer);
}
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.removeEventListener('wheel', this.onWheel); window.removeEventListener('wheel', this.onWheel);
window.removeEventListener('touchstart', this.onTouchStart); window.removeEventListener('touchstart', this.onTouchStart);

View File

@ -6,11 +6,11 @@
<figure class="ascii-portrait"> <figure class="ascii-portrait">
@if (asciiArt()) { @if (asciiArt()) {
<pre aria-label="Retrato ASCII de Bryan">{{ asciiArt() }}</pre> <pre [attr.aria-label]="i18n.t().tpAboutAria">{{ asciiArt() }}</pre>
} @else if (asciiLoadError()) { } @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 { } @else {
<figcaption class="ascii-status" role="status">Cargando retrato...</figcaption> <figcaption class="ascii-status" role="status">{{ i18n.t().tpAsciiLoading }}</figcaption>
} }
</figure> </figure>
@ -18,7 +18,7 @@
@for (paragraph of aboutParagraphs(); track $index) { @for (paragraph of aboutParagraphs(); track $index) {
<p class="about-description">{{ paragraph }}</p> <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> </div>
</section> </section>
</div> </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"> <html lang="es" data-theme="zen-dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Bryan // Portafolio Profesional Interactivo</title> <title>Bryan Baño</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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) --> <!-- Tipografía: Plus Jakarta Sans, JetBrains Mono y VT323 (Retro) -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">