Skip to content

Commit 09015d3

Browse files
authored
Merge pull request #1 from jensenpat/aether/rf-hero-background
Add an animated RF atmosphere to the homepage
2 parents 4c56998 + c94b3e7 commit 09015d3

5 files changed

Lines changed: 305 additions & 15 deletions

File tree

‎assets/js/rf-ambient.js‎

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/* Subtle RF atmosphere for the overview hero.
2+
*
3+
* The canvas is decorative, clipped to the hero, and intentionally light on
4+
* resources: it throttles its frame rate, lowers pixel density on small
5+
* screens, pauses while off-screen or in a hidden tab, and becomes a static
6+
* composition when the visitor prefers reduced motion.
7+
*/
8+
(function () {
9+
'use strict';
10+
11+
var hero = document.querySelector('[data-rf-hero]');
12+
var canvas = hero && hero.querySelector('.rf-ambient-canvas');
13+
if (!hero || !canvas || !canvas.getContext) return;
14+
15+
var ctx = canvas.getContext('2d', { alpha: true });
16+
if (!ctx) return;
17+
18+
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
19+
var smallScreen = window.matchMedia('(max-width: 700px)');
20+
var visible = true;
21+
var pageVisible = !document.hidden;
22+
var running = false;
23+
var frameRequest = 0;
24+
var lastFrame = 0;
25+
var width = 0;
26+
var height = 0;
27+
var MOTION_RATE = 1.2;
28+
29+
var signals = [
30+
{ x: 0.11, width: 0.010, level: 0.42, drift: 0.55, travel: 0.009, phase: 0.3 },
31+
{ x: 0.24, width: 0.016, level: 0.25, drift: 0.42, travel: 0.012, phase: 1.7 },
32+
{ x: 0.39, width: 0.009, level: 0.62, drift: 0.64, travel: 0.008, phase: 2.5 },
33+
{ x: 0.55, width: 0.021, level: 0.34, drift: 0.48, travel: 0.014, phase: 4.2 },
34+
{ x: 0.72, width: 0.012, level: 0.53, drift: 0.58, travel: 0.010, phase: 5.4 },
35+
{ x: 0.88, width: 0.018, level: 0.29, drift: 0.38, travel: 0.013, phase: 3.1 }
36+
];
37+
38+
function gaussian(distance, spread) {
39+
return Math.exp(-(distance * distance) / (2 * spread * spread));
40+
}
41+
42+
function spectrumAt(normalX, seconds) {
43+
var floor = 0.10
44+
+ Math.sin(normalX * 91 + seconds * 0.75) * 0.018
45+
+ Math.sin(normalX * 217 - seconds * 0.50) * 0.012
46+
+ Math.sin(normalX * 509 + 1.4) * 0.007;
47+
48+
for (var i = 0; i < signals.length; i++) {
49+
var signal = signals[i];
50+
var center = signal.x + Math.sin(seconds * signal.drift + signal.phase) * signal.travel;
51+
var pulse = 0.78 + Math.sin(seconds * 0.72 + signal.phase) * 0.22;
52+
floor += gaussian(normalX - center, signal.width) * signal.level * pulse;
53+
}
54+
return Math.min(0.88, floor);
55+
}
56+
57+
function drawGrid(baseline) {
58+
var top = Math.max(76, height * 0.09);
59+
var bottom = Math.min(height * 0.79, baseline + 92);
60+
61+
ctx.lineWidth = 1;
62+
ctx.strokeStyle = 'rgba(93, 227, 255, 0.055)';
63+
for (var i = 1; i < 16; i++) {
64+
var x = (width / 16) * i;
65+
ctx.beginPath();
66+
ctx.moveTo(x, top);
67+
ctx.lineTo(x, bottom);
68+
ctx.stroke();
69+
}
70+
71+
ctx.strokeStyle = 'rgba(123, 242, 220, 0.035)';
72+
for (var j = 0; j < 5; j++) {
73+
var y = top + ((bottom - top) / 5) * j;
74+
ctx.beginPath();
75+
ctx.moveTo(0, y);
76+
ctx.lineTo(width, y);
77+
ctx.stroke();
78+
}
79+
}
80+
81+
function tracePath(seconds, baseline, amplitude, offset) {
82+
var points = smallScreen.matches ? 110 : 190;
83+
ctx.beginPath();
84+
for (var i = 0; i <= points; i++) {
85+
var nx = i / points;
86+
var x = nx * width;
87+
var y = baseline - spectrumAt(nx, seconds - offset * 0.06) * amplitude + offset;
88+
if (i === 0) ctx.moveTo(x, y);
89+
else ctx.lineTo(x, y);
90+
}
91+
}
92+
93+
function drawSpectrum(seconds, baseline) {
94+
var breathe = 0.98 + Math.sin(seconds * 0.65) * 0.02;
95+
var amplitude = Math.min(118, Math.max(58, height * 0.13)) * breathe;
96+
97+
// A few dim history traces suggest a stacked panadapter without pulling
98+
// focus from the headline and calls to action.
99+
for (var history = 3; history >= 1; history--) {
100+
tracePath(seconds, baseline, amplitude * (1 - history * 0.025), history * 10);
101+
ctx.strokeStyle = 'rgba(58, 167, 255, ' + (0.035 + history * 0.012) + ')';
102+
ctx.lineWidth = 1;
103+
ctx.stroke();
104+
}
105+
106+
tracePath(seconds, baseline, amplitude, 0);
107+
var fill = ctx.createLinearGradient(0, baseline - amplitude, 0, baseline + 44);
108+
fill.addColorStop(0, 'rgba(93, 227, 255, 0.16)');
109+
fill.addColorStop(0.48, 'rgba(58, 167, 255, 0.07)');
110+
fill.addColorStop(1, 'rgba(58, 167, 255, 0)');
111+
ctx.lineTo(width, baseline + 52);
112+
ctx.lineTo(0, baseline + 52);
113+
ctx.closePath();
114+
ctx.fillStyle = fill;
115+
ctx.fill();
116+
117+
tracePath(seconds, baseline, amplitude, 0);
118+
var stroke = ctx.createLinearGradient(0, 0, width, 0);
119+
stroke.addColorStop(0, 'rgba(58, 167, 255, 0.10)');
120+
stroke.addColorStop(0.22, 'rgba(93, 227, 255, 0.40)');
121+
stroke.addColorStop(0.52, 'rgba(142, 247, 230, 0.52)');
122+
stroke.addColorStop(0.82, 'rgba(93, 227, 255, 0.34)');
123+
stroke.addColorStop(1, 'rgba(58, 167, 255, 0.08)');
124+
ctx.strokeStyle = stroke;
125+
ctx.lineWidth = smallScreen.matches ? 1 : 1.25;
126+
ctx.shadowColor = 'rgba(93, 227, 255, 0.34)';
127+
ctx.shadowBlur = 8;
128+
ctx.stroke();
129+
ctx.shadowBlur = 0;
130+
}
131+
132+
function drawSweep(seconds, baseline) {
133+
var progress = (seconds / 16) % 1;
134+
var x = progress * width;
135+
var sweep = ctx.createLinearGradient(x - 90, 0, x + 18, 0);
136+
sweep.addColorStop(0, 'rgba(93, 227, 255, 0)');
137+
sweep.addColorStop(0.82, 'rgba(93, 227, 255, 0.04)');
138+
sweep.addColorStop(1, 'rgba(142, 247, 230, 0)');
139+
ctx.fillStyle = sweep;
140+
ctx.fillRect(Math.max(0, x - 90), height * 0.08, 108, baseline + 88);
141+
}
142+
143+
function draw(time) {
144+
if (!width || !height) return;
145+
var seconds = (time / 1000) * MOTION_RATE;
146+
var baseline = Math.min(height * 0.64, 560);
147+
148+
ctx.clearRect(0, 0, width, height);
149+
ctx.save();
150+
ctx.globalCompositeOperation = 'screen';
151+
drawGrid(baseline);
152+
drawSweep(seconds, baseline);
153+
drawSpectrum(seconds, baseline);
154+
ctx.restore();
155+
}
156+
157+
function resize() {
158+
var rect = hero.getBoundingClientRect();
159+
width = Math.max(1, Math.round(rect.width));
160+
height = Math.max(1, Math.round(rect.height));
161+
var density = Math.min(window.devicePixelRatio || 1, smallScreen.matches ? 1.15 : 1.5);
162+
canvas.width = Math.round(width * density);
163+
canvas.height = Math.round(height * density);
164+
canvas.style.width = width + 'px';
165+
canvas.style.height = height + 'px';
166+
ctx.setTransform(density, 0, 0, density, 0, 0);
167+
draw(performance.now());
168+
}
169+
170+
function animate(time) {
171+
if (!running) return;
172+
var frameInterval = smallScreen.matches ? 1000 / 18 : 1000 / 24;
173+
if (time - lastFrame >= frameInterval) {
174+
lastFrame = time;
175+
draw(time);
176+
}
177+
frameRequest = window.requestAnimationFrame(animate);
178+
}
179+
180+
function updatePlayback() {
181+
var shouldRun = visible && pageVisible && !reduceMotion.matches;
182+
if (shouldRun && !running) {
183+
running = true;
184+
lastFrame = 0;
185+
frameRequest = window.requestAnimationFrame(animate);
186+
} else if (!shouldRun && running) {
187+
running = false;
188+
window.cancelAnimationFrame(frameRequest);
189+
}
190+
if (!shouldRun) draw(0);
191+
}
192+
193+
document.addEventListener('visibilitychange', function () {
194+
pageVisible = !document.hidden;
195+
updatePlayback();
196+
});
197+
198+
if ('IntersectionObserver' in window) {
199+
new IntersectionObserver(function (entries) {
200+
visible = entries[0].isIntersecting;
201+
updatePlayback();
202+
}, { rootMargin: '80px 0px' }).observe(hero);
203+
}
204+
205+
if ('ResizeObserver' in window) {
206+
new ResizeObserver(resize).observe(hero);
207+
} else {
208+
window.addEventListener('resize', resize);
209+
}
210+
211+
function preferenceChanged() {
212+
resize();
213+
updatePlayback();
214+
}
215+
if (reduceMotion.addEventListener) reduceMotion.addEventListener('change', preferenceChanged);
216+
else reduceMotion.addListener(preferenceChanged);
217+
if (smallScreen.addEventListener) smallScreen.addEventListener('change', preferenceChanged);
218+
else smallScreen.addListener(preferenceChanged);
219+
220+
resize();
221+
updatePlayback();
222+
})();

