PageSourceSearch

https://electerm.org/blogs/window-opacity/banner.js

js electerm.org collected 2026-10-03 23:13:56 UTC 15,689 bytes, 458 lines download raw bytes

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.