• 01 - Critter Models 02
  • 02 - VantaJS Shaders
  • 03 - App Bundle 02
  • 04 - Cathedrals
  • 05 - App Bundle 01
  • 06 - Retroscopic
  • 07 - Games
  • 08 - Voronoi
  • 09 - Terrain
  • 10 - Critter Models 01
  • 11 - Chromatophores
  • 12 - Conformal Maps
  • 13 - Echo Logix
  • 14 - Music
  • 15 - Colour Theory
MIKE'S NOTEBOOK
Notes
Number:
Project:
01Critter Models 02
└ Locust└ Butterfly└ Cockroach└ Spider└ Scorpion└ Dragon└ Brazilian Treehopper└ Tardigrade└ Diatoms└ Bumblebee└ Wasp└ Hammerhead Worm└ Giraffe Weevil└ Trilobite Beetle
02VantaJS Shaders
└ Clouds└ Waves└ Cells└ Rings└ Halo└ Globe└ Net└ Dots└ Birds└ Trunk└ Topology└ About VantaJS
03App Bundle 02
└ Gutindex└ Insect Builder└ OpenLayers
04Cathedrals
└ Lattice└ Lost└ Lucent└ Lumen└ Glass└ Disco└ Hyperion└ Latent
05App Bundle 01
└ Robohash└ PrismJS└ Horoscope└ Binary Jazz└ Morse Code└ URL Extractor└ Entity Escaper└ Phonetic Alphabet└ Toki Pona└ Semaphore└ Cut Up Machine└ Text Mixer└ Currency Freaks└ Host IO└ CrUX API└ Logo Dev└ Web Speech API└ GEO DB
06Retroscopic
└ Galaxy└ Ocean└ Slime└ 24-Cell└ Unicorn└ Polyp└ Oneiric 1└ Oneiric 2└ Node└ Torus Knot└ GLSL Grid└ Liquid Crystal└ Silky└ Butterfly└ Ocean 2└ Icosahedron└ Luma└ Swarm└ Broken Solid└ Hypersphere└ Constraints└ Psycho Killer
01 / 03

