|
116 | 116 | } |
117 | 117 | const wait = (ms) => new Promise(r => setTimeout(r, ms)); |
118 | 118 |
|
119 | | - // ---------- install terminal sequence ---------- |
120 | | - let installTimer = null; |
121 | | - async function playInstall(root) { |
122 | | - if (!root) return; |
123 | | - clearTimeout(installTimer); |
124 | | - // reset |
| 119 | + // the commands the two terminals type, single-sourced so the up-front height |
| 120 | + // reservation below types the exact same final text the animation will. |
| 121 | + const INSTALL_CMDS = [ |
| 122 | + 'npm install -g little-coder', |
| 123 | + 'llama-server -hf unsloth/Qwen3.6-35B-A3B-GGUF', |
| 124 | + 'little-coder --model llamacpp/qwen3.6-35b-a3b', |
| 125 | + ]; |
| 126 | + const SESSION_CMD = 'implement the fizzbuzz exercise'; |
| 127 | + |
| 128 | + // Pin a terminal's *final* height before its animation starts, so the box |
| 129 | + // never grows (and shoves the page down) as commands type in and outputs |
| 130 | + // reveal. The CSS min-height is correct on desktop, where each command fits |
| 131 | + // on one line — but on mobile the long commands wrap to extra lines, so the |
| 132 | + // fully-revealed terminal is much taller than the static reservation and the |
| 133 | + // page visibly jumps mid-animation. We render the final state, measure it at |
| 134 | + // the current viewport width, and pin that as min-height. Done synchronously |
| 135 | + // (fill → measure → the caller's reset, with no await between) so the filled |
| 136 | + // state is never painted; clearing min-height first keeps the CSS value as a |
| 137 | + // floor so we never reserve *less* than desktop. |
| 138 | + function reserveTerminalHeight(root, fill) { |
| 139 | + const body = root && root.querySelector('.t-body'); |
| 140 | + if (!body) return; |
| 141 | + body.style.minHeight = ''; |
| 142 | + fill(); |
| 143 | + body.style.minHeight = Math.ceil(body.getBoundingClientRect().height) + 'px'; |
| 144 | + } |
| 145 | + function fillInstallFinal(root) { |
| 146 | + root.querySelectorAll('.t-line').forEach(l => { l.style.visibility = 'visible'; }); |
| 147 | + root.querySelectorAll('.cmd').forEach((c, i) => { c.textContent = INSTALL_CMDS[i] ?? ''; }); |
| 148 | + root.querySelectorAll('.t-out').forEach(o => o.classList.add('show')); |
| 149 | + } |
| 150 | + function fillSessionFinal(root) { |
| 151 | + const u = root.querySelector('.user'); |
| 152 | + if (u) u.textContent = SESSION_CMD; |
| 153 | + root.querySelectorAll('.s-out').forEach(o => o.classList.add('show')); |
| 154 | + } |
| 155 | + // restore a terminal to its pre-animation frame (first prompt visible, the |
| 156 | + // rest empty/hidden) |
| 157 | + function resetInstall(root) { |
125 | 158 | root.querySelectorAll('.t-out').forEach(o => o.classList.remove('show')); |
126 | 159 | root.querySelectorAll('.cmd').forEach(c => { c.textContent = ''; }); |
127 | 160 | root.querySelectorAll('.caret').forEach(c => c.classList.remove('hide')); |
128 | 161 | root.querySelectorAll('.bar-fill').forEach(b => b.classList.remove('go')); |
129 | 162 | root.querySelectorAll('[data-step]').forEach(el => { |
130 | | - if (el.classList.contains('t-line')) { |
131 | | - el.style.visibility = 'hidden'; |
132 | | - } |
| 163 | + if (el.classList.contains('t-line')) el.style.visibility = 'hidden'; |
133 | 164 | }); |
134 | | - root.querySelectorAll('.t-line')[0].style.visibility = 'visible'; |
| 165 | + const first = root.querySelectorAll('.t-line')[0]; |
| 166 | + if (first) first.style.visibility = 'visible'; |
| 167 | + } |
| 168 | + function resetSession(root) { |
| 169 | + root.querySelectorAll('.s-out').forEach(o => o.classList.remove('show')); |
| 170 | + root.querySelectorAll('.user').forEach(u => { u.textContent = ''; }); |
| 171 | + root.querySelectorAll('.caret').forEach(c => c.classList.remove('hide')); |
| 172 | + } |
| 173 | + // Pin final height AND leave the terminal in its initial frame — run at load |
| 174 | + // (so the box is its final size before it's ever scrolled into view, no jump) |
| 175 | + // and again on resize. fill → measure → reset is synchronous, so neither the |
| 176 | + // filled nor a half-state is ever painted. |
| 177 | + function primeInstall(root) { |
| 178 | + if (!root) return; |
| 179 | + reserveTerminalHeight(root, () => fillInstallFinal(root)); |
| 180 | + resetInstall(root); |
| 181 | + } |
| 182 | + function primeSession(root) { |
| 183 | + if (!root) return; |
| 184 | + reserveTerminalHeight(root, () => fillSessionFinal(root)); |
| 185 | + resetSession(root); |
| 186 | + } |
| 187 | + |
| 188 | + // ---------- install terminal sequence ---------- |
| 189 | + let installTimer = null; |
| 190 | + async function playInstall(root) { |
| 191 | + if (!root) return; |
| 192 | + clearTimeout(installTimer); |
| 193 | + // re-pin the final height for the current viewport, then reset to the |
| 194 | + // initial frame (both synchronous — the filled state is never painted) |
| 195 | + primeInstall(root); |
135 | 196 |
|
136 | 197 | const lines = root.querySelectorAll('.t-line'); |
137 | 198 | const outs = root.querySelectorAll('.t-out'); |
138 | 199 | const carets = root.querySelectorAll('.caret'); |
139 | 200 |
|
140 | 201 | // line 1: npm install |
141 | 202 | await wait(300); |
142 | | - await typeInto(lines[0].querySelector('.cmd'), 'npm install -g little-coder', 36); |
| 203 | + await typeInto(lines[0].querySelector('.cmd'), INSTALL_CMDS[0], 36); |
143 | 204 | carets[0].classList.add('hide'); |
144 | 205 | await wait(220); |
145 | 206 | outs[0].classList.add('show'); |
|
150 | 211 | // line 2: llama-server (pull + serve the model) |
151 | 212 | lines[1].style.visibility = 'visible'; |
152 | 213 | carets[1].classList.remove('hide'); |
153 | | - await typeInto(lines[1].querySelector('.cmd'), 'llama-server -hf unsloth/Qwen3.6-35B-A3B-GGUF', 30); |
| 214 | + await typeInto(lines[1].querySelector('.cmd'), INSTALL_CMDS[1], 30); |
154 | 215 | carets[1].classList.add('hide'); |
155 | 216 | await wait(220); |
156 | 217 | outs[1].classList.add('show'); |
|
159 | 220 | // line 3: little-coder --model llamacpp/qwen3.6-35b-a3b |
160 | 221 | lines[2].style.visibility = 'visible'; |
161 | 222 | carets[2].classList.remove('hide'); |
162 | | - await typeInto(lines[2].querySelector('.cmd'), 'little-coder --model llamacpp/qwen3.6-35b-a3b', 50); |
| 223 | + await typeInto(lines[2].querySelector('.cmd'), INSTALL_CMDS[2], 50); |
163 | 224 | carets[2].classList.add('hide'); |
164 | 225 | await wait(200); |
165 | 226 | outs[2].classList.add('show'); |
|
168 | 229 | installTimer = setTimeout(() => playInstall(root), 10000); |
169 | 230 | } |
170 | 231 |
|
| 232 | + // track which terminals have begun, so a viewport resize can re-pin their |
| 233 | + // height (the reservation is width-dependent) without starting one early. |
| 234 | + let installStarted = false; |
| 235 | + let sessionStarted = false; |
| 236 | + |
171 | 237 | // play install once on view; then it loops itself |
172 | 238 | const installRoot = document.querySelector('.install-terminal'); |
173 | 239 | const installObserver = new IntersectionObserver((entries) => { |
174 | 240 | entries.forEach(e => { |
175 | 241 | if (!e.isIntersecting) return; |
| 242 | + installStarted = true; |
176 | 243 | playInstall(installRoot); |
177 | 244 | installObserver.unobserve(e.target); |
178 | 245 | }); |
|
182 | 249 | // ---------- sample session: real tool-use flow ---------- |
183 | 250 | async function playSession(root) { |
184 | 251 | if (!root) return; |
185 | | - root.querySelectorAll('.s-out').forEach(o => o.classList.remove('show')); |
186 | | - root.querySelectorAll('.user').forEach(u => { u.textContent = ''; }); |
187 | | - root.querySelectorAll('.caret').forEach(c => c.classList.remove('hide')); |
| 252 | + // re-pin the final height for the current viewport, then reset (synchronous) |
| 253 | + primeSession(root); |
188 | 254 |
|
189 | 255 | const lines = root.querySelectorAll('.s-line'); |
190 | 256 | const outs = root.querySelectorAll('.s-out'); |
191 | 257 | const carets = root.querySelectorAll('.caret'); |
192 | 258 |
|
193 | 259 | // type the request |
194 | 260 | await wait(400); |
195 | | - await typeInto(lines[0].querySelector('.user'), 'implement the fizzbuzz exercise', 30); |
| 261 | + await typeInto(lines[0].querySelector('.user'), SESSION_CMD, 30); |
196 | 262 | carets[0].classList.add('hide'); |
197 | 263 | await wait(320); |
198 | 264 | outs[0].classList.add('show'); // running... |
|
206 | 272 | const sessionObserver = new IntersectionObserver((entries) => { |
207 | 273 | entries.forEach(e => { |
208 | 274 | if (!e.isIntersecting) return; |
| 275 | + sessionStarted = true; |
209 | 276 | playSession(sessionRoot); |
210 | 277 | sessionObserver.unobserve(e.target); |
211 | 278 | }); |
212 | 279 | }, { threshold: 0.25 }); |
213 | 280 | if (sessionRoot) sessionObserver.observe(sessionRoot); |
214 | 281 |
|
| 282 | + // Pin both terminals' final heights up-front — before either is scrolled |
| 283 | + // into view — so entering the viewport never triggers a CSS-floor→final jump, |
| 284 | + // only the typing animation inside an already-correctly-sized box. Each |
| 285 | + // prime leaves the terminal in its initial frame. |
| 286 | + primeInstall(installRoot); |
| 287 | + primeSession(sessionRoot); |
| 288 | + |
| 289 | + // The reservation depends on text wrapping, which depends on the font. IBM |
| 290 | + // Plex Mono loads async, so the load-time pin above uses fallback-monospace |
| 291 | + // metrics; re-pin once the real font is ready (the terminals are below the |
| 292 | + // fold, so this lands before they're ever seen). Skip any already animating — |
| 293 | + // it pinned with the loaded font when it started. |
| 294 | + if (document.fonts && document.fonts.ready) { |
| 295 | + document.fonts.ready.then(() => { |
| 296 | + if (!installStarted) primeInstall(installRoot); |
| 297 | + if (!sessionStarted) primeSession(sessionRoot); |
| 298 | + }).catch(() => {}); |
| 299 | + } |
| 300 | + |
| 301 | + // On resize / orientation change the wrapped final height changes. Re-pin both |
| 302 | + // (so far-off terminals stay correct), and replay the ones already animating |
| 303 | + // so their reservation matches the new width mid-flight. |
| 304 | + let resizeTimer = null; |
| 305 | + let lastW = window.innerWidth; |
| 306 | + window.addEventListener('resize', () => { |
| 307 | + if (window.innerWidth === lastW) return; // ignore mobile scroll-driven toolbar height changes |
| 308 | + lastW = window.innerWidth; |
| 309 | + clearTimeout(resizeTimer); |
| 310 | + resizeTimer = setTimeout(() => { |
| 311 | + if (installStarted) playInstall(installRoot); else primeInstall(installRoot); |
| 312 | + if (sessionStarted) playSession(sessionRoot); else primeSession(sessionRoot); |
| 313 | + }, 250); |
| 314 | + }); |
| 315 | + |
215 | 316 | })(); |
0 commit comments