Custom Code Experiment •
Fragments of Finn
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>Fragments of Finn</title>
<style>
:root {
--bg-primary: #0a0a0a;
--bg-secondary: #32446D;
--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>
<div id="progress" role="status">Loading...</div>
<div id="copy-notification">Text copied to clipboard! Press Ctrl+C to copy</div>
<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 = `Finn didn't return to the archive by choice. The summons on his wristband read: "Mandatory Archive Sync. Your echoes require optimisation." He ignored it, but the pings kept coming, sharper each time. By the fifth, the message changed: "Noncompliance will result in permanent silence."
Now he sat in a Sync booth, wires pressed to his temples, the walls humming faintly with trapped voices. His voices. The attendant, a glossy avatar, materialised. "Mr. Finn," it said, too cheerful. "This will be quick."
"What are you doing to me?" Finn asked. He hated how flat his words sounded: stripped of warmth, already machine-like.
"Refinement," the avatar replied. "Your voiceprint is overcrowded. Fragments are competing for value. We're consolidating."
Screens flickered on, showing rows of phrases Finn had once said: "It's not my fault," "I'm sorry," "What do you mean?" Familiar words, drained of context. One fragment blinked red: "Let me go."
"What's that?" Finn asked.
The avatar's smile widened. "A high-value phrase. Frequently downloaded."
"I never said that."
"You don't need to remember. EchoLogix preserves all utterances for maximum usability."
Finn's stomach tightened. "Stop," he said. "I don't want this."
The hum grew louder. "I'm afraid your compliance is required," the avatar replied. Wires tightened around his temples. Finn tried to scream, but his voice dissolved into static, siphoned into the system. On the screens, his fragments began collapsing, merging into one looping phrase: "Let me go. Let me go. Let me go."
The booth went silent, but Finn could still hear himself somewhere, echoing endlessly without him.`;
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() {
// Split into paragraphs and filter out empty ones
this.paragraphs = this.storyText.split('\n\n').map(p => p.trim()).filter(p => p.length > 0);
// Pre-compute quote regions for each paragraph
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 = [
'Let me go',
'Mandatory Archive Sync',
'Your echoes require optimisation',
'Noncompliance will result in permanent silence',
'high-value phrase',
'Frequently downloaded',
'Let me go. Let me go. Let me go.'
];
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 = ['Stop', "I don't want this"];
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>