‎index.html‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@
7676
<a id="top"></a>
7777

7878
<!-- ===================== HERO ===================== -->
79-
<section class="hero">
79+
<section class="hero" data-rf-hero>
80+
<div class="rf-ambient" aria-hidden="true">
81+
<canvas class="rf-ambient-canvas"></canvas>
82+
</div>
8083
<div class="wrap hero-grid">
8184
<span class="eyebrow"><span class="dot"></span> Open-source · Cross-platform · Native</span>
8285
<h1>The native workstation for your <span class="grad-text">whole station</span></h1>
@@ -564,6 +567,7 @@ <h4>Resources</h4>
564567
</div>
565568
</footer>
566569

570+
<script src="assets/js/rf-ambient.js?v=3" defer></script>
567571
<script src="assets/js/downloads.js?v=2" defer></script>
568572
</body>
569573
</html>

‎scripts/build.py‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,19 @@ def main():
3333
f"<style>\n{css}\n</style>",
3434
)
3535

36-
# Inline JS as a real <script> before the asset pass — data-URI-ing a
37-
# script src would break execution in the single-file bundle.
38-
with open(os.path.join(ROOT, "assets/js/downloads.js"), encoding="utf-8") as f:
39-
js = f.read()
40-
# Function replacement: a plain-string repl would interpret backslashes
41-
# in the JS (e.g. regex \d) as re escape sequences and crash.
36+
# Inline local JS as real <script> elements before the asset pass —
37+
# data-URI-ing a script src would break execution in the single-file bundle.
38+
def inline_script(match):
39+
path = match.group(1)
40+
with open(os.path.join(ROOT, path), encoding="utf-8") as f:
41+
js = f.read()
42+
# A function replacement avoids interpreting JS backslashes (for
43+
# example regex \d) as replacement-string escape sequences.
44+
return f"<script>\n{js}\n</script>"
45+
4246
html = re.sub(
43-
r'<script src="assets/js/downloads\.js[^"]*"[^>]*></script>',
44-
lambda _m: f"<script>\n{js}\n</script>",
47+
r'<script src="(assets/js/[^"?]+)(?:\?[^\"]*)?"[^>]*></script>',
48+
inline_script,
4549
html,
4650
)
4751

