mirror of
https://github.com/lennyzeltser/conversation-replay
synced 2026-06-21 13:55:53 +00:00
16e33e469f
scrollIntoView() scrolls all ancestor containers including the parent page when embedded in an iframe. Replaced with manual scrollTo() on the specific container to isolate scroll behavior. - Use container.scrollTo() instead of element.scrollIntoView() - Add browser fallback for older browsers without scrollTo support - Add Math.max(0, ...) guard for edge cases - Remove redundant scroll-margin-top CSS (only affects scrollIntoView)
2185 lines
67 KiB
HTML
2185 lines
67 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>AI-Assisted IR Report Writing</title>
|
|
<style>
|
|
*, *::before, *::after {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
/* Base Slate/Zinc Palette - Light */
|
|
--color-slate-50: #f8fafc;
|
|
--color-slate-100: #f1f5f9;
|
|
--color-slate-200: #e2e8f0;
|
|
--color-slate-300: #cbd5e1;
|
|
--color-slate-400: #94a3b8;
|
|
--color-slate-500: #64748b;
|
|
--color-slate-600: #475569;
|
|
--color-slate-700: #334155;
|
|
--color-slate-800: #1e293b;
|
|
--color-slate-900: #0f172a;
|
|
|
|
/* Primary Brand Color - Deep Blue/Purple */
|
|
--color-primary-50: #eef2ff;
|
|
--color-primary-100: #e0e7ff;
|
|
--color-primary-500: #6366f1;
|
|
--color-primary-600: #4f46e5;
|
|
--color-primary-700: #4338ca;
|
|
|
|
--bg-primary: var(--color-slate-50);
|
|
--bg-secondary: #ffffff;
|
|
--bg-chat: #ffffff;
|
|
|
|
--text-primary: var(--color-slate-900);
|
|
--text-secondary: var(--color-slate-600);
|
|
--text-muted: var(--color-slate-400);
|
|
|
|
--accent: var(--color-primary-600);
|
|
--accent-hover: var(--color-primary-500);
|
|
--accent-light: var(--color-primary-50);
|
|
|
|
--user-bg: var(--color-primary-50);
|
|
--user-border: transparent;
|
|
--user-text: var(--color-slate-800);
|
|
|
|
--ai-bg: var(--color-slate-100);
|
|
--ai-border: transparent;
|
|
--ai-text: var(--color-slate-800);
|
|
|
|
--annotation-bg: transparent;
|
|
--annotation-border: var(--color-slate-300);
|
|
--annotation-text: var(--color-slate-600);
|
|
|
|
--transition-bg: var(--color-primary-50);
|
|
--transition-border: var(--color-primary-300);
|
|
--border-color: var(--color-slate-200);
|
|
|
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
|
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
|
|
|
--radius: 8px;
|
|
--radius-lg: 12px;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg-primary: #0f172a; /* Slate 900 */
|
|
--bg-secondary: #1e293b; /* Slate 800 */
|
|
--bg-chat: #1e293b;
|
|
|
|
--text-primary: #f8fafc; /* Slate 50 */
|
|
--text-secondary: #94a3b8; /* Slate 400 */
|
|
--text-muted: #64748b; /* Slate 500 */
|
|
|
|
--accent: #818cf8; /* Indigo 400 */
|
|
--accent-hover: #6366f1; /* Indigo 500 */
|
|
--accent-light: rgba(99, 102, 241, 0.15);
|
|
|
|
--user-bg: rgba(67, 56, 202, 0.4); /* Indigo 800 alpha */
|
|
--user-border: rgba(99, 102, 241, 0.3);
|
|
--user-text: #e2e8f0;
|
|
|
|
--ai-bg: rgba(51, 65, 85, 0.6); /* Slate 700 alpha */
|
|
--ai-border: rgba(148, 163, 184, 0.1);
|
|
--ai-text: #e2e8f0;
|
|
|
|
--annotation-border: #475569;
|
|
--annotation-text: #cbd5e1;
|
|
|
|
--border-color: #334155; /* Slate 700 */
|
|
|
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3);
|
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4);
|
|
}
|
|
|
|
.tab-scroll-btn svg {
|
|
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
|
|
drop-shadow(0 0 4px rgba(0, 0, 0, 0.6));
|
|
}
|
|
|
|
.play-overlay {
|
|
background: rgba(30, 41, 59, 0.92); /* Slate 800 - matches --bg-chat */
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
.play-overlay-icon {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.play-overlay-icon svg path {
|
|
fill: #ffffff;
|
|
}
|
|
}
|
|
|
|
/* Dark mode via data-theme attribute (for iframe sync with parent) */
|
|
:root[data-theme="dark"] {
|
|
--bg-primary: #0f172a; /* Slate 900 */
|
|
--bg-secondary: #1e293b; /* Slate 800 */
|
|
--bg-chat: #1e293b;
|
|
|
|
--text-primary: #f8fafc; /* Slate 50 */
|
|
--text-secondary: #94a3b8; /* Slate 400 */
|
|
--text-muted: #64748b; /* Slate 500 */
|
|
|
|
--accent: #818cf8; /* Indigo 400 */
|
|
--accent-hover: #6366f1; /* Indigo 500 */
|
|
--accent-light: rgba(99, 102, 241, 0.15);
|
|
|
|
--user-bg: rgba(67, 56, 202, 0.4); /* Indigo 800 alpha */
|
|
--user-border: rgba(99, 102, 241, 0.3);
|
|
--user-text: #e2e8f0;
|
|
|
|
--ai-bg: rgba(51, 65, 85, 0.6); /* Slate 700 alpha */
|
|
--ai-border: rgba(148, 163, 184, 0.1);
|
|
--ai-text: #e2e8f0;
|
|
|
|
--annotation-border: #475569;
|
|
--annotation-text: #cbd5e1;
|
|
|
|
--border-color: #334155; /* Slate 700 */
|
|
|
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3);
|
|
}
|
|
|
|
:root[data-theme="dark"] .play-overlay {
|
|
background: rgba(30, 41, 59, 0.92); /* Slate 800 - matches --bg-chat */
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
:root[data-theme="dark"] .play-overlay-icon {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
:root[data-theme="dark"] .play-overlay-icon svg path {
|
|
fill: #ffffff;
|
|
}
|
|
|
|
|
|
|
|
body {
|
|
margin: 0;
|
|
padding: 24px;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
font-size: 15px;
|
|
line-height: 1.6;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
}
|
|
|
|
.demo-container {
|
|
max-width: 768px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.demo-header {
|
|
margin-bottom: 24px;
|
|
padding-bottom: 0;
|
|
}
|
|
|
|
.demo-header.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.demo-title {
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
margin: 0 0 8px 0;
|
|
letter-spacing: -0.025em;
|
|
}
|
|
|
|
.demo-title-link {
|
|
color: inherit;
|
|
text-decoration: none;
|
|
transition: color 0.2s;
|
|
}
|
|
|
|
.demo-title-link:hover {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.demo-description {
|
|
font-size: 15px;
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
line-height: 1.6;
|
|
max-width: 600px;
|
|
}
|
|
|
|
.demo-description a {
|
|
color: var(--accent);
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.demo-description a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
|
|
/* Tabs - horizontal scrolling with arrow navigation */
|
|
.tabs-wrapper {
|
|
position: relative;
|
|
margin-left: 12px;
|
|
margin-right: 12px;
|
|
margin-bottom: -1px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tab-scroll-btn {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 1px;
|
|
width: 32px;
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
z-index: 4;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
color: var(--text-primary);
|
|
transition: color 0.2s ease;
|
|
}
|
|
|
|
.tab-scroll-btn:hover {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.tab-scroll-btn svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
fill: currentColor;
|
|
/* Glow effect makes icon visible on any background */
|
|
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1))
|
|
drop-shadow(0 0 4px rgba(255, 255, 255, 0.8))
|
|
drop-shadow(0 0 6px rgba(255, 255, 255, 0.6));
|
|
}
|
|
|
|
:root[data-theme="dark"] .tab-scroll-btn svg {
|
|
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
|
|
drop-shadow(0 0 4px rgba(0, 0, 0, 0.6));
|
|
}
|
|
|
|
.tab-scroll-btn.left {
|
|
left: 0;
|
|
}
|
|
|
|
.tab-scroll-btn.right {
|
|
right: 0;
|
|
}
|
|
|
|
/* Use mask-image to fade tabs at edges - works on any background */
|
|
/* Fade zone is larger so text is fully transparent before reaching the chevron */
|
|
.tabs-wrapper.can-scroll-right .tabs {
|
|
-webkit-mask-image: linear-gradient(to right, black 0%, black calc(100% - 60px), transparent calc(100% - 20px), transparent 100%);
|
|
mask-image: linear-gradient(to right, black 0%, black calc(100% - 60px), transparent calc(100% - 20px), transparent 100%);
|
|
}
|
|
|
|
.tabs-wrapper.can-scroll-left .tabs {
|
|
-webkit-mask-image: linear-gradient(to left, black 0%, black calc(100% - 60px), transparent calc(100% - 20px), transparent 100%);
|
|
mask-image: linear-gradient(to left, black 0%, black calc(100% - 60px), transparent calc(100% - 20px), transparent 100%);
|
|
}
|
|
|
|
.tabs-wrapper.can-scroll-left.can-scroll-right .tabs {
|
|
-webkit-mask-image: linear-gradient(to right, transparent 0%, transparent 20px, black 60px, black calc(100% - 60px), transparent calc(100% - 20px), transparent 100%);
|
|
mask-image: linear-gradient(to right, transparent 0%, transparent 20px, black 60px, black calc(100% - 60px), transparent calc(100% - 20px), transparent 100%);
|
|
}
|
|
|
|
.tabs-wrapper.can-scroll-left .tab-scroll-btn.left {
|
|
display: flex;
|
|
}
|
|
|
|
.tabs-wrapper.can-scroll-right .tab-scroll-btn.right {
|
|
display: flex;
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
gap: 0;
|
|
flex-wrap: nowrap;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
-webkit-overflow-scrolling: touch;
|
|
scroll-snap-type: x mandatory;
|
|
scroll-behavior: smooth;
|
|
scrollbar-width: none;
|
|
-ms-overflow-style: none;
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
|
|
.tabs::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.tab {
|
|
padding: 10px 20px;
|
|
border: 1px solid transparent;
|
|
border-bottom: 1px solid var(--border-color);
|
|
background: transparent;
|
|
color: var(--tab-inactive-color, var(--text-secondary));
|
|
border-radius: var(--radius) var(--radius) 0 0;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
transition: all 0.2s;
|
|
flex-shrink: 0;
|
|
scroll-snap-align: start;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tab:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.tab.active {
|
|
background: var(--bg-chat);
|
|
color: var(--text-primary);
|
|
border-color: var(--border-color);
|
|
border-bottom-color: var(--bg-chat);
|
|
position: relative;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.tabs-wrapper {
|
|
margin-left: 12px;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
.tabs {
|
|
padding: 0;
|
|
}
|
|
|
|
.tab {
|
|
padding: 10px 16px;
|
|
font-size: 12px;
|
|
min-width: 100px;
|
|
max-width: 150px;
|
|
}
|
|
|
|
.tab-scroll-btn {
|
|
width: 28px;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.chat-wrapper {
|
|
position: relative;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.chat-container {
|
|
position: relative;
|
|
background: var(--bg-chat);
|
|
border-radius: var(--radius-lg);
|
|
border: 1px solid var(--border-color);
|
|
box-shadow: var(--shadow-xl);
|
|
padding: 0; /* Reset padding for internal scroll container */
|
|
flex: 1;
|
|
min-height: 400px;
|
|
max-height: var(--chat-max-height, 600px);
|
|
overflow: hidden; /* Hide container overflow, scroll internal list */
|
|
transition: opacity 0.3s ease;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.chat-scroll-area {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 24px;
|
|
scroll-behavior: smooth;
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
/* Hide scrollbar for cleaner look */
|
|
scrollbar-width: none; /* Firefox */
|
|
-ms-overflow-style: none; /* IE 10+ */
|
|
}
|
|
|
|
.chat-scroll-area::-webkit-scrollbar {
|
|
display: none; /* Chrome Safari */
|
|
}
|
|
|
|
.chat-container.fading .chat-scroll-area {
|
|
opacity: 0;
|
|
}
|
|
|
|
/* Standalone mode: chat fills available space, controls stay visible */
|
|
body:not(.in-iframe) .demo-container {
|
|
height: calc(100vh - 48px);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.controls-wrapper {
|
|
position: relative;
|
|
z-index: 20; /* Ensure controls (and popovers) are above play overlay */
|
|
}
|
|
|
|
body:not(.in-iframe) .chat-container {
|
|
/* Fills remaining space after header/tabs/controls */
|
|
--chat-max-height: none;
|
|
flex: 1;
|
|
min-height: 200px;
|
|
}
|
|
|
|
/* Iframe mode: fill viewport, transparent background */
|
|
html.in-iframe,
|
|
body.in-iframe {
|
|
height: 100%;
|
|
background: transparent !important; /* Force transparency */
|
|
padding: 0 !important;
|
|
margin: 0 !important;
|
|
overflow: hidden;
|
|
}
|
|
|
|
body.in-iframe .demo-container {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: transparent !important;
|
|
padding: 0;
|
|
margin: 0 !important;
|
|
border: none !important;
|
|
box-shadow: none !important;
|
|
}
|
|
|
|
body.in-iframe .tabs {
|
|
flex-shrink: 0;
|
|
padding-top: 0; /* Align tabs to top if present */
|
|
}
|
|
|
|
body.in-iframe .chat-wrapper {
|
|
box-shadow: none !important; /* Remove wrapper shadow */
|
|
}
|
|
|
|
body.in-iframe .chat-container {
|
|
flex: 1;
|
|
min-height: 0;
|
|
max-height: none !important; /* Remove height constraint in iframe */
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05) !important; /* Subtle shadow in iframe */
|
|
background: var(--bg-chat) !important; /* Ensure opaque background in iframe */
|
|
}
|
|
|
|
body.in-iframe .controls-wrapper {
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 12px 0;
|
|
z-index: 20;
|
|
}
|
|
|
|
|
|
.chat-messages {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
padding-bottom: 80px; /* Space for floating controls */
|
|
padding-left: 32px; /* Symmetric with right */
|
|
padding-right: 32px; /* Clearance for timer circle */
|
|
}
|
|
|
|
.play-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(255, 255, 255, 0.1); /* Much more transparent */
|
|
backdrop-filter: blur(1px); /* Subtle blur - configurable via initialBlur */
|
|
-webkit-backdrop-filter: blur(1px);
|
|
border-radius: var(--radius-lg);
|
|
cursor: pointer;
|
|
transition: opacity 0.4s ease;
|
|
z-index: 10;
|
|
}
|
|
|
|
.play-overlay.hidden {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.play-overlay-icon {
|
|
width: 64px; /* Slightly smaller for elegance */
|
|
height: 64px;
|
|
flex: 0 0 auto; /* Prevent squishing on small screens */
|
|
min-width: 64px;
|
|
min-height: 64px;
|
|
aspect-ratio: 1/1; /* Enforce square aspect ratio */
|
|
margin: auto; /* Center in flex container if needed */
|
|
background: white;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
|
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
}
|
|
|
|
.play-overlay-icon svg {
|
|
margin-left: 5px; /* Optical center adjustment for play triangle */
|
|
width: 26px;
|
|
height: 26px;
|
|
fill: var(--accent);
|
|
}
|
|
|
|
.play-overlay:hover .play-overlay-icon {
|
|
transform: scale(1.1);
|
|
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.message {
|
|
max-width: 85%;
|
|
opacity: 0;
|
|
transform: translateY(12px) scale(0.98);
|
|
transform-origin: bottom center;
|
|
}
|
|
|
|
.message.visible {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
transition: opacity 0.4s cubic-bezier(0.2, 0.8, 0.2, 1), transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
}
|
|
|
|
/* Slightly stagger the content fade in for polish */
|
|
.message.visible .message-content {
|
|
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
|
|
}
|
|
|
|
.message.user {
|
|
align-self: flex-start;
|
|
}
|
|
|
|
.message.ai {
|
|
/* AI messages always on the right */
|
|
align-self: flex-end;
|
|
margin-left: 32px; /* Prevent being too wide on large screens */
|
|
}
|
|
|
|
.message-label {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
margin-bottom: 6px;
|
|
color: var(--text-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.message.ai .message-label {
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.message-content {
|
|
padding: 14px 18px;
|
|
border-radius: var(--radius);
|
|
position: relative;
|
|
line-height: 1.6;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
/* Add subtle tails */
|
|
.message-content::before {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 0;
|
|
width: 12px;
|
|
height: 20px;
|
|
}
|
|
|
|
.message.user .message-content {
|
|
background: var(--user-bg);
|
|
border: 1px solid var(--user-border);
|
|
color: var(--user-text);
|
|
border-bottom-left-radius: 2px;
|
|
}
|
|
|
|
.message.ai .message-content {
|
|
background: var(--ai-bg);
|
|
border: 1px solid var(--ai-border);
|
|
color: var(--ai-text);
|
|
border-bottom-right-radius: 2px;
|
|
}
|
|
|
|
.message-content pre {
|
|
/* User requested no background difference */
|
|
background: transparent;
|
|
border: 1px solid rgba(0, 0, 0, 0.08); /* Slightly clearer border since we lost background */
|
|
border-radius: 6px;
|
|
padding: 12px;
|
|
overflow-x: auto;
|
|
font-size: 13.5px;
|
|
margin: 10px 0;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
font-family: 'SF Mono', Consolas, Menlo, monospace;
|
|
}
|
|
|
|
:root[data-theme="dark"] .message-content pre {
|
|
background: transparent;
|
|
border-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.message-content code {
|
|
font-family: 'SF Mono', Consolas, Menlo, monospace;
|
|
font-size: 13.5px;
|
|
/* User requested no background difference */
|
|
background: transparent;
|
|
padding: 0;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
:root[data-theme="dark"] .message-content code {
|
|
background: transparent;
|
|
}
|
|
|
|
.annotation {
|
|
position: relative;
|
|
width: 100%;
|
|
margin: 24px 0;
|
|
padding: 0 0 0 20px;
|
|
font-size: 16px;
|
|
font-style: italic;
|
|
color: var(--annotation-text);
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
|
|
.annotation::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
top: 2px;
|
|
bottom: 2px;
|
|
width: 4px;
|
|
background: var(--annotation-border);
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.annotation.visible {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
transition: opacity 0.5s ease, transform 0.5s ease;
|
|
}
|
|
|
|
.annotation-label {
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--annotation-text);
|
|
margin-bottom: 6px;
|
|
font-style: normal;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.annotation-content {
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.transition {
|
|
position: relative;
|
|
width: 100%;
|
|
margin: 32px 0;
|
|
/* Center transition like a system message */
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
text-align: center;
|
|
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--transition-text, var(--text-muted));
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
padding: 0 40px;
|
|
}
|
|
|
|
/* Remove side border for centered style */
|
|
.transition::before {
|
|
display: none;
|
|
}
|
|
|
|
/* Add subtle separator lines */
|
|
.transition::after {
|
|
content: '';
|
|
display: block;
|
|
width: 40px;
|
|
height: 2px;
|
|
background: var(--transition-border);
|
|
margin-top: 12px;
|
|
border-radius: 1px;
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.transition.visible {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
transition: opacity 0.5s ease, transform 0.5s ease;
|
|
}
|
|
|
|
.scenario-ending {
|
|
text-align: center;
|
|
padding: 32px 20px;
|
|
margin-top: 24px;
|
|
color: var(--text-muted);
|
|
font-size: 13px;
|
|
opacity: 0;
|
|
transition: opacity 0.4s ease;
|
|
}
|
|
|
|
.scenario-ending.visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
.scenario-ending .next-label {
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.scenario-ending .next-title {
|
|
font-weight: 600;
|
|
font-size: 16px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.controls-wrapper {
|
|
margin-top: 16px;
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 8px 12px;
|
|
background: rgba(255, 255, 255, 0.85); /* Light glass */
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
border: 1px solid rgba(0,0,0,0.08);
|
|
border-radius: 9999px; /* Pill shape */
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
|
pointer-events: auto;
|
|
transition: opacity 0.3s, transform 0.3s;
|
|
}
|
|
|
|
/* In iframe, match standalone appearance */
|
|
body.in-iframe .controls {
|
|
margin-bottom: 0;
|
|
/* Keep same shadow as standalone for consistency */
|
|
}
|
|
|
|
:root[data-theme="dark"] .controls {
|
|
background: rgba(30, 41, 59, 0.8); /* Slate 800 alpha */
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
:root[data-theme="dark"] body.in-iframe .controls {
|
|
background: rgba(30, 41, 59, 0.9);
|
|
}
|
|
|
|
.control-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 36px;
|
|
height: 36px;
|
|
padding: 0;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.control-btn svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
fill: currentColor;
|
|
}
|
|
|
|
.control-btn:hover:not(:disabled) {
|
|
background: rgba(0, 0, 0, 0.05);
|
|
color: var(--text-primary);
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
:root[data-theme="dark"] .control-btn:hover:not(:disabled) {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.control-btn:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Primary Play/Pause Button */
|
|
.control-btn.primary {
|
|
background: var(--accent);
|
|
color: white;
|
|
width: 44px;
|
|
height: 44px;
|
|
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4); /* Colored shadow */
|
|
}
|
|
|
|
:root[data-theme="dark"] .control-btn.primary {
|
|
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
|
}
|
|
|
|
.control-btn.primary svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
fill: white;
|
|
}
|
|
|
|
.control-btn.primary:hover:not(:disabled) {
|
|
background: var(--accent-hover);
|
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5);
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.speed-select {
|
|
appearance: none;
|
|
-webkit-appearance: none;
|
|
padding: 6px 12px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
transition: color 0.2s, background-color 0.2s;
|
|
text-align: center;
|
|
min-width: 40px;
|
|
}
|
|
|
|
.speed-select:hover {
|
|
background: rgba(0, 0, 0, 0.05);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
:root[data-theme="dark"] .speed-select:hover {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.divider {
|
|
width: 1px;
|
|
height: 16px;
|
|
background: var(--border-color);
|
|
margin: 0 4px;
|
|
}
|
|
|
|
.progress {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
font-feature-settings: "tnum";
|
|
font-variant-numeric: tabular-nums;
|
|
min-width: 40px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.timer-bar {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
height: 3px;
|
|
width: 0%;
|
|
background: var(--accent);
|
|
opacity: 0;
|
|
z-index: 5;
|
|
transform-origin: left;
|
|
transition: opacity 0.2s ease;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.timer-bar.active {
|
|
opacity: 1;
|
|
transition: width 0.1s linear, opacity 0.2s ease;
|
|
}
|
|
|
|
.timer-circle {
|
|
position: absolute;
|
|
top: 14px;
|
|
right: 14px;
|
|
width: 20px;
|
|
height: 20px;
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
z-index: 5;
|
|
pointer-events: none;
|
|
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.1));
|
|
}
|
|
|
|
.timer-circle.active {
|
|
opacity: 1;
|
|
}
|
|
|
|
.timer-circle circle {
|
|
fill: none;
|
|
stroke: var(--accent);
|
|
stroke-width: 2.5;
|
|
stroke-dasharray: 44;
|
|
stroke-dashoffset: 0; /* 2 * PI * r (approx 44 for r=7) */
|
|
transform: rotate(-90deg);
|
|
transform-origin: center;
|
|
stroke-linecap: round;
|
|
}
|
|
|
|
.browser-error {
|
|
text-align: center;
|
|
padding: 40px 20px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.message,
|
|
.annotation,
|
|
.transition {
|
|
opacity: 1 !important;
|
|
transform: none !important;
|
|
transition: none !important;
|
|
}
|
|
.play-overlay {
|
|
transition: none !important;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
body {
|
|
padding: 12px;
|
|
}
|
|
|
|
.demo-title {
|
|
font-size: 16px;
|
|
}
|
|
|
|
.demo-description {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.tabs {
|
|
gap: 6px;
|
|
}
|
|
|
|
.tab {
|
|
padding: 10px 14px;
|
|
font-size: 12px;
|
|
min-height: 44px;
|
|
}
|
|
|
|
.message {
|
|
max-width: 95%;
|
|
}
|
|
|
|
.chat-container {
|
|
padding: 12px;
|
|
min-height: 250px;
|
|
}
|
|
|
|
.controls {
|
|
gap: 8px;
|
|
padding-top: 12px;
|
|
}
|
|
|
|
.control-btn {
|
|
padding: 10px 14px;
|
|
font-size: 13px;
|
|
min-height: 44px;
|
|
}
|
|
|
|
.control-btn.icon-only {
|
|
padding: 10px 12px;
|
|
min-width: 44px;
|
|
min-height: 44px;
|
|
}
|
|
|
|
.speed-select {
|
|
padding: 10px 12px;
|
|
font-size: 13px;
|
|
min-height: 44px;
|
|
}
|
|
|
|
.play-overlay-icon {
|
|
width: 70px;
|
|
height: 70px;
|
|
min-width: 70px;
|
|
min-height: 70px;
|
|
}
|
|
|
|
.play-overlay-icon svg {
|
|
width: 28px;
|
|
height: 28px;
|
|
}
|
|
|
|
.message-content pre {
|
|
overflow-x: auto;
|
|
max-width: 100%;
|
|
}
|
|
}
|
|
|
|
/* Very small phones */
|
|
@media (max-width: 400px) {
|
|
body {
|
|
padding: 8px;
|
|
}
|
|
|
|
/* Very small phones - keep scrolling tabs instead of stacking */
|
|
.tabs {
|
|
gap: 4px;
|
|
}
|
|
|
|
.tab {
|
|
/* width: 100%; Removed to prevent full stacking */
|
|
text-align: center;
|
|
}
|
|
|
|
.controls {
|
|
flex-wrap: nowrap;
|
|
gap: 4px;
|
|
padding: 6px 8px;
|
|
}
|
|
|
|
.controls .divider {
|
|
display: none; /* Hide dividers to save space */
|
|
}
|
|
|
|
.control-btn {
|
|
width: 36px;
|
|
height: 36px;
|
|
min-width: 36px;
|
|
min-height: 36px;
|
|
padding: 0;
|
|
}
|
|
|
|
.control-btn.primary {
|
|
width: 40px;
|
|
height: 40px;
|
|
}
|
|
|
|
.speed-select {
|
|
padding: 6px 8px;
|
|
min-width: 32px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.progress {
|
|
font-size: 11px;
|
|
min-width: 36px;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/* Info Popover */
|
|
.info-container {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
#info-btn {
|
|
opacity: 0.5;
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
#info-btn:hover, #info-btn[aria-expanded="true"] {
|
|
opacity: 1;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.info-popover {
|
|
position: absolute;
|
|
bottom: 100%;
|
|
right: -10px; /* Align near the button */
|
|
margin-bottom: 12px;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
padding: 12px 16px;
|
|
width: 220px;
|
|
box-shadow: var(--shadow-lg);
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transform: translateY(8px);
|
|
transition: all 0.2s ease;
|
|
z-index: 100;
|
|
pointer-events: none;
|
|
text-align: left;
|
|
}
|
|
|
|
.info-popover::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: -5px;
|
|
right: 20px;
|
|
width: 10px;
|
|
height: 10px;
|
|
background: var(--bg-primary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
border-right: 1px solid var(--border-color);
|
|
transform: rotate(45deg);
|
|
}
|
|
|
|
.info-popover.visible {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
transform: translateY(0);
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.info-title-link {
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 4px;
|
|
display: block;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.info-title-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.info-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.info-link {
|
|
color: var(--accent);
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.info-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="demo-container">
|
|
|
|
<header class="demo-header" id="demo-header">
|
|
<h1 class="demo-title">AI-Assisted IR Report Writing</h1>
|
|
<p class="demo-description">Using AI tools to create, review, and improve incident response documentation</p>
|
|
</header>
|
|
|
|
|
|
<div class="tabs-wrapper">
|
|
<button class="tab-scroll-btn left" aria-label="Scroll tabs left">
|
|
<svg viewBox="0 0 24 24"><path d="M18.41 7.41L17 6l-6 6 6 6 1.41-1.41L13.83 12z"/><path d="M12.41 7.41L11 6l-6 6 6 6 1.41-1.41L7.83 12z"/></svg>
|
|
</button>
|
|
<nav class="tabs" role="tablist">
|
|
<button class="tab active" role="tab" data-scenario="create" aria-selected="true" tabindex="0">IR Report Creation</button>
|
|
<button class="tab" role="tab" data-scenario="review" aria-selected="false" tabindex="-1">IR Report Review</button>
|
|
<button class="tab" role="tab" data-scenario="coach" aria-selected="false" tabindex="-1">Writing Coach</button>
|
|
</nav>
|
|
<button class="tab-scroll-btn right" aria-label="Scroll tabs right">
|
|
<svg viewBox="0 0 24 24"><path d="M5.59 7.41L7 6l6 6-6 6-1.41-1.41L10.17 12z"/><path d="M11.59 7.41L13 6l6 6-6 6-1.41-1.41L16.17 12z"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="chat-wrapper">
|
|
<div class="chat-container" id="chat-container" role="log" aria-live="polite">
|
|
<div class="chat-scroll-area">
|
|
<div class="chat-messages" id="chat-messages"></div>
|
|
</div>
|
|
<svg class="timer-circle" id="timer-element" aria-hidden="true" viewBox="0 0 18 18">
|
|
<circle cx="9" cy="9" r="7"/>
|
|
</svg>
|
|
<div class="play-overlay" id="play-overlay" role="button" aria-label="Play demo" tabindex="0">
|
|
<div class="play-overlay-icon">
|
|
<svg viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="controls-wrapper">
|
|
<div class="controls">
|
|
<button class="control-btn primary" id="play-pause-btn" aria-label="Play animation">
|
|
<svg id="play-icon" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
<svg id="pause-icon" viewBox="0 0 24 24" style="display:none">
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<button class="control-btn icon-only" id="reset-btn" aria-label="Restart animation" title="Restart">
|
|
<svg viewBox="0 0 24 24">
|
|
<path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-8 3.58-8 8s3.58 8 8 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<select class="speed-select" id="speed-select" aria-label="Animation speed" title="Playback Speed">
|
|
<option value="0.5">0.5x</option>
|
|
<option value="1" selected>1x</option>
|
|
<option value="2">2x</option>
|
|
<option value="4">4x</option>
|
|
</select>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<span class="progress" id="progress"></span>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<div class="info-container">
|
|
<button class="control-btn icon-only" id="info-btn" aria-label="About this player" aria-haspopup="true" aria-expanded="false">
|
|
<svg viewBox="0 0 24 24">
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
|
|
</svg>
|
|
</button>
|
|
<div class="info-popover" id="info-popover">
|
|
<a href="https://github.com/lennyzeltser/conversation-replay" target="_blank" rel="noopener noreferrer" class="info-title-link">Conversation Replay</a>
|
|
<div class="info-meta">
|
|
<span>Created by <a href="https://zeltser.com" target="_blank" rel="noopener noreferrer" class="info-link">Lenny Zeltser</a></span>
|
|
<span>Version 0.1.17</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<noscript>
|
|
<div class="browser-error">
|
|
<p>This demo requires JavaScript to be enabled.</p>
|
|
</div>
|
|
</noscript>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Feature detection
|
|
var hasRequiredFeatures = (
|
|
typeof document.querySelector === 'function' &&
|
|
typeof document.querySelectorAll === 'function' &&
|
|
typeof window.addEventListener === 'function' &&
|
|
typeof Array.prototype.forEach === 'function'
|
|
);
|
|
|
|
if (!hasRequiredFeatures) {
|
|
var errorDiv = document.querySelector('.browser-error');
|
|
if (errorDiv) {
|
|
errorDiv.style.display = 'block';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Iframe detection - add class for CSS and hide header when embedded
|
|
var isInIframe = false;
|
|
try {
|
|
isInIframe = window.self !== window.top;
|
|
} catch (e) {
|
|
isInIframe = true;
|
|
}
|
|
|
|
if (isInIframe) {
|
|
document.documentElement.classList.add('in-iframe');
|
|
document.body.classList.add('in-iframe');
|
|
|
|
// Listen for theme changes from parent page (cross-origin support)
|
|
window.addEventListener('message', function(event) {
|
|
if (event.data && event.data.type === 'theme-change') {
|
|
var theme = event.data.theme;
|
|
if (theme === 'dark') {
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
} else {
|
|
document.documentElement.removeAttribute('data-theme');
|
|
}
|
|
}
|
|
});
|
|
|
|
// For same-origin iframes, directly sync with parent's theme
|
|
try {
|
|
var parentDoc = window.parent.document;
|
|
var parentHtml = parentDoc.documentElement;
|
|
|
|
// Initial sync
|
|
var parentTheme = parentHtml.getAttribute('data-theme');
|
|
if (parentTheme === 'dark') {
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
}
|
|
|
|
// Watch for changes
|
|
var observer = new MutationObserver(function(mutations) {
|
|
mutations.forEach(function(mutation) {
|
|
if (mutation.attributeName === 'data-theme') {
|
|
var theme = parentHtml.getAttribute('data-theme');
|
|
if (theme === 'dark') {
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
} else {
|
|
document.documentElement.removeAttribute('data-theme');
|
|
}
|
|
}
|
|
});
|
|
});
|
|
observer.observe(parentHtml, { attributes: true, attributeFilter: ['data-theme'] });
|
|
} catch (e) {
|
|
// Cross-origin - fall back to postMessage listener (already set up above)
|
|
}
|
|
}
|
|
|
|
var header = document.getElementById('demo-header');
|
|
if (isInIframe && header) {
|
|
header.classList.add('hidden');
|
|
}
|
|
|
|
// Configurable annotation label
|
|
var annotationLabel = "Behind the Scenes";
|
|
|
|
// Scenarios data
|
|
var scenarios = {
|
|
"create": {
|
|
title: "IR Report Creation",
|
|
labels: { user: "Security Analyst", ai: "AI Assistant" },
|
|
steps: [{ type: "user", plainText: "I need to create an IR report for a BEC incident. The CFO received a fraudulent wire transfer request that appeared to be from the CEO." }, { type: "ai", plainText: "I can help you draft that IR report. Let me ask a few questions first to ensure we capture all the relevant details." }, { type: "annotation", plainText: "The AI uses a structured approach to gather incident details systematically." }, { type: "ai", plainText: "When was the incident discovered, and what was the initial indicator that something was wrong?" }, { type: "user", plainText: "Discovered yesterday at 2pm when the CFO called the CEO to confirm the transfer. The CEO had no knowledge of the request." }, { type: "ai", plainText: "Good catch by the CFO. What was the requested transfer amount and where was it supposed to go?" }, { type: "user", plainText: "$47,500 to an account at First National Bank, routing number ending in 4521." }, { type: "ai", plainText: "Based on what you've shared, here's a draft executive summary:", codeBlock: "EXECUTIVE SUMMARY\n\nOn [date], a Business Email Compromise (BEC) attempt\ntargeted our organization. An attacker impersonating\nthe CEO requested a $47,500 wire transfer from the CFO.\n\nThe attack was detected before funds were transferred\nwhen the CFO verbally confirmed the request with the CEO.\n\nImmediate actions: Email headers analyzed, account\nsecurity reviewed, finance team alerted to new protocols.\n" }, { type: "transition", plainText: "The analyst continues building the full report..." }]
|
|
},
|
|
"review": {
|
|
title: "IR Report Review",
|
|
labels: { user: "Security Analyst", ai: "AI Assistant" },
|
|
steps: [{ type: "user", plainText: "Can you review this section of my IR report? I want to make sure it's clear for executives.", codeBlock: "The threat actor utilized sophisticated social engineering\ntechniques leveraging compromised email infrastructure to\nperpetrate a financial fraud attempt via BEC methodology.\n" }, { type: "annotation", plainText: "The AI identifies jargon and passive voice that could confuse non-technical readers." }, { type: "ai", plainText: "This section could be clearer for executives. Here are specific suggestions:" }, { type: "ai", plainText: "1. 'sophisticated social engineering techniques' - What specifically made it sophisticated? Was it personalized?", footnote: "Executives appreciate specifics over vague adjectives" }, { type: "ai", plainText: "2. 'leveraging compromised email infrastructure' - Whose email was compromised? The CEO's? An external vendor's?" }, { type: "ai", plainText: "3. 'perpetrate a financial fraud attempt via BEC methodology' - This is passive and jargon-heavy. Try: 'The attacker posed as our CEO to request a fraudulent wire transfer.'" }, { type: "user", plainText: "Good points. Let me revise that paragraph." }, { type: "user", plainText: "Revised version:", codeBlock: "An attacker gained access to the CEO's email account\nand sent a convincing wire transfer request to the CFO.\nThe email matched the CEO's writing style and referenced\nan actual pending acquisition, showing the attacker had\nresearched our company.\n" }, { type: "ai", plainText: "Much better. This version is specific, active voice, and tells executives exactly what happened without requiring security background knowledge." }]
|
|
},
|
|
"coach": {
|
|
title: "Writing Coach",
|
|
labels: { user: "Security Analyst", ai: "Writing Coach" },
|
|
steps: [{ type: "user", plainText: "I struggle with executive summaries. They either end up too technical or too vague. Any tips?" }, { type: "ai", plainText: "Executive summaries for IR reports should answer four questions in plain language:" }, { type: "ai", plainText: "1. WHAT happened? (One sentence describing the incident)" }, { type: "ai", plainText: "2. WHAT's the impact? (Business terms: financial, operational, reputational)" }, { type: "ai", plainText: "3. WHAT did we do? (Immediate response actions)" }, { type: "ai", plainText: "4. WHAT's next? (Recommended follow-up actions)" }, { type: "annotation", plainText: "This framework helps analysts structure their thinking and ensures coverage of key points." }, { type: "user", plainText: "That's helpful. What about tone? My manager says my reports sound too alarmist." }, { type: "ai", plainText: "Good question. Compare these two approaches:", codeBlock: "ALARMIST:\n\"A catastrophic security breach nearly resulted in\nmassive financial losses. Urgent action is required\nimmediately to prevent future attacks.\"\n\nMEASURED:\n\"A BEC attempt was detected and blocked before any\nfunds transferred. We recommend three process\nimprovements to reduce future risk.\"\n" }, { type: "ai", plainText: "The measured version states facts, acknowledges what went right, and focuses on actionable improvements rather than fear." }, { type: "user", plainText: "I see the difference. Facts over fear, and focus on what we're doing about it." }, { type: "ai", plainText: "Exactly. Executives want to know the situation is under control and what resources you need. Alarmist language undermines confidence in the security team." }]
|
|
}
|
|
};
|
|
var scenarioOrder = ["create","review","coach"];
|
|
var autoAdvance = true;
|
|
var hasMultipleScenarios = true;
|
|
var useCircleTimer = true;
|
|
|
|
// Speed configuration
|
|
var speedConfig = {
|
|
minDelay: 3000,
|
|
maxDelay: 8000,
|
|
msPerWord: 200,
|
|
annotationMultiplier: 1.15,
|
|
upNextDelay: 2500
|
|
};
|
|
|
|
// Animation state
|
|
var currentScenario = scenarioOrder[0];
|
|
var currentStepIndex = 0;
|
|
var isPlaying = false;
|
|
var isPaused = false;
|
|
var animationTimeout = null;
|
|
var speed = 1;
|
|
var currentStepBaseDelay = 0; // Base delay before speed adjustment (for mid-playback speed changes)
|
|
var hasStarted = false;
|
|
|
|
// DOM elements
|
|
var chatMessages = document.getElementById('chat-messages');
|
|
var playPauseBtn = document.getElementById('play-pause-btn');
|
|
var playIcon = document.getElementById('play-icon');
|
|
var pauseIcon = document.getElementById('pause-icon');
|
|
var resetBtn = document.getElementById('reset-btn');
|
|
var speedSelect = document.getElementById('speed-select');
|
|
var progressEl = document.getElementById('progress');
|
|
var playOverlay = document.getElementById('play-overlay');
|
|
var timerElement = document.getElementById('timer-element');
|
|
var chatContainer = document.getElementById('chat-container');
|
|
var tabs = document.querySelectorAll('.tab');
|
|
var tabsContainer = document.querySelector('.tabs');
|
|
var tabsWrapper = document.querySelector('.tabs-wrapper');
|
|
var infoBtn = document.getElementById('info-btn');
|
|
var infoPopover = document.getElementById('info-popover');
|
|
|
|
// Timer animation
|
|
var timerAnimationFrame = null;
|
|
var timerStartTime = 0;
|
|
var timerDuration = 0;
|
|
|
|
// Reduced motion preference
|
|
var prefersReducedMotion = false;
|
|
try {
|
|
prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
} catch (e) {}
|
|
|
|
function getSteps() {
|
|
return scenarios[currentScenario].steps;
|
|
}
|
|
|
|
function getLabels() {
|
|
return scenarios[currentScenario].labels;
|
|
}
|
|
|
|
// Content-aware delay calculation
|
|
function calculateDelay(step) {
|
|
var wordCount = 0;
|
|
|
|
if (step.plainText) {
|
|
wordCount += step.plainText.split(/\s+/).length;
|
|
}
|
|
if (step.codeBlock) {
|
|
wordCount += Math.floor(step.codeBlock.split(/\s+/).length * 1.3);
|
|
}
|
|
if (step.footnote) {
|
|
wordCount += step.footnote.split(/\s+/).length;
|
|
}
|
|
|
|
var baseDelay = Math.max(speedConfig.minDelay, Math.min(speedConfig.maxDelay, wordCount * speedConfig.msPerWord));
|
|
|
|
if (step.type === 'annotation') {
|
|
baseDelay = Math.floor(baseDelay * speedConfig.annotationMultiplier);
|
|
}
|
|
|
|
return baseDelay / speed;
|
|
}
|
|
|
|
// Timer functions - supports bar (grows left to right) or circle (stroke countdown)
|
|
function startTimer(duration) {
|
|
timerDuration = duration;
|
|
timerStartTime = Date.now();
|
|
timerElement.classList.add('active');
|
|
|
|
if (useCircleTimer) {
|
|
// Circle: animate stroke-dashoffset from 0 to 44 (full circle = 44)
|
|
var circle = timerElement.querySelector('circle');
|
|
if (circle) circle.style.strokeDashoffset = '0';
|
|
} else {
|
|
// Bar: start at 0% width
|
|
timerElement.style.width = '0%';
|
|
}
|
|
|
|
function animate() {
|
|
var elapsed = Date.now() - timerStartTime;
|
|
var progress = Math.min(elapsed / timerDuration, 1);
|
|
|
|
if (useCircleTimer) {
|
|
// Circle: offset goes from 0 to 44 as time progresses
|
|
var circle = timerElement.querySelector('circle');
|
|
if (circle) circle.style.strokeDashoffset = (progress * 44) + '';
|
|
} else {
|
|
// Bar: width grows from 0% to 100%
|
|
timerElement.style.width = (progress * 100) + '%';
|
|
}
|
|
|
|
if (progress < 1 && isPlaying && !isPaused) {
|
|
timerAnimationFrame = requestAnimationFrame(animate);
|
|
}
|
|
}
|
|
|
|
if (typeof requestAnimationFrame === 'function') {
|
|
timerAnimationFrame = requestAnimationFrame(animate);
|
|
}
|
|
}
|
|
|
|
function stopTimer() {
|
|
if (timerAnimationFrame) {
|
|
cancelAnimationFrame(timerAnimationFrame);
|
|
timerAnimationFrame = null;
|
|
}
|
|
timerElement.classList.remove('active');
|
|
if (useCircleTimer) {
|
|
var circle = timerElement.querySelector('circle');
|
|
if (circle) circle.style.strokeDashoffset = '0';
|
|
} else {
|
|
timerElement.style.width = '0%';
|
|
}
|
|
}
|
|
|
|
function clearChatMessages() {
|
|
while (chatMessages.firstChild) {
|
|
chatMessages.removeChild(chatMessages.firstChild);
|
|
}
|
|
}
|
|
|
|
function scrollToNewElement(element) {
|
|
var container = chatMessages.parentElement;
|
|
var scrollMargin = 8;
|
|
var elementTop = Math.max(0, element.offsetTop - scrollMargin);
|
|
if (typeof container.scrollTo === 'function') {
|
|
container.scrollTo({ top: elementTop, behavior: 'smooth' });
|
|
} else {
|
|
container.scrollTop = elementTop;
|
|
}
|
|
}
|
|
|
|
function createStepElement(step) {
|
|
var div = document.createElement('div');
|
|
var labels = getLabels();
|
|
|
|
if (step.type === 'annotation') {
|
|
div.className = 'annotation';
|
|
|
|
// Label
|
|
var label = document.createElement('div');
|
|
label.className = 'annotation-label';
|
|
label.textContent = annotationLabel;
|
|
div.appendChild(label);
|
|
|
|
// Content
|
|
var content = document.createElement('div');
|
|
content.className = 'annotation-content';
|
|
content.textContent = step.plainText;
|
|
div.appendChild(content);
|
|
return div;
|
|
}
|
|
|
|
if (step.type === 'transition') {
|
|
div.className = 'transition';
|
|
div.textContent = step.plainText;
|
|
return div;
|
|
}
|
|
|
|
// Message
|
|
div.className = 'message ' + step.type;
|
|
|
|
var label = document.createElement('div');
|
|
label.className = 'message-label';
|
|
label.textContent = labels[step.type] || step.type;
|
|
div.appendChild(label);
|
|
|
|
var contentWrapper = document.createElement('div');
|
|
contentWrapper.className = 'message-content';
|
|
|
|
if (step.plainText) {
|
|
var textNode = document.createElement('p');
|
|
var hasMoreContent = step.codeBlock || step.footnote;
|
|
textNode.style.margin = hasMoreContent ? '0 0 8px 0' : '0';
|
|
textNode.textContent = step.plainText;
|
|
contentWrapper.appendChild(textNode);
|
|
}
|
|
|
|
if (step.codeBlock) {
|
|
var pre = document.createElement('pre');
|
|
var code = document.createElement('code');
|
|
code.textContent = step.codeBlock;
|
|
pre.appendChild(code);
|
|
contentWrapper.appendChild(pre);
|
|
}
|
|
|
|
if (step.footnote) {
|
|
var footnote = document.createElement('p');
|
|
footnote.style.cssText = 'margin: 8px 0 0 0; font-style: italic; font-size: 15px; color: var(--text-secondary);';
|
|
footnote.textContent = step.footnote;
|
|
contentWrapper.appendChild(footnote);
|
|
}
|
|
|
|
div.appendChild(contentWrapper);
|
|
return div;
|
|
}
|
|
|
|
function updateTabStates() {
|
|
tabs.forEach(function(t) {
|
|
var isActive = t.getAttribute('data-scenario') === currentScenario;
|
|
if (isActive) {
|
|
t.classList.add('active');
|
|
t.setAttribute('aria-selected', 'true');
|
|
t.setAttribute('tabindex', '0');
|
|
} else {
|
|
t.classList.remove('active');
|
|
t.setAttribute('aria-selected', 'false');
|
|
t.setAttribute('tabindex', '-1');
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateButtonStates() {
|
|
var steps = getSteps();
|
|
var isComplete = currentStepIndex >= steps.length;
|
|
var isActivelyPlaying = isPlaying && !isPaused;
|
|
|
|
// Toggle play/pause button icon
|
|
if (isActivelyPlaying) {
|
|
playIcon.style.display = 'none';
|
|
pauseIcon.style.display = 'block';
|
|
playPauseBtn.setAttribute('aria-label', 'Pause animation');
|
|
} else {
|
|
playIcon.style.display = 'block';
|
|
pauseIcon.style.display = 'none';
|
|
playPauseBtn.setAttribute('aria-label', 'Play animation');
|
|
}
|
|
|
|
playPauseBtn.disabled = isComplete && !isActivelyPlaying;
|
|
}
|
|
|
|
function updateProgress() {
|
|
var steps = getSteps();
|
|
progressEl.textContent = currentStepIndex + ' / ' + steps.length;
|
|
}
|
|
|
|
function showInitialPreview() {
|
|
var steps = getSteps();
|
|
if (steps.length > 0) {
|
|
var element = createStepElement(steps[0]);
|
|
element.classList.add('visible');
|
|
chatMessages.appendChild(element);
|
|
currentStepIndex = 0;
|
|
}
|
|
playOverlay.classList.remove('hidden');
|
|
hasStarted = false;
|
|
updateProgress();
|
|
updateButtonStates();
|
|
}
|
|
|
|
function switchToScenario(scenarioId, withFade) {
|
|
stopTimer();
|
|
if (animationTimeout) {
|
|
clearTimeout(animationTimeout);
|
|
animationTimeout = null;
|
|
}
|
|
|
|
function doSwitch() {
|
|
currentScenario = scenarioId;
|
|
currentStepIndex = 0;
|
|
isPlaying = false;
|
|
isPaused = false;
|
|
hasStarted = false;
|
|
|
|
updateTabStates();
|
|
clearChatMessages();
|
|
showInitialPreview();
|
|
|
|
if (withFade) {
|
|
// Fade back in
|
|
chatContainer.classList.remove('fading');
|
|
}
|
|
}
|
|
|
|
if (withFade) {
|
|
// Fade out first
|
|
chatContainer.classList.add('fading');
|
|
setTimeout(doSwitch, 300);
|
|
} else {
|
|
doSwitch();
|
|
}
|
|
}
|
|
|
|
function showTransitionMessage(nextScenarioTitle) {
|
|
var div = document.createElement('div');
|
|
div.className = 'transition';
|
|
div.textContent = 'Switching to: ' + nextScenarioTitle;
|
|
chatMessages.appendChild(div);
|
|
|
|
if (typeof requestAnimationFrame === 'function') {
|
|
requestAnimationFrame(function() {
|
|
div.classList.add('visible');
|
|
scrollToNewElement(div);
|
|
});
|
|
} else {
|
|
div.classList.add('visible');
|
|
scrollToNewElement(div);
|
|
}
|
|
}
|
|
|
|
function advanceToNextScenario() {
|
|
var currentIndex = scenarioOrder.indexOf(currentScenario);
|
|
var nextIndex = (currentIndex + 1) % scenarioOrder.length;
|
|
var nextScenarioId = scenarioOrder[nextIndex];
|
|
var nextTitle = scenarios[nextScenarioId].title;
|
|
|
|
// Show "Up Next" indicator at end of current scenario
|
|
var upNextDiv = document.createElement('div');
|
|
upNextDiv.className = 'scenario-ending';
|
|
var labelDiv = document.createElement('div');
|
|
labelDiv.className = 'next-label';
|
|
labelDiv.textContent = 'Up Next';
|
|
var titleDiv = document.createElement('div');
|
|
titleDiv.className = 'next-title';
|
|
titleDiv.textContent = nextTitle;
|
|
upNextDiv.appendChild(labelDiv);
|
|
upNextDiv.appendChild(titleDiv);
|
|
chatMessages.appendChild(upNextDiv);
|
|
|
|
// Animate it in
|
|
requestAnimationFrame(function() {
|
|
upNextDiv.classList.add('visible');
|
|
scrollToNewElement(upNextDiv);
|
|
});
|
|
|
|
// Wait for user to see it, then fade and switch
|
|
var upNextDelay = speedConfig.upNextDelay / speed;
|
|
startTimer(upNextDelay);
|
|
animationTimeout = setTimeout(function() {
|
|
stopTimer();
|
|
chatContainer.classList.add('fading');
|
|
|
|
setTimeout(function() {
|
|
currentScenario = nextScenarioId;
|
|
currentStepIndex = 0;
|
|
isPlaying = false;
|
|
isPaused = false;
|
|
hasStarted = false;
|
|
|
|
updateTabStates();
|
|
clearChatMessages();
|
|
// Skip showInitialPreview() during auto-advance to avoid overlay flash
|
|
// Keep overlay hidden and go directly to playing
|
|
playOverlay.classList.add('hidden');
|
|
chatContainer.classList.remove('fading');
|
|
|
|
// Ensure new active tab is visible (not in fade zone)
|
|
scrollActiveTabIntoView();
|
|
|
|
// Auto-play immediately
|
|
play();
|
|
}, 300);
|
|
}, upNextDelay);
|
|
}
|
|
|
|
function showNextStep() {
|
|
stopTimer();
|
|
var steps = getSteps();
|
|
|
|
if (currentStepIndex >= steps.length) {
|
|
// Scenario complete
|
|
if (autoAdvance && hasMultipleScenarios) {
|
|
// Advance to next scenario - it handles its own "Up Next" timing
|
|
advanceToNextScenario();
|
|
} else {
|
|
isPlaying = false;
|
|
updateButtonStates();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isPaused) return;
|
|
|
|
var step = steps[currentStepIndex];
|
|
var element = createStepElement(step);
|
|
chatMessages.appendChild(element);
|
|
|
|
if (typeof requestAnimationFrame === 'function') {
|
|
requestAnimationFrame(function() {
|
|
element.classList.add('visible');
|
|
scrollToNewElement(element);
|
|
});
|
|
} else {
|
|
element.classList.add('visible');
|
|
scrollToNewElement(element);
|
|
}
|
|
|
|
currentStepIndex++;
|
|
updateProgress();
|
|
|
|
if (currentStepIndex < steps.length) {
|
|
var delay = calculateDelay(step);
|
|
currentStepBaseDelay = delay * speed; // Store base delay for mid-playback speed changes
|
|
startTimer(delay);
|
|
animationTimeout = setTimeout(showNextStep, delay);
|
|
} else {
|
|
// Check if we should auto-advance
|
|
if (autoAdvance && hasMultipleScenarios) {
|
|
// Advance to next scenario - it handles its own "Up Next" timing
|
|
advanceToNextScenario();
|
|
} else {
|
|
isPlaying = false;
|
|
updateButtonStates();
|
|
}
|
|
}
|
|
}
|
|
|
|
function play() {
|
|
if (!hasStarted) {
|
|
playOverlay.classList.add('hidden');
|
|
hasStarted = true;
|
|
isPlaying = true;
|
|
isPaused = false;
|
|
updateButtonStates();
|
|
|
|
var steps = getSteps();
|
|
var firstStep = steps[0];
|
|
|
|
// Show first step if not already visible (e.g., during auto-advance)
|
|
if (chatMessages.children.length === 0) {
|
|
var element = createStepElement(firstStep);
|
|
element.classList.add('visible');
|
|
chatMessages.appendChild(element);
|
|
}
|
|
|
|
var delay = calculateDelay(firstStep);
|
|
currentStepBaseDelay = delay * speed; // Store base delay for mid-playback speed changes
|
|
currentStepIndex = 1;
|
|
|
|
startTimer(delay);
|
|
animationTimeout = setTimeout(showNextStep, delay);
|
|
return;
|
|
}
|
|
|
|
if (isPaused) {
|
|
isPaused = false;
|
|
updateButtonStates();
|
|
showNextStep();
|
|
return;
|
|
}
|
|
|
|
if (isPlaying) return;
|
|
|
|
var steps = getSteps();
|
|
if (currentStepIndex >= steps.length) {
|
|
currentStepIndex = 0;
|
|
clearChatMessages();
|
|
var element = createStepElement(steps[0]);
|
|
element.classList.add('visible');
|
|
chatMessages.appendChild(element);
|
|
currentStepIndex = 1;
|
|
|
|
isPlaying = true;
|
|
isPaused = false;
|
|
updateButtonStates();
|
|
|
|
var delay = calculateDelay(steps[0]);
|
|
currentStepBaseDelay = delay * speed; // Store base delay for mid-playback speed changes
|
|
startTimer(delay);
|
|
animationTimeout = setTimeout(showNextStep, delay);
|
|
return;
|
|
}
|
|
|
|
isPlaying = true;
|
|
isPaused = false;
|
|
updateButtonStates();
|
|
showNextStep();
|
|
}
|
|
|
|
function pause() {
|
|
isPaused = true;
|
|
updateButtonStates();
|
|
stopTimer();
|
|
if (animationTimeout) {
|
|
clearTimeout(animationTimeout);
|
|
animationTimeout = null;
|
|
}
|
|
}
|
|
|
|
function reset() {
|
|
switchToScenario(currentScenario);
|
|
}
|
|
|
|
function showAllInstantly() {
|
|
var steps = getSteps();
|
|
clearChatMessages();
|
|
playOverlay.classList.add('hidden');
|
|
hasStarted = true;
|
|
|
|
steps.forEach(function(step) {
|
|
var element = createStepElement(step);
|
|
element.classList.add('visible');
|
|
chatMessages.appendChild(element);
|
|
});
|
|
|
|
currentStepIndex = steps.length;
|
|
isPlaying = false;
|
|
updateProgress();
|
|
updateButtonStates();
|
|
}
|
|
|
|
// Event listeners
|
|
playOverlay.addEventListener('click', function() {
|
|
if (prefersReducedMotion) {
|
|
showAllInstantly();
|
|
} else {
|
|
play();
|
|
}
|
|
});
|
|
|
|
playOverlay.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
if (prefersReducedMotion) {
|
|
showAllInstantly();
|
|
} else {
|
|
play();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Tab switching logic (shared between click and keyboard)
|
|
function switchTab(tab) {
|
|
var scenarioId = tab.getAttribute('data-scenario');
|
|
if (scenarioId === currentScenario) return; // Already on this tab
|
|
|
|
// Remember if we were playing before switching
|
|
var wasPlaying = isPlaying && !isPaused;
|
|
|
|
// Stop any current animation
|
|
if (animationTimeout) {
|
|
clearTimeout(animationTimeout);
|
|
animationTimeout = null;
|
|
}
|
|
stopTimer();
|
|
|
|
// Fade out, switch, fade in
|
|
chatContainer.classList.add('fading');
|
|
setTimeout(function() {
|
|
currentScenario = scenarioId;
|
|
currentStepIndex = 0;
|
|
isPlaying = false;
|
|
isPaused = false;
|
|
hasStarted = false;
|
|
|
|
updateTabStates();
|
|
clearChatMessages();
|
|
|
|
// Keep overlay hidden to avoid flash during switch
|
|
playOverlay.classList.add('hidden');
|
|
chatContainer.classList.remove('fading');
|
|
|
|
if (prefersReducedMotion) {
|
|
showAllInstantly();
|
|
} else {
|
|
// Always show initial preview to render the first message
|
|
showInitialPreview();
|
|
|
|
if (wasPlaying) {
|
|
// If we were playing, continue playing (this will hide the overlay again)
|
|
play();
|
|
} else {
|
|
// If we weren't playing, ensure the overlay is hidden?
|
|
// The original comment said "keep overlay hidden" but showInitialPreview shows it.
|
|
// If we pause, we usually show the play button?
|
|
// Actually, standard behavior is usually to show the play button on a fresh tab.
|
|
// But strictly following the intention of "keep overlay hidden" would mean adding it back?
|
|
// Let's stick to standard behavior: new tab = reset state = show play button.
|
|
// showInitialPreview() handles that.
|
|
|
|
// However, check if we want to suppress it:
|
|
// "User can click play button or canvas to start"
|
|
}
|
|
}
|
|
|
|
// Ensure active tab is fully visible (not obscured by scroll buttons)
|
|
scrollActiveTabIntoView();
|
|
}, 300);
|
|
}
|
|
|
|
// Tab click handlers
|
|
tabs.forEach(function(tab) {
|
|
tab.addEventListener('click', function() {
|
|
switchTab(tab);
|
|
});
|
|
});
|
|
|
|
// Tab keyboard navigation (arrow keys per ARIA authoring practices)
|
|
tabs.forEach(function(tab, index) {
|
|
tab.addEventListener('keydown', function(e) {
|
|
var tabArray = Array.prototype.slice.call(tabs);
|
|
var currentIndex = tabArray.indexOf(tab);
|
|
var newIndex = currentIndex;
|
|
|
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
newIndex = currentIndex === 0 ? tabArray.length - 1 : currentIndex - 1;
|
|
e.preventDefault();
|
|
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
newIndex = currentIndex === tabArray.length - 1 ? 0 : currentIndex + 1;
|
|
e.preventDefault();
|
|
} else if (e.key === 'Home') {
|
|
newIndex = 0;
|
|
e.preventDefault();
|
|
} else if (e.key === 'End') {
|
|
newIndex = tabArray.length - 1;
|
|
e.preventDefault();
|
|
}
|
|
|
|
if (newIndex !== currentIndex) {
|
|
tabArray[newIndex].focus();
|
|
switchTab(tabArray[newIndex]);
|
|
}
|
|
});
|
|
});
|
|
|
|
function togglePlayPause() {
|
|
if (isPlaying && !isPaused) {
|
|
pause();
|
|
} else {
|
|
play();
|
|
}
|
|
}
|
|
|
|
playPauseBtn.addEventListener('click', togglePlayPause);
|
|
resetBtn.addEventListener('click', reset);
|
|
|
|
// Info popover
|
|
if (infoBtn && infoPopover) {
|
|
infoBtn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
var isVisible = infoPopover.classList.contains('visible');
|
|
if (isVisible) {
|
|
infoPopover.classList.remove('visible');
|
|
infoBtn.setAttribute('aria-expanded', 'false');
|
|
} else {
|
|
infoPopover.classList.add('visible');
|
|
infoBtn.setAttribute('aria-expanded', 'true');
|
|
}
|
|
});
|
|
|
|
// Close when clicking outside
|
|
document.addEventListener('click', function(e) {
|
|
if (!infoPopover.contains(e.target) && !infoBtn.contains(e.target)) {
|
|
infoPopover.classList.remove('visible');
|
|
infoBtn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
});
|
|
|
|
// Close on visual viewport resize (mobile keyboard, etc) or scroll
|
|
// Optional, but good for cleanliness
|
|
}
|
|
|
|
speedSelect.addEventListener('change', function(e) {
|
|
var oldSpeed = speed;
|
|
speed = parseFloat(e.target.value);
|
|
|
|
// If playing, recalculate and reschedule with new speed
|
|
if (isPlaying && !isPaused && animationTimeout && currentStepBaseDelay > 0) {
|
|
var elapsed = Date.now() - timerStartTime;
|
|
var elapsedBase = elapsed * oldSpeed; // Content time consumed
|
|
var remainingBase = currentStepBaseDelay - elapsedBase;
|
|
|
|
if (remainingBase > 0) {
|
|
// Cancel current timeout (but keep timer animation running)
|
|
clearTimeout(animationTimeout);
|
|
|
|
// Adjust timer variables so animation continues from current position
|
|
// progress = elapsed / timerDuration should remain the same
|
|
timerDuration = currentStepBaseDelay / speed;
|
|
timerStartTime = Date.now() - (elapsedBase / speed);
|
|
|
|
// Reschedule with remaining time at new speed
|
|
var remainingTime = remainingBase / speed;
|
|
animationTimeout = setTimeout(showNextStep, remainingTime);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle page visibility changes (pause timer animation when tab is hidden)
|
|
if (typeof document.hidden !== 'undefined') {
|
|
document.addEventListener('visibilitychange', function() {
|
|
if (document.hidden && timerAnimationFrame) {
|
|
cancelAnimationFrame(timerAnimationFrame);
|
|
timerAnimationFrame = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Scroll indicators for mobile tabs
|
|
function updateTabScrollIndicators() {
|
|
if (!tabsContainer || !tabsWrapper) return;
|
|
|
|
var scrollLeft = tabsContainer.scrollLeft;
|
|
var maxScroll = tabsContainer.scrollWidth - tabsContainer.clientWidth;
|
|
var threshold = 2; // Account for sub-pixel rendering
|
|
|
|
if (scrollLeft > threshold) {
|
|
tabsWrapper.classList.add('can-scroll-left');
|
|
} else {
|
|
tabsWrapper.classList.remove('can-scroll-left');
|
|
}
|
|
|
|
if (scrollLeft < maxScroll - threshold) {
|
|
tabsWrapper.classList.add('can-scroll-right');
|
|
} else {
|
|
tabsWrapper.classList.remove('can-scroll-right');
|
|
}
|
|
}
|
|
|
|
// Auto-scroll to ensure active tab is fully visible (outside the fade zone)
|
|
function scrollActiveTabIntoView() {
|
|
if (!tabsContainer) return;
|
|
var activeTab = tabsContainer.querySelector('.tab.active');
|
|
if (!activeTab) return;
|
|
|
|
var containerRect = tabsContainer.getBoundingClientRect();
|
|
var tabRect = activeTab.getBoundingClientRect();
|
|
var fadeZone = 70; // Must be larger than CSS mask fade zone (60px) to keep active tab fully opaque
|
|
|
|
// Check if tab is cut off on the right
|
|
if (tabRect.right > containerRect.right - fadeZone) {
|
|
tabsContainer.scrollBy({
|
|
left: tabRect.right - containerRect.right + fadeZone + 8,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
// Check if tab is cut off on the left
|
|
else if (tabRect.left < containerRect.left + fadeZone) {
|
|
tabsContainer.scrollBy({
|
|
left: tabRect.left - containerRect.left - fadeZone - 8,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
}
|
|
|
|
if (tabsContainer && tabsWrapper) {
|
|
tabsContainer.addEventListener('scroll', updateTabScrollIndicators, { passive: true });
|
|
window.addEventListener('resize', updateTabScrollIndicators, { passive: true });
|
|
|
|
// Arrow button click handlers
|
|
var scrollLeftBtn = tabsWrapper.querySelector('.tab-scroll-btn.left');
|
|
var scrollRightBtn = tabsWrapper.querySelector('.tab-scroll-btn.right');
|
|
var scrollAmount = 200; // pixels to scroll per click
|
|
|
|
if (scrollLeftBtn) {
|
|
scrollLeftBtn.addEventListener('click', function() {
|
|
tabsContainer.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
|
|
});
|
|
}
|
|
|
|
if (scrollRightBtn) {
|
|
scrollRightBtn.addEventListener('click', function() {
|
|
tabsContainer.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
|
});
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
updateTabStates();
|
|
updateTabScrollIndicators();
|
|
showInitialPreview();
|
|
|
|
if (prefersReducedMotion) {
|
|
showAllInstantly();
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html> |