TEXT MIXER - INTERACTIVE APP SOURCE CODE

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Text Mixer — character n‑gram · Markov chain · cento</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=DM+Mono:ital,wght@0,300;0,400;1,300&display=swap" rel="stylesheet">
    <style>
        :root {
            color-scheme: light dark;
            --color-text-primary: #e8e6e3;
            --color-text-secondary: #b0ada8;
            --color-text-tertiary: #7a7672;
            --color-background-primary: #121212;
            --color-background-secondary: #1e1e1e;
            --color-border-primary: #e8e6e3;
            --color-border-secondary: #3a3a3a;
            --color-border-tertiary: #2a2a2a;
            --color-border-danger: #e05555;
            --border-radius-md: 8px;
        }
        
        html:not(.dark) {
            --color-text-primary: #121212;
            --color-text-secondary: #4a4a4a;
            --color-text-tertiary: #7a7672;
            --color-background-primary: #f5f5f5;
            --color-background-secondary: #ffffff;
            --color-border-primary: #121212;
            --color-border-secondary: #cccccc;
            --color-border-tertiary: #e0e0e0;
        }

        *,
        *::before,
        *::after {
            box-sizing: border-box;
        }

        html,
        body {
            margin: 0;
            padding: 0;
            background: transparent;
            min-height: 100vh;
        }

        .sr-only {
            position: absolute;
            width: 1px;
            height: 1px;
            padding: 0;
            margin: -1px;
            overflow: hidden;
            clip: rect(0, 0, 0, 0);
            white-space: nowrap;
            border: 0;
        }

        .mixer-wrap {
            font-family: 'DM Mono', monospace;
            width: 100%;
        }

        .mixer-title {
            font-family: 'Special Elite', cursive;
            font-size: 28px;
            letter-spacing: 0.04em;
            color: var(--color-text-primary);
            margin: 0 0 0.25rem;
        }

        .mixer-sub {
            font-size: 12px;
            color: var(--color-text-tertiary);
            letter-spacing: 0.1em;
            text-transform: uppercase;
            margin: 0 0 2rem;
        }

        .mixer-label {
            font-size: 11px;
            letter-spacing: 0.12em;
            text-transform: uppercase;
            color: var(--color-text-tertiary);
            margin-bottom: 6px;
        }

        textarea {
            width: 100%;
            box-sizing: border-box;
            min-height: 140px;
            font-family: 'DM Mono', monospace;
            font-size: 14px;
            line-height: 1.6;
            padding: 12px 14px;
            border: 0.5px solid var(--color-border-secondary);
            border-radius: var(--border-radius-md);
            background: var(--color-background-secondary);
            color: var(--color-text-primary);
            resize: vertical;
            outline: none;
            transition: border-color 0.15s;
        }

        textarea:focus {
            border-color: var(--color-border-primary);
        }

        textarea::placeholder {
            color: var(--color-text-tertiary);
        }

        .controls {
            display: flex;
            flex-wrap: wrap;
            gap: 12px;
            align-items: center;
            margin: 1rem 0;
        }

        .mode-group {
            display: flex;
            gap: 6px;
            flex-wrap: wrap;
        }

        .mode-btn {
            font-family: 'DM Mono', monospace;
            font-size: 11px;
            letter-spacing: 0.08em;
            text-transform: uppercase;
            padding: 5px 12px;
            border: 0.5px solid var(--color-border-secondary);
            border-radius: 99px;
            background: transparent;
            color: var(--color-text-secondary);
            cursor: pointer;
            transition: all 0.15s;
        }

        .mode-btn:hover {
            background: var(--color-background-secondary);
            color: var(--color-text-primary);
        }

        .mode-btn.active {
            background: var(--color-text-primary);
            color: var(--color-background-primary);
            border-color: var(--color-text-primary);
        }

        .mix-btn {
            font-family: 'Special Elite', cursive;
            font-size: 15px;
            letter-spacing: 0.06em;
            padding: 8px 28px;
            background: var(--color-text-primary);
            color: var(--color-background-primary);
            border: none;
            border-radius: var(--border-radius-md);
            cursor: pointer;
            transition: opacity 0.15s, transform 0.1s;
            margin-left: auto;
        }

        .mix-btn:hover {
            opacity: 0.85;
        }

        .mix-btn:active {
            transform: scale(0.97);
        }

        .param-row {
            display: flex;
            flex-wrap: wrap;
            gap: 16px;
            align-items: center;
            margin-top: 8px;
        }

        .param-group {
            display: flex;
            align-items: center;
            gap: 8px;
            font-size: 11px;
            letter-spacing: 0.08em;
            text-transform: uppercase;
            color: var(--color-text-tertiary);
        }

        .param-group input[type=range] {
            width: 70px;
            accent-color: var(--color-text-primary);
        }

        .param-group input[type=number] {
            width: 50px;
            background: var(--color-background-secondary);
            border: 0.5px solid var(--color-border-secondary);
            border-radius: 4px;
            color: var(--color-text-primary);
            font-family: 'DM Mono', monospace;
            font-size: 12px;
            padding: 2px 6px;
            text-align: center;
            outline: none;
        }

        .param-group input[type=number]:focus {
            border-color: var(--color-border-primary);
        }

        .param-val {
            font-weight: 500;
            color: var(--color-text-secondary);
            min-width: 24px;
        }

        .output-area {
            position: relative;
            margin-top: 1.5rem;
            min-height: 60px;
        }

        .output-box {
            font-family: 'Special Elite', cursive;
            font-size: 15px;
            line-height: 1.85;
            letter-spacing: 0.02em;
            padding: 18px 20px;
            border: 0.5px solid var(--color-border-tertiary);
            border-radius: var(--border-radius-md);
            background: var(--color-background-secondary);
            color: var(--color-text-primary);
            white-space: pre-wrap;
            word-break: break-word;
            min-height: 80px;
            transition: opacity 0.25s;
        }

        .output-box.empty {
            color: var(--color-text-tertiary);
            font-family: 'DM Mono', monospace;
            font-size: 13px;
            font-style: italic;
        }

        .output-actions {
            display: flex;
            gap: 8px;
            margin-top: 10px;
            justify-content: flex-end;
        }

        .action-btn {
            font-family: 'DM Mono', monospace;
            font-size: 11px;
            letter-spacing: 0.08em;
            text-transform: uppercase;
            padding: 5px 14px;
            border: 0.5px solid var(--color-border-secondary);
            border-radius: 99px;
            background: transparent;
            color: var(--color-text-secondary);
            cursor: pointer;
            transition: all 0.15s;
            display: flex;
            align-items: center;
            gap: 5px;
        }

        .action-btn:hover {
            background: var(--color-background-secondary);
            color: var(--color-text-primary);
        }

        .divider {
            border: none;
            border-top: 0.5px solid var(--color-border-tertiary);
            margin: 1.5rem 0;
        }

        .history-row {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 8px;
        }

        .history-label {
            font-size: 11px;
            letter-spacing: 0.1em;
            text-transform: uppercase;
            color: var(--color-text-tertiary);
        }

        .history-clear {
            font-size: 11px;
            color: var(--color-text-tertiary);
            cursor: pointer;
            background: none;
            border: none;
            font-family: 'DM Mono', monospace;
            letter-spacing: 0.06em;
            padding: 0;
        }

        .history-clear:hover {
            color: var(--color-text-secondary);
        }

        .history-item {
            font-family: 'Special Elite', cursive;
            font-size: 13px;
            color: var(--color-text-secondary);
            line-height: 1.6;
            padding: 8px 12px;
            border-left: 2px solid var(--color-border-tertiary);
            margin-bottom: 8px;
            cursor: pointer;
            transition: border-color 0.15s, color 0.15s;
        }

        .history-item:hover {
            border-color: var(--color-border-primary);
            color: var(--color-text-primary);
        }

        .advanced-section {
            display: none;
            margin-top: 12px;
        }

        .advanced-section.visible {
            display: block;
        }

        html.dark {
            scrollbar-color: #b0ada8 #1a1a1a;
            scrollbar-width: thin;
        }

        html.dark ::-webkit-scrollbar {
            width: 8px;
            height: 8px;
        }

        html.dark ::-webkit-scrollbar-track {
            background: #1a1a1a;
            border-radius: 4px;
        }

        html.dark ::-webkit-scrollbar-thumb {
            background: #b0ada8;
            border-radius: 4px;
        }

        html.dark ::-webkit-scrollbar-thumb:hover {
            background: #e8e6e3;
        }

        html.dark ::-webkit-scrollbar-corner {
            background: #1a1a1a;
        }

        .toggle-options {
            font-family: 'DM Mono', monospace;
            font-size: 11px;
            letter-spacing: 0.08em;
            text-transform: uppercase;
            color: var(--color-text-tertiary);
            background: none;
            border: none;
            cursor: pointer;
            padding: 0;
            text-decoration: underline;
            text-underline-offset: 4px;
            margin-left: 8px;
        }

        .toggle-options:hover {
            color: var(--color-text-secondary);
        }
    </style>
