1/** 2 * Animated banner for the "window opacity" blog post. 3 * 4 * Everything you see is generated here â no image, no GIF, no canvas: the 5 * desktop with its two background apps, the electerm window (tab bar + 6 * terminal + opacity slider), and the slider knob are all plain DOM + CSS 7 * driven by one requestAnimationFrame timeline. 8 * 9 * The story, in three beats: 10 * 1. the electerm window sits solid (opacity 1) over a desktop â a runbook 11 * in a browser and a dashboard â that is completely hidden behind it; 12 * 2. the slider knob travels down to 0.7 and the whole window turns 13 * translucent, so the runbook shows straight through the terminal; 14 * 3. the knob travels back to 1 and the window is solid again. 15 * 16 * Two deliberate liberties, noted so nobody mistakes them for UI: the 17 * background apps are invented stand-ins (any app could be behind the 18 * window), and the banner maps the setting to a slightly lifted on-screen 19 * opacity so text stays readable at thumbnail scale. The real app applies 20 * `#outside-context { opacity: <value> }` verbatim 21 * (src/client/components/common/opacity.jsx) â text fades exactly as much 22 * as the background does. 23 * 24 * Mount points: any element carrying [data-eb-banner]: 25 * data-eb-banner="hero" -> headline + desktop stage (blog post page) 26 * data-eb-banner="card" -> desktop stage only (blog index thumbnail) 27 * Every banner script on the blog index is loaded for every card, so a mount 28 * also has to match data-eb-banner-src (the module the template loaded for 29 * that post) â otherwise the first script to run would claim every card. 30 * 31 * The whole layout is expressed in `em`, and the root font-size is kept at 32 * 1% of the banner width, so a single design scales to any container. 33 */ 34 35const STYLE_ID = 'eb-op-style' 36 37// One full pass of the story, in ms. 38const PERIOD = 9200 39 40const T = { 41 winFrom: 200, // the electerm window rises into place, solid 42 winTo: 800, 43 bgFrom: 100, // background apps fade in behind it 44 bgTo: 700, 45 knobDownFrom: 1600, // slider 1 -> 0.7, window turns translucent 46 knobDownTo: 3400, 47 holdTo: 5600, // reading through the window 48 knobUpFrom: 6000, // slider 0.7 -> 1, window goes solid again 49 knobUpTo: 7600, 50 badgeFrom: 3400, // "see-through" badge while translucent 51 badgeTo: 5600, 52 fadeFrom: 8500, 53 fadeTo: 9000 54} 55 56const V_FROM = 1 57const V_TO = 0.7 58 59// Display mapping: lift the on-screen opacity a little so terminal text stays 60// legible at banner scale. The app itself applies the value verbatim. 61function displayOpacity (v) { 62 return 0.35 + 0.65 * v 63} 64 65const CSS = ` 66.eb-op { 67 --eb-main: #121214; 68 --eb-main-dark: #000; 69 --eb-text: #ddd; 70 --eb-text-dark: #888; 71 --eb-primary: #08c; 72 --eb-success: #06d6a0; 73 --eb-term-bg: #20111b; 74 --eb-term-fg: #bbbbbb; 75 --eb-term-green: #19f9d8; 76 --eb-term-blue: #6fc1ff; 77 position: relative; 78 display: block; 79 width: 100%; 80 aspect-ratio: 16 / 9; 81 overflow: hidden; 82 border-radius: var(--radius-lg, 12px); 83 border: 1px solid var(--color-border, #d9e2f0); 84 background: linear-gradient(135deg, #dbeafe 0%, #f1f5f9 55%, #e0e7ff 100%); 85 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 86 line-height: 1.3; 87 isolation: isolate; 88 -webkit-font-smoothing: antialiased; 89} 90.eb-op-deco { position: absolute; inset: 0; pointer-events: none; } 91.eb-op-dots { 92 position: absolute; 93 inset: -6em; 94 background-image: radial-gradient(rgba(8, 136, 204, 0.18) 0.18em, transparent 0.18em); 95 background-size: 4.2em 4.2em; 96} 97.eb-op-head { position: absolute; left: 5em; right: 5em; top: 3em; pointer-events: none; } 98.eb-op-brand { 99 display: inline-block; 100 font-size: 1.4em; 101 font-weight: 800; 102 color: #0f172a; 103} 104.eb-op-brand i { font-style: normal; color: #1389fd; } 105.eb-op-h1 { 106 margin: 0.22em 0 0; 107 font-size: 3.2em; 108 font-weight: 800; 109 line-height: 1.06; 110 letter-spacing: -0.025em; 111 color: #0f172a; 112} 113.eb-op-h1 em { 114 font-style: normal; 115 background: linear-gradient(96deg, #1389fd, #7c3aed 60%, #06d6a0); 116 -webkit-background-clip: text; 117 background-clip: text; 118 -webkit-text-fill-color: transparent; 119} 120.eb-op-sub { margin: 0.5em 0 0; max-width: 60em; font-size: 1.45em; line-height: 1.4; color: #475569; } 121.eb-op-stage { position: absolute; left: 50%; bottom: 3em; width: 78em; height: 37em; transform: translateX(-50%); } 122.eb-op[data-variant='card'] .eb-op-stage { bottom: auto; top: 50%; transform: translate(-50%, -50%); } 123.eb-op-desktop { 124 position: absolute; 125 inset: 0; 126 border-radius: 0.8em; 127 background: linear-gradient(160deg, #1e293b 0%, #334155 100%); 128 box-shadow: 0 1.2em 2.4em rgba(9, 20, 40, 0.3); 129 overflow: hidden; 130} 131.eb-op-taskbar { 132 position: absolute; 133 left: 0; 134 right: 0; 135 bottom: 0; 136 height: 2.6em; 137 display: flex; 138 align-items: center; 139 gap: 0.8em; 140 padding: 0 1em; 141 background: rgba(2, 6, 23, 0.72); 142} 143.eb-op-taskbar i { width: 1.4em; height: 1.4em; border-radius: 0.3em; background: #475569; } 144.eb-op-taskbar i.is-on { background: #38bdf8; } 145.eb-op-bgapp { 146 position: absolute; 147 border-radius: 0.5em; 148 background: #f8fafc; 149 box-shadow: 0 0.5em 1.2em rgba(0, 0, 0, 0.35); 150 overflow: hidden; 151 text-align: left; 152} 153.eb-op-bgapp-doc { left: 4em; top: 3.4em; width: 30em; height: 24em; } 154.eb-op-bgapp-dash { right: 4em; top: 5.4em; width: 30em; height: 21em; } 155.eb-op-bgapp-bar { height: 2.2em; display: flex; align-items: center; gap: 0.4em; padding: 0 0.8em; background: #e2e8f0; } 156.eb-op-bgapp-bar i { width: 0.8em; height: 0.8em; border-radius: 50%; background: #cbd5e1; } 157.eb-op-bgapp-title { margin: 0.7em 1em 0; font-size: 1.15em; font-weight: 700; color: #0f172a; } 158.eb-op-bgapp-line { margin: 0.55em 1em 0; height: 0.85em; border-radius: 0.4em; background: #e2e8f0; } 159.eb-op-bgapp-line.is-hl { background: #bae6fd; } 160.eb-op-bgapp-line.is-short { width: 62%; } 161.eb-op-bgapp-cmd { 162 margin: 0.8em 1em 0; 163 padding: 0.55em 0.7em; 164 border-radius: 0.35em; 165 background: #0f172a; 166 color: #7dd3fc; 167 font-family: Menlo, Monaco, 'DejaVu Sans Mono', monospace; 168 font-size: 1.05em; 169} 170.eb-op-bars { display: flex; align-items: flex-end; gap: 0.7em; height: 9em; margin: 1em 1em 0; } 171.eb-op-bars span { flex: 1; border-radius: 0.25em 0.25em 0 0; background: #38bdf8; } 172.eb-op-win { 173 position: absolute; 174 left: 50%; 175 top: 2.2em; 176 width: 52em; 177 transform: translateX(-50%); 178 border-radius: 0.6em; 179 overflow: hidden; 180 background: var(--eb-term-bg); 181 box-shadow: 0 1em 2.2em rgba(0, 0, 0, 0.45); 182 text-align: left; 183 opacity: 0; 184} 185.eb-op-tabbar { display: flex; align-items: stretch; height: 2.6em; padding: 0 0.5em; background: var(--eb-main-dark); font-size: 1.15em; } 186.eb-op-tab { 187 display: flex; 188 align-items: center; 189 gap: 0.4em; 190 padding: 0 1em; 191 border-radius: 0.2em 0.2em 0 0; 192 background: var(--eb-main); 193 color: var(--eb-text); 194 font-weight: 700; 195 white-space: nowrap; 196} 197.eb-op-tab-count { 198 height: 1.43em; 199 padding: 0 0.3em; 200 border-radius: 0.72em 0.14em 0.14em 0.72em; 201 background: var(--eb-primary); 202 color: #fff; 203 line-height: 1.43em; 204} 205.eb-op-term { padding: 1em 1.3em 0.6em; font-size: 1.7em; font-family: Menlo, Monaco, 'DejaVu Sans Mono', monospace; color: var(--eb-term-fg); } 206.eb-op-line { line-height: 1.5; white-space: nowrap; } 207.eb-op-ps1 { color: var(--eb-term-green); } 208.eb-op-path { color: var(--eb-term-blue); } 209.eb-op-caret { display: inline-block; width: 0.6em; height: 1.15em; margin-left: 0.1em; vertical-align: -0.2em; background: #b5bd68; } 210.eb-op-slider { 211 display: flex; 212 align-items: center; 213 gap: 0.8em; 214 margin: 0.4em 1.1em 1em; 215 padding: 0.7em 0.9em; 216 border-radius: 0.4em; 217 background: rgba(255, 255, 255, 0.06); 218 border: 1px solid rgba(255, 255, 255, 0.12); 219 font-size: 1.1em; 220 color: var(--eb-text); 221} 222.eb-op-slider-label { color: var(--eb-text-dark); } 223.eb-op-track { position: relative; flex: 1; height: 0.45em; border-radius: 0.25em; background: rgba(255, 255, 255, 0.18); } 224.eb-op-fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 0.25em; background: var(--eb-primary); } 225.eb-op-knob { 226 position: absolute; 227 top: 50%; 228 width: 1.3em; 229 height: 1.3em; 230 border-radius: 50%; 231 background: #fff; 232 border: 0.16em solid var(--eb-primary); 233 transform: translate(-50%, -50%); 234 box-shadow: 0 0.15em 0.4em rgba(0, 0, 0, 0.5); 235} 236.eb-op-val { min-width: 3.2em; text-align: right; font-variant-numeric: tabular-nums; color: #fff; font-weight: 700; } 237.eb-op-badge { 238 position: absolute; 239 left: 50%; 240 top: 0.4em; 241 transform: translateX(-50%); 242 padding: 0.35em 0.9em; 243 border-radius: 1em; 244 background: rgba(6, 214, 160, 0.95); 245 color: #052e22; 246 font-size: 1.15em; 247 font-weight: 800; 248 white-space: nowrap; 249 opacity: 0; 250 box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35); 251} 252@media (prefers-reduced-motion: reduce) { 253 .eb-op-dots { animation: none !important; } 254} 255` 256 257function injectStyle () { 258 if (document.getElementById(STYLE_ID)) return 259 const style = document.createElement('style') 260 style.id = STYLE_ID 261 style.textContent = CSS 262 document.head.appendChild(style) 263} 264 265function clamp (v, min, max) { 266 return Math.max(min, Math.min(max, v)) 267} 268 269function seg (t, from, to) { 270 return clamp((t - from) / (to - from), 0, 1) 271} 272 273function easeOut (p) { 274 return 1 - Math.pow(1 - p, 3) 275} 276 277function easeInOut (p) { 278 return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2 279} 280 281function markup (variant) { 282 const head = variant === 'card' 283 ? '' 284 : ` 285 <div class="eb-op-head">
286 <span class="eb-op-brand">electerm <i>opacity</i></span> 287 <h2 class="eb-op-h1">Turn it down, <em>see through it</em></h2> 288 <p class="eb-op-sub">One setting fades the whole window â the app behind shows straight through.</p> 289 </div>` 290 return ` 291 <div class="eb-op-deco"><span class="eb-op-dots"></span></div> 292 ${head} 293 <div class="eb-op-stage"> 294 <div class="eb-op-desktop"> 295 <div class="eb-op-bgapp eb-op-bgapp-doc"> 296 <div class="eb-op-bgapp-bar"><i></i><i></i><i></i></div> 297 <div class="eb-op-bgapp-title">runbook â deploy steps</div> 298 <div class="eb-op-bgapp-line"></div> 299 <div class="eb-op-bgapp-line is-short"></div> 300 <div class="eb-op-bgapp-cmd">rsync -a ./dist/ web-02:/srv/app</div> 301 <div class="eb-op-bgapp-line is-hl"></div> 302 <div class="eb-op-bgapp-line is-short"></div> 303 </div> 304 <div class="eb-op-bgapp eb-op-bgapp-dash"> 305 <div class="eb-op-bgapp-bar"><i></i><i></i><i></i></div> 306 <div class="eb-op-bgapp-title">dashboard</div> 307 <div class="eb-op-bars"> 308 <span style="height:38%"></span><span style="height:64%"></span> 309 <span style="height:46%"></span><span style="height:82%"></span> 310 <span style="height:58%"></span><span style="height:72%"></span> 311 </div> 312 </div> 313 <div class="eb-op-win"> 314 <div class="eb-op-tabbar"> 315 <span class="eb-op-tab"><span class="eb-op-tab-count">2</span><span>zxd@web-01:22</span></span> 316 </div> 317 <div class="eb-op-term"> 318 <div class="eb-op-line"><span class="eb-op-ps1">zxd@web-01</span>:<span class="eb-op-path">~</span>$ rsync -a ./dist/ web-02:/srv/app</div> 319 <div class="eb-op-line">sending incremental file list<span class="eb-op-caret"></span></div> 320 </div> 321 <div class="eb-op-slider"> 322 <span class="eb-op-slider-label">opacity</span> 323 <span class="eb-op-track"><span class="eb-op-fill"></span><span class="eb-op-knob"></span></span> 324 <span class="eb-op-val">1.00</span> 325 </div> 326 </div> 327 <div class="eb-op-badge">see-through at 0.7</div> 328 <div class="eb-op-taskbar"><i class="is-on"></i><i></i><i></i></div> 329 </div> 330 </div>` 331} 332 333function mount (host) { 334 if (host.getAttribute('data-eb-mounted')) return 335 host.setAttribute('data-eb-mounted', '1') 336 337 const variant = host.getAttribute('data-eb-banner') === 'card' ? 'card' : 'hero' 338 const root = document.createElement('div') 339 root.className = 'eb-op' 340 root.setAttribute('data-variant', variant) 341 root.setAttribute('role', 'img') 342 root.setAttribute('aria-label', 343 'electerm window turning translucent as the opacity slider moves from 1 to 0.7, revealing the apps behind it') 344 root.innerHTML = markup(variant) 345 host.appendChild(root) 346 347 const q = (sel) => root.querySelector(sel) 348 const el = { 349 win: q('.eb-op-win'), 350 knob: q('.eb-op-knob'), 351 fill: q('.eb-op-fill'), 352 val: q('.eb-op-val'), 353 badge: q('.eb-op-badge'), 354 bgapps: Array.prototype.slice.call(root.querySelectorAll('.eb-op-bgapp')) 355 } 356 357 const fit = () => { 358 const w = root.clientWidth 359 if (w) root.style.fontSize = (w / 100) + 'px' 360 } 361 fit() 362 if (window.ResizeObserver) { 363 new window.ResizeObserver(fit).observe(root) 364 } else { 365 window.addEventListener('resize', fit) 366 } 367 368 function valueAt (t) { 369 const down = easeInOut(seg(t, T.knobDownFrom, T.knobDownTo)) 370 const up = easeInOut(seg(t, T.knobUpFrom, T.knobUpTo)) 371 return V_FROM + (V_TO - V_FROM) * down + (V_FROM - V_TO) * up 372 } 373 374 function frame (t) { 375 const gone = easeInOut(seg(t, T.fadeFrom, T.fadeTo)) 376 root.style.opacity = (1 - gone).toFixed(3) 377 378 const winP = easeOut(seg(t, T.winFrom, T.winTo)) 379 el.win.style.opacity = (winP * displayOpacity(valueAt(t)) * (1 - gone)).toFixed(3) 380 el.win.style.transform = 'translateX(-50%) translateY(' + ((1 - winP) * 2).toFixed(3) + 'em)' 381 382 const bgP = easeOut(seg(t, T.bgFrom, T.bgTo)) 383 el.bgapps.forEach((node, i) => { 384 node.style.opacity = (bgP * (1 - gone)).toFixed(3) 385 node.style.transform = 'translateY(' + ((1 - bgP) * (1 + i * 0.5)).toFixed(3) + 'em)' 386 }) 387 388 const v = valueAt(t) 389 const frac = (v - V_TO) / (V_FROM - V_TO) 390 el.knob.style.left = (frac * 100).toFixed(2) + '%' 391 el.fill.style.width = (frac * 100).toFixed(2) + '%' 392 const text = v.toFixed(2) 393 if (el.val.textContent !== text) el.val.textContent = text 394 395 const badgeP = seg(t, T.badgeFrom, T.badgeFrom + 350) * (1 - seg(t, T.badgeTo, T.badgeTo + 350)) 396 el.badge.style.opacity = (badgeP * (1 - gone)).toFixed(3) 397 el.badge.style.transform = 'translateX(-50%) translateY(' + ((1 - badgeP) * -0.6).toFixed(3) + 'em)' 398 } 399 400 const reduced = window.matchMedia && 401 window.matchMedia('(prefers-reduced-motion: reduce)').matches 402 if (reduced) { 403 frame(T.holdTo) 404 return 405 } 406 407 const state = { raf: null } 408 const start = performance.now() 409 const tick = (now) => { 410 frame((now - start) % PERIOD) 411 state.raf = window.requestAnimationFrame(tick) 412 } 413 const play = () => { 414 if (state.raf === null) state.raf = window.requestAnimationFrame(tick) 415 } 416 const pause = () => { 417 if (state.raf !== null) { 418 window.cancelAnimationFrame(state.raf) 419 state.raf = null 420 } 421 } 422 423 if (window.IntersectionObserver) { 424 new window.IntersectionObserver((entries) => { 425 if (entries[0].isIntersecting) play() 426 else pause() 427 }, { threshold: 0 }).observe(root) 428 } else { 429 play() 430 } 431} 432 433function selfPath () { 434 try { 435 return new URL(import.meta.url).pathname 436 } catch (err) { 437 return '' 438 } 439} 440 441export function initBanners (doc = document) { 442 injectStyle() 443 const self = selfPath() 444 const hosts = doc.querySelectorAll('[data-eb-banner]') 445 for (const host of hosts) { 446 const src = host.getAttribute('data-eb-banner-src') 447 if (src && self && src !== self) continue 448 mount(host) 449 } 450} 451 452if (typeof document !== 'undefined') { 453 if (document.readyState === 'loading') { 454 document.addEventListener('DOMContentLoaded', () => initBanners()) 455 } else { 456 initBanners() 457 } 458}
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.