Custom Code Experiment •
Leo
Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EchoLogix Terminal</title>
<style>
:root {
--bg-primary: #27161E;
--bg-secondary: #27161E;
--text-primary: #C5EC6E;
--text-highlight: #FF6BC7;
--text-error: #FF8A8A;
--text-emphasis: #FFB380;
--accent-cursor: #F32AC4;
--accent-border: #9F563F;
--accent-scrollbar: #C63976;
--spacing-unit: 1.5rem;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.cursor {
animation: none;
opacity: 1;
}
#copy-notification {
transition: none;
}
}
@media (max-width: 768px) {
:root {
--spacing-unit: 1rem;
}
#terminal-container {
padding: 1rem;
}
#terminal {
font-size: 16px;
max-width: 100%;
line-height: 1.4;
}
.paragraph {
padding: 0.75rem;
margin-bottom: 1rem;
border-left: 2px solid var(--accent-border);
}
#progress {
font-size: 13px;
right: 10px;
top: 10px;
}
#copy-notification {
top: 10px;
right: 10px;
left: 10px;
text-align: center;
font-size: 13px;
padding: 10px;
}
}
@media (min-width: 769px) {
#terminal-container {
padding: 2rem 3rem;
}
#terminal {
font-size: 18px;
max-width: none;
width: 100%;
}
.paragraph {
max-width: none;
margin-left: 0;
margin-right: 0;
}
}
@media (min-width: 1200px) {
#terminal-container {
padding: 3rem 4rem;
}
#terminal {
font-size: 19px;
}
.paragraph {
padding: 1.5rem;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: 'Courier New', Courier, 'Lucida Console', monospace;
line-height: 1.5;
overflow: hidden;
height: 100vh;
background: #0a0a0a;
}
#terminal-container {
height: 100vh;
overflow-y: auto;
background-color: var(--bg-primary);
position: relative;
background: #0a0a0a;
}
#terminal {
white-space: pre-wrap;
font-size: 18px;
}
.cursor {
display: inline-block;
width: 8px;
height: 1.2em;
background-color: var(--accent-cursor);
margin-left: 2px;
animation: blink 1.2s infinite;
vertical-align: baseline;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.paragraph {
margin-bottom: var(--spacing-unit);
padding: 1rem;
border-left: 3px solid var(--accent-border);
background: rgba(50, 68, 109, 0.15);
border-radius: 0 8px 8px 0;
}
.highlight {
color: var(--text-highlight);
font-weight: bold;
}
.error {
color: var(--text-error);
font-weight: bold;
}
.emphasis {
color: var(--text-emphasis);
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--accent-scrollbar);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-cursor);
}
@media (max-width: 768px) {
::-webkit-scrollbar {
display: none;
}
#terminal-container {
-webkit-overflow-scrolling: touch;
}
}
#copy-notification {
position: fixed;
top: 20px;
right: 20px;
background-color: var(--accent-border);
color: var(--text-primary);
padding: 12px 18px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
font-family: inherit;
font-size: 14px;
z-index: 1000;
border: 1px solid var(--accent-cursor);
}
#copy-notification.show {
opacity: 1;
}
#progress {
position: fixed;
top: 20px;
right: 20px;
color: var(--text-primary);
font-size: 14px;
background: rgba(10, 10, 10, 0.8);
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--accent-border);
z-index: 50;
opacity: 0.7;
transition: opacity 0.3s ease;
}
#progress:hover {
opacity: 1;
}
@media (max-width: 768px) and (orientation: landscape) {
#terminal-container {
padding: 0.75rem;
}
#progress {
top: 10px;
right: 10px;
font-size: 12px;
}
}
@media (max-width: 360px) {
#terminal {
font-size: 15px;
}
.paragraph {
padding: 0.5rem;
}
#progress {
font-size: 11px;
padding: 3px 6px;
}
}
</style>
</head>
<body>
<div id="terminal-container"
role="log"
aria-live="polite"
aria-label="Interactive story terminal">
<div id="terminal"></div>
</div>
<!-- Minimal progress indicator in corner -->
<div id="progress" role="status">Loading...</div>
<div id="copy-notification">Text copied to clipboard! Press Ctrl+C to copy</div>
<!-- Screen reader only restart button -->
<button id="restart-sr" class="sr-only">Press Space to restart story</button>
<script>
class TerminalAnimator {
constructor(config = {}) {
this.speed = config.speed || 60;
this.autoScroll = config.autoScroll ?? true;
this.batchSize = config.batchSize || 2;
this.paragraphs = [];
this.quoteRegions = [];
this.state = { currentPara: 0, currentChar: 0 };
this.isPaused = false;
this.userScrolled = false;
this.isTransitioning = false;
this.typingInterval = null;
this.scrollTimeout = null;
this.cursorElement = null;
this.elements = {
terminal: document.getElementById('terminal'),
container: document.getElementById('terminal-container'),
copyNotification: document.getElementById('copy-notification'),
progress: document.getElementById('progress')
};
const missingElements = Object.entries(this.elements)
.filter(([key, element]) => !element)
.map(([key]) => key);
if (missingElements.length > 0) {
console.error('Missing required DOM elements:', missingElements);
return;
}
this.storyText = `His name was from a list, a name chosen for its phonetic neutrality, for the way it could be spoken by a digital voice without inflection, a name that carried no history he could recognise as his own. They called him Leo in the white halls of the facility, a clean and simple sound that felt like a garment he hadn't chosen, something woven from a synthesised thread that never warmed to the skin.
Beneath that name was a deeper current, a memory of his father's voice singing a song in a language that had no written form in the system's archives, the smell of charcoal smoke and drying fish, the heavy, wet air of a coast he could no longer find on any official map. These things persisted inside him as a kind of quiet, illegal data, a sensory record that had no field to be entered into, no value to be extracted by the smooth, administrative machinery that arranged his days.`;
this.init();
this.bindEvents();
}
init() {
this.reset();
this.preprocessText();
setTimeout(() => this.startTyping(), 500);
}
reset() {
clearInterval(this.typingInterval);
clearTimeout(this.scrollTimeout);
this.state = { currentPara: 0, currentChar: 0 };
this.isPaused = false;
this.userScrolled = false;
this.isTransitioning = false;
this.elements.terminal.innerHTML = '';
this.elements.progress.textContent = '0%';
this.cursorElement = null;
}
preprocessText() {
this.paragraphs = this.storyText.split('\n\n').map(p => p.trim()).filter(p => p.length > 0);
this.quoteRegions = this.paragraphs.map(p => this.findQuotedRegions(p));
}
findQuotedRegions(text) {
const regions = [];
if (!text) return regions;
let inQuote = false;
let start = -1;
for (let i = 0; i < text.length; i++) {
if (text[i] === '"') {
if (!inQuote) {
start = i;
inQuote = true;
} else {
regions.push([start, i]);
inQuote = false;
}
}
}
return regions;
}
isInQuotedText(paraIndex, charIndex) {
if (!this.quoteRegions[paraIndex]) return false;
return this.quoteRegions[paraIndex].some(([start, end]) =>
charIndex >= start && charIndex <= end
);
}
isSpecialPhrase(paraIndex, charIndex) {
const phrases = [
'Leo',
'white halls',
'phonetic neutrality',
'digital voice',
'synthesised thread',
'illegal data',
'sensory record',
'administrative machinery'
];
const text = this.paragraphs[paraIndex];
if (!text) return false;
for (const phrase of phrases) {
const idx = text.indexOf(phrase);
if (idx !== -1 && charIndex >= idx && charIndex < idx + phrase.length) {
return true;
}
}
return false;
}
isErrorPhrase(paraIndex, charIndex) {
const phrases = ['no history', 'no written form', 'no longer find', 'no field', 'no value'];
const text = this.paragraphs[paraIndex];
if (!text) return false;
for (const phrase of phrases) {
const idx = text.indexOf(phrase);
if (idx !== -1 && charIndex >= idx && charIndex < idx + phrase.length) {
return true;
}
}
return false;
}
startTyping() {
if (this.paragraphs.length === 0) {
console.error('No paragraphs to type');
return;
}
const p = this.createParagraphElement(0);
this.elements.terminal.appendChild(p);
this.cursorElement = this.createCursorElement();
p.appendChild(this.cursorElement);
this.typingInterval = setInterval(() => this.typeNextBatch(), this.speed);
}
createParagraphElement(index) {
const p = document.createElement('div');
p.className = 'paragraph';
p.id = `paragraph-${index}`;
return p;
}
createCursorElement() {
const cursor = document.createElement('span');
cursor.className = 'cursor';
return cursor;
}
getParagraphElement(index) {
let element = document.getElementById(`paragraph-${index}`);
if (!element) {
element = this.createParagraphElement(index);
this.elements.terminal.appendChild(element);
}
return element;
}
typeNextBatch() {
if (this.isPaused || this.isTransitioning) return;
if (this.state.currentPara >= this.paragraphs.length) {
this.complete();
return;
}
const para = this.paragraphs[this.state.currentPara];
const paraEl = this.getParagraphElement(this.state.currentPara);
if (this.state.currentChar >= para.length) {
this.moveToNextParagraph();
return;
}
if (!paraEl) {
console.error(`Paragraph element not found: paragraph-${this.state.currentPara}`);
this.complete();
return;
}
if (!this.cursorElement) {
this.cursorElement = this.createCursorElement();
paraEl.appendChild(this.cursorElement);
}
const remaining = para.length - this.state.currentChar;
const batchSize = Math.min(this.batchSize, remaining);
const textBatch = para.slice(this.state.currentChar, this.state.currentChar + batchSize);
const fragment = this.createStyledFragment(
this.state.currentPara,
this.state.currentChar,
textBatch
);
paraEl.insertBefore(fragment, this.cursorElement);
this.state.currentChar += batchSize;
this.updateProgress();
this.handleAutoScroll();
}
moveToNextParagraph() {
this.isTransitioning = true;
this.state.currentPara++;
this.state.currentChar = 0;
if (this.state.currentPara >= this.paragraphs.length) {
this.complete();
this.isTransitioning = false;
return;
}
clearInterval(this.typingInterval);
setTimeout(() => {
const p = this.createParagraphElement(this.state.currentPara);
this.elements.terminal.appendChild(p);
if (this.cursorElement) {
this.cursorElement.remove();
}
this.cursorElement = this.createCursorElement();
p.appendChild(this.cursorElement);
this.isTransitioning = false;
this.typingInterval = setInterval(() => this.typeNextBatch(), this.speed);
}, 400);
}
createStyledFragment(paraIndex, startIdx, text) {
try {
const fragment = document.createDocumentFragment();
if (!text || text.length === 0) return fragment;
if (!this.paragraphs[paraIndex]) {
console.error(`Invalid paragraph index: ${paraIndex}`);
return fragment;
}
let currentSpan = null;
let currentClass = null;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const globalIdx = startIdx + i;
const inQuote = this.isInQuotedText(paraIndex, globalIdx);
let charClass = '';
if (inQuote) {
if (this.isSpecialPhrase(paraIndex, globalIdx)) {
charClass = 'highlight';
} else if (this.isErrorPhrase(paraIndex, globalIdx)) {
charClass = 'error';
} else {
charClass = 'emphasis';
}
}
if (!currentSpan || charClass !== currentClass) {
if (currentSpan) {
fragment.appendChild(currentSpan);
}
currentSpan = document.createElement('span');
currentSpan.className = charClass;
currentClass = charClass;
}
currentSpan.textContent += char;
}
if (currentSpan) {
fragment.appendChild(currentSpan);
}
return fragment;
} catch (error) {
console.error('Error creating styled fragment:', error);
const fallback = document.createDocumentFragment();
const span = document.createElement('span');
span.textContent = text;
fallback.appendChild(span);
return fallback;
}
}
handleAutoScroll() {
if (!this.autoScroll || this.userScrolled) return;
const { container } = this.elements;
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
if (distance <= 100) {
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
}
}
updateProgress() {
if (this.paragraphs.length === 0) return;
const totalParagraphs = this.paragraphs.length;
const currentProgress = (this.state.currentPara / totalParagraphs) * 100;
const paragraphProgress = (this.state.currentChar / this.paragraphs[this.state.currentPara]?.length || 1) * (100 / totalParagraphs);
const percent = Math.round(currentProgress + paragraphProgress);
this.elements.progress.textContent = `${Math.min(percent, 100)}%`;
}
complete() {
clearInterval(this.typingInterval);
if (this.cursorElement) {
this.cursorElement.style.animation = 'blink 1.2s infinite';
}
this.elements.progress.textContent = 'Complete';
}
async copyToClipboard() {
try {
await navigator.clipboard.writeText(this.storyText);
this.showCopyNotification();
} catch (err) {
console.error('Clipboard API failed:', err);
this.fallbackCopy();
}
}
fallbackCopy() {
const textarea = document.createElement('textarea');
textarea.value = this.storyText;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
this.showCopyNotification();
} catch (err) {
console.error('Fallback copy failed:', err);
}
document.body.removeChild(textarea);
}
showCopyNotification() {
this.elements.copyNotification.classList.add('show');
setTimeout(() => {
this.elements.copyNotification.classList.remove('show');
}, 2000);
}
announceToScreenReader(msg) {
const announcer = document.createElement('div');
announcer.setAttribute('role', 'status');
announcer.setAttribute('aria-live', 'polite');
announcer.className = 'sr-only';
announcer.textContent = msg;
document.body.appendChild(announcer);
setTimeout(() => announcer.remove(), 1000);
}
bindEvents() {
let scrollTimeout;
this.elements.container.addEventListener('scroll', () => {
this.userScrolled = true;
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const distance = this.elements.container.scrollHeight -
this.elements.container.scrollTop -
this.elements.container.clientHeight;
this.userScrolled = distance > 150;
}, 100);
});
let touchStartY = 0;
this.elements.container.addEventListener('touchstart', (e) => {
touchStartY = e.touches[0].clientY;
});
this.elements.container.addEventListener('touchmove', (e) => {
if (Math.abs(e.touches[0].clientY - touchStartY) > 10) {
this.userScrolled = true;
}
});
document.addEventListener('keydown', async (e) => {
if (e.ctrlKey && e.key === 'c') {
e.preventDefault();
await this.copyToClipboard();
}
if (e.code === 'Space' && !e.ctrlKey && !e.altKey) {
e.preventDefault();
this.init();
this.announceToScreenReader('Story restarted');
}
});
document.getElementById('restart-sr').addEventListener('click', () => {
this.init();
});
}
destroy() {
clearInterval(this.typingInterval);
clearTimeout(this.scrollTimeout);
this.elements = null;
}
}
window.addEventListener('load', () => {
window.terminalAnimator = new TerminalAnimator({
speed: 60,
batchSize: 2,
autoScroll: true
});
});
window.addEventListener('beforeunload', () => {
if (window.terminalAnimator) {
window.terminalAnimator.destroy();
}
});
</script>
</body>
</html>