← Mike Higgins Home
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>