</head>
<body>
    <div class="mixer-wrap">

        <p class="mixer-sub">Character n‑gram · Markov chain · Cento · line & word shuffle</p>

        <div class="mixer-label">Your text</div>
        <textarea id="inputText" placeholder="Paste or type any text here — a paragraph, a poem, a letter, anything…"></textarea>

        <div class="controls">
            <div>
                <div class="mixer-label" style="margin-bottom:4px;">Algorithm</div>
                <div class="mode-group">
                    <button class="mode-btn active" data-mode="char-ngram">Char N‑gram</button>
                    <button class="mode-btn" data-mode="markov">Markov chain</button>
                    <button class="mode-btn" data-mode="cento">Cento</button>
                    <button class="mode-btn" data-mode="word-shuffle">Word shuffle</button>
                    <button class="mode-btn" data-mode="line-shuffle">Line shuffle</button>
                </div>
            </div>
            <button class="toggle-options" id="toggleOptions" onclick="toggleAdvanced()">Options ▾</button>
            <button class="mix-btn" id="mixBtn" onclick="doMix()">Mix it up ⚗</button>
        </div>

        <div class="advanced-section" id="advancedSection">
            <div class="param-row">
                <div class="param-group" id="ngramGroup">
                    <span>N‑gram size</span>
                    <input type="range" id="ngramSize" min="2" max="12" value="4" step="1">
                    <span class="param-val" id="ngramSizeVal">4</span>
                </div>
                <div class="param-group" id="outputLenGroup">
                    <span>Output length</span>
                    <input type="range" id="outputLen" min="50" max="2000" value="400" step="10">
                    <span class="param-val" id="outputLenVal">400</span>
                </div>
                <div class="param-group" id="randomnessGroup">
                    <span>Randomness</span>
                    <input type="range" id="randomness" min="0" max="100" value="60" step="1">
                    <span class="param-val" id="randomnessVal">60</span>
                </div>
            </div>
        </div>

        <div class="output-area">
            <div class="mixer-label">Result</div>
            <div class="output-box empty" id="outputBox">Your mixed text will appear here…</div>
            <div class="output-actions" id="outputActions" style="display:none;">
                <button class="action-btn" onclick="copyOutput()"><i class="ti ti-copy" aria-hidden="true"></i> Copy</button>
                <button class="action-btn" onclick="doMix()"><i class="ti ti-refresh" aria-hidden="true"></i> Re‑mix</button>
                <button class="action-btn" onclick="useAsInput()"><i class="ti ti-arrow-down" aria-hidden="true"></i> Use as input</button>
            </div>
        </div>

        <div id="historySection" style="display:none;">
            <hr class="divider">
            <div class="history-row">
                <span class="history-label">History</span>
                <button class="history-clear" onclick="clearHistory()">Clear</button>
            </div>
            <div id="historyList"></div>
        </div>
    </div>

    <script>
        (function() {
            function syncTheme() {
                try {
                    if (window.parent && window.parent.document) {
                        const isDark = window.parent.document.documentElement.classList.contains('dark');
                        if (isDark) {
                            document.documentElement.classList.add('dark');
                        } else {
                            document.documentElement.classList.remove('dark');
                        }
                    }
                } catch (e) {}
            }
            syncTheme();
            try {
                if (window.parent && window.parent.document) {
                    const observer = new MutationObserver(syncTheme);
                    observer.observe(window.parent.document.documentElement, { attributes: true, attributeFilter: ['class'] });
                }
            } catch (e) {}
        })();
        
        let mode = 'char-ngram';
        let history = [];

        document.querySelectorAll('.mode-btn').forEach(b => {
            b.addEventListener('click', () => {
                document.querySelectorAll('.mode-btn').forEach(x => x.classList.remove('active'));
                b.classList.add('active');
                mode = b.dataset.mode;
                updateAdvancedVisibility();
            });
        });

        document.getElementById('ngramSize').addEventListener('input', function() {
            document.getElementById('ngramSizeVal').textContent = this.value;
        });
        document.getElementById('outputLen').addEventListener('input', function() {
            document.getElementById('outputLenVal').textContent = this.value;
        });
        document.getElementById('randomness').addEventListener('input', function() {
            document.getElementById('randomnessVal').textContent = this.value;
        });

        function updateAdvancedVisibility() {
            const ngramGroup = document.getElementById('ngramGroup');
            const outputLenGroup = document.getElementById('outputLenGroup');
            const randomnessGroup = document.getElementById('randomnessGroup');
            if (mode === 'char-ngram' || mode === 'markov') {
                ngramGroup.style.display = 'flex';
                outputLenGroup.style.display = 'flex';
                randomnessGroup.style.display = 'flex';
            } else if (mode === 'cento' || mode === 'word-shuffle' || mode === 'line-shuffle') {
                ngramGroup.style.display = 'none';
                outputLenGroup.style.display = 'none';
                randomnessGroup.style.display = 'flex';
            }
        }

        function toggleAdvanced() {
            const section = document.getElementById('advancedSection');
            const btn = document.getElementById('toggleOptions');
            section.classList.toggle('visible');
            btn.textContent = section.classList.contains('visible') ? 'Options ▴' : 'Options ▾';
            updateAdvancedVisibility();
        }

        function getWords(text) {
            return text.split(/\s+/).filter(w => w.length > 0);
        }

        function getLines(text) {
            return text.split(/\n+/).map(s => s.trim()).filter(s => s.length > 0);
        }

        function getSentences(text) {
            return text.split(/[.!?]+/).map(s => s.trim()).filter(s => s.length > 0);
        }

        function charNgram(text) {
            const n = parseInt(document.getElementById('ngramSize').value);
            const targetLen = parseInt(document.getElementById('outputLen').value);
            const randomness = parseInt(document.getElementById('randomness').value) / 100;
            if (text.length < n) return text;

            const map = {};
            for (let i = 0; i <= text.length - n; i++) {
                const key = text.substring(i, i + n);
                const nextChar = i + n < text.length ? text[i + n] : '';
                if (!map[key]) map[key] = [];
                if (nextChar) map[key].push(nextChar);
            }

            const keys = Object.keys(map);
            if (keys.length === 0) return text;

            let seed = keys[Math.floor(Math.random() * keys.length)];
            let result = seed;
            while (result.length < targetLen) {
                const lastN = result.substring(result.length - n);
                const candidates = map[lastN];
                if (!candidates || candidates.length === 0) {
                    const randomKey = keys[Math.floor(Math.random() * keys.length)];
                    result += randomKey;
                    continue;
                }
                if (Math.random() < randomness) {
                    result += candidates[Math.floor(Math.random() * candidates.length)];
                } else {
                    const mostCommon = candidates.sort((a, b) =>
                        candidates.filter(v => v === a).length - candidates.filter(v => v === b).length
                    ).pop();
                    result += mostCommon;
                }
            }
            return result;
        }

        function markovChain(text) {
            const n = parseInt(document.getElementById('ngramSize').value);
            const targetLen = parseInt(document.getElementById('outputLen').value);
            const randomness = parseInt(document.getElementById('randomness').value) / 100;
            const words = getWords(text);
            if (words.length < n) return words.join(' ');

            const map = {};
            for (let i = 0; i <= words.length - n; i++) {
                const key = words.slice(i, i + n).join(' ');
                const nextWord = i + n < words.length ? words[i + n] : '';
                if (!map[key]) map[key] = [];
                if (nextWord) map[key].push(nextWord);
            }

            const keys = Object.keys(map);
            if (keys.length === 0) return words.join(' ');

            let seed = keys[Math.floor(Math.random() * keys.length)];
            let resultWords = seed.split(' ');
            while (resultWords.length < targetLen / 5) {
                const lastN = resultWords.slice(-n).join(' ');
                const candidates = map[lastN];
                if (!candidates || candidates.length === 0) {
                    const randomKey = keys[Math.floor(Math.random() * keys.length)];
                    resultWords = resultWords.concat(randomKey.split(' '));
                    continue;
                }
                if (Math.random() < randomness) {
                    resultWords.push(candidates[Math.floor(Math.random() * candidates.length)]);
                } else {
                    const mostCommon = candidates.sort((a, b) =>
                        candidates.filter(v => v === a).length - candidates.filter(v => v === b).length
                    ).pop();
                    resultWords.push(mostCommon);
                }
            }
            return resultWords.join(' ');
        }

        function cento(text) {
            const randomness = parseInt(document.getElementById('randomness').value) / 100;
            const lines = getLines(text);
            if (lines.length === 0) return text;
            let shuffled = lines.slice();
            let swaps = Math.floor(randomness * lines.length * 2);
            for (let i = 0; i < swaps; i++) {
                let x = Math.floor(Math.random() * shuffled.length);
                let y = Math.floor(Math.random() * shuffled.length);
                [shuffled[x], shuffled[y]] = [shuffled[y], shuffled[x]];
            }
            return shuffled.join('\n');
        }

        function wordShuffle(text) {
            const randomness = parseInt(document.getElementById('randomness').value) / 100;
            const words = getWords(text);
            if (words.length === 0) return text;
            let shuffled = words.slice();
            let swaps = Math.floor(randomness * words.length * 2);
            for (let i = 0; i < swaps; i++) {
                let x = Math.floor(Math.random() * shuffled.length);
                let y = Math.floor(Math.random() * shuffled.length);
                [shuffled[x], shuffled[y]] = [shuffled[y], shuffled[x]];
            }
            let result = [];
            let lineLen = 4 + Math.floor(Math.random() * 6);
            for (let i = 0; i < shuffled.length; i += lineLen) {
                result.push(shuffled.slice(i, i + lineLen).join(' '));
                lineLen = 4 + Math.floor(Math.random() * 6);
            }
            return result.join('\n');
        }

        function lineShuffle(text) {
            const randomness = parseInt(document.getElementById('randomness').value) / 100;
            const lines = getLines(text);
            if (lines.length === 0) return text;
            let shuffled = lines.slice();
            let swaps = Math.floor(randomness * lines.length * 2);
            for (let i = 0; i < swaps; i++) {
                let x = Math.floor(Math.random() * shuffled.length);
                let y = Math.floor(Math.random() * shuffled.length);
                [shuffled[x], shuffled[y]] = [shuffled[y], shuffled[x]];
            }
            return shuffled.join('\n');
        }

        function doMix() {
            const text = document.getElementById('inputText').value.trim();
            if (!text) {
                flashEmpty();
                return;
            }

            let result = '';
            switch (mode) {
                case 'char-ngram':
                    result = charNgram(text);
                    break;
                case 'markov':
                    result = markovChain(text);
                    break;
                case 'cento':
                    result = cento(text);
                    break;
                case 'word-shuffle':
                    result = wordShuffle(text);
                    break;
                case 'line-shuffle':
                    result = lineShuffle(text);
                    break;
                default:
                    result = text;
            }

            animateMixer();
            showResult(result);
            addToHistory(result);
        }

        function animateMixer() {
            const btn = document.getElementById('mixBtn');
            btn.style.transform = 'scale(0.95)';
            setTimeout(() => { btn.style.transform = ''; }, 200);
        }

        function showResult(text) {
            const box = document.getElementById('outputBox');
            box.style.opacity = '0';
            setTimeout(() => {
                box.textContent = text;
                box.classList.remove('empty');
                box.style.opacity = '1';
                document.getElementById('outputActions').style.display = 'flex';
            }, 180);
        }

        function flashEmpty() {
            const ta = document.getElementById('inputText');
            ta.style.borderColor = 'var(--color-border-danger)';
            setTimeout(() => ta.style.borderColor = '', 600);
        }

        function copyOutput() {
            const text = document.getElementById('outputBox').textContent;
            if (navigator.clipboard) navigator.clipboard.writeText(text);
        }

        function useAsInput() {
            const text = document.getElementById('outputBox').textContent;
            document.getElementById('inputText').value = text;
        }

        function addToHistory(text) {
            history.unshift(text.slice(0, 120) + (text.length > 120 ? '…' : ''));
            if (history.length > 5) history.pop();
            renderHistory();
        }

        function renderHistory() {
            const section = document.getElementById('historySection');
            const list = document.getElementById('historyList');
            if (history.length === 0) {
                section.style.display = 'none';
                return;
            }
            section.style.display = '';
            list.innerHTML = '';
            history.forEach((h, i) => {
                const div = document.createElement('div');
                div.className = 'history-item';
                div.textContent = h;
                div.title = 'Click to restore';
                div.addEventListener('click', () => {
                    document.getElementById('outputBox').textContent = history[i];
                    document.getElementById('outputBox').classList.remove('empty');
                    document.getElementById('outputActions').style.display = 'flex';
                });
                list.appendChild(div);
            });
        }

        function clearHistory() {
            history = [];
            renderHistory();
        }

        updateAdvancedVisibility();
    </script>
    <script>
        (function syncTheme() {
            try {
                if (window.parent && window.parent !== window) {
                    const parentHtml = window.parent.document.documentElement;
                    const localHtml = document.documentElement;
                    
                    const updateTheme = () => {
                        if (parentHtml.classList.contains('dark')) {
                            localHtml.classList.add('dark');
                        } else {
                            localHtml.classList.remove('dark');
                        }
                    };
                    
                    updateTheme();
                    
                    const observer = new MutationObserver((mutations) => {
                        mutations.forEach((mutation) => {
                            if (mutation.attributeName === 'class') {
                                updateTheme();
                            }
                        });
                    });
                    observer.observe(parentHtml, { attributes: true });
                    return;
                }
            } catch (e) {
                console.warn('Could not sync theme with parent window.', e);
            }
            
            if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
                document.documentElement.classList.add('dark');
            }
        })();
    </script>
</body>
</html>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.