PageSourceSearch

https://sakizli.ai/

html sakizli.ai collected 2026-10-05 15:15:34 UTC 47,563 bytes, 965 lines download raw bytes

1<!doctype html>
2<html lang="en">
3<head>
4  <meta charset="utf-8">
5  <title>SAKIZLI AI — Applied-AI Learning &amp; Research</title>
6  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">
7  <!-- Preload the 4 fonts Lighthouse flagged on the critical rendering path
8       (only discovered otherwise once the CSS parses, adding a round trip). -->
9  <link rel="preload" href="/fonts/plexmono-regular.woff2" as="font" type="font/woff2" crossorigin>
10  <link rel="preload" href="/fonts/plexmono-500.woff2" as="font" type="font/woff2" crossorigin>
11  <link rel="preload" href="/fonts/fraunces-300.woff2" as="font" type="font/woff2" crossorigin>
12  <link rel="preload" href="/fonts/fraunces-300italic.woff2" as="font" type="font/woff2" crossorigin>
13  <!-- set theme before first paint (light is default) — prevents a dark flash -->
14  
14<script>(function(){try{document.documentElement.classList.toggle('light',localStorage.getItem('skzl_theme')!=='dark');}catch(e){document.documentElement.classList.add('light');}})();</script>
14
15  <meta name="description" content="Learn applied AI, hands-on — a trilingual journal, courses and research notes on prompt &amp; context engineering, plus an interactive neural-network studio.">
16
17  <!-- Open Graph / social sharing (crawlers read these, not the visible page) -->
18  <link rel="canonical" href="https://sakizli.ai/">
19  <meta property="og:type" content="website">
20  <meta property="og:site_name" content="SAKIZLI AI">
21  <meta property="og:title" content="SAKIZLI AI — Applied-AI Learning &amp; Research">
22  <meta property="og:description" content="Learn applied AI, hands-on — a trilingual journal, courses and research notes on prompt &amp; context engineering, plus an interactive neural-network studio.">
23  <meta property="og:url" content="https://sakizli.ai/">
24  <meta property="og:image" content="https://sakizli.ai/og-image.png">
25  <meta property="og:image:width" content="1200">
26  <meta property="og:image:height" content="630">
27  <meta property="og:image:alt" content="SAKIZLI AI">
28  <meta property="og:locale" content="en_US">
29  <meta name="twitter:card" content="summary_large_image">
30  <meta name="twitter:title" content="SAKIZLI AI — Applied-AI Learning &amp; Research">
31  <meta name="twitter:description" content="Learn applied AI — a trilingual journal, courses &amp; research on prompt and context engineering.">
32  <meta name="twitter:image" content="https://sakizli.ai/og-image.png">
33  <style>
34    /* self-hosted fonts (no Google CDN — DSGVO) */
35    @font-face { font-family:"Fraunces"; font-style:normal; font-weight:300; font-display:swap; src:url("/fonts/fraunces-300.woff2") format("woff2"); }
36    @font-face { font-family:"Fraunces"; font-style:italic; font-weight:300; font-display:swap; src:url("/fonts/fraunces-300italic.woff2") format("woff2"); }
37    @font-face { font-family:"Fraunces"; font-style:normal; font-weight:400; font-display:swap; src:url("/fonts/fraunces-regular.woff2") format("woff2"); }
38    @font-face { font-family:"Fraunces"; font-style:italic; font-weight:400; font-display:swap; src:url("/fonts/fraunces-italic.woff2") format("woff2"); }
39    @font-face { font-family:"IBM Plex Mono"; font-style:normal; font-weight:400; font-display:swap; src:url("/fonts/plexmono-regular.woff2") format("woff2"); }
40    @font-face { font-family:"IBM Plex Mono"; font-style:normal; font-weight:500; font-display:swap; src:url("/fonts/plexmono-500.woff2") format("woff2"); }
41
42    :root {
43      --ink: #e8ecf4;
44      --dim: #8b94a8;
45      --blue: #4f7cff;
46      --bg: #06080f;
47    }
48    html, body { margin:0; padding:0; height:100%; background:var(--bg); color:var(--ink); overflow:hidden; }
49    body { font-family:"IBM Plex Mono", monospace; }
50    a { color:var(--blue); text-decoration:none; }
51    a:hover { color:#8fadff; }
52
53    /* ── mystic background: two slow-drifting blue glows ── */
54    .glow { position:fixed; inset:0; z-index:0; pointer-events:none; }
55    .glow::before, .glow::after {
56      content:""; position:absolute; width:110vmax; height:110vmax; border-radius:50%;
57      filter:blur(60px); opacity:0.5;
58    }
59    .glow::before {
60      background:radial-gradient(circle at center, rgba(38,64,140,0.22) 0%, rgba(20,32,72,0.10) 32%, transparent 62%);
61      left:-30vmax; top:-40vmax;
62    }
63    .glow::after {
64      background:radial-gradient(circle at center, rgba(52,86,190,0.14) 0%, rgba(16,26,60,0.07) 36%, transparent 64%);
65      right:-38vmax; bottom:-44vmax;
66    }
67    @media (prefers-reduced-motion: no-preference) {
68      .glow::before { animation:driftA 26s cubic-bezier(0.16,1,0.3,1) infinite alternate, breathe 9s ease-in-out infinite; }
69      .glow::after  { animation:driftB 34s cubic-bezier(0.16,1,0.3,1) infinite alternate, breathe 12s ease-in-out infinite -4s; }
70    }
71    @keyframes driftA { from { transform:translate(0,0); } to { transform:translate(9vmax,6vmax); } }
72    @keyframes driftB { from { transform:translate(0,0); } to { transform:translate(-8vmax,-5vmax); } }
73    @keyframes breathe { 0%,100% { opacity:0.38; } 50% { opacity:0.6; } }
74
75    #stage { position:fixed; inset:0; z-index:1; cursor:grab; }
76    #stage:active { cursor:grabbing; }
77    #stage canvas { display:block; }
78
79    /* ── chrome ── */
80    .top {
81      position:fixed; top:0; left:0; right:0; z-index:3;
82      display:flex; justify-content:space-between; align-items:center;
83      padding:26px 38px; pointer-events:none;
84    }
85    .wordmark { pointer-events:auto; font-family:"Fraunces", Times, serif; font-weight:300; font-size:21px; letter-spacing:0.14em; color:var(--ink); }
86    .wordmark .ai { color:var(--blue); font-style:italic; }
87    .top .status { font-size:10px; letter-spacing:0.26em; text-transform:uppercase; color:var(--dim); display:flex; align-items:center; gap:10px; }
88    .top .status .dot { width:5px; height:5px; border-radius:50%; background:var(--blue); box-shadow:0 0 8px var(--blue); }
89    @media (prefers-reduced-motion: no-preference) { .top .status .dot { animation:breathe 4s ease-in-out infinite; } }
90
91    /* ── conversation surface: diffuse, network stays visible behind ── */
92    .io {
93      position:fixed; left:50%; bottom:44px; transform:translateX(-50%);
94      z-index:3; width:min(680px, calc(100vw - 48px));
95      display:flex; flex-direction:column; gap:16px;
96    }
97    .answer {
98      max-height:118px; overflow-y:auto;
99      font-size:13.5px; line-height:1.75; color:var(--ink);
100      background:rgba(6,10,20,0.22);
100 backdrop-filter:blur(7px); -webkit-backdrop-filter:blur(7px);
101      border:1px solid rgba(79,124,255,0.12); border-radius:2px;
102      padding:20px 24px; white-space:pre-wrap; word-break:break-word;
103      text-shadow:0 1px 8px rgba(4,6,12,0.9);
104      opacity:0; transform:translateY(8px); transition:opacity 700ms cubic-bezier(0.16,1,0.3,1), transform 700ms cubic-bezier(0.16,1,0.3,1);
105      display:none; scrollbar-width:thin; scrollbar-color:rgba(79,124,255,0.3) transparent;
106    }
107    .answer.on { display:block; opacity:1; transform:none; }
108    .answer .q { color:var(--dim); font-size:11px; letter-spacing:0.18em; text-transform:uppercase; display:block; margin-bottom:10px; }
109
110    form.bar {
111      display:flex; gap:0; align-items:stretch;
112      background:rgba(6,10,20,0.26); backdrop-filter:blur(7px); -webkit-backdrop-filter:blur(7px);
113      border:1px solid rgba(79,124,255,0.18); border-radius:2px;
114      transition:border-color 300ms ease, box-shadow 300ms ease, background 300ms ease;
115    }
116    form.bar:focus-within { border-color:rgba(79,124,255,0.5); box-shadow:0 0 32px rgba(79,124,255,0.1); background:rgba(6,10,20,0.34); }
117    form.bar input {
118      flex:1; background:none; border:none; outline:none; color:var(--ink);
119      font-family:"IBM Plex Mono", monospace; font-size:14px; padding:18px 20px;
120      text-shadow:0 1px 6px rgba(4,6,12,0.9);
121    }
122    form.bar input::placeholder { color:#6a7390; }
123    form.bar button {
124      font-family:"IBM Plex Mono", monospace; font-size:11px; letter-spacing:0.2em; text-transform:uppercase;
125      background:none; border:none; border-left:1px solid rgba(79,124,255,0.18);
126      color:var(--blue); padding:0 26px; cursor:pointer; transition:background 200ms ease, color 200ms ease;
127    }
128    form.bar button:hover:enabled { background:rgba(79,124,255,0.12); color:#a9c0ff; }
129    form.bar button:disabled { color:#3a4258; cursor:default; }
130    .hint { text-align:center; font-size:10px; letter-spacing:0.22em; text-transform:uppercase; color:#4a5268; }
131    .hint b { color:var(--dim); font-weight:400; }
132    .hint.ai-note { letter-spacing:0.14em; line-height:1.7; margin-top:2px; }
133    .hint.ai-note a { color:var(--dim); text-decoration:underline; text-underline-offset:3px; }
134    .hint.ai-note a:hover { color:var(--blue); }
135
136    /* controls hint — fixed at top, directly under the menu */
137    .controls-hint {
138      position:fixed; top:76px; left:0; right:0; z-index:3; text-align:center;
139      pointer-events:none;
140    }
141    @media (max-width: 760px) { .controls-hint { top:112px; } }
142
143    /* inline "hide" button, sits next to Send inside the chat bar */
144    form.bar button.bar-hide {
145      border-left:1px solid rgba(79,124,255,0.18);
146      padding:0 18px; color:var(--dim);
147    }
148    form.bar button.bar-hide:hover:enabled { background:rgba(79,124,255,0.08); color:var(--blue); }
149
150    ::selection { background:rgba(79,124,255,0.35); }
151
152    /* ── light mode ── */
153    html.light { --ink:#1a2334; --dim:#5a6478; --bg:#eef1f7; }
154    html.light .glow::before { background:radial-gradient(circle at center, rgba(79,124,255,0.14) 0%, rgba(79,124,255,0.06) 32%, transparent 62%); }
155    html.light .glow::after  { background:radial-gradient(circle at center, rgba(52,86,190,0.10) 0%, rgba(52,86,190,0.04) 36%, transparent 64%); }
156    html.light .answer, html.light form.bar { background:rgba(255,255,255,0.5); border-color:rgba(36,83,230,0.2); }
157    html.light .answer, html.light form.bar input { text-shadow:none; }
158    html.light form.bar input::placeholder { color:#8a93a8; }
159    html.light form.bar button:disabled { color:#aab2c4; }
160    html.light .hint { color:#7a8398; }
161
162    .mode {
163      pointer-events:auto; font-family:"IBM Plex Mono", monospace; font-size:10px; letter-spacing:0.2em;
164      text-transform:uppercase; background:none; border:1px solid rgba(139,148,168,0.35);
165      color:var(--dim); padding:7px 14px; cursor:pointer; border-radius:2px;
166      transition:color 200ms ease, border-color 200ms ease;
167    }
168    .mode:hover { color:var(--blue); border-color:var(--blue); }
169    .top .right { display:flex; gap:18px; align-items:center; pointer-events:auto; }
170
171    /* ── nav menu ── */
172    nav.menu {
173      pointer-events:auto; display:flex; gap:22px; align-items:center;
174      font-size:10px; letter-spacing:0.22em; text-transform:uppercase;
175    }
176    nav.menu a { color:var(--dim); transition:color 200ms ease; text-shadow:0 1px 6px rgba(4,6,12,0.9); }
177    nav.menu a:hover { color:var(--blue); }
178    html.light nav.menu a { text-shadow:none; }
179
180    /* ── chat hide/minimize ── */
181    .io { transition:opacity 400ms cubic-bezier(0.16,1,0.3,1), transform 400ms cubic-bezier(0.16,1,0.3,1); }
182    body.chat-hidden .io { opacity:0; transform:translateX(-50%) translateY(24px); pointer-events:none; }
183    .hint button.hint-link {
184      background:none; border:none; cursor:pointer; padding:0;
185      font-family:"IBM Plex Mono", monospace; font-size:10px; letter-spacing:0.22em;
186      text-transform:uppercase; color:var(--dim); text-decoration:underline;
187      text-underline-offset:3px; transition:color 200ms ease;
188    }
189    .hint button.hint-link:hover { color:var(--blue); }
190    .chat-toggle {
191      display:none;
192      position:fixed; left:50%; transform:translateX(-50%); bottom:44px; z-index:4; pointer-events:auto;
193      font-family:"IBM Plex Mono", monospace; font-size:10px; letter-spacing:0.2em;
194      text-transform:uppercase; background:rgba(6,10,20,0.26);
195      backdrop-filter:blur(7px); -webkit-backdrop-filter:blur(7px);
196      border:1px solid rgba(79,124,255,0.18); color:var(--dim);
197      padding:9px 16px; cursor:pointer; border-radius:2px;
198      transition:color 200ms ease, border-color 200ms ease;
199    }
200    .chat-toggle:hover { color:var(--blue); border-color:rgba(79,124,255,0.5); }
201    html.light .chat-toggle { background:rgba(255,255,255,0.5); border-color:rgba(36,83,230,0.2); }
202    body.chat-hidden .chat-toggle { display:block; }
203
204    /* ── legal corner links ── */
205    .legal {
206      position:fixed; bottom:16px; z-index:3; pointer-events:auto;
207      font-size:9px; letter-spacing:0.22em; text-transform:uppercase; color:#4a5268;
208    }
209    .legal a { color:inherit; transition:color 200ms ease; }
210    .legal a:hover { color:var(--dim); }
211    .legal.l { left:38px; }
212    .legal.r { right:38px; }
213
214    @media (max-width: 760px) {
215      /* 16px input font — prevents iOS Safari's automatic zoom on focus */
216      form.bar input { font-size:16px; }
217      .top { flex-wrap:wrap; gap:12px; padding:18px 20px; }
218      nav.menu { order:3; width:100%; justify-content:center; flex-wrap:wrap; gap:12px 18px; }
219      .top .status { display:none; }
220      .legal.l { left:20px; }
221      .legal.r { right:20px; }
222    }
223    /* ── language switch ── */
224    .lang { pointer-events:auto; display:inline-flex; border:1px solid rgba(139,148,168,0.35); border-radius:2px; overflow:hidden; }
225    .lang button { font-family:"IBM Plex Mono", monospace; font-size:10px; letter-spacing:0.16em; text-transform:uppercase; background:none; border:none; color:var(--dim); padding:6px 9px; cursor:pointer; transition:color 200ms ease, background 200ms ease; }
226    .lang button + button { border-left:1px solid rgba(139,148,168,0.35); }
227    .lang button.active { background:var(--blue); color:#fff; }
228    .lang button:not(.active):hover { color:var(--blue); }
229  </style>
230  <!-- Structured data: homepage is the canonical place for WebSite (sitelinks
231       search box) + Organization. -->
232  
232<script type="application/ld+json">
233  {
234    "@context": "https://schema.org",
235    "@graph": [
236      {
237        "@type": "Organization",
238        "name": "SAKIZLI AI",
239        "url": "https://sakizli.ai",
240        "logo": "https://sakizli.ai/android-chrome-512x512.png",
241        "founder": { "@type": "Person", "name": "Furkan Sakızlı" },
242        "sameAs": [
243          "https://github.com/SKZL-AI",
244          "https://www.youtube.com/@SakizliAI",
245          "https://de.linkedin.com/in/sakizli",
246          "https://www.instagram.com/sakizli_ai/",
247          "https://x.com/SAKIZLI_AI"
248        ]
249      },
250      {
251        "@type": "WebSite",
252        "name": "SAKIZLI AI",
253        "url": "https://sakizli.ai",
254        "description": "Applied-AI learning, courses & research by Furkan Sakızlı",
255        "inLanguage": ["en", "de", "tr"],
256        "potentialAction": {
257          "@type": "SearchAction",
258          "target": { "@type": "EntryPoint", "urlTemplate": "https://sakizli.ai/search?q={search_term_string}" },
259          "query-input": "required name=search_term_string"
260        }
261      }
262    ]
263  }
264  </script>
264
265</head>
266<body>
267  <div class="glow"></div>
268  <div id="stage"></div>
269
270  <header class="top">
271    <div class="wordmark">SAKIZLI <span class="ai">AI</span></div>
272    <nav class="menu">
273      <a href="/journal" data-i18n="nav_journal">Dossier</a>
274      <a href="/research" data-i18n="nav_research">Research</a>
275      <a href="/courses" data-i18n="nav_courses">Courses</a>
276      <a href="/enter" data-i18n="nav_immersive">Immersive</a>
277      <a href="/dashboard" data-i18n="nav_dashboard" id="nav-dashboard-link" style="display:none">Dashboard</a>
278      <a href="/account" data-i18n="nav_login" id="nav-login-link">Sign in</a>
279    </nav>
280    <div class="right">
281      <div class="status"><span class="dot"></span><span id="status-label" data-i18n="status">standby · network resting</span></div>
282      <div class="lang" id="lang-switch" role="group" aria-label="Language">
283        <button type="button" data-lang="en">EN</button><button type="button" data-lang="de">DE</button><button type="button" data-lang="tr">TR</button>
284      </div>
285      <button class="mode" id="mode-toggle" type="button">Light</button>
286    </div>
287  </header>
288
289  <!-- controls hint — top, under the menu -->
290  <div class="controls-hint hint" data-i18n="hint">Drag to rotate · scroll to zoom</div>
291
292  <div class="io">
293    <div class="answer" id="answer"><span class="q" id="answer-q"></span><span id="answer-text"></span></div>
294    <form class="bar" id="ask">
295      <input id="prompt" type="text" autocomplete="off" spellcheck="false" placeholder="Ask the network anything…" data-i18n-ph="ask" aria-label="Prompt">
296      <button type="submit" id="send" data-i18n="send">Send</button>
297      <button type="button" id="chat-hide" class="bar-hide" title="Hide chat" data-i18n="hide">Hide</button>
298    </form>
299    <div class="hint ai-note">You are chatting with an <b>AI language model</b> (LLM) — answers may be inaccurate.
300      Messages are sent to AI providers (Groq&nbsp;·&nbsp;USA, Mistral&nbsp;·&nbsp;EU, Google&nbsp;·&nbsp;USA) to generate a reply; please do not
301      enter personal data. <a href="/privacy">Privacy&nbsp;Policy</a></div>
302  </div>
303
304  <button class="chat-toggle" id="chat-toggle" type="button" aria-label="Show chat">Chat</button>
305
306  <div class="legal l"><a href="/imprint" data-i18n="imprint">Imprint</a></div>
307  <div class="legal r"><a href="/privacy" data-i18n="privacy">Privacy Policy</a></div>
308
309  
309<script>
310  // Site-wide language switch (shares the "skzl-locale" key with the React app).
311  (function () {
312    var T = {
313      en: { nav_journal:"Dossier", nav_research:"Research", nav_courses:"Courses", nav_immersive:"Immersive", nav_dashboard:"Dashboard", nav_login:"Sign in", status:"standby · network resting", hint:"Drag to rotate · scroll to zoom", ask:"Ask the network anything…", send:"Send", hide:"Hide", imprint:"Imprint", privacy:"Privacy Policy" },
314      de: { nav_journal:"Dossier", nav_research:"Forschung", nav_courses:"Kurse", nav_immersive:"Immersiv", nav_dashboard:"Dashboard", nav_login:"Anmelden", status:"Standby · Netzwerk ruht", hint:"Zum Drehen ziehen · zum Zoomen scrollen", ask:"Frag das Netzwerk…", send:"Senden", hide:"Ausblenden", imprint:"Impressum", privacy:"Datenschutz" },
315      tr: { nav_journal:"Dossier", nav_research:"Araştırma", nav_courses:"Kurslar", nav_immersive:"Immersive", nav_dashboard:"Panel", nav_login:"Giriş yap", status:"Beklemede · ağ dinleniyor", hint:"Döndürmek için sürükle · yakınlaştırmak için kaydır", ask:"Ağa istediğini sor…", send:"Gönder", hide:"Gizle", imprint:"Künye", privacy:"Gizlilik" },
316    };
317    function read() {
318      try {
319        var m = document.cookie.match(/(?:^|; )skzl-locale=([^;]+)/);
320        var v = localStorage.getItem("skzl-locale") || (m && m[1]);
321        return (v === "de" || v === "tr" || v === "en") ? v : "en";
322      } catch (e) { return "en"; }
323    }
324    function apply(loc) {
325      var dict = T[loc] || T.en;
326      document.querySelectorAll("[data-i18n]").forEach(function (el) {
327        var k = el.getAttribute("data-i18n"); if (dict[k]) el.textContent = dict[k];
328      });
329      document.querySelectorAll("[data-i18n-ph]").forEach(function (el) {
330        var k = el.getAttribute("data-i18n-ph"); if (dict[k]) el.setAttribute("placeholder", dict[k]);
331      });
332      // Intentionally not setting <html lang> — keeps CSS uppercase casing
333      // Latin (SAKIZLI / LIGHT with a normal I, not the Turkish İ).
334      document.querySelectorAll("#lang-switch button").forEach(function (b) {
335        b.classList.toggle("active", b.getAttribute("data-lang") === loc);
336      });
337    }
338    function set(loc) {
339      try {
340        localStorage.setItem("skzl-locale", loc);
341        document.cookie = "skzl-locale=" + loc + ";path=/;max-age=31536000;samesite=lax";
342      } catch (e) {}
343      apply(loc);
344    }
345    document.querySelectorAll("#lang-switch button").forEach(function (b) {
346      b.addEventListener("click", function () { set(b.getAttribute("data-lang")); });
347    });
348    apply(read());
349  })();
350  </script>
350
351
352  
352<script type="importmap">
353    {
354      "imports": {
355        "three": "https://unpkg.com/[email protected]/build/three.module.min.js",
356        "three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
357      }
358    }
359  </script>
359
360  
360<script type="module">
361    import * as THREE from 'three';
362    import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
363
364    const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
365
366    /* ── chat hide/minimize: enjoy the network without the chat surface ──
367       Wired immediately (cheap, independent of the 3D hero below), so the
368       chat is fully usable the instant the page paints. */
369    function setChatHidden(hidden) {
370      document.body.classList.toggle('chat-hidden', hidden);
371      try { localStorage.setItem('skzl_chat_hidden', hidden ? '1' : '0'); } catch (e) {}
372    }
373    document.getElementById('chat-hide').addEventListener('click', () => setChatHidden(true));
374    document.getElementById('chat-toggle').addEventListener('click', () => setChatHidden(false));
375    setChatHidden((() => { try { return localStorage.getItem('skzl_chat_hidden') === '1'; } catch (e) { return false; } })());
376
377    /* ── chat wiring: storm intensity + duration follow the answer.
378       window.NEURAL is only defined once the deferred 3D scene below has
379       loaded — optional-chained so chatting works immediately regardless. ── */
380    const form = document.getElementById('ask');
381    const input = document.getElementById('prompt');
382    const send = document.getElementById('send');
383    const answer = document.getElementById('answer');
384    const answerQ = document.getElementById('answer-q');
385    const answerText = document.getElementById('answer-text');
386
387    form.addEventListener('submit', async (e) => {
388      e.preventDefault();
389      const q = input.value.trim();
390      if (!q || send.disabled) return;
391      send.disabled = true;
392      send.textContent = '···';
393      window.NEURAL?.think();
394      answerQ.textContent = q;
395      answerText.textContent = '';
396      answer.classList.add('on');
397      try {
398        const res = await fetch('/api/chat', {
399          method: 'POST',
400          headers: { 'Content-Type': 'application/json' },
401          body: JSON.stringify({ messages: [{ role: 'user', content: q }] })
402        });
403        if (!res.ok) throw new Error('chat api ' + res.status);
404        const text = (await res.json()).message;
405        if (!text) throw new Error('empty answer');
406        window.NEURAL?.talk(text.length);
407        if (reduced) { answerText.textContent = text; }
408        else {
409          let i = 0;
410          await new Promise(res => {
411            (function tick() {
412              answerText.textContent = text.slice(0, i += 3);
413              answer.scrollTop = answer.scrollHeight;
414              if (i < text.length) setTimeout(tick, 16); else res();
415            })();
416          });
417        }
418      } catch (err) {
419        answerText.textContent = 'The network is unreachable right now — please try again.';
420      } finally {
421        window.NEURAL?.idle();
422        send.disabled = false;
423        send.textContent = 'Send';
424        input.value = '';
425        input.focus();
426      }
427    });
428
429    /* ── heavy 3D hero: deferred until the browser is idle so building
430       ~4,500 instanced nodes + ~4,200 edges and starting the render loop
431       doesn't compete with the page's initial interactive paint (this was
432       the actual driver of a very high Total Blocking Time in Lighthouse
433       audits — the chat above is already fully usable before this runs). ── */
434    function initScene() {
435    const stage = document.getElementById('stage');
436    const renderer = new THREE.WebGLRenderer({ antialias:true, alpha:true });
437    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
438    renderer.setSize(innerWidth, innerHeight);
439    stage.appendChild(renderer.domElement);
440
441    const scene = new THREE.Scene();
442    const camera = new THREE.PerspectiveCamera(38, innerWidth/innerHeight, 0.1, 300);
443    camera.position.set(5.6, 8.2, 43);
444
445    const controls = new OrbitControls(camera, renderer.domElement);
446    controls.enableDamping = true;
447    controls.dampingFactor = 0.06;
448    controls.enablePan = true;
449    controls.screenSpacePanning = true;
450    controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
451    controls.minDistance = 4;
452    controls.maxDistance = 60;
453    controls.rotateSpeed = 0.55;
454    /* target sits below the net's center so the network renders ~12% higher
455       on screen — keeps the answer panel from covering the visualization */
456    controls.target.set(0, 2.6, 0);
457
458    /* — responsive framing: on narrow screens the horizontal FOV shrinks, so the
459         camera must sit further back to keep the whole net (input → digits) visible.
460         Also lifts maxDistance so two-finger pinch-out can always reveal the full net. — */
461    const NET_W = 34, NET_H = 25; /* network bounds incl. digit column, around offset target */
462    function fitDistance() {
463      const vFov = camera.fov * Math.PI / 180;
464      const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
465      return Math.max((NET_H / 2) / Math.tan(vFov / 2), (NET_W / 2) / Math.tan(hFov / 2)) * 1.06;
466    }
467    function frameNetwork(force) {
468      const d = fitDistance();
469      controls.maxDistance = Math.max(60, d * 1.35);
470      if (force && camera.position.distanceTo(controls.target) < d) {
471        const dir = camera.position.clone().sub(controls.target).normalize();
472        camera.position.copy(controls.target.clone().addScaledVector(dir, d));
473      }
474    }
475    frameNetwork(true);
476
477    /* lights — for the slightly reflective black nodes */
478    scene.add(new THREE.AmbientLight(0xffffff, 0.55));
479    scene.add(new THREE.HemisphereLight(0xcfd8ee, 0x2a2f3c, 0.85));
480    const keyLight = new THREE.DirectionalLight(0xffffff, 1.15);
481    keyLight.position.set(6, 10, 8);
482    scene.add(keyLight);
483    const rimLight = new THREE.DirectionalLight(0x9db4e8, 0.45);
484    rimLight.position.set(-8, -4, -6);
485    scene.add(rimLight);
486
487    /* ═══ MLP after the reference images — 3× density ═══
488       input pixel screen (16×16) → 3 block-shaped hidden layers (dark cubes)
489       → output digits 0–9.  Deterministic (seed fixed). */
490    let seed = 20260712;
491    const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
492
493    const group = new THREE.Group();
494    group.position.y = 6.2;                 // sit high, clear of the chat area (default view)
495    scene.add(group);
496
497    const layers = [];
498    const nodePos = [];
499    const nodeLayer = [];
500    function addNode(v, l) { nodePos.push(v); nodeLayer.push(l); (layers[l] ||= []).push(nodePos.length - 1); return nodePos.length - 1; }
501
502    /* L0 · input screen: 16×16 grid of flat pixels */
503    const GRID = 16, PIX = 0.62;
504    for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; c++)
505      addNode(new THREE.Vector3(-13.2, ((GRID-1)/2 - r) * PIX, (c - (GRID-1)/2) * PIX), 0);
506
507    /* L1–L3 · hidden blocks: 3× more, much smaller cubes */
508    const blockDefs = [
509      { n: 1370, x: -6.4, w: 2.6, h: 9.2, d: 7.6 },
510      { n: 1490, x:  0.4, w: 2.8, h: 9.6, d: 8.0 },
511      { n: 1370, x:  7.2, w: 2.6, h: 9.2, d: 7.6 }
512    ];
513    blockDefs.forEach((b, bi) => {
514      for (let i = 0; i < b.n; i++)
515        addNode(new THREE.Vector3(
516          b.x + (rnd() - 0.5) * b.w,
517          (rnd() - 0.5) * b.h,
518          (rnd() - 0.5) * b.d), bi + 1);
519    });
520
521    /* L4 · output: 10 nodes in a receding column (digits drawn next to them) */
522    const OUT_X = 13.6;
523    for (let i = 0; i < 10; i++)
524      addNode(new THREE.Vector3(OUT_X + i * 0.34, 3.4 - i * 0.76, -i * 0.30), 4);
525
526    const N = nodePos.length;
527
528    /* ── nodes as instanced cubes: small dark blocks that flare white ── */
529    const cubeGeo = new THREE.BoxGeometry(1, 1, 1);
530    const cubeMat = new THREE.MeshStandardMaterial({ roughness:0.16, metalness:0.55, transparent:true, opacity:0.98 });
531    const cubes = new THREE.InstancedMesh(cubeGeo, cubeMat, N);
532    cubes.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
533    const iColor = new Float32Array(N * 3);
534    cubes.instanceColor = new THREE.InstancedBufferAttribute(iColor, 3);
535    const m4 = new THREE.Matrix4(), q0 = new THREE.Quaternion(), sV = new THREE.Vector3();
536    const baseScale = new Float32Array(N);
537    for (let i = 0; i < N; i++) {
538      const l = nodeLayer[i];
539      let s;
540      if (l === 0) { sV.set(0.08, 0.44, 0.44); s = 1; }                 // flat pixels
541      else if (l === 4) { sV.set(0.22, 0.22, 0.22); s = 1; }            // output nodes
542      else { s = 0.038 + rnd() * 0.042; sV.set(s, s, s); }              // tiny hidden cubes
543      baseScale[i] = sV.x;
544      m4.compose(nodePos[i], q0, sV.clone());
545      cubes.setMatrixAt(i, m4);
546    }
547    group.add(cubes);
548
549    /* ── per-node weights: random −0.99…+0.99, readable only up close ── */
550    const nodeVal = new Float32Array(N);
551    for (let i = 0; i < N; i++) nodeVal[i] = Math.round((rnd() * 1.98 - 0.99) * 100) / 100;
552    const valTexCache = new Map();
553    function valTexture(v) {
554      const s = (v < 0 ? '−' : '') + Math.abs(v).toFixed(2);
555      if (valTexCache.has(s)) return valTexCache.get(s);
556      const cv = document.createElement('canvas'); cv.width = 128; cv.height = 64;
557      const g = cv.getContext('2d');
558      g.font = '500 30px "IBM Plex Mono", monospace';
559      g.textAlign = 'center'; g.textBaseline = 'middle';
560      g.fillStyle = '#fff';
561      g.fillText(s, 64, 34);
562      const tx = new THREE.CanvasTexture(cv);
563      valTexCache.set(s, tx);
564      return tx;
565    }
566    const VPOOL = 56;
567    const valSprites = [];
568    for (let k = 0; k < VPOOL; k++) {
569      const m = new THREE.SpriteMaterial({ transparent:true, opacity:0, depthWrite:false, depthTest:false });
570      const sp = new THREE.Sprite(m);
571      sp.scale.set(0.30, 0.15, 1);
572      sp.visible = false;
573      group.add(sp);
574      valSprites.push(sp);
575    }
576    let valTimer = 0;
577    const wv = new THREE.Vector3();
578    function updateValues(dt) {
579      valTimer += dt;
580      if (valTimer < 0.2) return;
581      valTimer = 0;
582      const cand = [];
583      for (let l = 1; l <= 3; l++) for (const i of layers[l]) {
584        wv.copy(nodePos[i]).applyMatrix4(group.matrixWorld);
585        const d = wv.distanceTo(camera.position);
586        if (d < 7.5) cand.push([d, i]);
587      }
588      cand.sort((a, b) => a[0] - b[0]);
589      /* greedy screen-space thinning so numbers don't overlap */
590      const placed = [];
591      const pv = new THREE.Vector3();
592      let used = 0;
593      for (let c = 0; c < cand.length && used < VPOOL; c++) {
594        const d = cand[c][0], i = cand[c][1];
595        pv.copy(nodePos[i]).applyMatrix4(group.matrixWorld).project(camera);
596        if (pv.z > 1) continue;
597        let clash = false;
598        for (const q of placed) {
599          if (Math.abs(pv.x - q.x) < 0.075 && Math.abs(pv.y - q.y) < 0.05) { clash = true; break; }
600        }
601        if (clash) continue;
602        placed.push({ x: pv.x, y: pv.y });
603        const sp = valSprites[used++];
604        sp.visible = true;
605        sp.position.copy(nodePos[i]);
606        sp.material.map = valTexture(nodeVal[i]);
607        sp.material.opacity = Math.min(1, (7.5 - d) / 2.2) * 0.95;
608        sp.material.color.set(LIGHT ? 0x202634 : 0xd7deec);
609      }
610      for (let k = used; k < VPOOL; k++) valSprites[k].visible = false;
611    }
612
613    /* ── edges: dense spider-web fan-in, ALWAYS visible light gray;
614       vertex colors so single edges can glow when a pulse rides them ── */
615    const edges = [];                        // {a,b}
616    const linePos = [];
617    const adjF = Array.from({ length:N }, () => []);   // forward: {n, e}
618    const adjA = Array.from({ length:N }, () => []);   // any dir: {n, e}
619    const linkCounts = [1040, 1360, 1360, 480];
620    for (let l = 0; l < 4; l++) {
621      const A = layers[l], B = layers[l+1];
622      for (let k = 0; k < linkCounts[l]; k++) {
623        const a = A[Math.floor(rnd()*A.length)], b = B[Math.floor(rnd()*B.length)];
624        const e = edges.length;
625        edges.push({ a, b });
626        adjF[a].push({ n:b, e });
627        adjA[a].push({ n:b, e }); adjA[b].push({ n:a, e });
628        const va = nodePos[a], vb = nodePos[b];
629        linePos.push(va.x, va.y, va.z, vb.x, vb.y, vb.z);
630      }
631    }
632    const E = edges.length;
633    const edgeGlow = new Float32Array(E);
634    const lCol = new Float32Array(E * 6);    // 2 verts × rgb
635    const EDGE_BASE = 0.045;                 // near-black threads
636    for (let i = 0; i < E * 2; i++) { lCol[i*3] = EDGE_BASE; lCol[i*3+1] = EDGE_BASE; lCol[i*3+2] = EDGE_BASE * 1.08; }
637    const lGeo = new THREE.BufferGeometry();
638    lGeo.setAttribute('position', new THREE.Float32BufferAttribute(linePos, 3));
639    lGeo.setAttribute('color', new THREE.BufferAttribute(lCol, 3));
640    const lMat = new THREE.LineBasicMaterial({ vertexColors:true, transparent:true, opacity:0.045, blending:THREE.NormalBlending, depthWrite:false });
641    group.add(new THREE.LineSegments(lGeo, lMat));
642
643    /* glow overlay: small dynamic buffer holding ONLY currently glowing edges */
644    const MAXG = 4000;
645    const gPos = new Float32Array(MAXG * 6);
646    const gCol = new Float32Array(MAXG * 6);
647    const gGeo = new THREE.BufferGeometry();
648    gGeo.setAttribute('position', new THREE.BufferAttribute(gPos, 3));
649    gGeo.setAttribute('color', new THREE.BufferAttribute(gCol, 3));
650    gGeo.setDrawRange(0, 0);
651    const gMat = new THREE.LineBasicMaterial({ vertexColors:true, transparent:true, opacity:0.5, blending:THREE.NormalBlending, depthWrite:false });
652    group.add(new THREE.LineSegments(gGeo, gMat));
653
654    /* ── output digits 0–9 as canvas sprites next to their nodes ── */
655    const digitMats = [];
656    for (let d = 0; d < 10; d++) {
657      const cv = document.createElement('canvas'); cv.width = cv.height = 128;
658      const g = cv.getContext('2d');
659      g.font = '300 92px "Fraunces", Times, serif';
660      g.textAlign = 'center'; g.textBaseline = 'middle';
661      g.fillStyle = '#dfe7f5';
662      g.fillText(String(d), 64, 70);
663      const tex = new THREE.CanvasTexture(cv);
664      const mat = new THREE.SpriteMaterial({ map:tex, transparent:true, opacity:0.5, depthWrite:false });
665      const sp = new THREE.Sprite(mat);
666      const np = nodePos[layers[4][d]];
667      sp.position.set(np.x + 0.62, np.y + 0.30, np.z);
668      sp.scale.setScalar(0.85);
669      group.add(sp);
670      digitMats.push(mat);
671    }
672
673    /* ── digit bitmaps for the input screen (16×16 samples of 0–9) ── */
674    const digitPix = [];
675    {
676      const cv = document.createElement('canvas'); cv.width = cv.height = GRID;
677      const g = cv.getContext('2d');
678      for (let d = 0; d < 10; d++) {
679        g.clearRect(0, 0, GRID, GRID);
680        g.font = '600 15px "IBM Plex Mono", monospace';
681        g.textAlign = 'center'; g.textBaseline = 'middle';
682        g.fillStyle = '#fff';
683        g.fillText(String(d), GRID/2, GRID/2 + 1);
684        const data = g.getImageData(0, 0, GRID, GRID).data;
685        const bm = new Float32Array(GRID * GRID);
686        for (let p = 0; p < GRID * GRID; p++) bm[p] = data[p*4 + 3] / 255;
687        digitPix.push(bm);
688      }
689    }
690    let screenDigit = -1;                    // -1 = blank (idle)
691
692    /* ── activations & travelling pulses ── */
693    const act = new Float32Array(N);
694    const MAXP = 3200;
695    const sPos = new Float32Array(MAXP * 3);
696    const sGeo = new THREE.BufferGeometry();
697    sGeo.setAttribute('position', new THREE.BufferAttribute(sPos, 3));
698    const sMat = new THREE.PointsMaterial({ color:0x6f96ff, size:0.15, transparent:true, opacity:0.9, blending:THREE.AdditiveBlending, depthWrite:false, sizeAttenuation:true });
699    group.add(new THREE.Points(sGeo, sMat));
700    const pulses = [];                       // {a,b,e,t,speed,forward,depth}
701
702    function spawn(i, { forward = true, depth = 1, fanout = 4 } = {}) {
703      const pool = forward ? adjF[i] : adjA[i];
704      if (!pool.length) return;
705      for (let k = 0; k < fanout; k++) {
706        if (pulses.length >= MAXP) return;
707        const pick = pool[Math.floor(Math.random() * pool.length)];
708        pulses.push({ a:i, b:pick.n, e:pick.e, t:-Math.random()*0.12, speed:2.8 + Math.random()*3.0, forward, depth });
709      }
710    }
711    function fire(i, opts) { act[i] = 1; spawn(i, opts); }
712
713    /* forward wave from the lit screen pixels down to the digits */
714    function wave(strength = 1) {
715      const src = layers[0];
716      const bm = screenDigit >= 0 ? digitPix[screenDigit] : null;
717      const count = Math.round(16 * strength);
718      let tries = 0;
719      for (let k = 0; k < count && tries < 220; ) {
720        const p = Math.floor(Math.random() * src.length); tries++;
721        if (bm && bm[p] < 0.45) continue;                 // fire from lit pixels only
722        fire(src[p], { forward:true, depth:3, fanout:4 });
723        k++;
724      }
725    }
726
727    /* ── modes ── */
728    let storm = 0;
729    const statusLabel = document.getElementById('status-label');
730    window.NEURAL = {
731      think()     { storm = 10; statusLabel.textContent = 'processing · forward pass'; },
732      talk(chars) { storm = Math.min(46, Math.max(18, chars / 22)); statusLabel.textContent = 'answering · network firing'; },
733      idle()      { storm = 0; screenDigit = -1; statusLabel.textContent = 'standby · network resting'; },
734      fire, wave, mode: () => storm > 0 ? 'storm' : 'standby'
735    };
736
737    /* hover: gentle — a small local blip only */
738    const ray = new THREE.Raycaster();
739    const ndc = new THREE.Vector2(2, 2);
740    let dragging = false, lastHoverFire = 0;
741    renderer.domElement.addEventListener('pointerdown', () => dragging = true);
742    addEventListener('pointerup', () => dragging = false);
743    renderer.domElement.addEventListener('pointermove', e => {
744      ndc.x = (e.clientX / innerWidth) * 2 - 1;
745      ndc.y = -(e.clientY / innerHeight) * 2 + 1;
746    });
747
748    /* ── theme ── */
749    let LIGHT = false;
750    const modeBtn = document.getElementById('mode-toggle');
751    function applyTheme(light) {
752      LIGHT = light;
753      document.documentElement.classList.toggle('light', light);
754      modeBtn.textContent = light ? 'Dark' : 'Light';
755      lMat.transparent = true;
756      lMat.opacity = light ? 0.14 : 0.045;
757      const eb = light ? 0.42 : EDGE_BASE;
758      for (let i = 0; i < E * 2; i++) { lCol[i*3] = eb; lCol[i*3+1] = eb; lCol[i*3+2] = eb * 1.08; }
759      lGeo.attributes.color.needsUpdate = true;
760      lMat.needsUpdate = true;
761      sMat.blending = light ? THREE.NormalBlending : THREE.AdditiveBlending;
762      sMat.color.set(light ? 0x1a5cff : 0xe6ecf8);
763      sMat.needsUpdate = true;
764      try { localStorage.setItem('skzl_theme', light ? 'light' : 'dark'); } catch (e) {}
765    }
766    modeBtn.addEventListener('click', () => applyTheme(!LIGHT));
767
768    const clock = new THREE.Clock();
769    let stormAcc = 0, digitTimer = 0, standbyAcc = 0;
770    const digitGlow = new Float32Array(10);
771    const white = new THREE.Color(1, 1, 1), flare = new THREE.Color(0xe6ecf8);
772    const inkL = new THREE.Color(0x243154), flareL = new THREE.Color(0x1a5cff);
773
774    function frame() {
775      requestAnimationFrame(frame);
776      const dt = Math.min(clock.getDelta(), 0.05);
777      const t = clock.elapsedTime;
778      controls.update();
779
780      if (!reduced) {
781        if (!dragging && storm === 0) group.rotation.y += dt * 0.035;
782        updateValues(dt);
783
784        /* screen: cycle digits fast while processing, blank at idle */
785        if (storm > 0) {
786          digitTimer += dt;
787          if (digitTimer > 0.14) { screenDigit = (screenDigit + 1 + Math.floor(Math.random()*3)) % 10; digitTimer = 0; }
788        }
789
790        /* hover: small, quiet blip (the real show is the prompt storm) */
791        if (!dragging && t - lastHoverFire > 0.22 && ndc.x <= 1) {
792          ray.setFromCamera(ndc, camera);
793          const hit = ray.intersectObject(cubes, false)[0];
794          if (hit && hit.instanceId != null && nodeLayer[hit.instanceId] >= 1 && nodeLayer[hit.instanceId] <= 3) {
795            fire(hit.instanceId, { forward:false, depth:2, fanout:3 });
796            lastHoverFire = t;
797          }
798        }
799
800        /* chat storm: heavy continuous waves; idle: silence */
801        if (storm > 0) {
802          stormAcc += dt * storm;
803          while (stormAcc >= 1) { wave(1.8); stormAcc--; }
804        } else if (LIGHT) {
805          /* light mode: subtle standby blips so the blue is discoverable */
806          standbyAcc += dt;
807          if (standbyAcc > 2.0 + Math.random() * 1.5) {
808            const l = 1 + Math.floor(Math.random() * 3);
809            fire(layers[l][Math.floor(Math.random() * layers[l].length)], { forward:false, depth:1, fanout:2 });
810            standbyAcc = 0;
811          }
812        }
813
814        /* advance pulses; light their edges as they travel */
815        for (let i = pulses.length - 1; i >= 0; i--) {
816          const p = pulses[i];
817          p.t += dt * p.speed;
818          if (p.t > 0) edgeGlow[p.e] = Math.max(edgeGlow[p.e], Math.min(1, p.t < 0.5 ? p.t * 2.4 : (1 - p.t) * 2.4 + 0.3));
819          if (p.t >= 1) {
820            act[p.b] = 1;
821            if (nodeLayer[p.b] === 4) digitGlow[layers[4].indexOf(p.b)] = 1;
822            if (p.depth > 0 && Math.random() < 0.8)
823              spawn(p.b, { forward:p.forward, depth:p.depth - 1, fanout:p.forward ? 4 : 2 });
824            pulses.splice(i, 1);
825          }
826        }
827        sMat.opacity = pulses.length ? 0.95 : 0;
828        for (let i = 0; i < MAXP; i++) {
829          if (i < pulses.length) {
830            const p = pulses[i], a = nodePos[p.a], b = nodePos[p.b], k = Math.max(p.t, 0);
831            sPos[i*3]   = a.x + (b.x - a.x) * k;
832            sPos[i*3+1] = a.y + (b.y - a.y) * k;
833            sPos[i*3+2] = a.z + (b.z - a.z) * k;
834          } else { sPos[i*3] = 0; sPos[i*3+1] = 9999; sPos[i*3+2] = 0; }
835        }
836        sGeo.attributes.position.needsUpdate = true;
837
838        /* edge glow: base threads stay static; overlay holds only glowing edges */
839        let gc = 0;
840        for (let e = 0; e < E; e++) {
841          const g = edgeGlow[e];
842          if (g <= 0.02) continue;
843          edgeGlow[e] = g * Math.exp(-dt * 4.2);
844          if (gc >= MAXG) continue;
845          const o = gc * 6, p = e * 6;
846          gPos[o] = linePos[p]; gPos[o+1] = linePos[p+1]; gPos[o+2] = linePos[p+2];
847          gPos[o+3] = linePos[p+3]; gPos[o+4] = linePos[p+4]; gPos[o+5] = linePos[p+5];
848          let r, gg, b;
849          if (!LIGHT) {
850            /* fade toward dark bg as glow decays; peak = bright white */
851            r = 0.08 + g * 0.92; gg = 0.10 + g * 0.90; b = 0.14 + g * 0.86;
852          } else {
853            /* fade toward light bg; peak = strong blue */
854            r = 0.93 - g * 0.83; gg = 0.95 - g * 0.59; b = 0.98;
855          }
856          gCol[o] = r; gCol[o+1] = gg; gCol[o+2] = b;
857          gCol[o+3] = r; gCol[o+4] = gg; gCol[o+5] = b;
858          gc++;
859        }
860        gGeo.setDrawRange(0, gc * 2);
861        gGeo.attributes.position.needsUpdate = true;
862        gGeo.attributes.color.needsUpdate = true;
863
864        /* cube colors + flare pop */
865        const breathe = 0.85 + Math.sin(t * 0.55) * 0.15;
866        for (let i = 0; i < N; i++) {
867          act[i] *= Math.exp(-dt * 3.4);
868          const a = act[i];
869          const l = nodeLayer[i];
870          const bm0 = l === 0 ? (screenDigit >= 0 ? digitPix[screenDigit][i] : 0) : 0;
871          if (!LIGHT) {
872            /* black, slightly reflective nodes; warm amber current on firing */
873            let base;
874            if (l === 0) base = 0.42;         // light gray screen pixels
875            else if (l === 4) base = 0.34;
876            else base = 0.50 * breathe;       // light gray cubes
877            iColor[i*3]   = base + bm0 * 0.82 + a * 0.60;
878            iColor[i*3+1] = base + bm0 * 0.84 + a * 0.62;
879            iColor[i*3+2] = base + bm0 * 0.88 + a * 0.68;
880          } else {
881            /* dark gray / black nodes on light ground; firing turns blue */
882            let base;
883            if (l === 0) base = 0.32 - bm0 * 0.26;
884            else if (l === 4) base = 0.20;
885            else base = 0.24;
886            iColor[i*3]   = base - a * 0.14;
887            iColor[i*3+1] = base + a * 0.18;
888            iColor[i*3+2] = base + a * 0.88;
889          }
890          if (a > 0.01 && l >= 1 && l <= 3) {
891            const s = baseScale[i] * (1 + a * 0.45);
892            sV.set(s, s, s);
893            m4.compose(nodePos[i], q0, sV);
894            cubes.setMatrixAt(i, m4);
895            cubes.instanceMatrix.needsUpdate = true;
896          }
897        }
898        cubes.instanceColor.needsUpdate = true;
899
900        /* digits glow when their node is hit */
901        for (let d = 0; d < 10; d++) {
902          digitGlow[d] *= Math.exp(-dt * 1.8);
903          digitMats[d].opacity = (LIGHT ? 0.75 : 0.45) + digitGlow[d] * (LIGHT ? 0.25 : 0.55);
904          if (!LIGHT) digitMats[d].color.copy(white).lerp(flare, digitGlow[d] * 0.8);
905          else digitMats[d].color.copy(inkL).lerp(flareL, digitGlow[d]);
906        }
907      } else {
908        for (let i = 0; i < N; i++) { const v = nodeLayer[i] === 0 ? 0.16 : 0.1; iColor[i*3] = v; iColor[i*3+1] = v * 1.05; iColor[i*3+2] = v * 1.25; }
909        cubes.instanceColor.needsUpdate = true;
910        sMat.opacity = 0;
911      }
912      renderer.render(scene, camera);
913    }
914    frame();
915    applyTheme((() => { try { return localStorage.getItem('skzl_theme') !== 'dark'; } catch (e) { return true; } })());
916
917    /* re-sync size + framing once layout is final (init runs before the stage
918       reaches its final viewport size, which otherwise leaves the net off-screen) */
919    function syncViewport() {
920      camera.aspect = innerWidth / innerHeight;
921      camera.updateProjectionMatrix();
922      renderer.setSize(innerWidth, innerHeight);
923      frameNetwork(true);
924    }
925    requestAnimationFrame(syncViewport);
926    addEventListener('load', syncViewport);
927
928    addEventListener('resize', () => {
929      camera.aspect = innerWidth/innerHeight;
930      camera.updateProjectionMatrix();
931      renderer.setSize(innerWidth, innerHeight);
932      /* re-frame on narrow/portrait viewports (e.g. rotation); never yank the
933         camera away from a user who deliberately zoomed in on desktop */
934      frameNetwork(camera.aspect < 1);
935    });
936    } // end initScene
937
938    if ('requestIdleCallback' in window) requestIdleCallback(initScene, { timeout: 2000 });
939    else setTimeout(initScene, 200);
940  </script>
940
941  
941<script src="/consent-banner.js" defer></script>
941
942  
942<script>
943    /* Toggle Dashboard vs. Sign-in in the nav based on whether a Supabase
944       session exists (Weg B — consistent with the React header, which shows
945       Dashboard only when signed in and a sign-in link otherwise). */
946    (function () {
947      try {
948        var dash = document.getElementById("nav-dashboard-link");
949        var login = document.getElementById("nav-login-link");
950        if (!dash || !login) return;
951        var signedIn = false;
952        for (var i = 0; i < localStorage.length; i++) {
953          var k = localStorage.key(i);
954          if (k && k.indexOf("sb-") === 0 && k.indexOf("-auth-token") > -1) {
955            var v = localStorage.getItem(k) || "";
956            if (v.indexOf("access_token") > -1) { signedIn = true; break; }
957          }
958        }
959        dash.style.display = signedIn ? "" : "none";
960        login.style.display = signedIn ? "none" : "";
961      } catch (e) {}
962    })();
963  </script>
963
964</body>
965</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.