‎serve.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import http.server, socketserver
2-
socketserver.TCPServer.allow_reuse_address = True
3-
with socketserver.TCPServer(('127.0.0.1', 4321), http.server.SimpleHTTPRequestHandler) as httpd:
4-
print('serving aetherweb on http://127.0.0.1:4321')
1+
import http.server
2+
3+
http.server.ThreadingHTTPServer.allow_reuse_address = True
4+
with http.server.ThreadingHTTPServer(('127.0.0.1', 4321), http.server.SimpleHTTPRequestHandler) as httpd:
5+
print('serving aetherweb on http://127.0.0.1:4321', flush=True)
56
httpd.serve_forever()

‎styles.css‎

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,60 @@ header.nav {
211211
.nav-dd a svg { width: 17px; height: 17px; stroke: var(--cyan); flex: none; }
212212

213213
/* ---------- Hero ---------- */
214-
.hero { padding: 92px 0 40px; position: relative; }
215-
.hero-grid { display: grid; gap: 30px; text-align: center; justify-items: center; }
214+
.hero {
215+
padding: 92px 0 40px;
216+
position: relative;
217+
isolation: isolate;
218+
overflow: hidden;
219+
}
220+
.hero::before {
221+
content: "";
222+
position: absolute;
223+
z-index: 0;
224+
inset: 0;
225+
pointer-events: none;
226+
background:
227+
radial-gradient(ellipse 62% 46% at 50% 47%, rgba(31, 128, 192, 0.11), transparent 72%),
228+
linear-gradient(180deg, rgba(6, 11, 19, 0.02) 62%, var(--bg) 100%);
229+
}
230+
.rf-ambient {
231+
position: absolute;
232+
z-index: 0;
233+
inset: 0;
234+
overflow: hidden;
235+
pointer-events: none;
236+
opacity: 0.68;
237+
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 8%, #000 78%, transparent 100%);
238+
mask-image: linear-gradient(to bottom, transparent 0%, #000 8%, #000 78%, transparent 100%);
239+
}
240+
.rf-ambient::before {
241+
content: "";
242+
position: absolute;
243+
inset: 7% -12% 16%;
244+
background:
245+
repeating-radial-gradient(circle at 8% 64%, transparent 0 78px, rgba(93, 227, 255, 0.035) 79px 80px, transparent 81px 155px),
246+
repeating-radial-gradient(circle at 92% 50%, transparent 0 106px, rgba(123, 242, 220, 0.025) 107px 108px, transparent 109px 212px);
247+
transform: scale(1.02);
248+
animation: rf-breathe 8.333s ease-in-out infinite alternate;
249+
}
250+
.rf-ambient-canvas {
251+
position: absolute;
252+
inset: 0;
253+
width: 100%;
254+
height: 100%;
255+
}
256+
.hero-grid {
257+
position: relative;
258+
z-index: 1;
259+
display: grid;
260+
gap: 30px;
261+
text-align: center;
262+
justify-items: center;
263+
}
264+
@keyframes rf-breathe {
265+
from { transform: scale(1.01); opacity: 0.72; }
266+
to { transform: scale(1.07); opacity: 1; }
267+
}
216268
.hero h1 { font-size: clamp(34px, 5.4vw, 62px); max-width: 16ch; }
217269
.hero .sub {
218270
font-size: clamp(16px, 2vw, 20px);
@@ -563,5 +615,12 @@ footer.foot { border-top: 1px solid var(--line); margin-top: 30px; padding: 46px
563615
.built-grid { grid-template-columns: 1fr 1fr; }
564616
.foot-grid { grid-template-columns: 1fr; }
565617
.hero { padding-top: 62px; }
618+
.rf-ambient { opacity: 0.52; }
619+
.rf-ambient::before { inset-inline: -32%; }
566620
.built-wrap { padding: 34px 22px; }
567621
}
622+
623+
@media (prefers-reduced-motion: reduce) {
624+
html { scroll-behavior: auto; }
625+
.rf-ambient::before { animation: none; }
626+
}

0 commit comments

Comments
 (0)